세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
300 lines
9.2 KiB
JavaScript
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
|
|
})
|