Files
sori.studio/server/utils/media-library.js

232 lines
5.9 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'
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<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: relativePath.split('/')[0] || 'uploads'
}
}
/**
* 업로드 디렉토리의 이미지 파일을 재귀적으로 조회
* @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 [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<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))
}
/**
* 미디어 파일명 변경
* @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)
return createMediaItem(nextPath)
}