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> { 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 } 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 { const expectedSize = Number(entry.header.size) const buffer = await new Promise((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 { await Promise.allSettled(urls.map((url) => deleteFromS3(url))) } async function regeneratePreview(materialId: string) { let releaseMaterialLock: Awaited> | 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 }, ) } }