From ffc59b3fd4d4d6439f1b4c50f773c80339457fd9 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Thu, 4 Jun 2026 00:36:43 -0300 Subject: [PATCH] fix(server): serve Swagger /docs correctly behind a reverse-proxy path prefix When FUSE is hosted under a sub-path (e.g. api.example.com/fuse), the /docs redirect used an absolute /docs/index.html, so the browser dropped the /fuse prefix and 404'd. Now the redirect honors the proxy's X-Forwarded-Prefix header, falling back to a new SERVER_BASE_PATH config; and the Swagger UI loads its spec via a relative URL (doc.json) so it resolves under the prefix automatically (or an absolute base-path URL when SERVER_BASE_PATH is set). Root hosting is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 3 +++ internal/actors/mux_server.go | 19 +++++++++++++++++-- internal/app/config/config.go | 5 +++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 2a3e535..3387f7a 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ APP_NAME=Fuse PORT=9090 +# Path prefix when hosted behind a reverse proxy (e.g. api.example.com/fuse) so the Swagger UI at +# /docs works under the prefix. The proxy's X-Forwarded-Prefix header takes precedence if set. +# SERVER_BASE_PATH=/fuse # Database: memory | postgres DB_DRIVER=memory diff --git a/internal/actors/mux_server.go b/internal/actors/mux_server.go index f852ced..04ce1fc 100644 --- a/internal/actors/mux_server.go +++ b/internal/actors/mux_server.go @@ -3,6 +3,7 @@ package actors import ( "net/http" "strconv" + "strings" "ergo.services/ergo/act" "ergo.services/ergo/gen" @@ -84,10 +85,24 @@ func (m *muxServer) Init(_ ...any) error { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(spec.ReadDoc())) }) + // When FUSE is hosted under a reverse-proxy path prefix (e.g. /fuse), the /docs redirect and + // the Swagger UI spec URL must carry that prefix or the browser drops it. The prefix comes from + // the proxy's X-Forwarded-Prefix header when set, else SERVER_BASE_PATH. + configuredBasePath := strings.TrimRight(m.config.Server.BasePath, "/") muxRouter.HandleFunc("/docs", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/docs/index.html", http.StatusMovedPermanently) + prefix := strings.TrimRight(r.Header.Get("X-Forwarded-Prefix"), "/") + if prefix == "" { + prefix = configuredBasePath + } + http.Redirect(w, r, prefix+"/docs/index.html", http.StatusMovedPermanently) }) - muxRouter.PathPrefix("/docs/").Handler(httpSwagger.WrapHandler) + // The UI loads its spec relatively ("doc.json" resolves under /docs/) so it works + // behind any prefix; a configured base path makes it explicit/absolute. + specURL := "doc.json" + if configuredBasePath != "" { + specURL = configuredBasePath + "/docs/doc.json" + } + muxRouter.PathPrefix("/docs/").Handler(httpSwagger.Handler(httpSwagger.URL(specURL))) m.Log().Info("swagger documentation available at /docs or /docs/index.html") // create and spawn a web server meta-process diff --git a/internal/app/config/config.go b/internal/app/config/config.go index c9313ad..61fd5b5 100644 --- a/internal/app/config/config.go +++ b/internal/app/config/config.go @@ -85,6 +85,11 @@ type ( ServerConfig struct { Host string `env:"HOST" envDefault:"0.0.0.0"` Port string `env:"PORT" envDefault:"9090"` + // BasePath is the URL path prefix the engine is reachable under when behind a + // reverse proxy (e.g. "/fuse" for https://api.example.com/fuse). It is used to build + // the Swagger UI redirect/spec URL so /docs works under the prefix. The proxy's + // X-Forwarded-Prefix header takes precedence when present. Empty = served at root. + BasePath string `env:"SERVER_BASE_PATH"` } // ClusterConfig configuration for ergo distributed clustering