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() 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="app/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}