회원 아바타 자산을 전용 경로로 분리해 작가용 미디어 목록과 섞이지 않게 하고, 설정 화면에서 파일 업로드로 바로 반영할 수 있게 했다. Co-authored-by: Cursor <cursoragent@cursor.com>
432 lines
11 KiB
JavaScript
432 lines
11 KiB
JavaScript
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<Object>} 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<Array<string>>} 미디어 폴더 경로 목록
|
|
*/
|
|
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<Object>} 미디어 항목
|
|
*/
|
|
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<Array<Object>>} 미디어 항목 목록
|
|
*/
|
|
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<Object>} 사용처 목록
|
|
*/
|
|
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<Array<Object>>} 사용처 목록
|
|
*/
|
|
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<Array<Object>>} 미디어 항목 목록
|
|
*/
|
|
export const listMediaItems = async () => {
|
|
const items = await readMediaDirectory(uploadRoot)
|
|
const publicAdminItems = items.filter((item) => !item.url.startsWith('/uploads/members/avatars/'))
|
|
const metadataMap = await getMediaMetadataMap()
|
|
const [posts, pages] = await Promise.all([
|
|
listAdminPosts(),
|
|
listPages()
|
|
])
|
|
const itemsWithUsage = publicAdminItems.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<void>}
|
|
*/
|
|
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<void>}
|
|
*/
|
|
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<Object>} 수정된 미디어 항목
|
|
*/
|
|
export const updateMediaCategory = async (url, category) => {
|
|
const [item] = await updateMediaCategories([url], category)
|
|
|
|
return item
|
|
}
|
|
|
|
/**
|
|
* 여러 미디어 카테고리 저장
|
|
* @param {Array<string>} urls - 미디어 URL 목록
|
|
* @param {string} category - 미디어 카테고리
|
|
* @returns {Promise<Array<Object>>} 수정된 미디어 항목 목록
|
|
*/
|
|
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<void>}
|
|
*/
|
|
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<Object>} 변경된 미디어 항목
|
|
*/
|
|
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)
|
|
}
|