공개 페이지 전환 경고 정리

This commit is contained in:
2026-07-24 16:01:11 +09:00
parent 24dc6b8af0
commit b38eb234d8
16 changed files with 252 additions and 64 deletions

11
app.vue
View File

@@ -1,16 +1,7 @@
<script setup>
import { DEFAULT_BRAND_COLOR, normalizeBrandColor } from './lib/brand-color.js'
const { data: appSiteSettings } = await useFetch('/api/site-settings', {
key: 'site-settings-public',
default: () => ({
title: 'sori.studio',
faviconUrl: '',
logoUrl: '',
logoText: '井',
brandColor: DEFAULT_BRAND_COLOR
})
})
const { data: appSiteSettings } = await usePublicSiteSettings()
const siteAccentColor = computed(() => normalizeBrandColor(appSiteSettings.value?.brandColor || DEFAULT_BRAND_COLOR))

View File

@@ -1,6 +1,4 @@
<script setup>
import { sanitizeHtmlBlock } from '../../lib/markdown-html-block.js'
const props = defineProps({
html: {
type: String,
@@ -8,6 +6,203 @@ const props = defineProps({
}
})
const allowedHtmlBlockTags = new Set([
'a',
'abbr',
'b',
'blockquote',
'br',
'caption',
'code',
'col',
'colgroup',
'dd',
'details',
'div',
'dl',
'dt',
'em',
'figcaption',
'figure',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'img',
'li',
'mark',
'ol',
'p',
'pre',
's',
'small',
'span',
'strong',
'sub',
'summary',
'sup',
'table',
'tbody',
'td',
'tfoot',
'th',
'thead',
'time',
'tr',
'u',
'ul'
])
const globalAllowedAttributes = new Set(['class', 'title', 'aria-label', 'aria-hidden'])
const allowedAttributesByTag = {
a: new Set(['href', 'target', 'rel']),
col: new Set(['span', 'width']),
img: new Set(['src', 'alt', 'width', 'height', 'loading']),
li: new Set(['value']),
ol: new Set(['start', 'type']),
td: new Set(['colspan', 'rowspan', 'headers']),
th: new Set(['colspan', 'rowspan', 'headers', 'scope']),
time: new Set(['datetime'])
}
const urlAttributeNames = new Set(['href', 'src'])
const allowedUrlSchemes = new Set(['http:', 'https:', 'mailto:', 'tel:'])
/**
* HTML 텍스트를 escape한다.
* @param {unknown} value - 원본 값
* @returns {string} HTML escape 문자열
*/
const escapeHtmlText = (value) => String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
/**
* 브라우저에서 URL 속성 값을 허용할지 확인한다.
* @param {string} value - URL 값
* @returns {boolean} 허용 여부
*/
const isAllowedBrowserUrl = (value) => {
const trimmedValue = String(value || '').trim()
if (!trimmedValue || trimmedValue.startsWith('//')) {
return false
}
if (trimmedValue.startsWith('/') || trimmedValue.startsWith('#')) {
return true
}
try {
return allowedUrlSchemes.has(new URL(trimmedValue, window.location.origin).protocol)
} catch {
return false
}
}
/**
* 브라우저에서 HTML 노드를 허용 목록 기준으로 정리한다.
* @param {Node} node - 정리할 노드
* @returns {Node|null} 정리된 노드
*/
const sanitizeBrowserNode = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
return document.createTextNode(node.textContent || '')
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return null
}
const element = /** @type {Element} */ (node)
const tagName = element.tagName.toLowerCase()
if (!allowedHtmlBlockTags.has(tagName)) {
const fragment = document.createDocumentFragment()
Array.from(element.childNodes).forEach((childNode) => {
const sanitizedChild = sanitizeBrowserNode(childNode)
if (sanitizedChild) {
fragment.appendChild(sanitizedChild)
}
})
return fragment
}
const sanitizedElement = document.createElement(tagName)
const tagAllowedAttributes = allowedAttributesByTag[tagName] || new Set()
Array.from(element.attributes).forEach((attribute) => {
const attributeName = attribute.name.toLowerCase()
if (!globalAllowedAttributes.has(attributeName) && !tagAllowedAttributes.has(attributeName)) {
return
}
if (urlAttributeNames.has(attributeName) && !isAllowedBrowserUrl(attribute.value)) {
return
}
sanitizedElement.setAttribute(attributeName, attribute.value)
})
if (tagName === 'a' && sanitizedElement.getAttribute('target') === '_blank') {
const relValues = new Set(String(sanitizedElement.getAttribute('rel') || '').split(/\s+/).filter(Boolean))
relValues.add('noopener')
relValues.add('noreferrer')
sanitizedElement.setAttribute('rel', [...relValues].join(' '))
}
Array.from(element.childNodes).forEach((childNode) => {
const sanitizedChild = sanitizeBrowserNode(childNode)
if (sanitizedChild) {
sanitizedElement.appendChild(sanitizedChild)
}
})
return sanitizedElement
}
/**
* 브라우저에서 HTML 블록을 정리한다.
* @param {unknown} value - 원본 HTML
* @returns {string} 정리된 HTML
*/
const sanitizeHtmlBlockInBrowser = (value) => {
const html = String(value || '').trim()
if (!html) {
return ''
}
if (!import.meta.client || typeof DOMParser === 'undefined') {
return escapeHtmlText(html)
}
const parser = new DOMParser()
const documentFragment = document.createDocumentFragment()
const parsedDocument = parser.parseFromString(`<template>${html}</template>`, 'text/html')
const template = parsedDocument.querySelector('template')
Array.from(template?.content.childNodes || []).forEach((node) => {
const sanitizedNode = sanitizeBrowserNode(node)
if (sanitizedNode) {
documentFragment.appendChild(sanitizedNode)
}
})
const container = document.createElement('div')
container.appendChild(documentFragment)
return container.innerHTML.trim()
}
const sanitizeHtmlBlock = import.meta.server
? (await import('../../lib/markdown-html-block.js')).sanitizeHtmlBlock
: sanitizeHtmlBlockInBrowser
const sanitizedHtml = computed(() => sanitizeHtmlBlock(props.html))
</script>

View File

@@ -7,18 +7,7 @@ const tocNavRef = ref(null)
const activeTocId = ref('')
let tocScrollFrame = 0
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
title: 'sori.studio',
description: 'sori.studio 개인 블로그',
logoText: '井',
logoUrl: '',
socialLinks: [],
adSidebarCode: '',
adPostSidebarCode: '',
copyrightText: '©2026 sori.studio'
})
})
const { data: siteSettings } = await usePublicSiteSettings()
const isPostDetailRoute = computed(() => route.path.startsWith('/post/'))
const sidebarAdCode = computed(() => isPostDetailRoute.value ? siteSettings.value?.adPostSidebarCode : siteSettings.value?.adSidebarCode)

View File

@@ -11,16 +11,7 @@ import {
/** @type {number} 슬라이드 애니메이션 시간(ms) */
const SLIDE_MS = 320
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
announcementEnabled: false,
announcementText: '',
announcementUrl: '',
announcementBackgroundColor: '#15171a',
announcementAlignment: 'center',
updatedAt: null
})
})
const { data: siteSettings } = await usePublicSiteSettings()
/** DOM에 바를 둘지(애니메이션 종료 전까지 유지) */
const inDom = ref(false)

View File

@@ -6,11 +6,7 @@ const userMenuToggleRef = ref(null)
const searchOpen = ref(false)
const member = ref(null)
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
title: 'sori.studio'
})
})
const { data: siteSettings } = await usePublicSiteSettings()
/**
* 사용자 메뉴를 닫는다.

View File

@@ -0,0 +1,34 @@
import { DEFAULT_BRAND_COLOR } from '~/lib/brand-color.js'
/**
* 공개 사이트 설정 기본값을 만든다.
* @returns {Object} 공개 사이트 설정 기본값
*/
export const createDefaultPublicSiteSettings = () => ({
title: 'sori.studio',
description: 'sori.studio 개인 블로그',
faviconUrl: '',
logoUrl: '',
logoText: '井',
brandColor: DEFAULT_BRAND_COLOR,
socialLinks: [],
adSidebarCode: '',
adPostSidebarCode: '',
copyrightText: '©2026 sori.studio',
announcementEnabled: false,
announcementText: '',
announcementUrl: '',
announcementBackgroundColor: '#15171a',
announcementAlignment: 'center',
updatedAt: null,
signupBlockedUsernames: []
})
/**
* 공개 사이트 설정을 공통 key와 옵션으로 조회한다.
* @returns {Promise<import('nuxt/app').AsyncData<Object, Error | null>>} 사이트 설정 async data
*/
export const usePublicSiteSettings = () => useFetch('/api/site-settings', {
key: 'site-settings-public',
default: createDefaultPublicSiteSettings
})

View File

@@ -26,6 +26,7 @@
| composables/useAdminFeaturedImagePicker.js | 관리자 게시물 폼 대표 이미지 선택 모달의 검색·페이지네이션·업로드·선택 상태 |
| composables/useAdminPostPublishState.js | 관리자 게시물 폼 발행·예약·발행취소 상태와 날짜 입력 상태 |
| composables/useAdminMediaThumbnailRegeneration.js | 관리자 미디어 화면 대표 이미지·페이지 커버·본문 이미지 재생성 가능 여부와 API 호출 상태 |
| composables/usePublicSiteSettings.js | 공개 화면의 `/api/site-settings` 조회 key(`site-settings-public`)와 기본 설정값 통일 |
## 공유 라이브러리(서버·클라이언트 공통)
@@ -264,7 +265,7 @@
| components/content/ProseSignup.vue | 본문 회원가입·뉴스레터 CTA 카드 |
| components/content/ProseHeaderCard.vue | 헤더 카드 |
| components/content/ProseEmbed.vue | 상단 외부 여백 없이 하단 여백만 두는 YouTube 영상 iframe, X/Twitter 소셜 iframe, Mastodon `/embed` iframe, 제한된 사용자 iframe HTML, 기타 `http(s)` 외부 링크 |
| components/content/ProseHtml.vue | `:::html` 본문 HTML 렌더링, 표 기본 스타일과 허용 목록 sanitizer 적용 |
| components/content/ProseHtml.vue | `:::html` 본문 HTML 렌더링, 표 기본 스타일과 허용 목록 sanitizer 적용, 서버 전용 `sanitize-html`과 브라우저 DOM sanitizer 분리 |
## 관리자 페이지

View File

@@ -898,6 +898,7 @@ components/content/
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
- 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. 오른쪽 사이드바 로고는 48px 정사각형으로 고정해 옆 텍스트에 밀려 줄어들지 않게 한다. `favicon_url`은 head의 icon 링크로 연결한다.
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
- 공개 앱·헤더·오른쪽 사이드바·어나운스 바·홈·목록·태그·상세·회원가입 화면의 공개 사이트 설정 조회는 `usePublicSiteSettings` composable의 `site-settings-public` key와 공통 기본값을 공유한다.
### 메뉴/네비게이션

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.11.57
- 공개 사이트 설정 조회를 `usePublicSiteSettings` composable로 통합해 Nuxt `site-settings-public` async data 옵션 충돌 경고를 제거.
- HTML 블록 렌더러의 `sanitize-html` 정적 import를 서버 전용 동적 import로 바꿔 브라우저 번들 유입을 차단.
- `/posts?page=2` SSR 응답과 게시물 페이지 API 응답, 프로덕션 빌드에서 페이지네이션 회귀를 확인.
## v1.11.56
- 공개 Markdown 렌더러의 라이트박스 이미지 목록과 갤러리 행 분할 계산을 `lib/content-gallery-images.js`로 분리.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.11.56",
"version": "1.11.57",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.11.56",
"version": "1.11.57",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.11.56",
"version": "1.11.57",
"private": true,
"type": "module",
"imports": {

View File

@@ -6,10 +6,7 @@ import {
normalizeDisplayCount
} from '~/lib/display-count-settings.js'
const { data: siteSettings } = await useFetch('/api/site-settings', {
key: 'site-settings-public',
default: () => ({})
})
const { data: siteSettings } = await usePublicSiteSettings()
const homeLatestPostLimit = computed(() => normalizeDisplayCount(siteSettings.value?.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT))
const featuredPostLimit = computed(() => normalizeDisplayCount(siteSettings.value?.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT))

View File

@@ -16,10 +16,7 @@ const { data: post } = await useFetch(() => `/api/posts/${slug.value}`)
const { data: tags } = useLazyFetch('/api/tags?tagType=all', {
default: () => []
})
const { data: siteSettings } = useLazyFetch('/api/site-settings', {
key: 'site-settings-public',
default: () => ({})
})
const { data: siteSettings } = await usePublicSiteSettings()
const postToc = useState('post-detail-toc', () => [])
if (!post.value) {

View File

@@ -7,9 +7,7 @@ import {
const route = useRoute()
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({})
})
const { data: siteSettings } = await usePublicSiteSettings()
const { data: tags } = await useFetch('/api/tags?tagType=all', {
default: () => []

View File

@@ -14,13 +14,7 @@ const signupCompleted = ref(false)
const createdAdmin = ref(false)
const statusMessage = ref('')
const submitErrorMessage = ref('')
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
title: 'AFFiNE',
description: 'Configure your Self Host AFFiNE with a few simple settings.',
signupBlockedUsernames: []
})
})
const { data: siteSettings } = await usePublicSiteSettings()
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
default: () => ({
hasUsers: true,

View File

@@ -28,9 +28,7 @@ if (!tag.value) {
})
}
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({})
})
const { data: siteSettings } = await usePublicSiteSettings()
const currentPage = computed(() => normalizeListPage(route.query.page))
const tagPostListPageSize = computed(() => normalizeDisplayCount(siteSettings.value?.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE))