diff --git a/app/pages/admin/media.vue b/app/pages/admin/media.vue index f2def15..1fefc0d 100644 --- a/app/pages/admin/media.vue +++ b/app/pages/admin/media.vue @@ -90,7 +90,7 @@ const formatBytes = (bytes: number) => bytes < 1024 * 1024 ? `${Math.ceil(bytes
正在加载素材…
-
上传新图片支持 JPG、PNG、WebP;最大 8MB、4000 万像素
+
上传新图片支持 JPG、PNG、WebP;最大 8MB、4000 万像素。Logo 建议使用透明 PNG,宽度 600–1600px
diff --git a/server/api/admin/media.post.ts b/server/api/admin/media.post.ts index eca980f..61d123a 100644 --- a/server/api/admin/media.post.ts +++ b/server/api/admin/media.post.ts @@ -3,6 +3,7 @@ import { mkdir, rm, writeFile } from 'node:fs/promises' import { extname } from 'node:path' import sharp from 'sharp' import { z } from 'zod' +import { MAX_IMAGE_BYTES, MAX_IMAGE_HEIGHT, MAX_IMAGE_PIXELS, MAX_IMAGE_WIDTH, isSharpPixelLimitError } from '../../utils/image-validation' import { mediaUrl, safeUploadPath, serializeMedia, type MediaAssetRow } from '../../utils/media' import { useDatabase } from '../../utils/db' @@ -30,7 +31,7 @@ export default defineEventHandler(async (event) => { const parts = await readMultipartFormData(event) const file = parts?.find(part => part.name === 'file' && part.filename) if (!file?.data?.length) throw createError({ statusCode: 400, statusMessage: '请选择图片文件' }) - if (file.data.length > 8 * 1024 * 1024) throw createError({ statusCode: 413, statusMessage: '单张图片不能超过 8MB' }) + if (file.data.length > MAX_IMAGE_BYTES) throw createError({ statusCode: 413, statusMessage: '单张图片不能超过 8MB' }) const detectedType = detectImageType(file.data) if (!detectedType || !(detectedType in allowedTypes)) throw createError({ statusCode: 415, statusMessage: '仅支持 JPG、PNG、WebP 图片' }) @@ -39,11 +40,16 @@ export default defineEventHandler(async (event) => { if (!typeConfig.inputExtensions.includes(inputExtension as never)) throw createError({ statusCode: 415, statusMessage: '文件扩展名与实际图片类型不一致' }) let imageMetadata - try { imageMetadata = await sharp(file.data, { limitInputPixels: 40_000_000 }).metadata() } - catch { throw createError({ statusCode: 415, statusMessage: '图片内容损坏或无法解析' }) } + try { imageMetadata = await sharp(file.data, { limitInputPixels: MAX_IMAGE_PIXELS }).metadata() } + catch (error) { + if (isSharpPixelLimitError(error)) { + throw createError({ statusCode: 413, statusMessage: '图片像素过大,请压缩至 10000×10000 且不超过 4000 万像素后上传' }) + } + throw createError({ statusCode: 415, statusMessage: '图片内容损坏或无法解析' }) + } const width = imageMetadata.width || 0 const height = imageMetadata.height || 0 - if (!width || !height || width > 10_000 || height > 10_000 || width * height > 40_000_000) { + if (!width || !height || width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT || width * height > MAX_IMAGE_PIXELS) { throw createError({ statusCode: 413, statusMessage: '图片尺寸不能超过 10000×10000 或 4000 万像素' }) } diff --git a/server/utils/image-validation.ts b/server/utils/image-validation.ts new file mode 100644 index 0000000..462a1b3 --- /dev/null +++ b/server/utils/image-validation.ts @@ -0,0 +1,8 @@ +export const MAX_IMAGE_BYTES = 8 * 1024 * 1024 +export const MAX_IMAGE_WIDTH = 10_000 +export const MAX_IMAGE_HEIGHT = 10_000 +export const MAX_IMAGE_PIXELS = 40_000_000 + +export function isSharpPixelLimitError(error: unknown) { + return error instanceof Error && /exceeds pixel limit/i.test(error.message) +} diff --git a/tests/image-validation.test.ts b/tests/image-validation.test.ts new file mode 100644 index 0000000..6fd77e2 --- /dev/null +++ b/tests/image-validation.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { MAX_IMAGE_PIXELS, isSharpPixelLimitError } from '../server/utils/image-validation' + +describe('图片上传校验', () => { + it('识别 Sharp 像素上限错误', () => { + expect(isSharpPixelLimitError(new Error('Input image exceeds pixel limit'))).toBe(true) + }) + + it('不把普通解析错误误判为像素过大', () => { + expect(isSharpPixelLimitError(new Error('Input buffer contains unsupported image format'))).toBe(false) + expect(MAX_IMAGE_PIXELS).toBe(40_000_000) + }) +})