diff --git a/app/api/controller/repo/create.go b/app/api/controller/repo/create.go index aeb26f623..d7e06e2ee 100644 --- a/app/api/controller/repo/create.go +++ b/app/api/controller/repo/create.go @@ -97,7 +97,7 @@ func (c *Controller) Create(ctx context.Context, session *auth.Session, in *Crea } err = c.repoStore.Create(ctx, repo) if err != nil { - if dErr := c.deleteGitRepository(ctx, session, repo); dErr != nil { + if dErr := c.DeleteGitRepository(ctx, session, repo); dErr != nil { log.Ctx(ctx).Warn().Err(dErr).Msg("failed to delete repo for cleanup") } return fmt.Errorf("failed to create repository in storage: %w", err) diff --git a/app/api/controller/repo/purge.go b/app/api/controller/repo/purge.go index fd8232b57..2aba25c71 100644 --- a/app/api/controller/repo/purge.go +++ b/app/api/controller/repo/purge.go @@ -68,8 +68,8 @@ func (c *Controller) PurgeNoAuth( return fmt.Errorf("failed to delete repo from db: %w", err) } - if err := c.deleteGitRepository(ctx, session, repo); err != nil { - return fmt.Errorf("failed to delete git repository: %w", err) + if err := c.DeleteGitRepository(ctx, session, repo); err != nil { + log.Ctx(ctx).Err(err).Msg("failed to remove git repository") } c.eventReporter.Deleted( @@ -81,7 +81,7 @@ func (c *Controller) PurgeNoAuth( return nil } -func (c *Controller) deleteGitRepository( +func (c *Controller) DeleteGitRepository( ctx context.Context, session *auth.Session, repo *types.Repository, diff --git a/app/api/controller/repo/restore.go b/app/api/controller/repo/restore.go index a0acef1bf..897e144d6 100644 --- a/app/api/controller/repo/restore.go +++ b/app/api/controller/repo/restore.go @@ -21,12 +21,15 @@ import ( apiauth "github.com/harness/gitness/app/api/auth" "github.com/harness/gitness/app/api/usererror" "github.com/harness/gitness/app/auth" + "github.com/harness/gitness/errors" + "github.com/harness/gitness/store" "github.com/harness/gitness/types" "github.com/harness/gitness/types/enum" ) type RestoreInput struct { - NewIdentifier string `json:"new_identifier,omitempty"` + NewIdentifier *string `json:"new_identifier,omitempty"` + NewParentRef *string `json:"new_parent_ref,omitempty"` } func (c *Controller) Restore( @@ -49,10 +52,31 @@ func (c *Controller) Restore( return nil, usererror.BadRequest("cannot restore a repo that hasn't been deleted") } - repo, err = c.repoStore.Restore(ctx, repo, in.NewIdentifier) + parentID := repo.ParentID + if in.NewParentRef != nil { + space, err := c.spaceStore.FindByRef(ctx, *in.NewParentRef) + if errors.Is(err, store.ErrResourceNotFound) { + return nil, usererror.BadRequest("The provided new parent ref wasn't found.") + } + if err != nil { + return nil, fmt.Errorf("failed to find the parent ref '%s': %w", *in.NewParentRef, err) + } + + parentID = space.ID + } + + return c.RestoreNoAuth(ctx, repo, in.NewIdentifier, &parentID) +} + +func (c *Controller) RestoreNoAuth( + ctx context.Context, + repo *types.Repository, + newIdentifier *string, + newParentID *int64, +) (*types.Repository, error) { + repo, err := c.repoStore.Restore(ctx, repo, newIdentifier, newParentID) if err != nil { return nil, fmt.Errorf("failed to restore the repo: %w", err) } - return repo, nil } diff --git a/app/api/controller/space/create.go b/app/api/controller/space/create.go index c039bd6ca..1b9e4fea0 100644 --- a/app/api/controller/space/create.go +++ b/app/api/controller/space/create.go @@ -87,7 +87,7 @@ func (c *Controller) createSpaceInnerInTX( if err != nil { return nil, fmt.Errorf("failed to find primary path for parent '%d': %w", parentID, err) } - spacePath = paths.Concatinate(parentPath.Value, in.Identifier) + spacePath = paths.Concatenate(parentPath.Value, in.Identifier) // ensure path is within accepted depth! err = check.PathDepth(spacePath, true) diff --git a/app/api/controller/space/delete.go b/app/api/controller/space/delete.go deleted file mode 100644 index f8b6b67f8..000000000 --- a/app/api/controller/space/delete.go +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright 2023 Harness, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package space - -import ( - "context" - "fmt" - "math" - "time" - - apiauth "github.com/harness/gitness/app/api/auth" - "github.com/harness/gitness/app/auth" - "github.com/harness/gitness/types" - "github.com/harness/gitness/types/enum" -) - -// Delete deletes a space. -func (c *Controller) Delete( - ctx context.Context, - session *auth.Session, - spaceRef string, -) error { - space, err := c.spaceStore.FindByRef(ctx, spaceRef) - if err != nil { - return err - } - if err = apiauth.CheckSpace(ctx, c.authorizer, session, space, enum.PermissionSpaceDelete, false); err != nil { - return err - } - - return c.DeleteNoAuth(ctx, session, space.ID) -} - -// DeleteNoAuth deletes the space - no authorization is verified. -// WARNING this is meant for internal calls only. -func (c *Controller) DeleteNoAuth( - ctx context.Context, - session *auth.Session, - spaceID int64, -) error { - filter := &types.SpaceFilter{ - Page: 1, - Size: math.MaxInt, - Query: "", - Order: enum.OrderAsc, - Sort: enum.SpaceAttrNone, - } - subSpaces, _, err := c.ListSpacesNoAuth(ctx, spaceID, filter) - if err != nil { - return fmt.Errorf("failed to list space %d sub spaces: %w", spaceID, err) - } - for _, space := range subSpaces { - err = c.DeleteNoAuth(ctx, session, space.ID) - if err != nil { - return fmt.Errorf("failed to delete space %d: %w", space.ID, err) - } - } - err = c.deleteRepositoriesNoAuth(ctx, session, spaceID) - if err != nil { - return fmt.Errorf("failed to delete repositories of space %d: %w", spaceID, err) - } - err = c.spaceStore.Delete(ctx, spaceID) - if err != nil { - return fmt.Errorf("spaceStore failed to delete space %d: %w", spaceID, err) - } - return nil -} - -// deleteRepositoriesNoAuth deletes all repositories in a space - no authorization is verified. -// WARNING this is meant for internal calls only. -func (c *Controller) deleteRepositoriesNoAuth( - ctx context.Context, - session *auth.Session, - spaceID int64, -) error { - filter := &types.RepoFilter{ - Page: 1, - Size: int(math.MaxInt), - Query: "", - Order: enum.OrderAsc, - Sort: enum.RepoAttrNone, - DeletedBeforeOrAt: nil, - } - - repos, _, err := c.ListRepositoriesNoAuth(ctx, spaceID, filter) - if err != nil { - return fmt.Errorf("failed to list space repositories: %w", err) - } - - // TEMPORARY until we support space delete/restore CODE-1413 - recent := time.Now().Add(+time.Hour * 24).UnixMilli() - filter.DeletedBeforeOrAt = &recent - alreadyDeletedRepos, _, err := c.ListRepositoriesNoAuth(ctx, spaceID, filter) - if err != nil { - return fmt.Errorf("failed to list delete repositories for space %d: %w", spaceID, err) - } - repos = append(repos, alreadyDeletedRepos...) - - for _, repo := range repos { - err = c.repoCtrl.PurgeNoAuth(ctx, session, repo) - if err != nil { - return fmt.Errorf("failed to delete repository %d: %w", repo.ID, err) - } - } - return nil -} diff --git a/app/api/controller/space/purge.go b/app/api/controller/space/purge.go new file mode 100644 index 000000000..bfa4b4c9a --- /dev/null +++ b/app/api/controller/space/purge.go @@ -0,0 +1,123 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package space + +import ( + "context" + "fmt" + "math" + "time" + + apiauth "github.com/harness/gitness/app/api/auth" + "github.com/harness/gitness/app/auth" + "github.com/harness/gitness/contextutil" + "github.com/harness/gitness/types" + "github.com/harness/gitness/types/enum" + + "github.com/rs/zerolog/log" +) + +// Purge deletes the space and all its subspaces and repositories permanently. +func (c *Controller) Purge( + ctx context.Context, + session *auth.Session, + spaceRef string, + deletedAt int64, +) error { + space, err := c.spaceStore.FindByRefAndDeletedAt(ctx, spaceRef, deletedAt) + if err != nil { + return err + } + + // authz will check the permission within the first existing parent since space was deleted. + // purge top level space is limited to admin only. + err = apiauth.CheckSpace(ctx, c.authorizer, session, space, enum.PermissionSpaceDelete, false) + if err != nil { + return fmt.Errorf("failed to authorize on space purge: %w", err) + } + + return c.PurgeNoAuth(ctx, session, space.ID, deletedAt) +} + +// PurgeNoAuth purges the space - no authorization is verified. +// WARNING For internal calls only. +func (c *Controller) PurgeNoAuth( + ctx context.Context, + session *auth.Session, + spaceID int64, + deletedAt int64, +) error { + // the max time we give a purge space to succeed + const timeout = 15 * time.Minute + // create new, time-restricted context to guarantee space purge completion, even if request is canceled. + ctx, cancel := context.WithTimeout( + contextutil.WithNewValues(context.Background(), ctx), + timeout, + ) + defer cancel() + + var toBeDeletedRepos []*types.Repository + var err error + err = c.tx.WithTx(ctx, func(ctx context.Context) error { + toBeDeletedRepos, err = c.purgeSpaceInnerInTx(ctx, spaceID, deletedAt) + return err + }) + if err != nil { + return fmt.Errorf("failed to purge space %d in a tnx: %w", spaceID, err) + } + + // permanently purge all repositories in the space and its subspaces after successful space purge tnx. + // cleanup will handle failed repository deletions. + for _, repo := range toBeDeletedRepos { + err := c.repoCtrl.DeleteGitRepository(ctx, session, repo) + if err != nil { + log.Ctx(ctx).Warn().Err(err). + Str("repo_identifier", repo.Identifier). + Int64("repo_id", repo.ID). + Int64("repo_parent_id", repo.ParentID). + Msg("failed to delete repository") + } + } + + return nil +} + +func (c *Controller) purgeSpaceInnerInTx( + ctx context.Context, + spaceID int64, + deletedAt int64, +) ([]*types.Repository, error) { + filter := &types.RepoFilter{ + Page: 1, + Size: int(math.MaxInt), + Query: "", + Order: enum.OrderAsc, + Sort: enum.RepoAttrDeleted, + DeletedBeforeOrAt: &deletedAt, + Recursive: true, + } + repos, err := c.repoStore.List(ctx, spaceID, filter) + if err != nil { + return nil, fmt.Errorf("failed to list space repositories: %w", err) + } + + // purge cascade deletes all the child spaces from DB. + err = c.spaceStore.Purge(ctx, spaceID, &deletedAt) + if err != nil { + return nil, fmt.Errorf("spaceStore failed to delete space %d: %w", spaceID, err) + } + + return repos, nil +} diff --git a/app/api/controller/space/restore.go b/app/api/controller/space/restore.go new file mode 100644 index 000000000..c19e97e10 --- /dev/null +++ b/app/api/controller/space/restore.go @@ -0,0 +1,260 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package space + +import ( + "context" + "errors" + "fmt" + "math" + "time" + + apiauth "github.com/harness/gitness/app/api/auth" + "github.com/harness/gitness/app/api/usererror" + "github.com/harness/gitness/app/auth" + "github.com/harness/gitness/app/paths" + "github.com/harness/gitness/store" + "github.com/harness/gitness/types" + "github.com/harness/gitness/types/check" + "github.com/harness/gitness/types/enum" +) + +type RestoreInput struct { + NewIdentifier *string `json:"new_identifier,omitempty"` + NewParentRef *string `json:"new_parent_ref,omitempty"` // Reference of the new parent space +} + +var errSpacePathInvalid = usererror.BadRequest("Space ref or identifier is invalid.") + +func (c *Controller) Restore( + ctx context.Context, + session *auth.Session, + spaceRef string, + deletedAt int64, + in *RestoreInput, +) (*types.Space, error) { + if err := c.sanitizeRestoreInput(in); err != nil { + return nil, fmt.Errorf("failed to sanitize restore input: %w", err) + } + + space, err := c.spaceStore.FindByRefAndDeletedAt(ctx, spaceRef, deletedAt) + if err != nil { + return nil, fmt.Errorf("failed to find the space: %w", err) + } + + // check view permission on the original ref. + err = apiauth.CheckSpace(ctx, c.authorizer, session, space, enum.PermissionSpaceView, false) + if err != nil { + return nil, fmt.Errorf("failed to authorize on space restore: %w", err) + } + + parentSpace, err := c.getParentSpace(ctx, space, in.NewParentRef) + if err != nil { + return nil, fmt.Errorf("failed to get space parent: %w", err) + } + + // check create permissions within the parent space scope. + if err = apiauth.CheckSpaceScope( + ctx, + c.authorizer, + session, + parentSpace, + enum.ResourceTypeSpace, + enum.PermissionSpaceEdit, + false, + ); err != nil { + return nil, fmt.Errorf("authorization failed on space restore: %w", err) + } + + spacePath := paths.Concatenate(parentSpace.Path, space.Identifier) + if in.NewIdentifier != nil { + spacePath = paths.Concatenate(parentSpace.Path, *in.NewIdentifier) + } + + err = c.tx.WithTx(ctx, func(ctx context.Context) error { + space, err = c.restoreSpaceInnerInTx( + ctx, + space, + deletedAt, + in.NewIdentifier, + &parentSpace.ID, + spacePath) + return err + }) + if err != nil { + return nil, fmt.Errorf("failed to restore space in a tnx: %w", err) + } + + return space, nil +} + +func (c *Controller) restoreSpaceInnerInTx( + ctx context.Context, + space *types.Space, + deletedAt int64, + newIdentifier *string, + newParentID *int64, + spacePath string, +) (*types.Space, error) { + filter := &types.SpaceFilter{ + Page: 1, + Size: math.MaxInt, + Query: "", + Order: enum.OrderDesc, + Sort: enum.SpaceAttrCreated, + DeletedBeforeOrAt: &deletedAt, + Recursive: true, + } + subSpaces, err := c.spaceStore.List(ctx, space.ID, filter) + if err != nil { + return nil, fmt.Errorf("failed to list space %d sub spaces recursively: %w", space.ID, err) + } + + var subspacePath string + for _, subspace := range subSpaces { + // check the path depth before restore nested subspaces. + subspacePath = subspace.Path[len(space.Path):] + + if err = check.PathDepth(paths.Concatenate(spacePath, subspacePath), true); err != nil { + return nil, fmt.Errorf("path is invalid: %w", err) + } + + // identifier and parent ID of sub spaces shouldn't change. + _, err = c.restoreNoAuth(ctx, subspace, nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to restore subspace: %w", err) + } + } + + if err := c.restoreRepositoriesNoAuth(ctx, space.ID, deletedAt); err != nil { + return nil, fmt.Errorf("failed to restore space %d repositories: %w", space.ID, err) + } + + // restore the target space + restoredSpace, err := c.restoreNoAuth(ctx, space, newIdentifier, newParentID) + if err != nil { + return nil, fmt.Errorf("failed to restore space: %w", err) + } + + if err = check.PathDepth(restoredSpace.Path, true); err != nil { + return nil, fmt.Errorf("path is invalid: %w", err) + } + + return restoredSpace, nil +} + +func (c *Controller) restoreNoAuth( + ctx context.Context, + space *types.Space, + newIdentifier *string, + newParentID *int64, +) (*types.Space, error) { + space, err := c.spaceStore.Restore(ctx, space, newIdentifier, newParentID) + if err != nil { + return nil, fmt.Errorf("failed to restore the space: %w", err) + } + + now := time.Now().UnixMilli() + pathSegment := &types.SpacePathSegment{ + Identifier: space.Identifier, + IsPrimary: true, + SpaceID: space.ID, + ParentID: space.ParentID, + CreatedBy: space.CreatedBy, + Created: now, + Updated: now, + } + err = c.spacePathStore.InsertSegment(ctx, pathSegment) + if errors.Is(err, store.ErrDuplicate) { + return nil, usererror.BadRequest(fmt.Sprintf("A primary path already exists for %s.", + space.Identifier)) + } + if err != nil { + return nil, fmt.Errorf("failed to insert space path on restore: %w", err) + } + + return space, nil +} + +func (c *Controller) restoreRepositoriesNoAuth( + ctx context.Context, + spaceID int64, + deletedAt int64, +) error { + filter := &types.RepoFilter{ + Page: 1, + Size: int(math.MaxInt), + Query: "", + Order: enum.OrderAsc, + Sort: enum.RepoAttrNone, + DeletedBeforeOrAt: &deletedAt, + Recursive: true, + } + repos, err := c.repoStore.List(ctx, spaceID, filter) + if err != nil { + return fmt.Errorf("failed to list space repositories: %w", err) + } + + for _, repo := range repos { + _, err = c.repoCtrl.RestoreNoAuth(ctx, repo, nil, nil) + if err != nil { + return fmt.Errorf("failed to restore repository: %w", err) + } + } + return nil +} + +func (c *Controller) getParentSpace( + ctx context.Context, + space *types.Space, + newParentRef *string, +) (*types.Space, error) { + var parentSpace *types.Space + var err error + + if newParentRef == nil { + if space.ParentID == 0 { + return &types.Space{}, nil + } + + parentSpace, err = c.spaceStore.Find(ctx, space.ParentID) + if err != nil { + return nil, fmt.Errorf("failed to find the space parent %d", space.ParentID) + } + + return parentSpace, nil + } + + // the provided new reference for space parent must exist. + parentSpace, err = c.spaceStore.FindByRef(ctx, *newParentRef) + if err != nil { + return nil, fmt.Errorf("failed to find the parent space by ref '%s': %w - returning usererror %w", + *newParentRef, err, errSpacePathInvalid) + } + + return parentSpace, nil +} + +func (c *Controller) sanitizeRestoreInput(in *RestoreInput) error { + if in.NewParentRef == nil { + return nil + } + + if len(*in.NewParentRef) > 0 && !c.nestedSpacesEnabled { + return errNestedSpacesNotSupported + } + + return nil +} diff --git a/app/api/controller/space/soft_delete.go b/app/api/controller/space/soft_delete.go new file mode 100644 index 000000000..ac5aa531b --- /dev/null +++ b/app/api/controller/space/soft_delete.go @@ -0,0 +1,149 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package space + +import ( + "context" + "fmt" + "math" + "time" + + apiauth "github.com/harness/gitness/app/api/auth" + "github.com/harness/gitness/app/auth" + "github.com/harness/gitness/types" + "github.com/harness/gitness/types/enum" +) + +type SoftDeleteResponse struct { + DeletedAt int64 `json:"deleted_at"` +} + +// SoftDelete marks deleted timestamp for the space and all its subspaces and repositories inside. +func (c *Controller) SoftDelete( + ctx context.Context, + session *auth.Session, + spaceRef string, +) (*SoftDeleteResponse, error) { + space, err := c.spaceStore.FindByRef(ctx, spaceRef) + if err != nil { + return nil, fmt.Errorf("failed to find space for soft delete: %w", err) + } + + if err = apiauth.CheckSpace( + ctx, + c.authorizer, + session, + space, + enum.PermissionSpaceDelete, + false, + ); err != nil { + return nil, fmt.Errorf("failed to check access: %w", err) + } + + return c.SoftDeleteNoAuth(ctx, space) +} + +// SoftDeleteNoAuth soft deletes the space - no authorization is verified. +// WARNING For internal calls only. +func (c *Controller) SoftDeleteNoAuth( + ctx context.Context, + space *types.Space, +) (*SoftDeleteResponse, error) { + var softDelRes *SoftDeleteResponse + var err error + + err = c.tx.WithTx(ctx, func(ctx context.Context) error { + softDelRes, err = c.softDeleteInnerInTx(ctx, space) + return err + }) + if err != nil { + return nil, fmt.Errorf("failed to soft delete the space: %w", err) + } + + return softDelRes, nil +} + +func (c *Controller) softDeleteInnerInTx( + ctx context.Context, + space *types.Space, +) (*SoftDeleteResponse, error) { + filter := &types.SpaceFilter{ + Page: 1, + Size: math.MaxInt, + Query: "", + Order: enum.OrderAsc, + Sort: enum.SpaceAttrCreated, + DeletedBeforeOrAt: nil, // only filter active subspaces + Recursive: true, + } + subSpaces, err := c.spaceStore.List(ctx, space.ID, filter) + if err != nil { + return nil, fmt.Errorf("failed to list space %d sub spaces recursively: %w", space.ID, err) + } + + now := time.Now().UnixMilli() + + for _, space := range subSpaces { + if err := c.spaceStore.SoftDelete(ctx, space, now); err != nil { + return nil, fmt.Errorf("failed to soft delete subspace: %w", err) + } + } + + err = c.softDeleteRepositoriesNoAuth(ctx, space.ID, now) + if err != nil { + return nil, fmt.Errorf("failed to soft delete repositories of space %d: %w", space.ID, err) + } + + if err = c.spaceStore.SoftDelete(ctx, space, now); err != nil { + return nil, fmt.Errorf("spaceStore failed to soft delete space: %w", err) + } + + err = c.spacePathStore.DeletePathsAndDescendandPaths(ctx, space.ID) + if err != nil { + return nil, fmt.Errorf("spacePathStore failed to delete descendant paths of %d: %w", space.ID, err) + } + + return &SoftDeleteResponse{DeletedAt: now}, nil +} + +// softDeleteRepositoriesNoAuth soft deletes all repositories in a space - no authorization is verified. +// WARNING For internal calls only. +func (c *Controller) softDeleteRepositoriesNoAuth( + ctx context.Context, + spaceID int64, + deletedAt int64, +) error { + filter := &types.RepoFilter{ + Page: 1, + Size: int(math.MaxInt), + Query: "", + Order: enum.OrderAsc, + Sort: enum.RepoAttrNone, + DeletedBeforeOrAt: nil, // only filter active repos + Recursive: true, + } + repos, err := c.repoStore.List(ctx, spaceID, filter) + if err != nil { + return fmt.Errorf("failed to list space repositories: %w", err) + } + + for _, repo := range repos { + err = c.repoCtrl.SoftDeleteNoAuth(ctx, repo, deletedAt) + if err != nil { + return fmt.Errorf("failed to soft delete repository: %w", err) + } + } + return nil +} diff --git a/app/api/handler/repo/purge.go b/app/api/handler/repo/purge.go index 74a9f127a..31ba12bfd 100644 --- a/app/api/handler/repo/purge.go +++ b/app/api/handler/repo/purge.go @@ -25,7 +25,6 @@ import ( func HandlePurge(repoCtrl *repo.Controller) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - session, _ := request.AuthSessionFrom(ctx) repoRef, err := request.GetRepoRefFromPath(r) @@ -34,7 +33,7 @@ func HandlePurge(repoCtrl *repo.Controller) http.HandlerFunc { return } - deletedAt, err := request.GetDeletedAtFromQuery(r) + deletedAt, err := request.GetDeletedAtFromQueryOrError(r) if err != nil { render.TranslatedUserError(ctx, w, err) return diff --git a/app/api/handler/repo/restore.go b/app/api/handler/repo/restore.go index 17965a1ad..cc49aa976 100644 --- a/app/api/handler/repo/restore.go +++ b/app/api/handler/repo/restore.go @@ -35,7 +35,7 @@ func HandleRestore(repoCtrl *repo.Controller) http.HandlerFunc { return } - deletedAt, err := request.GetDeletedAtFromQuery(r) + deletedAt, err := request.GetDeletedAtFromQueryOrError(r) if err != nil { render.TranslatedUserError(ctx, w, err) return diff --git a/app/api/handler/space/list.go b/app/api/handler/space/list.go index 84a4673c0..eca761ee2 100644 --- a/app/api/handler/space/list.go +++ b/app/api/handler/space/list.go @@ -34,7 +34,12 @@ func HandleListSpaces(spaceCtrl *space.Controller) http.HandlerFunc { return } - spaceFilter := request.ParseSpaceFilter(r) + spaceFilter, err := request.ParseSpaceFilter(r) + if err != nil { + render.TranslatedUserError(ctx, w, err) + return + } + if spaceFilter.Order == enum.OrderDefault { spaceFilter.Order = enum.OrderAsc } diff --git a/app/api/handler/space/list_repos.go b/app/api/handler/space/list_repos.go index 456c59a14..0f57f8157 100644 --- a/app/api/handler/space/list_repos.go +++ b/app/api/handler/space/list_repos.go @@ -34,17 +34,16 @@ func HandleListRepos(spaceCtrl *space.Controller) http.HandlerFunc { return } - filter := request.ParseRepoFilter(r) - if filter.Order == enum.OrderDefault { - filter.Order = enum.OrderAsc - } - - filter.Recursive, err = request.ParseRecursiveFromQuery(r) + filter, err := request.ParseRepoFilter(r) if err != nil { render.TranslatedUserError(ctx, w, err) return } + if filter.Order == enum.OrderDefault { + filter.Order = enum.OrderAsc + } + repos, count, err := spaceCtrl.ListRepositories( ctx, session, spaceRef, filter) if err != nil { diff --git a/app/api/handler/space/purge.go b/app/api/handler/space/purge.go new file mode 100644 index 000000000..13a28a663 --- /dev/null +++ b/app/api/handler/space/purge.go @@ -0,0 +1,51 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package space + +import ( + "net/http" + + "github.com/harness/gitness/app/api/controller/space" + "github.com/harness/gitness/app/api/render" + "github.com/harness/gitness/app/api/request" +) + +// HandlePurge handles the purge delete space HTTP API. +func HandlePurge(spaceCtrl *space.Controller) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + session, _ := request.AuthSessionFrom(ctx) + + spaceRef, err := request.GetSpaceRefFromPath(r) + if err != nil { + render.TranslatedUserError(ctx, w, err) + return + } + + deletedAt, err := request.GetDeletedAtFromQueryOrError(r) + if err != nil { + render.TranslatedUserError(ctx, w, err) + return + } + + err = spaceCtrl.Purge(ctx, session, spaceRef, deletedAt) + if err != nil { + render.TranslatedUserError(ctx, w, err) + return + } + + render.DeleteSuccessful(w) + } +} diff --git a/app/api/handler/space/restore.go b/app/api/handler/space/restore.go new file mode 100644 index 000000000..6526add81 --- /dev/null +++ b/app/api/handler/space/restore.go @@ -0,0 +1,59 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package space + +import ( + "encoding/json" + "net/http" + + "github.com/harness/gitness/app/api/controller/space" + "github.com/harness/gitness/app/api/render" + "github.com/harness/gitness/app/api/request" +) + +// HandleRestore handles the restore of soft deleted space HTTP API. +func HandleRestore(spaceCtrl *space.Controller) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + session, _ := request.AuthSessionFrom(ctx) + + spaceRef, err := request.GetSpaceRefFromPath(r) + if err != nil { + render.TranslatedUserError(ctx, w, err) + return + } + + deletedAt, err := request.GetDeletedAtFromQueryOrError(r) + if err != nil { + render.TranslatedUserError(ctx, w, err) + return + } + + in := new(space.RestoreInput) + err = json.NewDecoder(r.Body).Decode(in) + if err != nil { + render.BadRequestf(ctx, w, "Invalid request body: %s.", err) + return + } + + space, err := spaceCtrl.Restore(ctx, session, spaceRef, deletedAt, in) + if err != nil { + render.TranslatedUserError(ctx, w, err) + return + } + + render.JSON(w, http.StatusOK, space) + } +} diff --git a/app/api/handler/space/delete.go b/app/api/handler/space/soft_delete.go similarity index 82% rename from app/api/handler/space/delete.go rename to app/api/handler/space/soft_delete.go index 45244a1f4..48aff1ed7 100644 --- a/app/api/handler/space/delete.go +++ b/app/api/handler/space/soft_delete.go @@ -22,23 +22,25 @@ import ( "github.com/harness/gitness/app/api/request" ) -// HandleDelete handles the delete space HTTP API. -func HandleDelete(spaceCtrl *space.Controller) http.HandlerFunc { +// HandleSoftDelete handles the soft delete space HTTP API. +func HandleSoftDelete(spaceCtrl *space.Controller) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + session, _ := request.AuthSessionFrom(ctx) + spaceRef, err := request.GetSpaceRefFromPath(r) if err != nil { render.TranslatedUserError(ctx, w, err) return } - err = spaceCtrl.Delete(ctx, session, spaceRef) + res, err := spaceCtrl.SoftDelete(ctx, session, spaceRef) if err != nil { render.TranslatedUserError(ctx, w, err) return } - render.DeleteSuccessful(w) + render.JSON(w, http.StatusOK, res) } } diff --git a/app/api/openapi/space.go b/app/api/openapi/space.go index 201600104..bd52e72d8 100644 --- a/app/api/openapi/space.go +++ b/app/api/openapi/space.go @@ -50,6 +50,11 @@ type exportSpaceRequest struct { space.ExportInput } +type restoreSpaceRequest struct { + spaceRequest + space.RestoreInput +} + var queryParameterSortRepo = openapi3.ParameterOrRef{ Parameter: &openapi3.Parameter{ Name: request.QueryParamSort, @@ -246,13 +251,38 @@ func spaceOperations(reflector *openapi3.Reflector) { opDelete.WithTags("space") opDelete.WithMapOfAnything(map[string]interface{}{"operationId": "deleteSpace"}) _ = reflector.SetRequest(&opDelete, new(spaceRequest), http.MethodDelete) - _ = reflector.SetJSONResponse(&opDelete, nil, http.StatusNoContent) + _ = reflector.SetJSONResponse(&opDelete, new(space.SoftDeleteResponse), http.StatusOK) _ = reflector.SetJSONResponse(&opDelete, new(usererror.Error), http.StatusInternalServerError) _ = reflector.SetJSONResponse(&opDelete, new(usererror.Error), http.StatusUnauthorized) _ = reflector.SetJSONResponse(&opDelete, new(usererror.Error), http.StatusForbidden) _ = reflector.SetJSONResponse(&opDelete, new(usererror.Error), http.StatusNotFound) _ = reflector.Spec.AddOperation(http.MethodDelete, "/spaces/{space_ref}", opDelete) + opPurge := openapi3.Operation{} + opPurge.WithTags("space") + opPurge.WithMapOfAnything(map[string]interface{}{"operationId": "purgeSpace"}) + opPurge.WithParameters(queryParameterDeletedAt) + _ = reflector.SetRequest(&opPurge, new(spaceRequest), http.MethodPost) + _ = reflector.SetJSONResponse(&opPurge, nil, http.StatusNoContent) + _ = reflector.SetJSONResponse(&opPurge, new(usererror.Error), http.StatusInternalServerError) + _ = reflector.SetJSONResponse(&opPurge, new(usererror.Error), http.StatusUnauthorized) + _ = reflector.SetJSONResponse(&opPurge, new(usererror.Error), http.StatusForbidden) + _ = reflector.SetJSONResponse(&opPurge, new(usererror.Error), http.StatusNotFound) + _ = reflector.Spec.AddOperation(http.MethodPost, "/spaces/{space_ref}/purge", opPurge) + + opRestore := openapi3.Operation{} + opRestore.WithTags("space") + opRestore.WithMapOfAnything(map[string]interface{}{"operationId": "restoreSpace"}) + opRestore.WithParameters(queryParameterDeletedAt) + _ = reflector.SetRequest(&opRestore, new(restoreSpaceRequest), http.MethodPost) + _ = reflector.SetJSONResponse(&opRestore, new(types.Space), http.StatusOK) + _ = reflector.SetJSONResponse(&opRestore, new(usererror.Error), http.StatusBadRequest) + _ = reflector.SetJSONResponse(&opRestore, new(usererror.Error), http.StatusInternalServerError) + _ = reflector.SetJSONResponse(&opRestore, new(usererror.Error), http.StatusUnauthorized) + _ = reflector.SetJSONResponse(&opRestore, new(usererror.Error), http.StatusForbidden) + _ = reflector.SetJSONResponse(&opRestore, new(usererror.Error), http.StatusNotFound) + _ = reflector.Spec.AddOperation(http.MethodPost, "/spaces/{space_ref}/restore", opRestore) + opMove := openapi3.Operation{} opMove.WithTags("space") opMove.WithMapOfAnything(map[string]interface{}{"operationId": "moveSpace"}) diff --git a/app/api/request/common.go b/app/api/request/common.go index 06fb3dc53..eaedcc21b 100644 --- a/app/api/request/common.go +++ b/app/api/request/common.go @@ -29,6 +29,7 @@ const ( QueryParamSort = "sort" QueryParamOrder = "order" QueryParamQuery = "query" + QueryParamRecursive = "recursive" QueryParamState = "state" QueryParamKind = "kind" @@ -37,7 +38,8 @@ const ( QueryParamAfter = "after" QueryParamBefore = "before" - QueryParamDeletedAt = "deleted_at" + QueryParamDeletedBeforeOrAt = "deleted_before_or_at" + QueryParamDeletedAt = "deleted_at" QueryParamPage = "page" QueryParamLimit = "limit" @@ -121,7 +123,25 @@ func GetContentEncodingFromHeadersOrDefault(r *http.Request, dflt string) string return GetHeaderOrDefault(r, HeaderContentEncoding, dflt) } -// GetDeletedAtFromQuery extracts the resource deleted timestamp from the query. -func GetDeletedAtFromQuery(r *http.Request) (int64, error) { +// ParseRecursiveFromQuery extracts the recursive option from the URL query. +func ParseRecursiveFromQuery(r *http.Request) (bool, error) { + return QueryParamAsBoolOrDefault(r, QueryParamRecursive, false) +} + +// GetDeletedAtFromQueryOrError gets the exact resource deletion timestamp from the query. +func GetDeletedAtFromQueryOrError(r *http.Request) (int64, error) { return QueryParamAsPositiveInt64(r, QueryParamDeletedAt) } + +// GetDeletedBeforeOrAtFromQuery gets the resource deletion timestamp from the query as an optional parameter. +func GetDeletedBeforeOrAtFromQuery(r *http.Request) (int64, bool, error) { + value, err := QueryParamAsPositiveInt64OrDefault(r, QueryParamDeletedBeforeOrAt, 0) + if err != nil { + return 0, false, err + } + if value == 0 { + return value, false, nil + } + + return value, true, nil +} diff --git a/app/api/request/repo.go b/app/api/request/repo.go index 1c8951127..73a233961 100644 --- a/app/api/request/repo.go +++ b/app/api/request/repo.go @@ -23,9 +23,8 @@ import ( ) const ( - PathParamRepoRef = "repo_ref" - QueryParamRepoID = "repo_id" - QueryParamRecursive = "recursive" + PathParamRepoRef = "repo_ref" + QueryParamRepoID = "repo_id" ) func GetRepoRefFromPath(r *http.Request) (string, error) { @@ -46,17 +45,30 @@ func ParseSortRepo(r *http.Request) enum.RepoAttr { } // ParseRepoFilter extracts the repository filter from the url. -func ParseRepoFilter(r *http.Request) *types.RepoFilter { - return &types.RepoFilter{ - Query: ParseQuery(r), - Order: ParseOrder(r), - Page: ParsePage(r), - Sort: ParseSortRepo(r), - Size: ParseLimit(r), +func ParseRepoFilter(r *http.Request) (*types.RepoFilter, error) { + // recursive is optional to get all repos in a sapce and its subsapces recursively. + recursive, err := ParseRecursiveFromQuery(r) + if err != nil { + return nil, err } -} -// ParseRecursiveFromQuery extracts the recursive option from the URL query. -func ParseRecursiveFromQuery(r *http.Request) (bool, error) { - return QueryParamAsBoolOrDefault(r, QueryParamRecursive, false) + // deletedBeforeOrAt is optional to retrieve repos deleted before or at the specified timestamp. + var deletionTime *int64 + value, ok, err := GetDeletedBeforeOrAtFromQuery(r) + if err != nil { + return nil, err + } + if ok { + deletionTime = &value + } + + return &types.RepoFilter{ + Query: ParseQuery(r), + Order: ParseOrder(r), + Page: ParsePage(r), + Sort: ParseSortRepo(r), + Size: ParseLimit(r), + Recursive: recursive, + DeletedBeforeOrAt: deletionTime, + }, nil } diff --git a/app/api/request/space.go b/app/api/request/space.go index 2f3593f40..f4cdd7a7a 100644 --- a/app/api/request/space.go +++ b/app/api/request/space.go @@ -44,12 +44,30 @@ func ParseSortSpace(r *http.Request) enum.SpaceAttr { } // ParseSpaceFilter extracts the space filter from the url. -func ParseSpaceFilter(r *http.Request) *types.SpaceFilter { - return &types.SpaceFilter{ - Query: ParseQuery(r), - Order: ParseOrder(r), - Page: ParsePage(r), - Sort: ParseSortSpace(r), - Size: ParseLimit(r), +func ParseSpaceFilter(r *http.Request) (*types.SpaceFilter, error) { + // recursive is optional to get sapce and its subsapces recursively. + recursive, err := ParseRecursiveFromQuery(r) + if err != nil { + return nil, err } + + // deletedBeforeOrAt is optional to retrieve spaces deleted before or at the specified timestamp. + var deletionTime *int64 + value, ok, err := GetDeletedBeforeOrAtFromQuery(r) + if err != nil { + return nil, err + } + if ok { + deletionTime = &value + } + + return &types.SpaceFilter{ + Query: ParseQuery(r), + Order: ParseOrder(r), + Page: ParsePage(r), + Sort: ParseSortSpace(r), + Size: ParseLimit(r), + Recursive: recursive, + DeletedBeforeOrAt: deletionTime, + }, nil } diff --git a/app/auth/authz/membership.go b/app/auth/authz/membership.go index d98e16c48..2bfb6e918 100644 --- a/app/auth/authz/membership.go +++ b/app/auth/authz/membership.go @@ -81,7 +81,7 @@ func (a *MembershipAuthorizer) Check( //nolint:exhaustive // we want to fail on anything else switch resource.Type { case enum.ResourceTypeSpace: - spacePath = paths.Concatinate(scope.SpacePath, resource.Identifier) + spacePath = paths.Concatenate(scope.SpacePath, resource.Identifier) case enum.ResourceTypeRepo: spacePath = scope.SpacePath diff --git a/app/auth/authz/membership_cache.go b/app/auth/authz/membership_cache.go index 40eef3fba..b702aec93 100644 --- a/app/auth/authz/membership_cache.go +++ b/app/auth/authz/membership_cache.go @@ -57,10 +57,14 @@ func (g permissionCacheGetter) Find(ctx context.Context, key PermissionCacheKey) spaceRef := key.SpaceRef principalID := key.PrincipalID - // Find the starting space. - space, err := g.spaceStore.FindByRef(ctx, spaceRef) + // Find the first existing space. + space, err := g.findFirstExistingSpace(ctx, spaceRef) + // authz fails if no active space is found on the path; admins can still operate on deleted top-level spaces. + if errors.Is(err, gitness_store.ErrResourceNotFound) { + return false, nil + } if err != nil { - return false, fmt.Errorf("failed to find space '%s': %w", spaceRef, err) + return false, fmt.Errorf("failed to find an existing space on path '%s': %w", spaceRef, err) } // limit the depth to be safe (e.g. root/space1/space2 => maxDepth of 3) @@ -102,3 +106,27 @@ func roleHasPermission(role enum.MembershipRole, permission enum.Permission) boo _, hasRole := slices.BinarySearch(role.Permissions(), permission) return hasRole } + +// findFirstExistingSpace returns the initial or first existing ancestor space (permissions are inherited). +func (g permissionCacheGetter) findFirstExistingSpace(ctx context.Context, spaceRef string) (*types.Space, error) { + for { + space, err := g.spaceStore.FindByRef(ctx, spaceRef) + if err == nil { + return space, nil + } + + if !errors.Is(err, gitness_store.ErrResourceNotFound) { + return nil, fmt.Errorf("failed to find space '%s': %w", spaceRef, err) + } + + // check whether parent space exists as permissions are inherited. + spaceRef, _, err = paths.DisectLeaf(spaceRef) + if err != nil { + return nil, fmt.Errorf("failed to disect path '%s': %w", spaceRef, err) + } + + if spaceRef == "" { + return nil, gitness_store.ErrResourceNotFound + } + } +} diff --git a/app/paths/paths.go b/app/paths/paths.go index 9bb3071c9..d037ef71f 100644 --- a/app/paths/paths.go +++ b/app/paths/paths.go @@ -60,12 +60,12 @@ func DisectRoot(path string) (string, string, error) { } /* - * Concatinate two paths together (takes care of leading / trailing '/') + * Concatenate two paths together (takes care of leading / trailing '/') * e.g. (space1/, /space2/) -> space1/space2 * * NOTE: "//" is not a valid path, so all '/' will be trimmed. */ -func Concatinate(path1 string, path2 string) string { +func Concatenate(path1 string, path2 string) string { path1 = strings.Trim(path1, types.PathSeparator) path2 = strings.Trim(path2, types.PathSeparator) diff --git a/app/router/api.go b/app/router/api.go index 711bfbec0..7f94818dc 100644 --- a/app/router/api.go +++ b/app/router/api.go @@ -214,7 +214,9 @@ func setupSpaces(r chi.Router, appCtx context.Context, spaceCtrl *space.Controll // space operations r.Get("/", handlerspace.HandleFind(spaceCtrl)) r.Patch("/", handlerspace.HandleUpdate(spaceCtrl)) - r.Delete("/", handlerspace.HandleDelete(spaceCtrl)) + r.Delete("/", handlerspace.HandleSoftDelete(spaceCtrl)) + r.Post("/restore", handlerspace.HandleRestore(spaceCtrl)) + r.Post("/purge", handlerspace.HandlePurge(spaceCtrl)) r.Get("/events", handlerspace.HandleEvents(appCtx, spaceCtrl)) diff --git a/app/store/cache/path.go b/app/store/cache/path.go index 0a6f4cf29..cdff0fd2b 100644 --- a/app/store/cache/path.go +++ b/app/store/cache/path.go @@ -64,7 +64,7 @@ func (c *pathCache) Get(ctx context.Context, key string) (*types.SpacePath, erro segments := paths.Segments(key) uniqueKey := "" for i, segment := range segments { - uniqueKey = paths.Concatinate(uniqueKey, c.spacePathTransformation(segment, i == 0)) + uniqueKey = paths.Concatenate(uniqueKey, c.spacePathTransformation(segment, i == 0)) } return c.inner.Get(ctx, uniqueKey) diff --git a/app/store/database.go b/app/store/database.go index 2db3f0a26..72253222d 100644 --- a/app/store/database.go +++ b/app/store/database.go @@ -146,6 +146,9 @@ type ( // DeletePrimarySegment deletes the primary segment of a space. DeletePrimarySegment(ctx context.Context, spaceID int64) error + + // DeletePathsAndDescendandPaths deletes all space paths reachable from spaceID including itself. + DeletePathsAndDescendandPaths(ctx context.Context, spaceID int64) error } // SpaceStore defines the space data storage. @@ -156,6 +159,9 @@ type ( // FindByRef finds the space using the spaceRef as either the id or the space path. FindByRef(ctx context.Context, spaceRef string) (*types.Space, error) + // FindByRefAndDeletedAt finds the space using the spaceRef and deleted timestamp. + FindByRefAndDeletedAt(ctx context.Context, spaceRef string, deletedAt int64) (*types.Space, error) + // GetRootSpace returns a space where space_parent_id is NULL. GetRootSpace(ctx context.Context, spaceID int64) (*types.Space, error) @@ -169,8 +175,15 @@ type ( UpdateOptLock(ctx context.Context, space *types.Space, mutateFn func(space *types.Space) error) (*types.Space, error) - // Delete deletes the space. - Delete(ctx context.Context, id int64) error + // SoftDelete deletes the space. + SoftDelete(ctx context.Context, space *types.Space, deletedAt int64) error + + // Purge deletes a space permanently. + Purge(ctx context.Context, id int64, deletedAt *int64) error + + // Restore restores a soft deleted space. + Restore(ctx context.Context, space *types.Space, + newIdentifier *string, newParentID *int64) (*types.Space, error) // Count the child spaces of a space. Count(ctx context.Context, id int64, opts *types.SpaceFilter) (int64, error) @@ -214,7 +227,7 @@ type ( // Restore a deleted repo using the optimistic locking mechanism. Restore(ctx context.Context, repo *types.Repository, - newIdentifier string) (*types.Repository, error) + newIdentifier *string, newParentID *int64) (*types.Repository, error) // Count of active repos in a space. With "DeletedBeforeOrAt" filter, counts deleted repos. Count(ctx context.Context, parentID int64, opts *types.RepoFilter) (int64, error) diff --git a/app/store/database/membership.go b/app/store/database/membership.go index daf398c32..7a4c538aa 100644 --- a/app/store/database/membership.go +++ b/app/store/database/membership.go @@ -37,11 +37,13 @@ func NewMembershipStore( db *sqlx.DB, pCache store.PrincipalInfoCache, spacePathStore store.SpacePathStore, + spaceStore store.SpaceStore, ) *MembershipStore { return &MembershipStore{ db: db, pCache: pCache, spacePathStore: spacePathStore, + spaceStore: spaceStore, } } @@ -50,6 +52,7 @@ type MembershipStore struct { db *sqlx.DB pCache store.PrincipalInfoCache spacePathStore store.SpacePathStore + spaceStore store.SpaceStore } type membership struct { @@ -472,7 +475,7 @@ func (s *MembershipStore) mapToMembershipSpaces(ctx context.Context, for i := range ms { m := ms[i] res[i].Membership = mapToMembership(&m.membership) - space, err := mapToSpace(ctx, s.spacePathStore, &m.space) + space, err := mapToSpace(ctx, s.db, s.spacePathStore, &m.space) if err != nil { return nil, fmt.Errorf("faild to map space %d: %w", m.space.ID, err) } diff --git a/app/store/database/migrate/migrate_0039_alter_table_webhooks_uid.go b/app/store/database/migrate/migrate_0039_alter_table_webhooks_uid.go index 7ea1b32e4..51fd2bcf0 100644 --- a/app/store/database/migrate/migrate_0039_alter_table_webhooks_uid.go +++ b/app/store/database/migrate/migrate_0039_alter_table_webhooks_uid.go @@ -105,7 +105,7 @@ func migrateAfter_0039_alter_table_webhooks_uid(ctx context.Context, dbtx *sql.T for i := 0; i < n; i++ { wh := buffer[i] - // concatinate repoID + spaceID to get unique parent id (only used to identify same parents) + // concatenate repoID + spaceID to get unique parent id (only used to identify same parents) newParentID := fmt.Sprintf("%d_%d", wh.repoID.ValueOrZero(), wh.spaceID.ValueOrZero()) if newParentID != parentID { // new parent? reset child identifiers diff --git a/app/store/database/migrate/postgres/0046_alter_table_spaces_add_deletetimestamp.down.sql b/app/store/database/migrate/postgres/0046_alter_table_spaces_add_deletetimestamp.down.sql new file mode 100644 index 000000000..1dad425a7 --- /dev/null +++ b/app/store/database/migrate/postgres/0046_alter_table_spaces_add_deletetimestamp.down.sql @@ -0,0 +1,7 @@ +ALTER TABLE spaces DROP COLUMN space_deleted; + +DROP INDEX spaces_parent_id; +DROP INDEX spaces_deleted; + +CREATE INDEX spaces_parent_id +ON spaces(space_parent_id); \ No newline at end of file diff --git a/app/store/database/migrate/postgres/0046_alter_table_spaces_add_deletetimestamp.up.sql b/app/store/database/migrate/postgres/0046_alter_table_spaces_add_deletetimestamp.up.sql new file mode 100644 index 000000000..26ce664c9 --- /dev/null +++ b/app/store/database/migrate/postgres/0046_alter_table_spaces_add_deletetimestamp.up.sql @@ -0,0 +1,11 @@ +ALTER TABLE spaces ADD COLUMN space_deleted BIGINT DEFAULT NULL; + +DROP INDEX spaces_parent_id; + +CREATE INDEX spaces_parent_id + ON spaces(space_parent_id) + WHERE space_deleted IS NULL; + +CREATE INDEX spaces_deleted_parent_id + ON spaces(space_deleted, space_parent_id) + WHERE space_deleted IS NOT NULL; \ No newline at end of file diff --git a/app/store/database/migrate/sqlite/0046_alter_table_spaces_add_deletetimestamp.down.sql b/app/store/database/migrate/sqlite/0046_alter_table_spaces_add_deletetimestamp.down.sql new file mode 100644 index 000000000..1dad425a7 --- /dev/null +++ b/app/store/database/migrate/sqlite/0046_alter_table_spaces_add_deletetimestamp.down.sql @@ -0,0 +1,7 @@ +ALTER TABLE spaces DROP COLUMN space_deleted; + +DROP INDEX spaces_parent_id; +DROP INDEX spaces_deleted; + +CREATE INDEX spaces_parent_id +ON spaces(space_parent_id); \ No newline at end of file diff --git a/app/store/database/migrate/sqlite/0046_alter_table_spaces_add_deletetimestamp.up.sql b/app/store/database/migrate/sqlite/0046_alter_table_spaces_add_deletetimestamp.up.sql new file mode 100644 index 000000000..26ce664c9 --- /dev/null +++ b/app/store/database/migrate/sqlite/0046_alter_table_spaces_add_deletetimestamp.up.sql @@ -0,0 +1,11 @@ +ALTER TABLE spaces ADD COLUMN space_deleted BIGINT DEFAULT NULL; + +DROP INDEX spaces_parent_id; + +CREATE INDEX spaces_parent_id + ON spaces(space_parent_id) + WHERE space_deleted IS NULL; + +CREATE INDEX spaces_deleted_parent_id + ON spaces(space_deleted, space_parent_id) + WHERE space_deleted IS NOT NULL; \ No newline at end of file diff --git a/app/store/database/repo.go b/app/store/database/repo.go index abf1fab72..3d10c0d3f 100644 --- a/app/store/database/repo.go +++ b/app/store/database/repo.go @@ -42,11 +42,13 @@ func NewRepoStore( db *sqlx.DB, spacePathCache store.SpacePathCache, spacePathStore store.SpacePathStore, + spaceStore store.SpaceStore, ) *RepoStore { return &RepoStore{ db: db, spacePathCache: spacePathCache, spacePathStore: spacePathStore, + spaceStore: spaceStore, } } @@ -55,6 +57,7 @@ type RepoStore struct { db *sqlx.DB spacePathCache store.SpacePathCache spacePathStore store.SpacePathStore + spaceStore store.SpaceStore } type repository struct { @@ -492,12 +495,16 @@ func (s *RepoStore) Purge(ctx context.Context, id int64, deletedAt *int64) error func (s *RepoStore) Restore( ctx context.Context, repo *types.Repository, - newIdentifier string, + newIdentifier *string, + newParentID *int64, ) (*types.Repository, error) { repo, err := s.updateDeletedOptLock(ctx, repo, func(r *types.Repository) error { r.Deleted = nil - if newIdentifier != "" { - r.Identifier = newIdentifier + if newIdentifier != nil { + r.Identifier = *newIdentifier + } + if newParentID != nil { + r.ParentID = *newParentID } return nil }) @@ -752,10 +759,14 @@ func (s *RepoStore) mapToRepo( func (s *RepoStore) getRepoPath(ctx context.Context, parentID int64, repoIdentifier string) (string, error) { spacePath, err := s.spacePathStore.FindPrimaryBySpaceID(ctx, parentID) + // try to re-create the space path if was soft deleted. + if errors.Is(err, gitness_store.ErrResourceNotFound) { + return getPathForDeletedSpace(ctx, s.db, parentID) + } if err != nil { return "", fmt.Errorf("failed to get primary path for space %d: %w", parentID, err) } - return paths.Concatinate(spacePath.Value, repoIdentifier), nil + return paths.Concatenate(spacePath.Value, repoIdentifier), nil } func (s *RepoStore) mapToRepos( diff --git a/app/store/database/setup_test.go b/app/store/database/setup_test.go index 469e4e365..f9b450649 100644 --- a/app/store/database/setup_test.go +++ b/app/store/database/setup_test.go @@ -78,7 +78,7 @@ func setupStores(t *testing.T, db *sqlx.DB) ( spacePathCache := cache.New(spacePathStore, spacePathTransformation) spaceStore := database.NewSpaceStore(db, spacePathCache, spacePathStore) - repoStore := database.NewRepoStore(db, spacePathCache, spacePathStore) + repoStore := database.NewRepoStore(db, spacePathCache, spacePathStore, spaceStore) return principalStore, spaceStore, spacePathStore, repoStore } diff --git a/app/store/database/space.go b/app/store/database/space.go index 3a71d5c82..cf540e919 100644 --- a/app/store/database/space.go +++ b/app/store/database/space.go @@ -21,6 +21,7 @@ import ( "strings" "time" + "github.com/harness/gitness/app/paths" "github.com/harness/gitness/app/store" gitness_store "github.com/harness/gitness/store" "github.com/harness/gitness/store/database" @@ -28,6 +29,7 @@ import ( "github.com/harness/gitness/types" "github.com/harness/gitness/types/enum" + "github.com/Masterminds/squirrel" "github.com/guregu/null" "github.com/jmoiron/sqlx" "github.com/pkg/errors" @@ -67,6 +69,7 @@ type space struct { CreatedBy int64 `db:"space_created_by"` Created int64 `db:"space_created"` Updated int64 `db:"space_updated"` + Deleted null.Int `db:"space_deleted"` } const ( @@ -79,7 +82,8 @@ const ( ,space_is_public ,space_created_by ,space_created - ,space_updated` + ,space_updated + ,space_deleted` spaceSelectBase = ` SELECT` + spaceColumns + ` @@ -88,21 +92,57 @@ const ( // Find the space by id. func (s *SpaceStore) Find(ctx context.Context, id int64) (*types.Space, error) { - const sqlQuery = spaceSelectBase + ` - WHERE space_id = $1` + return s.find(ctx, id, nil) +} + +func (s *SpaceStore) find(ctx context.Context, id int64, deletedAt *int64) (*types.Space, error) { + stmt := database.Builder. + Select(spaceColumns). + From("spaces"). + Where("space_id = ?", id) + + if deletedAt != nil { + stmt = stmt.Where("space_deleted = ?", *deletedAt) + } else { + stmt = stmt.Where("space_deleted IS NULL") + } db := dbtx.GetAccessor(ctx, s.db) dst := new(space) - if err := db.GetContext(ctx, dst, sqlQuery, id); err != nil { + sql, args, err := stmt.ToSql() + if err != nil { + return nil, errors.Wrap(err, "Failed to convert query to sql") + } + + if err = db.GetContext(ctx, dst, sql, args...); err != nil { return nil, database.ProcessSQLErrorf(ctx, err, "Failed to find space") } - return mapToSpace(ctx, s.spacePathStore, dst) + return mapToSpace(ctx, s.db, s.spacePathStore, dst) } // FindByRef finds the space using the spaceRef as either the id or the space path. func (s *SpaceStore) FindByRef(ctx context.Context, spaceRef string) (*types.Space, error) { + return s.findByRef(ctx, spaceRef, nil) +} + +// FindByRefAndDeletedAt finds the space using the spaceRef as either the id or the space path and deleted timestamp. +func (s *SpaceStore) FindByRefAndDeletedAt( + ctx context.Context, + spaceRef string, + deletedAt int64, +) (*types.Space, error) { + // ASSUMPTION: digits only is not a valid space path + id, err := strconv.ParseInt(spaceRef, 10, 64) + if err != nil { + return s.findByPathAndDeletedAt(ctx, spaceRef, deletedAt) + } + + return s.find(ctx, id, &deletedAt) +} + +func (s *SpaceStore) findByRef(ctx context.Context, spaceRef string, deletedAt *int64) (*types.Space, error) { // ASSUMPTION: digits only is not a valid space path id, err := strconv.ParseInt(spaceRef, 10, 64) if err != nil { @@ -114,8 +154,44 @@ func (s *SpaceStore) FindByRef(ctx context.Context, spaceRef string) (*types.Spa id = path.SpaceID } + return s.find(ctx, id, deletedAt) +} - return s.Find(ctx, id) +func (s *SpaceStore) findByPathAndDeletedAt( + ctx context.Context, + spaceRef string, + deletedAt int64, +) (*types.Space, error) { + segments := paths.Segments(spaceRef) + if len(segments) < 1 { + return nil, fmt.Errorf("invalid space reference provided") + } + + var stmt squirrel.SelectBuilder + switch { + case len(segments) == 1: + stmt = database.Builder. + Select("space_id"). + From("spaces"). + Where("space_uid = ? AND space_deleted = ? AND space_parent_id IS NULL", segments[0], deletedAt) + + case len(segments) > 1: + stmt = buildRecursiveSelectQueryUsingPath(segments, deletedAt) + } + + sql, args, err := stmt.ToSql() + if err != nil { + return nil, errors.Wrap(err, "Failed to create sql query") + } + + db := dbtx.GetAccessor(ctx, s.db) + + var spaceID int64 + if err = db.GetContext(ctx, &spaceID, sql, args...); err != nil { + return nil, database.ProcessSQLErrorf(ctx, err, "Failed executing custom select query") + } + + return s.find(ctx, spaceID, &deletedAt) } // GetRootSpace returns a space where space_parent_id is NULL. @@ -161,6 +237,7 @@ func (s *SpaceStore) Create(ctx context.Context, space *types.Space) error { ,space_created_by ,space_created ,space_updated + ,space_deleted ) values ( :space_version ,:space_parent_id @@ -170,6 +247,7 @@ func (s *SpaceStore) Create(ctx context.Context, space *types.Space) error { ,:space_created_by ,:space_created ,:space_updated + ,:space_deleted ) RETURNING space_id` db := dbtx.GetAccessor(ctx, s.db) @@ -201,6 +279,7 @@ func (s *SpaceStore) Update(ctx context.Context, space *types.Space) error { ,space_uid = :space_uid ,space_description = :space_description ,space_is_public = :space_is_public + ,space_deleted = :space_deleted WHERE space_id = :space_id AND space_version = :space_version - 1` dbSpace := mapToInternalSpace(space) @@ -234,7 +313,7 @@ func (s *SpaceStore) Update(ctx context.Context, space *types.Space) error { space.Updated = dbSpace.Updated // update path in case parent/identifier changed - space.Path, err = getSpacePath(ctx, s.spacePathStore, space.ID) + space.Path, err = getSpacePath(ctx, s.db, s.spacePathStore, space.ID) if err != nil { return err } @@ -242,8 +321,9 @@ func (s *SpaceStore) Update(ctx context.Context, space *types.Space) error { return nil } -// UpdateOptLock updates the space using the optimistic locking mechanism. -func (s *SpaceStore) UpdateOptLock(ctx context.Context, +// updateOptLock updates the space using the optimistic locking mechanism. +func (s *SpaceStore) updateOptLock( + ctx context.Context, space *types.Space, mutateFn func(space *types.Space) error, ) (*types.Space, error) { @@ -263,30 +343,129 @@ func (s *SpaceStore) UpdateOptLock(ctx context.Context, return nil, err } - space, err = s.Find(ctx, space.ID) + space, err = s.find(ctx, space.ID, space.Deleted) if err != nil { return nil, err } } } -// Delete deletes a space. -func (s *SpaceStore) Delete(ctx context.Context, id int64) error { - const sqlQuery = ` - DELETE FROM spaces - WHERE space_id = $1` +// UpdateOptLock updates the space using the optimistic locking mechanism. +func (s *SpaceStore) UpdateOptLock( + ctx context.Context, + space *types.Space, + mutateFn func(space *types.Space) error, +) (*types.Space, error) { + return s.updateOptLock( + ctx, + space, + func(r *types.Space) error { + if space.Deleted != nil { + return gitness_store.ErrResourceNotFound + } + return mutateFn(r) + }, + ) +} + +// UpdateDeletedOptLock updates a soft deleted space using the optimistic locking mechanism. +func (s *SpaceStore) updateDeletedOptLock( + ctx context.Context, + space *types.Space, + mutateFn func(space *types.Space) error, +) (*types.Space, error) { + return s.updateOptLock( + ctx, + space, + func(r *types.Space) error { + if space.Deleted == nil { + return gitness_store.ErrResourceNotFound + } + return mutateFn(r) + }, + ) +} + +// SoftDelete deletes a space softly. +func (s *SpaceStore) SoftDelete( + ctx context.Context, + space *types.Space, + deletedAt int64, +) error { + _, err := s.UpdateOptLock(ctx, space, func(s *types.Space) error { + s.Deleted = &deletedAt + return nil + }) + if err != nil { + return err + } + return nil +} + +// Purge deletes a space permanently. +func (s *SpaceStore) Purge(ctx context.Context, id int64, deletedAt *int64) error { + stmt := database.Builder. + Delete("spaces"). + Where("space_id = ?", id) + + if deletedAt != nil { + stmt = stmt.Where("space_deleted = ?", *deletedAt) + } else { + stmt = stmt.Where("space_deleted IS NULL") + } + + sql, args, err := stmt.ToSql() + if err != nil { + return fmt.Errorf("failed to convert purge space query to sql: %w", err) + } db := dbtx.GetAccessor(ctx, s.db) - if _, err := db.ExecContext(ctx, sqlQuery, id); err != nil { - return database.ProcessSQLErrorf(ctx, err, "The delete query failed") + _, err = db.ExecContext(ctx, sql, args...) + if err != nil { + return database.ProcessSQLErrorf(ctx, err, "the delete query failed") } return nil } +// Restore restores a soft deleted space. +func (s *SpaceStore) Restore( + ctx context.Context, + space *types.Space, + newIdentifier *string, + newParentID *int64, +) (*types.Space, error) { + space, err := s.updateDeletedOptLock(ctx, space, func(s *types.Space) error { + s.Deleted = nil + if newParentID != nil { + s.ParentID = *newParentID + } + + if newIdentifier != nil { + s.Identifier = *newIdentifier + } + return nil + }) + if err != nil { + return nil, err + } + return space, nil +} + // Count the child spaces of a space. func (s *SpaceStore) Count(ctx context.Context, id int64, opts *types.SpaceFilter) (int64, error) { + if opts.Recursive { + return s.countAll(ctx, id, opts) + } + return s.count(ctx, id, opts) +} + +func (s *SpaceStore) count( + ctx context.Context, + id int64, + opts *types.SpaceFilter, +) (int64, error) { stmt := database.Builder. Select("count(*)"). From("spaces"). @@ -296,6 +475,8 @@ func (s *SpaceStore) Count(ctx context.Context, id int64, opts *types.SpaceFilte stmt = stmt.Where("LOWER(space_uid) LIKE ?", fmt.Sprintf("%%%s%%", strings.ToLower(opts.Query))) } + stmt = s.applyQueryFilter(stmt, opts) + sql, args, err := stmt.ToSql() if err != nil { return 0, errors.Wrap(err, "Failed to convert query to sql") @@ -312,33 +493,70 @@ func (s *SpaceStore) Count(ctx context.Context, id int64, opts *types.SpaceFilte return count, nil } +func (s *SpaceStore) countAll( + ctx context.Context, + id int64, + opts *types.SpaceFilter, +) (int64, error) { + ctePrefix := `WITH RECURSIVE SpaceHierarchy AS ( + SELECT space_id, space_parent_id, space_deleted, space_uid + FROM spaces + WHERE space_id = ? + + UNION + + SELECT s.space_id, s.space_parent_id, s.space_deleted, s.space_uid + FROM spaces s + JOIN SpaceHierarchy h ON s.space_parent_id = h.space_id + )` + + db := dbtx.GetAccessor(ctx, s.db) + + stmt := database.Builder. + Select("COUNT(*)"). + Prefix(ctePrefix, id). + From("SpaceHierarchy h1"). + Where("h1.space_id <> ?", id) + + stmt = s.applyQueryFilter(stmt, opts) + + sql, args, err := stmt.ToSql() + if err != nil { + return 0, errors.Wrap(err, "Failed to convert query to sql") + } + + var count int64 + if err = db.GetContext(ctx, &count, sql, args...); err != nil { + return 0, database.ProcessSQLErrorf(ctx, err, "failed to count sub spaces") + } + + return count, nil +} + // List returns a list of spaces under the parent space. -func (s *SpaceStore) List(ctx context.Context, id int64, opts *types.SpaceFilter) ([]*types.Space, error) { +func (s *SpaceStore) List( + ctx context.Context, + id int64, + opts *types.SpaceFilter, +) ([]*types.Space, error) { + if opts.Recursive { + return s.listAll(ctx, id, opts) + } + return s.list(ctx, id, opts) +} + +func (s *SpaceStore) list( + ctx context.Context, + id int64, + opts *types.SpaceFilter, +) ([]*types.Space, error) { stmt := database.Builder. Select(spaceColumns). From("spaces"). Where("space_parent_id = ?", fmt.Sprint(id)) - stmt = stmt.Limit(database.Limit(opts.Size)) - stmt = stmt.Offset(database.Offset(opts.Page, opts.Size)) - - if opts.Query != "" { - stmt = stmt.Where("LOWER(space_uid) LIKE ?", fmt.Sprintf("%%%s%%", strings.ToLower(opts.Query))) - } - - switch opts.Sort { - case enum.SpaceAttrUID, enum.SpaceAttrIdentifier, enum.SpaceAttrNone: - // NOTE: string concatenation is safe because the - // order attribute is an enum and is not user-defined, - // and is therefore not subject to injection attacks. - stmt = stmt.OrderBy("space_uid " + opts.Order.String()) - //TODO: Postgres does not support COLLATE NOCASE for UTF8 - // stmt = stmt.OrderBy("space_uid COLLATE NOCASE " + opts.Order.String()) - case enum.SpaceAttrCreated: - stmt = stmt.OrderBy("space_created " + opts.Order.String()) - case enum.SpaceAttrUpdated: - stmt = stmt.OrderBy("space_updated " + opts.Order.String()) - } + stmt = s.applyQueryFilter(stmt, opts) + stmt = s.applySortFilter(stmt, opts) sql, args, err := stmt.ToSql() if err != nil { @@ -352,11 +570,121 @@ func (s *SpaceStore) List(ctx context.Context, id int64, opts *types.SpaceFilter return nil, database.ProcessSQLErrorf(ctx, err, "Failed executing custom list query") } - return s.mapToSpaces(ctx, dst) + return s.mapToSpaces(ctx, s.db, dst) +} + +func (s *SpaceStore) listAll(ctx context.Context, + id int64, + opts *types.SpaceFilter, +) ([]*types.Space, error) { + ctePrefix := `WITH RECURSIVE SpaceHierarchy AS ( + SELECT * + FROM spaces + WHERE space_id = ? + + UNION + + SELECT s.* + FROM spaces s + JOIN SpaceHierarchy h ON s.space_parent_id = h.space_id + )` + + db := dbtx.GetAccessor(ctx, s.db) + + stmt := database.Builder. + Select(spaceColumns). + Prefix(ctePrefix, id). + From("SpaceHierarchy h1"). + Where("h1.space_id <> ?", id) + + stmt = s.applyQueryFilter(stmt, opts) + stmt = s.applySortFilter(stmt, opts) + + sql, args, err := stmt.ToSql() + if err != nil { + return nil, errors.Wrap(err, "Failed to convert query to sql") + } + + var dst []*space + if err = db.SelectContext(ctx, &dst, sql, args...); err != nil { + return nil, database.ProcessSQLErrorf(ctx, err, "Failed executing custom list query") + } + + return s.mapToSpaces(ctx, s.db, dst) +} + +func (s *SpaceStore) applyQueryFilter( + stmt squirrel.SelectBuilder, + opts *types.SpaceFilter, +) squirrel.SelectBuilder { + if opts.Query != "" { + stmt = stmt.Where("LOWER(space_uid) LIKE ?", fmt.Sprintf("%%%s%%", strings.ToLower(opts.Query))) + } + + if opts.DeletedBeforeOrAt != nil { + stmt = stmt.Where("space_deleted <= ?", opts.DeletedBeforeOrAt) + } else { + stmt = stmt.Where("space_deleted IS NULL") + } + + return stmt +} + +func getPathForDeletedSpace( + ctx context.Context, + sqlxdb *sqlx.DB, + id int64, +) (string, error) { + sqlQuery := spaceSelectBase + ` + where space_id = $1` + + path := "" + nextSpaceID := null.IntFrom(id) + + db := dbtx.GetAccessor(ctx, sqlxdb) + dst := new(space) + + for nextSpaceID.Valid { + err := db.GetContext(ctx, dst, sqlQuery, nextSpaceID.Int64) + if err != nil { + return "", fmt.Errorf("failed to find the space %d: %w", id, err) + } + + path = paths.Concatenate(dst.Identifier, path) + nextSpaceID = dst.ParentID + } + + return path, nil +} + +func (s *SpaceStore) applySortFilter( + stmt squirrel.SelectBuilder, + opts *types.SpaceFilter, +) squirrel.SelectBuilder { + stmt = stmt.Limit(database.Limit(opts.Size)) + stmt = stmt.Offset(database.Offset(opts.Page, opts.Size)) + + switch opts.Sort { + case enum.SpaceAttrUID, enum.SpaceAttrIdentifier, enum.SpaceAttrNone: + // NOTE: string concatenation is safe because the + // order attribute is an enum and is not user-defined, + // and is therefore not subject to injection attacks. + stmt = stmt.OrderBy("space_uid " + opts.Order.String()) + //TODO: Postgres does not support COLLATE NOCASE for UTF8 + // stmt = stmt.OrderBy("space_uid COLLATE NOCASE " + opts.Order.String()) + case enum.SpaceAttrCreated: + stmt = stmt.OrderBy("space_created " + opts.Order.String()) + case enum.SpaceAttrUpdated: + stmt = stmt.OrderBy("space_updated " + opts.Order.String()) + case enum.SpaceAttrDeleted: + stmt = stmt.OrderBy("space_deleted " + opts.Order.String()) + } + return stmt } func mapToSpace( ctx context.Context, + sqlxdb *sqlx.DB, spacePathStore store.SpacePathStore, in *space, ) (*types.Space, error) { @@ -370,6 +698,7 @@ func mapToSpace( Created: in.Created, CreatedBy: in.CreatedBy, Updated: in.Updated, + Deleted: in.Deleted.Ptr(), } // Only overwrite ParentID if it's not a root space @@ -378,7 +707,7 @@ func mapToSpace( } // backfill path - res.Path, err = getSpacePath(ctx, spacePathStore, in.ID) + res.Path, err = getSpacePath(ctx, sqlxdb, spacePathStore, in.ID) if err != nil { return nil, fmt.Errorf("failed to get primary path for space %d: %w", in.ID, err) } @@ -388,10 +717,15 @@ func mapToSpace( func getSpacePath( ctx context.Context, + sqlxdb *sqlx.DB, spacePathStore store.SpacePathStore, spaceID int64, ) (string, error) { spacePath, err := spacePathStore.FindPrimaryBySpaceID(ctx, spaceID) + // delete space will delete paths; generate the path if space is soft deleted. + if errors.Is(err, gitness_store.ErrResourceNotFound) { + return getPathForDeletedSpace(ctx, sqlxdb, spaceID) + } if err != nil { return "", fmt.Errorf("failed to get primary path for space %d: %w", spaceID, err) } @@ -401,12 +735,13 @@ func getSpacePath( func (s *SpaceStore) mapToSpaces( ctx context.Context, + sqlxdb *sqlx.DB, spaces []*space, ) ([]*types.Space, error) { var err error res := make([]*types.Space, len(spaces)) for i := range spaces { - res[i], err = mapToSpace(ctx, s.spacePathStore, spaces[i]) + res[i], err = mapToSpace(ctx, sqlxdb, s.spacePathStore, spaces[i]) if err != nil { return nil, err } @@ -424,6 +759,7 @@ func mapToInternalSpace(s *types.Space) *space { Created: s.Created, CreatedBy: s.CreatedBy, Updated: s.Updated, + Deleted: null.IntFromPtr(s.Deleted), } // Only overwrite ParentID if it's not a root space @@ -434,3 +770,27 @@ func mapToInternalSpace(s *types.Space) *space { return res } + +// buildRecursiveSelectQueryUsingPath builds the recursive select query using path among active or soft deleted spaces. +func buildRecursiveSelectQueryUsingPath(segments []string, deletedAt int64) squirrel.SelectBuilder { + leaf := "s" + fmt.Sprint(len(segments)-1) + + // add the current space (leaf) + stmt := database.Builder. + Select(leaf+".space_id"). + From("spaces "+leaf). + Where(leaf+".space_uid = ? AND "+leaf+".space_deleted = ?", segments[len(segments)-1], deletedAt) + + for i := len(segments) - 2; i >= 0; i-- { + parentAlias := "s" + fmt.Sprint(i) + alias := "s" + fmt.Sprint(i+1) + + stmt = stmt.InnerJoin(fmt.Sprintf("spaces %s ON %s.space_id = %s.space_parent_id", parentAlias, parentAlias, alias)). + Where(parentAlias+".space_uid = ?", segments[i]) + } + + // add parent check for root + stmt = stmt.Where("s0.space_parent_id IS NULL") + + return stmt +} diff --git a/app/store/database/space_path.go b/app/store/database/space_path.go index 8765cee94..42d947441 100644 --- a/app/store/database/space_path.go +++ b/app/store/database/space_path.go @@ -31,7 +31,10 @@ import ( var _ store.SpacePathStore = (*SpacePathStore)(nil) // NewSpacePathStore returns a new SpacePathStore. -func NewSpacePathStore(db *sqlx.DB, pathTransformation store.SpacePathTransformation) *SpacePathStore { +func NewSpacePathStore( + db *sqlx.DB, + pathTransformation store.SpacePathTransformation, +) *SpacePathStore { return &SpacePathStore{ db: db, spacePathTransformation: pathTransformation, @@ -132,7 +135,7 @@ func (s *SpacePathStore) FindPrimaryBySpaceID(ctx context.Context, spaceID int64 return nil, database.ProcessSQLErrorf(ctx, err, "Failed to find primary segment for %d", nextSpaceID.Int64) } - path = paths.Concatinate(dst.Identifier, path) + path = paths.Concatenate(dst.Identifier, path) nextSpaceID = dst.ParentID } @@ -176,7 +179,7 @@ func (s *SpacePathStore) FindByPath(ctx context.Context, path string) (*types.Sp ) } - originalPath = paths.Concatinate(originalPath, segment.Identifier) + originalPath = paths.Concatenate(originalPath, segment.Identifier) parentID = segment.SpaceID isPrimary = isPrimary && segment.IsPrimary.ValueOrZero() } @@ -203,6 +206,31 @@ func (s *SpacePathStore) DeletePrimarySegment(ctx context.Context, spaceID int64 return nil } +// DeletePathsAndDescendandPaths deletes all space paths reachable from spaceID including itself. +func (s *SpacePathStore) DeletePathsAndDescendandPaths(ctx context.Context, spaceID int64) error { + const sqlQuery = `WITH RECURSIVE DescendantPaths AS ( + SELECT space_path_id, space_path_space_id, space_path_parent_id + FROM space_paths + WHERE space_path_space_id = $1 + + UNION + + SELECT sp.space_path_id, sp.space_path_space_id, sp.space_path_parent_id + FROM space_paths sp + JOIN DescendantPaths dp ON sp.space_path_parent_id = dp.space_path_space_id + ) + DELETE FROM space_paths + WHERE space_path_id IN (SELECT space_path_id FROM DescendantPaths);` + + db := dbtx.GetAccessor(ctx, s.db) + + if _, err := db.ExecContext(ctx, sqlQuery, spaceID); err != nil { + return database.ProcessSQLErrorf(ctx, err, "the delete query failed") + } + + return nil +} + func (s *SpacePathStore) mapToInternalSpacePathSegment(p *types.SpacePathSegment) *spacePathSegment { res := &spacePathSegment{ ID: p.ID, diff --git a/app/store/database/wire.go b/app/store/database/wire.go index cf5c3d1b2..31a2baba8 100644 --- a/app/store/database/wire.go +++ b/app/store/database/wire.go @@ -107,8 +107,9 @@ func ProvideRepoStore( db *sqlx.DB, spacePathCache store.SpacePathCache, spacePathStore store.SpacePathStore, + spaceStore store.SpaceStore, ) store.RepoStore { - return NewRepoStore(db, spacePathCache, spacePathStore) + return NewRepoStore(db, spacePathCache, spacePathStore, spaceStore) } // ProvideRuleStore provides a rule store. @@ -178,8 +179,9 @@ func ProvideMembershipStore( db *sqlx.DB, principalInfoCache store.PrincipalInfoCache, spacePathStore store.SpacePathStore, + spaceStore store.SpaceStore, ) store.MembershipStore { - return NewMembershipStore(db, principalInfoCache, spacePathStore) + return NewMembershipStore(db, principalInfoCache, spacePathStore, spaceStore) } // ProvideTokenStore provides a token store. diff --git a/cmd/gitness/wire_gen.go b/cmd/gitness/wire_gen.go index 8eb42c95e..02a827171 100644 --- a/cmd/gitness/wire_gen.go +++ b/cmd/gitness/wire_gen.go @@ -108,7 +108,7 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro spaceStore := database.ProvideSpaceStore(db, spacePathCache, spacePathStore) principalInfoView := database.ProvidePrincipalInfoView(db) principalInfoCache := cache.ProvidePrincipalInfoCache(principalInfoView) - membershipStore := database.ProvideMembershipStore(db, principalInfoCache, spacePathStore) + membershipStore := database.ProvideMembershipStore(db, principalInfoCache, spacePathStore, spaceStore) permissionCache := authz.ProvidePermissionCache(spaceStore, membershipStore) authorizer := authz.ProvideAuthorizer(permissionCache, spaceStore) principalUIDTransformation := store.ProvidePrincipalUIDTransformation() @@ -122,7 +122,7 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro if err != nil { return nil, err } - repoStore := database.ProvideRepoStore(db, spacePathCache, spacePathStore) + repoStore := database.ProvideRepoStore(db, spacePathCache, spacePathStore, spaceStore) pipelineStore := database.ProvidePipelineStore(db) ruleStore := database.ProvideRuleStore(db, principalInfoCache) protectionManager, err := protection.ProvideManager(ruleStore) diff --git a/git/api/commit.go b/git/api/commit.go index ef013fa92..c004669e2 100644 --- a/git/api/commit.go +++ b/git/api/commit.go @@ -426,12 +426,11 @@ func getChangeInfoTypes( return changeInfoTypes, nil } -// Will match "31\t0\t.harness/apidiff.yaml". -// Will extract 31, 0 and .harness/apidiff.yaml. -var insertionsDeletionsRegex = regexp.MustCompile(`(\d+)\t(\d+)\t(.+)`) +// Will match "31\t0\t.harness/apidiff.yaml" and extract 31, 0 and .harness/apidiff.yaml. +// Will match "-\t-\ttools/code-api/chart/charts/harness-common-1.0.27.tgz" and extract -, -, and a filename. +var insertionsDeletionsRegex = regexp.MustCompile(`(\d+|-)\t(\d+|-)\t(.+)`) -// Will match "0\t0\tREADME.md => README_new.md". -// Will extract README_new.md. +// Will match "0\t0\tREADME.md => README_new.md" and extract README_new.md. var renameRegexWithArrow = regexp.MustCompile(`\d+\t\d+\t.+\s=>\s(.+)`) func getChangeInfoChanges( @@ -457,6 +456,11 @@ func getChangeInfoChanges( path = renMatches[1] } + if matches[1] == "-" || matches[2] == "-" { + changeInfos[path] = changeInfoChange{} + continue + } + insertions, err := strconv.ParseInt(matches[1], 10, 64) if err != nil { return map[string]changeInfoChange{}, diff --git a/go.mod b/go.mod index 59f5f5739..a6ecd206e 100644 --- a/go.mod +++ b/go.mod @@ -73,7 +73,6 @@ require ( cloud.google.com/go/iam v1.1.0 // indirect dario.cat/mergo v1.0.0 // indirect github.com/99designs/httpsignatures-go v0.0.0-20170731043157-88528bf4ca7e // indirect - github.com/andybalholm/brotli v1.0.5 // indirect github.com/antonmedv/expr v1.15.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bmatcuk/doublestar v1.3.4 // indirect diff --git a/go.sum b/go.sum index d242ccacb..4c1b0f9ad 100644 --- a/go.sum +++ b/go.sum @@ -40,11 +40,7 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= -github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= -github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= -github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antonmedv/expr v1.15.2 h1:afFXpDWIC2n3bF+kTZE1JvFo+c34uaM3sTqh8z0xfdU= github.com/antonmedv/expr v1.15.2/go.mod h1:0E/6TxnOlRNp81GMzX9QfDPAmHo2Phg00y4JUv1ihsE= @@ -69,9 +65,6 @@ github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQ github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= github.com/bmatcuk/doublestar/v4 v4.6.0 h1:HTuxyug8GyFbRkrffIpzNCSK4luc0TY3wzXvzIZhEXc= github.com/bmatcuk/doublestar/v4 v4.6.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -github.com/bool64/dev v0.1.41/go.mod h1:cTHiTDNc8EewrQPy3p1obNilpMpdmlUesDkFTF2zRWU= -github.com/bool64/dev v0.1.42/go.mod h1:cTHiTDNc8EewrQPy3p1obNilpMpdmlUesDkFTF2zRWU= -github.com/bool64/dev v0.2.22 h1:YJFKBRKplkt+0Emq/5Xk1Z5QRmMNzc1UOJkR3rxJksA= github.com/bool64/dev v0.2.32 h1:DRZtloaoH1Igky3zphaUHV9+SLIV2H3lsf78JsJHFg0= github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= github.com/buildkite/yaml v2.1.0+incompatible h1:xirI+ql5GzfikVNDmt+yeiXpf/v1Gt03qXTtT5WXdr8= @@ -194,7 +187,6 @@ github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgO github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/zerologr v1.2.3 h1:up5N9vcH9Xck3jJkXzgyOxozT14R47IyDODz8LM1KSs= @@ -236,7 +228,6 @@ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= -github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -415,15 +406,12 @@ github.com/jmoiron/sqlx v1.3.3/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXL github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= @@ -499,10 +487,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/natessilva/dag v0.0.0-20180124060714-7194b8dcc5c4 h1:dnMxwus89s86tI8rcGVp2HwZzlz7c5o92VOy7dSckBQ= github.com/natessilva/dag v0.0.0-20180124060714-7194b8dcc5c4/go.mod h1:cojhOHk1gbMeklOyDP2oKKLftefXoJreOQGOrXk+Z38= github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= @@ -619,10 +605,7 @@ github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NF github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= -github.com/shurcooL/httpgzip v0.0.0-20190720172056-320755c1c1b0/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/shurcooL/vfsgen v0.0.0-20181202132449-6a9ea43bcacd/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= @@ -663,8 +646,6 @@ github.com/swaggest/openapi-go v0.2.23 h1:DYUezSTyw180z1bL51wUnalYYbTMwHBjp1Itvj github.com/swaggest/openapi-go v0.2.23/go.mod h1:T1Koc6EAFAvnCI1MUqOOPDniqGzZy6dOiHtA/j54k14= github.com/swaggest/refl v1.1.0 h1:a+9a75Kv6ciMozPjVbOfcVTEQe81t2R3emvaD9oGQGc= github.com/swaggest/refl v1.1.0/go.mod h1:g3Qa6ki0A/L2yxiuUpT+cuBURuRaltF5SDQpg1kMZSY= -github.com/swaggest/swgui v1.4.2 h1:6AT8ICO0+t6WpbIFsACf5vBmviVX0sqspNbZLoe6vgw= -github.com/swaggest/swgui v1.4.2/go.mod h1:xWDsT2h8obEoGHzX/a6FRClUOS8NvkICyInhi7s3fN8= github.com/swaggest/swgui v1.8.0 h1:dPu8TsYIOraaObAkyNdoiLI8mu7nOqQ6SU7HOv254rM= github.com/swaggest/swgui v1.8.0/go.mod h1:YBaAVAwS3ndfvdtW8A4yWDJpge+W57y+8kW+f/DqZtU= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= @@ -674,8 +655,6 @@ github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijb github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc= github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= -github.com/vearutop/statigz v1.1.5 h1:qWvRgXFsseWVTFCkIvwHQPpaLNf9WI0+dDJE7I9432o= -github.com/vearutop/statigz v1.1.5/go.mod h1:czAv7iXgPv/s+xsgXpVEhhD0NSOQ4wZPgmM/n7LANDI= github.com/vearutop/statigz v1.4.0 h1:RQL0KG3j/uyA/PFpHeZ/L6l2ta920/MxlOAIGEOuwmU= github.com/vearutop/statigz v1.4.0/go.mod h1:LYTolBLiz9oJISwiVKnOQoIwhO1LWX1A7OECawGS8XE= github.com/vinzenz/yaml v0.0.0-20170920082545-91409cdd725d/go.mod h1:mb5taDqMnJiZNRQ3+02W2IFG+oEz1+dTuCXkp4jpkfo= @@ -684,7 +663,6 @@ github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCO github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -748,7 +726,6 @@ golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKG golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= @@ -778,9 +755,7 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= -golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211105192438-b53810dc28af/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= @@ -839,7 +814,6 @@ golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -894,7 +868,6 @@ golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= diff --git a/types/enum/space.go b/types/enum/space.go index ad90f6a24..43473eff5 100644 --- a/types/enum/space.go +++ b/types/enum/space.go @@ -27,6 +27,7 @@ const ( SpaceAttrIdentifier SpaceAttrCreated SpaceAttrUpdated + SpaceAttrDeleted ) // ParseSpaceAttr parses the space attribute string @@ -42,6 +43,8 @@ func ParseSpaceAttr(s string) SpaceAttr { return SpaceAttrCreated case updated, updatedAt: return SpaceAttrUpdated + case deleted, deletedAt: + return SpaceAttrDeleted default: return SpaceAttrNone } @@ -59,6 +62,8 @@ func (a SpaceAttr) String() string { return created case SpaceAttrUpdated: return updated + case SpaceAttrDeleted: + return deleted case SpaceAttrNone: return "" default: diff --git a/types/space.go b/types/space.go index b752a8fd3..40e7ad216 100644 --- a/types/space.go +++ b/types/space.go @@ -43,6 +43,7 @@ type Space struct { CreatedBy int64 `json:"created_by"` Created int64 `json:"created"` Updated int64 `json:"updated"` + Deleted *int64 `json:"deleted,omitempty"` } // TODO [CODE-1363]: remove after identifier migration. @@ -60,9 +61,11 @@ func (s Space) MarshalJSON() ([]byte, error) { // Stores spaces query parameters. type SpaceFilter struct { - Page int `json:"page"` - Size int `json:"size"` - Query string `json:"query"` - Sort enum.SpaceAttr `json:"sort"` - Order enum.Order `json:"order"` + Page int `json:"page"` + Size int `json:"size"` + Query string `json:"query"` + Sort enum.SpaceAttr `json:"sort"` + Order enum.Order `json:"order"` + DeletedBeforeOrAt *int64 `json:"deleted_before_or_at,omitempty"` + Recursive bool `json:"recursive"` } diff --git a/web/src/components/MarkdownEditorWithPreview/MarkdownEditorWithPreview.tsx b/web/src/components/MarkdownEditorWithPreview/MarkdownEditorWithPreview.tsx index 5fa5a7d39..a20e2cfb1 100644 --- a/web/src/components/MarkdownEditorWithPreview/MarkdownEditorWithPreview.tsx +++ b/web/src/components/MarkdownEditorWithPreview/MarkdownEditorWithPreview.tsx @@ -150,6 +150,14 @@ export function MarkdownEditorWithPreview({ verb: 'POST', path: `/api/v1/repos/${repoMetadata?.path}/+/genai/change-summary` }) + const isDirty = useRef(dirty) + + useEffect( + function setDirtyRef() { + isDirty.current = dirty + }, + [dirty] + ) const myKeymap = keymap.of([ { @@ -157,7 +165,15 @@ export function MarkdownEditorWithPreview({ run: undo, preventDefault: true }, - { key: 'Mod-Shift-z', run: redo, preventDefault: true } + { key: 'Mod-Shift-z', run: redo, preventDefault: true }, + { + key: 'Mod-Enter', + run: () => { + if (isDirty.current) onSaveHandler() + return true + }, + preventDefault: true + } ]) const dispatchContent = (content: string, userEvent: boolean) => { @@ -401,6 +417,7 @@ export function MarkdownEditorWithPreview({ const handleFileChange = (event: any) => { setFile(event?.target?.files[0]) } + const onSaveHandler = useCallback(() => onSave?.(viewRef.current?.state.doc.toString() || ''), [onSave]) return ( @@ -461,8 +478,12 @@ export function MarkdownEditorWithPreview({ variation={ButtonVariation.PRIMARY} disabled={false} onClick={() => { - handleUpload(file as File, setMarkdownContent, repoMetadata, showError, standalone, routingId) - setOpen(false) + if (file !== undefined) { + handleUpload(file as File, setMarkdownContent, repoMetadata, showError, standalone, routingId) + setOpen(false) + } else { + showError(getString('uploadAFileError')) + } }} />