| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | package lifecycle |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "os" |
| 10 | |
| 11 | "github.com/tenseleyFlow/shithub/internal/auth/audit" |
| 12 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| 13 | reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" |
| 14 | ) |
| 15 | |
| 16 | // SoftDelete sets repos.deleted_at to now. The repo disappears from |
| 17 | // listings and the home page returns 404 for non-owners (auth-aware). |
| 18 | // The bare repo is moved from the canonical owner/name path into an |
| 19 | // internal tombstone path so the owner can recreate the same repo name |
| 20 | // during the grace window without colliding with the deleted repo. |
| 21 | func SoftDelete(ctx context.Context, deps Deps, actorUserID, repoID int64) error { |
| 22 | rq := reposdb.New() |
| 23 | |
| 24 | tx, err := deps.Pool.Begin(ctx) |
| 25 | if err != nil { |
| 26 | return fmt.Errorf("begin: %w", err) |
| 27 | } |
| 28 | committed := false |
| 29 | defer func() { |
| 30 | if !committed { |
| 31 | _ = tx.Rollback(ctx) |
| 32 | } |
| 33 | }() |
| 34 | |
| 35 | repo, err := rq.GetRepoByID(ctx, tx, repoID) |
| 36 | if err != nil { |
| 37 | return fmt.Errorf("load repo: %w", err) |
| 38 | } |
| 39 | if err := lockRepoName(ctx, rq, tx, repo); err != nil { |
| 40 | return err |
| 41 | } |
| 42 | if repo.DeletedAt.Valid { |
| 43 | return ErrAlreadyDeleted |
| 44 | } |
| 45 | moved, err := moveCanonicalToDeleted(ctx, deps, repo) |
| 46 | if err != nil { |
| 47 | return err |
| 48 | } |
| 49 | if err := rq.SoftDeleteRepoLifecycle(ctx, tx, repoID); err != nil { |
| 50 | if moved { |
| 51 | moveDeletedBackToCanonical(ctx, deps, repo) |
| 52 | } |
| 53 | return fmt.Errorf("soft delete: %w", err) |
| 54 | } |
| 55 | if err := tx.Commit(ctx); err != nil { |
| 56 | if moved { |
| 57 | moveDeletedBackToCanonical(ctx, deps, repo) |
| 58 | } |
| 59 | return fmt.Errorf("commit: %w", err) |
| 60 | } |
| 61 | committed = true |
| 62 | if deps.Audit != nil { |
| 63 | _ = deps.Audit.Record(ctx, deps.Pool, actorUserID, |
| 64 | audit.ActionRepoSoftDeleted, audit.TargetRepo, repoID, |
| 65 | map[string]any{"name": repo.Name, "owner_user_id": int64ValueOrZero(repo.OwnerUserID.Int64, repo.OwnerUserID.Valid)}) |
| 66 | } |
| 67 | return nil |
| 68 | } |
| 69 | |
| 70 | // Restore clears repos.deleted_at within the grace window. Past the |
| 71 | // window the row may still exist (worker hasn't run yet) but we |
| 72 | // refuse — the operator-visible contract is "7 days, then it's gone". |
| 73 | func Restore(ctx context.Context, deps Deps, actorUserID, repoID int64) error { |
| 74 | rq := reposdb.New() |
| 75 | |
| 76 | tx, err := deps.Pool.Begin(ctx) |
| 77 | if err != nil { |
| 78 | return fmt.Errorf("begin: %w", err) |
| 79 | } |
| 80 | committed := false |
| 81 | defer func() { |
| 82 | if !committed { |
| 83 | _ = tx.Rollback(ctx) |
| 84 | } |
| 85 | }() |
| 86 | |
| 87 | repo, err := rq.GetRepoByID(ctx, tx, repoID) |
| 88 | if err != nil { |
| 89 | return fmt.Errorf("load repo: %w", err) |
| 90 | } |
| 91 | if err := lockRepoName(ctx, rq, tx, repo); err != nil { |
| 92 | return err |
| 93 | } |
| 94 | if !repo.DeletedAt.Valid { |
| 95 | return ErrNotDeleted |
| 96 | } |
| 97 | if deps.now().Sub(repo.DeletedAt.Time) > softDeleteGrace { |
| 98 | return ErrPastGrace |
| 99 | } |
| 100 | taken, err := activeRepoNameExists(ctx, rq, tx, repo) |
| 101 | if err != nil { |
| 102 | return fmt.Errorf("restore name check: %w", err) |
| 103 | } |
| 104 | if taken { |
| 105 | return ErrNameTaken |
| 106 | } |
| 107 | moved, err := moveDeletedToCanonical(ctx, deps, repo) |
| 108 | if err != nil { |
| 109 | if errors.Is(err, storage.ErrAlreadyExists) { |
| 110 | return ErrNameTaken |
| 111 | } |
| 112 | return err |
| 113 | } |
| 114 | if err := rq.RestoreRepo(ctx, tx, repoID); err != nil { |
| 115 | if moved { |
| 116 | moveCanonicalBackToDeleted(ctx, deps, repo) |
| 117 | } |
| 118 | if isUniqueViolation(err) { |
| 119 | return ErrNameTaken |
| 120 | } |
| 121 | return fmt.Errorf("restore: %w", err) |
| 122 | } |
| 123 | if err := tx.Commit(ctx); err != nil { |
| 124 | if moved { |
| 125 | moveCanonicalBackToDeleted(ctx, deps, repo) |
| 126 | } |
| 127 | return fmt.Errorf("commit: %w", err) |
| 128 | } |
| 129 | committed = true |
| 130 | if deps.Audit != nil { |
| 131 | _ = deps.Audit.Record(ctx, deps.Pool, actorUserID, |
| 132 | audit.ActionRepoRestored, audit.TargetRepo, repoID, nil) |
| 133 | } |
| 134 | return nil |
| 135 | } |
| 136 | |
| 137 | func moveCanonicalToDeleted(ctx context.Context, deps Deps, repo reposdb.Repo) (bool, error) { |
| 138 | paths, err := diskPathsForRepo(ctx, deps, repo) |
| 139 | if err != nil { |
| 140 | return false, err |
| 141 | } |
| 142 | if err := deps.RepoFS.Move(paths.canonical, paths.deleted); err != nil { |
| 143 | if errors.Is(err, os.ErrNotExist) { |
| 144 | exists, existsErr := deps.RepoFS.Exists(paths.deleted) |
| 145 | if existsErr != nil { |
| 146 | return false, existsErr |
| 147 | } |
| 148 | if exists { |
| 149 | return false, nil |
| 150 | } |
| 151 | } |
| 152 | return false, fmt.Errorf("move repo to deleted path: %w", err) |
| 153 | } |
| 154 | return true, nil |
| 155 | } |
| 156 | |
| 157 | func moveDeletedToCanonical(ctx context.Context, deps Deps, repo reposdb.Repo) (bool, error) { |
| 158 | paths, err := diskPathsForRepo(ctx, deps, repo) |
| 159 | if err != nil { |
| 160 | return false, err |
| 161 | } |
| 162 | if err := deps.RepoFS.Move(paths.deleted, paths.canonical); err != nil { |
| 163 | if errors.Is(err, os.ErrNotExist) { |
| 164 | exists, existsErr := deps.RepoFS.Exists(paths.canonical) |
| 165 | if existsErr != nil { |
| 166 | return false, existsErr |
| 167 | } |
| 168 | if exists { |
| 169 | return false, nil |
| 170 | } |
| 171 | } |
| 172 | return false, fmt.Errorf("move repo to canonical path: %w", err) |
| 173 | } |
| 174 | return true, nil |
| 175 | } |
| 176 | |
| 177 | func moveDeletedBackToCanonical(ctx context.Context, deps Deps, repo reposdb.Repo) { |
| 178 | paths, err := diskPathsForRepo(ctx, deps, repo) |
| 179 | if err != nil { |
| 180 | if deps.Logger != nil { |
| 181 | deps.Logger.WarnContext(ctx, "soft delete: compute rollback path failed", "repo_id", repo.ID, "error", err) |
| 182 | } |
| 183 | return |
| 184 | } |
| 185 | if err := deps.RepoFS.Move(paths.deleted, paths.canonical); err != nil && deps.Logger != nil { |
| 186 | deps.Logger.WarnContext(ctx, "soft delete: rollback fs move failed", |
| 187 | "repo_id", repo.ID, "from", paths.deleted, "to", paths.canonical, "error", err) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | func moveCanonicalBackToDeleted(ctx context.Context, deps Deps, repo reposdb.Repo) { |
| 192 | paths, err := diskPathsForRepo(ctx, deps, repo) |
| 193 | if err != nil { |
| 194 | if deps.Logger != nil { |
| 195 | deps.Logger.WarnContext(ctx, "restore: compute rollback path failed", "repo_id", repo.ID, "error", err) |
| 196 | } |
| 197 | return |
| 198 | } |
| 199 | if err := deps.RepoFS.Move(paths.canonical, paths.deleted); err != nil && deps.Logger != nil { |
| 200 | deps.Logger.WarnContext(ctx, "restore: rollback fs move failed", |
| 201 | "repo_id", repo.ID, "from", paths.canonical, "to", paths.deleted, "error", err) |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | // int64ValueOrZero unwraps a pgtype.Int8 stored as raw int64+bool. We |
| 206 | // keep this private helper duplicated across packages rather than |
| 207 | // pulling pgtype into the audit-meta hot path. |
| 208 | func int64ValueOrZero(v int64, valid bool) int64 { |
| 209 | if valid { |
| 210 | return v |
| 211 | } |
| 212 | return 0 |
| 213 | } |
| 214 |