From cc91d99b48764c8aa35f383717a844ca9ed7dbfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Chyeonggkim=E2=80=9D?= <“hyeonggkim@smilegate.com”> Date: Wed, 21 Jan 2026 18:44:08 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=20=EC=B6=94=EA=B0=80=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- layers/composables/useTemplateRegistry.ts | 2 + layers/plugins/dompurify-html.ts | 197 +++++--- layers/templates/CtLayout01/index.vue | 536 ++++++++++++++++++++++ 3 files changed, 683 insertions(+), 52 deletions(-) create mode 100644 layers/templates/CtLayout01/index.vue diff --git a/layers/composables/useTemplateRegistry.ts b/layers/composables/useTemplateRegistry.ts index 422e707..b3451f2 100644 --- a/layers/composables/useTemplateRegistry.ts +++ b/layers/composables/useTemplateRegistry.ts @@ -14,6 +14,7 @@ import FxDownload01 from '#layers/templates/FxDownload01/index.vue' import FxCoupon01 from '#layers/templates/FxCoupon01/index.vue' import FxSecure01 from '#layers/templates/FxSecure01/index.vue' import FxPreregist01 from '#layers/templates/FxPreregist01/index.vue' +import CtLayout01 from '#layers/templates/CtLayout01/index.vue' const templateRegistry = { GR_VISUAL_01: { component: GrVisual01 }, @@ -32,6 +33,7 @@ const templateRegistry = { FX_COUPON_01: { component: FxCoupon01 }, FX_SECURE_01: { component: FxSecure01 }, FX_PREREGIST_01: { component: FxPreregist01 }, + CT_LAYOUT_01: { component: CtLayout01 }, } as const type TemplateKey = keyof typeof templateRegistry diff --git a/layers/plugins/dompurify-html.ts b/layers/plugins/dompurify-html.ts index 59277c0..461ee31 100644 --- a/layers/plugins/dompurify-html.ts +++ b/layers/plugins/dompurify-html.ts @@ -1,55 +1,148 @@ -import VueDOMPurifyHTML from 'vue-dompurify-html' +import DOMPurify from 'dompurify' -export default defineNuxtPlugin(nuxtApp => { - nuxtApp.vueApp.use(VueDOMPurifyHTML, { - default: { - ALLOWED_TAGS: [ - 'br', - 'div', - 'b', - 'strong', - 'i', - 'em', - 'u', - 's', - 'a', - 'p', - 'ol', - 'ul', - 'li', - 'span', - 'img', - 'pre', - 'iframe', - 'input', - 'dl', - 'dt', - 'dd', - 'blockquote', - 'table', - 'thead', - 'tbody', - 'tfoot', - 'tr', - 'th', - 'td', - ], - ALLOWED_ATTR: [ - 'style', - 'id', - 'href', - 'src', - 'target', - 'alt', - 'class', - 'width', - 'height', - 'frameborder', - 'allowfullscreen', - 'colspan', - 'rowspan', - 'scope', - ], +import type { DirectiveBinding } from 'vue' + +// DOMPurify 설정 타입 +interface DOMPurifyConfig { + ALLOWED_TAGS: string[] + ALLOWED_ATTR: string[] +} + +// 공통 허용 태그 +const commonTags = [ + 'br', + 'div', + 'b', + 'strong', + 'i', + 'em', + 'u', + 'a', + 'p', + 'ul', + 'li', + 's', + 'ol', + 'button', + 'span', + 'img', + 'pre', + 'input', + 'dl', + 'dt', + 'dd', + 'table', + 'thead', + 'tbody', + 'tfoot', + 'tr', + 'th', + 'td', +] as const + +// 공통 허용 속성 +const commonAttrs = [ + 'style', + 'id', + 'href', + 'src', + 'target', + 'alt', + 'class', + 'width', + 'height', + 'frameborder', + 'colspan', + 'rowspan', + 'scope', +] as const + +// 기본 설정 (일반 사용자용) +const defaultConfig: DOMPurifyConfig = { + ALLOWED_TAGS: [ + ...commonTags + ], + ALLOWED_ATTR: [ + ...commonAttrs + ], +} + +// 관리자용 설정 (확장된 태그 및 속성) +const adminConfig: DOMPurifyConfig = { + ALLOWED_TAGS: [ + ...commonTags, + 'iframe', + 'link', + 'style', + 'blockquote', + ], + ALLOWED_ATTR: [ + ...commonAttrs, + 'onclick', + 'rel', + 'media', + 'integrity', + 'crossorigin', + 'referrerpolicy', + 'allowfullscreen', + ], +} + +/** + * HTML 문자열을 안전하게 정화하는 헬퍼 함수 + * @param value - 정화할 HTML 문자열 + * @param config - DOMPurify 설정 + * @returns 정화된 HTML 문자열 + */ +function sanitizeHtml(value: unknown, config: DOMPurifyConfig): string { + // SSR 환경에서는 빈 문자열 반환 + if (!import.meta.client) { + return '' + } + + // 값이 없거나 문자열이 아닌 경우 빈 문자열 반환 + if (!value || typeof value !== 'string') { + return '' + } + + try { + return DOMPurify.sanitize(value, config) + } catch (error) { + // 에러 발생 시 원본 값 반환하지 않고 빈 문자열 반환 (보안상 안전) + if (import.meta.dev) { + // eslint-disable-next-line no-console + console.error('[DOMPurify] Sanitization error:', error) + } + return '' + } +} + +/** + * 디렉티브 핸들러 생성 함수 + * @param config - DOMPurify 설정 + * @returns 디렉티브 핸들러 객체 + */ +function createDirectiveHandler(config: DOMPurifyConfig) { + return { + mounted(el: HTMLElement, binding: DirectiveBinding) { + el.innerHTML = sanitizeHtml(binding.value, config) }, - }) + updated(el: HTMLElement, binding: DirectiveBinding) { + el.innerHTML = sanitizeHtml(binding.value, config) + }, + } +} + +export default defineNuxtPlugin((nuxtApp) => { + // SSR 환경에서는 디렉티브 등록하지 않음 + // if (!import.meta.client) { + // return + // } + + // 기본 디렉티브: v-dompurify-html + nuxtApp.vueApp.directive('dompurify-html', createDirectiveHandler(defaultConfig)) + + // 관리자용 디렉티브: v-dompurify-admin + nuxtApp.vueApp.directive('dompurify-admin', createDirectiveHandler(adminConfig)) }) + diff --git a/layers/templates/CtLayout01/index.vue b/layers/templates/CtLayout01/index.vue new file mode 100644 index 0000000..cef4121 --- /dev/null +++ b/layers/templates/CtLayout01/index.vue @@ -0,0 +1,536 @@ + + + + + + + {{ tm('Newreturnevent_Section03_Title') }} + + + + + + \ No newline at end of file