feat: runtime-configurable sub-path serving#1600
Conversation
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 <base href> 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 sysadminsmedia#1430 (which requires a rebuild per path).
WalkthroughAdds a normalized ChangesApp base path support
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Browser
participant BackendRoutes as backend/app/api/routes.go
participant OIDCProvider as backend/app/api/providers/oidc.go
participant NuxtApp as frontend app
Browser->>BackendRoutes: request under AppBase
BackendRoutes-->>Browser: patched index.html / mounted base-aware routes
NuxtApp->>NuxtApp: read window.__HOMEBOX_BASE__
NuxtApp->>BackendRoutes: login, websocket, and telemetry requests under AppBase
BackendRoutes->>OIDCProvider: OIDC flow with base-scoped cookies and redirects
OIDCProvider-->>Browser: callback redirect to AppBase + home
Possibly related PRs
Suggested labels: Suggested reviewers: Security recommendations
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches✨ Simplify code
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (7)
frontend/composables/use-app-base.ts (1)
12-15: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider typing the global instead of casting to
any.For maintainability, declare a proper
Windowinterface augmentation (e.g.interface Window { __HOMEBOX_BASE__?: string }) rather than casting toany. Also consider guarding against a malformed value here (e.g. one that doesn't start with/), since it flows unchecked into URL construction downstream (seeurls.ts) — a defense-in-depth measure worth having for security hygiene, even though the backend is expected to normalize this value.♻️ Optional typing improvement
+declare global { + interface Window { + __HOMEBOX_BASE__?: string; + } +} + export function useAppBase(): string { if (typeof window === "undefined") return "/"; - return (window as any).__HOMEBOX_BASE__ || "/"; + return window.__HOMEBOX_BASE__ || "/"; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/composables/use-app-base.ts` around lines 12 - 15, The `useAppBase` helper is casting `window` to `any` to read `__HOMEBOX_BASE__`; replace that with a proper `Window` interface augmentation so the global is typed safely. While updating `useAppBase`, add a lightweight guard that returns the fallback when `__HOMEBOX_BASE__` is missing or doesn’t start with `/`, since the value is used downstream by `urls.ts` for URL construction.frontend/nuxt.config.ts (1)
65-80: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueMinor: workbox version regex is limited to single-digit API versions.
/\/api\/v\d/matches onlyv0-v9. Fine for the currentv1API, but if the API ever reaches double-digit versioning this pattern would silently stop matching new routes for caching/fallback purposes.♻️ More future-proof pattern
- navigateFallbackDenylist: [/\/api\/v\d/], + navigateFallbackDenylist: [/\/api\/v\d+/], cleanupOutdatedCaches: true, runtimeCaching: [ { - urlPattern: /\/api\/v\d/, + urlPattern: /\/api\/v\d+/,🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/nuxt.config.ts` around lines 65 - 80, The Workbox API route regex is too narrow and only matches single-digit versions, so update the patterns in the pwa.workbox config to support multi-digit API versions. Replace the current regex used in both navigateFallbackDenylist and runtimeCaching.urlPattern with a version that matches one or more digits, and keep the change localized to the workbox configuration in nuxt.config.ts.frontend/composables/use-server-events.ts (1)
74-78: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winSame inconsistency: raw base concatenation instead of the shared URL helper.
This mirrors the pattern in
pages/index.vue— building${base}api/v1/ws/eventsmanually rather than through theroute()helper already used inotel/index.ts. Consolidating on one helper reduces the risk of an inconsistent slash contract breaking WebSocket connectivity under a configured sub-path.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/composables/use-server-events.ts` around lines 74 - 78, The WebSocket URL in useServerEvents is still built by manually concatenating the base path with the events endpoint, which can drift from the shared URL contract. Update the URL construction in useServerEvents to use the same route() helper pattern already used elsewhere (for example in otel/index.ts) instead of assembling "${base}api/v1/ws/events" directly, so sub-path handling stays consistent under all configurations.frontend/components/Collection/JoinModal.vue (1)
57-57: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicate of the base-URL construction in
invites.vue.Same expression as
pages/collection/index/invites.vueline 30 — consider consolidating into a shared composable to avoid maintaining the same base/origin logic in two places.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/components/Collection/JoinModal.vue` at line 57, The base-URL construction in JoinModal.vue is duplicated with invites.vue, so consolidate this origin-building logic into a shared composable or helper and have JoinModal use it instead of recreating the URL inline. Update the JoinModal setup code around the domain calculation to call the shared utility, and keep the same behavior for deriving the app origin from useAppBase() and window.location.frontend/pages/collection/index/invites.vue (1)
30-30: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicate base-URL construction shared with
JoinModal.vue.The exact same
new URL(useAppBase(), \${window.location.protocol}//${window.location.host}`).toString()expression is also used incomponents/Collection/JoinModal.vue. Extracting this intouseAppBase()(e.g., a companionuseAppOrigin()/useAppBaseUrl()) or the sharedlib/api/base/urls.ts` helper would avoid the duplication and keep the base-URL contract in one place.♻️ Suggested extraction
- const baseUrl = new URL(useAppBase(), `${window.location.protocol}//${window.location.host}`).toString(); + const baseUrl = useAppOrigin(); // new shared composable wrapping the URL(...) logic🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/pages/collection/index/invites.vue` at line 30, The base-URL construction in this page duplicates the same logic used in JoinModal.vue, so extract the shared URL-building behavior into a single helper such as useAppOrigin/useAppBaseUrl or the existing lib/api/base/urls.ts utilities. Update the local baseUrl computation in invetes.vue to call that shared helper instead of rebuilding the URL inline, and then switch JoinModal.vue to the same shared source so the contract stays in one place.frontend/pages/index.vue (1)
216-219: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winPrefer the shared
route()helper over manual concatenation; verify base-value trust boundary.
otel/index.tsbuilds backend URLs viaroute("/telemetry")from the shared~~/lib/api/base/urlshelper, but this redirect manually concatenatesuseAppBase() + "api/v1/users/login/oidc". Two different idioms for the same "base + path" operation increase the risk that one call site breaks if the base's leading/trailing slash contract ever changes (e.g., empty string vs"/"vs"/sub/").Since this drives a full-page
window.location.hrefnavigation, please also confirmuseAppBase()is sourced only from a trusted, server-injected value (e.g.,window.__HOMEBOX_BASE__set at boot) and never reflects user-controllable input — otherwise this pattern could become an open-redirect vector if the base is ever influenced by query params or similar.♻️ Suggested consistency fix
+ import { route } from "~~/lib/api/base/urls"; + function loginWithOIDC() { - const base = useAppBase(); - window.location.href = base + "api/v1/users/login/oidc"; + window.location.href = route("/api/v1/users/login/oidc"); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/pages/index.vue` around lines 216 - 219, The OIDC login redirect in `loginWithOIDC()` should use the shared `route()` helper instead of manually concatenating `useAppBase()` with the path, so it matches the existing URL-building pattern used by `otel/index.ts` and stays safe if base slash handling changes. Update the redirect to build the login URL through the shared API/base URL helper, and verify `useAppBase()` only reads from a trusted boot-time source such as `window.__HOMEBOX_BASE__`, not any user-controlled input, since `window.location.href` performs a full navigation.backend/app/api/routes.go (1)
300-320: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winAdd a warning when the
<head>injection no-ops.The
baseURLpatch below logs a warning if the exact Nuxt string isn't found, but the<base href>/config-script injection viastrings.Replace(..., "<head>", ...)fails silently if the embeddedindex.htmldoesn't contain a literal lowercase<head>(e.g. minified output or<head lang="en">). Under a subpath that produces a broken SPA with no diagnostic. A quick guard mirrors the existing baseURL warning.🪵 Proposed warning on missed injection
- content := strings.Replace(string(raw), "<head>", "<head>"+injection, 1) + content := strings.Replace(string(raw), "<head>", "<head>"+injection, 1) + if !strings.Contains(content, injection) { + log.Warn().Msg("could not inject <base href> into index.html — SPA may break under subpath") + }On the security side: nice work here — validating
basevianormalizePathand HTML-escaping it before injecting into both the attribute and the inline<script>is solid defense in depth against injection.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/app/api/routes.go` around lines 300 - 320, The `<head>` injection in the SPA fallback can fail silently because `strings.Replace(..., "<head>", ...)` assumes an exact lowercase tag, so add a warning in the `indexHTML` assembly path when the injection no-ops and `content` is unchanged. Use the existing `baseURL` patch block and `log.Warn()` pattern in `routes.go` to detect when the `public.Open("static/public/index.html")` content does not contain the expected `<head>` marker, and emit a clear warning that the base href/script injection was skipped so subpath routing may break.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/internal/sys/config/conf.go`:
- Around line 222-241: Harden normalizePath in conf.go by rejecting any path
that contains traversal segments like ".." before adding leading/trailing
slashes. Update the normalizePath function to keep the existing character
validation but add an explicit check for ".." (and any equivalent dot-dot path
segment) so inputs such as /app/../admin are rejected with a clear error,
preserving predictable base-path handling.
---
Nitpick comments:
In `@backend/app/api/routes.go`:
- Around line 300-320: The `<head>` injection in the SPA fallback can fail
silently because `strings.Replace(..., "<head>", ...)` assumes an exact
lowercase tag, so add a warning in the `indexHTML` assembly path when the
injection no-ops and `content` is unchanged. Use the existing `baseURL` patch
block and `log.Warn()` pattern in `routes.go` to detect when the
`public.Open("static/public/index.html")` content does not contain the expected
`<head>` marker, and emit a clear warning that the base href/script injection
was skipped so subpath routing may break.
In `@frontend/components/Collection/JoinModal.vue`:
- Line 57: The base-URL construction in JoinModal.vue is duplicated with
invites.vue, so consolidate this origin-building logic into a shared composable
or helper and have JoinModal use it instead of recreating the URL inline. Update
the JoinModal setup code around the domain calculation to call the shared
utility, and keep the same behavior for deriving the app origin from
useAppBase() and window.location.
In `@frontend/composables/use-app-base.ts`:
- Around line 12-15: The `useAppBase` helper is casting `window` to `any` to
read `__HOMEBOX_BASE__`; replace that with a proper `Window` interface
augmentation so the global is typed safely. While updating `useAppBase`, add a
lightweight guard that returns the fallback when `__HOMEBOX_BASE__` is missing
or doesn’t start with `/`, since the value is used downstream by `urls.ts` for
URL construction.
In `@frontend/composables/use-server-events.ts`:
- Around line 74-78: The WebSocket URL in useServerEvents is still built by
manually concatenating the base path with the events endpoint, which can drift
from the shared URL contract. Update the URL construction in useServerEvents to
use the same route() helper pattern already used elsewhere (for example in
otel/index.ts) instead of assembling "${base}api/v1/ws/events" directly, so
sub-path handling stays consistent under all configurations.
In `@frontend/nuxt.config.ts`:
- Around line 65-80: The Workbox API route regex is too narrow and only matches
single-digit versions, so update the patterns in the pwa.workbox config to
support multi-digit API versions. Replace the current regex used in both
navigateFallbackDenylist and runtimeCaching.urlPattern with a version that
matches one or more digits, and keep the change localized to the workbox
configuration in nuxt.config.ts.
In `@frontend/pages/collection/index/invites.vue`:
- Line 30: The base-URL construction in this page duplicates the same logic used
in JoinModal.vue, so extract the shared URL-building behavior into a single
helper such as useAppOrigin/useAppBaseUrl or the existing lib/api/base/urls.ts
utilities. Update the local baseUrl computation in invetes.vue to call that
shared helper instead of rebuilding the URL inline, and then switch
JoinModal.vue to the same shared source so the contract stays in one place.
In `@frontend/pages/index.vue`:
- Around line 216-219: The OIDC login redirect in `loginWithOIDC()` should use
the shared `route()` helper instead of manually concatenating `useAppBase()`
with the path, so it matches the existing URL-building pattern used by
`otel/index.ts` and stays safe if base slash handling changes. Update the
redirect to build the login URL through the shared API/base URL helper, and
verify `useAppBase()` only reads from a trusted boot-time source such as
`window.__HOMEBOX_BASE__`, not any user-controlled input, since
`window.location.href` performs a full navigation.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 83b82c7c-4373-470e-8449-fc8ead2a7e09
📒 Files selected for processing (19)
Dockerfilebackend/app/api/handlers/v1/controller.gobackend/app/api/handlers/v1/v1_ctrl_auth.gobackend/app/api/providers/oidc.gobackend/app/api/routes.gobackend/internal/sys/config/conf.gobackend/internal/sys/config/conf_normalize_test.godocker-compose.ymlfrontend/app.vuefrontend/components/Collection/JoinModal.vuefrontend/composables/use-app-base.tsfrontend/composables/use-server-events.tsfrontend/lib/api/base/index.test.tsfrontend/lib/api/base/urls.tsfrontend/lib/otel/index.tsfrontend/nuxt.config.tsfrontend/pages/collection/index/invites.vuefrontend/pages/index.vuefrontend/plugins/api-base.ts
|
The typecheck failures (EntitySummary types, i18n plugin, etc.) also happen on current main without my changes – I verified locally. None of the failing files are touched by this PR. |
What type of PR is this?
What this PR does / why we need it:
Adds sub-path support. You can now serve HomeBox under a path like
/homebox/by setting one env var at runtime. No rebuild needed.podman run -e HBOX_WEB_APP_BASE=/homebox homebox podman run -e HBOX_WEB_APP_BASE=/apps/inventory homebox podman run homebox # default: serves at /Without setting the var, everything works as before (served at
/).This is similar to what #1430 does, but #1430 requires rebuilding the image for each path. Here it's runtime only.
How it works
The frontend uses
cdnURL: "./"so assets get relative paths. On startup the backend readsHBOX_WEB_APP_BASEand patches theindex.htmlin memory — injects<base href>so the browser resolves assets correctly, setswindow.__HOMEBOX_BASE__for the JS side, and patchesbaseURLin the Nuxt config so Vue Router works with the sub-path.Routes (API, Swagger, static files) are all mounted under the base. Paths outside the base get 404.
What changed
Backend:
conf.go— newAppBasefield, validated with regex, normalized to/path/formroutes.go— mounting under base, index.html patching at startup, root redirect, 404 for wrong pathsoidc.go,v1_ctrl_auth.go— cookie paths and redirects use AppBasecontroller.go— passes web config to OIDC providerFrontend:
nuxt.config.ts—cdnURL: "./", PWA denylist fixuseAppBase()composable + plugin that configures the URL builderuseAppBase()app.vue— links to favicon/icons/manifest made relativeroute()nowTests:
conf_normalize_test.go— normalization + invalid inputindex.test.ts— URL builder with custom baseInfra:
Which issue(s) this PR fixes:
Special notes for your reviewer:
The
baseURL:"/"string replace is not great — it depends on exact Nuxt output format. But Vue Router needs this value before hydration and there is no other way to set it at runtime. There is a warning log if the pattern is not found.Input is validated strictly (only
[a-zA-Z0-9/\-_.]allowed) and html-escaped before injection.Cookies use AppBase as path so multiple instances on same domain don't conflict.
Reverse proxy should NOT strip the prefix. Backend expects the full path.
Testing
go vetpasses, unit tests pass/,/homebox,/app/inventoryall from same image