From 9394bab1fbe9ac61e60696c1f10c2e85ea1ec118 Mon Sep 17 00:00:00 2001
From: rene
Date: Tue, 26 May 2026 20:12:01 +0200
Subject: [PATCH] Big Sweep: Security + Race-Conditions + Tests + DSGVO + A11y,
SW by-v1095
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
SECURITY (auth.py, routes/auth.py, database.py, main.py)
- JWT bekommt jti; Logout trägt in neue jwt_blacklist-Tabelle ein,
decode_token() prüft → server-side Invalidierung
- JWT-Expiry default 30 → 7 Tage (ENV JWT_EXPIRY_DAYS überschreibt)
- Sliding-Refresh-Middleware: erneuert Cookie wenn >50% verbraucht
(Schwelle via JWT_REFRESH_FRACTION, Default 2)
- Login-Lockout in DB-Tabelle login_attempts (5 Versuche / 15 Min,
überlebt Container-Restart) — alte In-Memory-Lockouts ersetzt
- SMTP-Versand: alle 'except: pass' durch logger.exception ersetzt;
Fehlversuche landen in failed_emails-Tabelle für späteres Retry
- Referral-Counter Race gefixt: UPDATE partner_codes SET uses=uses+1
... WHERE uses VERSION; fi
+ @CUR=$$(cat VERSION | tr -d '[:space:]'); \
+ if [ -n "$(APP_VER)" ]; then NEW="$(APP_VER)"; else NEW=$$(($$CUR + 1)); fi; \
+ printf "%s" "$$NEW" > VERSION; \
+ sed -i.bak -E "s/const VER[[:space:]]*=[[:space:]]*'[0-9]+'/const VER = '$$NEW'/" backend/static/sw.js && rm -f backend/static/sw.js.bak; \
+ sed -i.bak -E "s/const APP_VER[[:space:]]*=[[:space:]]*'[0-9]+'/const APP_VER = '$$NEW'/" backend/static/js/app.js && rm -f backend/static/js/app.js.bak; \
+ sed -i.bak -E "s/\?v=[0-9]+/?v=$$NEW/g" backend/static/index.html && rm -f backend/static/index.html.bak; \
+ echo " ✓ APP_VER $$CUR → $$NEW (VERSION, sw.js, app.js, index.html aktualisiert)"
+
+# ----------------------------------------------------------
+# TEST — Smoke-Tests gegen isolierte Test-DB (kein Docker, kein DS)
+# ----------------------------------------------------------
+test:
+ @cd backend && test -d venv || python3 -m venv venv
+ @backend/venv/bin/pip install -q -r backend/requirements.txt pytest pytest-asyncio
+ @backend/venv/bin/python -m pytest -q
+
# ----------------------------------------------------------
# CACHE leeren — SW-Version erhöhen, dann restart
# Nach größeren CSS/JS-Änderungen wenn SW gecacht hat
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..dab082f
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+1095
\ No newline at end of file
diff --git a/backend/auth.py b/backend/auth.py
index 0b3bff1..9cb25c6 100644
--- a/backend/auth.py
+++ b/backend/auth.py
@@ -4,11 +4,12 @@ JWT + Bcrypt. Einmal gebaut, von allen Routes genutzt.
"""
import os
+import uuid
import jwt
import bcrypt
import logging
from datetime import datetime, timedelta, timezone
-from fastapi import Depends, HTTPException, status, Request
+from fastapi import Depends, HTTPException, status, Request, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from database import db
@@ -16,7 +17,12 @@ from database import db
logger = logging.getLogger(__name__)
JWT_SECRET = os.getenv("JWT_SECRET", "change-me-in-production")
JWT_ALGO = "HS256"
-JWT_EXPIRY = int(os.getenv("JWT_EXPIRY_DAYS", "30"))
+# Default 7 Tage (vorher 30) — sensible Hundedaten brauchen kürzere Sessions.
+JWT_EXPIRY = int(os.getenv("JWT_EXPIRY_DAYS", "7"))
+# Wenn das Token älter als (JWT_EXPIRY / JWT_REFRESH_FRACTION) Tage ist,
+# wird das Cookie bei einer authentifizierten Anfrage neu gesetzt (Sliding Session).
+# Standard 2 → Refresh wenn mehr als die Hälfte der Laufzeit weg ist.
+JWT_REFRESH_FRACTION = int(os.getenv("JWT_REFRESH_FRACTION", "2"))
if JWT_SECRET == "change-me-in-production" and os.getenv("ENV") == "production":
raise RuntimeError(
@@ -27,6 +33,44 @@ if JWT_SECRET == "change-me-in-production" and os.getenv("ENV") == "production":
security = HTTPBearer(auto_error=False)
+# ------------------------------------------------------------------
+# JWT-Blacklist (Logout invalidiert Token serverseitig)
+# ------------------------------------------------------------------
+def _is_jti_blacklisted(jti: str) -> bool:
+ if not jti:
+ return False
+ with db() as conn:
+ row = conn.execute(
+ "SELECT 1 FROM jwt_blacklist WHERE jti=?", (jti,)
+ ).fetchone()
+ return bool(row)
+
+
+def blacklist_jti(jti: str, expires_at_iso: str):
+ """Token-ID auf die Blacklist setzen. expires_at_iso entspricht dem urspr. exp."""
+ if not jti or not expires_at_iso:
+ return
+ with db() as conn:
+ conn.execute(
+ "INSERT OR IGNORE INTO jwt_blacklist (jti, expires_at) VALUES (?,?)",
+ (jti, expires_at_iso)
+ )
+
+
+def _purge_expired_jwt() -> int:
+ """Entfernt abgelaufene Blacklist-Einträge. Gibt Anzahl gelöschter Zeilen zurück.
+ Kann später aus dem Scheduler aufgerufen werden."""
+ now = datetime.now(timezone.utc).isoformat()
+ with db() as conn:
+ cur = conn.execute(
+ "DELETE FROM jwt_blacklist WHERE expires_at < ?", (now,)
+ )
+ deleted = cur.rowcount or 0
+ if deleted:
+ logger.info("JWT-Blacklist Cleanup: %d abgelaufene Einträge entfernt.", deleted)
+ return deleted
+
+
# ------------------------------------------------------------------
# Passwort
# ------------------------------------------------------------------
@@ -42,17 +86,25 @@ def verify_password(password: str, hashed: str) -> bool:
# JWT
# ------------------------------------------------------------------
def create_token(user_id: int, rolle: str) -> str:
+ now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"rolle": rolle,
- "exp": datetime.now(timezone.utc) + timedelta(days=JWT_EXPIRY),
- "iat": datetime.now(timezone.utc),
+ "exp": now + timedelta(days=JWT_EXPIRY),
+ "iat": now,
+ "jti": uuid.uuid4().hex,
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGO)
def decode_token(token: str) -> dict:
- return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGO])
+ """Dekodiert + prüft Signatur, Ablauf und Blacklist.
+ Wirft jwt.InvalidTokenError wenn Token auf der Blacklist steht."""
+ payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGO])
+ jti = payload.get("jti")
+ if jti and _is_jti_blacklisted(jti):
+ raise jwt.InvalidTokenError("Token wurde invalidiert (Logout).")
+ return payload
# ------------------------------------------------------------------
@@ -68,6 +120,33 @@ def _get_token_from_request(
return request.cookies.get("by_token")
+def _maybe_refresh_token(request: Request, payload: dict):
+ """Sliding-Session: setzt das Cookie neu wenn das Token älter als JWT_EXPIRY/JWT_REFRESH_FRACTION ist.
+ Nur wirksam wenn das Token aus dem Cookie kommt (nicht Bearer-Header).
+ Hängt das neue Token an request.state.refresh_token, damit Middleware es ins Response-Cookie setzen kann."""
+ try:
+ # Nur refreshen wenn Token aus Cookie kam (Bearer-Clients verwalten Tokens selbst)
+ if not request.cookies.get("by_token"):
+ return
+ iat = payload.get("iat")
+ exp = payload.get("exp")
+ if not iat or not exp:
+ return
+ # Wenn mehr als 1/JWT_REFRESH_FRACTION der Laufzeit vergangen ist → neues Token ausstellen
+ now_ts = datetime.now(timezone.utc).timestamp()
+ lifetime = exp - iat
+ if lifetime <= 0 or JWT_REFRESH_FRACTION <= 0:
+ return
+ threshold = iat + (lifetime / JWT_REFRESH_FRACTION)
+ if now_ts < threshold:
+ return
+ new_token = create_token(int(payload["sub"]), payload.get("rolle", "user"))
+ request.state.refresh_token = new_token
+ except Exception:
+ # Refresh-Fehler dürfen die eigentliche Anfrage nie blockieren.
+ logger.exception("Sliding-Token-Refresh fehlgeschlagen")
+
+
def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
@@ -99,6 +178,9 @@ def get_current_user(
reason = user.get("ban_reason") or "Kein Grund angegeben."
raise HTTPException(status.HTTP_403_FORBIDDEN, f"Account gesperrt: {reason}")
+ # Sliding-Session: ggf. neues Token vorbereiten (in Middleware ins Cookie schreiben).
+ _maybe_refresh_token(request, payload)
+
return user
diff --git a/backend/database.py b/backend/database.py
index 00a5210..08ac7db 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -592,10 +592,15 @@ def _migrate(conn_factory):
]
with conn_factory() as conn:
for table, column, col_type in migrations:
+ # PRAGMA table_info() liefert leere Liste fuer nicht-existierende Tabellen.
+ # Wir ueberspringen — die CREATE TABLE-Bloecke darunter legen sie an,
+ # ihre Spalten sind dort dann sowieso schon enthalten.
existing = [
row[1] for row in
conn.execute(f"PRAGMA table_info({table})").fetchall()
]
+ if not existing:
+ continue
if column not in existing:
conn.execute(
f"ALTER TABLE {table} ADD COLUMN {column} {col_type}"
@@ -1209,7 +1214,7 @@ def _migrate(conn_factory):
# ki_daily_calls: source-Spalte + PK auf (user_id, date, source) erweitern
ki_cols = {r[1] for r in conn.execute("PRAGMA table_info(ki_daily_calls)").fetchall()}
- if "source" not in ki_cols:
+ if ki_cols and "source" not in ki_cols:
conn.executescript("""
ALTER TABLE ki_daily_calls RENAME TO ki_daily_calls_old;
CREATE TABLE ki_daily_calls (
@@ -1301,11 +1306,19 @@ def _migrate(conn_factory):
logger.info("Migration: dogs.gewicht_kg aus health synchronisiert.")
# Performance-Indizes für häufige Queries
- conn.execute("CREATE INDEX IF NOT EXISTS idx_health_naechstes ON health(naechstes) WHERE naechstes IS NOT NULL")
- conn.execute("CREATE INDEX IF NOT EXISTS idx_ki_calls_user_date ON ki_daily_calls(user_id, date)")
- conn.execute("CREATE INDEX IF NOT EXISTS idx_events_user_datum ON events(user_id, datum)")
- conn.execute("CREATE INDEX IF NOT EXISTS idx_diary_created ON diary(dog_id, created_at DESC)")
- conn.execute("CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(user_id, created_at DESC)")
+ # Defensiv — einige Tabellen werden erst beim ersten Zugriff in
+ # ihren jeweiligen Routes-Modulen angelegt (z.B. ki_daily_calls)
+ for _idx_sql in (
+ "CREATE INDEX IF NOT EXISTS idx_health_naechstes ON health(naechstes) WHERE naechstes IS NOT NULL",
+ "CREATE INDEX IF NOT EXISTS idx_ki_calls_user_date ON ki_daily_calls(user_id, date)",
+ "CREATE INDEX IF NOT EXISTS idx_events_user_datum ON events(user_id, datum)",
+ "CREATE INDEX IF NOT EXISTS idx_diary_created ON diary(dog_id, created_at DESC)",
+ "CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(user_id, created_at DESC)",
+ ):
+ try:
+ conn.execute(_idx_sql)
+ except Exception as _e:
+ logger.debug("Index-Migration uebersprungen (%s): %s", _idx_sql.split()[5], _e)
logger.info("Migration: Performance-Indizes bereit.")
# Züchter-Tabellen
@@ -2444,7 +2457,17 @@ def _migrate(conn_factory):
total REAL NOT NULL
)
""")
- logger.info("Migration: invoices + invoice_items bereit.")
+ # Atomare Rechnungsnummern-Vergabe pro (prefix, year)
+ # next_num = naechste zu vergebende Nummer (startet bei 1)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS invoice_counters (
+ prefix TEXT NOT NULL,
+ year INTEGER NOT NULL,
+ next_num INTEGER NOT NULL DEFAULT 1,
+ PRIMARY KEY (prefix, year)
+ )
+ """)
+ logger.info("Migration: invoices + invoice_items + invoice_counters bereit.")
except Exception as e:
logger.warning(f"Migration invoices: {e}")
@@ -2461,6 +2484,63 @@ def _migrate(conn_factory):
else:
logger.info("Migration: users.geburtstag bereits vorhanden.")
+ # ---- Security Hardening (Sprint60) ----
+ # JWT-Blacklist: invalidierte Tokens nach Logout / Sperrungen
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS jwt_blacklist (
+ jti TEXT PRIMARY KEY,
+ expires_at TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_jwt_blacklist_expires ON jwt_blacklist(expires_at);
+ """)
+ logger.info("Migration: jwt_blacklist Tabelle bereit.")
+
+ # Login-Brute-Force-Lockout (persistent statt In-Memory)
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS login_attempts (
+ email TEXT PRIMARY KEY COLLATE NOCASE,
+ attempts INTEGER NOT NULL DEFAULT 0,
+ last_attempt TEXT NOT NULL,
+ locked_until TEXT
+ );
+ CREATE INDEX IF NOT EXISTS idx_login_attempts_locked ON login_attempts(locked_until);
+ """)
+ logger.info("Migration: login_attempts Tabelle bereit.")
+
+ # Fehlgeschlagene E-Mail-Zustellungen — für Admin-Retry / Diagnose
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS failed_emails (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ to_email TEXT NOT NULL,
+ subject TEXT NOT NULL,
+ body TEXT NOT NULL,
+ error TEXT NOT NULL,
+ context TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_failed_emails_created ON failed_emails(created_at DESC);
+ """)
+ logger.info("Migration: failed_emails Tabelle bereit.")
+
+ # Second-Pass der ALTER-TABLE-Migrations:
+ # Manche Migrations (z.B. diary_media.img_width) referenzieren Tabellen,
+ # die erst weiter unten in dieser Funktion per CREATE IF NOT EXISTS angelegt
+ # werden. Beim ersten Durchgang wurden sie uebersprungen — jetzt nachziehen.
+ for table, column, col_type in migrations:
+ existing = [
+ row[1] for row in
+ conn.execute(f"PRAGMA table_info({table})").fetchall()
+ ]
+ if not existing:
+ continue
+ if column not in existing:
+ try:
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
+ logger.info(f"Migration (2nd pass): {table}.{column} hinzugefügt.")
+ except Exception as _e:
+ logger.debug(f"Migration (2nd pass) skipped {table}.{column}: {_e}")
+
def _seed_help_articles(conn):
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""
diff --git a/backend/main.py b/backend/main.py
index 6a3fb2e..569a887 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -123,6 +123,28 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
app.add_middleware(SecurityHeadersMiddleware)
+class _SlidingTokenRefreshMiddleware(BaseHTTPMiddleware):
+ """Sliding-Session: wenn get_current_user ein neues Token vorbereitet hat
+ (request.state.refresh_token), schreiben wir es als HttpOnly-Cookie zurück.
+ So bleibt der User eingeloggt solange er aktiv ist, ohne langlaufendes Token."""
+ async def dispatch(self, request: Request, call_next):
+ response = await call_next(request)
+ new_token = getattr(request.state, "refresh_token", None)
+ if new_token:
+ from auth import JWT_EXPIRY as _JWT_EXPIRY
+ response.set_cookie(
+ key="by_token",
+ value=new_token,
+ httponly=True,
+ secure=True,
+ samesite="lax",
+ max_age=_JWT_EXPIRY * 24 * 3600,
+ )
+ return response
+
+app.add_middleware(_SlidingTokenRefreshMiddleware)
+
+
# Globales File-Upload-Limit (20 MB)
_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
@@ -410,7 +432,26 @@ async def serve_media(path: str, request: _Request):
raise _HE(404, "Nicht gefunden.")
return _media_response(filepath)
-APP_VER = "1094" # muss mit APP_VER in app.js übereinstimmen
+# APP_VER wird zentral aus der VERSION-Datei im Projekt-Root gelesen.
+# Bumpe ausschliesslich via `make bump` — bumpt VERSION + sw.js + app.js + index.html atomar.
+def _read_app_ver() -> str:
+ from pathlib import Path
+ candidates = [
+ Path(__file__).resolve().parent.parent / "VERSION", # Projekt-Root (lokal/dev)
+ Path("/app/VERSION"), # Container-Layout
+ Path("/data/VERSION"), # falls als Volume gemountet
+ ]
+ for p in candidates:
+ try:
+ if p.is_file():
+ txt = p.read_text(encoding="utf-8").strip()
+ if txt:
+ return txt
+ except Exception:
+ pass
+ return "0"
+
+APP_VER = _read_app_ver() # muss mit APP_VER in app.js übereinstimmen (siehe VERSION + `make bump`)
@app.get("/.well-known/assetlinks.json")
async def assetlinks():
diff --git a/backend/routes/auth.py b/backend/routes/auth.py
index 7ee2d03..4a9def8 100644
--- a/backend/routes/auth.py
+++ b/backend/routes/auth.py
@@ -3,25 +3,119 @@
import os
import secrets
import string
-from datetime import datetime, timedelta
+import logging
+from datetime import datetime, timedelta, timezone
from typing import Optional
+import jwt as _pyjwt
from fastapi import APIRouter, HTTPException, Request, Response, Depends
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, EmailStr
from database import db
from auth import (
hash_password, verify_password, create_token,
- get_current_user
+ get_current_user, decode_token, blacklist_jti, JWT_EXPIRY
)
from username_blocklist import is_username_blocked
-from ratelimit import check as rl_check, record_login_failure, is_account_locked, clear_login_failures
+from ratelimit import check as rl_check
+
+logger = logging.getLogger(__name__)
router = APIRouter()
COOKIE_NAME = "by_token"
_APP_URL = os.getenv("APP_URL", "https://banyaro.app")
_SMTP_READY = bool(os.getenv("SMTP_SUPPORT_USER") and os.getenv("SMTP_SUPPORT_PASS"))
+# Login-Brute-Force-Lockout: 5 Fehlversuche in 15 Minuten → 15 Min. gesperrt.
+_LOCKOUT_WINDOW_MIN = 15
+_LOCKOUT_ATTEMPTS_MAX = 5
+
+
+# ------------------------------------------------------------------
+# Login-Lockout (DB-basiert, überlebt Container-Restart)
+# ------------------------------------------------------------------
+def _now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def _db_is_account_locked(email: str) -> Optional[int]:
+ """Gibt verbleibende Sperrzeit in Sekunden zurück (oder None falls nicht gesperrt)."""
+ with db() as conn:
+ row = conn.execute(
+ "SELECT locked_until FROM login_attempts WHERE email=? COLLATE NOCASE",
+ (email,)
+ ).fetchone()
+ if not row or not row["locked_until"]:
+ return None
+ try:
+ locked_until = datetime.fromisoformat(row["locked_until"])
+ except Exception:
+ return None
+ now = datetime.now(timezone.utc)
+ # Falls ohne TZ gespeichert (Legacy) → as-UTC interpretieren
+ if locked_until.tzinfo is None:
+ locked_until = locked_until.replace(tzinfo=timezone.utc)
+ if locked_until <= now:
+ return None
+ return int((locked_until - now).total_seconds())
+
+
+def _db_record_login_failure(email: str):
+ """Inkrementiert Fehlversuche; setzt locked_until wenn Schwelle erreicht."""
+ now = datetime.now(timezone.utc)
+ window_start = now - timedelta(minutes=_LOCKOUT_WINDOW_MIN)
+ with db() as conn:
+ row = conn.execute(
+ "SELECT attempts, last_attempt FROM login_attempts WHERE email=? COLLATE NOCASE",
+ (email,)
+ ).fetchone()
+ if row:
+ try:
+ last = datetime.fromisoformat(row["last_attempt"])
+ if last.tzinfo is None:
+ last = last.replace(tzinfo=timezone.utc)
+ except Exception:
+ last = now
+ # Zähler resetten wenn letzter Versuch außerhalb des Fensters lag
+ attempts = (row["attempts"] + 1) if last >= window_start else 1
+ else:
+ attempts = 1
+
+ locked_until = None
+ if attempts >= _LOCKOUT_ATTEMPTS_MAX:
+ locked_until = (now + timedelta(minutes=_LOCKOUT_WINDOW_MIN)).isoformat()
+
+ conn.execute(
+ """INSERT INTO login_attempts (email, attempts, last_attempt, locked_until)
+ VALUES (?,?,?,?)
+ ON CONFLICT(email) DO UPDATE SET
+ attempts=excluded.attempts,
+ last_attempt=excluded.last_attempt,
+ locked_until=excluded.locked_until""",
+ (email.lower(), attempts, now.isoformat(), locked_until)
+ )
+
+
+def _db_clear_login_failures(email: str):
+ with db() as conn:
+ conn.execute("DELETE FROM login_attempts WHERE email=? COLLATE NOCASE", (email,))
+
+
+# ------------------------------------------------------------------
+# SMTP-Fehler-Logging
+# ------------------------------------------------------------------
+def _log_smtp_failure(to_email: str, subject: str, body: str, error: Exception, context: str = ""):
+ """Loggt SMTP-Fehler und speichert in failed_emails für Admin-Retry."""
+ logger.exception("SMTP failed for %s | context=%s | subject=%s", to_email, context, subject)
+ try:
+ with db() as conn:
+ conn.execute(
+ "INSERT INTO failed_emails (to_email, subject, body, error, context) VALUES (?,?,?,?,?)",
+ (to_email, subject, body, repr(error), context or None)
+ )
+ except Exception:
+ logger.exception("failed_emails-Insert fehlgeschlagen")
+
def _send_verification_email(email: str, name: str, token: str):
if not _SMTP_READY:
@@ -45,8 +139,9 @@ def _send_verification_email(email: str, name: str, token: str):
plain = f"Hallo {name},\n\nbitte bestätige deine E-Mail:\n{url}\n\nLink ist 7 Tage gültig.\n"
try:
_send_smtp(email, subject, plain, "support", html=html)
- except Exception:
- pass # Nicht blockieren wenn SMTP fehlschlägt
+ except Exception as exc:
+ # Nicht blockieren wenn SMTP fehlschlägt — aber Fehler protokollieren + persistieren.
+ _log_smtp_failure(email, subject, plain, exc, context="verification_email")
class LoginRequest(BaseModel):
@@ -69,7 +164,7 @@ def _set_cookie(response: Response, token: str):
response.set_cookie(
key=COOKIE_NAME, value=token,
httponly=True, secure=True, samesite="lax",
- max_age=30 * 24 * 3600
+ max_age=JWT_EXPIRY * 24 * 3600
)
@@ -113,16 +208,24 @@ async def register(data: RegisterRequest, response: Response, request: Request):
code_upper = data.ref_code.strip().upper()
# Zuerst prüfen ob es ein Partner-Code ist
partner = conn.execute(
- "SELECT id, grants_founder, max_uses, uses FROM partner_codes WHERE code=?",
+ "SELECT id, grants_founder, max_uses FROM partner_codes WHERE code=?",
(code_upper,)
).fetchone()
if partner:
- # Nur einlösen wenn max_uses nicht erreicht
- if partner["max_uses"] is None or partner["uses"] < partner["max_uses"]:
- conn.execute(
- "UPDATE partner_codes SET uses=uses+1 WHERE id=?",
+ # ATOMARE Inkrementierung (SQLite ≥3.35 unterstützt RETURNING).
+ # Schließt Race-Condition wenn zwei User gleichzeitig den gleichen Code einlösen.
+ if partner["max_uses"] is None:
+ redeemed = conn.execute(
+ "UPDATE partner_codes SET uses=uses+1 WHERE id=? RETURNING uses",
(partner["id"],)
- )
+ ).fetchone()
+ else:
+ redeemed = conn.execute(
+ "UPDATE partner_codes SET uses=uses+1 WHERE id=? AND uses RETURNING uses",
+ (partner["id"], partner["max_uses"])
+ ).fetchone()
+
+ if redeemed:
updates = {"referred_by": -partner["id"]}
if partner["grants_founder"]:
total_founders = conn.execute(
@@ -155,8 +258,15 @@ async def login(data: LoginRequest, response: Response, request: Request):
rl_check(request, max_requests=10, window_seconds=300, key="login")
rl_check(request, max_requests=5, window_seconds=300, key=f"login_email:{data.email.lower()}")
- if is_account_locked(data.email):
- raise HTTPException(429, "Zu viele Fehlversuche. Bitte warte 15 Minuten und versuche es erneut.")
+ # DB-basierter Account-Lockout (überlebt Container-Restart)
+ remaining = _db_is_account_locked(data.email)
+ if remaining is not None:
+ minutes = max(1, remaining // 60)
+ raise HTTPException(
+ 429,
+ f"Zu viele Fehlversuche. Bitte warte {minutes} Minute(n) und versuche es erneut.",
+ headers={"Retry-After": str(remaining)}
+ )
with db() as conn:
user = conn.execute(
@@ -165,13 +275,13 @@ async def login(data: LoginRequest, response: Response, request: Request):
).fetchone()
if not user or not verify_password(data.password, user["pw_hash"]):
- record_login_failure(data.email)
+ _db_record_login_failure(data.email)
raise HTTPException(401, "E-Mail oder Passwort falsch.")
if not user["email_verified"]:
raise HTTPException(403, "EMAIL_NOT_VERIFIED")
- clear_login_failures(data.email)
+ _db_clear_login_failures(data.email)
token = create_token(user["id"], user["rolle"])
_set_cookie(response, token)
@@ -184,7 +294,30 @@ async def login(data: LoginRequest, response: Response, request: Request):
@router.post("/logout")
-async def logout(response: Response):
+async def logout(request: Request, response: Response):
+ # Token aus Cookie ODER Bearer-Header extrahieren und auf die Blacklist setzen,
+ # damit es serverseitig wirklich ungültig wird (nicht nur Cookie löschen).
+ raw_token = request.cookies.get(COOKIE_NAME)
+ if not raw_token:
+ auth_header = request.headers.get("Authorization", "")
+ if auth_header.lower().startswith("bearer "):
+ raw_token = auth_header.split(None, 1)[1].strip()
+
+ if raw_token:
+ try:
+ # options={'verify_exp': False}: auch bei abgelaufenem Token wollen wir jti+exp lesen,
+ # falls jemand sich vor Ablauf "ordentlich" abmelden möchte — und exp brauchen wir für TTL.
+ payload = _pyjwt.decode(
+ raw_token, options={"verify_signature": False}
+ )
+ jti = payload.get("jti")
+ exp = payload.get("exp")
+ if jti and exp:
+ expires_at = datetime.fromtimestamp(int(exp), tz=timezone.utc).isoformat()
+ blacklist_jti(jti, expires_at)
+ except Exception:
+ logger.exception("Logout: Token konnte nicht für Blacklist gelesen werden")
+
response.delete_cookie(COOKIE_NAME)
return {"ok": True}
@@ -332,8 +465,8 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request):
plain = f"Hallo {user['name']},\n\nPasswort zurücksetzen:\n{url}\n\nLink ist 2h gültig.\n"
try:
_send_smtp(data.email, subject, plain, "support", html=html)
- except Exception:
- pass
+ except Exception as exc:
+ _log_smtp_failure(data.email, subject, plain, exc, context="forgot_password")
return {"ok": True}
@@ -372,8 +505,8 @@ async def create_upgrade_request(data: UpgradeRequestBody, user=Depends(get_curr
f"Nachricht: {data.message or '—'}\n\n"
f"Admin-Panel: https://banyaro.app/#admin")
_send_smtp(admin_email, subject, body, "support")
- except Exception:
- pass
+ except Exception as exc:
+ _log_smtp_failure(admin_email, subject, body, exc, context="upgrade_request_admin_notify")
return {"ok": True}
@@ -450,8 +583,14 @@ async def cancel_subscription(user=Depends(get_current_user)):
+ (f"Aktiv bis: {expires_de}\n" if expires_de else "")
+ "\nAlle Daten bleiben erhalten.\n\nViele Grüße\nRené")
await send_email(user["email"], f"Kündigung bestätigt — {tier_label}", html, plain)
- except Exception:
- pass
+ except Exception as exc:
+ _log_smtp_failure(
+ user.get("email") or "",
+ f"Kündigung bestätigt — {tier_label if 'tier_label' in locals() else '?'}",
+ plain if 'plain' in locals() else "",
+ exc,
+ context="subscription_cancel_confirmation"
+ )
return {"ok": True, "expires_at": expires}
diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py
index 28f3d15..1f27759 100644
--- a/backend/routes/invoices.py
+++ b/backend/routes/invoices.py
@@ -49,15 +49,66 @@ class CancelBody(BaseModel):
# Hilfsfunktionen
# ------------------------------------------------------------------
def _next_invoice_number(conn, prefix="RG"):
+ """Vergibt atomar die naechste Rechnungsnummer fuer (prefix, year).
+
+ Race-frei dank dedizierter Counter-Tabelle 'invoice_counters' und
+ BEGIN IMMEDIATE — gleichzeitige Aufrufe von zwei Admins koennen nicht
+ dieselbe Nummer ziehen. SQLite serialisiert die Writer; der zweite
+ wartet bis busy_timeout.
+
+ Beim ersten Aufruf fuer (prefix, year) wird die Counter-Row angelegt;
+ dabei wird der aktuelle Stand aus der invoices-Tabelle uebernommen
+ (Backfill fuer bestehende Bestaende vor Einfuehrung des Counters).
+ """
year = datetime.now().year
- last = conn.execute(
- "SELECT invoice_number FROM invoices WHERE invoice_number LIKE ? ORDER BY id DESC LIMIT 1",
- (f"{prefix}-{year}-%",)
+
+ # Falls noch keine Transaktion offen ist: BEGIN IMMEDIATE,
+ # damit der naechste Writer serialisiert wird.
+ if not conn.in_transaction:
+ conn.execute("BEGIN IMMEDIATE")
+
+ row = conn.execute(
+ "SELECT next_num FROM invoice_counters WHERE prefix=? AND year=?",
+ (prefix, year)
).fetchone()
- if last:
- n = int(last[0].split("-")[-1]) + 1
+
+ if row is None:
+ # Counter fehlt → Backfill aus invoices-Tabelle (max. bisheriger
+ # Nummer + 1), damit ein nachtraeglich eingefuehrter Counter
+ # nicht bei 1 startet und Kollisionen erzeugt.
+ last = conn.execute(
+ "SELECT invoice_number FROM invoices WHERE invoice_number LIKE ? "
+ "ORDER BY id DESC LIMIT 1",
+ (f"{prefix}-{year}-%",)
+ ).fetchone()
+ # Auch cancellation_number kann den 'ST'-Prefix tragen
+ last_st = conn.execute(
+ "SELECT cancellation_number FROM invoices "
+ "WHERE cancellation_number LIKE ? ORDER BY id DESC LIMIT 1",
+ (f"{prefix}-{year}-%",)
+ ).fetchone()
+
+ existing_max = 0
+ for r in (last, last_st):
+ if r and r[0]:
+ try:
+ existing_max = max(existing_max, int(r[0].split("-")[-1]))
+ except (ValueError, IndexError):
+ pass
+
+ n = existing_max + 1
+ conn.execute(
+ "INSERT INTO invoice_counters (prefix, year, next_num) VALUES (?,?,?)",
+ (prefix, year, n + 1)
+ )
else:
- n = 1
+ n = row[0]
+ conn.execute(
+ "UPDATE invoice_counters SET next_num = next_num + 1 "
+ "WHERE prefix=? AND year=?",
+ (prefix, year)
+ )
+
return f"{prefix}-{year}-{n:04d}"
diff --git a/backend/scheduler.py b/backend/scheduler.py
index a6aef1f..11dcf62 100644
--- a/backend/scheduler.py
+++ b/backend/scheduler.py
@@ -231,6 +231,15 @@ def start():
misfire_grace_time=3600,
coalesce=True,
)
+ # Täglich 06:30 — Error-Digest-Mail an ADMIN_EMAIL
+ _scheduler.add_job(
+ _job_error_digest,
+ CronTrigger(hour=6, minute=30),
+ id="error_digest",
+ replace_existing=True,
+ misfire_grace_time=1800,
+ coalesce=True,
+ )
_scheduler.start()
logger.info("Scheduler gestartet (gestaffelt) — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import 1.+2./4./7./10. 02:00, Rassen-Seed 1. 03:00, Wikidata-Seed 1. 04:00, Status-Report 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:10, KI-Gesundheitsbericht Mo 07:05, Streak-Reminder 19:00, Rückruf-Check 08:05, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. 10:00, Foto-Challenge Mo 08:10, Weekly-Praise Mo 09:05, Abo-Check 03:15, Invoice-Reminder 08:30. OSM-Cache: on-demand (kein Prewarm).")
@@ -2092,3 +2101,133 @@ def _find_best_gassi_window(hourly: list[dict]) -> tuple[int, int, float | None,
best_wind = sum(winds) / len(winds) if winds else None
return best_start, best_score, best_temp, best_wind
+
+
+# ------------------------------------------------------------------
+# JOB: Error-Digest (täglich 06:30 Uhr)
+# ------------------------------------------------------------------
+# Quellen fuer Fehler:
+# 1. _job_log dieser Datei — Status der einzelnen Scheduler-Jobs
+# 2. main.log_buffer — In-Memory-Buffer der letzten 500 Log-Zeilen
+# Limitierung: log_buffer ist nicht persistent. Ein vollstaendiger 24h-Digest
+# wuerde eine externe Log-Senke (Datei oder DB) brauchen. Bis dahin liefert
+# der Job das was im Memory verfuegbar ist plus alle Scheduler-Errors.
+# ------------------------------------------------------------------
+async def _job_error_digest():
+ """Schickt eine Zusammenfassung aller bekannten ERROR/EXCEPTION-Eintraege
+ der letzten 24h an ADMIN_EMAIL."""
+ import os
+ import html as _html
+ from collections import Counter
+ from mailer import send_email, email_html
+
+ admin = os.getenv("ADMIN_EMAIL", "")
+ if not admin:
+ logger.info("Error-Digest: ADMIN_EMAIL nicht gesetzt, uebersprungen.")
+ _log_job("error_digest", "ok", "ADMIN_EMAIL nicht gesetzt")
+ return
+
+ now = datetime.now(tz=_TZ)
+ cutoff = now - timedelta(hours=24)
+
+ # ── 1. Scheduler-Job-Errors einsammeln ───────────────────────
+ scheduler_errors = []
+ for jid, log in _job_log.items():
+ last_run = log.get("last_run")
+ if last_run and last_run >= cutoff and log.get("status") == "error":
+ scheduler_errors.append({
+ "job": jid,
+ "ts": last_run.strftime("%d.%m. %H:%M"),
+ "result": log.get("result", ""),
+ })
+
+ # ── 2. In-Memory-Log-Buffer einsammeln (best-effort) ─────────
+ log_errors = []
+ try:
+ from main import log_buffer # type: ignore
+ for entry in list(log_buffer):
+ lvl = entry.get("l", "")
+ if lvl in ("ERROR", "CRITICAL", "EXCEPTION"):
+ log_errors.append({
+ "ts": entry.get("t", ""),
+ "lvl": lvl,
+ "name": entry.get("n", ""),
+ "msg": entry.get("m", ""),
+ })
+ except Exception as e:
+ logger.debug(f"Error-Digest: log_buffer nicht verfuegbar: {e}")
+
+ # Gruppieren: Fehler-Meldungen mit gleichem msg-Prefix zusammenfassen
+ grouped: Counter = Counter()
+ for e in log_errors:
+ # Erste 80 Zeichen als Schluessel — schluckt Variablenwerte am Ende
+ key = (e["name"], e["msg"][:80])
+ grouped[key] += 1
+
+ # ── Wenn nichts zu melden ist: leise raus ────────────────────
+ if not scheduler_errors and not grouped:
+ logger.info("Error-Digest: Keine Errors in den letzten 24h.")
+ _log_job("error_digest", "ok", "0 Errors")
+ return
+
+ # ── HTML-Body bauen ──────────────────────────────────────────
+ parts = []
+ parts.append(f'Fehler-Zusammenfassung der letzten 24h ({now.strftime("%d.%m.%Y %H:%M")}).
')
+
+ if scheduler_errors:
+ rows_html = "".join(
+ f''
+ f'{_html.escape(e["job"])} '
+ f'{e["ts"]} '
+ f'{_html.escape(e["result"])} '
+ f' '
+ for e in scheduler_errors
+ )
+ parts.append(
+ 'Scheduler-Job-Fehler ({0}) '.format(len(scheduler_errors))
+ + f''
+ )
+
+ if grouped:
+ sorted_groups = sorted(grouped.items(), key=lambda kv: -kv[1])
+ rows_html = "".join(
+ f''
+ f'{count}x '
+ f'{_html.escape(name)} '
+ f'{_html.escape(msg)} '
+ f' '
+ for (name, msg), count in sorted_groups[:30]
+ )
+ parts.append(
+ 'In-Memory-Log-Errors ({0} Typen, {1} Eintraege) '.format(len(grouped), sum(grouped.values()))
+ + f''
+ )
+
+ parts.append('Quellen: scheduler._job_log (24h) + main.log_buffer (Memory, max. 500 Eintraege). Fuer vollstaendige 24h-Historie waere eine persistente Log-Senke noetig.
')
+
+ body = "\n".join(parts)
+ html = email_html(body, footer_text="Ban Yaro · Error-Digest")
+ plain_lines = [f"Ban Yaro Error-Digest — {now.strftime('%d.%m.%Y %H:%M')}", ""]
+ if scheduler_errors:
+ plain_lines.append("Scheduler-Job-Fehler:")
+ for e in scheduler_errors:
+ plain_lines.append(f" - {e['ts']} {e['job']}: {e['result']}")
+ plain_lines.append("")
+ if grouped:
+ plain_lines.append("Log-Errors (Top 30 Typen, gruppiert):")
+ for (name, msg), count in sorted(grouped.items(), key=lambda kv: -kv[1])[:30]:
+ plain_lines.append(f" {count}x [{name}] {msg}")
+ plain = "\n".join(plain_lines)
+
+ try:
+ await send_email(
+ admin,
+ f"Ban Yaro Error-Digest ({len(scheduler_errors)} Scheduler + {len(grouped)} Log-Errors)",
+ html,
+ plain,
+ )
+ logger.info(f"Error-Digest gesendet an {admin} — {len(scheduler_errors)} scheduler, {len(grouped)} log-types.")
+ _log_job("error_digest", "ok", f"{len(scheduler_errors)} scheduler / {len(grouped)} log-types")
+ except Exception as e:
+ logger.error(f"Error-Digest: Mail-Fehler: {e}")
+ _log_job("error_digest", "error", str(e))
diff --git a/backend/static/index.html b/backend/static/index.html
index 5608b40..1bccf33 100644
--- a/backend/static/index.html
+++ b/backend/static/index.html
@@ -101,9 +101,9 @@
-
-
-
+
+
+
@@ -625,11 +625,11 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/backend/static/js/app.js b/backend/static/js/app.js
index 2fd94f2..746c4b4 100644
--- a/backend/static/js/app.js
+++ b/backend/static/js/app.js
@@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
-const APP_VER = '1094'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
+const APP_VER = '1095'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION;
diff --git a/backend/static/js/pages/datenschutz.js b/backend/static/js/pages/datenschutz.js
index 893883f..98b2e4b 100644
--- a/backend/static/js/pages/datenschutz.js
+++ b/backend/static/js/pages/datenschutz.js
@@ -270,8 +270,14 @@ window.Page_datenschutz = (() => {
Datenexport (Art. 20 DSGVO): Du kannst jederzeit unter
Einstellungen → „Meine Daten exportieren" eine vollständige Kopie deiner
gespeicherten Daten als JSON-Datei herunterladen. Der Export enthält Profildaten,
- Hundedaten, Tagebuch, Gesundheitseinträge, Trainingsfortschritt, Ausgaben,
- Verhaltensprotokoll, Forum-Beiträge und Gassi-Teilnahmen.
+ Hundedaten, Tagebuch (inkl. Medien-URLs), Gesundheitseinträge, Trainingsfortschritt,
+ Ausgaben, Verhaltensprotokoll, Versicherung, Ernährungsprofil und Futter-Reaktionen,
+ eigene Routen, Forum-Beiträge sowie Gassi-Teilnahmen und Gassi-Fotos.
+
+
+ Das JSON-Format ist maschinenlesbar und kann z. B. mit jedem Texteditor geöffnet
+ oder in andere Anwendungen importiert werden. Der Export wird direkt im Browser
+ erzeugt und nicht dauerhaft auf dem Server gespeichert.
Zur Ausübung weiterer Rechte wende dich per E-Mail an
@@ -303,10 +309,31 @@ window.Page_datenschutz = (() => {
${sec('Speicherdauer', `
- Deine Daten werden vollständig gelöscht, sobald du deinen Account löschst —
- einschließlich Tagebuch, Gesundheitseinträge, Fotos, Forenbeiträge und Hundeprofil.
- Es gibt keine anonymisierte Weiterverarbeitung deiner Inhalte nach Account-Löschung.
- Server-Logs werden nach 30 Tagen rotiert.
+ Server-Logs werden nach 30 Tagen rotiert. IP-Adressen werden ausschließlich
+ zur Sicherheit und für Rate-Limiting maximal 30 Tage gespeichert.
+
`)}
+
+ ${sec('Account-Löschung', `
+
+ Wenn du deinen Account löschst, werden deine Daten nach folgendem Schema verarbeitet:
+
+
+ Sofort und unwiderruflich gelöscht: Account, Hundeprofile, Tagebuch
+ und Tagebuch-Medien, Gesundheitseinträge, Trainingsfortschritt, Ausgaben,
+ Verhaltensprotokoll, Versicherung, Ernährungsprofil, Futter-Einträge und -Reaktionen,
+ Forum-Beiträge, eigene Notizen, Direktnachrichten, Freundschaften,
+ Push-Benachrichtigungen, Einstellungen und Welten-Konfiguration.
+ Anonymisiert (Urheber-Bezug auf NULL gesetzt): Eigene Routen,
+ Forum-Threads sowie von dir angelegte Wiki-Inhalte bleiben zur Verfügbarkeit für
+ die Community erhalten, sind aber nicht mehr deinem Account zuordenbar.
+ 10 Jahre aufbewahrt (gesetzliche Pflicht): Rechnungen und
+ Rechnungspositionen aus kostenpflichtigen Abonnements gemäß § 147 AO. Diese
+ enthalten Name, E-Mail-Adresse und Rechnungsadresse zum Zeitpunkt der Rechnung
+ und können vor Ablauf der Frist nicht gelöscht werden.
+
+
+ Es findet keine anonymisierte Weiterverarbeitung deiner privaten Inhalte
+ (Tagebuch, Gesundheit, Notizen) zu Trainings- oder Statistikzwecken statt.
`)}
${sec('Mindestalter', `
diff --git a/backend/static/js/pages/erste-hilfe.js b/backend/static/js/pages/erste-hilfe.js
index 6c76d86..570a450 100644
--- a/backend/static/js/pages/erste-hilfe.js
+++ b/backend/static/js/pages/erste-hilfe.js
@@ -15,10 +15,35 @@ window.Page_erste_hilfe = (() => {
// ----------------------------------------------------------------
// DATA
// ----------------------------------------------------------------
- const NOTFALLNUMMERN = [
- { label: 'Tiergiftzentrale München', tel: '+4989 19240', display: '+49 89 19240' },
- { label: 'Tiergiftzentrale Berlin', tel: '+4930 19240', display: '+49 30 19240' },
- { label: 'Tiergiftzentrale Wien', tel: '+431 4064343', display: '+43 1 4064343' },
+ // Liste von Notrufen, nach Land gruppiert.
+ // Struktur ist erweiterbar: weitere Länder/Städte einfach an die jeweilige
+ // Gruppe anhängen. Einträge mit tel:null werden als "TODO: Nummer einfügen"
+ // dargestellt — Rendering kümmert sich um Optik und tel: -Link.
+ const NOTFALLNUMMERN_GRUPPEN = [
+ {
+ land: 'Deutschland',
+ flag: 'DE',
+ eintraege: [
+ { label: 'Tiergiftzentrale München', tel: '+4989 19240', display: '+49 89 19240' },
+ { label: 'Tiergiftzentrale Berlin', tel: '+4930 19240', display: '+49 30 19240' },
+ ],
+ },
+ {
+ land: 'Österreich',
+ flag: 'AT',
+ eintraege: [
+ { label: 'Vergiftungsinformationszentrale Wien', tel: '+431 4064343', display: '+43 1 4064343' },
+ { label: 'Veterinärmedizinische Universität Wien (Notfallklinik)', tel: null, display: 'TODO: Nummer einfügen' },
+ ],
+ },
+ {
+ land: 'Schweiz',
+ flag: 'CH',
+ eintraege: [
+ { label: 'Tox Info Suisse (Tiergiftnotruf)', tel: null, display: 'TODO: Nummer einfügen (ggf. 145)' },
+ { label: 'Tierspital Zürich', tel: null, display: 'TODO: Nummer einfügen' },
+ ],
+ },
];
const SCHNELL = [
@@ -213,6 +238,7 @@ window.Page_erste_hilfe = (() => {
_container.innerHTML = `
+ ${_renderDisclaimer()}
${_renderNotfallbanner()}
${_renderSchnell()}
@@ -244,13 +270,51 @@ window.Page_erste_hilfe = (() => {
_activateTab('lebensgefahr');
}
+ function _renderDisclaimer() {
+ return `
+
+
+
+ Diese Hinweise ersetzen keine tierärztliche Beratung.
+ Im Notfall sofort einen Tierarzt aufsuchen!
+
+
+ `;
+ }
+
function _renderNotfallbanner() {
- const nums = NOTFALLNUMMERN.map(n => `
-
-
- ${n.label} ${n.display}
-
+ const renderEintrag = (n) => {
+ // Eintrag mit verfügbarer Nummer → tel:-Link
+ if (n.tel) {
+ return `
+
+
+ ${n.label} ${n.display}
+
+ `;
+ }
+ // Eintrag ohne Nummer → ausgegrauter Platzhalter
+ return `
+
+
+ ${n.label} ${n.display}
+
+ `;
+ };
+
+ const gruppen = NOTFALLNUMMERN_GRUPPEN.map(g => `
+
+
+ ${g.flag} · ${g.land}
+
+
+ ${g.eintraege.map(renderEintrag).join('')}
+
+
`).join('');
return `
@@ -259,8 +323,8 @@ window.Page_erste_hilfe = (() => {
Tiergiftzentralen — jetzt anrufen
-
- ${nums}
+
+ ${gruppen}
Tierärztlicher Notdienst: Über die Tierarztsuche in der Banyaro-Karte
diff --git a/backend/static/js/pages/impressum.js b/backend/static/js/pages/impressum.js
index e05776d..fa4d585 100644
--- a/backend/static/js/pages/impressum.js
+++ b/backend/static/js/pages/impressum.js
@@ -33,7 +33,7 @@ window.Page_impressum = (() => {