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 { 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 > 8 * 1024 * 1024) 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: 40_000_000 }).metadata() } catch { 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) { 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 } })