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

99 lines
3.2 KiB
TypeScript

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 })
}
}