Go · 6025 bytes Raw Blame History
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