import asyncio import logging from contextlib import asynccontextmanager from fastapi import FastAPI, Request from sqlalchemy import select, text from app.bootstrap_admin import ensure_initial_admin from app.config import settings from app.db import SessionLocal, engine from app.deps import get_setting, set_setting from app.maintenance import describe, run_cleanup from app.models import User from app.routers import ( admin, appinfo, auth, catalog, items, lists, prices, public, push, sharing, sync, ) from app.security import normalize_email, purge_rate_limits, utcnow logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") log = logging.getLogger("einkaufsapp") def bootstrap() -> None: """Beim Start: Startwerte setzen, abgelaufene Sessions und Rate-Limit-Zaehler aufraeumen.""" with SessionLocal() as db: ensure_initial_admin(db) if get_setting(db, "allow_self_registration") == "": set_setting( db, "allow_self_registration", "true" if settings.self_registration_default else "false", ) if settings.allow_self_registration == "admin": log.info( "Selbstregistrierung: über die Admin-API steuerbar (aktuell %s).", "an" if get_setting(db, "allow_self_registration") == "true" else "aus", ) else: log.info( "Selbstregistrierung: per ALLOW_SELF_REGISTRATION fest auf '%s' " "gesetzt, über die Admin-API nicht änderbar.", settings.allow_self_registration, ) # Nur ein gesetzter Schlüssel ist immer ein Fehler - dann bleibt # Push still abgeschaltet, und niemand weiß warum. has_public = bool(settings.vapid_public_key) has_private = bool(settings.vapid_private_key) if has_public != has_private: fehlend = "VAPID_PRIVATE_KEY" if has_public else "VAPID_PUBLIC_KEY" log.error( "Push-Benachrichtigungen sind ABGESCHALTET: %s fehlt oder ist " "leer, während der andere Schlüssel gesetzt ist. Häufigste " "Ursache: die Variable steht mehrfach in der .env - Docker " "Compose nimmt die letzte Definition, und das ist oft die " "leere Vorlagenzeile. Prüfen mit: grep -n VAPID .env", fehlend, ) elif has_public: log.info( "Push-Benachrichtigungen: aktiv, Drosselung %d Stunde(n), " "Kontakt %s", settings.push_throttle_hours, settings.vapid_contact, ) else: log.info( "Push-Benachrichtigungen: abgeschaltet (kein VAPID-Schlüsselpaar). " "Erzeugen mit: python3 tools/vapid-keys.py" ) log.info( "SMTP-Relay: %s:%s, Verschlüsselung=%s, Auth=%s, Envelope-From=%s", settings.smtp_host, settings.smtp_port, settings.smtp_security, "ja" if settings.smtp_user else "nein", settings.envelope_from, ) if settings.smtp_security == "none" and settings.smtp_host not in ("mailpit", "localhost"): log.warning( "SMTP_SECURITY=none bei externem Relay %s - Mails gehen " "unverschlüsselt über das Netz.", settings.smtp_host, ) # Konfiguriertes Admin-Konto markieren, falls es schon existiert. admin_mail = normalize_email(settings.admin_email) user = db.scalar(select(User).where(User.email == admin_mail)) if user is not None and not user.is_admin: user.is_admin = True log.info("Konto %s als Administrator markiert.", admin_mail) db.execute(text("DELETE FROM user_session WHERE expires_at <= :now"), {"now": utcnow()}) purge_rate_limits(db) db.commit() def cleanup_once() -> None: """Laeuft im Threadpool - SQLAlchemy ist hier synchron konfiguriert, und ein blockierender Aufruf in der Ereignisschleife wuerde den gesamten Server anhalten.""" try: with SessionLocal() as db: counts = run_cleanup(db) log.info("Aufräumen: %s", describe(counts)) except Exception: log.exception("Aufräumen fehlgeschlagen") async def cleanup_loop() -> None: """Einmal kurz nach dem Start, danach im eingestellten Abstand. Bewusst kein zusaetzlicher Cron-Container: Das waere eine weitere Stelle, an der etwas kaputtgehen kann, fuer eine Aufgabe, die einmal am Tag ein paar Zeilen loescht. """ import anyio # Kurz warten, damit der Start nicht durch Aufräumarbeiten # verzögert wird. await asyncio.sleep(30) while True: await anyio.to_thread.run_sync(cleanup_once) await asyncio.sleep(settings.cleanup_interval_hours * 3600) @asynccontextmanager async def lifespan(app: FastAPI): bootstrap() task = asyncio.create_task(cleanup_loop()) try: yield finally: task.cancel() engine.dispose() app = FastAPI( title=settings.app_name, version="0.1.0", lifespan=lifespan, # Kein CORS-Middleware: PWA und API laufen ab Phase 3 unter derselben # Origin hinter nginx. Damit entfaellt eine ganze Fehlerklasse. ) @app.middleware("http") async def security_headers(request: Request, call_next): response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Referrer-Policy"] = "no-referrer" response.headers["X-Frame-Options"] = "DENY" response.headers["Permissions-Policy"] = "camera=(self), geolocation=(), microphone=()" if settings.cookie_secure: response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" return response @app.get("/healthz", tags=["system"]) def healthz(): """Liveness: sagt nur, dass der Prozess antwortet.""" return {"status": "ok"} @app.get("/readyz", tags=["system"]) def readyz(): """Readiness: prueft zusaetzlich die Datenbankverbindung.""" with SessionLocal() as db: db.execute(text("SELECT 1")) return {"status": "ok", "database": "ok"} app.include_router(auth.router) app.include_router(admin.router) app.include_router(lists.router) app.include_router(catalog.router) app.include_router(items.router) app.include_router(sharing.router) app.include_router(public.router) app.include_router(sync.router) app.include_router(prices.router) app.include_router(push.router) app.include_router(appinfo.router)