PluginProbe
The Innovative Form Builder – IvyForms / 0.5
The Innovative Form Builder – IvyForms v0.5
1.5.1 1.5 1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 All 27 releases
ivyforms / frontend / src / views / _components / SafeHTML.vue

SafeHTML.vue in The Innovative Form Builder – IvyForms 0.5, at frontend/src/views/_components/SafeHTML.vue

201 lines 4.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <template>
2 <div ref="containerRef" class="ivyforms-safe-html"></div>
3 </template>
4
5 <script setup lang="ts">
6 import { ref, onMounted, watch, nextTick } from 'vue'
7 import DOMPurify from 'dompurify'
8
9 interface Props {
10 html: string
11 fieldType?: string
12 allowedTags?: string[]
13 allowedAttributes?: Record<string, string[]>
14 }
15
16 const props = withDefaults(defineProps<Props>(), {
17 fieldType: 'unknown',
18 allowedTags: undefined,
19 allowedAttributes: undefined,
20 })
21
22 const containerRef = ref<HTMLDivElement>()
23
24 /**
25 * Get DOMPurify configuration based on field type
26 */
27 const getDOMPurifyConfig = (): DOMPurify.Config => {
28 const baseConfig: DOMPurify.Config = {
29 RETURN_DOM_FRAGMENT: false,
30 RETURN_DOM: false,
31 ALLOWED_URI_REGEXP:
32 /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|data):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i,
33 }
34
35 // Field-specific configurations
36 switch (props.fieldType) {
37 case 'signature':
38 return {
39 ...baseConfig,
40 ALLOWED_TAGS: ['img'],
41 ALLOWED_ATTR: ['src', 'alt', 'style', 'class', 'width', 'height'],
42 ALLOWED_URI_REGEXP: /^data:image\/(png|jpeg|jpg|gif|svg\+xml);base64,/i,
43 }
44
45 case 'rich-text':
46 return {
47 ...baseConfig,
48 ALLOWED_TAGS: [
49 'p',
50 'br',
51 'strong',
52 'b',
53 'em',
54 'i',
55 'u',
56 'a',
57 'ul',
58 'ol',
59 'li',
60 'h1',
61 'h2',
62 'h3',
63 'h4',
64 'h5',
65 'h6',
66 'blockquote',
67 'code',
68 'pre',
69 'span',
70 'div',
71 ],
72 ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style'],
73 }
74
75 case 'chart':
76 case 'visualization':
77 return {
78 ...baseConfig,
79 ALLOWED_TAGS: ['div', 'svg', 'canvas', 'img'],
80 ALLOWED_ATTR: ['class', 'style', 'width', 'height', 'viewBox', 'src', 'alt', 'data-*'],
81 }
82
83 default:
84 // Custom configuration if provided
85 if (props.allowedTags || props.allowedAttributes) {
86 return {
87 ...baseConfig,
88 ALLOWED_TAGS: props.allowedTags,
89 ALLOWED_ATTR: props.allowedAttributes
90 ? Object.values(props.allowedAttributes).flat()
91 : undefined,
92 }
93 }
94
95 // Fallback: very restrictive
96 return {
97 ...baseConfig,
98 ALLOWED_TAGS: ['span', 'div'],
99 ALLOWED_ATTR: ['class'],
100 }
101 }
102 }
103
104 /**
105 * Sanitize HTML using DOMPurify with field-specific configuration
106 */
107 const sanitizeHTML = (html: string): string => {
108 if (!html || typeof html !== 'string') {
109 return ''
110 }
111
112 const config = getDOMPurifyConfig()
113
114 try {
115 // Sanitize the HTML
116 const clean = DOMPurify.sanitize(html, config)
117
118 return clean
119 } catch (error) {
120 console.error('SafeHTML: Error sanitizing HTML:', error)
121 return ''
122 }
123 }
124
125 /**
126 * Render sanitized HTML into the container
127 */
128 const renderHTML = () => {
129 if (!containerRef.value) return
130
131 const sanitized = sanitizeHTML(props.html)
132
133 // Set the sanitized HTML
134 containerRef.value.innerHTML = sanitized
135 }
136
137 // Render on mount and when HTML changes
138 onMounted(() => {
139 nextTick(() => {
140 renderHTML()
141 })
142 })
143
144 watch(
145 () => props.html,
146 () => {
147 nextTick(() => {
148 renderHTML()
149 })
150 },
151 )
152 </script>
153
154 <style scoped lang="scss">
155 .ivyforms-safe-html {
156 // Base styles for sanitized HTML content
157
158 :deep(img) {
159 max-width: 100%;
160 height: auto;
161 display: block;
162 }
163
164 :deep(a) {
165 color: var(--map-base-brand-symbol-0);
166 text-decoration: none;
167
168 &:hover {
169 text-decoration: underline;
170 }
171 }
172
173 :deep(code) {
174 background-color: var(--map-base-dusk-o05);
175 padding: 2px 6px;
176 border-radius: 4px;
177 font-family: monospace;
178 font-size: 0.9em;
179 }
180
181 :deep(pre) {
182 background-color: var(--map-base-dusk-o05);
183 padding: 12px;
184 border-radius: 8px;
185 overflow-x: auto;
186
187 code {
188 background: none;
189 padding: 0;
190 }
191 }
192
193 :deep(blockquote) {
194 border-left: 4px solid var(--map-base-dusk-stroke-0);
195 padding-left: 12px;
196 margin: 8px 0;
197 color: var(--map-base-text-1);
198 }
199 }
200 </style>
201