mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-09-09 20:28:29 -04:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88ca2a6269 | ||
|
|
7469ab05c7 | ||
|
|
cdbd75b086 | ||
|
|
fdebaba41d | ||
|
|
edcfa95a99 |
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user