From a31a48150b24b12954cb64c5fd61c4f66150565c Mon Sep 17 00:00:00 2001 From: Florian Schade Date: Thu, 25 Jul 2024 13:23:06 +0200 Subject: [PATCH 1/2] Commit Title (50 char max) Release Notes ============= Testing Notes ============= Deploy Notes ============ Technical Change Notes ====================== --- .../enhancement-local-app-configuration.md | 12 ++ services/web/README.md | 21 ++- services/web/pkg/apps/apps.go | 90 ++++++++---- services/web/pkg/apps/apps_test.go | 136 ++++++++++-------- 4 files changed, 173 insertions(+), 86 deletions(-) create mode 100644 changelog/unreleased/enhancement-local-app-configuration.md 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/README.md b/services/web/README.md index 426a9c53c2a..64e7499619c 100644 --- a/services/web/README.md +++ b/services/web/README.md @@ -88,6 +88,7 @@ applications from the WebUI. Everything else is skipped and not considered as an application. * Each application must be in its own directory accessed via `WEB_ASSET_APPS_PATH`. * Each application directory must contain a `manifest.json` file. + * Each application directory can contain a `config.json` file. * The `manifest.json` file contains the following fields: * `entrypoint` - required\ @@ -122,7 +123,18 @@ image-viewer-obj: maxSize: 512 ``` -The final configuration for web will be: +optional each application can have its own configuration file, which will be loaded by the WEB service. + +```json +{ + "config": { + "maxWidth": 320 + } +} +``` + +The Merge order is as follows: local.config overwrites > global.config overwrites > manifest.config. +The result will be: ```json { @@ -131,7 +143,7 @@ The final configuration for web will be: "id": "image-viewer-obj", "path": "index.js", "config": { - "maxWidth": 1280, + "maxWidth": 320, "maxHeight": 640, "maxSize": 512 } @@ -140,7 +152,8 @@ The final configuration for web will be: } ``` -Besides the configuration from the `manifest.json` file, the `apps.yaml` file can also contain the following fields: +Besides the configuration from the `manifest.json` file, +the `apps.yaml` or the `config.json` file can also contain the following fields: * `disabled` - optional\ Defaults to `false`. If set to `true`, the application will not be loaded. @@ -149,7 +162,7 @@ Besides the configuration from the `manifest.json` file, the `apps.yaml` file ca Besides the configuration and application registration, in the process of loading the application assets, the system uses a mechanism to load custom assets. -This is very useful for cases where just a single asset should be overwritten, like a logo or similar. +This is useful for cases where just a single asset should be overwritten, like a logo or similar. Consider the following: Infinite Scale is shipped with a default web app named `image-viewer-dfx` which contains a logo, but the administrator wants to provide a custom logo for that application. 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) { From 56bad68e21fefeadc6d3bfac03d6a5a7ee6eff84 Mon Sep 17 00:00:00 2001 From: Florian Schade Date: Fri, 2 Aug 2024 09:37:43 +0200 Subject: [PATCH 2/2] Update services/web/README.md Co-authored-by: Martin --- services/web/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/README.md b/services/web/README.md index 64e7499619c..80aa8fc4999 100644 --- a/services/web/README.md +++ b/services/web/README.md @@ -88,7 +88,7 @@ applications from the WebUI. Everything else is skipped and not considered as an application. * Each application must be in its own directory accessed via `WEB_ASSET_APPS_PATH`. * Each application directory must contain a `manifest.json` file. - * Each application directory can contain a `config.json` file. + * Each application directory can contain a `config.json` file. * The `manifest.json` file contains the following fields: * `entrypoint` - required\