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

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