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

@@ -48,10 +48,16 @@ linters-settings:
align: false
sort: true
order:
- json
- yaml
- mapstructure
- env
- desc
- introductionVersion
- deprecationVersion
- removalVersion
- deprecationInfo
- deprecationReplacement
gocyclo:
min-complexity: 35 # there are some func unfortunately who need this - should we refactor them?
gomoddirectives:

View File

@@ -0,0 +1,15 @@
Enhancement: Theme Processing and Logo Customization
We have made significant improvements to the theme processing in Infinite Scale.
The changes include:
- Enhanced the way themes are composed. Now, the final theme is a combination of the built-in theme and the custom theme provided by the administrator via `WEB_ASSET_THEMES_PATH` and `WEB_UI_THEME_PATH`.
- Introduced a new mechanism to load custom assets. This is particularly useful when a single asset, such as a logo, needs to be overwritten.
- Fixed the logo customization option. Previously, small theme changes would copy the entire theme. Now, only the changed keys are considered, making the process more efficient.
- Default themes are now part of ocis. This change simplifies the theme management process for web.
These changes enhance the robustness of the theme handling in Infinite Scale and provide a better user experience.
https://github.com/owncloud/ocis/pull/9133
https://github.com/owncloud/ocis/issues/8966

3
go.mod
View File

@@ -15,7 +15,7 @@ require (
github.com/cenkalti/backoff v2.2.1+incompatible
github.com/coreos/go-oidc/v3 v3.10.0
github.com/cs3org/go-cs3apis v0.0.0-20231023073225-7748710e0781
github.com/cs3org/reva/v2 v2.19.2-0.20240525160759-56879111e06a
github.com/cs3org/reva/v2 v2.19.2-0.20240529081036-419196f2342e
github.com/dhowden/tag v0.0.0-20230630033851-978a0926ee25
github.com/dutchcoders/go-clamd v0.0.0-20170520113014-b970184f4d9e
github.com/egirna/icap-client v0.1.1
@@ -88,6 +88,7 @@ require (
github.com/test-go/testify v1.1.4
github.com/thejerf/suture/v4 v4.0.5
github.com/tidwall/gjson v1.17.1
github.com/tidwall/sjson v1.2.5
github.com/tus/tusd v1.13.0
github.com/unrolled/secure v1.14.0
github.com/urfave/cli/v2 v2.27.2

11
go.sum
View File

@@ -1025,12 +1025,8 @@ github.com/crewjam/saml v0.4.14 h1:g9FBNx62osKusnFzs3QTN5L9CVA/Egfgm+stJShzw/c=
github.com/crewjam/saml v0.4.14/go.mod h1:UVSZCf18jJkk6GpWNVqcyQJMD5HsRugBPf4I1nl2mME=
github.com/cs3org/go-cs3apis v0.0.0-20231023073225-7748710e0781 h1:BUdwkIlf8IS2FasrrPg8gGPHQPOrQ18MS1Oew2tmGtY=
github.com/cs3org/go-cs3apis v0.0.0-20231023073225-7748710e0781/go.mod h1:UXha4TguuB52H14EMoSsCqDj7k8a/t7g4gVP+bgY5LY=
github.com/cs3org/reva/v2 v2.19.2-0.20240522140613-fae5c105158f h1:gQNgViSZs4DogTJNQ7yiWGPS+HbYwhccSw6OjYQYxlk=
github.com/cs3org/reva/v2 v2.19.2-0.20240522140613-fae5c105158f/go.mod h1:BOlJApKFrWRiaOoBCRxCTG5bghTTMlYaEZrRxOzKaS8=
github.com/cs3org/reva/v2 v2.19.2-0.20240525160759-56879111e06a h1:Xv2TmFIxhk642vN3NO03XSZl9NO0TVy7/QAC0CUiySc=
github.com/cs3org/reva/v2 v2.19.2-0.20240525160759-56879111e06a/go.mod h1:BOlJApKFrWRiaOoBCRxCTG5bghTTMlYaEZrRxOzKaS8=
github.com/cs3org/reva/v2 v2.19.7 h1:0hMIbCZq1VBSxAslEvDDkj1Si8RqxbxR0wx3JvIRXTE=
github.com/cs3org/reva/v2 v2.19.7/go.mod h1:GRUrOp5HbFVwZTgR9bVrMZ/MvVy+Jhxw1PdMmhhKP9E=
github.com/cs3org/reva/v2 v2.19.2-0.20240529081036-419196f2342e h1:nooOncj0aaM6S8TuNSLY4d7KEb8xSzO9/LfqL0loV60=
github.com/cs3org/reva/v2 v2.19.2-0.20240529081036-419196f2342e/go.mod h1:BOlJApKFrWRiaOoBCRxCTG5bghTTMlYaEZrRxOzKaS8=
github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4=
github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
@@ -2032,6 +2028,7 @@ github.com/thanhpk/randstr v1.0.6 h1:psAOktJFD4vV9NEVb3qkhRSMvYh4ORRaj1+w/hn4B+o
github.com/thanhpk/randstr v1.0.6/go.mod h1:M/H2P1eNLZzlDwAzpkkkUvoyNNMbzRGhESZuEQk3r0U=
github.com/thejerf/suture/v4 v4.0.5 h1:F1E/4FZwXWqvlWDKEUo6/ndLtxGAUzMmNqkrMknZbAA=
github.com/thejerf/suture/v4 v4.0.5/go.mod h1:gu9Y4dXNUWFrByqRt30Rm9/UZ0wzRSt9AJS6xu/ZGxU=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U=
github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -2039,6 +2036,8 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=

View File

@@ -0,0 +1,35 @@
package capabilities
import (
"sync/atomic"
"github.com/cs3org/reva/v2/pkg/owncloud/ocs"
)
// allow the consuming part to change defaults, e.g., tests
var defaultCapabilities atomic.Pointer[ocs.Capabilities]
func init() { //nolint:gochecknoinits
ResetDefault()
}
// ResetDefault resets the default [Capabilities] to the default values.
func ResetDefault() {
defaultCapabilities.Store(
&ocs.Capabilities{
Theme: &ocs.CapabilitiesTheme{
Logo: &ocs.CapabilitiesThemeLogo{
PermittedFileTypes: map[string]string{
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
},
},
},
},
)
}
// Default returns the default [Capabilities].
func Default() *ocs.Capabilities { return defaultCapabilities.Load() }

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

View File

@@ -22,13 +22,13 @@ import (
"encoding/json"
"net/http"
"github.com/cs3org/reva/v2/internal/http/services/owncloud/ocs/data"
"github.com/cs3org/reva/v2/pkg/appctx"
"github.com/cs3org/reva/v2/pkg/owncloud/ocs"
)
func (s *svc) doStatus(w http.ResponseWriter, r *http.Request) {
log := appctx.GetLogger(r.Context())
status := &data.Status{
status := &ocs.Status{
Installed: true,
Maintenance: false,
NeedsDBUpgrade: false,

View File

@@ -20,6 +20,7 @@ package config
import (
"github.com/cs3org/reva/v2/internal/http/services/owncloud/ocs/data"
"github.com/cs3org/reva/v2/pkg/owncloud/ocs"
"github.com/cs3org/reva/v2/pkg/sharedconf"
"github.com/cs3org/reva/v2/pkg/storage/cache"
)
@@ -28,7 +29,7 @@ import (
type Config struct {
Prefix string `mapstructure:"prefix"`
Config data.ConfigData `mapstructure:"config"`
Capabilities data.CapabilitiesData `mapstructure:"capabilities"`
Capabilities ocs.CapabilitiesData `mapstructure:"capabilities"`
GatewaySvc string `mapstructure:"gatewaysvc"`
StorageregistrySvc string `mapstructure:"storage_registry_svc"`
DefaultUploadProtocol string `mapstructure:"default_upload_protocol"`

View File

@@ -22,13 +22,13 @@ import (
"net/http"
"github.com/cs3org/reva/v2/internal/http/services/owncloud/ocs/config"
"github.com/cs3org/reva/v2/internal/http/services/owncloud/ocs/data"
"github.com/cs3org/reva/v2/internal/http/services/owncloud/ocs/response"
"github.com/cs3org/reva/v2/pkg/owncloud/ocs"
)
// Handler renders the capability endpoint
type Handler struct {
c data.CapabilitiesData
c ocs.CapabilitiesData
defaultUploadProtocol string
userAgentChunkingMap map[string]string
}
@@ -41,13 +41,13 @@ func (h *Handler) Init(c *config.Config) {
// capabilities
if h.c.Capabilities == nil {
h.c.Capabilities = &data.Capabilities{}
h.c.Capabilities = &ocs.Capabilities{}
}
// core
if h.c.Capabilities.Core == nil {
h.c.Capabilities.Core = &data.CapabilitiesCore{}
h.c.Capabilities.Core = &ocs.CapabilitiesCore{}
}
if h.c.Capabilities.Core.PollInterval == 0 {
h.c.Capabilities.Core.PollInterval = 60
@@ -58,7 +58,7 @@ func (h *Handler) Init(c *config.Config) {
// h.c.Capabilities.Core.SupportURLSigning is boolean
if h.c.Capabilities.Core.Status == nil {
h.c.Capabilities.Core.Status = &data.Status{}
h.c.Capabilities.Core.Status = &ocs.Status{}
}
// h.c.Capabilities.Core.Status.Installed is boolean
// h.c.Capabilities.Core.Status.Maintenance is boolean
@@ -85,7 +85,7 @@ func (h *Handler) Init(c *config.Config) {
// checksums
if h.c.Capabilities.Checksums == nil {
h.c.Capabilities.Checksums = &data.CapabilitiesChecksums{}
h.c.Capabilities.Checksums = &ocs.CapabilitiesChecksums{}
}
if h.c.Capabilities.Checksums.SupportedTypes == nil {
h.c.Capabilities.Checksums.SupportedTypes = []string{"SHA256"}
@@ -97,7 +97,7 @@ func (h *Handler) Init(c *config.Config) {
// files
if h.c.Capabilities.Files == nil {
h.c.Capabilities.Files = &data.CapabilitiesFiles{}
h.c.Capabilities.Files = &ocs.CapabilitiesFiles{}
}
if h.c.Capabilities.Files.BlacklistedFiles == nil {
@@ -108,17 +108,17 @@ func (h *Handler) Init(c *config.Config) {
// h.c.Capabilities.Files.Favorites is boolean
if h.c.Capabilities.Files.Archivers == nil {
h.c.Capabilities.Files.Archivers = []*data.CapabilitiesArchiver{}
h.c.Capabilities.Files.Archivers = []*ocs.CapabilitiesArchiver{}
}
if h.c.Capabilities.Files.AppProviders == nil {
h.c.Capabilities.Files.AppProviders = []*data.CapabilitiesAppProvider{}
h.c.Capabilities.Files.AppProviders = []*ocs.CapabilitiesAppProvider{}
}
// dav
if h.c.Capabilities.Dav == nil {
h.c.Capabilities.Dav = &data.CapabilitiesDav{}
h.c.Capabilities.Dav = &ocs.CapabilitiesDav{}
}
if h.c.Capabilities.Dav.Trashbin == "" {
h.c.Capabilities.Dav.Trashbin = "1.0"
@@ -130,24 +130,24 @@ func (h *Handler) Init(c *config.Config) {
// sharing
if h.c.Capabilities.FilesSharing == nil {
h.c.Capabilities.FilesSharing = &data.CapabilitiesFilesSharing{}
h.c.Capabilities.FilesSharing = &ocs.CapabilitiesFilesSharing{}
}
// h.c.Capabilities.FilesSharing.APIEnabled is boolean
if h.c.Capabilities.FilesSharing.Public == nil {
h.c.Capabilities.FilesSharing.Public = &data.CapabilitiesFilesSharingPublic{}
h.c.Capabilities.FilesSharing.Public = &ocs.CapabilitiesFilesSharingPublic{}
}
// h.c.Capabilities.FilesSharing.IsPublic.Enabled is boolean
h.c.Capabilities.FilesSharing.Public.Enabled = true
if h.c.Capabilities.FilesSharing.Public.Password == nil {
h.c.Capabilities.FilesSharing.Public.Password = &data.CapabilitiesFilesSharingPublicPassword{}
h.c.Capabilities.FilesSharing.Public.Password = &ocs.CapabilitiesFilesSharingPublicPassword{}
}
if h.c.Capabilities.FilesSharing.Public.Password.EnforcedFor == nil {
h.c.Capabilities.FilesSharing.Public.Password.EnforcedFor = &data.CapabilitiesFilesSharingPublicPasswordEnforcedFor{}
h.c.Capabilities.FilesSharing.Public.Password.EnforcedFor = &ocs.CapabilitiesFilesSharingPublicPasswordEnforcedFor{}
}
// h.c.Capabilities.FilesSharing.IsPublic.Password.EnforcedFor.ReadOnly is boolean
@@ -158,7 +158,7 @@ func (h *Handler) Init(c *config.Config) {
// h.c.Capabilities.FilesSharing.IsPublic.Password.Enforced is boolean
if h.c.Capabilities.FilesSharing.Public.ExpireDate == nil {
h.c.Capabilities.FilesSharing.Public.ExpireDate = &data.CapabilitiesFilesSharingPublicExpireDate{}
h.c.Capabilities.FilesSharing.Public.ExpireDate = &ocs.CapabilitiesFilesSharingPublicExpireDate{}
}
// h.c.Capabilities.FilesSharing.IsPublic.ExpireDate.Enabled is boolean
@@ -169,7 +169,7 @@ func (h *Handler) Init(c *config.Config) {
// h.c.Capabilities.FilesSharing.IsPublic.SupportsUploadOnly is boolean
if h.c.Capabilities.FilesSharing.User == nil {
h.c.Capabilities.FilesSharing.User = &data.CapabilitiesFilesSharingUser{}
h.c.Capabilities.FilesSharing.User = &ocs.CapabilitiesFilesSharingUser{}
}
// h.c.Capabilities.FilesSharing.User.SendMail is boolean
@@ -182,7 +182,7 @@ func (h *Handler) Init(c *config.Config) {
// h.c.Capabilities.FilesSharing.ShareWithMembershipGroupsOnly is boolean
if h.c.Capabilities.FilesSharing.UserEnumeration == nil {
h.c.Capabilities.FilesSharing.UserEnumeration = &data.CapabilitiesFilesSharingUserEnumeration{}
h.c.Capabilities.FilesSharing.UserEnumeration = &ocs.CapabilitiesFilesSharingUserEnumeration{}
}
// h.c.Capabilities.FilesSharing.UserEnumeration.Enabled is boolean
@@ -192,7 +192,7 @@ func (h *Handler) Init(c *config.Config) {
h.c.Capabilities.FilesSharing.DefaultPermissions = 31
}
if h.c.Capabilities.FilesSharing.Federation == nil {
h.c.Capabilities.FilesSharing.Federation = &data.CapabilitiesFilesSharingFederation{}
h.c.Capabilities.FilesSharing.Federation = &ocs.CapabilitiesFilesSharingFederation{}
}
// h.c.Capabilities.FilesSharing.Federation.Outgoing is boolean
@@ -205,7 +205,7 @@ func (h *Handler) Init(c *config.Config) {
// notifications
// if h.c.Capabilities.Notifications == nil {
// h.c.Capabilities.Notifications = &data.CapabilitiesNotifications{}
// h.c.Capabilities.Notifications = &ocs.CapabilitiesNotifications{}
// }
// if h.c.Capabilities.Notifications.Endpoints == nil {
// h.c.Capabilities.Notifications.Endpoints = []string{"list", "get", "delete"}
@@ -214,7 +214,7 @@ func (h *Handler) Init(c *config.Config) {
// version
if h.c.Version == nil {
h.c.Version = &data.Version{
h.c.Version = &ocs.Version{
// TODO get from build env
Major: 10,
Minor: 0,

View File

@@ -21,7 +21,7 @@ package capabilities
import (
"strings"
"github.com/cs3org/reva/v2/internal/http/services/owncloud/ocs/data"
"github.com/cs3org/reva/v2/pkg/owncloud/ocs"
)
type chunkProtocol string
@@ -32,7 +32,7 @@ var (
chunkTUS chunkProtocol = "tus"
)
func (h *Handler) getCapabilitiesForUserAgent(userAgent string) data.CapabilitiesData {
func (h *Handler) getCapabilitiesForUserAgent(userAgent string) ocs.CapabilitiesData {
if userAgent != "" {
for k, v := range h.userAgentChunkingMap {
// we could also use a regexp for pattern matching
@@ -47,7 +47,7 @@ func (h *Handler) getCapabilitiesForUserAgent(userAgent string) data.Capabilitie
return h.c
}
func setCapabilitiesForChunkProtocol(cp chunkProtocol, c *data.CapabilitiesData) {
func setCapabilitiesForChunkProtocol(cp chunkProtocol, c *ocs.CapabilitiesData) {
switch cp {
case chunkV1:
// 2.7+ will use Chunking V1 if "capabilities > files > bigfilechunking" is "true" AND "capabilities > dav > chunking" is not there

View File

@@ -16,7 +16,7 @@
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package data
package ocs
import (
"encoding/xml"
@@ -42,7 +42,7 @@ func (c ocsBool) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return e.EncodeElement("0", start)
}
// CapabilitiesData TODO document
// CapabilitiesData holds the capabilities data
type CapabilitiesData struct {
Capabilities *Capabilities `json:"capabilities" xml:"capabilities"`
Version *Version `json:"version" xml:"version"`
@@ -59,6 +59,7 @@ type Capabilities struct {
Graph *CapabilitiesGraph `json:"graph,omitempty" xml:"graph,omitempty" mapstructure:"graph"`
PasswordPolicy *CapabilitiesPasswordPolicy `json:"password_policy,omitempty" xml:"password_policy,omitempty" mapstructure:"password_policy"`
Search *CapabilitiesSearch `json:"search,omitempty" xml:"search,omitempty" mapstructure:"search"`
Theme *CapabilitiesTheme `json:"theme,omitempty" xml:"theme,omitempty" mapstructure:"theme"`
Notifications *CapabilitiesNotifications `json:"notifications,omitempty" xml:"notifications,omitempty"`
}
@@ -290,6 +291,17 @@ type CapabilitiesNotifications struct {
Endpoints []string `json:"ocs-endpoints,omitempty" xml:"ocs-endpoints>element,omitempty" mapstructure:"endpoints"`
}
// CapabilitiesTheme holds theming capabilities
type CapabilitiesTheme struct {
Logo *CapabilitiesThemeLogo `json:"logo" xml:"logo" mapstructure:"logo"`
}
// CapabilitiesThemeLogo holds theming logo capabilities
type CapabilitiesThemeLogo struct {
// xml marshal, unmarshal does not support map[string]string, needs a custom type with MarshalXML and UnmarshalXML implementations
PermittedFileTypes map[string]string `json:"permitted_file_types" xml:"-" mapstructure:"permitted_file_types"`
}
// Version holds version information
type Version struct {
Major int `json:"major" xml:"major"`

21
vendor/github.com/tidwall/sjson/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 Josh Baker
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

278
vendor/github.com/tidwall/sjson/README.md generated vendored Normal file
View File

@@ -0,0 +1,278 @@
<p align="center">
<img
src="logo.png"
width="240" height="78" border="0" alt="SJSON">
<br>
<a href="https://godoc.org/github.com/tidwall/sjson"><img src="https://img.shields.io/badge/api-reference-blue.svg?style=flat-square" alt="GoDoc"></a>
</p>
<p align="center">set a json value quickly</p>
SJSON is a Go package that provides a [very fast](#performance) and simple way to set a value in a json document.
For quickly retrieving json values check out [GJSON](https://github.com/tidwall/gjson).
For a command line interface check out [JJ](https://github.com/tidwall/jj).
Getting Started
===============
Installing
----------
To start using SJSON, install Go and run `go get`:
```sh
$ go get -u github.com/tidwall/sjson
```
This will retrieve the library.
Set a value
-----------
Set sets the value for the specified path.
A path is in dot syntax, such as "name.last" or "age".
This function expects that the json is well-formed and validated.
Invalid json will not panic, but it may return back unexpected results.
Invalid paths may return an error.
```go
package main
import "github.com/tidwall/sjson"
const json = `{"name":{"first":"Janet","last":"Prichard"},"age":47}`
func main() {
value, _ := sjson.Set(json, "name.last", "Anderson")
println(value)
}
```
This will print:
```json
{"name":{"first":"Janet","last":"Anderson"},"age":47}
```
Path syntax
-----------
A path is a series of keys separated by a dot.
The dot and colon characters can be escaped with ``\``.
```json
{
"name": {"first": "Tom", "last": "Anderson"},
"age":37,
"children": ["Sara","Alex","Jack"],
"fav.movie": "Deer Hunter",
"friends": [
{"first": "James", "last": "Murphy"},
{"first": "Roger", "last": "Craig"}
]
}
```
```
"name.last" >> "Anderson"
"age" >> 37
"children.1" >> "Alex"
"friends.1.last" >> "Craig"
```
The `-1` key can be used to append a value to an existing array:
```
"children.-1" >> appends a new value to the end of the children array
```
Normally number keys are used to modify arrays, but it's possible to force a numeric object key by using the colon character:
```json
{
"users":{
"2313":{"name":"Sara"},
"7839":{"name":"Andy"}
}
}
```
A colon path would look like:
```
"users.:2313.name" >> "Sara"
```
Supported types
---------------
Pretty much any type is supported:
```go
sjson.Set(`{"key":true}`, "key", nil)
sjson.Set(`{"key":true}`, "key", false)
sjson.Set(`{"key":true}`, "key", 1)
sjson.Set(`{"key":true}`, "key", 10.5)
sjson.Set(`{"key":true}`, "key", "hello")
sjson.Set(`{"key":true}`, "key", []string{"hello", "world"})
sjson.Set(`{"key":true}`, "key", map[string]interface{}{"hello":"world"})
```
When a type is not recognized, SJSON will fallback to the `encoding/json` Marshaller.
Examples
--------
Set a value from empty document:
```go
value, _ := sjson.Set("", "name", "Tom")
println(value)
// Output:
// {"name":"Tom"}
```
Set a nested value from empty document:
```go
value, _ := sjson.Set("", "name.last", "Anderson")
println(value)
// Output:
// {"name":{"last":"Anderson"}}
```
Set a new value:
```go
value, _ := sjson.Set(`{"name":{"last":"Anderson"}}`, "name.first", "Sara")
println(value)
// Output:
// {"name":{"first":"Sara","last":"Anderson"}}
```
Update an existing value:
```go
value, _ := sjson.Set(`{"name":{"last":"Anderson"}}`, "name.last", "Smith")
println(value)
// Output:
// {"name":{"last":"Smith"}}
```
Set a new array value:
```go
value, _ := sjson.Set(`{"friends":["Andy","Carol"]}`, "friends.2", "Sara")
println(value)
// Output:
// {"friends":["Andy","Carol","Sara"]
```
Append an array value by using the `-1` key in a path:
```go
value, _ := sjson.Set(`{"friends":["Andy","Carol"]}`, "friends.-1", "Sara")
println(value)
// Output:
// {"friends":["Andy","Carol","Sara"]
```
Append an array value that is past the end:
```go
value, _ := sjson.Set(`{"friends":["Andy","Carol"]}`, "friends.4", "Sara")
println(value)
// Output:
// {"friends":["Andy","Carol",null,null,"Sara"]
```
Delete a value:
```go
value, _ := sjson.Delete(`{"name":{"first":"Sara","last":"Anderson"}}`, "name.first")
println(value)
// Output:
// {"name":{"last":"Anderson"}}
```
Delete an array value:
```go
value, _ := sjson.Delete(`{"friends":["Andy","Carol"]}`, "friends.1")
println(value)
// Output:
// {"friends":["Andy"]}
```
Delete the last array value:
```go
value, _ := sjson.Delete(`{"friends":["Andy","Carol"]}`, "friends.-1")
println(value)
// Output:
// {"friends":["Andy"]}
```
## Performance
Benchmarks of SJSON alongside [encoding/json](https://golang.org/pkg/encoding/json/),
[ffjson](https://github.com/pquerna/ffjson),
[EasyJSON](https://github.com/mailru/easyjson),
and [Gabs](https://github.com/Jeffail/gabs)
```
Benchmark_SJSON-8 3000000 805 ns/op 1077 B/op 3 allocs/op
Benchmark_SJSON_ReplaceInPlace-8 3000000 449 ns/op 0 B/op 0 allocs/op
Benchmark_JSON_Map-8 300000 21236 ns/op 6392 B/op 150 allocs/op
Benchmark_JSON_Struct-8 300000 14691 ns/op 1789 B/op 24 allocs/op
Benchmark_Gabs-8 300000 21311 ns/op 6752 B/op 150 allocs/op
Benchmark_FFJSON-8 300000 17673 ns/op 3589 B/op 47 allocs/op
Benchmark_EasyJSON-8 1500000 3119 ns/op 1061 B/op 13 allocs/op
```
JSON document used:
```json
{
"widget": {
"debug": "on",
"window": {
"title": "Sample Konfabulator Widget",
"name": "main_window",
"width": 500,
"height": 500
},
"image": {
"src": "Images/Sun.png",
"hOffset": 250,
"vOffset": 250,
"alignment": "center"
},
"text": {
"data": "Click Here",
"size": 36,
"style": "bold",
"vOffset": 100,
"alignment": "center",
"onMouseUp": "sun1.opacity = (sun1.opacity / 100) * 90;"
}
}
}
```
Each operation was rotated though one of the following search paths:
```
widget.window.name
widget.image.hOffset
widget.text.onMouseUp
```
*These benchmarks were run on a MacBook Pro 15" 2.8 GHz Intel Core i7 using Go 1.7 and can be be found [here](https://github.com/tidwall/sjson-benchmarks)*.
## Contact
Josh Baker [@tidwall](http://twitter.com/tidwall)
## License
SJSON source code is available under the MIT [License](/LICENSE).

BIN
vendor/github.com/tidwall/sjson/logo.png generated vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

737
vendor/github.com/tidwall/sjson/sjson.go generated vendored Normal file
View File

@@ -0,0 +1,737 @@
// Package sjson provides setting json values.
package sjson
import (
jsongo "encoding/json"
"sort"
"strconv"
"unsafe"
"github.com/tidwall/gjson"
)
type errorType struct {
msg string
}
func (err *errorType) Error() string {
return err.msg
}
// Options represents additional options for the Set and Delete functions.
type Options struct {
// Optimistic is a hint that the value likely exists which
// allows for the sjson to perform a fast-track search and replace.
Optimistic bool
// ReplaceInPlace is a hint to replace the input json rather than
// allocate a new json byte slice. When this field is specified
// the input json will not longer be valid and it should not be used
// In the case when the destination slice doesn't have enough free
// bytes to replace the data in place, a new bytes slice will be
// created under the hood.
// The Optimistic flag must be set to true and the input must be a
// byte slice in order to use this field.
ReplaceInPlace bool
}
type pathResult struct {
part string // current key part
gpart string // gjson get part
path string // remaining path
force bool // force a string key
more bool // there is more path to parse
}
func isSimpleChar(ch byte) bool {
switch ch {
case '|', '#', '@', '*', '?':
return false
default:
return true
}
}
func parsePath(path string) (res pathResult, simple bool) {
var r pathResult
if len(path) > 0 && path[0] == ':' {
r.force = true
path = path[1:]
}
for i := 0; i < len(path); i++ {
if path[i] == '.' {
r.part = path[:i]
r.gpart = path[:i]
r.path = path[i+1:]
r.more = true
return r, true
}
if !isSimpleChar(path[i]) {
return r, false
}
if path[i] == '\\' {
// go into escape mode. this is a slower path that
// strips off the escape character from the part.
epart := []byte(path[:i])
gpart := []byte(path[:i+1])
i++
if i < len(path) {
epart = append(epart, path[i])
gpart = append(gpart, path[i])
i++
for ; i < len(path); i++ {
if path[i] == '\\' {
gpart = append(gpart, '\\')
i++
if i < len(path) {
epart = append(epart, path[i])
gpart = append(gpart, path[i])
}
continue
} else if path[i] == '.' {
r.part = string(epart)
r.gpart = string(gpart)
r.path = path[i+1:]
r.more = true
return r, true
} else if !isSimpleChar(path[i]) {
return r, false
}
epart = append(epart, path[i])
gpart = append(gpart, path[i])
}
}
// append the last part
r.part = string(epart)
r.gpart = string(gpart)
return r, true
}
}
r.part = path
r.gpart = path
return r, true
}
func mustMarshalString(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] < ' ' || s[i] > 0x7f || s[i] == '"' || s[i] == '\\' {
return true
}
}
return false
}
// appendStringify makes a json string and appends to buf.
func appendStringify(buf []byte, s string) []byte {
if mustMarshalString(s) {
b, _ := jsongo.Marshal(s)
return append(buf, b...)
}
buf = append(buf, '"')
buf = append(buf, s...)
buf = append(buf, '"')
return buf
}
// appendBuild builds a json block from a json path.
func appendBuild(buf []byte, array bool, paths []pathResult, raw string,
stringify bool) []byte {
if !array {
buf = appendStringify(buf, paths[0].part)
buf = append(buf, ':')
}
if len(paths) > 1 {
n, numeric := atoui(paths[1])
if numeric || (!paths[1].force && paths[1].part == "-1") {
buf = append(buf, '[')
buf = appendRepeat(buf, "null,", n)
buf = appendBuild(buf, true, paths[1:], raw, stringify)
buf = append(buf, ']')
} else {
buf = append(buf, '{')
buf = appendBuild(buf, false, paths[1:], raw, stringify)
buf = append(buf, '}')
}
} else {
if stringify {
buf = appendStringify(buf, raw)
} else {
buf = append(buf, raw...)
}
}
return buf
}
// atoui does a rip conversion of string -> unigned int.
func atoui(r pathResult) (n int, ok bool) {
if r.force {
return 0, false
}
for i := 0; i < len(r.part); i++ {
if r.part[i] < '0' || r.part[i] > '9' {
return 0, false
}
n = n*10 + int(r.part[i]-'0')
}
return n, true
}
// appendRepeat repeats string "n" times and appends to buf.
func appendRepeat(buf []byte, s string, n int) []byte {
for i := 0; i < n; i++ {
buf = append(buf, s...)
}
return buf
}
// trim does a rip trim
func trim(s string) string {
for len(s) > 0 {
if s[0] <= ' ' {
s = s[1:]
continue
}
break
}
for len(s) > 0 {
if s[len(s)-1] <= ' ' {
s = s[:len(s)-1]
continue
}
break
}
return s
}
// deleteTailItem deletes the previous key or comma.
func deleteTailItem(buf []byte) ([]byte, bool) {
loop:
for i := len(buf) - 1; i >= 0; i-- {
// look for either a ',',':','['
switch buf[i] {
case '[':
return buf, true
case ',':
return buf[:i], false
case ':':
// delete tail string
i--
for ; i >= 0; i-- {
if buf[i] == '"' {
i--
for ; i >= 0; i-- {
if buf[i] == '"' {
i--
if i >= 0 && buf[i] == '\\' {
i--
continue
}
for ; i >= 0; i-- {
// look for either a ',','{'
switch buf[i] {
case '{':
return buf[:i+1], true
case ',':
return buf[:i], false
}
}
}
}
break
}
}
break loop
}
}
return buf, false
}
var errNoChange = &errorType{"no change"}
func appendRawPaths(buf []byte, jstr string, paths []pathResult, raw string,
stringify, del bool) ([]byte, error) {
var err error
var res gjson.Result
var found bool
if del {
if paths[0].part == "-1" && !paths[0].force {
res = gjson.Get(jstr, "#")
if res.Int() > 0 {
res = gjson.Get(jstr, strconv.FormatInt(int64(res.Int()-1), 10))
found = true
}
}
}
if !found {
res = gjson.Get(jstr, paths[0].gpart)
}
if res.Index > 0 {
if len(paths) > 1 {
buf = append(buf, jstr[:res.Index]...)
buf, err = appendRawPaths(buf, res.Raw, paths[1:], raw,
stringify, del)
if err != nil {
return nil, err
}
buf = append(buf, jstr[res.Index+len(res.Raw):]...)
return buf, nil
}
buf = append(buf, jstr[:res.Index]...)
var exidx int // additional forward stripping
if del {
var delNextComma bool
buf, delNextComma = deleteTailItem(buf)
if delNextComma {
i, j := res.Index+len(res.Raw), 0
for ; i < len(jstr); i, j = i+1, j+1 {
if jstr[i] <= ' ' {
continue
}
if jstr[i] == ',' {
exidx = j + 1
}
break
}
}
} else {
if stringify {
buf = appendStringify(buf, raw)
} else {
buf = append(buf, raw...)
}
}
buf = append(buf, jstr[res.Index+len(res.Raw)+exidx:]...)
return buf, nil
}
if del {
return nil, errNoChange
}
n, numeric := atoui(paths[0])
isempty := true
for i := 0; i < len(jstr); i++ {
if jstr[i] > ' ' {
isempty = false
break
}
}
if isempty {
if numeric {
jstr = "[]"
} else {
jstr = "{}"
}
}
jsres := gjson.Parse(jstr)
if jsres.Type != gjson.JSON {
if numeric {
jstr = "[]"
} else {
jstr = "{}"
}
jsres = gjson.Parse(jstr)
}
var comma bool
for i := 1; i < len(jsres.Raw); i++ {
if jsres.Raw[i] <= ' ' {
continue
}
if jsres.Raw[i] == '}' || jsres.Raw[i] == ']' {
break
}
comma = true
break
}
switch jsres.Raw[0] {
default:
return nil, &errorType{"json must be an object or array"}
case '{':
end := len(jsres.Raw) - 1
for ; end > 0; end-- {
if jsres.Raw[end] == '}' {
break
}
}
buf = append(buf, jsres.Raw[:end]...)
if comma {
buf = append(buf, ',')
}
buf = appendBuild(buf, false, paths, raw, stringify)
buf = append(buf, '}')
return buf, nil
case '[':
var appendit bool
if !numeric {
if paths[0].part == "-1" && !paths[0].force {
appendit = true
} else {
return nil, &errorType{
"cannot set array element for non-numeric key '" +
paths[0].part + "'"}
}
}
if appendit {
njson := trim(jsres.Raw)
if njson[len(njson)-1] == ']' {
njson = njson[:len(njson)-1]
}
buf = append(buf, njson...)
if comma {
buf = append(buf, ',')
}
buf = appendBuild(buf, true, paths, raw, stringify)
buf = append(buf, ']')
return buf, nil
}
buf = append(buf, '[')
ress := jsres.Array()
for i := 0; i < len(ress); i++ {
if i > 0 {
buf = append(buf, ',')
}
buf = append(buf, ress[i].Raw...)
}
if len(ress) == 0 {
buf = appendRepeat(buf, "null,", n-len(ress))
} else {
buf = appendRepeat(buf, ",null", n-len(ress))
if comma {
buf = append(buf, ',')
}
}
buf = appendBuild(buf, true, paths, raw, stringify)
buf = append(buf, ']')
return buf, nil
}
}
func isOptimisticPath(path string) bool {
for i := 0; i < len(path); i++ {
if path[i] < '.' || path[i] > 'z' {
return false
}
if path[i] > '9' && path[i] < 'A' {
return false
}
if path[i] > 'z' {
return false
}
}
return true
}
// Set sets a json value for the specified path.
// A path is in dot syntax, such as "name.last" or "age".
// This function expects that the json is well-formed, and does not validate.
// Invalid json will not panic, but it may return back unexpected results.
// An error is returned if the path is not valid.
//
// A path is a series of keys separated by a dot.
//
// {
// "name": {"first": "Tom", "last": "Anderson"},
// "age":37,
// "children": ["Sara","Alex","Jack"],
// "friends": [
// {"first": "James", "last": "Murphy"},
// {"first": "Roger", "last": "Craig"}
// ]
// }
// "name.last" >> "Anderson"
// "age" >> 37
// "children.1" >> "Alex"
//
func Set(json, path string, value interface{}) (string, error) {
return SetOptions(json, path, value, nil)
}
// SetBytes sets a json value for the specified path.
// If working with bytes, this method preferred over
// Set(string(data), path, value)
func SetBytes(json []byte, path string, value interface{}) ([]byte, error) {
return SetBytesOptions(json, path, value, nil)
}
// SetRaw sets a raw json value for the specified path.
// This function works the same as Set except that the value is set as a
// raw block of json. This allows for setting premarshalled json objects.
func SetRaw(json, path, value string) (string, error) {
return SetRawOptions(json, path, value, nil)
}
// SetRawOptions sets a raw json value for the specified path with options.
// This furnction works the same as SetOptions except that the value is set
// as a raw block of json. This allows for setting premarshalled json objects.
func SetRawOptions(json, path, value string, opts *Options) (string, error) {
var optimistic bool
if opts != nil {
optimistic = opts.Optimistic
}
res, err := set(json, path, value, false, false, optimistic, false)
if err == errNoChange {
return json, nil
}
return string(res), err
}
// SetRawBytes sets a raw json value for the specified path.
// If working with bytes, this method preferred over
// SetRaw(string(data), path, value)
func SetRawBytes(json []byte, path string, value []byte) ([]byte, error) {
return SetRawBytesOptions(json, path, value, nil)
}
type dtype struct{}
// Delete deletes a value from json for the specified path.
func Delete(json, path string) (string, error) {
return Set(json, path, dtype{})
}
// DeleteBytes deletes a value from json for the specified path.
func DeleteBytes(json []byte, path string) ([]byte, error) {
return SetBytes(json, path, dtype{})
}
type stringHeader struct {
data unsafe.Pointer
len int
}
type sliceHeader struct {
data unsafe.Pointer
len int
cap int
}
func set(jstr, path, raw string,
stringify, del, optimistic, inplace bool) ([]byte, error) {
if path == "" {
return []byte(jstr), &errorType{"path cannot be empty"}
}
if !del && optimistic && isOptimisticPath(path) {
res := gjson.Get(jstr, path)
if res.Exists() && res.Index > 0 {
sz := len(jstr) - len(res.Raw) + len(raw)
if stringify {
sz += 2
}
if inplace && sz <= len(jstr) {
if !stringify || !mustMarshalString(raw) {
jsonh := *(*stringHeader)(unsafe.Pointer(&jstr))
jsonbh := sliceHeader{
data: jsonh.data, len: jsonh.len, cap: jsonh.len}
jbytes := *(*[]byte)(unsafe.Pointer(&jsonbh))
if stringify {
jbytes[res.Index] = '"'
copy(jbytes[res.Index+1:], []byte(raw))
jbytes[res.Index+1+len(raw)] = '"'
copy(jbytes[res.Index+1+len(raw)+1:],
jbytes[res.Index+len(res.Raw):])
} else {
copy(jbytes[res.Index:], []byte(raw))
copy(jbytes[res.Index+len(raw):],
jbytes[res.Index+len(res.Raw):])
}
return jbytes[:sz], nil
}
return []byte(jstr), nil
}
buf := make([]byte, 0, sz)
buf = append(buf, jstr[:res.Index]...)
if stringify {
buf = appendStringify(buf, raw)
} else {
buf = append(buf, raw...)
}
buf = append(buf, jstr[res.Index+len(res.Raw):]...)
return buf, nil
}
}
var paths []pathResult
r, simple := parsePath(path)
if simple {
paths = append(paths, r)
for r.more {
r, simple = parsePath(r.path)
if !simple {
break
}
paths = append(paths, r)
}
}
if !simple {
if del {
return []byte(jstr),
&errorType{"cannot delete value from a complex path"}
}
return setComplexPath(jstr, path, raw, stringify)
}
njson, err := appendRawPaths(nil, jstr, paths, raw, stringify, del)
if err != nil {
return []byte(jstr), err
}
return njson, nil
}
func setComplexPath(jstr, path, raw string, stringify bool) ([]byte, error) {
res := gjson.Get(jstr, path)
if !res.Exists() || !(res.Index != 0 || len(res.Indexes) != 0) {
return []byte(jstr), errNoChange
}
if res.Index != 0 {
njson := []byte(jstr[:res.Index])
if stringify {
njson = appendStringify(njson, raw)
} else {
njson = append(njson, raw...)
}
njson = append(njson, jstr[res.Index+len(res.Raw):]...)
jstr = string(njson)
}
if len(res.Indexes) > 0 {
type val struct {
index int
res gjson.Result
}
vals := make([]val, 0, len(res.Indexes))
res.ForEach(func(_, vres gjson.Result) bool {
vals = append(vals, val{res: vres})
return true
})
if len(res.Indexes) != len(vals) {
return []byte(jstr), errNoChange
}
for i := 0; i < len(res.Indexes); i++ {
vals[i].index = res.Indexes[i]
}
sort.SliceStable(vals, func(i, j int) bool {
return vals[i].index > vals[j].index
})
for _, val := range vals {
vres := val.res
index := val.index
njson := []byte(jstr[:index])
if stringify {
njson = appendStringify(njson, raw)
} else {
njson = append(njson, raw...)
}
njson = append(njson, jstr[index+len(vres.Raw):]...)
jstr = string(njson)
}
}
return []byte(jstr), nil
}
// SetOptions sets a json value for the specified path with options.
// A path is in dot syntax, such as "name.last" or "age".
// This function expects that the json is well-formed, and does not validate.
// Invalid json will not panic, but it may return back unexpected results.
// An error is returned if the path is not valid.
func SetOptions(json, path string, value interface{},
opts *Options) (string, error) {
if opts != nil {
if opts.ReplaceInPlace {
// it's not safe to replace bytes in-place for strings
// copy the Options and set options.ReplaceInPlace to false.
nopts := *opts
opts = &nopts
opts.ReplaceInPlace = false
}
}
jsonh := *(*stringHeader)(unsafe.Pointer(&json))
jsonbh := sliceHeader{data: jsonh.data, len: jsonh.len, cap: jsonh.len}
jsonb := *(*[]byte)(unsafe.Pointer(&jsonbh))
res, err := SetBytesOptions(jsonb, path, value, opts)
return string(res), err
}
// SetBytesOptions sets a json value for the specified path with options.
// If working with bytes, this method preferred over
// SetOptions(string(data), path, value)
func SetBytesOptions(json []byte, path string, value interface{},
opts *Options) ([]byte, error) {
var optimistic, inplace bool
if opts != nil {
optimistic = opts.Optimistic
inplace = opts.ReplaceInPlace
}
jstr := *(*string)(unsafe.Pointer(&json))
var res []byte
var err error
switch v := value.(type) {
default:
b, merr := jsongo.Marshal(value)
if merr != nil {
return nil, merr
}
raw := *(*string)(unsafe.Pointer(&b))
res, err = set(jstr, path, raw, false, false, optimistic, inplace)
case dtype:
res, err = set(jstr, path, "", false, true, optimistic, inplace)
case string:
res, err = set(jstr, path, v, true, false, optimistic, inplace)
case []byte:
raw := *(*string)(unsafe.Pointer(&v))
res, err = set(jstr, path, raw, true, false, optimistic, inplace)
case bool:
if v {
res, err = set(jstr, path, "true", false, false, optimistic, inplace)
} else {
res, err = set(jstr, path, "false", false, false, optimistic, inplace)
}
case int8:
res, err = set(jstr, path, strconv.FormatInt(int64(v), 10),
false, false, optimistic, inplace)
case int16:
res, err = set(jstr, path, strconv.FormatInt(int64(v), 10),
false, false, optimistic, inplace)
case int32:
res, err = set(jstr, path, strconv.FormatInt(int64(v), 10),
false, false, optimistic, inplace)
case int64:
res, err = set(jstr, path, strconv.FormatInt(int64(v), 10),
false, false, optimistic, inplace)
case uint8:
res, err = set(jstr, path, strconv.FormatUint(uint64(v), 10),
false, false, optimistic, inplace)
case uint16:
res, err = set(jstr, path, strconv.FormatUint(uint64(v), 10),
false, false, optimistic, inplace)
case uint32:
res, err = set(jstr, path, strconv.FormatUint(uint64(v), 10),
false, false, optimistic, inplace)
case uint64:
res, err = set(jstr, path, strconv.FormatUint(uint64(v), 10),
false, false, optimistic, inplace)
case float32:
res, err = set(jstr, path, strconv.FormatFloat(float64(v), 'f', -1, 64),
false, false, optimistic, inplace)
case float64:
res, err = set(jstr, path, strconv.FormatFloat(float64(v), 'f', -1, 64),
false, false, optimistic, inplace)
}
if err == errNoChange {
return json, nil
}
return res, err
}
// SetRawBytesOptions sets a raw json value for the specified path with options.
// If working with bytes, this method preferred over
// SetRawOptions(string(data), path, value, opts)
func SetRawBytesOptions(json []byte, path string, value []byte,
opts *Options) ([]byte, error) {
jstr := *(*string)(unsafe.Pointer(&json))
vstr := *(*string)(unsafe.Pointer(&value))
var optimistic, inplace bool
if opts != nil {
optimistic = opts.Optimistic
inplace = opts.ReplaceInPlace
}
res, err := set(jstr, path, vstr, false, false, optimistic, inplace)
if err == errNoChange {
return json, nil
}
return res, err
}

6
vendor/modules.txt vendored
View File

@@ -366,7 +366,7 @@ github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1
github.com/cs3org/go-cs3apis/cs3/storage/registry/v1beta1
github.com/cs3org/go-cs3apis/cs3/tx/v1beta1
github.com/cs3org/go-cs3apis/cs3/types/v1beta1
# github.com/cs3org/reva/v2 v2.19.2-0.20240525160759-56879111e06a
# github.com/cs3org/reva/v2 v2.19.2-0.20240529081036-419196f2342e
## explicit; go 1.21
github.com/cs3org/reva/v2/cmd/revad/internal/grace
github.com/cs3org/reva/v2/cmd/revad/runtime
@@ -569,6 +569,7 @@ github.com/cs3org/reva/v2/pkg/ocm/share/repository/loader
github.com/cs3org/reva/v2/pkg/ocm/share/repository/nextcloud
github.com/cs3org/reva/v2/pkg/ocm/share/repository/registry
github.com/cs3org/reva/v2/pkg/ocm/storage/received
github.com/cs3org/reva/v2/pkg/owncloud/ocs
github.com/cs3org/reva/v2/pkg/password
github.com/cs3org/reva/v2/pkg/permission
github.com/cs3org/reva/v2/pkg/permission/manager/demo
@@ -1790,6 +1791,9 @@ github.com/tidwall/match
# github.com/tidwall/pretty v1.2.1
## explicit; go 1.16
github.com/tidwall/pretty
# github.com/tidwall/sjson v1.2.5
## explicit; go 1.14
github.com/tidwall/sjson
# github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208
## explicit
github.com/toorop/go-dkim