Files
sori.studio/server/repositories/media-catalog-repository.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

1167 lines
37 KiB
JavaScript

import { readdir, stat } from 'node:fs/promises'
import { basename, extname, join, relative, sep } from 'node:path'
import {
detectMediaKind,
detectMediaRole,
escapePostgresLikeLiteral,
extractMediaReferences,
normalizeMediaCatalogUrl
} from '../../lib/media-catalog.js'
import {
getPageCoverThumbnailUrl,
getPostBodyPreviewThumbnailUrl,
getPostBodyThumbnailUrl,
isPageCoverThumbnailUrl,
isPostBody1600ThumbnailUrl,
isPostBody720ThumbnailUrl,
isPostCardThumbnailUrl
} from '../../lib/post-card-thumbnail-url.js'
import { getPostgresClient } from './postgres-client.js'
import { getUploadRoot } from '../utils/upload-root.js'
const uploadRoot = getUploadRoot()
const catalogFilePattern = /\.(jpe?g|png|webp|gif|mp4|webm|mov|mp3|wav|ogg|m4a|pdf|zip|txt|csv|docx|xlsx|pptx)$/i
/**
* 게시물 원본 URL의 카드 썸네일 URL을 만든다.
* @param {string} url - 게시물 원본 이미지 URL
* @returns {string} 카드 썸네일 URL
*/
const getCatalogPostThumbnailUrl = (url) => String(url || '').replace(
/\/([^/]+)\.(?:jpe?g|png|webp)$/i,
'/thumbs/$1-card.webp'
)
/**
* 카탈로그 오류 상세를 제한된 문자열로 정리한다.
* @param {unknown} error - 원본 오류
* @returns {string} 저장 가능한 오류 코드
*/
const getCatalogErrorDetail = (error) => String(error?.code || error?.message || 'MEDIA_CATALOG_ERROR').slice(0, 300)
/**
* 참조 필드의 관리자 표시 이름을 반환한다.
* @param {string} field - 참조 필드
* @returns {string} 표시 이름
*/
const getReferenceFieldLabel = (field) => ({
content: '본문',
featuredImage: '대표 이미지',
ogImage: '공유 이미지',
avatarUrl: '프로필 썸네일',
headerImageUrl: '태그 헤더 이미지',
logoUrl: '사이트 로고',
faviconUrl: '파비콘',
homeCoverImageUrl: '메인 화면 라이트 이미지',
homeCoverDarkImageUrl: '메인 화면 다크 이미지',
cachedFaviconUrl: '추천 사이트 아이콘',
url: '네비게이션 URL'
})[field] || field
/**
* 참조 행을 기존 미디어 사용처 계약으로 변환한다.
* @param {Object} reference - DB 참조 행
* @returns {Object} 사용처
*/
const mapMediaReferenceUsage = (reference) => {
const type = reference.reference_type === 'site_setting' ? 'settings' : reference.reference_type
const typeMetadata = {
post: {
typeLabel: '게시물',
adminUrl: `/admin/posts/${reference.reference_id}`,
publicUrl: reference.reference_slug ? `/post/${reference.reference_slug}` : ''
},
page: {
typeLabel: '페이지',
adminUrl: `/admin/pages/${reference.reference_id}`,
publicUrl: reference.reference_slug ? `/pages/${reference.reference_slug}` : ''
},
tag: {
typeLabel: '태그',
adminUrl: `/admin/tags/${reference.reference_id}`,
publicUrl: reference.reference_slug ? `/tag/${reference.reference_slug}` : ''
},
user: {
typeLabel: '회원',
adminUrl: `/admin/members/${reference.reference_id}`,
publicUrl: ''
},
navigation: {
typeLabel: '네비게이션',
adminUrl: '/admin/navigation',
publicUrl: reference.reference_slug || ''
},
settings: {
typeLabel: '사이트 설정',
adminUrl: '/admin/settings',
publicUrl: '/'
}
}[type] || {
typeLabel: '참조',
adminUrl: '',
publicUrl: ''
}
return {
type,
typeLabel: typeMetadata.typeLabel,
id: reference.reference_id,
title: reference.reference_title,
slug: reference.reference_slug,
status: reference.reference_status,
location: reference.reference_field,
label: getReferenceFieldLabel(reference.reference_field),
adminUrl: typeMetadata.adminUrl,
publicUrl: typeMetadata.publicUrl
}
}
/**
* 사용자 행을 기존 avatarOwner 계약으로 변환한다.
* @param {Object} row - 사용자 행
* @returns {Object} 아바타 소유자
*/
const mapAvatarOwner = (row) => ({
id: row.id,
username: row.username,
email: row.email,
roleCode: String(row.user_role || 'member'),
lastSeenAt: row.last_seen_at ? row.last_seen_at.toISOString() : null,
lastSeenIp: row.last_seen_ip || ''
})
/**
* 파생 파일의 원본 행 id를 찾는다.
* @param {ReturnType<typeof getPostgresClient>} sql - PostgreSQL 클라이언트
* @param {string} storageKey - 파생 저장 키
* @returns {Promise<number|null>} 원본 행 id
*/
const findParentMediaId = async (sql, storageKey) => {
const { parentStorageKey } = detectMediaRole(storageKey)
if (!parentStorageKey) {
return null
}
const stem = parentStorageKey.replace(/\.[^.]+$/, '')
const rows = await sql`
SELECT id
FROM media_metadata
WHERE storage_key LIKE ${`${stem}.%`}
AND role = 'original'
ORDER BY id ASC
LIMIT 1
`
return rows[0]?.id || null
}
/**
* 앱이 성공적으로 반영한 파일 변경 뒤 상태 집계를 현재 active 행으로 맞춘다.
* 상태 단일 행을 잠근 뒤 집계해 동시 업로드의 마지막 갱신이 오래된 값을 쓰지 않게 한다.
* @param {ReturnType<typeof getPostgresClient>} [client] - PostgreSQL 클라이언트
* @returns {Promise<boolean>} 갱신 후 준비 상태
*/
export const refreshManagedMediaCatalogState = async (client = getPostgresClient()) => {
if (!client) {
return false
}
return client.begin(async (transaction) => {
const stateRows = await transaction`
SELECT ready, reconciled_at, error_detail
FROM media_catalog_state
WHERE singleton = true
FOR UPDATE
`
if (!stateRows[0]) {
return false
}
const summaryRows = await transaction`
SELECT
count(*) FILTER (WHERE status = 'active')::bigint AS active_count,
COALESCE(sum(size_bytes) FILTER (WHERE status = 'active'), 0)::bigint AS active_bytes,
count(*) FILTER (WHERE status IN ('staging', 'error'))::int AS incomplete_count
FROM media_metadata
`
const summary = summaryRows[0]
const ready = Boolean(stateRows[0].ready)
&& Boolean(stateRows[0].reconciled_at)
&& Number(summary.incomplete_count || 0) === 0
await transaction`
UPDATE media_catalog_state
SET
ready = ${ready},
filesystem_count = ${summary.active_count},
filesystem_bytes = ${summary.active_bytes},
catalog_count = ${summary.active_count},
catalog_bytes = ${summary.active_bytes},
error_detail = ${ready
? null
: Number(summary.incomplete_count || 0) > 0
? 'INCOMPLETE_MEDIA_ROWS'
: stateRows[0].error_detail},
updated_at = now()
WHERE singleton = true
`
return ready
})
}
/**
* 앱에서 실제 삭제를 완료한 미디어 카탈로그 행을 제거하고 상태 집계를 맞춘다.
* @param {Array<string>} urls - 삭제된 공개 URL 목록
* @returns {Promise<number>} 삭제한 카탈로그 행 수
*/
export const deleteMediaCatalogEntries = async (urls) => {
const sql = getPostgresClient()
const normalizedUrls = [...new Set(urls.map((url) => {
try {
return normalizeMediaCatalogUrl(url).url
} catch {
return ''
}
}).filter(Boolean))]
if (!sql || !normalizedUrls.length) {
return 0
}
const rows = await sql`
DELETE FROM media_metadata
WHERE url IN ${sql(normalizedUrls)}
RETURNING id
`
await refreshManagedMediaCatalogState(sql).catch(() => {
console.warn('[media-catalog] 삭제 후 상태 집계 갱신 실패')
})
return rows.length
}
/**
* 실제 파일 정보를 미디어 카탈로그에 upsert한다.
* @param {string} inputUrl - 공개 미디어 URL
* @param {{ category?: string, origin?: string, role?: string, refreshState?: boolean }} [options] - 기록 옵션
* @returns {Promise<Object|null>} 카탈로그 행
*/
export const upsertMediaCatalogFile = async (inputUrl, options = {}) => {
const sql = getPostgresClient()
if (!sql) {
return null
}
const { url, storageKey } = normalizeMediaCatalogUrl(inputUrl)
const filePath = join(uploadRoot, ...storageKey.split('/'))
const fileStat = await stat(filePath)
const detected = detectMediaRole(storageKey)
const role = options.role || detected.role
const origin = options.origin || (role === 'original' ? 'upload' : 'derived')
const parentId = await findParentMediaId(sql, storageKey)
const rows = await sql`
INSERT INTO media_metadata (
url,
category,
file_name,
storage_key,
extension,
kind,
role,
size_bytes,
modified_at,
parent_id,
origin,
status,
last_seen_at,
missing_since,
error_detail
)
VALUES (
${url},
${String(options.category || '미분류').trim() || '미분류'},
${basename(storageKey)},
${storageKey},
${extname(storageKey).toLowerCase()},
${detectMediaKind(storageKey)},
${role},
${fileStat.size},
${fileStat.mtime},
${parentId},
${origin},
'active',
now(),
NULL,
NULL
)
ON CONFLICT (url) DO UPDATE
SET
category = CASE
WHEN ${Boolean(options.category)} THEN EXCLUDED.category
ELSE media_metadata.category
END,
file_name = EXCLUDED.file_name,
storage_key = EXCLUDED.storage_key,
extension = EXCLUDED.extension,
kind = EXCLUDED.kind,
role = EXCLUDED.role,
size_bytes = EXCLUDED.size_bytes,
modified_at = EXCLUDED.modified_at,
parent_id = COALESCE(EXCLUDED.parent_id, media_metadata.parent_id),
origin = CASE
WHEN media_metadata.origin = 'external' THEN EXCLUDED.origin
ELSE media_metadata.origin
END,
status = 'active',
last_seen_at = now(),
missing_since = NULL,
error_detail = NULL,
updated_at = now()
RETURNING *
`
if (options.refreshState !== false) {
await refreshManagedMediaCatalogState(sql).catch(() => {
console.warn('[media-catalog] 관리 파일 상태 집계 갱신 실패')
})
}
return rows[0] || null
}
/**
* 기존 기능을 막지 않는 최선 노력 방식으로 파일을 카탈로그에 기록한다.
* @param {string} url - 공개 미디어 URL
* @param {{ category?: string, origin?: string, role?: string, refreshState?: boolean }} [options] - 기록 옵션
* @returns {Promise<boolean>} 기록 성공 여부
*/
export const recordMediaCatalogFile = async (url, options = {}) => {
try {
await upsertMediaCatalogFile(url, options)
return true
} catch (error) {
const sql = getPostgresClient()
if (sql) {
try {
const normalized = normalizeMediaCatalogUrl(url)
await sql`
INSERT INTO media_metadata (
url,
category,
file_name,
storage_key,
extension,
kind,
role,
origin,
status,
error_detail
)
VALUES (
${normalized.url},
${String(options.category || '미분류').trim() || '미분류'},
${basename(normalized.storageKey)},
${normalized.storageKey},
${extname(normalized.storageKey).toLowerCase()},
${detectMediaKind(normalized.storageKey)},
${options.role || detectMediaRole(normalized.storageKey).role},
${options.origin || 'upload'},
'error',
${getCatalogErrorDetail(error)}
)
ON CONFLICT (url) DO UPDATE
SET
status = 'error',
error_detail = ${getCatalogErrorDetail(error)},
updated_at = now()
`
} catch {
// 기존 파일 작업 성공을 카탈로그 보조 기록 실패로 되돌리지 않는다.
}
}
console.warn('[media-catalog] 파일 인덱스 기록 실패')
return false
}
}
/**
* 한 대상의 미디어 참조를 현재 필드 값으로 교체한다.
* @param {string} referenceType - 참조 종류
* @param {string|number} referenceId - 참조 대상 id
* @param {Record<string, unknown>} fields - URL을 포함할 수 있는 필드
* @param {{ title?: string, slug?: string, status?: string }} [metadata] - 표시 메타
* @returns {Promise<number>} 저장한 참조 수
*/
export const replaceMediaReferences = async (referenceType, referenceId, fields, metadata = {}) => {
const sql = getPostgresClient()
if (!sql) {
return 0
}
const references = extractMediaReferences(fields)
const targetId = String(referenceId)
await sql.begin(async (tx) => {
await tx`
DELETE FROM media_references
WHERE reference_type = ${referenceType}
AND reference_id = ${targetId}
`
for (const reference of references) {
await tx`
INSERT INTO media_references (
media_id,
media_url,
reference_type,
reference_id,
reference_field,
reference_title,
reference_slug,
reference_status
)
SELECT
media_metadata.id,
${reference.mediaUrl},
${referenceType},
${targetId},
${reference.referenceField},
${String(metadata.title || '')},
${String(metadata.slug || '')},
${String(metadata.status || '')}
FROM (SELECT 1) AS source
LEFT JOIN media_metadata
ON media_metadata.url = ${reference.mediaUrl}
ON CONFLICT (media_url, reference_type, reference_id, reference_field)
DO UPDATE SET
media_id = EXCLUDED.media_id,
reference_title = EXCLUDED.reference_title,
reference_slug = EXCLUDED.reference_slug,
reference_status = EXCLUDED.reference_status,
updated_at = now()
`
}
})
return references.length
}
/**
* DB 행 묶음을 기존 관리자 미디어 항목 계약으로 변환한다.
* @param {Array<Object>} outputRows - 응답에 포함할 미디어 행
* @param {Array<Object>} contextRows - parent·child 판별용 행
* @param {Array<Object>} referenceRows - 관련 참조 행
* @param {Array<Object>} avatarRows - 관련 사용자 행
* @returns {Array<Object>} 미디어 항목 목록
*/
const buildMediaCatalogItems = (outputRows, contextRows, referenceRows, avatarRows) => {
const referencesByUrl = new Map()
for (const reference of referenceRows) {
const usages = referencesByUrl.get(reference.media_url) || []
usages.push(mapMediaReferenceUsage(reference))
referencesByUrl.set(reference.media_url, usages)
}
const avatarOwnerByUrl = new Map()
for (const avatar of avatarRows) {
if (avatar.avatar_url && !avatarOwnerByUrl.has(avatar.avatar_url)) {
avatarOwnerByUrl.set(avatar.avatar_url, mapAvatarOwner(avatar))
}
}
const byId = new Map(contextRows.map((row) => [String(row.id), row]))
const childrenByParentId = new Map()
for (const row of contextRows) {
if (!row.parent_id) {
continue
}
const children = childrenByParentId.get(String(row.parent_id)) || []
children.push(row)
childrenByParentId.set(String(row.parent_id), children)
}
return outputRows.map((row) => {
const original = row.parent_id ? byId.get(String(row.parent_id)) : null
const directUsage = referencesByUrl.get(row.url) || []
const inheritedUsage = original
? (referencesByUrl.get(original.url) || []).filter((usage) => {
if (row.role === 'card') {
return usage.location === 'featuredImage'
}
if (row.role === 'page_cover') {
return usage.type === 'page' && usage.location === 'featuredImage'
}
return usage.location === 'content'
}).map((usage) => ({
...usage,
location: row.role === 'card'
? 'featuredImageThumbnail'
: row.role === 'page_cover'
? 'pageCoverThumbnail'
: 'contentBodyImage',
sourceUrl: original.url
}))
: []
const children = childrenByParentId.get(String(row.id)) || []
const cardChild = children.find((child) => child.role === 'card' && isPostCardThumbnailUrl(child.url))
const pageCoverChild = children.find((child) => child.role === 'page_cover' && isPageCoverThumbnailUrl(child.url))
const body1600Child = children.find((child) => child.role === 'body' && isPostBody1600ThumbnailUrl(child.url))
const body720Child = children.find((child) => child.role === 'body' && isPostBody720ThumbnailUrl(child.url))
const isPostUploadImage = row.role === 'original'
&& row.kind === 'image'
&& row.storage_key.startsWith('posts/')
&& !String(row.storage_key).includes('/thumbs/')
const isFeaturedImage = directUsage.some((usage) => usage.type === 'post' && usage.location === 'featuredImage')
const isPageCoverImage = directUsage.some((usage) => usage.type === 'page' && usage.location === 'featuredImage')
const isContentImage = directUsage.some((usage) => usage.location === 'content')
const avatarOwner = avatarOwnerByUrl.get(row.url) || null
const usage = [...directUsage, ...inheritedUsage].filter((entry) => !(
entry.type === 'user' && entry.location === 'avatarUrl'
))
return {
url: row.url,
name: row.file_name,
title: basename(row.file_name, row.extension || extname(row.file_name)),
size: Number(row.size_bytes || 0),
extension: row.extension || extname(row.file_name).toLowerCase(),
kind: row.kind,
updatedAt: (row.modified_at || row.updated_at).toISOString(),
category: row.category,
usage,
thumbnailStatus: cardChild
? {
role: 'original',
variant: 'postCard',
thumbnailUrl: cardChild.url,
hasThumbnail: true,
isFallbackActive: false
}
: pageCoverChild
? {
role: 'original',
variant: 'pageCover',
thumbnailUrl: pageCoverChild.url,
hasThumbnail: true,
isFallbackActive: false
}
: isPostUploadImage && isFeaturedImage
? {
role: 'original',
variant: 'postCard',
thumbnailUrl: getCatalogPostThumbnailUrl(row.url),
hasThumbnail: false,
isFallbackActive: true
}
: isPostUploadImage && isPageCoverImage
? {
role: 'original',
variant: 'pageCover',
thumbnailUrl: getPageCoverThumbnailUrl(row.url),
hasThumbnail: false,
isFallbackActive: true
}
: isPostUploadImage
? {
role: 'original',
variant: 'postCard',
thumbnailUrl: getCatalogPostThumbnailUrl(row.url),
hasThumbnail: false,
isFallbackActive: false
}
: ['card', 'page_cover'].includes(row.role)
? { role: row.role === 'card' ? 'card' : 'pageCover', originalUrl: original?.url || '' }
: null,
bodyThumbnailStatus: isPostUploadImage
? {
role: 'original',
thumbnailUrl: body1600Child?.url || getPostBodyThumbnailUrl(row.url),
previewThumbnailUrl: body720Child?.url || getPostBodyPreviewThumbnailUrl(row.url),
hasThumbnail: Boolean(body1600Child),
hasPreviewThumbnail: Boolean(body720Child),
isFallbackActive: isContentImage && !body1600Child
}
: row.role === 'body'
? { role: 'body', originalUrl: original?.url || '' }
: null,
avatarOwner
}
})
}
/**
* 선택된 미디어 행의 parent·child·참조·아바타 소유자를 함께 조회한다.
* @param {ReturnType<typeof getPostgresClient>} sql - PostgreSQL 클라이언트
* @param {Array<Object>} rows - 응답 미디어 행
* @returns {Promise<Array<Object>>} 기존 계약 미디어 항목
*/
const hydrateMediaCatalogRows = async (sql, rows) => {
if (!rows.length) {
return []
}
const rowIds = rows.map((row) => row.id)
const parentIds = rows.map((row) => row.parent_id).filter(Boolean)
const contextRows = await sql`
SELECT *
FROM media_metadata
WHERE status = 'active'
AND (
id IN ${sql([...new Set([...rowIds, ...parentIds])])}
OR parent_id IN ${sql(rowIds)}
)
`
const contextById = new Map(contextRows.map((row) => [String(row.id), row]))
const referenceUrls = [...new Set(rows.flatMap((row) => {
const parent = row.parent_id ? contextById.get(String(row.parent_id)) : null
return [row.url, parent?.url].filter(Boolean)
}))]
const [referenceRows, avatarRows] = await Promise.all([
sql`
SELECT *
FROM media_references
WHERE media_url IN ${sql(referenceUrls)}
ORDER BY id ASC
`,
sql`
SELECT id, username, email, user_role, last_seen_at, last_seen_ip, avatar_url
FROM users
WHERE avatar_url IN ${sql(rows.map((row) => row.url))}
`
])
return buildMediaCatalogItems(rows, contextRows, referenceRows, avatarRows)
}
/**
* DB 카탈로그 준비 상태를 DB 조회만으로 판정한다.
* @returns {Promise<{ ready: boolean, reason: string }>} 준비 상태
*/
export const getMediaCatalogReadiness = async () => {
const sql = getPostgresClient()
if (!sql) {
return { ready: false, reason: 'database-unavailable' }
}
const rows = await sql`
SELECT
state.ready,
state.reconciled_at,
state.filesystem_count,
state.filesystem_bytes,
state.catalog_count,
state.catalog_bytes,
(
SELECT count(*)::int
FROM media_metadata
WHERE status IN ('staging', 'error')
) AS incomplete_count,
(
SELECT count(*)::int
FROM media_metadata
WHERE status = 'active'
) AS live_catalog_count,
(
SELECT COALESCE(sum(size_bytes), 0)::bigint
FROM media_metadata
WHERE status = 'active'
) AS live_catalog_bytes
FROM media_catalog_state AS state
WHERE state.singleton = true
`
const state = rows[0]
if (!state?.reconciled_at) {
return { ready: false, reason: 'reconcile-required' }
}
if (Number(state.incomplete_count || 0) > 0) {
return { ready: false, reason: 'incomplete-rows' }
}
if (!state.ready
|| Number(state.filesystem_count) !== Number(state.catalog_count)
|| Number(state.filesystem_bytes) !== Number(state.catalog_bytes)
|| Number(state.live_catalog_count) !== Number(state.catalog_count)
|| Number(state.live_catalog_bytes) !== Number(state.catalog_bytes)) {
return { ready: false, reason: 'catalog-difference' }
}
return { ready: true, reason: 'ready' }
}
/**
* DB 카탈로그 전체를 기존 관리자 미디어 항목 계약으로 조회한다.
* @returns {Promise<Array<Object>>} 미디어 항목 목록
*/
export const listMediaCatalogItems = async () => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
SELECT *
FROM media_metadata
WHERE status = 'active'
ORDER BY modified_at DESC NULLS LAST, updated_at DESC
`
return hydrateMediaCatalogRows(sql, rows)
}
/**
* 관리자 미디어 DB 페이지 조회 입력을 정규화한다.
* @param {Record<string, unknown>} query - API query
* @returns {Object} 정규화된 조회 입력
*/
const normalizePagedMediaQuery = (query) => {
const tabValue = String(query.tab || 'library')
const kindValue = String(query.kind || 'all')
const pageSizeValue = Number(query.pageSize) || 50
return {
tab: ['library', 'postThumbnails', 'postBodyThumbnails', 'thumbnails'].includes(tabValue) ? tabValue : 'library',
kind: ['all', 'image', 'video', 'audio', 'file'].includes(kindValue) ? kindValue : 'all',
pageSize: [50, 100, 200].includes(pageSizeValue) ? pageSizeValue : 50,
requestedPage: Math.max(1, Number(query.page) || 1),
search: String(query.search || '').trim().toLowerCase(),
folder: String(query.folder || '').trim(),
unusedOnly: query.unused === '1' || query.unused === 'true'
}
}
/**
* DB 페이지 조회용 탭 조건을 만든다.
* @param {ReturnType<typeof getPostgresClient>} sql - PostgreSQL 클라이언트
* @param {string} tab - 탭
* @returns {Object} SQL 조각
*/
const getCatalogTabCondition = (sql, tab) => {
if (tab === 'thumbnails') {
return sql`metadata.storage_key LIKE 'members/avatars/%'`
}
if (tab === 'postThumbnails') {
return sql`metadata.role IN ('card', 'page_cover')`
}
if (tab === 'postBodyThumbnails') {
return sql`metadata.role = 'body'`
}
return sql`metadata.storage_key NOT LIKE 'members/avatars/%' AND metadata.role NOT IN ('card', 'page_cover', 'body')`
}
/**
* DB 페이지 조회용 잠금 조건을 만든다.
* @param {ReturnType<typeof getPostgresClient>} sql - PostgreSQL 클라이언트
* @returns {Object} SQL 조각
*/
const getCatalogLockedCondition = (sql) => sql`
EXISTS (
SELECT 1
FROM media_references AS direct_reference
WHERE direct_reference.media_url = metadata.url
)
OR EXISTS (
SELECT 1
FROM users AS avatar_user
WHERE avatar_user.avatar_url = metadata.url
)
OR (
metadata.parent_id IS NOT NULL
AND EXISTS (
SELECT 1
FROM media_metadata AS parent_metadata
JOIN media_references AS parent_reference
ON parent_reference.media_url = parent_metadata.url
WHERE parent_metadata.id = metadata.parent_id
AND (
(metadata.role = 'card' AND parent_reference.reference_field = 'featuredImage')
OR (
metadata.role = 'page_cover'
AND parent_reference.reference_type = 'page'
AND parent_reference.reference_field = 'featuredImage'
)
OR (metadata.role = 'body' AND parent_reference.reference_field = 'content')
)
)
)
`
/**
* DB에서 필터·검색·페이지 slice를 수행해 기존 paged 응답 계약을 반환한다.
* counts는 SQL 집계 후 작은 객체만 메모리에서 조립한다.
* @param {Record<string, unknown>} query - API query
* @returns {Promise<Object>} paged 응답
*/
export const listPagedMediaCatalogItems = async (query) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const input = normalizePagedMediaQuery(query)
const escapedSearch = escapePostgresLikeLiteral(input.search)
const escapedFolder = escapePostgresLikeLiteral(input.folder)
const tabCondition = getCatalogTabCondition(sql, input.tab)
const libraryCondition = getCatalogTabCondition(sql, 'library')
const folderCondition = input.tab === 'library' && input.folder
? sql`
AND (
metadata.category = ${input.folder}
OR metadata.category LIKE ${`${escapedFolder}/%`} ESCAPE '!'
)
`
: sql``
const kindCondition = input.kind === 'all' ? sql`` : sql`AND metadata.kind = ${input.kind}`
const lockedCondition = getCatalogLockedCondition(sql)
const unusedCondition = input.unusedOnly ? sql`AND NOT (${lockedCondition})` : sql``
const searchCondition = input.search
? sql`
AND (
lower(metadata.file_name) LIKE ${`%${escapedSearch}%`} ESCAPE '!'
OR EXISTS (
SELECT 1
FROM media_references AS search_reference
LEFT JOIN media_metadata AS search_parent
ON search_parent.id = metadata.parent_id
WHERE lower(search_reference.reference_title) LIKE ${`%${escapedSearch}%`} ESCAPE '!'
AND (
search_reference.media_url = metadata.url
OR (
search_reference.media_url = search_parent.url
AND (
(metadata.role = 'card' AND search_reference.reference_field = 'featuredImage')
OR (
metadata.role = 'page_cover'
AND search_reference.reference_type = 'page'
AND search_reference.reference_field = 'featuredImage'
)
OR (metadata.role = 'body' AND search_reference.reference_field = 'content')
)
)
)
)
)
`
: sql``
const totalRows = await sql`
SELECT count(*)::int AS total
FROM media_metadata AS metadata
WHERE metadata.status = 'active'
AND ${tabCondition}
${folderCondition}
${kindCondition}
${unusedCondition}
${searchCondition}
`
const total = Number(totalRows[0]?.total || 0)
const pageCount = Math.max(1, Math.ceil(total / input.pageSize))
const page = Math.min(input.requestedPage, pageCount)
const offset = (page - 1) * input.pageSize
const [rows, tabCountRows, folderCountRows, kindCountRows, usageCountRows] = await Promise.all([
sql`
SELECT metadata.*
FROM media_metadata AS metadata
WHERE metadata.status = 'active'
AND ${tabCondition}
${folderCondition}
${kindCondition}
${unusedCondition}
${searchCondition}
ORDER BY metadata.modified_at DESC NULLS LAST, metadata.updated_at DESC
LIMIT ${input.pageSize}
OFFSET ${offset}
`,
sql`
SELECT
count(*) FILTER (
WHERE storage_key NOT LIKE 'members/avatars/%'
AND role NOT IN ('card', 'page_cover', 'body')
)::int AS library,
count(*) FILTER (WHERE role IN ('card', 'page_cover'))::int AS post_thumbnails,
count(*) FILTER (WHERE role = 'body')::int AS post_body_thumbnails,
count(*) FILTER (WHERE storage_key LIKE 'members/avatars/%')::int AS thumbnails
FROM media_metadata
WHERE status = 'active'
`,
sql`
SELECT
array_to_string(category_parts[1:depth], '/') AS folder,
count(*)::int AS count
FROM media_metadata AS metadata
CROSS JOIN LATERAL (
SELECT string_to_array(COALESCE(NULLIF(metadata.category, ''), '미분류'), '/') AS category_parts
) AS category_values
CROSS JOIN LATERAL generate_series(1, array_length(category_parts, 1)) AS depth
WHERE metadata.status = 'active'
AND ${libraryCondition}
GROUP BY folder
`,
sql`
SELECT metadata.kind, count(*)::int AS count
FROM media_metadata AS metadata
WHERE metadata.status = 'active'
AND ${tabCondition}
${folderCondition}
GROUP BY metadata.kind
`,
sql`
SELECT
count(*)::int AS all_count,
count(*) FILTER (WHERE NOT (${lockedCondition}))::int AS unused_count
FROM media_metadata AS metadata
WHERE metadata.status = 'active'
AND ${tabCondition}
${folderCondition}
${kindCondition}
`
])
const items = await hydrateMediaCatalogRows(sql, rows)
const folders = {}
for (const row of folderCountRows) {
folders[row.folder] = Number(row.count)
}
const kinds = { all: 0, image: 0, video: 0, audio: 0, file: 0 }
for (const row of kindCountRows) {
kinds[row.kind] = Number(row.count)
kinds.all += Number(row.count)
}
return {
items,
pagination: {
page,
pageSize: input.pageSize,
total,
pageCount
},
counts: {
tabs: {
library: Number(tabCountRows[0]?.library || 0),
postThumbnails: Number(tabCountRows[0]?.post_thumbnails || 0),
postBodyThumbnails: Number(tabCountRows[0]?.post_body_thumbnails || 0),
thumbnails: Number(tabCountRows[0]?.thumbnails || 0)
},
folders,
kinds,
usage: {
all: Number(usageCountRows[0]?.all_count || 0),
unused: Number(usageCountRows[0]?.unused_count || 0)
}
}
}
}
/**
* 업로드 루트의 지원 파일 경로를 재귀적으로 읽는다.
* @param {string} directoryPath - 스캔 디렉터리
* @returns {Promise<Array<string>>} 절대 파일 경로
*/
const scanCatalogFiles = async (directoryPath) => {
const entries = await readdir(directoryPath, { withFileTypes: true }).catch(() => [])
const nested = await Promise.all(entries.map((entry) => {
const path = join(directoryPath, entry.name)
if (entry.isDirectory()) {
return scanCatalogFiles(path)
}
return catalogFilePattern.test(entry.name) ? [path] : []
}))
return nested.flat()
}
/**
* 콘텐츠 전반의 현재 미디어 참조를 초기 백필한다.
* @returns {Promise<number>} 저장된 참조 수
*/
export const backfillMediaReferences = async () => {
const sql = getPostgresClient()
if (!sql) {
return 0
}
const [posts, pages, tags, users, settings, navigation] = await Promise.all([
sql`SELECT id, title, slug, status, content, featured_image FROM posts`,
sql`SELECT id, title, slug, status, content, featured_image FROM pages`,
sql`SELECT id, name, slug, tag_type, header_image_url FROM tags`,
sql`SELECT id, username, avatar_url FROM users WHERE avatar_url IS NOT NULL`,
sql`SELECT * FROM site_settings LIMIT 1`,
sql`SELECT id, label, url, cached_favicon_url FROM navigation_items`
])
let count = 0
for (const post of posts) {
count += await replaceMediaReferences('post', post.id, {
content: post.content,
featuredImage: post.featured_image
}, post)
}
for (const page of pages) {
count += await replaceMediaReferences('page', page.id, {
content: page.content,
featuredImage: page.featured_image
}, page)
}
for (const tag of tags) {
count += await replaceMediaReferences('tag', tag.id, {
headerImageUrl: tag.header_image_url
}, {
title: tag.name,
slug: tag.slug,
status: tag.tag_type
})
}
for (const user of users) {
count += await replaceMediaReferences('user', user.id, {
avatarUrl: user.avatar_url
}, {
title: user.username
})
}
if (settings[0]) {
count += await replaceMediaReferences('site_setting', 'site', {
logoUrl: settings[0].logo_url,
faviconUrl: settings[0].favicon_url,
homeCoverImageUrl: settings[0].home_cover_image_url,
homeCoverDarkImageUrl: settings[0].home_cover_dark_image_url
}, {
title: '사이트 설정'
})
}
for (const item of navigation) {
count += await replaceMediaReferences('navigation', item.id, {
url: item.url,
cachedFaviconUrl: item.cached_favicon_url
}, {
title: item.label,
slug: item.url,
status: 'navigation'
})
}
return count
}
/**
* 파일을 변경하지 않고 카탈로그를 실제 업로드 디렉터리와 재동기화한다.
* @returns {Promise<Object>} 비교 보고
*/
export const reconcileMediaCatalog = async () => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
await sql`
UPDATE media_catalog_state
SET
ready = false,
error_detail = NULL,
updated_at = now()
WHERE singleton = true
`
const startedAt = new Date()
const files = await scanCatalogFiles(uploadRoot)
const derivativeUrls = []
let totalBytes = 0
const kinds = { image: 0, video: 0, audio: 0, file: 0 }
for (const filePath of files) {
const storageKey = relative(uploadRoot, filePath).split(sep).join('/')
const url = `/uploads/${storageKey}`
const fileStat = await stat(filePath)
const kind = detectMediaKind(storageKey)
totalBytes += fileStat.size
kinds[kind] += 1
await upsertMediaCatalogFile(url, {
origin: 'external',
refreshState: false
})
if (detectMediaRole(storageKey).role !== 'original') {
derivativeUrls.push(url)
}
}
for (const url of derivativeUrls) {
await upsertMediaCatalogFile(url, {
origin: 'external',
refreshState: false
})
}
const missingRows = await sql`
UPDATE media_metadata
SET
status = 'missing',
missing_since = COALESCE(missing_since, now()),
updated_at = now()
WHERE status IN ('active', 'error', 'staging')
AND (last_seen_at IS NULL OR last_seen_at < ${startedAt})
RETURNING id
`
const referenceCount = await backfillMediaReferences()
const catalogRows = await sql`
SELECT
count(*) FILTER (WHERE status = 'active')::int AS active_count,
COALESCE(sum(size_bytes) FILTER (WHERE status = 'active'), 0)::bigint AS active_bytes,
count(*) FILTER (WHERE status IN ('staging', 'error'))::int AS incomplete_count
FROM media_metadata
`
const activeCount = Number(catalogRows[0]?.active_count || 0)
const activeBytes = Number(catalogRows[0]?.active_bytes || 0)
const isReady = activeCount === files.length
&& activeBytes === totalBytes
&& Number(catalogRows[0]?.incomplete_count || 0) === 0
await sql`
UPDATE media_catalog_state
SET
ready = ${isReady},
reconciled_at = now(),
filesystem_count = ${files.length},
filesystem_bytes = ${totalBytes},
catalog_count = ${activeCount},
catalog_bytes = ${activeBytes},
error_detail = ${isReady ? null : 'CATALOG_DIFFERENCE'},
updated_at = now()
WHERE singleton = true
`
return {
scanned: {
count: files.length,
bytes: totalBytes,
kinds
},
catalog: {
activeCount,
activeBytes,
markedMissing: missingRows.length,
references: referenceCount,
ready: isReady
},
difference: {
count: activeCount - files.length,
bytes: activeBytes - totalBytes
}
}
}