Files
guanwang/server/api/admin/media.post.ts

89 lines
5.8 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 { 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 { LARGE_IMAGE_EDGE, MAX_IMAGE_BYTES, MAX_IMAGE_HEIGHT, MAX_IMAGE_MB, 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'),
})
const pixelLimitLabel = `${MAX_IMAGE_PIXELS / 10_000} 万像素`
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: `单张图片不能超过 ${MAX_IMAGE_MB}MB` })
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: `图片像素过大,请压缩至 ${MAX_IMAGE_WIDTH}×${MAX_IMAGE_HEIGHT} 且不超过 ${pixelLimitLabel} 后上传` })
}
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: `图片尺寸不能超过 ${MAX_IMAGE_WIDTH}×${MAX_IMAGE_HEIGHT}${pixelLimitLabel}` })
}
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, { limitInputPixels: MAX_IMAGE_PIXELS }).rotate().resize({ width: LARGE_IMAGE_EDGE, height: LARGE_IMAGE_EDGE, fit: 'inside', withoutEnlargement: true }).webp({ quality: 88 }).toFile(safeUploadPath(derivedPath))
await sharp(file.data, { limitInputPixels: MAX_IMAGE_PIXELS }).rotate().resize({ width: 480, height: 480, fit: 'inside', withoutEnlargement: true }).webp({ quality: 80 }).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
}
})