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