Skip to content

Commit a34afba

Browse files
committed
feat(graph): expose @microsoft.graph.downloadUrl and /content on driveItems
File driveItems now carry a signed, short-lived @microsoft.graph.downloadUrl (by-id WebDAV URL). The v1beta1 /content endpoint redirects (302) to the same URL. The signer moves to BaseGraphService so the driveItem conversion can build it.
1 parent 0fb3439 commit a34afba

8 files changed

Lines changed: 211 additions & 39 deletions

File tree

services/graph/pkg/service/v0/api_driveitem_permissions.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,7 @@ func (s DriveItemPermissionsService) ListPermissions(ctx context.Context, itemID
404404

405405
driveItems := make(driveItemsByResourceID, 1)
406406
// we can use the statResponse to build the drive item before fetching the shares
407-
item, err := cs3ResourceToDriveItem(s.logger, s.publicBaseURL, statResponse.GetInfo())
407+
item, err := s.cs3ResourceToDriveItem(statResponse.GetInfo())
408408
if err != nil {
409409
return collectionOfPermissions, err
410410
}

services/graph/pkg/service/v0/base.go

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323

2424
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
2525
"github.com/opencloud-eu/reva/v2/pkg/share"
26+
"github.com/opencloud-eu/reva/v2/pkg/signedurl"
2627
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
2728
"github.com/opencloud-eu/reva/v2/pkg/utils"
2829

@@ -44,12 +45,13 @@ type BaseGraphProvider interface {
4445
// BaseGraphService implements a couple of helper functions that are
4546
// shared between the different graph services
4647
type BaseGraphService struct {
47-
logger *log.Logger
48-
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
49-
identityCache cache.IdentityCache
50-
config *config.Config
51-
availableRoles []*libregraph.UnifiedRoleDefinition
52-
publicBaseURL *url.URL
48+
logger *log.Logger
49+
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
50+
identityCache cache.IdentityCache
51+
config *config.Config
52+
availableRoles []*libregraph.UnifiedRoleDefinition
53+
publicBaseURL *url.URL
54+
downloadURLSigner signedurl.Signer
5355
}
5456

5557
func (g BaseGraphService) getDriveItem(ctx context.Context, ref *storageprovider.Reference) (*libregraph.DriveItem, error) {
@@ -66,7 +68,28 @@ func (g BaseGraphService) getDriveItem(ctx context.Context, ref *storageprovider
6668
refStr, _ := storagespace.FormatReference(ref)
6769
return nil, fmt.Errorf("could not stat %s: %s", refStr, res.GetStatus().GetMessage())
6870
}
69-
return cs3ResourceToDriveItem(g.logger, g.publicBaseURL, res.GetInfo())
71+
return g.cs3ResourceToDriveItem(res.GetInfo())
72+
}
73+
74+
func (g BaseGraphService) getWebDavBaseURL() (*url.URL, error) {
75+
webDavBaseURL, err := url.Parse(g.config.Spaces.WebDavBase)
76+
if err != nil {
77+
return nil, err
78+
}
79+
webDavBaseURL.Path = path.Join(webDavBaseURL.Path, g.config.Spaces.WebDavPath)
80+
return webDavBaseURL, nil
81+
}
82+
83+
func (g BaseGraphService) signedDownloadURL(id *storageprovider.ResourceId, userID string) (string, error) {
84+
if g.downloadURLSigner == nil {
85+
return "", errors.New("download url signing is not configured")
86+
}
87+
base, err := g.getWebDavBaseURL()
88+
if err != nil {
89+
return "", err
90+
}
91+
base.Path = path.Join(base.Path, storagespace.FormatResourceID(id))
92+
return g.downloadURLSigner.Sign(base.String(), userID, 30*time.Minute)
7093
}
7194

7295
func (g BaseGraphService) CS3ReceivedSharesToDriveItems(ctx context.Context, receivedShares []*collaboration.ReceivedShare) ([]libregraph.DriveItem, error) {

services/graph/pkg/service/v0/driveitems.go

Lines changed: 109 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,67 @@ func (g Graph) CreateUploadSession(w http.ResponseWriter, r *http.Request) {
120120
render.JSON(w, r, &uploadSession)
121121
}
122122

123+
// GetDriveItemContent redirects (302) to the signed download URL for the item,
124+
// the same URL exposed via @microsoft.graph.downloadUrl.
125+
func (g Graph) GetDriveItemContent(w http.ResponseWriter, r *http.Request) {
126+
ctx := r.Context()
127+
128+
driveID, err := parseIDParam(r, "driveID")
129+
if err != nil {
130+
errorcode.RenderError(w, r, err)
131+
return
132+
}
133+
itemID, err := parseIDParam(r, "itemID")
134+
if err != nil {
135+
errorcode.RenderError(w, r, err)
136+
return
137+
}
138+
if driveID.GetStorageId() != itemID.GetStorageId() || driveID.GetSpaceId() != itemID.GetSpaceId() {
139+
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Item does not exist")
140+
return
141+
}
142+
143+
user, ok := revactx.ContextGetUser(ctx)
144+
if !ok {
145+
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "user not in context")
146+
return
147+
}
148+
149+
gatewayClient, err := g.gatewaySelector.Next()
150+
if err != nil {
151+
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
152+
return
153+
}
154+
stat, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{Ref: &storageprovider.Reference{ResourceId: &itemID}})
155+
switch {
156+
case err != nil:
157+
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
158+
return
159+
case stat.GetStatus().GetCode() == cs3rpc.Code_CODE_OK:
160+
// ok
161+
case stat.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND:
162+
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, stat.GetStatus().GetMessage())
163+
return
164+
case stat.GetStatus().GetCode() == cs3rpc.Code_CODE_PERMISSION_DENIED:
165+
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, stat.GetStatus().GetMessage())
166+
return
167+
case stat.GetStatus().GetCode() == cs3rpc.Code_CODE_UNAUTHENTICATED:
168+
errorcode.Unauthenticated.Render(w, r, http.StatusUnauthorized, stat.GetStatus().GetMessage())
169+
return
170+
default:
171+
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, stat.GetStatus().GetMessage())
172+
return
173+
}
174+
175+
downloadURL, err := g.signedDownloadURL(&itemID, user.GetId().GetOpaqueId())
176+
if err != nil {
177+
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
178+
return
179+
}
180+
181+
http.Redirect(w, r, downloadURL, http.StatusFound)
182+
}
183+
123184
type createUploadSessionRequest struct {
124185
DeferCommit bool `json:"deferCommit"`
125186
Item driveItemUploadableProperties `json:"item"`
@@ -138,6 +199,37 @@ type uploadSession struct {
138199
CS3Protocols []*gateway.FileUploadProtocol
139200
}
140201

202+
func shouldSelect(r *http.Request, property string) bool {
203+
for _, v := range strings.Split(r.URL.Query().Get("$select"), ",") {
204+
if strings.TrimSpace(v) == property {
205+
return true
206+
}
207+
}
208+
return false
209+
}
210+
211+
// setDriveItemsDownloadURL adds @microsoft.graph.downloadUrl to file items when
212+
// selected via $select. items and infos must be parallel.
213+
func (g Graph) setDriveItemsDownloadURL(r *http.Request, items []*libregraph.DriveItem, infos []*storageprovider.ResourceInfo) {
214+
if g.downloadURLSigner == nil || !shouldSelect(r, "@microsoft.graph.downloadUrl") {
215+
return
216+
}
217+
user, ok := revactx.ContextGetUser(r.Context())
218+
if !ok {
219+
return
220+
}
221+
for i := range items {
222+
if items[i].File == nil {
223+
continue
224+
}
225+
u, err := g.signedDownloadURL(infos[i].GetId(), user.GetId().GetOpaqueId())
226+
if err != nil {
227+
continue
228+
}
229+
items[i].MicrosoftGraphDownloadUrl = &u
230+
}
231+
}
232+
141233
// GetRootDriveChildren implements the Service interface.
142234
func (g Graph) GetRootDriveChildren(w http.ResponseWriter, r *http.Request) {
143235
g.logger.Info().Msg("Calling GetRootDriveChildren")
@@ -204,12 +296,13 @@ func (g Graph) GetRootDriveChildren(w http.ResponseWriter, r *http.Request) {
204296
return
205297
}
206298

207-
files, err := formatDriveItems(g.logger, g.publicBaseURL, lRes.GetInfos())
299+
files, err := g.formatDriveItems(lRes.GetInfos())
208300
if err != nil {
209301
g.logger.Error().Err(err).Msg("error encoding response as json")
210302
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
211303
return
212304
}
305+
g.setDriveItemsDownloadURL(r, files, lRes.GetInfos())
213306

214307
render.Status(r, http.StatusOK)
215308
render.JSON(w, r, &ListResponse{Value: files})
@@ -269,11 +362,12 @@ func (g Graph) GetDriveItem(w http.ResponseWriter, r *http.Request) {
269362
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage())
270363
return
271364
}
272-
driveItem, err := cs3ResourceToDriveItem(g.logger, g.publicBaseURL, res.GetInfo())
365+
driveItem, err := g.cs3ResourceToDriveItem(res.GetInfo())
273366
if err != nil {
274367
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
275368
return
276369
}
370+
g.setDriveItemsDownloadURL(r, []*libregraph.DriveItem{driveItem}, []*storageprovider.ResourceInfo{res.GetInfo()})
277371

278372
render.Status(r, http.StatusOK)
279373
render.JSON(w, r, &driveItem)
@@ -337,11 +431,12 @@ func (g Graph) GetDriveItemChildren(w http.ResponseWriter, r *http.Request) {
337431
return
338432
}
339433

340-
files, err := formatDriveItems(g.logger, g.publicBaseURL, res.GetInfos())
434+
files, err := g.formatDriveItems(res.GetInfos())
341435
if err != nil {
342436
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
343437
return
344438
}
439+
g.setDriveItemsDownloadURL(r, files, res.GetInfos())
345440

346441
render.Status(r, http.StatusOK)
347442
render.JSON(w, r, &ListResponse{Value: files})
@@ -385,10 +480,13 @@ func (g Graph) getRemoteItem(ctx context.Context, root *storageprovider.Resource
385480
return item, nil
386481
}
387482

388-
func formatDriveItems(logger *log.Logger, publicBaseURL *url.URL, mds []*storageprovider.ResourceInfo) ([]*libregraph.DriveItem, error) {
483+
// TODO: formatDriveItems/cs3ResourceToDriveItem and the cs3ResourceToDriveItem*Facet
484+
// helpers live here; moving them now doesn't make sense, they are part of the
485+
// refactor in https://github.com/opencloud-eu/opencloud/pull/2659.
486+
func (g BaseGraphService) formatDriveItems(mds []*storageprovider.ResourceInfo) ([]*libregraph.DriveItem, error) {
389487
responses := make([]*libregraph.DriveItem, 0, len(mds))
390488
for i := range mds {
391-
res, err := cs3ResourceToDriveItem(logger, publicBaseURL, mds[i])
489+
res, err := g.cs3ResourceToDriveItem(mds[i])
392490
if err != nil {
393491
return nil, err
394492
}
@@ -402,7 +500,7 @@ func cs3TimestampToTime(t *types.Timestamp) time.Time {
402500
return time.Unix(int64(t.GetSeconds()), int64(t.GetNanos()))
403501
}
404502

405-
func cs3ResourceToDriveItem(logger *log.Logger, publicBaseURL *url.URL, res *storageprovider.ResourceInfo) (*libregraph.DriveItem, error) {
503+
func (g BaseGraphService) cs3ResourceToDriveItem(res *storageprovider.ResourceInfo) (*libregraph.DriveItem, error) {
406504
size := new(int64)
407505
*size = int64(res.GetSize()) // TODO lurking overflow: make size of libregraph drive item use uint64
408506

@@ -411,7 +509,7 @@ func cs3ResourceToDriveItem(logger *log.Logger, publicBaseURL *url.URL, res *sto
411509
Size: size,
412510
}
413511

414-
webURL := *publicBaseURL
512+
webURL := *g.publicBaseURL
415513
webURL.Path = path.Join(webURL.Path, "f", storagespace.FormatResourceID(res.GetId()))
416514
driveItem.WebUrl = libregraph.PtrString(webURL.String())
417515

@@ -453,10 +551,10 @@ func cs3ResourceToDriveItem(logger *log.Logger, publicBaseURL *url.URL, res *sto
453551
}
454552

455553
if res.GetArbitraryMetadata() != nil {
456-
driveItem.Audio = cs3ResourceToDriveItemAudioFacet(logger, res)
457-
driveItem.Image = cs3ResourceToDriveItemImageFacet(logger, res)
458-
driveItem.Location = cs3ResourceToDriveItemLocationFacet(logger, res)
459-
driveItem.Photo = cs3ResourceToDriveItemPhotoFacet(logger, res)
554+
driveItem.Audio = cs3ResourceToDriveItemAudioFacet(g.logger, res)
555+
driveItem.Image = cs3ResourceToDriveItemImageFacet(g.logger, res)
556+
driveItem.Location = cs3ResourceToDriveItemLocationFacet(g.logger, res)
557+
driveItem.Photo = cs3ResourceToDriveItemPhotoFacet(g.logger, res)
460558
}
461559

462560
return driveItem, nil

services/graph/pkg/service/v0/driveitems_test.go

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,9 @@ var _ = Describe("Driveitems", func() {
8282
cfg = defaults.FullDefaultConfig()
8383
cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
8484
cfg.TokenManager.JWTSecret = "loremipsum"
85-
cfg.Commons = &shared.Commons{}
85+
cfg.Commons = &shared.Commons{
86+
URLSigningSecret: "url-signing-secret",
87+
}
8688
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
8789

8890
var err error
@@ -95,6 +97,25 @@ var _ = Describe("Driveitems", func() {
9597
Expect(err).ToNot(HaveOccurred())
9698
})
9799

100+
Describe("GetDriveItemContent", func() {
101+
It("redirects (302) to the signed by-id WebDAV download URL", func() {
102+
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&provider.StatResponse{
103+
Status: status.NewOK(ctx),
104+
}, nil)
105+
r := httptest.NewRequest(http.MethodGet, "/graph/v1beta1/drives/storageid$spaceid/items/storageid$spaceid!nodeid/content", nil)
106+
rctx := chi.NewRouteContext()
107+
rctx.URLParams.Add("driveID", "storageid$spaceid")
108+
rctx.URLParams.Add("itemID", "storageid$spaceid!nodeid")
109+
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
110+
svc.GetDriveItemContent(rr, r)
111+
112+
Expect(rr.Code).To(Equal(http.StatusFound))
113+
location := rr.Header().Get("Location")
114+
Expect(location).To(ContainSubstring("/dav/spaces/storageid$spaceid%21nodeid"))
115+
Expect(location).To(ContainSubstring("oc-jwt-sig="))
116+
})
117+
})
118+
98119
Describe("GetRootDriveChildren", func() {
99120
It("handles ListStorageSpaces not found", func() {
100121
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
@@ -293,6 +314,32 @@ var _ = Describe("Driveitems", func() {
293314
res := assertItemsList(1)
294315
Expect(res.Value[0].Audio).To(BeNil())
295316
Expect(res.Value[0].Location).To(BeNil())
317+
// not requested via $select -> no downloadUrl
318+
Expect(res.Value[0].MicrosoftGraphDownloadUrl).To(BeNil())
319+
})
320+
321+
It("adds @microsoft.graph.downloadUrl to files when selected via $select", func() {
322+
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
323+
Status: status.NewOK(ctx),
324+
Infos: []*provider.ResourceInfo{
325+
{
326+
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
327+
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
328+
Etag: "etag",
329+
Mtime: utils.TimeToTS(mtime),
330+
},
331+
},
332+
}, nil)
333+
334+
r = httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives/storageid$spaceid/items/storageid$spaceid!nodeid/children?$select=@microsoft.graph.downloadUrl", nil)
335+
rctx := chi.NewRouteContext()
336+
rctx.URLParams.Add("driveID", "storageid$spaceid")
337+
rctx.URLParams.Add("driveItemID", "storageid$spaceid!nodeid")
338+
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
339+
340+
res := assertItemsList(1)
341+
Expect(res.Value[0].GetMicrosoftGraphDownloadUrl()).To(ContainSubstring("/dav/spaces/storageid$spaceid%21opaqueid"))
342+
Expect(res.Value[0].GetMicrosoftGraphDownloadUrl()).To(ContainSubstring("oc-jwt-sig="))
296343
})
297344

298345
It("returns the audio facet if metadata is available", func() {

services/graph/pkg/service/v0/driveitems_weburl_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ func TestCS3ResourceToDriveItemPopulatesWebUrl(t *testing.T) {
2626
base, err := url.Parse("https://example.com")
2727
require.NoError(t, err)
2828

29-
item, err := cs3ResourceToDriveItem(&logger, base, res)
29+
g := BaseGraphService{logger: &logger, publicBaseURL: base}
30+
item, err := g.cs3ResourceToDriveItem(res)
3031
require.NoError(t, err)
3132
require.NotNil(t, item.WebUrl)
3233
assert.Equal(t, "https://example.com/f/storage-1$space-1%21item-1", *item.WebUrl)
@@ -36,7 +37,8 @@ func TestCS3ResourceToDriveItemPopulatesWebUrl(t *testing.T) {
3637
base, err := url.Parse("https://example.com/cloud")
3738
require.NoError(t, err)
3839

39-
item, err := cs3ResourceToDriveItem(&logger, base, res)
40+
g := BaseGraphService{logger: &logger, publicBaseURL: base}
41+
item, err := g.cs3ResourceToDriveItem(res)
4042
require.NoError(t, err)
4143
require.NotNil(t, item.WebUrl)
4244
assert.Equal(t, "https://example.com/cloud/f/storage-1$space-1%21item-1", *item.WebUrl)

services/graph/pkg/service/v0/follow.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ func (g Graph) FollowDriveItem(w http.ResponseWriter, r *http.Request) {
9494
}
9595
}
9696

97-
driveItem, err := cs3ResourceToDriveItem(g.logger, g.publicBaseURL, statRes.GetInfo())
97+
driveItem, err := g.cs3ResourceToDriveItem(statRes.GetInfo())
9898
if err != nil {
9999
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
100100
return

services/graph/pkg/service/v0/graph.go

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"errors"
66
"net/http"
77
"net/url"
8-
"path"
98
"strings"
109

1110
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
@@ -93,15 +92,6 @@ func (g Graph) publishEvent(ctx context.Context, ev any) {
9392
}
9493
}
9594

96-
func (g Graph) getWebDavBaseURL() (*url.URL, error) {
97-
webDavBaseURL, err := url.Parse(g.config.Spaces.WebDavBase)
98-
if err != nil {
99-
return nil, err
100-
}
101-
webDavBaseURL.Path = path.Join(webDavBaseURL.Path, g.config.Spaces.WebDavPath)
102-
return webDavBaseURL, nil
103-
}
104-
10595
// ListResponse is used for proper marshalling of Graph list responses
10696
type ListResponse struct {
10797
Value any `json:"value,omitempty"`

0 commit comments

Comments
 (0)