diff --git a/changelog/unreleased/spaces.md b/changelog/unreleased/spaces.md new file mode 100644 index 0000000000..623fb8bc27 --- /dev/null +++ b/changelog/unreleased/spaces.md @@ -0,0 +1,5 @@ +Enhancement: add support for Spaces + +Credits to @gmgigi96 + +https://github.com/cs3org/reva/pull/4404 diff --git a/cmd/revad/runtime/loader.go b/cmd/revad/runtime/loader.go index 40d9253fad..a886d03c6f 100644 --- a/cmd/revad/runtime/loader.go +++ b/cmd/revad/runtime/loader.go @@ -43,6 +43,7 @@ import ( _ "github.com/cs3org/reva/pkg/ocm/share/repository/loader" _ "github.com/cs3org/reva/pkg/permission/manager/loader" _ "github.com/cs3org/reva/pkg/preferences/loader" + _ "github.com/cs3org/reva/pkg/projects/manager/loader" _ "github.com/cs3org/reva/pkg/prom/loader" _ "github.com/cs3org/reva/pkg/publicshare/manager/loader" _ "github.com/cs3org/reva/pkg/rhttp/datatx/manager/loader" diff --git a/docs/content/en/docs/config/grpc/services/storageprovider/_index.md b/docs/content/en/docs/config/grpc/services/storageprovider/_index.md index 6c1b9fbb75..bb73bcb81f 100644 --- a/docs/content/en/docs/config/grpc/services/storageprovider/_index.md +++ b/docs/content/en/docs/config/grpc/services/storageprovider/_index.md @@ -43,16 +43,8 @@ user_layout = "{{.Username}}" {{< /highlight >}} {{% /dir %}} -{{% dir name="tmp_folder" type="string" default="/var/tmp" %}} -Path to temporary folder. [[Ref]](https://github.com/cs3org/reva/tree/master/internal/grpc/services/storageprovider/storageprovider.go#L65) -{{< highlight toml >}} -[grpc.services.storageprovider] -tmp_folder = "/var/tmp" -{{< /highlight >}} -{{% /dir %}} - {{% dir name="data_server_url" type="string" default="http://localhost/data" %}} -The URL for the data server. [[Ref]](https://github.com/cs3org/reva/tree/master/internal/grpc/services/storageprovider/storageprovider.go#L66) +The URL for the data server. [[Ref]](https://github.com/cs3org/reva/tree/master/internal/grpc/services/storageprovider/storageprovider.go#L65) {{< highlight toml >}} [grpc.services.storageprovider] data_server_url = "http://localhost/data" @@ -60,7 +52,7 @@ data_server_url = "http://localhost/data" {{% /dir %}} {{% dir name="expose_data_server" type="bool" default=false %}} -Whether to expose data server. [[Ref]](https://github.com/cs3org/reva/tree/master/internal/grpc/services/storageprovider/storageprovider.go#L67) +Whether to expose data server. [[Ref]](https://github.com/cs3org/reva/tree/master/internal/grpc/services/storageprovider/storageprovider.go#L66) {{< highlight toml >}} [grpc.services.storageprovider] expose_data_server = false @@ -68,7 +60,7 @@ expose_data_server = false {{% /dir %}} {{% dir name="available_checksums" type="map[string]uint32" default=nil %}} -List of available checksums. [[Ref]](https://github.com/cs3org/reva/tree/master/internal/grpc/services/storageprovider/storageprovider.go#L68) +List of available checksums. [[Ref]](https://github.com/cs3org/reva/tree/master/internal/grpc/services/storageprovider/storageprovider.go#L67) {{< highlight toml >}} [grpc.services.storageprovider] available_checksums = nil @@ -76,7 +68,7 @@ available_checksums = nil {{% /dir %}} {{% dir name="custom_mime_types_json" type="string" default="nil" %}} -An optional mapping file with the list of supported custom file extensions and corresponding mime types. [[Ref]](https://github.com/cs3org/reva/tree/master/internal/grpc/services/storageprovider/storageprovider.go#L69) +An optional mapping file with the list of supported custom file extensions and corresponding mime types. [[Ref]](https://github.com/cs3org/reva/tree/master/internal/grpc/services/storageprovider/storageprovider.go#L68) {{< highlight toml >}} [grpc.services.storageprovider] custom_mime_types_json = "nil" diff --git a/docs/content/en/docs/config/http/services/owncloud/ocdav/_index.md b/docs/content/en/docs/config/http/services/owncloud/ocdav/_index.md index 600436541d..dbcc34cdb2 100644 --- a/docs/content/en/docs/config/http/services/owncloud/ocdav/_index.md +++ b/docs/content/en/docs/config/http/services/owncloud/ocdav/_index.md @@ -9,7 +9,7 @@ description: > # _struct: Config_ {{% dir name="insecure" type="bool" default=false %}} -Whether to skip certificate checks when sending requests. [[Ref]](https://github.com/cs3org/reva/tree/master/internal/http/services/owncloud/ocdav/ocdav.go#L110) +Whether to skip certificate checks when sending requests. [[Ref]](https://github.com/cs3org/reva/tree/master/internal/http/services/owncloud/ocdav/ocdav.go#L114) {{< highlight toml >}} [http.services.owncloud.ocdav] insecure = false @@ -17,7 +17,7 @@ insecure = false {{% /dir %}} {{% dir name="notifications" type="map[string]interface{}" default=nil %}} - settings for the notification helper [[Ref]](https://github.com/cs3org/reva/tree/master/internal/http/services/owncloud/ocdav/ocdav.go#L123) + settings for the notification helper [[Ref]](https://github.com/cs3org/reva/tree/master/internal/http/services/owncloud/ocdav/ocdav.go#L127) {{< highlight toml >}} [http.services.owncloud.ocdav] notifications = nil diff --git a/go.mod b/go.mod index 3af6c7c49e..d20e7d1a7d 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,7 @@ module github.com/cs3org/reva require ( github.com/BurntSushi/toml v1.3.2 + github.com/CiscoM31/godata v1.0.8 github.com/Masterminds/sprig v2.22.0+incompatible github.com/ReneKroon/ttlcache/v2 v2.11.0 github.com/beevik/etree v1.3.0 @@ -18,7 +19,7 @@ require ( github.com/dolthub/go-mysql-server v0.14.0 github.com/gdexlab/go-render v1.0.1 github.com/glpatcern/go-mime v0.0.0-20221026162842-2a8d71ad17a9 - github.com/go-chi/chi/v5 v5.0.12 + github.com/go-chi/chi/v5 v5.1.0 github.com/go-ldap/ldap/v3 v3.4.6 github.com/go-playground/locales v0.14.1 github.com/go-playground/universal-translator v0.18.1 @@ -41,6 +42,7 @@ require ( github.com/nats-io/nats.go v1.37.0 github.com/onsi/ginkgo v1.16.5 github.com/onsi/gomega v1.30.0 + github.com/owncloud/libre-graph-api-go v1.0.5-0.20240425090020-dba6d1507c38 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.19.0 github.com/rs/cors v1.10.1 @@ -64,11 +66,14 @@ require ( gotest.tools v2.2.0+incompatible ) +require github.com/google/go-cmp v0.6.0 // indirect + require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect + github.com/alitto/pond v1.8.3 github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bmizerany/pat v0.0.0-20210406213842-e4b6760bdd6f // indirect @@ -132,3 +137,8 @@ require ( ) go 1.21 + +replace ( + github.com/eventials/go-tus => github.com/andrewmostello/go-tus v0.0.0-20200314041820-904a9904af9a + github.com/oleiade/reflections => github.com/oleiade/reflections v1.0.1 +) diff --git a/go.sum b/go.sum index 470b07d00c..2d392c9a69 100644 --- a/go.sum +++ b/go.sum @@ -770,6 +770,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/CiscoM31/godata v1.0.8 h1:ZhPjm1dSwZWMUvb33P4bcVm048iiQ1wbncoCc9bLChQ= +github.com/CiscoM31/godata v1.0.8/go.mod h1:ZMiT6JuD3Rm83HEtiTx4JEChsd25YCrxchKGag/sdTc= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= @@ -802,6 +804,8 @@ github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk5 github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA= github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/alitto/pond v1.8.3 h1:ydIqygCLVPqIX/USe5EaV/aSRXTRXDEI9JwuDdu+/xs= +github.com/alitto/pond v1.8.3/go.mod h1:CmvIIGd5jKLasGI3D87qDkQxjzChdKMmnXMg3fG6M6Q= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0= @@ -957,6 +961,8 @@ github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g= github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks= github.com/go-fonts/liberation v0.1.1/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY= @@ -1376,6 +1382,8 @@ github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxS github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= +github.com/owncloud/libre-graph-api-go v1.0.5-0.20240425090020-dba6d1507c38 h1:Ld9bPh0c4y1H22mhiWZBw4AoupWjg8L0WLKX0hfbJho= +github.com/owncloud/libre-graph-api-go v1.0.5-0.20240425090020-dba6d1507c38/go.mod h1:yXI+rmE8yYx+ZsGVrnCpprw/gZMcxjwntnX2y2+VKxY= github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= diff --git a/internal/grpc/services/gateway/gateway.go b/internal/grpc/services/gateway/gateway.go index 20bea533e9..d18ad85021 100644 --- a/internal/grpc/services/gateway/gateway.go +++ b/internal/grpc/services/gateway/gateway.go @@ -57,6 +57,7 @@ type config struct { DataTxEndpoint string `mapstructure:"datatx"` DataGatewayEndpoint string `mapstructure:"datagateway"` PermissionsEndpoint string `mapstructure:"permissionssvc"` + SpacesEndpoint string `mapstructure:"spacessvc"` CommitShareToStorageGrant bool `mapstructure:"commit_share_to_storage_grant"` CommitShareToStorageRef bool `mapstructure:"commit_share_to_storage_ref"` DisableHomeCreationOnLogin bool `mapstructure:"disable_home_creation_on_login"` @@ -101,6 +102,7 @@ func (c *config) ApplyDefaults() { c.UserProviderEndpoint = sharedconf.GetGatewaySVC(c.UserProviderEndpoint) c.GroupProviderEndpoint = sharedconf.GetGatewaySVC(c.GroupProviderEndpoint) c.DataTxEndpoint = sharedconf.GetGatewaySVC(c.DataTxEndpoint) + c.SpacesEndpoint = sharedconf.GetGatewaySVC(c.SpacesEndpoint) c.DataGatewayEndpoint = sharedconf.GetDataGateway(c.DataGatewayEndpoint) diff --git a/internal/grpc/services/gateway/publicshareprovider.go b/internal/grpc/services/gateway/publicshareprovider.go index 8111de49ad..24afcc1d0a 100644 --- a/internal/grpc/services/gateway/publicshareprovider.go +++ b/internal/grpc/services/gateway/publicshareprovider.go @@ -21,12 +21,14 @@ package gateway import ( "context" + "github.com/alitto/pond" gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" - collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1" link "github.com/cs3org/go-cs3apis/cs3/sharing/link/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" "github.com/cs3org/reva/pkg/appctx" "github.com/cs3org/reva/pkg/errtypes" + "github.com/cs3org/reva/pkg/rgrpc/status" "github.com/cs3org/reva/pkg/rgrpc/todo/pool" "github.com/pkg/errors" ) @@ -101,16 +103,6 @@ func (s *svc) GetPublicShare(ctx context.Context, req *link.GetPublicShareReques return pClient.GetPublicShare(ctx, req) } -func (s *svc) ListExistingPublicShares(ctx context.Context, req *link.ListPublicSharesRequest) (*gateway.ListExistingPublicSharesResponse, error) { - return nil, nil -} -func (s *svc) ListExistingReceivedShares(ctx context.Context, req *collaboration.ListReceivedSharesRequest) (*gateway.ListExistingReceivedSharesResponse, error) { - return nil, nil -} -func (s *svc) ListExistingShares(ctx context.Context, req *collaboration.ListSharesRequest) (*gateway.ListExistingSharesResponse, error) { - return nil, nil -} - func (s *svc) ListPublicShares(ctx context.Context, req *link.ListPublicSharesRequest) (*link.ListPublicSharesResponse, error) { log := appctx.GetLogger(ctx) log.Info().Msg("listing public shares") @@ -133,6 +125,60 @@ func (s *svc) ListPublicShares(ctx context.Context, req *link.ListPublicSharesRe return res, nil } +func (s *svc) ListExistingPublicShares(ctx context.Context, req *link.ListPublicSharesRequest) (*gateway.ListExistingPublicSharesResponse, error) { + shares, err := s.ListPublicShares(ctx, req) + if err != nil { + err := errors.Wrap(err, "gateway: error calling ListExistingPublicShares") + return &gateway.ListExistingPublicSharesResponse{ + Status: status.NewInternal(ctx, err, "error listing public shares"), + }, nil + } + + sharesCh := make(chan *gateway.PublicShareResourceInfo, len(shares.Share)) + pool := pond.New(50, len(shares.Share)) + for _, share := range shares.Share { + share := share + // TODO (gdelmont): we should report any eventual error raised by the goroutines + pool.Submit(func() { + // TODO(lopresti) incorporate the cache layer from internal/http/services/owncloud/ocs/handlers/apps/sharing/shares/shares.go + stat, err := s.Stat(ctx, &provider.StatRequest{ + Ref: &provider.Reference{ + ResourceId: share.ResourceId, + }, + }) + if err != nil { + return + } + if stat.Status.Code != rpc.Code_CODE_OK { + return + } + + sharesCh <- &gateway.PublicShareResourceInfo{ + ResourceInfo: stat.Info, + PublicShare: share, + } + }) + } + + sris := make([]*gateway.PublicShareResourceInfo, 0, len(shares.Share)) + done := make(chan struct{}) + go func() { + for s := range sharesCh { + sris = append(sris, s) + } + done <- struct{}{} + }() + pool.StopAndWait() + close(sharesCh) + <-done + close(done) + + return &gateway.ListExistingPublicSharesResponse{ + ShareInfos: sris, + Status: status.NewOK(ctx), + }, nil +} + func (s *svc) UpdatePublicShare(ctx context.Context, req *link.UpdatePublicShareRequest) (*link.UpdatePublicShareResponse, error) { log := appctx.GetLogger(ctx) log.Info().Msg("update public share") diff --git a/internal/grpc/services/gateway/spaces.go b/internal/grpc/services/gateway/spaces.go index 5a157694c4..c5658a507e 100644 --- a/internal/grpc/services/gateway/spaces.go +++ b/internal/grpc/services/gateway/spaces.go @@ -22,20 +22,71 @@ import ( "context" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/pkg/rgrpc/status" + "github.com/cs3org/reva/pkg/rgrpc/todo/pool" + "github.com/pkg/errors" ) func (s *svc) CreateStorageSpace(ctx context.Context, req *provider.CreateStorageSpaceRequest) (*provider.CreateStorageSpaceResponse, error) { - return nil, nil + c, err := pool.GetSpacesClient(pool.Endpoint(s.c.SpacesEndpoint)) + if err != nil { + return &provider.CreateStorageSpaceResponse{ + Status: status.NewInternal(ctx, err, "error getting spaces client"), + }, nil + } + + res, err := c.CreateStorageSpace(ctx, req) + if err != nil { + return nil, errors.Wrap(err, "gateway: error calling CreateStorageSpace") + } + + return res, nil } func (s *svc) ListStorageSpaces(ctx context.Context, req *provider.ListStorageSpacesRequest) (*provider.ListStorageSpacesResponse, error) { - return nil, nil + c, err := pool.GetSpacesClient(pool.Endpoint(s.c.SpacesEndpoint)) + if err != nil { + return &provider.ListStorageSpacesResponse{ + Status: status.NewInternal(ctx, err, "error getting spaces client"), + }, nil + } + + res, err := c.ListStorageSpaces(ctx, req) + if err != nil { + return nil, errors.Wrap(err, "gateway: error calling ListStorageSpaces") + } + + return res, nil } func (s *svc) UpdateStorageSpace(ctx context.Context, req *provider.UpdateStorageSpaceRequest) (*provider.UpdateStorageSpaceResponse, error) { - return nil, nil + c, err := pool.GetSpacesClient(pool.Endpoint(s.c.SpacesEndpoint)) + if err != nil { + return &provider.UpdateStorageSpaceResponse{ + Status: status.NewInternal(ctx, err, "error getting spaces client"), + }, nil + } + + res, err := c.UpdateStorageSpace(ctx, req) + if err != nil { + return nil, errors.Wrap(err, "gateway: error calling ListStorageSpaces") + } + + return res, nil } func (s *svc) DeleteStorageSpace(ctx context.Context, req *provider.DeleteStorageSpaceRequest) (*provider.DeleteStorageSpaceResponse, error) { - return nil, nil + c, err := pool.GetSpacesClient(pool.Endpoint(s.c.SpacesEndpoint)) + if err != nil { + return &provider.DeleteStorageSpaceResponse{ + Status: status.NewInternal(ctx, err, "error getting spaces client"), + }, nil + } + + res, err := c.DeleteStorageSpace(ctx, req) + if err != nil { + return nil, errors.Wrap(err, "gateway: error calling ListStorageSpaces") + } + + return res, nil } diff --git a/internal/grpc/services/gateway/usershareprovider.go b/internal/grpc/services/gateway/usershareprovider.go index 74e612b0c9..fd60610394 100644 --- a/internal/grpc/services/gateway/usershareprovider.go +++ b/internal/grpc/services/gateway/usershareprovider.go @@ -23,6 +23,8 @@ import ( "fmt" "path" + "github.com/alitto/pond" + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1" @@ -192,6 +194,60 @@ func (s *svc) ListShares(ctx context.Context, req *collaboration.ListSharesReque return res, nil } +func (s *svc) ListExistingShares(ctx context.Context, req *collaboration.ListSharesRequest) (*gateway.ListExistingSharesResponse, error) { + shares, err := s.ListShares(ctx, req) + if err != nil { + err := errors.Wrap(err, "gateway: error calling ListExistingShares") + return &gateway.ListExistingSharesResponse{ + Status: status.NewInternal(ctx, err, "error listing shares"), + }, nil + } + + sharesCh := make(chan *gateway.ShareResourceInfo, len(shares.Shares)) + pool := pond.New(50, len(shares.Shares)) + for _, share := range shares.Shares { + share := share + // TODO (gdelmont): we should report any eventual error raised by the goroutines + pool.Submit(func() { + // TODO(lopresti) incorporate the cache layer from internal/http/services/owncloud/ocs/handlers/apps/sharing/shares/shares.go + stat, err := s.Stat(ctx, &provider.StatRequest{ + Ref: &provider.Reference{ + ResourceId: share.ResourceId, + }, + }) + if err != nil { + return + } + if stat.Status.Code != rpc.Code_CODE_OK { + return + } + + sharesCh <- &gateway.ShareResourceInfo{ + ResourceInfo: stat.Info, + Share: share, + } + }) + } + + sris := make([]*gateway.ShareResourceInfo, 0, len(shares.Shares)) + done := make(chan struct{}) + go func() { + for s := range sharesCh { + sris = append(sris, s) + } + done <- struct{}{} + }() + pool.StopAndWait() + close(sharesCh) + <-done + close(done) + + return &gateway.ListExistingSharesResponse{ + ShareInfos: sris, + Status: status.NewOK(ctx), + }, nil +} + func (s *svc) UpdateShare(ctx context.Context, req *collaboration.UpdateShareRequest) (*collaboration.UpdateShareResponse, error) { c, err := pool.GetUserShareProviderClient(pool.Endpoint(s.c.UserShareProviderEndpoint)) if err != nil { @@ -252,6 +308,64 @@ func (s *svc) ListReceivedShares(ctx context.Context, req *collaboration.ListRec return res, nil } +func (s *svc) ListExistingReceivedShares(ctx context.Context, req *collaboration.ListReceivedSharesRequest) (*gateway.ListExistingReceivedSharesResponse, error) { + rshares, err := s.ListReceivedShares(ctx, req) + if err != nil { + err := errors.Wrap(err, "gateway: error calling ListExistingReceivedShares") + return &gateway.ListExistingReceivedSharesResponse{ + Status: status.NewInternal(ctx, err, "error listing received shares"), + }, nil + } + + sharesCh := make(chan *gateway.ReceivedShareResourceInfo, len(rshares.Shares)) + pool := pond.New(50, len(rshares.Shares)) + for _, rs := range rshares.Shares { + rs := rs + // TODO (gdelmont): we should report any eventual error raised by the goroutines + pool.Submit(func() { + if rs.State == collaboration.ShareState_SHARE_STATE_REJECTED || rs.State == collaboration.ShareState_SHARE_STATE_INVALID { + return + } + + // TODO(lopresti) incorporate the cache layer from internal/http/services/owncloud/ocs/handlers/apps/sharing/shares/shares.go + stat, err := s.Stat(ctx, &provider.StatRequest{ + Ref: &provider.Reference{ + ResourceId: rs.Share.ResourceId, + }, + }) + if err != nil { + return + } + if stat.Status.Code != rpc.Code_CODE_OK { + return + } + + sharesCh <- &gateway.ReceivedShareResourceInfo{ + ResourceInfo: stat.Info, + ReceivedShare: rs, + } + }) + } + + sris := make([]*gateway.ReceivedShareResourceInfo, 0, len(rshares.Shares)) + done := make(chan struct{}) + go func() { + for s := range sharesCh { + sris = append(sris, s) + } + done <- struct{}{} + }() + pool.StopAndWait() + close(sharesCh) + <-done + close(done) + + return &gateway.ListExistingReceivedSharesResponse{ + ShareInfos: sris, + Status: status.NewOK(ctx), + }, nil +} + func (s *svc) GetReceivedShare(ctx context.Context, req *collaboration.GetReceivedShareRequest) (*collaboration.GetReceivedShareResponse, error) { c, err := pool.GetUserShareProviderClient(pool.Endpoint(s.c.UserShareProviderEndpoint)) if err != nil { diff --git a/internal/grpc/services/loader/loader.go b/internal/grpc/services/loader/loader.go index b767307db7..f5646b7efa 100644 --- a/internal/grpc/services/loader/loader.go +++ b/internal/grpc/services/loader/loader.go @@ -38,6 +38,7 @@ import ( _ "github.com/cs3org/reva/internal/grpc/services/preferences" _ "github.com/cs3org/reva/internal/grpc/services/publicshareprovider" _ "github.com/cs3org/reva/internal/grpc/services/publicstorageprovider" + _ "github.com/cs3org/reva/internal/grpc/services/spacesregistry" _ "github.com/cs3org/reva/internal/grpc/services/storageprovider" _ "github.com/cs3org/reva/internal/grpc/services/storageregistry" _ "github.com/cs3org/reva/internal/grpc/services/userprovider" diff --git a/internal/grpc/services/spacesregistry/spacesregistry.go b/internal/grpc/services/spacesregistry/spacesregistry.go new file mode 100644 index 0000000000..1f20041241 --- /dev/null +++ b/internal/grpc/services/spacesregistry/spacesregistry.go @@ -0,0 +1,312 @@ +// Copyright 2018-2024 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package spacesregistry + +import ( + "context" + "errors" + + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/internal/http/services/owncloud/ocs/conversions" + "github.com/cs3org/reva/pkg/appctx" + "github.com/cs3org/reva/pkg/errtypes" + "github.com/cs3org/reva/pkg/plugin" + "github.com/cs3org/reva/pkg/projects" + "github.com/cs3org/reva/pkg/projects/manager/registry" + "github.com/cs3org/reva/pkg/rgrpc" + "github.com/cs3org/reva/pkg/rgrpc/status" + "github.com/cs3org/reva/pkg/rgrpc/todo/pool" + "github.com/cs3org/reva/pkg/sharedconf" + "github.com/cs3org/reva/pkg/spaces" + "github.com/cs3org/reva/pkg/storage/utils/templates" + "github.com/cs3org/reva/pkg/utils" + "github.com/cs3org/reva/pkg/utils/cfg" + "github.com/cs3org/reva/pkg/utils/list" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +func init() { + rgrpc.Register("spacesregistry", New) + plugin.RegisterNamespace("grpc.services.spacesregistry.drivers", func(name string, newFunc any) { + var f registry.NewFunc + utils.Cast(newFunc, &f) + registry.Register(name, f) + }) +} + +type config struct { + Driver string `mapstructure:"driver"` + Drivers map[string]map[string]any `mapstructure:"drivers"` + UserSpace string `mapstructure:"user_space" validate:"required"` + MachineSecret string `mapstructure:"machine_secret" validate:"required"` +} + +func (c *config) ApplyDefaults() { + if c.UserSpace == "" { + c.UserSpace = "/home" + } +} + +type service struct { + c *config + projects projects.Catalogue + gw gateway.GatewayAPIClient +} + +func New(ctx context.Context, m map[string]interface{}) (rgrpc.Service, error) { + var c config + if err := cfg.Decode(m, &c); err != nil { + return nil, err + } + s, err := getSpacesDriver(ctx, c.Driver, c.Drivers) + if err != nil { + return nil, err + } + + client, err := pool.GetGatewayServiceClient(pool.Endpoint(sharedconf.GetGatewaySVC(""))) + if err != nil { + return nil, err + } + + svc := service{ + c: &c, + projects: s, + gw: client, + } + return &svc, nil +} + +func getSpacesDriver(ctx context.Context, driver string, cfg map[string]map[string]any) (projects.Catalogue, error) { + if f, ok := registry.NewFuncs[driver]; ok { + return f(ctx, cfg[driver]) + } + return nil, errtypes.NotFound("driver not found: " + driver) +} + +func (s *service) CreateStorageSpace(ctx context.Context, req *provider.CreateStorageSpaceRequest) (*provider.CreateStorageSpaceResponse, error) { + return nil, errors.New("not yet implemented") +} + +func countTypeFilters(filters []*provider.ListStorageSpacesRequest_Filter) (count int) { + for _, f := range filters { + if f.Type == provider.ListStorageSpacesRequest_Filter_TYPE_SPACE_TYPE { + count++ + } + } + return +} + +func (s *service) ListStorageSpaces(ctx context.Context, req *provider.ListStorageSpacesRequest) (*provider.ListStorageSpacesResponse, error) { + user := appctx.ContextMustGetUser(ctx) + filters := req.Filters + + sp := []*provider.StorageSpace{} + if countTypeFilters(filters) == 0 { + homes, err := s.listSpacesByType(ctx, user, spaces.SpaceTypeHome) + if err != nil { + return &provider.ListStorageSpacesResponse{Status: status.NewInternal(ctx, err, err.Error())}, nil + } + sp = append(sp, homes...) + + projects, err := s.listSpacesByType(ctx, user, spaces.SpaceTypeProject) + if err != nil { + return &provider.ListStorageSpacesResponse{Status: status.NewInternal(ctx, err, err.Error())}, nil + } + sp = append(sp, projects...) + } + + for _, filter := range filters { + switch filter.Type { + case provider.ListStorageSpacesRequest_Filter_TYPE_SPACE_TYPE: + spaces, err := s.listSpacesByType(ctx, user, spaces.SpaceType(filter.Term.(*provider.ListStorageSpacesRequest_Filter_SpaceType).SpaceType)) + if err != nil { + return &provider.ListStorageSpacesResponse{Status: status.NewInternal(ctx, err, err.Error())}, nil + } + sp = append(sp, spaces...) + case provider.ListStorageSpacesRequest_Filter_TYPE_ID: + default: + return nil, errtypes.NotSupported("filter not supported") + } + } + + // TODO: we should filter at the driver level. + // for now let's do it here. optimizations later :) + if id, ok := isFilterByID(req.Filters); ok { + sp = list.Filter(sp, func(s *provider.StorageSpace) bool { return s.Id.OpaqueId == id }) + } + + return &provider.ListStorageSpacesResponse{Status: status.NewOK(ctx), StorageSpaces: sp}, nil +} + +func isFilterByID(filters []*provider.ListStorageSpacesRequest_Filter) (string, bool) { + for _, f := range filters { + if f.Type == provider.ListStorageSpacesRequest_Filter_TYPE_ID { + return f.Term.(*provider.ListStorageSpacesRequest_Filter_Id).Id.OpaqueId, true + } + } + return "", false +} + +func (s *service) listSpacesByType(ctx context.Context, user *userpb.User, spaceType spaces.SpaceType) ([]*provider.StorageSpace, error) { + sp := []*provider.StorageSpace{} + + if spaceType == spaces.SpaceTypeHome { + space, err := s.userSpace(ctx, user) + if err != nil { + return nil, err + } + if space != nil { + sp = append(sp, space) + } + } else if spaceType == spaces.SpaceTypeProject { + projects, err := s.projects.ListProjects(ctx, user) + if err != nil { + return nil, err + } + if err := s.decorateProjects(ctx, projects); err != nil { + return nil, err + } + sp = append(sp, projects...) + } + + return sp, nil +} + +func (s *service) decorateProjects(ctx context.Context, projects []*provider.StorageSpace) error { + for _, proj := range projects { + // ADD QUOTA + + // To get the quota for a project, we cannot do the request + // on behalf of the current logged user, because the project + // is owned by an other account, in general different from the + // logged in user. + // We need then to impersonate the owner and ask the quota + // on behalf of him. + + authRes, err := s.gw.Authenticate(ctx, &gateway.AuthenticateRequest{ + Type: "machine", + ClientId: proj.Owner.Id.OpaqueId, + ClientSecret: s.c.MachineSecret, + }) + if err != nil { + return err + } + if authRes.Status.Code != rpcv1beta1.Code_CODE_OK { + return errors.New(authRes.Status.Message) + } + + token := authRes.Token + owner := authRes.User + + ownerCtx := appctx.ContextSetToken(context.TODO(), token) + ownerCtx = metadata.AppendToOutgoingContext(ownerCtx, appctx.TokenHeader, token) + ownerCtx = appctx.ContextSetUser(ownerCtx, owner) + + quota, err := s.gw.GetQuota(ownerCtx, &gateway.GetQuotaRequest{ + Ref: &provider.Reference{ + Path: proj.RootInfo.Path, + }, + }) + if err != nil { + return err + } + proj.Quota = &provider.Quota{ + QuotaMaxBytes: quota.TotalBytes, + RemainingBytes: quota.TotalBytes - quota.UsedBytes, + } + + // ADD LAST ACTIVITY + statRes, err := s.gw.Stat(ctx, &provider.StatRequest{ + Ref: &provider.Reference{ + Path: proj.RootInfo.Path, + }, + }) + if err != nil { + return err + } + if statRes.Status.Code != rpcv1beta1.Code_CODE_OK { + return errors.New(statRes.Status.Message) + } + + proj.Mtime = statRes.Info.Mtime + } + return nil +} + +func (s *service) userSpace(ctx context.Context, user *userpb.User) (*provider.StorageSpace, error) { + if utils.UserIsLightweight(user) { + return nil, nil // lightweight accounts and federated do not have a user space + } + + home := templates.WithUser(user, s.c.UserSpace) // TODO: we can use gw.GetHome() call + stat, err := s.gw.Stat(ctx, &provider.StatRequest{ + Ref: &provider.Reference{ + Path: home, + }, + }) + if err != nil { + return nil, err + } + + quota, err := s.gw.GetQuota(ctx, &gateway.GetQuotaRequest{ + Ref: &provider.Reference{ + Path: home, + }, + }) + if err != nil { + return nil, err + } + + return &provider.StorageSpace{ + Id: &provider.StorageSpaceId{ + OpaqueId: spaces.EncodeSpaceID(stat.Info.Id.StorageId, home), + }, + Owner: user, + Name: user.Username, + SpaceType: spaces.SpaceTypeHome.AsString(), + RootInfo: &provider.ResourceInfo{ + PermissionSet: conversions.NewManagerRole().CS3ResourcePermissions(), + Path: home, + }, + Quota: &provider.Quota{ + QuotaMaxBytes: quota.TotalBytes, + RemainingBytes: quota.TotalBytes - quota.UsedBytes, + }, + }, nil +} + +func (s *service) UpdateStorageSpace(ctx context.Context, req *provider.UpdateStorageSpaceRequest) (*provider.UpdateStorageSpaceResponse, error) { + return nil, errors.New("not yet implemented") +} + +func (s *service) DeleteStorageSpace(ctx context.Context, req *provider.DeleteStorageSpaceRequest) (*provider.DeleteStorageSpaceResponse, error) { + return nil, errors.New("not yet implemented") +} + +func (s *service) Register(ss *grpc.Server) { + provider.RegisterSpacesAPIServer(ss, s) +} + +func (s *service) UnprotectedEndpoints() []string { return nil } + +func (s *service) Close() error { return nil } diff --git a/internal/grpc/services/storageprovider/storageprovider.go b/internal/grpc/services/storageprovider/storageprovider.go index fe90c334b4..ec4c3824a0 100644 --- a/internal/grpc/services/storageprovider/storageprovider.go +++ b/internal/grpc/services/storageprovider/storageprovider.go @@ -62,12 +62,12 @@ type config struct { MountID string `docs:"-;The ID of the mounted file system." mapstructure:"mount_id"` Driver string `docs:"localhome;The storage driver to be used." mapstructure:"driver"` Drivers map[string]map[string]interface{} `docs:"url:pkg/storage/fs/localhome/localhome.go" mapstructure:"drivers"` - TmpFolder string `docs:"/var/tmp;Path to temporary folder." mapstructure:"tmp_folder"` DataServerURL string `docs:"http://localhost/data;The URL for the data server." mapstructure:"data_server_url"` ExposeDataServer bool `docs:"false;Whether to expose data server." mapstructure:"expose_data_server"` // if true the client will be able to upload/download directly to it AvailableXS map[string]uint32 `docs:"nil;List of available checksums." mapstructure:"available_checksums"` CustomMimeTypesJSON string `docs:"nil;An optional mapping file with the list of supported custom file extensions and corresponding mime types." mapstructure:"custom_mime_types_json"` MinimunAllowedPathLevelForShare int `mapstructure:"minimum_allowed_path_level_for_share"` + SpaceLevel int `mapstructure:"space_level"` } func (c *config) ApplyDefaults() { @@ -83,10 +83,6 @@ func (c *config) ApplyDefaults() { c.MountID = "00000000-0000-0000-0000-000000000000" } - if c.TmpFolder == "" { - c.TmpFolder = "/var/tmp/reva/tmp" - } - if c.DataServerURL == "" { host, err := os.Hostname() if err != nil || host == "" { @@ -96,6 +92,10 @@ func (c *config) ApplyDefaults() { } } + if c.SpaceLevel == 0 { + c.SpaceLevel = 4 + } + // set sane defaults if len(c.AvailableXS) == 0 { c.AvailableXS = map[string]uint32{"md5": 100, "unset": 1000} @@ -106,7 +106,6 @@ type service struct { conf *config storage storage.FS mountPath, mountID string - tmpFolder string dataServerURL *url.URL availableXS []*provider.ResourceChecksumPriority } @@ -163,10 +162,6 @@ func New(ctx context.Context, m map[string]interface{}) (rgrpc.Service, error) { return nil, err } - if err := os.MkdirAll(c.TmpFolder, 0755); err != nil { - return nil, err - } - mountPath := c.MountPath mountID := c.MountID @@ -200,7 +195,6 @@ func New(ctx context.Context, m map[string]interface{}) (rgrpc.Service, error) { service := &service{ conf: &c, storage: fs, - tmpFolder: c.TmpFolder, mountPath: mountPath, mountID: mountID, dataServerURL: u, @@ -769,6 +763,22 @@ func (s *service) Move(ctx context.Context, req *provider.MoveRequest) (*provide return res, nil } +func spaceFromPath(path string, lvl int) string { + path = strings.TrimPrefix(path, "/") + s := strings.SplitN(path, "/", lvl+1) + if len(s) < lvl { + // TODO: outside space. what to do?? + return "" + } + + return "/" + strings.Join(s[:lvl], "/") +} + +func (s *service) addSpaceInfo(ri *provider.ResourceInfo) { + space := spaceFromPath(ri.Path, s.conf.SpaceLevel) + ri.Id.SpaceId = space +} + func (s *service) Stat(ctx context.Context, req *provider.StatRequest) (*provider.StatResponse, error) { newRef, err := s.unwrap(ctx, req.Ref) if err != nil { @@ -800,6 +810,7 @@ func (s *service) Stat(ctx context.Context, req *provider.StatRequest) (*provide }, nil } s.fixPermissions(md) + s.addSpaceInfo(md) res := &provider.StatResponse{ Status: status.NewOK(ctx), Info: md, @@ -960,6 +971,7 @@ func (s *service) ListContainer(ctx context.Context, req *provider.ListContainer }, nil } s.fixPermissions(md) + s.addSpaceInfo(md) infos = append(infos, md) } res := &provider.ListContainerResponse{ diff --git a/internal/grpc/services/userprovider/userprovider.go b/internal/grpc/services/userprovider/userprovider.go index 4e6a63ba0f..d12671ad2c 100644 --- a/internal/grpc/services/userprovider/userprovider.go +++ b/internal/grpc/services/userprovider/userprovider.go @@ -125,7 +125,7 @@ func (s *service) GetUserByClaim(ctx context.Context, req *userpb.GetUserByClaim res.Status = status.NewNotFound(ctx, fmt.Sprintf("user not found %s %s", req.Claim, req.Value)) } else { err = errors.Wrap(err, "userprovidersvc: error getting user by claim") - res.Status = status.NewInternal(ctx, err, "error getting user by claim") + res.Status = status.NewInternal(ctx, err, fmt.Sprintf("error getting user %s by claim %s", req.Value, req.Claim)) } return res, nil } diff --git a/internal/http/services/appprovider/appprovider.go b/internal/http/services/appprovider/appprovider.go index 9d1483b7a4..8dbe57fc3c 100644 --- a/internal/http/services/appprovider/appprovider.go +++ b/internal/http/services/appprovider/appprovider.go @@ -38,9 +38,9 @@ import ( "github.com/cs3org/reva/pkg/rgrpc/todo/pool" "github.com/cs3org/reva/pkg/rhttp/global" "github.com/cs3org/reva/pkg/sharedconf" + "github.com/cs3org/reva/pkg/spaces" "github.com/cs3org/reva/pkg/utils" "github.com/cs3org/reva/pkg/utils/cfg" - "github.com/cs3org/reva/pkg/utils/resourceid" "github.com/go-chi/chi/v5" ua "github.com/mileusna/useragent" "github.com/pkg/errors" @@ -142,8 +142,8 @@ func (s *svc) handleNew(w http.ResponseWriter, r *http.Request) { return } - parentContainerRef := resourceid.OwnCloudResourceIDUnwrap(parentContainerID) - if parentContainerRef == nil { + parentContainerRef, ok := spaces.ParseResourceID(parentContainerID) + if !ok { writeError(w, r, appErrorInvalidParameter, "invalid parent container ID", nil) return } @@ -277,7 +277,7 @@ func (s *svc) handleNew(w http.ResponseWriter, r *http.Request) { js, err := json.Marshal( map[string]interface{}{ - "file_id": resourceid.OwnCloudResourceIDWrap(statRes.Info.Id), + "file_id": spaces.EncodeResourceID(statRes.Info.Id), }, ) if err != nil { @@ -349,8 +349,8 @@ func (s *svc) handleOpen(w http.ResponseWriter, r *http.Request) { } fileRef.Path = path } else { - resourceID := resourceid.OwnCloudResourceIDUnwrap(fileID) - if resourceID == nil { + resourceID, ok := spaces.ParseResourceID(fileID) + if !ok { writeError(w, r, appErrorInvalidParameter, "invalid file ID", nil) return } @@ -435,7 +435,7 @@ func (s *svc) handleOpen(w http.ResponseWriter, r *http.Request) { } log := appctx.GetLogger(ctx) - log.Info().Interface("resource", fileRef).Str("url", openRes.AppUrl.AppUrl).Str("method", openRes.AppUrl.Method).Interface("target", openRes.AppUrl.Target).Msg("returning app URL for file") + log.Info().Interface("resource", &fileRef).Str("url", openRes.AppUrl.AppUrl).Str("method", openRes.AppUrl.Method).Interface("target", openRes.AppUrl.Target).Msg("returning app URL for file") w.Header().Set("Content-Type", "application/json") if _, err = w.Write(js); err != nil { @@ -461,8 +461,8 @@ func (s *svc) handleNotify(w http.ResponseWriter, r *http.Request) { } fileRef.Path = path } else { - resourceID := resourceid.OwnCloudResourceIDUnwrap(fileID) - if resourceID == nil { + resourceID, ok := spaces.ParseResourceID(fileID) + if !ok { writeError(w, r, appErrorInvalidParameter, "invalid file ID", nil) return } @@ -472,7 +472,7 @@ func (s *svc) handleNotify(w http.ResponseWriter, r *http.Request) { // log the fileid for later correlation / monitoring ctx := r.Context() log := appctx.GetLogger(ctx) - log.Info().Interface("resource", fileRef).Msg("file successfully opened in app") + log.Info().Interface("resource", &fileRef).Msg("file successfully opened in app") w.WriteHeader(http.StatusOK) } diff --git a/internal/http/services/archiver/handler.go b/internal/http/services/archiver/handler.go index 4330637034..18529001c4 100644 --- a/internal/http/services/archiver/handler.go +++ b/internal/http/services/archiver/handler.go @@ -39,10 +39,10 @@ import ( "github.com/cs3org/reva/pkg/rgrpc/todo/pool" "github.com/cs3org/reva/pkg/rhttp/global" "github.com/cs3org/reva/pkg/sharedconf" + "github.com/cs3org/reva/pkg/spaces" "github.com/cs3org/reva/pkg/storage/utils/downloader" "github.com/cs3org/reva/pkg/storage/utils/walker" "github.com/cs3org/reva/pkg/utils/cfg" - "github.com/cs3org/reva/pkg/utils/resourceid" "github.com/gdexlab/go-render/render" ua "github.com/mileusna/useragent" ) @@ -128,8 +128,8 @@ func (s *svc) getFiles(ctx context.Context, files, ids []string) ([]string, erro for _, id := range ids { // id is base64 encoded and after decoding has the form : - ref := resourceid.OwnCloudResourceIDUnwrap(id) - if ref == nil { + ref, ok := spaces.ParseResourceID(id) + if !ok { return nil, errors.New("could not unwrap given file id") } diff --git a/internal/http/services/experimental/overleaf/overleaf.go b/internal/http/services/experimental/overleaf/overleaf.go index 07944eb0d5..1bfefa541e 100644 --- a/internal/http/services/experimental/overleaf/overleaf.go +++ b/internal/http/services/experimental/overleaf/overleaf.go @@ -137,7 +137,7 @@ func (s *svc) handleExport(w http.ResponseWriter, r *http.Request) { return } - statRes, err := s.gtwClient.Stat(ctx, &storagepb.StatRequest{Ref: &exportRequest.ResourceRef}) + statRes, err := s.gtwClient.Stat(ctx, &storagepb.StatRequest{Ref: exportRequest.ResourceRef}) if err != nil { reqres.WriteError(w, r, reqres.APIErrorServerError, "Internal error accessing the resource, please try again later", err) return @@ -307,12 +307,12 @@ func getExportRequest(w http.ResponseWriter, r *http.Request) (*exportRequest, e // Override is true if field is set override := r.Form.Get("override") != "" return &exportRequest{ - ResourceRef: resourceRef, + ResourceRef: &resourceRef, Override: override, }, nil } type exportRequest struct { - ResourceRef storagepb.Reference `json:"resourceId"` - Override bool `json:"override"` + ResourceRef *storagepb.Reference `json:"resourceId"` + Override bool `json:"override"` } diff --git a/internal/http/services/loader/loader.go b/internal/http/services/loader/loader.go index 20982876df..437b1e8b3f 100644 --- a/internal/http/services/loader/loader.go +++ b/internal/http/services/loader/loader.go @@ -29,7 +29,9 @@ import ( _ "github.com/cs3org/reva/internal/http/services/helloworld" _ "github.com/cs3org/reva/internal/http/services/metrics" _ "github.com/cs3org/reva/internal/http/services/opencloudmesh/ocmd" + _ "github.com/cs3org/reva/internal/http/services/owncloud/ocapi" _ "github.com/cs3org/reva/internal/http/services/owncloud/ocdav" + _ "github.com/cs3org/reva/internal/http/services/owncloud/ocgraph" _ "github.com/cs3org/reva/internal/http/services/owncloud/ocs" _ "github.com/cs3org/reva/internal/http/services/pingpong" _ "github.com/cs3org/reva/internal/http/services/plugins" diff --git a/internal/http/services/owncloud/ocapi/ocapi.go b/internal/http/services/owncloud/ocapi/ocapi.go new file mode 100644 index 0000000000..1d00b11f9a --- /dev/null +++ b/internal/http/services/owncloud/ocapi/ocapi.go @@ -0,0 +1,82 @@ +// Copyright 2018-2024 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package ocapi + +import ( + "context" + "net/http" + + "github.com/cs3org/reva/pkg/rhttp/global" + "github.com/go-chi/chi/v5" +) + +const roleslistMock = `{"bundles":[{"id":"2aadd357-682c-406b-8874-293091995fdd","name":"spaceadmin","type":"TYPE_ROLE","extension":"ocis-roles","displayName":"Space Admin","settings":[{"id":"b44b4054-31a2-42b8-bb71-968b15cfbd4f","name":"Drives.ReadWrite","displayName":"Manage space properties","description":"This permission allows managing space properties such as name and description.","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_SYSTEM"}},{"id":"cf3faa8c-50d9-4f84-9650-ff9faf21aa9d","name":"Drives.ReadWriteEnabled","displayName":"Space ability","description":"This permission allows enabling and disabling spaces.","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_SYSTEM"}},{"id":"fb60b004-c1fa-4f09-bf87-55ce7d46ac61","name":"Drives.DeleteProject","displayName":"Delete AllSpaces","description":"This permission allows to delete all spaces.","permissionValue":{"operation":"OPERATION_DELETE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_SYSTEM"}},{"id":"977f0ae6-0da2-4856-93f3-22e0a8482489","name":"Drives.ReadWriteProjectQuota","displayName":"Set Project Space Quota","description":"This permission allows managing project space quotas.","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_SYSTEM"}},{"id":"79e13b30-3e22-11eb-bc51-0b9f0bad9a58","name":"Drives.Create","displayName":"Create Space","description":"This permission allows creating new spaces.","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_SYSTEM"}},{"id":"016f6ddd-9501-4a0a-8ebe-64a20ee8ec82","name":"Drives.List","displayName":"List All Spaces","description":"This permission allows list all spaces.","permissionValue":{"operation":"OPERATION_READ","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_SYSTEM"}},{"id":"7d81f103-0488-4853-bce5-98dcce36d649","name":"Language.ReadWrite","displayName":"Permission to read and set the language (self)","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_OWN"},"resource":{"type":"TYPE_SETTING","id":"aa8cfbe5-95d4-4f7e-a032-c3c01f5f062f"}},{"id":"ad5bb5e5-dc13-4cd3-9304-09a424564ea8","name":"EmailNotifications.ReadWriteDisabled","displayName":"Disable Email Notifications","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_OWN"},"resource":{"type":"TYPE_SETTING","id":"33ffb5d6-cd07-4dc0-afb0-84f7559ae438"}},{"id":"4e41363c-a058-40a5-aec8-958897511209","name":"AutoAcceptShares.ReadWriteDisabled","displayName":"enable/disable auto accept shares","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_OWN"},"resource":{"type":"TYPE_SETTING","id":"ec3ed4a3-3946-4efc-8f9f-76d38b12d3a9"}},{"id":"e03070e9-4362-4cc6-a872-1c7cb2eb2b8e","name":"Self.ReadWrite","displayName":"Self Management","description":"This permission gives access to self management.","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_OWN"},"resource":{"type":"TYPE_USER","id":"me"}},{"id":"79e13b30-3e22-11eb-bc51-0b9f0bad9a58","name":"Drives.Create","displayName":"Create own Space","description":"This permission allows creating a space owned by the current user.","permissionValue":{"operation":"OPERATION_CREATE","constraint":"CONSTRAINT_OWN"},"resource":{"type":"TYPE_SYSTEM"}},{"id":"11516bbd-7157-49e1-b6ac-d00c820f980b","name":"PublicLink.Write","displayName":"Write publiclink","description":"This permission permits to write a public link.","permissionValue":{"operation":"OPERATION_WRITE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_SHARE"}},{"id":"e9a697c5-c67b-40fc-982b-bcf628e9916d","name":"ReadOnlyPublicLinkPassword.Delete","displayName":"Delete Read-Only Public link password","description":"This permission permits to opt out of a public link password enforcement.","permissionValue":{"operation":"OPERATION_WRITE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_SHARE"}}],"resource":{"type":"TYPE_SYSTEM"}},{"id":"38071a68-456a-4553-846a-fa67bf5596cc","name":"user-light","type":"TYPE_ROLE","extension":"ocis-roles","displayName":"User Light","settings":[{"id":"7d81f103-0488-4853-bce5-98dcce36d649","name":"Language.ReadWrite","displayName":"Permission to read and set the language (self)","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_OWN"},"resource":{"type":"TYPE_SETTING","id":"aa8cfbe5-95d4-4f7e-a032-c3c01f5f062f"}},{"id":"ad5bb5e5-dc13-4cd3-9304-09a424564ea8","name":"EmailNotifications.ReadWriteDisabled","displayName":"Disable Email Notifications","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_OWN"},"resource":{"type":"TYPE_SETTING","id":"33ffb5d6-cd07-4dc0-afb0-84f7559ae438"}},{"id":"4e41363c-a058-40a5-aec8-958897511209","name":"AutoAcceptShares.ReadWriteDisabled","displayName":"enable/disable auto accept shares","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_OWN"},"resource":{"type":"TYPE_SETTING","id":"ec3ed4a3-3946-4efc-8f9f-76d38b12d3a9"}}],"resource":{"type":"TYPE_SYSTEM"}},{"id":"71881883-1768-46bd-a24d-a356a2afdf7f","name":"admin","type":"TYPE_ROLE","extension":"ocis-roles","displayName":"Admin","settings":[{"id":"a53e601e-571f-4f86-8fec-d4576ef49c62","name":"Roles.ReadWrite","displayName":"Role Management","description":"This permission gives full access to everything that is related to role management.","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_USER","id":"all"}},{"id":"3d58f441-4a05-42f8-9411-ef5874528ae1","name":"Settings.ReadWrite","displayName":"Settings Management","description":"This permission gives full access to everything that is related to settings management.","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_USER","id":"all"}},{"id":"7d81f103-0488-4853-bce5-98dcce36d649","name":"Language.ReadWrite","displayName":"Permission to read and set the language (anyone)","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_SETTING","id":"aa8cfbe5-95d4-4f7e-a032-c3c01f5f062f"}},{"id":"ad5bb5e5-dc13-4cd3-9304-09a424564ea8","name":"EmailNotifications.ReadWriteDisabled","displayName":"Disable Email Notifications","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_OWN"},"resource":{"type":"TYPE_SETTING","id":"33ffb5d6-cd07-4dc0-afb0-84f7559ae438"}},{"id":"4e41363c-a058-40a5-aec8-958897511209","name":"AutoAcceptShares.ReadWriteDisabled","displayName":"enable/disable auto accept shares","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_OWN"},"resource":{"type":"TYPE_SETTING","id":"ec3ed4a3-3946-4efc-8f9f-76d38b12d3a9"}},{"id":"8e587774-d929-4215-910b-a317b1e80f73","name":"Accounts.ReadWrite","displayName":"Account Management","description":"This permission gives full access to everything that is related to account management.","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_USER","id":"all"}},{"id":"522adfbe-5908-45b4-b135-41979de73245","name":"Groups.ReadWrite","displayName":"Group Management","description":"This permission gives full access to everything that is related to group management.","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_GROUP","id":"all"}},{"id":"4e6f9709-f9e7-44f1-95d4-b762d27b7896","name":"Drives.ReadWritePersonalQuota","displayName":"Set Personal Space Quota","description":"This permission allows managing personal space quotas.","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_SYSTEM"}},{"id":"977f0ae6-0da2-4856-93f3-22e0a8482489","name":"Drives.ReadWriteProjectQuota","displayName":"Set Project Space Quota","description":"This permission allows managing project space quotas.","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_SYSTEM"}},{"id":"79e13b30-3e22-11eb-bc51-0b9f0bad9a58","name":"Drives.Create","displayName":"Create Space","description":"This permission allows creating new spaces.","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_SYSTEM"}},{"id":"016f6ddd-9501-4a0a-8ebe-64a20ee8ec82","name":"Drives.List","displayName":"List All Spaces","description":"This permission allows listing all spaces.","permissionValue":{"operation":"OPERATION_READ","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_SYSTEM"}},{"id":"5de9fe0a-4bc5-4a47-b758-28f370caf169","name":"Drives.DeletePersonal","displayName":"Delete All Home Spaces","description":"This permission allows deleting home spaces.","permissionValue":{"operation":"OPERATION_DELETE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_SYSTEM"}},{"id":"fb60b004-c1fa-4f09-bf87-55ce7d46ac61","name":"Drives.DeleteProject","displayName":"Delete AllSpaces","description":"This permission allows deleting all spaces.","permissionValue":{"operation":"OPERATION_DELETE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_SYSTEM"}},{"id":"ed83fc10-1f54-4a9e-b5a7-fb517f5f3e01","name":"Logo.Write","displayName":"Change logo","description":"This permission permits to change the system logo.","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_SYSTEM"}},{"id":"11516bbd-7157-49e1-b6ac-d00c820f980b","name":"PublicLink.Write","displayName":"Write publiclink","description":"This permission allows creating public links.","permissionValue":{"operation":"OPERATION_WRITE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_SHARE"}},{"id":"e9a697c5-c67b-40fc-982b-bcf628e9916d","name":"ReadOnlyPublicLinkPassword.Delete","displayName":"Delete Read-Only Public link password","description":"This permission permits to opt out of a public link password enforcement.","permissionValue":{"operation":"OPERATION_WRITE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_SHARE"}},{"id":"b44b4054-31a2-42b8-bb71-968b15cfbd4f","name":"Drives.ReadWrite","displayName":"Manage space properties","description":"This permission allows managing space properties such as name and description.","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_SYSTEM"}},{"id":"cf3faa8c-50d9-4f84-9650-ff9faf21aa9d","name":"Drives.ReadWriteEnabled","displayName":"Space ability","description":"This permission allows enabling and disabling spaces.","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_SYSTEM"}}],"resource":{"type":"TYPE_SYSTEM"}},{"id":"d7beeea8-8ff4-406b-8fb6-ab2dd81e6b11","name":"user","type":"TYPE_ROLE","extension":"ocis-roles","displayName":"User","settings":[{"id":"7d81f103-0488-4853-bce5-98dcce36d649","name":"Language.ReadWrite","displayName":"Permission to read and set the language (self)","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_OWN"},"resource":{"type":"TYPE_SETTING","id":"aa8cfbe5-95d4-4f7e-a032-c3c01f5f062f"}},{"id":"ad5bb5e5-dc13-4cd3-9304-09a424564ea8","name":"EmailNotifications.ReadWriteDisabled","displayName":"Disable Email Notifications","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_OWN"},"resource":{"type":"TYPE_SETTING","id":"33ffb5d6-cd07-4dc0-afb0-84f7559ae438"}},{"id":"4e41363c-a058-40a5-aec8-958897511209","name":"AutoAcceptShares.ReadWriteDisabled","displayName":"enable/disable auto accept shares","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_OWN"},"resource":{"type":"TYPE_SETTING","id":"ec3ed4a3-3946-4efc-8f9f-76d38b12d3a9"}},{"id":"e03070e9-4362-4cc6-a872-1c7cb2eb2b8e","name":"Self.ReadWrite","displayName":"Self Management","description":"This permission gives access to self management.","permissionValue":{"operation":"OPERATION_READWRITE","constraint":"CONSTRAINT_OWN"},"resource":{"type":"TYPE_USER","id":"me"}},{"id":"79e13b30-3e22-11eb-bc51-0b9f0bad9a58","name":"Drives.Create","displayName":"Create own Space","description":"This permission allows creating a space owned by the current user.","permissionValue":{"operation":"OPERATION_CREATE","constraint":"CONSTRAINT_OWN"},"resource":{"type":"TYPE_SYSTEM"}},{"id":"11516bbd-7157-49e1-b6ac-d00c820f980b","name":"PublicLink.Write","displayName":"Write publiclink","description":"This permission permits to write a public link.","permissionValue":{"operation":"OPERATION_WRITE","constraint":"CONSTRAINT_ALL"},"resource":{"type":"TYPE_SHARE"}}],"resource":{"type":"TYPE_SYSTEM"}}]}` + +const assigmentMock = `{"assignments":[{"id":"412cbb5a-48cf-401b-8709-6f88d1d33b9d","accountUuid":"619201e3-d9ca-41ab-a03d-c995e3f876f6","roleId":"71881883-1768-46bd-a24d-a356a2afdf7f"}]}` + +// TODO(lopresti) this is currently mocked for a "primary" user, need to remove some of those permissions for other types. +const permissionsMock = `{"permissions": [ + "ReadOnlyPublicLinkPassword.Delete.all", + "EmailNotifications.ReadWriteDisabled.own", + "Favorites.Write.own", + "AutoAcceptShares.ReadWriteDisabled.own", + "PublicLink.Write.all", + "Drives.ReadWriteEnabled.all", + "Language.ReadWrite.all", + "Favorites.List.own", + "Drives.ReadWrite.all", + "Shares.Write.all" +]}` + +const valuesMock = `{"values":[{"identifier":{"extension":"ocis-accounts","bundle":"profile","setting":"language"},"value":{"bundleId":"2a506de7-99bd-4f0d-994e-c38e72c28fd9","settingId":"aa8cfbe5-95d4-4f7e-a032-c3c01f5f062f","accountUuid":"619201e3-d9ca-41ab-a03d-c995e3f876f6","resource":{"type":"TYPE_USER"},"listValue":{"values":[{"stringValue":"en"}]}}}]}` + +func init() { + global.Register("ocapi", New) +} + +func New(ctx context.Context, m map[string]any) (global.Service, error) { + r := chi.NewRouter() + + r.Post("/v0/settings/roles-list", mockResponse(roleslistMock)) + r.Post("/v0/settings/assignments-list", mockResponse(assigmentMock)) + r.Post("/v0/settings/permissions-list", mockResponse(permissionsMock)) + r.Post("/v0/settings/values-list", mockResponse(valuesMock)) + + return svc{r: r}, nil +} + +func mockResponse(content string) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(content)) + }) +} + +type svc struct { + r *chi.Mux +} + +func (s svc) Handler() http.Handler { + return s.r +} + +func (s svc) Prefix() string { return "api" } + +func (s svc) Close() error { return nil } + +func (s svc) Unprotected() []string { return []string{"/"} } diff --git a/internal/http/services/owncloud/ocdav/dav.go b/internal/http/services/owncloud/ocdav/dav.go index 2532b63b67..222f8461a9 100644 --- a/internal/http/services/owncloud/ocdav/dav.go +++ b/internal/http/services/owncloud/ocdav/dav.go @@ -30,6 +30,7 @@ import ( rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" "github.com/cs3org/reva/pkg/appctx" + "github.com/cs3org/reva/pkg/spaces" "github.com/cs3org/reva/pkg/rgrpc/todo/pool" "github.com/cs3org/reva/pkg/rhttp/router" @@ -45,7 +46,7 @@ type DavHandler struct { FilesHomeHandler *WebDavHandler MetaHandler *MetaHandler TrashbinHandler *TrashbinHandler - SpacesHandler *SpacesHandler + SpacesHandler *WebDavHandler PublicFolderHandler *WebDavHandler PublicFileHandler *PublicFileHandler OCMSharesHandler *WebDavHandler @@ -70,8 +71,8 @@ func (h *DavHandler) init(c *Config) error { } h.TrashbinHandler = new(TrashbinHandler) - h.SpacesHandler = new(SpacesHandler) - if err := h.SpacesHandler.init(c); err != nil { + h.SpacesHandler = new(WebDavHandler) + if err := h.SpacesHandler.init("", false); err != nil { return err } @@ -176,8 +177,35 @@ func (h *DavHandler) Handler(s *svc) http.Handler { case "spaces": base := path.Join(ctx.Value(ctxKeyBaseURI).(string), "spaces") ctx := context.WithValue(ctx, ctxKeyBaseURI, base) - r = r.WithContext(ctx) - h.SpacesHandler.Handler(s).ServeHTTP(w, r) + + var head string + head, r.URL.Path = router.ShiftPath(r.URL.Path) + + switch head { + case "trash-bin": + r = r.WithContext(ctx) + h.TrashbinHandler.Handler(s).ServeHTTP(w, r) + default: + // path is of type: space_id/relative/path/from/space + // the space_id is the base64 encode of the path where + // the space is located + + _, base, ok := spaces.DecodeSpaceID(head) + if !ok { + w.WriteHeader(http.StatusBadRequest) + return + } + + fullPath := filepath.Join(base, r.URL.Path) + r.URL.Path = fullPath + + ctx = context.WithValue(ctx, ctxSpaceID, head) + ctx = context.WithValue(ctx, ctxSpaceFullPath, fullPath) + ctx = context.WithValue(ctx, ctxSpacePath, base) + ctx = context.WithValue(ctx, ctxSpaceRelativePath, r.URL.Path) + r = r.WithContext(ctx) + h.SpacesHandler.Handler(s).ServeHTTP(w, r) + } case "ocm": base := path.Join(ctx.Value(ctxKeyBaseURI).(string), "ocm") ctx := context.WithValue(ctx, ctxKeyBaseURI, base) diff --git a/internal/http/services/owncloud/ocdav/meta.go b/internal/http/services/owncloud/ocdav/meta.go index 99407e4cef..5964327bfd 100644 --- a/internal/http/services/owncloud/ocdav/meta.go +++ b/internal/http/services/owncloud/ocdav/meta.go @@ -22,7 +22,7 @@ import ( "net/http" "github.com/cs3org/reva/pkg/rhttp/router" - "github.com/cs3org/reva/pkg/utils/resourceid" + "github.com/cs3org/reva/pkg/spaces" ) // MetaHandler handles meta requests. @@ -45,13 +45,17 @@ func (h *MetaHandler) Handler(s *svc) http.Handler { return } - did := resourceid.OwnCloudResourceIDUnwrap(id) + rid, ok := spaces.ParseResourceID(id) + if !ok { + http.Error(w, "400 Bad Request", http.StatusBadRequest) + return + } var head string head, r.URL.Path = router.ShiftPath(r.URL.Path) switch head { case "v": - h.VersionsHandler.Handler(s, did).ServeHTTP(w, r) + h.VersionsHandler.Handler(s, rid).ServeHTTP(w, r) default: w.WriteHeader(http.StatusNotFound) } diff --git a/internal/http/services/owncloud/ocdav/move.go b/internal/http/services/owncloud/ocdav/move.go index 8afc7c38ce..e984e663d2 100644 --- a/internal/http/services/owncloud/ocdav/move.go +++ b/internal/http/services/owncloud/ocdav/move.go @@ -29,6 +29,7 @@ import ( provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" "github.com/cs3org/reva/pkg/appctx" "github.com/cs3org/reva/pkg/rhttp/router" + "github.com/cs3org/reva/pkg/spaces" "github.com/cs3org/reva/pkg/utils/resourceid" "github.com/rs/zerolog" ) @@ -42,6 +43,11 @@ func (s *svc) handlePathMove(w http.ResponseWriter, r *http.Request, ns string) return } + head, rel := router.ShiftPath(dstPath) + if _, base, ok := spaces.DecodeSpaceID(head); ok { + dstPath = path.Join(base, rel) + } + for _, r := range nameRules { if !r.Test(dstPath) { w.WriteHeader(http.StatusBadRequest) diff --git a/internal/http/services/owncloud/ocdav/ocdav.go b/internal/http/services/owncloud/ocdav/ocdav.go index 4e996c1ee4..bcd3fecf0a 100644 --- a/internal/http/services/owncloud/ocdav/ocdav.go +++ b/internal/http/services/owncloud/ocdav/ocdav.go @@ -51,6 +51,10 @@ type ctxKey int const ( ctxKeyBaseURI ctxKey = iota + ctxSpaceID + ctxSpacePath + ctxSpaceFullPath + ctxSpaceRelativePath ctxOCM ) @@ -343,12 +347,7 @@ func extractDestination(r *http.Request) (string, error) { baseURI := r.Context().Value(ctxKeyBaseURI).(string) // TODO check if path is on same storage, return 502 on problems, see https://tools.ietf.org/html/rfc4918#section-9.9.4 // Strip the base URI from the destination. The destination might contain redirection prefixes which need to be handled - urlSplit := strings.Split(dstURL.Path, baseURI) - if len(urlSplit) != 2 { - return "", errors.Wrap(errInvalidValue, "destination path does not contain base URI") - } - - return urlSplit[1], nil + return strings.TrimPrefix(dstURL.Path, baseURI), nil } // replaceAllStringSubmatchFunc is taken from 'Go: Replace String with Regular Expression Callback' diff --git a/internal/http/services/owncloud/ocdav/ocdav_test.go b/internal/http/services/owncloud/ocdav/ocdav_test.go index c4f03ba729..bf16fd6d19 100644 --- a/internal/http/services/owncloud/ocdav/ocdav_test.go +++ b/internal/http/services/owncloud/ocdav/ocdav_test.go @@ -56,7 +56,7 @@ func TestExtractDestination(t *testing.T) { request := httptest.NewRequest(http.MethodGet, "https://example.org/remote.php/dav/src", nil) request.Header.Set(HeaderDestination, "https://example.org/remote.php/dav/dst") - ctx := context.WithValue(context.Background(), ctxKeyBaseURI, "remote.php/dav") + ctx := context.WithValue(context.Background(), ctxKeyBaseURI, "/remote.php/dav") destination, err := extractDestination(request.WithContext(ctx)) if err != nil { t.Errorf("Expected err to be nil got %s", err) @@ -93,21 +93,6 @@ func TestExtractDestinationWithInvalidDestination(t *testing.T) { } } -func TestExtractDestinationWithDestinationWrongBaseURI(t *testing.T) { - request := httptest.NewRequest(http.MethodGet, "https://example.org/remote.php/dav/src", nil) - request.Header.Set(HeaderDestination, "https://example.org/remote.php/dav/dst") - - ctx := context.WithValue(context.Background(), ctxKeyBaseURI, "remote.php/webdav") - _, err := extractDestination(request.WithContext(ctx)) - if err == nil { - t.Errorf("Expected err to be nil got %s", err) - } - - if !errors.Is(err, errInvalidValue) { - t.Errorf("Expected error invalid value, got %s", err) - } -} - func TestNameNotEmptyRule(t *testing.T) { tests := map[string]bool{ "": false, diff --git a/internal/http/services/owncloud/ocdav/propfind.go b/internal/http/services/owncloud/ocdav/propfind.go index 0ef6544c00..4a0930281e 100644 --- a/internal/http/services/owncloud/ocdav/propfind.go +++ b/internal/http/services/owncloud/ocdav/propfind.go @@ -42,6 +42,7 @@ import ( "github.com/cs3org/reva/internal/grpc/services/storageprovider" "github.com/cs3org/reva/internal/http/services/owncloud/ocs/conversions" "github.com/cs3org/reva/pkg/appctx" + "github.com/cs3org/reva/pkg/spaces" "github.com/cs3org/reva/pkg/publicshare" "github.com/cs3org/reva/pkg/rhttp/router" @@ -505,6 +506,20 @@ func (s *svc) newPropRaw(key, val string) *propertyXML { } } +func spaceHref(ctx context.Context, baseURI, fullPath string) string { + // in the context of spaces, the final URL will be baseURI + //relative/path/to/space + spacePath, ok := ctx.Value(ctxSpacePath).(string) + if !ok { + panic("space path expected to be in the context") + } + relativePath := strings.TrimPrefix(fullPath, spacePath) + spaceID, ok := ctx.Value(ctxSpaceID).(string) + if !ok { + panic("space id expected to be in the context") + } + return path.Join(baseURI, spaceID, relativePath) +} + func appendSlash(path string) string { if path == "" { return "/" @@ -532,15 +547,22 @@ func (s *svc) isOpenable(path string) bool { func (s *svc) mdToPropResponse(ctx context.Context, pf *propfindXML, md *provider.ResourceInfo, ns string, usershares, linkshares map[string]struct{}) (*responseXML, error) { sublog := appctx.GetLogger(ctx).With().Str("ns", ns).Logger() - md.Path = strings.TrimPrefix(md.Path, ns) - ocm, _ := ctx.Value(ctxOCM).(bool) - if ocm { - // // was injected in front of the OCM path for the routing to work, we now remove it (see internal/http/services/owncloud/ocdav/dav.go) - _, md.Path = router.ShiftPath(md.Path) - } - baseURI := ctx.Value(ctxKeyBaseURI).(string) - ref := path.Join(baseURI, md.Path) + var ref string + if _, ok := ctx.Value(ctxSpaceID).(string); ok { + // spaces are enabled; for now we do not support the OCM case with spaces + ref = spaceHref(ctx, baseURI, md.Path) + } else { + // spaces are not enabled + md.Path = strings.TrimPrefix(md.Path, ns) + + if ocm, _ := ctx.Value(ctxOCM).(bool); ocm { + // // was injected in front of the OCM path for the routing to work, we now remove it (see internal/http/services/owncloud/ocdav/dav.go) + _, md.Path = router.ShiftPath(md.Path) + } + + ref = path.Join(baseURI, md.Path) + } if md.Type == provider.ResourceType_RESOURCE_TYPE_CONTAINER { ref += "/" } @@ -596,12 +618,17 @@ func (s *svc) mdToPropResponse(ctx context.Context, pf *propfindXML, md *provide Status: "HTTP/1.1 404 Not Found", Prop: []*propertyXML{}, } + + propstatOK.Prop = append(propstatOK.Prop, + s.newProp("oc:name", path.Base(md.Path)), + ) + // when allprops has been requested if pf.Allprop != nil { // return all known properties if md.Id != nil { - id := resourceid.OwnCloudResourceIDWrap(md.Id) + id := spaces.EncodeResourceID(md.Id) propstatOK.Prop = append(propstatOK.Prop, s.newProp("oc:id", id), s.newProp("oc:fileid", id), @@ -700,13 +727,13 @@ func (s *svc) mdToPropResponse(ctx context.Context, pf *propfindXML, md *provide // I tested the desktop client and phoenix to annotate which properties are requestted, see below cases case "fileid": // phoenix only if md.Id != nil { - propstatOK.Prop = append(propstatOK.Prop, s.newProp("oc:fileid", resourceid.OwnCloudResourceIDWrap(md.Id))) + propstatOK.Prop = append(propstatOK.Prop, s.newProp("oc:fileid", spaces.EncodeResourceID(md.Id))) } else { propstatNotFound.Prop = append(propstatNotFound.Prop, s.newProp("oc:fileid", "")) } case "id": // desktop client only if md.Id != nil { - propstatOK.Prop = append(propstatOK.Prop, s.newProp("oc:id", resourceid.OwnCloudResourceIDWrap(md.Id))) + propstatOK.Prop = append(propstatOK.Prop, s.newProp("oc:id", spaces.EncodeResourceID(md.Id))) } else { propstatNotFound.Prop = append(propstatNotFound.Prop, s.newProp("oc:id", "")) } diff --git a/internal/http/services/owncloud/ocdav/proppatch.go b/internal/http/services/owncloud/ocdav/proppatch.go index 1d9995726a..e72ffd10c1 100644 --- a/internal/http/services/owncloud/ocdav/proppatch.go +++ b/internal/http/services/owncloud/ocdav/proppatch.go @@ -301,7 +301,7 @@ func (s *svc) handleProppatchResponse(ctx context.Context, w http.ResponseWriter } w.Header().Set(HeaderDav, "1, 3, extended-mkcol") w.Header().Set(HeaderContentType, "application/xml; charset=utf-8") - w.WriteHeader(http.StatusMultiStatus) + w.WriteHeader(http.StatusOK) if _, err := w.Write([]byte(propRes)); err != nil { log.Err(err).Msg("error writing response") } diff --git a/internal/http/services/owncloud/ocdav/trashbin.go b/internal/http/services/owncloud/ocdav/trashbin.go index 35dfe60d14..ff5c4c3dcb 100644 --- a/internal/http/services/owncloud/ocdav/trashbin.go +++ b/internal/http/services/owncloud/ocdav/trashbin.go @@ -34,6 +34,7 @@ import ( provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" "github.com/cs3org/reva/internal/http/services/owncloud/ocs/conversions" "github.com/cs3org/reva/pkg/appctx" + "github.com/cs3org/reva/pkg/spaces" "github.com/cs3org/reva/pkg/rgrpc/todo/pool" "github.com/cs3org/reva/pkg/rhttp/router" @@ -51,6 +52,59 @@ func (h *TrashbinHandler) init(c *Config) error { return nil } +func (h *TrashbinHandler) handleTrashbinSpaces(s *svc, w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := appctx.GetLogger(ctx) + + var spaceID string + spaceID, r.URL.Path = router.ShiftPath(r.URL.Path) + + _, base, ok := spaces.DecodeSpaceID(spaceID) + if !ok { + w.WriteHeader(http.StatusBadRequest) + return + } + log.Debug().Str("path", base).Msg("decoded space base path") + + u := appctx.ContextMustGetUser(ctx) + + if r.Method == MethodPropfind { + h.listTrashbin(w, r, s, u, base, "", "") + return + } + + var key string + key, r.URL.Path = router.ShiftPath(r.URL.Path) + if key != "" && r.Method == MethodMove { + // find path in url relative to trash base + // trashBase := ctx.Value(ctxKeyBaseURI).(string) + // baseURI := path.Join(path.Dir(trashBase), "files", username) + // ctx = context.WithValue(ctx, ctxKeyBaseURI, baseURI) + // r = r.WithContext(ctx) + + // TODO make request.php optional in destination header + dst, err := extractDestination(r) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + dst = path.Clean(dst) + _, dst = router.ShiftPath(dst) + + log.Debug().Str("key", key).Str("dst", dst).Msg("restore") + + h.restore(w, r, s, u, base, dst, key, "") + return + } + + if r.Method == http.MethodDelete { + h.delete(w, r, s, u, base, key, "") + return + } + + http.Error(w, "501 Not implemented", http.StatusNotImplemented) +} + // Handler handles requests. func (h *TrashbinHandler) Handler(s *svc) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -62,6 +116,13 @@ func (h *TrashbinHandler) Handler(s *svc) http.Handler { return } + // check if we are in a space + spaceID, _ := router.ShiftPath(r.URL.Path) + if _, _, ok := spaces.DecodeSpaceID(spaceID); ok { + h.handleTrashbinSpaces(s, w, r) + return + } + var username string username, r.URL.Path = router.ShiftPath(r.URL.Path) diff --git a/internal/http/services/owncloud/ocdav/versions.go b/internal/http/services/owncloud/ocdav/versions.go index 75a3faf07c..101adb413c 100644 --- a/internal/http/services/owncloud/ocdav/versions.go +++ b/internal/http/services/owncloud/ocdav/versions.go @@ -154,6 +154,7 @@ func (h *VersionsHandler) doListVersions(w http.ResponseWriter, r *http.Request, Id: &provider.ResourceId{ StorageId: "versions", OpaqueId: info.Id.OpaqueId + "@" + versions[i].GetKey(), + SpaceId: rid.SpaceId, }, // Checksum Etag: versions[i].Etag, diff --git a/internal/http/services/owncloud/ocgraph/drives.go b/internal/http/services/owncloud/ocgraph/drives.go new file mode 100644 index 0000000000..791a1c6264 --- /dev/null +++ b/internal/http/services/owncloud/ocgraph/drives.go @@ -0,0 +1,448 @@ +// Copyright 2018-2024 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +// This package implements the APIs defined in https://owncloud.dev/apis/http/graph/spaces/ + +package ocgraph + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path/filepath" + "strings" + "time" + + "github.com/CiscoM31/godata" + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + collaborationv1beta1 "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1" + providerpb "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/pkg/appctx" + "github.com/cs3org/reva/pkg/rhttp/router" + "github.com/cs3org/reva/pkg/spaces" + "github.com/cs3org/reva/pkg/utils/list" + "github.com/go-chi/chi/v5" + libregraph "github.com/owncloud/libre-graph-api-go" + "github.com/pkg/errors" + "github.com/rs/zerolog" +) + +func (s *svc) listMySpaces(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := appctx.GetLogger(ctx) + + gw, err := s.getClient() + if err != nil { + log.Error().Err(err).Msg("error getting grpc client") + w.WriteHeader(http.StatusInternalServerError) + return + } + + odataReq, err := godata.ParseRequest(r.Context(), r.URL.Path, r.URL.Query()) + if err != nil { + log.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get drives: query error") + w.WriteHeader(http.StatusBadRequest) + return + } + + var spaces []*libregraph.Drive + if isMountpointRequest(odataReq) { + spaces, err = getDrivesForShares(ctx, gw) + if err != nil { + log.Error().Err(err).Msg("error getting share spaces") + w.WriteHeader(http.StatusInternalServerError) + return + } + } else { + filters, err := generateCs3Filters(odataReq) + if err != nil { + log.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get drives: error parsing filters") + w.WriteHeader(http.StatusInternalServerError) + return + } + + res, err := gw.ListStorageSpaces(ctx, &providerpb.ListStorageSpacesRequest{ + Filters: filters, + }) + if err != nil { + log.Error().Err(err).Msg("error listing storage spaces") + w.WriteHeader(http.StatusInternalServerError) + return + } + if res.Status.Code != rpcv1beta1.Code_CODE_OK { + log.Error().Int("code", int(res.Status.Code)).Str("message", res.Status.Message).Msg("error listing storage spaces") + w.WriteHeader(http.StatusInternalServerError) + return + } + + me := appctx.ContextMustGetUser(ctx) + spaces = list.Map(res.StorageSpaces, func(space *providerpb.StorageSpace) *libregraph.Drive { + return s.cs3StorageSpaceToDrive(me, space) + }) + } + + if err := json.NewEncoder(w).Encode(map[string]any{ + "value": spaces, + }); err != nil { + log.Error().Err(err).Msg("error marshalling spaces as json") + w.WriteHeader(http.StatusInternalServerError) + return + } +} + +func isMountpointRequest(request *godata.GoDataRequest) bool { + if request.Query.Filter == nil { + return false + } + if request.Query.Filter.Tree.Token.Value != "eq" { + return false + } + return request.Query.Filter.Tree.Children[0].Token.Value == "driveType" && strings.Trim(request.Query.Filter.Tree.Children[1].Token.Value, "'") == "mountpoint" +} + +const shareJailID = "a0ca6a90-a365-4782-871e-d44447bbc668" + +func getDrivesForShares(ctx context.Context, gw gateway.GatewayAPIClient) ([]*libregraph.Drive, error) { + res, err := gw.ListExistingReceivedShares(ctx, &collaborationv1beta1.ListReceivedSharesRequest{}) + if err != nil { + return nil, err + } + if res.Status.Code != rpcv1beta1.Code_CODE_OK { + return nil, errors.New(res.Status.Message) + } + + spacesRes := make([]*libregraph.Drive, 0, len(res.ShareInfos)) + for _, s := range res.ShareInfos { + spacesRes = append(spacesRes, convertShareToSpace(s)) + } + return spacesRes, nil +} + +func libregraphShareID(shareID *collaborationv1beta1.ShareId) string { + return fmt.Sprintf("%s$%s!%s", shareJailID, shareJailID, shareID.OpaqueId) +} + +func convertShareToSpace(rsi *gateway.ReceivedShareResourceInfo) *libregraph.Drive { + // the prefix of the remote_item.id and rootid + return &libregraph.Drive{ + Id: libregraph.PtrString(libregraphShareID(rsi.ReceivedShare.Share.Id)), + DriveType: libregraph.PtrString("mountpoint"), + DriveAlias: libregraph.PtrString(rsi.ReceivedShare.Share.Id.OpaqueId), // this is not used, but must not be the same alias as the drive item + Name: filepath.Base(rsi.ResourceInfo.Path), + Quota: &libregraph.Quota{ + Total: libregraph.PtrInt64(24154390300000), + Used: libregraph.PtrInt64(3141592), + Remaining: libregraph.PtrInt64(24154387158408), + }, + Root: &libregraph.DriveItem{ + Id: libregraph.PtrString(fmt.Sprintf("%s$%s!%s", shareJailID, shareJailID, rsi.ReceivedShare.Share.Id.OpaqueId)), + RemoteItem: &libregraph.RemoteItem{ + DriveAlias: libregraph.PtrString(strings.TrimSuffix(strings.TrimPrefix(rsi.ResourceInfo.Path, "/"), relativePathToSpaceID(rsi.ResourceInfo))), // the drive alias must not start with / + ETag: libregraph.PtrString(rsi.ResourceInfo.Etag), + Folder: &libregraph.Folder{}, + // The Id must correspond to the id in the OCS response, for the time being + // It is in the form ! + Id: libregraph.PtrString(spaces.EncodeResourceID(rsi.ResourceInfo.Id)), + LastModifiedDateTime: libregraph.PtrTime(time.Unix(int64(rsi.ResourceInfo.Mtime.Seconds), int64(rsi.ResourceInfo.Mtime.Nanos))), + Name: libregraph.PtrString(filepath.Base(rsi.ResourceInfo.Path)), + Path: libregraph.PtrString(relativePathToSpaceID(rsi.ResourceInfo)), + // RootId must have the same token before ! as Id + // the second part for the time being is not used + RootId: libregraph.PtrString(fmt.Sprintf("%s!unused_root_id", spaces.EncodeSpaceID(rsi.ResourceInfo.Id.StorageId, rsi.ResourceInfo.Id.SpaceId))), + Size: libregraph.PtrInt64(int64(rsi.ResourceInfo.Size)), + }, + }, + } +} + +func relativePathToSpaceID(info *providerpb.ResourceInfo) string { + return strings.TrimPrefix(info.Path, info.Id.SpaceId) +} + +func generateCs3Filters(request *godata.GoDataRequest) ([]*providerpb.ListStorageSpacesRequest_Filter, error) { + var filters spaces.ListStorageSpaceFilter + if request.Query.Filter != nil { + if request.Query.Filter.Tree.Token.Value == "eq" { + switch request.Query.Filter.Tree.Children[0].Token.Value { + case "driveType": + spaceType := spaces.SpaceType(strings.Trim(request.Query.Filter.Tree.Children[1].Token.Value, "'")) + filters = filters.BySpaceType(spaceType) + case "id": + id := strings.Trim(request.Query.Filter.Tree.Children[1].Token.Value, "'") + filters = filters.ByID(&providerpb.StorageSpaceId{OpaqueId: id}) + } + } else { + err := errors.Errorf("unsupported filter operand: %s", request.Query.Filter.Tree.Token.Value) + return nil, err + } + } + return filters.List(), nil +} + +func (s *svc) cs3StorageSpaceToDrive(user *userpb.User, space *providerpb.StorageSpace) *libregraph.Drive { + drive := &libregraph.Drive{ + DriveAlias: libregraph.PtrString(space.RootInfo.Path[1:]), + Id: libregraph.PtrString(space.Id.OpaqueId), + Name: space.Name, + DriveType: libregraph.PtrString(space.SpaceType), + } + + drive.Root = &libregraph.DriveItem{} + + if space.SpaceType != "personal" { + drive.Root = &libregraph.DriveItem{ + Id: libregraph.PtrString(space.Id.OpaqueId), + Permissions: cs3PermissionsToLibreGraph(user, space.RootInfo.PermissionSet), + } + } + + drive.Root.WebDavUrl = libregraph.PtrString(fullURL(s.c.WebDavBase, space.RootInfo.Path)) + drive.WebUrl = libregraph.PtrString(fullURL(s.c.WebBase, space.RootInfo.Path)) + + if space.Owner != nil && space.Owner.Id != nil { + drive.Owner = &libregraph.IdentitySet{ + User: &libregraph.Identity{ + Id: &space.Owner.Id.OpaqueId, + }, + } + } + + if space.Quota != nil { + drive.Quota = &libregraph.Quota{ + Total: libregraph.PtrInt64(int64(space.Quota.QuotaMaxBytes)), + Remaining: libregraph.PtrInt64(int64(space.Quota.RemainingBytes)), + Used: libregraph.PtrInt64(int64(space.Quota.QuotaMaxBytes - space.Quota.RemainingBytes)), + } + } + + if space.Mtime != nil { + drive.LastModifiedDateTime = libregraph.PtrTime(time.Unix(int64(space.Mtime.Seconds), int64(space.Mtime.Nanos))) + } + return drive +} + +func (s *svc) getSpace(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := appctx.GetLogger(ctx) + + gw, err := s.getClient() + if err != nil { + log.Error().Err(err).Msg("error getting grpc client") + w.WriteHeader(http.StatusInternalServerError) + return + } + + spaceID, _ := router.ShiftPath(r.URL.Path) + if isShareJail(spaceID) { + shareRes, err := gw.GetReceivedShare(ctx, &collaborationv1beta1.GetReceivedShareRequest{ + Ref: &collaborationv1beta1.ShareReference{ + Spec: &collaborationv1beta1.ShareReference_Id{ + Id: &collaborationv1beta1.ShareId{ + OpaqueId: shareID(spaceID), + }, + }, + }, + }) + if err != nil { + log.Error().Err(err).Msg("error getting received share") + w.WriteHeader(http.StatusInternalServerError) + return + } + if shareRes.Status.Code != rpcv1beta1.Code_CODE_OK { + log.Error().Int("code", int(shareRes.Status.Code)).Str("message", shareRes.Status.Message).Msg("error getting received share") + w.WriteHeader(http.StatusInternalServerError) + return + } + + stat, err := gw.Stat(ctx, &providerpb.StatRequest{ + Ref: &providerpb.Reference{ + ResourceId: shareRes.Share.Share.ResourceId, + }, + }) + if err != nil { + log.Error().Err(err).Msg("error statting received share") + w.WriteHeader(http.StatusInternalServerError) + return + } + if stat.Status.Code != rpcv1beta1.Code_CODE_OK { + log.Error().Interface("stat.Status", stat.Status).Msg("error statting received share") + w.WriteHeader(http.StatusInternalServerError) + return + } + + space := convertShareToSpace(&gateway.ReceivedShareResourceInfo{ + ResourceInfo: stat.Info, + ReceivedShare: shareRes.Share, + }) + _ = json.NewEncoder(w).Encode(space) + return + } else { + listRes, err := gw.ListStorageSpaces(ctx, &providerpb.ListStorageSpacesRequest{ + Filters: []*providerpb.ListStorageSpacesRequest_Filter{ + { + Type: providerpb.ListStorageSpacesRequest_Filter_TYPE_ID, + Term: &providerpb.ListStorageSpacesRequest_Filter_Id{ + Id: &providerpb.StorageSpaceId{ + OpaqueId: spaceID, + }, + }, + }, + }, + }) + if err != nil { + log.Error().Err(err).Msg("error getting space by id") + w.WriteHeader(http.StatusInternalServerError) + return + } + if listRes.Status.Code != rpcv1beta1.Code_CODE_OK { + log.Error().Int("code", int(listRes.Status.Code)).Str("message", listRes.Status.Message).Msg("error getting space by id") + w.WriteHeader(http.StatusInternalServerError) + return + } + + spaces := listRes.StorageSpaces + if len(spaces) == 1 { + user := appctx.ContextMustGetUser(ctx) + space := s.cs3StorageSpaceToDrive(user, spaces[0]) + _ = json.NewEncoder(w).Encode(space) + return + } + } + + w.WriteHeader(http.StatusNotFound) +} + +func isShareJail(spaceID string) bool { + return false // TODO +} + +func shareID(spaceID string) string { + return "" // TODO +} + +func fullURL(base, path string) string { + full, _ := url.JoinPath(base, path) + return full +} + +func cs3PermissionsToLibreGraph(user *userpb.User, perms *providerpb.ResourcePermissions) []libregraph.Permission { + var p libregraph.Permission + // we need to map the permissions to the roles + switch { + // having RemoveGrant qualifies you as a manager + case perms.RemoveGrant: + p.SetRoles([]string{"manager"}) + // InitiateFileUpload means you are an editor + case perms.InitiateFileUpload: + p.SetRoles([]string{"editor"}) + // Stat permission at least makes you a viewer + case perms.Stat: + p.SetRoles([]string{"viewer"}) + } + + identity := &libregraph.Identity{ + DisplayName: user.DisplayName, + Id: &user.Id.OpaqueId, + } + + p.GrantedToIdentities = []libregraph.IdentitySet{ + { + User: identity, + }, + } + + p.GrantedToV2 = &libregraph.SharePointIdentitySet{ + User: identity, + } + return []libregraph.Permission{p} +} + +func (s *svc) getDrivePermissions(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := appctx.GetLogger(ctx) + + resourceID := chi.URLParam(r, "resource-id") + resourceID, _ = url.QueryUnescape(resourceID) + storageID, _, itemID, ok := spaces.DecodeResourceID(resourceID) + if !ok { + log.Error().Str("resource-id", resourceID).Msg("resource id cannot be decoded") + w.WriteHeader(http.StatusBadRequest) + return + } + s.getPermissionsByCs3Reference(ctx, w, log, &providerpb.Reference{ + ResourceId: &providerpb.ResourceId{ + StorageId: storageID, + OpaqueId: itemID, + }, + }) +} + +func (s *svc) getRootDrivePermissions(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := appctx.GetLogger(ctx) + + spaceID := chi.URLParam(r, "space-id") + spaceID, _ = url.QueryUnescape(spaceID) + _, path, ok := spaces.DecodeSpaceID(spaceID) + if !ok { + log.Error().Str("space-id", spaceID).Msg("space id cannot be decoded") + w.WriteHeader(http.StatusBadRequest) + return + } + + s.getPermissionsByCs3Reference(ctx, w, log, &providerpb.Reference{Path: path}) +} + +func (s *svc) getPermissionsByCs3Reference(ctx context.Context, w http.ResponseWriter, log *zerolog.Logger, ref *providerpb.Reference) { + gw, err := s.getClient() + if err != nil { + log.Error().Err(err).Msg("error getting grpc client") + w.WriteHeader(http.StatusInternalServerError) + return + } + + statRes, err := gw.Stat(ctx, &providerpb.StatRequest{ + Ref: ref, + }) + if err != nil { + log.Error().Err(err).Msg("error getting space by id") + w.WriteHeader(http.StatusInternalServerError) + return + } + if statRes.Status.Code != rpcv1beta1.Code_CODE_OK { + log.Error().Interface("ref", ref).Int("code", int(statRes.Status.Code)).Str("message", statRes.Status.Message).Msg("error statting resource") + w.WriteHeader(http.StatusInternalServerError) + return + } + + actions := CS3ResourcePermissionsToLibregraphActions(statRes.Info.PermissionSet) + roles := GetApplicableRoleDefinitionsForActions(actions) + + if err := json.NewEncoder(w).Encode(map[string]any{ + "@libre.graph.permissions.actions.allowedValues": actions, + "@libre.graph.permissions.roles.allowedValues": roles, + }); err != nil { + log.Error().Err(err).Msg("error marshalling spaces as json") + w.WriteHeader(http.StatusInternalServerError) + return + } +} diff --git a/internal/http/services/owncloud/ocgraph/linktype.go b/internal/http/services/owncloud/ocgraph/linktype.go new file mode 100644 index 0000000000..07c1f51a24 --- /dev/null +++ b/internal/http/services/owncloud/ocgraph/linktype.go @@ -0,0 +1,200 @@ +// Copyright 2018-2024 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +// This package implements the APIs defined in https://owncloud.dev/apis/http/graph/spaces/ + +package ocgraph + +import ( + "errors" + + linkv1beta1 "github.com/cs3org/go-cs3apis/cs3/sharing/link/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/pkg/storage/utils/grants" + libregraph "github.com/owncloud/libre-graph-api-go" +) + +// NoPermissionMatchError is the message returned by a failed conversion +const NoPermissionMatchError = "no matching permission set found" + +// LinkType contains cs3 permissions and a libregraph +// linktype reference +type LinkType struct { + Permissions *provider.ResourcePermissions + linkType libregraph.SharingLinkType +} + +// GetPermissions returns the cs3 permissions type +func (l *LinkType) GetPermissions() *provider.ResourcePermissions { + if l != nil { + return l.Permissions + } + return nil +} + +// SharingLinkTypeFromCS3Permissions creates a libregraph link type +// It returns a list of libregraph actions when the conversion is not possible +func SharingLinkTypeFromCS3Permissions(permissions *linkv1beta1.PublicSharePermissions) (*libregraph.SharingLinkType, []string) { + if permissions == nil { + return nil, nil + } + linkTypes := GetAvailableLinkTypes() + for _, linkType := range linkTypes { + if grants.PermissionsEqual(linkType.GetPermissions(), permissions.GetPermissions()) { + return &linkType.linkType, nil + } + } + return nil, CS3ResourcePermissionsToLibregraphActions(permissions.GetPermissions()) +} + +// CS3ResourcePermissionsFromSharingLink creates a cs3 resource permissions type +// it returns an error when the link type is not allowed or empty +func CS3ResourcePermissionsFromSharingLink(createLink libregraph.DriveItemCreateLink, info provider.ResourceType) (*provider.ResourcePermissions, error) { + switch createLink.GetType() { + case "": + return nil, errors.New("link type is empty") + case libregraph.VIEW: + return NewViewLinkPermissionSet().GetPermissions(), nil + case libregraph.EDIT: + if info == provider.ResourceType_RESOURCE_TYPE_FILE { + return NewFileEditLinkPermissionSet().GetPermissions(), nil + } + return NewFolderEditLinkPermissionSet().GetPermissions(), nil + case libregraph.CREATE_ONLY: + if info == provider.ResourceType_RESOURCE_TYPE_FILE { + return nil, errors.New(NoPermissionMatchError) + } + return NewFolderDropLinkPermissionSet().GetPermissions(), nil + case libregraph.UPLOAD: + if info == provider.ResourceType_RESOURCE_TYPE_FILE { + return nil, errors.New(NoPermissionMatchError) + } + return NewFolderUploadLinkPermissionSet().GetPermissions(), nil + case libregraph.INTERNAL: + return NewInternalLinkPermissionSet().GetPermissions(), nil + default: + return nil, errors.New(NoPermissionMatchError) + } +} + +// NewInternalLinkPermissionSet creates cs3 permissions for the internal link type +func NewInternalLinkPermissionSet() *LinkType { + return &LinkType{ + Permissions: &provider.ResourcePermissions{}, + linkType: libregraph.INTERNAL, + } +} + +// NewViewLinkPermissionSet creates cs3 permissions for the view link type +func NewViewLinkPermissionSet() *LinkType { + return &LinkType{ + Permissions: &provider.ResourcePermissions{ + GetPath: true, + GetQuota: true, + InitiateFileDownload: true, + ListContainer: true, + // why is this needed? + ListRecycle: true, + Stat: true, + }, + linkType: libregraph.VIEW, + } +} + +// NewFileEditLinkPermissionSet creates cs3 permissions for the file edit link type +func NewFileEditLinkPermissionSet() *LinkType { + return &LinkType{ + Permissions: &provider.ResourcePermissions{ + GetPath: true, + GetQuota: true, + InitiateFileDownload: true, + InitiateFileUpload: true, + ListContainer: true, + // why is this needed? + ListRecycle: true, + // why is this needed? + RestoreRecycleItem: true, + Stat: true, + }, + linkType: libregraph.EDIT, + } +} + +// NewFolderEditLinkPermissionSet creates cs3 permissions for the folder edit link type +func NewFolderEditLinkPermissionSet() *LinkType { + return &LinkType{ + Permissions: &provider.ResourcePermissions{ + CreateContainer: true, + Delete: true, + GetPath: true, + GetQuota: true, + InitiateFileDownload: true, + InitiateFileUpload: true, + ListContainer: true, + // why is this needed? + ListRecycle: true, + Move: true, + // why is this needed? + RestoreRecycleItem: true, + Stat: true, + }, + linkType: libregraph.EDIT, + } +} + +// NewFolderDropLinkPermissionSet creates cs3 permissions for the folder createOnly link type +func NewFolderDropLinkPermissionSet() *LinkType { + return &LinkType{ + Permissions: &provider.ResourcePermissions{ + Stat: true, + GetPath: true, + CreateContainer: true, + InitiateFileUpload: true, + }, + linkType: libregraph.CREATE_ONLY, + } +} + +// NewFolderUploadLinkPermissionSet creates cs3 permissions for the folder upload link type +func NewFolderUploadLinkPermissionSet() *LinkType { + return &LinkType{ + Permissions: &provider.ResourcePermissions{ + CreateContainer: true, + GetPath: true, + GetQuota: true, + InitiateFileDownload: true, + InitiateFileUpload: true, + ListContainer: true, + ListRecycle: true, + Stat: true, + }, + linkType: libregraph.UPLOAD, + } +} + +// GetAvailableLinkTypes returns a slice of all available link types +func GetAvailableLinkTypes() []*LinkType { + return []*LinkType{ + NewInternalLinkPermissionSet(), + NewViewLinkPermissionSet(), + NewFolderUploadLinkPermissionSet(), + NewFileEditLinkPermissionSet(), + NewFolderEditLinkPermissionSet(), + NewFolderDropLinkPermissionSet(), + } +} diff --git a/internal/http/services/owncloud/ocgraph/ocgraph.go b/internal/http/services/owncloud/ocgraph/ocgraph.go new file mode 100644 index 0000000000..6f8d71806c --- /dev/null +++ b/internal/http/services/owncloud/ocgraph/ocgraph.go @@ -0,0 +1,106 @@ +// Copyright 2018-2024 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +// This package implements the APIs defined in https://owncloud.dev/apis/http/graph/ + +package ocgraph + +import ( + "context" + "net/http" + + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + "github.com/cs3org/reva/pkg/rgrpc/todo/pool" + "github.com/cs3org/reva/pkg/rhttp/global" + "github.com/cs3org/reva/pkg/sharedconf" + "github.com/cs3org/reva/pkg/utils/cfg" + "github.com/go-chi/chi/v5" +) + +func init() { + global.Register("ocgraph", New) +} + +type config struct { + GatewaySvc string `mapstructure:"gatewaysvc" validate:"required"` + WebDavBase string `mapstructure:"webdav_base" validate:"required"` + WebBase string `mapstructure:"web_base" validate:"required"` +} + +func (c *config) ApplyDefaults() { + c.GatewaySvc = sharedconf.GetGatewaySVC(c.GatewaySvc) +} + +type svc struct { + c *config + router *chi.Mux +} + +func New(ctx context.Context, m map[string]interface{}) (global.Service, error) { + var c config + if err := cfg.Decode(m, &c); err != nil { + return nil, err + } + + s := &svc{ + c: &c, + } + s.initRouter() + + return s, nil +} + +func (s *svc) initRouter() { + s.router = chi.NewRouter() + + s.router.Route("/v1.0", func(r chi.Router) { + r.Route("/me", func(r chi.Router) { + r.Get("/", s.getMe) + }) + r.Route("/drives", func(r chi.Router) { + r.Get("/{space-id}", s.getSpace) + }) + }) + s.router.Route("/v1beta1", func(r chi.Router) { + r.Route("/me", func(r chi.Router) { + r.Route("/drives", func(r chi.Router) { + r.Get("/", s.listMySpaces) + + }) + }) + r.Route("/me/drive", func(r chi.Router) { + r.Get("/sharedWithMe", s.getSharedWithMe) + r.Get("/sharedByMe", s.getSharedByMe) + }) + r.Get("/roleManagement/permissions/roleDefinitions", s.getRoleDefinitions) + r.Get("/drives/{space-id}/root/permissions", s.getRootDrivePermissions) + r.Get("/drives/{space-id}/items/{resource-id}/permissions", s.getDrivePermissions) + }) +} + +func (s *svc) getClient() (gateway.GatewayAPIClient, error) { + return pool.GetGatewayServiceClient(pool.Endpoint(s.c.GatewaySvc)) +} + +func (s *svc) Handler() http.Handler { return s.router } + +func (s *svc) Prefix() string { return "graph" } + +func (s *svc) Close() error { return nil } + +func (s *svc) Unprotected() []string { return nil } diff --git a/internal/http/services/owncloud/ocgraph/roles.go b/internal/http/services/owncloud/ocgraph/roles.go new file mode 100644 index 0000000000..3604d39add --- /dev/null +++ b/internal/http/services/owncloud/ocgraph/roles.go @@ -0,0 +1,37 @@ +// Copyright 2018-2024 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +// This package implements the APIs defined in https://owncloud.dev/apis/http/graph/ + +package ocgraph + +import ( + "encoding/json" + "net/http" + + "github.com/cs3org/reva/pkg/appctx" +) + +func (s *svc) getRoleDefinitions(w http.ResponseWriter, r *http.Request) { + if err := json.NewEncoder(w).Encode(GetBuiltinRoleDefinitionList()); err != nil { + log := appctx.GetLogger(r.Context()) + log.Error().Err(err).Msg("error marshalling roles as json") + w.WriteHeader(http.StatusInternalServerError) + return + } +} diff --git a/internal/http/services/owncloud/ocgraph/shares.go b/internal/http/services/owncloud/ocgraph/shares.go new file mode 100644 index 0000000000..26666972e6 --- /dev/null +++ b/internal/http/services/owncloud/ocgraph/shares.go @@ -0,0 +1,417 @@ +// Copyright 2018-2024 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +// This package implements the APIs defined in https://owncloud.dev/apis/http/graph/spaces/ + +package ocgraph + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "path" + "strings" + + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + groupv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1" + userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + + collaborationv1beta1 "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1" + linkv1beta1 "github.com/cs3org/go-cs3apis/cs3/sharing/link/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/pkg/appctx" + "github.com/cs3org/reva/pkg/spaces" + "github.com/cs3org/reva/pkg/utils" + libregraph "github.com/owncloud/libre-graph-api-go" +) + +func (s *svc) getSharedWithMe(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := appctx.GetLogger(ctx) + + gw, err := s.getClient() + if err != nil { + log.Error().Err(err).Msg("error getting grpc client") + w.WriteHeader(http.StatusInternalServerError) + return + } + + resShares, err := gw.ListExistingReceivedShares(ctx, &collaborationv1beta1.ListReceivedSharesRequest{}) + if err != nil { + log.Error().Err(err).Msg("error getting received shares") + w.WriteHeader(http.StatusInternalServerError) + return + } + + shares := make([]*libregraph.DriveItem, 0, len(resShares.ShareInfos)) + for _, share := range resShares.ShareInfos { + drive, err := s.cs3ReceivedShareToDriveItem(ctx, share) + if err != nil { + log.Error().Err(err).Msg("error getting received shares") + w.WriteHeader(http.StatusInternalServerError) + return + } + shares = append(shares, drive) + } + + if err := json.NewEncoder(w).Encode(map[string]any{ + "value": shares, + }); err != nil { + log.Error().Err(err).Msg("error marshalling shares as json") + w.WriteHeader(http.StatusInternalServerError) + return + } +} + +func encodeSpaceIDForShareJail(res *provider.ResourceInfo) string { + return spaces.EncodeResourceID(res.Id) + //return spaces.EncodeSpaceID(res.Id.StorageId, res.Path) +} + +func (s *svc) cs3ReceivedShareToDriveItem(ctx context.Context, rsi *gateway.ReceivedShareResourceInfo) (*libregraph.DriveItem, error) { + createdTime := utils.TSToTime(rsi.ReceivedShare.Share.Ctime) + + creator, err := s.getUserByID(ctx, rsi.ReceivedShare.Share.Creator) + if err != nil { + return nil, err + } + + grantee, err := s.cs3GranteeToSharePointIdentitySet(ctx, rsi.ReceivedShare.Share.Grantee) + if err != nil { + return nil, err + } + + roles := make([]string, 0, 1) + role := CS3ResourcePermissionsToUnifiedRole(rsi.ResourceInfo.PermissionSet) + if role != nil { + roles = append(roles, *role.Id) + } + + d := &libregraph.DriveItem{ + UIHidden: libregraph.PtrBool(rsi.ReceivedShare.Hidden), + ClientSynchronize: libregraph.PtrBool(true), + CreatedBy: &libregraph.IdentitySet{ + User: &libregraph.Identity{ + DisplayName: creator.DisplayName, + Id: libregraph.PtrString(creator.Id.OpaqueId), + }, + }, + ETag: &rsi.ResourceInfo.Etag, + Id: libregraph.PtrString(libregraphShareID(rsi.ReceivedShare.Share.Id)), + LastModifiedDateTime: libregraph.PtrTime(utils.TSToTime(rsi.ResourceInfo.Mtime)), + Name: libregraph.PtrString(rsi.ResourceInfo.Name), + ParentReference: &libregraph.ItemReference{ + DriveId: libregraph.PtrString(fmt.Sprintf("%s$%s", shareJailID, shareJailID)), + DriveType: libregraph.PtrString("virtual"), + Id: libregraph.PtrString(fmt.Sprintf("%s$%s!%s", shareJailID, shareJailID, shareJailID)), + }, + RemoteItem: &libregraph.RemoteItem{ + CreatedBy: &libregraph.IdentitySet{ + User: &libregraph.Identity{ + DisplayName: creator.DisplayName, + Id: libregraph.PtrString(creator.Id.OpaqueId), + }, + }, + ETag: &rsi.ResourceInfo.Etag, + File: &libregraph.OpenGraphFile{ + MimeType: &rsi.ResourceInfo.MimeType, + }, + Id: libregraph.PtrString(encodeSpaceIDForShareJail(rsi.ResourceInfo)), + LastModifiedDateTime: libregraph.PtrTime(utils.TSToTime(rsi.ResourceInfo.Mtime)), + Name: libregraph.PtrString(rsi.ResourceInfo.Name), + Path: libregraph.PtrString(relativePathToSpaceID(rsi.ResourceInfo)), + // ParentReference: &libregraph.ItemReference{ + // DriveId: libregraph.PtrString(spaces.EncodeResourceID(share.ResourceInfo.ParentId)), + // DriveType: nil, // FIXME: no way to know it unless we hardcode it + // }, + Permissions: []libregraph.Permission{ + { + CreatedDateTime: *libregraph.NewNullableTime(&createdTime), + GrantedToV2: grantee, + Id: nil, // TODO: what is this?? + Invitation: &libregraph.SharingInvitation{ + InvitedBy: &libregraph.IdentitySet{ + User: &libregraph.Identity{ + DisplayName: creator.DisplayName, + Id: libregraph.PtrString(creator.Id.OpaqueId), + }, + }, + }, + Roles: roles, + }, + }, + Size: libregraph.PtrInt64(int64(rsi.ResourceInfo.Size)), + }, + Size: libregraph.PtrInt64(int64(rsi.ResourceInfo.Size)), + } + + if rsi.ResourceInfo.Type == provider.ResourceType_RESOURCE_TYPE_CONTAINER { + d.Folder = libregraph.NewFolder() + } else { + d.File = &libregraph.OpenGraphFile{ + MimeType: &rsi.ResourceInfo.MimeType, + } + } + + return d, nil +} + +func (s *svc) getUserByID(ctx context.Context, u *userv1beta1.UserId) (*userv1beta1.User, error) { + client, err := s.getClient() + if err != nil { + return nil, err + } + + res, err := client.GetUser(ctx, &userv1beta1.GetUserRequest{ + UserId: u, + }) + if err != nil { + return nil, err + } + + return res.User, nil +} + +func (s *svc) getGroupByID(ctx context.Context, g *groupv1beta1.GroupId) (*groupv1beta1.Group, error) { + client, err := s.getClient() + if err != nil { + return nil, err + } + + res, err := client.GetGroup(ctx, &groupv1beta1.GetGroupRequest{ + GroupId: g, + }) + if err != nil { + return nil, err + } + + return res.Group, nil +} + +func (s *svc) cs3GranteeToSharePointIdentitySet(ctx context.Context, grantee *provider.Grantee) (*libregraph.SharePointIdentitySet, error) { + p := &libregraph.SharePointIdentitySet{} + + if u := grantee.GetUserId(); u != nil { + user, err := s.getUserByID(ctx, u) + if err != nil { + return nil, err + } + p.User = &libregraph.Identity{ + DisplayName: user.DisplayName, + Id: libregraph.PtrString(u.OpaqueId), + } + } else if g := grantee.GetGroupId(); g != nil { + group, err := s.getGroupByID(ctx, g) + if err != nil { + return nil, err + } + p.Group = &libregraph.Identity{ + DisplayName: group.DisplayName, + Id: libregraph.PtrString(g.OpaqueId), + } + } + + return p, nil +} + +type share struct { + share *collaborationv1beta1.Share + public *linkv1beta1.PublicShare +} + +func resourceIdToString(id *provider.ResourceId) string { + return fmt.Sprintf("%s!%s", id.StorageId, id.OpaqueId) +} + +func resourceIdFromString(s string) *provider.ResourceId { + parts := strings.Split(s, "!") + return &provider.ResourceId{ + StorageId: parts[0], + OpaqueId: parts[1], + } +} + +func groupByResourceID(shares []*gateway.ShareResourceInfo, publicShares []*gateway.PublicShareResourceInfo) (map[string][]*share, map[string]*provider.ResourceInfo) { + grouped := make(map[string][]*share, len(shares)+len(publicShares)) // at most we have the sum of both lists + infos := make(map[string]*provider.ResourceInfo, len(shares)+len(publicShares)) + + for _, s := range shares { + id := resourceIdToString(s.Share.ResourceId) + grouped[id] = append(grouped[id], &share{ + share: s.Share, + }) + infos[id] = s.ResourceInfo // all shares of the same resource are assumed to have the same ResourceInfo payload, here we take the last + } + + for _, s := range publicShares { + id := resourceIdToString(s.PublicShare.ResourceId) + grouped[id] = append(grouped[id], &share{ + public: s.PublicShare, + }) + infos[id] = s.ResourceInfo + } + + return grouped, infos +} + +type pair[T, V any] struct { + First T + Second V +} + +func (s *svc) getSharedByMe(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := appctx.GetLogger(ctx) + + gw, err := s.getClient() + if err != nil { + // TODO + return + } + + shares, err := gw.ListExistingShares(ctx, &collaborationv1beta1.ListSharesRequest{}) + if err != nil { + // TODO + return + } + + publicShares, err := gw.ListExistingPublicShares(ctx, &linkv1beta1.ListPublicSharesRequest{}) + if err != nil { + // TODO + return + } + + grouped, infos := groupByResourceID(shares.ShareInfos, publicShares.ShareInfos) + + // convert to libregraph share drives + shareDrives := make([]*libregraph.DriveItem, 0, len(grouped)) + for id, shares := range grouped { + info := infos[id] + drive, err := s.cs3ShareToDriveItem(ctx, info, shares) + if err != nil { + log.Error().Err(err).Msg("error getting received shares") + w.WriteHeader(http.StatusInternalServerError) + return + } + shareDrives = append(shareDrives, drive) + } + + if err := json.NewEncoder(w).Encode(map[string]any{ + "value": shareDrives, + }); err != nil { + log.Error().Err(err).Msg("error marshalling shares as json") + w.WriteHeader(http.StatusInternalServerError) + return + } +} + +func (s *svc) cs3ShareToDriveItem(ctx context.Context, info *provider.ResourceInfo, shares []*share) (*libregraph.DriveItem, error) { + + parentRelativePath := path.Dir(relativePathToSpaceID(info)) + + permissions, err := s.cs3sharesToPermissions(ctx, shares) + if err != nil { + return nil, err + } + + d := &libregraph.DriveItem{ + ETag: libregraph.PtrString(info.Etag), + Id: libregraph.PtrString(spaces.EncodeResourceID(info.Id)), + LastModifiedDateTime: libregraph.PtrTime(utils.TSToTime(info.Mtime)), + Name: libregraph.PtrString(info.Name), + ParentReference: &libregraph.ItemReference{ + DriveId: libregraph.PtrString(spaces.EncodeSpaceID(info.Id.StorageId, info.Id.SpaceId)), + // DriveType: libregraph.PtrString(info.Space.SpaceType), + Id: libregraph.PtrString(spaces.EncodeResourceID(info.ParentId)), + Name: libregraph.PtrString(path.Base(parentRelativePath)), + Path: libregraph.PtrString(parentRelativePath), + }, + Permissions: permissions, + + Size: libregraph.PtrInt64(int64(info.Size)), + } + + if info.Type == provider.ResourceType_RESOURCE_TYPE_CONTAINER { + d.Folder = libregraph.NewFolder() + } else { + d.File = &libregraph.OpenGraphFile{ + MimeType: &info.MimeType, + } + } + + return d, nil +} + +func (s *svc) cs3sharesToPermissions(ctx context.Context, shares []*share) ([]libregraph.Permission, error) { + permissions := make([]libregraph.Permission, 0, len(shares)) + + for _, e := range shares { + if e.share != nil { + createdTime := utils.TSToTime(e.share.Ctime) + + creator, err := s.getUserByID(ctx, e.share.Creator) + if err != nil { + return nil, err + } + + grantee, err := s.cs3GranteeToSharePointIdentitySet(ctx, e.share.Grantee) + if err != nil { + return nil, err + } + + roles := make([]string, 0, 1) + role := CS3ResourcePermissionsToUnifiedRole(e.share.Permissions.Permissions) + if role != nil { + roles = append(roles, *role.Id) + } + permissions = append(permissions, libregraph.Permission{ + CreatedDateTime: *libregraph.NewNullableTime(&createdTime), + GrantedToV2: grantee, + Id: nil, // TODO: what is this?? + Invitation: &libregraph.SharingInvitation{ + InvitedBy: &libregraph.IdentitySet{ + User: &libregraph.Identity{ + DisplayName: creator.DisplayName, + Id: libregraph.PtrString(creator.Id.OpaqueId), + }, + }, + }, + Roles: roles, + }) + } else if e.public != nil { + createdTime := utils.TSToTime(e.public.Ctime) + linktype, _ := SharingLinkTypeFromCS3Permissions(e.public.Permissions) + + permissions = append(permissions, libregraph.Permission{ + CreatedDateTime: *libregraph.NewNullableTime(&createdTime), + HasPassword: libregraph.PtrBool(e.public.PasswordProtected), + Id: libregraph.PtrString(e.public.Token), + Link: &libregraph.SharingLink{ + LibreGraphDisplayName: libregraph.PtrString("Link"), + LibreGraphQuickLink: libregraph.PtrBool(e.public.Quicklink), + PreventsDownload: libregraph.PtrBool(false), + Type: linktype, + // WebUrl: libregraph.PtrString(""), + }, + }) + } + } + + return permissions, nil +} diff --git a/internal/http/services/owncloud/ocgraph/unifiedrole.go b/internal/http/services/owncloud/ocgraph/unifiedrole.go new file mode 100644 index 0000000000..46938a34ea --- /dev/null +++ b/internal/http/services/owncloud/ocgraph/unifiedrole.go @@ -0,0 +1,429 @@ +// Copyright 2018-2024 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +// This package implements the APIs defined in https://owncloud.dev/apis/http/graph/ + +package ocgraph + +import ( + "errors" + "slices" + + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/internal/http/services/owncloud/ocs/conversions" + libregraph "github.com/owncloud/libre-graph-api-go" + "google.golang.org/protobuf/proto" +) + +const ( + // UnifiedRoleViewerID Unified role viewer id. + UnifiedRoleViewerID = "b1e2218d-eef8-4d4c-b82d-0f1a1b48f3b5" + // UnifiedRoleSpaceViewerID Unified role space viewer id. + UnifiedRoleSpaceViewerID = "a8d5fe5e-96e3-418d-825b-534dbdf22b99" + // UnifiedRoleEditorID Unified role editor id. + UnifiedRoleEditorID = "fb6c3e19-e378-47e5-b277-9732f9de6e21" + // UnifiedRoleSpaceEditorID Unified role space editor id. + UnifiedRoleSpaceEditorID = "58c63c02-1d89-4572-916a-870abc5a1b7d" + // UnifiedRoleFileEditorID Unified role file editor id. + UnifiedRoleFileEditorID = "2d00ce52-1fc2-4dbc-8b95-a73b73395f5a" + // UnifiedRoleEditorLiteID Unified role editor-lite id. + UnifiedRoleEditorLiteID = "1c996275-f1c9-4e71-abdf-a42f6495e960" + // UnifiedRoleManagerID Unified role manager id. + UnifiedRoleManagerID = "312c0871-5ef7-4b3a-85b6-0e4074c64049" + // UnifiedRoleSecureViewerID Unified role secure viewer id. + UnifiedRoleSecureViewerID = "aa97fe03-7980-45ac-9e50-b325749fd7e6" + + // UnifiedRoleConditionDrive defines constraint that matches a Driveroot/Spaceroot + UnifiedRoleConditionDrive = "exists @Resource.Root" + // UnifiedRoleConditionFolder defines constraints that matches a DriveItem representing a Folder + UnifiedRoleConditionFolder = "exists @Resource.Folder" + // UnifiedRoleConditionFile defines a constraint that matches a DriveItem representing a File + UnifiedRoleConditionFile = "exists @Resource.File" + + DriveItemPermissionsCreate = "libre.graph/driveItem/permissions/create" + DriveItemChildrenCreate = "libre.graph/driveItem/children/create" + DriveItemStandardDelete = "libre.graph/driveItem/standard/delete" + DriveItemPathRead = "libre.graph/driveItem/path/read" + DriveItemQuotaRead = "libre.graph/driveItem/quota/read" + DriveItemContentRead = "libre.graph/driveItem/content/read" + DriveItemUploadCreate = "libre.graph/driveItem/upload/create" + DriveItemPermissionsRead = "libre.graph/driveItem/permissions/read" + DriveItemChildrenRead = "libre.graph/driveItem/children/read" + DriveItemVersionsRead = "libre.graph/driveItem/versions/read" + DriveItemDeletedRead = "libre.graph/driveItem/deleted/read" + DriveItemPathUpdate = "libre.graph/driveItem/path/update" + DriveItemPermissionsDelete = "libre.graph/driveItem/permissions/delete" + DriveItemDeletedDelete = "libre.graph/driveItem/deleted/delete" + DriveItemVersionsUpdate = "libre.graph/driveItem/versions/update" + DriveItemDeletedUpdate = "libre.graph/driveItem/deleted/update" + DriveItemBasicRead = "libre.graph/driveItem/basic/read" + DriveItemPermissionsUpdate = "libre.graph/driveItem/permissions/update" + DriveItemPermissionsDeny = "libre.graph/driveItem/permissions/deny" +) + +var legacyNames map[string]string = map[string]string{ + UnifiedRoleViewerID: conversions.RoleViewer, + // one V1 api the "spaceviewer" role was call "viewer" and the "spaceeditor" was "editor", + // we need to stay compatible with that + UnifiedRoleSpaceViewerID: "viewer", + UnifiedRoleSpaceEditorID: "editor", + UnifiedRoleEditorID: conversions.RoleEditor, + UnifiedRoleFileEditorID: conversions.RoleFileEditor, + // UnifiedRoleEditorLiteID: conversions.RoleEditorLite, + UnifiedRoleManagerID: conversions.RoleManager, +} + +// NewViewerUnifiedRole creates a viewer role. +func NewViewerUnifiedRole() *libregraph.UnifiedRoleDefinition { + r := conversions.NewViewerRole() + return &libregraph.UnifiedRoleDefinition{ + Id: proto.String(UnifiedRoleViewerID), + Description: proto.String("View and download."), + DisplayName: displayName(r), + RolePermissions: []libregraph.UnifiedRolePermission{ + { + AllowedResourceActions: convert(r), + Condition: proto.String(UnifiedRoleConditionFile), + }, + { + AllowedResourceActions: convert(r), + Condition: proto.String(UnifiedRoleConditionFolder), + }, + }, + LibreGraphWeight: proto.Int32(0), + } +} + +// NewSpaceViewerUnifiedRole creates a spaceviewer role +func NewSpaceViewerUnifiedRole() *libregraph.UnifiedRoleDefinition { + r := conversions.NewViewerRole() + return &libregraph.UnifiedRoleDefinition{ + Id: proto.String(UnifiedRoleSpaceViewerID), + Description: proto.String("View and download."), + DisplayName: displayName(r), + RolePermissions: []libregraph.UnifiedRolePermission{ + { + AllowedResourceActions: convert(r), + Condition: proto.String(UnifiedRoleConditionDrive), + }, + }, + LibreGraphWeight: proto.Int32(0), + } +} + +// NewEditorUnifiedRole creates an editor role. +func NewEditorUnifiedRole() *libregraph.UnifiedRoleDefinition { + r := conversions.NewEditorRole() + return &libregraph.UnifiedRoleDefinition{ + Id: proto.String(UnifiedRoleEditorID), + Description: proto.String("View, download, upload, edit, add and delete."), + DisplayName: displayName(r), + RolePermissions: []libregraph.UnifiedRolePermission{ + { + AllowedResourceActions: convert(r), + Condition: proto.String(UnifiedRoleConditionFolder), + }, + }, + LibreGraphWeight: proto.Int32(0), + } +} + +// NewSpaceEditorUnifiedRole creates an editor role +func NewSpaceEditorUnifiedRole() *libregraph.UnifiedRoleDefinition { + r := conversions.NewEditorRole() + return &libregraph.UnifiedRoleDefinition{ + Id: proto.String(UnifiedRoleSpaceEditorID), + Description: proto.String("View, download, upload, edit, add and delete."), + DisplayName: displayName(r), + RolePermissions: []libregraph.UnifiedRolePermission{ + { + AllowedResourceActions: convert(r), + Condition: proto.String(UnifiedRoleConditionDrive), + }, + }, + LibreGraphWeight: proto.Int32(0), + } +} + +// NewFileEditorUnifiedRole creates a file-editor role +func NewFileEditorUnifiedRole() *libregraph.UnifiedRoleDefinition { + r := conversions.NewFileEditorRole() + return &libregraph.UnifiedRoleDefinition{ + Id: proto.String(UnifiedRoleFileEditorID), + Description: proto.String("View, download and edit."), + DisplayName: displayName(r), + RolePermissions: []libregraph.UnifiedRolePermission{ + { + AllowedResourceActions: convert(r), + Condition: proto.String(UnifiedRoleConditionFile), + }, + }, + LibreGraphWeight: proto.Int32(0), + } +} + +// NewManagerUnifiedRole creates a manager role +func NewManagerUnifiedRole() *libregraph.UnifiedRoleDefinition { + r := conversions.NewManagerRole() + return &libregraph.UnifiedRoleDefinition{ + Id: proto.String(UnifiedRoleManagerID), + Description: proto.String("View, download, upload, edit, add, delete and manage members."), + DisplayName: displayName(r), + RolePermissions: []libregraph.UnifiedRolePermission{ + { + AllowedResourceActions: convert(r), + Condition: proto.String(UnifiedRoleConditionDrive), + }, + }, + LibreGraphWeight: proto.Int32(0), + } +} + +// NewUnifiedRoleFromID returns a unified role definition from the provided id +func NewUnifiedRoleFromID(id string) (*libregraph.UnifiedRoleDefinition, error) { + for _, definition := range GetBuiltinRoleDefinitionList() { + if definition.GetId() != id { + continue + } + + return definition, nil + } + + return nil, errors.New("role not found") +} + +// GetApplicableRoleDefinitionsForActions returns a list of role definitions +// that match the provided actions and constraints +func GetApplicableRoleDefinitionsForActions(actions []string) []*libregraph.UnifiedRoleDefinition { + builtin := GetBuiltinRoleDefinitionList() + definitions := make([]*libregraph.UnifiedRoleDefinition, 0, len(builtin)) + + for _, definition := range builtin { + var definitionMatch bool + + for _, permission := range definition.GetRolePermissions() { + + for i, action := range permission.GetAllowedResourceActions() { + if !slices.Contains(actions, action) { + break + } + if i == len(permission.GetAllowedResourceActions())-1 { + definitionMatch = true + } + } + + if definitionMatch { + break + } + } + + if definitionMatch { + definitions = append(definitions, definition) + } + + } + + return definitions +} + +// PermissionsToCS3ResourcePermissions converts the provided libregraph UnifiedRolePermissions to a cs3 ResourcePermissions +func PermissionsToCS3ResourcePermissions(unifiedRolePermissions []*libregraph.UnifiedRolePermission) *provider.ResourcePermissions { + p := &provider.ResourcePermissions{} + + for _, permission := range unifiedRolePermissions { + for _, allowedResourceAction := range permission.AllowedResourceActions { + switch allowedResourceAction { + case DriveItemPermissionsCreate: + p.AddGrant = true + case DriveItemChildrenCreate: + p.CreateContainer = true + case DriveItemStandardDelete: + p.Delete = true + case DriveItemPathRead: + p.GetPath = true + case DriveItemQuotaRead: + p.GetQuota = true + case DriveItemContentRead: + p.InitiateFileDownload = true + case DriveItemUploadCreate: + p.InitiateFileUpload = true + case DriveItemPermissionsRead: + p.ListGrants = true + case DriveItemChildrenRead: + p.ListContainer = true + case DriveItemVersionsRead: + p.ListFileVersions = true + case DriveItemDeletedRead: + p.ListRecycle = true + case DriveItemPathUpdate: + p.Move = true + case DriveItemPermissionsDelete: + p.RemoveGrant = true + case DriveItemDeletedDelete: + p.PurgeRecycle = true + case DriveItemVersionsUpdate: + p.RestoreFileVersion = true + case DriveItemDeletedUpdate: + p.RestoreRecycleItem = true + case DriveItemBasicRead: + p.Stat = true + case DriveItemPermissionsUpdate: + p.UpdateGrant = true + case DriveItemPermissionsDeny: + p.DenyGrant = true + } + } + } + + return p +} + +// CS3ResourcePermissionsToLibregraphActions converts the provided cs3 ResourcePermissions to a list of +// libregraph actions +func CS3ResourcePermissionsToLibregraphActions(p *provider.ResourcePermissions) (actions []string) { + if p.GetAddGrant() { + actions = append(actions, DriveItemPermissionsCreate) + } + if p.GetCreateContainer() { + actions = append(actions, DriveItemChildrenCreate) + } + if p.GetDelete() { + actions = append(actions, DriveItemStandardDelete) + } + if p.GetGetPath() { + actions = append(actions, DriveItemPathRead) + } + if p.GetGetQuota() { + actions = append(actions, DriveItemQuotaRead) + } + if p.GetInitiateFileDownload() { + actions = append(actions, DriveItemContentRead) + } + if p.GetInitiateFileUpload() { + actions = append(actions, DriveItemUploadCreate) + } + if p.GetListGrants() { + actions = append(actions, DriveItemPermissionsRead) + } + if p.GetListContainer() { + actions = append(actions, DriveItemChildrenRead) + } + if p.GetListFileVersions() { + actions = append(actions, DriveItemVersionsRead) + } + if p.GetListRecycle() { + actions = append(actions, DriveItemDeletedRead) + } + if p.GetMove() { + actions = append(actions, DriveItemPathUpdate) + } + if p.GetRemoveGrant() { + actions = append(actions, DriveItemPermissionsDelete) + } + if p.GetPurgeRecycle() { + actions = append(actions, DriveItemDeletedDelete) + } + if p.GetRestoreFileVersion() { + actions = append(actions, DriveItemVersionsUpdate) + } + if p.GetRestoreRecycleItem() { + actions = append(actions, DriveItemDeletedUpdate) + } + if p.GetStat() { + actions = append(actions, DriveItemBasicRead) + } + if p.GetUpdateGrant() { + actions = append(actions, DriveItemPermissionsUpdate) + } + if p.GetDenyGrant() { + actions = append(actions, DriveItemPermissionsDeny) + } + return actions +} + +func GetLegacyName(role libregraph.UnifiedRoleDefinition) string { + return legacyNames[role.GetId()] +} + +// CS3ResourcePermissionsToUnifiedRole tries to find the UnifiedRoleDefinition that matches the supplied +// CS3 ResourcePermissions. +func CS3ResourcePermissionsToUnifiedRole(p *provider.ResourcePermissions) *libregraph.UnifiedRoleDefinition { + role := conversions.RoleFromResourcePermissions(p) + return ocsRoleUnifiedRole[role.Name] +} + +func displayName(role *conversions.Role) *string { + if role == nil { + return nil + } + + // linter wants this to be a var + canEdit := "Can edit" + + var displayName string + switch role.Name { + case conversions.RoleViewer: + displayName = "Can view" + case conversions.RoleEditor: + displayName = canEdit + case conversions.RoleFileEditor: + displayName = canEdit + case conversions.RoleManager: + displayName = "Can manage" + default: + return nil + } + return proto.String(displayName) +} + +func convert(role *conversions.Role) []string { + actions := make([]string, 0, 8) + if role == nil && role.CS3ResourcePermissions() == nil { + return actions + } + return CS3ResourcePermissionsToLibregraphActions(role.CS3ResourcePermissions()) +} + +func GetAllowedResourceActions(role *libregraph.UnifiedRoleDefinition, condition string) []string { + for _, p := range role.GetRolePermissions() { + if p.GetCondition() == condition { + return p.GetAllowedResourceActions() + } + } + return []string{} +} + +func GetBuiltinRoleDefinitionList() []*libregraph.UnifiedRoleDefinition { + return []*libregraph.UnifiedRoleDefinition{ + NewViewerUnifiedRole(), + NewEditorUnifiedRole(), + NewFileEditorUnifiedRole(), + NewManagerUnifiedRole(), + } +} + +var ocsRoleUnifiedRole = map[string]*libregraph.UnifiedRoleDefinition{ + conversions.RoleViewer: NewViewerUnifiedRole(), + conversions.RoleReader: NewViewerUnifiedRole(), + conversions.RoleEditor: NewEditorUnifiedRole(), + conversions.RoleFileEditor: NewFileEditorUnifiedRole(), + conversions.RoleCollaborator: NewManagerUnifiedRole(), + // FIXME: this is a wrong mapping, but it looks like in ocis has not been defined so far + conversions.RoleUploader: NewEditorUnifiedRole(), + conversions.RoleManager: NewManagerUnifiedRole(), +} diff --git a/internal/http/services/owncloud/ocgraph/users.go b/internal/http/services/owncloud/ocgraph/users.go new file mode 100644 index 0000000000..bd08e1167a --- /dev/null +++ b/internal/http/services/owncloud/ocgraph/users.go @@ -0,0 +1,41 @@ +// Copyright 2018-2024 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +// This package implements the APIs defined in https://owncloud.dev/apis/http/graph/ + +package ocgraph + +import ( + "encoding/json" + "net/http" + + "github.com/cs3org/reva/pkg/appctx" + libregraph "github.com/owncloud/libre-graph-api-go" +) + +// https://owncloud.dev/apis/http/graph/users/#reading-users +func (s *svc) getMe(w http.ResponseWriter, r *http.Request) { + user := appctx.ContextMustGetUser(r.Context()) + me := &libregraph.User{ + DisplayName: &user.DisplayName, + Mail: &user.Mail, + OnPremisesSamAccountName: &user.Username, + Id: &user.Id.OpaqueId, + } + _ = json.NewEncoder(w).Encode(me) +} diff --git a/internal/http/services/owncloud/ocs/data/capabilities.go b/internal/http/services/owncloud/ocs/data/capabilities.go index 7fc6dcaedf..524b5e86b1 100644 --- a/internal/http/services/owncloud/ocs/data/capabilities.go +++ b/internal/http/services/owncloud/ocs/data/capabilities.go @@ -62,9 +62,10 @@ type Capabilities struct { // Spaces lets a service configure its advertised options related to Storage Spaces. type Spaces struct { - Version string `json:"version" mapstructure:"version" xml:"version"` - Enabled ocsBool `json:"enabled" mapstructure:"enabled" xml:"enabled"` - Projects ocsBool `json:"projects" mapstructure:"projects" xml:"projects"` + Version string `json:"version" mapstructure:"version" xml:"version"` + Enabled ocsBool `json:"enabled" mapstructure:"enabled" xml:"enabled"` + Projects ocsBool `json:"projects" mapstructure:"projects" xml:"projects"` + ShareJail ocsBool `json:"share_jail" mapstructure:"share_jail" xml:"share_jail"` } // CapabilitiesCore holds webdav config. @@ -103,6 +104,13 @@ type CapabilitiesFilesTusSupport struct { HTTPMethodOverride string `json:"http_method_override" mapstructure:"http_method_override" xml:"http_method_override"` } +// CapabilitiesFilesThumbnail used to enable thumbnails on specific files on web +type CapabilitiesFilesThumbnail struct { + Enabled bool `json:"enabled" mapstructure:"enabled" xml:"enabled"` + Version string `json:"version" mapstructure:"version" xml:"version"` + SupportedMimeTypes []string `json:"supportedMimeTypes" mapstructure:"supported_mime_types" xml:"supportedMimeTypes"` +} + // CapabilitiesArchiver holds available archivers information. type CapabilitiesArchiver struct { Enabled bool `json:"enabled" mapstructure:"enabled" xml:"enabled"` @@ -132,6 +140,7 @@ type CapabilitiesFiles struct { PermanentDeletion ocsBool `json:"permanent_deletion" xml:"permanent_deletion"` BlacklistedFiles []string `json:"blacklisted_files" mapstructure:"blacklisted_files" xml:"blacklisted_files>element"` TusSupport *CapabilitiesFilesTusSupport `json:"tus_support" mapstructure:"tus_support" xml:"tus_support"` + Thumbnail *CapabilitiesFilesThumbnail `json:"thumbnail" mapstructure:"thumbnail" xml:"thumbnail"` Archivers []*CapabilitiesArchiver `json:"archivers" mapstructure:"archivers" xml:"archivers"` AppProviders []*CapabilitiesAppProvider `json:"app_providers" mapstructure:"app_providers" xml:"app_providers"` } diff --git a/internal/http/services/owncloud/ocs/handlers/apps/sharing/shares/shares.go b/internal/http/services/owncloud/ocs/handlers/apps/sharing/shares/shares.go index 1829f31b31..ea618ad76b 100644 --- a/internal/http/services/owncloud/ocs/handlers/apps/sharing/shares/shares.go +++ b/internal/http/services/owncloud/ocs/handlers/apps/sharing/shares/shares.go @@ -48,6 +48,7 @@ import ( "github.com/cs3org/reva/internal/http/services/owncloud/ocs/conversions" "github.com/cs3org/reva/internal/http/services/owncloud/ocs/response" "github.com/cs3org/reva/pkg/appctx" + "github.com/cs3org/reva/pkg/spaces" "github.com/cs3org/reva/pkg/notification" "github.com/cs3org/reva/pkg/notification/notificationhelper" @@ -149,18 +150,24 @@ func (h *Handler) startCacheWarmup(c cache.Warmup) { } } -func (h *Handler) extractReference(r *http.Request) (provider.Reference, error) { +func (h *Handler) extractReference(r *http.Request) (*provider.Reference, error) { var ref provider.Reference + if spaceID := r.FormValue("space_ref"); spaceID != "" { + _, base, _, ok := spaces.DecodeResourceID(spaceID) + if !ok { + return nil, errors.New("bad space id format") + } + + ref.Path = base + } if p := r.FormValue("path"); p != "" { - ref = provider.Reference{Path: path.Join(h.homeNamespace, p)} - } else if spaceRef := r.FormValue("space_ref"); spaceRef != "" { - var err error - ref, err = utils.ParseStorageSpaceReference(spaceRef) - if err != nil { - return provider.Reference{}, err + if ref.Path == "" { + ref.Path = path.Join(h.homeNamespace, p) + } else { + ref.Path = path.Join(ref.Path, p) } } - return ref, nil + return &ref, nil } // CreateShare handles POST requests on /apps/files_sharing/api/v1/shares. @@ -186,7 +193,7 @@ func (h *Handler) CreateShare(w http.ResponseWriter, r *http.Request) { } statReq := provider.StatRequest{ - Ref: &ref, + Ref: ref, } log := appctx.GetLogger(ctx).With().Interface("ref", ref).Logger() @@ -1110,8 +1117,12 @@ func (h *Handler) addFilters(w http.ResponseWriter, r *http.Request, prefix stri return nil, nil, err } - target := path.Join(prefix, r.FormValue("path")) - info, status, err := h.getResourceInfoByPath(ctx, client, target) + target, err := h.extractReference(r) + if err != nil { + response.WriteOCSError(w, r, response.MetaServerError.StatusCode, "error extracting reference from request", err) + return nil, nil, err + } + info, status, err := h.getResourceInfoByPath(ctx, client, target.Path) if err != nil { response.WriteOCSError(w, r, response.MetaServerError.StatusCode, "error sending a grpc stat request", err) return nil, nil, err @@ -1134,6 +1145,10 @@ func (h *Handler) addFilters(w http.ResponseWriter, r *http.Request, prefix stri return collaborationFilters, linkFilters, nil } +func relativePathToSpaceID(info *provider.ResourceInfo) string { + return strings.TrimPrefix(info.Path, info.Id.SpaceId) +} + func (h *Handler) addFileInfo(ctx context.Context, s *conversions.ShareData, info *provider.ResourceInfo) error { log := appctx.GetLogger(ctx) if info != nil { @@ -1146,12 +1161,14 @@ func (h *Handler) addFileInfo(ctx context.Context, s *conversions.ShareData, inf s.MimeType = parsedMt // TODO STime: &types.Timestamp{Seconds: info.Mtime.Seconds, Nanos: info.Mtime.Nanos}, // TODO Storage: int - s.ItemSource = resourceid.OwnCloudResourceIDWrap(info.Id) + itemID := spaces.EncodeResourceID(info.Id) + + s.ItemSource = itemID s.FileSource = s.ItemSource switch { case h.sharePrefix == "/": - s.FileTarget = info.Path - s.Path = info.Path + s.FileTarget = relativePathToSpaceID(info) + s.Path = relativePathToSpaceID(info) case s.ShareType == conversions.ShareTypePublicLink: s.FileTarget = path.Join("/", path.Base(info.Path)) s.Path = path.Join("/", path.Base(info.Path)) @@ -1387,7 +1404,8 @@ func mapState(state collaboration.ShareState) int { var mapped int switch state { case collaboration.ShareState_SHARE_STATE_PENDING: - mapped = ocsStatePending + mapped = ocsStateAccepted + // mapped = ocsStatePending case collaboration.ShareState_SHARE_STATE_ACCEPTED: mapped = ocsStateAccepted case collaboration.ShareState_SHARE_STATE_REJECTED: diff --git a/internal/http/services/owncloud/ocs/handlers/apps/sharing/shares/shares_test.go b/internal/http/services/owncloud/ocs/handlers/apps/sharing/shares/shares_test.go index b35a06fd33..cbb263c29f 100644 --- a/internal/http/services/owncloud/ocs/handlers/apps/sharing/shares/shares_test.go +++ b/internal/http/services/owncloud/ocs/handlers/apps/sharing/shares/shares_test.go @@ -58,7 +58,7 @@ func TestMapState(t *testing.T) { input collaboration.ShareState expected int }{ - {collaboration.ShareState_SHARE_STATE_PENDING, ocsStatePending}, + {collaboration.ShareState_SHARE_STATE_PENDING, ocsStateAccepted}, {collaboration.ShareState_SHARE_STATE_ACCEPTED, ocsStateAccepted}, {collaboration.ShareState_SHARE_STATE_REJECTED, ocsStateRejected}, {42, ocsStateUnknown}, diff --git a/internal/http/services/owncloud/ocs/handlers/cloud/user/user.go b/internal/http/services/owncloud/ocs/handlers/cloud/user/user.go index ab9a92b77b..66c1a955c4 100644 --- a/internal/http/services/owncloud/ocs/handlers/cloud/user/user.go +++ b/internal/http/services/owncloud/ocs/handlers/cloud/user/user.go @@ -75,6 +75,20 @@ func (h *Handler) GetSelf(w http.ResponseWriter, r *http.Request) { }) } +type SigningKey struct { + User string `json:"user" xml:"user"` + SigningKey string `json:"signing-key" xml:"signing-key"` +} + +func (h *Handler) SigningKey(w http.ResponseWriter, r *http.Request) { + u := appctx.ContextMustGetUser(r.Context()) + + response.WriteOCSSuccess(w, r, &SigningKey{ + User: u.Username, + SigningKey: "UGFyY2UgbWVybywgY29lbmF0byBwYXJ1bTogbm9uIHNpdCB0aWJpIHZhbnVtClN1cmdlcmUgcG9zdCBlcHVsYXM6IHNvbW51bSBmdWdlIG1lcmlkaWFudW06Ck5vbiBtaWN0dW0gcmV0aW5lLCBuZWMgY29tcHJpbWUgZm9ydGl0ZXIgYW51bS4KSGFlYyBiZW5lIHNpIHNlcnZlcywgdHUgbG9uZ28gdGVtcG9yZSB2aXZlcw==", + }) +} + func (h *Handler) getLanguage(ctx context.Context) string { gw, err := pool.GetGatewayServiceClient(pool.Endpoint(h.gatewayAddr)) if err != nil { diff --git a/internal/http/services/owncloud/ocs/ocs.go b/internal/http/services/owncloud/ocs/ocs.go index 0b80a56564..c52cec9fde 100644 --- a/internal/http/services/owncloud/ocs/ocs.go +++ b/internal/http/services/owncloud/ocs/ocs.go @@ -129,8 +129,11 @@ func (s *svc) routerInit(l *zerolog.Logger) error { r.Route("/cloud", func(r chi.Router) { r.Get("/capabilities", capabilitiesHandler.GetCapabilities) - r.Get("/user", userHandler.GetSelf) - r.Patch("/user", userHandler.UpdateSelf) + r.Route("/user", func(r chi.Router) { + r.Get("/", userHandler.GetSelf) + r.Patch("/", userHandler.UpdateSelf) + r.Get("/signing-key", userHandler.SigningKey) + }) r.Route("/users", func(r chi.Router) { r.Get("/{userid}", usersHandler.GetUsers) r.Get("/{userid}/groups", usersHandler.GetGroups) diff --git a/pkg/auth/manager/nextcloud/nextcloud.go b/pkg/auth/manager/nextcloud/nextcloud.go index 65c408fec3..81805e4dba 100644 --- a/pkg/auth/manager/nextcloud/nextcloud.go +++ b/pkg/auth/manager/nextcloud/nextcloud.go @@ -172,8 +172,8 @@ func (am *Manager) Authenticate(ctx context.Context, clientID, clientSecret stri } type resultsObj struct { - User user.User `json:"user"` - Scopes map[string]authpb.Scope `json:"scopes"` + User user.User `json:"user"` + Scopes map[string]*authpb.Scope `json:"scopes"` } result := &resultsObj{} err = json.Unmarshal(body, &result) @@ -182,8 +182,7 @@ func (am *Manager) Authenticate(ctx context.Context, clientID, clientSecret stri } var pointersMap = make(map[string]*authpb.Scope) for k := range result.Scopes { - scope := result.Scopes[k] - pointersMap[k] = &scope + pointersMap[k] = result.Scopes[k] } return &result.User, pointersMap, nil } diff --git a/pkg/auth/scope/lightweight.go b/pkg/auth/scope/lightweight.go index 648dacaccb..22add527c3 100644 --- a/pkg/auth/scope/lightweight.go +++ b/pkg/auth/scope/lightweight.go @@ -38,6 +38,8 @@ func lightweightAccountScope(_ context.Context, scope *authpb.Scope, resource in switch v := resource.(type) { case *collaboration.ListReceivedSharesRequest: return true, nil + case *provider.ListStorageSpacesRequest: + return true, nil case string: return checkLightweightPath(v), nil } @@ -56,6 +58,7 @@ func checkLightweightPath(path string) bool { "/ocs/v1.php/cloud/user", "/remote.php/webdav", "/remote.php/dav/files", + "/remote.php/dav/spaces", "/thumbnails", "/app/open", "/app/new", @@ -64,6 +67,7 @@ func checkLightweightPath(path string) bool { "/data", "/app/open", "/projects", + "/graph", } for _, p := range paths { if strings.HasPrefix(path, p) { diff --git a/pkg/auth/scope/ocmshare.go b/pkg/auth/scope/ocmshare.go index 341e2acea8..ba4e39f6d0 100644 --- a/pkg/auth/scope/ocmshare.go +++ b/pkg/auth/scope/ocmshare.go @@ -66,25 +66,25 @@ func ocmShareScope(_ context.Context, scope *authpb.Scope, resource interface{}, // editor role case *provider.CreateContainerRequest: - return hasRoleEditor(*scope) && checkStorageRefForOCMShare(&share, v.GetRef(), ocmNamespace), nil + return hasRoleEditor(scope) && checkStorageRefForOCMShare(&share, v.GetRef(), ocmNamespace), nil case *provider.TouchFileRequest: - return hasRoleEditor(*scope) && checkStorageRefForOCMShare(&share, v.GetRef(), ocmNamespace), nil + return hasRoleEditor(scope) && checkStorageRefForOCMShare(&share, v.GetRef(), ocmNamespace), nil case *provider.DeleteRequest: - return hasRoleEditor(*scope) && checkStorageRefForOCMShare(&share, v.GetRef(), ocmNamespace), nil + return hasRoleEditor(scope) && checkStorageRefForOCMShare(&share, v.GetRef(), ocmNamespace), nil case *provider.MoveRequest: - return hasRoleEditor(*scope) && checkStorageRefForOCMShare(&share, v.GetSource(), ocmNamespace) && checkStorageRefForOCMShare(&share, v.GetDestination(), ocmNamespace), nil + return hasRoleEditor(scope) && checkStorageRefForOCMShare(&share, v.GetSource(), ocmNamespace) && checkStorageRefForOCMShare(&share, v.GetDestination(), ocmNamespace), nil case *provider.InitiateFileUploadRequest: - return hasRoleEditor(*scope) && checkStorageRefForOCMShare(&share, v.GetRef(), ocmNamespace), nil + return hasRoleEditor(scope) && checkStorageRefForOCMShare(&share, v.GetRef(), ocmNamespace), nil case *provider.SetArbitraryMetadataRequest: - return hasRoleEditor(*scope) && checkStorageRefForOCMShare(&share, v.GetRef(), ocmNamespace), nil + return hasRoleEditor(scope) && checkStorageRefForOCMShare(&share, v.GetRef(), ocmNamespace), nil case *provider.UnsetArbitraryMetadataRequest: - return hasRoleEditor(*scope) && checkStorageRefForOCMShare(&share, v.GetRef(), ocmNamespace), nil + return hasRoleEditor(scope) && checkStorageRefForOCMShare(&share, v.GetRef(), ocmNamespace), nil case *provider.SetLockRequest: - return hasRoleEditor(*scope) && checkStorageRefForOCMShare(&share, v.GetRef(), ocmNamespace), nil + return hasRoleEditor(scope) && checkStorageRefForOCMShare(&share, v.GetRef(), ocmNamespace), nil case *provider.RefreshLockRequest: - return hasRoleEditor(*scope) && checkStorageRefForOCMShare(&share, v.GetRef(), ocmNamespace), nil + return hasRoleEditor(scope) && checkStorageRefForOCMShare(&share, v.GetRef(), ocmNamespace), nil case *provider.UnlockRequest: - return hasRoleEditor(*scope) && checkStorageRefForOCMShare(&share, v.GetRef(), ocmNamespace), nil + return hasRoleEditor(scope) && checkStorageRefForOCMShare(&share, v.GetRef(), ocmNamespace), nil // App provider requests case *appregistry.GetDefaultAppProviderForMimeTypeRequest: diff --git a/pkg/auth/scope/publicshare.go b/pkg/auth/scope/publicshare.go index 59a6c4573e..af1662fd0c 100644 --- a/pkg/auth/scope/publicshare.go +++ b/pkg/auth/scope/publicshare.go @@ -62,19 +62,19 @@ func publicshareScope(ctx context.Context, scope *authpb.Scope, resource interfa // Editor role // need to return appropriate status codes in the ocs/ocdav layers. case *provider.CreateContainerRequest: - return hasRoleEditor(*scope) && checkStorageRef(ctx, &share, v.GetRef()), nil + return hasRoleEditor(scope) && checkStorageRef(ctx, &share, v.GetRef()), nil case *provider.TouchFileRequest: - return hasRoleEditor(*scope) && checkStorageRef(ctx, &share, v.GetRef()), nil + return hasRoleEditor(scope) && checkStorageRef(ctx, &share, v.GetRef()), nil case *provider.DeleteRequest: - return hasRoleEditor(*scope) && checkStorageRef(ctx, &share, v.GetRef()), nil + return hasRoleEditor(scope) && checkStorageRef(ctx, &share, v.GetRef()), nil case *provider.MoveRequest: - return hasRoleEditor(*scope) && checkStorageRef(ctx, &share, v.GetSource()) && checkStorageRef(ctx, &share, v.GetDestination()), nil + return hasRoleEditor(scope) && checkStorageRef(ctx, &share, v.GetSource()) && checkStorageRef(ctx, &share, v.GetDestination()), nil case *provider.InitiateFileUploadRequest: - return hasRoleEditor(*scope) && checkStorageRef(ctx, &share, v.GetRef()), nil + return hasRoleEditor(scope) && checkStorageRef(ctx, &share, v.GetRef()), nil case *provider.SetArbitraryMetadataRequest: - return hasRoleEditor(*scope) && checkStorageRef(ctx, &share, v.GetRef()), nil + return hasRoleEditor(scope) && checkStorageRef(ctx, &share, v.GetRef()), nil case *provider.UnsetArbitraryMetadataRequest: - return hasRoleEditor(*scope) && checkStorageRef(ctx, &share, v.GetRef()), nil + return hasRoleEditor(scope) && checkStorageRef(ctx, &share, v.GetRef()), nil // App provider requests case *appregistry.GetDefaultAppProviderForMimeTypeRequest: diff --git a/pkg/auth/scope/resourceinfo.go b/pkg/auth/scope/resourceinfo.go index 4abb377c92..4ae941d007 100644 --- a/pkg/auth/scope/resourceinfo.go +++ b/pkg/auth/scope/resourceinfo.go @@ -59,19 +59,19 @@ func resourceinfoScope(_ context.Context, scope *authpb.Scope, resource interfac // Editor role // need to return appropriate status codes in the ocs/ocdav layers. case *provider.CreateContainerRequest: - return hasRoleEditor(*scope) && checkResourceInfo(&r, v.GetRef()), nil + return hasRoleEditor(scope) && checkResourceInfo(&r, v.GetRef()), nil case *provider.TouchFileRequest: - return hasRoleEditor(*scope) && checkResourceInfo(&r, v.GetRef()), nil + return hasRoleEditor(scope) && checkResourceInfo(&r, v.GetRef()), nil case *provider.DeleteRequest: - return hasRoleEditor(*scope) && checkResourceInfo(&r, v.GetRef()), nil + return hasRoleEditor(scope) && checkResourceInfo(&r, v.GetRef()), nil case *provider.MoveRequest: - return hasRoleEditor(*scope) && checkResourceInfo(&r, v.GetSource()) && checkResourceInfo(&r, v.GetDestination()), nil + return hasRoleEditor(scope) && checkResourceInfo(&r, v.GetSource()) && checkResourceInfo(&r, v.GetDestination()), nil case *provider.InitiateFileUploadRequest: - return hasRoleEditor(*scope) && checkResourceInfo(&r, v.GetRef()), nil + return hasRoleEditor(scope) && checkResourceInfo(&r, v.GetRef()), nil case *provider.SetArbitraryMetadataRequest: - return hasRoleEditor(*scope) && checkResourceInfo(&r, v.GetRef()), nil + return hasRoleEditor(scope) && checkResourceInfo(&r, v.GetRef()), nil case *provider.UnsetArbitraryMetadataRequest: - return hasRoleEditor(*scope) && checkResourceInfo(&r, v.GetRef()), nil + return hasRoleEditor(scope) && checkResourceInfo(&r, v.GetRef()), nil case string: return checkResourcePath(v), nil diff --git a/pkg/auth/scope/scope.go b/pkg/auth/scope/scope.go index 819f6eb0c9..3c490823e8 100644 --- a/pkg/auth/scope/scope.go +++ b/pkg/auth/scope/scope.go @@ -58,6 +58,6 @@ func VerifyScope(ctx context.Context, scopeMap map[string]*authpb.Scope, resourc return false, nil } -func hasRoleEditor(scope authpb.Scope) bool { +func hasRoleEditor(scope *authpb.Scope) bool { return scope.Role == authpb.Role_ROLE_OWNER || scope.Role == authpb.Role_ROLE_EDITOR || scope.Role == authpb.Role_ROLE_UPLOADER } diff --git a/pkg/ocm/share/repository/nextcloud/nextcloud_test.go b/pkg/ocm/share/repository/nextcloud/nextcloud_test.go index 1273dc944f..e6896d40d7 100644 --- a/pkg/ocm/share/repository/nextcloud/nextcloud_test.go +++ b/pkg/ocm/share/repository/nextcloud/nextcloud_test.go @@ -258,7 +258,7 @@ var _ = Describe("Nextcloud", func() { }, }) Expect(err).ToNot(HaveOccurred()) - Expect(*share).To(Equal(ocm.Share{ + Expect(share).To(Equal(&ocm.Share{ Id: &ocm.ShareId{}, ResourceId: &provider.ResourceId{ OpaqueId: "fileid-/some/path", @@ -388,7 +388,7 @@ var _ = Describe("Nextcloud", func() { }) Expect(err).ToNot(HaveOccurred()) Expect(len(shares)).To(Equal(1)) - Expect(*shares[0]).To(Equal(ocm.Share{ + Expect(shares[0]).To(Equal(&ocm.Share{ Id: &ocm.ShareId{}, ResourceId: &provider.ResourceId{ OpaqueId: "fileid-/some/path", @@ -441,7 +441,7 @@ var _ = Describe("Nextcloud", func() { receivedShares, err := am.ListReceivedShares(ctx, user) Expect(err).ToNot(HaveOccurred()) Expect(len(receivedShares)).To(Equal(1)) - Expect(*receivedShares[0]).To(Equal(ocm.ReceivedShare{ + Expect(receivedShares[0]).To(Equal(&ocm.ReceivedShare{ Id: &ocm.ShareId{}, Name: "test share", RemoteShareId: "", @@ -499,7 +499,7 @@ var _ = Describe("Nextcloud", func() { }, }) Expect(err).ToNot(HaveOccurred()) - Expect(*receivedShare).To(Equal(ocm.ReceivedShare{ + Expect(receivedShare).To(Equal(&ocm.ReceivedShare{ Id: &ocm.ShareId{}, Name: "test share", RemoteShareId: "", @@ -588,7 +588,7 @@ var _ = Describe("Nextcloud", func() { Paths: []string{"state"}, }) Expect(err).ToNot(HaveOccurred()) - Expect(*receivedShare).To(Equal(ocm.ReceivedShare{ + Expect(receivedShare).To(Equal(&ocm.ReceivedShare{ Id: &ocm.ShareId{}, Name: "test share", RemoteShareId: "", diff --git a/pkg/projects/manager/loader/loader.go b/pkg/projects/manager/loader/loader.go new file mode 100644 index 0000000000..8b99e5dcf8 --- /dev/null +++ b/pkg/projects/manager/loader/loader.go @@ -0,0 +1,26 @@ +// Copyright 2018-2024 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package loader + +import ( + // Load core spaces backends. + _ "github.com/cs3org/reva/pkg/projects/manager/memory" + _ "github.com/cs3org/reva/pkg/projects/manager/sql" + // Add your own here. +) diff --git a/pkg/projects/manager/memory/memory.go b/pkg/projects/manager/memory/memory.go new file mode 100644 index 0000000000..97e14eae37 --- /dev/null +++ b/pkg/projects/manager/memory/memory.go @@ -0,0 +1,110 @@ +// Copyright 2018-2024 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package memory + +import ( + "context" + "slices" + + "github.com/cs3org/reva/pkg/projects" + "github.com/cs3org/reva/pkg/projects/manager/registry" + "github.com/cs3org/reva/pkg/spaces" + "github.com/cs3org/reva/pkg/utils/cfg" + + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + conversions "github.com/cs3org/reva/internal/http/services/owncloud/ocs/conversions" +) + +func init() { + registry.Register("memory", New) +} + +type SpaceDescription struct { + StorageID string `mapstructure:"storage_id" validate:"required"` + Path string `mapstructure:"path" validate:"required"` + Name string `mapstructure:"name" validate:"required"` + Owner string `mapstructure:"owner" validate:"required"` + Readers string `mapstructure:"readers" validate:"required"` + Writers string `mapstructure:"writers" validate:"required"` + Admins string `mapstructure:"admins" validate:"required"` +} + +type Config struct { + Spaces []SpaceDescription `mapstructure:"spaces"` +} + +type service struct { + c *Config +} + +func New(ctx context.Context, m map[string]any) (projects.Catalogue, error) { + var c Config + if err := cfg.Decode(m, &c); err != nil { + return nil, err + } + return NewWithConfig(ctx, &c) +} + +func NewWithConfig(ctx context.Context, c *Config) (projects.Catalogue, error) { + return &service{c: c}, nil +} + +func (s *service) ListProjects(ctx context.Context, user *userpb.User) ([]*provider.StorageSpace, error) { + projects := []*provider.StorageSpace{} + for _, space := range s.c.Spaces { + if perms, ok := projectBelongToUser(user, &space); ok { + projects = append(projects, &provider.StorageSpace{ + Id: &provider.StorageSpaceId{ + OpaqueId: spaces.EncodeSpaceID(space.StorageID, space.Path), + }, + Owner: &userpb.User{ + Id: &userpb.UserId{ + OpaqueId: space.Owner, + }, + }, + Name: space.Name, + SpaceType: spaces.SpaceTypeProject.AsString(), + RootInfo: &provider.ResourceInfo{ + Path: space.Path, + PermissionSet: perms, + }, + }) + } + } + return projects, nil +} + +func projectBelongToUser(user *userpb.User, project *SpaceDescription) (*provider.ResourcePermissions, bool) { + if user.Id.OpaqueId == project.Owner { + return conversions.NewManagerRole().CS3ResourcePermissions(), true + } + if slices.Contains(user.Groups, project.Admins) { + return conversions.NewManagerRole().CS3ResourcePermissions(), true + } + if slices.Contains(user.Groups, project.Writers) { + return conversions.NewEditorRole().CS3ResourcePermissions(), true + } + if slices.Contains(user.Groups, project.Readers) { + return conversions.NewViewerRole().CS3ResourcePermissions(), true + } + return nil, false +} + +var _ projects.Catalogue = (*service)(nil) diff --git a/pkg/projects/manager/registry/registry.go b/pkg/projects/manager/registry/registry.go new file mode 100644 index 0000000000..b92a3dc60a --- /dev/null +++ b/pkg/projects/manager/registry/registry.go @@ -0,0 +1,38 @@ +// Copyright 2018-2024 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package registry + +import ( + "context" + + "github.com/cs3org/reva/pkg/projects" +) + +// NewFunc is the function that the projects' catalogues implementations +// should register at init time. +type NewFunc func(context.Context, map[string]interface{}) (projects.Catalogue, error) + +// NewFuncs is a map containing all the registered projects' catalogues. +var NewFuncs = map[string]NewFunc{} + +// Register registers a new project catalogue new function. +// Not safe for concurrent use. Safe for use from package init. +func Register(name string, f NewFunc) { + NewFuncs[name] = f +} diff --git a/pkg/projects/manager/sql/init.sql b/pkg/projects/manager/sql/init.sql new file mode 100644 index 0000000000..5ffd35a6b5 --- /dev/null +++ b/pkg/projects/manager/sql/init.sql @@ -0,0 +1,14 @@ +SET ANSI_NULLS ON; +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; +SET QUOTED_IDENTIFIER ON; +SET NOCOUNT ON; + +CREATE TABLE IF NOT EXISTS projects ( + storage_id VARCHAR(255) NOT NULL, + path VARCHAR(1024) NOT NULL, + name VARCHAR(255) NOT NULL PRIMARY KEY, + owner VARCHAR(255) NOT NULL, + readers VARCHAR(255) NOT NULL, + writers VARCHAR(255) NOT NULL, + admins VARCHAR(255) NOT NULL +); diff --git a/pkg/projects/manager/sql/sql.go b/pkg/projects/manager/sql/sql.go new file mode 100644 index 0000000000..94bb49ffd1 --- /dev/null +++ b/pkg/projects/manager/sql/sql.go @@ -0,0 +1,145 @@ +// Copyright 2018-2024 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package sql + +import ( + "context" + "database/sql" + "fmt" + "slices" + + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/internal/http/services/owncloud/ocs/conversions" + "github.com/cs3org/reva/pkg/projects" + "github.com/cs3org/reva/pkg/projects/manager/registry" + "github.com/cs3org/reva/pkg/spaces" + "github.com/cs3org/reva/pkg/utils/cfg" + "github.com/pkg/errors" +) + +func init() { + registry.Register("sql", New) +} + +// Config is the configuration to use for the mysql driver +// implementing the projects.Catalogue interface. +type Config struct { + DBUsername string `mapstructure:"db_username"` + DBPassword string `mapstructure:"db_password"` + DBAddress string `mapstructure:"db_address"` + DBName string `mapstructure:"db_name"` +} + +type mgr struct { + c *Config + db *sql.DB +} + +func New(ctx context.Context, m map[string]any) (projects.Catalogue, error) { + var c Config + if err := cfg.Decode(m, &c); err != nil { + return nil, err + } + return NewFromConfig(ctx, &c) +} + +// Project represents a project in the DB. +type Project struct { + StorageID string + Path string + Name string + Owner string + Readers string + Writers string + Admins string +} + +// NewFromConfig creates a Repository with a SQL driver using the given config. +func NewFromConfig(ctx context.Context, conf *Config) (projects.Catalogue, error) { + db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s)/%s", conf.DBUsername, conf.DBPassword, conf.DBAddress, conf.DBName)) + if err != nil { + return nil, errors.Wrap(err, "sql: error opening connection to mysql database") + } + + m := &mgr{ + c: conf, + db: db, + } + return m, nil +} + +func (m *mgr) ListProjects(ctx context.Context, user *userpb.User) ([]*provider.StorageSpace, error) { + // TODO: for the time being we load everything in memory. We may find a better + // solution in future when the number of projects will grow. + query := "SELECT storage_id, path, name, owner, readers, writers, admins FROM projects" + results, err := m.db.QueryContext(ctx, query) + if err != nil { + return nil, errors.Wrap(err, "error getting projects from db") + } + + var dbProjects []*Project + for results.Next() { + var p Project + if err := results.Scan(&p.StorageID, &p.Path, &p.Name, &p.Owner, &p.Readers, &p.Writers, &p.Admins); err != nil { + return nil, errors.Wrap(err, "error scanning rows from db") + } + dbProjects = append(dbProjects, &p) + } + + projects := []*provider.StorageSpace{} + for _, p := range dbProjects { + if perms, ok := projectBelongToUser(user, p); ok { + projects = append(projects, &provider.StorageSpace{ + Id: &provider.StorageSpaceId{ + OpaqueId: spaces.EncodeSpaceID(p.StorageID, p.Path), + }, + Owner: &userpb.User{ + Id: &userpb.UserId{ + OpaqueId: p.Owner, + }, + }, + Name: p.Name, + SpaceType: spaces.SpaceTypeProject.AsString(), + RootInfo: &provider.ResourceInfo{ + Path: p.Path, + PermissionSet: perms, + }, + }) + } + } + + return projects, nil +} + +func projectBelongToUser(user *userpb.User, p *Project) (*provider.ResourcePermissions, bool) { + if user.Id.OpaqueId == p.Owner { + return conversions.NewManagerRole().CS3ResourcePermissions(), true + } + if slices.Contains(user.Groups, p.Admins) { + return conversions.NewManagerRole().CS3ResourcePermissions(), true + } + if slices.Contains(user.Groups, p.Writers) { + return conversions.NewEditorRole().CS3ResourcePermissions(), true + } + if slices.Contains(user.Groups, p.Readers) { + return conversions.NewViewerRole().CS3ResourcePermissions(), true + } + return nil, false +} diff --git a/pkg/projects/manager/sql/sql_test.go b/pkg/projects/manager/sql/sql_test.go new file mode 100644 index 0000000000..5ecf781f4f --- /dev/null +++ b/pkg/projects/manager/sql/sql_test.go @@ -0,0 +1,335 @@ +// Copyright 2018-2024 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package sql_test + +import ( + "context" + "fmt" + "reflect" + "sync" + "testing" + + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/internal/http/services/owncloud/ocs/conversions" + projects "github.com/cs3org/reva/pkg/projects/manager/sql" + "github.com/cs3org/reva/pkg/spaces" + sqle "github.com/dolthub/go-mysql-server" + "github.com/dolthub/go-mysql-server/memory" + "github.com/dolthub/go-mysql-server/server" + "github.com/dolthub/go-mysql-server/sql" + "github.com/gdexlab/go-render/render" +) + +var ( + dbName = "reva_tests" + address = "localhost" + port = 33059 + m sync.Mutex // for increasing the port + projectsTable = "projects" +) + +func startDatabase(ctx *sql.Context, tables map[string]*memory.Table) (engine *sqle.Engine, p int, cleanup func()) { + m.Lock() + defer m.Unlock() + + db := memory.NewDatabase(dbName) + db.EnablePrimaryKeyIndexes() + for name, table := range tables { + db.AddTable(name, table) + } + + p = port + config := server.Config{ + Protocol: "tcp", + Address: fmt.Sprintf("%s:%d", address, p), + } + port++ + engine = sqle.NewDefault(memory.NewMemoryDBProvider(db)) + s, err := server.NewDefaultServer(config, engine) + if err != nil { + panic(err) + } + + go func() { + if err := s.Start(); err != nil { + panic(err) + } + }() + cleanup = func() { + if err := s.Close(); err != nil { + panic(err) + } + } + return +} + +func must(err error) { + if err != nil { + panic(err) + } +} + +func createProjectsTable(ctx *sql.Context, initData []*projects.Project) map[string]*memory.Table { + tables := make(map[string]*memory.Table) + + // projects table + tableProjects := memory.NewTable(projectsTable, sql.NewPrimaryKeySchema(sql.Schema{ + {Name: "storage_id", Type: sql.Text, Nullable: false, Source: projectsTable}, + {Name: "path", Type: sql.Text, Nullable: false, Source: projectsTable}, + {Name: "name", Type: sql.Text, Nullable: false, Source: projectsTable, PrimaryKey: true}, + {Name: "owner", Type: sql.Text, Nullable: false, Source: projectsTable}, + {Name: "readers", Type: sql.Text, Nullable: false, Source: projectsTable}, + {Name: "writers", Type: sql.Text, Nullable: false, Source: projectsTable}, + {Name: "admins", Type: sql.Text, Nullable: false, Source: projectsTable}, + }), &memory.ForeignKeyCollection{}) + + tables[projectsTable] = tableProjects + + for _, p := range initData { + must(tableProjects.Insert(ctx, sql.NewRow(p.StorageID, p.Path, p.Name, p.Owner, p.Readers, p.Writers, p.Admins))) + } + + return tables +} + +func TestListProjects(t *testing.T) { + tests := []struct { + description string + projects []*projects.Project + user *userpb.User + expected []*provider.StorageSpace + }{ + { + description: "empty list", + projects: []*projects.Project{}, + user: &userpb.User{Id: &userpb.UserId{OpaqueId: "opaque", Idp: "idp"}}, + expected: []*provider.StorageSpace{}, + }, + { + description: "user is owner of the projects", + projects: []*projects.Project{ + { + StorageID: "storage_id", + Path: "/path/to/project", + Name: "project", + Owner: "owner", + Readers: "project-readers", + Writers: "project-writers", + Admins: "project-admins", + }, + }, + user: &userpb.User{Id: &userpb.UserId{OpaqueId: "owner", Idp: "idp"}}, + expected: []*provider.StorageSpace{ + { + Id: &provider.StorageSpaceId{ + OpaqueId: spaces.EncodeSpaceID("storage_id", "/path/to/project"), + }, + Owner: &userpb.User{ + Id: &userpb.UserId{ + OpaqueId: "owner", + }, + }, + Name: "project", + SpaceType: spaces.SpaceTypeProject.AsString(), + RootInfo: &provider.ResourceInfo{ + Path: "/path/to/project", + PermissionSet: conversions.NewManagerRole().CS3ResourcePermissions(), + }, + }, + }, + }, + { + description: "user part of the readers group", + projects: []*projects.Project{ + { + StorageID: "storage_id", + Path: "/path/to/project", + Name: "project", + Owner: "unknown", + Readers: "project-readers", + Writers: "project-writers", + Admins: "project-admins", + }, + }, + user: &userpb.User{Id: &userpb.UserId{OpaqueId: "owner", Idp: "idp"}, Groups: []string{"project-readers"}}, + expected: []*provider.StorageSpace{ + { + Id: &provider.StorageSpaceId{ + OpaqueId: spaces.EncodeSpaceID("storage_id", "/path/to/project"), + }, + Owner: &userpb.User{ + Id: &userpb.UserId{ + OpaqueId: "unknown", + }, + }, + Name: "project", + SpaceType: spaces.SpaceTypeProject.AsString(), + RootInfo: &provider.ResourceInfo{ + Path: "/path/to/project", + PermissionSet: conversions.NewReaderRole().CS3ResourcePermissions(), + }, + }, + }, + }, + { + description: "user part of the writers group", + projects: []*projects.Project{ + { + StorageID: "storage_id", + Path: "/path/to/project", + Name: "project", + Owner: "unknown", + Readers: "project-readers", + Writers: "project-writers", + Admins: "project-admins", + }, + }, + user: &userpb.User{Id: &userpb.UserId{OpaqueId: "owner", Idp: "idp"}, Groups: []string{"project-writers"}}, + expected: []*provider.StorageSpace{ + { + Id: &provider.StorageSpaceId{ + OpaqueId: spaces.EncodeSpaceID("storage_id", "/path/to/project"), + }, + Owner: &userpb.User{ + Id: &userpb.UserId{ + OpaqueId: "unknown", + }, + }, + Name: "project", + SpaceType: spaces.SpaceTypeProject.AsString(), + RootInfo: &provider.ResourceInfo{ + Path: "/path/to/project", + PermissionSet: conversions.NewEditorRole().CS3ResourcePermissions(), + }, + }, + }, + }, + { + description: "user part of the admins group", + projects: []*projects.Project{ + { + StorageID: "storage_id", + Path: "/path/to/project", + Name: "project", + Owner: "unknown", + Readers: "project-readers", + Writers: "project-writers", + Admins: "project-admins", + }, + }, + user: &userpb.User{Id: &userpb.UserId{OpaqueId: "owner", Idp: "idp"}, Groups: []string{"project-admins"}}, + expected: []*provider.StorageSpace{ + { + Id: &provider.StorageSpaceId{ + OpaqueId: spaces.EncodeSpaceID("storage_id", "/path/to/project"), + }, + Owner: &userpb.User{ + Id: &userpb.UserId{ + OpaqueId: "unknown", + }, + }, + Name: "project", + SpaceType: spaces.SpaceTypeProject.AsString(), + RootInfo: &provider.ResourceInfo{ + Path: "/path/to/project", + PermissionSet: conversions.NewManagerRole().CS3ResourcePermissions(), + }, + }, + }, + }, + { + description: "user part of the admins and readers group", + projects: []*projects.Project{ + { + StorageID: "storage_id", + Path: "/path/to/project", + Name: "project", + Owner: "unknown", + Readers: "project-readers", + Writers: "project-writers", + Admins: "project-admins", + }, + }, + user: &userpb.User{Id: &userpb.UserId{OpaqueId: "owner", Idp: "idp"}, Groups: []string{"project-readers", "project-admins"}}, + expected: []*provider.StorageSpace{ + { + Id: &provider.StorageSpaceId{ + OpaqueId: spaces.EncodeSpaceID("storage_id", "/path/to/project"), + }, + Owner: &userpb.User{ + Id: &userpb.UserId{ + OpaqueId: "unknown", + }, + }, + Name: "project", + SpaceType: spaces.SpaceTypeProject.AsString(), + RootInfo: &provider.ResourceInfo{ + Path: "/path/to/project", + PermissionSet: conversions.NewManagerRole().CS3ResourcePermissions(), + }, + }, + }, + }, + { + description: "user is neither the owner nor part of the projects' groups", + projects: []*projects.Project{ + { + StorageID: "storage_id", + Path: "/path/to/project", + Name: "project", + Owner: "unknown", + Readers: "project-readers", + Writers: "project-writers", + Admins: "project-admins", + }, + }, + user: &userpb.User{Id: &userpb.UserId{OpaqueId: "owner", Idp: "idp"}, Groups: []string{"something-readers"}}, + expected: []*provider.StorageSpace{}, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + ctx := sql.NewEmptyContext() + tables := createProjectsTable(ctx, tt.projects) + _, port, cleanup := startDatabase(ctx, tables) + t.Cleanup(cleanup) + + r, err := projects.NewFromConfig(ctx, &projects.Config{ + DBUsername: "root", + DBPassword: "", + DBAddress: fmt.Sprintf("%s:%d", address, port), + DBName: dbName, + }) + if err != nil { + t.Fatalf("not expected error while creating projects driver: %+v", err) + } + + got, err := r.ListProjects(context.TODO(), tt.user) + if err != nil { + t.Fatalf("not expected error while listing projects: %+v", err) + } + + if !reflect.DeepEqual(got, tt.expected) { + t.Fatalf("projects' list do not match. got=%+v expected=%+v", render.AsCode(got), render.AsCode(tt.expected)) + } + }) + } +} diff --git a/pkg/projects/projects.go b/pkg/projects/projects.go new file mode 100644 index 0000000000..db64d0831c --- /dev/null +++ b/pkg/projects/projects.go @@ -0,0 +1,31 @@ +// Copyright 2018-2024 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package projects + +import ( + "context" + + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" +) + +// Catalogue is the interface that stores the project spaces. +type Catalogue interface { + ListProjects(ctx context.Context, user *userpb.User) ([]*provider.StorageSpace, error) +} diff --git a/pkg/publicshare/manager/json/json.go b/pkg/publicshare/manager/json/json.go index 4a92c5bd4c..df7d5ae5f9 100644 --- a/pkg/publicshare/manager/json/json.go +++ b/pkg/publicshare/manager/json/json.go @@ -183,14 +183,14 @@ func (m *manager) CreatePublicShare(ctx context.Context, u *user.User, rInfo *pr } ps := &publicShare{ - PublicShare: s, + PublicShare: &s, Password: password, } m.mutex.Lock() defer m.mutex.Unlock() - encShare, err := utils.MarshalProtoV1ToJSON(&ps.PublicShare) + encShare, err := utils.MarshalProtoV1ToJSON(ps.PublicShare) if err != nil { return nil, err } @@ -364,7 +364,7 @@ func (m *manager) ListPublicShares(ctx context.Context, u *user.User, filters [] for _, v := range db { var local publicShare - if err := utils.UnmarshalJSONToProtoV1([]byte(v.(map[string]interface{})["share"].(string)), &local.PublicShare); err != nil { + if err := utils.UnmarshalJSONToProtoV1([]byte(v.(map[string]interface{})["share"].(string)), local.PublicShare); err != nil { return nil, err } @@ -374,20 +374,20 @@ func (m *manager) ListPublicShares(ctx context.Context, u *user.User, filters [] } if local.PublicShare.PasswordProtected && sign { - if err := publicshare.AddSignature(&local.PublicShare, local.Password); err != nil { + if err := publicshare.AddSignature(local.PublicShare, local.Password); err != nil { return nil, err } } if len(filters) == 0 { - shares = append(shares, &local.PublicShare) + shares = append(shares, local.PublicShare) continue } - if publicshare.MatchesFilters(&local.PublicShare, filters) { - if !publicshare.IsExpired(&local.PublicShare) { - shares = append(shares, &local.PublicShare) - } else if err := m.revokeExpiredPublicShare(ctx, &local.PublicShare, u); err != nil { + if publicshare.MatchesFilters(local.PublicShare, filters) { + if !publicshare.IsExpired(local.PublicShare) { + shares = append(shares, local.PublicShare) + } else if err := m.revokeExpiredPublicShare(ctx, local.PublicShare, u); err != nil { return nil, err } } @@ -584,6 +584,6 @@ func authenticate(share *link.PublicShare, pw string, auth *link.PublicShareAuth } type publicShare struct { - link.PublicShare + *link.PublicShare Password string `json:"password"` } diff --git a/pkg/publicshare/manager/memory/memory.go b/pkg/publicshare/manager/memory/memory.go index 9bde3d0adc..0c9cfee82f 100644 --- a/pkg/publicshare/manager/memory/memory.go +++ b/pkg/publicshare/manager/memory/memory.go @@ -161,7 +161,7 @@ func (m *manager) GetPublicShare(ctx context.Context, u *user.User, ref *link.Pu // Attempt to fetch public share by Id if ref.GetId() != nil { - share, err = m.getPublicShareByTokenID(ctx, *ref.GetId()) + share, err = m.getPublicShareByTokenID(ctx, ref.GetId()) if err != nil { return nil, errors.New("no shares found by id") } @@ -200,7 +200,7 @@ func (m *manager) RevokePublicShare(ctx context.Context, u *user.User, ref *link // check whether the reference exists switch { case ref.GetId() != nil && ref.GetId().OpaqueId != "": - s, err := m.getPublicShareByTokenID(ctx, *ref.GetId()) + s, err := m.getPublicShareByTokenID(ctx, ref.GetId()) if err != nil { return errors.New("reference does not exist") } @@ -232,7 +232,7 @@ func randString(n int) string { return string(b) } -func (m *manager) getPublicShareByTokenID(ctx context.Context, targetID link.PublicShareId) (*link.PublicShare, error) { +func (m *manager) getPublicShareByTokenID(ctx context.Context, targetID *link.PublicShareId) (*link.PublicShare, error) { var found *link.PublicShare m.shares.Range(func(k, v interface{}) bool { id := v.(*link.PublicShare).GetId() diff --git a/pkg/rgrpc/todo/pool/pool.go b/pkg/rgrpc/todo/pool/pool.go index c6bf42c2c9..14f21da7bd 100644 --- a/pkg/rgrpc/todo/pool/pool.go +++ b/pkg/rgrpc/todo/pool/pool.go @@ -74,6 +74,7 @@ var ( appRegistries = newProvider() appProviders = newProvider() storageRegistries = newProvider() + spacesProvider = newProvider() gatewayProviders = newProvider() userProviders = newProvider() groupProviders = newProvider() @@ -421,6 +422,26 @@ func GetStorageRegistryClient(opts ...Option) (storageregistry.RegistryAPIClient return v, nil } +// GetSpacesClient returns a new StorageRegistryClient. +func GetSpacesClient(opts ...Option) (storageprovider.SpacesAPIClient, error) { + spacesProvider.m.Lock() + defer spacesProvider.m.Unlock() + + options := newOptions(opts...) + if c, ok := spacesProvider.conn[options.Endpoint]; ok { + return c.(storageprovider.SpacesAPIClient), nil + } + + conn, err := NewConn(options) + if err != nil { + return nil, err + } + + v := storageprovider.NewSpacesAPIClient(conn) + spacesProvider.conn[options.Endpoint] = v + return v, nil +} + // GetOCMProviderAuthorizerClient returns a new OCMProviderAuthorizerClient. func GetOCMProviderAuthorizerClient(opts ...Option) (ocmprovider.ProviderAPIClient, error) { ocmProviderAuthorizers.m.Lock() @@ -480,19 +501,3 @@ func GetDataTxClient(opts ...Option) (datatx.TxAPIClient, error) { dataTxs.conn[options.Endpoint] = v return v, nil } - -// getEndpointByName resolve service names to ip addresses present on the registry. -// func getEndpointByName(name string) (string, error) { -// if services, err := utils.GlobalRegistry.GetService(name); err == nil { -// if len(services) > 0 { -// for i := range services { -// for j := range services[i].Nodes() { -// // return the first one. This MUST be improved upon with selectors. -// return services[i].Nodes()[j].Address(), nil -// } -// } -// } -// } -// -// return "", fmt.Errorf("could not get service by name: %v", name) -// } diff --git a/pkg/rhttp/rhttp.go b/pkg/rhttp/rhttp.go index ac53b8bffb..773bd9792f 100644 --- a/pkg/rhttp/rhttp.go +++ b/pkg/rhttp/rhttp.go @@ -243,6 +243,9 @@ func (s *Server) getHandlerLongestCommongURL(url string) (http.Handler, string, } func getSubURL(url, prefix string) string { + if url == "" { + return "" + } // pre cond: prefix is a prefix for url // example: url = "/api/v0/", prefix = "/api", res = "/v0" url = cleanURL(url) @@ -264,6 +267,9 @@ func (s *Server) getHandler() (http.Handler, error) { if h, url, ok := s.getHandlerLongestCommongURL(r.URL.Path); ok { s.log.Debug().Msgf("http routing: url=%s", url) r.URL.Path = getSubURL(r.URL.Path, url) + // go chi internally uses the RawPath for the routing + // so this has to be adapted accordingly + r.URL.RawPath = getSubURL(r.URL.RawPath, url) h.ServeHTTP(w, r) return } diff --git a/pkg/spaces/filters.go b/pkg/spaces/filters.go new file mode 100644 index 0000000000..30b59ac3b2 --- /dev/null +++ b/pkg/spaces/filters.go @@ -0,0 +1,82 @@ +// Copyright 2018-2024 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package spaces + +import ( + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + providerpb "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" +) + +type ListStorageSpaceFilter struct { + filters []*providerpb.ListStorageSpacesRequest_Filter +} + +func (f ListStorageSpaceFilter) ByID(id *providerpb.StorageSpaceId) ListStorageSpaceFilter { + f.filters = append(f.filters, &providerpb.ListStorageSpacesRequest_Filter{ + Type: providerpb.ListStorageSpacesRequest_Filter_TYPE_ID, + Term: &providerpb.ListStorageSpacesRequest_Filter_Id{ + Id: id, + }, + }) + return f +} + +func (f ListStorageSpaceFilter) ByOwner(owner *userpb.UserId) ListStorageSpaceFilter { + f.filters = append(f.filters, &providerpb.ListStorageSpacesRequest_Filter{ + Type: providerpb.ListStorageSpacesRequest_Filter_TYPE_OWNER, + Term: &providerpb.ListStorageSpacesRequest_Filter_Owner{ + Owner: owner, + }, + }) + return f +} + +func (f ListStorageSpaceFilter) BySpaceType(spaceType SpaceType) ListStorageSpaceFilter { + f.filters = append(f.filters, &providerpb.ListStorageSpacesRequest_Filter{ + Type: providerpb.ListStorageSpacesRequest_Filter_TYPE_SPACE_TYPE, + Term: &providerpb.ListStorageSpacesRequest_Filter_SpaceType{ + SpaceType: spaceType.AsString(), + }, + }) + return f +} + +func (f ListStorageSpaceFilter) ByPath(path string) ListStorageSpaceFilter { + f.filters = append(f.filters, &providerpb.ListStorageSpacesRequest_Filter{ + Type: providerpb.ListStorageSpacesRequest_Filter_TYPE_PATH, + Term: &providerpb.ListStorageSpacesRequest_Filter_Path{ + Path: path, + }, + }) + return f +} + +func (f ListStorageSpaceFilter) ByUser(user *userpb.UserId) ListStorageSpaceFilter { + f.filters = append(f.filters, &providerpb.ListStorageSpacesRequest_Filter{ + Type: providerpb.ListStorageSpacesRequest_Filter_TYPE_USER, + Term: &providerpb.ListStorageSpacesRequest_Filter_User{ + User: user, + }, + }) + return f +} + +func (f ListStorageSpaceFilter) List() []*providerpb.ListStorageSpacesRequest_Filter { + return f.filters +} diff --git a/pkg/spaces/spaces.go b/pkg/spaces/spaces.go new file mode 100644 index 0000000000..e07c34b286 --- /dev/null +++ b/pkg/spaces/spaces.go @@ -0,0 +1,28 @@ +// Copyright 2018-2024 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package spaces + +type SpaceType string + +const ( + SpaceTypeHome SpaceType = "personal" + SpaceTypeProject SpaceType = "project" +) + +func (t SpaceType) AsString() string { return string(t) } diff --git a/pkg/spaces/utils.go b/pkg/spaces/utils.go new file mode 100644 index 0000000000..7d48c66a0b --- /dev/null +++ b/pkg/spaces/utils.go @@ -0,0 +1,107 @@ +// Copyright 2018-2024 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package spaces + +import ( + "encoding/base32" + "fmt" + "strings" + + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" +) + +// DecodeSpaceID returns the components of the space ID. +// The space ID is expected to be in the format $). +func DecodeSpaceID(raw string) (storageID, path string, ok bool) { + // The input is expected to be in the form of $) + s := strings.SplitN(raw, "$", 2) + if len(s) != 2 { + return + } + + storageID = s[0] + encodedPath := s[1] + p, err := base32.StdEncoding.DecodeString(encodedPath) + if err != nil { + return + } + + path = string(p) + ok = true + return +} + +// Decode resourceID returns the components of the space ID. +// The resource ID is expected to be in the form of $)!. +func DecodeResourceID(raw string) (storageID, path, itemID string, ok bool) { + // The input is expected to be in the form of $)! + s := strings.SplitN(raw, "!", 2) + if len(s) != 2 { + return + } + itemID = s[1] + storageID, path, ok = DecodeSpaceID(s[0]) + return +} + +// ParseResourceID converts the encoded resource id in a CS3API ResourceId. +func ParseResourceID(raw string) (*provider.ResourceId, bool) { + storageID, path, itemID, ok := DecodeResourceID(raw) + if !ok { + return nil, false + } + return &provider.ResourceId{ + StorageId: storageID, + SpaceId: path, + OpaqueId: itemID, + }, true +} + +// EncodeResourceID encodes the provided resource ID as a string, +// in the format $!. +func EncodeResourceID(r *provider.ResourceId) string { + // TODO (gdelmont): these guards are disabled because current testes are failing + // enable them to help debug future programming error + // if r.OpaqueId == "" { + // panic("opaque id cannot be empty") + // } + // if r.SpaceId == "" { + // panic("space id cannot be empty") + // } + // if r.StorageId == "" { + // panic("storage id cannot be empty") + // } + spaceID := EncodeSpaceID(r.StorageId, r.SpaceId) + return fmt.Sprintf("%s!%s", spaceID, r.OpaqueId) +} + +// EncodeSpaceID encodes storage ID and path to create a space ID, +// in the format $). +func EncodeSpaceID(storageID, path string) string { + // TODO (gdelmont): these guards are disabled because current testes are failing + // enable them to help debug future programming error + // if storageID == "" { + // panic("storage id cannot be empty") + // } + // if path == "" { + // panic("path cannot be empty") + // } + encodedPath := base32.StdEncoding.EncodeToString([]byte(path)) + return fmt.Sprintf("%s$%s", storageID, encodedPath) +} diff --git a/pkg/storage/fs/nextcloud/nextcloud_test.go b/pkg/storage/fs/nextcloud/nextcloud_test.go index 8d56566ace..0908c8f639 100644 --- a/pkg/storage/fs/nextcloud/nextcloud_test.go +++ b/pkg/storage/fs/nextcloud/nextcloud_test.go @@ -213,7 +213,7 @@ var _ = Describe("Nextcloud", func() { mdKeys := []string{"val1", "val2", "val3"} result, err := nc.GetMD(ctx, ref, mdKeys) Expect(err).ToNot(HaveOccurred()) - Expect(*result).To(Equal(provider.ResourceInfo{ + Expect(result).To(Equal(&provider.ResourceInfo{ Opaque: nil, Type: provider.ResourceType_RESOURCE_TYPE_FILE, Id: &provider.ResourceId{ @@ -263,7 +263,7 @@ var _ = Describe("Nextcloud", func() { results, err := nc.ListFolder(ctx, ref, mdKeys) Expect(err).NotTo(HaveOccurred()) Expect(len(results)).To(Equal(1)) - Expect(*results[0]).To(Equal(provider.ResourceInfo{ + Expect(results[0]).To(Equal(&provider.ResourceInfo{ Opaque: nil, Type: provider.ResourceType_RESOURCE_TYPE_FILE, Id: &provider.ResourceId{ @@ -387,7 +387,7 @@ var _ = Describe("Nextcloud", func() { Expect(err).ToNot(HaveOccurred()) // https://github.com/cs3org/go-cs3apis/blob/970eec3/cs3/storage/provider/v1beta1/resources.pb.go#L1003-L1023 Expect(len(results)).To(Equal(2)) - Expect(*results[0]).To(Equal(provider.FileVersion{ + Expect(results[0]).To(Equal(&provider.FileVersion{ Opaque: &types.Opaque{ Map: map[string]*types.OpaqueEntry{ "some": { @@ -400,7 +400,7 @@ var _ = Describe("Nextcloud", func() { Mtime: uint64(1234567890), Etag: "deadb00f", })) - Expect(*results[1]).To(Equal(provider.FileVersion{ + Expect(results[1]).To(Equal(&provider.FileVersion{ Opaque: &types.Opaque{ Map: map[string]*types.OpaqueEntry{ "different": { @@ -471,7 +471,7 @@ var _ = Describe("Nextcloud", func() { Expect(err).ToNot(HaveOccurred()) // https://github.com/cs3org/go-cs3apis/blob/970eec3/cs3/storage/provider/v1beta1/resources.pb.go#L1085-L1110 Expect(len(results)).To(Equal(1)) - Expect(*results[0]).To(Equal(provider.RecycleItem{ + Expect(results[0]).To(Equal(&provider.RecycleItem{ Opaque: &types.Opaque{}, Key: "some-deleted-version", Ref: &provider.Reference{ diff --git a/pkg/storage/utils/eosfs/eosfs.go b/pkg/storage/utils/eosfs/eosfs.go index d231224756..6b818672e8 100644 --- a/pkg/storage/utils/eosfs/eosfs.go +++ b/pkg/storage/utils/eosfs/eosfs.go @@ -2239,7 +2239,7 @@ func mergePermissions(l *provider.ResourcePermissions, r *provider.ResourcePermi } func (fs *eosfs) convert(ctx context.Context, eosFileInfo *eosclient.FileInfo) (*provider.ResourceInfo, error) { - path, err := fs.unwrap(ctx, eosFileInfo.File) + p, err := fs.unwrap(ctx, eosFileInfo.File) if err != nil { return nil, err } @@ -2278,10 +2278,11 @@ func (fs *eosfs) convert(ctx context.Context, eosFileInfo *eosclient.FileInfo) ( info := &provider.ResourceInfo{ Id: &provider.ResourceId{OpaqueId: fmt.Sprintf("%d", eosFileInfo.Inode)}, - Path: path, + Path: p, + Name: path.Base(p), Owner: owner, Etag: fmt.Sprintf("\"%s\"", strings.Trim(eosFileInfo.ETag, "\"")), - MimeType: mime.Detect(eosFileInfo.IsDir, path), + MimeType: mime.Detect(eosFileInfo.IsDir, p), Size: size, ParentId: &provider.ResourceId{OpaqueId: fmt.Sprintf("%d", eosFileInfo.FID)}, PermissionSet: fs.permissionSet(ctx, eosFileInfo, owner), diff --git a/pkg/storage/utils/grants/grants.go b/pkg/storage/utils/grants/grants.go index b4aee7fb41..5e72090b4c 100644 --- a/pkg/storage/utils/grants/grants.go +++ b/pkg/storage/utils/grants/grants.go @@ -27,6 +27,8 @@ import ( "google.golang.org/protobuf/proto" ) +var noPermissions = &provider.ResourcePermissions{} + // GetACLPerm generates a string representation of CS3APIs' ResourcePermissions, // modeled after the EOS ACLs. // TODO(labkode): fine grained permission controls. diff --git a/pkg/utils/list/list.go b/pkg/utils/list/list.go index 4b370d3f8a..e4e721c7d0 100644 --- a/pkg/utils/list/list.go +++ b/pkg/utils/list/list.go @@ -56,3 +56,15 @@ func ToMap[K comparable, T any](l []T, k func(T) K) map[K]T { } return m } + +// Filter returns a list having the elements from l that +// satisfy the predicate f. +func Filter[T any](l []T, f func(T) bool) []T { + r := make([]T, 0) + for _, e := range l { + if f(e) { + r = append(r, e) + } + } + return r +} diff --git a/tests/integration/grpc/ocm_share_test.go b/tests/integration/grpc/ocm_share_test.go index a560528179..fdf6056e74 100644 --- a/tests/integration/grpc/ocm_share_test.go +++ b/tests/integration/grpc/ocm_share_test.go @@ -724,7 +724,8 @@ func ocmPath(id *ocmv1beta1.ShareId, p string) string { } func checkResourceInfo(info, target *provider.ResourceInfo) { - Expect(info.Id).To(Equal(target.Id)) + Expect(info.Id.OpaqueId).To(Equal(target.Id.OpaqueId)) + Expect(info.Id.StorageId).To(Equal(target.Id.StorageId)) Expect(info.Name).To(Equal(target.Name)) Expect(info.Path).To(Equal(target.Path)) Expect(info.Size).To(Equal(target.Size))