From cc156c06d8a14a0f9557478f337527bd532826ea Mon Sep 17 00:00:00 2001 From: Vlatko Kosturjak Date: Sat, 25 Jul 2026 21:21:19 +0200 Subject: [PATCH 1/2] Add full support for deploying OpenCloud under a subpath Config fields for exactly this (WEB_HTTP_ROOT, GRAPH_HTTP_ROOT, WEBDAV_HTTP_ROOT, IDP_URI_BASE_PATH, FRONTEND_HTTP_PREFIX, PROXY_HTTP_ROOT, OCM_HTTP_PREFIX, ...) already existed on nearly every service, and most services' own routers already gated correctly on them. But the proxy -- the single front door all traffic passes through -- never consulted PROXY_HTTP_ROOT when matching a request against a policy route, so a prefixed request never matched and fell through to the web app's catch-all; web's HTML server hardcoded ; frontend/ocm declared a prefix but reva's rhttp had no way to apply it; and ocm/sse/userlog/activitylog had no root-gated mux at all. Nothing derived any of this from OC_URL, so even where the mechanism worked, a user had to discover and set 10+ separate env vars by hand. - pkg/shared.SubPath: extracts and cleans a URL's path component. Every service's EnsureDefaults derives its own root/prefix field from OC_URL via this helper. - proxy: Router.Route matches policy routes against a root-stripped copy of the request URL; the real, forwarded request is untouched. New Route.StripRoot escape hatch for the handful of backends that hardcode an unprefixed path regardless: lico's RFC 8615 well-known endpoint, and the idp identifier/welcome/goodbye/consent/ chooseaccount pages and static assets (see below). - web: now uses the service's own already-correctly-gated root instead of a hardcoded "/". - frontend/ocm: wire the existing, previously-unused HTTP.Prefix into reva's rhttp config (a single top-level "prefix" key consumed once by rhttp's own dispatch -- not into each sub-handler's own registration prefix, which stays unprefixed; wrapping it twice broke dispatch to every reva-based endpoint entirely, caught by live deployment testing, see below). Needs a companion change to opencloud-eu/reva (rhttp has no concept of a server-wide prefix at all); the vendored copy is hand-patched to match until that lands and a real version bump can be made. - ocm, sse, userlog, activitylog: new Root-gated mux wrapping, mirroring the pattern already used by web/graph. - idp: two Go-side bugs in Index() -- a hardcoded /signin/v1 react-router basename that's supposed to include the deployment root, and a bytes.Replace chaining bug that silently discarded earlier template substitutions -- plus two bugs in the identifier's own React source (services/idp/src): config.json and the theme logo fetched from a hardcoded absolute path, when neither is one of the identifier app's own bundled assets and both need the true deployment root derived independently of the identifier app's own nested URL. - idp: the vendored lico library also registers /welcome, /goodbye, /consent, and /chooseaccount as routes served by its own built-in identifier webapp, whose index.html is only ever read from disk when IdentifierClientDisabled is false -- which it never is here, since OpenCloud always runs its own, correctly-templated identifier app instead. Previously only /signin/v1/identifier was overridden this way; hitting any of the other four directly (a bookmarked URL, or a SAML logout redirect) served a literally empty 200 response. Fixed by registering all five through the same override, with a matching StripRoot regex extension so they still resolve correctly under a subpath. (This is the same fix as the independent fix-idp-static-routes-empty-response branch, which fixes it for root-of-domain deployments too and doesn't depend on any of the rest of this patch; it's folded in here as well only because this branch's StripRoot regex extension needs it to exist. If that PR merges first, rebase this one to drop the now-duplicate commit.) Testing: go build ./... and go test ./services/... ./pkg/... clean across the whole repo, aside from one pre-existing, environment-only failure (services/search/pkg/opensearch needs a live OpenSearch instance, unrelated to this change). New unit tests for the router, web asset server, subpath helper, the idp Index() template substitution regression, and the Root/Prefix derivation regression described below. Verified end to end against a real, unmodified passthrough nginx reverse proxy and a real browser: built opencloud-eu/web from source (not the pinned release) and the idp identifier app, built the full Go binary, and deployed it live under /test/opencloud -- a separate container/port/data volumes from any other deployment. This caught two real bugs no unit test had surfaced: 1. Every reva-based endpoint (archiver, ocdav, ocs, datagateway, appprovider, sciencemesh, ocmd) doubled the deployment prefix, because each sub-handler's own registration prefix was wrapped with the deployment prefix a second time on top of what rhttp's dispatch already strips once -- breaking dispatch to all of them under a subpath. Fixed by reverting those wraps; the single top-level "prefix" key is the only place it needs to be wired in. 2. The OC_URL-derived subpath was silently dropped for 9 of 15 services: every EnsureDefaults derivation checked "cfg.HTTP.Root == \"\"", but DefaultConfig gives most services a non-empty baseline of their own ("/" for proxy/web/webdav/settings/ webfinger/sse/userlog/activitylog, or a fixed namespace like "/graph"/"/ocs"/"/graph/v1.0"/"/thumbnails" for graph/ocs/ invitations/thumbnails), so the check never fired and every request fell through to the web app's catch-all regardless of OC_URL. Fixed by prepending the derived subpath onto whatever Root/Prefix already resolved to, guarded against double-prepending since EnsureDefaults runs more than once per process. After both fixes: config.json, capabilities, .well-known, the identifier login page and its static assets, WebDAV, Graph, Archiver, and Settings all correctly reach their real handler under /test/opencloud instead of the web app's catch-all, and a full interactive login flow (landing page through OIDC callback into the authenticated file browser) works with a clean DevTools console. Known limitation, out of scope for this PR: some of the web frontend's own empty-state illustrations and a couple of other images hardcoded an absolute, domain-root image path rather than a deployment-relative one, so they 404'd under a subpath. That's a separate, already-prepared fix in opencloud-eu/web (not this repo). Migrating from manual per-service env vars: if anyone was already working around this by hand-setting PROXY_HTTP_ROOT/WEB_HTTP_ROOT/ IDP_URI_BASE_PATH/etc. individually, those explicit values are still respected (this only fills in defaults otherwise) -- but they can now be dropped in favor of just OC_URL. --- pkg/shared/subpath.go | 25 +++++ pkg/shared/subpath_test.go | 21 +++++ .../pkg/config/defaults/defaultconfig.go | 8 ++ services/activitylog/pkg/service/service.go | 11 ++- .../pkg/config/defaults/defaultconfig.go | 8 ++ services/frontend/pkg/revaconfig/config.go | 1 + .../pkg/config/defaults/defaultconfig.go | 7 ++ .../pkg/config/defaults/defaultconfig_test.go | 36 ++++++++ .../idp/pkg/config/defaults/defaultconfig.go | 7 ++ services/idp/pkg/service/v0/service.go | 32 +++++-- services/idp/pkg/service/v0/service_test.go | 69 ++++++++++++++ services/idp/src/App.jsx | 21 ++++- .../idp/src/components/ResponsiveScreen.jsx | 3 +- .../pkg/config/defaults/defaultconfig.go | 8 ++ .../ocm/pkg/config/defaults/defaultconfig.go | 8 ++ services/ocm/pkg/revaconfig/config.go | 1 + .../ocs/pkg/config/defaults/defaultconfig.go | 8 ++ services/proxy/pkg/command/server.go | 2 +- services/proxy/pkg/config/config.go | 7 ++ .../pkg/config/defaults/defaultconfig.go | 44 +++++++++ .../pkg/config/defaults/defaultconfig_test.go | 49 ++++++++++ .../proxy/pkg/proxy/proxy_integration_test.go | 2 +- services/proxy/pkg/router/router.go | 46 +++++++++- services/proxy/pkg/router/router_test.go | 92 ++++++++++++++++++- .../pkg/config/defaults/defaultconfig.go | 8 ++ .../sse/pkg/config/defaults/defaultconfig.go | 8 ++ services/sse/pkg/service/service.go | 13 ++- .../pkg/config/defaults/defaultconfig.go | 6 ++ .../pkg/config/defaults/defaultconfig.go | 7 ++ services/userlog/pkg/service/service.go | 19 +++- services/web/pkg/assets/server.go | 12 ++- services/web/pkg/assets/server_test.go | 38 +++++++- .../web/pkg/config/defaults/defaultconfig.go | 8 ++ services/web/pkg/service/v0/service.go | 2 +- .../pkg/config/defaults/defaultconfig.go | 7 ++ .../pkg/config/defaults/defaultconfig.go | 8 ++ .../opencloud-eu/reva/v2/pkg/rhttp/rhttp.go | 76 ++++++++++----- 37 files changed, 671 insertions(+), 57 deletions(-) create mode 100644 pkg/shared/subpath.go create mode 100644 pkg/shared/subpath_test.go create mode 100644 services/graph/pkg/config/defaults/defaultconfig_test.go create mode 100644 services/idp/pkg/service/v0/service_test.go create mode 100644 services/proxy/pkg/config/defaults/defaultconfig_test.go diff --git a/pkg/shared/subpath.go b/pkg/shared/subpath.go new file mode 100644 index 0000000000..e0ea4c3ae0 --- /dev/null +++ b/pkg/shared/subpath.go @@ -0,0 +1,25 @@ +package shared + +import ( + "net/url" + "path" + "strings" +) + +// SubPath returns the path component of rawURL, cleaned and without a +// trailing slash ("" if the URL has no path, or is just "/"). It is used to +// derive the various per-service root/prefix defaults from OC_URL, so that a +// single env var is enough to deploy OpenCloud under a subpath. +func SubPath(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return "" + } + + p := strings.TrimRight(u.Path, "/") + if p == "" { + return "" + } + + return path.Clean(p) +} diff --git a/pkg/shared/subpath_test.go b/pkg/shared/subpath_test.go new file mode 100644 index 0000000000..04743e6220 --- /dev/null +++ b/pkg/shared/subpath_test.go @@ -0,0 +1,21 @@ +package shared + +import "testing" + +func TestSubPath(t *testing.T) { + tests := map[string]string{ + "https://example.com": "", + "https://example.com/": "", + "https://example.com/test/opencloud": "/test/opencloud", + "https://example.com/test/opencloud/": "/test/opencloud", + "https://example.com/test//opencloud//": "/test/opencloud", + "not a url \x7f": "", + "": "", + } + + for rawURL, want := range tests { + if got := SubPath(rawURL); got != want { + t.Errorf("SubPath(%q) = %q, want %q", rawURL, got, want) + } + } +} diff --git a/services/activitylog/pkg/config/defaults/defaultconfig.go b/services/activitylog/pkg/config/defaults/defaultconfig.go index 7b6e8306d7..49f50c633d 100644 --- a/services/activitylog/pkg/config/defaults/defaultconfig.go +++ b/services/activitylog/pkg/config/defaults/defaultconfig.go @@ -1,6 +1,8 @@ package defaults import ( + "path" + "strings" "time" "github.com/opencloud-eu/opencloud/pkg/shared" @@ -76,6 +78,12 @@ func EnsureDefaults(cfg *config.Config) { if cfg.Commons != nil { cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS } + + if cfg.Commons != nil { + if root := shared.SubPath(cfg.Commons.OpenCloudURL); root != "" && cfg.HTTP.Root != root && !strings.HasPrefix(cfg.HTTP.Root, root+"/") { + cfg.HTTP.Root = path.Join(root, cfg.HTTP.Root) + } + } } // Sanitize sanitizes the config diff --git a/services/activitylog/pkg/service/service.go b/services/activitylog/pkg/service/service.go index 9eec13962c..ac38ae1744 100644 --- a/services/activitylog/pkg/service/service.go +++ b/services/activitylog/pkg/service/service.go @@ -230,7 +230,16 @@ func New(opts ...Option) (*ActivitylogService, error) { return nil, err } - s.mux.Get("/graph/v1beta1/extensions/org.libregraph/activities", s.HandleGetItemActivities) + root := o.Config.HTTP.Root + if root == "" { + // chi.Mux.Route panics on a literal empty string; this also covers + // callers (e.g. tests) that construct a config.Config by hand rather + // than through defaults.DefaultConfig(), leaving Root at its zero value. + root = "/" + } + s.mux.Route(root, func(r chi.Router) { + r.Get("/graph/v1beta1/extensions/org.libregraph/activities", s.HandleGetItemActivities) + }) for _, e := range o.RegisteredEvents { typ := reflect.TypeOf(e) diff --git a/services/frontend/pkg/config/defaults/defaultconfig.go b/services/frontend/pkg/config/defaults/defaultconfig.go index ee6825caf6..b615c9799d 100644 --- a/services/frontend/pkg/config/defaults/defaultconfig.go +++ b/services/frontend/pkg/config/defaults/defaultconfig.go @@ -1,6 +1,8 @@ package defaults import ( + "path" + "strings" "time" "github.com/opencloud-eu/opencloud/pkg/shared" @@ -193,6 +195,12 @@ func EnsureDefaults(cfg *config.Config) { cfg.HTTP.CORS.AllowedOrigins[0] == "https://localhost:9200") { cfg.HTTP.CORS.AllowedOrigins = []string{cfg.Commons.OpenCloudURL} } + + if cfg.Commons != nil { + if root := shared.SubPath(cfg.Commons.OpenCloudURL); root != "" && cfg.HTTP.Prefix != root && !strings.HasPrefix(cfg.HTTP.Prefix, root+"/") { + cfg.HTTP.Prefix = path.Join(root, cfg.HTTP.Prefix) + } + } } // Sanitize sanitized the configuration diff --git a/services/frontend/pkg/revaconfig/config.go b/services/frontend/pkg/revaconfig/config.go index 7c86a5b5d2..e04073c095 100644 --- a/services/frontend/pkg/revaconfig/config.go +++ b/services/frontend/pkg/revaconfig/config.go @@ -98,6 +98,7 @@ func FrontendConfigFromStruct(cfg *config.Config, logger log.Logger) (map[string "http": map[string]any{ "network": cfg.HTTP.Protocol, "address": cfg.HTTP.Addr, + "prefix": cfg.HTTP.Prefix, "middlewares": map[string]any{ "cors": map[string]any{ "allowed_origins": cfg.HTTP.CORS.AllowedOrigins, diff --git a/services/graph/pkg/config/defaults/defaultconfig.go b/services/graph/pkg/config/defaults/defaultconfig.go index f27bcce186..8bea53ab7e 100644 --- a/services/graph/pkg/config/defaults/defaultconfig.go +++ b/services/graph/pkg/config/defaults/defaultconfig.go @@ -1,6 +1,7 @@ package defaults import ( + "path" "strings" "time" @@ -192,6 +193,12 @@ func EnsureDefaults(cfg *config.Config) { cfg.Metadata.SystemUserID = cfg.Commons.SystemUserID } + if cfg.Commons != nil { + if root := shared.SubPath(cfg.Commons.OpenCloudURL); root != "" && cfg.HTTP.Root != root && !strings.HasPrefix(cfg.HTTP.Root, root+"/") { + cfg.HTTP.Root = path.Join(root, cfg.HTTP.Root) + } + } + } // Sanitize sanitized the configuration diff --git a/services/graph/pkg/config/defaults/defaultconfig_test.go b/services/graph/pkg/config/defaults/defaultconfig_test.go new file mode 100644 index 0000000000..cac9a08e61 --- /dev/null +++ b/services/graph/pkg/config/defaults/defaultconfig_test.go @@ -0,0 +1,36 @@ +package defaults + +import ( + "testing" + + "github.com/opencloud-eu/opencloud/pkg/shared" +) + +// TestEnsureDefaultsDerivesRootUnderSubpath guards against a regression +// where the OC_URL-derived subpath was never prepended to a service's own, +// non-empty default HTTP.Root (here "/graph") -- see the identical test in +// services/proxy for the "/" case. Without the prepend, graph's own mux +// stayed gated at "/graph" while the proxy forwarded the full, prefixed +// path (e.g. "/test/opencloud/graph/..."), so nothing matched under a +// subpath deployment. +func TestEnsureDefaultsDerivesRootUnderSubpath(t *testing.T) { + cfg := DefaultConfig() + if cfg.HTTP.Root != "/graph" { + t.Fatalf("test assumes DefaultConfig's baseline HTTP.Root is \"/graph\", got %q -- update this test", cfg.HTTP.Root) + } + + cfg.Commons = &shared.Commons{OpenCloudURL: "https://host.example/test/opencloud"} + + EnsureDefaults(cfg) + + if want := "/test/opencloud/graph"; cfg.HTTP.Root != want { + t.Errorf("HTTP.Root = %q, want %q", cfg.HTTP.Root, want) + } + + // EnsureDefaults runs more than once against the same *config.Config in + // practice; it must not prepend the subpath a second time. + EnsureDefaults(cfg) + if want := "/test/opencloud/graph"; cfg.HTTP.Root != want { + t.Errorf("HTTP.Root after a second EnsureDefaults call = %q, want %q (derivation should be idempotent)", cfg.HTTP.Root, want) + } +} diff --git a/services/idp/pkg/config/defaults/defaultconfig.go b/services/idp/pkg/config/defaults/defaultconfig.go index 7ed83f11d2..485b2e0ff6 100644 --- a/services/idp/pkg/config/defaults/defaultconfig.go +++ b/services/idp/pkg/config/defaults/defaultconfig.go @@ -2,6 +2,7 @@ package defaults import ( "net/http" + "path" "path/filepath" "strings" @@ -148,6 +149,12 @@ func EnsureDefaults(cfg *config.Config) { if cfg.MachineAuthAPIKey == "" && cfg.Commons != nil && cfg.Commons.MachineAuthAPIKey != "" { cfg.MachineAuthAPIKey = cfg.Commons.MachineAuthAPIKey } + + if cfg.Commons != nil { + if root := shared.SubPath(cfg.Commons.OpenCloudURL); root != "" && cfg.IDP.URIBasePath != root && !strings.HasPrefix(cfg.IDP.URIBasePath, root+"/") { + cfg.IDP.URIBasePath = path.Join(root, cfg.IDP.URIBasePath) + } + } } // Sanitize sanitizes the configuration diff --git a/services/idp/pkg/service/v0/service.go b/services/idp/pkg/service/v0/service.go index d34a982879..0a597154c7 100644 --- a/services/idp/pkg/service/v0/service.go +++ b/services/idp/pkg/service/v0/service.go @@ -292,10 +292,24 @@ func (idp *IDP) initMux(ctx context.Context, r []server.WithRoutes, h http.Handl ), ) - // handle / | index.html with a template that needs to have the BASE_PREFIX replaced - idp.mux.Get("/signin/v1/identifier", idp.Index()) - idp.mux.Get("/signin/v1/identifier/", idp.Index()) - idp.mux.Get("/signin/v1/identifier/index.html", idp.Index()) + // handle / | index.html with a template that needs to have the BASE_PREFIX replaced. + // + // This covers every route the vendored lico library registers for its own + // built-in identifier webapp (vendor/github.com/libregraph/lico/identifier/identifier.go's + // `r.Handle("/", i)` calls), not just "identifier" -- lico's own + // webappIndexHTML is only ever populated when IdentifierClientDisabled is + // false, which it never is here (that's the whole point of running our own, + // correctly-templated identifier app instead). Without an override, hitting + // any of these directly (e.g. after a SAML logout redirect, or a bookmarked + // URL) reached lico's handler, which serves that permanently-empty template: + // HTTP 200 with a literally empty body. All five are routes the identifier + // app's own client-side router (services/idp/src/Routes.jsx) recognizes, so + // serving our real index.html for them is all that's needed. + for _, path := range []string{"identifier", "chooseaccount", "consent", "welcome", "goodbye"} { + idp.mux.Get("/signin/v1/"+path, idp.Index()) + idp.mux.Get("/signin/v1/"+path+"/", idp.Index()) + idp.mux.Get("/signin/v1/"+path+"/index.html", idp.Index()) + } idp.mux.Mount("/", gm) @@ -325,12 +339,16 @@ func (idp IDP) Index() http.HandlerFunc { idp.logger.Fatal().Err(err).Msg("Could not close body") } - // TODO add environment variable to make the path prefix configurable - pp := "/signin/v1" + // This is also used as react-router's basename in the identifier app + // (see services/idp/src/Main.jsx), which must match the actual browser + // URL for client-side routing to work at all -- so when OpenCloud is + // deployed under a subpath (IDP_URI_BASE_PATH), that prefix has to be + // included here too, not just the identifier's own "/signin/v1". + pp := path.Join(idp.config.IDP.URIBasePath, "/signin/v1") indexHTML := bytes.Replace(template, []byte("__PATH_PREFIX__"), []byte(pp), 1) background := idp.config.Asset.LoginBackgroundUrl - indexHTML = bytes.Replace(template, []byte("__BG_IMG_URL__"), []byte(background), 1) + indexHTML = bytes.Replace(indexHTML, []byte("__BG_IMG_URL__"), []byte(background), 1) nonce := rndm.GenerateRandomString(32) indexHTML = bytes.Replace(indexHTML, []byte("__CSP_NONCE__"), []byte(nonce), 1) diff --git a/services/idp/pkg/service/v0/service_test.go b/services/idp/pkg/service/v0/service_test.go new file mode 100644 index 0000000000..d3ddc9dc0e --- /dev/null +++ b/services/idp/pkg/service/v0/service_test.go @@ -0,0 +1,69 @@ +package svc + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" + + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/services/idp/pkg/config" +) + +// TestIndexTemplateSubstitution guards against a regression where each +// bytes.Replace call in Index() re-read from the original template instead +// of chaining off the previous result, silently discarding earlier +// replacements (only the last one, __PASSWORD_RESET_LINK__, would ever +// survive). It also covers that __PATH_PREFIX__ includes IDP_URI_BASE_PATH +// when OpenCloud is deployed under a subpath. +func TestIndexTemplateSubstitution(t *testing.T) { + const tmpl = `
` + + `` + + fsys := fstest.MapFS{ + "identifier/index.html": &fstest.MapFile{Data: []byte(tmpl)}, + } + + idp := IDP{ + logger: log.NewLogger(), + assets: http.FS(fsys), + config: &config.Config{ + IDP: config.Settings{URIBasePath: "/test/opencloud"}, + Asset: config.Asset{ + LoginBackgroundUrl: "https://example.com/bg.png", + }, + Service: config.Service{ + PasswordResetURI: "https://example.com/reset", + }, + }, + } + + w := httptest.NewRecorder() + idp.Index().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil)) + + body, err := io.ReadAll(w.Result().Body) + if err != nil { + t.Fatalf("reading response body: %v", err) + } + got := string(body) + + for placeholder, want := range map[string]string{ + "__PATH_PREFIX__": "/test/opencloud/signin/v1", + "__BG_IMG_URL__": "https://example.com/bg.png", + "__PASSWORD_RESET_LINK__": "https://example.com/reset", + } { + if !strings.Contains(got, want) { + t.Errorf("expected substituted value %q (for %s) in response, got: %s", want, placeholder, got) + } + if strings.Contains(got, placeholder) { + t.Errorf("placeholder %s was left unsubstituted in response: %s", placeholder, got) + } + } + if strings.Contains(got, "__CSP_NONCE__") { + t.Errorf("placeholder __CSP_NONCE__ was left unsubstituted in response: %s", got) + } +} diff --git a/services/idp/src/App.jsx b/services/idp/src/App.jsx index 315a5876eb..781afefbf8 100644 --- a/services/idp/src/App.jsx +++ b/services/idp/src/App.jsx @@ -12,6 +12,25 @@ const LazyMain = lazy(() => import(/* webpackChunkName: "identifier-main" */ './ console.info(`Kopano Identifier build version: ${version.build}`); // eslint-disable-line no-console +// config.json, and theme.json's own relative asset paths (theme.common.logo +// etc., see components/ResponsiveScreen.jsx), are served by the main web app +// at the deployment root, not under this app's own /signin/v1 path -- so +// unlike every other asset reference in this file, they can't be resolved +// via process.env.PUBLIC_URL (which is relative to *this* page). Derive the +// deployment root the same way reducers/common.js derives pathPrefix, by +// reading the server-templated data-path-prefix attribute (itself +// "/signin/v1") and stripping the "/signin/v1" suffix back off. Empty +// root ("") is correct for a root-of-domain deployment. +export const deploymentRoot = (() => { + const root = document.getElementById('root'); + const pathPrefix = root ? root.getAttribute('data-path-prefix') : null; + return pathPrefix && pathPrefix !== '__PATH_PREFIX__' + ? pathPrefix.replace(/\/signin\/v1$/, '') + : ''; +})(); + +const configJsonUrl = `${deploymentRoot}/config.json`; + const App = ({ bgImg }): ReactElement => { const [theme, setTheme] = useState(null); const [config, setConfig] = useState(null); @@ -20,7 +39,7 @@ const App = ({ bgImg }): ReactElement => { useEffect(() => { const fetchData = async () => { try { - const configResponse = await fetch('/config.json'); + const configResponse = await fetch(configJsonUrl); const configData = await configResponse.json(); setConfig(configData); diff --git a/services/idp/src/components/ResponsiveScreen.jsx b/services/idp/src/components/ResponsiveScreen.jsx index 86c3eba00d..4d723711c2 100644 --- a/services/idp/src/components/ResponsiveScreen.jsx +++ b/services/idp/src/components/ResponsiveScreen.jsx @@ -8,6 +8,7 @@ import DialogContent from '@material-ui/core/DialogContent'; import Loading from './Loading'; import { OpenCloudContext } from "../openCloudContext"; +import { deploymentRoot } from "../App"; const styles = theme => ({ root: { @@ -48,7 +49,7 @@ const ResponsiveScreen = (props) => { const { theme } = useContext(OpenCloudContext); const logo = (theme && !withoutLogo) ? ( - OpenCloud Logo + OpenCloud Logo ) : null; const content = loading ? : (withoutPadding ? children : {children}); diff --git a/services/invitations/pkg/config/defaults/defaultconfig.go b/services/invitations/pkg/config/defaults/defaultconfig.go index efa629e25c..bec4f2b47b 100644 --- a/services/invitations/pkg/config/defaults/defaultconfig.go +++ b/services/invitations/pkg/config/defaults/defaultconfig.go @@ -1,8 +1,10 @@ package defaults import ( + "path" "strings" + "github.com/opencloud-eu/opencloud/pkg/shared" "github.com/opencloud-eu/opencloud/services/invitations/pkg/config" ) @@ -65,6 +67,12 @@ func EnsureDefaults(cfg *config.Config) { cfg.HTTP.CORS.AllowedOrigins[0] == "https://localhost:9200") { cfg.HTTP.CORS.AllowedOrigins = []string{cfg.Commons.OpenCloudURL} } + + if cfg.Commons != nil { + if root := shared.SubPath(cfg.Commons.OpenCloudURL); root != "" && cfg.HTTP.Root != root && !strings.HasPrefix(cfg.HTTP.Root, root+"/") { + cfg.HTTP.Root = path.Join(root, cfg.HTTP.Root) + } + } } func Sanitize(cfg *config.Config) { diff --git a/services/ocm/pkg/config/defaults/defaultconfig.go b/services/ocm/pkg/config/defaults/defaultconfig.go index 3042c94c59..4d8c3a6041 100644 --- a/services/ocm/pkg/config/defaults/defaultconfig.go +++ b/services/ocm/pkg/config/defaults/defaultconfig.go @@ -1,7 +1,9 @@ package defaults import ( + "path" "path/filepath" + "strings" "time" "github.com/opencloud-eu/opencloud/pkg/config/defaults" @@ -170,6 +172,12 @@ func EnsureDefaults(cfg *config.Config) { cfg.HTTP.CORS.AllowedOrigins[0] == "https://localhost:9200") { cfg.HTTP.CORS.AllowedOrigins = []string{cfg.Commons.OpenCloudURL} } + + if cfg.Commons != nil { + if root := shared.SubPath(cfg.Commons.OpenCloudURL); root != "" && cfg.HTTP.Prefix != root && !strings.HasPrefix(cfg.HTTP.Prefix, root+"/") { + cfg.HTTP.Prefix = path.Join(root, cfg.HTTP.Prefix) + } + } } // Sanitize sanitizes the config diff --git a/services/ocm/pkg/revaconfig/config.go b/services/ocm/pkg/revaconfig/config.go index cdc562d7a4..32d530ed68 100644 --- a/services/ocm/pkg/revaconfig/config.go +++ b/services/ocm/pkg/revaconfig/config.go @@ -33,6 +33,7 @@ func OCMConfigFromStruct(cfg *config.Config, logger log.Logger) map[string]any { "http": map[string]any{ "network": cfg.HTTP.Protocol, "address": cfg.HTTP.Addr, + "prefix": cfg.HTTP.Prefix, "middlewares": map[string]any{ "cors": map[string]any{ "allowed_origins": cfg.HTTP.CORS.AllowedOrigins, diff --git a/services/ocs/pkg/config/defaults/defaultconfig.go b/services/ocs/pkg/config/defaults/defaultconfig.go index e39f83a31c..4c2e57a121 100644 --- a/services/ocs/pkg/config/defaults/defaultconfig.go +++ b/services/ocs/pkg/config/defaults/defaultconfig.go @@ -1,9 +1,11 @@ package defaults import ( + "path" "strings" "time" + "github.com/opencloud-eu/opencloud/pkg/shared" "github.com/opencloud-eu/opencloud/pkg/structs" "github.com/opencloud-eu/opencloud/services/ocs/pkg/config" ) @@ -68,6 +70,12 @@ func EnsureDefaults(cfg *config.Config) { if cfg.GRPCClientTLS == nil && cfg.Commons != nil { cfg.GRPCClientTLS = structs.CopyOrZeroValue(cfg.Commons.GRPCClientTLS) } + + if cfg.Commons != nil { + if root := shared.SubPath(cfg.Commons.OpenCloudURL); root != "" && cfg.HTTP.Root != root && !strings.HasPrefix(cfg.HTTP.Root, root+"/") { + cfg.HTTP.Root = path.Join(root, cfg.HTTP.Root) + } + } } // Sanitize sanitizes the configuration diff --git a/services/proxy/pkg/command/server.go b/services/proxy/pkg/command/server.go index 3c0406671e..e530f1218b 100644 --- a/services/proxy/pkg/command/server.go +++ b/services/proxy/pkg/command/server.go @@ -357,7 +357,7 @@ func loadMiddlewares(logger log.Logger, cfg *config.Config, middleware.ContextLogger(logger), middleware.HTTPSRedirect, middleware.Security(cspConfig), - router.Middleware(serviceSelector, cfg.PolicySelector, cfg.Policies, logger), + router.Middleware(serviceSelector, cfg.PolicySelector, cfg.Policies, logger, cfg.HTTP.Root), middleware.Authentication( authenticators, middleware.CredentialsByUserAgent(cfg.AuthMiddleware.CredentialsByUserAgent), diff --git a/services/proxy/pkg/config/config.go b/services/proxy/pkg/config/config.go index 471a1be72f..6465901222 100644 --- a/services/proxy/pkg/config/config.go +++ b/services/proxy/pkg/config/config.go @@ -72,6 +72,13 @@ type Route struct { AdditionalHeaders map[string]string `yaml:"additional_headers,omitempty"` RemoteUserHeader string `yaml:"remote_user_header,omitempty"` SkipXAccessToken bool `yaml:"skip_x_access_token"` + // StripRoot removes the deployment root (see HTTPConfig.Root) from the + // forwarded request path before it reaches the backend. Every backend + // service is expected to be root-aware and forward the prefix + // unchanged; this is only for the rare backend that hardcodes an + // unprefixed path, e.g. the vendored lico IDP library's RFC 8615 + // well-known endpoint, or the identifier login page's own routes. + StripRoot bool `yaml:"strip_root,omitempty"` } // RouteType defines the type of route diff --git a/services/proxy/pkg/config/defaults/defaultconfig.go b/services/proxy/pkg/config/defaults/defaultconfig.go index e3f2e380c5..6f2892584b 100644 --- a/services/proxy/pkg/config/defaults/defaultconfig.go +++ b/services/proxy/pkg/config/defaults/defaultconfig.go @@ -129,9 +129,15 @@ func DefaultPolicies() []config.Policy { Unprotected: true, }, { + // The vendored lico IDP library hardcodes this path per RFC 8615 + // (bootstrap.WellKnownPath), unlike every other lico endpoint, + // which is built prefix-aware via MakeURIPath -- so under a + // subpath deployment the deployment root has to be stripped + // before forwarding. Endpoint: "/.well-known/openid-configuration", Service: "eu.opencloud.web.idp", Unprotected: true, + StripRoot: true, }, { Endpoint: "/branding/logo", @@ -142,6 +148,32 @@ func DefaultPolicies() []config.Policy { Service: "eu.opencloud.web.idp", Unprotected: true, }, + { + // The identifier login page's own routes -- along with the + // sibling welcome/goodbye/consent/chooseaccount routes + // services/idp/pkg/service/v0/service.go also registers this + // way -- are hardcoded unprefixed (registered directly with + // chi, not via lico's MakeURIPath), so this needs StripRoot. + // Deliberately scoped to just these five pages themselves + // (and their trailing index.html/query-string variants), NOT + // deeper paths like ".../identifier/_/authorize", which are + // separate, already-prefix-aware lico endpoints routed by the + // general /signin/ rule below. + Type: config.RegexRoute, + Endpoint: `^/signin/v1/(identifier|chooseaccount|consent|welcome|goodbye)(/(index\.html)?)?(\?.*)?$`, + Service: "eu.opencloud.web.idp", + Unprotected: true, + StripRoot: true, + }, + { + // The identifier app's static JS/CSS bundle is served through + // services/idp/pkg/middleware/static.go, which also only + // recognizes the literal, unprefixed string "/signin/v1/static/". + Endpoint: "/signin/v1/static/", + Service: "eu.opencloud.web.idp", + Unprotected: true, + StripRoot: true, + }, { Endpoint: "/signin/", Service: "eu.opencloud.web.idp", @@ -337,6 +369,18 @@ func EnsureDefaults(cfg *config.Config) { if cfg.GRPCClientTLS == nil && cfg.Commons != nil { cfg.GRPCClientTLS = structs.CopyOrZeroValue(cfg.Commons.GRPCClientTLS) } + + if cfg.Commons != nil { + // DefaultConfig sets HTTP.Root to "/" as its own baseline (a + // sensible default for a root-of-domain deployment), so it's never + // actually empty by the time EnsureDefaults runs -- a plain + // "== ''" unset check would never fire. Prepend the OC_URL-derived + // subpath instead, guarded so repeated EnsureDefaults calls (this + // runs more than once) don't prepend it twice. + if root := shared.SubPath(cfg.Commons.OpenCloudURL); root != "" && cfg.HTTP.Root != root && !strings.HasPrefix(cfg.HTTP.Root, root+"/") { + cfg.HTTP.Root = path.Join(root, cfg.HTTP.Root) + } + } } // Sanitize sanitizes the configuration diff --git a/services/proxy/pkg/config/defaults/defaultconfig_test.go b/services/proxy/pkg/config/defaults/defaultconfig_test.go new file mode 100644 index 0000000000..039372f5cd --- /dev/null +++ b/services/proxy/pkg/config/defaults/defaultconfig_test.go @@ -0,0 +1,49 @@ +package defaults + +import ( + "testing" + + "github.com/opencloud-eu/opencloud/pkg/shared" +) + +// TestEnsureDefaultsDerivesRootUnderSubpath guards against a regression where +// EnsureDefaults's derivation of HTTP.Root from OC_URL never fired, because +// DefaultConfig sets HTTP.Root to "/" as its own baseline default -- a +// "cfg.HTTP.Root == ''" unset check is never true, so the derivation was +// silently skipped and the deployment's subpath was dropped entirely. +// Only caught by actually deploying under a real subpath and observing every +// request fall through to the web app's catch-all route. +func TestEnsureDefaultsDerivesRootUnderSubpath(t *testing.T) { + cfg := DefaultConfig() + if cfg.HTTP.Root != "/" { + t.Fatalf("test assumes DefaultConfig's baseline HTTP.Root is \"/\", got %q -- update this test", cfg.HTTP.Root) + } + + cfg.Commons = &shared.Commons{OpenCloudURL: "https://host.example/test/opencloud"} + + EnsureDefaults(cfg) + + if want := "/test/opencloud"; cfg.HTTP.Root != want { + t.Errorf("HTTP.Root = %q, want %q", cfg.HTTP.Root, want) + } + + // EnsureDefaults runs more than once against the same *config.Config in + // practice; it must not prepend the subpath a second time. + EnsureDefaults(cfg) + if want := "/test/opencloud"; cfg.HTTP.Root != want { + t.Errorf("HTTP.Root after a second EnsureDefaults call = %q, want %q (derivation should be idempotent)", cfg.HTTP.Root, want) + } +} + +// TestEnsureDefaultsRootOfDomainUnaffected covers the common case: no +// subpath in OC_URL, HTTP.Root should stay at its "/" baseline. +func TestEnsureDefaultsRootOfDomainUnaffected(t *testing.T) { + cfg := DefaultConfig() + cfg.Commons = &shared.Commons{OpenCloudURL: "https://host.example"} + + EnsureDefaults(cfg) + + if cfg.HTTP.Root != "/" { + t.Errorf("HTTP.Root = %q, want \"/\" (no subpath in OC_URL)", cfg.HTTP.Root) + } +} diff --git a/services/proxy/pkg/proxy/proxy_integration_test.go b/services/proxy/pkg/proxy/proxy_integration_test.go index ace5543a59..e448e1b01f 100644 --- a/services/proxy/pkg/proxy/proxy_integration_test.go +++ b/services/proxy/pkg/proxy/proxy_integration_test.go @@ -121,7 +121,7 @@ func TestProxyIntegration(t *testing.T) { t.Parallel() tc := tests[k] - rt := router.Middleware(sel, nil, tc.conf, log.NewLogger()) + rt := router.Middleware(sel, nil, tc.conf, log.NewLogger(), "") rp := newTestProxy(testConfig(tc.conf), func(req *http.Request) *http.Response { if got, want := req.URL.String(), tc.expect.String(); got != want { t.Errorf("Proxied url should be %v got %v", want, got) diff --git a/services/proxy/pkg/router/router.go b/services/proxy/pkg/router/router.go index 9406ef0af2..a5f3117b9c 100644 --- a/services/proxy/pkg/router/router.go +++ b/services/proxy/pkg/router/router.go @@ -20,8 +20,11 @@ type routingInfoCtxKey struct{} var noInfo = RoutingInfo{} // Middleware returns a HTTP middleware containing the router. -func Middleware(serviceSelector selector.Selector, policySelectorCfg *config.PolicySelector, policies []config.Policy, logger log.Logger) func(http.Handler) http.Handler { - router := New(serviceSelector, policySelectorCfg, policies, logger) +// root is the subpath OpenCloud is deployed under (e.g. from OC_URL / +// PROXY_HTTP_ROOT), used to match requests against policy routes regardless +// of the prefix a reverse proxy forwards unchanged. +func Middleware(serviceSelector selector.Selector, policySelectorCfg *config.PolicySelector, policies []config.Policy, logger log.Logger, root string) func(http.Handler) http.Handler { + router := New(serviceSelector, policySelectorCfg, policies, logger, root) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ri, ok := router.Route(r) @@ -36,7 +39,7 @@ func Middleware(serviceSelector selector.Selector, policySelectorCfg *config.Pol // New creates a new request router. // It initializes the routes before returning the router. -func New(serviceSelector selector.Selector, policySelectorCfg *config.PolicySelector, policies []config.Policy, logger log.Logger) Router { +func New(serviceSelector selector.Selector, policySelectorCfg *config.PolicySelector, policies []config.Policy, logger log.Logger, root string) Router { if policySelectorCfg == nil { firstPolicy := policies[0].Name logger.Warn().Str("policy", firstPolicy).Msg("policy-selector not configured. Will always use first policy") @@ -61,6 +64,7 @@ func New(serviceSelector selector.Selector, policySelectorCfg *config.PolicySele rewriters: make(map[string]map[config.RouteType]map[string][]RoutingInfo), policySelector: policySelector, serviceSelector: serviceSelector, + root: root, } for _, pol := range policies { for _, route := range pol.Routes { @@ -121,6 +125,28 @@ type Router struct { rewriters map[string]map[config.RouteType]map[string][]RoutingInfo policySelector policy.Selector serviceSelector selector.Selector + // root is the subpath OpenCloud is deployed under. Policy Endpoint + // patterns are written unprefixed, so it is stripped from a copy of the + // request URL before matching (see Route); the real, forwarded request + // keeps the prefix untouched, since every backend service is expected to + // be root-aware. The exception is routes with StripRoot set, for + // backends that hardcode an unprefixed path (see config.Route.StripRoot). + root string +} + +// stripRootPrefix removes root from the start of p, if present, leaving a +// leading slash behind. It is a no-op when root is empty or "/". +func stripRootPrefix(root, p string) string { + if root == "" || root == "/" { + return p + } + if p == root { + return "/" + } + if strings.HasPrefix(p, root+"/") { + return p[len(root):] + } + return p } func (rt Router) addHost(policy string, target *url.URL, route config.Route) { @@ -181,7 +207,11 @@ func (rt Router) addHost(policy string, target *url.URL, route config.Route) { req.Out.Header.Set(k, v) } - req.Out.URL.Path = singleJoiningSlash(target.Path, req.Out.URL.Path) + outPath := req.Out.URL.Path + if route.StripRoot { + outPath = stripRootPrefix(rt.root, outPath) + } + req.Out.URL.Path = singleJoiningSlash(target.Path, outPath) if targetQuery == "" || req.Out.URL.RawQuery == "" { req.Out.URL.RawQuery = targetQuery + req.Out.URL.RawQuery } else { @@ -212,6 +242,12 @@ func (rt Router) Route(r *http.Request) (RoutingInfo, bool) { return noInfo, false } + // Match against a copy of the URL with root stripped -- policy Endpoint + // patterns are unprefixed. The real request (r.URL), and what eventually + // gets forwarded, is never touched here. + matchURL := *r.URL + matchURL.Path = stripRootPrefix(rt.root, matchURL.Path) + method := "" // find matching rewrite hook for _, rtype := range config.RouteTypes { @@ -234,7 +270,7 @@ func (rt Router) Route(r *http.Request) (RoutingInfo, bool) { } for _, ri := range rt.rewriters[pol][rtype][method] { - if handler(ri.endpoint, *r.URL) { + if handler(ri.endpoint, matchURL) { rt.logger.Debug(). Str("policy", pol). Str("method", r.Method). diff --git a/services/proxy/pkg/router/router_test.go b/services/proxy/pkg/router/router_test.go index 42972350d7..4a3c130e80 100644 --- a/services/proxy/pkg/router/router_test.go +++ b/services/proxy/pkg/router/router_test.go @@ -73,7 +73,7 @@ func TestRegexRouteMatcher(t *testing.T) { cfg.Policies = defaults.DefaultPolicies() reg := registry.GetRegistry() sel := selector.NewSelector(selector.Registry(reg)) - rt := New(sel, cfg.PolicySelector, cfg.Policies, log.NewLogger()) + rt := New(sel, cfg.PolicySelector, cfg.Policies, log.NewLogger(), "") table := []matchertest{ {endpoint: ".*some\\/url.*parameter=true", target: "/foobar/baz/some/url?parameter=true", matches: true}, @@ -135,7 +135,7 @@ func TestRouter(t *testing.T) { reg := registry.GetRegistry() sel := selector.NewSelector(selector.Registry(reg)) - router := New(sel, policySelectorCfg, policies, log.NewLogger()) + router := New(sel, policySelectorCfg, policies, log.NewLogger(), "") table := []matchertest{ {method: "PROPFIND", endpoint: "/dav/files/demo/", target: "frontend"}, @@ -165,3 +165,91 @@ func TestRouter(t *testing.T) { } } } + +// TestRouterWithRoot verifies that a request prefixed with the deployment +// root matches an unprefixed policy route, and that -- since StripRoot is +// not set -- the forwarded request still carries the full, prefixed path, +// because every root-aware backend expects to see it. +func TestRouterWithRoot(t *testing.T) { + policySelectorCfg := &config.PolicySelector{ + Static: &config.StaticSelectorConf{ + Policy: "default", + }, + } + + policies := []config.Policy{ + { + Name: "default", + Routes: []config.Route{ + {Type: config.PrefixRoute, Endpoint: "/dav", Backend: "http://frontend"}, + }, + }, + } + + reg := registry.GetRegistry() + sel := selector.NewSelector(selector.Registry(reg)) + router := New(sel, policySelectorCfg, policies, log.NewLogger(), "/test/opencloud") + + r := httptest.NewRequest("PROPFIND", "/test/opencloud/dav/files/demo/", nil) + routingInfo, ok := router.Route(r) + if !ok { + t.Fatalf("expected a request prefixed with root to match the unprefixed policy route") + } + + pr := &httputil.ProxyRequest{ + In: r, + Out: r.Clone(context.Background()), + } + routingInfo.Rewrite()(pr) + + if pr.Out.URL.Host != "frontend" { + t.Errorf("got host %s expected frontend", pr.Out.URL.Host) + } + want := "/test/opencloud/dav/files/demo/" + if pr.Out.URL.Path != want { + t.Errorf("forwarded path = %q, want %q (root should be preserved when StripRoot is unset)", pr.Out.URL.Path, want) + } +} + +// TestRouterWithRootAndStripRoot covers the escape hatch for backends that +// hardcode an unprefixed path (e.g. lico's RFC 8615 well-known endpoint): +// the request still matches the unprefixed policy route via root-stripped +// matching, but with StripRoot set the forwarded request also has the root +// removed. +func TestRouterWithRootAndStripRoot(t *testing.T) { + policySelectorCfg := &config.PolicySelector{ + Static: &config.StaticSelectorConf{ + Policy: "default", + }, + } + + policies := []config.Policy{ + { + Name: "default", + Routes: []config.Route{ + {Type: config.PrefixRoute, Endpoint: "/.well-known/openid-configuration", Backend: "http://idp", StripRoot: true}, + }, + }, + } + + reg := registry.GetRegistry() + sel := selector.NewSelector(selector.Registry(reg)) + router := New(sel, policySelectorCfg, policies, log.NewLogger(), "/test/opencloud") + + r := httptest.NewRequest("GET", "/test/opencloud/.well-known/openid-configuration", nil) + routingInfo, ok := router.Route(r) + if !ok { + t.Fatalf("expected a request prefixed with root to match the unprefixed policy route") + } + + pr := &httputil.ProxyRequest{ + In: r, + Out: r.Clone(context.Background()), + } + routingInfo.Rewrite()(pr) + + want := "/.well-known/openid-configuration" + if pr.Out.URL.Path != want { + t.Errorf("forwarded path = %q, want %q (StripRoot should remove the deployment root)", pr.Out.URL.Path, want) + } +} diff --git a/services/settings/pkg/config/defaults/defaultconfig.go b/services/settings/pkg/config/defaults/defaultconfig.go index d881be54db..a09b1d9063 100644 --- a/services/settings/pkg/config/defaults/defaultconfig.go +++ b/services/settings/pkg/config/defaults/defaultconfig.go @@ -3,9 +3,11 @@ package defaults import ( "encoding/json" "os" + "path" "strings" "time" + "github.com/opencloud-eu/opencloud/pkg/shared" "github.com/opencloud-eu/opencloud/pkg/structs" "github.com/opencloud-eu/opencloud/services/settings/pkg/config" rdefaults "github.com/opencloud-eu/opencloud/services/settings/pkg/store/defaults" @@ -103,6 +105,12 @@ func EnsureDefaults(cfg *config.Config) { if cfg.Commons != nil { cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS } + + if cfg.Commons != nil { + if root := shared.SubPath(cfg.Commons.OpenCloudURL); root != "" && cfg.HTTP.Root != root && !strings.HasPrefix(cfg.HTTP.Root, root+"/") { + cfg.HTTP.Root = path.Join(root, cfg.HTTP.Root) + } + } } // Sanitize sanitized the configuration diff --git a/services/sse/pkg/config/defaults/defaultconfig.go b/services/sse/pkg/config/defaults/defaultconfig.go index 30e2854ef9..68bb5d0b0d 100644 --- a/services/sse/pkg/config/defaults/defaultconfig.go +++ b/services/sse/pkg/config/defaults/defaultconfig.go @@ -1,8 +1,10 @@ package defaults import ( + "path" "strings" + "github.com/opencloud-eu/opencloud/pkg/shared" "github.com/opencloud-eu/opencloud/services/sse/pkg/config" ) @@ -60,6 +62,12 @@ func EnsureDefaults(cfg *config.Config) { cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS } + if cfg.Commons != nil { + if root := shared.SubPath(cfg.Commons.OpenCloudURL); root != "" && cfg.HTTP.Root != root && !strings.HasPrefix(cfg.HTTP.Root, root+"/") { + cfg.HTTP.Root = path.Join(root, cfg.HTTP.Root) + } + } + } // Sanitize sanitizes the configuration diff --git a/services/sse/pkg/service/service.go b/services/sse/pkg/service/service.go index b4b9b26fbf..7ede739b49 100644 --- a/services/sse/pkg/service/service.go +++ b/services/sse/pkg/service/service.go @@ -32,8 +32,17 @@ func NewSSE(c *config.Config, l log.Logger, ch <-chan events.Event, mux *chi.Mux sse: sse.New(), evChannel: ch, } - mux.Route("/ocs/v2.php/apps/notifications/api/v1/notifications", func(r chi.Router) { - r.Get("/sse", s.HandleSSE) + root := c.HTTP.Root + if root == "" { + // chi.Mux.Route panics on a literal empty string; this also covers + // callers (e.g. tests) that construct a config.Config by hand rather + // than through defaults.DefaultConfig(), leaving Root at its zero value. + root = "/" + } + mux.Route(root, func(r chi.Router) { + r.Route("/ocs/v2.php/apps/notifications/api/v1/notifications", func(r chi.Router) { + r.Get("/sse", s.HandleSSE) + }) }) go s.ListenForEvents() diff --git a/services/thumbnails/pkg/config/defaults/defaultconfig.go b/services/thumbnails/pkg/config/defaults/defaultconfig.go index ede8412133..30004098ad 100644 --- a/services/thumbnails/pkg/config/defaults/defaultconfig.go +++ b/services/thumbnails/pkg/config/defaults/defaultconfig.go @@ -78,6 +78,12 @@ func EnsureDefaults(cfg *config.Config) { if cfg.Commons != nil { cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS } + + if cfg.Commons != nil { + if root := shared.SubPath(cfg.Commons.OpenCloudURL); root != "" && cfg.HTTP.Root != root && !strings.HasPrefix(cfg.HTTP.Root, root+"/") { + cfg.HTTP.Root = path.Join(root, cfg.HTTP.Root) + } + } } // Sanitize sanitized the configuration diff --git a/services/userlog/pkg/config/defaults/defaultconfig.go b/services/userlog/pkg/config/defaults/defaultconfig.go index b8e40eea88..ec5d3e9e89 100644 --- a/services/userlog/pkg/config/defaults/defaultconfig.go +++ b/services/userlog/pkg/config/defaults/defaultconfig.go @@ -1,6 +1,7 @@ package defaults import ( + "path" "strings" "time" @@ -78,6 +79,12 @@ func EnsureDefaults(cfg *config.Config) { cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS } + if cfg.Commons != nil { + if root := shared.SubPath(cfg.Commons.OpenCloudURL); root != "" && cfg.HTTP.Root != root && !strings.HasPrefix(cfg.HTTP.Root, root+"/") { + cfg.HTTP.Root = path.Join(root, cfg.HTTP.Root) + } + } + } // Sanitize sanitizes the config diff --git a/services/userlog/pkg/service/service.go b/services/userlog/pkg/service/service.go index ee59e7b40c..a70055e00d 100644 --- a/services/userlog/pkg/service/service.go +++ b/services/userlog/pkg/service/service.go @@ -85,11 +85,20 @@ func NewUserlogService(opts ...Option) (*UserlogService, error) { roles.RoleService(o.RoleClient), ) - ul.m.Route("/ocs/v2.php/apps/notifications/api/v1/notifications", func(r chi.Router) { - r.Get("/", ul.HandleGetEvents) - r.Delete("/", ul.HandleDeleteEvents) - r.Post("/global", RequireAdminOrSecret(&m, o.Config.GlobalNotificationsSecret)(ul.HandlePostGlobalEvent)) - r.Delete("/global", RequireAdminOrSecret(&m, o.Config.GlobalNotificationsSecret)(ul.HandleDeleteGlobalEvent)) + root := o.Config.HTTP.Root + if root == "" { + // chi.Mux.Route panics on a literal empty string; this also covers + // callers (e.g. tests) that construct a config.Config by hand rather + // than through defaults.DefaultConfig(), leaving Root at its zero value. + root = "/" + } + ul.m.Route(root, func(r chi.Router) { + r.Route("/ocs/v2.php/apps/notifications/api/v1/notifications", func(r chi.Router) { + r.Get("/", ul.HandleGetEvents) + r.Delete("/", ul.HandleDeleteEvents) + r.Post("/global", RequireAdminOrSecret(&m, o.Config.GlobalNotificationsSecret)(ul.HandlePostGlobalEvent)) + r.Delete("/global", RequireAdminOrSecret(&m, o.Config.GlobalNotificationsSecret)(ul.HandleDeleteGlobalEvent)) + }) }) go ul.MemorizeEvents(ch) diff --git a/services/web/pkg/assets/server.go b/services/web/pkg/assets/server.go index ffd0bb645a..99cb831acb 100644 --- a/services/web/pkg/assets/server.go +++ b/services/web/pkg/assets/server.go @@ -14,11 +14,15 @@ import ( type fileServer struct { fsys http.FileSystem + root string } -// FileServer defines the http handler for the embedded files -func FileServer(fsys fs.FS) http.Handler { - return &fileServer{http.FS(fsys)} +// FileServer defines the http handler for the embedded files. root is the +// subpath OpenCloud is deployed under (e.g. from OC_URL / WEB_HTTP_ROOT), +// templated into the served HTML's so relative asset/API +// references resolve under the deployment prefix instead of the domain root. +func FileServer(fsys fs.FS, root string) http.Handler { + return &fileServer{fsys: http.FS(fsys), root: root} } func (f *fileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -61,7 +65,7 @@ func (f *fileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { case "index.html", "oidc-callback.html", "oidc-silent-redirect.html": w.Header().Del("Expires") w.Header().Set("Cache-Control", "no-cache") - _ = withBase(buf, asset, "/") + _ = withBase(buf, asset, f.root+"/") default: _, _ = buf.ReadFrom(asset) } diff --git a/services/web/pkg/assets/server_test.go b/services/web/pkg/assets/server_test.go index e60bf23014..89856e91eb 100644 --- a/services/web/pkg/assets/server_test.go +++ b/services/web/pkg/assets/server_test.go @@ -19,7 +19,7 @@ func TestFileServer(t *testing.T) { } { - s := FileServer(fstest.MapFS{}) + s := FileServer(fstest.MapFS{}, "") w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/foo", nil) //defer req.Body.Close() @@ -33,6 +33,7 @@ func TestFileServer(t *testing.T) { for _, tt := range []struct { name string url string + root string fs fstest.MapFS expected string }{ @@ -46,6 +47,39 @@ func TestFileServer(t *testing.T) { }, expected: `index file content`, }, + { + name: "index.html under a subpath deployment", + url: "/index.html", + root: "/test/opencloud", + fs: fstest.MapFS{ + "index.html": &fstest.MapFile{ + Data: []byte("index file content"), + }, + }, + expected: `index file content`, + }, + { + name: "oidc-callback.html under a subpath deployment", + url: "/oidc-callback.html", + root: "/test/opencloud", + fs: fstest.MapFS{ + "index.html": &fstest.MapFile{ + Data: []byte("oidc-callback file content"), + }, + }, + expected: `oidc-callback file content`, + }, + { + name: "some-file.txt under a subpath deployment is untouched", + url: "/some-file.txt", + root: "/test/opencloud", + fs: fstest.MapFS{ + "some-file.txt": &fstest.MapFile{ + Data: []byte("some file content"), + }, + }, + expected: "some file content", + }, { name: "directory fallback", url: "/some-folder", @@ -105,7 +139,7 @@ func TestFileServer(t *testing.T) { w := httptest.NewRecorder() req := httptest.NewRequest("GET", tt.url, nil) - FileServer(tt.fs).ServeHTTP(w, req) + FileServer(tt.fs, tt.root).ServeHTTP(w, req) res := w.Result() defer res.Body.Close() diff --git a/services/web/pkg/config/defaults/defaultconfig.go b/services/web/pkg/config/defaults/defaultconfig.go index 4c18aae0bd..14950ae4b1 100644 --- a/services/web/pkg/config/defaults/defaultconfig.go +++ b/services/web/pkg/config/defaults/defaultconfig.go @@ -1,10 +1,12 @@ package defaults import ( + "path" "path/filepath" "strings" "github.com/opencloud-eu/opencloud/pkg/config/defaults" + "github.com/opencloud-eu/opencloud/pkg/shared" "github.com/opencloud-eu/opencloud/services/web/pkg/config" ) @@ -141,6 +143,12 @@ func EnsureDefaults(cfg *config.Config) { cfg.HTTP.CORS.AllowedOrigins[0] == "https://localhost:9200") { cfg.HTTP.CORS.AllowedOrigins = []string{cfg.Commons.OpenCloudURL} } + + if cfg.Commons != nil { + if root := shared.SubPath(cfg.Commons.OpenCloudURL); root != "" && cfg.HTTP.Root != root && !strings.HasPrefix(cfg.HTTP.Root, root+"/") { + cfg.HTTP.Root = path.Join(root, cfg.HTTP.Root) + } + } } // Sanitize sanitized the configuration diff --git a/services/web/pkg/service/v0/service.go b/services/web/pkg/service/v0/service.go index e21e9bc637..59fcba5555 100644 --- a/services/web/pkg/service/v0/service.go +++ b/services/web/pkg/service/v0/service.go @@ -162,7 +162,7 @@ func (p Web) Static(f fs.FS, root string, ttl int) http.HandlerFunc { static := http.StripPrefix( rootWithSlash, - assets.FileServer(f), + assets.FileServer(f, root), ) lastModified := time.Now().UTC().Format(http.TimeFormat) diff --git a/services/webdav/pkg/config/defaults/defaultconfig.go b/services/webdav/pkg/config/defaults/defaultconfig.go index 7bf35b7b01..e391f9fcba 100644 --- a/services/webdav/pkg/config/defaults/defaultconfig.go +++ b/services/webdav/pkg/config/defaults/defaultconfig.go @@ -1,6 +1,7 @@ package defaults import ( + "path" "strings" "github.com/opencloud-eu/opencloud/pkg/shared" @@ -58,6 +59,12 @@ func EnsureDefaults(cfg *config.Config) { if cfg.Commons != nil { cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS } + + if cfg.Commons != nil { + if root := shared.SubPath(cfg.Commons.OpenCloudURL); root != "" && cfg.HTTP.Root != root && !strings.HasPrefix(cfg.HTTP.Root, root+"/") { + cfg.HTTP.Root = path.Join(root, cfg.HTTP.Root) + } + } } // Sanitize sanitized the configuration diff --git a/services/webfinger/pkg/config/defaults/defaultconfig.go b/services/webfinger/pkg/config/defaults/defaultconfig.go index f3f8ad05d7..47e336abd2 100644 --- a/services/webfinger/pkg/config/defaults/defaultconfig.go +++ b/services/webfinger/pkg/config/defaults/defaultconfig.go @@ -1,8 +1,10 @@ package defaults import ( + "path" "strings" + "github.com/opencloud-eu/opencloud/pkg/shared" "github.com/opencloud-eu/opencloud/services/webfinger/pkg/config" "github.com/opencloud-eu/opencloud/services/webfinger/pkg/relations" ) @@ -83,6 +85,12 @@ func EnsureDefaults(cfg *config.Config) { cfg.HTTP.CORS.AllowedOrigins[0] == "https://localhost:9200") { cfg.HTTP.CORS.AllowedOrigins = []string{cfg.Commons.OpenCloudURL} } + + if cfg.Commons != nil { + if root := shared.SubPath(cfg.Commons.OpenCloudURL); root != "" && cfg.HTTP.Root != root && !strings.HasPrefix(cfg.HTTP.Root, root+"/") { + cfg.HTTP.Root = path.Join(root, cfg.HTTP.Root) + } + } } // Sanitize sanitized the configuration diff --git a/vendor/github.com/opencloud-eu/reva/v2/pkg/rhttp/rhttp.go b/vendor/github.com/opencloud-eu/reva/v2/pkg/rhttp/rhttp.go index b7e17649a5..91f961c908 100644 --- a/vendor/github.com/opencloud-eu/reva/v2/pkg/rhttp/rhttp.go +++ b/vendor/github.com/opencloud-eu/reva/v2/pkg/rhttp/rhttp.go @@ -25,6 +25,7 @@ import ( "net/http" "path" "sort" + "strings" "time" "github.com/go-viper/mapstructure/v2" @@ -80,8 +81,15 @@ type Server struct { } type config struct { - Network string `mapstructure:"network"` - Address string `mapstructure:"address"` + Network string `mapstructure:"network"` + Address string `mapstructure:"address"` + // Prefix is the subpath this server is exposed under by a reverse proxy + // in front of it (e.g. when OpenCloud is deployed at + // https://host/some/subpath/). rhttp's own dispatch (getHandler) only + // ever shifts a single path segment at a time to look up a registered + // service, so a multi-segment external prefix has to be stripped before + // that dispatch runs; see stripServerPrefix. + Prefix string `mapstructure:"prefix"` Services map[string]map[string]interface{} `mapstructure:"services"` Middlewares map[string]map[string]interface{} `mapstructure:"middlewares"` CertFile string `mapstructure:"certfile"` @@ -216,7 +224,7 @@ func (s *Server) registerServices() error { h := traceHandler(svcName, svc.Handler(), s.tracerProvider) s.handlers[svc.Prefix()] = h s.svcs[svc.Prefix()] = svc - s.unprotected = append(s.unprotected, getUnprotected(svc.Prefix(), svc.Unprotected())...) + s.unprotected = append(s.unprotected, getUnprotected(path.Join(s.conf.Prefix, svc.Prefix()), svc.Unprotected())...) s.log.Info().Msgf("http service enabled: %s@/%s", svcName, svc.Prefix()) } else { message := fmt.Sprintf("http service %s does not exist", svcName) @@ -231,8 +239,6 @@ func (s *Server) isServiceEnabled(svcName string) bool { return ok } -// TODO(labkode): if the http server is exposed under a basename we need to prepend -// to prefix. func getUnprotected(prefix string, unprotected []string) []string { for i := range unprotected { unprotected[i] = path.Join("/", prefix, unprotected[i]) @@ -240,27 +246,49 @@ func getUnprotected(prefix string, unprotected []string) []string { return unprotected } -func (s *Server) getHandler() (http.Handler, error) { - h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - head, tail := router.ShiftPath(r.URL.Path) - if h, ok := s.handlers[head]; ok { - r.URL.Path = tail - s.log.Debug().Msgf("http routing: head=%s tail=%s svc=%s", head, r.URL.Path, head) - h.ServeHTTP(w, r) - return - } +// stripServerPrefix removes prefix from the start of p, if present, leaving +// a leading slash behind. It is a no-op when prefix is empty or "/". +func stripServerPrefix(prefix, p string) string { + if prefix == "" || prefix == "/" { + return p + } + if p == prefix { + return "/" + } + if strings.HasPrefix(p, prefix+"/") { + return p[len(prefix):] + } + return p +} - // when a service is exposed at the root. - if h, ok := s.handlers[""]; ok { - r.URL.Path = "/" + head + tail - s.log.Debug().Msgf("http routing: head= tail=%s svc=root", r.URL.Path) - h.ServeHTTP(w, r) - return - } +// dispatch shifts the deployment prefix and a single path segment off the +// request and forwards it to the matching registered service, if any. +// Split out of getHandler so it can be exercised directly in tests without +// pulling in the rest of the middleware chain (auth, tracing, ...), which +// isn't relevant to routing/prefix-handling behavior. +func (s *Server) dispatch(w http.ResponseWriter, r *http.Request) { + head, tail := router.ShiftPath(stripServerPrefix(s.conf.Prefix, r.URL.Path)) + if h, ok := s.handlers[head]; ok { + r.URL.Path = tail + s.log.Debug().Msgf("http routing: head=%s tail=%s svc=%s", head, r.URL.Path, head) + h.ServeHTTP(w, r) + return + } - s.log.Debug().Msgf("http routing: head=%s tail=%s svc=not-found", head, tail) - w.WriteHeader(http.StatusNotFound) - }) + // when a service is exposed at the root. + if h, ok := s.handlers[""]; ok { + r.URL.Path = "/" + head + tail + s.log.Debug().Msgf("http routing: head= tail=%s svc=root", r.URL.Path) + h.ServeHTTP(w, r) + return + } + + s.log.Debug().Msgf("http routing: head=%s tail=%s svc=not-found", head, tail) + w.WriteHeader(http.StatusNotFound) +} + +func (s *Server) getHandler() (http.Handler, error) { + h := http.HandlerFunc(s.dispatch) // sort middlewares by priority. sort.SliceStable(s.middlewares, func(i, j int) bool { From 8ddfb30468ad3ba18dd8b763d5c05a58a347afba Mon Sep 17 00:00:00 2001 From: Vlatko Kosturjak Date: Sun, 26 Jul 2026 04:47:39 +0200 Subject: [PATCH 2/2] fix: correct .codacy.yml exclude paths, add img height attribute Codacy flagged services/idp/src/components/ResponsiveScreen.jsx's logo (added by this PR) for missing width/height and for using a raw element. Both come from .codacy.yml's idp exclude entries being wrong -- and have been since before this PR, unrelated to any of its changes: 'idp/src/**', 'idp/scripts/**', and 'idp/ui_config/**' don't match anything, since those directories actually live under services/idp/. Fixed the paths so the identifier app's source (a semi-vendored Kopano/lico-derived CRA app with different conventions than the rest of this Go-centric repo) is excluded as originally intended. Also added an explicit height attribute to the flagged matching its existing CSS default (services/idp/src/app.css's .oc-logo rule), which resolves that specific complaint regardless of the exclude fix. --- .codacy.yml | 6 +++--- services/idp/src/components/ResponsiveScreen.jsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.codacy.yml b/.codacy.yml index a3a7c00220..3e369e2f0e 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -10,9 +10,9 @@ exclude_paths: - '**/pkg/proto/**' - 'protogen/**' - 'scripts/**' - - 'idp/ui_config/**' - - 'idp/scripts/**' - - 'idp/src/**' + - 'services/idp/ui_config/**' + - 'services/idp/scripts/**' + - 'services/idp/src/**' - 'devtools/**' - 'deployments/**' - "release-config.ts" diff --git a/services/idp/src/components/ResponsiveScreen.jsx b/services/idp/src/components/ResponsiveScreen.jsx index 4d723711c2..a69e9da73a 100644 --- a/services/idp/src/components/ResponsiveScreen.jsx +++ b/services/idp/src/components/ResponsiveScreen.jsx @@ -49,7 +49,7 @@ const ResponsiveScreen = (props) => { const { theme } = useContext(OpenCloudContext); const logo = (theme && !withoutLogo) ? ( - OpenCloud Logo + OpenCloud Logo ) : null; const content = loading ? : (withoutPadding ? children : {children});