From 5ac2e11c3e5c0132c4a28f4ed6d87566b29cfec2 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 28 May 2026 17:57:37 +0800 Subject: [PATCH] =?UTF-8?q?=E9=99=90=E5=88=B6=E7=B3=BB=E7=BB=9FLOGO?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=A0=BC=E5=BC=8F=E5=92=8C=E5=A4=A7=E5=B0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../system-settings/__tests__/route.test.ts | 62 ++++++++++++++++++- app/api/system-settings/upload-logo/route.ts | 30 ++++++--- docs/商业化地基排查问题清单_20260524.md | 7 ++- 3 files changed, 86 insertions(+), 13 deletions(-) diff --git a/app/api/system-settings/__tests__/route.test.ts b/app/api/system-settings/__tests__/route.test.ts index 4250a16..28276fc 100644 --- a/app/api/system-settings/__tests__/route.test.ts +++ b/app/api/system-settings/__tests__/route.test.ts @@ -56,11 +56,16 @@ jest.mock('@/lib/s3', () => ({ uploadToS3: jest.fn(), })) +jest.mock('@/lib/image-processor', () => ({ + processImage: jest.fn(), +})) + const { getServerSession } = require('next-auth') const { prisma } = require('@/lib/prisma') const { cache } = require('@/lib/cache') const { canUseSystemSettings } = require('@/lib/license') const { uploadToS3 } = require('@/lib/s3') +const { processImage } = require('@/lib/image-processor') const { ensureSystemSettingsOptionalColumns } = require('@/lib/system-settings-columns') const mockSettings = { @@ -345,6 +350,7 @@ describe('PUT /api/system-settings 权限边界', () => { describe('POST /api/system-settings/upload-logo 权限边界', () => { beforeEach(() => { jest.resetAllMocks() + processImage.mockResolvedValue(Buffer.from('processed-logo')) uploadToS3.mockResolvedValue('https://cdn.example.com/header-logo.png') prisma.systemSettings.findFirst.mockResolvedValue({ ...mockSettings }) prisma.systemSettings.update.mockResolvedValue({ @@ -389,10 +395,64 @@ describe('POST /api/system-settings/upload-logo 权限边界', () => { expect(response.status).toBe(200) expect(data.logoUrl).toBe('https://cdn.example.com/header-logo.png') - expect(uploadToS3).toHaveBeenCalled() + expect(processImage).toHaveBeenCalledWith(expect.any(Buffer), { + maxSizeKB: 300, + maxWidth: 1200, + maxHeight: 400, + format: 'webp', + }) + expect(uploadToS3).toHaveBeenCalledWith( + Buffer.from('processed-logo'), + expect.stringMatching(/^header-logo-\d+\.webp$/), + 'image/webp' + ) expect(prisma.systemSettings.update).toHaveBeenCalledWith({ where: { id: 'settings-1' }, data: { headerLogoUrl: 'https://cdn.example.com/header-logo.png' }, }) }) + + it('拒绝 SVG 或非白名单图片格式', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true }) + + const formData = new FormData() + formData.set('type', 'header') + formData.set('logo', new File([Buffer.from('')], 'logo.svg', { type: 'image/svg+xml' })) + + const response = await uploadLogo(new NextRequest('http://localhost/api/system-settings/upload-logo', { + method: 'POST', + body: formData, + })) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain('JPG、PNG 或 WebP') + expect(processImage).not.toHaveBeenCalled() + expect(uploadToS3).not.toHaveBeenCalled() + }) + + it('拒绝超过 5MB 的 LOGO 图片', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true }) + + const formData = new FormData() + formData.set('type', 'header') + formData.set('logo', new File([new Uint8Array(5 * 1024 * 1024 + 1)], 'large.png', { type: 'image/png' })) + + const response = await uploadLogo(new NextRequest('http://localhost/api/system-settings/upload-logo', { + method: 'POST', + body: formData, + })) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain('小于5MB') + expect(processImage).not.toHaveBeenCalled() + expect(uploadToS3).not.toHaveBeenCalled() + }) }) diff --git a/app/api/system-settings/upload-logo/route.ts b/app/api/system-settings/upload-logo/route.ts index a947d9a..dc7bcf5 100644 --- a/app/api/system-settings/upload-logo/route.ts +++ b/app/api/system-settings/upload-logo/route.ts @@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { uploadToS3 } from '@/lib/s3' import { isCurrentSuperAdmin } from '@/lib/admin-permissions' +import { processImage } from '@/lib/image-processor' // POST - 上传LOGO(最高权限) export async function POST(request: NextRequest) { @@ -36,25 +37,36 @@ export async function POST(request: NextRequest) { ) } - // 验证文件类型 - if (!logoFile.type.startsWith('image/')) { + const allowedImageTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'] + if (!allowedImageTypes.includes(logoFile.type)) { return NextResponse.json( - { error: '只支持图片格式' }, + { error: '不支持的图片格式,请使用 JPG、PNG 或 WebP 格式' }, { status: 400 } ) } - // 转换File为Buffer - const bytes = await logoFile.arrayBuffer() - const buffer = Buffer.from(bytes) + const maxSize = 5 * 1024 * 1024 // 5MB + if (logoFile.size > maxSize) { + return NextResponse.json( + { error: `LOGO 过大,请使用小于5MB的图片(当前:${(logoFile.size / 1024 / 1024).toFixed(2)}MB)` }, + { status: 400 } + ) + } + + const originalBuffer = Buffer.from(await logoFile.arrayBuffer()) + const processedBuffer = await processImage(originalBuffer, { + maxSizeKB: 300, + maxWidth: 1200, + maxHeight: 400, + format: 'webp', + }) // 生成文件名 const timestamp = Date.now() - const ext = logoFile.name.split('.').pop() - const filename = `${logoType}-logo-${timestamp}.${ext}` + const filename = `${logoType}-logo-${timestamp}.webp` // 上传到S3 - const s3Url = await uploadToS3(buffer, filename, logoFile.type) + const s3Url = await uploadToS3(processedBuffer, filename, 'image/webp') // 更新系统设置 let settings = await prisma.systemSettings.findFirst() diff --git a/docs/商业化地基排查问题清单_20260524.md b/docs/商业化地基排查问题清单_20260524.md index 79b1151..611377d 100644 --- a/docs/商业化地基排查问题清单_20260524.md +++ b/docs/商业化地基排查问题清单_20260524.md @@ -401,13 +401,13 @@ 客户影响: -普通管理员之前可修改站点名、Logo、S3 存储配置等出厂级设置。对商业交付来说,这类设置更接近“超管/运维”权限。当前已先把系统设置写入和系统 LOGO 上传收紧到最高权限账号,并且每次写入前回查数据库确认当前用户仍是最高权限,避免旧会话残留权限。 +普通管理员之前可修改站点名、Logo、S3 存储配置等出厂级设置。对商业交付来说,这类设置更接近“超管/运维”权限。当前已先把系统设置写入和系统 LOGO 上传收紧到最高权限账号,并且每次写入前回查数据库确认当前用户仍是最高权限,避免旧会话残留权限;LOGO 上传也已增加格式、大小和压缩处理,避免把 SVG、超大图或异常图片原样保存为公共展示资源。 证据: - `app/api/system-settings/route.ts`:PUT 现在使用 `isCurrentSuperAdmin()`,普通管理员不能修改站点名、注册开关、S3 endpoint、access key、secret key 等系统设置。 -- `app/api/system-settings/upload-logo/route.ts`:系统 LOGO 上传也改为最高权限账号可操作,避免通过上传接口绕过外观设置权限。 -- `app/api/system-settings/__tests__/route.test.ts`:新增测试覆盖普通管理员不能修改系统设置、旧会话权限已撤销时不能修改、最高权限账号可修改,以及普通管理员不能上传 LOGO。 +- `app/api/system-settings/upload-logo/route.ts`:系统 LOGO 上传也改为最高权限账号可操作,避免通过上传接口绕过外观设置权限;只允许 JPG、PNG、WebP,限制 5MB,并压缩转换为 WebP 后上传。 +- `app/api/system-settings/__tests__/route.test.ts`:新增/补充测试覆盖普通管理员不能修改系统设置、旧会话权限已撤销时不能修改、最高权限账号可修改、普通管理员不能上传 LOGO、拒绝 SVG 和拒绝超过 5MB 的 LOGO。 验证: @@ -417,6 +417,7 @@ 建议动作: - 已完成:系统设置写入口和系统 LOGO 上传入口必须限最高权限账号。 +- 已完成:系统 LOGO 上传只接受 JPG、PNG、WebP 且不超过 5MB,服务端压缩为 WebP 后再保存。 - 后续增强:如果后续确实需要普通管理员改某些业务级外观项,应拆出单独接口和字段白名单,不能复用包含对象存储密钥的全量系统设置写入口。 ### P1-04 许可证过期和降级没有贯穿所有业务 API(已完成底层收口)