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

@@ -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
}
/**