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 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'{rows_html}
' + ) + + 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'{rows_html}
' + ) + + 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: +

+ +

+ 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 = (() => {

- +
- + Betreff * +
- +