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