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

118 lines
3.6 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.

// 上传预览图API
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { uploadToS3 } from '@/lib/s3';
import { compressImage, sanitizeFileName } from '@/lib/image-processor';
import { randomBytes } from 'crypto';
import { assertBoundedMultipartHeaders, assertMultipartFiles } from '@/lib/multipart-request-guard';
import {
boundedMultipartRequestLimiter,
getBoundedMultipartFailure,
MULTIPART_ROUTE_LIMITS,
} from '@/lib/multipart-route-limits';
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 10800
// 生成唯一文件名
function generateUniqueFilename(prefix: string, ext: string): string {
const timestamp = Date.now();
const random = randomBytes(8).toString('hex');
return `${prefix}-${timestamp}-${random}${ext}`;
}
// POST - 上传预览图
export async function POST(request: NextRequest) {
let releaseRequestSlot: (() => void) | null = null;
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
);
}
assertBoundedMultipartHeaders(request.headers, MULTIPART_ROUTE_LIMITS.previewRequestBytes);
releaseRequestSlot = await boundedMultipartRequestLimiter.acquire();
const formData = await request.formData();
assertMultipartFiles(formData, {
fields: {
file: {
maxCount: 1,
maxFileBytes: MULTIPART_ROUTE_LIMITS.previewFileBytes,
allowedMimeTypes: ['image/*'],
},
},
maxFiles: 1,
maxTotalBytes: MULTIPART_ROUTE_LIMITS.previewFileBytes,
});
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json(
{ error: '请选择文件' },
{ status: 400 }
);
}
// 验证文件类型
if (!file.type.startsWith('image/')) {
return NextResponse.json(
{ error: '只能上传图片文件' },
{ status: 400 }
);
}
// 验证文件大小(50MB限制)
if (file.size > MULTIPART_ROUTE_LIMITS.previewFileBytes) {
return NextResponse.json(
{ error: '预览图过大,请使用小于50MB的图片' },
{ status: 400 }
);
}
// 转换为Buffer
const buffer = Buffer.from(await file.arrayBuffer());
// 压缩预览图
console.log('[Preview Upload] Compressing preview image...');
const compressedBuffer = await compressImage(buffer, {
maxSizeKB: 300,
maxWidth: 1200,
maxHeight: 1600,
format: 'webp'
});
// 生成文件名并上传到S3
const filename = generateUniqueFilename('preview', '.webp');
console.log('[Preview Upload] Uploading compressed preview image to S3...');
const url = await uploadToS3(compressedBuffer, filename, 'image/webp');
console.log('[Preview Upload] Preview image uploaded:', url);
return NextResponse.json({ url });
} catch (error) {
console.error('上传预览图失败:', error);
const boundedFailure = getBoundedMultipartFailure(error);
if (boundedFailure) {
return NextResponse.json(
{ error: boundedFailure.message, code: boundedFailure.code },
{
status: boundedFailure.status,
...(boundedFailure.retryAfterSeconds
? { headers: { 'Retry-After': String(boundedFailure.retryAfterSeconds) } }
: {}),
},
);
}
return NextResponse.json(
{ error: '上传失败,请稍后重试' },
{ status: 500 }
);
} finally {
releaseRequestSlot?.();
}
}