""" Übungs-Fließtext: Inline-Verweise auf exercise_media.id (MEDIA_ASSETS_AND_ARCHIVE_SPEC §11). - Kurzsyntax beim Speichern → kanonisches . - Verweise müssen für die betreffende Übung gültige exercise_media-Zeilen sein. """ from __future__ import annotations import re from html.parser import HTMLParser from typing import FrozenSet, Optional, Set from html import escape as html_escape from fastapi import HTTPException # {{exerciseMedia:123}} (Whitespace tolerant, case-insensitive Schlüssel) _BRACE_PATTERN = re.compile(r"\{\{\s*exerciseMedia\s*:\s*(\d+)\s*\}\}", re.IGNORECASE) # bereits gespeichertes Markup (einfache Anführungszeichen-varianten durch Regex abgedeckt) _DATA_ATTR_PATTERN = re.compile(r'data-shinkan-exercise-media\s*=\s*["\']?(\d+)["\']?', re.IGNORECASE) RICH_HTML_EXERCISE_FIELDS: FrozenSet[str] = frozenset( {"summary", "goal", "execution", "preparation", "trainer_notes"} ) def normalize_inline_exercise_media_markup(html: Optional[str]) -> Optional[str]: """Wandelt {{exerciseMedia:id}} in kanonisches span mit data-shinkan-exercise-media.""" if html is None: return None if not isinstance(html, str): html = str(html) stripped = html.strip() if not stripped: return html def _repl(match: re.Match) -> str: mid = int(match.group(1)) return ( f'' ) return sanitize_exercise_rich_html(_BRACE_PATTERN.sub(_repl, html)) _ALLOWED_RICH_TAGS = frozenset( {"p", "div", "br", "ul", "ol", "li", "h3", "b", "strong", "i", "em", "u", "span", "a"} ) _DROP_WITH_CHILDREN = frozenset( {"script", "iframe", "object", "embed", "style", "link", "meta", "svg", "math"} ) _VOID_TAGS = frozenset({"br"}) _MEDIA_SIZES = frozenset({"small", "medium", "full"}) _CAPTION_MAX = 120 def _clean_caption(raw: str) -> str: text = re.sub(r"[\u0000-\u001f\u007f]", " ", raw or "") text = re.sub(r"[\"'`<>]", " ", text) text = re.sub(r"\s+", " ", text).strip() return text[:_CAPTION_MAX] def _safe_rich_attrs(tag: str, attrs: list[tuple[str, Optional[str]]]) -> list[tuple[str, str]]: values = {} for name, value in attrs: if not name: continue values[name.lower()] = "" if value is None else str(value) if tag == "a": href = values.get("href", "").strip() if href.lower().startswith(("http://", "https://")) and not re.search(r"[\u0000-\u001f\u007f]", href): return [("href", href)] return [] if tag != "span": return [] media_id = values.get("data-shinkan-exercise-media", "").strip() if not re.fullmatch(r"\d+", media_id): return [] out = [("data-shinkan-exercise-media", media_id)] size = values.get("data-shinkan-exercise-media-size", "").strip().lower() if size in _MEDIA_SIZES: out.append(("data-shinkan-exercise-media-size", size)) caption = _clean_caption(values.get("data-shinkan-exercise-media-caption", "")) if caption: out.append(("data-shinkan-exercise-media-caption", caption)) classes = [c for c in values.get("class", "").split() if c == "shinkan-inline-media"] if classes: out.append(("class", "shinkan-inline-media")) return out class _RichHtmlSanitizer(HTMLParser): """Baut erlaubtes Übungs-HTML neu auf. Unbekannte Tags fallen weg, ihr Text bleibt.""" def __init__(self) -> None: super().__init__(convert_charrefs=True) self.parts: list[str] = [] self._drop_depth = 0 self._stack: list[str] = [] def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None: self._start(tag.lower(), attrs, void=False) def handle_startendtag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None: self._start(tag.lower(), attrs, void=True) def _start(self, tag: str, attrs: list[tuple[str, Optional[str]]], *, void: bool) -> None: if self._drop_depth: if not void and tag not in _VOID_TAGS: self._drop_depth += 1 return if tag in _DROP_WITH_CHILDREN: if not void: self._drop_depth = 1 return if tag not in _ALLOWED_RICH_TAGS: return safe = _safe_rich_attrs(tag, attrs) rendered = tag if safe: rendered += "".join(f' {name}="{html_escape(value, quote=True)}"' for name, value in safe) if void or tag in _VOID_TAGS: self.parts.append(f"<{rendered}>") return self._stack.append(tag) self.parts.append(f"<{rendered}>") def handle_endtag(self, tag: str) -> None: tag = tag.lower() if self._drop_depth: if tag in _DROP_WITH_CHILDREN or self._drop_depth > 1: self._drop_depth = max(0, self._drop_depth - 1) return if self._stack and self._stack[-1] == tag: self._stack.pop() self.parts.append(f"") def handle_data(self, data: str) -> None: if self._drop_depth or not data: return self.parts.append(html_escape(data)) def handle_entityref(self, name: str) -> None: self.handle_data(f"&{name};") def handle_charref(self, name: str) -> None: self.handle_data(f"&#{name};") def sanitize_exercise_rich_html(html: Optional[str]) -> Optional[str]: """Allowlist für gespeichertes Übungs-HTML. Skripte, Handler und fremde Attribute fallen weg.""" if html is None: return None if not isinstance(html, str): html = str(html) if not html.strip(): return html parser = _RichHtmlSanitizer() parser.feed(html) parser.close() return "".join(parser.parts) def collect_inline_exercise_media_ids(html: Optional[str]) -> Set[int]: """Sammelt alle referenzierten exercise_media.ids aus Kurzsyntax und kanonischem Span.""" if html is None or not isinstance(html, str): return set() if not html.strip(): return set() ids: Set[int] = set() ids.update(int(m) for m in _BRACE_PATTERN.findall(html)) ids.update(int(m) for m in _DATA_ATTR_PATTERN.findall(html)) return ids def assert_no_inline_media_references_on_create(ids: Set[int]) -> None: """Neue Übung hat noch keine exercise_media-Zeilen — Platzhalter verboten.""" if not ids: return raise HTTPException( status_code=400, detail={ "code": "INLINE_EXERCISE_MEDIA_ON_CREATE", "message": ( "Medienverweise im Fließtext sind beim ersten Anlegen der Übung nicht möglich. " "Bitte Übung ohne Platzhalter speichern, Medien hochladen oder verknüpfen " "und die Verweise dann bearbeiten ({{exerciseMedia:id}} oder „Medium einfügen“)." ), "invalid_exercise_media_ids": sorted(ids), }, ) def validate_inline_exercise_media_ids_for_exercise(cur, exercise_id: int, ids: Set[int]) -> None: """Prüft, dass jede genannte exercise_media.id zu dieser Übung gehört.""" if not ids: return sid = sorted(ids) ph = ",".join(["%s"] * len(sid)) cur.execute( f"SELECT id FROM exercise_media WHERE exercise_id = %s AND id IN ({ph})", (exercise_id, *sid), ) found = set() for row in cur.fetchall(): rid = row["id"] if isinstance(row, dict) else row[0] found.add(int(rid)) missing = ids - found if missing: raise HTTPException( status_code=400, detail={ "code": "INLINE_EXERCISE_MEDIA_INVALID", "message": "Ein oder mehrere eingebettete Medien-Verweise gehören nicht zu dieser Übung.", "invalid_exercise_media_ids": sorted(missing), }, )