chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

153 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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