113 lines
3.8 KiB
Python
113 lines
3.8 KiB
Python
"""The FastAPI app. `packetstation.main:app` is built on first access so imports need no settings."""
|
|||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import contextlib
|
||
|
|
import logging
|
||
|
|
from collections.abc import AsyncIterator
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from fastapi import FastAPI
|
||
|
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||
|
|
from starlette.concurrency import run_in_threadpool
|
||
|
|
|
||
|
|
from . import db, guard
|
||
|
|
from .proxy import TrustedProxies, TrustedProxyMiddleware
|
||
|
|
from .security import RequestLogMiddleware, SecurityHeadersMiddleware
|
||
|
|
from .settings import Settings, get_settings
|
||
|
|
|
||
|
|
log = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
PLACEHOLDER = """<!doctype html>
|
||
|
|
<html lang="en"><head><meta charset="utf-8"><title>Packet Station</title></head>
|
||
|
|
<body><h1>Packet Station</h1><p>The web client is not built into this image (STATIC_DIR missing).</p></body></html>
|
||
|
|
"""
|
||
|
|
|
||
|
|
NO_FALLBACK = ("api", "_blob")
|
||
|
|
|
||
|
|
|
||
|
|
def _static_file(static_dir: Path, path: str) -> Path | None:
|
||
|
|
"""The file under static_dir for a URL path, or None. Never escapes static_dir."""
|
||
|
|
if not path:
|
||
|
|
return None
|
||
|
|
root = static_dir.resolve()
|
||
|
|
candidate = (root / path).resolve()
|
||
|
|
if not candidate.is_relative_to(root) or not candidate.is_file():
|
||
|
|
return None
|
||
|
|
return candidate
|
||
|
|
|
||
|
|
|
||
|
|
def create_app(settings: Settings | None = None, *, run_guard: bool = True) -> FastAPI:
|
||
|
|
s = settings or get_settings()
|
||
|
|
if run_guard:
|
||
|
|
guard.startup_checks(s)
|
||
|
|
trusted = TrustedProxies(s.trusted_proxy_host)
|
||
|
|
|
||
|
|
@contextlib.asynccontextmanager
|
||
|
|
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||
|
|
await asyncio.to_thread(trusted.refresh)
|
||
|
|
task = asyncio.create_task(trusted.run()) if trusted.host else None
|
||
|
|
log.info("packetstation app starting", extra={"ps": {"build": s.ps_build, "stack": s.stack}})
|
||
|
|
try:
|
||
|
|
yield
|
||
|
|
finally:
|
||
|
|
if task:
|
||
|
|
task.cancel()
|
||
|
|
await run_in_threadpool(db.close_pool)
|
||
|
|
|
||
|
|
app = FastAPI(
|
||
|
|
title="Packet Station", version=s.ps_build, lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None
|
||
|
|
)
|
||
|
|
app.state.settings = s
|
||
|
|
app.state.trusted = trusted
|
||
|
|
|
||
|
|
@app.get("/healthz")
|
||
|
|
def healthz() -> dict[str, Any]:
|
||
|
|
return {"ok": True, "build": s.ps_build}
|
||
|
|
|
||
|
|
@app.get("/api/version")
|
||
|
|
def version() -> dict[str, Any]:
|
||
|
|
return {"build": s.ps_build, "stack": s.stack, "site_tz": s.site_tz}
|
||
|
|
|
||
|
|
@app.get("/api/db-health")
|
||
|
|
def db_health() -> Response:
|
||
|
|
try:
|
||
|
|
db.ping(s)
|
||
|
|
except db.DatabaseUnavailable:
|
||
|
|
log.warning("db-health: database unavailable")
|
||
|
|
return JSONResponse({"ok": False, "error": "database unavailable"}, status_code=503)
|
||
|
|
return JSONResponse({"ok": True})
|
||
|
|
|
||
|
|
@app.api_route("/{path:path}", methods=["GET", "HEAD"], include_in_schema=False)
|
||
|
|
def static(path: str) -> Response:
|
||
|
|
if path.split("/", 1)[0] in NO_FALLBACK:
|
||
|
|
return JSONResponse({"detail": "Not Found"}, status_code=404)
|
||
|
|
static_dir = s.static_dir
|
||
|
|
index = static_dir / "index.html"
|
||
|
|
if not index.is_file():
|
||
|
|
return HTMLResponse(PLACEHOLDER)
|
||
|
|
found = _static_file(static_dir, path)
|
||
|
|
if found is not None:
|
||
|
|
return FileResponse(found)
|
||
|
|
return FileResponse(index, headers={"Cache-Control": "no-cache"})
|
||
|
|
|
||
|
|
# Last added runs first: proxy rewrite, then the log (sees the real ip), then headers
|
||
|
|
app.add_middleware(SecurityHeadersMiddleware)
|
||
|
|
app.add_middleware(RequestLogMiddleware, build=s.ps_build)
|
||
|
|
app.add_middleware(TrustedProxyMiddleware, trusted=trusted)
|
||
|
|
return app
|
||
|
|
|
||
|
|
|
||
|
|
_app: FastAPI | None = None
|
||
|
|
|
||
|
|
|
||
|
|
def __getattr__(name: str) -> Any:
|
||
|
|
global _app
|
||
|
|
if name == "app":
|
||
|
|
if _app is None:
|
||
|
|
_app = create_app()
|
||
|
|
return _app
|
||
|
|
raise AttributeError(name)
|