v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.

세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-22 11:05:40 +09:00
parent 9bc0f0a90e
commit de767095ba
116 changed files with 8128 additions and 2933 deletions

View File

@@ -1,5 +1,11 @@
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,
@@ -172,7 +178,7 @@ const countMediaKinds = (items) => items.reduce((counts, item) => {
* @param {Record<string, unknown>} query - 요청 쿼리
* @returns {Object} 페이지 미디어 응답
*/
const createPagedMediaResponse = (items, query) => {
export const createPagedMediaResponse = (items, query) => {
const tab = normalizeMediaTab(query.tab)
const pageSize = normalizeMediaPageSize(query.pageSize)
const requestedPage = Math.max(1, Number(query.page) || 1)
@@ -220,6 +226,17 @@ const createPagedMediaResponse = (items, query) => {
}
}
/**
* 미디어 목록의 민감정보 없는 비교 수치를 만든다.
* @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 - 요청 이벤트
@@ -228,10 +245,53 @@ const createPagedMediaResponse = (items, query) => {
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const items = await listMediaItems()
const mode = getMediaCatalogMode()
const query = getQuery(event)
const paged = query.paged === '1' || query.paged === 'true'
let items
if (query.paged === '1' || query.paged === 'true') {
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)
}