From 52e277782127ef53ab7c3f08d5d0b199598b3f52 Mon Sep 17 00:00:00 2001 From: cmcgrabby-hue <267390149+cmcgrabby-hue@users.noreply.github.com> Date: Sun, 3 May 2026 18:19:50 -0700 Subject: [PATCH] feat(dashboard): support serving under URL prefix via X-Forwarded-Prefix The Hermes dashboard previously assumed it was served at the root of its host (e.g. https://kanban.tilos.com/). When mounted behind a path-prefix reverse proxy (e.g. https://mission-control.tilos.com/hermes/), the SPA 404'd because: - index.html shipped absolute /assets/index-*.js URLs - React Router had no basename - The plugin loader hit /dashboard-plugins//... at the root host - CSS in the bundle had absolute url(/fonts/...) references This patch makes the dashboard prefix-aware at runtime, no rebuild required. The proxy injects 'X-Forwarded-Prefix: /hermes' on every request and the Python server: - Rewrites href/src in served index.html to '${prefix}/assets/...' - Injects 'window.__HERMES_BASE_PATH__="${prefix}"' for the SPA to read - Rewrites url() refs in CSS at serve time The SPA reads window.__HERMES_BASE_PATH__ once at boot and: - Prefixes all /api/... fetches via api.ts - Prefixes all /dashboard-plugins/... script/css URLs in usePlugins - Sets so client-side routing works When no X-Forwarded-Prefix header is present, behavior is unchanged (empty prefix => serves at root, kanban.tilos.com keeps working). Refs: MC-AUTO-13 --- hermes_cli/web_server.py | 80 ++++++++++++++++++++++++++++++++--- web/src/lib/api.ts | 20 ++++++++- web/src/main.tsx | 3 +- web/src/plugins/usePlugins.ts | 6 +-- 4 files changed, 98 insertions(+), 11 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index a6af66bc9a..46786455ce 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -52,7 +52,7 @@ from gateway.status import get_running_pid, read_runtime_status try: from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware - from fastapi.responses import FileResponse, HTMLResponse, JSONResponse + from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel except ImportError: @@ -3308,12 +3308,42 @@ async def events_ws(ws: WebSocket) -> None: _event_channels.pop(channel, None) +def _normalise_prefix(raw: Optional[str]) -> str: + """Normalise an X-Forwarded-Prefix header value. + + Returns a string like ``"/hermes"`` (no trailing slash) or ``""`` when + no prefix is set / the header is malformed. We deliberately reject + anything containing ``..`` or non-printable bytes so a hostile proxy + can't inject HTML via the prefix. + """ + if not raw: + return "" + p = raw.strip() + if not p: + return "" + if not p.startswith("/"): + p = "/" + p + p = p.rstrip("/") + if "//" in p or ".." in p or any(c in p for c in ('"', "'", "<", ">", " ", "\n", "\r", "\t")): + return "" + if len(p) > 64: + return "" + return p + + def mount_spa(application: FastAPI): """Mount the built SPA. Falls back to index.html for client-side routing. The session token is injected into index.html via a ``" + f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};" + f'window.__HERMES_BASE_PATH__="{prefix}";' ) + if prefix: + # Rewrite absolute asset URLs baked into the Vite build so the + # browser fetches them through the same proxy prefix. + html = html.replace('href="/assets/', f'href="{prefix}/assets/') + html = html.replace('src="/assets/', f'src="{prefix}/assets/') + html = html.replace('href="/favicon.ico"', f'href="{prefix}/favicon.ico"') + html = html.replace('href="/fonts/', f'href="{prefix}/fonts/') + html = html.replace('href="/ds-assets/', f'href="{prefix}/ds-assets/') + html = html.replace('src="/ds-assets/', f'src="{prefix}/ds-assets/') html = html.replace("", f"{token_script}", 1) return HTMLResponse( html, headers={"Cache-Control": "no-store, no-cache, must-revalidate"}, ) + # When served behind a path-prefix proxy, the built CSS contains + # absolute ``url(/fonts/...)`` and ``url(/ds-assets/...)`` references. + # Browsers resolve those against the document origin, which means + # under ``/hermes`` they'd hit ``mission-control.tilos.com/fonts/...`` + # (the MC Pages app), not the Hermes backend. Intercept CSS asset + # requests BEFORE the StaticFiles mount and rewrite the absolute paths + # when a prefix is in play. + @application.get("/assets/{filename}.css") + async def serve_css(filename: str, request: Request): + css_path = WEB_DIST / "assets" / f"{filename}.css" + if not css_path.is_file() or not css_path.resolve().is_relative_to( + WEB_DIST.resolve() + ): + return JSONResponse({"error": "not found"}, status_code=404) + prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix")) + css = css_path.read_text() + if prefix: + for asset_dir in ("/fonts/", "/fonts-terminal/", "/ds-assets/", "/assets/"): + css = css.replace(f"url({asset_dir}", f"url({prefix}{asset_dir}") + css = css.replace(f"url(\"{asset_dir}", f"url(\"{prefix}{asset_dir}") + css = css.replace(f"url('{asset_dir}", f"url('{prefix}{asset_dir}") + return Response(content=css, media_type="text/css") + application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets") @application.get("/{full_path:path}") - async def serve_spa(full_path: str): + async def serve_spa(full_path: str, request: Request): + prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix")) file_path = WEB_DIST / full_path # Prevent path traversal via url-encoded sequences (%2e%2e/) if ( @@ -3353,7 +3421,7 @@ def mount_spa(application: FastAPI): and file_path.is_file() ): return FileResponse(file_path) - return _serve_index() + return _serve_index(prefix) # --------------------------------------------------------------------------- diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 94d5b547d6..6568e979bc 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,4 +1,21 @@ -const BASE = ""; +// The dashboard can be served either at the root of its host (e.g. +// https://kanban.tilos.com/) or under a URL prefix when reverse-proxied +// (e.g. https://mission-control.tilos.com/hermes/). The Python backend +// injects ``window.__HERMES_BASE_PATH__`` into index.html based on the +// incoming ``X-Forwarded-Prefix`` header so the SPA can address its own +// ``/api/...`` and ``/dashboard-plugins/...`` URLs correctly without a +// rebuild. Empty string means "served at root". +function readBasePath(): string { + if (typeof window === "undefined") return ""; + const raw = window.__HERMES_BASE_PATH__ ?? ""; + if (!raw) return ""; + // Normalise: ensure leading slash, strip trailing slash. + const withLead = raw.startsWith("/") ? raw : `/${raw}`; + return withLead.replace(/\/+$/, ""); +} + +export const HERMES_BASE_PATH = readBasePath(); +const BASE = HERMES_BASE_PATH; import type { DashboardTheme } from "@/themes/types"; @@ -7,6 +24,7 @@ import type { DashboardTheme } from "@/themes/types"; declare global { interface Window { __HERMES_SESSION_TOKEN__?: string; + __HERMES_BASE_PATH__?: string; } } let _sessionToken: string | null = null; diff --git a/web/src/main.tsx b/web/src/main.tsx index 57a08b9634..e0d00fdf63 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -6,13 +6,14 @@ import { SystemActionsProvider } from "./contexts/SystemActions"; import { I18nProvider } from "./i18n"; import { exposePluginSDK } from "./plugins"; import { ThemeProvider } from "./themes"; +import { HERMES_BASE_PATH } from "./lib/api"; // Expose the plugin SDK before rendering so plugins loaded via