2026-10-15 23:08:47 -06:00
|
|
|
"""The FastAPI app. `packetstation.main:app` is built on first access so imports need no settings."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import contextlib
|
2026-10-16 14:45:38 -06:00
|
|
|
import importlib
|
2026-10-15 23:08:47 -06:00
|
|
|
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
|
2026-10-16 07:40:29 -06:00
|
|
|
from .api import errors as api_errors
|
|
|
|
|
from .api.origin import OriginCheckMiddleware
|
|
|
|
|
from .auth.ldap import DirectoryProtocol
|
|
|
|
|
from .events import EventHub
|
2026-10-15 23:08:47 -06:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 07:40:29 -06:00
|
|
|
def _load_departments(s: Settings) -> None:
|
|
|
|
|
"""Rebuild the group map from the departments table; the built-in seed stays when the DB is not there."""
|
|
|
|
|
from .auth import groups
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
with db.connect_as("app", s, autocommit=True) as conn:
|
|
|
|
|
deps = groups.load_departments(conn)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
log.warning("group map from the built-in departments (database: %s)", type(e).__name__)
|
|
|
|
|
return
|
|
|
|
|
if deps:
|
|
|
|
|
groups.use_departments(deps)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_app(
|
|
|
|
|
settings: Settings | None = None, *, run_guard: bool = True, directory: DirectoryProtocol | None = None
|
|
|
|
|
) -> FastAPI:
|
2026-10-15 23:08:47 -06:00
|
|
|
s = settings or get_settings()
|
|
|
|
|
if run_guard:
|
|
|
|
|
guard.startup_checks(s)
|
|
|
|
|
trusted = TrustedProxies(s.trusted_proxy_host)
|
2026-10-16 07:40:29 -06:00
|
|
|
hub = EventHub(connect=lambda: db.connect_as("app", s, autocommit=True))
|
2026-10-15 23:08:47 -06:00
|
|
|
|
|
|
|
|
@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
|
2026-10-16 07:40:29 -06:00
|
|
|
deps_task = asyncio.create_task(asyncio.to_thread(_load_departments, s))
|
|
|
|
|
hub.start() # connects in its own thread and retries with backoff; never blocks startup
|
2026-10-15 23:08:47 -06:00
|
|
|
log.info("packetstation app starting", extra={"ps": {"build": s.ps_build, "stack": s.stack}})
|
|
|
|
|
try:
|
|
|
|
|
yield
|
|
|
|
|
finally:
|
|
|
|
|
if task:
|
|
|
|
|
task.cancel()
|
2026-10-16 07:40:29 -06:00
|
|
|
deps_task.cancel()
|
|
|
|
|
await asyncio.to_thread(hub.stop)
|
2026-10-15 23:08:47 -06:00
|
|
|
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
|
2026-10-16 07:40:29 -06:00
|
|
|
app.state.directory = directory # None: built from settings on first use
|
|
|
|
|
app.state.events = hub
|
|
|
|
|
app.state.session_key = None
|
|
|
|
|
app.state.groups_cache = None
|
|
|
|
|
api_errors.install(app)
|
|
|
|
|
|
2026-10-16 14:45:38 -06:00
|
|
|
from .api import (
|
|
|
|
|
account,
|
|
|
|
|
admin,
|
|
|
|
|
assets,
|
|
|
|
|
auth,
|
|
|
|
|
departments,
|
|
|
|
|
feed_settings,
|
2026-10-17 20:08:53 -06:00
|
|
|
itp,
|
2026-10-16 14:45:38 -06:00
|
|
|
jobs,
|
|
|
|
|
packet_children,
|
|
|
|
|
packets,
|
|
|
|
|
projects,
|
2026-10-17 20:08:53 -06:00
|
|
|
qc,
|
2026-10-16 22:46:56 -06:00
|
|
|
shop,
|
2026-10-16 14:45:38 -06:00
|
|
|
units,
|
2026-10-16 17:13:31 -06:00
|
|
|
warehouse,
|
2026-10-16 14:45:38 -06:00
|
|
|
)
|
2026-10-16 07:40:29 -06:00
|
|
|
from .api import events as events_api
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
# Routers go in before the static catch-all below, which answers every other GET.
|
2026-10-16 14:45:38 -06:00
|
|
|
for module in (
|
|
|
|
|
auth,
|
|
|
|
|
account,
|
|
|
|
|
admin,
|
|
|
|
|
departments,
|
|
|
|
|
events_api,
|
|
|
|
|
projects,
|
|
|
|
|
units,
|
|
|
|
|
packets,
|
|
|
|
|
packet_children,
|
|
|
|
|
assets,
|
|
|
|
|
jobs,
|
|
|
|
|
feed_settings,
|
2026-10-16 17:13:31 -06:00
|
|
|
warehouse,
|
2026-10-16 22:46:56 -06:00
|
|
|
shop,
|
2026-10-17 20:08:53 -06:00
|
|
|
qc,
|
|
|
|
|
itp,
|
2026-10-16 14:45:38 -06:00
|
|
|
):
|
2026-10-16 07:40:29 -06:00
|
|
|
app.include_router(module.router)
|
2026-10-16 09:31:45 -06:00
|
|
|
app.include_router(assets.blob_router)
|
2026-10-16 14:45:38 -06:00
|
|
|
for name in ("feed", "packet_visual"):
|
|
|
|
|
modname = f"{__package__}.api.{name}"
|
|
|
|
|
try:
|
|
|
|
|
extra = importlib.import_module(modname)
|
|
|
|
|
except ModuleNotFoundError as e:
|
|
|
|
|
if e.name != modname:
|
|
|
|
|
raise
|
|
|
|
|
log.warning("router api.%s not there yet; not loaded", name)
|
|
|
|
|
continue
|
|
|
|
|
app.include_router(extra.router)
|
2026-10-15 23:08:47 -06:00
|
|
|
|
|
|
|
|
@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"})
|
|
|
|
|
|
2026-10-16 07:40:29 -06:00
|
|
|
# Last added runs first: proxy rewrite, then the log (sees the real ip), then headers, then the Origin check
|
|
|
|
|
app.add_middleware(OriginCheckMiddleware)
|
2026-10-15 23:08:47 -06:00
|
|
|
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)
|