v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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') || '')
|
||||
|
||||
|
||||
@@ -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') || '')
|
||||
|
||||
|
||||
71
server/utils/analytics-request-limit.js
Normal file
71
server/utils/analytics-request-limit.js
Normal 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
|
||||
}
|
||||
@@ -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'])
|
||||
|
||||
77
server/utils/csp-report-request-limit.js
Normal file
77
server/utils/csp-report-request-limit.js
Normal 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 보고 요청이 너무 많습니다.'
|
||||
})
|
||||
}
|
||||
135
server/utils/login-attempt-limit.js
Normal file
135
server/utils/login-attempt-limit.js
Normal 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)
|
||||
}
|
||||
22
server/utils/media-catalog-mode.js
Normal file
22
server/utils/media-catalog-mode.js
Normal 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'
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
275
server/utils/multipart-file-upload.js
Normal file
275
server/utils/multipart-file-upload.js
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`
|
||||
|
||||
70
server/utils/security-headers.js
Normal file
70
server/utils/security-headers.js
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
])
|
||||
|
||||
45
server/utils/upload-root.js
Normal file
45
server/utils/upload-root.js
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user