import { NextRequest } from 'next/server' import { createReadStream, statSync } from 'fs' import { promises as fsp } from 'fs' import path from 'path' import mime from 'mime-types' import { DownloadErrorCode } from '@/lib/download-errors' import { findBlockedVideoPreviewByUrl, isVideoMediaPath } from '@/lib/video-preview-access' // Fallback streaming for /uploads/* when NAS/static mapping is not configured. // Supports Range requests for video playback. export async function GET(req: NextRequest, context: { params: { path?: string[] } }) { const segments = context.params.path || [] // Health probe: /uploads/health if (segments.length === 1 && segments[0] === 'health') { return new Response('OK', { status: 200 }) } const uploadsRoot = process.env.UPLOAD_DIR || './uploads' // Prevent path traversal const safeRel = path.join(...segments) const normalizedSafeRel = safeRel.replace(/\\/g, '/') const absPath = path.resolve(uploadsRoot, safeRel) const rootResolved = path.resolve(uploadsRoot) if (!absPath.startsWith(rootResolved)) { return new Response('Forbidden', { status: 403 }) } if (isVideoMediaPath(normalizedSafeRel)) { const blockedPreview = await findBlockedVideoPreviewByUrl([ req.url, `/uploads/${normalizedSafeRel}`, `/api/uploads/${normalizedSafeRel}`, normalizedSafeRel, ]) if (blockedPreview) { return Response.json( { error: '视频类素材上传后满 15 天才能预览和下载', code: DownloadErrorCode.VIDEO_WAIT_PERIOD, details: blockedPreview.message, }, { status: 403, headers: { 'Cache-Control': 'no-store' }, }, ) } } try { const stat = await fsp.stat(absPath) if (!stat.isFile()) { return new Response('Not Found', { status: 404 }) } const range = req.headers.get('range') const contentType = (mime.contentType(path.extname(absPath)) as string) || 'application/octet-stream' if (range) { const size = stat.size const match = range.match(/bytes=(\d*)-(\d*)/) const start = match && match[1] ? parseInt(match[1], 10) : 0 const end = match && match[2] ? parseInt(match[2], 10) : size - 1 if (isNaN(start) || isNaN(end) || start > end || end >= size) { return new Response('Invalid Range', { status: 416 }) } const chunkSize = end - start + 1 const stream = createReadStream(absPath, { start, end }) return new Response(stream as any, { status: 206, headers: { 'Content-Range': `bytes ${start}-${end}/${size}`, 'Accept-Ranges': 'bytes', 'Content-Length': chunkSize.toString(), 'Content-Type': contentType, 'Cache-Control': 'public, max-age=31536000, immutable', }, }) } const stream = createReadStream(absPath) return new Response(stream as any, { status: 200, headers: { 'Content-Length': stat.size.toString(), 'Content-Type': contentType, 'Cache-Control': 'public, max-age=31536000, immutable', }, }) } catch (err: any) { if (err.code === 'ENOENT') { return new Response('Not Found', { status: 404 }) } return new Response('Server Error', { status: 500 }) } }