import { createHash, timingSafeEqual } from 'crypto' import { lstat, realpath } from 'fs/promises' import path from 'path' import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission' import { withImageProcessingSlot } from '@/lib/image-processing-limiter' import { processPdfUpload } from '@/lib/pdf-upload-handler' import { prisma } from '@/lib/prisma' import { getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' const MAX_REQUEST_BODY_BYTES = 2 * 1024 const MAX_API_KEY_BYTES = 1024 const MAX_PDF_SOURCE_BYTES = 128 * 1024 * 1024 const MATERIAL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/ class RegeneratePdfPreviewInputError extends Error { constructor( message: string, readonly status: number = 400, ) { super(message) this.name = 'RegeneratePdfPreviewInputError' } } type FileSnapshot = { size: number | bigint dev: number | bigint ino: number | bigint mtimeMs: number | bigint } function apiKeyMatchesConfiguredSecret(providedApiKey: string | null): boolean { const configuredApiKey = process.env.ADMIN_API_KEY if (!configuredApiKey || configuredApiKey.trim().length === 0 || !providedApiKey) { return false } if (Buffer.byteLength(providedApiKey, 'utf8') > MAX_API_KEY_BYTES) { return false } // 固定长度摘要后再恒时比较,避免直接字符串比较泄露已配置密钥的长度或前缀。 const configuredDigest = createHash('sha256').update(configuredApiKey, 'utf8').digest() const providedDigest = createHash('sha256').update(providedApiKey, 'utf8').digest() return timingSafeEqual(configuredDigest, providedDigest) } async function authorizeRequest(request: NextRequest): Promise { if (apiKeyMatchesConfiguredSecret(request.headers.get('x-api-key'))) { return null } 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 role = (session?.user as { role?: string | null } | undefined)?.role if (!hasSystemAdminPermission(role, await isSuperAdmin(userId))) { return NextResponse.json({ error: '无权限操作' }, { status: 403 }) } return null } async function readBoundedJsonBody(request: NextRequest): Promise> { const contentType = String(request.headers.get('content-type') || '').toLowerCase() if (!/^application\/json(?:\s*;|$)/.test(contentType)) { throw new RegeneratePdfPreviewInputError('请求必须使用 JSON 格式', 415) } const contentEncoding = String(request.headers.get('content-encoding') || 'identity').toLowerCase() if (contentEncoding !== 'identity') { throw new RegeneratePdfPreviewInputError('不支持压缩请求体', 415) } const contentLengthHeader = request.headers.get('content-length') if (contentLengthHeader !== null) { if (!/^\d+$/.test(contentLengthHeader)) { throw new RegeneratePdfPreviewInputError('请求体长度无效') } const contentLength = Number(contentLengthHeader) if (!Number.isSafeInteger(contentLength) || contentLength > MAX_REQUEST_BODY_BYTES) { throw new RegeneratePdfPreviewInputError('请求体过大', 413) } } if (!request.body) { throw new RegeneratePdfPreviewInputError('请求体不能为空') } 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) { await reader.cancel().catch(() => undefined) throw new RegeneratePdfPreviewInputError('请求体过大', 413) } chunks.push(value) } } finally { reader.releaseLock() } if (totalBytes === 0) { throw new RegeneratePdfPreviewInputError('请求体不能为空') } let parsed: unknown try { parsed = JSON.parse(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), totalBytes).toString('utf8')) } catch { throw new RegeneratePdfPreviewInputError('JSON 格式无效') } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new RegeneratePdfPreviewInputError('请求体格式无效') } return parsed as Record } function isStrictChildPath(rootPath: string, candidatePath: string): boolean { const relativePath = path.relative(rootPath, candidatePath) return relativePath.length > 0 && relativePath !== '..' && !relativePath.startsWith(`..${path.sep}`) && !path.isAbsolute(relativePath) } function toFileSnapshot(stats: Awaited>): FileSnapshot { return { size: stats.size, dev: stats.dev, ino: stats.ino, mtimeMs: stats.mtimeMs, } } function matchesFileSnapshot( stats: Awaited>, snapshot: FileSnapshot, ): boolean { return stats.size === snapshot.size && stats.dev === snapshot.dev && stats.ino === snapshot.ino && stats.mtimeMs === snapshot.mtimeMs } function assertRegularPdfStats(stats: Awaited>): void { if (stats.isSymbolicLink() || !stats.isFile()) { throw new RegeneratePdfPreviewInputError('PDF 源文件不是普通文件') } if (stats.size <= 0) { throw new RegeneratePdfPreviewInputError('PDF 源文件为空') } if (stats.size > MAX_PDF_SOURCE_BYTES) { throw new RegeneratePdfPreviewInputError('PDF 源文件过大', 413) } } async function inspectTrustedPdfSource(storedPath: string): Promise<{ realPath: string snapshot: FileSnapshot }> { const uploadRootPath = path.resolve(getUploadRootDir()) const sourcePath = path.resolve(resolveStoredUploadPath(storedPath)) if (path.extname(sourcePath).toLowerCase() !== '.pdf') { throw new RegeneratePdfPreviewInputError('源文件必须是 PDF') } if (!isStrictChildPath(uploadRootPath, sourcePath)) { throw new RegeneratePdfPreviewInputError('PDF 源文件路径越界') } const uploadRootStats = await lstat(uploadRootPath) if (uploadRootStats.isSymbolicLink() || !uploadRootStats.isDirectory()) { throw new Error('UPLOAD_DIR 不是可信目录') } let sourceStats: Awaited> try { sourceStats = await lstat(sourcePath) } catch (error) { if ((error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') { throw new RegeneratePdfPreviewInputError('PDF 源文件不存在', 404) } throw error } assertRegularPdfStats(sourceStats) let uploadRootRealPath: string let sourceRealPath: string try { ;[uploadRootRealPath, sourceRealPath] = await Promise.all([ realpath(uploadRootPath), realpath(sourcePath), ]) } catch (error) { if ((error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') { throw new RegeneratePdfPreviewInputError('PDF 源文件不存在', 404) } throw error } if (!isStrictChildPath(uploadRootRealPath, sourceRealPath)) { throw new RegeneratePdfPreviewInputError('PDF 源文件路径越界') } if (path.extname(sourceRealPath).toLowerCase() !== '.pdf') { throw new RegeneratePdfPreviewInputError('源文件必须是 PDF') } const realSourceStats = await lstat(sourceRealPath) assertRegularPdfStats(realSourceStats) if (!matchesFileSnapshot(realSourceStats, toFileSnapshot(sourceStats))) { throw new RegeneratePdfPreviewInputError('PDF 源文件在检查期间发生变化', 409) } return { realPath: sourceRealPath, snapshot: toFileSnapshot(realSourceStats), } } async function processTrustedPdfSource( realPath: string, snapshot: FileSnapshot, ) { return withImageProcessingSlot(async () => { // processPdfUpload 会读取 PDF 文件头和生成内容;这些读取必须全程处在全局锁内。 const currentRealPath = await realpath(realPath) if (path.resolve(currentRealPath) !== path.resolve(realPath)) { throw new RegeneratePdfPreviewInputError('PDF 源文件在处理前发生变化', 409) } const currentStats = await lstat(currentRealPath) assertRegularPdfStats(currentStats) if (!matchesFileSnapshot(currentStats, snapshot)) { throw new RegeneratePdfPreviewInputError('PDF 源文件在处理前发生变化', 409) } return processPdfUpload(currentRealPath, '') }) } export async function POST(request: NextRequest) { try { const authorizationError = await authorizeRequest(request) if (authorizationError) return authorizationError const body = await readBoundedJsonBody(request) if (Object.keys(body).some((key) => key !== 'materialId')) { throw new RegeneratePdfPreviewInputError('请求体包含不支持的字段') } const materialId = typeof body.materialId === 'string' ? body.materialId.trim() : '' if (!MATERIAL_ID_PATTERN.test(materialId)) { throw new RegeneratePdfPreviewInputError('素材编号无效') } const material = await prisma.material.findUnique({ where: { id: materialId }, select: { id: true, materialType: true, zipFilePath: true, }, }) if (!material) { throw new RegeneratePdfPreviewInputError('素材不存在', 404) } if (material.materialType !== '品牌VI灵感') { throw new RegeneratePdfPreviewInputError('此接口仅适用于品牌VI灵感类型素材') } const storedPath = String(material.zipFilePath || '').trim() if (!storedPath) { throw new RegeneratePdfPreviewInputError('PDF 源文件路径不存在') } const source = await inspectTrustedPdfSource(storedPath) const pdfResult = await processTrustedPdfSource(source.realPath, source.snapshot) if (!pdfResult.success || !pdfResult.previewUrl) { console.error('[Regenerate PDF Preview] PDF 预览生成失败:', pdfResult.error) throw new Error('PDF 预览生成失败') } await prisma.material.update({ where: { id: materialId }, data: { previewUrl: pdfResult.previewUrl }, }) return NextResponse.json({ success: true, previewUrl: pdfResult.previewUrl, }) } catch (error) { console.error('[Regenerate PDF Preview] Error:', error) const status = error instanceof RegeneratePdfPreviewInputError ? error.status : 500 return NextResponse.json( { error: error instanceof RegeneratePdfPreviewInputError ? error.message : 'PDF 预览重建失败,请稍后重试', }, { status }, ) } }