From 20e4e56d2846d4a0ed93f397e9dfa417c599eaf0 Mon Sep 17 00:00:00 2001 From: David Christofas Date: Tue, 7 Feb 2023 17:05:36 +0100 Subject: [PATCH 01/10] implement first prototype of the logo upload API --- ocis-pkg/assetsfs/assetsfs.go | 42 +++++++--------- services/web/pkg/assets/option.go | 50 ------------------- services/web/pkg/assets/server.go | 3 +- .../web/pkg/config/defaults/defaultconfig.go | 4 +- services/web/pkg/service/v0/instrument.go | 5 ++ services/web/pkg/service/v0/logging.go | 5 ++ services/web/pkg/service/v0/service.go | 44 +++++++++++++--- services/web/pkg/service/v0/tracing.go | 5 ++ 8 files changed, 76 insertions(+), 82 deletions(-) delete mode 100644 services/web/pkg/assets/option.go diff --git a/ocis-pkg/assetsfs/assetsfs.go b/ocis-pkg/assetsfs/assetsfs.go index 1b6e9cbacd7..311f904662e 100644 --- a/ocis-pkg/assetsfs/assetsfs.go +++ b/ocis-pkg/assetsfs/assetsfs.go @@ -1,12 +1,11 @@ package assetsfs import ( - "embed" "fmt" "io/fs" "net/http" "os" - "path" + "path/filepath" "github.com/owncloud/ocis/v2/ocis-pkg/log" ) @@ -21,22 +20,31 @@ type FileSystem struct { // Open checks if assetPath is set and tries to load from there. Falls back to fs if that is not possible func (f *FileSystem) Open(original string) (http.File, error) { if f.assetPath != "" { - file, err := read(f.assetPath, original) + file, err := os.Open(filepath.Join(f.assetPath, original)) if err == nil { return file, nil } - f.log.Warn(). - Str("path", f.assetPath). - Str("filename", original). - Str("error", err.Error()). - Msg("error reading from assetPath") } - return f.fs.Open(original) } +// Create creates a new file in the assetPath +func (f *FileSystem) Create(name string) (*os.File, error) { + fullPath := f.jailPath(name) + if err := os.MkdirAll(filepath.Dir(fullPath), 0770); err != nil { + return nil, err + } + return os.Create(fullPath) +} + +// jailPath returns the fullPath `/`. It makes sure that the path is +// always under `` to prevent directory traversal. +func (f *FileSystem) jailPath(name string) string { + return filepath.Join(f.assetPath, filepath.Join("/", name)) +} + // New initializes a new FileSystem. Quits on error -func New(embedFS embed.FS, assetPath string, logger log.Logger) *FileSystem { +func New(embedFS fs.FS, assetPath string, logger log.Logger) *FileSystem { f, err := fs.Sub(embedFS, "assets") if err != nil { fmt.Println("Cannot load subtree fs:", err.Error()) @@ -49,17 +57,3 @@ func New(embedFS embed.FS, assetPath string, logger log.Logger) *FileSystem { log: logger, } } - -// tries to read file from disk or errors -func read(assetPath string, fileName string) (http.File, error) { - if stat, err := os.Stat(assetPath); err != nil || !stat.IsDir() { - return nil, fmt.Errorf("can't load asset path: %s", err) - } - - p := path.Join(assetPath, fileName) - if _, err := os.Stat(p); err != nil { - return nil, err - } - - return os.Open(p) -} diff --git a/services/web/pkg/assets/option.go b/services/web/pkg/assets/option.go deleted file mode 100644 index c839c29dddb..00000000000 --- a/services/web/pkg/assets/option.go +++ /dev/null @@ -1,50 +0,0 @@ -package assets - -import ( - "net/http" - - "github.com/owncloud/ocis/v2/ocis-pkg/assetsfs" - "github.com/owncloud/ocis/v2/ocis-pkg/log" - "github.com/owncloud/ocis/v2/services/web" - "github.com/owncloud/ocis/v2/services/web/pkg/config" -) - -// New returns a new http filesystem to serve assets. -func New(opts ...Option) http.FileSystem { - options := newOptions(opts...) - return assetsfs.New(web.Assets, options.Config.Asset.Path, options.Logger) -} - -// Option defines a single option function. -type Option func(o *Options) - -// Options defines the available options for this package. -type Options struct { - Logger log.Logger - Config *config.Config -} - -// newOptions initializes the available default options. -func newOptions(opts ...Option) Options { - opt := Options{} - - for _, o := range opts { - o(&opt) - } - - return opt -} - -// Logger provides a function to set the logger option. -func Logger(val log.Logger) Option { - return func(o *Options) { - o.Logger = val - } -} - -// Config provides a function to set the config option. -func Config(val *config.Config) Option { - return func(o *Options) { - o.Config = val - } -} diff --git a/services/web/pkg/assets/server.go b/services/web/pkg/assets/server.go index 7b139e81986..0e487cc29dd 100644 --- a/services/web/pkg/assets/server.go +++ b/services/web/pkg/assets/server.go @@ -2,12 +2,13 @@ package assets import ( "bytes" - "golang.org/x/net/html" "io" "mime" "net/http" "path" "path/filepath" + + "golang.org/x/net/html" ) type fileServer struct { diff --git a/services/web/pkg/config/defaults/defaultconfig.go b/services/web/pkg/config/defaults/defaultconfig.go index f32c1f34dc4..e4579166f86 100644 --- a/services/web/pkg/config/defaults/defaultconfig.go +++ b/services/web/pkg/config/defaults/defaultconfig.go @@ -1,8 +1,10 @@ package defaults import ( + "path/filepath" "strings" + "github.com/owncloud/ocis/v2/ocis-pkg/config/defaults" "github.com/owncloud/ocis/v2/services/web/pkg/config" ) @@ -31,7 +33,7 @@ func DefaultConfig() *config.Config { Name: "web", }, Asset: config.Asset{ - Path: "", + Path: filepath.Join(defaults.BaseDataPath(), "web/assets"), }, Web: config.Web{ Path: "", diff --git a/services/web/pkg/service/v0/instrument.go b/services/web/pkg/service/v0/instrument.go index 2d89f069d6e..72c64082cee 100644 --- a/services/web/pkg/service/v0/instrument.go +++ b/services/web/pkg/service/v0/instrument.go @@ -28,3 +28,8 @@ func (i instrument) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (i instrument) Config(w http.ResponseWriter, r *http.Request) { i.next.Config(w, r) } + +// UploadLogo implements the Service interface. +func (i instrument) UploadLogo(w http.ResponseWriter, r *http.Request) { + i.next.UploadLogo(w, r) +} diff --git a/services/web/pkg/service/v0/logging.go b/services/web/pkg/service/v0/logging.go index 9e2c9e31ea4..09ab4145e0b 100644 --- a/services/web/pkg/service/v0/logging.go +++ b/services/web/pkg/service/v0/logging.go @@ -28,3 +28,8 @@ func (l logging) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (l logging) Config(w http.ResponseWriter, r *http.Request) { l.next.Config(w, r) } + +// UploadLogo implements the Service interface. +func (l logging) UploadLogo(w http.ResponseWriter, r *http.Request) { + l.next.UploadLogo(w, r) +} diff --git a/services/web/pkg/service/v0/service.go b/services/web/pkg/service/v0/service.go index aeb1c3083df..dc4cfce938b 100644 --- a/services/web/pkg/service/v0/service.go +++ b/services/web/pkg/service/v0/service.go @@ -2,16 +2,21 @@ package svc import ( "encoding/json" + "errors" "fmt" + "io" "net/http" "net/url" "os" + "path/filepath" "strconv" "strings" "time" "github.com/go-chi/chi/v5" + "github.com/owncloud/ocis/v2/ocis-pkg/assetsfs" "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/web" "github.com/owncloud/ocis/v2/services/web/pkg/assets" "github.com/owncloud/ocis/v2/services/web/pkg/config" ) @@ -25,6 +30,7 @@ var ( type Service interface { ServeHTTP(http.ResponseWriter, *http.Request) Config(http.ResponseWriter, *http.Request) + UploadLogo(http.ResponseWriter, *http.Request) } // NewService returns a service implementation for Service. @@ -38,10 +44,12 @@ func NewService(opts ...Option) Service { logger: options.Logger, config: options.Config, mux: m, + fs: assetsfs.New(web.Assets, options.Config.Asset.Path, options.Logger), } m.Route(options.Config.HTTP.Root, func(r chi.Router) { r.Get("/config.json", svc.Config) + r.Post("/branding/logo", svc.UploadLogo) r.Mount("/", svc.Static(options.Config.HTTP.CacheTTL)) }) @@ -58,6 +66,7 @@ type Web struct { logger log.Logger config *config.Config mux *chi.Mux + fs *assetsfs.FileSystem } // ServeHTTP implements the Service interface. @@ -131,12 +140,7 @@ func (p Web) Static(ttl int) http.HandlerFunc { static := http.StripPrefix( rootWithSlash, - assets.FileServer( - assets.New( - assets.Logger(p.logger), - assets.Config(p.config), - ), - ), + assets.FileServer(p.fs), ) lastModified := time.Now().UTC().Format(http.TimeFormat) @@ -161,3 +165,31 @@ func (p Web) Static(ttl int) http.HandlerFunc { static.ServeHTTP(w, r) } } + +// UploadLogo implements the endpoint to upload a custom logo for the oCIS instance. +func (p Web) UploadLogo(w http.ResponseWriter, r *http.Request) { + file, fileHeader, err := r.FormFile("logo") + if err != nil { + if errors.Is(err, http.ErrMissingFile) { + w.WriteHeader(http.StatusBadRequest) + } + w.WriteHeader(http.StatusInternalServerError) + return + } + defer file.Close() + + dst, err := p.fs.Create(filepath.Join("branding", filepath.Join("/", fileHeader.Filename))) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + defer dst.Close() + + _, err = io.Copy(dst, file) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/services/web/pkg/service/v0/tracing.go b/services/web/pkg/service/v0/tracing.go index 0b44df2bfb3..2889715bdce 100644 --- a/services/web/pkg/service/v0/tracing.go +++ b/services/web/pkg/service/v0/tracing.go @@ -24,3 +24,8 @@ func (t tracing) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (t tracing) Config(w http.ResponseWriter, r *http.Request) { t.next.Config(w, r) } + +// UploadLogo implements the Service interface. +func (t tracing) UploadLogo(w http.ResponseWriter, r *http.Request) { + t.next.UploadLogo(w, r) +} From 85f79dd7655f46d6d45f746ea9695456786c2be5 Mon Sep 17 00:00:00 2001 From: David Christofas Date: Wed, 8 Feb 2023 09:58:16 +0100 Subject: [PATCH 02/10] first implementation of the logo update --- services/web/pkg/service/v0/service.go | 28 +++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/services/web/pkg/service/v0/service.go b/services/web/pkg/service/v0/service.go index dc4cfce938b..8767304c82b 100644 --- a/services/web/pkg/service/v0/service.go +++ b/services/web/pkg/service/v0/service.go @@ -41,10 +41,9 @@ func NewService(opts ...Option) Service { m.Use(options.Middleware...) svc := Web{ - logger: options.Logger, - config: options.Config, - mux: m, - fs: assetsfs.New(web.Assets, options.Config.Asset.Path, options.Logger), + logger: options.Logger, config: options.Config, + mux: m, + fs: assetsfs.New(web.Assets, options.Config.Asset.Path, options.Logger), } m.Route(options.Config.HTTP.Root, func(r chi.Router) { @@ -178,7 +177,8 @@ func (p Web) UploadLogo(w http.ResponseWriter, r *http.Request) { } defer file.Close() - dst, err := p.fs.Create(filepath.Join("branding", filepath.Join("/", fileHeader.Filename))) + fp := filepath.Join("branding", filepath.Join("/", fileHeader.Filename)) + dst, err := p.fs.Create(fp) if err != nil { w.WriteHeader(http.StatusInternalServerError) return @@ -191,5 +191,23 @@ func (p Web) UploadLogo(w http.ResponseWriter, r *http.Request) { return } + f, err := p.fs.Open("themes/owncloud/theme.json") + if err == nil { + defer f.Close() + } + var m map[string]interface{} + _ = json.NewDecoder(f).Decode(&m) + logos := m["default"].(map[string]interface{})["logo"].(map[string]interface{}) + logos["login"] = fp + logos["topbar"] = fp + + dst, err = p.fs.Create("themes/owncloud/theme.json") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + _ = json.NewEncoder(dst).Encode(m) + w.WriteHeader(http.StatusOK) } From 3a685b6f50dc2dff12dd5958e982efdee91ac6e5 Mon Sep 17 00:00:00 2001 From: David Christofas Date: Wed, 8 Feb 2023 15:24:57 +0100 Subject: [PATCH 03/10] clean up the logo upload --- services/web/pkg/service/v0/branding.go | 100 ++++++++++++++++++++++++ services/web/pkg/service/v0/service.go | 50 ------------ 2 files changed, 100 insertions(+), 50 deletions(-) create mode 100644 services/web/pkg/service/v0/branding.go diff --git a/services/web/pkg/service/v0/branding.go b/services/web/pkg/service/v0/branding.go new file mode 100644 index 00000000000..0201487eab2 --- /dev/null +++ b/services/web/pkg/service/v0/branding.go @@ -0,0 +1,100 @@ +package svc + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "path/filepath" +) + +var ( + errInvalidThemeConfig = errors.New("invalid themes config") + _themesConfigPath = filepath.FromSlash("themes/owncloud/theme.json") +) + +// UploadLogo implements the endpoint to upload a custom logo for the oCIS instance. +func (p Web) UploadLogo(w http.ResponseWriter, r *http.Request) { + file, fileHeader, err := r.FormFile("logo") + if err != nil { + if errors.Is(err, http.ErrMissingFile) { + w.WriteHeader(http.StatusBadRequest) + } + w.WriteHeader(http.StatusInternalServerError) + return + } + defer file.Close() + + fp := filepath.Join("branding", filepath.Join("/", fileHeader.Filename)) + err = p.storeAsset(fp, file) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + err = p.updateLogoThemeConfig(fp) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +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) updateLogoThemeConfig(logoPath string) error { + f, err := p.fs.Open(_themesConfigPath) + if err == nil { + defer f.Close() + } + var m map[string]interface{} + _ = json.NewDecoder(f).Decode(&m) + + webCfg, ok := m["web"].(map[string]interface{}) + if !ok { + return errInvalidThemeConfig + } + + defaultCfg, ok := webCfg["default"].(map[string]interface{}) + if !ok { + return errInvalidThemeConfig + } + + logoCfg, ok := defaultCfg["logo"].(map[string]interface{}) + if !ok { + return errInvalidThemeConfig + } + + logoCfg["login"] = logoPath + logoCfg["topbar"] = logoPath + + defaultDarkCfg, ok := webCfg["default-dark"].(map[string]interface{}) + if !ok { + return errInvalidThemeConfig + } + + logoDarkCfg, ok := defaultDarkCfg["logo"].(map[string]interface{}) + if !ok { + return errInvalidThemeConfig + } + + logoDarkCfg["login"] = logoPath + logoDarkCfg["topbar"] = logoPath + + dst, err := p.fs.Create(_themesConfigPath) + if err != nil { + return err + } + + return json.NewEncoder(dst).Encode(m) +} diff --git a/services/web/pkg/service/v0/service.go b/services/web/pkg/service/v0/service.go index 8767304c82b..ef151d2bb27 100644 --- a/services/web/pkg/service/v0/service.go +++ b/services/web/pkg/service/v0/service.go @@ -2,13 +2,10 @@ package svc import ( "encoding/json" - "errors" "fmt" - "io" "net/http" "net/url" "os" - "path/filepath" "strconv" "strings" "time" @@ -164,50 +161,3 @@ func (p Web) Static(ttl int) http.HandlerFunc { static.ServeHTTP(w, r) } } - -// UploadLogo implements the endpoint to upload a custom logo for the oCIS instance. -func (p Web) UploadLogo(w http.ResponseWriter, r *http.Request) { - file, fileHeader, err := r.FormFile("logo") - if err != nil { - if errors.Is(err, http.ErrMissingFile) { - w.WriteHeader(http.StatusBadRequest) - } - w.WriteHeader(http.StatusInternalServerError) - return - } - defer file.Close() - - fp := filepath.Join("branding", filepath.Join("/", fileHeader.Filename)) - dst, err := p.fs.Create(fp) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - defer dst.Close() - - _, err = io.Copy(dst, file) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - f, err := p.fs.Open("themes/owncloud/theme.json") - if err == nil { - defer f.Close() - } - var m map[string]interface{} - _ = json.NewDecoder(f).Decode(&m) - logos := m["default"].(map[string]interface{})["logo"].(map[string]interface{}) - logos["login"] = fp - logos["topbar"] = fp - - dst, err = p.fs.Create("themes/owncloud/theme.json") - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - _ = json.NewEncoder(dst).Encode(m) - - w.WriteHeader(http.StatusOK) -} From 6e83effb45a08685b87a6dfd80731f65879f0367 Mon Sep 17 00:00:00 2001 From: David Christofas Date: Wed, 8 Feb 2023 16:05:38 +0100 Subject: [PATCH 04/10] add simple filetype validator for the logo upload --- services/web/pkg/service/v0/branding.go | 28 +++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/services/web/pkg/service/v0/branding.go b/services/web/pkg/service/v0/branding.go index 0201487eab2..2ebe6bf5e65 100644 --- a/services/web/pkg/service/v0/branding.go +++ b/services/web/pkg/service/v0/branding.go @@ -5,12 +5,19 @@ import ( "errors" "io" "net/http" + "path" "path/filepath" ) var ( - errInvalidThemeConfig = errors.New("invalid themes config") - _themesConfigPath = filepath.FromSlash("themes/owncloud/theme.json") + errInvalidThemeConfig = errors.New("invalid themes config") + _themesConfigPath = filepath.FromSlash("themes/owncloud/theme.json") + _allowedExtensionMediatypes = map[string]string{ + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + } ) // UploadLogo implements the endpoint to upload a custom logo for the oCIS instance. @@ -25,6 +32,12 @@ func (p Web) UploadLogo(w http.ResponseWriter, r *http.Request) { } defer file.Close() + mediatype := fileHeader.Header.Get("Content-Type") + if !allowedFiletype(fileHeader.Filename, mediatype) { + w.WriteHeader(http.StatusBadRequest) + return + } + fp := filepath.Join("branding", filepath.Join("/", fileHeader.Filename)) err = p.storeAsset(fp, file) if err != nil { @@ -57,6 +70,9 @@ func (p Web) updateLogoThemeConfig(logoPath string) error { if err == nil { defer f.Close() } + + // 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. var m map[string]interface{} _ = json.NewDecoder(f).Decode(&m) @@ -98,3 +114,11 @@ func (p Web) updateLogoThemeConfig(logoPath string) error { return json.NewEncoder(dst).Encode(m) } + +func allowedFiletype(filename, mediatype string) bool { + ext := path.Ext(filename) + + // Check if we allow that extension and if the mediatype matches the extension + mt, ok := _allowedExtensionMediatypes[ext] + return ok && mt == mediatype +} From 13dfc06f6397e220f80be9d1ee2bd9ac3eb0ec31 Mon Sep 17 00:00:00 2001 From: David Christofas Date: Wed, 8 Feb 2023 16:49:34 +0100 Subject: [PATCH 05/10] add proxy route for the branding endpoint --- services/proxy/pkg/config/defaults/defaultconfig.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/services/proxy/pkg/config/defaults/defaultconfig.go b/services/proxy/pkg/config/defaults/defaultconfig.go index 14bc159e79e..d9f69f60048 100644 --- a/services/proxy/pkg/config/defaults/defaultconfig.go +++ b/services/proxy/pkg/config/defaults/defaultconfig.go @@ -1,6 +1,7 @@ package defaults import ( + "net/http" "path" "strings" @@ -198,6 +199,11 @@ func DefaultPolicies() []config.Policy { Endpoint: "/api/v0/settings", Service: "com.owncloud.web.settings", }, + { + Method: http.MethodPost, + Endpoint: "/branding/logo", + Service: "com.owncloud.web.web", + }, }, }, } From 7c17ddb0b0ed75137bc76cbc5c111f7750600d6b Mon Sep 17 00:00:00 2001 From: David Christofas Date: Thu, 9 Feb 2023 16:18:44 +0100 Subject: [PATCH 06/10] add a permission check to the logo upload --- .../pkg/config/defaults/defaultconfig.go | 10 +++---- services/settings/pkg/service/v0/settings.go | 23 +++++++++++++++ .../settings/pkg/store/defaults/defaults.go | 20 +++++++++++++ services/web/pkg/config/config.go | 25 +++++++++++------ .../web/pkg/config/defaults/defaultconfig.go | 8 ++++++ services/web/pkg/config/parser/parse.go | 4 +++ services/web/pkg/server/http/server.go | 7 +++++ services/web/pkg/service/v0/branding.go | 22 +++++++++++++++ services/web/pkg/service/v0/option.go | 15 ++++++++-- services/web/pkg/service/v0/service.go | 28 +++++++++++++------ 10 files changed, 137 insertions(+), 25 deletions(-) diff --git a/services/proxy/pkg/config/defaults/defaultconfig.go b/services/proxy/pkg/config/defaults/defaultconfig.go index d9f69f60048..af5a712049e 100644 --- a/services/proxy/pkg/config/defaults/defaultconfig.go +++ b/services/proxy/pkg/config/defaults/defaultconfig.go @@ -1,7 +1,6 @@ package defaults import ( - "net/http" "path" "strings" @@ -84,6 +83,10 @@ func DefaultPolicies() []config.Policy { Service: "com.owncloud.web.idp", Unprotected: true, }, + { + Endpoint: "/branding/logo", + Service: "com.owncloud.web.web", + }, { Endpoint: "/konnect/", Service: "com.owncloud.web.idp", @@ -199,11 +202,6 @@ func DefaultPolicies() []config.Policy { Endpoint: "/api/v0/settings", Service: "com.owncloud.web.settings", }, - { - Method: http.MethodPost, - Endpoint: "/branding/logo", - Service: "com.owncloud.web.web", - }, }, }, } diff --git a/services/settings/pkg/service/v0/settings.go b/services/settings/pkg/service/v0/settings.go index 481ff078449..411e6310bfc 100644 --- a/services/settings/pkg/service/v0/settings.go +++ b/services/settings/pkg/service/v0/settings.go @@ -57,6 +57,11 @@ const ( SelfManagementPermissionID string = "e03070e9-4362-4cc6-a872-1c7cb2eb2b8e" // SelfManagementPermissionName is the hardcoded setting name for the self management permission SelfManagementPermissionName string = "self-management" + + // ChangeLogoPermissionID is the hardcoded setting UUID for the change-logo permission + ChangeLogoPermissionID string = "ed83fc10-1f54-4a9e-b5a7-fb517f5f3e01" + // ChangeLogoPermissionName is the hardcoded setting name for the change-logo permission + ChangeLogoPermissionName string = "change-logo" ) // generateBundlesDefaultRoles bootstraps the default roles. @@ -438,6 +443,24 @@ func generatePermissionRequests() []*settingssvc.AddSettingToBundleRequest { }, }, }, + { + BundleId: BundleUUIDRoleAdmin, + Setting: &settingsmsg.Setting{ + Id: ChangeLogoPermissionID, + Name: ChangeLogoPermissionName, + DisplayName: "Change logo", + Description: "This permission permits to change the system logo.", + Resource: &settingsmsg.Resource{ + Type: settingsmsg.Resource_TYPE_SYSTEM, + }, + Value: &settingsmsg.Setting_PermissionValue{ + PermissionValue: &settingsmsg.Permission{ + Operation: settingsmsg.Permission_OPERATION_READWRITE, + Constraint: settingsmsg.Permission_CONSTRAINT_ALL, + }, + }, + }, + }, { BundleId: BundleUUIDRoleSpaceAdmin, Setting: &settingsmsg.Setting{ diff --git a/services/settings/pkg/store/defaults/defaults.go b/services/settings/pkg/store/defaults/defaults.go index 52cc0b66963..6486b5415b8 100644 --- a/services/settings/pkg/store/defaults/defaults.go +++ b/services/settings/pkg/store/defaults/defaults.go @@ -82,6 +82,11 @@ const ( SelfManagementPermissionID string = "e03070e9-4362-4cc6-a872-1c7cb2eb2b8e" // SelfManagementPermissionName is the hardcoded setting name for the self management permission SelfManagementPermissionName string = "self-management" + + // ChangeLogoPermissionID is the hardcoded setting UUID for the change-logo permission + ChangeLogoPermissionID string = "ed83fc10-1f54-4a9e-b5a7-fb517f5f3e01" + // ChangeLogoPermissionName is the hardcoded setting name for the change-logo permission + ChangeLogoPermissionName string = "change-logo" ) // GenerateBundlesDefaultRoles bootstraps the default roles. @@ -260,6 +265,21 @@ func generateBundleAdminRole() *settingsmsg.Bundle { }, }, }, + { + Id: ChangeLogoPermissionID, + Name: ChangeLogoPermissionName, + DisplayName: "Change logo", + Description: "This permission permits to change the system logo.", + Resource: &settingsmsg.Resource{ + Type: settingsmsg.Resource_TYPE_SYSTEM, + }, + Value: &settingsmsg.Setting_PermissionValue{ + PermissionValue: &settingsmsg.Permission{ + Operation: settingsmsg.Permission_OPERATION_READWRITE, + Constraint: settingsmsg.Permission_CONSTRAINT_ALL, + }, + }, + }, }, } } diff --git a/services/web/pkg/config/config.go b/services/web/pkg/config/config.go index 6259d7666a5..bd61b69d197 100644 --- a/services/web/pkg/config/config.go +++ b/services/web/pkg/config/config.go @@ -22,7 +22,10 @@ type Config struct { File string `yaml:"file" env:"WEB_UI_CONFIG" desc:"Read the ownCloud Web configuration from this file."` // TODO: rename this to a more self explaining string Web Web `yaml:"web"` - Context context.Context `yaml:"-"` + TokenManager *TokenManager `yaml:"token_manager"` + + GatewayAddress string `yaml:"gateway_addr" env:"WEB_GATEWAY_GRPC_ADDR" desc:"GRPC address of the Reva gateway service."` + Context context.Context `yaml:"-"` } // Asset defines the available asset configuration. @@ -60,13 +63,14 @@ type Application struct { } // ExternalApp defines an external web app. -// { -// "name": "hello", -// "path": "http://localhost:9105/hello.js", -// "config": { -// "url": "http://localhost:9105" -// } -// } +// +// { +// "name": "hello", +// "path": "http://localhost:9105/hello.js", +// "config": { +// "url": "http://localhost:9105" +// } +// } type ExternalApp struct { ID string `json:"id,omitempty" yaml:"id"` Path string `json:"path,omitempty" yaml:"path"` @@ -86,3 +90,8 @@ type Web struct { ThemePath string `yaml:"theme_path" env:"WEB_UI_THEME_PATH" desc:"URL path to load themes from. The theme server will be prepended."` // used to build Theme in WebConfig Config WebConfig `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."` +} diff --git a/services/web/pkg/config/defaults/defaultconfig.go b/services/web/pkg/config/defaults/defaultconfig.go index e4579166f86..af64d6635a6 100644 --- a/services/web/pkg/config/defaults/defaultconfig.go +++ b/services/web/pkg/config/defaults/defaultconfig.go @@ -35,6 +35,7 @@ func DefaultConfig() *config.Config { Asset: config.Asset{ Path: filepath.Join(defaults.BaseDataPath(), "web/assets"), }, + GatewayAddress: "127.0.0.1:9142", Web: config.Web{ Path: "", ThemeServer: "https://localhost:9200", @@ -95,6 +96,13 @@ func EnsureDefaults(cfg *config.Config) { cfg.Tracing = &config.Tracing{} } + if cfg.TokenManager == nil && cfg.Commons != nil && cfg.Commons.TokenManager != nil { + cfg.TokenManager = &config.TokenManager{ + JWTSecret: cfg.Commons.TokenManager.JWTSecret, + } + } else if cfg.TokenManager == nil { + cfg.TokenManager = &config.TokenManager{} + } if cfg.Commons != nil { cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS } diff --git a/services/web/pkg/config/parser/parse.go b/services/web/pkg/config/parser/parse.go index 664a6f59691..f90db175646 100644 --- a/services/web/pkg/config/parser/parse.go +++ b/services/web/pkg/config/parser/parse.go @@ -4,6 +4,7 @@ import ( "errors" ociscfg "github.com/owncloud/ocis/v2/ocis-pkg/config" + "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" @@ -33,5 +34,8 @@ func ParseConfig(cfg *config.Config) error { } func Validate(cfg *config.Config) error { + if cfg.TokenManager.JWTSecret == "" { + return shared.MissingJWTTokenError(cfg.Service.Name) + } return nil } diff --git a/services/web/pkg/server/http/server.go b/services/web/pkg/server/http/server.go index c36406407b6..dbe283029de 100644 --- a/services/web/pkg/server/http/server.go +++ b/services/web/pkg/server/http/server.go @@ -3,6 +3,7 @@ package http import ( "fmt" + "github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool" chimiddleware "github.com/go-chi/chi/v5/middleware" "github.com/owncloud/ocis/v2/ocis-pkg/middleware" "github.com/owncloud/ocis/v2/ocis-pkg/service/http" @@ -33,9 +34,15 @@ func Server(opts ...Option) (http.Service, error) { return http.Service{}, fmt.Errorf("could not initialize http service: %w", err) } + client, err := pool.GetGatewayServiceClient(options.Config.GatewayAddress) + if err != nil { + return http.Service{}, err + } + handle := svc.NewService( svc.Logger(options.Logger), svc.Config(options.Config), + svc.GatewayClient(client), svc.Middleware( chimiddleware.RealIP, chimiddleware.RequestID, diff --git a/services/web/pkg/service/v0/branding.go b/services/web/pkg/service/v0/branding.go index 2ebe6bf5e65..0b34e3e32b4 100644 --- a/services/web/pkg/service/v0/branding.go +++ b/services/web/pkg/service/v0/branding.go @@ -7,6 +7,10 @@ import ( "net/http" "path" "path/filepath" + + 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" ) var ( @@ -22,6 +26,24 @@ var ( // UploadLogo implements the endpoint to upload a custom logo for the oCIS instance. func (p Web) UploadLogo(w http.ResponseWriter, r *http.Request) { + user := revactx.ContextMustGetUser(r.Context()) + rsp, err := p.gatewayClient.CheckPermission(r.Context(), &permissionsapi.CheckPermissionRequest{ + Permission: "change-logo", + SubjectRef: &permissionsapi.SubjectReference{ + Spec: &permissionsapi.SubjectReference_UserId{ + UserId: user.Id, + }, + }, + }) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + if rsp.Status.Code != rpc.Code_CODE_OK { + w.WriteHeader(http.StatusForbidden) + return + } + file, fileHeader, err := r.FormFile("logo") if err != nil { if errors.Is(err, http.ErrMissingFile) { diff --git a/services/web/pkg/service/v0/option.go b/services/web/pkg/service/v0/option.go index a7f4cd0312b..8a0ed024bef 100644 --- a/services/web/pkg/service/v0/option.go +++ b/services/web/pkg/service/v0/option.go @@ -3,6 +3,7 @@ package svc import ( "net/http" + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/services/web/pkg/config" ) @@ -12,9 +13,10 @@ type Option func(o *Options) // Options defines the available options for this package. type Options struct { - Logger log.Logger - Config *config.Config - Middleware []func(http.Handler) http.Handler + Logger log.Logger + Config *config.Config + Middleware []func(http.Handler) http.Handler + GatewayClient gateway.GatewayAPIClient } // newOptions initializes the available default options. @@ -48,3 +50,10 @@ func Middleware(val ...func(http.Handler) http.Handler) Option { o.Middleware = val } } + +// GatewayClient provides a function to set the GatewayClient option. +func GatewayClient(client gateway.GatewayAPIClient) Option { + return func(o *Options) { + o.GatewayClient = client + } +} diff --git a/services/web/pkg/service/v0/service.go b/services/web/pkg/service/v0/service.go index ef151d2bb27..3f816a3e51f 100644 --- a/services/web/pkg/service/v0/service.go +++ b/services/web/pkg/service/v0/service.go @@ -10,9 +10,12 @@ import ( "strings" "time" + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" "github.com/go-chi/chi/v5" + "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/services/web" "github.com/owncloud/ocis/v2/services/web/pkg/assets" "github.com/owncloud/ocis/v2/services/web/pkg/config" @@ -38,14 +41,22 @@ func NewService(opts ...Option) Service { m.Use(options.Middleware...) svc := Web{ - logger: options.Logger, config: options.Config, - mux: m, - fs: assetsfs.New(web.Assets, options.Config.Asset.Path, options.Logger), + logger: options.Logger, + config: options.Config, + mux: m, + fs: assetsfs.New(web.Assets, options.Config.Asset.Path, options.Logger), + gatewayClient: options.GatewayClient, } m.Route(options.Config.HTTP.Root, func(r chi.Router) { r.Get("/config.json", svc.Config) - r.Post("/branding/logo", svc.UploadLogo) + r.Route("/branding/logo", func(r chi.Router) { + r.Use(middleware.ExtractAccountUUID( + account.Logger(options.Logger), + account.JWTSecret(options.Config.TokenManager.JWTSecret), + )) + r.Post("/", svc.UploadLogo) + }) r.Mount("/", svc.Static(options.Config.HTTP.CacheTTL)) }) @@ -59,10 +70,11 @@ func NewService(opts ...Option) Service { // Web defines implements the business logic for Service. type Web struct { - logger log.Logger - config *config.Config - mux *chi.Mux - fs *assetsfs.FileSystem + logger log.Logger + config *config.Config + mux *chi.Mux + fs *assetsfs.FileSystem + gatewayClient gateway.GatewayAPIClient } // ServeHTTP implements the Service interface. From 45d1ba25c099c42a565fe8fc8fb1ab0a19cab006 Mon Sep 17 00:00:00 2001 From: David Christofas Date: Mon, 13 Feb 2023 13:22:52 +0100 Subject: [PATCH 07/10] add logo reset endpoint when resetting the logo we are falling back to the embedded logo asset --- ocis-pkg/assetsfs/assetsfs.go | 4 ++ services/web/pkg/service/v0/branding.go | 69 +++++++++++++++++++++++ services/web/pkg/service/v0/instrument.go | 5 ++ services/web/pkg/service/v0/logging.go | 5 ++ services/web/pkg/service/v0/service.go | 2 + services/web/pkg/service/v0/tracing.go | 5 ++ 6 files changed, 90 insertions(+) diff --git a/ocis-pkg/assetsfs/assetsfs.go b/ocis-pkg/assetsfs/assetsfs.go index 311f904662e..826acff4ab1 100644 --- a/ocis-pkg/assetsfs/assetsfs.go +++ b/ocis-pkg/assetsfs/assetsfs.go @@ -28,6 +28,10 @@ func (f *FileSystem) Open(original string) (http.File, error) { return f.fs.Open(original) } +func (f *FileSystem) OpenEmbedded(name string) (http.File, error) { + return f.fs.Open(name) +} + // Create creates a new file in the assetPath func (f *FileSystem) Create(name string) (*os.File, error) { fullPath := f.jailPath(name) diff --git a/services/web/pkg/service/v0/branding.go b/services/web/pkg/service/v0/branding.go index 0b34e3e32b4..afc8592df85 100644 --- a/services/web/pkg/service/v0/branding.go +++ b/services/web/pkg/service/v0/branding.go @@ -76,6 +76,46 @@ func (p Web) UploadLogo(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } +// ResetLogo implements the endpoint to reset the instance logo. +// The config will be changed back to use the embedded logo asset. +func (p Web) ResetLogo(w http.ResponseWriter, r *http.Request) { + user := revactx.ContextMustGetUser(r.Context()) + rsp, err := p.gatewayClient.CheckPermission(r.Context(), &permissionsapi.CheckPermissionRequest{ + Permission: "change-logo", + SubjectRef: &permissionsapi.SubjectReference{ + Spec: &permissionsapi.SubjectReference_UserId{ + UserId: user.Id, + }, + }, + }) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + if rsp.Status.Code != rpc.Code_CODE_OK { + w.WriteHeader(http.StatusForbidden) + return + } + + f, err := p.fs.OpenEmbedded(_themesConfigPath) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + defer f.Close() + + originalPath, err := p.getLogoPath(f) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + if err := p.updateLogoThemeConfig(originalPath); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } +} + func (p Web) storeAsset(name string, asset io.Reader) error { dst, err := p.fs.Create(name) if err != nil { @@ -87,6 +127,35 @@ func (p Web) storeAsset(name string, asset io.Reader) error { 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. + var m map[string]interface{} + _ = json.NewDecoder(r).Decode(&m) + + webCfg, ok := m["web"].(map[string]interface{}) + if !ok { + return "", errInvalidThemeConfig + } + + defaultCfg, ok := webCfg["default"].(map[string]interface{}) + if !ok { + return "", errInvalidThemeConfig + } + + logoCfg, ok := defaultCfg["logo"].(map[string]interface{}) + if !ok { + return "", errInvalidThemeConfig + } + + logoPath, ok := logoCfg["login"].(string) + if !ok { + return "", errInvalidThemeConfig + } + + return logoPath, nil +} + func (p Web) updateLogoThemeConfig(logoPath string) error { f, err := p.fs.Open(_themesConfigPath) if err == nil { diff --git a/services/web/pkg/service/v0/instrument.go b/services/web/pkg/service/v0/instrument.go index 72c64082cee..e01a3b1dc1d 100644 --- a/services/web/pkg/service/v0/instrument.go +++ b/services/web/pkg/service/v0/instrument.go @@ -33,3 +33,8 @@ func (i instrument) Config(w http.ResponseWriter, r *http.Request) { func (i instrument) UploadLogo(w http.ResponseWriter, r *http.Request) { i.next.UploadLogo(w, r) } + +// ResetLogo implements the Service interface. +func (i instrument) ResetLogo(w http.ResponseWriter, r *http.Request) { + i.next.ResetLogo(w, r) +} diff --git a/services/web/pkg/service/v0/logging.go b/services/web/pkg/service/v0/logging.go index 09ab4145e0b..dbdb3abb91e 100644 --- a/services/web/pkg/service/v0/logging.go +++ b/services/web/pkg/service/v0/logging.go @@ -33,3 +33,8 @@ func (l logging) Config(w http.ResponseWriter, r *http.Request) { func (l logging) UploadLogo(w http.ResponseWriter, r *http.Request) { l.next.UploadLogo(w, r) } + +// ResetLogo implements the Service interface. +func (l logging) ResetLogo(w http.ResponseWriter, r *http.Request) { + l.next.ResetLogo(w, r) +} diff --git a/services/web/pkg/service/v0/service.go b/services/web/pkg/service/v0/service.go index 3f816a3e51f..2699f975f2e 100644 --- a/services/web/pkg/service/v0/service.go +++ b/services/web/pkg/service/v0/service.go @@ -31,6 +31,7 @@ type Service interface { ServeHTTP(http.ResponseWriter, *http.Request) Config(http.ResponseWriter, *http.Request) UploadLogo(http.ResponseWriter, *http.Request) + ResetLogo(http.ResponseWriter, *http.Request) } // NewService returns a service implementation for Service. @@ -56,6 +57,7 @@ func NewService(opts ...Option) Service { account.JWTSecret(options.Config.TokenManager.JWTSecret), )) r.Post("/", svc.UploadLogo) + r.Delete("/", svc.ResetLogo) }) r.Mount("/", svc.Static(options.Config.HTTP.CacheTTL)) }) diff --git a/services/web/pkg/service/v0/tracing.go b/services/web/pkg/service/v0/tracing.go index 2889715bdce..413257e9cd8 100644 --- a/services/web/pkg/service/v0/tracing.go +++ b/services/web/pkg/service/v0/tracing.go @@ -29,3 +29,8 @@ func (t tracing) Config(w http.ResponseWriter, r *http.Request) { func (t tracing) UploadLogo(w http.ResponseWriter, r *http.Request) { t.next.UploadLogo(w, r) } + +// ResetLogo implements the Service interface. +func (t tracing) ResetLogo(w http.ResponseWriter, r *http.Request) { + t.next.ResetLogo(w, r) +} From b318269de4ffcb9e3859a63bcf9dca7d4211380c Mon Sep 17 00:00:00 2001 From: David Christofas Date: Mon, 13 Feb 2023 13:46:59 +0100 Subject: [PATCH 08/10] add changelog --- changelog/unreleased/branding-api.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog/unreleased/branding-api.md diff --git a/changelog/unreleased/branding-api.md b/changelog/unreleased/branding-api.md new file mode 100644 index 00000000000..3722cb523ea --- /dev/null +++ b/changelog/unreleased/branding-api.md @@ -0,0 +1,6 @@ +Enhancement: Add endpoints to upload a custom logo + +Added endpoints to upload and reset custom logos. The files are stored under the `WEB_ASSET_PATH` +which defaults to `$OCIS_BASE_DATA_PATH/web/assets`. + +https://github.com/owncloud/ocis/pull/5559 From e0753c20fbe9363c2c06373fedf40aa179367b0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn=20Friedrich=20Dreyer?= Date: Mon, 13 Feb 2023 16:21:59 +0100 Subject: [PATCH 09/10] Update services/web/pkg/config/config.go Co-authored-by: Martin --- services/web/pkg/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/pkg/config/config.go b/services/web/pkg/config/config.go index bd61b69d197..81fcfb989e1 100644 --- a/services/web/pkg/config/config.go +++ b/services/web/pkg/config/config.go @@ -24,7 +24,7 @@ type Config struct { TokenManager *TokenManager `yaml:"token_manager"` - GatewayAddress string `yaml:"gateway_addr" env:"WEB_GATEWAY_GRPC_ADDR" desc:"GRPC address of the Reva gateway service."` + GatewayAddress string `yaml:"gateway_addr" env:"WEB_GATEWAY_GRPC_ADDR" desc:"The bind address of the GRPC service."` Context context.Context `yaml:"-"` } From 2323410f3613e5dce4ef688bbed9534066b8207d Mon Sep 17 00:00:00 2001 From: Michael Barz Date: Mon, 13 Feb 2023 21:15:57 +0100 Subject: [PATCH 10/10] bump wopiserver version --- .drone.star | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.drone.star b/.drone.star index 95e5bc7a487..dc3258777ff 100644 --- a/.drone.star +++ b/.drone.star @@ -798,7 +798,7 @@ def wopiValidatorTests(ctx, storage, accounts_hash_difficulty = 4): }, { "name": "wopiserver", - "image": "cs3org/wopiserver:v9.4.0", + "image": "cs3org/wopiserver:v9.4.1", "detach": True, "commands": [ "cp %s/tests/config/drone/wopiserver.conf /etc/wopi/wopiserver.conf" % (dirs["base"]), @@ -810,7 +810,7 @@ def wopiValidatorTests(ctx, storage, accounts_hash_difficulty = 4): "name": "wait-for-wopi-server", "image": OC_CI_ALPINE, "commands": [ - "curl -k --fail --retry-connrefused --retry 7 --retry-all-errors 'http://wopiserver:8880/wopi'", + "curl -k --fail --retry-connrefused --retry 9 --retry-all-errors 'http://wopiserver:8880/wopi'", ], }, ] +