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_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 new file mode 100644 index 0000000000..09b62ceb43 --- /dev/null +++ b/services/graph/pkg/service/v0/driveitems_special.go @@ -0,0 +1,237 @@ +package svc + +import ( + "mime" + "net/http" + "path" + "strings" + + 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. +// $expand=children embeds the listing for folders. +func (g Graph) GetDriveSpecial(w http.ResponseWriter, r *http.Request) { + g.logger.Debug().Msg("Calling GetDriveSpecial") + + 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 + } + + key := specialFolderKey(r) + 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 + } + item, ok := g.findRecycleItem(w, r, driveID, key) + if !ok { + return nil, false + } + return recycleItemToDriveItem(driveID, item), true +} + +// 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) + "/" + } + items, ok := g.listRecycle(w, r, driveID, listKey) + if !ok { + return nil, false + } + for _, item := range items { + if item.GetKey() == key { + return 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 +// 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, 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 + } + + 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 != "" { + listKey = key + "/" + } + items, ok := g.listRecycle(w, r, driveID, listKey) + if !ok { + return nil, false + } + + files := make([]libregraph.DriveItem, 0, len(items)) + for _, item := range items { + files = append(files, *recycleItemToDriveItem(driveID, item)) + } + return files, 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, + }) + if err != nil { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return nil, false + } + // like listDriveItemChildren, a denied listing does not disclose existence + if !renderTrashStatus(w, r, res.GetStatus(), true) { + 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_trash.go b/services/graph/pkg/service/v0/driveitems_trash.go new file mode 100644 index 0000000000..b07cb9a666 --- /dev/null +++ b/services/graph/pkg/service/v0/driveitems_trash.go @@ -0,0 +1,304 @@ +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() + trashed, ok := g.findRecycleItem(w, r, itemID, key) + if !ok { + 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(), false) { + 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(), false) { + 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, 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) + 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, 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 + } + + 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(), false) { + 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 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: + 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: + 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/service.go b/services/graph/pkg/service/v0/service.go index 9b9636e370..dc49c258c5 100644 --- a/services/graph/pkg/service/v0/service.go +++ b/services/graph/pkg/service/v0/service.go @@ -104,6 +104,12 @@ 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) + 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) @@ -271,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) { @@ -306,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)) @@ -370,6 +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) }) }) })