Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

184
backend/app/main.py Normal file
View File

@@ -0,0 +1,184 @@
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)