gongxiangp2/lib/material-upload-request-guard.ts
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

306 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { MAX_LOGICAL_UPLOAD_BYTES } from '@/lib/material-upload-chunk-policy'
const MEBIBYTE = 1024 * 1024
export const MATERIAL_UPLOAD_REQUEST_LIMITS = Object.freeze({
maxRequestBytes: 128 * MEBIBYTE,
maxLogicalSourceBytes: MAX_LOGICAL_UPLOAD_BYTES,
maxDirectSourceBytesExclusive: 5 * MEBIBYTE,
maxFileParts: 50,
maxPreviewImageBytes: 8 * MEBIBYTE,
maxPreviewImages: 19,
maxPreviewBytes: 32 * MEBIBYTE,
maxDisplayImageBytes: 50 * MEBIBYTE,
maxGalleryImageBytes: 16 * MEBIBYTE,
maxGalleryImagesPerField: 49,
maxGalleryBytes: 112 * MEBIBYTE,
})
const ALLOWED_FILE_FIELDS = new Set([
'zipFile',
'previewImage',
'previewImages',
'displayImage',
'modelImages',
'displayImages',
])
export class MaterialUploadRequestError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly code: string,
public readonly retryAfterSeconds?: number,
) {
super(message)
this.name = 'MaterialUploadRequestError'
}
}
export function validateMaterialUploadRequestHeaders(headers: Headers): number {
const contentType = (headers.get('content-type') || '').toLowerCase()
if (!contentType.includes('multipart/form-data')) {
throw new MaterialUploadRequestError(
'上传请求必须使用 multipart/form-data',
415,
'MULTIPART_REQUIRED',
)
}
const contentEncoding = (headers.get('content-encoding') || 'identity').trim().toLowerCase()
if (contentEncoding !== 'identity') {
throw new MaterialUploadRequestError(
'上传请求不支持压缩传输编码',
415,
'CONTENT_ENCODING_UNSUPPORTED',
)
}
const rawLength = headers.get('content-length')
if (!rawLength || !/^\d+$/.test(rawLength.trim())) {
throw new MaterialUploadRequestError(
'上传请求缺少有效的 Content-Length',
411,
'CONTENT_LENGTH_REQUIRED',
)
}
const contentLength = Number(rawLength)
if (!Number.isSafeInteger(contentLength) || contentLength <= 0) {
throw new MaterialUploadRequestError(
'上传请求的 Content-Length 无效',
411,
'CONTENT_LENGTH_REQUIRED',
)
}
if (contentLength > MATERIAL_UPLOAD_REQUEST_LIMITS.maxRequestBytes) {
throw new MaterialUploadRequestError(
'本次上传内容超过 128MB,请减少图片数量或使用分片上传',
413,
'UPLOAD_REQUEST_TOO_LARGE',
)
}
return contentLength
}
function isFilePart(value: FormDataEntryValue): value is File {
return typeof value === 'object'
&& value !== null
&& typeof (value as File).name === 'string'
&& typeof (value as File).size === 'number'
&& typeof (value as File).arrayBuffer === 'function'
}
function sumFileBytes(files: File[]): number {
return files.reduce((sum, file) => sum + file.size, 0)
}
function assertFilesWithinLimit(files: File[], maxBytes: number, label: string): void {
const oversized = files.find((file) => file.size > maxBytes)
if (oversized) {
throw new MaterialUploadRequestError(
`${label}「${oversized.name}」过大`,
413,
'UPLOAD_FILE_TOO_LARGE',
)
}
}
export function validateMaterialUploadFormData(formData: FormData): void {
const filesByField = new Map<string, File[]>()
let totalFileBytes = 0
let totalFileParts = 0
for (const [field, value] of Array.from(formData.entries())) {
if (!isFilePart(value)) {
continue
}
if (!ALLOWED_FILE_FIELDS.has(field)) {
throw new MaterialUploadRequestError(
`不支持的文件字段:${field}`,
400,
'UNKNOWN_FILE_FIELD',
)
}
if (!Number.isSafeInteger(value.size) || value.size <= 0) {
throw new MaterialUploadRequestError(
`文件「${value.name || field}」为空或大小无效`,
400,
'EMPTY_UPLOAD_FILE',
)
}
const fieldFiles = filesByField.get(field) || []
fieldFiles.push(value)
filesByField.set(field, fieldFiles)
totalFileParts += 1
totalFileBytes += value.size
}
if (totalFileParts > MATERIAL_UPLOAD_REQUEST_LIMITS.maxFileParts) {
throw new MaterialUploadRequestError(
`一次最多上传 ${MATERIAL_UPLOAD_REQUEST_LIMITS.maxFileParts} 个文件`,
413,
'TOO_MANY_UPLOAD_FILES',
)
}
if (totalFileBytes > MATERIAL_UPLOAD_REQUEST_LIMITS.maxRequestBytes) {
throw new MaterialUploadRequestError(
'本次上传文件总大小超过 128MB',
413,
'UPLOAD_FILES_TOO_LARGE',
)
}
const zipFiles = filesByField.get('zipFile') || []
const previewImage = filesByField.get('previewImage') || []
const previewImages = filesByField.get('previewImages') || []
const displayImage = filesByField.get('displayImage') || []
const modelImages = filesByField.get('modelImages') || []
const displayImages = filesByField.get('displayImages') || []
if (zipFiles.length > 1 || previewImage.length > 1 || displayImage.length > 1) {
throw new MaterialUploadRequestError(
'zipFile、previewImage 和 displayImage 均只能提交一个文件',
400,
'DUPLICATE_SINGLE_FILE_FIELD',
)
}
if (zipFiles.some((file) => file.size >= MATERIAL_UPLOAD_REQUEST_LIMITS.maxDirectSourceBytesExclusive)) {
throw new MaterialUploadRequestError(
'主素材文件达到 5MB,请使用分片上传',
413,
'CHUNK_UPLOAD_REQUIRED',
)
}
if (previewImages.length > MATERIAL_UPLOAD_REQUEST_LIMITS.maxPreviewImages) {
throw new MaterialUploadRequestError(
`其他预览图最多 ${MATERIAL_UPLOAD_REQUEST_LIMITS.maxPreviewImages} 张`,
413,
'TOO_MANY_PREVIEW_IMAGES',
)
}
const allPreviewImages = [...previewImage, ...previewImages]
assertFilesWithinLimit(
allPreviewImages,
MATERIAL_UPLOAD_REQUEST_LIMITS.maxPreviewImageBytes,
'预览图',
)
if (sumFileBytes(allPreviewImages) > MATERIAL_UPLOAD_REQUEST_LIMITS.maxPreviewBytes) {
throw new MaterialUploadRequestError(
'预览图总大小超过 32MB',
413,
'PREVIEW_IMAGES_TOO_LARGE',
)
}
assertFilesWithinLimit(
displayImage,
MATERIAL_UPLOAD_REQUEST_LIMITS.maxDisplayImageBytes,
'展示原图',
)
if (
modelImages.length > MATERIAL_UPLOAD_REQUEST_LIMITS.maxGalleryImagesPerField
|| displayImages.length > MATERIAL_UPLOAD_REQUEST_LIMITS.maxGalleryImagesPerField
) {
throw new MaterialUploadRequestError(
`展示图片每组最多 ${MATERIAL_UPLOAD_REQUEST_LIMITS.maxGalleryImagesPerField} 张`,
413,
'TOO_MANY_GALLERY_IMAGES',
)
}
const galleryImages = [...modelImages, ...displayImages]
assertFilesWithinLimit(
galleryImages,
MATERIAL_UPLOAD_REQUEST_LIMITS.maxGalleryImageBytes,
'展示图片',
)
if (sumFileBytes(galleryImages) > MATERIAL_UPLOAD_REQUEST_LIMITS.maxGalleryBytes) {
throw new MaterialUploadRequestError(
'展示图片总大小超过 112MB',
413,
'GALLERY_IMAGES_TOO_LARGE',
)
}
if (zipFiles.length > 0 && String(formData.get('existingFilePath') || '').trim()) {
throw new MaterialUploadRequestError(
'不能同时提交上传文件和服务器合并文件',
400,
'SOURCE_FILE_CONFLICT',
)
}
}
type WaitingAcquire = {
resolve: (release: () => void) => void
}
export class MaterialUploadRequestLimiter {
private active = 0
private readonly waiting: WaitingAcquire[] = []
constructor(
private readonly maxActive = 1,
private readonly maxWaiting = 8,
) {
if (!Number.isInteger(maxActive) || maxActive <= 0) {
throw new Error('maxActive 必须是正整数')
}
if (!Number.isInteger(maxWaiting) || maxWaiting < 0) {
throw new Error('maxWaiting 必须是非负整数')
}
}
async acquire(): Promise<() => void> {
if (this.active < this.maxActive) {
this.active += 1
return this.createRelease()
}
if (this.waiting.length >= this.maxWaiting) {
throw new MaterialUploadRequestError(
'当前上传处理繁忙,请稍后重试',
503,
'UPLOAD_BUSY',
3,
)
}
return new Promise<() => void>((resolve) => {
this.waiting.push({ resolve })
})
}
snapshot(): { active: number; waiting: number } {
return { active: this.active, waiting: this.waiting.length }
}
private createRelease(): () => void {
let released = false
return () => {
if (released) {
return
}
released = true
const next = this.waiting.shift()
if (next) {
next.resolve(this.createRelease())
return
}
this.active = Math.max(0, this.active - 1)
}
}
}
export const materialUploadRequestLimiter = new MaterialUploadRequestLimiter(1, 8)
// 与浏览器允许的最高分片并发保持一致。它只限制服务器同时写盘数量,
// 不把大文件网络上传退化为单并发。
export const materialUploadChunkRequestLimiter = new MaterialUploadRequestLimiter(8, 64)
// 合并会额外生成一份完整源文件。全局单槽避免多个大文件同时复制,
// 把瞬时磁盘占用控制在“一个合并文件 + 固定安全余量”以内。
export const materialUploadMergeRequestLimiter = new MaterialUploadRequestLimiter(1, 8)