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 RETURNING uses", - (partner["id"], partner["max_uses"]) - ).fetchone() - - if redeemed: + ) updates = {"referred_by": -partner["id"]} if partner["grants_founder"]: total_founders = conn.execute( @@ -258,15 +155,8 @@ 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()}") - # 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)} - ) + if is_account_locked(data.email): + raise HTTPException(429, "Zu viele Fehlversuche. Bitte warte 15 Minuten und versuche es erneut.") with db() as conn: user = conn.execute( @@ -275,13 +165,13 @@ async def login(data: LoginRequest, response: Response, request: Request): ).fetchone() if not user or not verify_password(data.password, user["pw_hash"]): - _db_record_login_failure(data.email) + record_login_failure(data.email) raise HTTPException(401, "E-Mail oder Passwort falsch.") if not user["email_verified"]: raise HTTPException(403, "EMAIL_NOT_VERIFIED") - _db_clear_login_failures(data.email) + clear_login_failures(data.email) token = create_token(user["id"], user["rolle"]) _set_cookie(response, token) @@ -294,30 +184,7 @@ async def login(data: LoginRequest, response: Response, request: Request): @router.post("/logout") -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") - +async def logout(response: Response): response.delete_cookie(COOKIE_NAME) return {"ok": True} @@ -465,8 +332,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 as exc: - _log_smtp_failure(data.email, subject, plain, exc, context="forgot_password") + except Exception: + pass return {"ok": True} @@ -505,8 +372,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 as exc: - _log_smtp_failure(admin_email, subject, body, exc, context="upgrade_request_admin_notify") + except Exception: + pass return {"ok": True} @@ -583,14 +450,8 @@ 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 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" - ) + except Exception: + pass return {"ok": True, "expires_at": expires} diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py index 1f27759..28f3d15 100644 --- a/backend/routes/invoices.py +++ b/backend/routes/invoices.py @@ -49,66 +49,15 @@ 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 - - # 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) + last = conn.execute( + "SELECT invoice_number FROM invoices WHERE invoice_number LIKE ? ORDER BY id DESC LIMIT 1", + (f"{prefix}-{year}-%",) ).fetchone() - - 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) - ) + if last: + n = int(last[0].split("-")[-1]) + 1 else: - n = row[0] - conn.execute( - "UPDATE invoice_counters SET next_num = next_num + 1 " - "WHERE prefix=? AND year=?", - (prefix, year) - ) - + n = 1 return f"{prefix}-{year}-{n:04d}" diff --git a/backend/routes/osm.py b/backend/routes/osm.py index d130898..f58fd4c 100644 --- a/backend/routes/osm.py +++ b/backend/routes/osm.py @@ -46,7 +46,6 @@ OSM_QUERIES = { 'drinking_water': '[out:json][timeout:20];node["amenity"="drinking_water"]({bbox});out;', 'tierarzt': '[out:json][timeout:25];(node["amenity"="veterinary"]({bbox});way["amenity"="veterinary"]({bbox}););out center;', 'shop': '[out:json][timeout:25];(node["shop"="pet"]({bbox});way["shop"="pet"]({bbox}););out center;', - 'hundesalon': '[out:json][timeout:25];(node["shop"="pet_grooming"]({bbox});way["shop"="pet_grooming"]({bbox});node["craft"="pet_grooming"]({bbox});way["craft"="pet_grooming"]({bbox}););out center;', 'restaurant': '[out:json][timeout:35];(node["amenity"~"restaurant|cafe"]["dog"~"yes|allowed"]({bbox});way["amenity"~"restaurant|cafe"]["dog"~"yes|allowed"]({bbox});node["amenity"="biergarten"]({bbox});way["amenity"="biergarten"]({bbox}););out center;', 'bank': '[out:json][timeout:20];node["amenity"="bench"]({bbox});out;', 'hotel': '[out:json][timeout:25];(node["tourism"~"hotel|guest_house|hostel"]["dog"~"yes|allowed"]({bbox});way["tourism"~"hotel|guest_house|hostel"]["dog"~"yes|allowed"]({bbox}););out center;', @@ -287,7 +286,6 @@ ALLOWED_TYPES = { 'restaurant', # Hundefreundliches Restaurant / Café 'shop', # Hundefreundlicher Shop 'tierarzt', # Tierarzt / Tierklinik - 'hundesalon', # Hundesalon / Hundefriseur / Groomer 'hundeschule', # Hundeschule / Trainer 'kotbeutel', # Kotbeutelspender 'bank', # Sitzbank diff --git a/backend/routes/places.py b/backend/routes/places.py index c8ca526..6e5c082 100644 --- a/backend/routes/places.py +++ b/backend/routes/places.py @@ -9,7 +9,7 @@ from auth import get_current_user router = APIRouter() -TYPEN = {'restaurant', 'shop', 'freilauf', 'kotbeutel', 'tierarzt', 'hundesalon', 'hundeschule'} +TYPEN = {'restaurant', 'shop', 'freilauf', 'kotbeutel', 'tierarzt', 'hundeschule'} def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> 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'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
+- OSM-Cache nach Typ — ${(s.osm_total || 0).toLocaleString('de')} gecacht -
-- Nutzer-POIs nach Typ — ${userTotal.toLocaleString('de')} gesamt -
-- 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 = `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 = (() => {