import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { createError, readMultipartFormData } from 'h3' import sharp from 'sharp' import { requireAdminSession } from '../../../../utils/admin-auth' import { updateSiteLogo } from '../../../../repositories/content-repository' import { upsertMediaMetadataCategory } from '../../../../utils/media-library' const allowedImageTypes = new Set(['image/jpeg', 'image/png', 'image/webp']) /** * 숫자 설정값을 최소/최대 범위로 보정한다. * @param {number} value - 원본 값 * @param {number} minimum - 최소값 * @param {number} maximum - 최대값 * @returns {number} 보정된 값 */ const clampNumber = (value, minimum, maximum) => { if (!Number.isFinite(value)) { return minimum } if (value < minimum) { return minimum } if (value > maximum) { return maximum } return Math.round(value) } /** * 시스템 로고 파일명에 사용할 짧은 고유 접미사를 만든다. * @returns {string} 파일명 접미사 */ const createSystemAssetSuffix = () => { const now = new Date() const year = now.getFullYear() const month = String(now.getMonth() + 1).padStart(2, '0') return `${year}${month}-${Math.random().toString(36).slice(2, 8)}` } /** * 사이트 로고 업로드 API * @param {import('h3').H3Event} event - 요청 이벤트 * @returns {Promise} 수정된 사이트 설정 */ export default defineEventHandler(async (event) => { requireAdminSession(event) const config = useRuntimeConfig() const maxFileSize = Number(config.maxFileSize || 10485760) const logoSize = clampNumber(Number(config.siteLogoSize || 512), 128, 2048) const formData = await readMultipartFormData(event) const file = (formData || []).find((part) => part.name === 'file' && part.filename) if (!file) { throw createError({ statusCode: 400, message: '업로드할 로고 이미지가 없습니다.' }) } if (!allowedImageTypes.has(file.type)) { throw createError({ statusCode: 400, message: 'JPG, PNG, WebP 이미지만 로고로 사용할 수 있습니다.' }) } if (file.data.length > maxFileSize) { throw createError({ statusCode: 413, message: '업로드 가능한 파일 크기를 초과했습니다.' }) } const metadata = await sharp(file.data).metadata() if (!metadata.width || !metadata.height) { throw createError({ statusCode: 400, message: '이미지 메타데이터를 읽을 수 없습니다.' }) } const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads' const publicBasePath = uploadBaseUrl.replace(/^\/+/, '') const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system') const assetSuffix = createSystemAssetSuffix() const logoFileName = `logo-${assetSuffix}.webp` const faviconFileName = `favicon-${assetSuffix}.png` const logoPath = join(directoryPath, logoFileName) const faviconPath = join(directoryPath, faviconFileName) const logoUrl = `${uploadBaseUrl}/system/${logoFileName}` const faviconUrl = `${uploadBaseUrl}/system/${faviconFileName}` await mkdir(directoryPath, { recursive: true }) const logoBuffer = await sharp(file.data) .rotate() .resize({ width: logoSize, height: logoSize, fit: 'cover', position: 'centre' }) .webp({ quality: 90 }) .toBuffer() const faviconBuffer = await sharp(file.data) .rotate() .resize({ width: 256, height: 256, fit: 'cover', position: 'centre' }) .png() .toBuffer() await writeFile(logoPath, logoBuffer) await writeFile(faviconPath, faviconBuffer) await upsertMediaMetadataCategory(logoUrl, '시스템') await upsertMediaMetadataCategory(faviconUrl, '시스템') return updateSiteLogo({ logoUrl, faviconUrl }) })