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

138 lines
3.6 KiB
JavaScript

import { readdir, rename, rm, stat } from 'node:fs/promises'
import { basename, dirname, extname, join, relative } from 'node:path'
import { createError } from 'h3'
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()
}
/**
* 미디어 목록 조회
* @returns {Promise<Array<Object>>} 미디어 항목 목록
*/
export const listMediaItems = async () => {
const items = await readMediaDirectory(uploadRoot)
return items.sort((left, right) => new Date(right.updatedAt) - new Date(left.updatedAt))
}
/**
* 미디어 파일 삭제
* @param {string} url - 삭제할 미디어 URL
* @returns {Promise<void>}
*/
export const deleteMediaItem = async (url) => {
await rm(resolveMediaPath(url))
}
/**
* 미디어 파일명 변경
* @param {string} url - 기존 미디어 URL
* @param {string} name - 새 파일명
* @returns {Promise<Object>} 변경된 미디어 항목
*/
export const renameMediaItem = async (url, name) => {
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)
}