import { readdir, rename, rm, stat } from 'node:fs/promises' import { basename, dirname, extname, join, relative } from 'node:path' import { createError } from 'h3' import { getSiteSettings, listAdminPosts, listPages } from '../repositories/content-repository' import { getPostgresClient } from '../repositories/postgres-client' const uploadRoot = join(process.cwd(), 'public', 'uploads') const mediaFilePattern = /\.(jpe?g|png|webp|gif|mp4|webm|mov|mp3|wav|ogg|m4a|pdf|zip|txt|csv|docx|xlsx|pptx)$/i /** 회원 프로필 이미지 전용 논리 폴더명(디스크 경로와 별도) */ export const MEDIA_THUMBNAIL_ROOT = '썸네일' /** * 회원 아바타 공개 URL 여부 * @param {string} url - 미디어 URL * @returns {boolean} 아바타 경로이면 true */ export const isMemberAvatarPublicUrl = (url) => typeof url === 'string' && url.includes('/members/avatars/') /** * 미디어 카테고리 정리 * @param {string} category - 입력 카테고리 * @returns {string} 정리된 카테고리 */ const normalizeMediaCategory = (category) => String(category || '') .trim() .replace(/\s+/g, ' ') .replace(/\/+/g, '/') .replace(/^\/|\/$/g, '') || '미분류' /** * 기본 미디어 카테고리 이름 반환 * @param {string} relativePath - 업로드 루트 기준 상대 경로 * @returns {string} 기본 카테고리 */ const getDefaultMediaCategory = (relativePath) => { if (relativePath.startsWith('posts/')) { return '미분류' } return relativePath.split('/')[0] || '미분류' } /** * 저장된 논리 폴더명을 화면·API 기준으로 정규화한다. * @param {string} url - 미디어 URL * @param {string} category - DB 또는 디스크 기본 카테고리 * @returns {string} 정규화된 카테고리 */ const normalizeStoredDisplayCategory = (url, category) => { if (isMemberAvatarPublicUrl(url)) { return MEDIA_THUMBNAIL_ROOT } const base = normalizeMediaCategory(category) if (base === 'posts' || base === '회원/썸네일') { return '미분류' } return base } /** * 논리 폴더 경로 목록에서 썸네일 전용 루트를 제외한다. * @param {Array} paths - 폴더 경로 목록 * @returns {Array} 필터된 목록 */ const excludeThumbnailFolderPaths = (paths) => paths.filter((pathValue) => pathValue !== MEDIA_THUMBNAIL_ROOT && !pathValue.startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)) /** * 썸네일 전용 폴더 경로에 대한 변경을 검증한다. * @param {Array} urls - 대상 URL 목록 * @param {string} normalizedCategory - 목표 논리 폴더 * @returns {void} */ const assertCategoryMoveAllowed = (urls, normalizedCategory) => { const uniqueUrls = [...new Set(urls.filter(Boolean))] const hasAvatar = uniqueUrls.some((u) => isMemberAvatarPublicUrl(u)) const hasNonAvatar = uniqueUrls.some((u) => !isMemberAvatarPublicUrl(u)) if (hasNonAvatar && normalizedCategory === MEDIA_THUMBNAIL_ROOT) { throw createError({ statusCode: 400, message: '일반 미디어를 썸네일 폴더로 옮길 수 없습니다.' }) } if (hasAvatar && normalizedCategory !== MEDIA_THUMBNAIL_ROOT) { throw createError({ statusCode: 400, message: '프로필 썸네일의 논리 폴더는 「썸네일」로만 유지됩니다.' }) } } /** * 해당 URL이 회원 프로필 `avatar_url`로 참조 중인지 확인한다. * @param {string} url - 미디어 URL * @returns {Promise} 참조 중이면 true */ const isAvatarUrlReferencedByProfile = async (url) => { if (!isMemberAvatarPublicUrl(url)) { return false } const sql = getPostgresClient() if (!sql) { return false } const rows = await sql` SELECT 1 FROM users WHERE avatar_url = ${url} LIMIT 1 ` return rows.length > 0 } /** * URL 목록에 대해 아바타를 사용 중인 회원 정보를 조회한다. * @param {Array} urls - 미디어 URL 목록 * @returns {Promise>} URL별 회원 요약 */ const getAvatarOwnersByUrls = async (urls) => { const sql = getPostgresClient() const uniqueUrls = [...new Set(urls.filter((u) => isMemberAvatarPublicUrl(u)))] if (!sql || !uniqueUrls.length) { return new Map() } const rows = await sql` SELECT id, username, email, last_seen_at, last_seen_ip, avatar_url FROM users WHERE avatar_url IN ${sql(uniqueUrls)} ` const ownerByUrl = new Map() for (const row of rows) { if (!row.avatar_url || ownerByUrl.has(row.avatar_url)) { continue } ownerByUrl.set(row.avatar_url, { id: row.id, username: row.username, email: row.email, lastSeenAt: row.last_seen_at ? row.last_seen_at.toISOString() : null, lastSeenIp: row.last_seen_ip || '' }) } return ownerByUrl } /** * 미디어 논리 폴더 메타를 upsert한다. * @param {string} url - 미디어 URL * @param {string} category - 논리 폴더명 * @returns {Promise} */ export const upsertMediaMetadataCategory = async (url, category) => { const sql = getPostgresClient() if (!sql) { return } const normalizedCategory = normalizeMediaCategory(category) await sql` INSERT INTO media_metadata ( url, category ) VALUES ( ${url}, ${normalizedCategory} ) ON CONFLICT (url) DO UPDATE SET category = EXCLUDED.category, updated_at = now() ` } /** * 미디어 메타데이터 목록을 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() }])) } /** * 미디어 폴더 목록 조회 * @returns {Promise>} 미디어 폴더 경로 목록 */ export const listMediaFolders = async () => { const sql = getPostgresClient() const items = await readMediaDirectory(uploadRoot) const metadataMap = await getMediaMetadataMap() const defaultCategories = items.map((item) => { const rawCategory = metadataMap[item.url]?.category || item.category return normalizeStoredDisplayCategory(item.url, rawCategory) }) if (!sql) { return excludeThumbnailFolderPaths([...new Set(['미분류', ...defaultCategories])]) .sort((left, right) => left.localeCompare(right)) } const rows = await sql` SELECT path FROM media_folders ORDER BY path ASC ` return excludeThumbnailFolderPaths([...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') } if (normalizedPath === MEDIA_THUMBNAIL_ROOT || normalizedPath.startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)) { throw createError({ statusCode: 400, message: '「썸네일」 폴더는 시스템에서만 관리합니다.' }) } await sql` INSERT INTO media_folders (path) VALUES (${normalizedPath}) ON CONFLICT (path) DO UPDATE SET updated_at = now() ` return { path: normalizedPath } } /** * 미디어 폴더를 삭제하고 해당 폴더(및 하위 경로)에 묶인 미디어 메타는 미분류로 되돌린다. * @param {string} path - 삭제할 폴더 경로 * @returns {Promise<{ ok: boolean, path: string }>} 삭제 결과 */ export const deleteMediaFolder = async (path) => { const sql = getPostgresClient() const normalizedPath = normalizeMediaCategory(path) if (!sql) { throw new Error('DATABASE_REQUIRED') } if (!normalizedPath || normalizedPath === '미분류') { throw createError({ statusCode: 400, message: '이 폴더는 삭제할 수 없습니다.' }) } if (normalizedPath === MEDIA_THUMBNAIL_ROOT || normalizedPath.startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)) { throw createError({ statusCode: 400, message: '「썸네일」 폴더는 삭제할 수 없습니다.' }) } const childPrefix = `${normalizedPath}/` await sql.begin(async (tx) => { await tx` UPDATE media_metadata SET category = '미분류', updated_at = now() WHERE category = ${normalizedPath} OR category LIKE ${`${childPrefix}%`} ` await tx` DELETE FROM media_folders WHERE path = ${normalizedPath} OR path LIKE ${`${childPrefix}%`} ` }) return { ok: true, 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('/')}` const extension = extname(filePath).toLowerCase() const kind = /\.(jpe?g|png|webp|gif)$/i.test(extension) ? 'image' : /\.(mp4|webm|mov)$/i.test(extension) ? 'video' : /\.(mp3|wav|ogg|m4a)$/i.test(extension) ? 'audio' : 'file' return { url, name: basename(filePath), title: basename(filePath, extname(filePath)), size: fileStat.size, extension, kind, 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 (!mediaFilePattern.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] } /** * 사이트 설정에서 미디어 URL 사용처 조회 * @param {string} url - 미디어 URL * @param {Object} siteSettings - 사이트 설정 * @returns {Array} 사용처 목록 */ const getSiteSettingsMediaUsage = (url, siteSettings) => { const usages = [] if (siteSettings.logoUrl === url) { usages.push({ type: 'settings', typeLabel: '사이트 설정', id: 'site-logo', title: '사이트 로고', adminUrl: '/admin/settings', publicUrl: '/', status: 'system', location: 'logoUrl', label: '사이트 로고' }) } if (siteSettings.faviconUrl === url) { usages.push({ type: 'settings', typeLabel: '사이트 설정', id: 'site-favicon', title: '파비콘', adminUrl: '/admin/settings', publicUrl: '/', status: 'system', location: 'faviconUrl', label: '파비콘' }) } return usages } /** * 미디어 목록 조회 * @returns {Promise>} 미디어 항목 목록 */ export const listMediaItems = async () => { const items = await readMediaDirectory(uploadRoot) const metadataMap = await getMediaMetadataMap() const [posts, pages, siteSettings] = await Promise.all([ listAdminPosts(), listPages(), getSiteSettings() ]) const avatarOwnerByUrl = await getAvatarOwnersByUrls(items.map((item) => item.url)) const itemsWithUsage = items.map((item) => { const rawCategory = metadataMap[item.url]?.category ?? item.category const category = normalizeStoredDisplayCategory(item.url, rawCategory) const avatarOwner = avatarOwnerByUrl.get(item.url) || null return { ...item, category, usage: [ ...getMediaUsage(item.url, posts, pages), ...getSiteSettingsMediaUsage(item.url, siteSettings) ], avatarOwner } }) 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') } assertCategoryMoveAllowed(urls, normalizedCategory) if (normalizedCategory !== MEDIA_THUMBNAIL_ROOT) { 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: normalizeStoredDisplayCategory(url, normalizedCategory), usage: [] }) } return items } /** * 미디어 파일 삭제 * @param {string} url - 삭제할 미디어 URL * @returns {Promise} */ export const deleteMediaItem = async (url) => { const [posts, pages, siteSettings] = await Promise.all([ listAdminPosts(), listPages(), getSiteSettings() ]) const usage = [ ...getMediaUsage(url, posts, pages), ...getSiteSettingsMediaUsage(url, siteSettings) ] if (usage.length) { throw createError({ statusCode: 409, message: '사용 중인 미디어는 삭제할 수 없습니다.' }) } if (await isAvatarUrlReferencedByProfile(url)) { 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, siteSettings] = await Promise.all([ listAdminPosts(), listPages(), getSiteSettings() ]) const usage = [ ...getMediaUsage(url, posts, pages), ...getSiteSettingsMediaUsage(url, siteSettings) ] if (usage.length) { throw createError({ statusCode: 409, message: '사용 중인 미디어는 파일명을 변경할 수 없습니다.' }) } if (await isAvatarUrlReferencedByProfile(url)) { 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}`) if (currentPath === nextPath) { return createMediaItem(currentPath) } try { await stat(nextPath) throw createError({ statusCode: 409, message: '같은 폴더에 동일한 파일명이 이미 있습니다.' }) } catch (err) { if (err.statusCode === 409) { throw err } } await rename(currentPath, nextPath) const renamedItem = await createMediaItem(nextPath) await moveMediaMetadata(url, renamedItem.url) return createMediaItem(nextPath) }