From a21622ca18012f52760ea1b31f89dc6546efec2d Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Sat, 11 Jul 2026 16:04:45 +0200 Subject: [PATCH] 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. --- .../graph/mocks/drives_drive_item_provider.go | 86 +++++ services/graph/pkg/middleware/path_lookup.go | 107 +++++- .../graph/pkg/middleware/path_lookup_test.go | 93 +++++ .../pkg/service/v0/api_drives_drive_item.go | 178 ++++++++- .../service/v0/api_drives_drive_item_test.go | 347 +++++++++++++++++- services/graph/pkg/service/v0/service.go | 3 +- 6 files changed, 797 insertions(+), 17 deletions(-) diff --git a/services/graph/mocks/drives_drive_item_provider.go b/services/graph/mocks/drives_drive_item_provider.go index d89a326ccc..74f9613147 100644 --- a/services/graph/mocks/drives_drive_item_provider.go +++ b/services/graph/mocks/drives_drive_item_provider.go @@ -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) diff --git a/services/graph/pkg/middleware/path_lookup.go b/services/graph/pkg/middleware/path_lookup.go index 1fc89bcd6c..c788e93fd4 100644 --- a/services/graph/pkg/middleware/path_lookup.go +++ b/services/graph/pkg/middleware/path_lookup.go @@ -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{ diff --git a/services/graph/pkg/middleware/path_lookup_test.go b/services/graph/pkg/middleware/path_lookup_test.go index fa866d2fed..6dde8bbc89 100644 --- a/services/graph/pkg/middleware/path_lookup_test.go +++ b/services/graph/pkg/middleware/path_lookup_test.go @@ -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) + }) +} diff --git a/services/graph/pkg/service/v0/api_drives_drive_item.go b/services/graph/pkg/service/v0/api_drives_drive_item.go index 16b5d3a71a..f7357b6331 100644 --- a/services/graph/pkg/service/v0/api_drives_drive_item.go +++ b/services/graph/pkg/service/v0/api_drives_drive_item.go @@ -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() diff --git a/services/graph/pkg/service/v0/api_drives_drive_item_test.go b/services/graph/pkg/service/v0/api_drives_drive_item_test.go index 394188482b..87c4f0c090 100644 --- a/services/graph/pkg/service/v0/api_drives_drive_item_test.go +++ b/services/graph/pkg/service/v0/api_drives_drive_item_test.go @@ -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")) + }) }) }) diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go index fb5e490851..864ee00569 100644 --- a/services/graph/pkg/service/v0/service.go +++ b/services/graph/pkg/service/v0/service.go @@ -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) {