공개 페이지 전환 경고 정리

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

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()
/**
* 사용자 메뉴를 닫는다.