Some checks failed
Deploy Development / deploy (push) Successful in 59s
Test Suite / pytest-backend (push) Failing after 47s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 17s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Failing after 7m48s
A superadmin can also turn off another account''s two-factor when the authenticator is lost. Co-authored-by: Cursor <cursoragent@cursor.com>
223 lines
7.8 KiB
Python
223 lines
7.8 KiB
Python
"""
|
|
Übungs-Fließtext: Inline-Verweise auf exercise_media.id (MEDIA_ASSETS_AND_ARCHIVE_SPEC §11).
|
|
|
|
- Kurzsyntax beim Speichern → kanonisches <span data-shinkan-exercise-media="…">.
|
|
- 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'<span data-shinkan-exercise-media="{mid}" data-shinkan-exercise-media-size="medium" '
|
|
f'class="shinkan-inline-media"></span>'
|
|
)
|
|
|
|
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"</{tag}>")
|
|
|
|
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),
|
|
},
|
|
)
|