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

757 lines
19 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 { getSiteSettings, 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]
}
/**
* 사이트 설정에서 미디어 URL 사용처 조회
* @param {string} url - 미디어 URL
* @param {Object} siteSettings - 사이트 설정
* @returns {Array<Object>} 사용처 목록
*/
const getSiteSettingsMediaUsage = (url, siteSettings) => {
const usages = []
if (siteSettings.logoUrl === url) {
usages.push({
type: 'settings',
typeLabel: '사이트 설정',
id: 'site-logo',
title: '사이트 로고',
adminUrl: '/admin/settings',
publicUrl: '/',
status: 'system',
location: 'logoUrl',
label: '사이트 로고'
})
}
if (siteSettings.faviconUrl === url) {
usages.push({
type: 'settings',
typeLabel: '사이트 설정',
id: 'site-favicon',
title: '파비콘',
adminUrl: '/admin/settings',
publicUrl: '/',
status: 'system',
location: 'faviconUrl',
label: '파비콘'
})
}
return usages
}
/**
* 미디어 목록 조회
* @returns {Promise<Array<Object>>} 미디어 항목 목록
*/
export const listMediaItems = async () => {
const items = await readMediaDirectory(uploadRoot)
const metadataMap = await getMediaMetadataMap()
const [posts, pages, siteSettings] = await Promise.all([
listAdminPosts(),
listPages(),
getSiteSettings()
])
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),
...getSiteSettingsMediaUsage(item.url, siteSettings)
],
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, siteSettings] = await Promise.all([
listAdminPosts(),
listPages(),
getSiteSettings()
])
const usage = [
...getMediaUsage(url, posts, pages),
...getSiteSettingsMediaUsage(url, siteSettings)
]
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, siteSettings] = await Promise.all([
listAdminPosts(),
listPages(),
getSiteSettings()
])
const usage = [
...getMediaUsage(url, posts, pages),
...getSiteSettingsMediaUsage(url, siteSettings)
]
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)
}