[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:
co-authored by
Benedikt Kulmann
kobergj
Martin
parent
6ba9e4adf7
commit
6814c61506
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package assets
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
@@ -12,32 +13,42 @@ import (
|
||||
)
|
||||
|
||||
type fileServer struct {
|
||||
root http.FileSystem
|
||||
fsys http.FileSystem
|
||||
}
|
||||
|
||||
func FileServer(root http.FileSystem) http.Handler {
|
||||
return &fileServer{root}
|
||||
func FileServer(fsys fs.FS) http.Handler {
|
||||
return &fileServer{http.FS(fsys)}
|
||||
}
|
||||
|
||||
func (f *fileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
upath := path.Clean(path.Join("/", r.URL.Path))
|
||||
r.URL.Path = upath
|
||||
uPath := path.Clean(path.Join("/", r.URL.Path))
|
||||
r.URL.Path = uPath
|
||||
|
||||
fallbackIndex := func() {
|
||||
tryIndex := func() {
|
||||
r.URL.Path = "/index.html"
|
||||
|
||||
// not every fs contains a file named index.html,
|
||||
// therefore, we need to check if the file exists and stop the recursion if it doesn't
|
||||
file, err := f.fsys.Open(r.URL.Path)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
f.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
asset, err := f.root.Open(upath)
|
||||
asset, err := f.fsys.Open(uPath)
|
||||
if err != nil {
|
||||
fallbackIndex()
|
||||
tryIndex()
|
||||
return
|
||||
}
|
||||
defer asset.Close()
|
||||
|
||||
s, _ := asset.Stat()
|
||||
if s.IsDir() {
|
||||
fallbackIndex()
|
||||
tryIndex()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package assets_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/onsi/gomega"
|
||||
|
||||
"github.com/owncloud/ocis/v2/services/web/pkg/assets"
|
||||
)
|
||||
|
||||
func TestFileServer(t *testing.T) {
|
||||
g := gomega.NewWithT(t)
|
||||
recorderStatus := func(s int) string {
|
||||
return fmt.Sprintf("%03d %s", s, http.StatusText(s))
|
||||
}
|
||||
|
||||
{
|
||||
s := assets.FileServer(fstest.MapFS{})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/foo", nil)
|
||||
//defer req.Body.Close()
|
||||
s.ServeHTTP(w, req)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
g.Expect(res.Status).To(gomega.Equal(recorderStatus(http.StatusNotFound)))
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
url string
|
||||
fs fstest.MapFS
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "not found fallback",
|
||||
url: "/index.txt",
|
||||
fs: fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{
|
||||
Data: []byte("index file content"),
|
||||
},
|
||||
},
|
||||
expected: `<html><head><base href="/"/></head><body>index file content</body></html>`,
|
||||
},
|
||||
{
|
||||
name: "directory fallback",
|
||||
url: "/some-folder",
|
||||
fs: fstest.MapFS{
|
||||
"some-folder": &fstest.MapFile{
|
||||
Mode: fs.ModeDir,
|
||||
},
|
||||
"index.html": &fstest.MapFile{
|
||||
Data: []byte("index file content"),
|
||||
},
|
||||
},
|
||||
expected: `<html><head><base href="/"/></head><body>index file content</body></html>`,
|
||||
},
|
||||
{
|
||||
name: "index.html",
|
||||
url: "/index.html",
|
||||
fs: fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{
|
||||
Data: []byte("index file content"),
|
||||
},
|
||||
},
|
||||
expected: `<html><head><base href="/"/></head><body>index file content</body></html>`,
|
||||
},
|
||||
{
|
||||
name: "oidc-callback.html",
|
||||
url: "/oidc-callback.html",
|
||||
fs: fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{
|
||||
Data: []byte("oidc-callback file content"),
|
||||
},
|
||||
},
|
||||
expected: `<html><head><base href="/"/></head><body>oidc-callback file content</body></html>`,
|
||||
},
|
||||
{
|
||||
name: "oidc-silent-redirect.html",
|
||||
url: "/oidc-silent-redirect.html",
|
||||
fs: fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{
|
||||
Data: []byte("oidc-silent-redirect file content"),
|
||||
},
|
||||
},
|
||||
expected: `<html><head><base href="/"/></head><body>oidc-silent-redirect file content</body></html>`,
|
||||
},
|
||||
{
|
||||
name: "some-file.txt",
|
||||
url: "/some-file.txt",
|
||||
fs: fstest.MapFS{
|
||||
"some-file.txt": &fstest.MapFile{
|
||||
Data: []byte("some file content"),
|
||||
},
|
||||
},
|
||||
expected: "some file content",
|
||||
},
|
||||
} {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", tt.url, nil)
|
||||
assets.FileServer(tt.fs).ServeHTTP(w, req)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
g.Expect(res.Status).To(gomega.Equal(recorderStatus(http.StatusOK)))
|
||||
|
||||
data, err := io.ReadAll(res.Body)
|
||||
g.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
g.Expect(string(data)).To(gomega.Equal(tt.expected))
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,6 +21,7 @@ type Config struct {
|
||||
Asset Asset `yaml:"asset"`
|
||||
File string `yaml:"file" env:"WEB_UI_CONFIG_FILE" desc:"Read the ownCloud Web json based configuration from this path/file. The config file takes precedence over WEB_OPTION_xxx environment variables. See the text description for more details."`
|
||||
Web Web `yaml:"web"`
|
||||
Apps map[string]App
|
||||
|
||||
TokenManager *TokenManager `yaml:"token_manager"`
|
||||
|
||||
@@ -30,7 +31,9 @@ type Config struct {
|
||||
|
||||
// Asset defines the available asset configuration.
|
||||
type Asset struct {
|
||||
Path string `yaml:"path" env:"WEB_ASSET_PATH" desc:"Serve ownCloud Web assets from a path on the filesystem instead of the builtin assets."`
|
||||
DeprecatedPath string `yaml:"path" env:"WEB_ASSET_PATH" desc:"Serve ownCloud Web assets from a path on the filesystem instead of the builtin assets." deprecationVersion:"5.1.0" removalVersion:"6.0.0" deprecationInfo:"The WEB_ASSET_PATH is deprecated and will be removed in the future." deprecationReplacement:"Use WEB_ASSET_CORE_PATH instead."`
|
||||
CorePath string `yaml:"core_path" env:"WEB_ASSET_CORE_PATH" desc:"Serve ownCloud Web assets from a path on the filesystem instead of the builtin assets." introductionVersion:"5.1"`
|
||||
AppsPath string `yaml:"apps_path" env:"WEB_ASSET_APPS_PATH" desc:"Serve ownCloud Web apps assets from a path on the filesystem instead of the builtin assets." introductionVersion:"5.1"`
|
||||
}
|
||||
|
||||
// CustomStyle references additional css to be loaded into ownCloud Web.
|
||||
@@ -110,6 +113,12 @@ type Web struct {
|
||||
Config WebConfig `yaml:"config"`
|
||||
}
|
||||
|
||||
// App defines the individual app configuration.
|
||||
type App struct {
|
||||
Disabled bool `yaml:"disabled"`
|
||||
Config map[string]any `yaml:"config"`
|
||||
}
|
||||
|
||||
// TokenManager is the config for using the reva token manager
|
||||
type TokenManager struct {
|
||||
JWTSecret string `yaml:"jwt_secret" env:"OCIS_JWT_SECRET;WEB_JWT_SECRET" desc:"The secret to mint and validate jwt tokens."`
|
||||
|
||||
@@ -80,7 +80,8 @@ func DefaultConfig() *config.Config {
|
||||
Name: "web",
|
||||
},
|
||||
Asset: config.Asset{
|
||||
Path: filepath.Join(defaults.BaseDataPath(), "web/assets"),
|
||||
CorePath: filepath.Join(defaults.BaseDataPath(), "web/assets/core"),
|
||||
AppsPath: filepath.Join(defaults.BaseDataPath(), "web/assets/apps"),
|
||||
},
|
||||
GatewayAddress: "com.owncloud.api.gateway",
|
||||
Web: config.Web{
|
||||
|
||||
@@ -4,11 +4,11 @@ import (
|
||||
"errors"
|
||||
|
||||
ociscfg "github.com/owncloud/ocis/v2/ocis-pkg/config"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/config/envdecode"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/shared"
|
||||
"github.com/owncloud/ocis/v2/services/web/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/web/pkg/config/defaults"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/config/envdecode"
|
||||
)
|
||||
|
||||
// ParseConfig loads configuration from known paths.
|
||||
@@ -28,6 +28,12 @@ func ParseConfig(cfg *config.Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
// apps are a special case, as they are not part of the main config, but are loaded from a separate config file
|
||||
_, err = ociscfg.BindSourcesToStructs("apps", &cfg.Apps)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defaults.Sanitize(cfg)
|
||||
|
||||
return Validate(cfg)
|
||||
@@ -37,5 +43,20 @@ func Validate(cfg *config.Config) error {
|
||||
if cfg.TokenManager.JWTSecret == "" {
|
||||
return shared.MissingJWTTokenError(cfg.Service.Name)
|
||||
}
|
||||
|
||||
// deprecation: migration requested
|
||||
// check if the config still uses the deprecated asset path, if so,
|
||||
// log a warning and copy the value to the setting that is actually used
|
||||
// this is to ensure a smooth transition from the old to the new core asset path (pre 5.1 to 5.1)
|
||||
if cfg.Asset.DeprecatedPath != "" {
|
||||
if cfg.Asset.CorePath == "" {
|
||||
cfg.Asset.CorePath = cfg.Asset.DeprecatedPath
|
||||
}
|
||||
|
||||
// message should be logged to the console,
|
||||
// do not use a logger here because the message MUST be visible independent of the log level
|
||||
log.Deprecation("WEB_ASSET_PATH is deprecated and will be removed in the future. Use WEB_ASSET_CORE_PATH instead.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,17 +2,27 @@ package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
|
||||
"github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"go-micro.dev/v4"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/cors"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/middleware"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/registry"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/service/http"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/version"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/x/io/fsx"
|
||||
"github.com/owncloud/ocis/v2/services/web"
|
||||
"github.com/owncloud/ocis/v2/services/web/pkg/apps"
|
||||
webmid "github.com/owncloud/ocis/v2/services/web/pkg/middleware"
|
||||
svc "github.com/owncloud/ocis/v2/services/web/pkg/service/v0"
|
||||
"go-micro.dev/v4"
|
||||
)
|
||||
|
||||
var (
|
||||
// _customAppsEndpoint path is used to make app artifacts available by the web service.
|
||||
_customAppsEndpoint = "/assets/apps"
|
||||
)
|
||||
|
||||
// Server initializes the http service and server.
|
||||
@@ -46,8 +56,28 @@ func Server(opts ...Option) (http.Service, error) {
|
||||
return http.Service{}, err
|
||||
}
|
||||
|
||||
coreFS := fsx.NewFallbackFS(
|
||||
fsx.NewBasePathFs(fsx.NewOsFs(), options.Config.Asset.CorePath),
|
||||
fsx.NewBasePathFs(fsx.FromIOFS(web.Assets), "assets/core"),
|
||||
)
|
||||
appsFS := fsx.NewFallbackFS(
|
||||
fsx.NewReadOnlyFs(fsx.NewBasePathFs(fsx.NewOsFs(), options.Config.Asset.AppsPath)),
|
||||
fsx.NewBasePathFs(fsx.FromIOFS(web.Assets), "assets/apps"),
|
||||
)
|
||||
|
||||
// build and inject the list of applications into the config
|
||||
for _, application := range apps.List(options.Logger, options.Config.Apps, appsFS.Secondary().IOFS(), appsFS.Primary().IOFS()) {
|
||||
options.Config.Web.Config.ExternalApps = append(
|
||||
options.Config.Web.Config.ExternalApps,
|
||||
application.ToExternal(path.Join(options.Config.HTTP.Root, _customAppsEndpoint)),
|
||||
)
|
||||
}
|
||||
|
||||
handle := svc.NewService(
|
||||
svc.Logger(options.Logger),
|
||||
svc.CoreFS(coreFS),
|
||||
svc.AppFS(appsFS.IOFS()),
|
||||
svc.AppsHTTPEndpoint(_customAppsEndpoint),
|
||||
svc.Config(options.Config),
|
||||
svc.GatewaySelector(gatewaySelector),
|
||||
svc.Middleware(
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
permissionsapi "github.com/cs3org/go-cs3apis/cs3/permissions/v1beta1"
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
revactx "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -67,7 +68,7 @@ func (p Web) UploadLogo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
fp := filepath.Join("branding", filepath.Join("/", fileHeader.Filename))
|
||||
err = p.storeAsset(fp, file)
|
||||
err = afero.WriteReader(p.coreFS, fp, file)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
@@ -109,7 +110,7 @@ func (p Web) ResetLogo(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
f, err := p.fs.OpenEmbedded(_themesConfigPath)
|
||||
f, err := p.coreFS.Secondary().Open(_themesConfigPath)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
@@ -128,17 +129,6 @@ func (p Web) ResetLogo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (p Web) storeAsset(name string, asset io.Reader) error {
|
||||
dst, err := p.fs.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
_, err = io.Copy(dst, asset)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p Web) getLogoPath(r io.Reader) (string, error) {
|
||||
// This decoding of the themes.json file is not optimal. If we need to decode it for other
|
||||
// usecases as well we should consider decoding to a struct.
|
||||
@@ -159,7 +149,7 @@ func (p Web) getLogoPath(r io.Reader) (string, error) {
|
||||
}
|
||||
|
||||
func (p Web) updateLogoThemeConfig(logoPath string) error {
|
||||
f, err := p.fs.Open(_themesConfigPath)
|
||||
f, err := p.coreFS.Open(_themesConfigPath)
|
||||
if err == nil {
|
||||
defer f.Close()
|
||||
}
|
||||
@@ -184,10 +174,11 @@ func (p Web) updateLogoThemeConfig(logoPath string) error {
|
||||
logoCfg["login"] = logoPath
|
||||
logoCfg["topbar"] = logoPath
|
||||
|
||||
dst, err := p.fs.Create(_themesConfigPath)
|
||||
dst, err := p.coreFS.Create(_themesConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
return json.NewEncoder(dst).Encode(m)
|
||||
}
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/services/web/pkg/config"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/x/io/fsx"
|
||||
"github.com/owncloud/ocis/v2/services/web/pkg/config"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
// Options define the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Config *config.Config
|
||||
Middleware []func(http.Handler) http.Handler
|
||||
GatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
TraceProvider trace.TracerProvider
|
||||
Logger log.Logger
|
||||
Config *config.Config
|
||||
Middleware []func(http.Handler) http.Handler
|
||||
GatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
TraceProvider trace.TracerProvider
|
||||
AppFS fs.FS
|
||||
AppsHTTPEndpoint string
|
||||
CoreFS *fsx.FallbackFS
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
@@ -67,3 +73,24 @@ func TraceProvider(val trace.TracerProvider) Option {
|
||||
o.TraceProvider = val
|
||||
}
|
||||
}
|
||||
|
||||
// AppFS provides a function to set the appFS option.
|
||||
func AppFS(val fs.FS) Option {
|
||||
return func(o *Options) {
|
||||
o.AppFS = val
|
||||
}
|
||||
}
|
||||
|
||||
// AppsHTTPEndpoint provides a function to set the appsHTTPEndpoint option.
|
||||
func AppsHTTPEndpoint(val string) Option {
|
||||
return func(o *Options) {
|
||||
o.AppsHTTPEndpoint = val
|
||||
}
|
||||
}
|
||||
|
||||
// CoreFS provides a function to set the coreFS option.
|
||||
func CoreFS(val *fsx.FallbackFS) Option {
|
||||
return func(o *Options) {
|
||||
o.CoreFS = val
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package svc
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -12,15 +14,15 @@ import (
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/riandyrn/otelchi"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/account"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/assetsfs"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/middleware"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/tracing"
|
||||
"github.com/owncloud/ocis/v2/services/web"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/x/io/fsx"
|
||||
"github.com/owncloud/ocis/v2/services/web/pkg/assets"
|
||||
"github.com/owncloud/ocis/v2/services/web/pkg/config"
|
||||
"github.com/riandyrn/otelchi"
|
||||
)
|
||||
|
||||
// ErrConfigInvalid is returned when the config parse is invalid.
|
||||
@@ -49,11 +51,12 @@ func NewService(opts ...Option) Service {
|
||||
otelchi.WithPropagators(tracing.GetPropagator()),
|
||||
),
|
||||
)
|
||||
|
||||
svc := Web{
|
||||
logger: options.Logger,
|
||||
config: options.Config,
|
||||
mux: m,
|
||||
fs: assetsfs.New(web.Assets, options.Config.Asset.Path, options.Logger),
|
||||
coreFS: options.CoreFS,
|
||||
gatewaySelector: options.GatewaySelector,
|
||||
}
|
||||
|
||||
@@ -67,7 +70,16 @@ func NewService(opts ...Option) Service {
|
||||
r.Post("/", svc.UploadLogo)
|
||||
r.Delete("/", svc.ResetLogo)
|
||||
})
|
||||
r.Mount("/", svc.Static(options.Config.HTTP.CacheTTL))
|
||||
r.Mount(options.AppsHTTPEndpoint, svc.Static(
|
||||
options.AppFS,
|
||||
path.Join(svc.config.HTTP.Root, options.AppsHTTPEndpoint),
|
||||
options.Config.HTTP.CacheTTL,
|
||||
))
|
||||
r.Mount("/", svc.Static(
|
||||
svc.coreFS.IOFS(),
|
||||
svc.config.HTTP.Root,
|
||||
options.Config.HTTP.CacheTTL,
|
||||
))
|
||||
})
|
||||
|
||||
_ = chi.Walk(m, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
|
||||
@@ -78,12 +90,12 @@ func NewService(opts ...Option) Service {
|
||||
return svc
|
||||
}
|
||||
|
||||
// Web defines implements the business logic for Service.
|
||||
// Web defines the handlers for the web service.
|
||||
type Web struct {
|
||||
logger log.Logger
|
||||
config *config.Config
|
||||
mux *chi.Mux
|
||||
fs *assetsfs.FileSystem
|
||||
coreFS *fsx.FallbackFS
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
}
|
||||
|
||||
@@ -127,8 +139,8 @@ func (p Web) Config(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
|
||||
// Static simply serves all static files.
|
||||
func (p Web) Static(ttl int) http.HandlerFunc {
|
||||
rootWithSlash := p.config.HTTP.Root
|
||||
func (p Web) Static(f fs.FS, root string, ttl int) http.HandlerFunc {
|
||||
rootWithSlash := root
|
||||
|
||||
if !strings.HasSuffix(rootWithSlash, "/") {
|
||||
rootWithSlash = rootWithSlash + "/"
|
||||
@@ -136,7 +148,7 @@ func (p Web) Static(ttl int) http.HandlerFunc {
|
||||
|
||||
static := http.StripPrefix(
|
||||
rootWithSlash,
|
||||
assets.FileServer(p.fs),
|
||||
assets.FileServer(f),
|
||||
)
|
||||
|
||||
lastModified := time.Now().UTC().Format(http.TimeFormat)
|
||||
|
||||
Reference in New Issue
Block a user