Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
153 lines
5.2 KiB
TypeScript
153 lines
5.2 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
||
import { getAuthenticatedUploadUserId } from '@/lib/upload-auth'
|
||
import { saveUploadSession } from '@/lib/upload-sessions'
|
||
import { getUploadRootDir } from '@/lib/upload-paths'
|
||
import {
|
||
ensureSafeDirectoryWithinRoot,
|
||
normalizeSafeFileName,
|
||
} from '@/lib/path-security'
|
||
import { randomBytes } from 'crypto'
|
||
import { rm, statfs, writeFile } from 'fs/promises'
|
||
import path from 'path'
|
||
import {
|
||
MAX_LOGICAL_UPLOAD_BYTES,
|
||
MAX_UPLOAD_CHUNKS,
|
||
resolveUploadChunkSize,
|
||
} from '@/lib/material-upload-chunk-policy'
|
||
|
||
const MEBIBYTE = 1024 * 1024
|
||
const GIBIBYTE = 1024 * MEBIBYTE
|
||
const MIN_FREE_SPACE_RESERVE_BYTES = 2 * GIBIBYTE
|
||
|
||
export async function POST(req: NextRequest) {
|
||
try {
|
||
const userId = await getAuthenticatedUploadUserId(req)
|
||
if (!userId) {
|
||
return NextResponse.json({ error: '请先登录' }, { status: 401 })
|
||
}
|
||
|
||
let body
|
||
try {
|
||
body = await req.json()
|
||
} catch (jsonError) {
|
||
console.error('[UploadInit] JSON解析错误:', jsonError)
|
||
return NextResponse.json(
|
||
{ error: '请求体格式错误,需要JSON格式' },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
const rawFileName = typeof body?.fileName === 'string' ? body.fileName : ''
|
||
const normalizedFileName = normalizeSafeFileName(rawFileName)
|
||
const normalizedFileSize = Number(body?.fileSize)
|
||
const normalizedTotalChunks = Number(body?.totalChunks)
|
||
|
||
if (!rawFileName.trim()) {
|
||
return NextResponse.json({ error: '文件名不能为空' }, { status: 400 })
|
||
}
|
||
if (!normalizedFileName) {
|
||
return NextResponse.json({ error: '文件名包含不安全的路径或字符' }, { status: 400 })
|
||
}
|
||
|
||
if (!Number.isSafeInteger(normalizedFileSize) || normalizedFileSize <= 0) {
|
||
return NextResponse.json({ error: '上传文件不能为空' }, { status: 400 })
|
||
}
|
||
if (normalizedFileSize > MAX_LOGICAL_UPLOAD_BYTES) {
|
||
return NextResponse.json(
|
||
{ error: '单个源文件不能超过 10GB', code: 'SOURCE_FILE_TOO_LARGE' },
|
||
{ status: 413 },
|
||
)
|
||
}
|
||
|
||
if (
|
||
!Number.isInteger(normalizedTotalChunks)
|
||
|| normalizedTotalChunks <= 0
|
||
|| normalizedTotalChunks > MAX_UPLOAD_CHUNKS
|
||
) {
|
||
return NextResponse.json({ error: '上传分片参数无效' }, { status: 400 })
|
||
}
|
||
|
||
const normalizedChunkSize = resolveUploadChunkSize(
|
||
normalizedFileSize,
|
||
normalizedTotalChunks,
|
||
body?.chunkSize,
|
||
)
|
||
if (!normalizedChunkSize) {
|
||
return NextResponse.json(
|
||
{ error: '上传分片大小与文件总大小不匹配', code: 'CHUNK_LAYOUT_INVALID' },
|
||
{ status: 400 },
|
||
)
|
||
}
|
||
|
||
// 生成上传ID
|
||
const uploadId = `upload-${Date.now()}-${randomBytes(16).toString('hex')}`
|
||
|
||
// 创建临时目录
|
||
const uploadDir = getUploadRootDir()
|
||
const tempDir = await ensureSafeDirectoryWithinRoot(uploadDir, 'temp', uploadId)
|
||
if (!tempDir) {
|
||
throw new Error('上传临时目录不安全')
|
||
}
|
||
|
||
// 分片合并期间会同时保留分片与完整输出,必须为两份源文件再加固定
|
||
// 安全余量预留空间。无法确认剩余空间时保守拒绝,避免写满 uploads/NAS。
|
||
try {
|
||
const disk = await statfs(tempDir, { bigint: true })
|
||
const availableBytes = disk.bavail * disk.bsize
|
||
const requiredBytes = BigInt(normalizedFileSize) * BigInt(2) + BigInt(MIN_FREE_SPACE_RESERVE_BYTES)
|
||
if (availableBytes < requiredBytes) {
|
||
await rm(tempDir, { recursive: true, force: true })
|
||
return NextResponse.json(
|
||
{ error: '上传存储空间不足,请联系管理员清理后重试', code: 'UPLOAD_STORAGE_LOW' },
|
||
{ status: 507 },
|
||
)
|
||
}
|
||
} catch (diskError) {
|
||
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
|
||
console.error('[UploadInit] 无法确认上传盘剩余空间:', diskError)
|
||
return NextResponse.json(
|
||
{ error: '暂时无法确认上传存储空间,请稍后重试', code: 'UPLOAD_STORAGE_UNKNOWN' },
|
||
{ status: 503 },
|
||
)
|
||
}
|
||
// 写入元数据,方便在Redis丢失时回溯
|
||
const metaPath = path.join(tempDir, 'meta.json')
|
||
const metaPayload = {
|
||
fileName: normalizedFileName,
|
||
fileSize: normalizedFileSize,
|
||
totalChunks: normalizedTotalChunks,
|
||
chunkSize: normalizedChunkSize,
|
||
userId,
|
||
createdAt: Date.now(),
|
||
}
|
||
await writeFile(metaPath, JSON.stringify(metaPayload), 'utf-8')
|
||
|
||
// 保存会话(使用统一的接口,自动选择Redis或内存Map)
|
||
const now = Date.now()
|
||
await saveUploadSession(uploadId, {
|
||
fileName: normalizedFileName,
|
||
fileSize: normalizedFileSize,
|
||
totalChunks: normalizedTotalChunks,
|
||
chunkSize: normalizedChunkSize,
|
||
chunks: new Map(),
|
||
createdAt: now,
|
||
lastActivityAt: now,
|
||
uploadedBytes: 0,
|
||
currentSpeed: 0,
|
||
_speedSampleAt: now,
|
||
_speedSampleBytes: 0,
|
||
userId,
|
||
})
|
||
|
||
console.log(`[UploadInit] Created upload session: ${uploadId}, chunks: ${normalizedTotalChunks}`)
|
||
|
||
return NextResponse.json({ uploadId })
|
||
} catch (error) {
|
||
console.error('[UploadInit] Error:', error)
|
||
return NextResponse.json(
|
||
{ error: '初始化上传失败' },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
}
|