chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

297 lines
8.6 KiB
TypeScript

// LEGACY_READ_ONLY: 旧域名仅用于代理读取历史素材,禁止作为新写入目标。
import { NextRequest, NextResponse } from 'next/server'
import { DownloadErrorCode } from '@/lib/download-errors'
import {
assertProxyMediaFetchTargetIsSafe,
createProxyMediaPolicy,
parseMediaProxyTarget,
ProxyMediaFetchTarget,
ProxyMediaSecurityError,
resolveInitialProxyMediaFetchTarget,
resolveProxyMediaRedirect,
safeProxyMediaUrlForLog,
} from '@/lib/proxy-media-security'
import { findBlockedVideoPreviewByUrl, isVideoMediaPath } from '@/lib/video-preview-access'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
const MAX_REDIRECTS = 3
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
function securityErrorResponse(error: ProxyMediaSecurityError) {
return NextResponse.json(
{ error: error.message },
{
status: error.status,
headers: { 'Cache-Control': 'no-store' },
},
)
}
function createLinkedAbortController(request: NextRequest, timeoutMs: number) {
const controller = new AbortController()
let cleanedUp = false
const abortFromClient = () => controller.abort(request.signal.reason)
if (request.signal.aborted) {
abortFromClient()
} else {
request.signal.addEventListener('abort', abortFromClient, { once: true })
}
const timeoutId = setTimeout(() => controller.abort(new Error('proxy-media-timeout')), timeoutMs)
return {
controller,
cleanup() {
if (cleanedUp) return
cleanedUp = true
clearTimeout(timeoutId)
request.signal.removeEventListener('abort', abortFromClient)
},
}
}
function wrapResponseBody(
body: ReadableStream<Uint8Array>,
cleanup: () => void,
): ReadableStream<Uint8Array> {
const reader = body.getReader()
return new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const result = await reader.read()
if (result.done) {
cleanup()
controller.close()
return
}
controller.enqueue(result.value)
} catch (error) {
cleanup()
controller.error(error)
}
},
async cancel(reason) {
cleanup()
await reader.cancel(reason)
},
})
}
async function cancelResponseBody(response: Response): Promise<void> {
try {
await response.body?.cancel()
} catch {
// 连接已经关闭时无需再次处理。
}
}
async function fetchWithValidatedRedirects(
initialTarget: ProxyMediaFetchTarget,
headers: HeadersInit,
signal: AbortSignal,
): Promise<{ response: Response; finalTarget: ProxyMediaFetchTarget }> {
const policy = createProxyMediaPolicy()
let currentTarget = initialTarget
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount += 1) {
await assertProxyMediaFetchTargetIsSafe(currentTarget)
const response = await fetch(currentTarget.url.toString(), {
method: 'GET',
headers,
redirect: 'manual',
signal,
})
if (!REDIRECT_STATUSES.has(response.status)) {
return { response, finalTarget: currentTarget }
}
const location = response.headers.get('location')
await cancelResponseBody(response)
if (!location) {
throw new ProxyMediaSecurityError('上游跳转缺少目标地址', 502)
}
if (redirectCount === MAX_REDIRECTS) {
throw new ProxyMediaSecurityError('上游跳转次数过多', 502)
}
currentTarget = resolveProxyMediaRedirect(currentTarget, location, policy)
}
throw new ProxyMediaSecurityError('上游跳转次数过多', 502)
}
export async function GET(request: NextRequest) {
const rawUrl = request.nextUrl.searchParams.get('url')
if (!rawUrl) {
return NextResponse.json(
{ error: '缺少 url 参数' },
{ status: 400, headers: { 'Cache-Control': 'no-store' } },
)
}
const policy = createProxyMediaPolicy()
let targetUrl: URL
try {
targetUrl = parseMediaProxyTarget(rawUrl, policy)
} catch (error) {
if (error instanceof ProxyMediaSecurityError) {
return securityErrorResponse(error)
}
return NextResponse.json(
{ error: '无效的媒体地址' },
{ status: 400, headers: { 'Cache-Control': 'no-store' } },
)
}
const safeTargetLabel = safeProxyMediaUrlForLog(targetUrl)
try {
if (isVideoMediaPath(targetUrl.pathname)) {
const blockedPreview = await findBlockedVideoPreviewByUrl([
targetUrl.toString(),
targetUrl.pathname,
])
if (blockedPreview) {
return NextResponse.json(
{
error: '视频类素材上传后满 15 天才能预览和下载',
code: DownloadErrorCode.VIDEO_WAIT_PERIOD,
details: blockedPreview.message,
},
{
status: 403,
headers: { 'Cache-Control': 'no-store' },
},
)
}
}
const rangeHeader = request.headers.get('range')
const fetchHeaders: HeadersInit = {
'User-Agent': 'Mozilla/5.0 (compatible; MediaProxy/2.0)',
...(rangeHeader ? { Range: rangeHeader } : {}),
}
const hostname = targetUrl.hostname.toLowerCase()
const isKnownPublicS3 = hostname === 's3.yydd.ai'
|| hostname === 's3.jiansun.vip'
|| hostname === 's3.1080p.hk'
const timeoutMs = isKnownPublicS3
? (rangeHeader ? 120_000 : 60_000)
: (rangeHeader ? 30_000 : 10_000)
const abort = createLinkedAbortController(request, timeoutMs)
try {
const initialTarget = resolveInitialProxyMediaFetchTarget(targetUrl, policy)
if (initialTarget.trustedInternalS3) {
console.info('[Proxy Media] 使用受信任的内网 S3 回源:', safeTargetLabel)
}
const { response, finalTarget } = await fetchWithValidatedRedirects(
initialTarget,
fetchHeaders,
abort.controller.signal,
)
if (!response.ok && response.status !== 206) {
await cancelResponseBody(response)
abort.cleanup()
console.warn('[Proxy Media] 上游返回失败状态:', {
status: response.status,
target: safeTargetLabel,
upstream: safeProxyMediaUrlForLog(finalTarget.url),
})
return NextResponse.json(
{ error: `资源获取失败: ${response.status}` },
{
status: response.status,
headers: { 'Cache-Control': 'no-store' },
},
)
}
const contentType = response.headers.get('content-type') || 'application/octet-stream'
const responseHeaders = new Headers({
'Content-Type': contentType,
'Cache-Control': response.status === 206
? 'public, max-age=31536000, immutable'
: 'public, max-age=3600, must-revalidate',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Range',
})
for (const headerName of [
'content-length',
'content-range',
'accept-ranges',
'etag',
'last-modified',
]) {
const value = response.headers.get(headerName)
if (value) responseHeaders.set(headerName, value)
}
if (!response.body) {
abort.cleanup()
return new NextResponse(null, {
status: response.status,
headers: responseHeaders,
})
}
return new NextResponse(wrapResponseBody(response.body, abort.cleanup), {
status: response.status,
headers: responseHeaders,
})
} catch (error) {
abort.cleanup()
throw error
}
} catch (error: any) {
if (error instanceof ProxyMediaSecurityError) {
console.warn('[Proxy Media] 安全校验拒绝请求:', {
target: safeTargetLabel,
reason: error.message,
})
return securityErrorResponse(error)
}
if (error?.name === 'AbortError' || error?.message === 'proxy-media-timeout') {
console.warn('[Proxy Media] 请求超时或已取消:', safeTargetLabel)
return NextResponse.json(
{ error: '请求超时或已取消' },
{ status: 504, headers: { 'Cache-Control': 'no-store' } },
)
}
const code = error?.code || error?.cause?.code
if (code === 'EAI_AGAIN' || code === 'ENOTFOUND') {
console.warn('[Proxy Media] DNS 解析失败:', safeTargetLabel)
return NextResponse.json(
{ error: '资源域名解析失败,请稍后重试' },
{ status: 502, headers: { 'Cache-Control': 'no-store' } },
)
}
console.error('[Proxy Media] 代理请求失败:', {
target: safeTargetLabel,
name: error?.name,
code,
})
return NextResponse.json(
{ error: '代理请求失败' },
{ status: 502, headers: { 'Cache-Control': 'no-store' } },
)
}
}