graph: implement driveItem creation under drives/items/children

Implements the CreateDriveItem/CreateChildDriveItem operations from
libre-graph-api#42 including @libre.graph.conflictBehavior and
@libre.graph.missingParentsBehavior.
This commit is contained in:
Dominik Schmidt
2026-07-11 16:04:45 +02:00
parent 98f3a9eb08
commit a21622ca18
6 changed files with 797 additions and 17 deletions

View File

@@ -41,6 +41,92 @@ func (_m *DrivesDriveItemProvider) EXPECT() *DrivesDriveItemProvider_Expecter {
return &DrivesDriveItemProvider_Expecter{mock: &_m.Mock}
}
// CreateChild provides a mock function for the type DrivesDriveItemProvider
func (_mock *DrivesDriveItemProvider) CreateChild(ctx context.Context, parentID *providerv1beta1.ResourceId, name string, isFolder bool, replace bool) (*providerv1beta1.ResourceInfo, error) {
ret := _mock.Called(ctx, parentID, name, isFolder, replace)
if len(ret) == 0 {
panic("no return value specified for CreateChild")
}
var r0 *providerv1beta1.ResourceInfo
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceId, string, bool, bool) (*providerv1beta1.ResourceInfo, error)); ok {
return returnFunc(ctx, parentID, name, isFolder, replace)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceId, string, bool, bool) *providerv1beta1.ResourceInfo); ok {
r0 = returnFunc(ctx, parentID, name, isFolder, replace)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*providerv1beta1.ResourceInfo)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, *providerv1beta1.ResourceId, string, bool, bool) error); ok {
r1 = returnFunc(ctx, parentID, name, isFolder, replace)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// DrivesDriveItemProvider_CreateChild_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateChild'
type DrivesDriveItemProvider_CreateChild_Call struct {
*mock.Call
}
// CreateChild is a helper method to define mock.On call
// - ctx context.Context
// - parentID *providerv1beta1.ResourceId
// - name string
// - isFolder bool
// - replace bool
func (_e *DrivesDriveItemProvider_Expecter) CreateChild(ctx interface{}, parentID interface{}, name interface{}, isFolder interface{}, replace interface{}) *DrivesDriveItemProvider_CreateChild_Call {
return &DrivesDriveItemProvider_CreateChild_Call{Call: _e.mock.On("CreateChild", ctx, parentID, name, isFolder, replace)}
}
func (_c *DrivesDriveItemProvider_CreateChild_Call) Run(run func(ctx context.Context, parentID *providerv1beta1.ResourceId, name string, isFolder bool, replace bool)) *DrivesDriveItemProvider_CreateChild_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *providerv1beta1.ResourceId
if args[1] != nil {
arg1 = args[1].(*providerv1beta1.ResourceId)
}
var arg2 string
if args[2] != nil {
arg2 = args[2].(string)
}
var arg3 bool
if args[3] != nil {
arg3 = args[3].(bool)
}
var arg4 bool
if args[4] != nil {
arg4 = args[4].(bool)
}
run(
arg0,
arg1,
arg2,
arg3,
arg4,
)
})
return _c
}
func (_c *DrivesDriveItemProvider_CreateChild_Call) Return(resourceInfo *providerv1beta1.ResourceInfo, err error) *DrivesDriveItemProvider_CreateChild_Call {
_c.Call.Return(resourceInfo, err)
return _c
}
func (_c *DrivesDriveItemProvider_CreateChild_Call) RunAndReturn(run func(ctx context.Context, parentID *providerv1beta1.ResourceId, name string, isFolder bool, replace bool) (*providerv1beta1.ResourceInfo, error)) *DrivesDriveItemProvider_CreateChild_Call {
_c.Call.Return(run)
return _c
}
// GetShare provides a mock function for the type DrivesDriveItemProvider
func (_mock *DrivesDriveItemProvider) GetShare(ctx context.Context, shareID *collaborationv1beta1.ShareId) (*collaborationv1beta1.ReceivedShare, error) {
ret := _mock.Called(ctx, shareID)

View File

@@ -49,6 +49,7 @@ var (
errPathNotFound = errors.New("path not found")
errInvalidRequest = errors.New("invalid request")
errUnauthenticated = errors.New("unauthenticated")
errAccessDenied = errors.New("access denied")
)
// ResolveGraphPath returns middleware that detects MS Graph colon-syntax path
@@ -91,7 +92,7 @@ func ResolveGraphPath(gws pool.Selectable[gateway.GatewayAPIClient], logger log.
driveID := chi.URLParam(r, "driveID")
original := r.URL.Path
rewritten, err := rewriteColonPath(r.Context(), gws, l, driveID, rctx.RoutePath)
rewritten, err := rewriteColonPath(r.Context(), gws, l, driveID, rctx.RoutePath, r)
switch {
case errors.Is(err, errPathNotFound):
l.Debug().Str("original", original).Msg("colon-path resolution: not found")
@@ -105,6 +106,10 @@ func ResolveGraphPath(gws pool.Selectable[gateway.GatewayAPIClient], logger log.
l.Debug().Str("original", original).Msg("colon-path resolution: unauthenticated")
errorcode.Unauthenticated.Render(w, r, http.StatusUnauthorized, "unauthenticated")
return
case errors.Is(err, errAccessDenied):
l.Debug().Str("original", original).Msg("colon-path resolution: access denied")
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "access denied")
return
case err != nil:
l.Error().Err(err).Str("original", original).Msg("colon-path resolution: internal error")
errorcode.GeneralException.Render(
@@ -154,19 +159,38 @@ type colonMatch struct {
// - "" + 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}).
// is chi.RouteContext().RoutePath (the part below /drives/{driveID}). r is only
// consulted for the method and the @libre.graph.missingParentsBehavior query
// parameter, which is meaningful for POST .../children requests only and
// ignored otherwise.
func rewriteColonPath(
ctx context.Context,
gws pool.Selectable[gateway.GatewayAPIClient],
logger zerolog.Logger,
driveIDParam string,
routePath string,
r *http.Request,
) (string, error) {
match, ok := parseColonPath(routePath)
if !ok {
return "", nil
}
// The colon path addresses the parent of the item a POST .../children
// creates. With missingParentsBehavior=create the missing folders along
// that path are created instead of returning 404.
createParents := false
if r.Method == http.MethodPost && match.suffix == "/children" {
switch r.URL.Query().Get("@libre.graph.missingParentsBehavior") {
case "", "fail":
case "create":
createParents = true
default:
logger.Debug().Msg("invalid @libre.graph.missingParentsBehavior in colon path")
return "", errInvalidRequest
}
}
// 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
@@ -218,7 +242,11 @@ func rewriteColonPath(
return "", errInvalidRequest
}
itemID, err := resolvePath(ctx, gws, &anchor, relPath)
resolve := resolvePath
if createParents {
resolve = resolveOrCreatePath
}
itemID, err := resolve(ctx, gws, &anchor, relPath)
if err != nil {
return "", err
}
@@ -320,6 +348,79 @@ func resolvePath(
return "", fmt.Errorf("gateway selector: %w", err)
}
return statPath(ctx, gw, anchor, relPath)
}
// resolveOrCreatePath is resolvePath for POST .../children requests with
// @libre.graph.missingParentsBehavior=create: it walks relPath segment by
// segment and creates the missing folders along the way.
func resolveOrCreatePath(
ctx context.Context,
gws pool.Selectable[gateway.GatewayAPIClient],
anchor *storageprovider.ResourceId,
relPath string,
) (string, error) {
gw, err := gws.Next()
if err != nil {
return "", fmt.Errorf("gateway selector: %w", err)
}
var id, walked string
for _, segment := range strings.Split(strings.Trim(relPath, "/"), "/") {
walked += "/" + segment
id, err = statPath(ctx, gw, anchor, walked)
if err == nil {
continue
}
if !errors.Is(err, errPathNotFound) {
return "", err
}
res, err := gw.CreateContainer(ctx, &storageprovider.CreateContainerRequest{
Ref: &storageprovider.Reference{
ResourceId: anchor,
Path: utils.MakeRelativePath(walked),
},
})
if err != nil {
return "", fmt.Errorf("CS3 CreateContainer: %w", err)
}
switch res.GetStatus().GetCode() {
case cs3rpc.Code_CODE_OK:
// fall through
case cs3rpc.Code_CODE_ALREADY_EXISTS:
// lost a creation race, the folder is there
case cs3rpc.Code_CODE_NOT_FOUND:
return "", errPathNotFound
case cs3rpc.Code_CODE_PERMISSION_DENIED:
return "", errAccessDenied
case cs3rpc.Code_CODE_UNAUTHENTICATED:
return "", errUnauthenticated
default:
return "", fmt.Errorf(
"CS3 CreateContainer returned %s: %s",
res.GetStatus().GetCode(),
res.GetStatus().GetMessage(),
)
}
id, err = statPath(ctx, gw, anchor, walked)
if err != nil {
return "", err
}
}
return id, nil
}
// statPath stats a relative filesystem path (anchored at the given CS3
// resource id) and returns the item's id, running with the request user's
// permissions.
func statPath(
ctx context.Context,
gw gateway.GatewayAPIClient,
anchor *storageprovider.ResourceId,
relPath string,
) (string, error) {
// relPath is already decoded (PathUnescape'd once by the caller), matching
// the form a normal handler would receive from r.URL.Path.
statRes, err := gw.Stat(ctx, &storageprovider.StatRequest{

View File

@@ -1,6 +1,7 @@
package middleware_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
@@ -115,6 +116,7 @@ func newGraphTestRouter(t *testing.T, gw *cs3mocks.GatewayAPIClient) (http.Handl
r.Use(middleware.ResolveGraphPath(selector, log.NopLogger()))
r.Route("/items/{itemID}", func(r chi.Router) {
r.Get("/", leaf("item"))
r.Post("/children", leaf("createChild"))
r.Post("/createLink", leaf("createLink"))
r.Route("/permissions", func(r chi.Router) {
r.Get("/", leaf("permissions"))
@@ -469,3 +471,94 @@ 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_MissingParentsBehavior covers the
// @libre.graph.missingParentsBehavior query parameter on POST .../children
// colon-syntax requests.
func TestResolveGraphPath_MissingParentsBehavior(t *testing.T) {
childrenURL := "/graph/v1beta1/drives/" + testDriveID + "/root:/a/b:/children"
t.Run("create creates the missing folders along the path", func(t *testing.T) {
gw := cs3mocks.NewGatewayAPIClient(t)
created := false
gw.EXPECT().Stat(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
func(_ context.Context, req *storageprovider.StatRequest, _ ...grpc.CallOption) (*storageprovider.StatResponse, error) {
switch path := req.GetRef().GetPath(); path {
case "./a":
return statResponse(cs3rpc.Code_CODE_OK, true), nil
case "./a/b":
if created {
return statResponse(cs3rpc.Code_CODE_OK, true), nil
}
return statResponse(cs3rpc.Code_CODE_NOT_FOUND, false), nil
default:
t.Errorf("unexpected stat path %q", path)
return statResponse(cs3rpc.Code_CODE_NOT_FOUND, false), nil
}
})
gw.EXPECT().CreateContainer(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
func(_ context.Context, req *storageprovider.CreateContainerRequest, _ ...grpc.CallOption) (*storageprovider.CreateContainerResponse, error) {
assert.Equal(t, "./a/b", req.GetRef().GetPath())
created = true
return &storageprovider.CreateContainerResponse{Status: &cs3rpc.Status{Code: cs3rpc.Code_CODE_OK}}, nil
}).Once()
router, cap := newGraphTestRouter(t, gw)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, httptest.NewRequest(
http.MethodPost, childrenURL+"?%40libre.graph.missingParentsBehavior=create", nil,
))
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "createChild", cap.hit)
assert.Equal(t, testItemID, cap.itemID)
})
t.Run("default fail returns 404 for a missing path without creating anything", func(t *testing.T) {
gw := cs3mocks.NewGatewayAPIClient(t)
gw.EXPECT().Stat(mock.Anything, mock.Anything, mock.Anything).
Return(statResponse(cs3rpc.Code_CODE_NOT_FOUND, false), nil).
Once()
router, cap := newGraphTestRouter(t, gw)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, childrenURL, nil))
assert.Equal(t, http.StatusNotFound, rr.Code)
assert.Equal(t, "", cap.hit)
})
t.Run("invalid value returns 400", func(t *testing.T) {
gw := cs3mocks.NewGatewayAPIClient(t)
router, cap := newGraphTestRouter(t, gw)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, httptest.NewRequest(
http.MethodPost, childrenURL+"?%40libre.graph.missingParentsBehavior=maybe", nil,
))
assert.Equal(t, http.StatusBadRequest, rr.Code)
assert.Equal(t, "", cap.hit)
})
t.Run("denied folder creation returns 403", func(t *testing.T) {
gw := cs3mocks.NewGatewayAPIClient(t)
gw.EXPECT().Stat(mock.Anything, mock.Anything, mock.Anything).
Return(statResponse(cs3rpc.Code_CODE_NOT_FOUND, false), nil).
Once()
gw.EXPECT().CreateContainer(mock.Anything, mock.Anything, mock.Anything).
Return(&storageprovider.CreateContainerResponse{
Status: &cs3rpc.Status{Code: cs3rpc.Code_CODE_PERMISSION_DENIED},
}, nil).
Once()
router, cap := newGraphTestRouter(t, gw)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, httptest.NewRequest(
http.MethodPost, childrenURL+"?%40libre.graph.missingParentsBehavior=create", nil,
))
assert.Equal(t, http.StatusForbidden, rr.Code)
assert.Equal(t, "", cap.hit)
})
}

View File

@@ -4,7 +4,9 @@ import (
"context"
"errors"
"net/http"
"net/url"
"path/filepath"
"strings"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
@@ -72,6 +74,15 @@ var (
// ErrAlreadyUnmounted is returned when all shares are already unmounted
ErrAlreadyUnmounted = errorcode.New(errorcode.NameAlreadyExists, "shares already unmounted")
// ErrInvalidItemName is returned when the name for a new drive item is invalid
ErrInvalidItemName = errorcode.New(errorcode.InvalidRequest, "invalid item name")
// ErrExactlyOneFacet is returned when not exactly one of the create facets is set
ErrExactlyOneFacet = errorcode.New(errorcode.InvalidRequest, "exactly one of folder, file or remoteItem must be set")
// ErrInvalidConflictBehavior is returned when the conflictBehavior query parameter is invalid
ErrInvalidConflictBehavior = errorcode.New(errorcode.InvalidRequest, "invalid @libre.graph.conflictBehavior")
)
type (
@@ -80,6 +91,9 @@ type (
// DrivesDriveItemProvider is the interface that needs to be implemented by the individual space service
DrivesDriveItemProvider interface {
// CreateChild creates a folder or an empty file below the given parent
CreateChild(ctx context.Context, parentID *storageprovider.ResourceId, name string, isFolder, replace bool) (*storageprovider.ResourceInfo, error)
// MountShare mounts a share
MountShare(ctx context.Context, resourceID *storageprovider.ResourceId, name string) ([]*collaboration.ReceivedShare, error)
@@ -330,20 +344,76 @@ func (s DrivesDriveItemService) MountShare(ctx context.Context, resourceID *stor
return updatedShares, nil
}
// CreateChild creates a folder or an empty file below the given parent.
// With replace an existing child with the same name is deleted first.
func (s DrivesDriveItemService) CreateChild(ctx context.Context, parentID *storageprovider.ResourceId, name string, isFolder, replace bool) (*storageprovider.ResourceInfo, error) {
if name == "" || name == "." || name == ".." || strings.ContainsRune(name, '/') {
return nil, ErrInvalidItemName
}
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
ref := &storageprovider.Reference{
ResourceId: parentID,
Path: utils.MakeRelativePath(name),
}
create := func() error {
if isFolder {
res, err := gatewayClient.CreateContainer(ctx, &storageprovider.CreateContainerRequest{Ref: ref})
return errorcode.FromCS3Status(res.GetStatus(), err)
}
res, err := gatewayClient.TouchFile(ctx, &storageprovider.TouchFileRequest{Ref: ref})
return errorcode.FromCS3Status(res.GetStatus(), err)
}
err = create()
var lgErr errorcode.Error
if replace && errors.As(err, &lgErr) && lgErr.GetCode() == errorcode.NameAlreadyExists {
delRes, delErr := gatewayClient.Delete(ctx, &storageprovider.DeleteRequest{Ref: ref})
if err := errorcode.FromCS3Status(delRes.GetStatus(), delErr); err != nil {
return nil, err
}
err = create()
}
if err != nil {
return nil, err
}
statRes, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{Ref: ref})
if err := errorcode.FromCS3Status(statRes.GetStatus(), err); err != nil {
return nil, err
}
// the path based stat above returns the path relative to ref, stat again by id to get the space relative path
idRes, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{
Ref: &storageprovider.Reference{ResourceId: statRes.GetInfo().GetId()},
})
if err := errorcode.FromCS3Status(idRes.GetStatus(), err); err != nil {
return nil, err
}
return idRes.GetInfo(), nil
}
// DrivesDriveItemApi is the api that registers the http endpoints which expose needed operation to the graph api.
// the business logic is delegated to the space service and further down to the cs3 client.
type DrivesDriveItemApi struct {
logger log.Logger
drivesDriveItemService DrivesDriveItemProvider
baseGraphService BaseGraphProvider
publicBaseURL *url.URL
}
// NewDrivesDriveItemApi creates a new DrivesDriveItemApi
func NewDrivesDriveItemApi(drivesDriveItemService DrivesDriveItemProvider, baseGraphService BaseGraphProvider, logger log.Logger) (DrivesDriveItemApi, error) {
func NewDrivesDriveItemApi(drivesDriveItemService DrivesDriveItemProvider, baseGraphService BaseGraphProvider, publicBaseURL *url.URL, logger log.Logger) (DrivesDriveItemApi, error) {
return DrivesDriveItemApi{
logger: log.Logger{Logger: logger.With().Str("graph api", "DrivesDriveItemApi").Logger()},
drivesDriveItemService: drivesDriveItemService,
baseGraphService: baseGraphService,
publicBaseURL: publicBaseURL,
}, nil
}
@@ -489,9 +559,9 @@ func (api DrivesDriveItemApi) UpdateDriveItem(w http.ResponseWriter, r *http.Req
render.JSON(w, r, driveItems[0])
}
// CreateDriveItem creates a drive item
// CreateDriveItem creates a new driveItem at the drive root: a folder, an
// empty file or a mounted share (remoteItem).
func (api DrivesDriveItemApi) CreateDriveItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
driveID, err := parseIDParam(r, "driveID")
if err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidDriveIDOrItemID.Error())
@@ -499,18 +569,108 @@ func (api DrivesDriveItemApi) CreateDriveItem(w http.ResponseWriter, r *http.Req
return
}
if !IsShareJail(&driveID) {
api.logger.Debug().Interface("driveID", driveID).Msg(ErrNotAShareJail.Error())
ErrNotAShareJail.Render(w, r)
return
}
requestDriveItem := libregraph.DriveItem{}
if err := StrictJSONUnmarshal(r.Body, &requestDriveItem); err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidRequestBody.Error())
ErrInvalidRequestBody.Render(w, r)
return
}
facets := 0
for _, set := range []bool{requestDriveItem.Folder != nil, requestDriveItem.File != nil, requestDriveItem.RemoteItem != nil} {
if set {
facets++
}
}
if facets != 1 {
api.logger.Debug().Msg(ErrExactlyOneFacet.Error())
ErrExactlyOneFacet.Render(w, r)
return
}
if requestDriveItem.RemoteItem != nil {
api.mountShare(w, r, &driveID, requestDriveItem)
return
}
if IsShareJail(&driveID) {
api.logger.Debug().Interface("driveID", &driveID).Msg("cannot create items in the share jail")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "cannot create items in the share jail")
return
}
root := &storageprovider.ResourceId{
StorageId: driveID.GetStorageId(),
SpaceId: driveID.GetSpaceId(),
OpaqueId: driveID.GetSpaceId(),
}
api.createChild(w, r, root, requestDriveItem)
}
// CreateChildDriveItem creates a new driveItem (folder or empty file) below an existing parent item
func (api DrivesDriveItemApi) CreateChildDriveItem(w http.ResponseWriter, r *http.Request) {
_, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidDriveIDOrItemID.Error())
ErrInvalidDriveIDOrItemID.Render(w, r)
return
}
requestDriveItem := libregraph.DriveItem{}
if err := StrictJSONUnmarshal(r.Body, &requestDriveItem); err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidRequestBody.Error())
ErrInvalidRequestBody.Render(w, r)
return
}
if (requestDriveItem.Folder != nil) == (requestDriveItem.File != nil) || requestDriveItem.RemoteItem != nil {
api.logger.Debug().Msg(ErrExactlyOneFacet.Error())
ErrExactlyOneFacet.Render(w, r)
return
}
api.createChild(w, r, itemID, requestDriveItem)
}
// createChild creates a folder or an empty file below the given parent and renders the result
func (api DrivesDriveItemApi) createChild(w http.ResponseWriter, r *http.Request, parentID *storageprovider.ResourceId, requestDriveItem libregraph.DriveItem) {
var replace bool
switch r.URL.Query().Get("@libre.graph.conflictBehavior") {
case "", "fail":
case "replace":
replace = true
default:
api.logger.Debug().Msg(ErrInvalidConflictBehavior.Error())
ErrInvalidConflictBehavior.Render(w, r)
return
}
info, err := api.drivesDriveItemService.CreateChild(r.Context(), parentID, requestDriveItem.GetName(), requestDriveItem.Folder != nil, replace)
if err != nil {
api.logger.Debug().Err(err).Msg("creating drive item failed")
errorcode.RenderError(w, r, err)
return
}
driveItem, err := cs3ResourceToDriveItem(&api.logger, api.publicBaseURL, info)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrDriveItemConversion.Error())
ErrDriveItemConversion.Render(w, r)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, driveItem)
}
// mountShare mounts a share into the share jail
func (api DrivesDriveItemApi) mountShare(w http.ResponseWriter, r *http.Request, driveID *storageprovider.ResourceId, requestDriveItem libregraph.DriveItem) {
ctx := r.Context()
if !IsShareJail(driveID) {
api.logger.Debug().Interface("driveID", driveID).Msg(ErrNotAShareJail.Error())
ErrNotAShareJail.Render(w, r)
return
}
remoteItem := requestDriveItem.GetRemoteItem()

View File

@@ -7,6 +7,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
collaborationv1beta1 "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
@@ -478,6 +479,109 @@ var _ = Describe("DrivesDriveItemService", func() {
Expect(shares).To(HaveLen(1))
})
})
var _ = Describe("CreateChild", func() {
parentID := &storageprovider.ResourceId{StorageId: "1", SpaceId: "2", OpaqueId: "2"}
It("rejects invalid names", func() {
_, _ = gatewaySelector.Next() // make mockery call count happy
for _, name := range []string{"", ".", "..", "a/b", "/a"} {
_, err := drivesDriveItemService.CreateChild(context.Background(), parentID, name, true, false)
Expect(err).To(MatchError(svc.ErrInvalidItemName))
}
})
It("creates a folder", func() {
gatewayClient.
EXPECT().
CreateContainer(mock.Anything, mock.Anything, mock.Anything).
RunAndReturn(func(ctx context.Context, request *storageprovider.CreateContainerRequest, _ ...grpc.CallOption) (*storageprovider.CreateContainerResponse, error) {
Expect(request.GetRef().GetResourceId().GetOpaqueId()).To(Equal("2"))
Expect(request.GetRef().GetPath()).To(Equal("./New Folder"))
return &storageprovider.CreateContainerResponse{Status: status.NewOK(ctx)}, nil
}).
Once()
gatewayClient.
EXPECT().
Stat(mock.Anything, mock.Anything, mock.Anything).
Return(&storageprovider.StatResponse{
Status: status.NewOK(context.Background()),
Info: &storageprovider.ResourceInfo{Id: &storageprovider.ResourceId{StorageId: "1", SpaceId: "2", OpaqueId: "3"}},
}, nil).
Times(2)
info, err := drivesDriveItemService.CreateChild(context.Background(), parentID, "New Folder", true, false)
Expect(err).ToNot(HaveOccurred())
Expect(info.GetId().GetOpaqueId()).To(Equal("3"))
})
It("creates an empty file", func() {
gatewayClient.
EXPECT().
TouchFile(mock.Anything, mock.Anything, mock.Anything).
RunAndReturn(func(ctx context.Context, request *storageprovider.TouchFileRequest, _ ...grpc.CallOption) (*storageprovider.TouchFileResponse, error) {
Expect(request.GetRef().GetPath()).To(Equal("./file.txt"))
return &storageprovider.TouchFileResponse{Status: status.NewOK(ctx)}, nil
}).
Once()
gatewayClient.
EXPECT().
Stat(mock.Anything, mock.Anything, mock.Anything).
Return(&storageprovider.StatResponse{
Status: status.NewOK(context.Background()),
Info: &storageprovider.ResourceInfo{Id: &storageprovider.ResourceId{StorageId: "1", SpaceId: "2", OpaqueId: "3"}},
}, nil).
Times(2)
_, err := drivesDriveItemService.CreateChild(context.Background(), parentID, "file.txt", false, false)
Expect(err).ToNot(HaveOccurred())
})
It("fails with nameAlreadyExists without replace", func() {
gatewayClient.
EXPECT().
TouchFile(mock.Anything, mock.Anything, mock.Anything).
Return(&storageprovider.TouchFileResponse{Status: status.NewAlreadyExists(context.Background(), nil, "exists")}, nil).
Once()
_, err := drivesDriveItemService.CreateChild(context.Background(), parentID, "file.txt", false, false)
var lgErr errorcode.Error
Expect(errors.As(err, &lgErr)).To(BeTrue())
Expect(lgErr.GetCode()).To(Equal(errorcode.NameAlreadyExists))
})
It("deletes the existing item with replace", func() {
gatewayClient.
EXPECT().
CreateContainer(mock.Anything, mock.Anything, mock.Anything).
Return(&storageprovider.CreateContainerResponse{Status: status.NewAlreadyExists(context.Background(), nil, "exists")}, nil).
Once()
gatewayClient.
EXPECT().
Delete(mock.Anything, mock.Anything, mock.Anything).
RunAndReturn(func(ctx context.Context, request *storageprovider.DeleteRequest, _ ...grpc.CallOption) (*storageprovider.DeleteResponse, error) {
Expect(request.GetRef().GetPath()).To(Equal("./New Folder"))
return &storageprovider.DeleteResponse{Status: status.NewOK(ctx)}, nil
}).
Once()
gatewayClient.
EXPECT().
CreateContainer(mock.Anything, mock.Anything, mock.Anything).
Return(&storageprovider.CreateContainerResponse{Status: status.NewOK(context.Background())}, nil).
Once()
gatewayClient.
EXPECT().
Stat(mock.Anything, mock.Anything, mock.Anything).
Return(&storageprovider.StatResponse{
Status: status.NewOK(context.Background()),
Info: &storageprovider.ResourceInfo{Id: &storageprovider.ResourceId{StorageId: "1", SpaceId: "2", OpaqueId: "3"}},
}, nil).
Times(2)
_, err := drivesDriveItemService.CreateChild(context.Background(), parentID, "New Folder", true, true)
Expect(err).ToNot(HaveOccurred())
})
})
})
var _ = Describe("DrivesDriveItemApi", func() {
@@ -494,7 +598,9 @@ var _ = Describe("DrivesDriveItemApi", func() {
baseGraphProvider = mocks.NewBaseGraphProvider(GinkgoT())
drivesDriveItemProvider = mocks.NewDrivesDriveItemProvider(GinkgoT())
api, err := svc.NewDrivesDriveItemApi(drivesDriveItemProvider, baseGraphProvider, logger)
publicBaseURL, err := url.Parse("https://localhost:9200")
Expect(err).ToNot(HaveOccurred())
api, err := svc.NewDrivesDriveItemApi(drivesDriveItemProvider, baseGraphProvider, publicBaseURL, logger)
Expect(err).ToNot(HaveOccurred())
drivesDriveItemApi = api
@@ -1037,17 +1143,67 @@ var _ = Describe("DrivesDriveItemApi", func() {
Expect(jsonData.Get("code").String() + ": " + jsonData.Get("message").String()).To(Equal(svc.ErrInvalidDriveIDOrItemID.Error()))
})
failOnNonShareJailDriveID(drivesDriveItemApi.CreateDriveItem)
failOninvalidDriveItemBody(drivesDriveItemApi.CreateDriveItem)
It("fails on non share jail driveID when mounting a share", func() {
rCTX.URLParams.Add("driveID", "1$2")
w := httptest.NewRecorder()
driveItemJson, err := json.Marshal(libregraph.DriveItem{
RemoteItem: &libregraph.RemoteItem{
Id: conversions.ToPointer("123"),
},
})
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(driveItemJson)).
WithContext(
context.WithValue(context.Background(), chi.RouteCtxKey, rCTX),
)
drivesDriveItemApi.CreateDriveItem(w, r)
Expect(w.Code).To(Equal(http.StatusBadRequest))
jsonData := gjson.Get(w.Body.String(), "error")
Expect(jsonData.Get("code").String() + ": " + jsonData.Get("message").String()).To(Equal(svc.ErrNotAShareJail.Error()))
})
It("fails if not exactly one facet is set", func() {
rCTX.URLParams.Add("driveID", "1$2")
for _, driveItem := range []libregraph.DriveItem{
{},
{Folder: &libregraph.Folder{}, File: &libregraph.OpenGraphFile{}},
{Folder: &libregraph.Folder{}, RemoteItem: &libregraph.RemoteItem{}},
} {
w := httptest.NewRecorder()
driveItemJson, err := json.Marshal(driveItem)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(driveItemJson)).
WithContext(
context.WithValue(context.Background(), chi.RouteCtxKey, rCTX),
)
drivesDriveItemApi.CreateDriveItem(w, r)
Expect(w.Code).To(Equal(http.StatusBadRequest))
jsonData := gjson.Get(w.Body.String(), "error")
Expect(jsonData.Get("code").String() + ": " + jsonData.Get("message").String()).To(Equal(svc.ErrExactlyOneFacet.Error()))
}
})
It("fails on invalid request body id", func() {
rCTX.URLParams.Add("driveID", "a0ca6a90-a365-4782-871e-d44447bbc668$a0ca6a90-a365-4782-871e-d44447bbc668")
rCTX.URLParams.Add("itemID", "a0ca6a90-a365-4782-871e-d44447bbc668$a0ca6a90-a365-4782-871e-d44447bbc668!1")
w := httptest.NewRecorder()
driveItemJson, err := json.Marshal(libregraph.DriveItem{})
driveItemJson, err := json.Marshal(libregraph.DriveItem{
RemoteItem: &libregraph.RemoteItem{},
})
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(driveItemJson)).
@@ -1179,5 +1335,188 @@ var _ = Describe("DrivesDriveItemApi", func() {
drivesDriveItemApi.CreateDriveItem(w, r)
Expect(w.Code).To(Equal(http.StatusCreated))
})
It("fails on an invalid conflictBehavior", func() {
rCTX.URLParams.Add("driveID", "1$2")
w := httptest.NewRecorder()
driveItemJson, err := json.Marshal(libregraph.DriveItem{
Name: conversions.ToPointer("New Folder"),
Folder: &libregraph.Folder{},
})
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/?%40libre.graph.conflictBehavior=rename", bytes.NewBuffer(driveItemJson)).
WithContext(
context.WithValue(context.Background(), chi.RouteCtxKey, rCTX),
)
drivesDriveItemApi.CreateDriveItem(w, r)
Expect(w.Code).To(Equal(http.StatusBadRequest))
jsonData := gjson.Get(w.Body.String(), "error")
Expect(jsonData.Get("code").String() + ": " + jsonData.Get("message").String()).To(Equal(svc.ErrInvalidConflictBehavior.Error()))
})
It("successfully creates a folder at the drive root", func() {
rCTX.URLParams.Add("driveID", "1$2")
w := httptest.NewRecorder()
driveItemJson, err := json.Marshal(libregraph.DriveItem{
Name: conversions.ToPointer("New Folder"),
Folder: &libregraph.Folder{},
})
Expect(err).ToNot(HaveOccurred())
drivesDriveItemProvider.
EXPECT().
CreateChild(mock.Anything, mock.MatchedBy(func(id *storageprovider.ResourceId) bool {
return id.GetStorageId() == "1" && id.GetSpaceId() == "2" && id.GetOpaqueId() == "2"
}), "New Folder", true, false).
Return(&storageprovider.ResourceInfo{
Id: &storageprovider.ResourceId{StorageId: "1", SpaceId: "2", OpaqueId: "3"},
Path: "./New Folder",
Type: storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER,
}, nil).
Once()
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(driveItemJson)).
WithContext(
context.WithValue(context.Background(), chi.RouteCtxKey, rCTX),
)
drivesDriveItemApi.CreateDriveItem(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(gjson.Get(w.Body.String(), "name").String()).To(Equal("New Folder"))
Expect(gjson.Get(w.Body.String(), "folder").Exists()).To(BeTrue())
})
It("passes replace on conflictBehavior=replace", func() {
rCTX.URLParams.Add("driveID", "1$2")
w := httptest.NewRecorder()
driveItemJson, err := json.Marshal(libregraph.DriveItem{
Name: conversions.ToPointer("file.txt"),
File: &libregraph.OpenGraphFile{},
})
Expect(err).ToNot(HaveOccurred())
drivesDriveItemProvider.
EXPECT().
CreateChild(mock.Anything, mock.Anything, "file.txt", false, true).
Return(&storageprovider.ResourceInfo{
Id: &storageprovider.ResourceId{StorageId: "1", SpaceId: "2", OpaqueId: "3"},
Path: "./file.txt",
Type: storageprovider.ResourceType_RESOURCE_TYPE_FILE,
}, nil).
Once()
r := httptest.NewRequest(http.MethodPost, "/?%40libre.graph.conflictBehavior=replace", bytes.NewBuffer(driveItemJson)).
WithContext(
context.WithValue(context.Background(), chi.RouteCtxKey, rCTX),
)
drivesDriveItemApi.CreateDriveItem(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
})
})
Describe("CreateChildDriveItem", func() {
failOnInvalidDriveIDOrItemID(drivesDriveItemApi.CreateChildDriveItem)
failOninvalidDriveItemBody(drivesDriveItemApi.CreateChildDriveItem)
It("fails if not exactly one of folder and file is set", func() {
rCTX.URLParams.Add("driveID", "1$2")
rCTX.URLParams.Add("itemID", "1$2!3")
for _, driveItem := range []libregraph.DriveItem{
{},
{Folder: &libregraph.Folder{}, File: &libregraph.OpenGraphFile{}},
{RemoteItem: &libregraph.RemoteItem{}},
} {
w := httptest.NewRecorder()
driveItemJson, err := json.Marshal(driveItem)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(driveItemJson)).
WithContext(
context.WithValue(context.Background(), chi.RouteCtxKey, rCTX),
)
drivesDriveItemApi.CreateChildDriveItem(w, r)
Expect(w.Code).To(Equal(http.StatusBadRequest))
jsonData := gjson.Get(w.Body.String(), "error")
Expect(jsonData.Get("code").String() + ": " + jsonData.Get("message").String()).To(Equal(svc.ErrExactlyOneFacet.Error()))
}
})
It("renders the service error", func() {
rCTX.URLParams.Add("driveID", "1$2")
rCTX.URLParams.Add("itemID", "1$2!3")
w := httptest.NewRecorder()
driveItemJson, err := json.Marshal(libregraph.DriveItem{
Name: conversions.ToPointer("New Folder"),
Folder: &libregraph.Folder{},
})
Expect(err).ToNot(HaveOccurred())
drivesDriveItemProvider.
EXPECT().
CreateChild(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(nil, errorcode.New(errorcode.NameAlreadyExists, "already exists")).
Once()
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(driveItemJson)).
WithContext(
context.WithValue(context.Background(), chi.RouteCtxKey, rCTX),
)
drivesDriveItemApi.CreateChildDriveItem(w, r)
Expect(w.Code).To(Equal(http.StatusConflict))
})
It("successfully creates a file below the parent", func() {
rCTX.URLParams.Add("driveID", "1$2")
rCTX.URLParams.Add("itemID", "1$2!3")
w := httptest.NewRecorder()
driveItemJson, err := json.Marshal(libregraph.DriveItem{
Name: conversions.ToPointer("file.txt"),
File: &libregraph.OpenGraphFile{},
})
Expect(err).ToNot(HaveOccurred())
drivesDriveItemProvider.
EXPECT().
CreateChild(mock.Anything, mock.MatchedBy(func(id *storageprovider.ResourceId) bool {
return id.GetStorageId() == "1" && id.GetSpaceId() == "2" && id.GetOpaqueId() == "3"
}), "file.txt", false, false).
Return(&storageprovider.ResourceInfo{
Id: &storageprovider.ResourceId{StorageId: "1", SpaceId: "2", OpaqueId: "4"},
Path: "./file.txt",
Type: storageprovider.ResourceType_RESOURCE_TYPE_FILE,
MimeType: "text/plain",
}, nil).
Once()
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(driveItemJson)).
WithContext(
context.WithValue(context.Background(), chi.RouteCtxKey, rCTX),
)
drivesDriveItemApi.CreateChildDriveItem(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(gjson.Get(w.Body.String(), "name").String()).To(Equal("file.txt"))
Expect(gjson.Get(w.Body.String(), "file.mimeType").String()).To(Equal("text/plain"))
})
})
})

View File

@@ -174,7 +174,7 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
return Graph{}, err
}
drivesDriveItemApi, err := NewDrivesDriveItemApi(drivesDriveItemService, baseGraphService, options.Logger)
drivesDriveItemApi, err := NewDrivesDriveItemApi(drivesDriveItemService, baseGraphService, publicBaseURL, options.Logger)
if err != nil {
return Graph{}, err
}
@@ -288,6 +288,7 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
r.Get("/", drivesDriveItemApi.GetDriveItem)
r.Patch("/", drivesDriveItemApi.UpdateDriveItem)
r.Delete("/", drivesDriveItemApi.DeleteDriveItem)
r.Post("/children", drivesDriveItemApi.CreateChildDriveItem)
r.Post("/invite", driveItemPermissionsApi.Invite)
r.Post("/createLink", driveItemPermissionsApi.CreateLink)
r.Route("/permissions", func(r chi.Router) {