Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
297 lines
8.6 KiB
TypeScript
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' } },
|
|
)
|
|
}
|
|
}
|