diff --git a/changelog/unreleased/enhancement-local-app-configuration.md b/changelog/unreleased/enhancement-local-app-configuration.md new file mode 100644 index 00000000000..86fd1c082ee --- /dev/null +++ b/changelog/unreleased/enhancement-local-app-configuration.md @@ -0,0 +1,12 @@ +Enhancement: Local WEB App configuration + +We've added a new feature which allows configuring applications individually instead of using the global apps.yaml file. +With that, each application can have its own configuration file, which will be loaded by the WEB service. + +The local configuration has the highest priority and will override the global configuration. +The Following order of precedence is used: local.config > global.config > manifest.config. + +Besides the configuration, the application now be disabled by setting the `disabled` field to `true` in one of the configuration files. + +https://github.com/owncloud/ocis/pull/9691 +https://github.com/owncloud/ocis/issues/9687 diff --git a/services/web/pkg/apps/apps.go b/services/web/pkg/apps/apps.go index 1c617b36c9f..79e3775ac0c 100644 --- a/services/web/pkg/apps/apps.go +++ b/services/web/pkg/apps/apps.go @@ -34,6 +34,9 @@ var ( const ( // _manifest is the name of the manifest file for an application _manifest = "manifest.json" + + // _config contains the dedicated app configuration + _config = "config.json" ) // Application contains the metadata of an application @@ -41,6 +44,9 @@ type Application struct { // ID is the unique identifier of the application ID string `json:"-"` + // Disabled is a flag to disable the application + Disabled bool `json:"disabled,omitempty"` + // Entrypoint is the entrypoint of the application within the bundle Entrypoint string `json:"entrypoint" validate:"required"` @@ -83,18 +89,18 @@ func List(logger log.Logger, data map[string]config.App, fSystems ...fs.FS) []Ap appData = data } - if appData.Disabled { - // if the app is disabled, skip it - continue - } - - application, err := build(fSystem, name, appData.Config) + application, err := build(fSystem, name, appData) if err != nil { // if app creation fails, log the error and continue with the next app logger.Debug().Err(err).Str("path", entry.Name()).Msg("failed to load application") continue } + if application.Disabled { + // if the app is disabled, skip it + continue + } + // everything is fine, add the application to the list of applications registry[name] = application } @@ -103,36 +109,72 @@ func List(logger log.Logger, data map[string]config.App, fSystems ...fs.FS) []Ap return maps.Values(registry) } -func build(fSystem fs.FS, id string, conf map[string]any) (Application, error) { +func build(fSystem fs.FS, id string, globalConfig config.App) (Application, error) { // skip non-directory listings, every app needs to be contained inside a directory entry, err := fs.Stat(fSystem, id) if err != nil || !entry.IsDir() { return Application{}, ErrInvalidApp } - // read the manifest.json from the app directory. - manifest := path.Join(id, _manifest) - reader, err := fSystem.Open(manifest) - if err != nil { - // manifest.json is required - return Application{}, errors.Join(err, ErrMissingManifest) + var application Application + // build the application + { + r, err := fSystem.Open(path.Join(id, _manifest)) + if err != nil { + return Application{}, errors.Join(err, ErrMissingManifest) + } + defer r.Close() + + if json.NewDecoder(r).Decode(&application) != nil { + return Application{}, errors.Join(err, ErrInvalidManifest) + } + + if err := validate.Struct(application); err != nil { + return Application{}, errors.Join(err, ErrInvalidManifest) + } } - defer reader.Close() - var application Application - if json.NewDecoder(reader).Decode(&application) != nil { - // a valid manifest.json is required - return Application{}, errors.Join(err, ErrInvalidManifest) + var localConfig config.App + // build the local configuration + { + r, err := fSystem.Open(path.Join(id, _config)) + if err == nil { + defer r.Close() + } + + // as soon as we have a local configuration, we expect it to be valid + if err == nil && json.NewDecoder(r).Decode(&localConfig) != nil { + return Application{}, errors.Join(err, ErrInvalidManifest) + } } - if err := validate.Struct(application); err != nil { - // the application is required to be valid - return Application{}, errors.Join(err, ErrInvalidManifest) + // apply overloads the application with the source configuration, + // not all fields are considered secure, therefore, the allowed values are hand-picked + overloadApplication := func(source config.App) error { + // overload the configuration, + // configuration options are considered secure and can be overloaded + err = mergo.Merge(&application.Config, source.Config, mergo.WithOverride) + if err != nil { + return err + } + + // overload the disabled state, consider it secure and allow overloading + application.Disabled = source.Disabled + + return nil } - // overload the default configuration with the application-specific configuration, - // the application-specific configuration has priority, and failing is fine here - _ = mergo.Merge(&application.Config, conf, mergo.WithOverride) + // overload the application configuration from the manifest with the local and global configuration + // priority is local.config > global.config > manifest.config + { + // overload the default configuration with the global application-specific configuration, + // that configuration has priority, and failing is fine here + _ = overloadApplication(globalConfig) + + // overload the default configuration with the local application-specific configuration, + // that configuration has priority, and failing is fine here + _ = overloadApplication(localConfig) + } // the entrypoint is jailed to the app directory application.Entrypoint = filepathx.JailJoin(id, application.Entrypoint) diff --git a/services/web/pkg/apps/apps_test.go b/services/web/pkg/apps/apps_test.go index 0f226ed9b70..4b27e027dc8 100644 --- a/services/web/pkg/apps/apps_test.go +++ b/services/web/pkg/apps/apps_test.go @@ -35,69 +35,89 @@ func TestBuild(t *testing.T) { Mode: fs.ModeDir, } - _, err := apps.Build(fstest.MapFS{ - "app": &fstest.MapFile{}, - }, "app", map[string]any{}) - g.Expect(err).To(gomega.MatchError(apps.ErrInvalidApp)) + { + _, err := apps.Build(fstest.MapFS{ + "app": &fstest.MapFile{}, + }, "app", config.App{}) + g.Expect(err).To(gomega.MatchError(apps.ErrInvalidApp)) + } - _, err = apps.Build(fstest.MapFS{ - "app": dir, - }, "app", map[string]any{}) - g.Expect(err).To(gomega.MatchError(apps.ErrMissingManifest)) + { + _, err := apps.Build(fstest.MapFS{ + "app": dir, + }, "app", config.App{}) + g.Expect(err).To(gomega.MatchError(apps.ErrMissingManifest)) + } - _, err = apps.Build(fstest.MapFS{ - "app": dir, - "app/manifest.json": dir, - }, "app", map[string]any{}) - g.Expect(err).To(gomega.MatchError(apps.ErrInvalidManifest)) + { + _, err := apps.Build(fstest.MapFS{ + "app": dir, + "app/manifest.json": dir, + }, "app", config.App{}) + g.Expect(err).To(gomega.MatchError(apps.ErrInvalidManifest)) + } - _, err = apps.Build(fstest.MapFS{ - "app": dir, - "app/manifest.json": &fstest.MapFile{ - Data: []byte("{}"), - }, - }, "app", map[string]any{}) - g.Expect(err).To(gomega.MatchError(apps.ErrInvalidManifest)) - - _, err = apps.Build(fstest.MapFS{ - "app": dir, - "app/entrypoint.js": &fstest.MapFile{}, - "app/manifest.json": &fstest.MapFile{ - Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js"}`), - }, - }, "app", map[string]any{}) - g.Expect(err).ToNot(gomega.HaveOccurred()) - - _, err = apps.Build(fstest.MapFS{ - "app": dir, - "app/entrypoint.js": dir, - "app/manifest.json": &fstest.MapFile{ - Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js"}`), - }, - }, "app", map[string]any{}) - g.Expect(err).To(gomega.MatchError(apps.ErrEntrypointDoesNotExist)) + { + _, err := apps.Build(fstest.MapFS{ + "app": dir, + "app/manifest.json": &fstest.MapFile{ + Data: []byte("{}"), + }, + }, "app", config.App{}) + g.Expect(err).To(gomega.MatchError(apps.ErrInvalidManifest)) - _, err = apps.Build(fstest.MapFS{ - "app": dir, - "app/manifest.json": &fstest.MapFile{ - Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js"}`), - }, - }, "app", map[string]any{}) - g.Expect(err).To(gomega.MatchError(apps.ErrEntrypointDoesNotExist)) - - application, err := apps.Build(fstest.MapFS{ - "app": dir, - "app/entrypoint.js": &fstest.MapFile{}, - "app/manifest.json": &fstest.MapFile{ - Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js", "config": {"foo": "1", "bar": "2"}}`), - }, - }, "app", map[string]any{"foo": "overwritten-1", "baz": "injected-1"}) - g.Expect(err).ToNot(gomega.HaveOccurred()) + } - g.Expect(application.Entrypoint).To(gomega.Equal("app/entrypoint.js")) - g.Expect(application.Config).To(gomega.Equal(map[string]interface{}{ - "foo": "overwritten-1", "baz": "injected-1", "bar": "2", - })) + { + _, err := apps.Build(fstest.MapFS{ + "app": dir, + "app/entrypoint.js": &fstest.MapFile{}, + "app/manifest.json": &fstest.MapFile{ + Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js"}`), + }, + }, "app", config.App{}) + g.Expect(err).ToNot(gomega.HaveOccurred()) + } + + { + _, err := apps.Build(fstest.MapFS{ + "app": dir, + "app/entrypoint.js": dir, + "app/manifest.json": &fstest.MapFile{ + Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js"}`), + }, + }, "app", config.App{}) + g.Expect(err).To(gomega.MatchError(apps.ErrEntrypointDoesNotExist)) + } + + { + _, err := apps.Build(fstest.MapFS{ + "app": dir, + "app/manifest.json": &fstest.MapFile{ + Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js"}`), + }, + }, "app", config.App{}) + g.Expect(err).To(gomega.MatchError(apps.ErrEntrypointDoesNotExist)) + } + + { + application, err := apps.Build(fstest.MapFS{ + "app": dir, + "app/entrypoint.js": &fstest.MapFile{}, + "app/manifest.json": &fstest.MapFile{ + Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js", "config": {"k1": "1", "k2": "2", "k3": "3"}}`), + }, + "app/config.json": &fstest.MapFile{ + Data: []byte(`{"config": {"k2": "overwritten-from-config.json", "injected_from_config_json": "11"}}`), + }, + }, "app", config.App{Config: map[string]any{"k2": "overwritten-from-apps.yaml", "k3": "overwritten-from-apps.yaml", "injected_from_apps_yaml": "22"}}) + g.Expect(err).ToNot(gomega.HaveOccurred()) + + g.Expect(application.Entrypoint).To(gomega.Equal("app/entrypoint.js")) + g.Expect(application.Config).To(gomega.Equal(map[string]interface{}{ + "k1": "1", "k2": "overwritten-from-config.json", "k3": "overwritten-from-apps.yaml", "injected_from_config_json": "11", "injected_from_apps_yaml": "22", + })) + } } func TestList(t *testing.T) {