feat: CtLayout01 커스텀 콘텐츠 기능 개선
- fnCustomVideo 유튜브 팝업 함수 추가 - script/link exclude 속성 지원 - 전역 함수 등록 로직 통합 (registerGlobalFunctions) - CSS Selector injection 보안 취약점 수정 - 사용되지 않는 변수/props 제거 - DOMPurify exclude, defer 속성 허용 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,13 +11,12 @@
|
||||
cancelText?: string,
|
||||
) => void
|
||||
fnCustomLog: (clickSarea: string, clickItem: string) => void
|
||||
fnCustomVideo: (youtubeUrl: string) => void
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
components: PageDataTemplateComponents
|
||||
pageVerTmplSeq: number
|
||||
pageVerTmplNameEn: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -29,16 +28,18 @@
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const staticUrl = runtimeConfig.public.staticUrl
|
||||
|
||||
// CSR에서만 fnCustomAction, fnCustomLog 즉시 설정 (HTML 렌더링 전에 필요)
|
||||
// handleCustomAction, handleCustomLog가 정의되기 전이므로 나중에 업데이트됨
|
||||
// CSR에서만 fnCustomAction, fnCustomLog, fnCustomVideo 즉시 설정 (HTML 렌더링 전에 필요)
|
||||
// handleCustomAction, handleCustomLog, handleCustomVideo가 정의되기 전이므로 나중에 업데이트됨
|
||||
if (import.meta.client) {
|
||||
// onMounted에서 실제 함수로 교체됨
|
||||
;window.fnCustomAction = () => {
|
||||
// handleCustomAction이 아직 정의되지 않았을 수 있으므로 무시
|
||||
// onMounted에서 실제 함수로 교체됨
|
||||
}
|
||||
;window.fnCustomLog = () => {
|
||||
// handleCustomLog가 아직 정의되지 않았을 수 있으므로 무시
|
||||
// onMounted에서 실제 함수로 교체됨
|
||||
}
|
||||
;window.fnCustomVideo = () => {
|
||||
// handleCustomVideo가 아직 정의되지 않았을 수 있으므로 무시
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +70,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
// tm('key') 또는 tm("key") 형태의 문자열을 i18n 번역으로 변환
|
||||
// 일반 텍스트는 그대로 반환
|
||||
const resolveI18nText = (text: string): string => {
|
||||
if (!text) return ''
|
||||
|
||||
// tm('...') 또는 tm("...") 형태 감지
|
||||
const tmPattern = /^tm\(\s*(["'])([^"']+)\1\s*\)$/
|
||||
const match = text.trim().match(tmPattern)
|
||||
|
||||
if (match) {
|
||||
const key = match[2]
|
||||
return coerceToString(tm(key))
|
||||
}
|
||||
|
||||
// 일반 텍스트는 그대로 반환
|
||||
return text
|
||||
}
|
||||
|
||||
// API로 내려온 "HTML 문자열" 내부의 {{ tm('key') }} 형태를 현재 locale 기준 번역 문자열로 치환
|
||||
// (Vue 템플릿 컴파일이 일어나지 않으므로 사전 치환이 필요)
|
||||
const applyI18nToApiHtml = (html: string): string => {
|
||||
@@ -84,14 +103,12 @@
|
||||
|
||||
// fetch한 HTML을 저장할 ref
|
||||
const fetchedHtml = ref<string>('')
|
||||
const isLoadingHtml = ref(false)
|
||||
// script/link 상대 경로 해석용 베이스 URL (staticUrl + cleanedResPath)
|
||||
const assetBaseUrl = ref<string>('')
|
||||
|
||||
// URL에서 HTML 가져오기
|
||||
const fetchHtmlFromUrl = async (url: string): Promise<string> => {
|
||||
try {
|
||||
isLoadingHtml.value = true
|
||||
const response = await $fetch<string>(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -103,8 +120,6 @@
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Exception] Failed to fetch custom HTML:', error)
|
||||
return ''
|
||||
} finally {
|
||||
isLoadingHtml.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,20 +154,11 @@
|
||||
return
|
||||
}
|
||||
|
||||
// 로케일 코드 변환 맵 (소문자 하이픈 -> 대문자 하이픈)
|
||||
const localeMap: Record<string, string> = {
|
||||
'zh-tw': 'zh-TW',
|
||||
'zh-cn': 'zh-CN',
|
||||
}
|
||||
|
||||
// 현재 locale에 해당하는 번역 데이터 찾기
|
||||
const currentLocale = locale.value
|
||||
const normalizedLocale = localeMap[currentLocale] || currentLocale
|
||||
|
||||
// 대소문자 구분 없이 로케일 데이터 찾기
|
||||
const localeData =
|
||||
translations[normalizedLocale] ||
|
||||
translations[normalizedLocale.toLowerCase()] ||
|
||||
translations[currentLocale] ||
|
||||
translations[currentLocale.toLowerCase()]
|
||||
|
||||
@@ -347,9 +353,12 @@
|
||||
|
||||
const attrs = parseTagAttributes(tagHtml)
|
||||
|
||||
// exclude 속성이 있으면 건너뛰기 (boolean attribute)
|
||||
const hasExclude = /\bexclude\b/i.test(tagHtml)
|
||||
|
||||
if (tagName === 'link') {
|
||||
const href = attrs.href
|
||||
if (!href) continue
|
||||
if (!href || hasExclude) continue
|
||||
assets.push({
|
||||
kind: 'link',
|
||||
href,
|
||||
@@ -364,7 +373,7 @@
|
||||
|
||||
if (tagName === 'script') {
|
||||
const src = attrs.src
|
||||
if (!src) continue
|
||||
if (!src || hasExclude) continue
|
||||
assets.push({
|
||||
kind: 'script',
|
||||
src,
|
||||
@@ -386,15 +395,17 @@
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const href = resolveAssetUrl(asset.href)
|
||||
// 이미 로드된 스타일시트 확인
|
||||
const existingLink = document.querySelector(`link[href="${href}"]`)
|
||||
// 이미 로드된 스타일시트 확인 (selector injection 방지)
|
||||
const existingLink = Array.from(document.querySelectorAll('link')).find(
|
||||
el => el.getAttribute('href') === href
|
||||
)
|
||||
if (existingLink) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const link = document.createElement('link')
|
||||
link.rel = asset.rel || 'stylesheet'
|
||||
link.href = href
|
||||
@@ -407,7 +418,7 @@
|
||||
getAssetMountEl().appendChild(link)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// JS 파일 동적 로드
|
||||
const loadScript = (asset: Extract<OrderedAsset, { kind: 'script' }>): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -415,15 +426,17 @@
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const src = resolveAssetUrl(asset.src)
|
||||
// 이미 로드된 스크립트 확인
|
||||
const existingScript = document.querySelector(`script[src="${src}"]`)
|
||||
// 이미 로드된 스크립트 확인 (selector injection 방지)
|
||||
const existingScript = Array.from(document.querySelectorAll('script')).find(
|
||||
el => el.getAttribute('src') === src
|
||||
)
|
||||
if (existingScript) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = src
|
||||
script.defer = true
|
||||
@@ -450,30 +463,27 @@
|
||||
// inline <style> 주입 (작성 순서 보존 + 중복 방지)
|
||||
const loadInlineStyle = (asset: Extract<OrderedAsset, { kind: 'style' }>): void => {
|
||||
if (!import.meta.client) return
|
||||
const mountEl = getAssetMountEl()
|
||||
const cssText = asset.cssText || ''
|
||||
const key = hashString(cssText)
|
||||
|
||||
const existing = document.head.querySelector(`style[data-custom-style-hash="${key}"]`)
|
||||
// 동일한 위치에서 체크하고 삽입
|
||||
const existing = mountEl.querySelector(`style[data-custom-style-hash="${key}"]`)
|
||||
if (existing) return
|
||||
|
||||
const styleEl = document.createElement('style')
|
||||
styleEl.setAttribute('data-custom-style-hash', key)
|
||||
styleEl.textContent = cssText
|
||||
getAssetMountEl().appendChild(styleEl)
|
||||
mountEl.appendChild(styleEl)
|
||||
}
|
||||
|
||||
// 스크립트 로드 완료 여부 추적
|
||||
const scriptsLoaded = ref(false)
|
||||
|
||||
|
||||
const modalStore = useModalStore()
|
||||
|
||||
// 로그 전송 함수 (fnCustomLog로 노출)
|
||||
// - 호출 방식: fnCustomLog('로그값') 또는 fnCustomLog(element)
|
||||
// - 호출 방식: fnCustomLog('clickSarea', 'clickItem')
|
||||
const handleCustomLog = (clickSarea: string, clickItem: string | null) => {
|
||||
if (!import.meta.client) return
|
||||
if (clickItem == null) return
|
||||
|
||||
|
||||
// null, undefined, 빈 문자열 모두 체크
|
||||
if (!clickSarea || !clickItem) return
|
||||
|
||||
handleSendLog(clickSarea, clickItem)
|
||||
@@ -513,44 +523,66 @@
|
||||
? cancelText || ''
|
||||
: actionOrEl.getAttribute('data-cancel-text') || ''
|
||||
|
||||
// i18n 변환 적용 (tm('key') 형태 또는 일반 텍스트)
|
||||
const resolvedContent = contentRaw ? resolveI18nText(contentRaw) : ''
|
||||
const resolvedConfirmText = confirmButtonText ? resolveI18nText(confirmButtonText) : ''
|
||||
const resolvedCancelText = cancelButtonText ? resolveI18nText(cancelButtonText) : ''
|
||||
|
||||
// HTML 내부의 {{ tm('key') }} 형태도 변환
|
||||
const contentText = resolvedContent ? applyI18nToApiHtml(resolvedContent) : ''
|
||||
|
||||
// 1) open-confirm
|
||||
if (action === 'open-confirm') {
|
||||
const contentText = contentRaw ? applyI18nToApiHtml(contentRaw) : ''
|
||||
if (contentText) {
|
||||
modalStore.handleOpenConfirm({
|
||||
contentText,
|
||||
...(confirmButtonText ? { confirmButtonText } : {}),
|
||||
...(cancelButtonText ? { cancelButtonText } : {}),
|
||||
...(resolvedConfirmText ? { confirmButtonText: resolvedConfirmText } : {}),
|
||||
...(resolvedCancelText ? { cancelButtonText: resolvedCancelText } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (action === 'open-alert') {
|
||||
const contentText = contentRaw ? applyI18nToApiHtml(contentRaw) : ''
|
||||
if (contentText) {
|
||||
modalStore.handleOpenAlert({
|
||||
contentText,
|
||||
...(confirmButtonText ? { confirmButtonText } : {}),
|
||||
...(cancelButtonText ? { cancelButtonText } : {}),
|
||||
...(resolvedConfirmText ? { confirmButtonText: resolvedConfirmText } : {}),
|
||||
...(resolvedCancelText ? { cancelButtonText: resolvedCancelText } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 유튜브 팝업 호출 함수 (fnCustomVideo로 노출)
|
||||
// - 호출 방식: fnCustomVideo("tm('Ci_Video_ID')") 또는 fnCustomVideo('youtube_video_id')
|
||||
const handleCustomVideo = (youtubeUrl: string) => {
|
||||
if (!import.meta.client) return
|
||||
if (!youtubeUrl) return
|
||||
|
||||
// i18n 변환 적용 (tm('key') 형태 또는 일반 텍스트)
|
||||
const resolvedUrl = resolveI18nText(youtubeUrl)
|
||||
if (!resolvedUrl) return
|
||||
|
||||
modalStore.handleOpenYoutube({ youtubeUrl: resolvedUrl })
|
||||
}
|
||||
|
||||
// 전역 함수 등록 헬퍼
|
||||
const registerGlobalFunctions = () => {
|
||||
if (!import.meta.client) return
|
||||
window.fnCustomAction = handleCustomAction
|
||||
window.fnCustomLog = handleCustomLog
|
||||
window.fnCustomVideo = handleCustomVideo
|
||||
}
|
||||
|
||||
// custom_contents 변경 시 link/script를 "작성된 순서"대로 순차 로드
|
||||
watch(
|
||||
() => customContentsRaw.value,
|
||||
async (html, _prev, onCleanup) => {
|
||||
if (!import.meta.client) return
|
||||
if (!html) return
|
||||
|
||||
// HTML이 업데이트될 때마다 fnCustomAction, fnCustomLog 재설정 (CSR에서만)
|
||||
if (import.meta.client) {
|
||||
;window.fnCustomAction = handleCustomAction
|
||||
;window.fnCustomLog = handleCustomLog
|
||||
}
|
||||
|
||||
scriptsLoaded.value = false
|
||||
|
||||
|
||||
// HTML이 업데이트될 때마다 전역 함수 재설정
|
||||
registerGlobalFunctions()
|
||||
|
||||
let cancelled = false
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
@@ -623,9 +655,7 @@
|
||||
const container = customContainerRef.value
|
||||
if (!container) return
|
||||
|
||||
;window.fnCustomAction = handleCustomAction
|
||||
;window.fnCustomLog = handleCustomLog
|
||||
scriptsLoaded.value = false
|
||||
registerGlobalFunctions()
|
||||
|
||||
// DOM이 실제로 렌더링될 때까지 대기
|
||||
await waitForDomReady(container)
|
||||
@@ -640,13 +670,10 @@
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[CustomContent] Script load error:', err)
|
||||
}
|
||||
scriptsLoaded.value = true
|
||||
;window.fnCustomAction = handleCustomAction
|
||||
;window.fnCustomLog = handleCustomLog
|
||||
// 동적 로드 시점에는 DOMContentLoaded/load가 이미 발생했으므로, 스크립트에서 구독할 커스텀 이벤트 발생
|
||||
if (import.meta.client) {
|
||||
window.dispatchEvent(new CustomEvent('customContentReady'))
|
||||
}
|
||||
|
||||
// 스크립트 로드 완료 후 전역 함수 재등록 및 이벤트 발생
|
||||
registerGlobalFunctions()
|
||||
window.dispatchEvent(new CustomEvent('customContentReady'))
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -666,9 +693,8 @@
|
||||
const container = customContainerRef.value
|
||||
if (!container) return
|
||||
|
||||
// fnCustomAction, fnCustomLog 전역 등록
|
||||
window.fnCustomAction = handleCustomAction
|
||||
window.fnCustomLog = handleCustomLog
|
||||
// 전역 함수 등록
|
||||
registerGlobalFunctions()
|
||||
|
||||
// 이벤트 위임 등록
|
||||
container.addEventListener('click', onDelegatedClick)
|
||||
@@ -687,25 +713,20 @@
|
||||
if (w.fnCustomLog === handleCustomLog) {
|
||||
delete w.fnCustomLog
|
||||
}
|
||||
if (w.fnCustomVideo === handleCustomVideo) {
|
||||
delete w.fnCustomVideo
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="section-standard">
|
||||
<div
|
||||
v-if="customContentsHtml"
|
||||
ref="customContainerRef"
|
||||
v-dompurify-admin="customContentsHtml"
|
||||
class="custom-container"
|
||||
></div>
|
||||
</section>
|
||||
<div
|
||||
v-if="customContentsHtml"
|
||||
ref="customContainerRef"
|
||||
v-dompurify-admin="customContentsHtml"
|
||||
class="custom-container"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
|
||||
<style>
|
||||
.custom-container {
|
||||
min-height: 20rem;
|
||||
background-color: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user