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