Big Sweep: Security + Race-Conditions + Tests + DSGVO + A11y, SW by-v1095
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<max_uses RETURNING — atomar statt SELECT+UPDATE RACE CONDITIONS (routes/invoices.py, database.py) - Neue invoice_counters-Tabelle für atomare Nummernvergabe - _next_invoice_number nutzt BEGIN IMMEDIATE + atomares UPDATE - Funktioniert für RG- und ST-Prefixe (Stornorechnungen) - Race-Test verifiziert (5 Threads × 20 Calls = 100 eindeutige Nummern) VERSION + TESTS + ERROR-DIGEST (VERSION, Makefile, tests/, scheduler.py) - Neue VERSION-Datei (Single Source of Truth) — main.py liest beim Startup - Makefile-Target 'make bump' propagiert in sw.js, app.js, index.html - Makefile-Target 'make test' setzt venv auf, läuft pytest - 19 Smoke-Tests in tests/ (health, auth, diary, invoice) — alle grün - Scheduler: täglicher _job_error_digest um 06:30 → schickt Error- Zusammenfassung an ADMIN_EMAIL (still wenn keine Errors) DSGVO + A11Y + ERSTE-HILFE - landing.html: 'HTML und ODS' → 'JSON' (tatsächlich implementiert) - datenschutz.js: Sektion Account-Löschung erweitert (sofort gelöscht / anonymisiert / 10 Jahre für Rechnungen) - erste-hilfe.js: prominentes Warning-Banner oben (ersetzt keine Tierarzt-Beratung); Notfallnummern gruppiert nach Land, TODO-Platz- halter für AT-Uni-Klinik, CH Tox Info Suisse, CH Tierspital Zürich - ui.js Modal: ESC schließt, Focus-Trap, Auto-Focus erstes Element, Restore Focus auf vorigen Caller - impressum.js Kontaktformular: Labels mit for=cf-name etc. NEUE DB-TABELLEN (idempotent via CREATE TABLE IF NOT EXISTS) - jwt_blacklist, login_attempts, failed_emails, invoice_counters NEUE ENV-VARS - JWT_REFRESH_FRACTION (Default 2) - JWT_EXPIRY_DAYS Default geändert (30 → 7)
This commit is contained in:
parent
6224044654
commit
9394bab1fb
23 changed files with 1208 additions and 78 deletions
27
Makefile
27
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
|
||||
logs logs-f shell db dev clean-cache check-ssh reports bump test
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# SSH-Prüfung — Abhängigkeit aller DS-Befehle
|
||||
|
|
@ -272,6 +272,31 @@ 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
|
||||
|
|
|
|||
1
VERSION
Normal file
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
1095
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -3,25 +3,119 @@
|
|||
import os
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import jwt as _pyjwt
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, Depends
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from database import db
|
||||
from auth import (
|
||||
hash_password, verify_password, create_token,
|
||||
get_current_user
|
||||
get_current_user, decode_token, blacklist_jti, JWT_EXPIRY
|
||||
)
|
||||
from username_blocklist import is_username_blocked
|
||||
from ratelimit import check as rl_check, record_login_failure, is_account_locked, clear_login_failures
|
||||
from ratelimit import check as rl_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
COOKIE_NAME = "by_token"
|
||||
_APP_URL = os.getenv("APP_URL", "https://banyaro.app")
|
||||
_SMTP_READY = bool(os.getenv("SMTP_SUPPORT_USER") and os.getenv("SMTP_SUPPORT_PASS"))
|
||||
|
||||
# Login-Brute-Force-Lockout: 5 Fehlversuche in 15 Minuten → 15 Min. gesperrt.
|
||||
_LOCKOUT_WINDOW_MIN = 15
|
||||
_LOCKOUT_ATTEMPTS_MAX = 5
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Login-Lockout (DB-basiert, überlebt Container-Restart)
|
||||
# ------------------------------------------------------------------
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _db_is_account_locked(email: str) -> Optional[int]:
|
||||
"""Gibt verbleibende Sperrzeit in Sekunden zurück (oder None falls nicht gesperrt)."""
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT locked_until FROM login_attempts WHERE email=? COLLATE NOCASE",
|
||||
(email,)
|
||||
).fetchone()
|
||||
if not row or not row["locked_until"]:
|
||||
return None
|
||||
try:
|
||||
locked_until = datetime.fromisoformat(row["locked_until"])
|
||||
except Exception:
|
||||
return None
|
||||
now = datetime.now(timezone.utc)
|
||||
# Falls ohne TZ gespeichert (Legacy) → as-UTC interpretieren
|
||||
if locked_until.tzinfo is None:
|
||||
locked_until = locked_until.replace(tzinfo=timezone.utc)
|
||||
if locked_until <= now:
|
||||
return None
|
||||
return int((locked_until - now).total_seconds())
|
||||
|
||||
|
||||
def _db_record_login_failure(email: str):
|
||||
"""Inkrementiert Fehlversuche; setzt locked_until wenn Schwelle erreicht."""
|
||||
now = datetime.now(timezone.utc)
|
||||
window_start = now - timedelta(minutes=_LOCKOUT_WINDOW_MIN)
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT attempts, last_attempt FROM login_attempts WHERE email=? COLLATE NOCASE",
|
||||
(email,)
|
||||
).fetchone()
|
||||
if row:
|
||||
try:
|
||||
last = datetime.fromisoformat(row["last_attempt"])
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
last = now
|
||||
# Zähler resetten wenn letzter Versuch außerhalb des Fensters lag
|
||||
attempts = (row["attempts"] + 1) if last >= window_start else 1
|
||||
else:
|
||||
attempts = 1
|
||||
|
||||
locked_until = None
|
||||
if attempts >= _LOCKOUT_ATTEMPTS_MAX:
|
||||
locked_until = (now + timedelta(minutes=_LOCKOUT_WINDOW_MIN)).isoformat()
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO login_attempts (email, attempts, last_attempt, locked_until)
|
||||
VALUES (?,?,?,?)
|
||||
ON CONFLICT(email) DO UPDATE SET
|
||||
attempts=excluded.attempts,
|
||||
last_attempt=excluded.last_attempt,
|
||||
locked_until=excluded.locked_until""",
|
||||
(email.lower(), attempts, now.isoformat(), locked_until)
|
||||
)
|
||||
|
||||
|
||||
def _db_clear_login_failures(email: str):
|
||||
with db() as conn:
|
||||
conn.execute("DELETE FROM login_attempts WHERE email=? COLLATE NOCASE", (email,))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SMTP-Fehler-Logging
|
||||
# ------------------------------------------------------------------
|
||||
def _log_smtp_failure(to_email: str, subject: str, body: str, error: Exception, context: str = ""):
|
||||
"""Loggt SMTP-Fehler und speichert in failed_emails für Admin-Retry."""
|
||||
logger.exception("SMTP failed for %s | context=%s | subject=%s", to_email, context, subject)
|
||||
try:
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO failed_emails (to_email, subject, body, error, context) VALUES (?,?,?,?,?)",
|
||||
(to_email, subject, body, repr(error), context or None)
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("failed_emails-Insert fehlgeschlagen")
|
||||
|
||||
|
||||
def _send_verification_email(email: str, name: str, token: str):
|
||||
if not _SMTP_READY:
|
||||
|
|
@ -45,8 +139,9 @@ def _send_verification_email(email: str, name: str, token: str):
|
|||
plain = f"Hallo {name},\n\nbitte bestätige deine E-Mail:\n{url}\n\nLink ist 7 Tage gültig.\n"
|
||||
try:
|
||||
_send_smtp(email, subject, plain, "support", html=html)
|
||||
except Exception:
|
||||
pass # Nicht blockieren wenn SMTP fehlschlägt
|
||||
except Exception as exc:
|
||||
# Nicht blockieren wenn SMTP fehlschlägt — aber Fehler protokollieren + persistieren.
|
||||
_log_smtp_failure(email, subject, plain, exc, context="verification_email")
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
|
|
@ -69,7 +164,7 @@ def _set_cookie(response: Response, token: str):
|
|||
response.set_cookie(
|
||||
key=COOKIE_NAME, value=token,
|
||||
httponly=True, secure=True, samesite="lax",
|
||||
max_age=30 * 24 * 3600
|
||||
max_age=JWT_EXPIRY * 24 * 3600
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -113,16 +208,24 @@ async def register(data: RegisterRequest, response: Response, request: Request):
|
|||
code_upper = data.ref_code.strip().upper()
|
||||
# Zuerst prüfen ob es ein Partner-Code ist
|
||||
partner = conn.execute(
|
||||
"SELECT id, grants_founder, max_uses, uses FROM partner_codes WHERE code=?",
|
||||
"SELECT id, grants_founder, max_uses FROM partner_codes WHERE code=?",
|
||||
(code_upper,)
|
||||
).fetchone()
|
||||
if partner:
|
||||
# Nur einlösen wenn max_uses nicht erreicht
|
||||
if partner["max_uses"] is None or partner["uses"] < partner["max_uses"]:
|
||||
conn.execute(
|
||||
"UPDATE partner_codes SET uses=uses+1 WHERE id=?",
|
||||
# ATOMARE Inkrementierung (SQLite ≥3.35 unterstützt RETURNING).
|
||||
# Schließt Race-Condition wenn zwei User gleichzeitig den gleichen Code einlösen.
|
||||
if partner["max_uses"] is None:
|
||||
redeemed = conn.execute(
|
||||
"UPDATE partner_codes SET uses=uses+1 WHERE id=? RETURNING uses",
|
||||
(partner["id"],)
|
||||
)
|
||||
).fetchone()
|
||||
else:
|
||||
redeemed = conn.execute(
|
||||
"UPDATE partner_codes SET uses=uses+1 WHERE id=? AND uses<? RETURNING uses",
|
||||
(partner["id"], partner["max_uses"])
|
||||
).fetchone()
|
||||
|
||||
if redeemed:
|
||||
updates = {"referred_by": -partner["id"]}
|
||||
if partner["grants_founder"]:
|
||||
total_founders = conn.execute(
|
||||
|
|
@ -155,8 +258,15 @@ async def login(data: LoginRequest, response: Response, request: Request):
|
|||
rl_check(request, max_requests=10, window_seconds=300, key="login")
|
||||
rl_check(request, max_requests=5, window_seconds=300, key=f"login_email:{data.email.lower()}")
|
||||
|
||||
if is_account_locked(data.email):
|
||||
raise HTTPException(429, "Zu viele Fehlversuche. Bitte warte 15 Minuten und versuche es erneut.")
|
||||
# DB-basierter Account-Lockout (überlebt Container-Restart)
|
||||
remaining = _db_is_account_locked(data.email)
|
||||
if remaining is not None:
|
||||
minutes = max(1, remaining // 60)
|
||||
raise HTTPException(
|
||||
429,
|
||||
f"Zu viele Fehlversuche. Bitte warte {minutes} Minute(n) und versuche es erneut.",
|
||||
headers={"Retry-After": str(remaining)}
|
||||
)
|
||||
|
||||
with db() as conn:
|
||||
user = conn.execute(
|
||||
|
|
@ -165,13 +275,13 @@ async def login(data: LoginRequest, response: Response, request: Request):
|
|||
).fetchone()
|
||||
|
||||
if not user or not verify_password(data.password, user["pw_hash"]):
|
||||
record_login_failure(data.email)
|
||||
_db_record_login_failure(data.email)
|
||||
raise HTTPException(401, "E-Mail oder Passwort falsch.")
|
||||
|
||||
if not user["email_verified"]:
|
||||
raise HTTPException(403, "EMAIL_NOT_VERIFIED")
|
||||
|
||||
clear_login_failures(data.email)
|
||||
_db_clear_login_failures(data.email)
|
||||
token = create_token(user["id"], user["rolle"])
|
||||
_set_cookie(response, token)
|
||||
|
||||
|
|
@ -184,7 +294,30 @@ async def login(data: LoginRequest, response: Response, request: Request):
|
|||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(response: Response):
|
||||
async def logout(request: Request, response: Response):
|
||||
# Token aus Cookie ODER Bearer-Header extrahieren und auf die Blacklist setzen,
|
||||
# damit es serverseitig wirklich ungültig wird (nicht nur Cookie löschen).
|
||||
raw_token = request.cookies.get(COOKIE_NAME)
|
||||
if not raw_token:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
raw_token = auth_header.split(None, 1)[1].strip()
|
||||
|
||||
if raw_token:
|
||||
try:
|
||||
# options={'verify_exp': False}: auch bei abgelaufenem Token wollen wir jti+exp lesen,
|
||||
# falls jemand sich vor Ablauf "ordentlich" abmelden möchte — und exp brauchen wir für TTL.
|
||||
payload = _pyjwt.decode(
|
||||
raw_token, options={"verify_signature": False}
|
||||
)
|
||||
jti = payload.get("jti")
|
||||
exp = payload.get("exp")
|
||||
if jti and exp:
|
||||
expires_at = datetime.fromtimestamp(int(exp), tz=timezone.utc).isoformat()
|
||||
blacklist_jti(jti, expires_at)
|
||||
except Exception:
|
||||
logger.exception("Logout: Token konnte nicht für Blacklist gelesen werden")
|
||||
|
||||
response.delete_cookie(COOKIE_NAME)
|
||||
return {"ok": True}
|
||||
|
||||
|
|
@ -332,8 +465,8 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request):
|
|||
plain = f"Hallo {user['name']},\n\nPasswort zurücksetzen:\n{url}\n\nLink ist 2h gültig.\n"
|
||||
try:
|
||||
_send_smtp(data.email, subject, plain, "support", html=html)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
_log_smtp_failure(data.email, subject, plain, exc, context="forgot_password")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
|
|
@ -372,8 +505,8 @@ async def create_upgrade_request(data: UpgradeRequestBody, user=Depends(get_curr
|
|||
f"Nachricht: {data.message or '—'}\n\n"
|
||||
f"Admin-Panel: https://banyaro.app/#admin")
|
||||
_send_smtp(admin_email, subject, body, "support")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
_log_smtp_failure(admin_email, subject, body, exc, context="upgrade_request_admin_notify")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
|
|
@ -450,8 +583,14 @@ async def cancel_subscription(user=Depends(get_current_user)):
|
|||
+ (f"Aktiv bis: {expires_de}\n" if expires_de else "")
|
||||
+ "\nAlle Daten bleiben erhalten.\n\nViele Grüße\nRené")
|
||||
await send_email(user["email"], f"Kündigung bestätigt — {tier_label}", html, plain)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
_log_smtp_failure(
|
||||
user.get("email") or "",
|
||||
f"Kündigung bestätigt — {tier_label if 'tier_label' in locals() else '?'}",
|
||||
plain if 'plain' in locals() else "",
|
||||
exc,
|
||||
context="subscription_cancel_confirmation"
|
||||
)
|
||||
|
||||
return {"ok": True, "expires_at": expires}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,15 +49,66 @@ class CancelBody(BaseModel):
|
|||
# Hilfsfunktionen
|
||||
# ------------------------------------------------------------------
|
||||
def _next_invoice_number(conn, prefix="RG"):
|
||||
"""Vergibt atomar die naechste Rechnungsnummer fuer (prefix, year).
|
||||
|
||||
Race-frei dank dedizierter Counter-Tabelle 'invoice_counters' und
|
||||
BEGIN IMMEDIATE — gleichzeitige Aufrufe von zwei Admins koennen nicht
|
||||
dieselbe Nummer ziehen. SQLite serialisiert die Writer; der zweite
|
||||
wartet bis busy_timeout.
|
||||
|
||||
Beim ersten Aufruf fuer (prefix, year) wird die Counter-Row angelegt;
|
||||
dabei wird der aktuelle Stand aus der invoices-Tabelle uebernommen
|
||||
(Backfill fuer bestehende Bestaende vor Einfuehrung des Counters).
|
||||
"""
|
||||
year = datetime.now().year
|
||||
|
||||
# Falls noch keine Transaktion offen ist: BEGIN IMMEDIATE,
|
||||
# damit der naechste Writer serialisiert wird.
|
||||
if not conn.in_transaction:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT next_num FROM invoice_counters WHERE prefix=? AND year=?",
|
||||
(prefix, year)
|
||||
).fetchone()
|
||||
|
||||
if 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",
|
||||
"SELECT invoice_number FROM invoices WHERE invoice_number LIKE ? "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(f"{prefix}-{year}-%",)
|
||||
).fetchone()
|
||||
if last:
|
||||
n = int(last[0].split("-")[-1]) + 1
|
||||
# Auch cancellation_number kann den 'ST'-Prefix tragen
|
||||
last_st = conn.execute(
|
||||
"SELECT cancellation_number FROM invoices "
|
||||
"WHERE cancellation_number LIKE ? ORDER BY id DESC LIMIT 1",
|
||||
(f"{prefix}-{year}-%",)
|
||||
).fetchone()
|
||||
|
||||
existing_max = 0
|
||||
for r in (last, last_st):
|
||||
if r and r[0]:
|
||||
try:
|
||||
existing_max = max(existing_max, int(r[0].split("-")[-1]))
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
n = existing_max + 1
|
||||
conn.execute(
|
||||
"INSERT INTO invoice_counters (prefix, year, next_num) VALUES (?,?,?)",
|
||||
(prefix, year, n + 1)
|
||||
)
|
||||
else:
|
||||
n = 1
|
||||
n = row[0]
|
||||
conn.execute(
|
||||
"UPDATE invoice_counters SET next_num = next_num + 1 "
|
||||
"WHERE prefix=? AND year=?",
|
||||
(prefix, year)
|
||||
)
|
||||
|
||||
return f"{prefix}-{year}-{n:04d}"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -231,6 +231,15 @@ def start():
|
|||
misfire_grace_time=3600,
|
||||
coalesce=True,
|
||||
)
|
||||
# Täglich 06:30 — Error-Digest-Mail an ADMIN_EMAIL
|
||||
_scheduler.add_job(
|
||||
_job_error_digest,
|
||||
CronTrigger(hour=6, minute=30),
|
||||
id="error_digest",
|
||||
replace_existing=True,
|
||||
misfire_grace_time=1800,
|
||||
coalesce=True,
|
||||
)
|
||||
_scheduler.start()
|
||||
logger.info("Scheduler gestartet (gestaffelt) — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import 1.+2./4./7./10. 02:00, Rassen-Seed 1. 03:00, Wikidata-Seed 1. 04:00, Status-Report 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:10, KI-Gesundheitsbericht Mo 07:05, Streak-Reminder 19:00, Rückruf-Check 08:05, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. 10:00, Foto-Challenge Mo 08:10, Weekly-Praise Mo 09:05, Abo-Check 03:15, Invoice-Reminder 08:30. OSM-Cache: on-demand (kein Prewarm).")
|
||||
|
||||
|
|
@ -2092,3 +2101,133 @@ def _find_best_gassi_window(hourly: list[dict]) -> tuple[int, int, float | None,
|
|||
best_wind = sum(winds) / len(winds) if winds else None
|
||||
|
||||
return best_start, best_score, best_temp, best_wind
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# JOB: Error-Digest (täglich 06:30 Uhr)
|
||||
# ------------------------------------------------------------------
|
||||
# Quellen fuer Fehler:
|
||||
# 1. _job_log dieser Datei — Status der einzelnen Scheduler-Jobs
|
||||
# 2. main.log_buffer — In-Memory-Buffer der letzten 500 Log-Zeilen
|
||||
# Limitierung: log_buffer ist nicht persistent. Ein vollstaendiger 24h-Digest
|
||||
# wuerde eine externe Log-Senke (Datei oder DB) brauchen. Bis dahin liefert
|
||||
# der Job das was im Memory verfuegbar ist plus alle Scheduler-Errors.
|
||||
# ------------------------------------------------------------------
|
||||
async def _job_error_digest():
|
||||
"""Schickt eine Zusammenfassung aller bekannten ERROR/EXCEPTION-Eintraege
|
||||
der letzten 24h an ADMIN_EMAIL."""
|
||||
import os
|
||||
import html as _html
|
||||
from collections import Counter
|
||||
from mailer import send_email, email_html
|
||||
|
||||
admin = os.getenv("ADMIN_EMAIL", "")
|
||||
if not admin:
|
||||
logger.info("Error-Digest: ADMIN_EMAIL nicht gesetzt, uebersprungen.")
|
||||
_log_job("error_digest", "ok", "ADMIN_EMAIL nicht gesetzt")
|
||||
return
|
||||
|
||||
now = datetime.now(tz=_TZ)
|
||||
cutoff = now - timedelta(hours=24)
|
||||
|
||||
# ── 1. Scheduler-Job-Errors einsammeln ───────────────────────
|
||||
scheduler_errors = []
|
||||
for jid, log in _job_log.items():
|
||||
last_run = log.get("last_run")
|
||||
if last_run and last_run >= cutoff and log.get("status") == "error":
|
||||
scheduler_errors.append({
|
||||
"job": jid,
|
||||
"ts": last_run.strftime("%d.%m. %H:%M"),
|
||||
"result": log.get("result", ""),
|
||||
})
|
||||
|
||||
# ── 2. In-Memory-Log-Buffer einsammeln (best-effort) ─────────
|
||||
log_errors = []
|
||||
try:
|
||||
from main import log_buffer # type: ignore
|
||||
for entry in list(log_buffer):
|
||||
lvl = entry.get("l", "")
|
||||
if lvl in ("ERROR", "CRITICAL", "EXCEPTION"):
|
||||
log_errors.append({
|
||||
"ts": entry.get("t", ""),
|
||||
"lvl": lvl,
|
||||
"name": entry.get("n", ""),
|
||||
"msg": entry.get("m", ""),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Error-Digest: log_buffer nicht verfuegbar: {e}")
|
||||
|
||||
# Gruppieren: Fehler-Meldungen mit gleichem msg-Prefix zusammenfassen
|
||||
grouped: Counter = Counter()
|
||||
for e in log_errors:
|
||||
# Erste 80 Zeichen als Schluessel — schluckt Variablenwerte am Ende
|
||||
key = (e["name"], e["msg"][:80])
|
||||
grouped[key] += 1
|
||||
|
||||
# ── Wenn nichts zu melden ist: leise raus ────────────────────
|
||||
if not scheduler_errors and not grouped:
|
||||
logger.info("Error-Digest: Keine Errors in den letzten 24h.")
|
||||
_log_job("error_digest", "ok", "0 Errors")
|
||||
return
|
||||
|
||||
# ── HTML-Body bauen ──────────────────────────────────────────
|
||||
parts = []
|
||||
parts.append(f'<p style="margin:0 0 16px;color:#444">Fehler-Zusammenfassung der letzten 24h ({now.strftime("%d.%m.%Y %H:%M")}).</p>')
|
||||
|
||||
if scheduler_errors:
|
||||
rows_html = "".join(
|
||||
f'<tr>'
|
||||
f'<td style="padding:6px 12px;color:#c45000;font-weight:600">{_html.escape(e["job"])}</td>'
|
||||
f'<td style="padding:6px 12px;font-family:monospace;font-size:12px">{e["ts"]}</td>'
|
||||
f'<td style="padding:6px 12px;color:#555">{_html.escape(e["result"])}</td>'
|
||||
f'</tr>'
|
||||
for e in scheduler_errors
|
||||
)
|
||||
parts.append(
|
||||
'<h2 style="font-size:14px;color:#c45000;margin:20px 0 6px">Scheduler-Job-Fehler ({0})</h2>'.format(len(scheduler_errors))
|
||||
+ f'<table style="width:100%;border-collapse:collapse;font-size:13px"><tbody>{rows_html}</tbody></table>'
|
||||
)
|
||||
|
||||
if grouped:
|
||||
sorted_groups = sorted(grouped.items(), key=lambda kv: -kv[1])
|
||||
rows_html = "".join(
|
||||
f'<tr>'
|
||||
f'<td style="padding:6px 12px;text-align:right;font-weight:700;color:#dc2626">{count}x</td>'
|
||||
f'<td style="padding:6px 12px;font-family:monospace;font-size:11px;color:#888">{_html.escape(name)}</td>'
|
||||
f'<td style="padding:6px 12px;color:#444">{_html.escape(msg)}</td>'
|
||||
f'</tr>'
|
||||
for (name, msg), count in sorted_groups[:30]
|
||||
)
|
||||
parts.append(
|
||||
'<h2 style="font-size:14px;color:#c45000;margin:20px 0 6px">In-Memory-Log-Errors ({0} Typen, {1} Eintraege)</h2>'.format(len(grouped), sum(grouped.values()))
|
||||
+ f'<table style="width:100%;border-collapse:collapse;font-size:12px"><tbody>{rows_html}</tbody></table>'
|
||||
)
|
||||
|
||||
parts.append('<p style="margin:24px 0 0;font-size:11px;color:#aaa">Quellen: scheduler._job_log (24h) + main.log_buffer (Memory, max. 500 Eintraege). Fuer vollstaendige 24h-Historie waere eine persistente Log-Senke noetig.</p>')
|
||||
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -101,9 +101,9 @@
|
|||
</script>
|
||||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1094">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1094">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1094">
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1095">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1095">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1095">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -625,11 +625,11 @@
|
|||
<div id="modal-container"></div>
|
||||
|
||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||
<script src="/js/api.js?v=1094"></script>
|
||||
<script src="/js/ui.js?v=1094"></script>
|
||||
<script src="/js/app.js?v=1094"></script>
|
||||
<script src="/js/worlds.js?v=1094"></script>
|
||||
<script src="/js/offline-indicator.js?v=1094"></script>
|
||||
<script src="/js/api.js?v=1095"></script>
|
||||
<script src="/js/ui.js?v=1095"></script>
|
||||
<script src="/js/app.js?v=1095"></script>
|
||||
<script src="/js/worlds.js?v=1095"></script>
|
||||
<script src="/js/offline-indicator.js?v=1095"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -270,8 +270,14 @@ window.Page_datenschutz = (() => {
|
|||
<strong>Datenexport (Art. 20 DSGVO):</strong> 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.
|
||||
</p>
|
||||
<p style="${S.p};margin-top:var(--space-3)">
|
||||
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.
|
||||
</p>
|
||||
<p style="${S.p};margin-top:var(--space-3)">
|
||||
Zur Ausübung weiterer Rechte wende dich per E-Mail an
|
||||
|
|
@ -303,10 +309,31 @@ window.Page_datenschutz = (() => {
|
|||
|
||||
${sec('Speicherdauer', `
|
||||
<p style="${S.p}">
|
||||
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.
|
||||
</p>`)}
|
||||
|
||||
${sec('Account-Löschung', `
|
||||
<p style="${S.p}">
|
||||
Wenn du deinen Account löschst, werden deine Daten nach folgendem Schema verarbeitet:
|
||||
</p>
|
||||
<ul style="${S.ul}">
|
||||
<li><strong>Sofort und unwiderruflich gelöscht:</strong> Account, Hundeprofile, Tagebuch
|
||||
und Tagebuch-Medien, Gesundheitseinträge, Trainingsfortschritt, Ausgaben,
|
||||
Verhaltensprotokoll, Versicherung, Ernährungsprofil, Futter-Einträge und -Reaktionen,
|
||||
Forum-Beiträge, eigene Notizen, Direktnachrichten, Freundschaften,
|
||||
Push-Benachrichtigungen, Einstellungen und Welten-Konfiguration.</li>
|
||||
<li><strong>Anonymisiert (Urheber-Bezug auf NULL gesetzt):</strong> Eigene Routen,
|
||||
Forum-Threads sowie von dir angelegte Wiki-Inhalte bleiben zur Verfügbarkeit für
|
||||
die Community erhalten, sind aber nicht mehr deinem Account zuordenbar.</li>
|
||||
<li><strong>10 Jahre aufbewahrt (gesetzliche Pflicht):</strong> Rechnungen und
|
||||
Rechnungspositionen aus kostenpflichtigen Abonnements gemäß § 147 AO. Diese
|
||||
enthalten Name, E-Mail-Adresse und Rechnungsadresse zum Zeitpunkt der Rechnung
|
||||
und können vor Ablauf der Frist nicht gelöscht werden.</li>
|
||||
</ul>
|
||||
<p style="${S.p};margin-top:var(--space-3)">
|
||||
Es findet keine anonymisierte Weiterverarbeitung deiner privaten Inhalte
|
||||
(Tagebuch, Gesundheit, Notizen) zu Trainings- oder Statistikzwecken statt.
|
||||
</p>`)}
|
||||
|
||||
${sec('Mindestalter', `
|
||||
|
|
|
|||
|
|
@ -15,10 +15,35 @@ window.Page_erste_hilfe = (() => {
|
|||
// ----------------------------------------------------------------
|
||||
// DATA
|
||||
// ----------------------------------------------------------------
|
||||
const NOTFALLNUMMERN = [
|
||||
// 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' },
|
||||
{ label: 'Tiergiftzentrale Wien', tel: '+431 4064343', display: '+43 1 4064343' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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 = `
|
||||
<div id="eh-wrap" style="padding-bottom:var(--space-8)">
|
||||
|
||||
${_renderDisclaimer()}
|
||||
${_renderNotfallbanner()}
|
||||
${_renderSchnell()}
|
||||
|
||||
|
|
@ -244,13 +270,51 @@ window.Page_erste_hilfe = (() => {
|
|||
_activateTab('lebensgefahr');
|
||||
}
|
||||
|
||||
function _renderDisclaimer() {
|
||||
return `
|
||||
<div role="alert" style="display:flex;align-items:flex-start;gap:var(--space-3);
|
||||
background:#fef3c7;color:#78350f;border-left:4px solid #d97706;
|
||||
border-radius:var(--radius-md);padding:var(--space-3) var(--space-4);
|
||||
margin-bottom:var(--space-4);font-size:var(--text-sm);line-height:1.5">
|
||||
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:#d97706;width:22px;height:22px;margin-top:2px"><use href="/icons/phosphor.svg#warning"></use></svg>
|
||||
<div>
|
||||
<strong style="display:block;margin-bottom:2px">Diese Hinweise ersetzen keine tierärztliche Beratung.</strong>
|
||||
Im Notfall sofort einen Tierarzt aufsuchen!
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _renderNotfallbanner() {
|
||||
const nums = NOTFALLNUMMERN.map(n => `
|
||||
const renderEintrag = (n) => {
|
||||
// Eintrag mit verfügbarer Nummer → tel:-Link
|
||||
if (n.tel) {
|
||||
return `
|
||||
<a href="tel:${n.tel}"
|
||||
style="display:flex;align-items:center;gap:var(--space-2);color:#fff;text-decoration:none;font-size:var(--text-sm);padding:var(--space-2) var(--space-3);background:rgba(255,255,255,0.15);border-radius:var(--radius-md)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone"></use></svg>
|
||||
<span><strong>${n.label}</strong><br>${n.display}</span>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
// Eintrag ohne Nummer → ausgegrauter Platzhalter
|
||||
return `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);color:rgba(255,255,255,0.85);font-size:var(--text-sm);padding:var(--space-2) var(--space-3);background:rgba(255,255,255,0.08);border-radius:var(--radius-md);border:1px dashed rgba(255,255,255,0.35)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone"></use></svg>
|
||||
<span><strong>${n.label}</strong><br><em style="font-style:normal;opacity:.85">${n.display}</em></span>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const gruppen = NOTFALLNUMMERN_GRUPPEN.map(g => `
|
||||
<div>
|
||||
<div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);color:rgba(255,255,255,0.85);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:var(--space-1)">
|
||||
${g.flag} · ${g.land}
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||
${g.eintraege.map(renderEintrag).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
|
|
@ -259,8 +323,8 @@ window.Page_erste_hilfe = (() => {
|
|||
<svg class="ph-icon" style="width:20px;height:20px" aria-hidden="true"><use href="/icons/phosphor.svg#siren"></use></svg>
|
||||
Tiergiftzentralen — jetzt anrufen
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||
${nums}
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
${gruppen}
|
||||
</div>
|
||||
<p style="margin-top:var(--space-3);font-size:var(--text-xs);color:rgba(255,255,255,0.8)">
|
||||
Tierärztlicher Notdienst: Über die Tierarztsuche in der Banyaro-Karte
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ window.Page_impressum = (() => {
|
|||
<form id="contact-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Name *</label>
|
||||
<label for="cf-name" style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Name *</label>
|
||||
<input id="cf-name" type="text" required maxlength="100"
|
||||
placeholder="Dein Name"
|
||||
style="width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||||
|
|
@ -41,7 +41,7 @@ window.Page_impressum = (() => {
|
|||
color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">E-Mail *</label>
|
||||
<label for="cf-email" style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">E-Mail *</label>
|
||||
<input id="cf-email" type="email" required maxlength="200"
|
||||
placeholder="deine@email.de"
|
||||
style="width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||||
|
|
@ -50,7 +50,7 @@ window.Page_impressum = (() => {
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Betreff *</label>
|
||||
<label for="cf-subject" style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Betreff *</label>
|
||||
<input id="cf-subject" type="text" required maxlength="150"
|
||||
placeholder="Worum geht es?"
|
||||
style="width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||||
|
|
@ -58,7 +58,7 @@ window.Page_impressum = (() => {
|
|||
color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Nachricht *</label>
|
||||
<label for="cf-message" style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Nachricht *</label>
|
||||
<textarea id="cf-message" required maxlength="3000" rows="5"
|
||||
placeholder="Deine Nachricht…"
|
||||
style="width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||||
|
|
|
|||
|
|
@ -123,20 +123,69 @@ const UI = (() => {
|
|||
};
|
||||
overlay.addEventListener('focusin', _onFocusin);
|
||||
|
||||
_current = { overlay, onClose, _vvCleanup, _onFocusin };
|
||||
// -----------------------------------------------------
|
||||
// Accessibility: ESC schließt + Focus-Trap
|
||||
// -----------------------------------------------------
|
||||
const FOCUSABLE_SEL = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
const _getFocusables = () =>
|
||||
Array.from(modal?.querySelectorAll(FOCUSABLE_SEL) || [])
|
||||
.filter(el => el.offsetParent !== null || el === document.activeElement);
|
||||
|
||||
const _prevFocus = document.activeElement;
|
||||
|
||||
const _onKeydown = e => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Tab') return;
|
||||
const focusables = _getFocusables();
|
||||
if (!focusables.length) { e.preventDefault(); return; }
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first || !modal.contains(document.activeElement)) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last || !modal.contains(document.activeElement)) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', _onKeydown);
|
||||
|
||||
// Erstes fokussierbares Element autofokussieren (nach Render)
|
||||
setTimeout(() => {
|
||||
const focusables = _getFocusables();
|
||||
// Schließen-Button überspringen, falls weitere Elemente vorhanden
|
||||
const target = focusables.find(el => !el.classList.contains('modal-close-btn')) || focusables[0];
|
||||
target?.focus();
|
||||
}, 50);
|
||||
|
||||
_current = { overlay, onClose, _vvCleanup, _onFocusin, _onKeydown, _prevFocus };
|
||||
|
||||
return overlay.querySelector('.modal');
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!_current) return;
|
||||
const { onClose, _vvCleanup, _onFocusin } = _current;
|
||||
const { onClose, _vvCleanup, _onFocusin, _onKeydown, _prevFocus } = _current;
|
||||
onClose?.();
|
||||
_vvCleanup?.();
|
||||
if (_onFocusin) _current.overlay.removeEventListener('focusin', _onFocusin);
|
||||
if (_onKeydown) document.removeEventListener('keydown', _onKeydown);
|
||||
_current.overlay.remove();
|
||||
document.documentElement.classList.remove('modal-open');
|
||||
_current = null;
|
||||
// Fokus auf vorheriges Element zurücksetzen (falls noch im DOM)
|
||||
if (_prevFocus && typeof _prevFocus.focus === 'function' && document.body.contains(_prevFocus)) {
|
||||
try { _prevFocus.focus(); } catch (_) {}
|
||||
}
|
||||
// iOS Safari setzt den Zoom nach Input-Fokus nicht zurück — Viewport kurz neu setzen
|
||||
const meta = document.querySelector('meta[name="viewport"]');
|
||||
if (meta) {
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@
|
|||
"Probeverpaarung mit IK-Simulation und genetischer Risikoanalyse",
|
||||
"Tierschutz-Check automatisch bei jeder Verpaarung",
|
||||
"KI-Züchter-Assistenz: Wurfankündigungen, Genetik, Paarungsanalyse",
|
||||
"Datenexport als HTML und ODS",
|
||||
"Datenexport als JSON (DSGVO Art. 20)",
|
||||
"Hunde-Filmdatenbank: 68 Filme, Serien und Dokumentationen sortier- und filterbar",
|
||||
"Filmdatenbank-Feature: Stirbt der Hund? — Taschentuch-Warnung",
|
||||
"Berühmte Hunde der Geschichte",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
============================================================ */
|
||||
|
||||
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
|
||||
const VER = '1094';
|
||||
const VER = '1095';
|
||||
const CACHE_VERSION = `by-v${VER}`;
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
|
|
|||
10
pytest.ini
Normal file
10
pytest.ini
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts = -ra --strict-markers
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
markers =
|
||||
smoke: lightweight smoke tests
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
172
tests/conftest.py
Normal file
172
tests/conftest.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
"""
|
||||
BAN YARO — pytest Fixtures
|
||||
Wichtig:
|
||||
- ENV-Variablen werden VOR dem Import von `backend.main` gesetzt,
|
||||
weil `database.DB_PATH = os.getenv("DB_PATH", ...)` beim Modul-Import gebunden wird.
|
||||
- Wir mocken APScheduler weg, damit beim lifespan-Startup keine Hintergrund-Jobs starten.
|
||||
- Pro Test-Session legen wir eine frische SQLite-Datei in tmp_path an.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import secrets
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
BACKEND_DIR = ROOT / "backend"
|
||||
|
||||
# Backend-Pfad in sys.path, damit `from main import app` funktioniert
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session-weite App-Instanz
|
||||
# ------------------------------------------------------------------
|
||||
@pytest.fixture(scope="session")
|
||||
def _tmp_db_dir():
|
||||
"""Temp-Verzeichnis für DB + Media während der Test-Session."""
|
||||
d = tempfile.mkdtemp(prefix="banyaro-tests-")
|
||||
yield Path(d)
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def app(_tmp_db_dir):
|
||||
"""
|
||||
Initialisiert die FastAPI-App einmal pro Test-Session mit:
|
||||
- Test-DB in tmp_path
|
||||
- Stubbed Scheduler (keine Background-Jobs)
|
||||
- KI_MODE=off (keine Netzwerk-Aufrufe)
|
||||
- SMTP-Versand wird gemockt
|
||||
"""
|
||||
db_file = _tmp_db_dir / "test.db"
|
||||
media_dir = _tmp_db_dir / "media"
|
||||
breeder_dir = _tmp_db_dir / "breeder_docs"
|
||||
media_dir.mkdir(exist_ok=True)
|
||||
breeder_dir.mkdir(exist_ok=True)
|
||||
|
||||
os.environ["DB_PATH"] = str(db_file)
|
||||
os.environ["MEDIA_DIR"] = str(media_dir)
|
||||
os.environ["BREEDER_DOCS_DIR"] = str(breeder_dir)
|
||||
os.environ["KI_MODE"] = "off"
|
||||
os.environ["ENV"] = "test"
|
||||
os.environ["JWT_SECRET"] = "test-secret-" + secrets.token_hex(8)
|
||||
os.environ["STAGING"] = "false"
|
||||
os.environ["ADMIN_EMAIL"] = "test-admin@example.com"
|
||||
# SMTP nicht konfigurieren → _SMTP_READY=False, Mails werden geskippt
|
||||
os.environ.pop("SMTP_SUPPORT_USER", None)
|
||||
os.environ.pop("SMTP_SUPPORT_PASS", None)
|
||||
|
||||
# Scheduler-Modul vor main-Import stubben — sonst startet APScheduler beim Lifespan
|
||||
import scheduler as _sched
|
||||
_sched.start = lambda: None # type: ignore[assignment]
|
||||
_sched.stop = lambda: None # type: ignore[assignment]
|
||||
|
||||
# Mailer abklemmen — keine echten E-Mails
|
||||
import mailer as _mailer
|
||||
async def _noop_send(*args, **kwargs):
|
||||
return True
|
||||
_mailer.send_email = _noop_send # type: ignore[assignment]
|
||||
|
||||
# Rate-Limit + Login-Lockout fuer Tests deaktivieren — sonst
|
||||
# blockiert /api/auth/register nach 5 Aufrufen die ganze Test-Session.
|
||||
import ratelimit as _rl
|
||||
_noop_rl = lambda *a, **kw: None
|
||||
_rl.check = _noop_rl # type: ignore[assignment]
|
||||
_rl.is_account_locked = lambda *a, **kw: False # type: ignore[assignment]
|
||||
_rl.record_login_failure = lambda *a, **kw: 0 # type: ignore[assignment]
|
||||
|
||||
# App importieren (initialisiert DB beim lifespan)
|
||||
from main import app as fastapi_app
|
||||
|
||||
# Module-level Aliase ersetzen (Imports der Form `from ratelimit import check as rl_check`)
|
||||
import routes.auth as _routes_auth
|
||||
_routes_auth.rl_check = _noop_rl # type: ignore[assignment]
|
||||
if hasattr(_routes_auth, "_db_is_account_locked"):
|
||||
_routes_auth._db_is_account_locked = lambda *a, **kw: None # type: ignore[assignment]
|
||||
if hasattr(_routes_auth, "_db_record_login_failure"):
|
||||
_routes_auth._db_record_login_failure = lambda *a, **kw: 0 # type: ignore[assignment]
|
||||
|
||||
return fastapi_app
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def client(app):
|
||||
"""FastAPI TestClient — triggert lifespan (init_db, seed_movies, …)."""
|
||||
from fastapi.testclient import TestClient
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Test-User
|
||||
# ------------------------------------------------------------------
|
||||
def _register_and_verify(client, email: str, password: str, name: str) -> dict:
|
||||
"""Helper: Register User + setzt email_verified=1 direkt in der DB."""
|
||||
r = client.post("/api/auth/register", json={
|
||||
"email": email, "password": password, "name": name
|
||||
})
|
||||
assert r.status_code == 200, f"Register failed: {r.status_code} {r.text}"
|
||||
|
||||
# email_verified=1 setzen (umgeht E-Mail-Verifikation für Tests)
|
||||
from database import db
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET email_verified=1 WHERE email=?", (email,)
|
||||
)
|
||||
return {"email": email, "password": password, "name": name}
|
||||
|
||||
|
||||
def _login(client, email: str, password: str) -> str:
|
||||
"""Helper: Login → JWT-Token zurueck."""
|
||||
r = client.post("/api/auth/login", json={"email": email, "password": password})
|
||||
assert r.status_code == 200, f"Login failed: {r.status_code} {r.text}"
|
||||
return r.json()["token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(client):
|
||||
"""Frisch registrierter und verifizierter Test-User mit JWT-Token."""
|
||||
email = f"user-{secrets.token_hex(4)}@example.com"
|
||||
pw = "TestPass123!"
|
||||
name = f"tester{secrets.token_hex(3)}"
|
||||
info = _register_and_verify(client, email, pw, name)
|
||||
token = _login(client, email, pw)
|
||||
info["token"] = token
|
||||
info["headers"] = {"Authorization": f"Bearer {token}"}
|
||||
return info
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin(client):
|
||||
"""Test-Admin: registriert, verifiziert, rolle='admin' direkt in DB gesetzt."""
|
||||
email = f"admin-{secrets.token_hex(4)}@example.com"
|
||||
pw = "AdminPass123!"
|
||||
# "admin"/"administrator" sind in der Blocklist → Bezeichner "ops..." verwenden
|
||||
name = f"ops{secrets.token_hex(3)}"
|
||||
info = _register_and_verify(client, email, pw, name)
|
||||
from database import db
|
||||
with db() as conn:
|
||||
conn.execute("UPDATE users SET rolle='admin' WHERE email=?", (email,))
|
||||
token = _login(client, email, pw)
|
||||
info["token"] = token
|
||||
info["headers"] = {"Authorization": f"Bearer {token}"}
|
||||
return info
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dog(client, user):
|
||||
"""Erstellt einen Hund fuer den Test-User und gibt das Hund-Objekt zurueck."""
|
||||
r = client.post(
|
||||
"/api/dogs",
|
||||
headers=user["headers"],
|
||||
json={"name": "Buddy", "rasse": "Labrador", "is_public": False},
|
||||
)
|
||||
assert r.status_code in (200, 201), f"create_dog failed: {r.status_code} {r.text}"
|
||||
return r.json()
|
||||
56
tests/test_auth.py
Normal file
56
tests/test_auth.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Smoke-Tests fuer Auth-Flows: Register, Login, Logout, /me."""
|
||||
|
||||
import secrets
|
||||
|
||||
|
||||
def test_register_creates_pending_user(client):
|
||||
"""Frischer User -> pending_verification=True."""
|
||||
email = f"new-{secrets.token_hex(4)}@example.com"
|
||||
r = client.post("/api/auth/register", json={
|
||||
"email": email,
|
||||
"password": "TestPass123!",
|
||||
"name": f"user{secrets.token_hex(3)}",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json().get("pending_verification") is True
|
||||
|
||||
|
||||
def test_login_with_wrong_password_returns_401(client, user):
|
||||
"""Falsches Passwort -> 401."""
|
||||
r = client.post("/api/auth/login", json={
|
||||
"email": user["email"], "password": "WrongPass!!"
|
||||
})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_login_returns_token(client, user):
|
||||
"""Korrekte Credentials -> JWT-Token."""
|
||||
r = client.post("/api/auth/login", json={
|
||||
"email": user["email"], "password": user["password"]
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert "token" in r.json()
|
||||
|
||||
|
||||
def test_me_requires_auth(client):
|
||||
"""/api/auth/me ohne Token -> 401."""
|
||||
r = client.get("/api/auth/me")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_me_returns_user_info(client, user):
|
||||
"""/api/auth/me mit gueltigem Token -> User-Objekt."""
|
||||
r = client.get("/api/auth/me", headers=user["headers"])
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["email"] == user["email"]
|
||||
assert data["name"] == user["name"]
|
||||
# email_verified wurde im Fixture per DB-Update auf 1 gesetzt
|
||||
assert data["email_verified"] == 1
|
||||
|
||||
|
||||
def test_logout_clears_cookie(client, user):
|
||||
"""/api/auth/logout -> ok."""
|
||||
r = client.post("/api/auth/logout", headers=user["headers"])
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
71
tests/test_diary.py
Normal file
71
tests/test_diary.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""Smoke-Tests fuer Tagebuch-CRUD."""
|
||||
|
||||
|
||||
def test_create_diary_entry(client, user, dog):
|
||||
"""POST /api/dogs/{id}/diary -> 201 mit Entry."""
|
||||
r = client.post(
|
||||
f"/api/dogs/{dog['id']}/diary",
|
||||
headers=user["headers"],
|
||||
json={"titel": "Mein erster Eintrag", "text": "Heute war ein toller Tag."},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
entry = r.json()
|
||||
assert entry["titel"] == "Mein erster Eintrag"
|
||||
assert entry["dog_id"] == dog["id"]
|
||||
|
||||
|
||||
def test_list_diary_entries(client, user, dog):
|
||||
"""GET /api/dogs/{id}/diary listet erstellte Eintraege."""
|
||||
client.post(
|
||||
f"/api/dogs/{dog['id']}/diary",
|
||||
headers=user["headers"],
|
||||
json={"titel": "Eintrag A", "text": "Erstes Mal Gassi."},
|
||||
)
|
||||
client.post(
|
||||
f"/api/dogs/{dog['id']}/diary",
|
||||
headers=user["headers"],
|
||||
json={"titel": "Eintrag B", "text": "Tierarzt."},
|
||||
)
|
||||
r = client.get(f"/api/dogs/{dog['id']}/diary", headers=user["headers"])
|
||||
assert r.status_code == 200
|
||||
entries = r.json()
|
||||
assert isinstance(entries, list)
|
||||
titles = [e["titel"] for e in entries]
|
||||
assert "Eintrag A" in titles
|
||||
assert "Eintrag B" in titles
|
||||
|
||||
|
||||
def test_get_single_diary_entry(client, user, dog):
|
||||
"""GET /api/dogs/{id}/diary/{entry_id} liefert genauen Eintrag."""
|
||||
r = client.post(
|
||||
f"/api/dogs/{dog['id']}/diary",
|
||||
headers=user["headers"],
|
||||
json={"titel": "Einzeleintrag", "text": "Inhalt."},
|
||||
)
|
||||
eid = r.json()["id"]
|
||||
|
||||
r2 = client.get(f"/api/dogs/{dog['id']}/diary/{eid}", headers=user["headers"])
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["titel"] == "Einzeleintrag"
|
||||
|
||||
|
||||
def test_delete_diary_entry(client, user, dog):
|
||||
"""DELETE entfernt Eintrag (204) — danach 404."""
|
||||
r = client.post(
|
||||
f"/api/dogs/{dog['id']}/diary",
|
||||
headers=user["headers"],
|
||||
json={"titel": "Loeschen mich", "text": "."},
|
||||
)
|
||||
eid = r.json()["id"]
|
||||
|
||||
r2 = client.delete(f"/api/dogs/{dog['id']}/diary/{eid}", headers=user["headers"])
|
||||
assert r2.status_code == 204
|
||||
|
||||
r3 = client.get(f"/api/dogs/{dog['id']}/diary/{eid}", headers=user["headers"])
|
||||
assert r3.status_code == 404
|
||||
|
||||
|
||||
def test_diary_unauth(client, dog):
|
||||
"""Ohne Token -> 401."""
|
||||
r = client.get(f"/api/dogs/{dog['id']}/diary")
|
||||
assert r.status_code == 401
|
||||
32
tests/test_health.py
Normal file
32
tests/test_health.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""Smoke-Tests fuer trivialste Endpoints (kein Auth noetig)."""
|
||||
|
||||
|
||||
def test_api_version(client):
|
||||
"""GET /api/version sollte die zentrale VERSION zurueckgeben."""
|
||||
r = client.get("/api/version")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert "version" in data
|
||||
# APP_VER wird aus VERSION-Datei gelesen → muss ein Zahlen-String sein
|
||||
assert data["version"].isdigit(), f"version='{data['version']}' ist nicht numerisch"
|
||||
|
||||
|
||||
def test_assetlinks(client):
|
||||
"""TWA-Verifikation fuer Play Store."""
|
||||
r = client.get("/.well-known/assetlinks.json")
|
||||
assert r.status_code == 200
|
||||
assert "package_name" in r.text
|
||||
|
||||
|
||||
def test_robots(client):
|
||||
"""robots.txt muss erreichbar sein."""
|
||||
r = client.get("/robots.txt")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_manifest(client):
|
||||
"""PWA-Manifest."""
|
||||
r = client.get("/manifest.json")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert "name" in data
|
||||
91
tests/test_invoice.py
Normal file
91
tests/test_invoice.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""Smoke-Tests fuer Rechnungs-CRUD (Admin)."""
|
||||
|
||||
import os
|
||||
import secrets
|
||||
|
||||
|
||||
def test_admin_create_invoice(client, admin):
|
||||
"""POST /api/admin/invoices als Admin -> 201 mit Rechnungsnummer."""
|
||||
r = client.post(
|
||||
"/api/admin/invoices",
|
||||
headers=admin["headers"],
|
||||
json={
|
||||
"recipient_name": "Max Mustermann",
|
||||
"recipient_email": "max@example.com",
|
||||
"items": [{"description": "Pro-Jahresabo", "quantity": 1, "unit_price": 29.0}],
|
||||
"service_period": "2026-01-01 - 2026-12-31",
|
||||
"notes": "Test-Rechnung",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
data = r.json()
|
||||
assert data["invoice_number"].startswith("RG-")
|
||||
assert data["amount_gross"] == 29.0
|
||||
assert data["status"] == "draft"
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
|
||||
def test_admin_list_invoices(client, admin):
|
||||
"""GET /api/admin/invoices listet vorhandene Rechnungen."""
|
||||
# Eine erstellen
|
||||
client.post(
|
||||
"/api/admin/invoices",
|
||||
headers=admin["headers"],
|
||||
json={
|
||||
"recipient_name": "Tester Listing",
|
||||
"recipient_email": f"list-{secrets.token_hex(3)}@example.com",
|
||||
"items": [{"description": "Sub", "quantity": 1, "unit_price": 49.0}],
|
||||
},
|
||||
)
|
||||
r = client.get("/api/admin/invoices", headers=admin["headers"])
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) >= 1
|
||||
|
||||
|
||||
def test_non_admin_cannot_create_invoice(client, user):
|
||||
"""Normaler User -> 403."""
|
||||
r = client.post(
|
||||
"/api/admin/invoices",
|
||||
headers=user["headers"],
|
||||
json={
|
||||
"recipient_name": "x",
|
||||
"recipient_email": "x@example.com",
|
||||
"items": [{"description": "x", "quantity": 1, "unit_price": 1.0}],
|
||||
},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_admin_send_invoice(client, admin, tmp_path, monkeypatch):
|
||||
"""Send-Endpoint generiert PDF, ruft (gemockten) Mailer auf, setzt Status auf 'sent'."""
|
||||
# Paperless-Verzeichnis auf tmp_path setzen
|
||||
scaninput = tmp_path / "scaninput"
|
||||
scaninput.mkdir()
|
||||
monkeypatch.setenv("SCANINPUT_DIR", str(scaninput))
|
||||
monkeypatch.setenv("PAPERLESS_URL", "") # kein HTTP-Call
|
||||
monkeypatch.setenv("KLEINUNTERNEHMER", "true")
|
||||
|
||||
# Rechnung anlegen
|
||||
r = client.post(
|
||||
"/api/admin/invoices",
|
||||
headers=admin["headers"],
|
||||
json={
|
||||
"recipient_name": "Send-Test",
|
||||
"recipient_email": f"send-{secrets.token_hex(3)}@example.com",
|
||||
"items": [{"description": "Sub", "quantity": 1, "unit_price": 29.0}],
|
||||
"service_period": "2026-01-01 - 2026-12-31",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
inv = r.json()
|
||||
|
||||
# Senden
|
||||
r2 = client.post(f"/api/admin/invoices/{inv['id']}/send", headers=admin["headers"])
|
||||
assert r2.status_code == 200, r2.text
|
||||
|
||||
# Status auf 'sent'?
|
||||
r3 = client.get(f"/api/admin/invoices/{inv['id']}", headers=admin["headers"])
|
||||
assert r3.status_code == 200
|
||||
assert r3.json()["status"] == "sent"
|
||||
Loading…
Add table
Add a link
Reference in a new issue