v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user