185 lines
6.5 KiB
Python
185 lines
6.5 KiB
Python
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)
|