限制系统LOGO上传格式和大小

This commit is contained in:
WuYongJie 2026-05-28 17:57:37 +08:00
parent f282d6aa73
commit 5ac2e11c3e
3 changed files with 86 additions and 13 deletions

View File

@ -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('<svg />')], '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()
})
})

View File

@ -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()

View File

@ -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(已完成底层收口)