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
4.2 KiB
TypeScript
118 lines
4.2 KiB
TypeScript
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?.();
|
||
}
|
||
}
|