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:
Florian Schade
2024-05-29 15:48:49 +02:00
committed by GitHub
parent 80480cb7fa
commit eb7c36443f
34 changed files with 2040 additions and 287 deletions

View File

@@ -9,6 +9,7 @@ import (
"path/filepath"
"strconv"
"github.com/owncloud/ocis/v2/ocis-pkg/capabilities"
"github.com/owncloud/ocis/v2/ocis-pkg/config/defaults"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/ocis-pkg/version"
@@ -290,6 +291,7 @@ func FrontendConfigFromStruct(cfg *config.Config, logger log.Logger) (map[string
"share_jail": true,
"max_quota": cfg.MaxQuota,
},
"theme": capabilities.Default().Theme,
"search": map[string]interface{}{
"property": map[string]interface{}{
"name": map[string]interface{}{

View File

@@ -43,6 +43,28 @@ This feature is useful for organizations that want to provide third party or cus
It's important to note that the feature at the moment is only capable of providing static (js, mjs, e.g.) web applications
and does not support injection of dynamic web applications (custom dynamic backends).
### Loading Themes
Web themes are loaded, if added in the Infinite Scale source code, at build-time from
`<ocis_repo>/services/web/assets/themes`.
This cannot be manipulated at runtime.
Additionally, the administrator can provide custom themes by storing it in the path defined by the environment
variable `WEB_ASSET_THEMES_PATH`.
With the theme root directory defined, the system needs to know which theme to use.
This can be done by setting the `WEB_UI_THEME_PATH` environment variable.
The final theme is composed of the built-in and the custom theme provided by the
administrator via `WEB_ASSET_THEMES_PATH` and `WEB_UI_THEME_PATH`.
For example, Infinite Scale by default contains a built-in ownCloud theme.
If the administrator provides a custom theme via the `WEB_ASSET_THEMES_PATH` directory like,
`WEB_ASSET_THEMES_PATH/owncloud/themes.json`, this one will be used instead of the built-in one.
Some theme keys are mandatory, like the `common.shareRoles` settings.
Such mandatory keys are injected automatically at runtime if not provided.
### Loading Applications
Web applications are loaded, if added in the Infinite Scale source code, at build-time from
@@ -57,7 +79,7 @@ but can be redefined with any path set manually.
The final list of available applications is composed of the built-in and the custom applications provided by the
administrator via `WEB_ASSET_APPS_PATH`.
For example, if Infinite Scale would contain a built-in extension named `image-viewer-dfx` and the administrator provides a custom application named `image-viewer-obj` via the `WEB_ASSET_APPS_PATH` directory, the user will be able to access both
For example, if Infinite Scale contains a built-in extension named `image-viewer-dfx` and the administrator provides a custom application named `image-viewer-obj` via the `WEB_ASSET_APPS_PATH` directory, the user will be able to access both
applications from the WebUI.
### Application Structure

View File

View File

@@ -0,0 +1,198 @@
{
"common": {
"name": "ownCloud",
"slogan": "ownCloud A safe home for all your data",
"logo": "themes/owncloud/assets/logo.svg",
"urls": {
"accessDeniedHelp": "",
"imprint": "",
"privacy": ""
}
},
"clients": {
"android": {},
"desktop": {},
"ios": {},
"web": {
"defaults": {
"appBanner": {},
"logo": {
"topbar": "themes/owncloud/assets/logo.svg",
"favicon": "themes/owncloud/assets/favicon.jpg",
"login": "themes/owncloud/assets/logo.svg"
},
"loginPage": {
"backgroundImg": "themes/owncloud/assets/loginBackground.jpg"
},
"designTokens": {
"breakpoints": {
"xsmall-max": "",
"small-default": "",
"small-max": "",
"medium-default": "",
"medium-max": "",
"large-default": "",
"large-max": "",
"xlarge": ""
},
"fontSizes": {
"default": "",
"large": "",
"medium": ""
},
"sizes": {
"form-check-default": "",
"height-small": "",
"height-table-row": "",
"icon-default": "",
"max-height-logo": "",
"max-width-logo": "",
"width-medium": "",
"tiles-default": "",
"tiles-resize-step": ""
},
"spacing": {
"xsmall": "",
"small": "",
"medium": "",
"large": "",
"xlarge": "",
"xxlarge": ""
}
}
},
"themes": [
{
"isDark": false,
"name": "Light Theme",
"designTokens": {
"colorPalette": {
"background-accentuate": "rgba(255, 255, 5, 0.1)",
"background-default": "#ffffff",
"background-highlight": "#edf3fa",
"background-muted": "#f8f8f8",
"background-secondary": "#ffffff",
"background-hover": "rgb(236, 236, 236)",
"color-components-apptopbar-background": "transparent",
"color-components-apptopbar-border": "#ceddee",
"border": "#ecebee",
"input-bg": "#ffffff",
"input-border": "#ceddee",
"input-text-default": "#041e42",
"input-text-muted": "#4c5f79",
"swatch-brand-default": "#041e42",
"swatch-brand-hover": "#223959",
"swatch-brand-contrast": "#ffffff",
"swatch-danger-contrast": "#ffffff",
"swatch-danger-default": "rgb(197, 48, 48)",
"swatch-danger-hover": "#b12b2b",
"swatch-danger-muted": "rgb(204, 117, 117)",
"swatch-inverse-default": "#ffffff",
"swatch-inverse-hover": "#ffffff",
"swatch-inverse-muted": "#bfbfbf",
"swatch-passive-default": "#4c5f79",
"swatch-passive-hover": "#43536b",
"swatch-passive-hover-outline": "#f7fafd",
"swatch-passive-muted": "#283e5d",
"swatch-passive-contrast": "#ffffff",
"swatch-primary-default": "#4a76ac",
"swatch-primary-hover": "#80a7d7",
"swatch-primary-muted": "#2c588e",
"swatch-primary-muted-hover": "rgb(36, 75, 119)",
"swatch-primary-gradient": "#4e85c8",
"swatch-primary-gradient-hover": "rgb(59, 118, 194)",
"swatch-primary-contrast": "#ffffff",
"swatch-success-default": "rgb(3, 84, 63)",
"swatch-success-hover": "#023b2c",
"swatch-success-muted": "rgb(83, 150, 10)",
"swatch-success-contrast": "#ffffff",
"swatch-warning-default": "rgb(183, 76, 27)",
"swatch-warning-hover": "#a04318",
"swatch-warning-muted": "rgba(183, 76, 27, .5)",
"swatch-warning-contrast": "#ffffff",
"text-default": "#041e42",
"text-inverse": "#ffffff",
"text-muted": "#4c5f79",
"icon-folder": "#4d7eaf",
"icon-archive": "#fbbe54",
"icon-image": "#ee6b3b",
"icon-spreadsheet": "#15c286",
"icon-document": "#3b44a6",
"icon-video": "#045459",
"icon-audio": "#700460",
"icon-presentation": "#ee6b3b",
"icon-pdf": "#ec0d47",
"icon-medical": "#0984db"
}
}
},
{
"isDark": true,
"name": "Dark Theme",
"designTokens": {
"colorPalette": {
"background-accentuate": "#696969",
"background-default": "#292929",
"background-highlight": "#383838",
"background-muted": "#383838",
"background-secondary": "#4f4f4f",
"background-hover": "#383838",
"color-components-apptopbar-background": "transparent",
"color-components-apptopbar-border": "#ceddee",
"border": "#383838",
"input-bg": "#4f4f4f",
"input-border": "#696969",
"input-text-default": "#dadcdf",
"input-text-muted": "#bdbfc3",
"swatch-brand-default": "#212121",
"swatch-brand-hover": "#ffffff",
"swatch-brand-contrast": "#dadcdf",
"swatch-inverse-default": "",
"swatch-inverse-hover": "",
"swatch-inverse-muted": "#696969",
"swatch-passive-default": "#c2c2c2",
"swatch-passive-hover": "",
"swatch-passive-hover-outline": "#3B3B3B",
"swatch-passive-muted": "#bdbfc3",
"swatch-passive-contrast": "#000000",
"swatch-primary-default": "#73b0f2",
"swatch-primary-hover": "#7bafef",
"swatch-primary-muted": "",
"swatch-primary-muted-hover": "#2282f7",
"swatch-primary-gradient": "#4e85c8",
"swatch-primary-gradient-hover": "#76a1d5",
"swatch-primary-contrast": "#dadcdf",
"swatch-success-background": "rgba(0, 188, 140, 0)",
"swatch-success-default": "rgb(0, 188, 140)",
"swatch-success-hover": "#00f0b4",
"swatch-success-muted": "rgba(0, 188, 140, .5)",
"swatch-success-contrast": "#000000",
"swatch-warning-background": "rgba(0,0,0,0)",
"swatch-warning-default": "rgb(232, 191, 73)",
"swatch-warning-hover": "#eed077",
"swatch-warning-muted": "rgba(232, 178, 19, .5)",
"swatch-danger-default": "rgb(255, 72, 53)",
"swatch-danger-hover": "#ff7566",
"swatch-danger-muted": "rgba(255, 72, 53, .5)",
"swatch-danger-contrast": "#dadcdf",
"swatch-warning-contrast": "#000000",
"text-default": "#dadcdf",
"text-inverse": "#000000",
"text-muted": "#c2c2c2",
"icon-folder": "rgb(44, 101, 255)",
"icon-archive": "rgb(255, 207, 1)",
"icon-image": "rgb(255, 111, 0)",
"icon-spreadsheet": "rgb(0, 182, 87)",
"icon-document": "rgb(44, 101, 255)",
"icon-video": "rgb(0, 187, 219)",
"icon-audio": "rgb(208, 67, 236)",
"icon-presentation": "rgb(255, 64, 6)",
"icon-pdf": "rgb(225, 5, 14)",
"icon-medical": "rgb(9,132,219)"
}
}
}
]
}
}
}

View File

@@ -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.

View File

@@ -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{

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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]
}

View File

@@ -0,0 +1,3 @@
package theme
var IsFiletypePermitted = isFiletypePermitted

View 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)
}

View 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",
},
})
}

View 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)
}

View 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")
}

View 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
}

View 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)
}
}