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
- 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.
112 lines
3.7 KiB
JavaScript
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
|
|
}
|