From edcfa95a99625cce867f1c23d1f50986bb9b5628 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Mon, 7 Sep 2026 12:28:44 +0200 Subject: [PATCH 1/5] graph: add drive special folder endpoints for the recycle bin --- services/graph/pkg/middleware/path_lookup.go | 83 +++-- .../graph/pkg/middleware/path_lookup_test.go | 103 ++++++ .../pkg/service/v0/driveitems_special.go | 219 +++++++++++++ .../pkg/service/v0/driveitems_special_test.go | 309 ++++++++++++++++++ services/graph/pkg/service/v0/service.go | 6 + 5 files changed, 700 insertions(+), 20 deletions(-) create mode 100644 services/graph/pkg/service/v0/driveitems_special.go create mode 100644 services/graph/pkg/service/v0/driveitems_special_test.go diff --git a/services/graph/pkg/middleware/path_lookup.go b/services/graph/pkg/middleware/path_lookup.go index 1fc89bcd6c..9d48fcc085 100644 --- a/services/graph/pkg/middleware/path_lookup.go +++ b/services/graph/pkg/middleware/path_lookup.go @@ -27,8 +27,9 @@ import ( // and exposes the remainder via chi.RouteContext().RoutePath. parseColonPath // therefore only needs to handle the part below the drive: // -// root-anchored: /root:/[:/][:] -// item-anchored: /items/{itemID}:/[:/][:] +// root-anchored: /root:/[:/][:] +// item-anchored: /items/{itemID}:/[:/][:] +// special-anchored: /special/{specialName}:/[:/][:] type contextKey string @@ -36,6 +37,24 @@ type contextKey string // tracing/logging consumers. const OriginalPathContextKey contextKey = "graph.original_path" +// specialPathContextKey holds the decoded path below a special folder for the +// special-anchored colon form. Special folders are not part of the item tree, +// so the path cannot be resolved to an item id here; the handler interprets it. +const specialPathContextKey contextKey = "graph.special_path" + +// SpecialFolderPath returns the path below the special folder for a request +// that arrived in the special-anchored colon form, e.g. "/key/sub" for +// /special/recyclebin:/key/sub:/children. ok is false for plain requests. +func SpecialFolderPath(ctx context.Context) (string, bool) { + p, ok := ctx.Value(specialPathContextKey).(string) + return p, ok +} + +// WithSpecialFolderPath stores the decoded path below a special folder, see SpecialFolderPath. +func WithSpecialFolderPath(ctx context.Context, p string) context.Context { + return context.WithValue(ctx, specialPathContextKey, p) +} + // Sentinels distinguishing the resolution outcomes that map to specific HTTP // statuses. Anything else surfaces as 500. // @@ -64,12 +83,17 @@ var ( // descended into a sub-router, routeHTTP matches against rctx.RoutePath and // ignores r.URL.Path.) // -// Two URL shapes are recognized: +// Three URL shapes are recognized: // // /drives/{driveID}/root:/[:/][:] // /drives/{driveID}/items/{itemID}:/[:/][:] +// /drives/{driveID}/special/{specialName}:/[:/][:] // -// Path resolution runs as the request user via CS3 Stat. NOT_FOUND and +// The special-anchored form is rewritten to /special/{specialName}{suffix} +// without a lookup; the decoded path travels in the context (see +// SpecialFolderPath) because special folders live outside the item tree. +// +// Path resolution for the other two runs as the request user via CS3 Stat. NOT_FOUND and // PERMISSION_DENIED collapse to 404 (no existence disclosure); operational // failures (gateway selection, RPC transport, unexpected status) surface // as 5xx so outages aren't masked. @@ -89,9 +113,30 @@ func ResolveGraphPath(gws pool.Selectable[gateway.GatewayAPIClient], logger log. return } + match, ok := parseColonPath(rctx.RoutePath) + if !ok { + // No colon-syntax match - pass through untouched. + next.ServeHTTP(w, r) + return + } + driveID := chi.URLParam(r, "driveID") original := r.URL.Path - rewritten, err := rewriteColonPath(r.Context(), gws, l, driveID, rctx.RoutePath) + var rewritten string + var err error + if match.specialName != "" { + var specialPath string + specialPath, err = url.PathUnescape(match.relPath) + if err != nil { + l.Debug().Err(err).Str("relPath", match.relPath).Msg("undecodable path in special colon path") + err = errInvalidRequest + } else { + r = r.WithContext(WithSpecialFolderPath(r.Context(), specialPath)) + rewritten = "/special/" + match.specialName + match.suffix + } + } else { + rewritten, err = rewriteColonPath(r.Context(), gws, l, driveID, match) + } switch { case errors.Is(err, errPathNotFound): l.Debug().Str("original", original).Msg("colon-path resolution: not found") @@ -111,10 +156,6 @@ func ResolveGraphPath(gws pool.Selectable[gateway.GatewayAPIClient], logger log. w, r, http.StatusInternalServerError, "internal error resolving path", ) return - case rewritten == "": - // No colon-syntax match - pass through untouched. - next.ServeHTTP(w, r) - return } l.Debug(). @@ -141,32 +182,26 @@ func ResolveGraphPath(gws pool.Selectable[gateway.GatewayAPIClient], logger log. type colonMatch struct { isItemAnchored bool // item-anchored form: anchor is itemAnchorID, validate against driveID itemAnchorID string // itemID from the path for the item-anchored form (empty for root-anchored) + specialName string // special-anchored form: the special folder name (empty otherwise) relPath string // relative path with leading slash suffix string // suffix with leading slash (e.g. "/children"); may be empty } -// rewriteColonPath returns: -// - "" + nil - no colon-syntax pattern matched (passthrough) -// - rewritten + nil - matched and resolved to a canonical RoutePath +// rewriteColonPath resolves a root- or item-anchored match and returns: +// - rewritten + nil - resolved to a canonical RoutePath // - "" + errPathNotFound - path doesn't exist or user lacks permission (404) // - "" + errInvalidRequest - malformed input (400) // - "" + errUnauthenticated - gateway said caller isn't authenticated (401) // - "" + other error - operational / internal failure (5xx) // -// driveIDParam is the {driveID} route param (raw chi.URLParam value); routePath -// is chi.RouteContext().RoutePath (the part below /drives/{driveID}). +// driveIDParam is the {driveID} route param (raw chi.URLParam value). func rewriteColonPath( ctx context.Context, gws pool.Selectable[gateway.GatewayAPIClient], logger zerolog.Logger, driveIDParam string, - routePath string, + match colonMatch, ) (string, error) { - match, ok := parseColonPath(routePath) - if !ok { - return "", nil - } - // RoutePath follows chi's RawPath, i.e. the percent-encoded wire form // (e.g. "/Documents/My%20File"). A single PathUnescape reproduces exactly // what net/http put in r.URL.Path; it is NOT a double-decode (a crafted @@ -233,6 +268,7 @@ func rewriteColonPath( // // /root:/[:/][:] // /items/:/[:/][:] +// /special/:/[:/][:] // // The structural delimiter is ":/" (a colon immediately followed by the // leading slash of the path or suffix); a trailing ":" is the no-suffix @@ -279,6 +315,13 @@ func parseColonPath(routePath string) (colonMatch, bool) { } m.isItemAnchored = true m.itemAnchorID = itemID + case strings.HasPrefix(anchor, "/special/"): + // Special-anchored: /special/{specialName} with a single-segment name. + name := strings.TrimPrefix(anchor, "/special/") + if name == "" || strings.Contains(name, "/") { + return m, false + } + m.specialName = name default: return m, false } diff --git a/services/graph/pkg/middleware/path_lookup_test.go b/services/graph/pkg/middleware/path_lookup_test.go index fa866d2fed..8f7c362c90 100644 --- a/services/graph/pkg/middleware/path_lookup_test.go +++ b/services/graph/pkg/middleware/path_lookup_test.go @@ -66,6 +66,10 @@ type leafCapture struct { driveID string // chi.URLParam(driveID) itemID string // resolved item id, decoded via PathUnescape original any // OriginalPathContextKey value + + specialName string // chi.URLParam(specialName) for the special leaves + specialPath string // middleware.SpecialFolderPath value + specialPathSet bool // whether SpecialFolderPath reported ok } // newGraphTestRouter wires ResolveGraphPath into a chi router that mirrors the @@ -94,6 +98,8 @@ func newGraphTestRouter(t *testing.T, gw *cs3mocks.GatewayAPIClient) (http.Handl // mirror that here so we assert on the recovered id. cap.itemID, _ = url.PathUnescape(raw) cap.original = r.Context().Value(middleware.OriginalPathContextKey) + cap.specialName = chi.URLParam(r, "specialName") + cap.specialPath, cap.specialPathSet = middleware.SpecialFolderPath(r.Context()) w.WriteHeader(http.StatusOK) } } @@ -128,6 +134,10 @@ func newGraphTestRouter(t *testing.T, gw *cs3mocks.GatewayAPIClient) (http.Handl r.Get("/", leaf("item")) r.Get("/children", leaf("children")) }) + r.Route("/special/{specialName}", func(r chi.Router) { + r.Get("/", leaf("special")) + r.Get("/children", leaf("specialChildren")) + }) }) }) return m, cap @@ -469,3 +479,96 @@ func TestResolveGraphPath_OriginalPathContext(t *testing.T) { assert.Equal(t, original, cap.original, "original URL must be available via OriginalPathContextKey") assert.Equal(t, original, cap.urlPath, "r.URL.Path must remain the original request path") } + +// TestResolveGraphPath_SpecialFolder pins the special-anchored colon form: +// no CS3 lookup (special folders live outside the item tree), the request is +// re-routed to /special/{specialName}{suffix} and the decoded path below the +// special folder reaches the handler through SpecialFolderPath. +func TestResolveGraphPath_SpecialFolder(t *testing.T) { + tests := []struct { + name string + urlPath string + expectStatus int + expectHit string + expectPath string + expectPathSet bool + expectSpecName string + }{ + { + name: "plain special route passes through without a path", + urlPath: "/graph/v1.0/drives/" + testDriveID + "/special/recyclebin/children", + expectStatus: http.StatusOK, + expectHit: "specialChildren", + expectPathSet: false, + expectSpecName: "recyclebin", + }, + { + name: "special-anchored with /children rewrites and carries the path", + urlPath: "/graph/v1.0/drives/" + testDriveID + "/special/recyclebin:/key/sub:/children", + expectStatus: http.StatusOK, + expectHit: "specialChildren", + expectPath: "/key/sub", + expectPathSet: true, + expectSpecName: "recyclebin", + }, + { + name: "special-anchored without suffix rewrites to the bare special URL", + urlPath: "/graph/v1.0/drives/" + testDriveID + "/special/recyclebin:/key", + expectStatus: http.StatusOK, + expectHit: "special", + expectPath: "/key", + expectPathSet: true, + expectSpecName: "recyclebin", + }, + { + name: "special-anchored with trailing colon rewrites to the bare special URL", + urlPath: "/graph/v1.0/drives/" + testDriveID + "/special/recyclebin:/key:", + expectStatus: http.StatusOK, + expectHit: "special", + expectPath: "/key", + expectPathSet: true, + expectSpecName: "recyclebin", + }, + { + name: "percent-encoded path is decoded once", + urlPath: "/graph/v1.0/drives/" + testDriveID + "/special/recyclebin:/key/My%20File:/children", + expectStatus: http.StatusOK, + expectHit: "specialChildren", + expectPath: "/key/My File", + expectPathSet: true, + expectSpecName: "recyclebin", + }, + { + // The name is a single segment; a slash inside means this is not the + // colon form and chi decides (here: no such route). + name: "multi-segment special name is not colon syntax", + urlPath: "/graph/v1.0/drives/" + testDriveID + "/special/recyclebin/x:/key:/children", + expectStatus: http.StatusNotFound, + expectHit: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gw := &cs3mocks.GatewayAPIClient{} + router, cap := newGraphTestRouter(t, gw) + req := httptest.NewRequest(http.MethodGet, "http://localhost"+tt.urlPath, nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + assert.Equal(t, tt.expectStatus, rr.Code, "status code") + assert.Equal(t, tt.expectHit, cap.hit, "leaf handler reached") + gw.AssertNotCalled(t, "Stat", mock.Anything, mock.Anything) + + if tt.expectHit != "" { + assert.Equal(t, testDriveID, cap.driveID, "driveID param") + assert.Equal(t, tt.expectSpecName, cap.specialName, "specialName param") + assert.Equal(t, tt.expectPathSet, cap.specialPathSet, "SpecialFolderPath ok") + assert.Equal(t, tt.expectPath, cap.specialPath, "SpecialFolderPath value") + // r.URL.Path is the decoded form; only chi's RoutePath is rewritten. + decoded, _ := url.PathUnescape(tt.urlPath) + assert.Equal(t, decoded, cap.urlPath, "r.URL.Path must remain the original request path") + } + }) + } +} diff --git a/services/graph/pkg/service/v0/driveitems_special.go b/services/graph/pkg/service/v0/driveitems_special.go new file mode 100644 index 0000000000..8826c4506a --- /dev/null +++ b/services/graph/pkg/service/v0/driveitems_special.go @@ -0,0 +1,219 @@ +package svc + +import ( + "mime" + "net/http" + "path" + "strings" + + 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" + + "github.com/opencloud-eu/reva/v2/pkg/storagespace" + "github.com/opencloud-eu/reva/v2/pkg/utils" + + "github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode" + graphm "github.com/opencloud-eu/opencloud/services/graph/pkg/middleware" +) + +// RecycleBinSpecialFolderName addresses a drive's trash as a special folder +const RecycleBinSpecialFolderName = "recyclebin" + +// GetDriveSpecial returns the driveItem of a special folder. In the colon path +// form (special/recyclebin:/{key}) it returns the trashed item with that key. +func (g Graph) GetDriveSpecial(w http.ResponseWriter, r *http.Request) { + g.logger.Debug().Msg("Calling GetDriveSpecial") + + driveID, ok := parseSpecialParams(w, r) + if !ok { + return + } + + key := specialFolderKey(r) + if key == "" { + render.Status(r, http.StatusOK) + render.JSON(w, r, recycleBinDriveItem(&driveID)) + return + } + + // A top-level key lists itself, a nested key only shows up in its parent's listing. + listKey := key + if strings.Contains(key, "/") { + listKey = path.Dir(key) + "/" + } + items, ok := g.listRecycle(w, r, &driveID, listKey) + if !ok { + return + } + for _, item := range items { + if item.GetKey() == key { + render.Status(r, http.StatusOK) + render.JSON(w, r, recycleItemToDriveItem(&driveID, item)) + return + } + } + errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "item not found") +} + +// ListDriveSpecialChildren lists the children of a special folder. For the +// recycle bin this is the trash listing; the colon path form +// (special/recyclebin:/{key}:/children) lists inside a trashed folder. +func (g Graph) ListDriveSpecialChildren(w http.ResponseWriter, r *http.Request) { + g.logger.Debug().Msg("Calling ListDriveSpecialChildren") + + driveID, ok := parseSpecialParams(w, r) + if !ok { + return + } + + // the trailing slash asks reva for the children of the key instead of the key itself + listKey := "" + if key := specialFolderKey(r); key != "" { + listKey = key + "/" + } + items, ok := g.listRecycle(w, r, &driveID, listKey) + if !ok { + return + } + + files := make([]libregraph.DriveItem, 0, len(items)) + for _, item := range items { + files = append(files, *recycleItemToDriveItem(&driveID, item)) + } + + render.Status(r, http.StatusOK) + render.JSON(w, r, &ListResponse{Value: files}) +} + +func parseSpecialParams(w http.ResponseWriter, r *http.Request) (storageprovider.ResourceId, bool) { + driveID, err := parseIDParam(r, "driveID") + if err != nil { + errorcode.RenderError(w, r, err) + return storageprovider.ResourceId{}, false + } + if chi.URLParam(r, "specialName") != RecycleBinSpecialFolderName { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unknown special folder") + return storageprovider.ResourceId{}, false + } + return driveID, true +} + +// specialFolderKey returns the recycle key addressed by the colon path form, "" for the trash root +func specialFolderKey(r *http.Request) string { + p, _ := graphm.SpecialFolderPath(r.Context()) + return strings.Trim(p, "/") +} + +func (g Graph) listRecycle(w http.ResponseWriter, r *http.Request, driveID *storageprovider.ResourceId, key string) ([]*storageprovider.RecycleItem, bool) { + gatewayClient, err := g.gatewaySelector.Next() + if err != nil { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return nil, false + } + + res, err := gatewayClient.ListRecycle(r.Context(), &storageprovider.ListRecycleRequest{ + Ref: &storageprovider.Reference{ResourceId: spaceRootID(driveID)}, + Key: key, + }) + switch { + case err != nil: + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return nil, false + case res.GetStatus().GetCode() == cs3rpc.Code_CODE_OK: + // ok + case res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND: + errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage()) + return nil, false + case res.GetStatus().GetCode() == cs3rpc.Code_CODE_PERMISSION_DENIED: + errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage()) // do not leak existence, like listDriveItemChildren + return nil, false + case res.GetStatus().GetCode() == cs3rpc.Code_CODE_UNAUTHENTICATED: + errorcode.Unauthenticated.Render(w, r, http.StatusUnauthorized, res.GetStatus().GetMessage()) + return nil, false + default: + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage()) + return nil, false + } + + return res.GetRecycleItems(), true +} + +func spaceRootID(driveID *storageprovider.ResourceId) *storageprovider.ResourceId { + return &storageprovider.ResourceId{ + StorageId: driveID.GetStorageId(), + SpaceId: driveID.GetSpaceId(), + OpaqueId: driveID.GetSpaceId(), + } +} + +// recycleBinDriveItem is the synthetic folder for the trash root. It has no node in the storage. +func recycleBinDriveItem(driveID *storageprovider.ResourceId) *libregraph.DriveItem { + item := libregraph.NewDriveItem() + item.SetId(storagespace.FormatResourceID(&storageprovider.ResourceId{ + StorageId: driveID.GetStorageId(), + SpaceId: driveID.GetSpaceId(), + OpaqueId: RecycleBinSpecialFolderName, + })) + item.SetName(RecycleBinSpecialFolderName) + item.SetFolder(libregraph.Folder{}) + item.SetSpecialFolder(libregraph.SpecialFolder{Name: libregraph.PtrString(RecycleBinSpecialFolderName)}) + + parentRef := libregraph.NewItemReference() + parentRef.SetDriveId(storagespace.FormatStorageID(driveID.GetStorageId(), driveID.GetSpaceId())) + parentRef.SetId(storagespace.FormatResourceID(spaceRootID(driveID))) + item.SetParentReference(*parentRef) + return item +} + +// recycleItemToDriveItem maps a trashed item. The id carries the recycle key, which is the +// node id for top-level entries and key/relative/path inside a trashed folder. +func recycleItemToDriveItem(driveID *storageprovider.ResourceId, ri *storageprovider.RecycleItem) *libregraph.DriveItem { + origin := ri.GetRef().GetPath() + name := path.Base(origin) + if name == "." || name == "/" { + name = path.Base(ri.GetKey()) + } + + item := libregraph.NewDriveItem() + item.SetId(storagespace.FormatResourceID(&storageprovider.ResourceId{ + StorageId: driveID.GetStorageId(), + SpaceId: driveID.GetSpaceId(), + OpaqueId: ri.GetKey(), + })) + item.SetName(name) + item.SetSize(int64(ri.GetSize())) // TODO lurking overflow, see cs3ResourceToDriveItem + + switch ri.GetType() { + case storageprovider.ResourceType_RESOURCE_TYPE_FILE: + mimeType := mime.TypeByExtension(path.Ext(name)) + if mimeType == "" { + mimeType = "application/octet-stream" + } + item.SetFile(libregraph.OpenGraphFile{MimeType: &mimeType}) + case storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER: + item.SetFolder(libregraph.Folder{}) + } + + trash := libregraph.NewTrash() + if ri.GetDeletionTime() != nil { + trash.SetTrashedDateTime(utils.TSToTime(ri.GetDeletionTime()).UTC()) + } + item.SetTrash(*trash) + + if origin != "" { + // the original location; the parent may itself be trashed or gone, so no id + dir := path.Dir(origin) + parentRef := libregraph.NewItemReference() + parentRef.SetDriveId(storagespace.FormatStorageID(driveID.GetStorageId(), driveID.GetSpaceId())) + parentRef.SetPath(dir) + if base := path.Base(dir); base != "/" && base != "." { + parentRef.SetName(base) + } + item.SetParentReference(*parentRef) + } + + return item +} diff --git a/services/graph/pkg/service/v0/driveitems_special_test.go b/services/graph/pkg/service/v0/driveitems_special_test.go new file mode 100644 index 0000000000..48066a0be4 --- /dev/null +++ b/services/graph/pkg/service/v0/driveitems_special_test.go @@ -0,0 +1,309 @@ +package svc_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "time" + + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/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/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" + "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" + graphm "github.com/opencloud-eu/opencloud/services/graph/pkg/middleware" + service "github.com/opencloud-eu/opencloud/services/graph/pkg/service/v0" +) + +var _ = Describe("Drive special folders", func() { + var ( + svc service.Service + ctx context.Context + cfg *config.Config + gatewayClient *cs3mocks.GatewayAPIClient + gatewaySelector pool.Selectable[gateway.GatewayAPIClient] + eventsPublisher mocks.Publisher + identityBackend *identitymocks.Backend + + rr *httptest.ResponseRecorder + + currentUser = &userpb.User{ + Id: &userpb.UserId{ + OpaqueId: "user", + }, + } + deletedAt = time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + ) + + // newRequest builds a request for /drives/{driveID}/special/{specialName}[/children]; + // specialPath mimics what the colon-path middleware puts into the context. + newRequest := func(specialName, specialPath string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives/storageid$spaceid/special/"+specialName, nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("driveID", "storageid$spaceid") + rctx.URLParams.Add("specialName", specialName) + c := context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx) + if specialPath != "" { + c = graphm.WithSpecialFolderPath(c, specialPath) + } + return r.WithContext(c) + } + + decodeItem := func() libregraph.DriveItem { + var item libregraph.DriveItem + Expect(json.Unmarshal(rr.Body.Bytes(), &item)).To(Succeed()) + return item + } + decodeList := func() []libregraph.DriveItem { + var res struct { + Value []libregraph.DriveItem + } + Expect(json.Unmarshal(rr.Body.Bytes(), &res)).To(Succeed()) + return res.Value + } + + 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 = "" + cfg.TokenManager.JWTSecret = "loremipsum" + cfg.Commons = &shared.Commons{} + 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()) + }) + + Describe("GetDriveSpecial", func() { + It("rejects an unknown special folder", func() { + svc.GetDriveSpecial(rr, newRequest("nope", "")) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + gatewayClient.AssertNotCalled(GinkgoT(), "ListRecycle", mock.Anything, mock.Anything) + }) + + It("returns the synthetic recycle bin folder without touching the storage", func() { + svc.GetDriveSpecial(rr, newRequest("recyclebin", "")) + Expect(rr.Code).To(Equal(http.StatusOK)) + gatewayClient.AssertNotCalled(GinkgoT(), "ListRecycle", mock.Anything, mock.Anything) + + item := decodeItem() + Expect(item.GetId()).To(Equal("storageid$spaceid!recyclebin")) + Expect(item.GetName()).To(Equal("recyclebin")) + Expect(item.Folder).ToNot(BeNil()) + Expect(item.SpecialFolder.GetName()).To(Equal("recyclebin")) + Expect(item.ParentReference.GetDriveId()).To(Equal("storageid$spaceid")) + Expect(item.ParentReference.GetId()).To(Equal("storageid$spaceid!spaceid")) + }) + + It("returns a top-level trashed item by key", func() { + var req *provider.ListRecycleRequest + gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + req = args.Get(1).(*provider.ListRecycleRequest) + }).Return(&provider.ListRecycleResponse{ + Status: status.NewOK(ctx), + RecycleItems: []*provider.RecycleItem{{ + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Key: "nodeid", + Size: 42, + DeletionTime: utils.TimeToTS(deletedAt), + Ref: &provider.Reference{Path: "/Documents/notes.txt"}, + }}, + }, nil) + + svc.GetDriveSpecial(rr, newRequest("recyclebin", "/nodeid")) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(req.GetKey()).To(Equal("nodeid")) + Expect(req.GetRef().GetResourceId().GetStorageId()).To(Equal("storageid")) + Expect(req.GetRef().GetResourceId().GetSpaceId()).To(Equal("spaceid")) + Expect(req.GetRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid")) + + item := decodeItem() + Expect(item.GetId()).To(Equal("storageid$spaceid!nodeid")) + Expect(item.GetName()).To(Equal("notes.txt")) + Expect(item.GetSize()).To(Equal(int64(42))) + Expect(item.File.GetMimeType()).To(Equal("text/plain; charset=utf-8")) + Expect(item.Folder).To(BeNil()) + Expect(item.Trash.GetTrashedDateTime()).To(BeTemporally("==", deletedAt)) + Expect(item.Trash.TrashedBy).To(BeNil()) + Expect(item.ParentReference.GetDriveId()).To(Equal("storageid$spaceid")) + Expect(item.ParentReference.GetPath()).To(Equal("/Documents")) + Expect(item.ParentReference.GetName()).To(Equal("Documents")) + }) + + It("returns a nested trashed item through its parent listing", func() { + var req *provider.ListRecycleRequest + gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + req = args.Get(1).(*provider.ListRecycleRequest) + }).Return(&provider.ListRecycleResponse{ + Status: status.NewOK(ctx), + RecycleItems: []*provider.RecycleItem{ + {Type: provider.ResourceType_RESOURCE_TYPE_FILE, Key: "nodeid/sub/other.txt", Ref: &provider.Reference{Path: "/folder/sub/other.txt"}}, + {Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER, Key: "nodeid/sub/inner", Ref: &provider.Reference{Path: "/folder/sub/inner"}}, + }, + }, nil) + + svc.GetDriveSpecial(rr, newRequest("recyclebin", "/nodeid/sub/inner")) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(req.GetKey()).To(Equal("nodeid/sub/")) + + item := decodeItem() + Expect(item.GetId()).To(Equal("storageid$spaceid!nodeid/sub/inner")) + Expect(item.GetName()).To(Equal("inner")) + Expect(item.Folder).ToNot(BeNil()) + Expect(item.File).To(BeNil()) + }) + + It("returns not found when the key is not in the listing", func() { + gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Return(&provider.ListRecycleResponse{ + Status: status.NewOK(ctx), + RecycleItems: []*provider.RecycleItem{{Key: "other"}}, + }, nil) + + svc.GetDriveSpecial(rr, newRequest("recyclebin", "/nodeid")) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("ListDriveSpecialChildren", func() { + It("rejects an unknown special folder", func() { + svc.ListDriveSpecialChildren(rr, newRequest("nope", "")) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + DescribeTable("maps ListRecycle errors", + func(st *cs3rpc.Status, code int) { + gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Return(&provider.ListRecycleResponse{Status: st}, nil) + svc.ListDriveSpecialChildren(rr, newRequest("recyclebin", "")) + Expect(rr.Code).To(Equal(code)) + }, + Entry("not found", status.NewNotFound(context.Background(), "not found"), http.StatusNotFound), + Entry("permission denied as not found", status.NewPermissionDenied(context.Background(), errors.New("denied"), "denied"), http.StatusNotFound), + Entry("unauthenticated", status.NewUnauthenticated(context.Background(), errors.New("no"), "no"), http.StatusUnauthorized), + Entry("internal", status.NewInternal(context.Background(), "internal"), http.StatusInternalServerError), + ) + + It("lists the trash root with the trash facet", func() { + var req *provider.ListRecycleRequest + gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + req = args.Get(1).(*provider.ListRecycleRequest) + }).Return(&provider.ListRecycleResponse{ + Status: status.NewOK(ctx), + RecycleItems: []*provider.RecycleItem{ + { + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Key: "file-node", + Size: 7, + DeletionTime: utils.TimeToTS(deletedAt), + Ref: &provider.Reference{Path: "/photo.jpg"}, + }, + { + Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER, + Key: "folder-node", + Size: 100, + DeletionTime: utils.TimeToTS(deletedAt), + Ref: &provider.Reference{Path: "/Projects/old"}, + }, + }, + }, nil) + + svc.ListDriveSpecialChildren(rr, newRequest("recyclebin", "")) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(req.GetKey()).To(Equal("")) + + items := decodeList() + Expect(items).To(HaveLen(2)) + + Expect(items[0].GetId()).To(Equal("storageid$spaceid!file-node")) + Expect(items[0].GetName()).To(Equal("photo.jpg")) + Expect(items[0].File.GetMimeType()).To(Equal("image/jpeg")) + Expect(items[0].Trash.GetTrashedDateTime()).To(BeTemporally("==", deletedAt)) + Expect(items[0].ParentReference.GetPath()).To(Equal("/")) + Expect(items[0].ParentReference.Name).To(BeNil()) + + Expect(items[1].GetId()).To(Equal("storageid$spaceid!folder-node")) + Expect(items[1].GetName()).To(Equal("old")) + Expect(items[1].Folder).ToNot(BeNil()) + Expect(items[1].GetSize()).To(Equal(int64(100))) + Expect(items[1].ParentReference.GetPath()).To(Equal("/Projects")) + Expect(items[1].ParentReference.GetName()).To(Equal("Projects")) + }) + + It("returns an empty list for an empty trash", func() { + gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Return(&provider.ListRecycleResponse{ + Status: status.NewOK(ctx), + }, nil) + + svc.ListDriveSpecialChildren(rr, newRequest("recyclebin", "")) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(rr.Body.String()).To(MatchJSON(`{"value":[]}`)) + }) + + It("lists inside a trashed folder with a trailing slash key", func() { + var req *provider.ListRecycleRequest + gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + req = args.Get(1).(*provider.ListRecycleRequest) + }).Return(&provider.ListRecycleResponse{ + Status: status.NewOK(ctx), + RecycleItems: []*provider.RecycleItem{ + {Type: provider.ResourceType_RESOURCE_TYPE_FILE, Key: "folder-node/sub/a.txt", Ref: &provider.Reference{Path: "/Projects/old/sub/a.txt"}}, + }, + }, nil) + + svc.ListDriveSpecialChildren(rr, newRequest("recyclebin", "/folder-node/sub")) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(req.GetKey()).To(Equal("folder-node/sub/")) + + items := decodeList() + Expect(items).To(HaveLen(1)) + Expect(items[0].GetId()).To(Equal("storageid$spaceid!folder-node/sub/a.txt")) + Expect(items[0].ParentReference.GetPath()).To(Equal("/Projects/old/sub")) + }) + }) +}) diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go index 9b9636e370..c30513d657 100644 --- a/services/graph/pkg/service/v0/service.go +++ b/services/graph/pkg/service/v0/service.go @@ -104,6 +104,8 @@ type Service interface { //nolint:interfacebloat GetRootDriveChildren(w http.ResponseWriter, r *http.Request) GetDriveItem(w http.ResponseWriter, r *http.Request) GetDriveItemChildren(w http.ResponseWriter, r *http.Request) + GetDriveSpecial(w http.ResponseWriter, r *http.Request) + ListDriveSpecialChildren(w http.ResponseWriter, r *http.Request) CreateUploadSession(w http.ResponseWriter, r *http.Request) @@ -371,6 +373,10 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx r.Get("/children", svc.GetDriveItemChildren) r.Post("/createUploadSession", svc.CreateUploadSession) }) + r.Route("/special/{specialName}", func(r chi.Router) { + r.Get("/", svc.GetDriveSpecial) + r.Get("/children", svc.ListDriveSpecialChildren) + }) }) }) r.With(requireAdmin).Route("/education", func(r chi.Router) { From fdebaba41db42ee6a0de63df43fbff1e7c1930e3 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Mon, 7 Sep 2026 12:32:57 +0200 Subject: [PATCH 2/5] graph: support $expand=children on drive special folders --- .../pkg/service/v0/driveitems_special.go | 56 ++++++++++---- .../pkg/service/v0/driveitems_special_test.go | 74 ++++++++++++++++++- 2 files changed, 112 insertions(+), 18 deletions(-) diff --git a/services/graph/pkg/service/v0/driveitems_special.go b/services/graph/pkg/service/v0/driveitems_special.go index 8826c4506a..4bb83a4010 100644 --- a/services/graph/pkg/service/v0/driveitems_special.go +++ b/services/graph/pkg/service/v0/driveitems_special.go @@ -24,6 +24,7 @@ const RecycleBinSpecialFolderName = "recyclebin" // GetDriveSpecial returns the driveItem of a special folder. In the colon path // form (special/recyclebin:/{key}) it returns the trashed item with that key. +// $expand=children embeds the listing for folders. func (g Graph) GetDriveSpecial(w http.ResponseWriter, r *http.Request) { g.logger.Debug().Msg("Calling GetDriveSpecial") @@ -33,29 +34,45 @@ func (g Graph) GetDriveSpecial(w http.ResponseWriter, r *http.Request) { } key := specialFolderKey(r) - if key == "" { - render.Status(r, http.StatusOK) - render.JSON(w, r, recycleBinDriveItem(&driveID)) + driveItem, ok := g.getSpecialDriveItemForKey(w, r, &driveID, key) + if !ok { return } + if driveItem.Folder != nil && driveItemRelationExpanded(r, _expandChildren) { + children, ok := g.listRecycleChildren(w, r, &driveID, key) + if !ok { + return + } + driveItem.Children = children + } + + render.Status(r, http.StatusOK) + render.JSON(w, r, driveItem) +} + +// getSpecialDriveItemForKey returns the trash root for an empty key, else the trashed item with that key +func (g Graph) getSpecialDriveItemForKey(w http.ResponseWriter, r *http.Request, driveID *storageprovider.ResourceId, key string) (*libregraph.DriveItem, bool) { + if key == "" { + return recycleBinDriveItem(driveID), true + } + // A top-level key lists itself, a nested key only shows up in its parent's listing. listKey := key if strings.Contains(key, "/") { listKey = path.Dir(key) + "/" } - items, ok := g.listRecycle(w, r, &driveID, listKey) + items, ok := g.listRecycle(w, r, driveID, listKey) if !ok { - return + return nil, false } for _, item := range items { if item.GetKey() == key { - render.Status(r, http.StatusOK) - render.JSON(w, r, recycleItemToDriveItem(&driveID, item)) - return + return recycleItemToDriveItem(driveID, item), true } } errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "item not found") + return nil, false } // ListDriveSpecialChildren lists the children of a special folder. For the @@ -69,23 +86,32 @@ func (g Graph) ListDriveSpecialChildren(w http.ResponseWriter, r *http.Request) return } + files, ok := g.listRecycleChildren(w, r, &driveID, specialFolderKey(r)) + if !ok { + return + } + + render.Status(r, http.StatusOK) + render.JSON(w, r, &ListResponse{Value: files}) +} + +// listRecycleChildren lists the trash root for an empty key, else the children of the trashed folder with that key +func (g Graph) listRecycleChildren(w http.ResponseWriter, r *http.Request, driveID *storageprovider.ResourceId, key string) ([]libregraph.DriveItem, bool) { // the trailing slash asks reva for the children of the key instead of the key itself listKey := "" - if key := specialFolderKey(r); key != "" { + if key != "" { listKey = key + "/" } - items, ok := g.listRecycle(w, r, &driveID, listKey) + items, ok := g.listRecycle(w, r, driveID, listKey) if !ok { - return + return nil, false } files := make([]libregraph.DriveItem, 0, len(items)) for _, item := range items { - files = append(files, *recycleItemToDriveItem(&driveID, item)) + files = append(files, *recycleItemToDriveItem(driveID, item)) } - - render.Status(r, http.StatusOK) - render.JSON(w, r, &ListResponse{Value: files}) + return files, true } func parseSpecialParams(w http.ResponseWriter, r *http.Request) (storageprovider.ResourceId, bool) { diff --git a/services/graph/pkg/service/v0/driveitems_special_test.go b/services/graph/pkg/service/v0/driveitems_special_test.go index 48066a0be4..205e5f6bf8 100644 --- a/services/graph/pkg/service/v0/driveitems_special_test.go +++ b/services/graph/pkg/service/v0/driveitems_special_test.go @@ -57,10 +57,10 @@ var _ = Describe("Drive special folders", func() { deletedAt = time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) ) - // newRequest builds a request for /drives/{driveID}/special/{specialName}[/children]; + // newRequestWithQuery builds a request for /drives/{driveID}/special/{specialName}[/children]; // specialPath mimics what the colon-path middleware puts into the context. - newRequest := func(specialName, specialPath string) *http.Request { - r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives/storageid$spaceid/special/"+specialName, nil) + newRequestWithQuery := func(specialName, specialPath, query string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives/storageid$spaceid/special/"+specialName+query, nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("driveID", "storageid$spaceid") rctx.URLParams.Add("specialName", specialName) @@ -70,6 +70,9 @@ var _ = Describe("Drive special folders", func() { } return r.WithContext(c) } + newRequest := func(specialName, specialPath string) *http.Request { + return newRequestWithQuery(specialName, specialPath, "") + } decodeItem := func() libregraph.DriveItem { var item libregraph.DriveItem @@ -200,6 +203,71 @@ var _ = Describe("Drive special folders", func() { Expect(item.File).To(BeNil()) }) + It("embeds the trash listing with $expand=children", func() { + var keys []string + gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + keys = append(keys, args.Get(1).(*provider.ListRecycleRequest).GetKey()) + }).Return(&provider.ListRecycleResponse{ + Status: status.NewOK(ctx), + RecycleItems: []*provider.RecycleItem{ + {Type: provider.ResourceType_RESOURCE_TYPE_FILE, Key: "nodeid", Ref: &provider.Reference{Path: "/a.txt"}}, + }, + }, nil) + + svc.GetDriveSpecial(rr, newRequestWithQuery("recyclebin", "", "?$expand=children")) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(keys).To(Equal([]string{""})) + + item := decodeItem() + Expect(item.GetId()).To(Equal("storageid$spaceid!recyclebin")) + Expect(item.Children).To(HaveLen(1)) + Expect(item.Children[0].GetId()).To(Equal("storageid$spaceid!nodeid")) + Expect(item.Children[0].Trash).ToNot(BeNil()) + }) + + It("embeds the children of a trashed folder with $expand=children", func() { + var keys []string + gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + keys = append(keys, args.Get(1).(*provider.ListRecycleRequest).GetKey()) + }).Return(&provider.ListRecycleResponse{ + Status: status.NewOK(ctx), + RecycleItems: []*provider.RecycleItem{ + {Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER, Key: "nodeid", Ref: &provider.Reference{Path: "/folder"}}, + }, + }, nil).Once() + gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + keys = append(keys, args.Get(1).(*provider.ListRecycleRequest).GetKey()) + }).Return(&provider.ListRecycleResponse{ + Status: status.NewOK(ctx), + RecycleItems: []*provider.RecycleItem{ + {Type: provider.ResourceType_RESOURCE_TYPE_FILE, Key: "nodeid/a.txt", Ref: &provider.Reference{Path: "/folder/a.txt"}}, + }, + }, nil).Once() + + svc.GetDriveSpecial(rr, newRequestWithQuery("recyclebin", "/nodeid", "?$expand=children")) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(keys).To(Equal([]string{"nodeid", "nodeid/"})) + + item := decodeItem() + Expect(item.GetId()).To(Equal("storageid$spaceid!nodeid")) + Expect(item.Children).To(HaveLen(1)) + Expect(item.Children[0].GetId()).To(Equal("storageid$spaceid!nodeid/a.txt")) + }) + + It("does not expand children of a trashed file", func() { + gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Return(&provider.ListRecycleResponse{ + Status: status.NewOK(ctx), + RecycleItems: []*provider.RecycleItem{ + {Type: provider.ResourceType_RESOURCE_TYPE_FILE, Key: "nodeid", Ref: &provider.Reference{Path: "/a.txt"}}, + }, + }, nil) + + svc.GetDriveSpecial(rr, newRequestWithQuery("recyclebin", "/nodeid", "?$expand=children")) + Expect(rr.Code).To(Equal(http.StatusOK)) + gatewayClient.AssertNumberOfCalls(GinkgoT(), "ListRecycle", 1) + Expect(decodeItem().Children).To(BeNil()) + }) + It("returns not found when the key is not in the listing", func() { gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Return(&provider.ListRecycleResponse{ Status: status.NewOK(ctx), From cdbd75b086c5cc38581f8e08bf7eabc98e91f646 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Mon, 7 Sep 2026 13:03:24 +0200 Subject: [PATCH 3/5] graph: add restore, permanentDelete, purge and empty for the recycle bin --- .../graph/pkg/service/v0/driveitems_trash.go | 299 +++++++++++++++ .../pkg/service/v0/driveitems_trash_test.go | 354 ++++++++++++++++++ services/graph/pkg/service/v0/service.go | 12 + 3 files changed, 665 insertions(+) create mode 100644 services/graph/pkg/service/v0/driveitems_trash.go create mode 100644 services/graph/pkg/service/v0/driveitems_trash_test.go diff --git a/services/graph/pkg/service/v0/driveitems_trash.go b/services/graph/pkg/service/v0/driveitems_trash.go new file mode 100644 index 0000000000..122e7fa39a --- /dev/null +++ b/services/graph/pkg/service/v0/driveitems_trash.go @@ -0,0 +1,299 @@ +package svc + +import ( + "errors" + "io" + "net/http" + "path" + + 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" + + "github.com/opencloud-eu/reva/v2/pkg/storagespace" + "github.com/opencloud-eu/reva/v2/pkg/utils" + + "github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode" +) + +// restoreRequest is the optional body of driveItem: restore +type restoreRequest struct { + ParentReference *libregraph.ItemReference `json:"parentReference,omitempty"` + Name *string `json:"name,omitempty"` +} + +// RestoreDriveItem restores a trashed item, by default to its original location. +// The item id carries the recycle key, see recycleItemToDriveItem. +func (g Graph) RestoreDriveItem(w http.ResponseWriter, r *http.Request) { + g.logger.Debug().Msg("Calling RestoreDriveItem") + ctx := r.Context() + + itemID, ok := parseTrashItemID(w, r) + if !ok { + return + } + + var body restoreRequest + if err := StrictJSONUnmarshal(r.Body, &body); err != nil && !errors.Is(err, io.EOF) { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid body: "+err.Error()) + return + } + + // the listing gives us the original location, needed for the default target and the response + key := itemID.GetOpaqueId() + items, ok := g.listRecycle(w, r, itemID, key) + if !ok { + return + } + var trashed *storageprovider.RecycleItem + for _, item := range items { + if item.GetKey() == key { + trashed = item + } + } + if trashed == nil { + errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "item not found") + return + } + + target, ok := restoreTarget(w, r, itemID, trashed, body) + if !ok { + return + } + + gatewayClient, ok := g.GetGatewayClient(w, r) + if !ok { + return + } + res, err := gatewayClient.RestoreRecycleItem(ctx, &storageprovider.RestoreRecycleItemRequest{ + Ref: &storageprovider.Reference{ResourceId: spaceRootID(itemID)}, + Key: key, + RestoreRef: target, + }) + if err != nil { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return + } + if !renderTrashStatus(w, r, res.GetStatus()) { + return + } + + statRes, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{Ref: target}) + switch { + case err != nil: + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return + case statRes.GetStatus().GetCode() != cs3rpc.Code_CODE_OK: + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "restored, but could not stat the item: "+statRes.GetStatus().GetMessage()) + return + } + driveItem, err := cs3ResourceToDriveItem(g.logger, g.publicBaseURL, statRes.GetInfo()) + if err != nil { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return + } + + render.Status(r, http.StatusOK) + render.JSON(w, r, driveItem) +} + +// restoreTarget builds the restore reference: the original location unless the body moves the item +func restoreTarget(w http.ResponseWriter, r *http.Request, itemID *storageprovider.ResourceId, trashed *storageprovider.RecycleItem, body restoreRequest) (*storageprovider.Reference, bool) { + origin := trashed.GetRef().GetPath() + name := path.Base(origin) + if body.Name != nil && *body.Name != "" { + name = *body.Name + } + if name != path.Base(name) || name == "." || name == "/" { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid name") + return nil, false + } + + parent := body.ParentReference + if parent != nil && parent.DriveId != nil { + parentDrive, err := storagespace.ParseID(parent.GetDriveId()) + if err != nil || parentDrive.GetStorageId() != itemID.GetStorageId() || parentDrive.GetSpaceId() != itemID.GetSpaceId() { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "restore into another drive is not supported") + return nil, false + } + } + + switch { + case parent != nil && parent.Id != nil: + parentID, err := storagespace.ParseID(parent.GetId()) + if err != nil || parentID.GetStorageId() != itemID.GetStorageId() || parentID.GetSpaceId() != itemID.GetSpaceId() { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid parentReference.id") + return nil, false + } + return &storageprovider.Reference{ResourceId: &parentID, Path: utils.MakeRelativePath(name)}, true + case parent != nil && parent.Path != nil: + return &storageprovider.Reference{ + ResourceId: spaceRootID(itemID), + Path: utils.MakeRelativePath(path.Join(parent.GetPath(), name)), + }, true + default: + return &storageprovider.Reference{ + ResourceId: spaceRootID(itemID), + Path: utils.MakeRelativePath(path.Join(path.Dir(origin), name)), + }, true + } +} + +// PermanentDeleteDriveItem deletes a live item and purges it from the trash right away. +// reva has no delete that bypasses the trash, so this is two steps; the trash key of a +// freshly deleted item is its node id in both decomposedfs and posixfs. +func (g Graph) PermanentDeleteDriveItem(w http.ResponseWriter, r *http.Request) { + g.logger.Debug().Msg("Calling PermanentDeleteDriveItem") + ctx := r.Context() + + itemID, ok := parseTrashItemID(w, r) + if !ok { + return + } + if IsSpaceRoot(itemID) { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "cannot delete the drive root") + return + } + if IsShareJail(itemID) { + // the trash of a shared item lives in the owner's space, which the share jail id does not tell us + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "not supported for shared items, use the item id of the owning drive") + return + } + + gatewayClient, ok := g.GetGatewayClient(w, r) + if !ok { + return + } + delRes, err := gatewayClient.Delete(ctx, &storageprovider.DeleteRequest{ + Ref: &storageprovider.Reference{ResourceId: itemID}, + }) + if err != nil { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return + } + if !renderTrashStatus(w, r, delRes.GetStatus()) { + return + } + + purgeRes, err := gatewayClient.PurgeRecycle(ctx, &storageprovider.PurgeRecycleRequest{ + Ref: &storageprovider.Reference{ResourceId: spaceRootID(itemID)}, + Key: itemID.GetOpaqueId(), + }) + if err != nil { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "deleted, but could not purge from the trash: "+err.Error()) + return + } + if purgeRes.GetStatus().GetCode() != cs3rpc.Code_CODE_OK { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "deleted, but could not purge from the trash: "+purgeRes.GetStatus().GetMessage()) + return + } + + render.Status(r, http.StatusNoContent) + render.NoContent(w, r) +} + +// DeleteDriveSpecialItem purges one trashed item +func (g Graph) DeleteDriveSpecialItem(w http.ResponseWriter, r *http.Request) { + g.logger.Debug().Msg("Calling DeleteDriveSpecialItem") + + driveID, ok := parseSpecialParams(w, r) + if !ok { + return + } + itemID, ok := parseTrashItemID(w, r) + if !ok { + return + } + + g.purgeRecycle(w, r, &driveID, itemID.GetOpaqueId()) +} + +// EmptyDriveSpecial purges the whole trash. In the colon path form +// (special/recyclebin:/{key}) it purges only that item. +func (g Graph) EmptyDriveSpecial(w http.ResponseWriter, r *http.Request) { + g.logger.Debug().Msg("Calling EmptyDriveSpecial") + + driveID, ok := parseSpecialParams(w, r) + if !ok { + return + } + + g.purgeRecycle(w, r, &driveID, specialFolderKey(r)) +} + +// purgeRecycle purges the key, or the whole trash for an empty key +func (g Graph) purgeRecycle(w http.ResponseWriter, r *http.Request, driveID *storageprovider.ResourceId, key string) { + gatewayClient, ok := g.GetGatewayClient(w, r) + if !ok { + return + } + res, err := gatewayClient.PurgeRecycle(r.Context(), &storageprovider.PurgeRecycleRequest{ + Ref: &storageprovider.Reference{ResourceId: spaceRootID(driveID)}, + Key: key, + }) + if err != nil { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return + } + if !renderTrashStatus(w, r, res.GetStatus()) { + return + } + + render.Status(r, http.StatusNoContent) + render.NoContent(w, r) +} + +// parseTrashItemID reads the item id, which the v1.0 and v1beta1 routes bind under different names, +// and checks it against the drive id when the route has one +func parseTrashItemID(w http.ResponseWriter, r *http.Request) (*storageprovider.ResourceId, bool) { + param := "itemID" + if chi.URLParam(r, param) == "" { + param = "driveItemID" + } + itemID, err := parseIDParam(r, param) + if err != nil { + errorcode.RenderError(w, r, err) + return nil, false + } + if itemID.GetOpaqueId() == "" { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid itemID") + return nil, false + } + + if chi.URLParam(r, "driveID") != "" { + driveID, err := parseIDParam(r, "driveID") + 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 not found") + return nil, false + } + } + return &itemID, true +} + +// renderTrashStatus maps a mutating trash call's status; true means OK. Unlike the listing, +// PERMISSION_DENIED is a 403 here: the caller could already see the item. +func renderTrashStatus(w http.ResponseWriter, r *http.Request, st *cs3rpc.Status) bool { + switch st.GetCode() { + case cs3rpc.Code_CODE_OK: + return true + case cs3rpc.Code_CODE_NOT_FOUND: + errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, st.GetMessage()) + case cs3rpc.Code_CODE_PERMISSION_DENIED: + errorcode.AccessDenied.Render(w, r, http.StatusForbidden, st.GetMessage()) + case cs3rpc.Code_CODE_UNAUTHENTICATED: + errorcode.Unauthenticated.Render(w, r, http.StatusUnauthorized, st.GetMessage()) + case cs3rpc.Code_CODE_ALREADY_EXISTS: + errorcode.NameAlreadyExists.Render(w, r, http.StatusConflict, st.GetMessage()) + case cs3rpc.Code_CODE_LOCKED, cs3rpc.Code_CODE_FAILED_PRECONDITION: + errorcode.ItemIsLocked.Render(w, r, http.StatusLocked, st.GetMessage()) + default: + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, st.GetMessage()) + } + return false +} diff --git a/services/graph/pkg/service/v0/driveitems_trash_test.go b/services/graph/pkg/service/v0/driveitems_trash_test.go new file mode 100644 index 0000000000..6546bc8c1b --- /dev/null +++ b/services/graph/pkg/service/v0/driveitems_trash_test.go @@ -0,0 +1,354 @@ +package svc_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/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" + 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" + "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" + graphm "github.com/opencloud-eu/opencloud/services/graph/pkg/middleware" + service "github.com/opencloud-eu/opencloud/services/graph/pkg/service/v0" +) + +var _ = Describe("Drive item trash operations", func() { + var ( + svc service.Service + ctx context.Context + cfg *config.Config + gatewayClient *cs3mocks.GatewayAPIClient + gatewaySelector pool.Selectable[gateway.GatewayAPIClient] + eventsPublisher mocks.Publisher + identityBackend *identitymocks.Backend + + rr *httptest.ResponseRecorder + + currentUser = &userpb.User{Id: &userpb.UserId{OpaqueId: "user"}} + ) + + type params map[string]string + + // newRequest binds the given chi params; body is sent as the JSON request body + newRequest := func(method, url string, p params, body string) *http.Request { + r := httptest.NewRequest(method, url, strings.NewReader(body)) + rctx := chi.NewRouteContext() + for k, v := range p { + rctx.URLParams.Add(k, v) + } + return r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + } + + trashedFile := &provider.RecycleItem{ + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Key: "nodeid", + Ref: &provider.Reference{Path: "/Documents/notes.txt"}, + } + + mockListRecycle := func(items ...*provider.RecycleItem) { + gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Return(&provider.ListRecycleResponse{ + Status: status.NewOK(ctx), + RecycleItems: items, + }, nil) + } + mockRestore := func(st *cs3rpc.Status) **provider.RestoreRecycleItemRequest { + var req *provider.RestoreRecycleItemRequest + gatewayClient.On("RestoreRecycleItem", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + req = args.Get(1).(*provider.RestoreRecycleItemRequest) + }).Return(&provider.RestoreRecycleItemResponse{Status: st}, nil) + return &req + } + mockStatOK := func() { + gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&provider.StatResponse{ + Status: status.NewOK(ctx), + Info: &provider.ResourceInfo{ + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "nodeid"}, + Path: "./Documents/notes.txt", + }, + }, nil) + } + mockPurge := func(st *cs3rpc.Status) **provider.PurgeRecycleRequest { + var req *provider.PurgeRecycleRequest + gatewayClient.On("PurgeRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + req = args.Get(1).(*provider.PurgeRecycleRequest) + }).Return(&provider.PurgeRecycleResponse{Status: st}, nil) + return &req + } + + 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 = "" + cfg.TokenManager.JWTSecret = "loremipsum" + cfg.Commons = &shared.Commons{} + 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()) + }) + + Describe("RestoreDriveItem", func() { + v1Params := params{"driveID": "storageid$spaceid", "driveItemID": "storageid$spaceid!nodeid"} + + It("rejects an item from another drive", func() { + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", params{"driveID": "storageid$other", "driveItemID": "storageid$spaceid!nodeid"}, "")) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + gatewayClient.AssertNotCalled(GinkgoT(), "ListRecycle", mock.Anything, mock.Anything) + }) + + It("rejects a malformed body", func() { + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, `{"nope": 1}`)) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns not found for an unknown key", func() { + mockListRecycle() + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, "")) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + gatewayClient.AssertNotCalled(GinkgoT(), "RestoreRecycleItem", mock.Anything, mock.Anything) + }) + + It("restores to the original location by default and returns the item", func() { + mockListRecycle(trashedFile) + req := mockRestore(status.NewOK(ctx)) + mockStatOK() + + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, "")) + Expect(rr.Code).To(Equal(http.StatusOK)) + + Expect((*req).GetKey()).To(Equal("nodeid")) + Expect((*req).GetRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid")) + Expect((*req).GetRestoreRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid")) + Expect((*req).GetRestoreRef().GetPath()).To(Equal("./Documents/notes.txt")) + + var item libregraph.DriveItem + Expect(json.Unmarshal(rr.Body.Bytes(), &item)).To(Succeed()) + Expect(item.GetId()).To(Equal("storageid$spaceid!nodeid")) + Expect(item.GetName()).To(Equal("notes.txt")) + }) + + It("accepts the v1beta1 itemID param", func() { + mockListRecycle(trashedFile) + mockRestore(status.NewOK(ctx)) + mockStatOK() + + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", params{"driveID": "storageid$spaceid", "itemID": "storageid$spaceid!nodeid"}, "")) + Expect(rr.Code).To(Equal(http.StatusOK)) + }) + + It("works without a drive id (me/drive)", func() { + mockListRecycle(trashedFile) + mockRestore(status.NewOK(ctx)) + mockStatOK() + + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", params{"itemID": "storageid$spaceid!nodeid"}, "")) + Expect(rr.Code).To(Equal(http.StatusOK)) + }) + + It("restores into the given parent with a new name", func() { + mockListRecycle(trashedFile) + req := mockRestore(status.NewOK(ctx)) + mockStatOK() + + body := `{"parentReference": {"id": "storageid$spaceid!parentid"}, "name": "renamed.txt"}` + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, body)) + Expect(rr.Code).To(Equal(http.StatusOK)) + + Expect((*req).GetRestoreRef().GetResourceId().GetOpaqueId()).To(Equal("parentid")) + Expect((*req).GetRestoreRef().GetPath()).To(Equal("./renamed.txt")) + }) + + It("restores into a parent given by path", func() { + mockListRecycle(trashedFile) + req := mockRestore(status.NewOK(ctx)) + mockStatOK() + + body := `{"parentReference": {"path": "/Archive/2025"}}` + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, body)) + Expect(rr.Code).To(Equal(http.StatusOK)) + + Expect((*req).GetRestoreRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid")) + Expect((*req).GetRestoreRef().GetPath()).To(Equal("./Archive/2025/notes.txt")) + }) + + It("only renames when just a name is given", func() { + mockListRecycle(trashedFile) + req := mockRestore(status.NewOK(ctx)) + mockStatOK() + + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, `{"name": "renamed.txt"}`)) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect((*req).GetRestoreRef().GetPath()).To(Equal("./Documents/renamed.txt")) + }) + + It("rejects a name with a slash", func() { + mockListRecycle(trashedFile) + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, `{"name": "a/b.txt"}`)) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + gatewayClient.AssertNotCalled(GinkgoT(), "RestoreRecycleItem", mock.Anything, mock.Anything) + }) + + It("rejects a parent in another drive", func() { + mockListRecycle(trashedFile) + body := `{"parentReference": {"driveId": "storageid$other", "id": "storageid$other!parentid"}}` + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, body)) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + gatewayClient.AssertNotCalled(GinkgoT(), "RestoreRecycleItem", mock.Anything, mock.Anything) + }) + + DescribeTable("maps restore errors", + func(st *cs3rpc.Status, code int) { + mockListRecycle(trashedFile) + mockRestore(st) + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, "")) + Expect(rr.Code).To(Equal(code)) + gatewayClient.AssertNotCalled(GinkgoT(), "Stat", mock.Anything, mock.Anything) + }, + Entry("already exists", status.NewAlreadyExists(context.Background(), errors.New("exists"), "exists"), http.StatusConflict), + Entry("permission denied", status.NewPermissionDenied(context.Background(), errors.New("denied"), "denied"), http.StatusForbidden), + Entry("not found", status.NewNotFound(context.Background(), "gone"), http.StatusNotFound), + Entry("internal", status.NewInternal(context.Background(), "internal"), http.StatusInternalServerError), + ) + }) + + Describe("PermanentDeleteDriveItem", func() { + v1Params := params{"driveID": "storageid$spaceid", "driveItemID": "storageid$spaceid!nodeid"} + + It("refuses the drive root", func() { + svc.PermanentDeleteDriveItem(rr, newRequest(http.MethodPost, "/", params{"driveID": "storageid$spaceid", "driveItemID": "storageid$spaceid!spaceid"}, "")) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + gatewayClient.AssertNotCalled(GinkgoT(), "Delete", mock.Anything, mock.Anything) + }) + + It("refuses share jail items", func() { + p := params{"driveID": "a0ca6a90-a365-4782-871e-d44447bbc668$a0ca6a90-a365-4782-871e-d44447bbc668", "driveItemID": "a0ca6a90-a365-4782-871e-d44447bbc668$a0ca6a90-a365-4782-871e-d44447bbc668!shareid"} + svc.PermanentDeleteDriveItem(rr, newRequest(http.MethodPost, "/", p, "")) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + gatewayClient.AssertNotCalled(GinkgoT(), "Delete", mock.Anything, mock.Anything) + }) + + It("deletes and purges the trash key", func() { + var delReq *provider.DeleteRequest + gatewayClient.On("Delete", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + delReq = args.Get(1).(*provider.DeleteRequest) + }).Return(&provider.DeleteResponse{Status: status.NewOK(ctx)}, nil) + purge := mockPurge(status.NewOK(ctx)) + + svc.PermanentDeleteDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, "")) + Expect(rr.Code).To(Equal(http.StatusNoContent)) + + Expect(delReq.GetRef().GetResourceId().GetOpaqueId()).To(Equal("nodeid")) + Expect((*purge).GetRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid")) + Expect((*purge).GetKey()).To(Equal("nodeid")) + }) + + It("maps a failed delete and does not purge", func() { + gatewayClient.On("Delete", mock.Anything, mock.Anything).Return(&provider.DeleteResponse{Status: status.NewNotFound(ctx, "gone")}, nil) + svc.PermanentDeleteDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, "")) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + gatewayClient.AssertNotCalled(GinkgoT(), "PurgeRecycle", mock.Anything, mock.Anything) + }) + + It("reports a failed purge after a successful delete", func() { + gatewayClient.On("Delete", mock.Anything, mock.Anything).Return(&provider.DeleteResponse{Status: status.NewOK(ctx)}, nil) + mockPurge(status.NewInternal(ctx, "boom")) + svc.PermanentDeleteDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, "")) + Expect(rr.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Describe("DeleteDriveSpecialItem", func() { + It("rejects an unknown special folder", func() { + svc.DeleteDriveSpecialItem(rr, newRequest(http.MethodDelete, "/", params{"driveID": "storageid$spaceid", "specialName": "nope", "itemID": "storageid$spaceid!nodeid"}, "")) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("rejects an item from another drive", func() { + svc.DeleteDriveSpecialItem(rr, newRequest(http.MethodDelete, "/", params{"driveID": "storageid$spaceid", "specialName": "recyclebin", "itemID": "storageid$other!nodeid"}, "")) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + gatewayClient.AssertNotCalled(GinkgoT(), "PurgeRecycle", mock.Anything, mock.Anything) + }) + + It("purges the key", func() { + purge := mockPurge(status.NewOK(ctx)) + svc.DeleteDriveSpecialItem(rr, newRequest(http.MethodDelete, "/", params{"driveID": "storageid$spaceid", "specialName": "recyclebin", "itemID": "storageid$spaceid!nodeid/sub/file"}, "")) + Expect(rr.Code).To(Equal(http.StatusNoContent)) + Expect((*purge).GetKey()).To(Equal("nodeid/sub/file")) + Expect((*purge).GetRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid")) + }) + + It("maps permission denied to forbidden", func() { + mockPurge(status.NewPermissionDenied(ctx, errors.New("denied"), "denied")) + svc.DeleteDriveSpecialItem(rr, newRequest(http.MethodDelete, "/", params{"driveID": "storageid$spaceid", "specialName": "recyclebin", "itemID": "storageid$spaceid!nodeid"}, "")) + Expect(rr.Code).To(Equal(http.StatusForbidden)) + }) + }) + + Describe("EmptyDriveSpecial", func() { + It("purges the whole trash", func() { + purge := mockPurge(status.NewOK(ctx)) + svc.EmptyDriveSpecial(rr, newRequest(http.MethodDelete, "/", params{"driveID": "storageid$spaceid", "specialName": "recyclebin"}, "")) + Expect(rr.Code).To(Equal(http.StatusNoContent)) + Expect((*purge).GetKey()).To(Equal("")) + }) + + It("purges only the addressed key in the colon path form", func() { + purge := mockPurge(status.NewOK(ctx)) + r := newRequest(http.MethodDelete, "/", params{"driveID": "storageid$spaceid", "specialName": "recyclebin"}, "") + r = r.WithContext(graphm.WithSpecialFolderPath(r.Context(), "/nodeid")) + svc.EmptyDriveSpecial(rr, r) + Expect(rr.Code).To(Equal(http.StatusNoContent)) + Expect((*purge).GetKey()).To(Equal("nodeid")) + }) + }) +}) diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go index c30513d657..dc49c258c5 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 GetDriveItemChildren(w http.ResponseWriter, r *http.Request) GetDriveSpecial(w http.ResponseWriter, r *http.Request) ListDriveSpecialChildren(w http.ResponseWriter, r *http.Request) + EmptyDriveSpecial(w http.ResponseWriter, r *http.Request) + DeleteDriveSpecialItem(w http.ResponseWriter, r *http.Request) + RestoreDriveItem(w http.ResponseWriter, r *http.Request) + PermanentDeleteDriveItem(w http.ResponseWriter, r *http.Request) CreateUploadSession(w http.ResponseWriter, r *http.Request) @@ -273,6 +277,8 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx r.Get("/", drivesDriveItemApi.GetDriveItem) r.Patch("/", drivesDriveItemApi.UpdateDriveItem) r.Delete("/", drivesDriveItemApi.DeleteDriveItem) + r.Post("/restore", svc.RestoreDriveItem) + r.Post("/permanentDelete", svc.PermanentDeleteDriveItem) r.Post("/invite", driveItemPermissionsApi.Invite) r.Post("/createLink", driveItemPermissionsApi.CreateLink) r.Route("/permissions", func(r chi.Router) { @@ -308,6 +314,8 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx r.Get("/", svc.GetUserDrive) r.Get("/root/children", svc.GetRootDriveChildren) r.Post("/items/{itemID}/follow", svc.FollowDriveItem) + r.Post("/items/{itemID}/restore", svc.RestoreDriveItem) + r.Post("/items/{itemID}/permanentDelete", svc.PermanentDeleteDriveItem) r.Delete("/following/{itemID}", svc.UnfollowDriveItem) }) r.Get("/drives", svc.GetDrives(APIVersion_1)) @@ -372,10 +380,14 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx r.Get("/", svc.GetDriveItem) r.Get("/children", svc.GetDriveItemChildren) r.Post("/createUploadSession", svc.CreateUploadSession) + r.Post("/restore", svc.RestoreDriveItem) + r.Post("/permanentDelete", svc.PermanentDeleteDriveItem) }) r.Route("/special/{specialName}", func(r chi.Router) { r.Get("/", svc.GetDriveSpecial) + r.Delete("/", svc.EmptyDriveSpecial) r.Get("/children", svc.ListDriveSpecialChildren) + r.Delete("/items/{itemID}", svc.DeleteDriveSpecialItem) }) }) }) From 7469ab05c7f09e83d0661fc356e528620aea43dc Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Mon, 7 Sep 2026 13:08:31 +0200 Subject: [PATCH 4/5] graph: share the recycle item lookup between listing and restore, merge the recycle bin tests --- .../service/v0/driveitems_recyclebin_test.go | 595 ++++++++++++++++++ .../pkg/service/v0/driveitems_special.go | 33 +- .../pkg/service/v0/driveitems_special_test.go | 377 ----------- .../graph/pkg/service/v0/driveitems_trash.go | 31 +- .../pkg/service/v0/driveitems_trash_test.go | 354 ----------- 5 files changed, 622 insertions(+), 768 deletions(-) create mode 100644 services/graph/pkg/service/v0/driveitems_recyclebin_test.go delete mode 100644 services/graph/pkg/service/v0/driveitems_special_test.go delete mode 100644 services/graph/pkg/service/v0/driveitems_trash_test.go diff --git a/services/graph/pkg/service/v0/driveitems_recyclebin_test.go b/services/graph/pkg/service/v0/driveitems_recyclebin_test.go new file mode 100644 index 0000000000..2c1a61f8fd --- /dev/null +++ b/services/graph/pkg/service/v0/driveitems_recyclebin_test.go @@ -0,0 +1,595 @@ +package svc_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "time" + + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/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/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" + "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" + graphm "github.com/opencloud-eu/opencloud/services/graph/pkg/middleware" + service "github.com/opencloud-eu/opencloud/services/graph/pkg/service/v0" +) + +var _ = Describe("Drive recycle bin", func() { + var ( + svc service.Service + ctx context.Context + cfg *config.Config + gatewayClient *cs3mocks.GatewayAPIClient + gatewaySelector pool.Selectable[gateway.GatewayAPIClient] + eventsPublisher mocks.Publisher + identityBackend *identitymocks.Backend + + rr *httptest.ResponseRecorder + + currentUser = &userpb.User{Id: &userpb.UserId{OpaqueId: "user"}} + deletedAt = time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + ) + + type params map[string]string + + specialParams := params{"driveID": "storageid$spaceid", "specialName": "recyclebin"} + itemParams := params{"driveID": "storageid$spaceid", "driveItemID": "storageid$spaceid!nodeid"} + + // newRequest binds the given chi params; query goes into the URL, body is sent as JSON + newRequest := func(method string, p params, query, body string) *http.Request { + r := httptest.NewRequest(method, "/graph/v1.0/drives/storageid$spaceid"+query, strings.NewReader(body)) + rctx := chi.NewRouteContext() + for k, v := range p { + rctx.URLParams.Add(k, v) + } + return r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + } + // withSpecialPath mimics what the colon-path middleware puts into the context + withSpecialPath := func(r *http.Request, specialPath string) *http.Request { + return r.WithContext(graphm.WithSpecialFolderPath(r.Context(), specialPath)) + } + + decodeItem := func() libregraph.DriveItem { + var item libregraph.DriveItem + Expect(json.Unmarshal(rr.Body.Bytes(), &item)).To(Succeed()) + return item + } + decodeList := func() []libregraph.DriveItem { + var res struct { + Value []libregraph.DriveItem + } + Expect(json.Unmarshal(rr.Body.Bytes(), &res)).To(Succeed()) + return res.Value + } + + trashedFile := &provider.RecycleItem{ + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Key: "nodeid", + Size: 42, + DeletionTime: utils.TimeToTS(deletedAt), + Ref: &provider.Reference{Path: "/Documents/notes.txt"}, + } + + // mockListRecycle answers every ListRecycle with the items and records the requested keys + mockListRecycle := func(items ...*provider.RecycleItem) *[]string { + var keys []string + gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + keys = append(keys, args.Get(1).(*provider.ListRecycleRequest).GetKey()) + }).Return(&provider.ListRecycleResponse{ + Status: status.NewOK(ctx), + RecycleItems: items, + }, nil) + return &keys + } + mockRestore := func(st *cs3rpc.Status) **provider.RestoreRecycleItemRequest { + var req *provider.RestoreRecycleItemRequest + gatewayClient.On("RestoreRecycleItem", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + req = args.Get(1).(*provider.RestoreRecycleItemRequest) + }).Return(&provider.RestoreRecycleItemResponse{Status: st}, nil) + return &req + } + mockStatOK := func() { + gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&provider.StatResponse{ + Status: status.NewOK(ctx), + Info: &provider.ResourceInfo{ + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "nodeid"}, + Path: "./Documents/notes.txt", + }, + }, nil) + } + mockPurge := func(st *cs3rpc.Status) **provider.PurgeRecycleRequest { + var req *provider.PurgeRecycleRequest + gatewayClient.On("PurgeRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + req = args.Get(1).(*provider.PurgeRecycleRequest) + }).Return(&provider.PurgeRecycleResponse{Status: st}, nil) + return &req + } + + 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 = "" + cfg.TokenManager.JWTSecret = "loremipsum" + cfg.Commons = &shared.Commons{} + 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()) + }) + + Describe("GetDriveSpecial", func() { + It("rejects an unknown special folder", func() { + svc.GetDriveSpecial(rr, newRequest(http.MethodGet, params{"driveID": "storageid$spaceid", "specialName": "nope"}, "", "")) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + gatewayClient.AssertNotCalled(GinkgoT(), "ListRecycle", mock.Anything, mock.Anything) + }) + + It("returns the synthetic recycle bin folder without touching the storage", func() { + svc.GetDriveSpecial(rr, newRequest(http.MethodGet, specialParams, "", "")) + Expect(rr.Code).To(Equal(http.StatusOK)) + gatewayClient.AssertNotCalled(GinkgoT(), "ListRecycle", mock.Anything, mock.Anything) + + item := decodeItem() + Expect(item.GetId()).To(Equal("storageid$spaceid!recyclebin")) + Expect(item.GetName()).To(Equal("recyclebin")) + Expect(item.Folder).ToNot(BeNil()) + Expect(item.SpecialFolder.GetName()).To(Equal("recyclebin")) + Expect(item.ParentReference.GetDriveId()).To(Equal("storageid$spaceid")) + Expect(item.ParentReference.GetId()).To(Equal("storageid$spaceid!spaceid")) + }) + + It("returns a top-level trashed item by key", func() { + var req *provider.ListRecycleRequest + gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + req = args.Get(1).(*provider.ListRecycleRequest) + }).Return(&provider.ListRecycleResponse{ + Status: status.NewOK(ctx), + RecycleItems: []*provider.RecycleItem{trashedFile}, + }, nil) + + svc.GetDriveSpecial(rr, withSpecialPath(newRequest(http.MethodGet, specialParams, "", ""), "/nodeid")) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(req.GetKey()).To(Equal("nodeid")) + Expect(req.GetRef().GetResourceId().GetStorageId()).To(Equal("storageid")) + Expect(req.GetRef().GetResourceId().GetSpaceId()).To(Equal("spaceid")) + Expect(req.GetRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid")) + + item := decodeItem() + Expect(item.GetId()).To(Equal("storageid$spaceid!nodeid")) + Expect(item.GetName()).To(Equal("notes.txt")) + Expect(item.GetSize()).To(Equal(int64(42))) + Expect(item.File.GetMimeType()).To(Equal("text/plain; charset=utf-8")) + Expect(item.Folder).To(BeNil()) + Expect(item.Trash.GetTrashedDateTime()).To(BeTemporally("==", deletedAt)) + Expect(item.Trash.TrashedBy).To(BeNil()) + Expect(item.ParentReference.GetDriveId()).To(Equal("storageid$spaceid")) + Expect(item.ParentReference.GetPath()).To(Equal("/Documents")) + Expect(item.ParentReference.GetName()).To(Equal("Documents")) + }) + + It("returns a nested trashed item through its parent listing", func() { + keys := mockListRecycle( + &provider.RecycleItem{Type: provider.ResourceType_RESOURCE_TYPE_FILE, Key: "nodeid/sub/other.txt", Ref: &provider.Reference{Path: "/folder/sub/other.txt"}}, + &provider.RecycleItem{Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER, Key: "nodeid/sub/inner", Ref: &provider.Reference{Path: "/folder/sub/inner"}}, + ) + + svc.GetDriveSpecial(rr, withSpecialPath(newRequest(http.MethodGet, specialParams, "", ""), "/nodeid/sub/inner")) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(*keys).To(Equal([]string{"nodeid/sub/"})) + + item := decodeItem() + Expect(item.GetId()).To(Equal("storageid$spaceid!nodeid/sub/inner")) + Expect(item.GetName()).To(Equal("inner")) + Expect(item.Folder).ToNot(BeNil()) + Expect(item.File).To(BeNil()) + }) + + It("embeds the trash listing with $expand=children", func() { + keys := mockListRecycle(trashedFile) + + svc.GetDriveSpecial(rr, newRequest(http.MethodGet, specialParams, "?$expand=children", "")) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(*keys).To(Equal([]string{""})) + + item := decodeItem() + Expect(item.GetId()).To(Equal("storageid$spaceid!recyclebin")) + Expect(item.Children).To(HaveLen(1)) + Expect(item.Children[0].GetId()).To(Equal("storageid$spaceid!nodeid")) + Expect(item.Children[0].Trash).ToNot(BeNil()) + }) + + It("embeds the children of a trashed folder with $expand=children", func() { + var keys []string + record := func(args mock.Arguments) { + keys = append(keys, args.Get(1).(*provider.ListRecycleRequest).GetKey()) + } + gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Run(record).Return(&provider.ListRecycleResponse{ + Status: status.NewOK(ctx), + RecycleItems: []*provider.RecycleItem{{Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER, Key: "nodeid", Ref: &provider.Reference{Path: "/folder"}}}, + }, nil).Once() + gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Run(record).Return(&provider.ListRecycleResponse{ + Status: status.NewOK(ctx), + RecycleItems: []*provider.RecycleItem{{Type: provider.ResourceType_RESOURCE_TYPE_FILE, Key: "nodeid/a.txt", Ref: &provider.Reference{Path: "/folder/a.txt"}}}, + }, nil).Once() + + svc.GetDriveSpecial(rr, withSpecialPath(newRequest(http.MethodGet, specialParams, "?$expand=children", ""), "/nodeid")) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(keys).To(Equal([]string{"nodeid", "nodeid/"})) + + item := decodeItem() + Expect(item.GetId()).To(Equal("storageid$spaceid!nodeid")) + Expect(item.Children).To(HaveLen(1)) + Expect(item.Children[0].GetId()).To(Equal("storageid$spaceid!nodeid/a.txt")) + }) + + It("does not expand children of a trashed file", func() { + mockListRecycle(trashedFile) + + svc.GetDriveSpecial(rr, withSpecialPath(newRequest(http.MethodGet, specialParams, "?$expand=children", ""), "/nodeid")) + Expect(rr.Code).To(Equal(http.StatusOK)) + gatewayClient.AssertNumberOfCalls(GinkgoT(), "ListRecycle", 1) + Expect(decodeItem().Children).To(BeNil()) + }) + + It("returns not found when the key is not in the listing", func() { + mockListRecycle(&provider.RecycleItem{Key: "other"}) + + svc.GetDriveSpecial(rr, withSpecialPath(newRequest(http.MethodGet, specialParams, "", ""), "/nodeid")) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("ListDriveSpecialChildren", func() { + It("rejects an unknown special folder", func() { + svc.ListDriveSpecialChildren(rr, newRequest(http.MethodGet, params{"driveID": "storageid$spaceid", "specialName": "nope"}, "", "")) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + DescribeTable("maps ListRecycle errors", + func(st *cs3rpc.Status, code int) { + gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Return(&provider.ListRecycleResponse{Status: st}, nil) + svc.ListDriveSpecialChildren(rr, newRequest(http.MethodGet, specialParams, "", "")) + Expect(rr.Code).To(Equal(code)) + }, + Entry("not found", status.NewNotFound(context.Background(), "not found"), http.StatusNotFound), + Entry("permission denied as not found", status.NewPermissionDenied(context.Background(), errors.New("denied"), "denied"), http.StatusNotFound), + Entry("unauthenticated", status.NewUnauthenticated(context.Background(), errors.New("no"), "no"), http.StatusUnauthorized), + Entry("internal", status.NewInternal(context.Background(), "internal"), http.StatusInternalServerError), + ) + + It("lists the trash root with the trash facet", func() { + keys := mockListRecycle( + &provider.RecycleItem{ + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Key: "file-node", + Size: 7, + DeletionTime: utils.TimeToTS(deletedAt), + Ref: &provider.Reference{Path: "/photo.jpg"}, + }, + &provider.RecycleItem{ + Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER, + Key: "folder-node", + Size: 100, + DeletionTime: utils.TimeToTS(deletedAt), + Ref: &provider.Reference{Path: "/Projects/old"}, + }, + ) + + svc.ListDriveSpecialChildren(rr, newRequest(http.MethodGet, specialParams, "", "")) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(*keys).To(Equal([]string{""})) + + items := decodeList() + Expect(items).To(HaveLen(2)) + + Expect(items[0].GetId()).To(Equal("storageid$spaceid!file-node")) + Expect(items[0].GetName()).To(Equal("photo.jpg")) + Expect(items[0].File.GetMimeType()).To(Equal("image/jpeg")) + Expect(items[0].Trash.GetTrashedDateTime()).To(BeTemporally("==", deletedAt)) + Expect(items[0].ParentReference.GetPath()).To(Equal("/")) + Expect(items[0].ParentReference.Name).To(BeNil()) + + Expect(items[1].GetId()).To(Equal("storageid$spaceid!folder-node")) + Expect(items[1].GetName()).To(Equal("old")) + Expect(items[1].Folder).ToNot(BeNil()) + Expect(items[1].GetSize()).To(Equal(int64(100))) + Expect(items[1].ParentReference.GetPath()).To(Equal("/Projects")) + Expect(items[1].ParentReference.GetName()).To(Equal("Projects")) + }) + + It("returns an empty list for an empty trash", func() { + mockListRecycle() + + svc.ListDriveSpecialChildren(rr, newRequest(http.MethodGet, specialParams, "", "")) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(rr.Body.String()).To(MatchJSON(`{"value":[]}`)) + }) + + It("lists inside a trashed folder with a trailing slash key", func() { + keys := mockListRecycle( + &provider.RecycleItem{Type: provider.ResourceType_RESOURCE_TYPE_FILE, Key: "folder-node/sub/a.txt", Ref: &provider.Reference{Path: "/Projects/old/sub/a.txt"}}, + ) + + svc.ListDriveSpecialChildren(rr, withSpecialPath(newRequest(http.MethodGet, specialParams, "", ""), "/folder-node/sub")) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(*keys).To(Equal([]string{"folder-node/sub/"})) + + items := decodeList() + Expect(items).To(HaveLen(1)) + Expect(items[0].GetId()).To(Equal("storageid$spaceid!folder-node/sub/a.txt")) + Expect(items[0].ParentReference.GetPath()).To(Equal("/Projects/old/sub")) + }) + }) + + Describe("RestoreDriveItem", func() { + It("rejects an item from another drive", func() { + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, params{"driveID": "storageid$other", "driveItemID": "storageid$spaceid!nodeid"}, "", "")) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + gatewayClient.AssertNotCalled(GinkgoT(), "ListRecycle", mock.Anything, mock.Anything) + }) + + It("rejects a malformed body", func() { + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, itemParams, "", `{"nope": 1}`)) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns not found for an unknown key", func() { + mockListRecycle() + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, itemParams, "", "")) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + gatewayClient.AssertNotCalled(GinkgoT(), "RestoreRecycleItem", mock.Anything, mock.Anything) + }) + + It("restores to the original location by default and returns the item", func() { + keys := mockListRecycle(trashedFile) + req := mockRestore(status.NewOK(ctx)) + mockStatOK() + + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, itemParams, "", "")) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(*keys).To(Equal([]string{"nodeid"})) + + Expect((*req).GetKey()).To(Equal("nodeid")) + Expect((*req).GetRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid")) + Expect((*req).GetRestoreRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid")) + Expect((*req).GetRestoreRef().GetPath()).To(Equal("./Documents/notes.txt")) + + item := decodeItem() + Expect(item.GetId()).To(Equal("storageid$spaceid!nodeid")) + Expect(item.GetName()).To(Equal("notes.txt")) + }) + + It("finds a nested key through its parent listing", func() { + keys := mockListRecycle( + &provider.RecycleItem{Type: provider.ResourceType_RESOURCE_TYPE_FILE, Key: "nodeid/sub/a.txt", Ref: &provider.Reference{Path: "/folder/sub/a.txt"}}, + ) + req := mockRestore(status.NewOK(ctx)) + mockStatOK() + + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, params{"driveID": "storageid$spaceid", "driveItemID": "storageid$spaceid!nodeid/sub/a.txt"}, "", "")) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(*keys).To(Equal([]string{"nodeid/sub/"})) + Expect((*req).GetKey()).To(Equal("nodeid/sub/a.txt")) + Expect((*req).GetRestoreRef().GetPath()).To(Equal("./folder/sub/a.txt")) + }) + + It("accepts the v1beta1 itemID param", func() { + mockListRecycle(trashedFile) + mockRestore(status.NewOK(ctx)) + mockStatOK() + + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, params{"driveID": "storageid$spaceid", "itemID": "storageid$spaceid!nodeid"}, "", "")) + Expect(rr.Code).To(Equal(http.StatusOK)) + }) + + It("works without a drive id (me/drive)", func() { + mockListRecycle(trashedFile) + mockRestore(status.NewOK(ctx)) + mockStatOK() + + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, params{"itemID": "storageid$spaceid!nodeid"}, "", "")) + Expect(rr.Code).To(Equal(http.StatusOK)) + }) + + It("restores into the given parent with a new name", func() { + mockListRecycle(trashedFile) + req := mockRestore(status.NewOK(ctx)) + mockStatOK() + + body := `{"parentReference": {"id": "storageid$spaceid!parentid"}, "name": "renamed.txt"}` + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, itemParams, "", body)) + Expect(rr.Code).To(Equal(http.StatusOK)) + + Expect((*req).GetRestoreRef().GetResourceId().GetOpaqueId()).To(Equal("parentid")) + Expect((*req).GetRestoreRef().GetPath()).To(Equal("./renamed.txt")) + }) + + It("restores into a parent given by path", func() { + mockListRecycle(trashedFile) + req := mockRestore(status.NewOK(ctx)) + mockStatOK() + + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, itemParams, "", `{"parentReference": {"path": "/Archive/2025"}}`)) + Expect(rr.Code).To(Equal(http.StatusOK)) + + Expect((*req).GetRestoreRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid")) + Expect((*req).GetRestoreRef().GetPath()).To(Equal("./Archive/2025/notes.txt")) + }) + + It("only renames when just a name is given", func() { + mockListRecycle(trashedFile) + req := mockRestore(status.NewOK(ctx)) + mockStatOK() + + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, itemParams, "", `{"name": "renamed.txt"}`)) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect((*req).GetRestoreRef().GetPath()).To(Equal("./Documents/renamed.txt")) + }) + + It("rejects a name with a slash", func() { + mockListRecycle(trashedFile) + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, itemParams, "", `{"name": "a/b.txt"}`)) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + gatewayClient.AssertNotCalled(GinkgoT(), "RestoreRecycleItem", mock.Anything, mock.Anything) + }) + + It("rejects a parent in another drive", func() { + mockListRecycle(trashedFile) + body := `{"parentReference": {"driveId": "storageid$other", "id": "storageid$other!parentid"}}` + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, itemParams, "", body)) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + gatewayClient.AssertNotCalled(GinkgoT(), "RestoreRecycleItem", mock.Anything, mock.Anything) + }) + + DescribeTable("maps restore errors", + func(st *cs3rpc.Status, code int) { + mockListRecycle(trashedFile) + mockRestore(st) + svc.RestoreDriveItem(rr, newRequest(http.MethodPost, itemParams, "", "")) + Expect(rr.Code).To(Equal(code)) + gatewayClient.AssertNotCalled(GinkgoT(), "Stat", mock.Anything, mock.Anything) + }, + Entry("already exists", status.NewAlreadyExists(context.Background(), errors.New("exists"), "exists"), http.StatusConflict), + Entry("permission denied", status.NewPermissionDenied(context.Background(), errors.New("denied"), "denied"), http.StatusForbidden), + Entry("not found", status.NewNotFound(context.Background(), "gone"), http.StatusNotFound), + Entry("internal", status.NewInternal(context.Background(), "internal"), http.StatusInternalServerError), + ) + }) + + Describe("PermanentDeleteDriveItem", func() { + It("refuses the drive root", func() { + svc.PermanentDeleteDriveItem(rr, newRequest(http.MethodPost, params{"driveID": "storageid$spaceid", "driveItemID": "storageid$spaceid!spaceid"}, "", "")) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + gatewayClient.AssertNotCalled(GinkgoT(), "Delete", mock.Anything, mock.Anything) + }) + + It("refuses share jail items", func() { + p := params{"driveID": "a0ca6a90-a365-4782-871e-d44447bbc668$a0ca6a90-a365-4782-871e-d44447bbc668", "driveItemID": "a0ca6a90-a365-4782-871e-d44447bbc668$a0ca6a90-a365-4782-871e-d44447bbc668!shareid"} + svc.PermanentDeleteDriveItem(rr, newRequest(http.MethodPost, p, "", "")) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + gatewayClient.AssertNotCalled(GinkgoT(), "Delete", mock.Anything, mock.Anything) + }) + + It("deletes and purges the trash key", func() { + var delReq *provider.DeleteRequest + gatewayClient.On("Delete", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + delReq = args.Get(1).(*provider.DeleteRequest) + }).Return(&provider.DeleteResponse{Status: status.NewOK(ctx)}, nil) + purge := mockPurge(status.NewOK(ctx)) + + svc.PermanentDeleteDriveItem(rr, newRequest(http.MethodPost, itemParams, "", "")) + Expect(rr.Code).To(Equal(http.StatusNoContent)) + + Expect(delReq.GetRef().GetResourceId().GetOpaqueId()).To(Equal("nodeid")) + Expect((*purge).GetRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid")) + Expect((*purge).GetKey()).To(Equal("nodeid")) + }) + + It("maps a failed delete and does not purge", func() { + gatewayClient.On("Delete", mock.Anything, mock.Anything).Return(&provider.DeleteResponse{Status: status.NewNotFound(ctx, "gone")}, nil) + svc.PermanentDeleteDriveItem(rr, newRequest(http.MethodPost, itemParams, "", "")) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + gatewayClient.AssertNotCalled(GinkgoT(), "PurgeRecycle", mock.Anything, mock.Anything) + }) + + It("reports a failed purge after a successful delete", func() { + gatewayClient.On("Delete", mock.Anything, mock.Anything).Return(&provider.DeleteResponse{Status: status.NewOK(ctx)}, nil) + mockPurge(status.NewInternal(ctx, "boom")) + svc.PermanentDeleteDriveItem(rr, newRequest(http.MethodPost, itemParams, "", "")) + Expect(rr.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Describe("DeleteDriveSpecialItem", func() { + It("rejects an unknown special folder", func() { + svc.DeleteDriveSpecialItem(rr, newRequest(http.MethodDelete, params{"driveID": "storageid$spaceid", "specialName": "nope", "itemID": "storageid$spaceid!nodeid"}, "", "")) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("rejects an item from another drive", func() { + svc.DeleteDriveSpecialItem(rr, newRequest(http.MethodDelete, params{"driveID": "storageid$spaceid", "specialName": "recyclebin", "itemID": "storageid$other!nodeid"}, "", "")) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + gatewayClient.AssertNotCalled(GinkgoT(), "PurgeRecycle", mock.Anything, mock.Anything) + }) + + It("purges the key", func() { + purge := mockPurge(status.NewOK(ctx)) + svc.DeleteDriveSpecialItem(rr, newRequest(http.MethodDelete, params{"driveID": "storageid$spaceid", "specialName": "recyclebin", "itemID": "storageid$spaceid!nodeid/sub/file"}, "", "")) + Expect(rr.Code).To(Equal(http.StatusNoContent)) + Expect((*purge).GetKey()).To(Equal("nodeid/sub/file")) + Expect((*purge).GetRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid")) + }) + + It("maps permission denied to forbidden", func() { + mockPurge(status.NewPermissionDenied(ctx, errors.New("denied"), "denied")) + svc.DeleteDriveSpecialItem(rr, newRequest(http.MethodDelete, params{"driveID": "storageid$spaceid", "specialName": "recyclebin", "itemID": "storageid$spaceid!nodeid"}, "", "")) + Expect(rr.Code).To(Equal(http.StatusForbidden)) + }) + }) + + Describe("EmptyDriveSpecial", func() { + It("purges the whole trash", func() { + purge := mockPurge(status.NewOK(ctx)) + svc.EmptyDriveSpecial(rr, newRequest(http.MethodDelete, specialParams, "", "")) + Expect(rr.Code).To(Equal(http.StatusNoContent)) + Expect((*purge).GetKey()).To(Equal("")) + }) + + It("purges only the addressed key in the colon path form", func() { + purge := mockPurge(status.NewOK(ctx)) + svc.EmptyDriveSpecial(rr, withSpecialPath(newRequest(http.MethodDelete, specialParams, "", ""), "/nodeid")) + Expect(rr.Code).To(Equal(http.StatusNoContent)) + Expect((*purge).GetKey()).To(Equal("nodeid")) + }) + }) +}) diff --git a/services/graph/pkg/service/v0/driveitems_special.go b/services/graph/pkg/service/v0/driveitems_special.go index 4bb83a4010..55e602c0f5 100644 --- a/services/graph/pkg/service/v0/driveitems_special.go +++ b/services/graph/pkg/service/v0/driveitems_special.go @@ -6,7 +6,6 @@ import ( "path" "strings" - 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" @@ -56,8 +55,16 @@ func (g Graph) getSpecialDriveItemForKey(w http.ResponseWriter, r *http.Request, if key == "" { return recycleBinDriveItem(driveID), true } + item, ok := g.findRecycleItem(w, r, driveID, key) + if !ok { + return nil, false + } + return recycleItemToDriveItem(driveID, item), true +} - // A top-level key lists itself, a nested key only shows up in its parent's listing. +// findRecycleItem returns the trashed item with the given key. A top-level key lists itself, +// a nested key (key/rel/path) only shows up in its parent's listing. +func (g Graph) findRecycleItem(w http.ResponseWriter, r *http.Request, driveID *storageprovider.ResourceId, key string) (*storageprovider.RecycleItem, bool) { listKey := key if strings.Contains(key, "/") { listKey = path.Dir(key) + "/" @@ -68,7 +75,7 @@ func (g Graph) getSpecialDriveItemForKey(w http.ResponseWriter, r *http.Request, } for _, item := range items { if item.GetKey() == key { - return recycleItemToDriveItem(driveID, item), true + return item, true } } errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "item not found") @@ -144,26 +151,14 @@ func (g Graph) listRecycle(w http.ResponseWriter, r *http.Request, driveID *stor Ref: &storageprovider.Reference{ResourceId: spaceRootID(driveID)}, Key: key, }) - switch { - case err != nil: + if err != nil { errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) return nil, false - case res.GetStatus().GetCode() == cs3rpc.Code_CODE_OK: - // ok - case res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND: - errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage()) - return nil, false - case res.GetStatus().GetCode() == cs3rpc.Code_CODE_PERMISSION_DENIED: - errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage()) // do not leak existence, like listDriveItemChildren - return nil, false - case res.GetStatus().GetCode() == cs3rpc.Code_CODE_UNAUTHENTICATED: - errorcode.Unauthenticated.Render(w, r, http.StatusUnauthorized, res.GetStatus().GetMessage()) - return nil, false - default: - errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage()) + } + // like listDriveItemChildren, a denied listing does not disclose existence + if !renderTrashStatus(w, r, res.GetStatus(), true) { return nil, false } - return res.GetRecycleItems(), true } diff --git a/services/graph/pkg/service/v0/driveitems_special_test.go b/services/graph/pkg/service/v0/driveitems_special_test.go deleted file mode 100644 index 205e5f6bf8..0000000000 --- a/services/graph/pkg/service/v0/driveitems_special_test.go +++ /dev/null @@ -1,377 +0,0 @@ -package svc_test - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "time" - - gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" - userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" - cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/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/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" - "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" - graphm "github.com/opencloud-eu/opencloud/services/graph/pkg/middleware" - service "github.com/opencloud-eu/opencloud/services/graph/pkg/service/v0" -) - -var _ = Describe("Drive special folders", func() { - var ( - svc service.Service - ctx context.Context - cfg *config.Config - gatewayClient *cs3mocks.GatewayAPIClient - gatewaySelector pool.Selectable[gateway.GatewayAPIClient] - eventsPublisher mocks.Publisher - identityBackend *identitymocks.Backend - - rr *httptest.ResponseRecorder - - currentUser = &userpb.User{ - Id: &userpb.UserId{ - OpaqueId: "user", - }, - } - deletedAt = time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) - ) - - // newRequestWithQuery builds a request for /drives/{driveID}/special/{specialName}[/children]; - // specialPath mimics what the colon-path middleware puts into the context. - newRequestWithQuery := func(specialName, specialPath, query string) *http.Request { - r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives/storageid$spaceid/special/"+specialName+query, nil) - rctx := chi.NewRouteContext() - rctx.URLParams.Add("driveID", "storageid$spaceid") - rctx.URLParams.Add("specialName", specialName) - c := context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx) - if specialPath != "" { - c = graphm.WithSpecialFolderPath(c, specialPath) - } - return r.WithContext(c) - } - newRequest := func(specialName, specialPath string) *http.Request { - return newRequestWithQuery(specialName, specialPath, "") - } - - decodeItem := func() libregraph.DriveItem { - var item libregraph.DriveItem - Expect(json.Unmarshal(rr.Body.Bytes(), &item)).To(Succeed()) - return item - } - decodeList := func() []libregraph.DriveItem { - var res struct { - Value []libregraph.DriveItem - } - Expect(json.Unmarshal(rr.Body.Bytes(), &res)).To(Succeed()) - return res.Value - } - - 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 = "" - cfg.TokenManager.JWTSecret = "loremipsum" - cfg.Commons = &shared.Commons{} - 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()) - }) - - Describe("GetDriveSpecial", func() { - It("rejects an unknown special folder", func() { - svc.GetDriveSpecial(rr, newRequest("nope", "")) - Expect(rr.Code).To(Equal(http.StatusBadRequest)) - gatewayClient.AssertNotCalled(GinkgoT(), "ListRecycle", mock.Anything, mock.Anything) - }) - - It("returns the synthetic recycle bin folder without touching the storage", func() { - svc.GetDriveSpecial(rr, newRequest("recyclebin", "")) - Expect(rr.Code).To(Equal(http.StatusOK)) - gatewayClient.AssertNotCalled(GinkgoT(), "ListRecycle", mock.Anything, mock.Anything) - - item := decodeItem() - Expect(item.GetId()).To(Equal("storageid$spaceid!recyclebin")) - Expect(item.GetName()).To(Equal("recyclebin")) - Expect(item.Folder).ToNot(BeNil()) - Expect(item.SpecialFolder.GetName()).To(Equal("recyclebin")) - Expect(item.ParentReference.GetDriveId()).To(Equal("storageid$spaceid")) - Expect(item.ParentReference.GetId()).To(Equal("storageid$spaceid!spaceid")) - }) - - It("returns a top-level trashed item by key", func() { - var req *provider.ListRecycleRequest - gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { - req = args.Get(1).(*provider.ListRecycleRequest) - }).Return(&provider.ListRecycleResponse{ - Status: status.NewOK(ctx), - RecycleItems: []*provider.RecycleItem{{ - Type: provider.ResourceType_RESOURCE_TYPE_FILE, - Key: "nodeid", - Size: 42, - DeletionTime: utils.TimeToTS(deletedAt), - Ref: &provider.Reference{Path: "/Documents/notes.txt"}, - }}, - }, nil) - - svc.GetDriveSpecial(rr, newRequest("recyclebin", "/nodeid")) - Expect(rr.Code).To(Equal(http.StatusOK)) - Expect(req.GetKey()).To(Equal("nodeid")) - Expect(req.GetRef().GetResourceId().GetStorageId()).To(Equal("storageid")) - Expect(req.GetRef().GetResourceId().GetSpaceId()).To(Equal("spaceid")) - Expect(req.GetRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid")) - - item := decodeItem() - Expect(item.GetId()).To(Equal("storageid$spaceid!nodeid")) - Expect(item.GetName()).To(Equal("notes.txt")) - Expect(item.GetSize()).To(Equal(int64(42))) - Expect(item.File.GetMimeType()).To(Equal("text/plain; charset=utf-8")) - Expect(item.Folder).To(BeNil()) - Expect(item.Trash.GetTrashedDateTime()).To(BeTemporally("==", deletedAt)) - Expect(item.Trash.TrashedBy).To(BeNil()) - Expect(item.ParentReference.GetDriveId()).To(Equal("storageid$spaceid")) - Expect(item.ParentReference.GetPath()).To(Equal("/Documents")) - Expect(item.ParentReference.GetName()).To(Equal("Documents")) - }) - - It("returns a nested trashed item through its parent listing", func() { - var req *provider.ListRecycleRequest - gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { - req = args.Get(1).(*provider.ListRecycleRequest) - }).Return(&provider.ListRecycleResponse{ - Status: status.NewOK(ctx), - RecycleItems: []*provider.RecycleItem{ - {Type: provider.ResourceType_RESOURCE_TYPE_FILE, Key: "nodeid/sub/other.txt", Ref: &provider.Reference{Path: "/folder/sub/other.txt"}}, - {Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER, Key: "nodeid/sub/inner", Ref: &provider.Reference{Path: "/folder/sub/inner"}}, - }, - }, nil) - - svc.GetDriveSpecial(rr, newRequest("recyclebin", "/nodeid/sub/inner")) - Expect(rr.Code).To(Equal(http.StatusOK)) - Expect(req.GetKey()).To(Equal("nodeid/sub/")) - - item := decodeItem() - Expect(item.GetId()).To(Equal("storageid$spaceid!nodeid/sub/inner")) - Expect(item.GetName()).To(Equal("inner")) - Expect(item.Folder).ToNot(BeNil()) - Expect(item.File).To(BeNil()) - }) - - It("embeds the trash listing with $expand=children", func() { - var keys []string - gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { - keys = append(keys, args.Get(1).(*provider.ListRecycleRequest).GetKey()) - }).Return(&provider.ListRecycleResponse{ - Status: status.NewOK(ctx), - RecycleItems: []*provider.RecycleItem{ - {Type: provider.ResourceType_RESOURCE_TYPE_FILE, Key: "nodeid", Ref: &provider.Reference{Path: "/a.txt"}}, - }, - }, nil) - - svc.GetDriveSpecial(rr, newRequestWithQuery("recyclebin", "", "?$expand=children")) - Expect(rr.Code).To(Equal(http.StatusOK)) - Expect(keys).To(Equal([]string{""})) - - item := decodeItem() - Expect(item.GetId()).To(Equal("storageid$spaceid!recyclebin")) - Expect(item.Children).To(HaveLen(1)) - Expect(item.Children[0].GetId()).To(Equal("storageid$spaceid!nodeid")) - Expect(item.Children[0].Trash).ToNot(BeNil()) - }) - - It("embeds the children of a trashed folder with $expand=children", func() { - var keys []string - gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { - keys = append(keys, args.Get(1).(*provider.ListRecycleRequest).GetKey()) - }).Return(&provider.ListRecycleResponse{ - Status: status.NewOK(ctx), - RecycleItems: []*provider.RecycleItem{ - {Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER, Key: "nodeid", Ref: &provider.Reference{Path: "/folder"}}, - }, - }, nil).Once() - gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { - keys = append(keys, args.Get(1).(*provider.ListRecycleRequest).GetKey()) - }).Return(&provider.ListRecycleResponse{ - Status: status.NewOK(ctx), - RecycleItems: []*provider.RecycleItem{ - {Type: provider.ResourceType_RESOURCE_TYPE_FILE, Key: "nodeid/a.txt", Ref: &provider.Reference{Path: "/folder/a.txt"}}, - }, - }, nil).Once() - - svc.GetDriveSpecial(rr, newRequestWithQuery("recyclebin", "/nodeid", "?$expand=children")) - Expect(rr.Code).To(Equal(http.StatusOK)) - Expect(keys).To(Equal([]string{"nodeid", "nodeid/"})) - - item := decodeItem() - Expect(item.GetId()).To(Equal("storageid$spaceid!nodeid")) - Expect(item.Children).To(HaveLen(1)) - Expect(item.Children[0].GetId()).To(Equal("storageid$spaceid!nodeid/a.txt")) - }) - - It("does not expand children of a trashed file", func() { - gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Return(&provider.ListRecycleResponse{ - Status: status.NewOK(ctx), - RecycleItems: []*provider.RecycleItem{ - {Type: provider.ResourceType_RESOURCE_TYPE_FILE, Key: "nodeid", Ref: &provider.Reference{Path: "/a.txt"}}, - }, - }, nil) - - svc.GetDriveSpecial(rr, newRequestWithQuery("recyclebin", "/nodeid", "?$expand=children")) - Expect(rr.Code).To(Equal(http.StatusOK)) - gatewayClient.AssertNumberOfCalls(GinkgoT(), "ListRecycle", 1) - Expect(decodeItem().Children).To(BeNil()) - }) - - It("returns not found when the key is not in the listing", func() { - gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Return(&provider.ListRecycleResponse{ - Status: status.NewOK(ctx), - RecycleItems: []*provider.RecycleItem{{Key: "other"}}, - }, nil) - - svc.GetDriveSpecial(rr, newRequest("recyclebin", "/nodeid")) - Expect(rr.Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("ListDriveSpecialChildren", func() { - It("rejects an unknown special folder", func() { - svc.ListDriveSpecialChildren(rr, newRequest("nope", "")) - Expect(rr.Code).To(Equal(http.StatusBadRequest)) - }) - - DescribeTable("maps ListRecycle errors", - func(st *cs3rpc.Status, code int) { - gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Return(&provider.ListRecycleResponse{Status: st}, nil) - svc.ListDriveSpecialChildren(rr, newRequest("recyclebin", "")) - Expect(rr.Code).To(Equal(code)) - }, - Entry("not found", status.NewNotFound(context.Background(), "not found"), http.StatusNotFound), - Entry("permission denied as not found", status.NewPermissionDenied(context.Background(), errors.New("denied"), "denied"), http.StatusNotFound), - Entry("unauthenticated", status.NewUnauthenticated(context.Background(), errors.New("no"), "no"), http.StatusUnauthorized), - Entry("internal", status.NewInternal(context.Background(), "internal"), http.StatusInternalServerError), - ) - - It("lists the trash root with the trash facet", func() { - var req *provider.ListRecycleRequest - gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { - req = args.Get(1).(*provider.ListRecycleRequest) - }).Return(&provider.ListRecycleResponse{ - Status: status.NewOK(ctx), - RecycleItems: []*provider.RecycleItem{ - { - Type: provider.ResourceType_RESOURCE_TYPE_FILE, - Key: "file-node", - Size: 7, - DeletionTime: utils.TimeToTS(deletedAt), - Ref: &provider.Reference{Path: "/photo.jpg"}, - }, - { - Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER, - Key: "folder-node", - Size: 100, - DeletionTime: utils.TimeToTS(deletedAt), - Ref: &provider.Reference{Path: "/Projects/old"}, - }, - }, - }, nil) - - svc.ListDriveSpecialChildren(rr, newRequest("recyclebin", "")) - Expect(rr.Code).To(Equal(http.StatusOK)) - Expect(req.GetKey()).To(Equal("")) - - items := decodeList() - Expect(items).To(HaveLen(2)) - - Expect(items[0].GetId()).To(Equal("storageid$spaceid!file-node")) - Expect(items[0].GetName()).To(Equal("photo.jpg")) - Expect(items[0].File.GetMimeType()).To(Equal("image/jpeg")) - Expect(items[0].Trash.GetTrashedDateTime()).To(BeTemporally("==", deletedAt)) - Expect(items[0].ParentReference.GetPath()).To(Equal("/")) - Expect(items[0].ParentReference.Name).To(BeNil()) - - Expect(items[1].GetId()).To(Equal("storageid$spaceid!folder-node")) - Expect(items[1].GetName()).To(Equal("old")) - Expect(items[1].Folder).ToNot(BeNil()) - Expect(items[1].GetSize()).To(Equal(int64(100))) - Expect(items[1].ParentReference.GetPath()).To(Equal("/Projects")) - Expect(items[1].ParentReference.GetName()).To(Equal("Projects")) - }) - - It("returns an empty list for an empty trash", func() { - gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Return(&provider.ListRecycleResponse{ - Status: status.NewOK(ctx), - }, nil) - - svc.ListDriveSpecialChildren(rr, newRequest("recyclebin", "")) - Expect(rr.Code).To(Equal(http.StatusOK)) - Expect(rr.Body.String()).To(MatchJSON(`{"value":[]}`)) - }) - - It("lists inside a trashed folder with a trailing slash key", func() { - var req *provider.ListRecycleRequest - gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { - req = args.Get(1).(*provider.ListRecycleRequest) - }).Return(&provider.ListRecycleResponse{ - Status: status.NewOK(ctx), - RecycleItems: []*provider.RecycleItem{ - {Type: provider.ResourceType_RESOURCE_TYPE_FILE, Key: "folder-node/sub/a.txt", Ref: &provider.Reference{Path: "/Projects/old/sub/a.txt"}}, - }, - }, nil) - - svc.ListDriveSpecialChildren(rr, newRequest("recyclebin", "/folder-node/sub")) - Expect(rr.Code).To(Equal(http.StatusOK)) - Expect(req.GetKey()).To(Equal("folder-node/sub/")) - - items := decodeList() - Expect(items).To(HaveLen(1)) - Expect(items[0].GetId()).To(Equal("storageid$spaceid!folder-node/sub/a.txt")) - Expect(items[0].ParentReference.GetPath()).To(Equal("/Projects/old/sub")) - }) - }) -}) diff --git a/services/graph/pkg/service/v0/driveitems_trash.go b/services/graph/pkg/service/v0/driveitems_trash.go index 122e7fa39a..f98e36d8bc 100644 --- a/services/graph/pkg/service/v0/driveitems_trash.go +++ b/services/graph/pkg/service/v0/driveitems_trash.go @@ -43,20 +43,10 @@ func (g Graph) RestoreDriveItem(w http.ResponseWriter, r *http.Request) { // the listing gives us the original location, needed for the default target and the response key := itemID.GetOpaqueId() - items, ok := g.listRecycle(w, r, itemID, key) + trashed, ok := g.findRecycleItem(w, r, itemID, key) if !ok { return } - var trashed *storageprovider.RecycleItem - for _, item := range items { - if item.GetKey() == key { - trashed = item - } - } - if trashed == nil { - errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "item not found") - return - } target, ok := restoreTarget(w, r, itemID, trashed, body) if !ok { @@ -76,7 +66,7 @@ func (g Graph) RestoreDriveItem(w http.ResponseWriter, r *http.Request) { errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) return } - if !renderTrashStatus(w, r, res.GetStatus()) { + if !renderTrashStatus(w, r, res.GetStatus(), false) { return } @@ -173,7 +163,7 @@ func (g Graph) PermanentDeleteDriveItem(w http.ResponseWriter, r *http.Request) errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) return } - if !renderTrashStatus(w, r, delRes.GetStatus()) { + if !renderTrashStatus(w, r, delRes.GetStatus(), false) { return } @@ -237,7 +227,7 @@ func (g Graph) purgeRecycle(w http.ResponseWriter, r *http.Request, driveID *sto errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) return } - if !renderTrashStatus(w, r, res.GetStatus()) { + if !renderTrashStatus(w, r, res.GetStatus(), false) { return } @@ -276,16 +266,21 @@ func parseTrashItemID(w http.ResponseWriter, r *http.Request) (*storageprovider. return &itemID, true } -// renderTrashStatus maps a mutating trash call's status; true means OK. Unlike the listing, -// PERMISSION_DENIED is a 403 here: the caller could already see the item. -func renderTrashStatus(w http.ResponseWriter, r *http.Request, st *cs3rpc.Status) bool { +// renderTrashStatus maps a trash call's status; true means OK. hideExistence turns +// PERMISSION_DENIED into a 404, for listings; mutations answer 403 because the caller +// could already see the item. +func renderTrashStatus(w http.ResponseWriter, r *http.Request, st *cs3rpc.Status, hideExistence bool) bool { switch st.GetCode() { case cs3rpc.Code_CODE_OK: return true case cs3rpc.Code_CODE_NOT_FOUND: errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, st.GetMessage()) case cs3rpc.Code_CODE_PERMISSION_DENIED: - errorcode.AccessDenied.Render(w, r, http.StatusForbidden, st.GetMessage()) + if hideExistence { + errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, st.GetMessage()) + } else { + errorcode.AccessDenied.Render(w, r, http.StatusForbidden, st.GetMessage()) + } case cs3rpc.Code_CODE_UNAUTHENTICATED: errorcode.Unauthenticated.Render(w, r, http.StatusUnauthorized, st.GetMessage()) case cs3rpc.Code_CODE_ALREADY_EXISTS: diff --git a/services/graph/pkg/service/v0/driveitems_trash_test.go b/services/graph/pkg/service/v0/driveitems_trash_test.go deleted file mode 100644 index 6546bc8c1b..0000000000 --- a/services/graph/pkg/service/v0/driveitems_trash_test.go +++ /dev/null @@ -1,354 +0,0 @@ -package svc_test - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "strings" - - gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" - userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" - cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/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" - 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" - "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" - graphm "github.com/opencloud-eu/opencloud/services/graph/pkg/middleware" - service "github.com/opencloud-eu/opencloud/services/graph/pkg/service/v0" -) - -var _ = Describe("Drive item trash operations", func() { - var ( - svc service.Service - ctx context.Context - cfg *config.Config - gatewayClient *cs3mocks.GatewayAPIClient - gatewaySelector pool.Selectable[gateway.GatewayAPIClient] - eventsPublisher mocks.Publisher - identityBackend *identitymocks.Backend - - rr *httptest.ResponseRecorder - - currentUser = &userpb.User{Id: &userpb.UserId{OpaqueId: "user"}} - ) - - type params map[string]string - - // newRequest binds the given chi params; body is sent as the JSON request body - newRequest := func(method, url string, p params, body string) *http.Request { - r := httptest.NewRequest(method, url, strings.NewReader(body)) - rctx := chi.NewRouteContext() - for k, v := range p { - rctx.URLParams.Add(k, v) - } - return r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) - } - - trashedFile := &provider.RecycleItem{ - Type: provider.ResourceType_RESOURCE_TYPE_FILE, - Key: "nodeid", - Ref: &provider.Reference{Path: "/Documents/notes.txt"}, - } - - mockListRecycle := func(items ...*provider.RecycleItem) { - gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Return(&provider.ListRecycleResponse{ - Status: status.NewOK(ctx), - RecycleItems: items, - }, nil) - } - mockRestore := func(st *cs3rpc.Status) **provider.RestoreRecycleItemRequest { - var req *provider.RestoreRecycleItemRequest - gatewayClient.On("RestoreRecycleItem", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { - req = args.Get(1).(*provider.RestoreRecycleItemRequest) - }).Return(&provider.RestoreRecycleItemResponse{Status: st}, nil) - return &req - } - mockStatOK := func() { - gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&provider.StatResponse{ - Status: status.NewOK(ctx), - Info: &provider.ResourceInfo{ - Type: provider.ResourceType_RESOURCE_TYPE_FILE, - Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "nodeid"}, - Path: "./Documents/notes.txt", - }, - }, nil) - } - mockPurge := func(st *cs3rpc.Status) **provider.PurgeRecycleRequest { - var req *provider.PurgeRecycleRequest - gatewayClient.On("PurgeRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { - req = args.Get(1).(*provider.PurgeRecycleRequest) - }).Return(&provider.PurgeRecycleResponse{Status: st}, nil) - return &req - } - - 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 = "" - cfg.TokenManager.JWTSecret = "loremipsum" - cfg.Commons = &shared.Commons{} - 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()) - }) - - Describe("RestoreDriveItem", func() { - v1Params := params{"driveID": "storageid$spaceid", "driveItemID": "storageid$spaceid!nodeid"} - - It("rejects an item from another drive", func() { - svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", params{"driveID": "storageid$other", "driveItemID": "storageid$spaceid!nodeid"}, "")) - Expect(rr.Code).To(Equal(http.StatusNotFound)) - gatewayClient.AssertNotCalled(GinkgoT(), "ListRecycle", mock.Anything, mock.Anything) - }) - - It("rejects a malformed body", func() { - svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, `{"nope": 1}`)) - Expect(rr.Code).To(Equal(http.StatusBadRequest)) - }) - - It("returns not found for an unknown key", func() { - mockListRecycle() - svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, "")) - Expect(rr.Code).To(Equal(http.StatusNotFound)) - gatewayClient.AssertNotCalled(GinkgoT(), "RestoreRecycleItem", mock.Anything, mock.Anything) - }) - - It("restores to the original location by default and returns the item", func() { - mockListRecycle(trashedFile) - req := mockRestore(status.NewOK(ctx)) - mockStatOK() - - svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, "")) - Expect(rr.Code).To(Equal(http.StatusOK)) - - Expect((*req).GetKey()).To(Equal("nodeid")) - Expect((*req).GetRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid")) - Expect((*req).GetRestoreRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid")) - Expect((*req).GetRestoreRef().GetPath()).To(Equal("./Documents/notes.txt")) - - var item libregraph.DriveItem - Expect(json.Unmarshal(rr.Body.Bytes(), &item)).To(Succeed()) - Expect(item.GetId()).To(Equal("storageid$spaceid!nodeid")) - Expect(item.GetName()).To(Equal("notes.txt")) - }) - - It("accepts the v1beta1 itemID param", func() { - mockListRecycle(trashedFile) - mockRestore(status.NewOK(ctx)) - mockStatOK() - - svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", params{"driveID": "storageid$spaceid", "itemID": "storageid$spaceid!nodeid"}, "")) - Expect(rr.Code).To(Equal(http.StatusOK)) - }) - - It("works without a drive id (me/drive)", func() { - mockListRecycle(trashedFile) - mockRestore(status.NewOK(ctx)) - mockStatOK() - - svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", params{"itemID": "storageid$spaceid!nodeid"}, "")) - Expect(rr.Code).To(Equal(http.StatusOK)) - }) - - It("restores into the given parent with a new name", func() { - mockListRecycle(trashedFile) - req := mockRestore(status.NewOK(ctx)) - mockStatOK() - - body := `{"parentReference": {"id": "storageid$spaceid!parentid"}, "name": "renamed.txt"}` - svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, body)) - Expect(rr.Code).To(Equal(http.StatusOK)) - - Expect((*req).GetRestoreRef().GetResourceId().GetOpaqueId()).To(Equal("parentid")) - Expect((*req).GetRestoreRef().GetPath()).To(Equal("./renamed.txt")) - }) - - It("restores into a parent given by path", func() { - mockListRecycle(trashedFile) - req := mockRestore(status.NewOK(ctx)) - mockStatOK() - - body := `{"parentReference": {"path": "/Archive/2025"}}` - svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, body)) - Expect(rr.Code).To(Equal(http.StatusOK)) - - Expect((*req).GetRestoreRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid")) - Expect((*req).GetRestoreRef().GetPath()).To(Equal("./Archive/2025/notes.txt")) - }) - - It("only renames when just a name is given", func() { - mockListRecycle(trashedFile) - req := mockRestore(status.NewOK(ctx)) - mockStatOK() - - svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, `{"name": "renamed.txt"}`)) - Expect(rr.Code).To(Equal(http.StatusOK)) - Expect((*req).GetRestoreRef().GetPath()).To(Equal("./Documents/renamed.txt")) - }) - - It("rejects a name with a slash", func() { - mockListRecycle(trashedFile) - svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, `{"name": "a/b.txt"}`)) - Expect(rr.Code).To(Equal(http.StatusBadRequest)) - gatewayClient.AssertNotCalled(GinkgoT(), "RestoreRecycleItem", mock.Anything, mock.Anything) - }) - - It("rejects a parent in another drive", func() { - mockListRecycle(trashedFile) - body := `{"parentReference": {"driveId": "storageid$other", "id": "storageid$other!parentid"}}` - svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, body)) - Expect(rr.Code).To(Equal(http.StatusBadRequest)) - gatewayClient.AssertNotCalled(GinkgoT(), "RestoreRecycleItem", mock.Anything, mock.Anything) - }) - - DescribeTable("maps restore errors", - func(st *cs3rpc.Status, code int) { - mockListRecycle(trashedFile) - mockRestore(st) - svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, "")) - Expect(rr.Code).To(Equal(code)) - gatewayClient.AssertNotCalled(GinkgoT(), "Stat", mock.Anything, mock.Anything) - }, - Entry("already exists", status.NewAlreadyExists(context.Background(), errors.New("exists"), "exists"), http.StatusConflict), - Entry("permission denied", status.NewPermissionDenied(context.Background(), errors.New("denied"), "denied"), http.StatusForbidden), - Entry("not found", status.NewNotFound(context.Background(), "gone"), http.StatusNotFound), - Entry("internal", status.NewInternal(context.Background(), "internal"), http.StatusInternalServerError), - ) - }) - - Describe("PermanentDeleteDriveItem", func() { - v1Params := params{"driveID": "storageid$spaceid", "driveItemID": "storageid$spaceid!nodeid"} - - It("refuses the drive root", func() { - svc.PermanentDeleteDriveItem(rr, newRequest(http.MethodPost, "/", params{"driveID": "storageid$spaceid", "driveItemID": "storageid$spaceid!spaceid"}, "")) - Expect(rr.Code).To(Equal(http.StatusBadRequest)) - gatewayClient.AssertNotCalled(GinkgoT(), "Delete", mock.Anything, mock.Anything) - }) - - It("refuses share jail items", func() { - p := params{"driveID": "a0ca6a90-a365-4782-871e-d44447bbc668$a0ca6a90-a365-4782-871e-d44447bbc668", "driveItemID": "a0ca6a90-a365-4782-871e-d44447bbc668$a0ca6a90-a365-4782-871e-d44447bbc668!shareid"} - svc.PermanentDeleteDriveItem(rr, newRequest(http.MethodPost, "/", p, "")) - Expect(rr.Code).To(Equal(http.StatusBadRequest)) - gatewayClient.AssertNotCalled(GinkgoT(), "Delete", mock.Anything, mock.Anything) - }) - - It("deletes and purges the trash key", func() { - var delReq *provider.DeleteRequest - gatewayClient.On("Delete", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { - delReq = args.Get(1).(*provider.DeleteRequest) - }).Return(&provider.DeleteResponse{Status: status.NewOK(ctx)}, nil) - purge := mockPurge(status.NewOK(ctx)) - - svc.PermanentDeleteDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, "")) - Expect(rr.Code).To(Equal(http.StatusNoContent)) - - Expect(delReq.GetRef().GetResourceId().GetOpaqueId()).To(Equal("nodeid")) - Expect((*purge).GetRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid")) - Expect((*purge).GetKey()).To(Equal("nodeid")) - }) - - It("maps a failed delete and does not purge", func() { - gatewayClient.On("Delete", mock.Anything, mock.Anything).Return(&provider.DeleteResponse{Status: status.NewNotFound(ctx, "gone")}, nil) - svc.PermanentDeleteDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, "")) - Expect(rr.Code).To(Equal(http.StatusNotFound)) - gatewayClient.AssertNotCalled(GinkgoT(), "PurgeRecycle", mock.Anything, mock.Anything) - }) - - It("reports a failed purge after a successful delete", func() { - gatewayClient.On("Delete", mock.Anything, mock.Anything).Return(&provider.DeleteResponse{Status: status.NewOK(ctx)}, nil) - mockPurge(status.NewInternal(ctx, "boom")) - svc.PermanentDeleteDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, "")) - Expect(rr.Code).To(Equal(http.StatusInternalServerError)) - }) - }) - - Describe("DeleteDriveSpecialItem", func() { - It("rejects an unknown special folder", func() { - svc.DeleteDriveSpecialItem(rr, newRequest(http.MethodDelete, "/", params{"driveID": "storageid$spaceid", "specialName": "nope", "itemID": "storageid$spaceid!nodeid"}, "")) - Expect(rr.Code).To(Equal(http.StatusBadRequest)) - }) - - It("rejects an item from another drive", func() { - svc.DeleteDriveSpecialItem(rr, newRequest(http.MethodDelete, "/", params{"driveID": "storageid$spaceid", "specialName": "recyclebin", "itemID": "storageid$other!nodeid"}, "")) - Expect(rr.Code).To(Equal(http.StatusNotFound)) - gatewayClient.AssertNotCalled(GinkgoT(), "PurgeRecycle", mock.Anything, mock.Anything) - }) - - It("purges the key", func() { - purge := mockPurge(status.NewOK(ctx)) - svc.DeleteDriveSpecialItem(rr, newRequest(http.MethodDelete, "/", params{"driveID": "storageid$spaceid", "specialName": "recyclebin", "itemID": "storageid$spaceid!nodeid/sub/file"}, "")) - Expect(rr.Code).To(Equal(http.StatusNoContent)) - Expect((*purge).GetKey()).To(Equal("nodeid/sub/file")) - Expect((*purge).GetRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid")) - }) - - It("maps permission denied to forbidden", func() { - mockPurge(status.NewPermissionDenied(ctx, errors.New("denied"), "denied")) - svc.DeleteDriveSpecialItem(rr, newRequest(http.MethodDelete, "/", params{"driveID": "storageid$spaceid", "specialName": "recyclebin", "itemID": "storageid$spaceid!nodeid"}, "")) - Expect(rr.Code).To(Equal(http.StatusForbidden)) - }) - }) - - Describe("EmptyDriveSpecial", func() { - It("purges the whole trash", func() { - purge := mockPurge(status.NewOK(ctx)) - svc.EmptyDriveSpecial(rr, newRequest(http.MethodDelete, "/", params{"driveID": "storageid$spaceid", "specialName": "recyclebin"}, "")) - Expect(rr.Code).To(Equal(http.StatusNoContent)) - Expect((*purge).GetKey()).To(Equal("")) - }) - - It("purges only the addressed key in the colon path form", func() { - purge := mockPurge(status.NewOK(ctx)) - r := newRequest(http.MethodDelete, "/", params{"driveID": "storageid$spaceid", "specialName": "recyclebin"}, "") - r = r.WithContext(graphm.WithSpecialFolderPath(r.Context(), "/nodeid")) - svc.EmptyDriveSpecial(rr, r) - Expect(rr.Code).To(Equal(http.StatusNoContent)) - Expect((*purge).GetKey()).To(Equal("nodeid")) - }) - }) -}) From 88ca2a62698f5f430c2cb3b06587413bfaa0adfa Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Mon, 7 Sep 2026 13:15:00 +0200 Subject: [PATCH 5/5] graph: check the special folder name explicitly in each handler --- .../pkg/service/v0/driveitems_special.go | 31 +++++++++---------- .../graph/pkg/service/v0/driveitems_trash.go | 18 ++++++++--- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/services/graph/pkg/service/v0/driveitems_special.go b/services/graph/pkg/service/v0/driveitems_special.go index 55e602c0f5..09b62ceb43 100644 --- a/services/graph/pkg/service/v0/driveitems_special.go +++ b/services/graph/pkg/service/v0/driveitems_special.go @@ -27,8 +27,13 @@ const RecycleBinSpecialFolderName = "recyclebin" func (g Graph) GetDriveSpecial(w http.ResponseWriter, r *http.Request) { g.logger.Debug().Msg("Calling GetDriveSpecial") - driveID, ok := parseSpecialParams(w, r) - if !ok { + driveID, err := parseIDParam(r, "driveID") + if err != nil { + errorcode.RenderError(w, r, err) + return + } + if chi.URLParam(r, "specialName") != RecycleBinSpecialFolderName { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unknown special folder") return } @@ -88,8 +93,13 @@ func (g Graph) findRecycleItem(w http.ResponseWriter, r *http.Request, driveID * func (g Graph) ListDriveSpecialChildren(w http.ResponseWriter, r *http.Request) { g.logger.Debug().Msg("Calling ListDriveSpecialChildren") - driveID, ok := parseSpecialParams(w, r) - if !ok { + driveID, err := parseIDParam(r, "driveID") + if err != nil { + errorcode.RenderError(w, r, err) + return + } + if chi.URLParam(r, "specialName") != RecycleBinSpecialFolderName { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unknown special folder") return } @@ -121,19 +131,6 @@ func (g Graph) listRecycleChildren(w http.ResponseWriter, r *http.Request, drive return files, true } -func parseSpecialParams(w http.ResponseWriter, r *http.Request) (storageprovider.ResourceId, bool) { - driveID, err := parseIDParam(r, "driveID") - if err != nil { - errorcode.RenderError(w, r, err) - return storageprovider.ResourceId{}, false - } - if chi.URLParam(r, "specialName") != RecycleBinSpecialFolderName { - errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unknown special folder") - return storageprovider.ResourceId{}, false - } - return driveID, true -} - // specialFolderKey returns the recycle key addressed by the colon path form, "" for the trash root func specialFolderKey(r *http.Request) string { p, _ := graphm.SpecialFolderPath(r.Context()) diff --git a/services/graph/pkg/service/v0/driveitems_trash.go b/services/graph/pkg/service/v0/driveitems_trash.go index f98e36d8bc..b07cb9a666 100644 --- a/services/graph/pkg/service/v0/driveitems_trash.go +++ b/services/graph/pkg/service/v0/driveitems_trash.go @@ -188,8 +188,13 @@ func (g Graph) PermanentDeleteDriveItem(w http.ResponseWriter, r *http.Request) func (g Graph) DeleteDriveSpecialItem(w http.ResponseWriter, r *http.Request) { g.logger.Debug().Msg("Calling DeleteDriveSpecialItem") - driveID, ok := parseSpecialParams(w, r) - if !ok { + driveID, err := parseIDParam(r, "driveID") + if err != nil { + errorcode.RenderError(w, r, err) + return + } + if chi.URLParam(r, "specialName") != RecycleBinSpecialFolderName { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unknown special folder") return } itemID, ok := parseTrashItemID(w, r) @@ -205,8 +210,13 @@ func (g Graph) DeleteDriveSpecialItem(w http.ResponseWriter, r *http.Request) { func (g Graph) EmptyDriveSpecial(w http.ResponseWriter, r *http.Request) { g.logger.Debug().Msg("Calling EmptyDriveSpecial") - driveID, ok := parseSpecialParams(w, r) - if !ok { + driveID, err := parseIDParam(r, "driveID") + if err != nil { + errorcode.RenderError(w, r, err) + return + } + if chi.URLParam(r, "specialName") != RecycleBinSpecialFolderName { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unknown special folder") return }