import { readdir, rename, rm, stat } from 'node:fs/promises' import { basename, dirname, extname, join, relative } from 'node:path' import { createError } from 'h3' import { listAdminPosts, listPages } from '../repositories/content-repository' 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() } /** * 콘텐츠에서 미디어 URL 사용 여부 확인 * @param {Object} contentItem - 콘텐츠 항목 * @param {string} url - 미디어 URL * @returns {Array} 사용처 목록 */ const getContentMediaUsage = (contentItem, url) => { const usages = [] if (contentItem.featuredImage === url) { usages.push({ location: 'featuredImage', label: '대표 이미지' }) } if (contentItem.content?.includes(url)) { usages.push({ location: 'content', label: '본문' }) } return usages } /** * 미디어 URL 사용처 조회 * @param {string} url - 미디어 URL * @returns {Promise>} 사용처 목록 */ const getMediaUsage = (url, posts, pages) => { const postUsages = posts.flatMap((post) => getContentMediaUsage(post, url).map((usage) => ({ type: 'post', typeLabel: '게시물', id: post.id, title: post.title, slug: post.slug, adminUrl: `/admin/posts/${post.id}`, publicUrl: `/post/${post.slug}`, status: post.status, ...usage }))) const pageUsages = pages.flatMap((page) => getContentMediaUsage(page, url).map((usage) => ({ type: 'page', typeLabel: '페이지', id: page.id, title: page.title, slug: page.slug, adminUrl: '', publicUrl: `/pages/${page.slug}`, status: 'page', ...usage }))) return [...postUsages, ...pageUsages] } /** * 미디어 목록 조회 * @returns {Promise>} 미디어 항목 목록 */ export const listMediaItems = async () => { const items = await readMediaDirectory(uploadRoot) const [posts, pages] = await Promise.all([ listAdminPosts(), listPages() ]) const itemsWithUsage = items.map((item) => ({ ...item, usage: getMediaUsage(item.url, posts, pages) })) return itemsWithUsage.sort((left, right) => new Date(right.updatedAt) - new Date(left.updatedAt)) } /** * 미디어 파일 삭제 * @param {string} url - 삭제할 미디어 URL * @returns {Promise} */ export const deleteMediaItem = async (url) => { const [posts, pages] = await Promise.all([ listAdminPosts(), listPages() ]) const usage = getMediaUsage(url, posts, pages) if (usage.length) { throw createError({ statusCode: 409, message: '사용 중인 미디어는 삭제할 수 없습니다.' }) } await rm(resolveMediaPath(url)) } /** * 미디어 파일명 변경 * @param {string} url - 기존 미디어 URL * @param {string} name - 새 파일명 * @returns {Promise} 변경된 미디어 항목 */ export const renameMediaItem = async (url, name) => { const [posts, pages] = await Promise.all([ listAdminPosts(), listPages() ]) const usage = getMediaUsage(url, posts, pages) if (usage.length) { throw createError({ statusCode: 409, message: '사용 중인 미디어는 파일명을 변경할 수 없습니다.' }) } 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) }