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

@@ -51,7 +51,7 @@ const signPayload = (payload) => createHmac('sha256', getSessionSecret())
/**
* 관리자 세션 토큰 생성
* @param {{ userId: string, email: string }} adminUser - 관리자 사용자 정보
* @param {{ userId: string, email: string, sessionVersion?: number }} adminUser - 관리자 사용자 정보
* @returns {string} 세션 토큰
*/
export const createAdminSessionToken = (adminUser) => {
@@ -59,6 +59,7 @@ export const createAdminSessionToken = (adminUser) => {
userId: adminUser.userId,
email: adminUser.email,
role: 'admin',
sessionVersion: Number.isInteger(adminUser.sessionVersion) ? adminUser.sessionVersion : 1,
expiresAt: Date.now() + sessionMaxAge * 1000
})).toString('base64url')
@@ -68,7 +69,7 @@ export const createAdminSessionToken = (adminUser) => {
/**
* 관리자 세션 토큰 검증
* @param {string | undefined} token - 세션 토큰
* @returns {{ userId: string, email: string, role: 'admin' } | null} 세션 정보
* @returns {{ userId: string, email: string, role: 'admin', sessionVersion: number } | null} 세션 정보
*/
export const verifyAdminSessionToken = (token) => {
if (!token) {
@@ -96,14 +97,17 @@ export const verifyAdminSessionToken = (token) => {
return {
userId: session.userId,
email: session.email,
role: 'admin'
role: 'admin',
sessionVersion: Number.isInteger(session.sessionVersion) && session.sessionVersion > 0
? session.sessionVersion
: 1
}
}
/**
* 관리자 세션 쿠키 설정
* @param {import('h3').H3Event} event - 요청 이벤트
* @param {{ userId: string, email: string }} adminUser - 관리자 사용자 정보
* @param {{ userId: string, email: string, sessionVersion?: number }} adminUser - 관리자 사용자 정보
* @returns {void}
*/
export const setAdminSession = (event, adminUser) => {
@@ -130,14 +134,14 @@ export const clearAdminSession = (event) => {
/**
* 관리자 세션 조회
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {{ userId: string, email: string, role: 'admin' } | null} 세션 정보
* @returns {{ userId: string, email: string, role: 'admin', sessionVersion: number } | null} 세션 정보
*/
export const getAdminSession = (event) => verifyAdminSessionToken(getCookie(event, adminSessionCookieName))
/**
* 관리자 세션 필수 확인
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {{ userId: string, email: string, role: 'admin' }} 세션 정보
* @returns {{ userId: string, email: string, role: 'admin', sessionVersion: number }} 세션 정보
*/
export const requireAdminSession = (event) => {
const session = getAdminSession(event)

View File

@@ -11,6 +11,7 @@ import {
recordAnalyticsHeartbeat
} from '../repositories/analytics-repository.js'
import { shouldExcludeAdminAnalytics } from './analytics-admin-exclusion.js'
import { shouldAcceptAnalyticsRequest } from './analytics-request-limit.js'
const heartbeatInputSchema = z.object({
path: z.string().trim().min(1).max(500),
@@ -36,6 +37,10 @@ export const handleAnalyticsHeartbeat = async (event) => {
})
}
if (!shouldAcceptAnalyticsRequest(event, 'heartbeat')) {
return { ok: true }
}
const body = parsedBody.data
const userAgent = String(getRequestHeader(event, 'user-agent') || '')

View File

@@ -15,6 +15,7 @@ import {
recordAnalyticsPageview
} from '../repositories/analytics-repository.js'
import { shouldExcludeAdminAnalytics } from './analytics-admin-exclusion.js'
import { shouldAcceptAnalyticsRequest } from './analytics-request-limit.js'
const pageviewInputSchema = z.object({
path: z.string().trim().min(1).max(500),
@@ -40,6 +41,10 @@ export const handleAnalyticsPageview = async (event) => {
})
}
if (!shouldAcceptAnalyticsRequest(event, 'pageview')) {
return { ok: true }
}
const body = parsedBody.data
const userAgent = String(getRequestHeader(event, 'user-agent') || '')

View File

@@ -0,0 +1,71 @@
import { createHash } from 'node:crypto'
import { getClientIp } from './request-ip'
const ANALYTICS_WINDOW_MS = 60 * 1000
const ANALYTICS_PAGEVIEW_LIMIT = 120
const ANALYTICS_HEARTBEAT_LIMIT = 240
const ANALYTICS_BUCKET_LIMIT = 20000
const analyticsBuckets = new Map()
let lastCleanupAt = 0
/**
* 통계 제한 키에 사용할 IP 해시를 생성한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @param {'pageview'|'heartbeat'} kind - 통계 요청 종류
* @returns {string} 제한 버킷 키
*/
const createAnalyticsLimitKey = (event, kind) => createHash('sha256')
.update(`${kind}:${getClientIp(event)}`)
.digest('hex')
/**
* 만료된 통계 제한 버킷을 정리한다.
* @param {number} now - 현재 시각
* @returns {void}
*/
const cleanupAnalyticsBuckets = (now) => {
if (now - lastCleanupAt < ANALYTICS_WINDOW_MS && analyticsBuckets.size < ANALYTICS_BUCKET_LIMIT) {
return
}
lastCleanupAt = now
for (const [key, bucket] of analyticsBuckets) {
if (bucket.expiresAt <= now) {
analyticsBuckets.delete(key)
}
}
while (analyticsBuckets.size >= ANALYTICS_BUCKET_LIMIT) {
const oldestKey = analyticsBuckets.keys().next().value
if (!oldestKey) {
break
}
analyticsBuckets.delete(oldestKey)
}
}
/**
* 현재 IP의 통계 쓰기 요청을 허용할지 확인한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @param {'pageview'|'heartbeat'} kind - 통계 요청 종류
* @returns {boolean} 허용 여부
*/
export const shouldAcceptAnalyticsRequest = (event, kind) => {
const now = Date.now()
const limit = kind === 'heartbeat' ? ANALYTICS_HEARTBEAT_LIMIT : ANALYTICS_PAGEVIEW_LIMIT
const key = createAnalyticsLimitKey(event, kind)
cleanupAnalyticsBuckets(now)
const current = analyticsBuckets.get(key)
if (!current || current.expiresAt <= now) {
analyticsBuckets.set(key, {
count: 1,
expiresAt: now + ANALYTICS_WINDOW_MS
})
return true
}
current.count += 1
return current.count <= limit
}

View File

@@ -1,7 +1,8 @@
import { readdir, stat } from 'node:fs/promises'
import { extname, join, relative, sep } from 'node:path'
import { getUploadRoot } from './upload-root'
const uploadRoot = join(process.cwd(), 'public', 'uploads')
const uploadRoot = getUploadRoot()
const imageExtensions = new Set(['.avif', '.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp'])
const videoExtensions = new Set(['.mp4', '.mov', '.webm'])
const audioExtensions = new Set(['.m4a', '.mp3', '.ogg', '.wav'])

View File

@@ -0,0 +1,77 @@
import { createHash } from 'node:crypto'
import { createError, setResponseHeader } from 'h3'
import { getClientIp } from './request-ip.js'
const CSP_REPORT_WINDOW_MS = 60 * 1000
const CSP_REPORT_REQUEST_LIMIT = 60
const CSP_REPORT_BUCKET_LIMIT = 10000
/** @type {Map<string, { count: number, resetAt: number }>} */
const cspReportBuckets = new Map()
let lastCleanupAt = 0
/**
* 원문 IP를 메모리에 남기지 않는 CSP 요청 제한 키를 만든다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {string} IP 해시 키
*/
const createCspReportLimitKey = (event) => createHash('sha256')
.update(`csp-report:${getClientIp(event)}`)
.digest('base64url')
/**
* 만료되거나 상한을 넘은 CSP 요청 제한 버킷을 정리한다.
* @param {number} now - 현재 시각
* @returns {void}
*/
const cleanupCspReportBuckets = (now) => {
if (now - lastCleanupAt < CSP_REPORT_WINDOW_MS && cspReportBuckets.size < CSP_REPORT_BUCKET_LIMIT) {
return
}
lastCleanupAt = now
for (const [key, bucket] of cspReportBuckets) {
if (bucket.resetAt <= now) {
cspReportBuckets.delete(key)
}
}
while (cspReportBuckets.size >= CSP_REPORT_BUCKET_LIMIT) {
const oldestKey = cspReportBuckets.keys().next().value
if (!oldestKey) {
break
}
cspReportBuckets.delete(oldestKey)
}
}
/**
* 현재 IP의 CSP 보고 요청이 분당 상한을 넘지 않았는지 확인한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {void}
*/
export const assertCspReportRequestAllowed = (event) => {
const now = Date.now()
const key = createCspReportLimitKey(event)
cleanupCspReportBuckets(now)
const current = cspReportBuckets.get(key)
if (!current || current.resetAt <= now) {
cspReportBuckets.set(key, {
count: 1,
resetAt: now + CSP_REPORT_WINDOW_MS
})
return
}
current.count += 1
if (current.count <= CSP_REPORT_REQUEST_LIMIT) {
return
}
setResponseHeader(event, 'Retry-After', String(Math.max(1, Math.ceil((current.resetAt - now) / 1000))))
throw createError({
statusCode: 429,
message: 'CSP 보고 요청이 너무 많습니다.'
})
}

View File

@@ -0,0 +1,135 @@
import { createHash } from 'node:crypto'
import { createError, setResponseHeader } from 'h3'
const LOGIN_ATTEMPT_WINDOW_MS = 15 * 60 * 1000
const LOGIN_ACCOUNT_FAILURE_LIMIT = 5
const LOGIN_IP_FAILURE_LIMIT = 20
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000
const MAX_ATTEMPT_BUCKET_COUNT = 10000
/** @type {Map<string, { count: number, resetAt: number }>} */
const attemptBuckets = new Map()
let lastCleanupAt = 0
/**
* 로그인 제한 키에 개인정보 원문이 남지 않도록 해시한다.
* @param {string} value - 원본 키
* @returns {string} 해시 키
*/
const createAttemptKey = (value) => createHash('sha256')
.update(String(value || ''))
.digest('base64url')
/**
* 만료된 로그인 제한 버킷을 정리한다.
* @param {number} now - 현재 시각
* @returns {void}
*/
const cleanupExpiredBuckets = (now) => {
if (now - lastCleanupAt < CLEANUP_INTERVAL_MS) {
return
}
lastCleanupAt = now
for (const [key, bucket] of attemptBuckets.entries()) {
if (bucket.resetAt <= now) {
attemptBuckets.delete(key)
}
}
}
/**
* 현재 로그인 제한 버킷을 조회한다.
* @param {string} key - 버킷 키
* @param {number} now - 현재 시각
* @returns {{ count: number, resetAt: number }} 버킷
*/
const getAttemptBucket = (key, now) => {
const current = attemptBuckets.get(key)
if (current && current.resetAt > now) {
return current
}
if (attemptBuckets.size >= MAX_ATTEMPT_BUCKET_COUNT) {
const oldestKey = attemptBuckets.keys().next().value
if (oldestKey) {
attemptBuckets.delete(oldestKey)
}
}
const next = {
count: 0,
resetAt: now + LOGIN_ATTEMPT_WINDOW_MS
}
attemptBuckets.set(key, next)
return next
}
/**
* 로그인 시도 제한용 계정·IP 키를 만든다.
* @param {{ scope: string, email: string, ip: string }} input - 로그인 요청 식별값
* @returns {{ accountKey: string, ipKey: string }} 제한 키
*/
const createLoginAttemptKeys = (input) => ({
accountKey: `account:${createAttemptKey(`${input.scope}:${String(input.email || '').trim().toLowerCase()}`)}`,
ipKey: `ip:${createAttemptKey(`${input.scope}:${String(input.ip || '').trim()}`)}`
})
/**
* 로그인 요청이 현재 제한을 넘지 않았는지 확인한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @param {{ scope: string, email: string, ip: string }} input - 로그인 요청 식별값
* @returns {void}
*/
export const assertLoginAttemptAllowed = (event, input) => {
const now = Date.now()
cleanupExpiredBuckets(now)
const keys = createLoginAttemptKeys(input)
const accountBucket = attemptBuckets.get(keys.accountKey)
const ipBucket = attemptBuckets.get(keys.ipKey)
const accountBlocked = Boolean(accountBucket && accountBucket.resetAt > now && accountBucket.count >= LOGIN_ACCOUNT_FAILURE_LIMIT)
const ipBlocked = Boolean(ipBucket && ipBucket.resetAt > now && ipBucket.count >= LOGIN_IP_FAILURE_LIMIT)
if (!accountBlocked && !ipBlocked) {
return
}
const retryAt = Math.max(
accountBlocked ? accountBucket.resetAt : 0,
ipBlocked ? ipBucket.resetAt : 0
)
const retryAfter = Math.max(1, Math.ceil((retryAt - now) / 1000))
setResponseHeader(event, 'Retry-After', String(retryAfter))
throw createError({
statusCode: 429,
message: '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.'
})
}
/**
* 실패한 로그인 시도를 계정과 IP 기준으로 기록한다.
* @param {{ scope: string, email: string, ip: string }} input - 로그인 요청 식별값
* @returns {void}
*/
export const recordLoginFailure = (input) => {
const now = Date.now()
cleanupExpiredBuckets(now)
const keys = createLoginAttemptKeys(input)
for (const key of [keys.accountKey, keys.ipKey]) {
const bucket = getAttemptBucket(key, now)
bucket.count += 1
}
}
/**
* 로그인에 성공한 계정의 실패 기록을 제거한다.
* @param {{ scope: string, email: string, ip: string }} input - 로그인 요청 식별값
* @returns {void}
*/
export const clearLoginAccountFailures = (input) => {
const keys = createLoginAttemptKeys(input)
attemptBuckets.delete(keys.accountKey)
}

View File

@@ -0,0 +1,22 @@
/**
* 미디어 카탈로그 조회 모드를 환경 변수로 해석한다.
*/
/**
* 카탈로그 모드 문자열을 허용 값으로 정규화한다.
* @param {unknown} value - 원본 모드
* @returns {'filesystem'|'shadow'|'database'|null} 정규화된 모드
*/
export const normalizeMediaCatalogMode = (value) => {
const mode = String(value || '').trim().toLowerCase()
return ['filesystem', 'shadow', 'database'].includes(mode) ? mode : null
}
/**
* 현재 적용 중인 미디어 카탈로그 모드를 반환한다.
* @returns {'filesystem'|'shadow'|'database'} 조회 모드
*/
export const getMediaCatalogMode = () => {
return normalizeMediaCatalogMode(process.env.MEDIA_CATALOG_MODE) || 'filesystem'
}

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)
}

View File

@@ -1,5 +1,6 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createError, deleteCookie, getCookie, setCookie } from 'h3'
import { isMemberSessionCurrent } from '../repositories/member-repository'
import { getRuntimeEnvValue } from './runtime-env'
import { shouldUseSecureSessionCookie } from './session-cookie'
@@ -51,13 +52,14 @@ const signPayload = (payload) => createHmac('sha256', getSessionSecret())
/**
* 회원 세션 토큰 생성
* @param {{ userId: string, email: string }} user - 회원 정보
* @param {{ userId: string, email: string, sessionVersion?: number }} user - 회원 정보
* @returns {string} 세션 토큰
*/
export const createMemberSessionToken = (user) => {
const payload = Buffer.from(JSON.stringify({
userId: user.userId,
email: user.email,
sessionVersion: Number.isInteger(user.sessionVersion) ? user.sessionVersion : 1,
expiresAt: Date.now() + sessionMaxAge * 1000
})).toString('base64url')
@@ -67,7 +69,7 @@ export const createMemberSessionToken = (user) => {
/**
* 회원 세션 토큰 검증
* @param {string | undefined} token - 세션 토큰
* @returns {{ userId: string, email: string } | null} 세션 정보
* @returns {{ userId: string, email: string, sessionVersion: number } | null} 세션 정보
*/
export const verifyMemberSessionToken = (token) => {
if (!token) {
@@ -94,14 +96,17 @@ export const verifyMemberSessionToken = (token) => {
return {
userId: session.userId,
email: session.email
email: session.email,
sessionVersion: Number.isInteger(session.sessionVersion) && session.sessionVersion > 0
? session.sessionVersion
: 1
}
}
/**
* 회원 세션 쿠키 설정
* @param {import('h3').H3Event} event - 요청 이벤트
* @param {{ userId: string, email: string }} user - 회원 정보
* @param {{ userId: string, email: string, sessionVersion?: number }} user - 회원 정보
* @returns {void}
*/
export const setMemberSession = (event, user) => {
@@ -128,17 +133,38 @@ export const clearMemberSession = (event) => {
/**
* 회원 세션 조회
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {{ userId: string, email: string } | null} 세션 정보
* @returns {{ userId: string, email: string, sessionVersion: number } | null} 세션 정보
*/
export const getMemberSession = (event) => verifyMemberSessionToken(getCookie(event, memberSessionCookieName))
/**
* DB 세션 버전까지 확인한 회원 세션을 조회한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ userId: string, email: string, sessionVersion: number } | null>} 현재 세션 정보
*/
export const getVerifiedMemberSession = async (event) => {
const session = getMemberSession(event)
if (!session) {
return null
}
const isCurrent = await isMemberSessionCurrent(session.userId, session.sessionVersion)
if (!isCurrent) {
clearMemberSession(event)
return null
}
return session
}
/**
* 회원 세션 필수 확인
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {{ userId: string, email: string }} 세션 정보
* @returns {Promise<{ userId: string, email: string, sessionVersion: number }>} 세션 정보
*/
export const requireMemberSession = (event) => {
const session = getMemberSession(event)
export const requireMemberSession = async (event) => {
const session = await getVerifiedMemberSession(event)
if (!session) {
throw createError({

View File

@@ -2,6 +2,7 @@ import { mkdir, stat, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { createError, readMultipartFormData } from 'h3'
import sharp from 'sharp'
import { getConfiguredUploadRoot } from './upload-root'
const allowedImageTypes = new Map([
['image/jpeg', '.jpg'],
@@ -134,7 +135,7 @@ export const uploadMemberAvatarImage = async (event) => {
const month = String(now.getMonth() + 1).padStart(2, '0')
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'members', 'avatars', year, month)
const directoryPath = join(getConfiguredUploadRoot(publicBasePath), 'members', 'avatars', year, month)
await mkdir(directoryPath, { recursive: true })

View File

@@ -1,6 +1,8 @@
import { join, relative } from 'node:path'
import { createError } from 'h3'
import { recordMediaCatalogFile } from '../repositories/media-catalog-repository'
import { getPostgresClient } from '../repositories/postgres-client'
import { getUploadRoot } from './upload-root'
const managedAvatarPrefix = '/uploads/members/avatars/'
@@ -25,8 +27,8 @@ export const resolveMemberAvatarPath = (url) => {
}
const decodedUrl = decodeURIComponent(url)
const filePath = join(process.cwd(), 'public', decodedUrl)
const uploadRoot = join(process.cwd(), 'public', 'uploads')
const uploadRoot = getUploadRoot()
const filePath = join(uploadRoot, decodedUrl.replace(/^\/uploads\//, ''))
const relativePath = relative(uploadRoot, filePath)
if (relativePath.startsWith('..') || relativePath === '') {
@@ -40,7 +42,8 @@ export const resolveMemberAvatarPath = (url) => {
}
/**
* 프로필에서 썸네일 URL을 끊을 때 `media_metadata` 행만 제거한다. 디스크 파일은 유지해 관리자 미디어(썸네일 탭)에서 미사용 자산으로 확인·삭제할 수 있게 한다.
* 프로필에서 연결을 끊어도 디스크와 카탈로그 행은 유지한다.
* 참조가 제거된 파일은 관리자 미디어의 미사용 썸네일로 계속 확인할 수 있다.
* @param {string} url - 정리 대상 URL
* @returns {Promise<void>}
*/
@@ -50,13 +53,19 @@ export const removeManagedAvatarAsset = async (url) => {
}
const sql = getPostgresClient()
if (!sql) {
return
if (sql) {
await sql`
DELETE FROM media_references
WHERE media_url = ${url}
AND reference_type = 'user'
`
}
await sql`
DELETE FROM media_metadata
WHERE url = ${url}
`
await recordMediaCatalogFile(url, {
category: '썸네일',
origin: 'upload',
role: 'avatar'
})
}

View File

@@ -0,0 +1,275 @@
import { createWriteStream } from 'node:fs'
import { Transform } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import Busboy from 'busboy'
/**
* 코드가 포함된 multipart 오류를 만든다.
* @param {string} code - 오류 코드
* @returns {Error & { code: string }} multipart 오류
*/
const createMultipartError = (code) => {
const error = new Error(code)
error.code = code
return error
}
/**
* busboy가 UTF-8 파일명 바이트를 Latin-1 문자열로 넘긴 경우를 복구한다.
* 이미 유니코드 코드포인트로 해석된 파일명은 그대로 둔다.
* @param {string|null|undefined} filename - multipart 파일명
* @returns {string} NFC 정규화된 파일명
*/
export const decodeMultipartFilename = (filename) => {
const value = String(filename || '')
if (!value) {
return ''
}
const hasNonLatin1 = [...value].some((char) => Number(char.codePointAt(0)) > 255)
if (hasNonLatin1) {
return value.normalize('NFC')
}
const repaired = Buffer.from(value, 'latin1').toString('utf8')
if (!repaired.includes('\uFFFD')) {
return repaired.normalize('NFC')
}
return value.normalize('NFC')
}
/**
* multipart 파일 하나를 메모리에 올리지 않고 디스크로 저장한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @param {{ fieldName: string, filePath: string, maxBytes: number }} input - 저장 옵션
* @returns {Promise<{ filename: string, mimeType: string, size: number }>} 저장 결과
*/
export const saveMultipartFileToDisk = async (event, input) => {
return new Promise((resolve, reject) => {
let parser = null
try {
parser = Busboy({
headers: event.node.req.headers,
limits: {
files: 1,
fields: 4,
parts: 5,
fileSize: input.maxBytes
}
})
} catch {
reject(createMultipartError('INVALID_MULTIPART_REQUEST'))
return
}
let matched = false
let truncated = false
let filename = ''
let mimeType = ''
let size = 0
/** @type {Promise<void>|null} */
let fileWritePromise = null
parser.on('file', (fieldName, fileStream, info) => {
if (fieldName !== input.fieldName || matched) {
fileStream.resume()
return
}
matched = true
filename = decodeMultipartFilename(info.filename)
mimeType = String(info.mimeType || '')
const output = createWriteStream(input.filePath, {
flags: 'wx'
})
fileStream.on('data', (chunk) => {
size += chunk.length
})
fileStream.on('limit', () => {
truncated = true
})
fileWritePromise = pipeline(fileStream, output)
})
parser.on('filesLimit', () => {
truncated = true
})
parser.on('error', async (error) => {
if (fileWritePromise) {
await fileWritePromise.catch(() => {})
}
reject(error)
})
parser.on('finish', async () => {
try {
if (fileWritePromise) {
await fileWritePromise
}
if (!matched) {
reject(createMultipartError('MULTIPART_FILE_NOT_FOUND'))
return
}
if (truncated) {
reject(createMultipartError('MULTIPART_FILE_TOO_LARGE'))
return
}
resolve({
filename,
mimeType,
size
})
} catch (error) {
reject(error)
}
})
event.node.req.on('aborted', () => {
parser.destroy(createMultipartError('MULTIPART_REQUEST_ABORTED'))
})
event.node.req.pipe(parser)
})
}
/**
* multipart 파일 여러 개를 메모리에 올리지 않고 디스크로 저장한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @param {{
* fieldName: string,
* maxFiles: number,
* maxTotalBytes: number,
* createFileTarget: (file: { filename: string, mimeType: string }, index: number) => Promise<{ filePath: string, maxBytes: number, metadata?: Object }>
* }} input - 저장 옵션
* @returns {Promise<Array<{ filename: string, mimeType: string, size: number, filePath: string, metadata: Object }>>} 저장 결과
*/
export const saveMultipartFilesToDisk = async (event, input) => {
return new Promise((resolve, reject) => {
let parser = null
try {
parser = Busboy({
headers: event.node.req.headers,
limits: {
files: input.maxFiles,
fields: 4,
parts: input.maxFiles + 4,
fileSize: input.maxTotalBytes
}
})
} catch {
reject(createMultipartError('INVALID_MULTIPART_REQUEST'))
return
}
let matchedFileCount = 0
let totalSize = 0
let limitError = null
let taskError = null
const savedFiles = []
const fileTasks = []
parser.on('file', (fieldName, fileStream, info) => {
if (fieldName !== input.fieldName) {
fileStream.resume()
return
}
const fileIndex = matchedFileCount
matchedFileCount += 1
const filename = decodeMultipartFilename(info.filename)
const mimeType = String(info.mimeType || '')
const fileTask = (async () => {
try {
const target = await input.createFileTarget({ filename, mimeType }, fileIndex)
let fileSize = 0
const sizeLimiter = new Transform({
transform(chunk, _encoding, callback) {
fileSize += chunk.length
totalSize += chunk.length
if (!limitError && fileSize > target.maxBytes) {
limitError = createMultipartError('MULTIPART_FILE_TOO_LARGE')
limitError.maxBytes = target.maxBytes
limitError.metadata = target.metadata || {}
}
if (!limitError && totalSize > input.maxTotalBytes) {
limitError = createMultipartError('MULTIPART_TOTAL_SIZE_TOO_LARGE')
}
callback(null, limitError ? null : chunk)
}
})
fileStream.on('limit', () => {
limitError = limitError || createMultipartError('MULTIPART_TOTAL_SIZE_TOO_LARGE')
})
await pipeline(
fileStream,
sizeLimiter,
createWriteStream(target.filePath, { flags: 'wx' })
)
savedFiles.push({
filename,
mimeType,
size: fileSize,
filePath: target.filePath,
metadata: target.metadata || {},
index: fileIndex
})
} catch (error) {
taskError = taskError || error
fileStream.resume()
}
})()
fileTasks.push(fileTask)
})
parser.on('filesLimit', () => {
limitError = limitError || createMultipartError('MULTIPART_TOO_MANY_FILES')
})
parser.on('partsLimit', () => {
limitError = limitError || createMultipartError('MULTIPART_TOO_MANY_PARTS')
})
parser.on('error', async (error) => {
await Promise.allSettled(fileTasks)
reject(error)
})
parser.on('finish', async () => {
await Promise.all(fileTasks)
if (taskError) {
reject(taskError)
return
}
if (limitError) {
reject(limitError)
return
}
if (!matchedFileCount) {
reject(createMultipartError('MULTIPART_FILE_NOT_FOUND'))
return
}
resolve(savedFiles
.sort((left, right) => left.index - right.index)
.map(({ index: _index, ...file }) => file))
})
event.node.req.on('aborted', () => {
parser.destroy(createMultipartError('MULTIPART_REQUEST_ABORTED'))
})
event.node.req.pipe(parser)
})
}

View File

@@ -13,6 +13,8 @@ import {
isPostBodyThumbnailUrl,
isPostCardThumbnailUrl
} from '../../lib/post-card-thumbnail-url.js'
import { recordMediaCatalogFile } from '../repositories/media-catalog-repository'
import { resolveUploadUrlPath } from './upload-root'
export {
getPageCoverThumbnailUrl,
@@ -183,7 +185,7 @@ export const getPostThumbnailDiskPath = (imageUrl) => {
return ''
}
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
return resolveUploadUrlPath(decodeUrlPathPart(thumbnailUrl))
}
/**
@@ -198,7 +200,7 @@ export const getPostBodyThumbnailDiskPath = (imageUrl) => {
return ''
}
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
return resolveUploadUrlPath(decodeUrlPathPart(thumbnailUrl))
}
/**
@@ -213,7 +215,7 @@ export const getPostBodyPreviewThumbnailDiskPath = (imageUrl) => {
return ''
}
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
return resolveUploadUrlPath(decodeUrlPathPart(thumbnailUrl))
}
/**
@@ -228,7 +230,7 @@ export const getPostLightboxThumbnailDiskPath = (imageUrl) => {
return ''
}
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
return resolveUploadUrlPath(decodeUrlPathPart(thumbnailUrl))
}
/**
@@ -243,7 +245,7 @@ export const getPageCoverThumbnailDiskPath = (imageUrl) => {
return ''
}
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
return resolveUploadUrlPath(decodeUrlPathPart(thumbnailUrl))
}
/**
@@ -256,7 +258,7 @@ export const getPostImageDiskPath = (imageUrl) => {
return ''
}
return join(process.cwd(), 'public', decodeUrlPathPart(imageUrl.replace(/^\/+/, '')))
return resolveUploadUrlPath(decodeUrlPathPart(imageUrl))
}
/**
@@ -384,6 +386,13 @@ export const createPostThumbnailForImageUrl = async (imageUrl) => {
.webp({ quality: POST_THUMBNAIL_QUALITY })
.toFile(temporaryPath))
if (created) {
await recordMediaCatalogFile(thumbnailUrl, {
origin: 'derived',
role: 'card'
})
}
return created ? thumbnailUrl : ''
}
@@ -419,6 +428,13 @@ export const createPageCoverThumbnailForImageUrl = async (imageUrl) => {
.webp({ quality: PAGE_COVER_THUMBNAIL_QUALITY })
.toFile(temporaryPath))
if (created) {
await recordMediaCatalogFile(thumbnailUrl, {
origin: 'derived',
role: 'page_cover'
})
}
return created ? thumbnailUrl : ''
}
@@ -481,6 +497,21 @@ export const createPostBodyThumbnailForImageUrl = async (imageUrl) => {
return ''
}
await Promise.all([
recordMediaCatalogFile(thumbnailUrl, {
origin: 'derived',
role: 'body'
}),
recordMediaCatalogFile(getPostBodyPreviewThumbnailUrl(imageUrl), {
origin: 'derived',
role: 'body'
}),
recordMediaCatalogFile(getPostLightboxThumbnailUrl(imageUrl), {
origin: 'derived',
role: 'body'
})
])
return thumbnailUrl
}

View File

@@ -9,6 +9,7 @@ import {
updateNavigationItemFaviconCache
} from '../repositories/content-repository.js'
import { upsertMediaMetadataCategory } from './media-library.js'
import { getConfiguredUploadRoot } from './upload-root'
const FETCH_TIMEOUT_MS = 15000
const FAVICON_OUTPUT_SIZE = 64
@@ -89,7 +90,7 @@ const fetchFaviconBytes = async (sourceUrl) => {
const writeRecommendedFaviconFile = async (itemId, bytes) => {
const uploadBaseUrl = getUploadBaseUrl()
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
const directoryPath = join(getConfiguredUploadRoot(publicBasePath), 'system')
const shortId = String(itemId).replace(/-/g, '').slice(0, 8)
const fileName = `recommended-favicon-${shortId}-${createFaviconAssetSuffix()}.png`
const filePath = join(directoryPath, fileName)

View File

@@ -118,7 +118,11 @@ const buildRssItem = (post, siteUrl) => {
export const buildRssFeed = async (selfPath = '/rss.xml', fallbackSiteUrl = '') => {
const [settings, posts] = await Promise.all([
getSiteSettings(),
listPosts({ includeMembership: false })
listPosts({
includeMembership: false,
includeContent: true,
limit: RSS_POST_LIMIT
})
])
const siteUrl = normalizeSiteUrl(settings.siteUrl || fallbackSiteUrl)
const feedUrl = `${siteUrl}${selfPath}`

View File

@@ -0,0 +1,70 @@
import { getRequestURL, setResponseHeader } from 'h3'
const enforcedContentSecurityPolicy = [
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'self'"
].join('; ')
const contentSecurityPolicyReportOnly = [
"default-src 'self' https: data: blob:",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https:",
"style-src 'self' 'unsafe-inline' https:",
"img-src 'self' data: blob: https:",
"font-src 'self' data: https:",
"media-src 'self' blob: https:",
"frame-src https:",
"connect-src 'self' https: wss:",
"worker-src 'self' blob: https:",
"manifest-src 'self' https:",
'report-uri /api/csp-report',
'report-to csp-endpoint'
].join('; ')
/**
* 현재 요청과 같은 origin의 CSP 보고 URL을 안전하게 만든다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {string} CSP 보고 URL
*/
const getCspReportEndpoint = (event) => {
try {
const requestUrl = getRequestURL(event)
if (!['http:', 'https:'].includes(requestUrl.protocol)) {
return '/api/csp-report'
}
const endpoint = new URL('/api/csp-report', requestUrl.origin).toString()
return /["\\\r\n]/.test(endpoint) ? '/api/csp-report' : endpoint
} catch {
return '/api/csp-report'
}
}
/**
* 공통 보안 헤더와 호환성 관찰 CSP를 적용한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @param {{ enforceCsp?: boolean }} options - 강제 CSP 적용 옵션
* @returns {void}
*/
export const applySecurityHeaders = (event, options = {}) => {
const reportEndpoint = getCspReportEndpoint(event)
setResponseHeader(event, 'X-Content-Type-Options', 'nosniff')
setResponseHeader(event, 'Referrer-Policy', 'strict-origin-when-cross-origin')
setResponseHeader(event, 'X-Frame-Options', 'SAMEORIGIN')
setResponseHeader(event, 'Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
setResponseHeader(event, 'Reporting-Endpoints', `csp-endpoint="${reportEndpoint}"`)
setResponseHeader(event, 'Report-To', JSON.stringify({
group: 'csp-endpoint',
max_age: 10886400,
endpoints: [{ url: reportEndpoint }]
}))
setResponseHeader(event, 'Content-Security-Policy-Report-Only', contentSecurityPolicyReportOnly)
if (options.enforceCsp !== false) {
setResponseHeader(event, 'Content-Security-Policy', enforcedContentSecurityPolicy)
}
}

View File

@@ -68,7 +68,10 @@ const buildUrlItem = ({ loc, lastmod = '', changefreq = '', priority = '' }) =>
export const buildSitemap = async (fallbackSiteUrl = '') => {
const [settings, posts, pages, tags] = await Promise.all([
getSiteSettings(),
listPosts({ includeMembership: false }),
listPosts({
includeMembership: false,
limit: 50000
}),
listPages(),
listTags()
])

View File

@@ -0,0 +1,45 @@
import { resolve } from 'node:path'
/**
* 런타임 업로드 파일 루트를 반환한다.
* Playwright 실행은 명시된 임시 디렉터리만 사용한다.
* @returns {string} 업로드 파일 절대 경로
*/
export const getUploadRoot = () => {
const testUploadRoot = String(process.env.SORI_TEST_UPLOAD_ROOT || '').trim()
if (process.env.PLAYWRIGHT_TEST === '1') {
if (!testUploadRoot) {
throw new Error('SORI_TEST_UPLOAD_ROOT_REQUIRED')
}
return resolve(testUploadRoot)
}
return resolve(process.cwd(), 'public', 'uploads')
}
/**
* 설정된 공개 업로드 경로의 디스크 루트를 반환한다.
* @param {string} publicBasePath - `uploads` 형태 공개 경로
* @returns {string} 업로드 디렉터리 절대 경로
*/
export const getConfiguredUploadRoot = (publicBasePath = 'uploads') => {
if (process.env.PLAYWRIGHT_TEST === '1') {
return getUploadRoot()
}
return resolve(process.cwd(), 'public', publicBasePath)
}
/**
* `/uploads/...` URL을 현재 실행 환경의 파일 경로로 변환한다.
* @param {string} uploadUrl - 업로드 공개 URL
* @returns {string} 파일 절대 경로
*/
export const resolveUploadUrlPath = (uploadUrl) => {
const decodedPath = decodeURIComponent(String(uploadUrl || ''))
.replace(/^\/+uploads\/?/, '')
return resolve(getUploadRoot(), decodedPath)
}

View File

@@ -1,4 +1,9 @@
import { inflateRawSync } from 'node:zlib'
import unzipper from 'unzipper'
const DEFAULT_MAX_ENTRY_COUNT = 5000
const DEFAULT_MAX_ENTRY_BYTES = 300 * 1024 * 1024
const DEFAULT_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024
const DEFAULT_MAX_COMPRESSION_RATIO = 250
/**
* ZIP 엔트리 경로를 안전하게 정리한다.
@@ -13,111 +18,116 @@ const normalizeZipEntryPath = (value) => String(value || '')
.join('/')
/**
* ZIP 종료 레코드 위치를 찾는다.
* @param {Buffer} buffer - ZIP 파일 버퍼
* @returns {number} 종료 레코드 위치
* ZIP 내부 경로가 상위 폴더나 절대 경로를 가리키는지 확인한다.
* @param {string} value - 원본 ZIP 경로
* @returns {boolean} 안전하지 않은 경로 여부
*/
const findEndOfCentralDirectoryOffset = (buffer) => {
const signature = 0x06054b50
const minOffset = Math.max(0, buffer.length - 65557)
const isUnsafeZipEntryPath = (value) => {
const normalized = String(value || '').replace(/\\/g, '/')
return normalized.startsWith('/')
|| normalized.includes('\0')
|| normalized.split('/').some((part) => part === '..')
}
for (let offset = buffer.length - 22; offset >= minOffset; offset -= 1) {
if (buffer.readUInt32LE(offset) === signature) {
return offset
/**
* ZIP 엔트리 스트림을 제한된 크기의 Buffer로 읽는다.
* @param {NodeJS.ReadableStream} stream - 엔트리 스트림
* @param {number} maxBytes - 최대 허용 바이트
* @returns {Promise<Buffer>} 엔트리 데이터
*/
const readStreamBuffer = async (stream, maxBytes) => {
const chunks = []
let totalBytes = 0
for await (const chunk of stream) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
totalBytes += buffer.length
if (totalBytes > maxBytes) {
stream.destroy()
throw new Error('ZIP_ENTRY_SIZE_LIMIT_EXCEEDED')
}
chunks.push(buffer)
}
return -1
return Buffer.concat(chunks, totalBytes)
}
/**
* ZIP 엔트리 데이터를 압축 해제한다.
* @param {Buffer} buffer - 전체 ZIP 버퍼
* @param {Object} entry - 중앙 디렉터리 엔트리
* @returns {Buffer} 압축 해제된 데이터
* ZIP 중앙 디렉터리를 읽고 엔트리 수·크기·압축률을 검증한다.
* 실제 파일 데이터는 필요할 때 각 엔트리 스트림으로 읽는다.
* @param {string} zipFilePath - ZIP 파일 절대 경로
* @param {{ maxEntryCount?: number, maxEntryBytes?: number, maxTotalBytes?: number, maxCompressionRatio?: number }} [options] - 제한 옵션
* @returns {Promise<Array<{ path: string, compressedSize: number, uncompressedSize: number, readBuffer: (maxBytes?: number) => Promise<Buffer>, createReadStream: () => NodeJS.ReadableStream }>>} ZIP 엔트리 목록
*/
const readZipEntryData = (buffer, entry) => {
const localHeaderOffset = entry.localHeaderOffset
export const openZipFileEntries = async (zipFilePath, options = {}) => {
const maxEntryCount = Number(options.maxEntryCount) || DEFAULT_MAX_ENTRY_COUNT
const maxEntryBytes = Number(options.maxEntryBytes) || DEFAULT_MAX_ENTRY_BYTES
const maxTotalBytes = Number(options.maxTotalBytes) || DEFAULT_MAX_TOTAL_BYTES
const maxCompressionRatio = Number(options.maxCompressionRatio) || DEFAULT_MAX_COMPRESSION_RATIO
let directory = null
if (buffer.readUInt32LE(localHeaderOffset) !== 0x04034b50) {
throw new Error('INVALID_ZIP_LOCAL_HEADER')
}
const localNameLength = buffer.readUInt16LE(localHeaderOffset + 26)
const localExtraLength = buffer.readUInt16LE(localHeaderOffset + 28)
const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength
const compressedData = buffer.subarray(dataOffset, dataOffset + entry.compressedSize)
if (entry.compressionMethod === 0) {
return Buffer.from(compressedData)
}
if (entry.compressionMethod === 8) {
return inflateRawSync(compressedData)
}
throw new Error('UNSUPPORTED_ZIP_COMPRESSION')
}
/**
* ZIP 파일 버퍼를 파일 엔트리 목록으로 읽는다.
* @param {Buffer} buffer - ZIP 파일 버퍼
* @returns {Array<{ path: string, data: Buffer }>} 파일 엔트리 목록
*/
export const readZipBufferEntries = (buffer) => {
if (!Buffer.isBuffer(buffer) || buffer.length < 22) {
try {
directory = await unzipper.Open.file(zipFilePath)
} catch {
throw new Error('INVALID_ZIP_FILE')
}
const endOffset = findEndOfCentralDirectoryOffset(buffer)
const sourceEntries = directory.files.filter((entry) => entry.type !== 'Directory')
if (endOffset < 0) {
throw new Error('INVALID_ZIP_FILE')
}
const entryCount = buffer.readUInt16LE(endOffset + 10)
const centralDirectorySize = buffer.readUInt32LE(endOffset + 12)
const centralDirectoryOffset = buffer.readUInt32LE(endOffset + 16)
const centralDirectoryEnd = centralDirectoryOffset + centralDirectorySize
if (centralDirectoryOffset < 0 || centralDirectoryEnd > buffer.length) {
throw new Error('INVALID_ZIP_FILE')
if (sourceEntries.length > maxEntryCount) {
throw new Error('ZIP_ENTRY_COUNT_LIMIT_EXCEEDED')
}
const entries = []
let offset = centralDirectoryOffset
const usedPaths = new Set()
let totalUncompressedBytes = 0
for (let index = 0; index < entryCount; index += 1) {
if (buffer.readUInt32LE(offset) !== 0x02014b50) {
throw new Error('INVALID_ZIP_CENTRAL_DIRECTORY')
for (const sourceEntry of sourceEntries) {
const rawPath = String(sourceEntry.path || '')
if (isUnsafeZipEntryPath(rawPath)) {
throw new Error('UNSAFE_ZIP_ENTRY_PATH')
}
const compressionMethod = buffer.readUInt16LE(offset + 10)
const compressedSize = buffer.readUInt32LE(offset + 20)
const uncompressedSize = buffer.readUInt32LE(offset + 24)
const nameLength = buffer.readUInt16LE(offset + 28)
const extraLength = buffer.readUInt16LE(offset + 30)
const commentLength = buffer.readUInt16LE(offset + 32)
const localHeaderOffset = buffer.readUInt32LE(offset + 42)
const rawPath = buffer.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')
const normalizedPath = normalizeZipEntryPath(rawPath)
const isDirectory = rawPath.replace(/\\/g, '/').endsWith('/')
if (normalizedPath && !isDirectory && !normalizedPath.startsWith('__MACOSX/')) {
entries.push({
path: normalizedPath,
compressionMethod,
compressedSize,
uncompressedSize,
localHeaderOffset
})
if (!normalizedPath || normalizedPath.startsWith('__MACOSX/')) {
continue
}
offset += 46 + nameLength + extraLength + commentLength
if (usedPaths.has(normalizedPath)) {
throw new Error('DUPLICATE_ZIP_ENTRY_PATH')
}
usedPaths.add(normalizedPath)
const compressedSize = Number(sourceEntry.compressedSize || 0)
const uncompressedSize = Number(sourceEntry.uncompressedSize || 0)
const compressionRatio = uncompressedSize / Math.max(compressedSize, 1)
if (uncompressedSize < 0 || uncompressedSize > maxEntryBytes) {
throw new Error('ZIP_ENTRY_SIZE_LIMIT_EXCEEDED')
}
if (uncompressedSize > 1024 * 1024 && compressionRatio > maxCompressionRatio) {
throw new Error('ZIP_COMPRESSION_RATIO_LIMIT_EXCEEDED')
}
totalUncompressedBytes += uncompressedSize
if (totalUncompressedBytes > maxTotalBytes) {
throw new Error('ZIP_TOTAL_SIZE_LIMIT_EXCEEDED')
}
entries.push({
path: normalizedPath,
compressedSize,
uncompressedSize,
readBuffer: (readMaxBytes = maxEntryBytes) => readStreamBuffer(
sourceEntry.stream(),
Math.min(readMaxBytes, maxEntryBytes)
),
createReadStream: () => sourceEntry.stream()
})
}
return entries.map((entry) => ({
path: entry.path,
data: readZipEntryData(buffer, entry)
}))
return entries
}

View File

@@ -1,167 +1,95 @@
import { deflateRawSync } from 'node:zlib'
const crcTable = Array.from({ length: 256 }, (_, index) => {
let value = index
for (let bit = 0; bit < 8; bit += 1) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1
}
return value >>> 0
})
import { createWriteStream } from 'node:fs'
import { rm } from 'node:fs/promises'
import { ZipArchive } from 'archiver'
/**
* CRC32 값을 계산한다.
* @param {Buffer} input - 원본 데이터
* @returns {number} CRC32
*/
const calculateCrc32 = (input) => {
let crc = 0xffffffff
for (const byte of input) {
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8)
}
return (crc ^ 0xffffffff) >>> 0
}
/**
* ZIP DOS 날짜/시간 값을 만든다.
* @param {Date} date - 기준 날짜
* @returns {{ dosDate: number, dosTime: number }} DOS 날짜/시간
*/
const createDosDateTime = (date) => {
const year = Math.max(date.getFullYear(), 1980)
return {
dosTime: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
dosDate: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate()
}
}
/**
* ZIP 엔트리 경로를 정리한다.
* ZIP 엔트리 경로를 안전하게 정리한다.
* @param {string} pathValue - 엔트리 경로
* @returns {string} 정리된 경로
*/
const normalizeZipPath = (pathValue) => String(pathValue || '')
.replace(/\\/g, '/')
.replace(/^\/+/g, '')
.replace(/\.\.+/g, '.')
.split('/')
.filter((part) => part && part !== '.' && part !== '..')
.join('/')
/**
* ZIP 로컬 파일 헤더를 만든다.
* @param {Object} entry - 엔트리 정보
* @returns {Buffer} 로컬 파일 헤더
* ZIP 파일을 디스크로 스트리밍 생성한다.
* 이미 압축된 이미지·영상·첨부 파일은 원본 바이트를 유지한 채 store 방식으로 담는다.
* @param {string} outputPath - 생성할 ZIP 절대 경로
* @param {Array<{ path: string, data?: Buffer|string, sourcePath?: string, store?: boolean }>} files - ZIP 엔트리
* @returns {Promise<void>}
*/
const createLocalFileHeader = (entry) => {
const header = Buffer.alloc(30)
header.writeUInt32LE(0x04034b50, 0)
header.writeUInt16LE(20, 4)
header.writeUInt16LE(0x0800, 6)
header.writeUInt16LE(8, 8)
header.writeUInt16LE(entry.dosTime, 10)
header.writeUInt16LE(entry.dosDate, 12)
header.writeUInt32LE(entry.crc32, 14)
header.writeUInt32LE(entry.compressedSize, 18)
header.writeUInt32LE(entry.uncompressedSize, 22)
header.writeUInt16LE(entry.name.length, 26)
header.writeUInt16LE(0, 28)
export const writeZipFile = async (outputPath, files) => {
await new Promise((resolve, reject) => {
const output = createWriteStream(outputPath, {
flags: 'wx'
})
const archive = new ZipArchive({
zlib: {
level: 6
},
forceZip64: true
})
let settled = false
return Buffer.concat([header, entry.name])
}
/**
* ZIP 스트림을 한 번만 종료한다.
* @param {Error|null} error - 실패 오류
* @returns {void}
*/
const finish = (error = null) => {
if (settled) {
return
}
/**
* ZIP 중앙 디렉터리 헤더를 만든다.
* @param {Object} entry - 엔트리 정보
* @returns {Buffer} 중앙 디렉터리 헤더
*/
const createCentralDirectoryHeader = (entry) => {
const header = Buffer.alloc(46)
header.writeUInt32LE(0x02014b50, 0)
header.writeUInt16LE(20, 4)
header.writeUInt16LE(20, 6)
header.writeUInt16LE(0x0800, 8)
header.writeUInt16LE(8, 10)
header.writeUInt16LE(entry.dosTime, 12)
header.writeUInt16LE(entry.dosDate, 14)
header.writeUInt32LE(entry.crc32, 16)
header.writeUInt32LE(entry.compressedSize, 20)
header.writeUInt32LE(entry.uncompressedSize, 24)
header.writeUInt16LE(entry.name.length, 28)
header.writeUInt16LE(0, 30)
header.writeUInt16LE(0, 32)
header.writeUInt16LE(0, 34)
header.writeUInt16LE(0, 36)
header.writeUInt32LE(0, 38)
header.writeUInt32LE(entry.offset, 42)
return Buffer.concat([header, entry.name])
}
/**
* ZIP 종료 레코드를 만든다.
* @param {number} entryCount - 엔트리 개수
* @param {number} centralDirectorySize - 중앙 디렉터리 크기
* @param {number} centralDirectoryOffset - 중앙 디렉터리 시작 위치
* @returns {Buffer} 종료 레코드
*/
const createEndOfCentralDirectory = (entryCount, centralDirectorySize, centralDirectoryOffset) => {
const endRecord = Buffer.alloc(22)
endRecord.writeUInt32LE(0x06054b50, 0)
endRecord.writeUInt16LE(0, 4)
endRecord.writeUInt16LE(0, 6)
endRecord.writeUInt16LE(entryCount, 8)
endRecord.writeUInt16LE(entryCount, 10)
endRecord.writeUInt32LE(centralDirectorySize, 12)
endRecord.writeUInt32LE(centralDirectoryOffset, 16)
endRecord.writeUInt16LE(0, 20)
return endRecord
}
/**
* 메모리에서 ZIP 파일 버퍼를 만든다.
* @param {Array<{ path: string, data: Buffer | string }>} files - ZIP 파일 목록
* @returns {Buffer} ZIP 버퍼
*/
export const createZipBuffer = (files) => {
const localFileParts = []
const centralDirectoryParts = []
const entries = []
let offset = 0
for (const file of files) {
const normalizedPath = normalizeZipPath(file.path)
if (!normalizedPath) {
continue
settled = true
if (error) {
reject(error)
return
}
resolve()
}
const rawData = Buffer.isBuffer(file.data) ? file.data : Buffer.from(String(file.data), 'utf8')
const compressedData = deflateRawSync(rawData)
const { dosDate, dosTime } = createDosDateTime(new Date())
const entry = {
name: Buffer.from(normalizedPath, 'utf8'),
crc32: calculateCrc32(rawData),
compressedSize: compressedData.length,
uncompressedSize: rawData.length,
dosDate,
dosTime,
offset
output.on('close', () => finish())
output.on('error', (error) => finish(error))
archive.on('error', (error) => finish(error))
archive.on('warning', (error) => {
if (error?.code !== 'ENOENT') {
finish(error)
}
})
archive.pipe(output)
for (const file of files) {
const path = normalizeZipPath(file.path)
if (!path) {
continue
}
if (file.sourcePath) {
archive.file(file.sourcePath, {
name: path,
store: file.store !== false
})
continue
}
const data = Buffer.isBuffer(file.data)
? file.data
: Buffer.from(String(file.data || ''), 'utf8')
archive.append(data, {
name: path,
store: Boolean(file.store)
})
}
const localHeader = createLocalFileHeader(entry)
localFileParts.push(localHeader, compressedData)
offset += localHeader.length + compressedData.length
entries.push(entry)
}
for (const entry of entries) {
centralDirectoryParts.push(createCentralDirectoryHeader(entry))
}
const centralDirectory = Buffer.concat(centralDirectoryParts)
const endRecord = createEndOfCentralDirectory(entries.length, centralDirectory.length, offset)
return Buffer.concat([...localFileParts, centralDirectory, endRecord])
archive.finalize().catch((error) => finish(error))
}).catch(async (error) => {
await rm(outputPath, {
force: true
})
throw error
})
}