Files
sori.studio/server/routes/admin/api/media.get.js
zenn de767095ba v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 11:05:40 +09:00

300 lines
9.2 KiB
JavaScript

import { requireAdminSession } from '../../../utils/admin-auth'
import { listMediaItems } from '../../../utils/media-library'
import { getMediaCatalogMode } from '../../../utils/media-catalog-mode'
import {
getMediaCatalogReadiness,
listMediaCatalogItems,
listPagedMediaCatalogItems
} from '../../../repositories/media-catalog-repository'
import {
isPageCoverThumbnailUrl,
isPostBodyThumbnailUrl,
isPostCardThumbnailUrl
} from '../../../../lib/post-card-thumbnail-url.js'
/** 관리자 미디어 페이지 표시 개수 허용값 */
const mediaPageSizeOptions = [50, 100, 200]
/**
* 미디어 항목 종류를 반환한다.
* @param {Object} item - 미디어 항목
* @returns {'image'|'video'|'audio'|'file'} 미디어 종류
*/
const getMediaItemKind = (item) => item?.kind || 'image'
/**
* 미디어 항목 잠금 여부를 반환한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 잠금 여부
*/
const isMediaItemLocked = (item) => Boolean(item?.usage?.length) || Boolean(item?.avatarOwner)
/**
* 프로필 이미지 파일인지 확인한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 프로필 이미지이면 true
*/
const isProfileThumbnailItem = (item) => Boolean(item?.url?.includes('/members/avatars/'))
/**
* 관리자 미디어 탭 id를 정규화한다.
* @param {unknown} value - 요청 탭
* @returns {'library'|'postThumbnails'|'postBodyThumbnails'|'thumbnails'} 탭 id
*/
const normalizeMediaTab = (value) => {
const tab = String(value || 'library')
return ['library', 'postThumbnails', 'postBodyThumbnails', 'thumbnails'].includes(tab)
? tab
: 'library'
}
/**
* 관리자 미디어 종류 필터를 정규화한다.
* @param {unknown} value - 요청 미디어 종류
* @returns {'all'|'image'|'video'|'audio'|'file'} 미디어 종류
*/
const normalizeMediaKind = (value) => {
const kind = String(value || 'all')
return ['all', 'image', 'video', 'audio', 'file'].includes(kind)
? kind
: 'all'
}
/**
* 관리자 미디어 페이지 표시 개수를 정규화한다.
* @param {unknown} value - 요청 표시 개수
* @returns {number} 표시 개수
*/
const normalizeMediaPageSize = (value) => {
const pageSize = Number(value) || 50
return mediaPageSizeOptions.includes(pageSize) ? pageSize : 50
}
/**
* 관리자 미디어 탭별 항목인지 확인한다.
* @param {Object} item - 미디어 항목
* @param {'library'|'postThumbnails'|'postBodyThumbnails'|'thumbnails'} tab - 탭 id
* @returns {boolean} 탭에 속하면 true
*/
const matchesMediaTab = (item, tab) => {
const url = item?.url
if (tab === 'thumbnails') {
return isProfileThumbnailItem(item)
}
if (tab === 'postThumbnails') {
return isPostCardThumbnailUrl(url) || isPageCoverThumbnailUrl(url)
}
if (tab === 'postBodyThumbnails') {
return isPostBodyThumbnailUrl(url)
}
return !isProfileThumbnailItem(item)
&& !isPostCardThumbnailUrl(url)
&& !isPageCoverThumbnailUrl(url)
&& !isPostBodyThumbnailUrl(url)
}
/**
* 폴더와 하위 폴더에 포함되는지 확인한다.
* @param {Object} item - 미디어 항목
* @param {string} folder - 폴더 경로
* @returns {boolean} 포함 여부
*/
const matchesMediaFolder = (item, folder) => !folder
|| item.category === folder
|| item.category?.startsWith(`${folder}/`)
/**
* 검색어와 미디어 항목이 일치하는지 확인한다.
* @param {Object} item - 미디어 항목
* @param {string} query - 소문자 검색어
* @returns {boolean} 일치 여부
*/
const matchesMediaQuery = (item, query) => {
if (!query) {
return true
}
const usageTitles = Array.isArray(item?.usage)
? item.usage.map((usage) => usage.title)
: []
return [
item?.name,
item?.title,
...usageTitles
].some((value) => String(value || '').toLowerCase().includes(query))
}
/**
* 미디어 폴더별 개수를 계산한다.
* @param {Array<Object>} items - 미디어 항목 목록
* @returns {Record<string, number>} 폴더별 개수
*/
const countMediaFolders = (items) => items.reduce((counts, item) => {
const segments = String(item.category || '미분류').split('/').filter(Boolean)
if (!segments.length) {
counts['미분류'] = (counts['미분류'] || 0) + 1
return counts
}
segments.reduce((parentPath, segment) => {
const folder = parentPath ? `${parentPath}/${segment}` : segment
counts[folder] = (counts[folder] || 0) + 1
return folder
}, '')
return counts
}, {})
/**
* 미디어 종류별 개수를 계산한다.
* @param {Array<Object>} items - 미디어 항목 목록
* @returns {Record<string, number>} 종류별 개수
*/
const countMediaKinds = (items) => items.reduce((counts, item) => {
const kind = getMediaItemKind(item)
counts[kind] = (counts[kind] || 0) + 1
counts.all += 1
return counts
}, {
all: 0,
image: 0,
video: 0,
audio: 0,
file: 0
})
/**
* 미디어 페이지 응답을 구성한다.
* @param {Array<Object>} items - 전체 미디어 목록
* @param {Record<string, unknown>} query - 요청 쿼리
* @returns {Object} 페이지 미디어 응답
*/
export const createPagedMediaResponse = (items, query) => {
const tab = normalizeMediaTab(query.tab)
const pageSize = normalizeMediaPageSize(query.pageSize)
const requestedPage = Math.max(1, Number(query.page) || 1)
const mediaKind = normalizeMediaKind(query.kind)
const searchQuery = String(query.search || '').trim().toLowerCase()
const folder = tab === 'library' ? String(query.folder || '').trim() : ''
const unusedOnly = query.unused === '1' || query.unused === 'true'
const libraryItems = items.filter((item) => matchesMediaTab(item, 'library'))
const tabItems = items.filter((item) => matchesMediaTab(item, tab))
const folderItems = tab === 'library'
? tabItems.filter((item) => matchesMediaFolder(item, folder))
: tabItems
const kindItems = folderItems.filter((item) => mediaKind === 'all' || getMediaItemKind(item) === mediaKind)
const usageFilteredItems = unusedOnly
? kindItems.filter((item) => !isMediaItemLocked(item))
: kindItems
const filteredItems = usageFilteredItems.filter((item) => matchesMediaQuery(item, searchQuery))
const pageCount = Math.max(1, Math.ceil(filteredItems.length / pageSize))
const page = Math.min(requestedPage, pageCount)
const startIndex = (page - 1) * pageSize
return {
items: filteredItems.slice(startIndex, startIndex + pageSize),
pagination: {
page,
pageSize,
total: filteredItems.length,
pageCount
},
counts: {
tabs: {
library: libraryItems.length,
postThumbnails: items.filter((item) => matchesMediaTab(item, 'postThumbnails')).length,
postBodyThumbnails: items.filter((item) => matchesMediaTab(item, 'postBodyThumbnails')).length,
thumbnails: items.filter((item) => matchesMediaTab(item, 'thumbnails')).length
},
folders: countMediaFolders(libraryItems),
kinds: countMediaKinds(folderItems),
usage: {
all: kindItems.length,
unused: kindItems.filter((item) => !isMediaItemLocked(item)).length
}
}
}
}
/**
* 미디어 목록의 민감정보 없는 비교 수치를 만든다.
* @param {Array<Object>} items - 미디어 항목 목록
* @returns {{ count: number, bytes: number, kinds: Record<string, number> }} 비교 수치
*/
const createMediaComparisonSummary = (items) => ({
count: items.length,
bytes: items.reduce((total, item) => total + Number(item.size || 0), 0),
kinds: countMediaKinds(items)
})
/**
* 관리자 미디어 목록 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Array<Object>|Object>} 미디어 목록 또는 페이지 응답
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const mode = getMediaCatalogMode()
const query = getQuery(event)
const paged = query.paged === '1' || query.paged === 'true'
let items
if (mode === 'database') {
try {
const readiness = await getMediaCatalogReadiness()
if (!readiness.ready) {
console.warn('[media-catalog] DB 카탈로그 준비 미완료, 파일시스템으로 대체', {
reason: readiness.reason
})
throw new Error('MEDIA_CATALOG_NOT_READY')
}
if (paged) {
return await listPagedMediaCatalogItems(query)
}
items = await listMediaCatalogItems()
} catch {
console.warn('[media-catalog] DB 목록 조회 실패, 파일시스템으로 대체')
items = await listMediaItems()
}
} else {
items = await listMediaItems()
if (mode === 'shadow') {
listMediaCatalogItems().then((catalogItems) => {
const filesystem = createMediaComparisonSummary(items)
const catalog = createMediaComparisonSummary(catalogItems)
console.info('[media-catalog] shadow 차이', {
count: catalog.count - filesystem.count,
bytes: catalog.bytes - filesystem.bytes,
kinds: Object.fromEntries(Object.keys(filesystem.kinds).map((kind) => [
kind,
Number(catalog.kinds[kind] || 0) - Number(filesystem.kinds[kind] || 0)
]))
})
}).catch(() => {
console.warn('[media-catalog] shadow 비교 실패')
})
}
}
if (paged) {
return createPagedMediaResponse(items, query)
}
return items
})