From e7c4c3384e7b5a88562a64f74d26b05365aedfb7 Mon Sep 17 00:00:00 2001 From: mm-mbs Date: Mon, 6 Jul 2026 12:12:20 +0200 Subject: [PATCH 1/3] feat: runtime-configurable sub-path serving Allow serving HomeBox under a sub-path (e.g. /homebox/) by setting HBOX_WEB_APP_BASE at runtime. Same image works for any path, no rebuild needed. Backend patches index.html at startup with and fixes Nuxt's baseURL config. API/Swagger/static files mount under the configured base. Requests outside the base return 404. Frontend uses cdnURL "./" for relative assets and reads the base from window.__HOMEBOX_BASE__ via useAppBase() composable. Supersedes #1430 (which requires a rebuild per path). --- Dockerfile | 4 +- backend/app/api/handlers/v1/controller.go | 2 +- backend/app/api/handlers/v1/v1_ctrl_auth.go | 20 ++--- backend/app/api/providers/oidc.go | 22 +++--- backend/app/api/routes.go | 78 ++++++++++++++++--- backend/internal/sys/config/conf.go | 29 +++++++ .../sys/config/conf_normalize_test.go | 47 +++++++++++ docker-compose.yml | 1 + frontend/app.vue | 8 +- frontend/components/Collection/JoinModal.vue | 2 +- frontend/composables/use-app-base.ts | 15 ++++ frontend/composables/use-server-events.ts | 4 +- frontend/lib/api/base/index.test.ts | 14 +++- frontend/lib/api/base/urls.ts | 7 +- frontend/lib/otel/index.ts | 4 +- frontend/nuxt.config.ts | 13 +++- frontend/pages/collection/index/invites.vue | 2 +- frontend/pages/index.vue | 3 +- frontend/plugins/api-base.ts | 17 ++++ 19 files changed, 242 insertions(+), 50 deletions(-) create mode 100644 backend/internal/sys/config/conf_normalize_test.go create mode 100644 frontend/composables/use-app-base.ts create mode 100644 frontend/plugins/api-base.ts diff --git a/Dockerfile b/Dockerfile index 2db5ef3f3..4bcf0389e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -90,8 +90,10 @@ EXPOSE 7745 WORKDIR /app # Healthcheck configuration +# Shell logic normalizes HBOX_WEB_APP_BASE to always have leading+trailing +# slash (e.g. "homebox" -> "/homebox/") to match Go's normalizePath(). HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ - CMD [ "wget", "--no-verbose", "--tries=1", "-O", "-", "http://localhost:7745/api/v1/status" ] + CMD sh -c 'B="${HBOX_WEB_APP_BASE:-/}"; B="/${B#/}"; B="${B%/}/"; wget --no-verbose --tries=1 -O - "http://localhost:7745${B}api/v1/status"' # Persist volume VOLUME [ "/data" ] diff --git a/backend/app/api/handlers/v1/controller.go b/backend/app/api/handlers/v1/controller.go index 5e9ce30fa..a6472b052 100644 --- a/backend/app/api/handlers/v1/controller.go +++ b/backend/app/api/handlers/v1/controller.go @@ -147,7 +147,7 @@ func NewControllerV1(svc *services.AllServices, repos *repo.AllRepos, bus *event func (ctrl *V1Controller) initOIDCProvider() { if ctrl.config.OIDC.Enabled { - oidcProvider, err := providers.NewOIDCProvider(ctrl.svc.User, &ctrl.config.OIDC, &ctrl.config.Options, ctrl.cookieSecure) + oidcProvider, err := providers.NewOIDCProvider(ctrl.svc.User, &ctrl.config.OIDC, &ctrl.config.Options, &ctrl.config.Web, ctrl.cookieSecure) if err != nil { log.Err(err).Msg("failed to initialize OIDC provider at startup") } else { diff --git a/backend/app/api/handlers/v1/v1_ctrl_auth.go b/backend/app/api/handlers/v1/v1_ctrl_auth.go index 2989daa6d..4768d6436 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_auth.go +++ b/backend/app/api/handlers/v1/v1_ctrl_auth.go @@ -412,7 +412,7 @@ func (ctrl *V1Controller) setCookies(w http.ResponseWriter, domain, token string Domain: domain, Secure: ctrl.cookieSecure, HttpOnly: true, - Path: "/", + Path: ctrl.config.Web.AppBase, SameSite: http.SameSiteLaxMode, }) @@ -424,7 +424,7 @@ func (ctrl *V1Controller) setCookies(w http.ResponseWriter, domain, token string Domain: domain, Secure: ctrl.cookieSecure, HttpOnly: true, - Path: "/", + Path: ctrl.config.Web.AppBase, SameSite: http.SameSiteLaxMode, }) @@ -436,7 +436,7 @@ func (ctrl *V1Controller) setCookies(w http.ResponseWriter, domain, token string Domain: domain, Secure: ctrl.cookieSecure, HttpOnly: false, - Path: "/", + Path: ctrl.config.Web.AppBase, SameSite: http.SameSiteLaxMode, }) @@ -449,7 +449,7 @@ func (ctrl *V1Controller) setCookies(w http.ResponseWriter, domain, token string Domain: domain, Secure: ctrl.cookieSecure, HttpOnly: false, - Path: "/", + Path: ctrl.config.Web.AppBase, SameSite: http.SameSiteLaxMode, }) } @@ -463,7 +463,7 @@ func (ctrl *V1Controller) unsetCookies(w http.ResponseWriter, domain string) { Domain: domain, Secure: ctrl.cookieSecure, HttpOnly: true, - Path: "/", + Path: ctrl.config.Web.AppBase, SameSite: http.SameSiteLaxMode, }) @@ -474,7 +474,7 @@ func (ctrl *V1Controller) unsetCookies(w http.ResponseWriter, domain string) { Domain: domain, Secure: ctrl.cookieSecure, HttpOnly: true, - Path: "/", + Path: ctrl.config.Web.AppBase, SameSite: http.SameSiteLaxMode, }) @@ -486,7 +486,7 @@ func (ctrl *V1Controller) unsetCookies(w http.ResponseWriter, domain string) { Domain: domain, Secure: ctrl.cookieSecure, HttpOnly: false, - Path: "/", + Path: ctrl.config.Web.AppBase, SameSite: http.SameSiteLaxMode, }) @@ -498,7 +498,7 @@ func (ctrl *V1Controller) unsetCookies(w http.ResponseWriter, domain string) { Domain: domain, Secure: ctrl.cookieSecure, HttpOnly: false, - Path: "/", + Path: ctrl.config.Web.AppBase, SameSite: http.SameSiteLaxMode, }) } @@ -569,7 +569,7 @@ func (ctrl *V1Controller) HandleOIDCCallback() errchain.HandlerFunc { recordCtrlSpanError(span, err) span.SetAttributes(attribute.String("oidc.outcome", "callback_failed")) log.Err(err).Msg("OIDC callback failed") - http.Redirect(w, r, "/?oidc_error=oidc_auth_failed", http.StatusFound) + http.Redirect(w, r, ctrl.config.Web.AppBase+"?oidc_error=oidc_auth_failed", http.StatusFound) return nil } @@ -578,7 +578,7 @@ func (ctrl *V1Controller) HandleOIDCCallback() errchain.HandlerFunc { attribute.String("session.expires_at", newToken.ExpiresAt.Format(time.RFC3339)), ) ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, true, newToken.AttachmentToken) - http.Redirect(w, r, "/home", http.StatusFound) + http.Redirect(w, r, ctrl.config.Web.AppBase+"home", http.StatusFound) return nil } } diff --git a/backend/app/api/providers/oidc.go b/backend/app/api/providers/oidc.go index ae64cbbc4..0b99b8987 100644 --- a/backend/app/api/providers/oidc.go +++ b/backend/app/api/providers/oidc.go @@ -24,6 +24,7 @@ type OIDCProvider struct { service *services.UserService config *config.OIDCConf options *config.Options + webConfig *config.WebConfig cookieSecure bool provider *oidc.Provider verifier *oidc.IDTokenVerifier @@ -39,7 +40,7 @@ type OIDCClaims struct { EmailVerified *bool } -func NewOIDCProvider(service *services.UserService, config *config.OIDCConf, options *config.Options, cookieSecure bool) (*OIDCProvider, error) { +func NewOIDCProvider(service *services.UserService, config *config.OIDCConf, options *config.Options, webConfig *config.WebConfig, cookieSecure bool) (*OIDCProvider, error) { if !config.Enabled { return nil, fmt.Errorf("OIDC is not enabled") } @@ -106,6 +107,7 @@ func NewOIDCProvider(service *services.UserService, config *config.OIDCConf, opt service: service, config: config, options: options, + webConfig: webConfig, cookieSecure: cookieSecure, provider: provider, verifier: verifier, @@ -361,8 +363,10 @@ func (p *OIDCProvider) GetAuthURL(baseURL, state, nonce, pkceVerifier string) st } func (p *OIDCProvider) getOAuth2Config(baseURL string) oauth2.Config { - // Construct full redirect URL with dedicated callback endpoint - redirectURL, err := url.JoinPath(baseURL, "/api/v1/users/login/oidc/callback") + // Construct full redirect URL with dedicated callback endpoint. + // Note: baseURL from getBaseURL() is scheme://host only (no path), + // so AppBase must be added separately to form the correct callback path. + redirectURL, err := url.JoinPath(baseURL, p.webConfig.AppBase, "api/v1/users/login/oidc/callback") if err != nil { log.Err(err).Msg("failed to construct redirect URL") return oauth2.Config{} @@ -416,7 +420,7 @@ func (p *OIDCProvider) initiateOIDCFlow(w http.ResponseWriter, r *http.Request) Domain: domain, Secure: p.isSecure(r), HttpOnly: true, - Path: "/", + Path: p.webConfig.AppBase, SameSite: http.SameSiteLaxMode, }) @@ -428,7 +432,7 @@ func (p *OIDCProvider) initiateOIDCFlow(w http.ResponseWriter, r *http.Request) Domain: domain, Secure: p.isSecure(r), HttpOnly: true, - Path: "/", + Path: p.webConfig.AppBase, SameSite: http.SameSiteLaxMode, }) @@ -440,7 +444,7 @@ func (p *OIDCProvider) initiateOIDCFlow(w http.ResponseWriter, r *http.Request) Domain: domain, Secure: p.isSecure(r), HttpOnly: true, - Path: "/", + Path: p.webConfig.AppBase, SameSite: http.SameSiteLaxMode, }) @@ -470,7 +474,7 @@ func (p *OIDCProvider) handleCallback(w http.ResponseWriter, r *http.Request) (s MaxAge: -1, Secure: p.isSecure(r), HttpOnly: true, - Path: "/", + Path: p.webConfig.AppBase, SameSite: http.SameSiteLaxMode, }) http.SetCookie(w, &http.Cookie{ @@ -481,7 +485,7 @@ func (p *OIDCProvider) handleCallback(w http.ResponseWriter, r *http.Request) (s MaxAge: -1, Secure: p.isSecure(r), HttpOnly: true, - Path: "/", + Path: p.webConfig.AppBase, SameSite: http.SameSiteLaxMode, }) http.SetCookie(w, &http.Cookie{ @@ -492,7 +496,7 @@ func (p *OIDCProvider) handleCallback(w http.ResponseWriter, r *http.Request) (s MaxAge: -1, Secure: p.isSecure(r), HttpOnly: true, - Path: "/", + Path: p.webConfig.AppBase, SameSite: http.SameSiteLaxMode, }) } diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index d2d11ed2b..c3933ffb4 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -4,14 +4,17 @@ import ( "embed" "errors" "fmt" + "html" "io" "mime" "net/http" "path" "path/filepath" + "strings" "github.com/go-chi/chi/v5" "github.com/hay-kot/httpkit/errchain" + "github.com/rs/zerolog/log" httpSwagger "github.com/swaggo/http-swagger/v2" // http-swagger middleware "github.com/sysadminsmedia/homebox/backend/app/api/handlers/debughandlers" v1 "github.com/sysadminsmedia/homebox/backend/app/api/handlers/v1" @@ -41,9 +44,11 @@ func (a *app) debugRouter() *http.ServeMux { func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllRepos) { registerMimes() + base := a.conf.Web.AppBase + // Serve doc.json dynamically so the Swagger UI "Base URL" reflects the // actual host of the user's instance rather than a hardcoded value. - r.Get("/swagger/doc.json", func(w http.ResponseWriter, r *http.Request) { + r.Get(base+"swagger/doc.json", func(w http.ResponseWriter, r *http.Request) { host := r.Host if fwdHost := r.Header.Get("X-Forwarded-Host"); fwdHost != "" { host = fwdHost @@ -55,8 +60,8 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR _, _ = w.Write([]byte(doc)) }) - r.Get("/swagger/*", httpSwagger.Handler( - httpSwagger.URL("/swagger/doc.json"), + r.Get(base+"swagger/*", httpSwagger.Handler( + httpSwagger.URL(base+"swagger/doc.json"), )) // ========================================================================= @@ -75,7 +80,7 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR v1.WithURL(fmt.Sprintf("%s:%s", a.conf.Web.Host, a.conf.Web.Port)), ) - r.Route(prefix+"/v1", func(r chi.Router) { + r.Route(strings.TrimRight(base, "/") + prefix + "/v1", func(r chi.Router) { r.Get("/status", chain.ToHandlerFunc(v1Ctrl.HandleBase(func() bool { return true }, v1.Build{ Version: version, Commit: commit, @@ -253,7 +258,7 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR r.NotFound(http.NotFound) }) - r.NotFound(chain.ToHandlerFunc(notFoundHandler())) + r.NotFound(chain.ToHandlerFunc(notFoundHandler(base))) } func registerMimes() { @@ -270,7 +275,7 @@ func registerMimes() { // notFoundHandler perform the main logic around handling the internal SPA embed and ensuring that // the client side routing is handled correctly. -func notFoundHandler() errchain.HandlerFunc { +func notFoundHandler(base string) errchain.HandlerFunc { tryRead := func(fs embed.FS, prefix, requestedPath string, w http.ResponseWriter) error { f, err := fs.Open(path.Join(prefix, requestedPath)) if err != nil { @@ -289,15 +294,64 @@ func notFoundHandler() errchain.HandlerFunc { return err } + // Pre-build the patched index.html with and config script. + // Safety: base is validated by normalizePath (alphanumeric + /.-_ only), + // but we HTML-escape anyway as defense in depth. + var indexHTML []byte + if f, err := public.Open("static/public/index.html"); err == nil { + raw, _ := io.ReadAll(f) + _ = f.Close() + escaped := html.EscapeString(base) + injection := `` + content := strings.Replace(string(raw), "", ""+injection, 1) + if base != "/" && !strings.Contains(content, `baseURL:"`+base+`"`) { + // Patch Nuxt's runtime config so Vue Router knows the app's base path. + // Without this, client-side routing breaks on page reload (router sees + // /homebox/home but expects /home). This relies on the exact string format + // Nuxt produces — if it changes, the warning below fires. + content = strings.Replace(content, `baseURL:"/"`, `baseURL:"`+base+`"`, 1) + if !strings.Contains(content, `baseURL:"`+base+`"`) { + log.Warn().Msg("could not patch baseURL in index.html — Nuxt router may not work under subpath") + } + } + indexHTML = []byte(content) + } else { + log.Error().Err(err).Msg("failed to open embedded index.html — SPA fallback will not work") + } + return func(w http.ResponseWriter, r *http.Request) error { - err := tryRead(public, "static/public", r.URL.Path, w) + // Redirect root to app base when running under a subpath + if base != "/" && (r.URL.Path == "/" || r.URL.Path == strings.TrimSuffix(base, "/")) { + http.Redirect(w, r, base, http.StatusFound) + return nil + } + + // Reject requests outside the base path with 404 + if base != "/" && !strings.HasPrefix(r.URL.Path, base) { + http.NotFound(w, r) + return nil + } + + // Strip the base prefix for file lookups + filePath := r.URL.Path + if base != "/" && strings.HasPrefix(filePath, base) { + filePath = "/" + strings.TrimPrefix(filePath, base) + } + + // Try to serve the actual file + err := tryRead(public, "static/public", filePath, w) if err != nil { - // Fallback to the index.html file. - // should succeed in all cases. - err = tryRead(public, "static/public", "index.html", w) - if err != nil { - return err + // SPA fallback: serve patched index.html + if indexHTML == nil { + http.Error(w, "index.html not available", http.StatusInternalServerError) + return nil } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(indexHTML))) + _, err = w.Write(indexHTML) + return err } return nil } diff --git a/backend/internal/sys/config/conf.go b/backend/internal/sys/config/conf.go index 1195c1786..66b12d791 100644 --- a/backend/internal/sys/config/conf.go +++ b/backend/internal/sys/config/conf.go @@ -7,6 +7,8 @@ import ( "fmt" "net/url" "os" + "regexp" + "strings" "time" "github.com/ardanlabs/conf/v3" @@ -89,6 +91,7 @@ type DebugConf struct { type WebConfig struct { Port string `yaml:"port" conf:"default:7745"` Host string `yaml:"host"` + AppBase string `yaml:"app_base" conf:"default:/"` // MaxUploadSize is the body cap (in MB) applied to ordinary upload // endpoints (attachments, item imports, etc.). Defaults to 10 MB. MaxUploadSize int64 `yaml:"max_file_upload" conf:"default:10"` @@ -208,9 +211,35 @@ func New(buildstr string, description string) (*Config, error) { return &cfg, fmt.Errorf("parsing config: %w", err) } + cfg.Web.AppBase, err = normalizePath(cfg.Web.AppBase) + if err != nil { + return &cfg, err + } + return &cfg, nil } +var validPathRe = regexp.MustCompile(`^[a-zA-Z0-9/\-_.]+$`) + +// normalizePath ensures the path always has a leading and trailing slash, +// so it can safely be used as a URL path prefix. +// Only allows alphanumeric characters, hyphens, underscores, dots, and slashes. +func normalizePath(p string) (string, error) { + if p == "" { + return "/", nil + } + if !validPathRe.MatchString(p) { + return "", fmt.Errorf("HBOX_WEB_APP_BASE contains invalid characters (%q); only a-z, 0-9, /, -, _, . are allowed", p) + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + if !strings.HasSuffix(p, "/") { + p = p + "/" + } + return p, nil +} + // Print prints the configuration to stdout as an indented JSON document. // Sensitive fields (secrets, tokens, passwords, embedded URL credentials) are // redacted via each sub-struct's MarshalJSON. Useful for debugging operator diff --git a/backend/internal/sys/config/conf_normalize_test.go b/backend/internal/sys/config/conf_normalize_test.go new file mode 100644 index 000000000..ac444735f --- /dev/null +++ b/backend/internal/sys/config/conf_normalize_test.go @@ -0,0 +1,47 @@ +package config + +import "testing" + +func Test_normalizePath(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"", "/"}, + {"/", "/"}, + {"homebox", "/homebox/"}, + {"/homebox", "/homebox/"}, + {"homebox/", "/homebox/"}, + {"/homebox/", "/homebox/"}, + {"/app/inventory", "/app/inventory/"}, + {"app/inventory/", "/app/inventory/"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := normalizePath(tt.input) + if err != nil { + t.Fatalf("normalizePath(%q) returned unexpected error: %v", tt.input, err) + } + if got != tt.want { + t.Errorf("normalizePath(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func Test_normalizePath_rejectsInvalidChars(t *testing.T) { + invalids := []string{ + "