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

431 lines
16 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 { randomUUID } from 'crypto'
import { mkdtemp, rm } from 'fs/promises'
import path from 'path'
import AdmZip from 'adm-zip'
import { getServerSession } from 'next-auth'
import { NextRequest, NextResponse } from 'next/server'
import sharp from 'sharp'
import { authOptions } from '@/lib/auth'
import { readBoundedRegularFile } from '@/lib/bounded-file-reader'
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
import { assertProcessableMetadata, sliceImage } from '@/lib/image-processor'
import { withImageProcessingSlot } from '@/lib/image-processing-limiter'
import { acquireTaskMaterialCompletionLock } from '@/lib/material-upload-cross-process-lock'
import {
ensureSafeDirectoryWithinRoot,
isPathFreeOfSymbolicLinks,
isPathInAllowedDirs,
} from '@/lib/path-security'
import { prisma } from '@/lib/prisma'
import {
buildTaskDetailPublicDisplayPath,
uploadPublicDisplayToS3,
} from '@/lib/public-display'
import { deleteFromS3, verifyUploadedS3Object } from '@/lib/s3'
import { getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths'
const MATERIAL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
const MAX_REQUEST_BODY_BYTES = 2 * 1024
const MAX_SOURCE_ZIP_BYTES = 64 * 1024 * 1024
const MAX_ZIP_ENTRIES = 2_000
const MAX_ZIP_ENTRY_BYTES = 48 * 1024 * 1024
const MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES = 256 * 1024 * 1024
const MAX_ZIP_COMPRESSION_RATIO = 200
const MAX_IMAGE_PIXELS = 60_000_000
const MAX_IMAGE_DIMENSION = 20_000
const ALLOWED_IMAGE_ENTRY_PATTERN = /\.(?:jpe?g|png|webp)$/i
class RegeneratePreviewInputError extends Error {
constructor(
message: string,
readonly status: number = 400,
) {
super(message)
this.name = 'RegeneratePreviewInputError'
}
}
async function readBoundedRequestBody(request: NextRequest): Promise<Record<string, unknown>> {
const contentType = String(request.headers.get('content-type') || '').toLowerCase()
if (!/^application\/json(?:\s*;|$)/.test(contentType)) {
throw new RegeneratePreviewInputError('请求必须使用 JSON 格式', 415)
}
const contentEncoding = String(request.headers.get('content-encoding') || 'identity').toLowerCase()
if (contentEncoding !== 'identity') {
throw new RegeneratePreviewInputError('不支持压缩请求体', 415)
}
const contentLengthHeader = request.headers.get('content-length')
if (contentLengthHeader === null) {
throw new RegeneratePreviewInputError('请求体长度缺失', 411)
}
if (!/^\d+$/.test(contentLengthHeader)) {
throw new RegeneratePreviewInputError('请求体长度无效')
}
const declaredBytes = Number(contentLengthHeader)
if (!Number.isSafeInteger(declaredBytes) || declaredBytes <= 0) {
throw new RegeneratePreviewInputError('请求体长度无效')
}
if (declaredBytes > MAX_REQUEST_BODY_BYTES) {
throw new RegeneratePreviewInputError('请求体过大', 413)
}
if (!request.body) throw new RegeneratePreviewInputError('请求体不能为空')
const reader = request.body.getReader()
const chunks: Uint8Array[] = []
let totalBytes = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (!value || value.byteLength === 0) continue
totalBytes += value.byteLength
if (totalBytes > MAX_REQUEST_BODY_BYTES || totalBytes > declaredBytes) {
await reader.cancel().catch(() => undefined)
throw new RegeneratePreviewInputError('请求体实际大小与声明不符', 413)
}
chunks.push(value)
}
} finally {
reader.releaseLock()
}
if (totalBytes === 0 || totalBytes !== declaredBytes) {
throw new RegeneratePreviewInputError('请求体实际大小与声明不符')
}
let parsed: unknown
try {
parsed = JSON.parse(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), totalBytes).toString('utf8'))
} catch {
throw new RegeneratePreviewInputError('JSON 格式无效')
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new RegeneratePreviewInputError('请求体格式无效')
}
const keys = Object.keys(parsed)
if (keys.length !== 1 || keys[0] !== 'materialId') {
throw new RegeneratePreviewInputError('请求体只能包含素材编号')
}
return parsed as Record<string, unknown>
}
class RegeneratePreviewTemporaryError extends Error {
readonly status = 503
constructor(message: string) {
super(message)
this.name = 'RegeneratePreviewTemporaryError'
}
}
function validateZipEntryName(entryName: string): string {
const normalized = String(entryName || '').normalize('NFC').replace(/\\/g, '/')
const segments = normalized.split('/').filter(Boolean)
if (
!normalized
|| normalized.startsWith('/')
|| path.posix.isAbsolute(normalized)
|| path.win32.isAbsolute(normalized)
|| /^[A-Za-z]:/.test(normalized)
|| /[\x00-\x1f\x7f]/.test(normalized)
|| segments.length === 0
|| segments.some((segment) => segment === '.' || segment === '..')
) {
throw new RegeneratePreviewInputError('压缩包包含不安全的文件路径')
}
return normalized
}
function selectBoundedImageEntry(zip: AdmZip): AdmZip.IZipEntry {
const entries = zip.getEntries()
if (entries.length === 0 || entries.length > MAX_ZIP_ENTRIES) {
throw new RegeneratePreviewInputError('压缩包文件数量超出安全范围')
}
let totalUncompressedBytes = 0
let selected: AdmZip.IZipEntry | null = null
for (const entry of entries) {
// AdmZip 会把部分 ../ 片段静默净化后才暴露 entryName;必须同时检查 ZIP
// 中原始文件名,否则不能把库的展示层净化误当成档案本身安全。
validateZipEntryName(entry.rawEntryName.toString('utf8'))
const entryName = validateZipEntryName(entry.entryName)
if (entry.isDirectory) continue
if (entry.header.encripted) {
throw new RegeneratePreviewInputError('不支持加密压缩包')
}
const size = Number(entry.header.size)
const compressedSize = Number(entry.header.compressedSize)
if (
!Number.isSafeInteger(size)
|| size <= 0
|| !Number.isSafeInteger(compressedSize)
|| compressedSize <= 0
) {
throw new RegeneratePreviewInputError('压缩包文件大小信息无效')
}
if (size > MAX_ZIP_ENTRY_BYTES) {
throw new RegeneratePreviewInputError('压缩包内单个文件超过安全上限')
}
if (size / compressedSize > MAX_ZIP_COMPRESSION_RATIO) {
throw new RegeneratePreviewInputError('压缩包压缩比异常,拒绝处理')
}
totalUncompressedBytes += size
if (!Number.isSafeInteger(totalUncompressedBytes)
|| totalUncompressedBytes > MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES) {
throw new RegeneratePreviewInputError('压缩包解压后总大小超过安全上限')
}
if (!selected && ALLOWED_IMAGE_ENTRY_PATTERN.test(entryName)) selected = entry
}
if (!selected) throw new RegeneratePreviewInputError('压缩包内未找到支持的图片文件')
return selected
}
async function readZipEntryBounded(entry: AdmZip.IZipEntry): Promise<Buffer> {
const expectedSize = Number(entry.header.size)
const buffer = await new Promise<Buffer>((resolve, reject) => {
entry.getDataAsync((data, error) => {
if (error) {
reject(new RegeneratePreviewInputError('压缩包内图片读取失败'))
return
}
resolve(data)
})
})
if (buffer.length !== expectedSize || buffer.length > MAX_ZIP_ENTRY_BYTES) {
throw new RegeneratePreviewInputError('压缩包内图片实际大小与记录不一致')
}
return buffer
}
async function inspectPreviewCommit(materialId: string, newUrls: string[]) {
try {
const [material, assets] = await Promise.all([
prisma.material.findUnique({
where: { id: materialId },
select: { previewUrl: true },
}),
prisma.materialAsset.findMany({
where: { materialId, assetType: 'detail-preview' },
select: { fileUrl: true, sortOrder: true },
orderBy: { sortOrder: 'asc' },
}),
])
const actualUrls = assets.map((asset) => asset.fileUrl)
if (
material?.previewUrl === newUrls[0]
&& actualUrls.length === newUrls.length
&& actualUrls.every((url, index) => url === newUrls[index])
) {
return 'committed' as const
}
if (
material?.previewUrl !== newUrls[0]
&& actualUrls.every((url) => !newUrls.includes(url))
) {
return 'not-committed' as const
}
return 'ambiguous' as const
} catch {
return 'ambiguous' as const
}
}
async function cleanupDerivedObjects(urls: string[]): Promise<void> {
await Promise.allSettled(urls.map((url) => deleteFromS3(url)))
}
async function regeneratePreview(materialId: string) {
let releaseMaterialLock: Awaited<ReturnType<typeof acquireTaskMaterialCompletionLock>> | null = null
let tempDir: string | null = null
const uploadedUrls: string[] = []
let allowDerivedCleanup = true
try {
releaseMaterialLock = await acquireTaskMaterialCompletionLock(materialId)
releaseMaterialLock.assertHeld()
const material = await prisma.material.findUnique({ where: { id: materialId } })
if (!material) throw new RegeneratePreviewInputError('素材不存在')
const detailAssets = await prisma.materialAsset.findMany({
where: { materialId, assetType: 'detail' },
orderBy: { sortOrder: 'asc' },
})
if (detailAssets.length === 0) {
throw new RegeneratePreviewInputError('没有找到详情图源文件')
}
const detailAsset = detailAssets[0]
if (!detailAsset.fileUrl.startsWith('uploads/')) {
throw new RegeneratePreviewInputError('该详情图源文件不支持安全重建')
}
const uploadRootDir = getUploadRootDir()
const localPath = resolveStoredUploadPath(detailAsset.fileUrl)
if (
!isPathInAllowedDirs(localPath, [uploadRootDir])
|| !(await isPathFreeOfSymbolicLinks(uploadRootDir, localPath))
|| path.extname(localPath).toLowerCase() !== '.zip'
) {
throw new RegeneratePreviewInputError('详情图源文件路径无效')
}
const tempRoot = await ensureSafeDirectoryWithinRoot(uploadRootDir, 'temp')
if (!tempRoot) throw new Error('无法创建安全的预览重建工作目录')
tempDir = await mkdtemp(path.join(tempRoot, `regenerate-${materialId}-`))
const sliceBuffers = await withImageProcessingSlot(async () => {
let zipBuffer: Buffer
try {
zipBuffer = await readBoundedRegularFile(localPath, {
maxBytes: MAX_SOURCE_ZIP_BYTES,
label: '详情图压缩包',
})
} catch (error) {
throw new RegeneratePreviewInputError(
error instanceof Error ? error.message : '详情图压缩包读取失败',
)
}
// AdmZip 只打开已经过磁盘大小上限检查的 ZIP;不再 extractAllTo,避免
// Zip Slip/Zip Bomb。ZIP 读取、解码、像素检查和切片全程共用同一把全局锁。
let zip: AdmZip
try {
zip = new AdmZip(zipBuffer)
} catch {
throw new RegeneratePreviewInputError('详情图压缩包格式无效')
}
const imageEntry = selectBoundedImageEntry(zip)
const buffer = await readZipEntryBounded(imageEntry)
const metadata = await sharp(buffer, {
limitInputPixels: MAX_IMAGE_PIXELS,
sequentialRead: true,
failOn: 'error',
}).metadata()
assertProcessableMetadata(metadata, {
maxInputWidth: MAX_IMAGE_DIMENSION,
maxInputHeight: MAX_IMAGE_DIMENSION,
maxInputPixels: MAX_IMAGE_PIXELS,
})
releaseMaterialLock!.assertHeld()
// sliceImage 内部也使用 withImageProcessingSlot;AsyncLocalStorage 会安全
// 复用当前锁,避免释放大 Buffer 后重新排队或发生同锁自锁。
return sliceImage(buffer, 1500, 200)
})
if (sliceBuffers.length === 0) throw new Error('未生成任何预览切片')
releaseMaterialLock.assertHeld()
const operationId = randomUUID()
for (let index = 0; index < sliceBuffers.length; index += 1) {
const slice = sliceBuffers[index]
const fileName = `detail-preview-${operationId}-${index}.webp`
const url = await uploadPublicDisplayToS3(
slice,
buildTaskDetailPublicDisplayPath(materialId, 'detail', fileName),
'image/webp',
)
uploadedUrls.push(url)
await verifyUploadedS3Object(url, slice.length, 'image/webp')
releaseMaterialLock.assertHeld()
}
const assetRecords = uploadedUrls.map((url, index) => ({
materialId,
assetType: 'detail-preview',
fileUrl: url,
fileName: `detail-preview-${index}.webp`,
fileSize: BigInt(sliceBuffers[index].length),
sortOrder: index,
}))
try {
releaseMaterialLock.assertHeld()
await prisma.$transaction(async (tx) => {
releaseMaterialLock!.assertHeld()
await tx.materialAsset.deleteMany({
where: { materialId, assetType: 'detail-preview' },
})
releaseMaterialLock!.assertHeld()
await tx.materialAsset.createMany({ data: assetRecords })
releaseMaterialLock!.assertHeld()
await tx.material.update({
where: { id: materialId },
data: { previewUrl: uploadedUrls[0] },
})
})
releaseMaterialLock.assertHeld()
allowDerivedCleanup = false
} catch (transactionError) {
const commitState = await inspectPreviewCommit(materialId, uploadedUrls)
if (commitState === 'committed') {
allowDerivedCleanup = false
} else if (commitState === 'ambiguous') {
// 数据库提交结果不明时绝不删除可能已被引用的新对象。
allowDerivedCleanup = false
throw new RegeneratePreviewTemporaryError(
'预览记录提交结果不明确,已保留全部文件,请管理员核查',
)
} else {
throw transactionError
}
}
return { previewCount: uploadedUrls.length, previewUrls: uploadedUrls }
} catch (error) {
if (allowDerivedCleanup && uploadedUrls.length > 0) {
await cleanupDerivedObjects(uploadedUrls)
}
throw error
} finally {
if (tempDir) await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
if (releaseMaterialLock) await releaseMaterialLock().catch(() => undefined)
}
}
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session || !(session.user as any).id) {
return NextResponse.json({ error: '未登录' }, { status: 401 })
}
const userId = String((session.user as any).id)
const userRole = (session.user as any).role
if (!hasSystemAdminPermission(userRole, await isSuperAdmin(userId))) {
return NextResponse.json({ error: '无权限操作' }, { status: 403 })
}
const materialId = String((await readBoundedRequestBody(request)).materialId || '').trim()
if (!MATERIAL_ID_PATTERN.test(materialId)) {
return NextResponse.json({ error: '素材编号无效' }, { status: 400 })
}
const result = await regeneratePreview(materialId)
return NextResponse.json({ success: true, ...result })
} catch (error) {
console.error('[Regenerate Preview] Error:', error)
const status = error instanceof RegeneratePreviewInputError
? (error.message === '素材不存在' || error.message === '没有找到详情图源文件'
? 404
: error.status)
: Number((error as { status?: number; statusCode?: number } | undefined)?.status
|| (error as { statusCode?: number } | undefined)?.statusCode
|| 500)
const exposeMessage = error instanceof RegeneratePreviewInputError
|| error instanceof RegeneratePreviewTemporaryError
|| status === 503
return NextResponse.json(
{
error: exposeMessage && error instanceof Error
? error.message
: '预览重建失败,请稍后重试',
},
{ status: Number.isInteger(status) && status >= 400 && status <= 599 ? status : 500 },
)
}
}