mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2025-12-31 01:10:20 -06:00
Merge pull request #9691 from fschade/app-config
isolated app configuration
This commit is contained in:
12
changelog/unreleased/enhancement-local-app-configuration.md
Normal file
12
changelog/unreleased/enhancement-local-app-configuration.md
Normal 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user