feat: one gateway port with path-mounted frontends, markdown chat, collapsible sidebars
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
"""Admin console: serves the ``ui/`` SPA and signs the operator in.
|
||||
|
||||
Everything the console shows comes from ``/api/*`` (``ApiFrontend``)
|
||||
with a bearer. The admin port only owns three JSON routes under
|
||||
``/admin/auth`` - login (``ADMIN_USER`` / ``ADMIN_PASS`` from env,
|
||||
with a bearer. This app, mounted at ``/admin``, owns three JSON routes
|
||||
under ``/admin/auth`` - login (``ADMIN_USER`` / ``ADMIN_PASS`` from env,
|
||||
session cookie signed with ``SESSION_SECRET``, 8 h), logout, and
|
||||
``session``, which hands a signed-in browser the process-lifetime admin
|
||||
bearer plus the API origin - and the static build under ``/admin/``.
|
||||
bearer - and the static build under ``/admin/``.
|
||||
|
||||
The bearer is minted at startup, registered in the token store with
|
||||
scope ``*`` and never persisted; a gateway restart rotates it, and the
|
||||
@@ -25,7 +25,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
import itsdangerous
|
||||
from fastapi import FastAPI, HTTPException, Request, status
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
|
||||
from beaver_gateway.core import audit
|
||||
from beaver_gateway.frontends.base import Frontend
|
||||
@@ -49,17 +49,10 @@ __all__ = ["AdminFrontend"]
|
||||
|
||||
|
||||
class AdminFrontend(Frontend):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
host: str = "0.0.0.0", # noqa: S104
|
||||
port: int = 8002,
|
||||
public_base_url: str | None = None,
|
||||
ui_dir: Path | None = None,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
|
||||
path = BASE
|
||||
landing = True
|
||||
|
||||
def __init__(self, *, ui_dir: Path | None = None) -> None:
|
||||
self.ui_dir = (ui_dir or DEFAULT_UI_DIR).resolve()
|
||||
self._app: FastAPI | None = None
|
||||
|
||||
@@ -78,16 +71,8 @@ class AdminFrontend(Frontend):
|
||||
"admin UI build missing at %s (run `make ui-build`)", self.ui_dir
|
||||
)
|
||||
|
||||
async def serve(self) -> None:
|
||||
import uvicorn
|
||||
|
||||
if self._app is None:
|
||||
msg = "configure() must be called before serve()"
|
||||
raise RuntimeError(msg)
|
||||
config = uvicorn.Config(
|
||||
self._app, host=self.host, port=self.port, log_level="info"
|
||||
)
|
||||
await uvicorn.Server(config).serve()
|
||||
def app(self) -> FastAPI | None:
|
||||
return self._app
|
||||
|
||||
|
||||
def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI:
|
||||
@@ -100,13 +85,7 @@ def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI:
|
||||
)
|
||||
index = ui_dir / "index.html"
|
||||
|
||||
@app.get("/")
|
||||
async def root() -> Response:
|
||||
return RedirectResponse(
|
||||
f"{BASE}/", status_code=status.HTTP_307_TEMPORARY_REDIRECT
|
||||
)
|
||||
|
||||
@app.post(f"{BASE}/auth/login")
|
||||
@app.post("/auth/login")
|
||||
async def login(request: Request) -> Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
@@ -157,7 +136,7 @@ def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI:
|
||||
await audit.log(runtime, actor=f"admin:{username}", kind="login_ok", ip=ip)
|
||||
return response
|
||||
|
||||
@app.post(f"{BASE}/auth/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@app.post("/auth/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(request: Request) -> Response:
|
||||
user = _current_user(request, signer)
|
||||
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -166,15 +145,15 @@ def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI:
|
||||
await audit.log(runtime, actor=f"admin:{user}", kind="logout")
|
||||
return response
|
||||
|
||||
@app.get(f"{BASE}/auth/session")
|
||||
@app.get("/auth/session")
|
||||
async def session(request: Request) -> dict[str, Any]:
|
||||
user = _current_user(request, signer)
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not signed in")
|
||||
return {"user": user, "token": token, "api_base": _api_base(request, runtime)}
|
||||
return {"user": user, "token": token}
|
||||
|
||||
@app.get(BASE)
|
||||
@app.get(f"{BASE}/{{path:path}}")
|
||||
@app.get("/")
|
||||
@app.get("/{path:path}")
|
||||
async def spa(path: str = "") -> Response:
|
||||
if path:
|
||||
target = (ui_dir / path).resolve()
|
||||
@@ -204,21 +183,6 @@ def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI:
|
||||
return app
|
||||
|
||||
|
||||
def _api_base(request: Request, runtime: GatewayRuntime) -> str | None:
|
||||
for fe in runtime.frontends:
|
||||
if fe.name != "api":
|
||||
continue
|
||||
public = getattr(fe, "public_base_url", None)
|
||||
if public:
|
||||
return public.removesuffix("/api")
|
||||
port = getattr(fe, "port", None)
|
||||
if port is None:
|
||||
return None
|
||||
scheme = request.headers.get("x-forwarded-proto") or request.url.scheme
|
||||
return f"{scheme}://{request.url.hostname}:{port}"
|
||||
return None
|
||||
|
||||
|
||||
def _current_user(
|
||||
request: Request, signer: itsdangerous.URLSafeTimedSerializer
|
||||
) -> str | None:
|
||||
|
||||
Reference in New Issue
Block a user