import asyncio import logging import time import uuid from typing import Awaitable, Callable from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from .config import settings from .db import init_db from .routers.requests import ( router as requests_router, startup_warmup_requests_cache, run_requests_delta_loop, run_daily_requests_full_sync, run_daily_db_cleanup, ) from .routers.auth import router as auth_router from .routers.admin import router as admin_router, events_router as admin_events_router from .routers.images import router as images_router from .routers.branding import router as branding_router from .routers.status import router as status_router from .routers.feedback import router as feedback_router from .routers.site import router as site_router from .routers.events import router as events_router from .services.jellyfin_sync import run_daily_jellyfin_sync from .logging_config import ( bind_request_id, configure_logging, reset_request_id, sanitize_headers, sanitize_value, summarize_http_body, ) from .runtime import get_runtime_settings logger = logging.getLogger(__name__) _background_tasks: list[asyncio.Task[None]] = [] app = FastAPI( title=settings.app_name, docs_url="/docs" if settings.api_docs_enabled else None, redoc_url=None, openapi_url="/openapi.json" if settings.api_docs_enabled else None, ) app.add_middleware( CORSMiddleware, allow_origins=[settings.cors_allow_origin], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.middleware("http") async def log_requests_and_add_security_headers(request: Request, call_next): request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12] token = bind_request_id(request_id) request.state.request_id = request_id started_at = time.perf_counter() body = await request.body() body_summary = summarize_http_body(body, request.headers.get("content-type")) async def receive() -> dict: return {"type": "http.request", "body": body, "more_body": False} request._receive = receive logger.info( "request started method=%s path=%s query=%s client=%s headers=%s body=%s", request.method, request.url.path, sanitize_value(dict(request.query_params)), request.client.host if request.client else "-", sanitize_headers( { key: value for key, value in request.headers.items() if key.lower() in { "content-type", "content-length", "user-agent", "x-forwarded-for", "x-forwarded-proto", "x-request-id", } } ), body_summary, ) try: response = await call_next(request) except Exception: duration_ms = round((time.perf_counter() - started_at) * 1000, 2) logger.exception( "request failed method=%s path=%s duration_ms=%s", request.method, request.url.path, duration_ms, ) reset_request_id(token) raise duration_ms = round((time.perf_counter() - started_at) * 1000, 2) response.headers.setdefault("X-Request-ID", request_id) response.headers.setdefault("X-Content-Type-Options", "nosniff") response.headers.setdefault("X-Frame-Options", "DENY") response.headers.setdefault("Referrer-Policy", "no-referrer") response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()") # Keep API responses non-executable and non-embeddable by default. if request.url.path not in {"/docs", "/redoc"} and not request.url.path.startswith("/openapi"): response.headers.setdefault( "Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'; base-uri 'none'", ) logger.info( "request completed method=%s path=%s status=%s duration_ms=%s response_headers=%s", request.method, request.url.path, response.status_code, duration_ms, sanitize_headers( { key: value for key, value in response.headers.items() if key.lower() in {"content-type", "content-length", "x-request-id"} } ), ) reset_request_id(token) return response @app.get("/health") async def health() -> dict: return {"status": "ok"} async def _run_background_task( name: str, coroutine_factory: Callable[[], Awaitable[None]] ) -> None: token = bind_request_id(f"task-{name}") logger.info("background task started task=%s", name) try: await coroutine_factory() logger.warning("background task exited task=%s", name) except asyncio.CancelledError: logger.info("background task cancelled task=%s", name) raise except Exception: logger.exception("background task crashed task=%s", name) raise finally: reset_request_id(token) def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable[None]]) -> None: task = asyncio.create_task( _run_background_task(name, coroutine_factory), name=f"magent:{name}" ) _background_tasks.append(task) @app.on_event("startup") async def startup() -> None: configure_logging( settings.log_level, settings.log_file, log_file_max_bytes=settings.log_file_max_bytes, log_file_backup_count=settings.log_file_backup_count, log_http_client_level=settings.log_http_client_level, log_background_sync_level=settings.log_background_sync_level, ) logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number) init_db() runtime = get_runtime_settings() configure_logging( runtime.log_level, runtime.log_file, log_file_max_bytes=runtime.log_file_max_bytes, log_file_backup_count=runtime.log_file_backup_count, log_http_client_level=runtime.log_http_client_level, log_background_sync_level=runtime.log_background_sync_level, ) logger.info( "runtime settings applied log_level=%s log_file=%s log_file_max_bytes=%s log_file_backup_count=%s log_http_client_level=%s log_background_sync_level=%s request_source=%s", runtime.log_level, runtime.log_file, runtime.log_file_max_bytes, runtime.log_file_backup_count, runtime.log_http_client_level, runtime.log_background_sync_level, runtime.requests_data_source, ) _launch_background_task("jellyfin-sync", run_daily_jellyfin_sync) _launch_background_task("requests-warmup", startup_warmup_requests_cache) _launch_background_task("requests-delta-loop", run_requests_delta_loop) _launch_background_task("requests-full-sync", run_daily_requests_full_sync) _launch_background_task("db-cleanup", run_daily_db_cleanup) logger.info("startup complete") app.include_router(requests_router) app.include_router(auth_router) app.include_router(admin_router) app.include_router(admin_events_router) app.include_router(images_router) app.include_router(branding_router) app.include_router(status_router) app.include_router(feedback_router) app.include_router(site_router) app.include_router(events_router)