Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]
Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/handlers/v1/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 10 additions & 10 deletions backend/app/api/handlers/v1/v1_ctrl_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})

Expand All @@ -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,
})

Expand All @@ -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,
})

Expand All @@ -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,
})
}
Expand All @@ -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,
})

Expand All @@ -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,
})

Expand All @@ -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,
})

Expand All @@ -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,
})
}
Expand Down Expand Up @@ -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
}

Expand All @@ -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
}
}
22 changes: 13 additions & 9 deletions backend/app/api/providers/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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,
})

Expand All @@ -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,
})

Expand All @@ -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,
})

Expand Down Expand Up @@ -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{
Expand All @@ -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{
Expand All @@ -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,
})
}
Expand Down
78 changes: 66 additions & 12 deletions backend/app/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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"),
))

// =========================================================================
Expand All @@ -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,
Expand Down Expand Up @@ -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() {
Expand All @@ -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 {
Expand All @@ -289,15 +294,64 @@ func notFoundHandler() errchain.HandlerFunc {
return err
}

// Pre-build the patched index.html with <base href> 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 := `<base href="` + escaped + `"><script>window.__HOMEBOX_BASE__="` + escaped + `";</script>`
content := strings.Replace(string(raw), "<head>", "<head>"+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
}
Expand Down
33 changes: 31 additions & 2 deletions backend/internal/sys/config/conf.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"fmt"
"net/url"
"os"
"regexp"
"strings"
"time"

"github.com/ardanlabs/conf/v3"
Expand Down Expand Up @@ -87,8 +89,9 @@ type DebugConf struct {
}

type WebConfig struct {
Port string `yaml:"port" conf:"default:7745"`
Host string `yaml:"host"`
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"`
Expand Down Expand Up @@ -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 += "/"
}
return p, nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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
Expand Down
Loading
Loading