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

@@ -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