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

@@ -5,9 +5,14 @@ import {
getSiteSettings,
listAdminTags,
listMediaReferencePages,
listMediaReferencePosts
listMediaReferencePosts,
listNavigationItems
} from '../repositories/content-repository'
import { getPostgresClient } from '../repositories/postgres-client'
import {
deleteMediaCatalogEntries,
recordMediaCatalogFile,
} from '../repositories/media-catalog-repository'
import {
getPageCoverThumbnailUrl,
getLegacyPostBodyThumbnailUrl,
@@ -21,13 +26,30 @@ import {
isPostCardThumbnailUrl,
isPostThumbnailSource
} from './post-thumbnail-image.js'
import { getUploadRoot, resolveUploadUrlPath } from './upload-root'
const uploadRoot = join(process.cwd(), 'public', 'uploads')
const uploadRoot = getUploadRoot()
const mediaFilePattern = /\.(jpe?g|png|webp|gif|mp4|webm|mov|mp3|wav|ogg|m4a|pdf|zip|txt|csv|docx|xlsx|pptx)$/i
const MEDIA_ITEMS_CACHE_TTL_MS = 30 * 1000
let mediaItemsCache = null
let mediaItemsCacheExpiresAt = 0
let mediaItemsLoadPromise = null
let mediaItemsCacheRevision = 0
/** 회원 프로필 이미지 전용 논리 폴더명(디스크 경로와 별도) */
export const MEDIA_THUMBNAIL_ROOT = '썸네일'
/**
* 미디어 목록 캐시를 무효화한다.
* @returns {void}
*/
export const invalidateMediaItemsCache = () => {
mediaItemsCacheRevision += 1
mediaItemsCache = null
mediaItemsCacheExpiresAt = 0
mediaItemsLoadPromise = null
}
/**
* 회원 아바타 공개 URL 여부
* @param {string} url - 미디어 URL
@@ -197,9 +219,10 @@ const getAvatarOwnersByUrls = async (urls) => {
* 미디어 논리 폴더 메타를 upsert한다.
* @param {string} url - 미디어 URL
* @param {string} category - 논리 폴더명
* @param {{ origin?: string, role?: string }} [options] - 카탈로그 기록 옵션
* @returns {Promise<void>}
*/
export const upsertMediaMetadataCategory = async (url, category) => {
export const upsertMediaMetadataCategory = async (url, category, options = {}) => {
const sql = getPostgresClient()
if (!sql) {
@@ -207,21 +230,24 @@ export const upsertMediaMetadataCategory = async (url, category) => {
}
const normalizedCategory = normalizeMediaCategory(category)
const catalogRecorded = await recordMediaCatalogFile(url, {
category: normalizedCategory,
...options
})
await sql`
INSERT INTO media_metadata (
url,
category
)
VALUES (
${url},
${normalizedCategory}
)
ON CONFLICT (url) DO UPDATE
SET
category = EXCLUDED.category,
updated_at = now()
`
if (!catalogRecorded) {
try {
await sql`
INSERT INTO media_metadata (url, category)
VALUES (${url}, ${normalizedCategory})
ON CONFLICT (url) DO UPDATE
SET category = EXCLUDED.category, updated_at = now()
`
} catch {
console.warn('[media-catalog] 호환 메타데이터 기록 실패')
}
}
invalidateMediaItemsCache()
}
/**
@@ -252,13 +278,8 @@ const getMediaMetadataMap = async () => {
*/
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)
})
const items = await listMediaItems()
const defaultCategories = items.map((item) => item.category)
if (!sql) {
return excludeThumbnailFolderPaths([...new Set(['미분류', ...defaultCategories])])
@@ -387,7 +408,7 @@ export const resolveMediaPath = (url) => {
}
const decodedUrl = decodeURIComponent(url)
const filePath = join(process.cwd(), 'public', decodedUrl)
const filePath = resolveUploadUrlPath(decodedUrl)
const relativePath = relative(uploadRoot, filePath)
if (relativePath.startsWith('..') || relativePath === '') {
@@ -541,6 +562,27 @@ const getTagMediaUsage = (url, tags) => tags
label: '태그 헤더 이미지'
}))
/**
* 네비게이션 추천 사이트 파비콘 사용처 조회
* @param {string} url - 미디어 URL
* @param {Array<Object>} navigationItems - 네비게이션 항목 목록
* @returns {Array<Object>} 사용처 목록
*/
const getNavigationMediaUsage = (url, navigationItems = []) => navigationItems
.filter((item) => item.cachedFaviconUrl === url)
.map((item) => ({
type: 'navigation',
typeLabel: '네비게이션',
id: item.id,
title: item.label,
slug: item.url || '',
adminUrl: '/admin/navigation',
publicUrl: item.url || '',
status: 'navigation',
location: 'cachedFaviconUrl',
label: '추천 사이트 아이콘'
}))
/**
* 미디어 URL이 실제 사용처에서 쓰이는지 조회한다.
* @param {string} url - 미디어 URL
@@ -548,12 +590,14 @@ const getTagMediaUsage = (url, tags) => tags
* @param {Array<Object>} pages - 페이지 목록
* @param {Object} siteSettings - 사이트 설정
* @param {Array<Object>} tags - 태그 목록
* @param {Array<Object>} [navigationItems] - 네비게이션 항목 목록
* @returns {Array<Object>} 사용처 목록
*/
const getDirectMediaUsage = (url, posts, pages, siteSettings, tags = []) => [
const getDirectMediaUsage = (url, posts, pages, siteSettings, tags = [], navigationItems = []) => [
...getMediaUsage(url, posts, pages),
...getSiteSettingsMediaUsage(url, siteSettings),
...getTagMediaUsage(url, tags)
...getTagMediaUsage(url, tags),
...getNavigationMediaUsage(url, navigationItems)
]
/**
@@ -763,10 +807,11 @@ const getOriginalDerivativeMediaUsage = (
* @param {Object} siteSettings - 사이트 설정
* @param {Array<Object>} tags - 태그 목록
* @param {{ thumbnailOwnerByUrl: Map<string, string>, pageCoverThumbnailOwnerByUrl: Map<string, string>, bodyThumbnailOwnerByUrl: Map<string, string> }} ownerMaps - 파생 URL별 원본 URL 맵 묶음
* @param {Array<Object>} [navigationItems] - 네비게이션 항목 목록
* @returns {Array<Object>} 사용처 목록
*/
const getOriginalMediaUsage = (url, posts, pages, siteSettings, tags, ownerMaps) => [
...getDirectMediaUsage(url, posts, pages, siteSettings, tags),
const getOriginalMediaUsage = (url, posts, pages, siteSettings, tags, ownerMaps, navigationItems = []) => [
...getDirectMediaUsage(url, posts, pages, siteSettings, tags, navigationItems),
...getOriginalDerivativeMediaUsage(url, ownerMaps.thumbnailOwnerByUrl, posts, pages, siteSettings, tags, {
location: 'featuredImageThumbnail',
label: '목록 카드 썸네일',
@@ -856,6 +901,24 @@ const getPageCoverThumbnailStatus = (item, itemUrlSet, directUsage) => {
}
}
/**
* 원본 업로드 이미지의 카드·페이지 커버 썸네일 상태를 조회한다.
* 페이지 대표 이미지면 페이지 커버 상태를 우선한다.
* @param {Object} item - 미디어 항목
* @param {Set<string>} itemUrlSet - 전체 미디어 URL 집합
* @param {Array<Object>} directUsage - 원본 URL 직접 사용처
* @returns {Object|null} 썸네일 상태
*/
const getOriginalImageThumbnailStatus = (item, itemUrlSet, directUsage) => {
const isPageCoverImage = directUsage.some((usage) => usage.type === 'page' && usage.location === 'featuredImage')
if (isPageCoverImage) {
return getPageCoverThumbnailStatus(item, itemUrlSet, directUsage)
}
return getPostThumbnailStatus(item, itemUrlSet, directUsage) || getPageCoverThumbnailStatus(item, itemUrlSet, directUsage)
}
/**
* 게시물 업로드 이미지의 본문 표시용 이미지 상태를 조회한다.
* @param {Object} item - 미디어 항목
@@ -954,17 +1017,18 @@ const getSiteSettingsMediaUsage = (url, siteSettings) => {
}
/**
* 미디어 목록 조회
* 디스크와 콘텐츠 참조에서 미디어 목록을 새로 구성한다.
* @returns {Promise<Array<Object>>} 미디어 항목 목록
*/
export const listMediaItems = async () => {
const loadMediaItems = async () => {
const items = await readMediaDirectory(uploadRoot)
const metadataMap = await getMediaMetadataMap()
const [posts, pages, siteSettings, tags] = await Promise.all([
const [posts, pages, siteSettings, tags, navigationItems] = await Promise.all([
listMediaReferencePosts(),
listMediaReferencePages(),
getSiteSettings(),
listAdminTags()
listAdminTags(),
listNavigationItems()
])
const avatarOwnerByUrl = await getAvatarOwnersByUrls(items.map((item) => item.url))
const itemUrlSet = new Set(items.map((item) => item.url))
@@ -980,7 +1044,7 @@ export const listMediaItems = async () => {
const rawCategory = metadataMap[item.url]?.category ?? item.category
const category = normalizeStoredDisplayCategory(item.url, rawCategory)
const avatarOwner = avatarOwnerByUrl.get(item.url) || null
const directUsage = getOriginalMediaUsage(item.url, posts, pages, siteSettings, tags, ownerMaps)
const directUsage = getOriginalMediaUsage(item.url, posts, pages, siteSettings, tags, ownerMaps, navigationItems)
const thumbnailUsage = getPostThumbnailMediaUsage(item.url, thumbnailOwnerByUrl, posts, pages, siteSettings)
const pageCoverThumbnailUsage = getPageCoverThumbnailMediaUsage(item.url, pageCoverThumbnailOwnerByUrl, posts, pages, siteSettings)
const bodyThumbnailUsage = getPostBodyThumbnailMediaUsage(item.url, bodyThumbnailOwnerByUrl, posts, pages, siteSettings)
@@ -1007,7 +1071,7 @@ export const listMediaItems = async () => {
role: 'pageCover',
originalUrl: pageCoverOriginalUrl
}
: getPostThumbnailStatus(item, itemUrlSet, directUsage) || getPageCoverThumbnailStatus(item, itemUrlSet, directUsage),
: getOriginalImageThumbnailStatus(item, itemUrlSet, directUsage),
bodyThumbnailStatus: bodyOriginalUrl
? {
role: 'body',
@@ -1021,22 +1085,46 @@ export const listMediaItems = async () => {
return itemsWithUsage.sort((left, right) => new Date(right.updatedAt) - new Date(left.updatedAt))
}
/**
* 짧은 공유 캐시를 사용해 미디어 전체 스캔의 요청별 반복을 막는다.
* @param {{ force?: boolean }} [options] - 조회 옵션
* @returns {Promise<Array<Object>>} 미디어 항목 목록
*/
export const listMediaItems = async (options = {}) => {
const now = Date.now()
if (!options.force && mediaItemsCache && mediaItemsCacheExpiresAt > now) {
return mediaItemsCache
}
if (!options.force && mediaItemsLoadPromise) {
return mediaItemsLoadPromise
}
const revision = mediaItemsCacheRevision
const loadPromise = loadMediaItems().then((items) => {
if (revision === mediaItemsCacheRevision) {
mediaItemsCache = items
mediaItemsCacheExpiresAt = Date.now() + MEDIA_ITEMS_CACHE_TTL_MS
}
return items
}).finally(() => {
if (mediaItemsLoadPromise === loadPromise) {
mediaItemsLoadPromise = null
}
})
mediaItemsLoadPromise = loadPromise
return loadPromise
}
/**
* 미디어 메타데이터 삭제
* @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}
`
await deleteMediaCatalogEntries([url])
invalidateMediaItemsCache()
}
/**
@@ -1059,6 +1147,7 @@ const moveMediaMetadata = async (currentUrl, nextUrl) => {
updated_at = now()
WHERE url = ${currentUrl}
`
invalidateMediaItemsCache()
}
/**
@@ -1097,21 +1186,26 @@ export const updateMediaCategories = async (urls, category) => {
for (const url of [...new Set(urls.filter(Boolean))]) {
const mediaPath = resolveMediaPath(url)
const catalogRecorded = await recordMediaCatalogFile(url, {
category: normalizedCategory
})
await sql`
INSERT INTO media_metadata (
url,
category
)
VALUES (
${url},
${normalizedCategory}
)
ON CONFLICT (url) DO UPDATE
SET
category = EXCLUDED.category,
updated_at = now()
`
if (!catalogRecorded) {
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({
@@ -1130,11 +1224,12 @@ export const updateMediaCategories = async (urls, category) => {
* @returns {Promise<void>}
*/
export const deleteMediaItem = async (url) => {
const [posts, pages, siteSettings, tags] = await Promise.all([
const [posts, pages, siteSettings, tags, navigationItems] = await Promise.all([
listMediaReferencePosts(),
listMediaReferencePages(),
getSiteSettings(),
listAdminTags()
listAdminTags(),
listNavigationItems()
])
const items = await readMediaDirectory(uploadRoot)
const thumbnailOwnerByUrl = buildPostThumbnailOwnerMap(items)
@@ -1146,7 +1241,7 @@ export const deleteMediaItem = async (url) => {
bodyThumbnailOwnerByUrl
}
const usage = [
...getOriginalMediaUsage(url, posts, pages, siteSettings, tags, ownerMaps),
...getOriginalMediaUsage(url, posts, pages, siteSettings, tags, ownerMaps, navigationItems),
...getPostThumbnailMediaUsage(url, thumbnailOwnerByUrl, posts, pages, siteSettings),
...getPageCoverThumbnailMediaUsage(url, pageCoverThumbnailOwnerByUrl, posts, pages, siteSettings),
...getPostBodyThumbnailMediaUsage(url, bodyThumbnailOwnerByUrl, posts, pages, siteSettings)
@@ -1191,11 +1286,12 @@ export const renameMediaItem = async (url, name) => {
})
}
const [posts, pages, siteSettings, tags] = await Promise.all([
const [posts, pages, siteSettings, tags, navigationItems] = await Promise.all([
listMediaReferencePosts(),
listMediaReferencePages(),
getSiteSettings(),
listAdminTags()
listAdminTags(),
listNavigationItems()
])
const items = await readMediaDirectory(uploadRoot)
const thumbnailOwnerByUrl = buildPostThumbnailOwnerMap(items)
@@ -1207,7 +1303,7 @@ export const renameMediaItem = async (url, name) => {
bodyThumbnailOwnerByUrl
}
const usage = [
...getOriginalMediaUsage(url, posts, pages, siteSettings, tags, ownerMaps),
...getOriginalMediaUsage(url, posts, pages, siteSettings, tags, ownerMaps, navigationItems),
...getPostThumbnailMediaUsage(url, thumbnailOwnerByUrl, posts, pages, siteSettings),
...getPageCoverThumbnailMediaUsage(url, pageCoverThumbnailOwnerByUrl, posts, pages, siteSettings),
...getPostBodyThumbnailMediaUsage(url, bodyThumbnailOwnerByUrl, posts, pages, siteSettings)
@@ -1260,6 +1356,7 @@ export const renameMediaItem = async (url, name) => {
const renamedItem = await createMediaItem(nextPath)
await moveMediaMetadata(url, renamedItem.url)
await recordMediaCatalogFile(renamedItem.url)
return createMediaItem(nextPath)
}