2026-05-29 19:01:59 +08:00

444 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 媒体资源代理 API - 解决混合内容问题
// 将 HTTP 资源通过 HTTPS 代理提供给前端
import { NextRequest, NextResponse } from 'next/server';
import dns from 'dns';
import { promisify } from 'util';
import { DownloadErrorCode } from '@/lib/download-errors';
import { findBlockedVideoPreviewByUrl, isVideoMediaPath } from '@/lib/video-preview-access';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
// 设置DNS服务器(使用docker-compose.yml中配置的DNS)
dns.setServers(['223.5.5.5', '8.8.8.8', '114.114.114.114']);
// DNS 解析缓存(避免高并发时DNS解析失败)
const dnsCache = new Map<string, { ip: string; expires: number }>();
const DNS_CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存
const MAX_DNS_CACHE_SIZE = 1000; // 最大缓存1000个域名
const resolve4 = promisify(dns.resolve4);
const PRIVATE_HOSTNAME_REGEX = /^192\.168\.|^10\.|^172\.(1[6-9]|2[0-9]|3[01])\./;
// 定期清理过期的DNS缓存(每10分钟)
setInterval(() => {
const now = Date.now();
const keysToDelete: string[] = [];
dnsCache.forEach((value, key) => {
if (value.expires < now) {
keysToDelete.push(key);
}
});
keysToDelete.forEach(key => dnsCache.delete(key));
if (keysToDelete.length > 0) {
console.log(`[DNS Cache] 清理 ${keysToDelete.length} 个过期缓存,当前缓存数: ${dnsCache.size}`);
}
}, 10 * 60 * 1000);
// DNS 解析函数(带缓存和重试)
async function resolveHostnameWithRetry(hostname: string, retries = 3): Promise<void> {
// 检查缓存
const cached = dnsCache.get(hostname);
if (cached && cached.expires > Date.now()) {
return; // 缓存有效,直接返回
}
// 尝试解析(预热DNS缓存)
for (let i = 0; i < retries; i++) {
try {
const addresses = await resolve4(hostname);
if (addresses && addresses.length > 0) {
const ip = addresses[0];
// 检查缓存大小,如果超过限制,删除最旧的缓存(LRU)
if (dnsCache.size >= MAX_DNS_CACHE_SIZE) {
const firstKey = dnsCache.keys().next().value;
if (firstKey) {
dnsCache.delete(firstKey);
console.log(`[DNS Cache] 缓存已满,删除最旧的缓存: ${firstKey}`);
}
}
// 更新缓存
dnsCache.set(hostname, {
ip,
expires: Date.now() + DNS_CACHE_TTL,
});
return; // 解析成功
}
} catch (error: any) {
if (i === retries - 1) {
// 最后一次重试失败,记录但继续(让fetch自己处理DNS)
console.warn(`[Proxy Media] DNS预解析失败: ${hostname} - ${error.message}`);
return;
}
// 等待后重试(指数退避)
await new Promise(resolve => setTimeout(resolve, 100 * Math.pow(2, i)));
}
}
}
// 允许的域名白名单
const ALLOWED_DOMAINS = [
's3.1080p.hk',
's3.yydd.ai',
's3.jiansun.vip',
'localhost',
'127.0.0.1',
];
// 允许的文件扩展名
const ALLOWED_EXTENSIONS = [
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', // 图片
'.mp4', '.webm', '.ogg', // 视频
'.mp3', '.wav', '.ogg', '.m4a', // 音频
];
function isPrivateHostname(hostname: string): boolean {
return hostname === 'localhost' || hostname === '127.0.0.1' || PRIVATE_HOSTNAME_REGEX.test(hostname);
}
function getConfiguredPublicS3Host(): string | null {
const publicDomain = (process.env.S3_PUBLIC_DOMAIN || '').trim();
if (!publicDomain) return null;
try {
const normalized = publicDomain.match(/^https?:\/\//i) ? publicDomain : `https://${publicDomain}`;
return new URL(normalized).hostname.toLowerCase();
} catch {
return null;
}
}
function getInternalS3BaseUrl(): URL | null {
const endpoint = (process.env.S3_ENDPOINT || '').trim();
if (endpoint) {
try {
return new URL(endpoint);
} catch {
console.warn('[Proxy Media] S3_ENDPOINT 配置无效,忽略内网回源:', endpoint);
}
}
const serverIp = (process.env.SERVER_IP || '').trim();
const minioPort = (process.env.MINIO_API_PORT || '9000').trim() || '9000';
if (serverIp) {
try {
return new URL(`http://${serverIp}:${minioPort}`);
} catch {
return null;
}
}
return null;
}
function resolveUpstreamUrl(targetUrl: URL): URL {
const hostname = targetUrl.hostname.toLowerCase();
const configuredPublicS3Host = getConfiguredPublicS3Host();
const shouldUseInternalS3 =
hostname === 's3.yydd.ai' ||
hostname === 's3.jiansun.vip' ||
(configuredPublicS3Host ? hostname === configuredPublicS3Host : false);
if (!shouldUseInternalS3) {
return targetUrl;
}
const internalS3BaseUrl = getInternalS3BaseUrl();
if (!internalS3BaseUrl) {
return targetUrl;
}
const upstreamUrl = new URL(targetUrl.toString());
upstreamUrl.protocol = internalS3BaseUrl.protocol;
upstreamUrl.hostname = internalS3BaseUrl.hostname;
upstreamUrl.port = internalS3BaseUrl.port;
return upstreamUrl;
}
export async function GET(request: NextRequest) {
let url: string | null = null;
try {
const searchParams = request.nextUrl.searchParams;
url = searchParams.get('url');
if (!url) {
console.warn('[Proxy Media] 缺少 url 参数');
return NextResponse.json(
{ error: '缺少 url 参数' },
{ status: 400 }
);
}
// 解析 URL
let targetUrl: URL;
try {
// 解码 URL(可能被双重编码)
const decodedUrl = decodeURIComponent(url);
targetUrl = new URL(decodedUrl);
} catch (error) {
console.error('[Proxy Media] 无效的 URL:', url, error);
return NextResponse.json(
{ error: '无效的 URL' },
{ status: 400 }
);
}
// 检查域名是否在白名单中
const hostname = targetUrl.hostname.toLowerCase();
const isAllowed = ALLOWED_DOMAINS.some(domain =>
hostname === domain || hostname.endsWith(`.${domain}`)
);
if (!isAllowed) {
console.warn('[Proxy Media] 不允许的域名:', hostname, 'URL:', targetUrl.toString());
return NextResponse.json(
{ error: '不允许的域名' },
{ status: 403 }
);
}
// 检查文件扩展名(从路径中提取,忽略查询参数)
const pathname = targetUrl.pathname.toLowerCase();
// 移除查询参数和片段,只检查路径部分
const pathWithoutQuery = pathname.split('?')[0];
const hasAllowedExtension = ALLOWED_EXTENSIONS.some(ext =>
pathWithoutQuery.endsWith(ext)
);
if (!hasAllowedExtension) {
console.warn('[Proxy Media] 不允许的文件类型:', pathname, 'URL:', targetUrl.toString());
return NextResponse.json(
{ error: '不允许的文件类型' },
{ status: 403 }
);
}
if (isVideoMediaPath(pathWithoutQuery)) {
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 upstreamUrl = resolveUpstreamUrl(targetUrl);
const upstreamHostname = upstreamUrl.hostname.toLowerCase();
if (upstreamUrl.toString() !== targetUrl.toString()) {
console.log('[Proxy Media] 公网S3切换为内网回源:', {
original: targetUrl.toString(),
upstream: upstreamUrl.toString(),
});
}
// 检查是否有 Range 请求(用于视频播放)
const rangeHeader = request.headers.get('range');
// 构建请求头
const fetchHeaders: HeadersInit = {
'User-Agent': 'Mozilla/5.0 (compatible; MediaProxy/1.0)',
};
// 如果有 Range 请求,转发给源服务器
if (rangeHeader) {
fetchHeaders['Range'] = rangeHeader;
}
// 创建超时控制器(兼容Node.js 18)
// 根据域名类型和请求类型设置不同的超时时间:
// - 公网S3域名(通过FRP转发):需要更长超时(60-120秒)
// - 内网MinIO:可以使用较短超时(10-30秒)
// - Range请求(视频播放):需要更长超时
const hasRangeRequest = !!request.headers.get('range')
const isPublicS3 =
hostname === 's3.yydd.ai' ||
hostname === 's3.jiansun.vip' ||
hostname === 's3.1080p.hk'
const timeout = isPublicS3
? (hasRangeRequest ? 120000 : 60000) // 公网S3: Range 120秒,完整 60秒
: (hasRangeRequest ? 30000 : 10000) // 内网: Range 30秒,完整 10秒
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
// 优化:对于内网MinIO地址,跳过DNS预解析(内网地址不需要DNS)
const isIntranetMinIO = isPrivateHostname(upstreamHostname)
// 预热DNS缓存(仅对公网域名,内网地址跳过)
if (!isIntranetMinIO) {
// 使用Promise.race确保不会无限等待,减少DNS解析时间
await Promise.race([
resolveHostnameWithRetry(upstreamHostname),
new Promise(resolve => setTimeout(resolve, 300)), // 减少到300ms,加快响应
]).catch(() => {
// 静默失败,继续执行fetch(让fetch自己处理DNS)
});
}
// 获取资源(fetch会使用Node.js的DNS缓存和我们的预解析结果)
// 优化:添加keepalive和更短的超时时间
const response = await fetch(upstreamUrl.toString(), {
headers: {
...fetchHeaders,
'Connection': 'keep-alive', // 保持连接,减少连接建立时间
},
signal: controller.signal,
// 优化:减少超时时间,加快失败响应
// 注意:超时由AbortController控制,这里只是设置更短的超时
});
clearTimeout(timeoutId);
if (!response.ok && response.status !== 206) { // 206 是部分内容响应,是正常的
// 对于 404 错误,记录详细信息以便诊断
if (response.status === 404) {
console.error(`[Proxy Media] 资源不存在 (404): ${targetUrl.toString()}`);
if (upstreamUrl.toString() !== targetUrl.toString()) {
console.error(`[Proxy Media] 实际回源地址: ${upstreamUrl.toString()}`);
}
console.error(`[Proxy Media] 原始请求 URL: ${url}`);
console.error(`[Proxy Media] 解析后的 URL: ${targetUrl.toString()}`);
} else {
console.error(`[Proxy Media] 资源获取失败: ${response.status} - ${targetUrl.toString()}`);
if (upstreamUrl.toString() !== targetUrl.toString()) {
console.error(`[Proxy Media] 实际回源地址: ${upstreamUrl.toString()}`);
}
}
// 对于 404,直接返回 404,让前端可以处理(比如显示占位图)
return NextResponse.json(
{ error: `资源获取失败: ${response.status}`, url: targetUrl.toString() },
{ status: response.status }
);
}
// 获取内容类型
const contentType = response.headers.get('content-type') || 'application/octet-stream';
const contentLength = response.headers.get('content-length');
const contentRange = response.headers.get('content-range');
const acceptRanges = response.headers.get('accept-ranges');
// 构建响应头(优化缓存策略)
const responseHeaders: HeadersInit = {
'Content-Type': contentType,
// 优化缓存:视频文件缓存1年,但允许重新验证
'Cache-Control': response.status === 206
? 'public, max-age=31536000, immutable' // Range请求:长期缓存
: 'public, max-age=3600, must-revalidate', // 完整请求:1小时缓存,需要重新验证
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Range',
// 添加ETag支持(如果源服务器提供)
...(response.headers.get('etag') && { 'ETag': response.headers.get('etag')! }),
// 添加Last-Modified支持(如果源服务器提供)
...(response.headers.get('last-modified') && { 'Last-Modified': response.headers.get('last-modified')! }),
};
// 添加 Range 相关头(如果存在)
if (contentLength) {
responseHeaders['Content-Length'] = contentLength;
}
if (contentRange) {
responseHeaders['Content-Range'] = contentRange;
}
if (acceptRanges) {
responseHeaders['Accept-Ranges'] = acceptRanges;
}
// 使用流式传输,避免大文件导致内存溢出
// 将 ReadableStream 转换为 NextResponse 可以使用的流
if (response.body) {
return new NextResponse(response.body, {
status: response.status, // 可能是 200 或 206
headers: responseHeaders,
});
} else {
// 如果 response.body 不存在,回退到 arrayBuffer(小文件)
const buffer = await response.arrayBuffer();
return new NextResponse(buffer, {
status: response.status,
headers: responseHeaders,
});
}
} catch (fetchError: any) {
clearTimeout(timeoutId);
// 如果是超时或中止错误
if (fetchError.name === 'AbortError' || fetchError.name === 'TimeoutError') {
console.error(`[Proxy Media] 请求超时: ${targetUrl.toString()}`);
if (upstreamUrl.toString() !== targetUrl.toString()) {
console.error(`[Proxy Media] 实际回源地址: ${upstreamUrl.toString()}`);
}
return NextResponse.json(
{ error: '请求超时', url: targetUrl.toString() },
{ status: 504 }
);
}
// 其他fetch错误(如连接失败)
console.error(`[Proxy Media] Fetch错误: ${fetchError.message} - ${targetUrl.toString()}`);
if (upstreamUrl.toString() !== targetUrl.toString()) {
console.error(`[Proxy Media] 实际回源地址: ${upstreamUrl.toString()}`);
}
console.error(`[Proxy Media] 原始请求 URL: ${url}`);
throw fetchError; // 重新抛出,让外层catch处理
}
} catch (error: any) {
console.error('[Proxy Media] Error:', error);
console.error('[Proxy Media] 原始请求 URL:', url);
// 如果是DNS解析错误
if (error.code === 'EAI_AGAIN' || error.code === 'ENOTFOUND' || error.message?.includes('getaddrinfo')) {
console.error(`[Proxy Media] DNS解析错误: ${error.code} - ${error.message}`);
return NextResponse.json(
{ error: 'DNS解析失败,请稍后重试', details: error.message },
{ status: 502 }
);
}
// 如果是网络连接错误
if (error.message?.includes('ECONNREFUSED') || error.message?.includes('ENOTFOUND') || error.message?.includes('ERR_CONNECTION')) {
console.error(`[Proxy Media] 网络连接错误: ${error.message}`);
return NextResponse.json(
{ error: '无法连接到资源服务器', details: error.message },
{ status: 502 }
);
}
// 如果是超时错误
if (error.name === 'AbortError' || error.name === 'TimeoutError') {
return NextResponse.json(
{ error: '请求超时' },
{ status: 504 }
);
}
console.error(`[Proxy Media] 未知错误: ${error.message}`, error);
// 记录完整的错误信息(包括cause)
if (error.cause) {
console.error(`[Proxy Media] 错误原因:`, error.cause);
}
return NextResponse.json(
{ error: '代理请求失败', details: error.message },
{ status: 500 }
);
}
}