Files
website/src/app/api/media-assets/download/[id]/route.ts
T
2026-05-08 23:40:42 +08:00

100 lines
3.0 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server'
import { getPayloadClient } from '@/lib/payload'
function cleanFilenamePart(value: string | null | undefined, fallback: string) {
const cleaned = value
?.trim()
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
return cleaned || fallback
}
function buildFilename(title: string | null | undefined, format: string | null | undefined) {
const name = cleanFilenamePart(title, 'media-asset')
const extension = format?.trim().replace(/^\./, '').toLowerCase()
return `${name}${extension ? `.${extension}` : ''}`
}
function getOriginalFilenameFromUrl(value: string) {
try {
const parsed = new URL(value, 'http://localhost')
const filename = decodeURIComponent(parsed.pathname.split('/').filter(Boolean).pop() || '')
const cleaned = filename
.trim()
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/^-|-$/g, '')
return cleaned && cleaned.includes('.') ? cleaned : null
} catch {
return null
}
}
function contentDisposition(filename: string) {
const ascii = filename.replace(/[^\x20-\x7E]/g, '_')
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`
}
function resolveDownloadUrl(value: string, request: NextRequest) {
const trimmed = value.trim()
if (trimmed.startsWith('/')) return new URL(trimmed, request.url).toString()
const parsed = new URL(trimmed)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('Unsupported download URL protocol.')
}
return parsed.toString()
}
async function proxyAttachment(url: string, filename: string, request: NextRequest) {
const resolvedUrl = resolveDownloadUrl(url, request)
const upstream = await fetch(resolvedUrl, {
cache: 'no-store',
headers: {
Accept: '*/*',
},
})
if (!upstream.ok || !upstream.body) {
return NextResponse.json({ error: 'Download source is not available.' }, { status: 502 })
}
const headers = new Headers()
headers.set('Content-Disposition', contentDisposition(getOriginalFilenameFromUrl(resolvedUrl) || filename))
headers.set('Content-Type', upstream.headers.get('content-type') || 'application/octet-stream')
headers.set('Cache-Control', 'private, no-store')
const contentLength = upstream.headers.get('content-length')
if (contentLength) headers.set('Content-Length', contentLength)
return new NextResponse(upstream.body, {
headers,
status: 200,
})
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
const payload = await getPayloadClient()
const asset = await payload.findByID({
collection: 'mediaAssets',
depth: 0,
id,
})
if (!asset.externalUrl || asset.status !== 'published') {
return NextResponse.json({ error: 'Download not found.' }, { status: 404 })
}
return proxyAttachment(asset.externalUrl, buildFilename(asset.title, asset.fileFormat), request)
}