""" lazyflat web app. Tabs: - / → Wohnungen - /bewerbungen → Bewerbungen (history + forensics ZIP for failed runs) - /einstellungen/
→ Einstellungen: profil | filter | benachrichtigungen | account - /admin/
→ Admin-only: protokoll | benutzer All state-changing POSTs require CSRF. Internal endpoints require INTERNAL_API_KEY. This module is intentionally thin: lifespan, middleware, template filters, and `include_router` calls. All business logic lives in `common.py` and `routes/*.py`. """ import logging from contextlib import asynccontextmanager from fastapi import FastAPI, Request, Response from fastapi.staticfiles import StaticFiles import db import enrichment import retention from auth import bootstrap_admin from common import _de_dt, _iso_utc, logger, templates from routes.admin import router as admin_router from routes.auth import router as auth_router from routes.bewerbungen import router as bewerbungen_router from routes.einstellungen import router as einstellungen_router from routes.internal import router as internal_router from routes.wohnungen import router as wohnungen_router logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)-5s %(name)s: %(message)s", datefmt="%H:%M:%S", ) logging.getLogger("urllib3").setLevel(logging.WARNING) # --------------------------------------------------------------------------- # App + Jinja # --------------------------------------------------------------------------- @asynccontextmanager async def lifespan(_app: FastAPI): db.init_db() db.seed_secrets_from_env() bootstrap_admin() retention.start() logger.info("web service ready") yield app = FastAPI(lifespan=lifespan, title="lazyflat", docs_url=None, redoc_url=None, openapi_url=None) app.mount("/static", StaticFiles(directory="static"), name="static") # Template filters are registered on the shared Jinja environment (owned by # common.templates) so every router that renders a template sees them. templates.env.filters["de_dt"] = _de_dt templates.env.filters["iso_utc"] = _iso_utc templates.env.filters["flat_slug"] = lambda s: enrichment.flat_slug(str(s or "")) @app.middleware("http") async def security_headers(request: Request, call_next): resp: Response = await call_next(request) resp.headers.setdefault("X-Frame-Options", "DENY") resp.headers.setdefault("X-Content-Type-Options", "nosniff") resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") resp.headers.setdefault("Permissions-Policy", "geolocation=(), camera=(), microphone=()") resp.headers.setdefault( "Content-Security-Policy", "default-src 'self'; " "script-src 'self' https://cdn.tailwindcss.com https://unpkg.com; " "style-src 'self' https://cdn.tailwindcss.com https://unpkg.com 'unsafe-inline'; " "img-src 'self' data: " "https://*.basemaps.cartocdn.com https://basemaps.cartocdn.com " "https://unpkg.com; " "connect-src 'self'; frame-ancestors 'none';" ) return resp @app.get("/health") def health(): return {"status": "ok"} # Routers are registered without prefixes — paths stay absolute. app.include_router(auth_router) app.include_router(wohnungen_router) app.include_router(bewerbungen_router) app.include_router(einstellungen_router) app.include_router(admin_router) app.include_router(internal_router)