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

278 lines
9.3 KiB
TypeScript

// 分片上传完成合并接口
// 负责将分片文件按顺序合并为完整文件,校验大小,并返回服务器侧可复用的文件路径
// 供前端在第二步调用正式上传接口时复用,避免重复上传大文件
import { lstat, rm } from 'fs/promises'
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import {
acquireUploadMergeDiskReservation,
mergeUploadChunksAtomically,
type UploadChunkToMerge,
} from '@/lib/material-upload-merge'
import {
getExpectedUploadChunkBytes,
resolveUploadChunkSize,
} from '@/lib/material-upload-chunk-policy'
import {
MaterialUploadRequestError,
materialUploadMergeRequestLimiter,
} from '@/lib/material-upload-request-guard'
import {
deleteUploadSession,
getOwnedUploadSession,
isSafeUploadId,
} from '@/lib/upload-sessions'
import { getUploadRootDir } from '@/lib/upload-paths'
import { normalizeSafeFileName, resolveStrictChildPath } from '@/lib/path-security'
import {
acquireMaterialUploadStorageLock,
type MaterialUploadStorageLockRelease,
} from '@/lib/material-upload-cross-process-lock'
import {
abortUploadCompletionSeal,
beginUploadCompletionSeal,
completeUploadCompletionSeal,
type UploadCompletionSealHandle,
} from '@/lib/material-upload-session-seal'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 10800 // 最长3小时
export async function POST(req: NextRequest) {
let releaseMergeSlot: (() => void) | null = null
let releaseCrossProcessLock: MaterialUploadStorageLockRelease | null = null
let releaseStorageReservation: (() => void) | null = null
let completionSealHandle: UploadCompletionSealHandle | null = null
let completionSealFinalized = false
try {
const session = await getServerSession(authOptions)
const userId = String((session?.user as { id?: unknown } | undefined)?.id || '').trim()
if (!userId) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const body = await req.json()
const uploadId = String(body?.uploadId || '').trim()
if (!isSafeUploadId(uploadId)) {
return NextResponse.json({ error: 'uploadId 无效' }, { status: 400 })
}
// Redis 会话可能在第一次成功响应前后被清理;归属明确的 meta.json 是
// 幂等重试依据,保证公网/FRP 丢响应后无需重新上传整个大文件。
const uploadSession = await getOwnedUploadSession(uploadId, userId)
if (!uploadSession) {
return NextResponse.json({ error: '上传会话不存在、已过期或无权访问' }, { status: 404 })
}
const mergedFileName = normalizeSafeFileName(uploadSession.fileName)
const uploadRoot = getUploadRootDir()
const tempDir = resolveStrictChildPath(uploadRoot, 'temp', uploadId)
const mergedPath = mergedFileName
? resolveStrictChildPath(tempDir || '', `merged-${mergedFileName}`)
: null
if (!tempDir || !mergedFileName || !mergedPath) {
return NextResponse.json({ error: '上传会话路径无效' }, { status: 400 })
}
const chunkSize = resolveUploadChunkSize(
uploadSession.fileSize,
uploadSession.totalChunks,
uploadSession.chunkSize,
)
if (!chunkSize) {
return NextResponse.json(
{ error: '上传会话的分片布局无效,请重新开始上传', code: 'CHUNK_LAYOUT_INVALID' },
{ status: 409 },
)
}
const chunks: UploadChunkToMerge[] = []
for (let index = 0; index < uploadSession.totalChunks; index += 1) {
const expectedBytes = getExpectedUploadChunkBytes({
fileSize: uploadSession.fileSize,
totalChunks: uploadSession.totalChunks,
chunkSize,
chunkIndex: index,
})
const chunkPath = resolveStrictChildPath(tempDir, `chunk-${index}`)
if (!expectedBytes || !chunkPath) {
return NextResponse.json(
{ error: '上传会话的分片布局无效,请重新开始上传', code: 'CHUNK_LAYOUT_INVALID' },
{ status: 409 },
)
}
chunks.push({ path: chunkPath, expectedBytes })
}
releaseMergeSlot = await materialUploadMergeRequestLimiter.acquire()
releaseCrossProcessLock = await acquireMaterialUploadStorageLock('exclusive')
const completionSeal = await beginUploadCompletionSeal(uploadId)
if (completionSeal.state === 'sealing') {
completionSealHandle = completionSeal.handle
}
// 第一次请求可能已经成功,只是响应在公网链路中丢失。完整且大小一致的
// merged 文件是同一会话的确定结果,重复调用直接返回同一路径。
const existingMerged = await inspectExistingMergedFile(
mergedPath,
uploadSession.fileSize,
)
if (existingMerged === 'valid') {
if (completionSealHandle) {
await completeUploadCompletionSeal(completionSealHandle)
completionSealFinalized = true
}
await cleanupCompletedUpload(uploadId, chunks.map((chunk) => chunk.path))
return createCompletedResponse({
uploadId,
mergedPath,
mergedFileName,
fileSize: uploadSession.fileSize,
})
}
if (completionSeal.state === 'completed') {
throw new MaterialUploadRequestError(
'上传会话已完成,但合并文件状态异常;服务器已停止覆盖,请联系管理员核查',
409,
'UPLOAD_COMPLETED_FILE_INVALID',
)
}
if (existingMerged === 'invalid') {
await rm(mergedPath, { force: true })
}
for (let index = 0; index < chunks.length; index += 1) {
const chunk = chunks[index]
try {
const stats = await lstat(chunk.path)
if (
stats.isSymbolicLink()
|| !stats.isFile()
|| stats.size !== chunk.expectedBytes
) {
return NextResponse.json(
{ error: `分片 ${index} 大小不匹配,请重新上传该文件`, code: 'CHUNK_SIZE_MISMATCH' },
{ status: 400 },
)
}
} catch (error: any) {
if (error?.code === 'ENOENT') {
return NextResponse.json(
{ error: '分片不完整,请继续上传后重试', code: 'CHUNK_INCOMPLETE' },
{ status: 400 },
)
}
throw error
}
}
// init 的空间判断只是上传开始时的快照。这里在唯一合并槽内重新确认
// “第二份完整文件 + 2GiB 安全余量”,否则必须 fail-closed。
releaseStorageReservation = await acquireUploadMergeDiskReservation(
tempDir,
uploadSession.fileSize,
)
await mergeUploadChunksAtomically({
chunks,
expectedTotalBytes: uploadSession.fileSize,
outputPath: mergedPath,
assertLockHeld: () => releaseCrossProcessLock?.assertHeld(),
})
if (!completionSealHandle) {
throw new Error('上传完成标记句柄缺失')
}
await completeUploadCompletionSeal(completionSealHandle)
completionSealFinalized = true
await cleanupCompletedUpload(uploadId, chunks.map((chunk) => chunk.path))
return createCompletedResponse({
uploadId,
mergedPath,
mergedFileName,
fileSize: uploadSession.fileSize,
})
} catch (error) {
if (error instanceof MaterialUploadRequestError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{
status: error.status,
headers: error.retryAfterSeconds
? { 'Retry-After': String(error.retryAfterSeconds) }
: undefined,
},
)
}
console.error('[UploadComplete] Error:', error)
return NextResponse.json({ error: '合并分片失败' }, { status: 500 })
} finally {
releaseStorageReservation?.()
if (completionSealHandle && !completionSealFinalized) {
await abortUploadCompletionSeal(completionSealHandle).catch((error) => {
console.error('[UploadComplete] 回收未完成封存标记失败:', error)
})
}
await releaseCrossProcessLock?.()
releaseMergeSlot?.()
}
}
async function inspectExistingMergedFile(
mergedPath: string,
expectedBytes: number,
): Promise<'missing' | 'valid' | 'invalid'> {
try {
const stats = await lstat(mergedPath)
return !stats.isSymbolicLink()
&& stats.isFile()
&& stats.size === expectedBytes
? 'valid'
: 'invalid'
} catch (error: any) {
if (error?.code === 'ENOENT') {
return 'missing'
}
throw error
}
}
async function cleanupCompletedUpload(uploadId: string, chunkFiles: string[]): Promise<void> {
for (const chunkFile of chunkFiles) {
try {
await rm(chunkFile, { force: true })
} catch (error) {
console.warn('[UploadComplete] 清理分片异常(保留已验证的合并文件):', {
chunkFile,
error,
})
}
}
try {
await deleteUploadSession(uploadId)
console.log('[UploadComplete] 已删除会话:', uploadId)
} catch (error) {
console.warn('[UploadComplete] 删除会话失败(保留已验证的合并文件):', error)
}
}
function createCompletedResponse(input: {
uploadId: string
mergedPath: string
mergedFileName: string
fileSize: number
}) {
return NextResponse.json({
message: '分片合并完成',
existingFilePath: input.mergedPath,
fileName: input.mergedFileName,
fileSize: input.fileSize,
uploadId: input.uploadId,
})
}