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

@@ -16,7 +16,7 @@ const deleteAccountSchema = z.object({
* @returns {Promise<{ ok: true }>} 처리 결과
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
const session = await requireMemberSession(event)
const parsedBody = deleteAccountSchema.safeParse(await readBody(event))
if (!parsedBody.success) {

View File

@@ -9,7 +9,7 @@ import { removeManagedAvatarAsset } from '../../utils/member-avatar'
* @returns {Promise<{ ok: true }>} 처리 결과
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
const session = await requireMemberSession(event)
const user = await getUserById(session.userId)
if (!user) {

View File

@@ -4,6 +4,7 @@ import { requireMemberSession } from '../../utils/member-auth'
import { removeManagedAvatarAsset } from '../../utils/member-avatar'
import { uploadMemberAvatarImage } from '../../utils/member-avatar-upload'
import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../utils/media-library'
import { replaceMediaReferences } from '../../repositories/media-catalog-repository'
/**
* 회원 썸네일 업로드 API
@@ -11,7 +12,7 @@ import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../utils/m
* @returns {Promise<{ avatarUrl: string }>} 업로드 결과
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
const session = await requireMemberSession(event)
const currentUser = await getUserById(session.userId)
if (!currentUser) {
throw createError({
@@ -29,6 +30,13 @@ export default defineEventHandler(async (event) => {
})
await upsertMediaMetadataCategory(avatarUrl, MEDIA_THUMBNAIL_ROOT)
await replaceMediaReferences('user', session.userId, {
avatarUrl
}, {
title: currentUser.username
}).catch(() => {
console.warn('[media-catalog] 회원 아바타 참조 갱신 실패')
})
if (currentUser.avatarUrl && currentUser.avatarUrl !== avatarUrl) {
await removeManagedAvatarAsset(currentUser.avatarUrl)

View File

@@ -8,7 +8,7 @@ import { requireMemberSession } from '../../utils/member-auth'
* @returns {Promise<{ available: boolean }>} 사용 가능 여부
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
const session = await requireMemberSession(event)
const rawUsername = getQuery(event).username
const username = typeof rawUsername === 'string' ? rawUsername.trim() : ''

View File

@@ -4,6 +4,11 @@ import { createError, readBody } from 'h3'
import { getUserByEmail, touchUserActivity } from '../../repositories/member-repository'
import { setMemberSession } from '../../utils/member-auth'
import { getClientIp } from '../../utils/request-ip'
import {
assertLoginAttemptAllowed,
clearLoginAccountFailures,
recordLoginFailure
} from '../../utils/login-attempt-limit'
const loginSchema = z.object({
email: z.string().trim().email(),
@@ -27,19 +32,34 @@ export default defineEventHandler(async (event) => {
const body = parsedBody.data
const emailNorm = body.email.trim().toLowerCase()
const ip = getClientIp(event)
const attempt = {
scope: 'member',
email: emailNorm,
ip
}
assertLoginAttemptAllowed(event, attempt)
const user = await getUserByEmail(emailNorm)
if (!user || !(await bcrypt.compare(body.password, user.passwordHash))) {
recordLoginFailure(attempt)
throw createError({
statusCode: 401,
message: '이메일 또는 비밀번호가 올바르지 않습니다.'
})
}
setMemberSession(event, { userId: user.id, email: user.email })
clearLoginAccountFailures(attempt)
setMemberSession(event, {
userId: user.id,
email: user.email,
sessionVersion: user.sessionVersion
})
await touchUserActivity({
userId: user.id,
ip: getClientIp(event)
ip
})
return {

View File

@@ -1,5 +1,5 @@
import { getUserById } from '../../repositories/member-repository'
import { getMemberSession, requireMemberSession } from '../../utils/member-auth'
import { getVerifiedMemberSession, requireMemberSession } from '../../utils/member-auth'
/**
* 회원 세션 조회 API
@@ -8,7 +8,9 @@ import { getMemberSession, requireMemberSession } from '../../utils/member-auth'
*/
export default defineEventHandler(async (event) => {
const isOptional = getQuery(event).optional === '1'
const session = isOptional ? getMemberSession(event) : requireMemberSession(event)
const session = isOptional
? await getVerifiedMemberSession(event)
: await requireMemberSession(event)
if (!session) {
return null

View File

@@ -2,7 +2,8 @@ import bcrypt from 'bcrypt'
import { createError, readBody } from 'h3'
import { z } from 'zod'
import { getUserByIdWithPassword, updateMemberPassword } from '../../repositories/member-repository'
import { requireMemberSession } from '../../utils/member-auth'
import { clearMemberSession, requireMemberSession } from '../../utils/member-auth'
import { clearAdminSession } from '../../utils/admin-auth'
const updatePasswordSchema = z.object({
currentPassword: z.string().min(1),
@@ -15,7 +16,7 @@ const updatePasswordSchema = z.object({
* @returns {Promise<{ ok: true }>} 변경 결과
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
const session = await requireMemberSession(event)
const parsedBody = updatePasswordSchema.safeParse(await readBody(event))
if (!parsedBody.success) {
@@ -46,6 +47,8 @@ export default defineEventHandler(async (event) => {
userId: session.userId,
passwordHash: nextHash
})
clearMemberSession(event)
clearAdminSession(event)
return { ok: true }
})

View File

@@ -8,7 +8,7 @@ import { createError } from 'h3'
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, previousLastSeenAt: string | null, previousLastSeenIp: string, commentCount: number }>} 회원 프로필
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
const session = await requireMemberSession(event)
const user = await getUserProfileById(session.userId)
if (!user) {

View File

@@ -16,7 +16,7 @@ const updateProfileSchema = z.object({
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string }>} 수정된 회원 프로필
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
const session = await requireMemberSession(event)
const parsedBody = updateProfileSchema.safeParse(await readBody(event))
if (!parsedBody.success) {

View File

@@ -104,11 +104,16 @@ export default defineEventHandler(async (event) => {
passwordHash
})
setMemberSession(event, { userId: created.id, email: created.email })
setMemberSession(event, {
userId: created.id,
email: created.email,
sessionVersion: created.sessionVersion
})
if (created.isAdmin) {
setAdminSession(event, {
userId: created.id,
email: created.email
email: created.email,
sessionVersion: created.sessionVersion
})
}
await touchUserActivity({

View File

@@ -0,0 +1,118 @@
import {
createError,
getRequestHeader,
setResponseStatus
} from 'h3'
import {
CSP_REPORT_MAX_BYTES,
isSupportedCspReportContentType,
parseCspReportPayload
} from '../../lib/csp-report.js'
import { recordCspReports } from '../repositories/csp-report-repository.js'
import { assertCspReportRequestAllowed } from '../utils/csp-report-request-limit.js'
const STORAGE_FAILURE_LOG_INTERVAL_MS = 5 * 60 * 1000
let lastStorageFailureLogAt = 0
/**
* 요청 스트림을 최대 크기 안에서 JSON으로 읽는다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<unknown>} JSON payload
*/
const readLimitedJsonBody = async (event) => {
const contentLengthHeader = String(getRequestHeader(event, 'content-length') || '').trim()
if (contentLengthHeader) {
const contentLength = Number(contentLengthHeader)
if (!Number.isSafeInteger(contentLength) || contentLength < 0) {
throw createError({
statusCode: 400,
message: '올바르지 않은 Content-Length입니다.'
})
}
if (contentLength > CSP_REPORT_MAX_BYTES) {
throw createError({
statusCode: 413,
message: 'CSP 보고 본문이 너무 큽니다.'
})
}
}
const chunks = []
let byteLength = 0
for await (const chunk of event.node.req) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
byteLength += buffer.byteLength
if (byteLength > CSP_REPORT_MAX_BYTES) {
throw createError({
statusCode: 413,
message: 'CSP 보고 본문이 너무 큽니다.'
})
}
chunks.push(buffer)
}
try {
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
} catch {
throw createError({
statusCode: 400,
message: '올바른 JSON CSP 보고서가 아닙니다.'
})
}
}
/**
* 저장 실패를 원문 정보 없이 제한된 빈도로 기록한다.
* @returns {void}
*/
const logStorageFailure = () => {
const now = Date.now()
if (now - lastStorageFailureLogAt < STORAGE_FAILURE_LOG_INTERVAL_MS) {
return
}
lastStorageFailureLogAt = now
console.warn('CSP 위반 집계 저장에 실패했습니다.')
}
/**
* 인증 없는 CSP 위반 보고를 개인정보 최소화 집계로 저장한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<null>} 빈 204 응답
*/
export default defineEventHandler(async (event) => {
assertCspReportRequestAllowed(event)
const contentType = String(getRequestHeader(event, 'content-type') || '')
if (!isSupportedCspReportContentType(contentType)) {
throw createError({
statusCode: 415,
message: '지원하지 않는 CSP 보고 형식입니다.'
})
}
const payload = await readLimitedJsonBody(event)
let reports
try {
reports = parseCspReportPayload(
payload,
String(getRequestHeader(event, 'user-agent') || '')
)
} catch {
throw createError({
statusCode: 400,
message: '올바른 CSP 보고서가 아닙니다.'
})
}
try {
await recordCspReports(reports)
} catch {
logStorageFailure()
}
setResponseStatus(event, 204)
return null
})

View File

@@ -1,7 +1,7 @@
import { getQuery } from 'h3'
import { listPosts } from '../repositories/content-repository'
import { canAccessMembershipContent } from '../repositories/member-repository'
import { getMemberSession } from '../utils/member-auth'
import { getVerifiedMemberSession } from '../utils/member-auth'
import {
DEFAULT_POST_LIST_PAGE_SIZE,
normalizeDisplayCount,
@@ -14,14 +14,12 @@ import {
* @returns {Promise<Array>} 게시물 목록
*/
export default defineEventHandler(async (event) => {
const session = getMemberSession(event)
const session = await getVerifiedMemberSession(event)
const includeMembership = await canAccessMembershipContent(session?.userId)
const query = getQuery(event)
const page = normalizeListPage(query.page)
const limit = query.limit
? normalizeDisplayCount(query.limit, DEFAULT_POST_LIST_PAGE_SIZE)
: null
const offset = limit ? (page - 1) * limit : 0
const limit = normalizeDisplayCount(query.limit, DEFAULT_POST_LIST_PAGE_SIZE)
const offset = (page - 1) * limit
const paginated = query.paginated === '1'
const result = await listPosts({
@@ -38,7 +36,7 @@ export default defineEventHandler(async (event) => {
}
const total = Number(result.total || 0)
const pageSize = limit || DEFAULT_POST_LIST_PAGE_SIZE
const pageSize = limit
return {
items: result.items,

View File

@@ -1,6 +1,6 @@
import { getAdjacentPosts, getPostBySlug } from '../../repositories/content-repository'
import { canAccessMembershipContent } from '../../repositories/member-repository'
import { getMemberSession } from '../../utils/member-auth'
import { getVerifiedMemberSession } from '../../utils/member-auth'
/**
* 공개 게시물 상세 API
@@ -9,7 +9,7 @@ import { getMemberSession } from '../../utils/member-auth'
*/
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')
const session = getMemberSession(event)
const session = await getVerifiedMemberSession(event)
const includeMembership = await canAccessMembershipContent(session?.userId)
const [post, adjacent] = await Promise.all([
getPostBySlug(slug, { includeMembership }),

View File

@@ -1,5 +1,6 @@
import { listPostCommentsBySlug } from '../../../repositories/comment-repository'
import { getMemberSession } from '../../../utils/member-auth'
import { canAccessMembershipContent } from '../../../repositories/member-repository'
import { getVerifiedMemberSession } from '../../../utils/member-auth'
/**
* 게시물 댓글 목록 조회 API
@@ -8,8 +9,9 @@ import { getMemberSession } from '../../../utils/member-auth'
*/
export default defineEventHandler(async (event) => {
const slug = String(getRouterParam(event, 'slug') || '')
const session = getMemberSession(event)
const comments = await listPostCommentsBySlug(slug, session?.userId || null)
const session = await getVerifiedMemberSession(event)
const includeMembership = await canAccessMembershipContent(session?.userId)
const comments = await listPostCommentsBySlug(slug, session?.userId || null, includeMembership)
return {
comments

View File

@@ -1,7 +1,10 @@
import { createError, readBody } from 'h3'
import { z } from 'zod'
import { createComment } from '../../../repositories/comment-repository'
import { touchUserActivity } from '../../../repositories/member-repository'
import {
canAccessMembershipContent,
touchUserActivity
} from '../../../repositories/member-repository'
import { requireMemberSession } from '../../../utils/member-auth'
import { getClientIp } from '../../../utils/request-ip'
@@ -16,7 +19,8 @@ const createCommentSchema = z.object({
* @returns {Promise<{ comment: Object }>} 생성 댓글
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
const session = await requireMemberSession(event)
const includeMembership = await canAccessMembershipContent(session.userId)
const slug = String(getRouterParam(event, 'slug') || '')
const parsedBody = createCommentSchema.safeParse(await readBody(event))
@@ -31,7 +35,8 @@ export default defineEventHandler(async (event) => {
slug,
userId: session.userId,
body: parsedBody.data.body,
parentId: parsedBody.data.parentId || null
parentId: parsedBody.data.parentId || null,
includeMembership
})
await touchUserActivity({
userId: session.userId,

View File

@@ -1,5 +1,8 @@
import { toggleCommentLike } from '../../../../../repositories/comment-repository'
import { touchUserActivity } from '../../../../../repositories/member-repository'
import {
canAccessMembershipContent,
touchUserActivity
} from '../../../../../repositories/member-repository'
import { requireMemberSession } from '../../../../../utils/member-auth'
import { getClientIp } from '../../../../../utils/request-ip'
@@ -9,14 +12,16 @@ import { getClientIp } from '../../../../../utils/request-ip'
* @returns {Promise<{ liked: boolean, likeCount: number }>} 좋아요 상태
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
const session = await requireMemberSession(event)
const includeMembership = await canAccessMembershipContent(session.userId)
const slug = String(getRouterParam(event, 'slug') || '')
const commentId = String(getRouterParam(event, 'commentId') || '')
const result = await toggleCommentLike({
slug,
commentId,
userId: session.userId
userId: session.userId,
includeMembership
})
await touchUserActivity({

View File

@@ -1,6 +1,7 @@
import { createError, getRequestURL } from 'h3'
import { getAdminSession } from '../utils/admin-auth'
import { isPrivilegedMember } from '../repositories/member-repository'
import { clearAdminSession, getAdminSession } from '../utils/admin-auth'
import { clearMemberSession } from '../utils/member-auth'
import { isPrivilegedMemberSession } from '../repositories/member-repository'
/**
* 관리자 API 요청마다 현재 DB 권한을 다시 확인한다.
@@ -23,12 +24,14 @@ export default defineEventHandler(async (event) => {
})
}
const stillPrivileged = await isPrivilegedMember(session.userId)
const stillPrivileged = await isPrivilegedMemberSession(session.userId, session.sessionVersion)
if (!stillPrivileged) {
clearAdminSession(event)
clearMemberSession(event)
throw createError({
statusCode: 403,
message: '현재 관리자 권한이 없습니다. 다시 로그인해 주세요.'
statusCode: 401,
message: '관리자 세션이 만료되었습니다. 다시 로그인해 주세요.'
})
}
})

View File

@@ -6,6 +6,7 @@ import {
} from '../repositories/analytics-repository'
import { getPageBySlug } from '../repositories/content-repository'
import { shouldExcludeAdminAnalytics } from '../utils/analytics-admin-exclusion'
import { applySecurityHeaders } from '../utils/security-headers.js'
/**
* 공개 페이지가 HTML 문서 모드일 때 Nuxt 렌더링 대신 원문 HTML을 응답한다.
@@ -49,6 +50,7 @@ export default defineEventHandler(async (event) => {
setResponseHeader(event, 'content-type', 'text/html; charset=utf-8')
setResponseHeader(event, 'cache-control', 'no-cache')
applySecurityHeaders(event, { enforceCsp: false })
return page.content || ''
})

View File

@@ -0,0 +1,14 @@
import { getRequestURL } from 'h3'
import { applySecurityHeaders } from '../utils/security-headers.js'
/**
* 모든 응답에 브라우저 기본 보안 헤더를 적용한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {void}
*/
export default defineEventHandler((event) => {
const pathname = getRequestURL(event).pathname
applySecurityHeaders(event, {
enforceCsp: !/^\/pages(?:\/|$)/.test(pathname)
})
})

View File

@@ -0,0 +1,29 @@
import { Cron } from 'croner'
import { queuePendingPostExportJobs } from '../repositories/post-export-repository'
/**
* 대기·중단된 게시물 Export 작업 실행을 요청한다.
* @returns {Promise<void>}
*/
const queuePostExportWork = async () => {
try {
await queuePendingPostExportJobs()
} catch (error) {
console.error('[post-export] 작업 복구 확인 실패', error)
}
}
/**
* 서버 시작과 주기 확인으로 게시물 Export 작업을 복구한다.
*/
export default defineNitroPlugin(() => {
if (process.env.NUXT_POST_EXPORT_WORKER === '0' || process.env.PLAYWRIGHT_TEST === '1') {
return
}
queuePostExportWork()
new Cron('*/30 * * * * *', {
protect: true
}, queuePostExportWork)
})

View File

@@ -29,18 +29,24 @@ const getSql = () => getPostgresClient()
const isUndefinedTableError = (error) => String(error?.code || '') === '42P01'
/**
* 게시물 ID 조회
* 댓글 접근 가능한 게시물 ID 조회
* @param {ReturnType<typeof import('postgres').default>} sql - postgres 클라이언트
* @param {string} slug - 게시물 슬러그
* @param {boolean} includeMembership - 멤버십 게시물 허용 여부
* @returns {Promise<string | null>} 게시물 ID
*/
const findPublishedPostIdBySlug = async (sql, slug) => {
const findAccessiblePostIdBySlug = async (sql, slug, includeMembership = false) => {
const rows = await sql`
SELECT id
FROM posts
WHERE slug = ${slug}
AND status = 'published'
AND (
status = 'published'
OR (${includeMembership} = true AND status = 'members')
)
AND (
status = 'members'
OR
published_at IS NULL
OR published_at <= now()
)
@@ -55,14 +61,14 @@ const findPublishedPostIdBySlug = async (sql, slug) => {
* @param {string} slug - 게시물 슬러그
* @returns {Promise<Array<PostComment>>} 댓글 목록
*/
export const listPostCommentsBySlug = async (slug, viewerUserId = null) => {
export const listPostCommentsBySlug = async (slug, viewerUserId = null, includeMembership = false) => {
const sql = getSql()
if (!sql) {
return []
}
const postId = await findPublishedPostIdBySlug(sql, slug)
const postId = await findAccessiblePostIdBySlug(sql, slug, includeMembership)
if (!postId) {
throw createError({
statusCode: 404,
@@ -149,7 +155,7 @@ export const listPostCommentsBySlug = async (slug, viewerUserId = null) => {
/**
* 댓글 생성
* @param {{ slug: string, userId: string, body: string, parentId?: string | null }} input - 댓글 입력값
* @param {{ slug: string, userId: string, body: string, parentId?: string | null, includeMembership?: boolean }} input - 댓글 입력값
* @returns {Promise<PostComment>} 생성된 댓글
*/
export const createComment = async (input) => {
@@ -162,7 +168,7 @@ export const createComment = async (input) => {
})
}
const postId = await findPublishedPostIdBySlug(sql, input.slug)
const postId = await findAccessiblePostIdBySlug(sql, input.slug, Boolean(input.includeMembership))
if (!postId) {
throw createError({
statusCode: 404,
@@ -250,7 +256,7 @@ export const createComment = async (input) => {
/**
* 댓글 좋아요를 토글한다.
* @param {{ slug: string, commentId: string, userId: string }} input - 좋아요 입력값
* @param {{ slug: string, commentId: string, userId: string, includeMembership?: boolean }} input - 좋아요 입력값
* @returns {Promise<{ liked: boolean, likeCount: number }>} 좋아요 결과
*/
export const toggleCommentLike = async (input) => {
@@ -263,7 +269,7 @@ export const toggleCommentLike = async (input) => {
})
}
const postId = await findPublishedPostIdBySlug(sql, input.slug)
const postId = await findAccessiblePostIdBySlug(sql, input.slug, Boolean(input.includeMembership))
if (!postId) {
throw createError({
statusCode: 404,

View File

@@ -42,6 +42,71 @@ import {
getExistingPostThumbnailUrl
} from '../utils/post-thumbnail-image.js'
import { getPostgresClient } from './postgres-client'
import { replaceMediaReferences } from './media-catalog-repository'
import { createPostSummary } from '../../composables/createPostSummary.js'
/**
* 콘텐츠 저장 성공을 방해하지 않고 미디어 참조를 갱신한다.
* @param {string} type - 참조 종류
* @param {string} id - 콘텐츠 id
* @param {Object} input - 저장 입력
* @returns {Promise<void>}
*/
const syncContentMediaReferences = async (type, id, input) => {
try {
await replaceMediaReferences(type, id, {
content: input.content,
featuredImage: input.featuredImage,
ogImage: input.ogImage
}, {
title: input.title,
slug: input.slug,
status: input.status
})
} catch {
console.warn('[media-catalog] 콘텐츠 참조 갱신 실패')
}
}
/**
* 사이트 설정 미디어 참조를 최선 노력 방식으로 갱신한다.
* @param {Object} settings - 현재 사이트 설정
* @returns {Promise<void>}
*/
const syncSiteSettingsMediaReferences = async (settings) => {
try {
await replaceMediaReferences('site_setting', 'site', {
logoUrl: settings.logoUrl,
faviconUrl: settings.faviconUrl,
homeCoverImageUrl: settings.homeCoverImageUrl,
homeCoverDarkImageUrl: settings.homeCoverDarkImageUrl
}, {
title: '사이트 설정',
status: 'system'
})
} catch {
console.warn('[media-catalog] 사이트 설정 참조 갱신 실패')
}
}
/**
* 태그 미디어 참조를 최선 노력 방식으로 갱신한다.
* @param {Object} tag - 현재 태그
* @returns {Promise<void>}
*/
const syncTagMediaReferences = async (tag) => {
try {
await replaceMediaReferences('tag', tag.id, {
headerImageUrl: tag.headerImageUrl
}, {
title: tag.name,
slug: tag.slug,
status: tag.tagType
})
} catch {
console.warn('[media-catalog] 태그 참조 갱신 실패')
}
}
/**
* 게시물 행을 API 응답 구조로 변환
@@ -74,6 +139,23 @@ const mapPostRow = (row) => ({
tags: row.tags || []
})
/**
* 공개 목록 게시물에서 본문을 제외하고 표시용 요약을 만든다.
* @param {Object} post - 게시물 응답
* @returns {Object} 공개 목록 게시물 요약
*/
const mapPublicPostSummary = (post) => {
const { content, ...summary } = post
return {
...summary,
excerpt: createPostSummary(post.excerpt, content, {
maxLength: 320,
appendEllipsis: false
})
}
}
/**
* 관리자 API용 게시물 행 변환(제목 없음 플레이스홀더는 빈 문자열)
* @param {Object} row - 게시물 행
@@ -500,11 +582,12 @@ const syncPostTags = async (sql, postId, tags, tagNames = {}) => {
/**
* 공개 게시물 목록 조회
* @param {{ includeMembership?: boolean, limit?: number|null, offset?: number, tagSlug?: string, featuredOnly?: boolean, includeTotal?: boolean }} [options] - 조회 옵션
* @param {{ includeMembership?: boolean, includeContent?: boolean, limit?: number|null, offset?: number, tagSlug?: string, featuredOnly?: boolean, includeTotal?: boolean }} [options] - 조회 옵션
* @returns {Promise<Array|{ items: Array, total: number }>} 게시물 목록
*/
export const listPosts = async ({
includeMembership = false,
includeContent = false,
limit = null,
offset = 0,
tagSlug = '',
@@ -525,16 +608,40 @@ export const listPosts = async ({
samplePosts = samplePosts.filter((post) => post.tags.includes(normalizedTagSlug))
}
const total = samplePosts.length
const items = resolvedLimit
const selectedItems = resolvedLimit
? samplePosts.slice(resolvedOffset, resolvedOffset + resolvedLimit)
: samplePosts
const items = includeContent
? selectedItems
: selectedItems.map(mapPublicPostSummary)
return includeTotal ? { items, total } : items
}
const rows = await sql`
SELECT
posts.*,
posts.id,
posts.title,
posts.slug,
posts.author_id,
CASE
WHEN ${includeContent} = true THEN posts.content
ELSE LEFT(posts.content, 4000)
END AS content,
posts.excerpt,
posts.featured_image,
posts.show_featured_image,
posts.is_featured,
posts.admin_pinned,
posts.seo_title,
posts.seo_description,
posts.canonical_url,
posts.noindex,
posts.og_image,
posts.status,
posts.published_at,
posts.created_at,
posts.updated_at,
(
SELECT COUNT(*)::int
FROM comments
@@ -576,7 +683,10 @@ export const listPosts = async ({
OFFSET ${resolvedOffset}
`
const items = rows.map(mapPostRow)
const mappedRows = rows.map(mapPostRow)
const items = includeContent
? mappedRows
: mappedRows.map(mapPublicPostSummary)
if (!includeTotal) {
return items
@@ -1122,7 +1232,8 @@ export const createAdminPost = async (input, authorId) => {
await Promise.all([
createPostThumbnailForImageUrl(input.featuredImage),
createPostBodyThumbnailsForContent(input.content)
createPostBodyThumbnailsForContent(input.content),
syncContentMediaReferences('post', rows[0].id, input)
])
return getAdminPostById(rows[0].id)
@@ -1178,7 +1289,8 @@ export const updateAdminPost = async (id, input, editorId) => {
if (rows[0]) {
await Promise.all([
createPostThumbnailForImageUrl(input.featuredImage),
createPostBodyThumbnailsForContent(input.content)
createPostBodyThumbnailsForContent(input.content),
syncContentMediaReferences('post', rows[0].id, input)
])
}
@@ -1838,7 +1950,10 @@ export const createAdminPage = async (input) => {
RETURNING *
`
await createPageCoverThumbnailForImageUrl(input.featuredImage)
await Promise.all([
createPageCoverThumbnailForImageUrl(input.featuredImage),
syncContentMediaReferences('page', rows[0].id, input)
])
return mapPageRow(rows[0])
}
@@ -1877,7 +1992,10 @@ export const updateAdminPage = async (id, input) => {
`
if (rows[0]) {
await createPageCoverThumbnailForImageUrl(input.featuredImage)
await Promise.all([
createPageCoverThumbnailForImageUrl(input.featuredImage),
syncContentMediaReferences('page', rows[0].id, input)
])
}
return rows[0] ? mapPageRow(rows[0]) : null
@@ -2315,7 +2433,85 @@ export const updateSiteSettings = async (input) => {
RETURNING *
`
return mapSiteSettingsRow(rows[0])
const settings = mapSiteSettingsRow(rows[0])
await syncSiteSettingsMediaReferences(settings)
return settings
}
/**
* 전달된 사이트 설정 필드만 원자적으로 수정한다.
* @param {Object} input - 부분 사이트 설정 입력값
* @returns {Promise<Object>} 수정된 사이트 설정
*/
export const updateSiteSettingsPartial = async (input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const hasOwn = (key) => Object.prototype.hasOwnProperty.call(input, key)
const values = {}
if (hasOwn('title')) values.title = input.title
if (hasOwn('description')) values.description = input.description
if (hasOwn('siteUrl')) values.site_url = input.siteUrl
if (hasOwn('logoText')) values.logo_text = input.logoText || '井'
if (hasOwn('logoUrl')) values.logo_url = input.logoUrl || ''
if (hasOwn('faviconUrl')) values.favicon_url = input.faviconUrl || ''
if (hasOwn('copyrightText')) values.copyright_text = input.copyrightText
if (hasOwn('socialLinks')) values.social_links = sql.json(normalizeSocialLinks(input.socialLinks))
if (hasOwn('showPostUpdatedAt')) values.show_post_updated_at = Boolean(input.showPostUpdatedAt)
if (hasOwn('postTagLimit')) values.post_tag_limit = normalizePostTagLimit(input.postTagLimit)
if (hasOwn('homeLatestPostLimit')) values.home_latest_post_limit = normalizeDisplayCount(input.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT)
if (hasOwn('postListPageSize')) values.post_list_page_size = normalizeDisplayCount(input.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE)
if (hasOwn('tagPostListPageSize')) values.tag_post_list_page_size = normalizeDisplayCount(input.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)
if (hasOwn('featuredPostLimit')) values.featured_post_limit = normalizeDisplayCount(input.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)
if (hasOwn('pageSectionItemLimit')) values.page_section_item_limit = normalizeDisplayCount(input.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)
if (hasOwn('uploadMaxFileCount')) values.upload_max_file_count = normalizeUploadMaxFileCount(input.uploadMaxFileCount)
if (hasOwn('uploadMaxTotalSize')) values.upload_max_total_size = normalizeUploadMaxTotalSize(input.uploadMaxTotalSize)
if (hasOwn('homeCoverImageUrl')) values.home_cover_image_url = input.homeCoverImageUrl || ''
if (hasOwn('homeCoverDarkImageUrl')) values.home_cover_dark_image_url = input.homeCoverDarkImageUrl || ''
if (hasOwn('homeCoverTitle')) values.home_cover_title = input.homeCoverTitle || ''
if (hasOwn('homeCoverText')) values.home_cover_text = input.homeCoverText || ''
if (hasOwn('brandColor')) values.brand_color = normalizeBrandColor(input.brandColor)
if (hasOwn('announcementEnabled')) values.announcement_enabled = Boolean(input.announcementEnabled)
if (hasOwn('announcementText')) values.announcement_text = input.announcementText || ''
if (hasOwn('announcementUrl')) values.announcement_url = input.announcementUrl || ''
if (hasOwn('announcementBackgroundColor')) values.announcement_background_color = normalizeAnnouncementBackgroundColor(input.announcementBackgroundColor)
if (hasOwn('announcementAlignment')) values.announcement_alignment = normalizeAnnouncementAlignment(input.announcementAlignment)
if (hasOwn('signupBlockedUsernames')) values.signup_blocked_usernames = JSON.stringify(normalizeSignupBlockedUsernames(input.signupBlockedUsernames))
if (hasOwn('adsTxt')) values.ads_txt = input.adsTxt || ''
if (hasOwn('customHeadCode')) values.custom_head_code = input.customHeadCode || ''
if (hasOwn('customFooterCode')) values.custom_footer_code = input.customFooterCode || ''
if (hasOwn('adHomeFeedCode')) values.ad_home_feed_code = input.adHomeFeedCode || ''
if (hasOwn('adHomeInfeedCode')) values.ad_home_infeed_code = input.adHomeInfeedCode || ''
if (hasOwn('adSidebarCode')) values.ad_sidebar_code = input.adSidebarCode || ''
if (hasOwn('adPostSidebarCode')) values.ad_post_sidebar_code = input.adPostSidebarCode || ''
if (hasOwn('adPostTopCode')) values.ad_post_top_code = input.adPostTopCode || ''
if (hasOwn('adPostInArticleCode')) values.ad_post_in_article_code = input.adPostInArticleCode || ''
if (hasOwn('adPostBottomCode')) values.ad_post_bottom_code = input.adPostBottomCode || ''
if (!Object.keys(values).length) {
return getSiteSettings()
}
const rows = await sql`
UPDATE site_settings
SET
${sql(values)},
updated_at = now()
WHERE id = 1
RETURNING *
`
if (!rows[0]) {
throw new Error('SITE_SETTINGS_NOT_FOUND')
}
const settings = mapSiteSettingsRow(rows[0])
await syncSiteSettingsMediaReferences(settings)
return settings
}
/**
@@ -2351,7 +2547,9 @@ export const updateSiteLogo = async (input) => {
RETURNING *
`
return mapSiteSettingsRow(rows[0])
const settings = mapSiteSettingsRow(rows[0])
await syncSiteSettingsMediaReferences(settings)
return settings
}
/**
@@ -2384,7 +2582,9 @@ export const updateSiteHomeCoverImage = async (input) => {
RETURNING *
`
return mapSiteSettingsRow(rows[0])
const settings = mapSiteSettingsRow(rows[0])
await syncSiteSettingsMediaReferences(settings)
return settings
}
/**
@@ -2613,7 +2813,9 @@ export const createAdminTag = async (input) => {
RETURNING *
`
return mapTagRow(rows[0])
const tag = mapTagRow(rows[0])
await syncTagMediaReferences(tag)
return tag
}
/**
@@ -2646,7 +2848,13 @@ export const updateAdminTag = async (id, input) => {
RETURNING *
`
return rows[0] ? mapTagRow(rows[0]) : null
if (!rows[0]) {
return null
}
const tag = mapTagRow(rows[0])
await syncTagMediaReferences(tag)
return tag
}
/**

View File

@@ -0,0 +1,50 @@
import { getPostgresClient } from './postgres-client.js'
/**
* CSP 위반을 원본 없이 정규화된 차원별로 집계한다.
* @param {Array<{ policyVersion: string, documentPath: string, effectiveDirective: string, blockedOrigin: string, userAgentFamily: string }>} reports - CSP 집계 행
* @returns {Promise<void>}
*/
export const recordCspReports = async (reports) => {
const sql = getPostgresClient()
if (!sql || reports.length === 0) {
return
}
await sql.begin(async (transaction) => {
for (const report of reports) {
await transaction`
INSERT INTO csp_violation_aggregates (
policy_version,
document_path,
effective_directive,
blocked_origin,
user_agent_family,
violation_count,
first_seen_at,
last_seen_at
)
VALUES (
${report.policyVersion},
${report.documentPath},
${report.effectiveDirective},
${report.blockedOrigin},
${report.userAgentFamily},
1,
now(),
now()
)
ON CONFLICT (
policy_version,
document_path,
effective_directive,
blocked_origin,
user_agent_family
)
DO UPDATE SET
violation_count = csp_violation_aggregates.violation_count + 1,
last_seen_at = now()
`
}
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -130,6 +130,7 @@ const mapAdminMemberRow = (row) => {
* @property {string} avatarUrl - 아바타 URL
* @property {boolean} isAdmin - 관리자 여부
* @property {'owner' | 'admin' | 'vip' | 'member'} role - 권한 코드
* @property {number} sessionVersion - 세션 무효화 버전
* @property {string} createdAt - 생성 시각(ISO)
* @property {string} updatedAt - 수정 시각(ISO)
* @property {string | null} lastSeenAt - 최근 접속 시각(ISO)
@@ -169,6 +170,7 @@ export const getUserByEmail = async (email) => {
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
session_version AS "sessionVersion",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
@@ -313,6 +315,7 @@ export const createUser = async (input) => {
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
session_version AS "sessionVersion",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
@@ -367,6 +370,7 @@ export const upsertBootstrapOwner = async (input) => {
UPDATE users
SET
password_hash = ${input.passwordHash},
session_version = session_version + 1,
is_admin = true,
user_role = ${MEMBER_ROLE.OWNER},
updated_at = now()
@@ -379,6 +383,7 @@ export const upsertBootstrapOwner = async (input) => {
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
session_version AS "sessionVersion",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
@@ -406,6 +411,7 @@ export const upsertBootstrapOwner = async (input) => {
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
session_version AS "sessionVersion",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
@@ -480,6 +486,7 @@ export const updateMemberPasswordByEmail = async (input) => {
UPDATE users
SET
password_hash = ${input.passwordHash},
session_version = session_version + 1,
updated_at = now()
WHERE lower(email) = lower(${input.email})
RETURNING id
@@ -499,6 +506,7 @@ export const updateMemberPassword = async (input) => {
UPDATE users
SET
password_hash = ${input.passwordHash},
session_version = session_version + 1,
updated_at = now()
WHERE id = ${input.userId}
`
@@ -787,6 +795,7 @@ export const getAdminUserByEmail = async (email) => {
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
session_version AS "sessionVersion",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
@@ -826,16 +835,37 @@ export const getMemberBootstrapState = async () => {
}
/**
* 관리자 권한을 가진 회원 여부를 확인한다.
* 회원 세션 버전이 현재 DB 값과 일치하는지 확인한다.
* @param {string} userId - 사용자 ID
* @returns {Promise<boolean>} 관리자 권한 여부
* @param {number} sessionVersion - 세션 토큰 버전
* @returns {Promise<boolean>} 현재 세션 여부
*/
export const isPrivilegedMember = async (userId) => {
export const isMemberSessionCurrent = async (userId, sessionVersion) => {
const sql = requireSql()
const rows = await sql`
SELECT id
FROM users
WHERE id = ${userId}
AND session_version = ${sessionVersion}
LIMIT 1
`
return Boolean(rows?.[0])
}
/**
* 관리자 권한과 세션 버전을 함께 확인한다.
* @param {string} userId - 사용자 ID
* @param {number} sessionVersion - 세션 토큰 버전
* @returns {Promise<boolean>} 현재 관리자 세션 여부
*/
export const isPrivilegedMemberSession = async (userId, sessionVersion) => {
const sql = requireSql()
const rows = await sql`
SELECT id
FROM users
WHERE id = ${userId}
AND session_version = ${sessionVersion}
AND user_role = ANY(${PRIVILEGED_ROLES})
LIMIT 1
`

View File

@@ -1,10 +1,15 @@
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { basename, dirname, extname, join, relative } from 'node:path'
import { createZipBuffer } from '../utils/zip-writer'
import { mkdir, rm, stat } from 'node:fs/promises'
import { basename, dirname, extname, join, relative, sep } from 'node:path'
import { writeZipFile } from '../utils/zip-writer'
import { getRuntimeEnvValue } from '../utils/runtime-env'
import { isResendConfigured, sendResendEmail } from '../utils/resend-mail'
import { getDefaultSiteSettings } from '../utils/site-settings'
import { getUploadRoot } from '../utils/upload-root'
import { getPostgresClient } from './postgres-client'
import {
deleteMediaCatalogEntries,
recordMediaCatalogFile
} from './media-catalog-repository'
const EXPORT_STATUS = {
QUEUED: 'queued',
@@ -26,7 +31,7 @@ const DEFAULT_RETENTION_DAYS = 100
const DEFAULT_MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024
const MIN_MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024
const MAX_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024 * 1024
const UPLOAD_ROOT = join(process.cwd(), 'public', 'uploads')
const UPLOAD_ROOT = getUploadRoot()
const EXPORT_ROOT_NAME = 'exports'
const runningPostExportJobIds = new Set()
const localUploadUrlPattern = /\/uploads\/[^\s"'<>)]*/g
@@ -245,7 +250,8 @@ const buildPostAssetFiles = async (post, postFolder) => {
replacements[url] = localPath
zipFiles.push({
path: `${postFolder}/${assetFolder}/${fileName}`,
data: await readFile(diskPath)
sourcePath: diskPath,
store: true
})
} catch {
replacements[url] = url
@@ -262,7 +268,7 @@ const buildPostAssetFiles = async (post, postFolder) => {
* 게시물을 Obsidian 친화적인 Markdown 파일 묶음으로 변환한다.
* @param {Object} post - 게시물
* @param {Set<string>} usedFolderNames - 사용한 폴더명
* @returns {Promise<Array<{path: string, data: Buffer|string}>>} ZIP 내부 파일 목록
* @returns {Promise<Array<{path: string, data?: Buffer|string, sourcePath?: string, store?: boolean}>>} ZIP 내부 파일 목록
*/
const buildPostZipFiles = async (post, usedFolderNames) => {
const postFolder = createUniqueSegment(post.title || post.slug || post.id, usedFolderNames)
@@ -724,6 +730,7 @@ export const cleanupExpiredPostExportJobs = async () => {
FROM post_export_files
WHERE job_id = ANY(${jobIds})
`
const deletedMediaUrls = []
for (const file of fileRows) {
if (!file.file_path) {
@@ -734,9 +741,11 @@ export const cleanupExpiredPostExportJobs = async () => {
if (absolutePath) {
await rm(absolutePath, { force: true })
deletedMediaUrls.push(`/uploads/${String(file.file_path).split(sep).join('/').replace(/^\/+/, '')}`)
}
}
await deleteMediaCatalogEntries(deletedMediaUrls)
await sql`
DELETE FROM post_export_jobs
WHERE id = ANY(${jobIds})
@@ -790,8 +799,8 @@ export const listPostExportJobs = async () => {
}
/**
* 실행 가능한 대기 작업 ID 목록 조회
* @returns {Promise<Array<string>>} 대기 작업 ID 목록
* 실행하거나 재개할 Export 작업 ID 목록 조회
* @returns {Promise<Array<string>>} 실행 가능한 작업 ID 목록
*/
export const listQueuedPostExportJobIds = async () => {
const sql = getPostgresClient()
@@ -803,7 +812,7 @@ export const listQueuedPostExportJobIds = async () => {
const rows = await sql`
SELECT id
FROM post_export_jobs
WHERE status = ${EXPORT_STATUS.QUEUED}
WHERE status IN (${EXPORT_STATUS.QUEUED}, ${EXPORT_STATUS.PROCESSING})
ORDER BY created_at ASC
LIMIT 3
`
@@ -1167,7 +1176,6 @@ const createPostExportZipFile = async (job, file) => {
zipEntries.push(...await buildPostZipFiles(post, usedFolderNames))
}
const zipBuffer = createZipBuffer(zipEntries)
const now = new Date()
const year = String(now.getFullYear())
const month = String(now.getMonth() + 1).padStart(2, '0')
@@ -1175,11 +1183,18 @@ const createPostExportZipFile = async (job, file) => {
const absolutePath = join(UPLOAD_ROOT, relativePath)
await mkdir(dirname(absolutePath), { recursive: true })
await writeFile(absolutePath, zipBuffer)
await rm(absolutePath, { force: true })
await writeZipFile(absolutePath, zipEntries)
const fileStat = await stat(absolutePath)
await recordMediaCatalogFile(`/uploads/${relativePath.split(sep).join('/')}`, {
origin: 'system',
role: 'export',
category: 'exports'
})
return {
relativePath,
fileSizeBytes: zipBuffer.length,
fileSizeBytes: fileStat.size,
postCount: posts.length
}
}
@@ -1197,8 +1212,24 @@ export const runPostExportJob = async (jobId) => {
}
runningPostExportJobIds.add(jobId)
let reservedSql = null
let advisoryLockAcquired = false
const advisoryLockNamespace = 'sori-post-export'
try {
reservedSql = await sql.reserve()
const lockRows = await reservedSql`
SELECT pg_try_advisory_lock(
hashtext(${advisoryLockNamespace}),
hashtext(${jobId})
) AS acquired
`
advisoryLockAcquired = Boolean(lockRows[0]?.acquired)
if (!advisoryLockAcquired) {
return
}
const job = await getPostExportJobById(jobId)
if (!job || ![EXPORT_STATUS.QUEUED, EXPORT_STATUS.PROCESSING].includes(job.status)) {
@@ -1263,9 +1294,31 @@ export const runPostExportJob = async (jobId) => {
})
}
} catch (error) {
await markPostExportJobFailed(sql, jobId, error)
if (advisoryLockAcquired) {
await markPostExportJobFailed(sql, jobId, error)
} else {
console.error('게시물 Export 작업 잠금 획득 실패', error)
}
} finally {
if (reservedSql && advisoryLockAcquired) {
await reservedSql`
SELECT pg_advisory_unlock(
hashtext(${advisoryLockNamespace}),
hashtext(${jobId})
)
`.catch((error) => {
console.error('게시물 Export 작업 잠금 해제 실패', error)
})
}
reservedSql?.release()
runningPostExportJobIds.delete(jobId)
if (advisoryLockAcquired) {
setTimeout(() => {
queuePendingPostExportJobs().catch((error) => {
console.error('후속 게시물 Export 작업 실행 실패', error)
})
}, 0)
}
}
}
@@ -1287,7 +1340,7 @@ export const queuePostExportJobRun = (jobId) => {
}
/**
* 대기 중인 Export 작업을 백그라운드 실행 대기열에 올린다.
* 대기 중이거나 중단된 Export 작업을 백그라운드 실행 대기열에 올린다.
* @returns {Promise<void>}
*/
export const queuePendingPostExportJobs = async () => {
@@ -1389,6 +1442,8 @@ export const deletePostExportJob = async (jobId) => {
return false
}
const deletedMediaUrls = []
for (const file of job.files) {
if (!file.filePath) {
continue
@@ -1398,9 +1453,11 @@ export const deletePostExportJob = async (jobId) => {
if (absolutePath) {
await rm(absolutePath, { force: true })
deletedMediaUrls.push(`/uploads/${String(file.filePath).split(sep).join('/').replace(/^\/+/, '')}`)
}
}
await deleteMediaCatalogEntries(deletedMediaUrls)
await sql`
DELETE FROM post_export_jobs
WHERE id = ${jobId}

View File

@@ -1,12 +1,21 @@
import { mkdir, stat, writeFile } from 'node:fs/promises'
import { createWriteStream } from 'node:fs'
import { mkdir, rm, stat } from 'node:fs/promises'
import { basename, extname, join } from 'node:path'
import { readZipBufferEntries } from '../utils/zip-reader'
import { pipeline } from 'node:stream/promises'
import { openZipFileEntries } from '../utils/zip-reader'
import { upsertMediaMetadataCategory } from '../utils/media-library'
import { getUploadRoot } from '../utils/upload-root'
import { getPostgresClient } from './postgres-client'
import { createAdminPost } from './content-repository'
const UPLOAD_BASE_URL = '/uploads'
const MAX_IMPORT_POSTS = 1000
const MAX_IMPORT_ZIP_ENTRY_COUNT = 5000
const MAX_IMPORT_ENTRY_BYTES = 300 * 1024 * 1024
const MAX_IMPORT_TOTAL_BYTES = 2 * 1024 * 1024 * 1024
const MAX_IMPORT_COMPRESSION_RATIO = 250
const MAX_IMPORT_MARKDOWN_BYTES = 5 * 1024 * 1024
const MAX_IMPORT_TOTAL_MARKDOWN_BYTES = 50 * 1024 * 1024
const MARKDOWN_EXTENSION_PATTERN = /\.md$/i
/**
@@ -252,10 +261,10 @@ const parseMarkdownDocument = (markdown) => {
/**
* ZIP 엔트리를 경로 기준 Map으로 만든다.
* @param {Array<{ path: string, data: Buffer }>} entries - ZIP 엔트리
* @returns {Map<string, Buffer>} 엔트리 맵
* @param {Array<{ path: string }>} entries - ZIP 엔트리
* @returns {Map<string, Object>} 엔트리 맵
*/
const createZipEntryMap = (entries) => new Map(entries.map((entry) => [entry.path, entry.data]))
const createZipEntryMap = (entries) => new Map(entries.map((entry) => [entry.path, entry]))
/**
* Markdown 파일의 상위 폴더를 조회한다.
@@ -324,7 +333,7 @@ const pickUniqueDiskFileName = async (directoryPath, originalName) => {
/**
* Import 자산을 업로드 폴더에 저장한다.
* @param {Object} input - 저장 입력
* @param {Map<string, Buffer>} input.entryMap - ZIP 엔트리 맵
* @param {Map<string, Object>} input.entryMap - ZIP 엔트리 맵
* @param {string} input.postFolder - 게시물 폴더
* @param {Set<string>} input.assetPaths - 자산 경로 목록
* @returns {Promise<{ replacements: Map<string, string>, missingAssets: Array<string> }>} 저장 결과
@@ -333,7 +342,7 @@ const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
const now = new Date()
const year = String(now.getFullYear())
const month = String(now.getMonth() + 1).padStart(2, '0')
const directoryPath = join(process.cwd(), 'public', 'uploads', 'posts', year, month)
const directoryPath = join(getUploadRoot(), 'posts', year, month)
const replacements = new Map()
const missingAssets = []
@@ -341,18 +350,32 @@ const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
for (const assetPath of assetPaths) {
const entryPath = resolveAssetEntryPath(postFolder, assetPath)
const data = entryMap.get(entryPath)
const entry = entryMap.get(entryPath)
if (!data) {
if (!entry) {
missingAssets.push(assetPath)
continue
}
const { fileName, filePath } = await pickUniqueDiskFileName(directoryPath, basename(entryPath))
await writeFile(filePath, data)
try {
await pipeline(
entry.createReadStream(),
createWriteStream(filePath, {
flags: 'wx'
})
)
} catch (error) {
await rm(filePath, {
force: true
})
throw error
}
const publicUrl = `${UPLOAD_BASE_URL}/posts/${year}/${month}/${fileName}`
await upsertMediaMetadataCategory(publicUrl, '미분류')
await upsertMediaMetadataCategory(publicUrl, '미분류', {
origin: 'import'
})
replacements.set(assetPath, publicUrl)
replacements.set(assetPath.replace(/^\.\//, ''), publicUrl)
@@ -511,35 +534,55 @@ const normalizeIsoDate = (value) => {
/**
* ZIP 엔트리에서 Markdown 게시물 목록을 만든다.
* @param {Array<{ path: string, data: Buffer }>} entries - ZIP 엔트리
* @returns {Array<Object>} Markdown 문서 목록
* @param {Array<{ path: string, uncompressedSize: number, readBuffer: (maxBytes?: number) => Promise<Buffer> }>} entries - ZIP 엔트리
* @returns {Promise<Array<Object>>} Markdown 문서 목록
*/
const collectMarkdownPosts = (entries) => entries
.filter((entry) => MARKDOWN_EXTENSION_PATTERN.test(entry.path))
.map((entry) => ({
path: entry.path,
postFolder: getPostFolder(entry.path),
...parseMarkdownDocument(entry.data.toString('utf8'))
}))
const collectMarkdownPosts = async (entries) => {
const markdownEntries = entries.filter((entry) => MARKDOWN_EXTENSION_PATTERN.test(entry.path))
if (markdownEntries.length > MAX_IMPORT_POSTS) {
throw new Error('IMPORT_POST_LIMIT_EXCEEDED')
}
const markdownPosts = []
let totalMarkdownBytes = 0
for (const entry of markdownEntries) {
totalMarkdownBytes += Number(entry.uncompressedSize || 0)
if (totalMarkdownBytes > MAX_IMPORT_TOTAL_MARKDOWN_BYTES) {
throw new Error('IMPORT_MARKDOWN_SIZE_LIMIT_EXCEEDED')
}
const data = await entry.readBuffer(MAX_IMPORT_MARKDOWN_BYTES)
markdownPosts.push({
path: entry.path,
postFolder: getPostFolder(entry.path),
...parseMarkdownDocument(data.toString('utf8'))
})
}
return markdownPosts
}
/**
* Export ZIP을 게시물로 가져온다.
* @param {{ zipBuffer: Buffer, authorId: string }} input - Import 입력
* @param {{ zipFilePath: string, authorId: string }} input - Import 입력
* @returns {Promise<{ importedCount: number, assetCount: number, warningCount: number, warnings: Array<string>, posts: Array<Object> }>} Import 결과
*/
export const importPostsFromExportZip = async ({ zipBuffer, authorId }) => {
const entries = readZipBufferEntries(zipBuffer)
export const importPostsFromExportZip = async ({ zipFilePath, authorId }) => {
const entries = await openZipFileEntries(zipFilePath, {
maxEntryCount: MAX_IMPORT_ZIP_ENTRY_COUNT,
maxEntryBytes: MAX_IMPORT_ENTRY_BYTES,
maxTotalBytes: MAX_IMPORT_TOTAL_BYTES,
maxCompressionRatio: MAX_IMPORT_COMPRESSION_RATIO
})
const entryMap = createZipEntryMap(entries)
const markdownPosts = collectMarkdownPosts(entries)
const markdownPosts = await collectMarkdownPosts(entries)
if (!markdownPosts.length) {
throw new Error('IMPORT_MARKDOWN_NOT_FOUND')
}
if (markdownPosts.length > MAX_IMPORT_POSTS) {
throw new Error('IMPORT_POST_LIMIT_EXCEEDED')
}
const reservedSlugs = new Set()
const importedPosts = []
const warnings = []

View File

@@ -1,5 +1,5 @@
import postgres from 'postgres'
import { getRuntimeEnvValue } from '../utils/runtime-env'
import { getRuntimeEnvValue } from '../utils/runtime-env.js'
let client = null

View File

@@ -6,6 +6,11 @@ import { getAdminUserByEmail, getMemberBootstrapState, touchUserActivity, upsert
import { setMemberSession } from '../../../../utils/member-auth'
import { getRuntimeEnvValue } from '../../../../utils/runtime-env'
import { getClientIp } from '../../../../utils/request-ip'
import {
assertLoginAttemptAllowed,
clearLoginAccountFailures,
recordLoginFailure
} from '../../../../utils/login-attempt-limit'
const loginSchema = z.object({
email: z.string().email(),
@@ -64,6 +69,15 @@ export default defineEventHandler(async (event) => {
}
const body = parsedBody.data
const emailNorm = body.email.trim().toLowerCase()
const ip = getClientIp(event)
const attempt = {
scope: 'admin',
email: emailNorm,
ip
}
assertLoginAttemptAllowed(event, attempt)
const adminUser = await getAdminUserByEmail(body.email) || await createBootstrapAdminUser(body)
const passwordMatched = adminUser
@@ -71,23 +85,27 @@ export default defineEventHandler(async (event) => {
: false
if (!adminUser || !passwordMatched) {
recordLoginFailure(attempt)
throw createError({
statusCode: 401,
message: '이메일 또는 비밀번호가 올바르지 않습니다.'
})
}
clearLoginAccountFailures(attempt)
setAdminSession(event, {
userId: adminUser.id,
email: adminUser.email
email: adminUser.email,
sessionVersion: adminUser.sessionVersion
})
setMemberSession(event, {
userId: adminUser.id,
email: adminUser.email
email: adminUser.email,
sessionVersion: adminUser.sessionVersion
})
await touchUserActivity({
userId: adminUser.id,
ip: getClientIp(event)
ip
})
return {

View File

@@ -17,7 +17,9 @@ export default defineEventHandler(async (event) => {
const member = await getMemberForAdmin(session.userId)
return {
...session,
userId: session.userId,
email: session.email,
role: session.role,
roleCode: member?.roleCode || 'admin',
roleLabel: member?.role || '관리자'
}

View File

@@ -1,5 +1,11 @@
import { requireAdminSession } from '../../../utils/admin-auth'
import { listMediaItems } from '../../../utils/media-library'
import { getMediaCatalogMode } from '../../../utils/media-catalog-mode'
import {
getMediaCatalogReadiness,
listMediaCatalogItems,
listPagedMediaCatalogItems
} from '../../../repositories/media-catalog-repository'
import {
isPageCoverThumbnailUrl,
isPostBodyThumbnailUrl,
@@ -172,7 +178,7 @@ const countMediaKinds = (items) => items.reduce((counts, item) => {
* @param {Record<string, unknown>} query - 요청 쿼리
* @returns {Object} 페이지 미디어 응답
*/
const createPagedMediaResponse = (items, query) => {
export const createPagedMediaResponse = (items, query) => {
const tab = normalizeMediaTab(query.tab)
const pageSize = normalizeMediaPageSize(query.pageSize)
const requestedPage = Math.max(1, Number(query.page) || 1)
@@ -220,6 +226,17 @@ const createPagedMediaResponse = (items, query) => {
}
}
/**
* 미디어 목록의 민감정보 없는 비교 수치를 만든다.
* @param {Array<Object>} items - 미디어 항목 목록
* @returns {{ count: number, bytes: number, kinds: Record<string, number> }} 비교 수치
*/
const createMediaComparisonSummary = (items) => ({
count: items.length,
bytes: items.reduce((total, item) => total + Number(item.size || 0), 0),
kinds: countMediaKinds(items)
})
/**
* 관리자 미디어 목록 API
* @param {import('h3').H3Event} event - 요청 이벤트
@@ -228,10 +245,53 @@ const createPagedMediaResponse = (items, query) => {
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const items = await listMediaItems()
const mode = getMediaCatalogMode()
const query = getQuery(event)
const paged = query.paged === '1' || query.paged === 'true'
let items
if (query.paged === '1' || query.paged === 'true') {
if (mode === 'database') {
try {
const readiness = await getMediaCatalogReadiness()
if (!readiness.ready) {
console.warn('[media-catalog] DB 카탈로그 준비 미완료, 파일시스템으로 대체', {
reason: readiness.reason
})
throw new Error('MEDIA_CATALOG_NOT_READY')
}
if (paged) {
return await listPagedMediaCatalogItems(query)
}
items = await listMediaCatalogItems()
} catch {
console.warn('[media-catalog] DB 목록 조회 실패, 파일시스템으로 대체')
items = await listMediaItems()
}
} else {
items = await listMediaItems()
if (mode === 'shadow') {
listMediaCatalogItems().then((catalogItems) => {
const filesystem = createMediaComparisonSummary(items)
const catalog = createMediaComparisonSummary(catalogItems)
console.info('[media-catalog] shadow 차이', {
count: catalog.count - filesystem.count,
bytes: catalog.bytes - filesystem.bytes,
kinds: Object.fromEntries(Object.keys(filesystem.kinds).map((kind) => [
kind,
Number(catalog.kinds[kind] || 0) - Number(filesystem.kinds[kind] || 0)
]))
})
}).catch(() => {
console.warn('[media-catalog] shadow 비교 실패')
})
}
}
if (paged) {
return createPagedMediaResponse(items, query)
}

View File

@@ -0,0 +1,14 @@
import { requireAdminSession } from '../../../../utils/admin-auth'
import { reconcileMediaCatalog } from '../../../../repositories/media-catalog-repository'
/**
* 관리자 수동 미디어 카탈로그 재동기화 API
* 파일을 삭제하거나 이동하지 않고 발견 파일 upsert와 누락 상태 표시만 수행한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 재동기화 비교 보고
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
return reconcileMediaCatalog()
})

View File

@@ -5,6 +5,7 @@ import {
createPostBodyThumbnailForImageUrl,
createPostThumbnailForImageUrl
} from '../../../../utils/post-thumbnail-image.js'
import { invalidateMediaItemsCache } from '../../../../utils/media-library.js'
/**
* 이미지 파생 파일 재생성 API
@@ -33,6 +34,8 @@ export default defineEventHandler(async (event) => {
})
}
invalidateMediaItemsCache()
return {
thumbnailUrl
}

View File

@@ -4,6 +4,7 @@ import { getMemberForAdmin, updateMemberAvatarByAdmin } from '../../../../../rep
import { removeManagedAvatarAsset } from '../../../../../utils/member-avatar'
import { uploadMemberAvatarImage } from '../../../../../utils/member-avatar-upload'
import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../../../../utils/media-library'
import { replaceMediaReferences } from '../../../../../repositories/media-catalog-repository'
/**
* 관리자 회원 썸네일 업로드 API
@@ -44,6 +45,13 @@ export default defineEventHandler(async (event) => {
}
await upsertMediaMetadataCategory(avatarUrl, MEDIA_THUMBNAIL_ROOT)
await replaceMediaReferences('user', memberId, {
avatarUrl
}, {
title: updated.username
}).catch(() => {
console.warn('[media-catalog] 회원 아바타 참조 갱신 실패')
})
if (member.avatarUrl && member.avatarUrl !== avatarUrl) {
await removeManagedAvatarAsset(member.avatarUrl)

View File

@@ -9,8 +9,9 @@ import {
} from 'h3'
import { requireAdminSession } from '../../../../../../utils/admin-auth'
import { getReadyPostExportFile } from '../../../../../../repositories/post-export-repository'
import { getUploadRoot } from '../../../../../../utils/upload-root'
const uploadRoot = join(process.cwd(), 'public', 'uploads')
const uploadRoot = getUploadRoot()
/**
* Export 파일 경로를 안전하게 해석한다.

View File

@@ -1,6 +1,10 @@
import { createError, readMultipartFormData } from 'h3'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createError } from 'h3'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { importPostsFromExportZip } from '../../../../repositories/post-import-repository'
import { saveMultipartFileToDisk } from '../../../../utils/multipart-file-upload'
const MAX_IMPORT_ZIP_BYTES = 300 * 1024 * 1024
@@ -11,43 +15,53 @@ const MAX_IMPORT_ZIP_BYTES = 300 * 1024 * 1024
*/
export default defineEventHandler(async (event) => {
const adminSession = requireAdminSession(event)
const formData = await readMultipartFormData(event)
const file = (formData || []).find((part) => part.name === 'file' && part.filename)
if (!file) {
throw createError({
statusCode: 400,
message: 'Import할 ZIP 파일을 선택해 주세요.'
})
}
if (!String(file.filename || '').toLowerCase().endsWith('.zip')) {
throw createError({
statusCode: 400,
message: 'ZIP 파일만 Import할 수 있습니다.'
})
}
if (!file.data?.length) {
throw createError({
statusCode: 400,
message: '비어 있는 ZIP 파일은 Import할 수 없습니다.'
})
}
if (file.data.length > MAX_IMPORT_ZIP_BYTES) {
throw createError({
statusCode: 413,
message: 'Import ZIP 파일은 최대 300MB까지 처리할 수 있습니다.'
})
}
const tempDirectory = await mkdtemp(join(tmpdir(), 'sori-studio-import-'))
const zipFilePath = join(tempDirectory, 'upload.zip')
try {
const uploaded = await saveMultipartFileToDisk(event, {
fieldName: 'file',
filePath: zipFilePath,
maxBytes: MAX_IMPORT_ZIP_BYTES
})
if (!String(uploaded.filename || '').toLowerCase().endsWith('.zip')) {
throw createError({
statusCode: 400,
message: 'ZIP 파일만 Import할 수 있습니다.'
})
}
if (!uploaded.size) {
throw createError({
statusCode: 400,
message: '비어 있는 ZIP 파일은 Import할 수 없습니다.'
})
}
return await importPostsFromExportZip({
zipBuffer: file.data,
zipFilePath,
authorId: adminSession.userId
})
} catch (error) {
if (error?.statusCode) {
throw error
}
if (error?.code === 'MULTIPART_FILE_TOO_LARGE') {
throw createError({
statusCode: 413,
message: 'Import ZIP 파일은 최대 300MB까지 처리할 수 있습니다.'
})
}
if (['MULTIPART_FILE_NOT_FOUND', 'INVALID_MULTIPART_REQUEST'].includes(error?.code)) {
throw createError({
statusCode: 400,
message: 'Import할 ZIP 파일을 선택해 주세요.'
})
}
if (error?.message === 'IMPORT_MARKDOWN_NOT_FOUND') {
throw createError({
statusCode: 400,
@@ -62,6 +76,30 @@ export default defineEventHandler(async (event) => {
})
}
if ([
'ZIP_ENTRY_COUNT_LIMIT_EXCEEDED',
'ZIP_ENTRY_SIZE_LIMIT_EXCEEDED',
'ZIP_TOTAL_SIZE_LIMIT_EXCEEDED',
'ZIP_COMPRESSION_RATIO_LIMIT_EXCEEDED',
'IMPORT_MARKDOWN_SIZE_LIMIT_EXCEEDED'
].includes(error?.message)) {
throw createError({
statusCode: 413,
message: 'ZIP 내부 파일의 개수·압축률 또는 압축 해제 크기가 허용 범위를 초과했습니다.'
})
}
if ([
'INVALID_ZIP_FILE',
'UNSAFE_ZIP_ENTRY_PATH',
'DUPLICATE_ZIP_ENTRY_PATH'
].includes(error?.message)) {
throw createError({
statusCode: 400,
message: '안전하게 처리할 수 없는 ZIP 파일입니다.'
})
}
if (error?.code === '23505') {
throw createError({
statusCode: 409,
@@ -73,5 +111,10 @@ export default defineEventHandler(async (event) => {
statusCode: 500,
message: '게시물 Import를 완료하지 못했습니다.'
})
} finally {
await rm(tempDirectory, {
recursive: true,
force: true
})
}
})

View File

@@ -0,0 +1,93 @@
import { createError, readBody } from 'h3'
import { requireAdminSession } from '../../../utils/admin-auth'
import { parseAdminSiteSettingsInput } from '../../../utils/admin-site-settings-input'
import {
getSiteSettings,
updateSiteSettingsPartial
} from '../../../repositories/content-repository'
const ALLOWED_SITE_SETTINGS_FIELDS = new Set([
'title',
'description',
'siteUrl',
'logoText',
'logoUrl',
'faviconUrl',
'copyrightText',
'socialLinks',
'showPostUpdatedAt',
'postTagLimit',
'homeLatestPostLimit',
'postListPageSize',
'tagPostListPageSize',
'featuredPostLimit',
'pageSectionItemLimit',
'uploadMaxFileCount',
'uploadMaxTotalSize',
'homeCoverImageUrl',
'homeCoverDarkImageUrl',
'homeCoverTitle',
'homeCoverText',
'brandColor',
'announcementEnabled',
'announcementText',
'announcementUrl',
'announcementBackgroundColor',
'announcementAlignment',
'signupBlockedUsernames',
'adsTxt',
'customHeadCode',
'customFooterCode',
'adHomeFeedCode',
'adHomeInfeedCode',
'adSidebarCode',
'adPostSidebarCode',
'adPostTopCode',
'adPostInArticleCode',
'adPostBottomCode'
])
/**
* 관리자 사이트 설정의 전달된 필드만 수정한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 수정된 사이트 설정
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const body = await readBody(event)
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw createError({
statusCode: 400,
message: '사이트 설정 입력 형식이 올바르지 않습니다.'
})
}
const fields = Object.keys(body)
if (!fields.length || fields.some((field) => !ALLOWED_SITE_SETTINGS_FIELDS.has(field))) {
throw createError({
statusCode: 400,
message: '수정할 수 없는 사이트 설정 필드가 포함되어 있습니다.'
})
}
const currentSettings = await getSiteSettings()
const parsedBody = parseAdminSiteSettingsInput({
...currentSettings,
...body
})
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '사이트 설정 입력 형식이 올바르지 않습니다.'
})
}
const validatedPatch = Object.fromEntries(fields.map((field) => [
field,
parsedBody.data[field]
]))
return updateSiteSettingsPartial(validatedPatch)
})

View File

@@ -4,6 +4,7 @@ import { createError, readMultipartFormData } from 'h3'
import sharp from 'sharp'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { upsertMediaMetadataCategory } from '../../../../utils/media-library'
import { getConfiguredUploadRoot } from '../../../../utils/upload-root'
const allowedImageTypes = new Set(['image/jpeg', 'image/png', 'image/webp'])
@@ -65,7 +66,7 @@ export default defineEventHandler(async (event) => {
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
const directoryPath = join(getConfiguredUploadRoot(publicBasePath), 'system')
const assetSuffix = createSystemAssetSuffix()
const coverFileName = `home-cover-${assetSuffix}.webp`
const coverPath = join(directoryPath, coverFileName)

View File

@@ -4,6 +4,7 @@ import { createError, readMultipartFormData } from 'h3'
import sharp from 'sharp'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { upsertMediaMetadataCategory } from '../../../../utils/media-library'
import { getConfiguredUploadRoot } from '../../../../utils/upload-root'
const allowedImageTypes = new Set(['image/jpeg', 'image/png', 'image/webp'])
@@ -87,7 +88,7 @@ export default defineEventHandler(async (event) => {
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
const directoryPath = join(getConfiguredUploadRoot(publicBasePath), 'system')
const assetSuffix = createSystemAssetSuffix()
const logoFileName = `logo-${assetSuffix}.webp`
const faviconFileName = `favicon-${assetSuffix}.png`

View File

@@ -4,6 +4,7 @@ import { createError, readMultipartFormData } from 'h3'
import sharp from 'sharp'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { upsertMediaMetadataCategory } from '../../../../utils/media-library'
import { getConfiguredUploadRoot } from '../../../../utils/upload-root'
const allowedImageTypes = new Set(['image/jpeg', 'image/png', 'image/webp'])
const tagCoverWidth = 720
@@ -64,7 +65,7 @@ export default defineEventHandler(async (event) => {
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
const directoryPath = join(getConfiguredUploadRoot(publicBasePath), 'system')
const assetSuffix = createSystemAssetSuffix()
const coverFileName = `tag-cover-${assetSuffix}.webp`
const coverPath = join(directoryPath, coverFileName)

View File

@@ -1,10 +1,9 @@
import { mkdir, stat, writeFile } from 'node:fs/promises'
import { link, mkdir, mkdtemp, rm, unlink } from 'node:fs/promises'
import { extname, join } from 'node:path'
import { createError, readMultipartFormData } from 'h3'
import { createError } from 'h3'
import {
buildDefaultUploadSizeLimits,
formatUploadSizeLimit,
getUploadFilesTotalSize,
getMaxUploadBytesForKind,
getUploadKind,
getUploadKindLabel,
@@ -14,6 +13,9 @@ import {
import { requireAdminSession } from '../../../utils/admin-auth'
import { getRuntimeEnvNumber } from '../../../utils/runtime-env.js'
import { upsertMediaMetadataCategory } from '../../../utils/media-library'
import { deleteMediaCatalogEntries } from '../../../repositories/media-catalog-repository'
import { saveMultipartFilesToDisk } from '../../../utils/multipart-file-upload'
import { getConfiguredUploadRoot } from '../../../utils/upload-root'
import { getSiteSettings } from '../../../repositories/content-repository'
const allowedUploadTypes = new Map([
@@ -72,7 +74,7 @@ const sanitizePathPart = (value) => String(value || '')
/**
* 파일 확장자 조회
* @param {Object} file - multipart 파일 파트
* @param {{ filename: string, type: string }} file - multipart 파일 정보
* @returns {string} 확장자
*/
const getUploadExtension = (file) => {
@@ -87,20 +89,21 @@ const getUploadExtension = (file) => {
/**
* 업로드 허용 파일인지 확인한다.
* @param {Object} file - multipart 파일 파트
* @param {{ filename: string, type: string }} file - multipart 파일 정보
* @returns {boolean} 허용 여부
*/
const isAllowedUploadFile = (file) => allowedUploadTypes.has(file.type)
|| allowedUploadExtensions.has(extname(file.filename || '').toLowerCase())
/**
* 디렉터리 안에서 비어 있는 저장 파일명을 고른다. 동일 이름이 있으면 `이름-2`, `이름-3` 식으로 넘버링한다.
* 임시 파일을 충돌하지 않는 최종 파일명으로 원자적으로 이동한다.
* @param {string} directoryPath - 저장 디렉터리 절대 경로
* @param {string} stem - 확장자 제외 파일명
* @param {string} extension - 확장자(점 포함, 예: `.png`)
* @param {string} sourcePath - 임시 파일 절대 경로
* @returns {Promise<{ fileName: string, filePath: string }>} 선택된 파일명과 절대 경로
*/
const pickUniqueDiskFileName = async (directoryPath, stem, extension) => {
const moveToUniqueDiskFileName = async (directoryPath, stem, extension, sourcePath) => {
let suffix = 1
while (suffix < 10000) {
@@ -108,13 +111,17 @@ const pickUniqueDiskFileName = async (directoryPath, stem, extension) => {
const filePath = join(directoryPath, fileName)
try {
await stat(filePath)
suffix += 1
} catch {
await link(sourcePath, filePath)
await unlink(sourcePath)
return {
fileName,
filePath
}
} catch (error) {
if (error?.code !== 'EEXIST') {
throw error
}
suffix += 1
}
}
@@ -142,79 +149,120 @@ export default defineEventHandler(async (event) => {
const settings = await getSiteSettings()
const uploadMaxFileCount = normalizeUploadMaxFileCount(settings.uploadMaxFileCount)
const uploadMaxTotalSize = normalizeUploadMaxTotalSize(settings.uploadMaxTotalSize)
const formData = await readMultipartFormData(event)
const files = (formData || []).filter((part) => part.name === 'files' && part.filename)
if (!files.length) {
throw createError({
statusCode: 400,
message: '업로드할 파일이 없습니다.'
})
}
if (files.length > uploadMaxFileCount) {
throw createError({
statusCode: 413,
message: `한 번에 업로드할 수 있는 파일은 최대 ${uploadMaxFileCount}개입니다.`
})
}
const uploadTotalSize = getUploadFilesTotalSize(files)
if (uploadTotalSize > uploadMaxTotalSize) {
throw createError({
statusCode: 413,
message: `한 번에 업로드 가능한 전체 용량을 초과했습니다. (최대 ${formatUploadSizeLimit(uploadMaxTotalSize)})`
})
}
const now = new Date()
const year = String(now.getFullYear())
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, 'posts', year, month)
const directoryPath = join(getConfiguredUploadRoot(publicBasePath), 'posts', year, month)
await mkdir(directoryPath, { recursive: true })
const tempDirectoryPath = await mkdtemp(join(directoryPath, '.upload-'))
const movedFilePaths = []
const catalogRecordedUrls = []
const uploadedFiles = []
try {
const streamedFiles = await saveMultipartFilesToDisk(event, {
fieldName: 'files',
maxFiles: uploadMaxFileCount,
maxTotalBytes: uploadMaxTotalSize,
createFileTarget: async (file, index) => {
const uploadFile = {
filename: file.filename,
type: file.mimeType
}
for (const file of files) {
if (!isAllowedUploadFile(file)) {
throw createError({
statusCode: 400,
message: '지원하지 않는 파일 형식입니다.'
if (!isAllowedUploadFile(uploadFile)) {
throw createError({
statusCode: 400,
message: '지원하지 않는 파일 형식입니다.'
})
}
const uploadKind = getUploadKind(uploadFile.type, uploadFile.filename)
const maxFileSize = getMaxUploadBytesForKind(uploadKind, uploadSizeLimits)
const originalStem = sanitizePathPart(uploadFile.filename.replace(/\.[^.]+$/g, '')) || 'file'
const extension = getUploadExtension(uploadFile)
return {
filePath: join(tempDirectoryPath, `${index}${extension}`),
maxBytes: maxFileSize,
metadata: {
originalStem,
extension,
uploadKind
}
}
}
})
const uploadedFiles = []
for (const file of streamedFiles) {
const { fileName, filePath } = await moveToUniqueDiskFileName(
directoryPath,
file.metadata.originalStem,
file.metadata.extension,
file.filePath
)
movedFilePaths.push(filePath)
const publicUrl = `${uploadBaseUrl}/posts/${year}/${month}/${fileName}`
await upsertMediaMetadataCategory(publicUrl, '미분류')
catalogRecordedUrls.push(publicUrl)
uploadedFiles.push({
url: publicUrl,
name: fileName,
size: file.size
})
}
const uploadKind = getUploadKind(file.type, file.filename)
const maxFileSize = getMaxUploadBytesForKind(uploadKind, uploadSizeLimits)
return {
files: uploadedFiles
}
} catch (error) {
await Promise.all(movedFilePaths.map((filePath) => unlink(filePath).catch(() => {})))
await deleteMediaCatalogEntries(catalogRecordedUrls).catch(() => {
console.warn('[media-catalog] 업로드 실패 정리 중 카탈로그 갱신 실패')
})
if (file.data.length > maxFileSize) {
if (error?.statusCode) {
throw error
}
if (error?.code === 'MULTIPART_TOO_MANY_FILES') {
throw createError({
statusCode: 413,
message: `${getUploadKindLabel(uploadKind)} 업로드 가능 크기를 초과했습니다. (최대 ${formatUploadSizeLimit(maxFileSize)})`
message: `한 번에 업로드할 수 있는 파일은 최대 ${uploadMaxFileCount}개입니다.`
})
}
if (error?.code === 'MULTIPART_TOTAL_SIZE_TOO_LARGE') {
throw createError({
statusCode: 413,
message: `한 번에 업로드 가능한 전체 용량을 초과했습니다. (최대 ${formatUploadSizeLimit(uploadMaxTotalSize)})`
})
}
if (error?.code === 'MULTIPART_FILE_TOO_LARGE') {
throw createError({
statusCode: 413,
message: `${getUploadKindLabel(error.metadata?.uploadKind)} 업로드 가능 크기를 초과했습니다. (최대 ${formatUploadSizeLimit(error.maxBytes)})`
})
}
if (['MULTIPART_FILE_NOT_FOUND', 'INVALID_MULTIPART_REQUEST', 'MULTIPART_TOO_MANY_PARTS'].includes(error?.code)) {
throw createError({
statusCode: 400,
message: '업로드할 파일이 없거나 요청 형식이 올바르지 않습니다.'
})
}
const originalStem = sanitizePathPart(file.filename.replace(/\.[^.]+$/g, '')) || 'image'
const extension = getUploadExtension(file)
const { fileName, filePath } = await pickUniqueDiskFileName(directoryPath, originalStem, extension)
await writeFile(filePath, file.data)
const publicUrl = `${uploadBaseUrl}/posts/${year}/${month}/${fileName}`
await upsertMediaMetadataCategory(publicUrl, '미분류')
uploadedFiles.push({
url: publicUrl,
name: fileName,
size: file.data.length
throw createError({
statusCode: 500,
message: '파일을 업로드하지 못했습니다.'
})
} finally {
await rm(tempDirectoryPath, {
recursive: true,
force: true
})
}
return {
files: uploadedFiles
}
})

View File

@@ -9,8 +9,9 @@ import {
setResponseHeader,
setResponseStatus
} from 'h3'
import { getUploadRoot } from '../../utils/upload-root'
const uploadRoot = join(process.cwd(), 'public', 'uploads')
const uploadRoot = getUploadRoot()
const contentTypes = new Map([
['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'],

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