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

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 },
)
}
}