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) +} + +