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' import { getPostgresClient } from '../repositories/postgres-client' const uploadRoot = join(process.cwd(), 'public', 'uploads') /** * 기본 미디어 카테고리 이름 반환 * @param {string} relativePath - 업로드 루트 기준 상대 경로 * @returns {string} 기본 카테고리 */ const getDefaultMediaCategory = (relativePath) => relativePath.split('/')[0] || '미분류' /** * 미디어 메타데이터 목록을 URL 기준 객체로 조회 * @returns {Promise} URL별 미디어 메타데이터 */ const getMediaMetadataMap = async () => { const sql = getPostgresClient() if (!sql) { return {} } const rows = await sql` SELECT * FROM media_metadata ` return Object.fromEntries(rows.map((row) => [row.url, { category: row.category, updatedAt: row.updated_at.toISOString() }])) } /** * 미디어 카테고리 정리 * @param {string} category - 입력 카테고리 * @returns {string} 정리된 카테고리 */ const normalizeMediaCategory = (category) => String(category || '') .trim() .replace(/\s+/g, ' ') .replace(/\/+/g, '/') .replace(/^\/|\/$/g, '') || '미분류' /** * 미디어 폴더 목록 조회 * @returns {Promise>} 미디어 폴더 경로 목록 */ export const listMediaFolders = async () => { const sql = getPostgresClient() const items = await readMediaDirectory(uploadRoot) const metadataMap = await getMediaMetadataMap() const defaultCategories = items.map((item) => metadataMap[item.url]?.category || item.category) if (!sql) { return [...new Set(['미분류', ...defaultCategories])].sort((left, right) => left.localeCompare(right)) } const rows = await sql` SELECT path FROM media_folders ORDER BY path ASC ` return [...new Set([ '미분류', ...rows.map((row) => row.path), ...defaultCategories ])].sort((left, right) => left.localeCompare(right)) } /** * 미디어 폴더 생성 * @param {string} path - 폴더 경로 * @returns {Promise<{ path: string }>} 생성된 폴더 */ export const createMediaFolder = async (path) => { const sql = getPostgresClient() const normalizedPath = normalizeMediaCategory(path) if (!sql) { throw new Error('DATABASE_REQUIRED') } await sql` INSERT INTO media_folders (path) VALUES (${normalizedPath}) ON CONFLICT (path) DO UPDATE SET updated_at = now() ` return { path: normalizedPath } } /** * 미디어 파일명 조각을 안전하게 정리 * @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: getDefaultMediaCategory(relativePath) } } /** * 업로드 디렉토리의 이미지 파일을 재귀적으로 조회 * @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 metadataMap = await getMediaMetadataMap() const [posts, pages] = await Promise.all([ listAdminPosts(), listPages() ]) const itemsWithUsage = items.map((item) => ({ ...item, category: metadataMap[item.url]?.category || item.category, 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} */ const deleteMediaMetadata = async (url) => { const sql = getPostgresClient() if (!sql) { return } await sql` DELETE FROM media_metadata WHERE url = ${url} ` } /** * 미디어 메타데이터 URL 변경 * @param {string} currentUrl - 기존 미디어 URL * @param {string} nextUrl - 새 미디어 URL * @returns {Promise} */ const moveMediaMetadata = async (currentUrl, nextUrl) => { const sql = getPostgresClient() if (!sql) { return } await sql` UPDATE media_metadata SET url = ${nextUrl}, updated_at = now() WHERE url = ${currentUrl} ` } /** * 미디어 카테고리 저장 * @param {string} url - 미디어 URL * @param {string} category - 미디어 카테고리 * @returns {Promise} 수정된 미디어 항목 */ export const updateMediaCategory = async (url, category) => { const [item] = await updateMediaCategories([url], category) return item } /** * 여러 미디어 카테고리 저장 * @param {Array} urls - 미디어 URL 목록 * @param {string} category - 미디어 카테고리 * @returns {Promise>} 수정된 미디어 항목 목록 */ export const updateMediaCategories = async (urls, category) => { const sql = getPostgresClient() const normalizedCategory = normalizeMediaCategory(category) if (!sql) { throw new Error('DATABASE_REQUIRED') } await createMediaFolder(normalizedCategory) const items = [] for (const url of [...new Set(urls.filter(Boolean))]) { const mediaPath = resolveMediaPath(url) await sql` INSERT INTO media_metadata ( url, category ) VALUES ( ${url}, ${normalizedCategory} ) ON CONFLICT (url) DO UPDATE SET category = EXCLUDED.category, updated_at = now() ` const item = await createMediaItem(mediaPath) items.push({ ...item, category: normalizedCategory, usage: [] }) } return items } /** * 미디어 파일 삭제 * @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)) await deleteMediaMetadata(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) const renamedItem = await createMediaItem(nextPath) await moveMediaMetadata(url, renamedItem.url) return createMediaItem(nextPath) }