"""FastAPI entry point for Gems.""" from __future__ import annotations import asyncio from contextlib import asynccontextmanager, suppress from typing import TYPE_CHECKING, Annotated import typer import uvicorn from fastapi import FastAPI, Request, Response from fastapi.staticfiles import StaticFiles from .config import load_config from .events import EventBroker from .persistence import Repository from .rooms import RoomService from .routes import router from .security import CredentialService from .web import STATIC_DIR if TYPE_CHECKING: from collections.abc import AsyncIterator from starlette.middleware.base import RequestResponseEndpoint @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: """Initialize and close application-scoped services.""" config = app.state.config repository = Repository(config.database_path) app.state.repository = repository app.state.rooms = RoomService(repository, CredentialService(config.key_path), EventBroker()) repository.cleanup() cleanup_task = asyncio.create_task(_cleanup_rooms(repository)) try: yield finally: cleanup_task.cancel() with suppress(asyncio.CancelledError): await cleanup_task repository.close() async def _cleanup_rooms(repository: Repository) -> None: while True: await asyncio.sleep(3600) repository.cleanup() def create_app() -> FastAPI: """Create an isolated application instance.""" app = FastAPI(title="Gems", docs_url=None, redoc_url=None, lifespan=lifespan) app.state.config = load_config() app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") app.include_router(router) @app.middleware("http") async def security_headers(request: Request, call_next: RequestResponseEndpoint) -> Response: response = await call_next(request) response.headers.setdefault("Referrer-Policy", "no-referrer") response.headers.setdefault("X-Content-Type-Options", "nosniff") response.headers.setdefault("X-Frame-Options", "DENY") response.headers.setdefault("X-Robots-Tag", "noindex, nofollow") response.headers.setdefault( "Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline'; " "img-src 'self' data:; connect-src 'self'", ) if request.url.path.startswith("/rooms/"): response.headers.setdefault("Cache-Control", "no-store") return response return app app = create_app() def serve( host: Annotated[str | None, typer.Option()] = None, port: Annotated[int | None, typer.Option()] = None, ) -> None: """Run the Gems ASGI application with Uvicorn.""" config = load_config() uvicorn.run("python.gems.main:app", host=host or config.host, port=port or config.port, workers=1) if __name__ == "__main__": typer.run(serve)