[full-ci] enhancement: allow ocis to provide custom web applications (#8523)

* enhancement: allow ocis to provide custom web applications

* enhancement: add an option to disable web apps

* test: add default logger tests

* test: add app loading tests

* test: add asset server tests

* enhancement: make use of dedicated app conf file and app asset paths

* enhancement: adjust asset locations and deprecate WEB_ASSET_PATH

* enhancement: get rid of default logger and use the service level logger instead

* Apply suggestions from code review

Co-authored-by: Benedikt Kulmann <benedikt@kulmann.biz>
Co-authored-by: kobergj <juliankoberg@googlemail.com>

* enhancement: use basename as app id

* Apply suggestions from code review

Co-authored-by: Martin <github@diemattels.at>

* enhancement: use afero as fs abstraction

* enhancement: simplify logo upload

* enhancement: make use of introductionVersion field annotations

---------

Co-authored-by: Benedikt Kulmann <benedikt@kulmann.biz>
Co-authored-by: kobergj <juliankoberg@googlemail.com>
Co-authored-by: Martin <github@diemattels.at>
This commit is contained in:
Florian Schade
2024-03-05 14:11:18 +01:00
committed by GitHub
co-authored by Benedikt Kulmann kobergj Martin
parent 6ba9e4adf7
commit 6814c61506
63 changed files with 5835 additions and 130 deletions
+154
View File
@@ -0,0 +1,154 @@
package apps
import (
"encoding/json"
"errors"
"io/fs"
"path"
"dario.cat/mergo"
"github.com/go-playground/validator/v10"
"golang.org/x/exp/maps"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/ocis-pkg/x/path/filepathx"
"github.com/owncloud/ocis/v2/services/web/pkg/config"
)
var (
validate *validator.Validate
// ErrInvalidApp is the error when an app is invalid
ErrInvalidApp = errors.New("invalid app")
// ErrMissingManifest is the error when the manifest is missing
ErrMissingManifest = errors.New("missing manifest")
// ErrInvalidManifest is the error when the manifest is invalid
ErrInvalidManifest = errors.New("invalid manifest")
// ErrEntrypointDoesNotExist is the error when the entrypoint does not exist or is not a file
ErrEntrypointDoesNotExist = errors.New("entrypoint does not exist")
)
const (
// _manifest is the name of the manifest file for an application
_manifest = "manifest.json"
)
func init() {
validate = validator.New(validator.WithRequiredStructEnabled())
}
// Application contains the metadata of an application
type Application struct {
// ID is the unique identifier of the application
ID string
// Entrypoint is the entrypoint of the application within the bundle
Entrypoint string `json:"entrypoint" validate:"required"`
// Config contains the application-specific configuration
Config map[string]interface{} `json:"config,omitempty"`
}
// ToExternal converts an Application to an ExternalApp configuration
func (a Application) ToExternal(entrypoint string) config.ExternalApp {
return config.ExternalApp{
ID: a.ID,
Path: filepathx.JailJoin(entrypoint, a.Entrypoint),
Config: a.Config,
}
}
// List returns a list of applications from the given filesystems,
// individual filesystems are searched for applications, and the list is merged.
// Last finding gets priority in case of conflicts, so the order of the filesystems is important.
func List(logger log.Logger, data map[string]config.App, fSystems ...fs.FS) []Application {
registry := map[string]Application{}
for _, fSystem := range fSystems {
if fSystem == nil {
continue
}
entries, err := fs.ReadDir(fSystem, ".")
if err != nil {
// skip non-directory listings, every app needs to be contained inside a directory
continue
}
for _, entry := range entries {
var appData config.App
name := entry.Name()
// configuration for the application is optional, if it is not present, the default configuration is used
if data, ok := data[name]; ok {
appData = data
}
if appData.Disabled {
// if the app is disabled, skip it
continue
}
application, err := Build(fSystem, name, appData.Config)
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
}
// everything is fine, add the application to the list of applications
registry[name] = application
}
}
return maps.Values(registry)
}
func Build(fSystem fs.FS, id string, conf map[string]any) (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)
}
if err := validate.Struct(application); err != nil {
// the application is required to be valid
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)
// the entrypoint is jailed to the app directory
application.Entrypoint = filepathx.JailJoin(id, application.Entrypoint)
info, err := fs.Stat(fSystem, application.Entrypoint)
switch {
case err != nil:
return Application{}, errors.Join(err, ErrEntrypointDoesNotExist)
case info.IsDir():
return Application{}, ErrEntrypointDoesNotExist
}
application.ID = id
return application, nil
}
+181
View File
@@ -0,0 +1,181 @@
package apps_test
import (
"io/fs"
"testing"
"testing/fstest"
"github.com/onsi/gomega"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/services/web/pkg/apps"
"github.com/owncloud/ocis/v2/services/web/pkg/config"
)
func TestApplication_ToExternal(t *testing.T) {
g := gomega.NewWithT(t)
app := apps.Application{
ID: "app",
Entrypoint: "entrypoint.js",
Config: map[string]interface{}{
"foo": "bar",
},
}
externalApp := app.ToExternal("path")
g.Expect(externalApp.ID).To(gomega.Equal("app"))
g.Expect(externalApp.Path).To(gomega.Equal("path/entrypoint.js"))
g.Expect(externalApp.Config).To(gomega.Equal(app.Config))
}
func TestBuild(t *testing.T) {
g := gomega.NewWithT(t)
dir := &fstest.MapFile{
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": dir,
}, "app", map[string]any{})
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": &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(`{"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",
}))
}
func TestList(t *testing.T) {
g := gomega.NewWithT(t)
applications := apps.List(log.NopLogger(), map[string]config.App{})
g.Expect(applications).To(gomega.BeEmpty())
applications = apps.List(log.NopLogger(), map[string]config.App{}, nil)
g.Expect(applications).To(gomega.BeEmpty())
applications = apps.List(log.NopLogger(), map[string]config.App{}, fstest.MapFS{})
g.Expect(applications).To(gomega.BeEmpty())
dir := &fstest.MapFile{
Mode: fs.ModeDir,
}
applications = apps.List(log.NopLogger(), map[string]config.App{
"app": {
Disabled: true,
},
}, fstest.MapFS{
"app": dir,
})
g.Expect(applications).To(gomega.BeEmpty())
applications = apps.List(log.NopLogger(), map[string]config.App{
"app": {},
}, fstest.MapFS{
"app": dir,
})
g.Expect(applications).To(gomega.BeEmpty())
applications = apps.List(log.NopLogger(), map[string]config.App{
"app-3": {
Config: map[string]any{
"foo": "local conf 1",
"bar": "local conf 2",
},
},
}, fstest.MapFS{
"app-1": dir,
"app-1/entrypoint.js": &fstest.MapFile{},
"app-1/manifest.json": &fstest.MapFile{
Data: []byte(`{"id":"app-1", "entrypoint":"entrypoint.js", "config": {"foo": "fs1"}}`),
},
"app-2": dir,
"app-2/entrypoint.js": &fstest.MapFile{},
"app-2/manifest.json": &fstest.MapFile{
Data: []byte(`{"id":"app-2", "entrypoint":"entrypoint.js", "config": {"foo": "fs1"}}`),
},
}, fstest.MapFS{
"app-1": dir,
"app-1/entrypoint.js": &fstest.MapFile{},
"app-1/manifest.json": &fstest.MapFile{
Data: []byte(`{"id":"app-1", "entrypoint":"entrypoint.js", "config": {"foo": "fs2"}}`),
},
"app-3": dir,
"app-3/entrypoint.js": &fstest.MapFile{},
"app-3/manifest.json": &fstest.MapFile{
Data: []byte(`{"id":"app-3", "entrypoint":"entrypoint.js", "config": {"foo": "fs2"}}`),
},
})
g.Expect(len(applications)).To(gomega.Equal(3))
for _, application := range applications {
switch {
case application.Entrypoint == "app-1/entrypoint.js":
g.Expect(application.Config["foo"]).To(gomega.Equal("fs2"))
case application.Entrypoint == "app-2/entrypoint.js":
g.Expect(application.Config["foo"]).To(gomega.Equal("fs1"))
case application.Entrypoint == "app-3/entrypoint.js":
g.Expect(application.Config["foo"]).To(gomega.Equal("local conf 1"))
g.Expect(application.Config["bar"]).To(gomega.Equal("local conf 2"))
default:
t.Fatalf("unexpected application %s", application.Entrypoint)
}
}
}