2026-05-08 11:44:29 +02:00
|
|
|
/**
|
|
|
|
|
* Sanitizer für Übungs-Rich-HTML inkl. §11 Platzhalter (span data-shinkan-exercise-media).
|
2026-10-13 16:47:57 +02:00
|
|
|
* DOMPurify mit fester Allowlist.
|
2026-05-08 11:44:29 +02:00
|
|
|
*/
|
|
|
|
|
|
2026-10-13 16:47:57 +02:00
|
|
|
import DOMPurify from 'dompurify'
|
2026-05-08 12:20:24 +02:00
|
|
|
import { sanitizeInlineMediaCaption } from './inlineMediaCaption'
|
|
|
|
|
|
2026-10-13 16:47:57 +02:00
|
|
|
const ALLOWED_TAGS = ['p', 'div', 'br', 'ul', 'ol', 'li', 'h3', 'b', 'strong', 'i', 'em', 'u', 'span', 'a']
|
|
|
|
|
const ALLOWED_ATTR = [
|
|
|
|
|
'href',
|
|
|
|
|
'class',
|
|
|
|
|
'data-shinkan-exercise-media',
|
|
|
|
|
'data-shinkan-exercise-media-size',
|
|
|
|
|
'data-shinkan-exercise-media-caption',
|
|
|
|
|
]
|
|
|
|
|
const SIZE_OK = new Set(['small', 'medium', 'full'])
|
2026-05-08 11:44:29 +02:00
|
|
|
|
2026-10-13 16:47:57 +02:00
|
|
|
function hookAttribute(_node, data) {
|
|
|
|
|
const name = data.attrName
|
|
|
|
|
if (name === 'href') {
|
|
|
|
|
const href = String(data.attrValue || '').trim()
|
|
|
|
|
if (!/^https?:\/\//i.test(href)) data.keepAttr = false
|
|
|
|
|
else data.attrValue = href
|
|
|
|
|
return
|
2026-05-08 11:44:29 +02:00
|
|
|
}
|
2026-10-13 16:47:57 +02:00
|
|
|
if (name === 'class') {
|
|
|
|
|
const keep = String(data.attrValue || '')
|
|
|
|
|
.split(/\s+/)
|
|
|
|
|
.filter((c) => c === 'shinkan-inline-media')
|
|
|
|
|
if (!keep.length) data.keepAttr = false
|
|
|
|
|
else data.attrValue = keep.join(' ')
|
|
|
|
|
return
|
2026-05-08 11:44:29 +02:00
|
|
|
}
|
2026-10-13 16:47:57 +02:00
|
|
|
if (name === 'data-shinkan-exercise-media') {
|
|
|
|
|
if (!/^\d+$/.test(String(data.attrValue || '').trim())) data.keepAttr = false
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (name === 'data-shinkan-exercise-media-size') {
|
|
|
|
|
const size = String(data.attrValue || '').trim().toLowerCase()
|
|
|
|
|
if (!SIZE_OK.has(size)) data.keepAttr = false
|
|
|
|
|
else data.attrValue = size
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (name === 'data-shinkan-exercise-media-caption') {
|
|
|
|
|
const caption = sanitizeInlineMediaCaption(String(data.attrValue || ''))
|
|
|
|
|
if (!caption) data.keepAttr = false
|
|
|
|
|
else data.attrValue = caption
|
2026-05-08 11:44:29 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 16:47:57 +02:00
|
|
|
const instances = new WeakMap()
|
|
|
|
|
|
|
|
|
|
function purifyFor(win) {
|
|
|
|
|
const existing = instances.get(win)
|
|
|
|
|
if (existing) return existing
|
|
|
|
|
const purify = DOMPurify(win)
|
|
|
|
|
purify.addHook('uponSanitizeAttribute', hookAttribute)
|
|
|
|
|
instances.set(win, purify)
|
|
|
|
|
return purify
|
2026-05-08 11:44:29 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {string|null|undefined} html
|
2026-10-13 16:47:57 +02:00
|
|
|
* @param {Window=} win
|
2026-05-08 11:44:29 +02:00
|
|
|
* @returns {string}
|
|
|
|
|
*/
|
2026-10-13 16:47:57 +02:00
|
|
|
export function sanitizeExerciseRichDisplayHtml(html, win) {
|
2026-05-08 11:44:29 +02:00
|
|
|
if (html == null || typeof html !== 'string') return ''
|
|
|
|
|
const trimmed = html.trim()
|
|
|
|
|
if (!trimmed) return ''
|
2026-10-13 16:47:57 +02:00
|
|
|
const target = win || (typeof window !== 'undefined' ? window : null)
|
|
|
|
|
if (!target) return ''
|
|
|
|
|
return purifyFor(target).sanitize(trimmed, {
|
|
|
|
|
ALLOWED_TAGS,
|
|
|
|
|
ALLOWED_ATTR,
|
|
|
|
|
ALLOW_DATA_ATTR: false,
|
|
|
|
|
})
|
2026-05-08 11:44:29 +02:00
|
|
|
}
|