gongxiangp2/lib/brand-image-validation.ts
2026-09-11 13:24:30 +08:00

86 lines
2.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import sharp from 'sharp'
export const MAX_BRAND_IMAGE_BYTES = 5 * 1024 * 1024
export const MAX_BRAND_IMAGE_PIXELS = 4_194_304
export const MAX_BRAND_IMAGE_DIMENSION = 4096
export type ValidatedBrandImage = {
extension: 'png' | 'jpg' | 'webp'
contentType: 'image/png' | 'image/jpeg' | 'image/webp'
width: number
height: number
}
export type BrandImageValidationOptions = {
maxBytes?: number
maxBytesLabel?: string
}
const FORMAT_DETAILS: Record<string, Pick<ValidatedBrandImage, 'extension' | 'contentType'>> = {
png: { extension: 'png', contentType: 'image/png' },
jpeg: { extension: 'jpg', contentType: 'image/jpeg' },
webp: { extension: 'webp', contentType: 'image/webp' },
}
/**
* 品牌图属于高可见度系统配置,不能只看 magic bytes。
* 这里会真正解码整张图,阻止截断文件、伪造 MIME、动画和解压炸弹进入数据库。
*/
export async function validateBrandImage(
buffer: Buffer,
declaredContentType?: string | null,
options: BrandImageValidationOptions = {},
): Promise<ValidatedBrandImage> {
const maxBytes = options.maxBytes ?? MAX_BRAND_IMAGE_BYTES
const maxBytesLabel = options.maxBytesLabel ?? '5MB'
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
throw new TypeError('品牌图片大小上限必须是正的安全整数')
}
if (buffer.length <= 0 || buffer.length > maxBytes) {
throw new Error(`品牌图片必须是 ${maxBytesLabel} 以内的有效文件`)
}
const image = sharp(buffer, {
failOn: 'error',
limitInputPixels: MAX_BRAND_IMAGE_PIXELS,
animated: false,
})
const metadata = await image.metadata()
const format = metadata.format ? FORMAT_DETAILS[metadata.format] : undefined
const width = metadata.width || 0
const height = metadata.height || 0
if (!format) {
throw new Error('品牌图片只支持 PNG、JPEG 或 WebP')
}
if (
width < 1 ||
height < 1 ||
width > MAX_BRAND_IMAGE_DIMENSION ||
height > MAX_BRAND_IMAGE_DIMENSION ||
width * height > MAX_BRAND_IMAGE_PIXELS
) {
throw new Error('品牌图片尺寸无效或像素过大')
}
if ((metadata.pages || 1) > 1) {
throw new Error('品牌图片不支持动画格式')
}
const normalizedDeclaredType = String(declaredContentType || '')
.split(';', 1)[0]
.trim()
.toLowerCase()
if (normalizedDeclaredType && normalizedDeclaredType !== format.contentType) {
throw new Error('品牌图片内容与声明格式不一致')
}
// metadata() 主要读取头信息;raw().toBuffer() 强制完整解码,截断图片会在这里失败。
await image.clone().raw().toBuffer()
return {
...format,
width,
height,
}
}