import sharp from 'sharp' import type { H3Event } from 'h3' import type { ImageItem, UploadErrorItem } from '~/types/image' import type { AllowedMimeType } from './constants' import { ALLOWED_MIME_TYPES, MAX_FILE_SIZE } from './constants' import { detectMimeFromSignature, isAllowedMimeType, validateFileSignature } from './file-signature' import type { LogSource } from './db' import { generateImageKey } from './image-key' import { buildImageItem, sanitizeOriginalName } from './image-response' import { getStorageLayout, getWebpQuality } from './env' import { insertActivityLog } from './db' import { putImage } from './storage' import { logInfo, logException } from './logger' /** 可转 WebP 的位图格式;SVG(矢量)、ICO、已是 WebP 的原样存储 */ const WEBP_CONVERTIBLE: readonly AllowedMimeType[] = [ 'image/jpeg', 'image/png', 'image/gif' ] /** * 服务端统一转 WebP(浏览器直传、Twikoo/EasyImage、油猴脚本一视同仁)。 * 转换失败或转出来更大时,保留原格式存储。 */ async function compressToWebp( bytes: Uint8Array, mime: AllowedMimeType, quality: number ): Promise<{ bytes: Uint8Array, mime: AllowedMimeType }> { if (!WEBP_CONVERTIBLE.includes(mime)) { return { bytes, mime } } try { const output = await sharp(Buffer.from(bytes), { // GIF 保留动画帧 animated: mime === 'image/gif' }) .webp({ quality }) .toBuffer() if (output.byteLength >= bytes.byteLength) { return { bytes, mime } } return { bytes: new Uint8Array(output), mime: 'image/webp' } } catch { return { bytes, mime } } } export async function processSingleImageUpload( event: H3Event, input: { bytes: Uint8Array filename?: string prefix?: string source?: LogSource userId?: number | null } ): Promise<{ item: ImageItem } | { error: UploadErrorItem }> { const originalName = sanitizeOriginalName(input.filename ?? 'image') const bytes = input.bytes if (bytes.byteLength > MAX_FILE_SIZE) { return { error: { name: originalName, error: { code: 'FILE_TOO_LARGE', message: '图片大小不能超过 10 MB' } } } } const detectedMime = detectMimeFromSignature(bytes) if (!detectedMime) { return { error: { name: originalName, error: { code: 'INVALID_FILE_SIGNATURE', message: '无法识别的图片格式' } } } } if (!ALLOWED_MIME_TYPES.includes(detectedMime)) { return { error: { name: originalName, error: { code: 'UNSUPPORTED_FILE_TYPE', message: '不支持的图片格式' } } } } if (!validateFileSignature(detectedMime, bytes)) { return { error: { name: originalName, error: { code: 'INVALID_FILE_SIGNATURE', message: '文件签名校验失败' } } } } const compressed = await compressToWebp(bytes, detectedMime, getWebpQuality(event)) const uploadedAt = new Date().toISOString() const layout = getStorageLayout(event) const key = generateImageKey( compressed.mime, new Date(uploadedAt), input.prefix ?? 'images', layout ) let backendId: string try { backendId = await putImage(key, compressed.bytes, { originalName, uploadedAt, contentType: compressed.mime, size: compressed.bytes.byteLength, userId: input.userId ?? null }) } catch (error) { logException('upload failed: storage error', error, { name: originalName, source: input.source ?? 'web' }) return { error: { name: originalName, error: { code: 'UPLOAD_FAILED', message: '上传失败,请稍后重试' } } } } insertActivityLog({ action: 'upload', key, originalName, size: compressed.bytes.byteLength, contentType: compressed.mime, source: input.source ?? 'web', userId: input.userId ?? null, backendId, createdAt: uploadedAt }) logInfo('upload success', { key, size: compressed.bytes.byteLength, contentType: compressed.mime, source: input.source ?? 'web' }) return { item: buildImageItem({ key, event, originalName, contentType: compressed.mime, size: compressed.bytes.byteLength, uploadedAt }) } } /** 与 multipart 声明的 MIME 交叉校验(可选) */ export function mimeMatchesDeclared( declaredMime: string, detectedMime: string ): boolean { if (!declaredMime || !isAllowedMimeType(declaredMime)) { return true } return declaredMime === detectedMime }