88 lines
5.5 KiB
TypeScript
88 lines
5.5 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
||
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'
|
||
|
||
const allowedTypes = {
|
||
'image/jpeg': { extension: '.jpg', inputExtensions: ['.jpg', '.jpeg'] },
|
||
'image/png': { extension: '.png', inputExtensions: ['.png'] },
|
||
'image/webp': { extension: '.webp', inputExtensions: ['.webp'] },
|
||
} as const
|
||
|
||
const metadataSchema = z.object({
|
||
altText: z.string().trim().max(160).default(''),
|
||
source: z.string().trim().max(200).default(''),
|
||
copyright: z.string().trim().max(200).default(''),
|
||
authorizationStatus: z.enum(['pending', 'authorized', 'restricted']).default('pending'),
|
||
})
|
||
|
||
function detectImageType(data: Buffer) {
|
||
if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return 'image/jpeg'
|
||
if (data.length >= 8 && data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
|
||
if (data.length >= 12 && data.toString('ascii', 0, 4) === 'RIFF' && data.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'
|
||
}
|
||
|
||
export default defineEventHandler(async (event) => {
|
||
const user = await requireAdmin(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 > 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 图片' })
|
||
const typeConfig = allowedTypes[detectedType as keyof typeof allowedTypes]
|
||
const inputExtension = extname(file.filename || '').toLowerCase()
|
||
if (!typeConfig.inputExtensions.includes(inputExtension as never)) throw createError({ statusCode: 415, statusMessage: '文件扩展名与实际图片类型不一致' })
|
||
|
||
let imageMetadata
|
||
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 > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT || width * height > MAX_IMAGE_PIXELS) {
|
||
throw createError({ statusCode: 413, statusMessage: '图片尺寸不能超过 10000×10000 或 4000 万像素' })
|
||
}
|
||
|
||
const fields = Object.fromEntries((parts || []).filter(part => !part.filename && part.name).map(part => [part.name!, part.data.toString('utf8')]))
|
||
const metadata = metadataSchema.parse(fields)
|
||
const now = new Date()
|
||
const dateDir = `${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}`
|
||
const assetKey = randomUUID()
|
||
const originalPath = `original/${dateDir}/${assetKey}${typeConfig.extension}`
|
||
const derivedPath = `derived/${dateDir}/${assetKey}-large.webp`
|
||
const thumbnailPath = `derived/${dateDir}/${assetKey}-thumb.webp`
|
||
const writtenPaths = [originalPath, derivedPath, thumbnailPath]
|
||
|
||
await Promise.all(writtenPaths.map(path => mkdir(safeUploadPath(path).replace(/\/[^/]+$/, ''), { recursive: true })))
|
||
try {
|
||
await writeFile(safeUploadPath(originalPath), file.data, { flag: 'wx' })
|
||
await sharp(file.data).rotate().resize({ width: 2400, height: 2400, fit: 'inside', withoutEnlargement: true }).webp({ quality: 84 }).toFile(safeUploadPath(derivedPath))
|
||
await sharp(file.data).rotate().resize({ width: 480, height: 480, fit: 'inside', withoutEnlargement: true }).webp({ quality: 78 }).toFile(safeUploadPath(thumbnailPath))
|
||
|
||
const timestamp = now.toISOString()
|
||
const db = useDatabase()
|
||
const row = db.transaction(() => {
|
||
const result = db.prepare(`
|
||
INSERT INTO media_assets (asset_key, original_name, mime_type, extension, bytes, width, height, original_path, derived_path, thumbnail_path, alt_text, source_text, copyright_text, authorization_status, status, created_by, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
|
||
`).run(assetKey, file.filename, detectedType, typeConfig.extension, file.data.length, width, height, originalPath, derivedPath, thumbnailPath, metadata.altText, metadata.source, metadata.copyright, metadata.authorizationStatus, user.id, timestamp, timestamp)
|
||
writeAuditLog(event, { operatorId: user.id, action: 'media.upload', entityType: 'media', entityKey: assetKey, metadata: { originalName: file.filename, bytes: file.data.length, width, height, derivedUrl: mediaUrl(derivedPath) } })
|
||
return db.prepare('SELECT * FROM media_assets WHERE id = ?').get(result.lastInsertRowid) as MediaAssetRow
|
||
})()
|
||
return { url: mediaUrl(derivedPath), media: serializeMedia(row) }
|
||
} catch (error) {
|
||
await Promise.all(writtenPaths.map(path => rm(safeUploadPath(path), { force: true })))
|
||
throw error
|
||
}
|
||
})
|