Skip to content

Commit

Permalink
Merge pull request #9691 from fschade/app-config
Browse files Browse the repository at this point in the history
isolated app configuration
  • Loading branch information
fschade authored Aug 6, 2024
2 parents 8c624ff + 56bad68 commit 598786a
Show file tree
Hide file tree
Showing 4 changed files with 173 additions and 86 deletions.
12 changes: 12 additions & 0 deletions changelog/unreleased/enhancement-local-app-configuration.md
Original file line number Diff line number Diff line change
@@ -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
21 changes: 17 additions & 4 deletions services/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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\
Expand Down Expand Up @@ -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
{
Expand All @@ -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
}
Expand All @@ -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.
Expand All @@ -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.
Expand Down
90 changes: 66 additions & 24 deletions services/web/pkg/apps/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,19 @@ 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
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"`

Expand Down Expand Up @@ -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
}
Expand All @@ -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)
Expand Down
136 changes: 78 additions & 58 deletions services/web/pkg/apps/apps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down

0 comments on commit 598786a

Please sign in to comment.