Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
431 lines
16 KiB
TypeScript
431 lines
16 KiB
TypeScript
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 },
|
||
)
|
||
}
|
||
}
|