Erste Produktivversion
This commit is contained in:
184
backend/app/main.py
Normal file
184
backend/app/main.py
Normal 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)
|
||||
Reference in New Issue
Block a user