From d6d07db2c519951f21d575352a565ee8085909e9 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Thu, 3 Sep 2026 19:38:15 +0000 Subject: [PATCH] Access-log extras: remote-user on /_api, visitor info on /_ws --- pagerite/app.py | 25 +++++++++++++++++++++++++ pagerite/tracking.py | 8 +++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/pagerite/app.py b/pagerite/app.py index 4880e14..65c9d4b 100644 --- a/pagerite/app.py +++ b/pagerite/app.py @@ -37,6 +37,7 @@ from pathlib import Path from fastapi import FastAPI, Request from fastapi.responses import Response from fastapi_vue import Frontend +from starlette.types import ASGIApp, Receive, Scope, Send from pagerite import api, files, pages, tracking from pagerite.__main__ import DEVMODE @@ -53,6 +54,28 @@ frontend = Frontend( ) +class _AccessLogExtraMiddleware: + """Fill the ``log_extra`` slot of fastapi_vue's access log. + + Everything under ``/_api`` is gated by the SSO forward-auth, which names + the authenticated user in the ``remote-user`` header; put that user on + the access-log line, for plain requests and WebSocket open/close alike. + The scope dict is shared with the outer AccessLogMiddleware, which reads + the slot back at response/accept/close time. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] in ("http", "websocket") and scope["path"].startswith("/_api"): + headers = dict(scope["headers"]) + user = headers.get(b"remote-user", b"").decode("latin-1") + if user: + scope.setdefault("state", {})["log_extra"] = user + await self.app(scope, receive, send) + + @asynccontextmanager async def lifespan(_app: FastAPI) -> AsyncGenerator: """Open the database (migrations run inside kanta.open), load assets, load GeoIP.""" @@ -83,6 +106,8 @@ app = FastAPI( openapi_url=None, ) +app.add_middleware(_AccessLogExtraMiddleware) + @app.middleware("http") async def _headers(request: Request, call_next) -> Response: diff --git a/pagerite/tracking.py b/pagerite/tracking.py index 3488d2a..5cd0467 100644 --- a/pagerite/tracking.py +++ b/pagerite/tracking.py @@ -435,10 +435,16 @@ async def activity_ws(ws: WebSocket) -> None: DB-IP geoip lookups happen in background tasks so message handling is never delayed by slow DNS or the first MMDB decompress. """ - await ws.accept() ip = _client_ip(ws) ua = ws.headers.get("user-agent", "") accept_language = ws.headers.get("accept-language", "") + # Identify the visitor on the access-log open/close lines (the IP is + # already printed there): compact UA plus the browser's language tag. + lang, _country = analytics._parse_accept_language(accept_language) + ws.scope.setdefault("state", {})["log_extra"] = " ".join( + part for part in (analytics._compact_user_agent(ua), lang) if part + ) + await ws.accept() try: while True: text = await ws.receive_text()