Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
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<NextResponse | null> {
|
|
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<Record<string, unknown>> {
|
|
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<string, unknown>
|
|
}
|
|
|
|
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<ReturnType<typeof lstat>>): FileSnapshot {
|
|
return {
|
|
size: stats.size,
|
|
dev: stats.dev,
|
|
ino: stats.ino,
|
|
mtimeMs: stats.mtimeMs,
|
|
}
|
|
}
|
|
|
|
function matchesFileSnapshot(
|
|
stats: Awaited<ReturnType<typeof lstat>>,
|
|
snapshot: FileSnapshot,
|
|
): boolean {
|
|
return stats.size === snapshot.size
|
|
&& stats.dev === snapshot.dev
|
|
&& stats.ino === snapshot.ino
|
|
&& stats.mtimeMs === snapshot.mtimeMs
|
|
}
|
|
|
|
function assertRegularPdfStats(stats: Awaited<ReturnType<typeof lstat>>): 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<ReturnType<typeof lstat>>
|
|
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 },
|
|
)
|
|
}
|
|
}
|