diff --git a/services/graph/README.md b/services/graph/README.md index 20507b7c98..9eb1e0859a 100644 --- a/services/graph/README.md +++ b/services/graph/README.md @@ -191,6 +191,10 @@ To specialize `graph` service instances in order to scale them independently, it * `GRAPH_HTTP_DISABLE`: when set to `true`, the service does not listen on HTTP and only consumes events (defaults to `false`) * `GRAPH_EVENTS_DISABLE_CONSUMER`: when set to `true`, the service does not consome events and only listens on HTTP (defaults to `false`) +## Download URLs + +`GET /drives/{drive-id}/items/{item-id}/content` and the `@microsoft.graph.downloadUrl` annotation (requested via `$select`) hand out WebDAV URLs signed with `OC_URL_SIGNING_SECRET`. The proxy verifies the signature, so the URLs work without an `Authorization` header. They expire after 30 minutes. Without the secret the annotation is omitted and the `content` endpoint answers with an error. + ## Metrics Metrics are disabled by default, and must be enabled using the following environment variables: diff --git a/services/graph/mocks/base_graph_provider.go b/services/graph/mocks/base_graph_provider.go index b74756bb30..45fb47971c 100644 --- a/services/graph/mocks/base_graph_provider.go +++ b/services/graph/mocks/base_graph_provider.go @@ -6,6 +6,7 @@ package mocks import ( "context" + "net/http" "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1" "github.com/cs3org/go-cs3apis/cs3/sharing/ocm/v1beta1" @@ -175,3 +176,49 @@ func (_c *BaseGraphProvider_CS3ReceivedSharesToDriveItems_Call) RunAndReturn(run _c.Call.Return(run) return _c } + +// SetDriveItemsDownloadURL provides a mock function for the type BaseGraphProvider +func (_mock *BaseGraphProvider) SetDriveItemsDownloadURL(r *http.Request, items []libregraph.DriveItem) { + _mock.Called(r, items) + return +} + +// BaseGraphProvider_SetDriveItemsDownloadURL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetDriveItemsDownloadURL' +type BaseGraphProvider_SetDriveItemsDownloadURL_Call struct { + *mock.Call +} + +// SetDriveItemsDownloadURL is a helper method to define mock.On call +// - r *http.Request +// - items []libregraph.DriveItem +func (_e *BaseGraphProvider_Expecter) SetDriveItemsDownloadURL(r interface{}, items interface{}) *BaseGraphProvider_SetDriveItemsDownloadURL_Call { + return &BaseGraphProvider_SetDriveItemsDownloadURL_Call{Call: _e.mock.On("SetDriveItemsDownloadURL", r, items)} +} + +func (_c *BaseGraphProvider_SetDriveItemsDownloadURL_Call) Run(run func(r *http.Request, items []libregraph.DriveItem)) *BaseGraphProvider_SetDriveItemsDownloadURL_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 *http.Request + if args[0] != nil { + arg0 = args[0].(*http.Request) + } + var arg1 []libregraph.DriveItem + if args[1] != nil { + arg1 = args[1].([]libregraph.DriveItem) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *BaseGraphProvider_SetDriveItemsDownloadURL_Call) Return() *BaseGraphProvider_SetDriveItemsDownloadURL_Call { + _c.Call.Return() + return _c +} + +func (_c *BaseGraphProvider_SetDriveItemsDownloadURL_Call) RunAndReturn(run func(r *http.Request, items []libregraph.DriveItem)) *BaseGraphProvider_SetDriveItemsDownloadURL_Call { + _c.Run(run) + return _c +} diff --git a/services/graph/pkg/service/v0/api_driveitem_permissions.go b/services/graph/pkg/service/v0/api_driveitem_permissions.go index 63d9ec49af..05b838be56 100644 --- a/services/graph/pkg/service/v0/api_driveitem_permissions.go +++ b/services/graph/pkg/service/v0/api_driveitem_permissions.go @@ -404,7 +404,7 @@ func (s DriveItemPermissionsService) ListPermissions(ctx context.Context, itemID driveItems := make(driveItemsByResourceID, 1) // we can use the statResponse to build the drive item before fetching the shares - item, err := cs3ResourceToDriveItem(s.logger, s.publicBaseURL, statResponse.GetInfo()) + item, err := s.cs3ResourceToDriveItem(statResponse.GetInfo()) if err != nil { return collectionOfPermissions, err } diff --git a/services/graph/pkg/service/v0/api_driveitem_permissions_links.go b/services/graph/pkg/service/v0/api_driveitem_permissions_links.go index 817cbc9515..1255844e2a 100644 --- a/services/graph/pkg/service/v0/api_driveitem_permissions_links.go +++ b/services/graph/pkg/service/v0/api_driveitem_permissions_links.go @@ -13,11 +13,11 @@ import ( types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" "github.com/go-chi/chi/v5" "github.com/go-chi/render" + libregraph "github.com/opencloud-eu/libre-graph-api-go" "github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode" "github.com/opencloud-eu/opencloud/services/graph/pkg/linktype" "github.com/opencloud-eu/reva/v2/pkg/storagespace" "github.com/opencloud-eu/reva/v2/pkg/utils" - libregraph "github.com/opencloud-eu/libre-graph-api-go" ) func (s DriveItemPermissionsService) CreateLink(ctx context.Context, driveItemID *storageprovider.ResourceId, createLink libregraph.DriveItemCreateLink) (libregraph.Permission, error) { diff --git a/services/graph/pkg/service/v0/api_drives_drive_item.go b/services/graph/pkg/service/v0/api_drives_drive_item.go index 16b5d3a71a..6ddd90d22b 100644 --- a/services/graph/pkg/service/v0/api_drives_drive_item.go +++ b/services/graph/pkg/service/v0/api_drives_drive_item.go @@ -416,6 +416,9 @@ func (api DrivesDriveItemApi) GetDriveItem(w http.ResponseWriter, r *http.Reques ErrDriveItemConversion.Render(w, r) return } + if driveItemPropertySelected(r, _selectDownloadURL) { + api.baseGraphService.SetDriveItemsDownloadURL(r, driveItems) + } render.Status(r, http.StatusOK) render.JSON(w, r, driveItems[0]) diff --git a/services/graph/pkg/service/v0/api_drives_drive_item_test.go b/services/graph/pkg/service/v0/api_drives_drive_item_test.go index 394188482b..684ed53312 100644 --- a/services/graph/pkg/service/v0/api_drives_drive_item_test.go +++ b/services/graph/pkg/service/v0/api_drives_drive_item_test.go @@ -980,6 +980,27 @@ var _ = Describe("DrivesDriveItemApi", func() { jsonData := gjson.Get(w.Body.String(), "error") Expect(jsonData.Get("code").String() + ": " + jsonData.Get("message").String()).To(Equal(svc.ErrDriveItemConversion.Error())) }) + + It("adds the download url when selected via $select", func() { + baseGraphProvider. + EXPECT(). + CS3ReceivedSharesToDriveItems(mock.Anything, mock.Anything). + Return([]libregraph.DriveItem{{}}, nil). + Once() + baseGraphProvider. + EXPECT(). + SetDriveItemsDownloadURL(mock.Anything, mock.Anything). + Return(). + Once() + + r = httptest.NewRequest(http.MethodGet, "/?$select=@microsoft.graph.downloadUrl", nil). + WithContext( + context.WithValue(context.Background(), chi.RouteCtxKey, rCTX), + ) + + drivesDriveItemApi.GetDriveItem(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) }) It("successfully returns the share", func() { diff --git a/services/graph/pkg/service/v0/application.go b/services/graph/pkg/service/v0/application.go index 2caa3c377c..f56865a0e8 100644 --- a/services/graph/pkg/service/v0/application.go +++ b/services/graph/pkg/service/v0/application.go @@ -6,9 +6,9 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/render" + libregraph "github.com/opencloud-eu/libre-graph-api-go" settingssvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/settings/v0" "github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode" - libregraph "github.com/opencloud-eu/libre-graph-api-go" ) // ListApplications implements the Service interface. diff --git a/services/graph/pkg/service/v0/approleassignments.go b/services/graph/pkg/service/v0/approleassignments.go index aafa6148d4..ecba5222b8 100644 --- a/services/graph/pkg/service/v0/approleassignments.go +++ b/services/graph/pkg/service/v0/approleassignments.go @@ -6,13 +6,13 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/render" + libregraph "github.com/opencloud-eu/libre-graph-api-go" settingsmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/settings/v0" settingssvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/settings/v0" "github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode" revactx "github.com/opencloud-eu/reva/v2/pkg/ctx" "github.com/opencloud-eu/reva/v2/pkg/events" "github.com/opencloud-eu/reva/v2/pkg/utils" - libregraph "github.com/opencloud-eu/libre-graph-api-go" merrors "go-micro.dev/v4/errors" ) diff --git a/services/graph/pkg/service/v0/base.go b/services/graph/pkg/service/v0/base.go index cc25d30da1..0e8d315fc3 100644 --- a/services/graph/pkg/service/v0/base.go +++ b/services/graph/pkg/service/v0/base.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net/http" "net/url" "path" "time" @@ -23,6 +24,7 @@ import ( "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" "github.com/opencloud-eu/reva/v2/pkg/share" + "github.com/opencloud-eu/reva/v2/pkg/signedurl" "github.com/opencloud-eu/reva/v2/pkg/storagespace" "github.com/opencloud-eu/reva/v2/pkg/utils" @@ -39,6 +41,7 @@ import ( type BaseGraphProvider interface { CS3ReceivedSharesToDriveItems(ctx context.Context, receivedShares []*collaboration.ReceivedShare) ([]libregraph.DriveItem, error) CS3ReceivedOCMSharesToDriveItems(ctx context.Context, receivedOCMShares []*ocm.ReceivedShare) ([]libregraph.DriveItem, error) + SetDriveItemsDownloadURL(r *http.Request, items []libregraph.DriveItem) } // BaseGraphService implements a couple of helper functions that are @@ -50,6 +53,16 @@ type BaseGraphService struct { config *config.Config availableRoles []*libregraph.UnifiedRoleDefinition publicBaseURL *url.URL + downloadSigner signedurl.Signer +} + +// webURLForResource returns the public web URL pointing at the given resource +// (e.g. https://cloud.example.com/f/), using the pre-parsed +// publicBaseURL held by the service. +func (g BaseGraphService) webURLForResource(rid *storageprovider.ResourceId) *string { + u := *g.publicBaseURL + u.Path = path.Join(u.Path, "f", storagespace.FormatResourceID(rid)) + return libregraph.PtrString(u.String()) } func (g BaseGraphService) getDriveItem(ctx context.Context, ref *storageprovider.Reference) (*libregraph.DriveItem, error) { @@ -66,7 +79,7 @@ func (g BaseGraphService) getDriveItem(ctx context.Context, ref *storageprovider refStr, _ := storagespace.FormatReference(ref) return nil, fmt.Errorf("could not stat %s: %s", refStr, res.GetStatus().GetMessage()) } - return cs3ResourceToDriveItem(g.logger, g.publicBaseURL, res.GetInfo()) + return g.cs3ResourceToDriveItem(res.GetInfo()) } func (g BaseGraphService) CS3ReceivedSharesToDriveItems(ctx context.Context, receivedShares []*collaboration.ReceivedShare) ([]libregraph.DriveItem, error) { @@ -217,14 +230,6 @@ func (g BaseGraphService) cs3SpacePermissionsToLibreGraph(ctx context.Context, s } func (g BaseGraphService) libreGraphPermissionFromCS3PublicShare(createdLink *link.PublicShare) (*libregraph.Permission, error) { - webURL, err := url.Parse(g.config.Spaces.WebDavBase) - if err != nil { - g.logger.Error(). - Err(err). - Str("url", g.config.Spaces.WebDavBase). - Msg("failed to parse webURL base url") - return nil, err - } lt, actions := linktype.SharingLinkTypeFromCS3Permissions(createdLink.GetPermissions()) perm := libregraph.NewPermission() perm.Id = libregraph.PtrString(createdLink.GetId().GetOpaqueId()) @@ -235,6 +240,7 @@ func (g BaseGraphService) libreGraphPermissionFromCS3PublicShare(createdLink *li LibreGraphQuickLink: libregraph.PtrBool(createdLink.GetQuicklink()), } perm.LibreGraphPermissionsActions = actions + webURL := *g.publicBaseURL webURL.Path = path.Join(webURL.Path, "s", createdLink.GetToken()) perm.Link.SetWebUrl(webURL.String()) diff --git a/services/graph/pkg/service/v0/driveitem_download.go b/services/graph/pkg/service/v0/driveitem_download.go new file mode 100644 index 0000000000..65256f5939 --- /dev/null +++ b/services/graph/pkg/service/v0/driveitem_download.go @@ -0,0 +1,142 @@ +package svc + +import ( + "errors" + "net/http" + "path" + "time" + + cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + libregraph "github.com/opencloud-eu/libre-graph-api-go" + revactx "github.com/opencloud-eu/reva/v2/pkg/ctx" + "github.com/opencloud-eu/reva/v2/pkg/storagespace" + + "github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode" +) + +const downloadURLTTL = 30 * time.Minute + +// ErrDownloadURLSigningNotConfigured is returned when no url signing secret is configured +var ErrDownloadURLSigningNotConfigured = errors.New("download url signing is not configured") + +// GetDriveItemContent redirects to a signed download url for a file +func (g Graph) GetDriveItemContent(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + driveID, err := parseIDParam(r, "driveID") + if err != nil { + errorcode.RenderError(w, r, err) + return + } + itemID, err := parseIDParam(r, "itemID") + if err != nil { + errorcode.RenderError(w, r, err) + return + } + if driveID.GetStorageId() != itemID.GetStorageId() || driveID.GetSpaceId() != itemID.GetSpaceId() { + errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Item does not exist") + return + } + + user, ok := revactx.ContextGetUser(ctx) + if !ok { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "user not in context") + return + } + + gatewayClient, err := g.gatewaySelector.Next() + if err != nil { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return + } + stat, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{Ref: &storageprovider.Reference{ResourceId: &itemID}}) + switch { + case err != nil: + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return + case stat.GetStatus().GetCode() == cs3rpc.Code_CODE_OK: + case stat.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND: + errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, stat.GetStatus().GetMessage()) + return + case stat.GetStatus().GetCode() == cs3rpc.Code_CODE_PERMISSION_DENIED: + errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, stat.GetStatus().GetMessage()) + return + case stat.GetStatus().GetCode() == cs3rpc.Code_CODE_UNAUTHENTICATED: + errorcode.Unauthenticated.Render(w, r, http.StatusUnauthorized, stat.GetStatus().GetMessage()) + return + default: + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, stat.GetStatus().GetMessage()) + return + } + if stat.GetInfo().GetType() != storageprovider.ResourceType_RESOURCE_TYPE_FILE { + errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Item is not a file") + return + } + + downloadURL, err := g.signedDownloadURL(&itemID, user.GetId().GetOpaqueId()) + if err != nil { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return + } + + http.Redirect(w, r, downloadURL, http.StatusFound) +} + +// SetDriveItemsDownloadURL adds a signed download url to every file in items when requested via $select +func (g BaseGraphService) SetDriveItemsDownloadURL(r *http.Request, items []libregraph.DriveItem) { + if !g.downloadURLRequested(r) { + return + } + user, ok := revactx.ContextGetUser(r.Context()) + if !ok { + return + } + for i := range items { + g.signDriveItemDownloadURL(&items[i], user.GetId().GetOpaqueId()) + } +} + +func (g BaseGraphService) setDriveItemDownloadURL(r *http.Request, item *libregraph.DriveItem) { + if !g.downloadURLRequested(r) { + return + } + user, ok := revactx.ContextGetUser(r.Context()) + if !ok { + return + } + g.signDriveItemDownloadURL(item, user.GetId().GetOpaqueId()) +} + +func (g BaseGraphService) signDriveItemDownloadURL(item *libregraph.DriveItem, userID string) { + if item.File == nil { + return + } + id, err := storagespace.ParseID(item.GetId()) + if err != nil { + g.logger.Debug().Err(err).Str("id", item.GetId()).Msg("could not parse drive item id for the download url") + return + } + u, err := g.signedDownloadURL(&id, userID) + if err != nil { + g.logger.Debug().Err(err).Str("id", item.GetId()).Msg("could not sign the download url") + return + } + item.MicrosoftGraphDownloadUrl = &u +} + +func (g BaseGraphService) signedDownloadURL(id *storageprovider.ResourceId, userID string) (string, error) { + if g.downloadSigner == nil { + return "", ErrDownloadURLSigningNotConfigured + } + base, err := g.getWebDavBaseURL() + if err != nil { + return "", err + } + base.Path = path.Join(base.Path, storagespace.FormatResourceID(id)) + return g.downloadSigner.Sign(base.String(), userID, downloadURLTTL) +} + +func (g BaseGraphService) downloadURLRequested(r *http.Request) bool { + return g.downloadSigner != nil && driveItemPropertySelected(r, _selectDownloadURL) +} diff --git a/services/graph/pkg/service/v0/driveitem_download_test.go b/services/graph/pkg/service/v0/driveitem_download_test.go new file mode 100644 index 0000000000..53a7b2a7d3 --- /dev/null +++ b/services/graph/pkg/service/v0/driveitem_download_test.go @@ -0,0 +1,180 @@ +package svc_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/go-chi/chi/v5" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/mock" + "google.golang.org/grpc" + + revactx "github.com/opencloud-eu/reva/v2/pkg/ctx" + "github.com/opencloud-eu/reva/v2/pkg/rgrpc/status" + "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" + "github.com/opencloud-eu/reva/v2/pkg/signedurl" + cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks" + + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/pkg/shared" + "github.com/opencloud-eu/opencloud/services/graph/mocks" + "github.com/opencloud-eu/opencloud/services/graph/pkg/config/defaults" + identitymocks "github.com/opencloud-eu/opencloud/services/graph/pkg/identity/mocks" + "github.com/opencloud-eu/opencloud/services/graph/pkg/metrics" + service "github.com/opencloud-eu/opencloud/services/graph/pkg/service/v0" +) + +const urlSigningSecret = "url-signing-secret" + +// verifySignedDownloadURL checks the signature of a download url for the given user and returns the url +func verifySignedDownloadURL(signed, userID string) *url.URL { + GinkgoHelper() + + verifier, err := signedurl.NewJWTSignedURL(signedurl.WithSecret(urlSigningSecret)) + Expect(err).ToNot(HaveOccurred()) + subject, err := verifier.Verify(signed) + Expect(err).ToNot(HaveOccurred()) + Expect(subject).To(Equal(userID)) + + u, err := url.Parse(signed) + Expect(err).ToNot(HaveOccurred()) + return u +} + +var _ = Describe("GetDriveItemContent", func() { + const ( + driveID = "storageid$spaceid" + itemID = "storageid$spaceid!nodeid" + ) + + var ( + ctx context.Context + gatewayClient *cs3mocks.GatewayAPIClient + gatewaySelector pool.Selectable[gateway.GatewayAPIClient] + eventsPublisher mocks.Publisher + rr *httptest.ResponseRecorder + fileInfo *provider.ResourceInfo + + currentUser = &userpb.User{Id: &userpb.UserId{OpaqueId: "user"}} + ) + + newService := func(signingSecret string) service.Service { + logger := log.NewLogger() + metrics, _ := metrics.New(prometheus.NewRegistry(), &logger, func([]string) (string, string) { return "", "" }) + + cfg := defaults.FullDefaultConfig() + cfg.Identity.LDAP.CACert = "" + cfg.TokenManager.JWTSecret = "loremipsum" + cfg.Commons = &shared.Commons{URLSigningSecret: signingSecret} + cfg.GRPCClientTLS = &shared.GRPCClientTLS{} + + svc, err := service.NewService( + service.Config(cfg), + service.Metrics(metrics), + service.WithGatewaySelector(gatewaySelector), + service.EventsPublisher(&eventsPublisher), + service.WithIdentityBackend(&identitymocks.Backend{}), + ) + Expect(err).ToNot(HaveOccurred()) + return svc + } + + newRequest := func(withUser bool) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/graph/v1beta1/drives/"+driveID+"/items/"+itemID+"/content", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("driveID", driveID) + rctx.URLParams.Add("itemID", itemID) + c := ctx + if withUser { + c = revactx.ContextSetUser(c, currentUser) + } + return r.WithContext(context.WithValue(c, chi.RouteCtxKey, rctx)) + } + + BeforeEach(func() { + eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + pool.RemoveSelector("GatewaySelector" + "eu.opencloud.api.gateway") + gatewayClient = &cs3mocks.GatewayAPIClient{} + gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient]( + "GatewaySelector", + "eu.opencloud.api.gateway", + func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient { + return gatewayClient + }, + ) + + rr = httptest.NewRecorder() + ctx = context.Background() + + fileInfo = &provider.ResourceInfo{ + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "nodeid"}, + } + gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&provider.StatResponse{ + Status: status.NewOK(ctx), + Info: fileInfo, + }, nil) + }) + + It("redirects to a signed download url", func() { + newService(urlSigningSecret).GetDriveItemContent(rr, newRequest(true)) + Expect(rr.Code).To(Equal(http.StatusFound)) + + target := verifySignedDownloadURL(rr.Header().Get("Location"), "user") + Expect(target.Path).To(Equal("/dav/spaces/" + itemID)) + }) + + It("returns 404 for a folder", func() { + fileInfo.Type = provider.ResourceType_RESOURCE_TYPE_CONTAINER + + newService(urlSigningSecret).GetDriveItemContent(rr, newRequest(true)) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 for an unknown item", func() { + gatewayClient.ExpectedCalls = nil + gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&provider.StatResponse{Status: status.NewNotFound(ctx, "not found")}, nil) + + newService(urlSigningSecret).GetDriveItemContent(rr, newRequest(true)) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + }) + + It("treats permission denied as not found", func() { + gatewayClient.ExpectedCalls = nil + gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&provider.StatResponse{Status: status.NewPermissionDenied(ctx, errors.New("denied"), "denied")}, nil) + + newService(urlSigningSecret).GetDriveItemContent(rr, newRequest(true)) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 when the item belongs to another drive", func() { + r := newRequest(true) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("driveID", "storageid$otherspace") + rctx.URLParams.Add("itemID", itemID) + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) + + newService(urlSigningSecret).GetDriveItemContent(rr, r) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + gatewayClient.AssertNotCalled(GinkgoT(), "Stat", mock.Anything, mock.Anything) + }) + + It("returns 500 without a user in the context", func() { + newService(urlSigningSecret).GetDriveItemContent(rr, newRequest(false)) + Expect(rr.Code).To(Equal(http.StatusInternalServerError)) + }) + + It("returns 500 when url signing is not configured", func() { + newService("").GetDriveItemContent(rr, newRequest(true)) + Expect(rr.Code).To(Equal(http.StatusInternalServerError)) + }) +}) diff --git a/services/graph/pkg/service/v0/driveitems.go b/services/graph/pkg/service/v0/driveitems.go index 167f8b1b62..c192723f67 100644 --- a/services/graph/pkg/service/v0/driveitems.go +++ b/services/graph/pkg/service/v0/driveitems.go @@ -29,7 +29,6 @@ import ( "github.com/opencloud-eu/reva/v2/pkg/tags" "github.com/opencloud-eu/reva/v2/pkg/utils" - "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode" "github.com/opencloud-eu/opencloud/services/graph/pkg/unifiedrole" "github.com/opencloud-eu/opencloud/services/search/pkg/mapping" @@ -39,6 +38,7 @@ import ( const ( _selectAllowedValues = "@libre.graph.permissions.actions.allowedValues" _selectShareTypes = "@libre.graph.shareTypes" + _selectDownloadURL = "@microsoft.graph.downloadUrl" ) // without it the provider leaves the share-types opaque empty @@ -250,7 +250,7 @@ func (g Graph) GetRootDriveChildren(w http.ResponseWriter, r *http.Request) { return } - files, err := formatDriveItems(g.logger, g.publicBaseURL, lRes.GetInfos()) + files, err := g.formatDriveItems(lRes.GetInfos()) if err != nil { g.logger.Error().Err(err).Msg("error encoding response as json") errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) @@ -267,6 +267,7 @@ func (g Graph) GetRootDriveChildren(w http.ResponseWriter, r *http.Request) { if driveItemPropertySelected(r, _selectShareTypes) { g.addShareTypes(ctx, files, lRes.GetInfos()) } + g.SetDriveItemsDownloadURL(r, files) render.Status(r, http.StatusOK) render.JSON(w, r, &ListResponse{Value: files}) @@ -326,12 +327,11 @@ func (g Graph) GetDriveItem(w http.ResponseWriter, r *http.Request) { errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage()) return } - driveItem, err := cs3ResourceToDriveItem(g.logger, g.publicBaseURL, res.GetInfo()) + driveItem, err := g.cs3ResourceToDriveItem(res.GetInfo()) if err != nil { errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) return } - if driveItemPropertySelected(r, _selectAllowedValues) { driveItem.LibreGraphPermissionsActionsAllowedValues = unifiedrole.CS3ResourcePermissionsToLibregraphActions(res.GetInfo().GetPermissionSet()) } @@ -349,6 +349,7 @@ func (g Graph) GetDriveItem(w http.ResponseWriter, r *http.Request) { infos := []*storageprovider.ResourceInfo{res.GetInfo()} driveItem.LibreGraphShareTypes = shareTypesOf(res.GetInfo(), g.listLinkShares(ctx, infos)) } + g.setDriveItemDownloadURL(r, driveItem) if driveItemRelationExpanded(r, _expandThumbnails) { setDriveItemThumbnails(driveItem, res.GetInfo(), g.config.Commons.OpenCloudURL) @@ -430,7 +431,7 @@ func (g Graph) listDriveItemChildren(w http.ResponseWriter, r *http.Request, dri return nil, false } - files, err := formatDriveItems(g.logger, g.publicBaseURL, res.GetInfos()) + files, err := g.formatDriveItems(res.GetInfos()) if err != nil { errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) return nil, false @@ -439,6 +440,7 @@ func (g Graph) listDriveItemChildren(w http.ResponseWriter, r *http.Request, dri if driveItemPropertySelected(r, _selectShareTypes) { g.addShareTypes(r.Context(), files, res.GetInfos()) } + g.SetDriveItemsDownloadURL(r, files) g.setDriveItemsThumbnails(r, files, res.GetInfos()) @@ -483,10 +485,10 @@ func (g Graph) getRemoteItem(ctx context.Context, root *storageprovider.Resource return item, nil } -func formatDriveItems(logger *log.Logger, publicBaseURL *url.URL, mds []*storageprovider.ResourceInfo) ([]libregraph.DriveItem, error) { +func (g BaseGraphService) formatDriveItems(mds []*storageprovider.ResourceInfo) ([]libregraph.DriveItem, error) { responses := make([]libregraph.DriveItem, 0, len(mds)) for i := range mds { - res, err := cs3ResourceToDriveItem(logger, publicBaseURL, mds[i]) + res, err := g.cs3ResourceToDriveItem(mds[i]) if err != nil { return nil, err } @@ -500,19 +502,16 @@ func cs3TimestampToTime(t *types.Timestamp) time.Time { return time.Unix(int64(t.GetSeconds()), int64(t.GetNanos())) } -func cs3ResourceToDriveItem(logger *log.Logger, publicBaseURL *url.URL, res *storageprovider.ResourceInfo) (*libregraph.DriveItem, error) { +func (g BaseGraphService) cs3ResourceToDriveItem(res *storageprovider.ResourceInfo) (*libregraph.DriveItem, error) { size := new(int64) *size = int64(res.GetSize()) // TODO lurking overflow: make size of libregraph drive item use uint64 driveItem := &libregraph.DriveItem{ - Id: libregraph.PtrString(storagespace.FormatResourceID(res.GetId())), - Size: size, + Id: libregraph.PtrString(storagespace.FormatResourceID(res.GetId())), + Size: size, + WebUrl: g.webURLForResource(res.GetId()), } - webURL := *publicBaseURL - webURL.Path = path.Join(webURL.Path, "f", storagespace.FormatResourceID(res.GetId())) - driveItem.WebUrl = libregraph.PtrString(webURL.String()) - if name := path.Base(res.GetPath()); name != "" { driveItem.Name = &name } diff --git a/services/graph/pkg/service/v0/driveitems_test.go b/services/graph/pkg/service/v0/driveitems_test.go index 42e698a096..fffab7113d 100644 --- a/services/graph/pkg/service/v0/driveitems_test.go +++ b/services/graph/pkg/service/v0/driveitems_test.go @@ -90,7 +90,9 @@ var _ = Describe("Driveitems", func() { cfg = defaults.FullDefaultConfig() cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests cfg.TokenManager.JWTSecret = "loremipsum" - cfg.Commons = &shared.Commons{} + cfg.Commons = &shared.Commons{ + URLSigningSecret: urlSigningSecret, + } cfg.GRPCClientTLS = &shared.GRPCClientTLS{} var err error @@ -277,6 +279,70 @@ var _ = Describe("Driveitems", func() { unifiedrole.DriveItemContentRead, )) }) + + It("adds @microsoft.graph.downloadUrl when selected via $select", func() { + gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{ + Status: status.NewOK(ctx), + StorageSpaces: []*provider.StorageSpace{{Owner: currentUser, Root: &provider.ResourceId{}}}, + }, nil) + gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{ + Status: status.NewOK(ctx), + Infos: []*provider.ResourceInfo{ + { + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"}, + Etag: "etag", + Mtime: utils.TimeToTS(time.Now()), + }, + }, + }, nil) + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children?$select=@microsoft.graph.downloadUrl", nil) + r = r.WithContext(revactx.ContextSetUser(ctx, currentUser)) + svc.GetRootDriveChildren(rr, r) + Expect(rr.Code).To(Equal(http.StatusOK)) + data, err := io.ReadAll(rr.Body) + Expect(err).ToNot(HaveOccurred()) + + res := itemsList{} + Expect(json.Unmarshal(data, &res)).To(Succeed()) + Expect(res.Value).To(HaveLen(1)) + target := verifySignedDownloadURL(res.Value[0].GetMicrosoftGraphDownloadUrl(), "user") + Expect(target.Path).To(Equal("/dav/spaces/storageid$spaceid!opaqueid")) + }) + + It("honours @microsoft.graph.downloadUrl in a combined $select", func() { + gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{ + Status: status.NewOK(ctx), + StorageSpaces: []*provider.StorageSpace{{Owner: currentUser, Root: &provider.ResourceId{}}}, + }, nil) + gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{ + Status: status.NewOK(ctx), + Infos: []*provider.ResourceInfo{ + { + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"}, + Etag: "etag", + Mtime: utils.TimeToTS(time.Now()), + PermissionSet: &provider.ResourcePermissions{GetPath: true, InitiateFileDownload: true}, + }, + }, + }, nil) + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children?$select=@libre.graph.permissions.actions.allowedValues,@microsoft.graph.downloadUrl", nil) + r = r.WithContext(revactx.ContextSetUser(ctx, currentUser)) + svc.GetRootDriveChildren(rr, r) + Expect(rr.Code).To(Equal(http.StatusOK)) + data, err := io.ReadAll(rr.Body) + Expect(err).ToNot(HaveOccurred()) + + res := itemsList{} + Expect(json.Unmarshal(data, &res)).To(Succeed()) + Expect(res.Value).To(HaveLen(1)) + Expect(res.Value[0].MicrosoftGraphDownloadUrl).ToNot(BeNil()) + Expect(res.Value[0].GetLibreGraphPermissionsActionsAllowedValues()).To(ConsistOf( + unifiedrole.DriveItemPathRead, + unifiedrole.DriveItemContentRead, + )) + }) }) Describe("GetDriveItem", func() { @@ -389,6 +455,21 @@ var _ = Describe("Driveitems", func() { Expect(item.Children[0].Thumbnails).To(HaveLen(1)) }) }) + + It("adds @microsoft.graph.downloadUrl to a file when selected via $select", func() { + folderInfo.Type = provider.ResourceType_RESOURCE_TYPE_FILE + + Expect(getItem(newRequest("")).MicrosoftGraphDownloadUrl).To(BeNil()) + + rr = httptest.NewRecorder() + item := getItem(newRequest("?$select=@microsoft.graph.downloadUrl")) + target := verifySignedDownloadURL(item.GetMicrosoftGraphDownloadUrl(), "user") + Expect(target.Path).To(Equal("/dav/spaces/storageid$spaceid!nodeid")) + }) + + It("omits @microsoft.graph.downloadUrl for a folder when selected via $select", func() { + Expect(getItem(newRequest("?$select=@microsoft.graph.downloadUrl")).MicrosoftGraphDownloadUrl).To(BeNil()) + }) }) Describe("GetDriveItem $expand=children error", func() { @@ -510,6 +591,7 @@ var _ = Describe("Driveitems", func() { res := assertItemsList(1) Expect(res.Value[0].Audio).To(BeNil()) Expect(res.Value[0].Location).To(BeNil()) + Expect(res.Value[0].MicrosoftGraphDownloadUrl).To(BeNil()) Expect(res.Value[0].LibreGraphMeFollowing).To(BeNil()) Expect(res.Value[0].LibreGraphTags).To(BeNil()) Expect(res.Value[0].PendingOperations).To(BeNil()) @@ -750,6 +832,60 @@ var _ = Describe("Driveitems", func() { Expect(res.Value[0].GetLibreGraphMeFollowing()).To(BeFalse()) }) + It("adds @microsoft.graph.downloadUrl to files when selected via $select", func() { + gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{ + Status: status.NewOK(ctx), + Infos: []*provider.ResourceInfo{ + { + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"}, + Etag: "etag", + Mtime: utils.TimeToTS(mtime), + }, + }, + }, nil) + + r = httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives/storageid$spaceid/items/storageid$spaceid!nodeid/children?$select=@microsoft.graph.downloadUrl", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("driveID", "storageid$spaceid") + rctx.URLParams.Add("driveItemID", "storageid$spaceid!nodeid") + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + + res := assertItemsList(1) + target := verifySignedDownloadURL(res.Value[0].GetMicrosoftGraphDownloadUrl(), "user") + Expect(target.Path).To(Equal("/dav/spaces/storageid$spaceid!opaqueid")) + }) + + It("omits @microsoft.graph.downloadUrl for folders when selected via $select", func() { + gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{ + Status: status.NewOK(ctx), + Infos: []*provider.ResourceInfo{ + { + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"}, + Etag: "etag", + Mtime: utils.TimeToTS(mtime), + }, + { + Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER, + Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "folderid"}, + Etag: "etag", + Mtime: utils.TimeToTS(mtime), + }, + }, + }, nil) + + r = httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives/storageid$spaceid/items/storageid$spaceid!nodeid/children?$select=@microsoft.graph.downloadUrl", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("driveID", "storageid$spaceid") + rctx.URLParams.Add("driveItemID", "storageid$spaceid!nodeid") + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + + res := assertItemsList(2) + Expect(res.Value[0].MicrosoftGraphDownloadUrl).ToNot(BeNil()) + Expect(res.Value[1].MicrosoftGraphDownloadUrl).To(BeNil()) + }) + It("returns the audio facet if metadata is available", func() { gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{ Status: status.NewOK(ctx), diff --git a/services/graph/pkg/service/v0/driveitems_weburl_test.go b/services/graph/pkg/service/v0/driveitems_weburl_test.go index 3d8cab7254..ed2661d831 100644 --- a/services/graph/pkg/service/v0/driveitems_weburl_test.go +++ b/services/graph/pkg/service/v0/driveitems_weburl_test.go @@ -26,7 +26,8 @@ func TestCS3ResourceToDriveItemPopulatesWebUrl(t *testing.T) { base, err := url.Parse("https://example.com") require.NoError(t, err) - item, err := cs3ResourceToDriveItem(&logger, base, res) + g := BaseGraphService{logger: &logger, publicBaseURL: base} + item, err := g.cs3ResourceToDriveItem(res) require.NoError(t, err) require.NotNil(t, item.WebUrl) assert.Equal(t, "https://example.com/f/storage-1$space-1%21item-1", *item.WebUrl) @@ -36,7 +37,8 @@ func TestCS3ResourceToDriveItemPopulatesWebUrl(t *testing.T) { base, err := url.Parse("https://example.com/cloud") require.NoError(t, err) - item, err := cs3ResourceToDriveItem(&logger, base, res) + g := BaseGraphService{logger: &logger, publicBaseURL: base} + item, err := g.cs3ResourceToDriveItem(res) require.NoError(t, err) require.NotNil(t, item.WebUrl) assert.Equal(t, "https://example.com/cloud/f/storage-1$space-1%21item-1", *item.WebUrl) diff --git a/services/graph/pkg/service/v0/drives.go b/services/graph/pkg/service/v0/drives.go index 954cd0791d..b9233a2ca1 100644 --- a/services/graph/pkg/service/v0/drives.go +++ b/services/graph/pkg/service/v0/drives.go @@ -862,17 +862,7 @@ func (g Graph) cs3StorageSpaceToDrive(ctx context.Context, baseURL *url.URL, spa drive.Root.WebDavUrl = libregraph.PtrString(webDavURL.String()) } - webURL, err := url.Parse(g.config.Spaces.WebDavBase) - if err != nil { - logger.Error(). - Err(err). - Str("url", g.config.Spaces.WebDavBase). - Msg("failed to parse webURL base url") - return nil, err - } - - webURL.Path = path.Join(webURL.Path, "f", storagespace.FormatResourceID(spaceRid)) - drive.WebUrl = libregraph.PtrString(webURL.String()) + drive.WebUrl = g.webURLForResource(spaceRid) if space.Owner != nil && space.Owner.Id != nil { drive.Owner = &libregraph.IdentitySet{ diff --git a/services/graph/pkg/service/v0/follow.go b/services/graph/pkg/service/v0/follow.go index d2866982f3..33ad7b2893 100644 --- a/services/graph/pkg/service/v0/follow.go +++ b/services/graph/pkg/service/v0/follow.go @@ -98,7 +98,7 @@ func (g Graph) FollowDriveItem(w http.ResponseWriter, r *http.Request) { } } - driveItem, err := cs3ResourceToDriveItem(g.logger, g.publicBaseURL, statRes.GetInfo()) + driveItem, err := g.cs3ResourceToDriveItem(statRes.GetInfo()) if err != nil { errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) return diff --git a/services/graph/pkg/service/v0/graph.go b/services/graph/pkg/service/v0/graph.go index 913d2ab087..d54cc3d50a 100644 --- a/services/graph/pkg/service/v0/graph.go +++ b/services/graph/pkg/service/v0/graph.go @@ -96,13 +96,10 @@ func (g Graph) publishEvent(ctx context.Context, ev any) { } } -func (g Graph) getWebDavBaseURL() (*url.URL, error) { - webDavBaseURL, err := url.Parse(g.config.Spaces.WebDavBase) - if err != nil { - return nil, err - } +func (g BaseGraphService) getWebDavBaseURL() (*url.URL, error) { + webDavBaseURL := *g.publicBaseURL webDavBaseURL.Path = path.Join(webDavBaseURL.Path, g.config.Spaces.WebDavPath) - return webDavBaseURL, nil + return &webDavBaseURL, nil } // ListResponse is used for proper marshalling of Graph list responses diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go index 9b9636e370..c189e61d1a 100644 --- a/services/graph/pkg/service/v0/service.go +++ b/services/graph/pkg/service/v0/service.go @@ -14,6 +14,7 @@ import ( "github.com/riandyrn/otelchi" microstore "go-micro.dev/v4/store" + "github.com/opencloud-eu/reva/v2/pkg/signedurl" "github.com/opencloud-eu/reva/v2/pkg/store" "github.com/opencloud-eu/opencloud/pkg/roles" @@ -104,6 +105,7 @@ type Service interface { //nolint:interfacebloat GetRootDriveChildren(w http.ResponseWriter, r *http.Request) GetDriveItem(w http.ResponseWriter, r *http.Request) GetDriveItemChildren(w http.ResponseWriter, r *http.Request) + GetDriveItemContent(w http.ResponseWriter, r *http.Request) CreateUploadSession(w http.ResponseWriter, r *http.Request) @@ -146,6 +148,16 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx return Graph{}, fmt.Errorf("could not parse graph.spaces.webdav_base: %w", err) } + var downloadSigner signedurl.Signer + if options.Config.Commons != nil && options.Config.Commons.URLSigningSecret != "" { + downloadSigner, err = signedurl.NewJWTSignedURL(signedurl.WithSecret(options.Config.Commons.URLSigningSecret)) + if err != nil { + return Graph{}, fmt.Errorf("could not create download url signer: %w", err) + } + } else { + options.Logger.Warn().Msg("no url signing secret configured, driveItem download urls are disabled") + } + baseGraphService := BaseGraphService{ logger: &options.Logger, identityCache: identityCache, @@ -153,6 +165,7 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx config: options.Config, availableRoles: unifiedrole.GetRoles(unifiedrole.RoleFilterIDs(options.Config.UnifiedRoles.AvailableRoles...)), publicBaseURL: publicBaseURL, + downloadSigner: downloadSigner, } drivesDriveItemService, err := NewDrivesDriveItemService(options.Logger, options.GatewaySelector) @@ -271,6 +284,7 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx r.Get("/", drivesDriveItemApi.GetDriveItem) r.Patch("/", drivesDriveItemApi.UpdateDriveItem) r.Delete("/", drivesDriveItemApi.DeleteDriveItem) + r.Get("/content", svc.GetDriveItemContent) r.Post("/invite", driveItemPermissionsApi.Invite) r.Post("/createLink", driveItemPermissionsApi.CreateLink) r.Route("/permissions", func(r chi.Router) { diff --git a/services/graph/pkg/service/v0/tags.go b/services/graph/pkg/service/v0/tags.go index 1a459e8962..f4ac5a087b 100644 --- a/services/graph/pkg/service/v0/tags.go +++ b/services/graph/pkg/service/v0/tags.go @@ -7,13 +7,13 @@ import ( rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" "github.com/go-chi/render" + libregraph "github.com/opencloud-eu/libre-graph-api-go" searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0" "github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode" revaCtx "github.com/opencloud-eu/reva/v2/pkg/ctx" "github.com/opencloud-eu/reva/v2/pkg/events" "github.com/opencloud-eu/reva/v2/pkg/storagespace" "github.com/opencloud-eu/reva/v2/pkg/tags" - libregraph "github.com/opencloud-eu/libre-graph-api-go" "go-micro.dev/v4/metadata" )