Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions changelog/unreleased/feat-per-provider-capabilities.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions internal/grpc/services/storageprovider/storageprovider.go
Original file line number Diff line number Diff line change
Expand Up @@ -597,13 +597,18 @@ 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")
continue
}

s.addMissingStorageProviderID(sp.GetRoot(), sp.GetId())
sp.Opaque = utils.AppendJSONToOpaque(sp.Opaque, storage.CapabilitiesOpaqueKey, caps)
}

return &provider.ListStorageSpacesResponse{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,15 @@ type Handler struct {
c ocs.CapabilitiesData
defaultUploadProtocol string
userAgentChunkingMap map[string]string
gatewayAddr string
}

// Init initializes this and any contained handlers
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 {
Expand Down Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
81 changes: 73 additions & 8 deletions pkg/owncloud/ocs/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <providers><provider id="...">...</provider>...</providers>,
// 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
Expand Down Expand Up @@ -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[<id>].trash.
Undelete ocsBool `json:"undelete" xml:"undelete"`
// Deprecated: use capabilities.providers[<id>].versioning.
Versioning ocsBool `json:"versioning" xml:"versioning"`
// Deprecated: use capabilities.providers[<id>].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"`
Expand All @@ -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[<id>].trash.
Trashbin string `json:"trashbin" xml:"trashbin"`
Reports []string `json:"reports" xml:"reports>element" mapstructure:"reports"`
ChunkingParallelUploadDisabled bool `json:"chunkingParallelUploadDisabled" xml:"chunkingParallelUploadDisabled"`
Expand Down
113 changes: 113 additions & 0 deletions pkg/owncloud/ocs/capabilities_test.go
Original file line number Diff line number Diff line change
@@ -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 := "<upload>1</upload>"; !strings.Contains(out, want) {
t.Errorf("xml %q missing %q", out, want)
}
if want := "<trash>0</trash>"; !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{
`<providers>`,
`<provider id="decomposedfs">`,
`<provider id="kiteworks">`,
`<upload>1</upload>`,
`<upload>0</upload>`,
} {
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), "<providers>") {
t.Errorf("empty providers should be omitted, got %q", string(b))
}
}
7 changes: 7 additions & 0 deletions pkg/storage/fs/kiteworks/kiteworks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
8 changes: 8 additions & 0 deletions pkg/storage/fs/kiteworks/kiteworks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}))
})
})
})
Loading