fix: handle legacy redirects and media previews

This commit is contained in:
Codex
2026-06-02 11:32:49 +08:00
parent 5a59fcd96b
commit efab08ea26
12 changed files with 251 additions and 67 deletions
@@ -450,15 +450,21 @@ export default async function FAQPage({
/> />
<section className="relative aspect-[16/9] overflow-hidden bg-[linear-gradient(135deg,#f4f1eb_0%,#fbfaf7_52%,#e8e1d4_100%)] px-5 py-5 shadow-[0_18px_55px_rgba(23,23,23,0.05)] md:aspect-auto md:px-10 md:py-10"> <section className="relative aspect-[16/9] overflow-hidden bg-[linear-gradient(135deg,#f4f1eb_0%,#fbfaf7_52%,#e8e1d4_100%)] px-5 py-5 shadow-[0_18px_55px_rgba(23,23,23,0.05)] md:aspect-auto md:px-10 md:py-10">
{heroImageUrl || heroMobileImageUrl ? ( {heroImageUrl || heroMobileImageUrl ? (
<picture> <>
{heroMobileImageUrl ? <source media="(max-width: 767px)" srcSet={heroMobileImageUrl} /> : null}
<img <img
src={heroImageUrl || heroMobileImageUrl || ''} src={heroImageUrl || heroMobileImageUrl || ''}
alt="" alt=""
className={['absolute inset-0 h-full w-full object-cover', heroOverlay ? 'opacity-100' : 'opacity-22'].join(' ')} className={['absolute inset-0 h-full w-full object-cover', heroOverlay ? 'opacity-100' : 'opacity-22'].join(' ')}
decoding="async" decoding="async"
/> />
</picture> {heroImageUrl && heroMobileImageUrl ? (
<div
aria-hidden="true"
className={['absolute inset-0 h-full w-full bg-cover bg-center md:hidden', heroOverlay ? 'opacity-100' : 'opacity-22'].join(' ')}
style={{ backgroundImage: `url(${heroMobileImageUrl})` }}
/>
) : null}
</>
) : null} ) : null}
{heroOverlay ? ( {heroOverlay ? (
<div className="absolute inset-0 bg-[linear-gradient(135deg,rgba(0,0,0,0.72)_0%,rgba(0,0,0,0.46)_48%,rgba(0,0,0,0.22)_100%)]" /> <div className="absolute inset-0 bg-[linear-gradient(135deg,rgba(0,0,0,0.72)_0%,rgba(0,0,0,0.46)_48%,rgba(0,0,0,0.22)_100%)]" />
+9
View File
@@ -0,0 +1,9 @@
import type { NextRequest } from 'next/server'
import { resolveCmsRedirect } from '@/lib/cms-redirect'
export const dynamic = 'force-dynamic'
export const revalidate = 0
export async function GET(request: NextRequest) {
return resolveCmsRedirect({ request })
}
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayloadClient } from '@/lib/payload'
import { getSiteUrl } from '@/lib/seo'
import { siteNotFoundRedirect } from '@/lib/site-not-found'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const legacyProductModelRedirects: Record<string, string> = {
'amp-f10': '/en/products/amp-f10',
'amp-f2': '/en/products/amp-f2',
'dac-z10': '/en/products/dac-z10',
'dac-z6': '/en/products/dac-z6',
'dac-z8': '/en/products/dac-z8',
'dmp-a10': '/en/products/dmp-a10',
'dmp-a6': '/en/products/dmp-a6-gen-2',
'dmp-a6 gen 2': '/en/products/dmp-a6-gen-2',
'dmp-a6 master edition': '/en/products/dmp-a6-master-gen-2',
'dmp-a6 master gen 2': '/en/products/dmp-a6-master-gen-2',
'dmp-a6 master+gen+2': '/en/products/dmp-a6-master-gen-2',
'dmp-a6+gen+2': '/en/products/dmp-a6-gen-2',
'dmp-a6+master+gen+2': '/en/products/dmp-a6-master-gen-2',
'eisa': '/en',
'eisa award winners': '/en',
'em-01': '/en/products/em-01',
'evotune™': '/en/products/evotune',
musicservices: '/en/products/musicservices',
play: '/en/products/play',
se100: '/en/products/se100',
t8: '/en/products/t8',
'topic-page': '/en/products/evotune',
v16: '/en/products/v16',
z10: '/en/products/dac-z10',
}
function normalizeLegacyModel(model: string) {
return decodeURIComponent(model).replace(/\+/g, ' ').trim().toLowerCase()
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ model: string; section: string; target: string }> },
) {
const { model } = await params
const payload = await getPayloadClient()
const rawPathname = request.nextUrl.pathname
const productIndexPath = rawPathname.replace(/^\/Product\/[^/]+\/model\//, '/Product/index/model/')
const redirects = await payload.find({
collection: 'redirects',
depth: 0,
limit: 2,
locale: 'en',
where: {
and: [
{ enabled: { equals: true } },
{
or: [{ from: { equals: rawPathname } }, { from: { equals: productIndexPath } }],
},
],
},
})
const redirectEntry =
redirects.docs.find((entry) => entry.from === rawPathname) ??
redirects.docs.find((entry) => entry.from === productIndexPath) ??
null
if (!redirectEntry?.to) {
const modelFallback = legacyProductModelRedirects[normalizeLegacyModel(model)]
if (modelFallback) {
return NextResponse.redirect(new URL(modelFallback, getSiteUrl()), 301)
}
return siteNotFoundRedirect()
}
return NextResponse.redirect(new URL(redirectEntry.to, getSiteUrl()), Number(redirectEntry.statusCode))
}
@@ -1,9 +1,42 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getPayloadClient } from '@/lib/payload' import { getPayloadClient } from '@/lib/payload'
import { getSiteUrl } from '@/lib/seo'
import { siteNotFoundRedirect } from '@/lib/site-not-found'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const revalidate = 0 export const revalidate = 0
const legacyProductModelRedirects: Record<string, string> = {
'amp-f10': '/en/products/amp-f10',
'amp-f2': '/en/products/amp-f2',
'dac-z10': '/en/products/dac-z10',
'dac-z6': '/en/products/dac-z6',
'dac-z8': '/en/products/dac-z8',
'dmp-a10': '/en/products/dmp-a10',
'dmp-a6': '/en/products/dmp-a6-gen-2',
'dmp-a6 gen 2': '/en/products/dmp-a6-gen-2',
'dmp-a6 master edition': '/en/products/dmp-a6-master-gen-2',
'dmp-a6 master gen 2': '/en/products/dmp-a6-master-gen-2',
'dmp-a6 master+gen+2': '/en/products/dmp-a6-master-gen-2',
'dmp-a6+gen+2': '/en/products/dmp-a6-gen-2',
'dmp-a6+master+gen+2': '/en/products/dmp-a6-master-gen-2',
'eisa': '/en',
'eisa award winners': '/en',
'em-01': '/en/products/em-01',
'evotune™': '/en/products/evotune',
musicservices: '/en/products/musicservices',
play: '/en/products/play',
se100: '/en/products/se100',
t8: '/en/products/t8',
'topic-page': '/en/products/evotune',
v16: '/en/products/v16',
z10: '/en/products/dac-z10',
}
function normalizeLegacyModel(model: string) {
return decodeURIComponent(model).replace(/\+/g, ' ').trim().toLowerCase()
}
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ model: string; target: string }> }, { params }: { params: Promise<{ model: string; target: string }> },
@@ -11,7 +44,7 @@ export async function GET(
const { model, target } = await params const { model, target } = await params
const payload = await getPayloadClient() const payload = await getPayloadClient()
const rawPathname = new URL(request.url).pathname const rawPathname = request.nextUrl.pathname
const fallbackPath = `/Product/index/model/${model}/target/${target}` const fallbackPath = `/Product/index/model/${model}/target/${target}`
const redirects = await payload.find({ const redirects = await payload.find({
@@ -35,8 +68,13 @@ export async function GET(
null null
if (!redirectEntry?.to) { if (!redirectEntry?.to) {
return NextResponse.json({ error: 'Redirect not found' }, { status: 404 }) const modelFallback = legacyProductModelRedirects[normalizeLegacyModel(model)]
if (modelFallback) {
return NextResponse.redirect(new URL(modelFallback, getSiteUrl()), 301)
} }
return NextResponse.redirect(new URL(redirectEntry.to, request.url), Number(redirectEntry.statusCode)) return siteNotFoundRedirect()
}
return NextResponse.redirect(new URL(redirectEntry.to, getSiteUrl()), Number(redirectEntry.statusCode))
} }
+17
View File
@@ -0,0 +1,17 @@
import type { NextRequest } from 'next/server'
import { resolveCmsRedirect } from '@/lib/cms-redirect'
export const dynamic = 'force-dynamic'
export const revalidate = 0
export async function GET(request: NextRequest) {
const pathname = request.nextUrl.pathname
const normalizedTutorialPage = pathname.match(/^\/Support\/tutorial\/target\/([^/]+)\/p\/\d+\.html$/)
return resolveCmsRedirect({
pathname: normalizedTutorialPage
? `/Support/tutorial/target/${normalizedTutorialPage[1]}.html`
: undefined,
request,
})
}
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getPayloadClient } from '@/lib/payload' import { getPayloadClient } from '@/lib/payload'
import { getSiteUrl } from '@/lib/seo'
function cleanFilenamePart(value: string | null | undefined, fallback: string) { function cleanFilenamePart(value: string | null | undefined, fallback: string) {
const cleaned = value const cleaned = value
@@ -39,9 +40,9 @@ function contentDisposition(filename: string) {
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}` return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`
} }
function resolveDownloadUrl(value: string, request: NextRequest) { function resolveDownloadUrl(value: string) {
const trimmed = value.trim() const trimmed = value.trim()
if (trimmed.startsWith('/')) return new URL(trimmed, request.url).toString() if (trimmed.startsWith('/')) return new URL(trimmed, getSiteUrl()).toString()
const parsed = new URL(trimmed) const parsed = new URL(trimmed)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
@@ -51,8 +52,8 @@ function resolveDownloadUrl(value: string, request: NextRequest) {
return parsed.toString() return parsed.toString()
} }
async function proxyAttachment(url: string, filename: string, request: NextRequest) { async function proxyAttachment(url: string, filename: string) {
const resolvedUrl = resolveDownloadUrl(url, request) const resolvedUrl = resolveDownloadUrl(url)
const upstream = await fetch(resolvedUrl, { const upstream = await fetch(resolvedUrl, {
cache: 'no-store', cache: 'no-store',
headers: { headers: {
@@ -79,7 +80,7 @@ async function proxyAttachment(url: string, filename: string, request: NextReque
} }
export async function GET( export async function GET(
request: NextRequest, _request: NextRequest,
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
) { ) {
const { id } = await params const { id } = await params
@@ -98,6 +99,5 @@ export async function GET(
return proxyAttachment( return proxyAttachment(
category.archiveDownloadUrl, category.archiveDownloadUrl,
buildFilename(category.name, category.archiveFormat), buildFilename(category.name, category.archiveFormat),
request,
) )
} }
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getPayloadClient } from '@/lib/payload' import { getPayloadClient } from '@/lib/payload'
import { getSiteUrl } from '@/lib/seo'
function cleanFilenamePart(value: string | null | undefined, fallback: string) { function cleanFilenamePart(value: string | null | undefined, fallback: string) {
const cleaned = value const cleaned = value
@@ -39,9 +40,9 @@ function contentDisposition(filename: string) {
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}` return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`
} }
function resolveDownloadUrl(value: string, request: NextRequest) { function resolveDownloadUrl(value: string) {
const trimmed = value.trim() const trimmed = value.trim()
if (trimmed.startsWith('/')) return new URL(trimmed, request.url).toString() if (trimmed.startsWith('/')) return new URL(trimmed, getSiteUrl()).toString()
const parsed = new URL(trimmed) const parsed = new URL(trimmed)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
@@ -51,8 +52,8 @@ function resolveDownloadUrl(value: string, request: NextRequest) {
return parsed.toString() return parsed.toString()
} }
async function proxyAttachment(url: string, filename: string, request: NextRequest) { async function proxyAttachment(url: string, filename: string) {
const resolvedUrl = resolveDownloadUrl(url, request) const resolvedUrl = resolveDownloadUrl(url)
const upstream = await fetch(resolvedUrl, { const upstream = await fetch(resolvedUrl, {
cache: 'no-store', cache: 'no-store',
headers: { headers: {
@@ -79,7 +80,7 @@ async function proxyAttachment(url: string, filename: string, request: NextReque
} }
export async function GET( export async function GET(
request: NextRequest, _request: NextRequest,
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
) { ) {
const { id } = await params const { id } = await params
@@ -95,5 +96,5 @@ export async function GET(
return NextResponse.json({ error: 'Download not found.' }, { status: 404 }) return NextResponse.json({ error: 'Download not found.' }, { status: 404 })
} }
return proxyAttachment(asset.externalUrl, buildFilename(asset.title, asset.fileFormat), request) return proxyAttachment(asset.externalUrl, buildFilename(asset.title, asset.fileFormat))
} }
+10 -3
View File
@@ -96,20 +96,27 @@ export function PageHero({
const contentPadding = size === 'tall' ? 'px-5 pb-6 pt-12 md:px-12 md:pb-14 md:pt-24' : 'px-5 py-5 md:px-10 md:py-10' const contentPadding = size === 'tall' ? 'px-5 pb-6 pt-12 md:px-12 md:pb-14 md:pt-24' : 'px-5 py-5 md:px-10 md:py-10'
const titleSize = size === 'tall' ? 'text-2xl sm:text-3xl md:text-5xl' : 'text-2xl sm:text-3xl md:text-5xl' const titleSize = size === 'tall' ? 'text-2xl sm:text-3xl md:text-5xl' : 'text-2xl sm:text-3xl md:text-5xl'
const resolvedImageUrl = imageUrl || mobileImageUrl const resolvedImageUrl = imageUrl || mobileImageUrl
const hasMobileFallback = Boolean(imageUrl && mobileImageUrl)
return ( return (
<section className={['overflow-hidden rounded-[24px]', overlay ? 'bg-black text-white' : 'bg-white text-text-primary'].join(' ')}> <section className={['overflow-hidden rounded-[24px]', overlay ? 'bg-black text-white' : 'bg-white text-text-primary'].join(' ')}>
<div className={['relative aspect-[16/9] md:aspect-auto', outerMinHeight].join(' ')}> <div className={['relative aspect-[16/9] md:aspect-auto', outerMinHeight].join(' ')}>
{resolvedImageUrl ? ( {resolvedImageUrl ? (
<picture> <>
{mobileImageUrl ? <source media="(max-width: 767px)" srcSet={mobileImageUrl} /> : null}
<img <img
src={resolvedImageUrl} src={resolvedImageUrl}
alt="" alt=""
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
decoding="async" decoding="async"
/> />
</picture> {hasMobileFallback ? (
<div
aria-hidden="true"
className="absolute inset-0 h-full w-full bg-cover bg-center md:hidden"
style={{ backgroundImage: `url(${mobileImageUrl})` }}
/>
) : null}
</>
) : null} ) : null}
{overlay ? ( {overlay ? (
<div className="absolute inset-0 bg-[linear-gradient(135deg,rgba(0,0,0,0.72)_0%,rgba(0,0,0,0.45)_48%,rgba(0,0,0,0.2)_100%)]" /> <div className="absolute inset-0 bg-[linear-gradient(135deg,rgba(0,0,0,0.72)_0%,rgba(0,0,0,0.45)_48%,rgba(0,0,0,0.2)_100%)]" />
+5 -3
View File
@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getPayloadClient } from '@/lib/payload' import { getPayloadClient } from '@/lib/payload'
import { getSiteUrl } from '@/lib/seo'
import { siteNotFoundRedirect } from '@/lib/site-not-found'
type ResolveCmsRedirectOptions = { type ResolveCmsRedirectOptions = {
locale?: 'any' | 'en' | 'zh' locale?: 'any' | 'en' | 'zh'
@@ -13,7 +15,7 @@ function isSafeRedirectTarget(value: string) {
export async function resolveCmsRedirect({ locale = 'any', pathname, request }: ResolveCmsRedirectOptions) { export async function resolveCmsRedirect({ locale = 'any', pathname, request }: ResolveCmsRedirectOptions) {
const payload = await getPayloadClient() const payload = await getPayloadClient()
const rawPathname = pathname ?? new URL(request.url).pathname const rawPathname = pathname ?? request.nextUrl.pathname
const redirects = await payload.find({ const redirects = await payload.find({
collection: 'redirects', collection: 'redirects',
@@ -38,8 +40,8 @@ export async function resolveCmsRedirect({ locale = 'any', pathname, request }:
const redirectEntry = redirects.docs[0] const redirectEntry = redirects.docs[0]
if (!redirectEntry?.to || !isSafeRedirectTarget(redirectEntry.to)) { if (!redirectEntry?.to || !isSafeRedirectTarget(redirectEntry.to)) {
return NextResponse.json({ error: 'Redirect not found' }, { status: 404 }) return siteNotFoundRedirect(locale === 'zh' ? 'zh' : 'en')
} }
return NextResponse.redirect(new URL(redirectEntry.to, request.url), Number(redirectEntry.statusCode)) return NextResponse.redirect(new URL(redirectEntry.to, getSiteUrl()), Number(redirectEntry.statusCode))
} }
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from 'next/server'
import { getSiteUrl } from '@/lib/seo'
export function siteNotFoundRedirect(locale: 'en' | 'zh' = 'en') {
const response = NextResponse.redirect(new URL(`/${locale}/404`, getSiteUrl()), 307)
response.headers.set('Cache-Control', 'no-store, max-age=0')
return response
}
+19 -4
View File
@@ -11,7 +11,7 @@ type ServeStaticFileOptions = {
headOnly?: boolean headOnly?: boolean
parts: string[] parts: string[]
request: Request request: Request
root: string root: string | string[]
} }
type ByteRange = { type ByteRange = {
@@ -44,6 +44,15 @@ async function resolveExistingFile(root: string, requestedPath: string) {
} }
} }
async function resolveExistingFileFromRoots(roots: string | string[], requestedPath: string) {
for (const root of Array.isArray(roots) ? roots : [roots]) {
const filePath = await resolveExistingFile(root, requestedPath)
if (filePath) return filePath
}
return null
}
function parseRangeHeader(value: string | null, size: number): ByteRange | 'invalid' | null { function parseRangeHeader(value: string | null, size: number): ByteRange | 'invalid' | null {
if (!value) return null if (!value) return null
const match = /^bytes=(\d*)-(\d*)$/.exec(value.trim()) const match = /^bytes=(\d*)-(\d*)$/.exec(value.trim())
@@ -119,12 +128,15 @@ export async function serveStaticFile({
request, request,
root, root,
}: ServeStaticFileOptions) { }: ServeStaticFileOptions) {
const notFound = () => new Response(headOnly ? null : 'Not Found', { status: 404 })
try {
const requestedPath = parts.join('/') const requestedPath = parts.join('/')
const filePath = await resolveExistingFile(root, requestedPath) const filePath = await resolveExistingFileFromRoots(root, requestedPath)
if (!filePath) return new Response('Not Found', { status: 404 }) if (!filePath) return notFound()
const stats = await fs.stat(filePath) const stats = await fs.stat(filePath)
if (!stats.isFile()) return new Response('Not Found', { status: 404 }) if (!stats.isFile()) return notFound()
const etag = `W/"${stats.size}-${Math.floor(stats.mtimeMs)}"` const etag = `W/"${stats.size}-${Math.floor(stats.mtimeMs)}"`
const lastModified = stats.mtime.toUTCString() const lastModified = stats.mtime.toUTCString()
@@ -168,4 +180,7 @@ export async function serveStaticFile({
headers, headers,
status: 200, status: 200,
}) })
} catch {
return notFound()
}
} }
+2 -1
View File
@@ -2,6 +2,7 @@ import createMiddleware from 'next-intl/middleware'
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { defaultLocale, locales } from './i18n/config' import { defaultLocale, locales } from './i18n/config'
import { getSiteUrl } from './lib/seo'
const intlMiddleware = createMiddleware({ const intlMiddleware = createMiddleware({
locales: [...locales], locales: [...locales],
@@ -39,7 +40,7 @@ export default function middleware(request: NextRequest) {
if (!hasLocalePrefix) { if (!hasLocalePrefix) {
const locale = shouldUseSimplifiedChinese(request) ? 'zh' : 'en' const locale = shouldUseSimplifiedChinese(request) ? 'zh' : 'en'
const url = request.nextUrl.clone() const url = new URL(request.nextUrl.pathname, getSiteUrl())
url.pathname = `/${locale}${pathname === '/' ? '' : pathname}` url.pathname = `/${locale}${pathname === '/' ? '' : pathname}`
url.search = search url.search = search
return NextResponse.redirect(url) return NextResponse.redirect(url)