diff --git a/changelog/unreleased/feat-per-provider-capabilities.md b/changelog/unreleased/feat-per-provider-capabilities.md new file mode 100644 index 00000000000..b44e500a666 --- /dev/null +++ b/changelog/unreleased/feat-per-provider-capabilities.md @@ -0,0 +1,11 @@ +Enhancement: Declare storage capabilities per provider + +Added a `providers` section to the `/ocs/v1.php/cloud/capabilities` response, +keyed by provider id, reporting the write actions each storage provider +supports. Drivers declare their capabilities through the optional +`storage.CapabilityProvider` interface and default to the full set when they do +not implement it. The global storage-capability keys (`files.undelete`, +`files.versioning`, `files.favorites` and `dav.trashbin`) are deprecated in +favor of the per-provider section. + +https://github.com/owncloud/reva/pull/722 diff --git a/internal/grpc/services/storageprovider/storageprovider.go b/internal/grpc/services/storageprovider/storageprovider.go index b23604a275f..ce0196fe0d5 100644 --- a/internal/grpc/services/storageprovider/storageprovider.go +++ b/internal/grpc/services/storageprovider/storageprovider.go @@ -597,6 +597,10 @@ func (s *Service) ListStorageSpaces(ctx context.Context, req *provider.ListStora }, nil } + caps := storage.FullCapabilities() + if cp, ok := s.Storage.(storage.CapabilityProvider); ok { + caps = cp.Capabilities(ctx) + } for _, sp := range spaces { if sp.Id == nil || sp.Id.OpaqueId == "" { log.Error().Str("service", "storageprovider").Str("driver", s.conf.Driver).Interface("space", sp).Msg("space is missing space id and root id") @@ -604,6 +608,7 @@ func (s *Service) ListStorageSpaces(ctx context.Context, req *provider.ListStora } s.addMissingStorageProviderID(sp.GetRoot(), sp.GetId()) + sp.Opaque = utils.AppendJSONToOpaque(sp.Opaque, storage.CapabilitiesOpaqueKey, caps) } return &provider.ListStorageSpacesResponse{ diff --git a/internal/http/services/owncloud/ocs/handlers/cloud/capabilities/capabilities.go b/internal/http/services/owncloud/ocs/handlers/cloud/capabilities/capabilities.go index f9449aafd26..d2b4f786e66 100644 --- a/internal/http/services/owncloud/ocs/handlers/cloud/capabilities/capabilities.go +++ b/internal/http/services/owncloud/ocs/handlers/cloud/capabilities/capabilities.go @@ -32,6 +32,7 @@ type Handler struct { c ocs.CapabilitiesData defaultUploadProtocol string userAgentChunkingMap map[string]string + gatewayAddr string } // Init initializes this and any contained handlers @@ -39,6 +40,7 @@ func (h *Handler) Init(c *config.Config) { h.c = c.Capabilities h.defaultUploadProtocol = c.DefaultUploadProtocol h.userAgentChunkingMap = c.UserAgentChunkingMap + h.gatewayAddr = c.GatewaySvc // capabilities if h.c.Capabilities == nil { @@ -237,6 +239,11 @@ func (h *Handler) GetCapabilities(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("vault") == "true" && c.Capabilities != nil && c.Capabilities.Vault != nil && bool(c.Capabilities.Vault.Enabled) { c = h.vaultCapabilities(c) } + if providers := h.resolveProviders(r.Context()); len(providers) > 0 && c.Capabilities != nil { + caps := *c.Capabilities + caps.Providers = providers + c.Capabilities = &caps + } response.WriteOCSSuccess(w, r, c) } diff --git a/internal/http/services/owncloud/ocs/handlers/cloud/capabilities/providers.go b/internal/http/services/owncloud/ocs/handlers/cloud/capabilities/providers.go new file mode 100644 index 00000000000..0b656608e22 --- /dev/null +++ b/internal/http/services/owncloud/ocs/handlers/cloud/capabilities/providers.go @@ -0,0 +1,53 @@ +package capabilities + +import ( + "context" + + rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + + "github.com/owncloud/reva/v2/pkg/appctx" + "github.com/owncloud/reva/v2/pkg/owncloud/ocs" + "github.com/owncloud/reva/v2/pkg/rgrpc/todo/pool" + "github.com/owncloud/reva/v2/pkg/storage" + "github.com/owncloud/reva/v2/pkg/utils" +) + +// resolveProviders builds the per-provider capability map from the spaces visible +// to the current user, keyed by provider ID. Returns nil on no user context or an +// unreachable gateway, so the response omits the section rather than failing. +func (h *Handler) resolveProviders(ctx context.Context) map[string]*ocs.ProviderCapabilities { + log := appctx.GetLogger(ctx) + + gc, err := pool.GetGatewayServiceClient(h.gatewayAddr) + if err != nil { + log.Error().Err(err).Msg("capabilities: error getting gateway client") + return nil + } + + res, err := gc.ListStorageSpaces(ctx, &provider.ListStorageSpacesRequest{}) + if err != nil { + log.Error().Err(err).Msg("capabilities: error listing storage spaces") + return nil + } + if res.GetStatus().GetCode() != rpc.Code_CODE_OK { + return nil + } + + providers := map[string]*ocs.ProviderCapabilities{} + for _, sp := range res.GetStorageSpaces() { + id := sp.GetRoot().GetStorageId() + if id == "" { + continue + } + if _, done := providers[id]; done { + continue + } + var caps storage.Capabilities + if err := utils.ReadJSONFromOpaque(sp.GetOpaque(), storage.CapabilitiesOpaqueKey, &caps); err != nil { + continue + } + providers[id] = ocs.NewProviderCapabilities(caps) + } + return providers +} diff --git a/pkg/owncloud/ocs/capabilities.go b/pkg/owncloud/ocs/capabilities.go index bac1d732c22..46a10700756 100644 --- a/pkg/owncloud/ocs/capabilities.go +++ b/pkg/owncloud/ocs/capabilities.go @@ -20,6 +20,10 @@ package ocs import ( "encoding/xml" + "maps" + "slices" + + "github.com/owncloud/reva/v2/pkg/storage" ) // ocsBool implements the xml/json Marshaler interface. The OCS API inconsistency require us to parse boolean values @@ -63,6 +67,63 @@ type Capabilities struct { Notifications *CapabilitiesNotifications `json:"notifications,omitempty" xml:"notifications,omitempty"` Auth *CapabilitiesAuth `json:"auth,omitempty" xml:"auth,omitempty"` Vault *CapabilitiesVault `json:"vault,omitempty" xml:"vault,omitempty" mapstructure:"vault"` + // Providers is the per-provider capability section, keyed by provider ID. It + // supersedes the deprecated global storage keys under Files/Dav; there is no + // fallback between them. + Providers ProviderCapabilitiesMap `json:"providers,omitempty" xml:"providers,omitempty" mapstructure:"providers"` +} + +// ProviderCapabilitiesMap is the per-provider section keyed by provider ID. +type ProviderCapabilitiesMap map[string]*ProviderCapabilities + +// MarshalXML renders the map as ......, +// ordered by id. encoding/xml cannot marshal a Go map, so XML output is produced +// explicitly; JSON uses the default map encoding. +func (m ProviderCapabilitiesMap) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + if len(m) == 0 { + return nil + } + if err := e.EncodeToken(start); err != nil { + return err + } + for _, id := range slices.Sorted(maps.Keys(m)) { + el := xml.StartElement{ + Name: xml.Name{Local: "provider"}, + Attr: []xml.Attr{{Name: xml.Name{Local: "id"}, Value: id}}, + } + if err := e.EncodeElement(m[id], el); err != nil { + return err + } + } + return e.EncodeToken(start.End()) +} + +// ProviderCapabilities is the wire form of a provider's declared capabilities. +type ProviderCapabilities struct { + Upload ocsBool `json:"upload" xml:"upload"` + CreateContainer ocsBool `json:"create_container" xml:"create_container"` + Delete ocsBool `json:"delete" xml:"delete"` + Move ocsBool `json:"move" xml:"move"` + Versioning ocsBool `json:"versioning" xml:"versioning"` + Trash ocsBool `json:"trash" xml:"trash"` + Locking ocsBool `json:"locking" xml:"locking"` + Sharing ocsBool `json:"sharing" xml:"sharing"` + ArbitraryMetadata ocsBool `json:"arbitrary_metadata" xml:"arbitrary_metadata"` +} + +// NewProviderCapabilities maps a driver's declaration onto the OCS response type. +func NewProviderCapabilities(c storage.Capabilities) *ProviderCapabilities { + return &ProviderCapabilities{ + Upload: ocsBool(c.Upload), + CreateContainer: ocsBool(c.CreateContainer), + Delete: ocsBool(c.Delete), + Move: ocsBool(c.Move), + Versioning: ocsBool(c.Versioning), + Trash: ocsBool(c.Trash), + Locking: ocsBool(c.Locking), + Sharing: ocsBool(c.Sharing), + ArbitraryMetadata: ocsBool(c.ArbitraryMetadata), + } } // CapabilitiesSearch holds the search capabilities @@ -207,13 +268,16 @@ type CapabilitiesAppProvider struct { // CapabilitiesFiles TODO this is storage specific, not global. What effect do these options have on the clients? type CapabilitiesFiles struct { - PrivateLinks ocsBool `json:"privateLinks" xml:"privateLinks" mapstructure:"private_links"` - BigFileChunking ocsBool `json:"bigfilechunking" xml:"bigfilechunking"` - Undelete ocsBool `json:"undelete" xml:"undelete"` - Versioning ocsBool `json:"versioning" xml:"versioning"` - Favorites ocsBool `json:"favorites" xml:"favorites"` - FullTextSearch ocsBool `json:"full_text_search" xml:"full_text_search" mapstructure:"full_text_search"` - Tags ocsBool `json:"tags" xml:"tags"` + PrivateLinks ocsBool `json:"privateLinks" xml:"privateLinks" mapstructure:"private_links"` + BigFileChunking ocsBool `json:"bigfilechunking" xml:"bigfilechunking"` + // Deprecated: use capabilities.providers[].trash. + Undelete ocsBool `json:"undelete" xml:"undelete"` + // Deprecated: use capabilities.providers[].versioning. + Versioning ocsBool `json:"versioning" xml:"versioning"` + // Deprecated: use capabilities.providers[].arbitrary_metadata. + Favorites ocsBool `json:"favorites" xml:"favorites"` + FullTextSearch ocsBool `json:"full_text_search" xml:"full_text_search" mapstructure:"full_text_search"` + Tags ocsBool `json:"tags" xml:"tags"` BlacklistedFiles []string `json:"blacklisted_files" xml:"blacklisted_files>element" mapstructure:"blacklisted_files"` TusSupport *CapabilitiesFilesTusSupport `json:"tus_support" xml:"tus_support" mapstructure:"tus_support"` Archivers []*CapabilitiesArchiver `json:"archivers" xml:"archivers" mapstructure:"archivers"` @@ -222,7 +286,8 @@ type CapabilitiesFiles struct { // CapabilitiesDav holds dav endpoint config type CapabilitiesDav struct { - Chunking string `json:"chunking" xml:"chunking"` + Chunking string `json:"chunking" xml:"chunking"` + // Deprecated: use capabilities.providers[].trash. Trashbin string `json:"trashbin" xml:"trashbin"` Reports []string `json:"reports" xml:"reports>element" mapstructure:"reports"` ChunkingParallelUploadDisabled bool `json:"chunkingParallelUploadDisabled" xml:"chunkingParallelUploadDisabled"` diff --git a/pkg/owncloud/ocs/capabilities_test.go b/pkg/owncloud/ocs/capabilities_test.go new file mode 100644 index 00000000000..2ec8a56b1a1 --- /dev/null +++ b/pkg/owncloud/ocs/capabilities_test.go @@ -0,0 +1,113 @@ +package ocs + +import ( + "encoding/json" + "encoding/xml" + "strings" + "testing" + + "github.com/owncloud/reva/v2/pkg/storage" +) + +func TestNewProviderCapabilitiesReadOnly(t *testing.T) { + pc := NewProviderCapabilities(storage.Capabilities{}) + + b, err := json.Marshal(pc) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got map[string]bool + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for k, v := range got { + if v { + t.Errorf("absent capability %q marshalled to true, want false", k) + } + } + if _, ok := got["trash"]; !ok { + t.Error("expected a trash key in the per-provider section") + } +} + +func TestNewProviderCapabilitiesFullSet(t *testing.T) { + pc := NewProviderCapabilities(storage.FullCapabilities()) + + b, err := json.Marshal(pc) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got map[string]bool + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for k, v := range got { + if !v { + t.Errorf("capability %q marshalled to false, want true", k) + } + } +} + +// TestProviderCapabilitiesXMLParity guards the ocsBool 1/0 XML rendering the OCS +// API requires, so the per-provider section stays consistent across formats. +func TestProviderCapabilitiesXMLParity(t *testing.T) { + pc := NewProviderCapabilities(storage.Capabilities{Upload: true}) + + b, err := xml.Marshal(pc) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + out := string(b) + if want := "1"; !strings.Contains(out, want) { + t.Errorf("xml %q missing %q", out, want) + } + if want := "0"; !strings.Contains(out, want) { + t.Errorf("xml %q missing %q", out, want) + } +} + +// TestCapabilitiesProvidersXML guards the whole Capabilities envelope: XML is the +// default OCS format and encoding/xml cannot marshal a Go map, so a populated +// providers section must still marshal via the custom ProviderCapabilitiesMap. +func TestCapabilitiesProvidersXML(t *testing.T) { + c := Capabilities{ + Providers: ProviderCapabilitiesMap{ + "kiteworks": NewProviderCapabilities(storage.Capabilities{}), + "decomposedfs": NewProviderCapabilities(storage.FullCapabilities()), + }, + } + + b, err := xml.Marshal(c) + if err != nil { + t.Fatalf("marshal populated capabilities: %v", err) + } + + out := string(b) + // ordered by id, so decomposedfs comes before kiteworks + for _, want := range []string{ + ``, + ``, + ``, + `1`, + `0`, + } { + if !strings.Contains(out, want) { + t.Errorf("xml %q missing %q", out, want) + } + } +} + +// TestCapabilitiesProvidersXMLEmpty verifies omitempty still drops the section +// when no provider resolved, leaving the rest of the envelope untouched. +func TestCapabilitiesProvidersXMLEmpty(t *testing.T) { + b, err := xml.Marshal(Capabilities{}) + if err != nil { + t.Fatalf("marshal empty capabilities: %v", err) + } + if strings.Contains(string(b), "") { + t.Errorf("empty providers should be omitted, got %q", string(b)) + } +} diff --git a/pkg/storage/fs/kiteworks/kiteworks.go b/pkg/storage/fs/kiteworks/kiteworks.go index 6f7f00edc93..1c844e26a16 100644 --- a/pkg/storage/fs/kiteworks/kiteworks.go +++ b/pkg/storage/fs/kiteworks/kiteworks.go @@ -94,6 +94,13 @@ func (d *Driver) toResourceInfo(fi *kwlib.FileInfo, spaceID string) *provider.Re return ri } +// Capabilities declares kiteworks read-only: every write method rejects with +// NotSupported. The zero value is the declaration, so any capability added later +// stays false here without an edit. +func (d *Driver) Capabilities(_ context.Context) storage.Capabilities { + return storage.Capabilities{} +} + // --- Read methods --- func (d *Driver) Shutdown(_ context.Context) error { return nil } diff --git a/pkg/storage/fs/kiteworks/kiteworks_test.go b/pkg/storage/fs/kiteworks/kiteworks_test.go index a451ac31ce0..61902b6e70c 100644 --- a/pkg/storage/fs/kiteworks/kiteworks_test.go +++ b/pkg/storage/fs/kiteworks/kiteworks_test.go @@ -239,4 +239,12 @@ var _ = Describe("kiteworks driver", func() { Expect(err).To(Satisfy(notSupported)) }) }) + + Context("capabilities", func() { + It("declares an all-false (read-only) set", func() { + cp, ok := d.(storage.CapabilityProvider) + Expect(ok).To(BeTrue()) + Expect(cp.Capabilities(fix.ctx)).To(Equal(storage.Capabilities{})) + }) + }) }) diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index 0716672f79f..ca405033df0 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -277,6 +277,46 @@ type ComposableFS interface { UseIn(composer *tusd.StoreComposer) } +// CapabilitiesOpaqueKey is the StorageSpace/ProviderInfo Opaque key under which +// a provider's Capabilities are carried as JSON up to the OCS capabilities handler. +const CapabilitiesOpaqueKey = "storage-capabilities" + +// Capabilities is a storage driver's self-declaration of the write-shaped +// actions it supports. A false key means unsupported: no merge, no inheritance. +type Capabilities struct { + Upload bool + CreateContainer bool + Delete bool + Move bool + Versioning bool + Trash bool + Locking bool + Sharing bool + ArbitraryMetadata bool +} + +// FullCapabilities is the default for drivers that do not implement +// CapabilityProvider, so existing drivers keep their current behavior. +func FullCapabilities() Capabilities { + return Capabilities{ + Upload: true, + CreateContainer: true, + Delete: true, + Move: true, + Versioning: true, + Trash: true, + Locking: true, + Sharing: true, + ArbitraryMetadata: true, + } +} + +// CapabilityProvider is an optional interface a storage.FS may implement to +// declare its capabilities. +type CapabilityProvider interface { + Capabilities(ctx context.Context) Capabilities +} + // Registry is the interface that storage registries implement // for discovering storage providers type Registry interface { diff --git a/pkg/storage/utils/decomposedfs/decomposedfs.go b/pkg/storage/utils/decomposedfs/decomposedfs.go index eab994b780c..301067d245a 100644 --- a/pkg/storage/utils/decomposedfs/decomposedfs.go +++ b/pkg/storage/utils/decomposedfs/decomposedfs.go @@ -625,6 +625,12 @@ func (fs *Decomposedfs) Shutdown(ctx context.Context) error { return nil } +// Capabilities declares decomposedfs fully writeable, stated explicitly so it is +// a decision here and not an inherited default. +func (fs *Decomposedfs) Capabilities(_ context.Context) storage.Capabilities { + return storage.FullCapabilities() +} + // GetQuota returns the quota available // TODO Document in the cs3 should we return quota or free space? func (fs *Decomposedfs) GetQuota(ctx context.Context, ref *provider.Reference) (total uint64, inUse uint64, remaining uint64, err error) {