import asyncio from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from pathlib import Path import asyncpg from dishka.integrations.fastapi import DishkaRoute, FromDishka, setup_dishka from fastapi import FastAPI, Request from fastapi.responses import FileResponse, RedirectResponse from fastmcp.utilities.lifespan import combine_lifespans from starlette.applications import Starlette from api.access_log import scrub_access_log from api.auth import BearerAuthMiddleware from api.mcp.server import mcp from api.realtime import hub from api.routers import ( accounts, analytics, annotations, avatars, backfill, bots, chats, custom_emoji, discover, events, files, folders, media, peers, policy, presence, profile, search, shares, social, storage, stories, watches, ) from dependencies.container import container from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS, NO_STORE_HEADERS from utils.env import env from utils.storage import ContentAddressedStorage from utils.trash import purge_loop if env.auth.token is None: msg = "AUTH__TOKEN is required for the api process" raise RuntimeError(msg) _token = env.auth.token.get_secret_value() scrub_access_log() mcp_app = mcp.http_app(path="/") @asynccontextmanager async def lifespan(app_: Starlette) -> AsyncGenerator[None]: pool = await container.get(asyncpg.Pool) await hub.start(pool) purger = asyncio.create_task( purge_loop(pool, await container.get(ContentAddressedStorage)) ) yield purger.cancel() await hub.stop() await app_.state.dishka_container.close() app = FastAPI( title="beavergram API", lifespan=combine_lifespans(lifespan, mcp_app.lifespan) ) app.router.route_class = DishkaRoute @app.get("/health") async def health(pool: FromDishka[asyncpg.Pool]) -> dict[str, bool]: db_ok = await pool.fetchval("SELECT 1") == 1 timescale_ok = await pool.fetchval( "SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')" ) return {"db": db_ok, "timescaledb": bool(timescale_ok)} app.include_router(accounts.router) app.include_router(analytics.router) app.include_router(bots.router) app.include_router(policy.router) app.include_router(folders.router) app.include_router(backfill.router) app.include_router(search.router) app.include_router(chats.router) app.include_router(media.router) app.include_router(avatars.router) app.include_router(custom_emoji.router) app.include_router(social.router) app.include_router(presence.router) app.include_router(stories.router) app.include_router(profile.router) app.include_router(events.router) app.include_router(peers.router) app.include_router(discover.router) app.include_router(annotations.router) app.include_router(watches.router) app.include_router(shares.router) app.include_router(storage.router) app.include_router(files.router) app.mount("/mcp", mcp_app) @app.api_route("/mcp", methods=["GET", "POST", "DELETE"]) async def mcp_trailing_slash(request: Request) -> RedirectResponse: query = request.url.query return RedirectResponse(f"/mcp/?{query}" if query else "/mcp/", status_code=307) _spa_dir = Path(env.api.static_dir).resolve() if _spa_dir.is_dir(): _spa_index = _spa_dir / "index.html" @app.get("/{spa_path:path}") async def serve_spa(spa_path: str) -> FileResponse: candidate = (_spa_dir / spa_path).resolve() if spa_path and candidate.is_relative_to(_spa_dir) and candidate.is_file(): immutable = spa_path.startswith("_app/immutable/") return FileResponse( candidate, headers=IMMUTABLE_HEADERS if immutable else DAY_HEADERS ) return FileResponse(_spa_index, headers=NO_STORE_HEADERS) app.add_middleware(BearerAuthMiddleware, token=_token) setup_dishka(container, app)