get_async_redis() hatte keine socket_connect_timeout/socket_timeout/ max_connections – ein gehängtes Redis hätte Requests unbegrenzt blockiert statt schnell zu failen. Zusätzlich einmaliger Ping im Lifespan-Startup, nur zur Log-Sichtbarkeit (blockiert Boot nicht). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
164 lines
6.9 KiB
Python
164 lines
6.9 KiB
Python
from contextlib import asynccontextmanager
|
||
|
||
from fastapi import FastAPI
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
||
from fastapi.staticfiles import StaticFiles
|
||
from slowapi import _rate_limit_exceeded_handler
|
||
from slowapi.errors import RateLimitExceeded
|
||
|
||
from app.core.config import settings
|
||
from app.core.database import engine, Base
|
||
from app.core.limiter import limiter
|
||
from app.routers import auth, users, companies
|
||
from app.routers import time_entries, absences, reports, ldap, smtp, caldav
|
||
from app.routers import import_kimai
|
||
from app.routers import kiosk
|
||
from app.routers import busylight
|
||
from app.routers import audit
|
||
from app.routers import special_assignments
|
||
from app.routers import hours_payouts
|
||
from app.routers import public_stamp
|
||
from app.routers import ical
|
||
from app.routers import reseller, tenants
|
||
from app.routers import tls_admin
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
# Startup: Tabellen anlegen nur in Development/Test.
|
||
# In Production verwaltet Alembic das Schema – create_all würde mit Migrationen kollidieren.
|
||
import logging
|
||
_log = logging.getLogger(__name__)
|
||
if not settings.is_production:
|
||
async with engine.begin() as conn:
|
||
await conn.run_sync(Base.metadata.create_all)
|
||
_log.info("Development-Modus: create_all ausgeführt.")
|
||
else:
|
||
_log.info("Production-Modus: create_all übersprungen — Alembic verwaltet das Schema.")
|
||
|
||
# M-4: Warnung wenn Production ohne ALLOWED_HOSTS läuft
|
||
if settings.is_production and not settings.allowed_hosts:
|
||
_log.warning(
|
||
"SICHERHEITSWARNUNG: ALLOWED_HOSTS ist nicht gesetzt. "
|
||
"In Production sollte ALLOWED_HOSTS in .env konfiguriert sein "
|
||
"um Host-Header-Injection zu verhindern."
|
||
)
|
||
|
||
# Erinnerungs-Scheduler (agent-11 PR3) – nicht im Test-Kontext starten
|
||
import sys
|
||
if settings.scheduler_enabled and "pytest" not in sys.modules:
|
||
from app.services.scheduler_service import start as start_scheduler
|
||
start_scheduler()
|
||
|
||
# Async-Redis-Pool: einmaliger Ping beim Start, nur zur Sichtbarkeit im Log
|
||
# (Sessions/Lockouts/Nonce-Cache brauchen Redis, App startet aber auch ohne –
|
||
# Fehler zeigen sich dann erst beim ersten Request, siehe get_async_redis()).
|
||
if "pytest" not in sys.modules:
|
||
from app.core.redis import get_async_redis
|
||
try:
|
||
await get_async_redis().ping()
|
||
_log.info("Async-Redis-Pool: Verbindung beim Start OK.")
|
||
except Exception as exc:
|
||
_log.warning("Async-Redis-Pool: kein Redis beim Start erreichbar (%s).", exc)
|
||
|
||
yield
|
||
# Shutdown
|
||
from app.services.scheduler_service import shutdown as shutdown_scheduler
|
||
shutdown_scheduler()
|
||
from app.core.redis import close_async_redis
|
||
await close_async_redis()
|
||
await engine.dispose()
|
||
|
||
|
||
app = FastAPI(
|
||
title=settings.app_name,
|
||
version="0.1.0",
|
||
docs_url=None, # eigene Route unten – Swagger-Assets lokal statt CDN
|
||
redoc_url=None, # eigene Route unten – ReDoc-Assets lokal statt CDN
|
||
lifespan=lifespan,
|
||
)
|
||
|
||
app.state.limiter = limiter
|
||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||
|
||
# Swagger/ReDoc-Assets lokal ausliefern statt von cdn.jsdelivr.net – Netzwerke ohne
|
||
# CDN-Zugriff (Firewall/Proxy) zeigten sonst eine leere /docs-Seite.
|
||
if not settings.is_production:
|
||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||
|
||
@app.get("/docs", include_in_schema=False)
|
||
async def custom_swagger_ui_html():
|
||
return get_swagger_ui_html(
|
||
openapi_url=app.openapi_url,
|
||
title=f"{app.title} - Swagger UI",
|
||
swagger_js_url="/static/swagger-ui/swagger-ui-bundle.js",
|
||
swagger_css_url="/static/swagger-ui/swagger-ui.css",
|
||
swagger_favicon_url="/static/swagger-ui/favicon.png",
|
||
)
|
||
|
||
@app.get("/redoc", include_in_schema=False)
|
||
async def custom_redoc_html():
|
||
return get_redoc_html(
|
||
openapi_url=app.openapi_url,
|
||
title=f"{app.title} - ReDoc",
|
||
redoc_js_url="/static/swagger-ui/redoc.standalone.js",
|
||
redoc_favicon_url="/static/swagger-ui/favicon.png",
|
||
with_google_fonts=False,
|
||
)
|
||
|
||
# ── Middleware ────────────────────────────────────────────────────────────────
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=[settings.frontend_url],
|
||
allow_credentials=True,
|
||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||
allow_headers=[
|
||
"Content-Type",
|
||
"Authorization",
|
||
"X-Kiosk-Key-Id",
|
||
"X-Kiosk-Timestamp",
|
||
"X-Kiosk-Nonce",
|
||
"X-Kiosk-Signature",
|
||
],
|
||
)
|
||
|
||
# TrustedHostMiddleware: aktiv sobald ALLOWED_HOSTS gesetzt (Development: leer = deaktiviert)
|
||
# Production: ALLOWED_HOSTS=timemaster.example.com in .env setzen
|
||
if settings.allowed_hosts:
|
||
app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.allowed_hosts)
|
||
|
||
# ── Routers ───────────────────────────────────────────────────────────────────
|
||
|
||
API_PREFIX = "/api/v1"
|
||
|
||
app.include_router(auth.router, prefix=API_PREFIX)
|
||
app.include_router(users.router, prefix=API_PREFIX)
|
||
app.include_router(companies.router, prefix=API_PREFIX)
|
||
app.include_router(time_entries.router, prefix=API_PREFIX)
|
||
app.include_router(public_stamp.router, prefix=API_PREFIX)
|
||
app.include_router(ical.router, prefix=API_PREFIX)
|
||
app.include_router(absences.router, prefix=API_PREFIX)
|
||
app.include_router(reports.router, prefix=API_PREFIX)
|
||
app.include_router(ldap.router, prefix=API_PREFIX)
|
||
app.include_router(smtp.router, prefix=API_PREFIX)
|
||
app.include_router(caldav.router, prefix=API_PREFIX)
|
||
app.include_router(import_kimai.router, prefix=API_PREFIX)
|
||
app.include_router(kiosk.router, prefix=API_PREFIX)
|
||
app.include_router(busylight.router, prefix=API_PREFIX)
|
||
app.include_router(audit.router, prefix=API_PREFIX)
|
||
app.include_router(special_assignments.router, prefix=API_PREFIX)
|
||
app.include_router(hours_payouts.router, prefix=API_PREFIX)
|
||
app.include_router(reseller.router, prefix=API_PREFIX)
|
||
app.include_router(tenants.router, prefix=API_PREFIX)
|
||
app.include_router(tls_admin.router, prefix=API_PREFIX)
|
||
|
||
|
||
# ── Health ────────────────────────────────────────────────────────────────────
|
||
|
||
@app.get("/health", tags=["System"])
|
||
async def health():
|
||
return {"status": "ok", "app": settings.app_name, "env": settings.app_env}
|