// 文件访问API - 提供本地上传文件的访问 import { NextRequest } from 'next/server'; import fs from 'fs'; import path from 'path'; import { lookup as mimeTypeLookup } from 'mime-types'; import { DownloadErrorCode } from '@/lib/download-errors'; import { findBlockedVideoPreviewByUrl, isVideoMediaPath } from '@/lib/video-preview-access'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; // GET - 获取文件 export async function GET( request: NextRequest, { params }: { params: { path: string[] } } ) { const pathSegments = params.path || []; // 健康检查 if (pathSegments.length === 1 && pathSegments[0] === 'health') { return new Response('OK', { status: 200 }); } const uploadDir = process.env.UPLOAD_DIR || '/app/uploads'; const relativePath = path.join(...pathSegments); const normalizedRelativePath = relativePath.replace(/\\/g, '/'); const filePath = path.resolve(uploadDir, relativePath); const baseDir = path.resolve(uploadDir); // 安全检查:防止路径遍历攻击 if (!filePath.startsWith(baseDir)) { return new Response('Forbidden', { status: 403 }); } if (isVideoMediaPath(normalizedRelativePath)) { const blockedPreview = await findBlockedVideoPreviewByUrl([ request.url, `/api/uploads/${normalizedRelativePath}`, `/uploads/${normalizedRelativePath}`, normalizedRelativePath, ]); if (blockedPreview) { return Response.json( { error: '视频类素材上传后满 15 天才能预览和下载', code: DownloadErrorCode.VIDEO_WAIT_PERIOD, details: blockedPreview.message, }, { status: 403, headers: { 'Cache-Control': 'no-store' }, }, ); } } try { const stats = await fs.promises.stat(filePath); if (!stats.isFile()) { return new Response('Not Found', { status: 404 }); } const range = request.headers.get('range'); const contentType = mimeTypeLookup(path.extname(filePath)) || 'application/octet-stream'; // 支持Range请求(用于视频流) if (range) { const fileSize = stats.size; const parts = range.match(/bytes=(\d*)-(\d*)/); const start = parts && parts[1] ? parseInt(parts[1], 10) : 0; const end = parts && parts[2] ? parseInt(parts[2], 10) : fileSize - 1; // 验证Range if (isNaN(start) || isNaN(end) || start > end || end >= fileSize) { return new Response('Invalid Range', { status: 416 }); } const stream = fs.createReadStream(filePath, { start, end }); return new Response(stream as any, { status: 206, headers: { 'Content-Range': `bytes ${start}-${end}/${fileSize}`, 'Accept-Ranges': 'bytes', 'Content-Length': String(end - start + 1), 'Content-Type': contentType, 'Cache-Control': 'public, max-age=31536000, immutable', }, }); } // 普通请求 const stream = fs.createReadStream(filePath); return new Response(stream as any, { status: 200, headers: { 'Content-Length': String(stats.size), 'Content-Type': contentType, 'Cache-Control': 'public, max-age=31536000, immutable', }, }); } catch (error: any) { if (error.code === 'ENOENT') { return new Response('Not Found', { status: 404 }); } return new Response('Server Error', { status: 500 }); } } // HEAD - 检查文件是否存在 export async function HEAD( request: NextRequest, { params }: { params: { path: string[] } } ) { const pathSegments = params.path || []; // 健康检查 if (pathSegments.length === 1 && pathSegments[0] === 'health') { return new Response(null, { status: 200 }); } const uploadDir = process.env.UPLOAD_DIR || '/app/uploads'; const relativePath = path.join(...pathSegments); const normalizedRelativePath = relativePath.replace(/\\/g, '/'); const filePath = path.resolve(uploadDir, relativePath); const baseDir = path.resolve(uploadDir); // 安全检查:防止路径遍历攻击 if (!filePath.startsWith(baseDir)) { return new Response(null, { status: 403 }); } if (isVideoMediaPath(normalizedRelativePath)) { const blockedPreview = await findBlockedVideoPreviewByUrl([ request.url, `/api/uploads/${normalizedRelativePath}`, `/uploads/${normalizedRelativePath}`, normalizedRelativePath, ]); if (blockedPreview) { return new Response(null, { status: 403, headers: { 'Cache-Control': 'no-store' }, }); } } try { const stats = await fs.promises.stat(filePath); if (!stats.isFile()) { return new Response(null, { status: 404 }); } const contentType = mimeTypeLookup(path.extname(filePath)) || 'application/octet-stream'; return new Response(null, { status: 200, headers: { 'Content-Length': String(stats.size), 'Content-Type': contentType, 'Cache-Control': 'public, max-age=31536000, immutable', }, }); } catch (error: any) { if (error.code === 'ENOENT') { return new Response(null, { status: 404 }); } return new Response(null, { status: 500 }); } }