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
4.2 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 { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { uploadToS3 } from '@/lib/s3';
import { normalizeUrl } from '@/lib/utils';
import { compressImage } from '@/lib/image-processor';
import { assertBoundedMultipartHeaders, assertMultipartFiles } from '@/lib/multipart-request-guard';
import {
boundedMultipartRequestLimiter,
getBoundedMultipartFailure,
MULTIPART_ROUTE_LIMITS,
} from '@/lib/multipart-route-limits';
// POST /api/user/avatar - 上传用户头像
export async function POST(request: NextRequest) {
let releaseRequestSlot: (() => void) | null = null;
try {
const session = await getServerSession(authOptions);
const userId = session?.user ? (session.user as any).id : undefined;
console.log('[UserAvatar] Session:', { userId, hasSession: !!session, hasUser: !!session?.user });
if (!userId) {
console.log('[UserAvatar] No user ID in session');
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
assertBoundedMultipartHeaders(request.headers, MULTIPART_ROUTE_LIMITS.avatarRequestBytes);
releaseRequestSlot = await boundedMultipartRequestLimiter.acquire();
const formData = await request.formData();
assertMultipartFiles(formData, {
fields: {
avatar: {
maxCount: 1,
maxFileBytes: MULTIPART_ROUTE_LIMITS.avatarFileBytes,
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
},
},
maxFiles: 1,
maxTotalBytes: MULTIPART_ROUTE_LIMITS.avatarFileBytes,
});
const avatar = formData.get('avatar') as File | null;
if (!avatar) {
console.log('[UserAvatar] No avatar file provided');
return NextResponse.json({ error: '请选择头像文件' }, { status: 400 });
}
console.log('[UserAvatar] File info:', { name: avatar.name, type: avatar.type, size: avatar.size });
// 验证文件类型
if (!['image/jpeg', 'image/jpg', 'image/png', 'image/webp'].includes(avatar.type)) {
return NextResponse.json(
{ error: '不支持的图片格式,请使用 JPG、PNG 或 WebP 格式' },
{ status: 400 }
);
}
// 验证文件大小(5MB)
if (avatar.size > MULTIPART_ROUTE_LIMITS.avatarFileBytes) {
return NextResponse.json(
{ error: `头像过大,请使用小于5MB的图片(当前:${(avatar.size / 1024 / 1024).toFixed(2)}MB)` },
{ status: 400 }
);
}
// 压缩图片
const imageBuffer = Buffer.from(await avatar.arrayBuffer());
const compressedBuffer = await compressImage(imageBuffer, {
maxSizeKB: 100,
maxWidth: 200,
maxHeight: 200,
format: 'webp',
});
// 上传到S3
const fileName = `avatar-${userId}-${Date.now()}.webp`;
console.log('[UserAvatar] Uploading to S3:', fileName, `(${(compressedBuffer.length / 1024).toFixed(2)}KB)`);
const avatarUrl = await uploadToS3(compressedBuffer, fileName, 'image/webp');
console.log('[UserAvatar] S3 URL:', avatarUrl);
// 更新用户头像
console.log('[UserAvatar] Updating user:', userId);
const updatedUser = await prisma.user.update({
where: { id: userId },
data: { avatar: avatarUrl },
});
console.log('[UserAvatar] User updated:', { id: updatedUser.id, avatar: updatedUser.avatar });
// 规范化URL
const normalizedUrl = normalizeUrl(avatarUrl);
return NextResponse.json({ avatarUrl: normalizedUrl });
} catch (error) {
console.error('[UserAvatar] POST 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?.();
}
}