mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-01-06 04:09:40 -06:00
enhancement: introduce theme processing (#9133)
* enhancement: introduce theme processing * enhancement: introduce theme processing * enhancement: add theme processing tests and changelog * Update services/web/pkg/config/config.go Co-authored-by: Michael Barz <michael.barz@zeitgestalten.eu> * fix: ci findings * Apply suggestions from code review Co-authored-by: Martin <github@diemattels.at> * enhancement: use the theme assets from web instead of having them inside the oCis repo (license clash Apache vs. AGPLv3) * fix: golangci tagalign order * fix: rename UnifiedRoleUploader to UnifiedRoleEditorLite * fix: some typos Co-authored-by: Michael Barz <michael.barz@zeitgestalten.eu> * enhancement: export supported theme logo upload filetypes * chore: bump reva * fix: allow init func --------- Co-authored-by: Michael Barz <michael.barz@zeitgestalten.eu> Co-authored-by: Martin <github@diemattels.at>
This commit is contained in:
@@ -32,8 +32,9 @@ type Config struct {
|
||||
// Asset defines the available asset configuration.
|
||||
type Asset struct {
|
||||
DeprecatedPath string `yaml:"path" env:"WEB_ASSET_PATH" desc:"Serve ownCloud Web assets from a path on the filesystem instead of the builtin assets." introductionVersion:"pre5.0" 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"`
|
||||
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. If not defined, the root directory derives from $OCIS_BASE_DATA_PATH:/web/assets/core" introductionVersion:"5.1"`
|
||||
ThemesPath string `yaml:"themes_path" env:"OCIS_ASSET_THEMES_PATH;WEB_ASSET_THEMES_PATH" desc:"Serve ownCloud themes from a path on the filesystem instead of the builtin assets. If not defined, the root directory derives from $OCIS_BASE_DATA_PATH:/web/assets/themes" 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. If not defined, the root directory derives from $OCIS_BASE_DATA_PATH:/web/assets/apps" introductionVersion:"5.1"`
|
||||
}
|
||||
|
||||
// CustomStyle references additional css to be loaded into ownCloud Web.
|
||||
|
||||
@@ -80,8 +80,9 @@ func DefaultConfig() *config.Config {
|
||||
Name: "web",
|
||||
},
|
||||
Asset: config.Asset{
|
||||
CorePath: filepath.Join(defaults.BaseDataPath(), "web/assets/core"),
|
||||
AppsPath: filepath.Join(defaults.BaseDataPath(), "web/assets/apps"),
|
||||
CorePath: filepath.Join(defaults.BaseDataPath(), "web/assets/core"),
|
||||
AppsPath: filepath.Join(defaults.BaseDataPath(), "web/assets/apps"),
|
||||
ThemesPath: filepath.Join(defaults.BaseDataPath(), "web/assets/themes"),
|
||||
},
|
||||
GatewayAddress: "com.owncloud.api.gateway",
|
||||
Web: config.Web{
|
||||
|
||||
@@ -56,15 +56,10 @@ 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(
|
||||
@@ -73,10 +68,31 @@ func Server(opts ...Option) (http.Service, error) {
|
||||
)
|
||||
}
|
||||
|
||||
handle := svc.NewService(
|
||||
coreFS := fsx.NewFallbackFS(
|
||||
fsx.NewBasePathFs(fsx.NewOsFs(), options.Config.Asset.CorePath),
|
||||
fsx.NewBasePathFs(fsx.FromIOFS(web.Assets), "assets/core"),
|
||||
)
|
||||
themeFS := fsx.NewFallbackFS(
|
||||
fsx.NewBasePathFs(fsx.NewOsFs(), options.Config.Asset.ThemesPath),
|
||||
fsx.NewBasePathFs(fsx.FromIOFS(web.Assets), "assets/themes"),
|
||||
)
|
||||
// oCis is Apache licensed, and the ownCloud branding is AGPLv3.
|
||||
// we are not allowed to have the ownCloud branding as part of the oCIS repository,
|
||||
// as workaround we layer the embedded core fs on top of the theme fs to provide the ownCloud branding.
|
||||
// each asset that is part of the embedded core fs (coreFS secondary fs)
|
||||
// is downloaded at build time from the ownCloud web repository,
|
||||
// web is licensed under AGPLv3 too, and is allowed to contain the ownCloud branding.
|
||||
// themeFS = themeFS.Primary (rw) < themeFS.Secondary (ro) < coreFS.Secondary (ro)
|
||||
themeFS = fsx.NewFallbackFS(
|
||||
themeFS,
|
||||
fsx.NewBasePathFs(coreFS.Secondary(), "themes"),
|
||||
)
|
||||
|
||||
handle, err := svc.NewService(
|
||||
svc.Logger(options.Logger),
|
||||
svc.CoreFS(coreFS),
|
||||
svc.CoreFS(coreFS.IOFS()),
|
||||
svc.AppFS(appsFS.IOFS()),
|
||||
svc.ThemeFS(themeFS),
|
||||
svc.AppsHTTPEndpoint(_customAppsEndpoint),
|
||||
svc.Config(options.Config),
|
||||
svc.GatewaySelector(gatewaySelector),
|
||||
@@ -103,6 +119,10 @@ func Server(opts ...Option) (http.Service, error) {
|
||||
svc.TraceProvider(options.TraceProvider),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return http.Service{}, err
|
||||
}
|
||||
|
||||
{
|
||||
handle = svc.NewInstrument(handle, options.Metrics)
|
||||
handle = svc.NewLogging(handle, options.Logger)
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"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"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
var (
|
||||
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.
|
||||
func (p Web) UploadLogo(w http.ResponseWriter, r *http.Request) {
|
||||
gatewayClient, err := p.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user := revactx.ContextMustGetUser(r.Context())
|
||||
rsp, err := gatewayClient.CheckPermission(r.Context(), &permissionsapi.CheckPermissionRequest{
|
||||
Permission: "Logo.Write",
|
||||
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) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
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 = afero.WriteReader(p.coreFS, 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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
gatewayClient, err := p.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user := revactx.ContextMustGetUser(r.Context())
|
||||
rsp, err := gatewayClient.CheckPermission(r.Context(), &permissionsapi.CheckPermissionRequest{
|
||||
Permission: "Logo.Write",
|
||||
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.coreFS.Secondary().Open(_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) 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)
|
||||
|
||||
logoCfg, ok := extractMap(m, "clients", "web", "defaults", "logo")
|
||||
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.coreFS.Open(_themesConfigPath)
|
||||
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)
|
||||
|
||||
// change logo in common part
|
||||
commonCfg, ok := extractMap(m, "common")
|
||||
if !ok {
|
||||
return errInvalidThemeConfig
|
||||
}
|
||||
commonCfg["logo"] = logoPath
|
||||
|
||||
logoCfg, ok := extractMap(m, "clients", "web", "defaults", "logo")
|
||||
if !ok {
|
||||
return errInvalidThemeConfig
|
||||
}
|
||||
|
||||
logoCfg["login"] = logoPath
|
||||
logoCfg["topbar"] = logoPath
|
||||
|
||||
dst, err := p.coreFS.Create(_themesConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// extractMap extracts embedded map[string]interface{} by the keys chain
|
||||
func extractMap(data map[string]interface{}, keys ...string) (map[string]interface{}, bool) {
|
||||
last := data
|
||||
var ok bool
|
||||
for _, key := range keys {
|
||||
last, ok = last[key].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return last, true
|
||||
}
|
||||
@@ -28,13 +28,3 @@ 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)
|
||||
}
|
||||
|
||||
// ResetLogo implements the Service interface.
|
||||
func (i instrument) ResetLogo(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.ResetLogo(w, r)
|
||||
}
|
||||
|
||||
@@ -28,13 +28,3 @@ 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)
|
||||
}
|
||||
|
||||
// ResetLogo implements the Service interface.
|
||||
func (l logging) ResetLogo(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.ResetLogo(w, r)
|
||||
}
|
||||
|
||||
@@ -23,9 +23,10 @@ type Options struct {
|
||||
Middleware []func(http.Handler) http.Handler
|
||||
GatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
TraceProvider trace.TracerProvider
|
||||
AppFS fs.FS
|
||||
AppsHTTPEndpoint string
|
||||
CoreFS *fsx.FallbackFS
|
||||
CoreFS fs.FS
|
||||
AppFS fs.FS
|
||||
ThemeFS *fsx.FallbackFS
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
@@ -81,6 +82,13 @@ func AppFS(val fs.FS) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// ThemeFS provides a function to set the themeFS option.
|
||||
func ThemeFS(val *fsx.FallbackFS) Option {
|
||||
return func(o *Options) {
|
||||
o.ThemeFS = val
|
||||
}
|
||||
}
|
||||
|
||||
// AppsHTTPEndpoint provides a function to set the appsHTTPEndpoint option.
|
||||
func AppsHTTPEndpoint(val string) Option {
|
||||
return func(o *Options) {
|
||||
@@ -89,7 +97,7 @@ func AppsHTTPEndpoint(val string) Option {
|
||||
}
|
||||
|
||||
// CoreFS provides a function to set the coreFS option.
|
||||
func CoreFS(val *fsx.FallbackFS) Option {
|
||||
func CoreFS(val fs.FS) Option {
|
||||
return func(o *Options) {
|
||||
o.CoreFS = val
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"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/owncloud/ocis/v2/services/web/pkg/theme"
|
||||
)
|
||||
|
||||
// ErrConfigInvalid is returned when the config parse is invalid.
|
||||
@@ -30,14 +31,12 @@ var ErrConfigInvalid = `Invalid or missing config`
|
||||
|
||||
// Service defines the service handlers.
|
||||
type Service interface {
|
||||
ServeHTTP(http.ResponseWriter, *http.Request)
|
||||
Config(http.ResponseWriter, *http.Request)
|
||||
UploadLogo(http.ResponseWriter, *http.Request)
|
||||
ResetLogo(http.ResponseWriter, *http.Request)
|
||||
ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
Config(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// NewService returns a service implementation for Service.
|
||||
func NewService(opts ...Option) Service {
|
||||
func NewService(opts ...Option) (Service, error) {
|
||||
options := newOptions(opts...)
|
||||
|
||||
m := chi.NewMux()
|
||||
@@ -57,9 +56,19 @@ func NewService(opts ...Option) Service {
|
||||
config: options.Config,
|
||||
mux: m,
|
||||
coreFS: options.CoreFS,
|
||||
themeFS: options.ThemeFS,
|
||||
gatewaySelector: options.GatewaySelector,
|
||||
}
|
||||
|
||||
themeService, err := theme.NewService(
|
||||
theme.ServiceOptions{}.
|
||||
WithThemeFS(options.ThemeFS).
|
||||
WithGatewaySelector(options.GatewaySelector),
|
||||
)
|
||||
if err != nil {
|
||||
return svc, err
|
||||
}
|
||||
|
||||
m.Route(options.Config.HTTP.Root, func(r chi.Router) {
|
||||
r.Get("/config.json", svc.Config)
|
||||
r.Route("/branding/logo", func(r chi.Router) {
|
||||
@@ -67,8 +76,16 @@ func NewService(opts ...Option) Service {
|
||||
account.Logger(options.Logger),
|
||||
account.JWTSecret(options.Config.TokenManager.JWTSecret),
|
||||
))
|
||||
r.Post("/", svc.UploadLogo)
|
||||
r.Delete("/", svc.ResetLogo)
|
||||
r.Post("/", themeService.LogoUpload)
|
||||
r.Delete("/", themeService.LogoReset)
|
||||
})
|
||||
r.Route("/themes", func(r chi.Router) {
|
||||
r.Get("/{id}/theme.json", themeService.Get)
|
||||
r.Mount("/", svc.Static(
|
||||
options.ThemeFS.IOFS(),
|
||||
path.Join(svc.config.HTTP.Root, "/themes"),
|
||||
options.Config.HTTP.CacheTTL,
|
||||
))
|
||||
})
|
||||
r.Mount(options.AppsHTTPEndpoint, svc.Static(
|
||||
options.AppFS,
|
||||
@@ -76,18 +93,17 @@ func NewService(opts ...Option) Service {
|
||||
options.Config.HTTP.CacheTTL,
|
||||
))
|
||||
r.Mount("/", svc.Static(
|
||||
svc.coreFS.IOFS(),
|
||||
svc.coreFS,
|
||||
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 {
|
||||
options.Logger.Debug().Str("method", method).Str("route", route).Int("middlewares", len(middlewares)).Msg("serving endpoint")
|
||||
return nil
|
||||
})
|
||||
|
||||
return svc
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// Web defines the handlers for the web service.
|
||||
@@ -95,7 +111,8 @@ type Web struct {
|
||||
logger log.Logger
|
||||
config *config.Config
|
||||
mux *chi.Mux
|
||||
coreFS *fsx.FallbackFS
|
||||
coreFS fs.FS
|
||||
themeFS *fsx.FallbackFS
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
}
|
||||
|
||||
|
||||
3
services/web/pkg/theme/export_test.go
Normal file
3
services/web/pkg/theme/export_test.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package theme
|
||||
|
||||
var IsFiletypePermitted = isFiletypePermitted
|
||||
98
services/web/pkg/theme/kv.go
Normal file
98
services/web/pkg/theme/kv.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
|
||||
"dario.cat/mergo"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// KV is a generic key-value map.
|
||||
type KV map[string]any
|
||||
|
||||
// MergeKV merges the given key-value maps.
|
||||
func MergeKV(values ...KV) (KV, error) {
|
||||
var kv KV
|
||||
|
||||
for _, v := range values {
|
||||
err := mergo.Merge(&kv, v, mergo.WithOverride)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return kv, nil
|
||||
}
|
||||
|
||||
// PatchKV injects the given values into to v.
|
||||
func PatchKV(v any, values KV) error {
|
||||
bv, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nv := string(bv)
|
||||
|
||||
for k, val := range values {
|
||||
var err error
|
||||
switch val {
|
||||
// if the value is nil, we delete the key
|
||||
case nil:
|
||||
nv, err = sjson.Delete(nv, k)
|
||||
default:
|
||||
nv, err = sjson.Set(nv, k, val)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return json.Unmarshal([]byte(nv), v)
|
||||
}
|
||||
|
||||
// LoadKV loads a key-value map from the given file system.
|
||||
func LoadKV(fsys afero.Fs, p string) (KV, error) {
|
||||
f, err := fsys.Open(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var kv KV
|
||||
err = json.NewDecoder(f).Decode(&kv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return kv, nil
|
||||
}
|
||||
|
||||
// WriteKV writes the given key-value map to the file system.
|
||||
func WriteKV(fsys afero.Fs, p string, kv KV) error {
|
||||
data, err := json.Marshal(kv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return afero.WriteReader(fsys, p, bytes.NewReader(data))
|
||||
}
|
||||
|
||||
// UpdateKV updates the key-value map at the given path with the given values.
|
||||
func UpdateKV(fsys afero.Fs, p string, values KV) error {
|
||||
var kv KV
|
||||
|
||||
existing, err := LoadKV(fsys, p)
|
||||
if err == nil {
|
||||
kv = existing
|
||||
}
|
||||
|
||||
err = PatchKV(&kv, values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return WriteKV(fsys, p, kv)
|
||||
}
|
||||
137
services/web/pkg/theme/kv_test.go
Normal file
137
services/web/pkg/theme/kv_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package theme_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/x/io/fsx"
|
||||
"github.com/owncloud/ocis/v2/services/web/pkg/theme"
|
||||
)
|
||||
|
||||
func TestMergeKV(t *testing.T) {
|
||||
left := theme.KV{
|
||||
"left": "left",
|
||||
"both": "left",
|
||||
}
|
||||
right := theme.KV{
|
||||
"right": "right",
|
||||
"both": "right",
|
||||
}
|
||||
|
||||
result, err := theme.MergeKV(left, right)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, result, theme.KV{
|
||||
"left": "left",
|
||||
"right": "right",
|
||||
"both": "right",
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchKV(t *testing.T) {
|
||||
in := theme.KV{
|
||||
"a": theme.KV{
|
||||
"value": "a",
|
||||
},
|
||||
"b": theme.KV{
|
||||
"value": "b",
|
||||
},
|
||||
}
|
||||
err := theme.PatchKV(&in, theme.KV{
|
||||
"b.value": "b-new",
|
||||
"c.value": "c-new",
|
||||
})
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, in, theme.KV{
|
||||
"a": map[string]interface{}{
|
||||
"value": "a",
|
||||
},
|
||||
"b": map[string]interface{}{
|
||||
"value": "b-new",
|
||||
},
|
||||
"c": map[string]interface{}{
|
||||
"value": "c-new",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadKV(t *testing.T) {
|
||||
in := theme.KV{
|
||||
"a": map[string]interface{}{
|
||||
"value": "a",
|
||||
},
|
||||
"b": map[string]interface{}{
|
||||
"value": "b",
|
||||
},
|
||||
}
|
||||
b, err := json.Marshal(in)
|
||||
assert.Nil(t, err)
|
||||
|
||||
fsys := fsx.NewMemMapFs()
|
||||
assert.Nil(t, afero.WriteFile(fsys, "some.json", b, 0644))
|
||||
|
||||
out, err := theme.LoadKV(fsys, "some.json")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, in, out)
|
||||
}
|
||||
|
||||
func TestWriteKV(t *testing.T) {
|
||||
in := theme.KV{
|
||||
"a": map[string]interface{}{
|
||||
"value": "a",
|
||||
},
|
||||
"b": map[string]interface{}{
|
||||
"value": "b",
|
||||
},
|
||||
}
|
||||
|
||||
fsys := fsx.NewMemMapFs()
|
||||
assert.Nil(t, theme.WriteKV(fsys, "some.json", in))
|
||||
|
||||
f, err := fsys.Open("some.json")
|
||||
assert.Nil(t, err)
|
||||
|
||||
var out theme.KV
|
||||
assert.Nil(t, json.NewDecoder(f).Decode(&out))
|
||||
assert.Equal(t, in, out)
|
||||
}
|
||||
|
||||
func TestUpdateKV(t *testing.T) {
|
||||
fileKV := theme.KV{
|
||||
"a": map[string]interface{}{
|
||||
"value": "a",
|
||||
},
|
||||
"b": map[string]interface{}{
|
||||
"value": "b",
|
||||
},
|
||||
}
|
||||
|
||||
wb, err := json.Marshal(fileKV)
|
||||
assert.Nil(t, err)
|
||||
|
||||
fsys := fsx.NewMemMapFs()
|
||||
assert.Nil(t, afero.WriteFile(fsys, "some.json", wb, 0644))
|
||||
assert.Nil(t, theme.UpdateKV(fsys, "some.json", theme.KV{
|
||||
"b.value": "b-new",
|
||||
"c.value": "c-new",
|
||||
}))
|
||||
|
||||
f, err := fsys.Open("some.json")
|
||||
assert.Nil(t, err)
|
||||
|
||||
var out theme.KV
|
||||
assert.Nil(t, json.NewDecoder(f).Decode(&out))
|
||||
assert.Equal(t, out, theme.KV{
|
||||
"a": map[string]interface{}{
|
||||
"value": "a",
|
||||
},
|
||||
"b": map[string]interface{}{
|
||||
"value": "b-new",
|
||||
},
|
||||
"c": map[string]interface{}{
|
||||
"value": "c-new",
|
||||
},
|
||||
})
|
||||
}
|
||||
197
services/web/pkg/theme/service.go
Normal file
197
services/web/pkg/theme/service.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
permissionsapi "github.com/cs3org/go-cs3apis/cs3/permissions/v1beta1"
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/afero"
|
||||
|
||||
revactx "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
"github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/x/io/fsx"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/x/path/filepathx"
|
||||
)
|
||||
|
||||
// ServiceOptions defines the options to configure the Service.
|
||||
type ServiceOptions struct {
|
||||
themeFS *fsx.FallbackFS
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
}
|
||||
|
||||
// WithThemeFS sets the theme filesystem.
|
||||
func (o ServiceOptions) WithThemeFS(fSys *fsx.FallbackFS) ServiceOptions {
|
||||
o.themeFS = fSys
|
||||
return o
|
||||
}
|
||||
|
||||
// WithGatewaySelector sets the gateway selector.
|
||||
func (o ServiceOptions) WithGatewaySelector(gws pool.Selectable[gateway.GatewayAPIClient]) ServiceOptions {
|
||||
o.gatewaySelector = gws
|
||||
return o
|
||||
}
|
||||
|
||||
// validate validates the input parameters.
|
||||
func (o ServiceOptions) validate() error {
|
||||
if o.themeFS == nil {
|
||||
return errors.New("themeFS is required")
|
||||
}
|
||||
|
||||
if o.gatewaySelector == nil {
|
||||
return errors.New("gatewaySelector is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Service defines the http service.
|
||||
type Service struct {
|
||||
themeFS *fsx.FallbackFS
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
}
|
||||
|
||||
// NewService initializes a new Service.
|
||||
func NewService(options ServiceOptions) (Service, error) {
|
||||
if err := options.validate(); err != nil {
|
||||
return Service{}, err
|
||||
}
|
||||
|
||||
return Service(options), nil
|
||||
}
|
||||
|
||||
// Get renders the theme, the theme is a merge of the default theme, the base theme, and the branding theme.
|
||||
func (s Service) Get(w http.ResponseWriter, r *http.Request) {
|
||||
// there is no guarantee that the theme exists, its optional; therefore, we ignore the error
|
||||
baseTheme, _ := LoadKV(s.themeFS, filepathx.JailJoin(r.PathValue("id"), _themeFileName))
|
||||
|
||||
// there is no guarantee that the theme exists, its optional; therefore, we ignore the error here too
|
||||
brandingTheme, _ := LoadKV(s.themeFS, filepathx.JailJoin(_brandingRoot, _themeFileName))
|
||||
|
||||
// merge the themes, the order is important, the last one wins and overrides the previous ones
|
||||
// themeDefaults: contains all the default values, this is guaranteed to exist
|
||||
// baseTheme: contains the base theme from the theme fs, there is no guarantee that it exists
|
||||
// brandingTheme: contains the branding theme from the theme fs, there is no guarantee that it exists
|
||||
// mergedTheme = themeDefaults < baseTheme < brandingTheme
|
||||
mergedTheme, err := MergeKV(themeDefaults, baseTheme, brandingTheme)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
b, err := json.Marshal(mergedTheme)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = w.Write(b)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// LogoUpload implements the endpoint to upload a custom logo for the oCIS instance.
|
||||
func (s Service) LogoUpload(w http.ResponseWriter, r *http.Request) {
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user := revactx.ContextMustGetUser(r.Context())
|
||||
rsp, err := gatewayClient.CheckPermission(r.Context(), &permissionsapi.CheckPermissionRequest{
|
||||
Permission: "Logo.Write",
|
||||
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) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if !isFiletypePermitted(fileHeader.Filename, fileHeader.Header.Get("Content-Type")) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
fp := filepathx.JailJoin(_brandingRoot, fileHeader.Filename)
|
||||
err = afero.WriteReader(s.themeFS, fp, file)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = UpdateKV(s.themeFS, filepathx.JailJoin(_brandingRoot, _themeFileName), KV{
|
||||
"common.logo": filepathx.JailJoin("themes", fp),
|
||||
"clients.web.defaults.logo.topbar": filepathx.JailJoin("themes", fp),
|
||||
"clients.web.defaults.logo.login": filepathx.JailJoin("themes", fp),
|
||||
})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// LogoReset implements the endpoint to reset the instance logo.
|
||||
// The config will be changed back to use the embedded logo asset.
|
||||
func (s Service) LogoReset(w http.ResponseWriter, r *http.Request) {
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user := revactx.ContextMustGetUser(r.Context())
|
||||
rsp, err := gatewayClient.CheckPermission(r.Context(), &permissionsapi.CheckPermissionRequest{
|
||||
Permission: "Logo.Write",
|
||||
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
|
||||
}
|
||||
|
||||
err = UpdateKV(s.themeFS, filepathx.JailJoin(_brandingRoot, _themeFileName), KV{
|
||||
"common.logo": nil,
|
||||
"clients.web.defaults.logo.topbar": nil,
|
||||
"clients.web.defaults.logo.login": nil,
|
||||
})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
74
services/web/pkg/theme/service_test.go
Normal file
74
services/web/pkg/theme/service_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package theme_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/x/io/fsx"
|
||||
"github.com/owncloud/ocis/v2/services/graph/mocks"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/unifiedrole"
|
||||
"github.com/owncloud/ocis/v2/services/web/pkg/theme"
|
||||
)
|
||||
|
||||
func TestNewService(t *testing.T) {
|
||||
t.Run("fails if the options are invalid", func(t *testing.T) {
|
||||
_, err := theme.NewService(theme.ServiceOptions{})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("success if the options are valid", func(t *testing.T) {
|
||||
_, err := theme.NewService(
|
||||
theme.ServiceOptions{}.
|
||||
WithThemeFS(fsx.NewFallbackFS(fsx.NewMemMapFs(), fsx.NewMemMapFs())).
|
||||
WithGatewaySelector(mocks.NewSelectable[gateway.GatewayAPIClient](t)),
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_Get(t *testing.T) {
|
||||
primaryFS := fsx.NewMemMapFs()
|
||||
fallbackFS := fsx.NewFallbackFS(primaryFS, fsx.NewMemMapFs())
|
||||
|
||||
add := func(filename string, content interface{}) {
|
||||
b, err := json.Marshal(content)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Nil(t, afero.WriteFile(primaryFS, filename, b, 0644))
|
||||
}
|
||||
|
||||
// baseTheme
|
||||
add("base/theme.json", map[string]interface{}{
|
||||
"base": "base",
|
||||
})
|
||||
// brandingTheme
|
||||
add("_branding/theme.json", map[string]interface{}{
|
||||
"_branding": "_branding",
|
||||
})
|
||||
|
||||
service, _ := theme.NewService(
|
||||
theme.ServiceOptions{}.
|
||||
WithThemeFS(fallbackFS).
|
||||
WithGatewaySelector(mocks.NewSelectable[gateway.GatewayAPIClient](t)),
|
||||
)
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.SetPathValue("id", "base")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
service.Get(w, r)
|
||||
|
||||
jsonData := gjson.Parse(w.Body.String())
|
||||
// baseTheme
|
||||
assert.Equal(t, jsonData.Get("base").String(), "base")
|
||||
// brandingTheme
|
||||
assert.Equal(t, jsonData.Get("_branding").String(), "_branding")
|
||||
// themeDefaults
|
||||
assert.Equal(t, jsonData.Get("common.shareRoles."+unifiedrole.UnifiedRoleViewerID+".name").String(), "UnifiedRoleViewer")
|
||||
}
|
||||
61
services/web/pkg/theme/theme.go
Normal file
61
services/web/pkg/theme/theme.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"path"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/capabilities"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/unifiedrole"
|
||||
)
|
||||
|
||||
var (
|
||||
_brandingRoot = "_branding"
|
||||
_themeFileName = "theme.json"
|
||||
)
|
||||
|
||||
// themeDefaults contains the default values for the theme.
|
||||
// all rendered themes get the default values from here.
|
||||
var themeDefaults = KV{
|
||||
"common": KV{
|
||||
"shareRoles": KV{
|
||||
unifiedrole.UnifiedRoleViewerID: KV{
|
||||
"name": "UnifiedRoleViewer",
|
||||
"iconName": "eye",
|
||||
},
|
||||
unifiedrole.UnifiedRoleSpaceViewerID: KV{
|
||||
"label": "UnifiedRoleSpaceViewer",
|
||||
"iconName": "eye",
|
||||
},
|
||||
unifiedrole.UnifiedRoleFileEditorID: KV{
|
||||
"label": "UnifiedRoleFileEditor",
|
||||
"iconName": "pencil",
|
||||
},
|
||||
unifiedrole.UnifiedRoleEditorID: KV{
|
||||
"label": "UnifiedRoleEditor",
|
||||
"iconName": "pencil",
|
||||
},
|
||||
unifiedrole.UnifiedRoleSpaceEditorID: KV{
|
||||
"label": "UnifiedRoleSpaceEditor",
|
||||
"iconName": "pencil",
|
||||
},
|
||||
unifiedrole.UnifiedRoleManagerID: KV{
|
||||
"label": "UnifiedRoleManager",
|
||||
"iconName": "user-star",
|
||||
},
|
||||
unifiedrole.UnifiedRoleEditorLiteID: KV{
|
||||
"label": "UnifiedRoleEditorLite",
|
||||
"iconName": "upload",
|
||||
},
|
||||
unifiedrole.UnifiedRoleSecureViewerID: KV{
|
||||
"label": "UnifiedRoleSecureView",
|
||||
"iconName": "shield",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// isFiletypePermitted checks if the given file extension is allowed.
|
||||
func isFiletypePermitted(filename string, givenMime string) bool {
|
||||
// Check if we allow that extension and if the mediatype matches the extension
|
||||
extensionMime, ok := capabilities.Default().Theme.Logo.PermittedFileTypes[path.Ext(filename)]
|
||||
return ok && extensionMime == givenMime
|
||||
}
|
||||
30
services/web/pkg/theme/theme_test.go
Normal file
30
services/web/pkg/theme/theme_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package theme_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/owncloud/ocis/v2/services/web/pkg/theme"
|
||||
)
|
||||
|
||||
// TestAllowedLogoFileTypes is here to ensure that a certain set of bare minimum file types are allowed for logos.
|
||||
func TestAllowedLogoFileTypes(t *testing.T) {
|
||||
type test struct {
|
||||
filename string
|
||||
mimetype string
|
||||
allowed bool
|
||||
}
|
||||
|
||||
tests := []test{
|
||||
{filename: "foo.jpg", mimetype: "image/jpeg", allowed: true},
|
||||
{filename: "foo.jpeg", mimetype: "image/jpeg", allowed: true},
|
||||
{filename: "foo.png", mimetype: "image/png", allowed: true},
|
||||
{filename: "foo.gif", mimetype: "image/gif", allowed: true},
|
||||
{filename: "foo.tiff", mimetype: "image/tiff", allowed: false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
assert.Equal(t, theme.IsFiletypePermitted(tc.filename, tc.mimetype), tc.allowed)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user