import { readdir, rename, rm, stat } from 'node:fs/promises' import { basename, dirname, extname, join, relative } from 'node:path' import { createError } from 'h3' const uploadRoot = join(process.cwd(), 'public', 'uploads') /** * 미디어 파일명 조각을 안전하게 정리 * @param {string} value - 원본 파일명 * @returns {string} 정리된 파일명 */ export const sanitizeMediaName = (value) => value .replace(/[^a-zA-Z0-9가-힣._-]/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') /** * 업로드 URL을 실제 파일 경로로 변환 * @param {string} url - 업로드 URL * @returns {string} 파일 경로 */ export const resolveMediaPath = (url) => { if (!url?.startsWith('/uploads/')) { throw createError({ statusCode: 400, message: '미디어 URL 형식이 올바르지 않습니다.' }) } const decodedUrl = decodeURIComponent(url) const filePath = join(process.cwd(), 'public', decodedUrl) const relativePath = relative(uploadRoot, filePath) if (relativePath.startsWith('..') || relativePath === '') { throw createError({ statusCode: 400, message: '미디어 경로가 올바르지 않습니다.' }) } return filePath } /** * 파일 경로를 미디어 항목으로 변환 * @param {string} filePath - 파일 경로 * @returns {Promise} 미디어 항목 */ const createMediaItem = async (filePath) => { const fileStat = await stat(filePath) const relativePath = relative(uploadRoot, filePath) const url = `/uploads/${relativePath.split('/').join('/')}` return { url, name: basename(filePath), title: basename(filePath, extname(filePath)), size: fileStat.size, updatedAt: fileStat.mtime.toISOString(), category: relativePath.split('/')[0] || 'uploads' } } /** * 업로드 디렉토리의 이미지 파일을 재귀적으로 조회 * @param {string} directoryPath - 조회할 디렉토리 * @returns {Promise>} 미디어 항목 목록 */ const readMediaDirectory = async (directoryPath) => { let entries = [] try { entries = await readdir(directoryPath, { withFileTypes: true }) } catch { return [] } const items = await Promise.all(entries.map(async (entry) => { const entryPath = join(directoryPath, entry.name) if (entry.isDirectory()) { return readMediaDirectory(entryPath) } if (!/\.(jpe?g|png|webp|gif)$/i.test(entry.name)) { return [] } return [await createMediaItem(entryPath)] })) return items.flat() } /** * 미디어 목록 조회 * @returns {Promise>} 미디어 항목 목록 */ export const listMediaItems = async () => { const items = await readMediaDirectory(uploadRoot) return items.sort((left, right) => new Date(right.updatedAt) - new Date(left.updatedAt)) } /** * 미디어 파일 삭제 * @param {string} url - 삭제할 미디어 URL * @returns {Promise} */ export const deleteMediaItem = async (url) => { await rm(resolveMediaPath(url)) } /** * 미디어 파일명 변경 * @param {string} url - 기존 미디어 URL * @param {string} name - 새 파일명 * @returns {Promise} 변경된 미디어 항목 */ export const renameMediaItem = async (url, name) => { const currentPath = resolveMediaPath(url) const currentExtension = extname(currentPath) const cleanName = sanitizeMediaName(name.replace(/\.[^.]+$/g, '')) if (!cleanName) { throw createError({ statusCode: 400, message: '파일명을 입력해 주세요.' }) } const nextPath = join(dirname(currentPath), `${cleanName}${currentExtension}`) await rename(currentPath, nextPath) return createMediaItem(nextPath) }