// LEGACY_READ_ONLY: 旧域名仅用于代理读取历史素材,禁止作为新写入目标。 import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' 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' import { userHasVideoEarlyAccess } from '@/lib/video-download-policy' 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, cleanup: () => void, ): ReadableStream { const reader = body.getReader() return new ReadableStream({ 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 { 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 videoSession = await getServerSession(authOptions) const blockedPreview = await findBlockedVideoPreviewByUrl( [ targetUrl.toString(), targetUrl.pathname, ], new Date(), { bypassWaitPeriod: userHasVideoEarlyAccess(videoSession?.user) }, ) 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' } }, ) } }