Files
PicHost/server/utils/process-image-upload.ts
T
O96u 6fc054328d feat: activity logs, gallery storage filter, and upload rate limits
Add per-user activity logs with storage backend tracking, gallery
storage filter, and IP/token upload rate limiting. Fix storage delete
modal flash, pin local backend in filter order, and link settings
version to GitHub releases.
2026-08-26 17:09:38 +08:00

197 lines
4.6 KiB
TypeScript

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 { 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 key = generateImageKey(
compressed.mime,
new Date(uploadedAt),
input.prefix ?? 'images'
)
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
}