138 lines
3.6 KiB
JavaScript
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)
|
|
}
|