v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user