Merge pull request #9691 from fschade/app-config

isolated app configuration
This commit is contained in:
Florian Schade
2024-08-06 09:35:30 +02:00
committed by GitHub
4 changed files with 171 additions and 84 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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)
}
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)
// 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)
}
}
if err := validate.Struct(application); err != nil {
// the application is required to be valid
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)
}
}
// 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)
// 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 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)

View File

@@ -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/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/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/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/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/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))
}
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())
{
_, 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))
}
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",
}))
{
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) {