공개 페이지 전환 경고 정리
This commit is contained in:
11
app.vue
11
app.vue
@@ -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))
|
||||
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
/**
|
||||
* 브라우저에서 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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
/**
|
||||
* 사용자 메뉴를 닫는다.
|
||||
|
||||
34
composables/usePublicSiteSettings.js
Normal file
34
composables/usePublicSiteSettings.js
Normal 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
|
||||
})
|
||||
@@ -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 분리 |
|
||||
|
||||
## 관리자 페이지
|
||||
|
||||
|
||||
@@ -898,6 +898,7 @@ components/content/
|
||||
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
|
||||
- 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. 오른쪽 사이드바 로고는 48px 정사각형으로 고정해 옆 텍스트에 밀려 줄어들지 않게 한다. `favicon_url`은 head의 icon 링크로 연결한다.
|
||||
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
|
||||
- 공개 앱·헤더·오른쪽 사이드바·어나운스 바·홈·목록·태그·상세·회원가입 화면의 공개 사이트 설정 조회는 `usePublicSiteSettings` composable의 `site-settings-public` key와 공통 기본값을 공유한다.
|
||||
|
||||
### 메뉴/네비게이션
|
||||
|
||||
|
||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.56",
|
||||
"version": "1.11.57",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: () => []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user