feat(graph): expose @microsoft.graph.downloadUrl and /content on driveItems

Populates @microsoft.graph.downloadUrl on file driveItems when requested
via $select and implements GET .../items/{item-id}/content as a 302 to
the same URL: a by-id WebDAV URL signed with OC_URL_SIGNING_SECRET,
verified by the proxy, valid for 30 minutes. Folders answer 404 on
/content and never carry the annotation.

The annotation is available on the driveItem stat, the children and
root children listings and the share jail item endpoint.
This commit is contained in:
Dominik Schmidt committed 2026-09-07 12:59:50 +02:00
1 parent 0459149e0c
commit 5807eea186
10 files changed
+556 -2

No files matched your search

+4
View File
@@ -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:
@@ -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
}
@@ -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])
@@ -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() {
+4
View File
@@ -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
@@ -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)
}
@@ -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))
})
})
+4 -1
View File
@@ -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())
@@ -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),
+14
View File
@@ -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) {