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_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/base.go b/services/graph/pkg/service/v0/base.go index b10da1a3d3..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,7 @@ 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 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 f65e072a45..c192723f67 100644 --- a/services/graph/pkg/service/v0/driveitems.go +++ b/services/graph/pkg/service/v0/driveitems.go @@ -38,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 @@ -266,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}) @@ -330,7 +332,6 @@ func (g Graph) GetDriveItem(w http.ResponseWriter, r *http.Request) { errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) return } - if driveItemPropertySelected(r, _selectAllowedValues) { driveItem.LibreGraphPermissionsActionsAllowedValues = unifiedrole.CS3ResourcePermissionsToLibregraphActions(res.GetInfo().GetPermissionSet()) } @@ -348,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) @@ -438,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()) 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/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) {