From be8cd461b458de366b29dedade77b36a3d18bf26 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Mon, 7 Sep 2026 11:34:49 +0200 Subject: [PATCH] feat(graph): add the driveItem versions API Implements the MS Graph driveItemVersion operations on files: - GET .../items/{id}/versions lists the versions, newest first - GET .../versions/{version-id} returns one version, "current" describes the file itself - GET .../versions/{version-id}/content redirects to a signed download url for the version - POST .../versions/{version-id}/restoreVersion makes the version the current content The operations map onto ListFileVersions and RestoreFileVersion of the gateway. Version downloads point at the WebDAV meta endpoint, signed the same way as the driveItem download url. The download url is only added when requested via $select. The driveItemVersion models are vendored from the regenerated client of opencloud-eu/libre-graph-api#73 until that spec change is merged and the dependency can be bumped. --- .../pkg/service/v0/driveitem_versions.go | 294 ++++++++++++++++ .../pkg/service/v0/driveitem_versions_test.go | 319 ++++++++++++++++++ services/graph/pkg/service/v0/service.go | 12 + ...model_collection_of_drive_item_versions.go | 126 +++++++ .../model_drive_item_version.go | 312 +++++++++++++++++ 5 files changed, 1063 insertions(+) create mode 100644 services/graph/pkg/service/v0/driveitem_versions.go create mode 100644 services/graph/pkg/service/v0/driveitem_versions_test.go create mode 100644 vendor/github.com/opencloud-eu/libre-graph-api-go/model_collection_of_drive_item_versions.go create mode 100644 vendor/github.com/opencloud-eu/libre-graph-api-go/model_drive_item_version.go diff --git a/services/graph/pkg/service/v0/driveitem_versions.go b/services/graph/pkg/service/v0/driveitem_versions.go new file mode 100644 index 0000000000..e057b197c5 --- /dev/null +++ b/services/graph/pkg/service/v0/driveitem_versions.go @@ -0,0 +1,294 @@ +package svc + +import ( + "net/http" + "net/url" + "path" + "sort" + "time" + + cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + 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 _versionIDCurrent = "current" + +// ListDriveItemVersions lists the versions of a file +func (g Graph) ListDriveItemVersions(w http.ResponseWriter, r *http.Request) { + g.logger.Info().Msg("Calling ListDriveItemVersions") + + itemID, ok := g.fileDriveItemFromRequest(w, r) + if !ok { + return + } + + versions, ok := g.listDriveItemVersions(w, r, itemID) + if !ok { + return + } + + render.Status(r, http.StatusOK) + render.JSON(w, r, &ListResponse{Value: versions}) +} + +// GetDriveItemVersion returns a single version of a file +func (g Graph) GetDriveItemVersion(w http.ResponseWriter, r *http.Request) { + g.logger.Info().Msg("Calling GetDriveItemVersion") + + itemID, ok := g.fileDriveItemFromRequest(w, r) + if !ok { + return + } + versionID := chi.URLParam(r, "versionID") + + if versionID == _versionIDCurrent { + version, ok := g.currentDriveItemVersion(w, r, itemID) + if !ok { + return + } + render.Status(r, http.StatusOK) + render.JSON(w, r, version) + return + } + + version, ok := g.getDriveItemVersion(w, r, itemID, versionID) + if !ok { + return + } + + render.Status(r, http.StatusOK) + render.JSON(w, r, version) +} + +// GetDriveItemVersionContent redirects to a signed download url for a version +func (g Graph) GetDriveItemVersionContent(w http.ResponseWriter, r *http.Request) { + g.logger.Info().Msg("Calling GetDriveItemVersionContent") + + itemID, ok := g.fileDriveItemFromRequest(w, r) + if !ok { + return + } + versionID := chi.URLParam(r, "versionID") + + user, ok := revactx.ContextGetUser(r.Context()) + if !ok { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "user not in context") + return + } + + var downloadURL string + var err error + switch versionID { + case _versionIDCurrent: + downloadURL, err = g.signedDownloadURL(itemID, user.GetId().GetOpaqueId()) + default: + if _, ok := g.getDriveItemVersion(w, r, itemID, versionID); !ok { + return + } + downloadURL, err = g.signedVersionDownloadURL(itemID, versionID, user.GetId().GetOpaqueId()) + } + if err != nil { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return + } + + http.Redirect(w, r, downloadURL, http.StatusFound) +} + +// RestoreDriveItemVersion restores a version of a file +func (g Graph) RestoreDriveItemVersion(w http.ResponseWriter, r *http.Request) { + g.logger.Info().Msg("Calling RestoreDriveItemVersion") + + itemID, ok := g.fileDriveItemFromRequest(w, r) + if !ok { + return + } + versionID := chi.URLParam(r, "versionID") + + gatewayClient, err := g.gatewaySelector.Next() + if err != nil { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return + } + res, err := gatewayClient.RestoreFileVersion(r.Context(), &storageprovider.RestoreFileVersionRequest{ + Ref: &storageprovider.Reference{ResourceId: itemID}, + Key: versionID, + }) + if err := errorcode.FromCS3Status(res.GetStatus(), err); err != nil { + errorcode.RenderError(w, r, err) + return + } + + render.Status(r, http.StatusNoContent) + render.NoContent(w, r) +} + +// fileDriveItemFromRequest parses and stats the item, rendering 404 unless it is a file +func (g Graph) fileDriveItemFromRequest(w http.ResponseWriter, r *http.Request) (*storageprovider.ResourceId, bool) { + driveID, err := parseIDParam(r, "driveID") + if err != nil { + errorcode.RenderError(w, r, err) + return nil, false + } + itemID, err := parseIDParam(r, "driveItemID") + if err != nil { + errorcode.RenderError(w, r, err) + return nil, false + } + if driveID.GetStorageId() != itemID.GetStorageId() || driveID.GetSpaceId() != itemID.GetSpaceId() { + errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Item does not exist") + return nil, false + } + + gatewayClient, err := g.gatewaySelector.Next() + if err != nil { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return nil, false + } + res, err := gatewayClient.Stat(r.Context(), &storageprovider.StatRequest{Ref: &storageprovider.Reference{ResourceId: &itemID}}) + if !renderStatStatus(w, r, res, err) { + return nil, false + } + if res.GetInfo().GetType() != storageprovider.ResourceType_RESOURCE_TYPE_FILE { + errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Item is not a file") + return nil, false + } + return &itemID, true +} + +// renderStatStatus renders the error of a failed stat +func renderStatStatus(w http.ResponseWriter, r *http.Request, res *storageprovider.StatResponse, err error) bool { + switch { + case err != nil: + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return false + case res.GetStatus().GetCode() == cs3rpc.Code_CODE_OK: + return true + case res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND: + errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage()) + return false + case res.GetStatus().GetCode() == cs3rpc.Code_CODE_PERMISSION_DENIED: + errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage()) + return false + case res.GetStatus().GetCode() == cs3rpc.Code_CODE_UNAUTHENTICATED: + errorcode.Unauthenticated.Render(w, r, http.StatusUnauthorized, res.GetStatus().GetMessage()) + return false + default: + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage()) + return false + } +} + +func (g Graph) listDriveItemVersions(w http.ResponseWriter, r *http.Request, itemID *storageprovider.ResourceId) ([]libregraph.DriveItemVersion, bool) { + gatewayClient, err := g.gatewaySelector.Next() + if err != nil { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return nil, false + } + res, err := gatewayClient.ListFileVersions(r.Context(), &storageprovider.ListFileVersionsRequest{ + Ref: &storageprovider.Reference{ResourceId: itemID}, + }) + if err := errorcode.FromCS3Status(res.GetStatus(), err); err != nil { + errorcode.RenderError(w, r, err) + return nil, false + } + + fileVersions := res.GetVersions() + // newest first, the storage does not guarantee an order + sort.SliceStable(fileVersions, func(i, j int) bool { + if fileVersions[i].GetMtime() != fileVersions[j].GetMtime() { + return fileVersions[i].GetMtime() > fileVersions[j].GetMtime() + } + return fileVersions[i].GetKey() > fileVersions[j].GetKey() + }) + + versions := make([]libregraph.DriveItemVersion, 0, len(fileVersions)) + for _, fv := range fileVersions { + version := libregraph.NewDriveItemVersion() + version.SetId(fv.GetKey()) + version.SetLastModifiedDateTime(time.Unix(int64(fv.GetMtime()), 0).UTC()) + version.SetSize(int64(fv.GetSize())) + g.setDriveItemVersionDownloadURL(r, version, itemID, fv.GetKey()) + versions = append(versions, *version) + } + return versions, true +} + +func (g Graph) getDriveItemVersion(w http.ResponseWriter, r *http.Request, itemID *storageprovider.ResourceId, versionID string) (*libregraph.DriveItemVersion, bool) { + versions, ok := g.listDriveItemVersions(w, r, itemID) + if !ok { + return nil, false + } + for i := range versions { + if versions[i].GetId() == versionID { + return &versions[i], true + } + } + errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Version does not exist") + return nil, false +} + +// currentDriveItemVersion describes the file itself as a version +func (g Graph) currentDriveItemVersion(w http.ResponseWriter, r *http.Request, itemID *storageprovider.ResourceId) (*libregraph.DriveItemVersion, bool) { + gatewayClient, err := g.gatewaySelector.Next() + if err != nil { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return nil, false + } + res, err := gatewayClient.Stat(r.Context(), &storageprovider.StatRequest{Ref: &storageprovider.Reference{ResourceId: itemID}}) + if !renderStatStatus(w, r, res, err) { + return nil, false + } + + version := libregraph.NewDriveItemVersion() + version.SetId(_versionIDCurrent) + version.SetLastModifiedDateTime(cs3TimestampToTime(res.GetInfo().GetMtime()).UTC()) + version.SetSize(int64(res.GetInfo().GetSize())) + if user, ok := revactx.ContextGetUser(r.Context()); ok && g.downloadURLRequested(r) { + if u, err := g.signedDownloadURL(itemID, user.GetId().GetOpaqueId()); err == nil { + version.SetMicrosoftGraphDownloadUrl(u) + } + } + return version, true +} + +func (g Graph) setDriveItemVersionDownloadURL(r *http.Request, version *libregraph.DriveItemVersion, itemID *storageprovider.ResourceId, key string) { + if !g.downloadURLRequested(r) { + return + } + user, ok := revactx.ContextGetUser(r.Context()) + if !ok { + return + } + u, err := g.signedVersionDownloadURL(itemID, key, user.GetId().GetOpaqueId()) + if err != nil { + return + } + version.SetMicrosoftGraphDownloadUrl(u) +} + +// signedVersionDownloadURL signs a download url for the WebDAV meta endpoint of a version +func (g BaseGraphService) signedVersionDownloadURL(itemID *storageprovider.ResourceId, key, userID string) (string, error) { + if g.downloadSigner == nil { + return "", ErrDownloadURLSigningNotConfigured + } + u, err := g.getWebDavMetaURL() + if err != nil { + return "", err + } + u.Path = path.Join(u.Path, storagespace.FormatResourceID(itemID), "v", key) + return g.downloadSigner.Sign(u.String(), userID, downloadURLTTL) +} + +func (g BaseGraphService) getWebDavMetaURL() (*url.URL, error) { + u := *g.publicBaseURL + u.Path = path.Join(u.Path, path.Dir(path.Clean(g.config.Spaces.WebDavPath)), "meta") + return &u, nil +} diff --git a/services/graph/pkg/service/v0/driveitem_versions_test.go b/services/graph/pkg/service/v0/driveitem_versions_test.go new file mode 100644 index 0000000000..d7033b2cf9 --- /dev/null +++ b/services/graph/pkg/service/v0/driveitem_versions_test.go @@ -0,0 +1,319 @@ +package svc_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "time" + + 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" + libregraph "github.com/opencloud-eu/libre-graph-api-go" + "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" + "github.com/opencloud-eu/reva/v2/pkg/utils" + 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" +) + +var _ = Describe("DriveItemVersions", func() { + const ( + urlSigningSecret = "url-signing-secret" + driveID = "storageid$spaceid" + itemID = "storageid$spaceid!nodeid" + olderKey = "nodeid.REV.2026-09-01T10:00:00.000000000Z" + newerKey = "nodeid.REV.2026-09-05T10:00:00.000000000Z" + ) + + var ( + svc service.Service + ctx context.Context + gatewayClient *cs3mocks.GatewayAPIClient + gatewaySelector pool.Selectable[gateway.GatewayAPIClient] + eventsPublisher mocks.Publisher + identityBackend *identitymocks.Backend + rr *httptest.ResponseRecorder + + fileInfo *provider.ResourceInfo + older *provider.FileVersion + newer *provider.FileVersion + fileTime = time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + + currentUser = &userpb.User{ + Id: &userpb.UserId{ + OpaqueId: "user", + }, + } + ) + + newRequest := func(method, versionPath, query string) *http.Request { + r := httptest.NewRequest(method, "/graph/v1.0/drives/"+driveID+"/items/"+itemID+"/versions"+versionPath+query, nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("driveID", driveID) + rctx.URLParams.Add("driveItemID", itemID) + return r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + } + + newVersionRequest := func(method, versionID, suffix, query string) *http.Request { + r := newRequest(method, "/"+versionID+suffix, query) + chi.RouteContext(r.Context()).URLParams.Add("versionID", versionID) + return r + } + + verifiedTarget := func(signed string) *url.URL { + verifier, err := signedurl.NewJWTSignedURL(signedurl.WithSecret(urlSigningSecret)) + Expect(err).ToNot(HaveOccurred()) + subject, err := verifier.Verify(signed) + Expect(err).ToNot(HaveOccurred()) + Expect(subject).To(Equal("user")) + + target, err := url.Parse(signed) + Expect(err).ToNot(HaveOccurred()) + return target + } + + 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 + }, + ) + + logger := log.NewLogger() + identityBackend = &identitymocks.Backend{} + metrics, _ := metrics.New(prometheus.NewRegistry(), &logger, func([]string) (string, string) { return "", "" }) + + rr = httptest.NewRecorder() + ctx = context.Background() + + 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{ + URLSigningSecret: urlSigningSecret, + } + cfg.GRPCClientTLS = &shared.GRPCClientTLS{} + + var err error + svc, err = service.NewService( + service.Config(cfg), + service.Metrics(metrics), + service.WithGatewaySelector(gatewaySelector), + service.EventsPublisher(&eventsPublisher), + service.WithIdentityBackend(identityBackend), + ) + Expect(err).ToNot(HaveOccurred()) + + fileInfo = &provider.ResourceInfo{ + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "nodeid"}, + Etag: "etag", + Size: 300, + Mtime: utils.TimeToTS(fileTime), + } + older = &provider.FileVersion{Key: olderKey, Size: 100, Mtime: uint64(fileTime.Add(-6 * 24 * time.Hour).Unix())} + newer = &provider.FileVersion{Key: newerKey, Size: 200, Mtime: uint64(fileTime.Add(-2 * 24 * time.Hour).Unix())} + + gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&provider.StatResponse{ + Status: status.NewOK(ctx), + Info: fileInfo, + }, nil) + gatewayClient.On("ListFileVersions", mock.Anything, mock.Anything).Return(&provider.ListFileVersionsResponse{ + Status: status.NewOK(ctx), + Versions: []*provider.FileVersion{older, newer}, + }, nil) + }) + + Describe("ListDriveItemVersions", func() { + list := func(r *http.Request) []libregraph.DriveItemVersion { + svc.ListDriveItemVersions(rr, r) + Expect(rr.Code).To(Equal(http.StatusOK)) + data, err := io.ReadAll(rr.Body) + Expect(err).ToNot(HaveOccurred()) + + res := libregraph.CollectionOfDriveItemVersions{} + Expect(json.Unmarshal(data, &res)).To(Succeed()) + return res.Value + } + + It("lists the versions newest first", func() { + versions := list(newRequest(http.MethodGet, "", "")) + Expect(versions).To(HaveLen(2)) + + Expect(versions[0].GetId()).To(Equal(newerKey)) + Expect(versions[0].GetSize()).To(Equal(int64(200))) + Expect(versions[0].GetLastModifiedDateTime()).To(Equal(fileTime.Add(-2 * 24 * time.Hour))) + Expect(versions[0].MicrosoftGraphDownloadUrl).To(BeNil()) + Expect(versions[0].LastModifiedBy).To(BeNil()) + + Expect(versions[1].GetId()).To(Equal(olderKey)) + Expect(versions[1].GetSize()).To(Equal(int64(100))) + }) + + It("returns an empty list for a file without versions", func() { + gatewayClient.ExpectedCalls = nil + gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&provider.StatResponse{Status: status.NewOK(ctx), Info: fileInfo}, nil) + gatewayClient.On("ListFileVersions", mock.Anything, mock.Anything).Return(&provider.ListFileVersionsResponse{Status: status.NewOK(ctx)}, nil) + + svc.ListDriveItemVersions(rr, newRequest(http.MethodGet, "", "")) + Expect(rr.Code).To(Equal(http.StatusOK)) + data, err := io.ReadAll(rr.Body) + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(MatchJSON(`{"value":[]}`)) + }) + + It("adds a signed download url for every version when requested via $select", func() { + versions := list(newRequest(http.MethodGet, "", "?$select=@microsoft.graph.downloadUrl")) + Expect(versions).To(HaveLen(2)) + + target := verifiedTarget(versions[0].GetMicrosoftGraphDownloadUrl()) + Expect(target.Path).To(Equal("/dav/meta/" + itemID + "/v/" + newerKey)) + Expect(verifiedTarget(versions[1].GetMicrosoftGraphDownloadUrl()).Path).To(Equal("/dav/meta/" + itemID + "/v/" + olderKey)) + }) + + It("returns 404 for a folder", func() { + fileInfo.Type = provider.ResourceType_RESOURCE_TYPE_CONTAINER + + svc.ListDriveItemVersions(rr, newRequest(http.MethodGet, "", "")) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + gatewayClient.AssertNotCalled(GinkgoT(), "ListFileVersions", mock.Anything, mock.Anything) + }) + + 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) + + svc.ListDriveItemVersions(rr, newRequest(http.MethodGet, "", "")) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 when the item belongs to another drive", func() { + r := newRequest(http.MethodGet, "", "") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("driveID", "storageid$otherspace") + rctx.URLParams.Add("driveItemID", itemID) + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) + + svc.ListDriveItemVersions(rr, r) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + gatewayClient.AssertNotCalled(GinkgoT(), "Stat", mock.Anything, mock.Anything) + }) + }) + + Describe("GetDriveItemVersion", func() { + get := func(r *http.Request) libregraph.DriveItemVersion { + svc.GetDriveItemVersion(rr, r) + Expect(rr.Code).To(Equal(http.StatusOK)) + data, err := io.ReadAll(rr.Body) + Expect(err).ToNot(HaveOccurred()) + + version := libregraph.DriveItemVersion{} + Expect(json.Unmarshal(data, &version)).To(Succeed()) + return version + } + + It("returns the requested version", func() { + version := get(newVersionRequest(http.MethodGet, olderKey, "", "")) + Expect(version.GetId()).To(Equal(olderKey)) + Expect(version.GetSize()).To(Equal(int64(100))) + Expect(version.MicrosoftGraphDownloadUrl).To(BeNil()) + }) + + It("adds a signed download url when requested via $select", func() { + version := get(newVersionRequest(http.MethodGet, olderKey, "", "?$select=@microsoft.graph.downloadUrl")) + Expect(verifiedTarget(version.GetMicrosoftGraphDownloadUrl()).Path).To(Equal("/dav/meta/" + itemID + "/v/" + olderKey)) + }) + + It("describes the file itself as the current version", func() { + version := get(newVersionRequest(http.MethodGet, "current", "", "?$select=@microsoft.graph.downloadUrl")) + Expect(version.GetId()).To(Equal("current")) + Expect(version.GetSize()).To(Equal(int64(300))) + Expect(version.GetLastModifiedDateTime()).To(Equal(fileTime)) + Expect(verifiedTarget(version.GetMicrosoftGraphDownloadUrl()).Path).To(Equal("/dav/spaces/" + itemID)) + gatewayClient.AssertNotCalled(GinkgoT(), "ListFileVersions", mock.Anything, mock.Anything) + }) + + It("returns 404 for an unknown version", func() { + svc.GetDriveItemVersion(rr, newVersionRequest(http.MethodGet, "nodeid.REV.unknown", "", "")) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("GetDriveItemVersionContent", func() { + It("redirects to a signed download url for the version", func() { + svc.GetDriveItemVersionContent(rr, newVersionRequest(http.MethodGet, olderKey, "/content", "")) + Expect(rr.Code).To(Equal(http.StatusFound)) + Expect(verifiedTarget(rr.Header().Get("Location")).Path).To(Equal("/dav/meta/" + itemID + "/v/" + olderKey)) + }) + + It("redirects to the file itself for the current version", func() { + svc.GetDriveItemVersionContent(rr, newVersionRequest(http.MethodGet, "current", "/content", "")) + Expect(rr.Code).To(Equal(http.StatusFound)) + Expect(verifiedTarget(rr.Header().Get("Location")).Path).To(Equal("/dav/spaces/" + itemID)) + }) + + It("returns 404 for an unknown version", func() { + svc.GetDriveItemVersionContent(rr, newVersionRequest(http.MethodGet, "nodeid.REV.unknown", "/content", "")) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("RestoreDriveItemVersion", func() { + It("restores the version and answers 204", func() { + gatewayClient.On("RestoreFileVersion", mock.Anything, mock.MatchedBy(func(req *provider.RestoreFileVersionRequest) bool { + return req.GetKey() == olderKey && req.GetRef().GetResourceId().GetOpaqueId() == "nodeid" + })).Return(&provider.RestoreFileVersionResponse{Status: status.NewOK(ctx)}, nil) + + svc.RestoreDriveItemVersion(rr, newVersionRequest(http.MethodPost, olderKey, "/restoreVersion", "")) + Expect(rr.Code).To(Equal(http.StatusNoContent)) + }) + + It("returns 423 when the file is locked", func() { + gatewayClient.On("RestoreFileVersion", mock.Anything, mock.Anything).Return(&provider.RestoreFileVersionResponse{Status: status.NewLocked(ctx, "locked")}, nil) + + svc.RestoreDriveItemVersion(rr, newVersionRequest(http.MethodPost, olderKey, "/restoreVersion", "")) + Expect(rr.Code).To(Equal(http.StatusLocked)) + }) + + It("returns 404 for an unknown version", func() { + gatewayClient.On("RestoreFileVersion", mock.Anything, mock.Anything).Return(&provider.RestoreFileVersionResponse{Status: status.NewNotFound(ctx, "not found")}, nil) + + svc.RestoreDriveItemVersion(rr, newVersionRequest(http.MethodPost, "nodeid.REV.unknown", "/restoreVersion", "")) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 for a folder", func() { + fileInfo.Type = provider.ResourceType_RESOURCE_TYPE_CONTAINER + + svc.RestoreDriveItemVersion(rr, newVersionRequest(http.MethodPost, olderKey, "/restoreVersion", "")) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + gatewayClient.AssertNotCalled(GinkgoT(), "RestoreFileVersion", mock.Anything, mock.Anything) + }) + }) +}) diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go index c189e61d1a..ff41d60062 100644 --- a/services/graph/pkg/service/v0/service.go +++ b/services/graph/pkg/service/v0/service.go @@ -106,6 +106,10 @@ type Service interface { //nolint:interfacebloat GetDriveItem(w http.ResponseWriter, r *http.Request) GetDriveItemChildren(w http.ResponseWriter, r *http.Request) GetDriveItemContent(w http.ResponseWriter, r *http.Request) + ListDriveItemVersions(w http.ResponseWriter, r *http.Request) + GetDriveItemVersion(w http.ResponseWriter, r *http.Request) + GetDriveItemVersionContent(w http.ResponseWriter, r *http.Request) + RestoreDriveItemVersion(w http.ResponseWriter, r *http.Request) CreateUploadSession(w http.ResponseWriter, r *http.Request) @@ -384,6 +388,14 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx r.Get("/", svc.GetDriveItem) r.Get("/children", svc.GetDriveItemChildren) r.Post("/createUploadSession", svc.CreateUploadSession) + r.Route("/versions", func(r chi.Router) { + r.Get("/", svc.ListDriveItemVersions) + r.Route("/{versionID}", func(r chi.Router) { + r.Get("/", svc.GetDriveItemVersion) + r.Get("/content", svc.GetDriveItemVersionContent) + r.Post("/restoreVersion", svc.RestoreDriveItemVersion) + }) + }) }) }) }) diff --git a/vendor/github.com/opencloud-eu/libre-graph-api-go/model_collection_of_drive_item_versions.go b/vendor/github.com/opencloud-eu/libre-graph-api-go/model_collection_of_drive_item_versions.go new file mode 100644 index 0000000000..eb1c133e68 --- /dev/null +++ b/vendor/github.com/opencloud-eu/libre-graph-api-go/model_collection_of_drive_item_versions.go @@ -0,0 +1,126 @@ +/* +Libre Graph API + +Libre Graph is a free API for cloud collaboration inspired by the MS Graph API. + +API version: v1.0.8 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package libregraph + +import ( + "encoding/json" +) + +// checks if the CollectionOfDriveItemVersions type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &CollectionOfDriveItemVersions{} + +// CollectionOfDriveItemVersions struct for CollectionOfDriveItemVersions +type CollectionOfDriveItemVersions struct { + Value []DriveItemVersion `json:"value,omitempty"` +} + +// NewCollectionOfDriveItemVersions instantiates a new CollectionOfDriveItemVersions object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewCollectionOfDriveItemVersions() *CollectionOfDriveItemVersions { + this := CollectionOfDriveItemVersions{} + return &this +} + +// NewCollectionOfDriveItemVersionsWithDefaults instantiates a new CollectionOfDriveItemVersions object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewCollectionOfDriveItemVersionsWithDefaults() *CollectionOfDriveItemVersions { + this := CollectionOfDriveItemVersions{} + return &this +} + +// GetValue returns the Value field value if set, zero value otherwise. +func (o *CollectionOfDriveItemVersions) GetValue() []DriveItemVersion { + if o == nil || IsNil(o.Value) { + var ret []DriveItemVersion + return ret + } + return o.Value +} + +// GetValueOk returns a tuple with the Value field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *CollectionOfDriveItemVersions) GetValueOk() ([]DriveItemVersion, bool) { + if o == nil || IsNil(o.Value) { + return nil, false + } + return o.Value, true +} + +// HasValue returns a boolean if a field has been set. +func (o *CollectionOfDriveItemVersions) HasValue() bool { + if o != nil && !IsNil(o.Value) { + return true + } + + return false +} + +// SetValue gets a reference to the given []DriveItemVersion and assigns it to the Value field. +func (o *CollectionOfDriveItemVersions) SetValue(v []DriveItemVersion) { + o.Value = v +} + +func (o CollectionOfDriveItemVersions) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o CollectionOfDriveItemVersions) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.Value) { + toSerialize["value"] = o.Value + } + return toSerialize, nil +} + +type NullableCollectionOfDriveItemVersions struct { + value *CollectionOfDriveItemVersions + isSet bool +} + +func (v NullableCollectionOfDriveItemVersions) Get() *CollectionOfDriveItemVersions { + return v.value +} + +func (v *NullableCollectionOfDriveItemVersions) Set(val *CollectionOfDriveItemVersions) { + v.value = val + v.isSet = true +} + +func (v NullableCollectionOfDriveItemVersions) IsSet() bool { + return v.isSet +} + +func (v *NullableCollectionOfDriveItemVersions) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableCollectionOfDriveItemVersions(val *CollectionOfDriveItemVersions) *NullableCollectionOfDriveItemVersions { + return &NullableCollectionOfDriveItemVersions{value: val, isSet: true} +} + +func (v NullableCollectionOfDriveItemVersions) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableCollectionOfDriveItemVersions) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + + diff --git a/vendor/github.com/opencloud-eu/libre-graph-api-go/model_drive_item_version.go b/vendor/github.com/opencloud-eu/libre-graph-api-go/model_drive_item_version.go new file mode 100644 index 0000000000..ffcd5bf759 --- /dev/null +++ b/vendor/github.com/opencloud-eu/libre-graph-api-go/model_drive_item_version.go @@ -0,0 +1,312 @@ +/* +Libre Graph API + +Libre Graph is a free API for cloud collaboration inspired by the MS Graph API. + +API version: v1.0.8 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package libregraph + +import ( + "encoding/json" + "time" +) + +// checks if the DriveItemVersion type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &DriveItemVersion{} + +// DriveItemVersion Represents a specific version of a driveItem. Read-only. Modeled on the MS Graph driveItemVersion resource (https://learn.microsoft.com/en-us/graph/api/resources/driveitemversion). The `publication` facet is not supported, OpenCloud has no checkout / publish workflow. +type DriveItemVersion struct { + // The ID of the version. Read-only. + Id *string `json:"id,omitempty"` + LastModifiedBy *IdentitySet `json:"lastModifiedBy,omitempty"` + // Date and time the version was last modified. Read-only. + LastModifiedDateTime *time.Time `json:"lastModifiedDateTime,omitempty" validate:"regexp=^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])[Tt]([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?([Zz]|[+-][0-9][0-9]:[0-9][0-9])$"` + // Size of the version content in bytes. Read-only. + Size *int64 `json:"size,omitempty"` + // The content stream of this version. Use the `/content` endpoint of the version to download it. + Content *string `json:"content,omitempty"` + // A pre-authenticated URL that can be used to download the content of this version without providing an Authorization header. The URL is short-lived and cannot be cached. This annotation is only populated when explicitly requested via `$select`, matching the behaviour of the annotation on the driveItem. + MicrosoftGraphDownloadUrl *string `json:"@microsoft.graph.downloadUrl,omitempty"` +} + +// NewDriveItemVersion instantiates a new DriveItemVersion object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewDriveItemVersion() *DriveItemVersion { + this := DriveItemVersion{} + return &this +} + +// NewDriveItemVersionWithDefaults instantiates a new DriveItemVersion object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewDriveItemVersionWithDefaults() *DriveItemVersion { + this := DriveItemVersion{} + return &this +} + +// GetId returns the Id field value if set, zero value otherwise. +func (o *DriveItemVersion) GetId() string { + if o == nil || IsNil(o.Id) { + var ret string + return ret + } + return *o.Id +} + +// GetIdOk returns a tuple with the Id field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DriveItemVersion) GetIdOk() (*string, bool) { + if o == nil || IsNil(o.Id) { + return nil, false + } + return o.Id, true +} + +// HasId returns a boolean if a field has been set. +func (o *DriveItemVersion) HasId() bool { + if o != nil && !IsNil(o.Id) { + return true + } + + return false +} + +// SetId gets a reference to the given string and assigns it to the Id field. +func (o *DriveItemVersion) SetId(v string) { + o.Id = &v +} + +// GetLastModifiedBy returns the LastModifiedBy field value if set, zero value otherwise. +func (o *DriveItemVersion) GetLastModifiedBy() IdentitySet { + if o == nil || IsNil(o.LastModifiedBy) { + var ret IdentitySet + return ret + } + return *o.LastModifiedBy +} + +// GetLastModifiedByOk returns a tuple with the LastModifiedBy field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DriveItemVersion) GetLastModifiedByOk() (*IdentitySet, bool) { + if o == nil || IsNil(o.LastModifiedBy) { + return nil, false + } + return o.LastModifiedBy, true +} + +// HasLastModifiedBy returns a boolean if a field has been set. +func (o *DriveItemVersion) HasLastModifiedBy() bool { + if o != nil && !IsNil(o.LastModifiedBy) { + return true + } + + return false +} + +// SetLastModifiedBy gets a reference to the given IdentitySet and assigns it to the LastModifiedBy field. +func (o *DriveItemVersion) SetLastModifiedBy(v IdentitySet) { + o.LastModifiedBy = &v +} + +// GetLastModifiedDateTime returns the LastModifiedDateTime field value if set, zero value otherwise. +func (o *DriveItemVersion) GetLastModifiedDateTime() time.Time { + if o == nil || IsNil(o.LastModifiedDateTime) { + var ret time.Time + return ret + } + return *o.LastModifiedDateTime +} + +// GetLastModifiedDateTimeOk returns a tuple with the LastModifiedDateTime field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DriveItemVersion) GetLastModifiedDateTimeOk() (*time.Time, bool) { + if o == nil || IsNil(o.LastModifiedDateTime) { + return nil, false + } + return o.LastModifiedDateTime, true +} + +// HasLastModifiedDateTime returns a boolean if a field has been set. +func (o *DriveItemVersion) HasLastModifiedDateTime() bool { + if o != nil && !IsNil(o.LastModifiedDateTime) { + return true + } + + return false +} + +// SetLastModifiedDateTime gets a reference to the given time.Time and assigns it to the LastModifiedDateTime field. +func (o *DriveItemVersion) SetLastModifiedDateTime(v time.Time) { + o.LastModifiedDateTime = &v +} + +// GetSize returns the Size field value if set, zero value otherwise. +func (o *DriveItemVersion) GetSize() int64 { + if o == nil || IsNil(o.Size) { + var ret int64 + return ret + } + return *o.Size +} + +// GetSizeOk returns a tuple with the Size field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DriveItemVersion) GetSizeOk() (*int64, bool) { + if o == nil || IsNil(o.Size) { + return nil, false + } + return o.Size, true +} + +// HasSize returns a boolean if a field has been set. +func (o *DriveItemVersion) HasSize() bool { + if o != nil && !IsNil(o.Size) { + return true + } + + return false +} + +// SetSize gets a reference to the given int64 and assigns it to the Size field. +func (o *DriveItemVersion) SetSize(v int64) { + o.Size = &v +} + +// GetContent returns the Content field value if set, zero value otherwise. +func (o *DriveItemVersion) GetContent() string { + if o == nil || IsNil(o.Content) { + var ret string + return ret + } + return *o.Content +} + +// GetContentOk returns a tuple with the Content field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DriveItemVersion) GetContentOk() (*string, bool) { + if o == nil || IsNil(o.Content) { + return nil, false + } + return o.Content, true +} + +// HasContent returns a boolean if a field has been set. +func (o *DriveItemVersion) HasContent() bool { + if o != nil && !IsNil(o.Content) { + return true + } + + return false +} + +// SetContent gets a reference to the given string and assigns it to the Content field. +func (o *DriveItemVersion) SetContent(v string) { + o.Content = &v +} + +// GetMicrosoftGraphDownloadUrl returns the MicrosoftGraphDownloadUrl field value if set, zero value otherwise. +func (o *DriveItemVersion) GetMicrosoftGraphDownloadUrl() string { + if o == nil || IsNil(o.MicrosoftGraphDownloadUrl) { + var ret string + return ret + } + return *o.MicrosoftGraphDownloadUrl +} + +// GetMicrosoftGraphDownloadUrlOk returns a tuple with the MicrosoftGraphDownloadUrl field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DriveItemVersion) GetMicrosoftGraphDownloadUrlOk() (*string, bool) { + if o == nil || IsNil(o.MicrosoftGraphDownloadUrl) { + return nil, false + } + return o.MicrosoftGraphDownloadUrl, true +} + +// HasMicrosoftGraphDownloadUrl returns a boolean if a field has been set. +func (o *DriveItemVersion) HasMicrosoftGraphDownloadUrl() bool { + if o != nil && !IsNil(o.MicrosoftGraphDownloadUrl) { + return true + } + + return false +} + +// SetMicrosoftGraphDownloadUrl gets a reference to the given string and assigns it to the MicrosoftGraphDownloadUrl field. +func (o *DriveItemVersion) SetMicrosoftGraphDownloadUrl(v string) { + o.MicrosoftGraphDownloadUrl = &v +} + +func (o DriveItemVersion) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o DriveItemVersion) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.Id) { + toSerialize["id"] = o.Id + } + if !IsNil(o.LastModifiedBy) { + toSerialize["lastModifiedBy"] = o.LastModifiedBy + } + if !IsNil(o.LastModifiedDateTime) { + toSerialize["lastModifiedDateTime"] = o.LastModifiedDateTime + } + if !IsNil(o.Size) { + toSerialize["size"] = o.Size + } + if !IsNil(o.Content) { + toSerialize["content"] = o.Content + } + if !IsNil(o.MicrosoftGraphDownloadUrl) { + toSerialize["@microsoft.graph.downloadUrl"] = o.MicrosoftGraphDownloadUrl + } + return toSerialize, nil +} + +type NullableDriveItemVersion struct { + value *DriveItemVersion + isSet bool +} + +func (v NullableDriveItemVersion) Get() *DriveItemVersion { + return v.value +} + +func (v *NullableDriveItemVersion) Set(val *DriveItemVersion) { + v.value = val + v.isSet = true +} + +func (v NullableDriveItemVersion) IsSet() bool { + return v.isSet +} + +func (v *NullableDriveItemVersion) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableDriveItemVersion(val *DriveItemVersion) *NullableDriveItemVersion { + return &NullableDriveItemVersion{value: val, isSet: true} +} + +func (v NullableDriveItemVersion) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableDriveItemVersion) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + +