99 lines
3.2 KiB
TypeScript
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 })
|
|
}
|
|
}
|