shinkan-jinkendo/frontend/src/utils/exerciseRichTextSanitize.js
Lars 337f29401b
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 30s
feat(exercises): update inline media functionality and version bump to 0.8.63
- Incremented application version to 0.8.63 and updated changelog with new features.
- Enhanced inline media handling in the Rich Text Editor, including support for captions.
- Introduced new CSS styles for improved media display and layout in the editor.
- Replaced `ExerciseMediaEmbed` with `ExerciseAttachmentMediaStrip` for better media management in exercise content.
2026-05-08 12:20:24 +02:00

112 lines
3.7 KiB
JavaScript

/**
* Sanitizer für Übungs-Rich-HTML inkl. §11 Platzhalter (span data-shinkan-exercise-media).
* Restriktiver als sanitizeTrainerHtml: Allowlist für XSS-Minimierung.
*/
import { sanitizeInlineMediaCaption } from './inlineMediaCaption'
const ALLOWED_BLOCK = new Set(['p', 'div', 'br', 'ul', 'ol', 'li', 'h3'])
const ALLOWED_INLINE = new Set(['b', 'strong', 'i', 'em', 'u', 'span', 'a'])
function isHttpsUrl(val) {
if (!val || typeof val !== 'string') return false
const s = val.trim()
return s.startsWith('http://') || s.startsWith('https://')
}
/** Nur für unsere Embed-Markierung: erlaubt data-attribut und optionale Marker-Klasse + Größe. */
const _SIZE_OK = new Set(['small', 'medium', 'full'])
function isInlineExerciseMediaPlaceholderSpan(el) {
if (!el?.getAttribute || el.tagName.toLowerCase() !== 'span') return false
const raw = el.getAttribute('data-shinkan-exercise-media')
if (!raw || !String(raw).trim().match(/^\d+$/)) return false
return true
}
function sanitizeAttributes(el, tagLower) {
if (tagLower === 'a') {
const href = el.getAttribute('href')
if (href && isHttpsUrl(href)) {
const out = document.createElement('a')
out.setAttribute('href', href.trim())
return out.attributes
}
return []
}
if (tagLower === 'span' && isInlineExerciseMediaPlaceholderSpan(el)) {
const out = document.createElement('span')
out.setAttribute('data-shinkan-exercise-media', el.getAttribute('data-shinkan-exercise-media').trim())
const sz = (el.getAttribute('data-shinkan-exercise-media-size') || '').trim().toLowerCase()
if (sz && _SIZE_OK.has(sz)) {
out.setAttribute('data-shinkan-exercise-media-size', sz)
}
const capRaw = el.getAttribute('data-shinkan-exercise-media-caption')
if (capRaw != null && String(capRaw).trim()) {
const cap = sanitizeInlineMediaCaption(String(capRaw))
if (cap) out.setAttribute('data-shinkan-exercise-media-caption', cap)
}
const cls = (el.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean)
const keep = cls.filter((c) => c === 'shinkan-inline-media')
if (keep.length) out.setAttribute('class', keep.join(' '))
return out.attributes
}
return []
}
function cloneAsAllowed(tagLower, el) {
const fresh = document.createElement(tagLower)
const attrs = sanitizeAttributes(el, tagLower)
for (const a of attrs) {
fresh.setAttribute(a.name, a.value)
}
return fresh
}
function cleanTree(parent) {
const nodes = Array.from(parent.childNodes)
for (const node of nodes) {
if (node.nodeType === Node.TEXT_NODE) continue
if (node.nodeType === Node.COMMENT_NODE) {
parent.removeChild(node)
continue
}
if (node.nodeType !== Node.ELEMENT_NODE) {
parent.removeChild(node)
continue
}
const tag = node.tagName.toLowerCase()
if (tag === 'script' || tag === 'iframe' || tag === 'object' || tag === 'embed') {
parent.removeChild(node)
continue
}
if (ALLOWED_BLOCK.has(tag) || ALLOWED_INLINE.has(tag)) {
const repl = cloneAsAllowed(tag, node)
while (node.firstChild) {
repl.appendChild(node.firstChild)
}
cleanTree(repl)
parent.replaceChild(repl, node)
continue
}
while (node.firstChild) {
parent.insertBefore(node.firstChild, node)
}
parent.removeChild(node)
}
}
/**
* @param {string|null|undefined} html
* @returns {string}
*/
export function sanitizeExerciseRichDisplayHtml(html) {
if (html == null || typeof html !== 'string') return ''
const trimmed = html.trim()
if (!trimmed) return ''
const tpl = document.createElement('template')
tpl.innerHTML = trimmed
cleanTree(tpl.content)
return tpl.innerHTML
}