705 lines
18 KiB
JavaScript
705 lines
18 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')
|
|
|
|
/** 회원 프로필 이미지 전용 논리 폴더명(디스크 경로와 별도) */
|
|
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<string>} paths - 폴더 경로 목록
|
|
* @returns {Array<string>} 필터된 목록
|
|
*/
|
|
const excludeThumbnailFolderPaths = (paths) => paths.filter((pathValue) => pathValue !== MEDIA_THUMBNAIL_ROOT
|
|
&& !pathValue.startsWith(`${MEDIA_THUMBNAIL_ROOT}/`))
|
|
|
|
/**
|
|
* 썸네일 전용 폴더 경로에 대한 변경을 검증한다.
|
|
* @param {Array<string>} 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<boolean>} 참조 중이면 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<string>} urls - 미디어 URL 목록
|
|
* @returns {Promise<Map<string, { id: string, username: string, email: string, lastSeenAt: string | null, lastSeenIp: string }>>} 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<void>}
|
|
*/
|
|
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<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()
|
|
}]))
|
|
}
|
|
|
|
/**
|
|
* 미디어 폴더 목록 조회
|
|
* @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) => {
|
|
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<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 metadataMap = await getMediaMetadataMap()
|
|
const [posts, pages] = await Promise.all([
|
|
listAdminPosts(),
|
|
listPages()
|
|
])
|
|
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),
|
|
avatarOwner
|
|
}
|
|
})
|
|
|
|
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')
|
|
}
|
|
|
|
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<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: '사용 중인 미디어는 삭제할 수 없습니다.'
|
|
})
|
|
}
|
|
|
|
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<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: '사용 중인 미디어는 파일명을 변경할 수 없습니다.'
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|