v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
50
server/repositories/csp-report-repository.js
Normal file
50
server/repositories/csp-report-repository.js
Normal 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()
|
||||
`
|
||||
}
|
||||
})
|
||||
}
|
||||
1166
server/repositories/media-catalog-repository.js
Normal file
1166
server/repositories/media-catalog-repository.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
`
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import postgres from 'postgres'
|
||||
import { getRuntimeEnvValue } from '../utils/runtime-env'
|
||||
import { getRuntimeEnvValue } from '../utils/runtime-env.js'
|
||||
|
||||
let client = null
|
||||
|
||||
|
||||
Reference in New Issue
Block a user