diff --git a/Dockerfile b/Dockerfile index 07e8bd6..c29da6b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,9 +15,6 @@ RUN pip install --no-cache-dir -r requirements.txt # App-Code COPY backend/ . -# Zentrale Version (wird von main.py beim Startup gelesen) -COPY VERSION /app/VERSION - # Media-Verzeichnis RUN mkdir -p /data/media/dogs /data/media/diary /data/media/poison \ /data/media/breeds/gallery /data/media/breeds/submissions diff --git a/Makefile b/Makefile index 9402ea3..692e7bb 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ TAR_EXCLUDE := --exclude='.git' \ --exclude='./.DS_Store' .PHONY: help deploy deploy-clean staging release sync push restart build stop status \ - logs logs-f shell db dev clean-cache check-ssh reports bump test + logs logs-f shell db dev clean-cache check-ssh reports # ---------------------------------------------------------- # SSH-Prüfung — Abhängigkeit aller DS-Befehle @@ -272,31 +272,6 @@ reports: check-ssh done -# ---------------------------------------------------------- -# BUMP — zentrale Versions-Erhöhung (VERSION-Datei + sw.js + app.js + index.html) -# Aufruf: -# make bump → liest aus VERSION, erhöht +1, schreibt zurück, propagiert in alle Frontend-Stellen -# make bump APP_VER=2000 → setzt VERSION explizit auf 2000 -# Backend liest APP_VER beim Startup aus VERSION (siehe main.py:_read_app_ver()). -# ---------------------------------------------------------- -bump: - @if [ ! -f VERSION ]; then echo "0" > 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 deleted file mode 100644 index 39987d0..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -1099 \ No newline at end of file diff --git a/backend/auth.py b/backend/auth.py index 9cb25c6..0b3bff1 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -4,12 +4,11 @@ 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, Response +from fastapi import Depends, HTTPException, status, Request from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from database import db @@ -17,12 +16,7 @@ from database import db logger = logging.getLogger(__name__) JWT_SECRET = os.getenv("JWT_SECRET", "change-me-in-production") JWT_ALGO = "HS256" -# 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")) +JWT_EXPIRY = int(os.getenv("JWT_EXPIRY_DAYS", "30")) if JWT_SECRET == "change-me-in-production" and os.getenv("ENV") == "production": raise RuntimeError( @@ -33,44 +27,6 @@ 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 # ------------------------------------------------------------------ @@ -86,25 +42,17 @@ 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": now + timedelta(days=JWT_EXPIRY), - "iat": now, - "jti": uuid.uuid4().hex, + "exp": datetime.now(timezone.utc) + timedelta(days=JWT_EXPIRY), + "iat": datetime.now(timezone.utc), } return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGO) def decode_token(token: str) -> dict: - """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 + return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGO]) # ------------------------------------------------------------------ @@ -120,33 +68,6 @@ 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), @@ -178,9 +99,6 @@ 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 08ac7db..00a5210 100644 --- a/backend/database.py +++ b/backend/database.py @@ -592,15 +592,10 @@ 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}" @@ -1214,7 +1209,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 ki_cols and "source" not in ki_cols: + if "source" not in ki_cols: conn.executescript(""" ALTER TABLE ki_daily_calls RENAME TO ki_daily_calls_old; CREATE TABLE ki_daily_calls ( @@ -1306,19 +1301,11 @@ def _migrate(conn_factory): logger.info("Migration: dogs.gewicht_kg aus health synchronisiert.") # Performance-Indizes für häufige Queries - # 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) + 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)") logger.info("Migration: Performance-Indizes bereit.") # Züchter-Tabellen @@ -2457,17 +2444,7 @@ def _migrate(conn_factory): total REAL NOT NULL ) """) - # 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.") + logger.info("Migration: invoices + invoice_items bereit.") except Exception as e: logger.warning(f"Migration invoices: {e}") @@ -2484,63 +2461,6 @@ 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 569a887..6a3fb2e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -123,28 +123,6 @@ 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 @@ -432,26 +410,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -# 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_VER = "1094" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 713a055..bfd02f8 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -211,16 +211,6 @@ async def stats(user=Depends(require_mod)): "SELECT type, COUNT(*) FROM osm_pois GROUP BY type ORDER BY 2 DESC" ).fetchall() } - # User-erstellte POIs nach Typ (type-Spalte kann komma-separiert sein wie 'freilauf,treffpunkt') - user_poi_total = conn.execute("SELECT COUNT(*) FROM user_map_pois").fetchone()[0] - user_poi_by_type = {} - for row in conn.execute("SELECT type FROM user_map_pois").fetchall(): - for t in (row[0] or "").split(","): - t = t.strip() - if t: - user_poi_by_type[t] = user_poi_by_type.get(t, 0) + 1 - # absteigend sortieren - user_poi_by_type = dict(sorted(user_poi_by_type.items(), key=lambda x: x[1], reverse=True)) # KI-Nutzung try: @@ -322,8 +312,6 @@ async def stats(user=Depends(require_mod)): "osm_total": osm_total, "osm_tiles": osm_tiles, "osm_by_type": osm_by_type, - "user_poi_total": user_poi_total, - "user_poi_by_type": user_poi_by_type, "ki_today": ki_today, "ki_week": ki_week, "ki_month": ki_month, diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 4a9def8..7ee2d03 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -3,119 +3,25 @@ import os import secrets import string -import logging -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta 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, decode_token, blacklist_jti, JWT_EXPIRY + get_current_user ) from username_blocklist import is_username_blocked -from ratelimit import check as rl_check - -logger = logging.getLogger(__name__) +from ratelimit import check as rl_check, record_login_failure, is_account_locked, clear_login_failures 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: @@ -139,9 +45,8 @@ 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 as exc: - # Nicht blockieren wenn SMTP fehlschlägt — aber Fehler protokollieren + persistieren. - _log_smtp_failure(email, subject, plain, exc, context="verification_email") + except Exception: + pass # Nicht blockieren wenn SMTP fehlschlägt class LoginRequest(BaseModel): @@ -164,7 +69,7 @@ def _set_cookie(response: Response, token: str): response.set_cookie( key=COOKIE_NAME, value=token, httponly=True, secure=True, samesite="lax", - max_age=JWT_EXPIRY * 24 * 3600 + max_age=30 * 24 * 3600 ) @@ -208,24 +113,16 @@ 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 FROM partner_codes WHERE code=?", + "SELECT id, grants_founder, max_uses, uses FROM partner_codes WHERE code=?", (code_upper,) ).fetchone() if partner: - # 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", + # 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=?", (partner["id"],) - ).fetchone() - else: - redeemed = conn.execute( - "UPDATE partner_codes SET uses=uses+1 WHERE id=? AND uses float: diff --git a/backend/scheduler.py b/backend/scheduler.py index 11dcf62..a6aef1f 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -231,15 +231,6 @@ 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).") @@ -2101,133 +2092,3 @@ 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'{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 bf2434e..5608b40 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 b275c15..2fd94f2 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 = '1099'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1094'; // ← 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/admin.js b/backend/static/js/pages/admin.js index 4a1a63a..311f104 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -652,43 +652,17 @@ window.Page_admin = (() => { ` : ''} - ${(() => { - const POI_LABELS = { - waste_basket: 'Mülleimer', dog_park: 'Hundewiese', drinking_water: 'Wasserstelle', - tierarzt: 'Tierarzt', hundesalon: 'Hundesalon', hundeschule: 'Hundeschule', - shop: 'Shop', restaurant: 'Café / Restaurant', bank: 'Sitzbank', - hotel: 'Hotel', freilauf: 'Freilauf', kotbeutel: 'Kotbeutel', - parkplatz: 'Parkplatz', treffpunkt: 'Treffpunkt', sonstiges: 'Sonstiges', - giftkoeder: 'Giftköder', gefahr: 'Gefahr', - }; - const label = t => POI_LABELS[t] || t; - const row = ([type, count]) => ` +
+

OSM-Cache nach Typ

+
+ ${Object.entries(s.osm_by_type).map(([type, count]) => `
- ${label(type)} + ${type} ${count.toLocaleString('de')} -
`; - const userByType = s.user_poi_by_type || {}; - const userTotal = s.user_poi_total ?? 0; - return ` -
-

- OSM-Cache nach Typ — ${(s.osm_total || 0).toLocaleString('de')} gecacht -

-
- ${Object.entries(s.osm_by_type).map(row).join('')} -
+
+ `).join('')}
-
-

- Nutzer-POIs nach Typ — ${userTotal.toLocaleString('de')} gesamt -

-
- ${Object.keys(userByType).length - ? Object.entries(userByType).map(row).join('') - : '
Noch keine Nutzer-POIs
'} -
-
`; - })()} +
diff --git a/backend/static/js/pages/datenschutz.js b/backend/static/js/pages/datenschutz.js index 98b2e4b..893883f 100644 --- a/backend/static/js/pages/datenschutz.js +++ b/backend/static/js/pages/datenschutz.js @@ -270,14 +270,8 @@ 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 (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. + Hundedaten, Tagebuch, Gesundheitseinträge, Trainingsfortschritt, Ausgaben, + Verhaltensprotokoll, Forum-Beiträge und Gassi-Teilnahmen.

Zur Ausübung weiterer Rechte wende dich per E-Mail an @@ -309,31 +303,10 @@ window.Page_datenschutz = (() => { ${sec('Speicherdauer', `

- 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. + 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.

`)} ${sec('Mindestalter', ` diff --git a/backend/static/js/pages/erste-hilfe.js b/backend/static/js/pages/erste-hilfe.js index 570a450..6c76d86 100644 --- a/backend/static/js/pages/erste-hilfe.js +++ b/backend/static/js/pages/erste-hilfe.js @@ -15,35 +15,10 @@ window.Page_erste_hilfe = (() => { // ---------------------------------------------------------------- // DATA // ---------------------------------------------------------------- - // 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 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' }, ]; const SCHNELL = [ @@ -238,7 +213,6 @@ window.Page_erste_hilfe = (() => { _container.innerHTML = `
- ${_renderDisclaimer()} ${_renderNotfallbanner()} ${_renderSchnell()} @@ -270,51 +244,13 @@ 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 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('')} -
-
+ const nums = NOTFALLNUMMERN.map(n => ` + + + ${n.label}
${n.display}
+
`).join(''); return ` @@ -323,8 +259,8 @@ window.Page_erste_hilfe = (() => { Tiergiftzentralen — jetzt anrufen
-
- ${gruppen} +
+ ${nums}

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 fa4d585..e05776d 100644 --- a/backend/static/js/pages/impressum.js +++ b/backend/static/js/pages/impressum.js @@ -33,7 +33,7 @@ window.Page_impressum = (() => {

- +
- + Betreff * +
- +