限制系统LOGO上传格式和大小
This commit is contained in:
parent
f282d6aa73
commit
5ac2e11c3e
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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(已完成底层收口)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user