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

168 lines
5.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.

// 上传系统Logo API
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { uploadToS3, verifyUploadedS3Object } from '@/lib/s3';
import { hasSystemAdminPermission } from '@/lib/department-permission';
import {
DEFAULT_HEADER_LOGO_URL,
DEFAULT_LOGIN_LOGO_URL,
normalizeBrandLogoUrl,
} from '@/lib/brand-assets';
import { MAX_BRAND_IMAGE_BYTES, validateBrandImage } from '@/lib/brand-image-validation';
import { assertBoundedMultipartHeaders, assertMultipartFiles } from '@/lib/multipart-request-guard';
import {
boundedMultipartRequestLimiter,
getBoundedMultipartFailure,
MULTIPART_ROUTE_LIMITS,
} from '@/lib/multipart-route-limits';
// POST - 上传Logo(仅管理员)
export async function POST(request: NextRequest) {
let releaseRequestSlot: (() => void) | null = null;
try {
const session = await getServerSession(authOptions);
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
);
}
const sessionUser = session.user as any;
if (!hasSystemAdminPermission(sessionUser.role, Boolean(sessionUser.isSuperAdmin))) {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
);
}
assertBoundedMultipartHeaders(request.headers, MULTIPART_ROUTE_LIMITS.logoRequestBytes);
releaseRequestSlot = await boundedMultipartRequestLimiter.acquire();
const formData = await request.formData();
assertMultipartFiles(formData, {
fields: {
logo: {
maxCount: 1,
maxFileBytes: MULTIPART_ROUTE_LIMITS.logoFileBytes,
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp'],
},
},
maxFiles: 1,
maxTotalBytes: MULTIPART_ROUTE_LIMITS.logoFileBytes,
});
const logo = formData.get('logo') as File | null;
const type = formData.get('type') as string;
if (!logo || typeof logo.arrayBuffer !== 'function') {
return NextResponse.json(
{ error: '请选择LOGO文件' },
{ status: 400 }
);
}
if (!type || !['header', 'login'].includes(type)) {
return NextResponse.json(
{ error: '请指定LOGO类型(header或login)' },
{ status: 400 }
);
}
if (!Number.isFinite(logo.size) || logo.size <= 0 || logo.size > MAX_BRAND_IMAGE_BYTES) {
return NextResponse.json(
{ error: 'Logo 必须是 5MB 以内的有效图片' },
{ status: 400 }
);
}
if (!['image/png', 'image/jpeg', 'image/webp'].includes(logo.type.toLowerCase())) {
return NextResponse.json(
{ error: '只支持 PNG、JPEG 或 WebP 图片' },
{ status: 400 }
);
}
// 转换为Buffer
const arrayBuffer = await logo.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
if (buffer.length !== logo.size || buffer.length > MAX_BRAND_IMAGE_BYTES) {
return NextResponse.json(
{ error: 'Logo 文件大小校验失败' },
{ status: 400 }
);
}
let detected
try {
detected = await validateBrandImage(buffer, logo.type)
} catch {
return NextResponse.json(
{ error: 'Logo 文件损坏、格式不一致或尺寸过大' },
{ status: 400 }
);
}
// 生成文件名
const timestamp = Date.now();
const filename = `${type}-logo-${timestamp}.${detected.extension}`;
// 上传到S3
const uploadedLogoUrl = await uploadToS3(buffer, filename, detected.contentType);
const logoUrl = normalizeBrandLogoUrl(uploadedLogoUrl)
if (!logoUrl) {
throw new Error('对象存储返回了不受信任的品牌图片地址')
}
await verifyUploadedS3Object(logoUrl, buffer.length, detected.contentType)
// 更新数据库
const existingSettings = await prisma.systemSettings.findFirst({
select: { id: true },
});
const fieldName = type === 'header' ? 'headerLogoUrl' : 'loginLogoUrl';
if (existingSettings) {
await prisma.systemSettings.update({
where: { id: existingSettings.id },
data: { [fieldName]: logoUrl },
select: { id: true },
});
} else {
const initialLogos = {
headerLogoUrl: DEFAULT_HEADER_LOGO_URL,
loginLogoUrl: DEFAULT_LOGIN_LOGO_URL,
[fieldName]: logoUrl,
}
await prisma.systemSettings.create({
data: initialLogos,
select: { id: true },
});
}
return NextResponse.json({
success: true,
logoUrl,
type,
});
} catch (error) {
console.error('Upload logo 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: '上传LOGO失败' },
{ status: 500 }
);
} finally {
releaseRequestSlot?.();
}
}