Close medium exposure on reports, registration, uploads, and exercise HTML.
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
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>
This commit is contained in:
parent
5ea225ec6b
commit
f10c28e5cd
|
|
@ -7,6 +7,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
||||||
| profiles | `GET /api/profiles/me` | ja | `resolve_tenant_context` inline (`invalid_header_policy=ignore`) | teils | + `effective_club_id`; veralteter Header bricht Refresh nicht |
|
| profiles | `GET /api/profiles/me` | ja | `resolve_tenant_context` inline (`invalid_header_policy=ignore`) | teils | + `effective_club_id`; veralteter Header bricht Refresh nicht |
|
||||||
| profiles | `GET /api/profiles`, `GET /profiles/{pid}`, `POST /profiles`, `DELETE /profiles/{pid}` | ja/teils | `require_auth` | ja | Liste nur Plattform-Admin; GET nach ID eigenes Profil oder Admin; POST/DELETE nur Admin |
|
| profiles | `GET /api/profiles`, `GET /profiles/{pid}`, `POST /profiles`, `DELETE /profiles/{pid}` | ja/teils | `require_auth` | ja | Liste nur Plattform-Admin; GET nach ID eigenes Profil oder Admin; POST/DELETE nur Admin |
|
||||||
| profiles | `PUT /api/profiles/{id}`, `PUT /api/profile` | ja | `get_tenant_context` | `active_club_id` Mitgliedschaft | Validiert `X-Active-Club-Id` konsistent zu Mitgliedschaft |
|
| profiles | `PUT /api/profiles/{id}`, `PUT /api/profile` | ja | `get_tenant_context` | `active_club_id` Mitgliedschaft | Validiert `X-Active-Club-Id` konsistent zu Mitgliedschaft |
|
||||||
|
| profiles | `POST /api/profiles/{id}/totp-reset` | Plattform | `get_tenant_context` | nur `superadmin`, nicht das eigene Konto | Schaltet TOTP ab, löscht Wiederherstellungscodes, beendet Sitzungen |
|
||||||
| clubs | geschützte `/api/clubs*`, `/divisions*`, `/groups*` | ja | `get_tenant_context` | Mitgliedschaft / `can_manage_*` | Öffentlich: `/clubs/public-directory` ohne Auth |
|
| clubs | geschützte `/api/clubs*`, `/divisions*`, `/groups*` | ja | `get_tenant_context` | Mitgliedschaft / `can_manage_*` | Öffentlich: `/clubs/public-directory` ohne Auth |
|
||||||
| club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | |
|
| club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | |
|
||||||
| club_join_requests | `/me/club-join-requests`, `/clubs/{id}/join-requests*` | ja | `get_tenant_context` | ja | |
|
| club_join_requests | `/me/club-join-requests`, `/clubs/{id}/join-requests*` | ja | `get_tenant_context` | ja | |
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from html.parser import HTMLParser
|
||||||
from typing import FrozenSet, Optional, Set
|
from typing import FrozenSet, Optional, Set
|
||||||
|
from html import escape as html_escape
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
@ -40,7 +42,126 @@ def normalize_inline_exercise_media_markup(html: Optional[str]) -> Optional[str]
|
||||||
f'class="shinkan-inline-media"></span>'
|
f'class="shinkan-inline-media"></span>'
|
||||||
)
|
)
|
||||||
|
|
||||||
return _BRACE_PATTERN.sub(_repl, html)
|
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]:
|
def collect_inline_exercise_media_ids(html: Optional[str]) -> Set[int]:
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,10 @@ class RegisterRequest(BaseModel):
|
||||||
requested_club_id: Optional[int] = Field(default=None, ge=1)
|
requested_club_id: Optional[int] = Field(default=None, ge=1)
|
||||||
|
|
||||||
class PasswordResetRequest(BaseModel):
|
class PasswordResetRequest(BaseModel):
|
||||||
email: str
|
email: EmailStr
|
||||||
|
|
||||||
|
class ResendVerificationRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
class PasswordResetConfirm(BaseModel):
|
class PasswordResetConfirm(BaseModel):
|
||||||
token: str
|
token: str
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ Neues Passwort setzen:
|
||||||
|
|
||||||
Der Link ist 1 Stunde gültig. Erst wenn du ihn nutzt und ein neues Passwort wählst, wird dein bestehendes
|
Der Link ist 1 Stunde gültig. Erst wenn du ihn nutzt und ein neues Passwort wählst, wird dein bestehendes
|
||||||
Passwort ersetzt — bis dahin kannst du dich wie gewohnt anmelden.
|
Passwort ersetzt — bis dahin kannst du dich wie gewohnt anmelden.
|
||||||
|
Der Link meldet dich nicht an und schaltet die Zwei-Faktor-Anmeldung nicht ab.
|
||||||
|
|
||||||
Falls du diese Anfrage nicht erwartest, ignoriere diese E-Mail; dein Zugang bleibt unverändert.
|
Falls du diese Anfrage nicht erwartest, ignoriere diese E-Mail; dein Zugang bleibt unverändert.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ router = APIRouter(prefix="/api/admin", tags=["admin_users"])
|
||||||
|
|
||||||
_SAFE_PROFILE_COLS = """
|
_SAFE_PROFILE_COLS = """
|
||||||
id, name, email, role, tier, email_verified, active_club_id,
|
id, name, email, role, tier, email_verified, active_club_id,
|
||||||
created_at, updated_at, auth_type
|
created_at, updated_at, auth_type, totp_enabled
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ from models import (
|
||||||
PasswordResetRequest,
|
PasswordResetRequest,
|
||||||
PasswordResetConfirm,
|
PasswordResetConfirm,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
|
ResendVerificationRequest,
|
||||||
TotpCodeRequest,
|
TotpCodeRequest,
|
||||||
TotpDisableRequest,
|
TotpDisableRequest,
|
||||||
)
|
)
|
||||||
|
|
@ -643,55 +644,76 @@ async def register(req: RegisterRequest, request: Request):
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
# Check if email already exists
|
cur.execute("SELECT id, name FROM profiles WHERE email=%s", (email,))
|
||||||
cur.execute("SELECT id FROM profiles WHERE email=%s", (email,))
|
existing = cur.fetchone()
|
||||||
if cur.fetchone():
|
if existing:
|
||||||
raise HTTPException(400, "E-Mail-Adresse bereits registriert")
|
existing_name = existing.get("name")
|
||||||
|
else:
|
||||||
|
existing_name = None
|
||||||
|
|
||||||
# Generate verification token
|
if existing is None:
|
||||||
verification_token = secrets.token_urlsafe(32)
|
# Generate verification token
|
||||||
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
|
verification_token = secrets.token_urlsafe(32)
|
||||||
|
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
|
||||||
|
|
||||||
# Rolle: erster Nutzer oder ADMIN_BOOTSTRAP_EMAILS → superadmin
|
# Rolle: erster Nutzer oder ADMIN_BOOTSTRAP_EMAILS → superadmin
|
||||||
role = registration_role(cur, email)
|
role = registration_role(cur, email)
|
||||||
|
|
||||||
# Create profile (inactive until verified) — profiles.id ist SERIAL (INT), keine String-IDs einfügen.
|
# Create profile (inactive until verified) — profiles.id ist SERIAL (INT), keine String-IDs einfügen.
|
||||||
pin_hash = hash_pin(password)
|
pin_hash = hash_pin(password)
|
||||||
trial_ends = datetime.now(timezone.utc) + timedelta(days=14) # 14-day trial
|
trial_ends = datetime.now(timezone.utc) + timedelta(days=14) # 14-day trial
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO profiles (
|
INSERT INTO profiles (
|
||||||
name, email, pin_hash, auth_type, role, tier,
|
name, email, pin_hash, auth_type, role, tier,
|
||||||
email_verified, verification_token, verification_expires,
|
email_verified, verification_token, verification_expires,
|
||||||
trial_ends_at, created_at
|
trial_ends_at, created_at
|
||||||
) VALUES (%s, %s, %s, 'email', %s, 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
|
) VALUES (%s, %s, %s, 'email', %s, 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""", (name, email, pin_hash, role, verification_token, verification_expires, trial_ends))
|
""", (name, email, pin_hash, role, verification_token, verification_expires, trial_ends))
|
||||||
new_profile_id = cur.fetchone()["id"]
|
new_profile_id = cur.fetchone()["id"]
|
||||||
|
|
||||||
req_club = req.requested_club_id
|
req_club = req.requested_club_id
|
||||||
if req_club is not None:
|
if req_club is not None:
|
||||||
cur.execute(
|
|
||||||
"SELECT id FROM clubs WHERE id = %s AND status = 'active'",
|
|
||||||
(int(req_club),),
|
|
||||||
)
|
|
||||||
if cur.fetchone():
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"SELECT id FROM clubs WHERE id = %s AND status = 'active'",
|
||||||
SELECT id FROM club_membership_requests
|
(int(req_club),),
|
||||||
WHERE profile_id = %s AND club_id = %s AND status = 'pending'
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(new_profile_id, int(req_club)),
|
|
||||||
)
|
)
|
||||||
if not cur.fetchone():
|
if cur.fetchone():
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO club_membership_requests (profile_id, club_id, status, message)
|
SELECT id FROM club_membership_requests
|
||||||
VALUES (%s, %s, 'pending', NULL)
|
WHERE profile_id = %s AND club_id = %s AND status = 'pending'
|
||||||
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
(new_profile_id, int(req_club)),
|
(new_profile_id, int(req_club)),
|
||||||
)
|
)
|
||||||
|
if not cur.fetchone():
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO club_membership_requests (profile_id, club_id, status, message)
|
||||||
|
VALUES (%s, %s, 'pending', NULL)
|
||||||
|
""",
|
||||||
|
(new_profile_id, int(req_club)),
|
||||||
|
)
|
||||||
|
|
||||||
|
generic = {
|
||||||
|
"ok": True,
|
||||||
|
"message": "Registrierung erfolgreich! Bitte prüfe dein E-Mail-Postfach und bestätige deine E-Mail-Adresse.",
|
||||||
|
}
|
||||||
|
if existing is not None:
|
||||||
|
send_email(
|
||||||
|
email,
|
||||||
|
"Shinkan Jinkendo – Registrierung",
|
||||||
|
f"""Hallo {existing_name or name},
|
||||||
|
|
||||||
|
für diese E-Mail-Adresse gibt es bereits ein Konto. Es wurde kein neues Passwort gesetzt.
|
||||||
|
Wenn du den Zugang brauchst, melde dich an oder fordere ein neues Passwort an.
|
||||||
|
|
||||||
|
Dein Shinkan Jinkendo Team
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
return generic
|
||||||
|
|
||||||
verify_url = verification_link(verification_token)
|
verify_url = verification_link(verification_token)
|
||||||
|
|
||||||
|
|
@ -711,10 +733,7 @@ Dein Shinkan Jinkendo Team
|
||||||
if not send_email(email, "Shinkan Jinkendo – E-Mail bestätigen", email_body):
|
if not send_email(email, "Shinkan Jinkendo – E-Mail bestätigen", email_body):
|
||||||
print("[SMTP] Verifizierungs-Mail konnte nicht gesendet werden — Logs prüfen, SMTP_* in der Laufzeitumgebung.")
|
print("[SMTP] Verifizierungs-Mail konnte nicht gesendet werden — Logs prüfen, SMTP_* in der Laufzeitumgebung.")
|
||||||
|
|
||||||
return {
|
return generic
|
||||||
"ok": True,
|
|
||||||
"message": "Registrierung erfolgreich! Bitte prüfe dein E-Mail-Postfach und bestätige deine E-Mail-Adresse."
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/verify/{token}")
|
@router.get("/verify/{token}")
|
||||||
|
|
@ -773,44 +792,34 @@ async def verify_email(token: str):
|
||||||
|
|
||||||
@router.post("/resend-verification")
|
@router.post("/resend-verification")
|
||||||
@limiter.limit("3/hour")
|
@limiter.limit("3/hour")
|
||||||
async def resend_verification(req: dict, request: Request):
|
async def resend_verification(req: ResendVerificationRequest, request: Request):
|
||||||
"""Resend verification email for unverified account."""
|
"""Bestätigung erneut senden. Bekannte und unbekannte Adressen antworten gleich."""
|
||||||
email = req.get('email', '').strip().lower()
|
email = str(req.email).lower().strip()
|
||||||
|
generic = {
|
||||||
if not email:
|
"ok": True,
|
||||||
raise HTTPException(400, "E-Mail-Adresse erforderlich")
|
"message": "Falls ein Account mit dieser E-Mail existiert, wurde eine Bestätigungs-E-Mail versendet.",
|
||||||
|
}
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
# Find profile by email
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id, name, email, email_verified, verification_token, verification_expires
|
SELECT id, name, email, email_verified, verification_token, verification_expires
|
||||||
FROM profiles
|
FROM profiles
|
||||||
WHERE email=%s
|
WHERE email=%s
|
||||||
""", (email,))
|
""", (email,))
|
||||||
|
|
||||||
prof = cur.fetchone()
|
prof = cur.fetchone()
|
||||||
|
if not prof or prof["email_verified"]:
|
||||||
|
return generic
|
||||||
|
|
||||||
if not prof:
|
|
||||||
# Don't leak info about existing emails
|
|
||||||
return {"ok": True, "message": "Falls ein Account mit dieser E-Mail existiert, wurde eine Bestätigungs-E-Mail versendet."}
|
|
||||||
|
|
||||||
if prof['email_verified']:
|
|
||||||
raise HTTPException(400, "E-Mail-Adresse bereits bestätigt")
|
|
||||||
|
|
||||||
# Generate new verification token
|
|
||||||
verification_token = secrets.token_urlsafe(32)
|
verification_token = secrets.token_urlsafe(32)
|
||||||
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
|
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
UPDATE profiles
|
UPDATE profiles
|
||||||
SET verification_token=%s, verification_expires=%s
|
SET verification_token=%s, verification_expires=%s
|
||||||
WHERE id=%s
|
WHERE id=%s
|
||||||
""", (verification_token, verification_expires, prof['id']))
|
""", (verification_token, verification_expires, prof["id"]))
|
||||||
|
|
||||||
verify_url = verification_link(verification_token)
|
verify_url = verification_link(verification_token)
|
||||||
|
|
||||||
email_body = f"""Hallo {prof['name']},
|
email_body = f"""Hallo {prof['name']},
|
||||||
|
|
||||||
du hast eine neue Bestätigungs-E-Mail angefordert.
|
du hast eine neue Bestätigungs-E-Mail angefordert.
|
||||||
|
|
@ -823,8 +832,5 @@ Dieser Link ist 24 Stunden gültig.
|
||||||
|
|
||||||
Dein Shinkan Jinkendo Team
|
Dein Shinkan Jinkendo Team
|
||||||
"""
|
"""
|
||||||
|
send_email(email, "Shinkan Jinkendo – E-Mail bestätigen", email_body)
|
||||||
if not send_email(email, "Shinkan Jinkendo – E-Mail bestätigen", email_body):
|
return generic
|
||||||
raise HTTPException(502, "E-Mail konnte nicht versendet werden. SMTP-Einstellungen und Container-Logs prüfen.")
|
|
||||||
|
|
||||||
return {"ok": True, "message": "Bestätigungs-E-Mail wurde erneut versendet."}
|
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,12 @@ import os
|
||||||
import re
|
import re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
from club_tenancy import is_platform_admin, is_superadmin
|
from club_tenancy import is_platform_admin, is_superadmin
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
|
from routers.auth import limiter
|
||||||
from media_legal_hold import (
|
from media_legal_hold import (
|
||||||
LEGAL_HOLD_REASON_CODES,
|
LEGAL_HOLD_REASON_CODES,
|
||||||
assert_superadmin_for_legal_hold,
|
assert_superadmin_for_legal_hold,
|
||||||
|
|
@ -432,8 +433,17 @@ def _report_row_to_dict(row) -> dict:
|
||||||
|
|
||||||
# ─── Endpoints ──────────────────────────────────────────────────────────────
|
# ─── Endpoints ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _content_report_rate_marker(request: Request) -> None:
|
||||||
|
"""SlowAPI-Ziel für 10 Meldungen pro Stunde. Nicht als Route: der Decorator bricht die Annotationen."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
limiter.limit("10/hour")(_content_report_rate_marker)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/content-reports", status_code=201)
|
@router.post("/content-reports", status_code=201)
|
||||||
def submit_content_report(
|
def submit_content_report(
|
||||||
|
request: Request,
|
||||||
body: ContentReportCreate,
|
body: ContentReportCreate,
|
||||||
x_auth_token: Optional[str] = Header(default=None),
|
x_auth_token: Optional[str] = Header(default=None),
|
||||||
):
|
):
|
||||||
|
|
@ -446,6 +456,7 @@ def submit_content_report(
|
||||||
- Journaleintrag im Medien-Audit-Log (wenn target_type=media_asset)
|
- Journaleintrag im Medien-Audit-Log (wenn target_type=media_asset)
|
||||||
- E-Mail-Bestaetigung an Melder + Benachrichtigung an Plattform-Admins (best-effort)
|
- E-Mail-Bestaetigung an Melder + Benachrichtigung an Plattform-Admins (best-effort)
|
||||||
"""
|
"""
|
||||||
|
limiter._check_request_limit(request, _content_report_rate_marker, False)
|
||||||
if not body.good_faith_confirmed:
|
if not body.good_faith_confirmed:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
|
|
|
||||||
|
|
@ -163,24 +163,9 @@ ALLOWED_UPLOAD_MIMES = frozenset(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Dateiendung → MIME wenn der Client keinen sinnvollen Content-Type sendet (häufig mobil / iOS).
|
|
||||||
_UPLOAD_FILENAME_MIME_FALLBACK = {
|
|
||||||
".jpg": "image/jpeg",
|
|
||||||
".jpeg": "image/jpeg",
|
|
||||||
".png": "image/png",
|
|
||||||
".gif": "image/gif",
|
|
||||||
".heic": "image/heic",
|
|
||||||
".heif": "image/heif",
|
|
||||||
".mp4": "video/mp4",
|
|
||||||
".mov": "video/quicktime",
|
|
||||||
".pdf": "application/pdf",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _sniff_allowed_upload_mime(raw: bytes) -> Optional[str]:
|
def _sniff_allowed_upload_mime(raw: bytes) -> Optional[str]:
|
||||||
"""Erkennt erlaubte MIME-Typen anhand weniger Magic Bytes (ohne Pillow/python-magic)."""
|
"""Erkennt erlaubte MIME-Typen anhand weniger Magic Bytes (ohne Pillow/python-magic)."""
|
||||||
if len(raw) < 12:
|
|
||||||
return None
|
|
||||||
if raw[:3] == b"\xff\xd8\xff":
|
if raw[:3] == b"\xff\xd8\xff":
|
||||||
return "image/jpeg"
|
return "image/jpeg"
|
||||||
if raw[:8] == b"\x89PNG\r\n\x1a\n":
|
if raw[:8] == b"\x89PNG\r\n\x1a\n":
|
||||||
|
|
@ -189,7 +174,7 @@ def _sniff_allowed_upload_mime(raw: bytes) -> Optional[str]:
|
||||||
return "image/gif"
|
return "image/gif"
|
||||||
if raw[:4] == b"%PDF":
|
if raw[:4] == b"%PDF":
|
||||||
return "application/pdf"
|
return "application/pdf"
|
||||||
if raw[4:8] != b"ftyp":
|
if len(raw) < 12 or raw[4:8] != b"ftyp":
|
||||||
return None
|
return None
|
||||||
brand = raw[8:12]
|
brand = raw[8:12]
|
||||||
# HEIC / HEIF (u. a. iPhone „高效率“)
|
# HEIC / HEIF (u. a. iPhone „高效率“)
|
||||||
|
|
@ -208,17 +193,11 @@ def resolve_upload_mime_type(
|
||||||
content_type: Optional[str],
|
content_type: Optional[str],
|
||||||
filename: Optional[str],
|
filename: Optional[str],
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Ermittelt ein erlaubtes MIME (Client-Header, Magic Bytes oder Dateiendung)."""
|
"""MIME nur aus dem Dateiinhalt. Client-Header und Dateiendung entscheiden nicht."""
|
||||||
ct = (content_type or "").split(";")[0].strip().lower()
|
|
||||||
if ct in ALLOWED_UPLOAD_MIMES:
|
|
||||||
return ct
|
|
||||||
guessed = _sniff_allowed_upload_mime(raw)
|
guessed = _sniff_allowed_upload_mime(raw)
|
||||||
if guessed in ALLOWED_UPLOAD_MIMES:
|
if guessed in ALLOWED_UPLOAD_MIMES:
|
||||||
return guessed
|
return guessed
|
||||||
ext = Path(filename or "").suffix.lower()
|
ct = (content_type or "").split(";")[0].strip().lower()
|
||||||
fb = _UPLOAD_FILENAME_MIME_FALLBACK.get(ext)
|
|
||||||
if fb in ALLOWED_UPLOAD_MIMES:
|
|
||||||
return fb
|
|
||||||
raise ValueError(f"Dateityp nicht erlaubt: {ct or 'unbekannt'}")
|
raise ValueError(f"Dateityp nicht erlaubt: {ct or 'unbekannt'}")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -3083,16 +3062,28 @@ def delete_exercise_variant(
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
def _content_disposition_inline(filename: Optional[str]) -> str:
|
def _safe_download_name(filename: Optional[str]) -> str:
|
||||||
"""Inline-Darstellung im Browser (<video>/<img>), keine attachment-Download-Leiste."""
|
"""Dateiname ohne Steuerzeichen und ohne Anführungszeichen für Content-Disposition."""
|
||||||
if not filename:
|
if not filename:
|
||||||
return "inline"
|
return ""
|
||||||
fn = str(filename).strip()
|
cleaned = []
|
||||||
|
for ch in str(filename):
|
||||||
|
if ch < " " or ch == "\x7f" or ch in {'"', "\\", ";"}:
|
||||||
|
continue
|
||||||
|
cleaned.append(ch)
|
||||||
|
return "".join(cleaned).strip()[:180]
|
||||||
|
|
||||||
|
|
||||||
|
def _content_disposition(filename: Optional[str], *, attachment: bool) -> str:
|
||||||
|
"""Bilder und Videos inline. PDF als Anhang. Dateiname ohne Steuerzeichen."""
|
||||||
|
disposition = "attachment" if attachment else "inline"
|
||||||
|
fn = _safe_download_name(filename)
|
||||||
|
if not fn:
|
||||||
|
return disposition
|
||||||
quoted = quote(fn, safe="")
|
quoted = quote(fn, safe="")
|
||||||
escaped = fn.replace('"', "\\")
|
|
||||||
if quoted != fn:
|
if quoted != fn:
|
||||||
return f"inline; filename*=utf-8''{quoted}"
|
return f"{disposition}; filename*=utf-8''{quoted}"
|
||||||
return f'inline; filename="{escaped}"'
|
return f'{disposition}; filename="{fn}"'
|
||||||
|
|
||||||
|
|
||||||
def _parse_bytes_range_single(range_header: str, file_size: int) -> Optional[Tuple[int, int]]:
|
def _parse_bytes_range_single(range_header: str, file_size: int) -> Optional[Tuple[int, int]]:
|
||||||
|
|
@ -3152,7 +3143,10 @@ def _binary_media_response(
|
||||||
path_str = str(path_arg.resolve())
|
path_str = str(path_arg.resolve())
|
||||||
stat_r = os.stat(path_str)
|
stat_r = os.stat(path_str)
|
||||||
file_size = int(stat_r.st_size)
|
file_size = int(stat_r.st_size)
|
||||||
cd_val = _content_disposition_inline(download_name)
|
cd_val = _content_disposition(
|
||||||
|
download_name,
|
||||||
|
attachment=(mime_type or "").split(";")[0].strip().lower() == "application/pdf",
|
||||||
|
)
|
||||||
base_h = {"accept-ranges": "bytes", "content-disposition": cd_val}
|
base_h = {"accept-ranges": "bytes", "content-disposition": cd_val}
|
||||||
|
|
||||||
if request.method.upper() == "HEAD":
|
if request.method.upper() == "HEAD":
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,39 @@ def management_password_reset(
|
||||||
return {"ok": True, "mode": "email", "email_sent": bool(sent)}
|
return {"ok": True, "mode": "email", "email_sent": bool(sent)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profiles/{pid}/totp-reset")
|
||||||
|
def management_totp_reset(pid: str, tenant: TenantContext = Depends(get_tenant_context)):
|
||||||
|
"""Superadmin schaltet Zwei-Faktor ab, wenn der Nutzer den Authenticator nicht mehr hat."""
|
||||||
|
if not is_superadmin(tenant.global_role or ""):
|
||||||
|
raise HTTPException(status_code=403, detail="Nur Super-Admins")
|
||||||
|
try:
|
||||||
|
target = int(pid)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültige Profil-ID")
|
||||||
|
if target == int(tenant.profile_id):
|
||||||
|
raise HTTPException(status_code=400, detail="Eigenen Zwei-Faktor unter Einstellungen ändern")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute("SELECT id, totp_enabled FROM profiles WHERE id=%s", (target,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
|
||||||
|
if not row.get("totp_enabled"):
|
||||||
|
return {"ok": True, "totp_enabled": False, "changed": False}
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE profiles
|
||||||
|
SET totp_enabled=FALSE, totp_secret=NULL, totp_pending_secret=NULL, updated_at=NOW()
|
||||||
|
WHERE id=%s
|
||||||
|
""",
|
||||||
|
(target,),
|
||||||
|
)
|
||||||
|
cur.execute("DELETE FROM profile_recovery_codes WHERE profile_id=%s", (target,))
|
||||||
|
revoke_profile_sessions(cur, target)
|
||||||
|
return {"ok": True, "totp_enabled": False, "changed": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profiles/{pid}")
|
@router.get("/profiles/{pid}")
|
||||||
def get_profile(pid: str, session=Depends(require_auth)):
|
def get_profile(pid: str, session=Depends(require_auth)):
|
||||||
"""Profil nach ID — nur eigenes Profil oder Plattform-Admin (wie PUT)."""
|
"""Profil nach ID — nur eigenes Profil oder Plattform-Admin (wie PUT)."""
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,41 @@ def test_change_password_requires_current_password() -> None:
|
||||||
ChangePasswordRequest.model_validate({"pin": "neuespasswort"})
|
ChangePasswordRequest.model_validate({"pin": "neuespasswort"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_reset_rejects_header_injection() -> None:
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from models import PasswordResetRequest, ResendVerificationRequest
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
PasswordResetRequest.model_validate({"email": "trainer@example.com\nBcc: x@y.z"})
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ResendVerificationRequest.model_validate({"email": "nicht-eine-email"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_mime_comes_from_bytes_not_client_header() -> None:
|
||||||
|
from routers.exercises import _content_disposition, resolve_upload_mime_type
|
||||||
|
|
||||||
|
jpeg = b"\xff\xd8\xff" + b"\x00" * 16
|
||||||
|
assert resolve_upload_mime_type(jpeg, "text/html", "evil.html") == "image/jpeg"
|
||||||
|
pdf = b"%PDF-1.4\n" + b"0" * 8
|
||||||
|
assert resolve_upload_mime_type(pdf, "image/png", "bild.png") == "application/pdf"
|
||||||
|
try:
|
||||||
|
resolve_upload_mime_type(b"<html><script>alert(1)</script></html>", "image/jpeg", "x.jpg")
|
||||||
|
raised = False
|
||||||
|
except ValueError:
|
||||||
|
raised = True
|
||||||
|
assert raised
|
||||||
|
disposition = _content_disposition('bericht.pdf\r\nX-Evil: 1', attachment=True)
|
||||||
|
assert disposition.startswith("attachment")
|
||||||
|
assert "\r" not in disposition
|
||||||
|
assert "\n" not in disposition
|
||||||
|
|
||||||
|
|
||||||
|
def test_totp_reset_requires_auth(client: TestClient) -> None:
|
||||||
|
response = client.post("/api/profiles/1/totp-reset")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_login_rejects_oversized_password(client: TestClient) -> None:
|
def test_login_rejects_oversized_password(client: TestClient) -> None:
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,31 @@ from exercise_rich_text import (
|
||||||
assert_no_inline_media_references_on_create,
|
assert_no_inline_media_references_on_create,
|
||||||
collect_inline_exercise_media_ids,
|
collect_inline_exercise_media_ids,
|
||||||
normalize_inline_exercise_media_markup,
|
normalize_inline_exercise_media_markup,
|
||||||
|
sanitize_exercise_rich_html,
|
||||||
validate_inline_exercise_media_ids_for_exercise,
|
validate_inline_exercise_media_ids_for_exercise,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_curly_to_span() -> None:
|
def test_normalize_strips_script_and_handlers() -> None:
|
||||||
|
raw = '<p onclick="alert(1)">Hallo <script>alert(1)</script></p><a href="javascript:alert(1)">Link</a>'
|
||||||
|
out = normalize_inline_exercise_media_markup(raw)
|
||||||
|
assert out is not None
|
||||||
|
lowered = out.lower()
|
||||||
|
assert "script" not in lowered
|
||||||
|
assert "onclick" not in lowered
|
||||||
|
assert "javascript:" not in lowered
|
||||||
|
assert "Hallo" in out
|
||||||
|
assert "Link" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_keeps_inline_media_span() -> None:
|
||||||
|
raw = '<span class="shinkan-inline-media evil" data-shinkan-exercise-media="7" data-shinkan-exercise-media-size="full">x</span>'
|
||||||
|
out = sanitize_exercise_rich_html(raw)
|
||||||
|
assert out is not None
|
||||||
|
assert 'data-shinkan-exercise-media="7"' in out
|
||||||
|
assert 'data-shinkan-exercise-media-size="full"' in out
|
||||||
|
assert "shinkan-inline-media" in out
|
||||||
|
assert "evil" not in out
|
||||||
s = '<p>Vor {{exerciseMedia: 42 }} nach</p>'
|
s = '<p>Vor {{exerciseMedia: 42 }} nach</p>'
|
||||||
out = normalize_inline_exercise_media_markup(s)
|
out = normalize_inline_exercise_media_markup(s)
|
||||||
assert 'data-shinkan-exercise-media="42"' in out
|
assert 'data-shinkan-exercise-media="42"' in out
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.241"
|
APP_VERSION = "0.8.243"
|
||||||
BUILD_DATE = "2026-10-13"
|
BUILD_DATE = "2026-10-13"
|
||||||
DB_SCHEMA_VERSION = "20261012096"
|
DB_SCHEMA_VERSION = "20261012096"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||||
"auth": "1.3.2", # TOTP-Einrichtung zeigt einen QR-Code zum Scannen
|
"auth": "1.3.3", # Registrierung und Bestätigung antworten gleich; Reset-Mail nur mit gültiger E-Mail
|
||||||
"profiles": "1.8.1", # GET /profiles/me: account_state + club_roles
|
"profiles": "1.8.2", # Superadmin: POST /profiles/{id}/totp-reset
|
||||||
"tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext
|
"tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext
|
||||||
"capabilities": "1.2.0", # Registry-first: module-Spalte; Admin nur registrierte Rechte
|
"capabilities": "1.2.0", # Registry-first: module-Spalte; Admin nur registrierte Rechte
|
||||||
"rights_registry": "1.0.0", # register_capability/feature + startup sync
|
"rights_registry": "1.0.0", # register_capability/feature + startup sync
|
||||||
|
|
@ -16,7 +16,7 @@ MODULE_VERSIONS = {
|
||||||
"club_memberships": "1.0.1", # Depends(get_tenant_context)
|
"club_memberships": "1.0.1", # Depends(get_tenant_context)
|
||||||
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
|
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
|
||||||
"club_creation_requests": "1.0.1", # superseded wenn freigegebener Verein gelöscht
|
"club_creation_requests": "1.0.1", # superseded wenn freigegebener Verein gelöscht
|
||||||
"admin_users": "1.0.0", # GET /api/admin/users
|
"admin_users": "1.0.1", # GET /api/admin/users inkl. totp_enabled
|
||||||
"club_features": "1.6.0",
|
"club_features": "1.6.0",
|
||||||
"admin_rights": "1.1.0", # Matrix UX, Enforcement-Audit, clear club grants by capability
|
"admin_rights": "1.1.0", # Matrix UX, Enforcement-Audit, clear club grants by capability
|
||||||
"capability_enforcement_audit": "1.0.0",
|
"capability_enforcement_audit": "1.0.0",
|
||||||
|
|
@ -37,7 +37,7 @@ MODULE_VERSIONS = {
|
||||||
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
||||||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||||
"methods": "0.1.0",
|
"methods": "0.1.0",
|
||||||
"exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume
|
"exercises": "2.37.2", # Upload-MIME nur aus Magic Bytes; PDF als Anhang; Rich-Text-Allowlist beim Speichern
|
||||||
"planning_exercise_suggest": "0.23.8", # Progressionsgraph: Katalog-Kontext (Fokus/Stil/TT/ZG) im Match-Profil
|
"planning_exercise_suggest": "0.23.8", # Progressionsgraph: Katalog-Kontext (Fokus/Stil/TT/ZG) im Match-Profil
|
||||||
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
|
|
@ -49,10 +49,28 @@ MODULE_VERSIONS = {
|
||||||
"membership": "1.0.0",
|
"membership": "1.0.0",
|
||||||
"catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012)
|
"catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012)
|
||||||
"maturity_models": "1.4.0", # matrix_stack_bundle: vollständiger Katalog+Modelle+Bindings Export/Import
|
"maturity_models": "1.4.0", # matrix_stack_bundle: vollständiger Katalog+Modelle+Bindings Export/Import
|
||||||
"content_reports": "1.5.1", # P-13: Fruehzeitige 403 fuer plain Admin in set_legal_hold_from_report (CI-Fix)
|
"content_reports": "1.5.2", # POST /content-reports: 10 Meldungen pro Stunde und IP
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.8.243",
|
||||||
|
"date": "2026-10-13",
|
||||||
|
"changes": [
|
||||||
|
"Übungstexte werden beim Speichern und in der Anzeige auf eine feste HTML-Allowlist gekürzt.",
|
||||||
|
"Uploads: der Dateityp kommt aus dem Inhalt. PDF wird als Anhang ausgeliefert.",
|
||||||
|
"Registrierung und erneute Bestätigung verraten nicht, ob die E-Mail existiert. Passwort-Reset verlangt eine gültige Adresse.",
|
||||||
|
"Anonyme und angemeldete Inhaltsmeldungen sind auf 10 pro Stunde und IP begrenzt.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.8.242",
|
||||||
|
"date": "2026-10-13",
|
||||||
|
"changes": [
|
||||||
|
"Superadmin kann die Zwei-Faktor-Anmeldung eines anderen Kontos aufheben, wenn der Authenticator verloren ist.",
|
||||||
|
"Passwort-Reset ändert nur das Passwort. Zwei-Faktor bleibt aktiv, der Link meldet nicht an.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.241",
|
"version": "0.8.241",
|
||||||
"date": "2026-10-13",
|
"date": "2026-10-13",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.13.24",
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
|
"dompurify": "^3.4.17",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"marked": "^18.0.3",
|
"marked": "^18.0.3",
|
||||||
|
|
@ -23,6 +24,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"vite": "^5.1.4",
|
"vite": "^5.1.4",
|
||||||
"vitest": "^3.0.5"
|
"vitest": "^3.0.5"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,24 @@ export default function AdminUsersPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearUserTotp = async (row) => {
|
||||||
|
const label = row.name || row.email || `#${row.id}`
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Zwei-Faktor für ${label} aufheben? Danach genügt das Passwort. Authenticator und Wiederherstellungscodes gelten nicht mehr. Offene Sitzungen werden beendet.`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.resetUserTotp(row.id)
|
||||||
|
await loadPlatform()
|
||||||
|
alert('Zwei-Faktor ist aus. Die Person meldet sich mit dem Passwort an und kann ihn neu einrichten.')
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const submitPasswordDirect = async () => {
|
const submitPasswordDirect = async () => {
|
||||||
if (!pwdModal) return
|
if (!pwdModal) return
|
||||||
if (pwdNew.length < 8) {
|
if (pwdNew.length < 8) {
|
||||||
|
|
@ -240,6 +258,8 @@ export default function AdminUsersPage() {
|
||||||
<div style={{ fontSize: '0.875rem', color: 'var(--text2)' }}>{row.email || '—'}</div>
|
<div style={{ fontSize: '0.875rem', color: 'var(--text2)' }}>{row.email || '—'}</div>
|
||||||
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
|
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
|
||||||
Verifiziert: {row.email_verified ? 'ja' : 'nein'}
|
Verifiziert: {row.email_verified ? 'ja' : 'nein'}
|
||||||
|
{' · '}
|
||||||
|
Zwei-Faktor: {row.totp_enabled ? 'aktiv' : 'aus'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'flex-end' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'flex-end' }}>
|
||||||
|
|
@ -279,6 +299,17 @@ export default function AdminUsersPage() {
|
||||||
>
|
>
|
||||||
Passwort / Link
|
Passwort / Link
|
||||||
</button>
|
</button>
|
||||||
|
{row.totp_enabled ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={row.id === user?.id}
|
||||||
|
title={row.id === user?.id ? 'Eigenen Zwei-Faktor unter Einstellungen ändern' : undefined}
|
||||||
|
onClick={() => clearUserTotp(row)}
|
||||||
|
>
|
||||||
|
Zwei-Faktor aufheben
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => savePortal(row.id)}>
|
<button type="button" className="btn btn-secondary" onClick={() => savePortal(row.id)}>
|
||||||
Portal speichern
|
Portal speichern
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -573,7 +604,7 @@ export default function AdminUsersPage() {
|
||||||
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{pwdModal.label}</p>
|
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{pwdModal.label}</p>
|
||||||
<p className="muted" style={{ fontSize: '0.82rem', marginBottom: '0.75rem' }}>
|
<p className="muted" style={{ fontSize: '0.82rem', marginBottom: '0.75rem' }}>
|
||||||
Standard: Es wird ein sicherer Link per E-Mail verschickt (wie „Passwort vergessen“). Das bisherige Passwort
|
Standard: Es wird ein sicherer Link per E-Mail verschickt (wie „Passwort vergessen“). Das bisherige Passwort
|
||||||
bleibt gültig, bis die Person den Link nutzt und ein neues Passwort wählt.
|
bleibt gültig, bis die Person den Link nutzt und ein neues Passwort wählt. Zwei-Faktor bleibt dabei aktiv.
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
<button type="button" className="btn btn-primary" onClick={submitPasswordEmail}>
|
<button type="button" className="btn btn-primary" onClick={submitPasswordEmail}>
|
||||||
|
|
|
||||||
|
|
@ -1571,7 +1571,7 @@ function ClubsPage() {
|
||||||
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{pwdModal.label}</p>
|
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{pwdModal.label}</p>
|
||||||
<p className="muted" style={{ fontSize: '0.82rem', marginBottom: '0.75rem' }}>
|
<p className="muted" style={{ fontSize: '0.82rem', marginBottom: '0.75rem' }}>
|
||||||
Standard: Es wird ein sicherer Link per E-Mail verschickt (wie „Passwort vergessen“). Das bisherige Passwort
|
Standard: Es wird ein sicherer Link per E-Mail verschickt (wie „Passwort vergessen“). Das bisherige Passwort
|
||||||
bleibt gültig, bis die Person den Link nutzt und ein neues Passwort wählt.
|
bleibt gültig, bis die Person den Link nutzt und ein neues Passwort wählt. Zwei-Faktor bleibt dabei aktiv.
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
<button type="button" className="btn btn-primary" onClick={submitMembersPasswordEmail}>
|
<button type="button" className="btn btn-primary" onClick={submitMembersPasswordEmail}>
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,11 @@ export async function updateProfile(profileId, data) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Superadmin: Zwei-Faktor eines anderen Kontos abschalten. */
|
||||||
|
export async function resetUserTotp(profileId) {
|
||||||
|
return request(`/api/profiles/${profileId}/totp-reset`, { method: 'POST', body: '{}' })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Passwort anderer Konten: Standard leerer Body → E-Mail mit Reset-Link (wie Passwort vergessen).
|
* Passwort anderer Konten: Standard leerer Body → E-Mail mit Reset-Link (wie Passwort vergessen).
|
||||||
* Nur Super-Admins dürfen `newPassword` setzen (direktes Überschreiben des Passwort-Hashes).
|
* Nur Super-Admins dürfen `newPassword` setzen (direktes Überschreiben des Passwort-Hashes).
|
||||||
|
|
@ -1055,6 +1060,7 @@ export const api = {
|
||||||
deleteAdminUserContentItem,
|
deleteAdminUserContentItem,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
managementPasswordReset,
|
managementPasswordReset,
|
||||||
|
resetUserTotp,
|
||||||
changePassword,
|
changePassword,
|
||||||
verifyEmail,
|
verifyEmail,
|
||||||
resendVerification,
|
resendVerification,
|
||||||
|
|
|
||||||
|
|
@ -1,111 +1,79 @@
|
||||||
/**
|
/**
|
||||||
* Sanitizer für Übungs-Rich-HTML inkl. §11 Platzhalter (span data-shinkan-exercise-media).
|
* Sanitizer für Übungs-Rich-HTML inkl. §11 Platzhalter (span data-shinkan-exercise-media).
|
||||||
* Restriktiver als sanitizeTrainerHtml: Allowlist für XSS-Minimierung.
|
* DOMPurify mit fester Allowlist.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
import { sanitizeInlineMediaCaption } from './inlineMediaCaption'
|
import { sanitizeInlineMediaCaption } from './inlineMediaCaption'
|
||||||
|
|
||||||
const ALLOWED_BLOCK = new Set(['p', 'div', 'br', 'ul', 'ol', 'li', 'h3'])
|
const ALLOWED_TAGS = ['p', 'div', 'br', 'ul', 'ol', 'li', 'h3', 'b', 'strong', 'i', 'em', 'u', 'span', 'a']
|
||||||
const ALLOWED_INLINE = new Set(['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'])
|
||||||
|
|
||||||
function isHttpsUrl(val) {
|
function hookAttribute(_node, data) {
|
||||||
if (!val || typeof val !== 'string') return false
|
const name = data.attrName
|
||||||
const s = val.trim()
|
if (name === 'href') {
|
||||||
return s.startsWith('http://') || s.startsWith('https://')
|
const href = String(data.attrValue || '').trim()
|
||||||
|
if (!/^https?:\/\//i.test(href)) data.keepAttr = false
|
||||||
|
else data.attrValue = href
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Nur für unsere Embed-Markierung: erlaubt data-attribut und optionale Marker-Klasse + Größe. */
|
const instances = new WeakMap()
|
||||||
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) {
|
function purifyFor(win) {
|
||||||
if (tagLower === 'a') {
|
const existing = instances.get(win)
|
||||||
const href = el.getAttribute('href')
|
if (existing) return existing
|
||||||
if (href && isHttpsUrl(href)) {
|
const purify = DOMPurify(win)
|
||||||
const out = document.createElement('a')
|
purify.addHook('uponSanitizeAttribute', hookAttribute)
|
||||||
out.setAttribute('href', href.trim())
|
instances.set(win, purify)
|
||||||
return out.attributes
|
return purify
|
||||||
}
|
|
||||||
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
|
* @param {string|null|undefined} html
|
||||||
|
* @param {Window=} win
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function sanitizeExerciseRichDisplayHtml(html) {
|
export function sanitizeExerciseRichDisplayHtml(html, win) {
|
||||||
if (html == null || typeof html !== 'string') return ''
|
if (html == null || typeof html !== 'string') return ''
|
||||||
const trimmed = html.trim()
|
const trimmed = html.trim()
|
||||||
if (!trimmed) return ''
|
if (!trimmed) return ''
|
||||||
|
const target = win || (typeof window !== 'undefined' ? window : null)
|
||||||
const tpl = document.createElement('template')
|
if (!target) return ''
|
||||||
tpl.innerHTML = trimmed
|
return purifyFor(target).sanitize(trimmed, {
|
||||||
cleanTree(tpl.content)
|
ALLOWED_TAGS,
|
||||||
return tpl.innerHTML
|
ALLOWED_ATTR,
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
frontend/src/utils/exerciseRichTextSanitize.test.js
Normal file
21
frontend/src/utils/exerciseRichTextSanitize.test.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { JSDOM } from 'jsdom'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { sanitizeExerciseRichDisplayHtml } from './exerciseRichTextSanitize'
|
||||||
|
|
||||||
|
const { window } = new JSDOM('<!doctype html><html><body></body></html>')
|
||||||
|
|
||||||
|
describe('sanitizeExerciseRichDisplayHtml', () => {
|
||||||
|
it('entfernt Skripte und Handler und behält den Medien-Platzhalter', () => {
|
||||||
|
const dirty =
|
||||||
|
'<p onclick="alert(1)">Hallo <script>alert(1)</script></p>' +
|
||||||
|
'<a href="javascript:alert(1)">Link</a>' +
|
||||||
|
'<span class="shinkan-inline-media evil" data-shinkan-exercise-media="7" data-shinkan-exercise-media-size="full"></span>'
|
||||||
|
const clean = sanitizeExerciseRichDisplayHtml(dirty, window)
|
||||||
|
expect(clean.toLowerCase()).not.toContain('script')
|
||||||
|
expect(clean.toLowerCase()).not.toContain('onclick')
|
||||||
|
expect(clean.toLowerCase()).not.toContain('javascript:')
|
||||||
|
expect(clean).toContain('Hallo')
|
||||||
|
expect(clean).toContain('data-shinkan-exercise-media="7"')
|
||||||
|
expect(clean).not.toContain('evil')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
/** Einfache HTML-Hilfen für Rich-Text (Trainer-Content, kein öffentliches CMS). */
|
/** Einfache HTML-Hilfen für Rich-Text (Trainer-Content, kein öffentliches CMS). */
|
||||||
|
|
||||||
|
import { sanitizeExerciseRichDisplayHtml } from './exerciseRichTextSanitize'
|
||||||
|
|
||||||
export function stripHtmlToText(html) {
|
export function stripHtmlToText(html) {
|
||||||
if (!html || typeof html !== 'string') return ''
|
if (!html || typeof html !== 'string') return ''
|
||||||
const d = document.createElement('div')
|
const d = document.createElement('div')
|
||||||
|
|
@ -7,17 +9,7 @@ export function stripHtmlToText(html) {
|
||||||
return (d.textContent || '').replace(/\s+/g, ' ').trim()
|
return (d.textContent || '').replace(/\s+/g, ' ').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Entfernt script/iframes und Event-Handler-Attribute grob. */
|
/** Entfernt alles außerhalb der Übungs-Allowlist. */
|
||||||
export function sanitizeTrainerHtml(html) {
|
export function sanitizeTrainerHtml(html) {
|
||||||
if (!html || typeof html !== 'string') return ''
|
return sanitizeExerciseRichDisplayHtml(html)
|
||||||
const d = document.createElement('div')
|
|
||||||
d.innerHTML = html
|
|
||||||
d.querySelectorAll('script, iframe, object, embed').forEach((n) => n.remove())
|
|
||||||
d.querySelectorAll('*').forEach((el) => {
|
|
||||||
for (const attr of [...el.attributes]) {
|
|
||||||
const n = attr.name.toLowerCase()
|
|
||||||
if (n.startsWith('on') || n === 'srcdoc') el.removeAttribute(attr.name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return d.innerHTML
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,11 @@
|
||||||
/**
|
/**
|
||||||
* Reduziert HTML aus Übungs-Kurztexten auf eine kleine erlaubte Menge von Tags (ohne Attribute).
|
* Reduziert HTML aus Übungs-Kurztexten auf die Allowlist von sanitizeExerciseRichDisplayHtml.
|
||||||
* Für Anzeige mit dangerouslySetInnerHTML.
|
|
||||||
*/
|
*/
|
||||||
const ALLOWED_TAGS = new Set(['b', 'strong', 'i', 'em', 'br', 'p', 'span', 'ul', 'ol', 'li'])
|
|
||||||
|
|
||||||
function cleanTree(parent) {
|
import { sanitizeExerciseRichDisplayHtml } from './exerciseRichTextSanitize'
|
||||||
const nodes = Array.from(parent.childNodes)
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.nodeType === Node.TEXT_NODE) continue
|
|
||||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
||||||
parent.removeChild(node)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const tag = node.tagName.toLowerCase()
|
|
||||||
if (!ALLOWED_TAGS.has(tag)) {
|
|
||||||
while (node.firstChild) {
|
|
||||||
parent.insertBefore(node.firstChild, node)
|
|
||||||
}
|
|
||||||
parent.removeChild(node)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
while (node.attributes.length > 0) {
|
|
||||||
node.removeAttribute(node.attributes[0].name)
|
|
||||||
}
|
|
||||||
cleanTree(node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitizeExerciseRichText(html) {
|
export function sanitizeExerciseRichText(html) {
|
||||||
if (html == null || typeof html !== 'string') return ''
|
return sanitizeExerciseRichDisplayHtml(html)
|
||||||
const trimmed = html.trim()
|
|
||||||
if (!trimmed) return ''
|
|
||||||
|
|
||||||
const tpl = document.createElement('template')
|
|
||||||
tpl.innerHTML = trimmed
|
|
||||||
cleanTree(tpl.content)
|
|
||||||
return tpl.innerHTML
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function coerceApiNameList(value) {
|
export function coerceApiNameList(value) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user