Compare commits

...
6 changed files with 1320 additions and 20 deletions

No files matched your search

+63 -20
View File
@@ -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:/<path>[:/<suffix>][:]
// item-anchored: /items/{itemID}:/<path>[:/<suffix>][:]
// root-anchored: /root:/<path>[:/<suffix>][:]
// item-anchored: /items/{itemID}:/<path>[:/<suffix>][:]
// special-anchored: /special/{specialName}:/<path>[:/<suffix>][:]
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:/<path>[:/<suffix>][:]
// /drives/{driveID}/items/{itemID}:/<path>[:/<suffix>][:]
// /drives/{driveID}/special/{specialName}:/<path>[:/<suffix>][:]
//
// 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:/<path>[:/<suffix>][:]
// /items/<itemID>:/<path>[:/<suffix>][:]
// /special/<specialName>:/<path>[:/<suffix>][:]
//
// 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
}
@@ -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")
}
})
}
}
@@ -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"))
})
})
})
@@ -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
}
@@ -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
}
+18
View File
@@ -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)
})
})
})