diff --git a/.dockerignore b/.dockerignore index 804ab223c..8dac96d50 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,7 @@ **/.classpath **/.dockerignore **/.env +!frontend/.env **/.git **/.gitignore **/.project diff --git a/backend/app/api/handlers/v1/controller.go b/backend/app/api/handlers/v1/controller.go index b4e78e810..d24e1b228 100644 --- a/backend/app/api/handlers/v1/controller.go +++ b/backend/app/api/handlers/v1/controller.go @@ -133,7 +133,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 cc9078eac..eabc5119a 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_auth.go +++ b/backend/app/api/handlers/v1/v1_ctrl_auth.go @@ -337,13 +337,13 @@ func (ctrl *V1Controller) HandleOIDCCallback() errchain.HandlerFunc { newToken, err := ctrl.oidcProvider.HandleCallback(w, r) if err != nil { 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 } // Set cookies and redirect to home 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..4ed336884 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, @@ -416,7 +418,7 @@ func (p *OIDCProvider) initiateOIDCFlow(w http.ResponseWriter, r *http.Request) Domain: domain, Secure: p.isSecure(r), HttpOnly: true, - Path: "/", + Path: u.Path, SameSite: http.SameSiteLaxMode, }) @@ -428,7 +430,7 @@ func (p *OIDCProvider) initiateOIDCFlow(w http.ResponseWriter, r *http.Request) Domain: domain, Secure: p.isSecure(r), HttpOnly: true, - Path: "/", + Path: u.Path, SameSite: http.SameSiteLaxMode, }) @@ -440,7 +442,7 @@ func (p *OIDCProvider) initiateOIDCFlow(w http.ResponseWriter, r *http.Request) Domain: domain, Secure: p.isSecure(r), HttpOnly: true, - Path: "/", + Path: u.Path, SameSite: http.SameSiteLaxMode, }) @@ -470,7 +472,7 @@ func (p *OIDCProvider) handleCallback(w http.ResponseWriter, r *http.Request) (s MaxAge: -1, Secure: p.isSecure(r), HttpOnly: true, - Path: "/", + Path: u.Path, SameSite: http.SameSiteLaxMode, }) http.SetCookie(w, &http.Cookie{ @@ -481,7 +483,7 @@ func (p *OIDCProvider) handleCallback(w http.ResponseWriter, r *http.Request) (s MaxAge: -1, Secure: p.isSecure(r), HttpOnly: true, - Path: "/", + Path: u.Path, SameSite: http.SameSiteLaxMode, }) http.SetCookie(w, &http.Cookie{ @@ -492,7 +494,7 @@ func (p *OIDCProvider) handleCallback(w http.ResponseWriter, r *http.Request) (s MaxAge: -1, Secure: p.isSecure(r), HttpOnly: true, - Path: "/", + Path: u.Path, SameSite: http.SameSiteLaxMode, }) } @@ -600,7 +602,7 @@ func (p *OIDCProvider) getBaseURL(r *http.Request) string { } } - return scheme + "://" + host + return scheme + "://" + host + p.webConfig.AppBase } func (p *OIDCProvider) isSecure(r *http.Request) bool { diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index 355f30fda..984054d2c 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -59,7 +59,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(path.Join(a.conf.Web.AppBase, prefix, "/v1"), func(r chi.Router) { r.Get("/status", chain.ToHandlerFunc(v1Ctrl.HandleBase(func() bool { return true }, v1.Build{ Version: version, Commit: commit, diff --git a/backend/internal/sys/config/conf.go b/backend/internal/sys/config/conf.go index 4750c8d18..82fcac6f2 100644 --- a/backend/internal/sys/config/conf.go +++ b/backend/internal/sys/config/conf.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "os" + "strings" "time" "github.com/ardanlabs/conf/v3" @@ -61,6 +62,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 int64 `yaml:"max_file_upload" conf:"default:10"` ReadTimeout time.Duration `yaml:"read_timeout" conf:"default:10s"` WriteTimeout time.Duration `yaml:"write_timeout" conf:"default:10s"` @@ -137,9 +139,27 @@ func New(buildstr string, description string) (*Config, error) { return &cfg, fmt.Errorf("parsing config: %w", err) } + cfg.Web.AppBase = normalizePath(cfg.Web.AppBase) + return &cfg, nil } +// normalizePath ensures a path is never empty and always has a leading and +// trailing slash. This prevents malformed URL concatenation when AppBase is +// used as a path prefix. +func normalizePath(p string) string { + if p == "" { + return "/" + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + if !strings.HasSuffix(p, "/") { + p = p + "/" + } + return p +} + // Print prints the configuration to stdout as a json indented string // This is useful for debugging. If the marshaller errors out, it will panic. func (c *Config) Print() { diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 000000000..a76dc62c4 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +NUXT_APP_BASE_URL=/ diff --git a/frontend/components/Collection/JoinModal.vue b/frontend/components/Collection/JoinModal.vue index 43dbb6c79..6f5b5c588 100644 --- a/frontend/components/Collection/JoinModal.vue +++ b/frontend/components/Collection/JoinModal.vue @@ -15,7 +15,7 @@
AYQ4W4K5MT4CZOPB2PZRCZ4PTY - {{ `${domain}?token=AYQ4W4K5MT4CZOPB2PZRCZ4PTY` }} + {{ `${baseUrl}?token=AYQ4W4K5MT4CZOPB2PZRCZ4PTY` }}
@@ -54,7 +54,7 @@ const api = useUserApi(); const collections = useCollections(); - const domain = window.location.protocol + "//" + window.location.host; + const baseUrl = new URL(useAppBase(), `${window.location.protocol}//${window.location.host}`).toString(); const redirectTo = ref(""); // Holds invite code from dialog params until the watcher applies it on open diff --git a/frontend/composables/use-app-base.ts b/frontend/composables/use-app-base.ts new file mode 100644 index 000000000..e8a132c96 --- /dev/null +++ b/frontend/composables/use-app-base.ts @@ -0,0 +1,8 @@ +/** + * Use the base URL of the app. + * @returns `config.app.baseURL` of Nuxt config + */ +export function useAppBase(): string { + const config = useRuntimeConfig(); + return config.app.baseURL; +} diff --git a/frontend/composables/use-server-events.ts b/frontend/composables/use-server-events.ts index be4964386..57d8f6b8c 100644 --- a/frontend/composables/use-server-events.ts +++ b/frontend/composables/use-server-events.ts @@ -1,3 +1,4 @@ +import { useAppBase } from "./use-app-base"; import { useViewPreferences } from "./use-preferences"; import { watch } from "vue"; @@ -27,8 +28,9 @@ function connect(onmessage: (m: EventMessage) => void) { const dev = import.meta.dev; const host = dev ? window.location.host.replace("3000", "7745") : window.location.host; + const base = useAppBase(); - let url = `${protocol}://${host}/api/v1/ws/events`; + let url = `${protocol}://${host}${base}api/v1/ws/events`; if (currentTenantId) { url += `?tenant=${currentTenantId}`; } diff --git a/frontend/lib/api/base/urls.ts b/frontend/lib/api/base/urls.ts index 47a1c5b27..e616c34f2 100644 --- a/frontend/lib/api/base/urls.ts +++ b/frontend/lib/api/base/urls.ts @@ -1,9 +1,11 @@ const parts = { + base: "/", host: "http://localhost.com", prefix: "/api/v1", }; -export function overrideParts(host: string, prefix: string) { +export function overrideParts(host: string, prefix: string, base: string = "/") { + parts.base = base; parts.host = host; parts.prefix = prefix; } @@ -20,7 +22,8 @@ export type QueryValue = string | string[] | number | number[] | boolean | null * relative URLs in production because the API and client bundle are served from the same server/host. */ export function route(rest: string, params: Record = {}): string { - const url = new URL(parts.prefix + rest, parts.host); + const base = parts.base.replace(/\/$/, ""); + const url = new URL(base + parts.prefix + rest, parts.host); for (const [key, value] of Object.entries(params)) { if (Array.isArray(value)) { diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 2cfa9425c..0b9e86346 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -1,5 +1,18 @@ import { defineNuxtConfig } from "nuxt/config"; +/** + * Ensure base path has leading and trailing slash + */ +const normalizeBase = (value = "/") => { + const trimmed = value.trim(); + if (!trimmed || trimmed === "/") { + return "/"; + } + return `/${trimmed.replace(/^\/+|\/+$/g, "")}/`; +}; + +const baseURL = normalizeBase(process.env.NUXT_APP_BASE_URL); + // https://v3.nuxtjs.org/api/configuration/nuxt.config export default defineNuxtConfig({ ssr: false, @@ -41,8 +54,8 @@ export default defineNuxtConfig({ nitro: { devProxy: { - "/api": { - target: "http://localhost:7745/api", + [baseURL + "api"]: { + target: new URL(baseURL + "api", "http://localhost:7745"), ws: true, changeOrigin: true, }, @@ -50,8 +63,9 @@ export default defineNuxtConfig({ }, app: { + baseURL, head: { - script: [{ src: "/set-theme.js" }], + script: [{ src: baseURL + "set-theme.js" }], }, }, @@ -59,11 +73,11 @@ export default defineNuxtConfig({ pwa: { workbox: { - navigateFallbackDenylist: [/^\/api/], + navigateFallbackDenylist: [RegExp(`^${baseURL}api`)], cleanupOutdatedCaches: true, runtimeCaching: [ { - urlPattern: /^\/api/, + urlPattern: RegExp(`^${baseURL}api`), handler: "NetworkFirst", method: "GET", options: { @@ -88,7 +102,7 @@ export default defineNuxtConfig({ short_name: "Homebox", description: "Home Inventory App", theme_color: "#5b7f67", - start_url: "/home", + start_url: baseURL + "home", icons: [ { src: "pwa-192x192.png", diff --git a/frontend/pages/collection/index/invites.vue b/frontend/pages/collection/index/invites.vue index 76c3967b6..fa6793702 100644 --- a/frontend/pages/collection/index/invites.vue +++ b/frontend/pages/collection/index/invites.vue @@ -27,7 +27,7 @@ const { openDialog } = useDialog(); const confirm = useConfirm(); const localInvites = ref([]); - const baseUrl = `${window.location.protocol}//${window.location.host}`; + const baseUrl = new URL(useAppBase(), `${window.location.protocol}//${window.location.host}`).toString(); const loading = ref(true); const invites = ref([]); const error = ref(null); diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue index 2044badfb..edf346c90 100644 --- a/frontend/pages/index.vue +++ b/frontend/pages/index.vue @@ -19,6 +19,7 @@ import FormPassword from "~/components/Form/Password.vue"; import FormCheckbox from "~/components/Form/Checkbox.vue"; import PasswordScore from "~/components/global/PasswordScore.vue"; + import { useAppBase } from "~/composables/use-app-base"; const { t } = useI18n(); @@ -213,7 +214,8 @@ } function loginWithOIDC() { - window.location.href = "/api/v1/users/login/oidc"; + const base = useAppBase(); + window.location.href = base + "api/v1/users/login/oidc"; } const [registerForm, toggleLogin] = useToggle(); diff --git a/frontend/plugins/api-base.ts b/frontend/plugins/api-base.ts new file mode 100644 index 000000000..836843e1c --- /dev/null +++ b/frontend/plugins/api-base.ts @@ -0,0 +1,11 @@ +/** + * Injects the Nuxt runtime base URL into the API URL builder so that + * `lib/api/` remains a pure TS layer with no composable dependencies. + */ + +import { overrideParts } from "~~/lib/api/base/urls"; + +export default defineNuxtPlugin(() => { + const config = useRuntimeConfig(); + overrideParts("http://localhost.com", "/api/v1", config.app.baseURL); +});