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
6 changes: 3 additions & 3 deletions .codacy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
25 changes: 25 additions & 0 deletions pkg/shared/subpath.go
Original file line number Diff line number Diff line change
@@ -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)
}
21 changes: 21 additions & 0 deletions pkg/shared/subpath_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
8 changes: 8 additions & 0 deletions services/activitylog/pkg/config/defaults/defaultconfig.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package defaults

import (
"path"
"strings"
"time"

"github.com/opencloud-eu/opencloud/pkg/shared"
Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion services/activitylog/pkg/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions services/frontend/pkg/config/defaults/defaultconfig.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package defaults

import (
"path"
"strings"
"time"

"github.com/opencloud-eu/opencloud/pkg/shared"
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions services/frontend/pkg/revaconfig/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions services/graph/pkg/config/defaults/defaultconfig.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package defaults

import (
"path"
"strings"
"time"

Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions services/graph/pkg/config/defaults/defaultconfig_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
7 changes: 7 additions & 0 deletions services/idp/pkg/config/defaults/defaultconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package defaults

import (
"net/http"
"path"
"path/filepath"
"strings"

Expand Down Expand Up @@ -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
Expand Down
32 changes: 25 additions & 7 deletions services/idp/pkg/service/v0/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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("/<path>", 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)

Expand Down Expand Up @@ -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)
Expand Down
69 changes: 69 additions & 0 deletions services/idp/pkg/service/v0/service_test.go
Original file line number Diff line number Diff line change
@@ -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 = `<div id="root" data-path-prefix="__PATH_PREFIX__" ` +
`passwort-reset-link="__PASSWORD_RESET_LINK__" ` +
`style="background-image: url(__BG_IMG_URL__)"></div>` +
`<meta property="csp-nonce" content="__CSP_NONCE__">`

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)
}
}
21 changes: 20 additions & 1 deletion services/idp/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
// "<root>/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);
Expand All @@ -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);

Expand Down
3 changes: 2 additions & 1 deletion services/idp/src/components/ResponsiveScreen.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -48,7 +49,7 @@ const ResponsiveScreen = (props) => {
const { theme } = useContext(OpenCloudContext);

const logo = (theme && !withoutLogo) ? (
<img src={'/' + theme.common?.logo} className="oc-logo" alt="OpenCloud Logo"/>
<img src={`${deploymentRoot}/${theme.common?.logo}`} className="oc-logo" height="80" alt="OpenCloud Logo"/>
) : null;

const content = loading ? <Loading/> : (withoutPadding ? children : <DialogContent>{children}</DialogContent>);
Expand Down
Loading