Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
118 lines
3.6 KiB
TypeScript
118 lines
3.6 KiB
TypeScript
// 上传预览图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?.();
|
||
}
|
||
}
|