Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
306 lines
9.0 KiB
TypeScript
306 lines
9.0 KiB
TypeScript
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)
|