Go · 15944 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package repo
4
5 import (
6 "errors"
7 "net/http"
8 "strconv"
9 "strings"
10
11 "github.com/go-chi/chi/v5"
12 "github.com/jackc/pgx/v5/pgtype"
13
14 "github.com/tenseleyFlow/shithub/internal/auth/policy"
15 "github.com/tenseleyFlow/shithub/internal/entitlements"
16 "github.com/tenseleyFlow/shithub/internal/orgs"
17 "github.com/tenseleyFlow/shithub/internal/repos/lifecycle"
18 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
19 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
20 "github.com/tenseleyFlow/shithub/internal/web/middleware"
21 )
22
23 // MountLifecycle registers the repo settings danger-zone routes plus
24 // the per-user inbox/restore views. Caller wraps with RequireUser so
25 // every route below has a logged-in viewer in context. Routes:
26 //
27 // GET /{owner}/{repo}/settings — danger zone view
28 // POST /{owner}/{repo}/settings/rename
29 // POST /{owner}/{repo}/settings/transfer
30 // POST /{owner}/{repo}/settings/archive
31 // POST /{owner}/{repo}/settings/unarchive
32 // POST /{owner}/{repo}/settings/visibility
33 // POST /{owner}/{repo}/settings/delete
34 // POST /transfers/{id}/accept
35 // POST /transfers/{id}/decline
36 // POST /transfers/{id}/cancel
37 // GET /transfers — recipient inbox
38 // GET /settings/repositories — restore listing
39 // POST /settings/repositories/restore/{id}
40 func (h *Handlers) MountLifecycle(r chi.Router) {
41 r.Get("/{owner}/{repo}/settings", h.repoSettings)
42 r.Post("/{owner}/{repo}/settings/rename", h.repoRename)
43 r.Post("/{owner}/{repo}/settings/transfer", h.repoTransferRequest)
44 r.Post("/{owner}/{repo}/settings/archive", h.repoArchive)
45 r.Post("/{owner}/{repo}/settings/unarchive", h.repoUnarchive)
46 r.Post("/{owner}/{repo}/settings/visibility", h.repoVisibility)
47 r.Post("/{owner}/{repo}/settings/delete", h.repoSoftDelete)
48 r.Post("/transfers/{id}/accept", h.transferAccept)
49 r.Post("/transfers/{id}/decline", h.transferDecline)
50 r.Post("/transfers/{id}/cancel", h.transferCancel)
51 r.Get("/transfers", h.transferInbox)
52 r.Get("/settings/repositories", h.restoreList)
53 r.Post("/settings/repositories/restore/{id}", h.repoRestore)
54 }
55
56 // loadRepoAndAuthorize is the common preamble for every settings-route
57 // handler. Resolves owner+repo, applies policy.Can with the chosen
58 // action, and either returns the row or writes the response.
59 //
60 // Owner kind dispatch goes through principals (S30) so org-owned
61 // repos resolve through the same path as user-owned ones. The
62 // returned `usersdb.User` is the OWNING USER for user-owned repos,
63 // or a synthetic row carrying just `ID` + `Username` (= the org slug)
64 // for org-owned repos — handlers that re-construct paths only need
65 // the slug, and the few that need a real users row already short-
66 // circuit via `viewer`.
67 func (h *Handlers) loadRepoAndAuthorize(w http.ResponseWriter, r *http.Request, action policy.Action) (reposdb.Repo, usersdb.User, bool) {
68 ownerName := chi.URLParam(r, "owner")
69 repoName := chi.URLParam(r, "repo")
70 principal, err := orgs.Resolve(r.Context(), h.d.Pool, ownerName)
71 if err != nil {
72 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
73 return reposdb.Repo{}, usersdb.User{}, false
74 }
75 var (
76 row reposdb.Repo
77 owner usersdb.User
78 )
79 switch principal.Kind {
80 case orgs.PrincipalUser:
81 owner, err = h.uq.GetUserByID(r.Context(), h.d.Pool, principal.ID)
82 if err != nil {
83 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
84 return reposdb.Repo{}, usersdb.User{}, false
85 }
86 row, err = h.rq.GetRepoByOwnerUserAndName(r.Context(), h.d.Pool, reposdb.GetRepoByOwnerUserAndNameParams{
87 OwnerUserID: pgtype.Int8{Int64: owner.ID, Valid: true},
88 Name: repoName,
89 })
90 case orgs.PrincipalOrg:
91 row, err = h.rq.GetRepoByOwnerOrgAndName(r.Context(), h.d.Pool, reposdb.GetRepoByOwnerOrgAndNameParams{
92 OwnerOrgID: pgtype.Int8{Int64: principal.ID, Valid: true},
93 Name: repoName,
94 })
95 // Synthesize an owner with the slug so callers that read
96 // `owner.Username` for path composition still work. ID is
97 // the org id; the field is repurposed but no caller treats
98 // it as a user id in path-only contexts.
99 owner = usersdb.User{ID: principal.ID, Username: principal.Slug}
100 default:
101 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
102 return reposdb.Repo{}, usersdb.User{}, false
103 }
104 if err != nil {
105 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
106 return reposdb.Repo{}, usersdb.User{}, false
107 }
108 viewer := middleware.CurrentUserFromContext(r.Context())
109 actor := viewer.PolicyActor()
110 repoRef := policy.NewRepoRefFromRepo(row)
111 dec := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, action, repoRef)
112 if !dec.Allow {
113 // Maybe404 picks 404 for "shouldn't know it exists" denials
114 // (private + non-collab) and 403 for honest "you can't do that
115 // to a repo you can see" denials (owner pushing to archived).
116 // Without this we leaked existence by always 404'ing — fixed
117 // per S00-S25 audit, finding H7.
118 h.d.Render.HTTPError(w, r, policy.Maybe404(dec, repoRef, actor), "")
119 return reposdb.Repo{}, usersdb.User{}, false
120 }
121 return row, owner, true
122 }
123
124 // repoSettings renders the danger-zone view. Limited to owners (admin
125 // action). The actual settings UI is S32; this is just the danger
126 // zone S16 owns.
127 func (h *Handlers) repoSettings(w http.ResponseWriter, r *http.Request) {
128 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoAdmin)
129 if !ok {
130 return
131 }
132 transfers, _ := h.rq.ListTransfersForRepo(r.Context(), h.d.Pool, row.ID)
133 h.d.Render.RenderPage(w, r, "repo/settings", map[string]any{
134 "Title": "Settings · " + row.Name,
135 "CSRFToken": middleware.CSRFTokenForRequest(r),
136 "Owner": owner.Username,
137 "Repo": row,
138 "Transfers": transfers,
139 "SettingsActive": "danger",
140 })
141 }
142
143 func (h *Handlers) repoRename(w http.ResponseWriter, r *http.Request) {
144 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoAdmin)
145 if !ok {
146 return
147 }
148 newName := strings.ToLower(strings.TrimSpace(r.PostFormValue("new_name")))
149 viewer := middleware.CurrentUserFromContext(r.Context())
150 err := lifecycle.Rename(r.Context(), h.lifecycleDeps(), lifecycle.RenameParams{
151 ActorUserID: viewer.ID,
152 RepoID: row.ID,
153 OwnerUserID: owner.ID,
154 OwnerName: owner.Username,
155 OldName: row.Name,
156 NewName: newName,
157 })
158 if err != nil {
159 h.lifecycleError(w, r, err)
160 return
161 }
162 policy.InvalidateRepo(r.Context(), row.ID)
163 http.Redirect(w, r, "/"+owner.Username+"/"+newName+"/settings", http.StatusSeeOther)
164 }
165
166 func (h *Handlers) repoTransferRequest(w http.ResponseWriter, r *http.Request) {
167 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoAdmin)
168 if !ok {
169 return
170 }
171 target := strings.ToLower(strings.TrimSpace(r.PostFormValue("to_user")))
172 confirm := strings.TrimSpace(r.PostFormValue("confirm"))
173 if confirm != owner.Username+"/"+row.Name {
174 http.Error(w, "confirmation text didn't match owner/repo", http.StatusBadRequest)
175 return
176 }
177 to, err := h.uq.GetUserByUsername(r.Context(), h.d.Pool, target)
178 if err != nil {
179 http.Error(w, "recipient not found", http.StatusBadRequest)
180 return
181 }
182 viewer := middleware.CurrentUserFromContext(r.Context())
183 if _, err := lifecycle.RequestTransfer(r.Context(), h.lifecycleDeps(), lifecycle.TransferRequestParams{
184 ActorUserID: viewer.ID, RepoID: row.ID,
185 FromUserID: owner.ID, ToPrincipalKind: "user", ToPrincipalID: to.ID,
186 }); err != nil {
187 h.lifecycleError(w, r, err)
188 return
189 }
190 http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings?notice=transfer-requested", http.StatusSeeOther)
191 }
192
193 func (h *Handlers) repoArchive(w http.ResponseWriter, r *http.Request) {
194 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoArchive)
195 if !ok {
196 return
197 }
198 viewer := middleware.CurrentUserFromContext(r.Context())
199 if err := lifecycle.Archive(r.Context(), h.lifecycleDeps(), viewer.ID, row.ID); err != nil {
200 h.lifecycleError(w, r, err)
201 return
202 }
203 http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings?notice=archived", http.StatusSeeOther)
204 }
205
206 func (h *Handlers) repoUnarchive(w http.ResponseWriter, r *http.Request) {
207 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoArchive)
208 if !ok {
209 return
210 }
211 viewer := middleware.CurrentUserFromContext(r.Context())
212 if err := lifecycle.Unarchive(r.Context(), h.lifecycleDeps(), viewer.ID, row.ID); err != nil {
213 h.lifecycleError(w, r, err)
214 return
215 }
216 http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings?notice=unarchived", http.StatusSeeOther)
217 }
218
219 func (h *Handlers) repoVisibility(w http.ResponseWriter, r *http.Request) {
220 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoVisibility)
221 if !ok {
222 return
223 }
224 to := strings.ToLower(strings.TrimSpace(r.PostFormValue("visibility")))
225 viewer := middleware.CurrentUserFromContext(r.Context())
226 if err := lifecycle.SetVisibility(r.Context(), h.lifecycleDeps(), viewer.ID, row.ID, to); err != nil {
227 h.lifecycleError(w, r, err)
228 return
229 }
230 http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings?notice=visibility", http.StatusSeeOther)
231 }
232
233 func (h *Handlers) repoSoftDelete(w http.ResponseWriter, r *http.Request) {
234 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoDelete)
235 if !ok {
236 return
237 }
238 confirm := strings.TrimSpace(r.PostFormValue("confirm"))
239 if confirm != owner.Username+"/"+row.Name {
240 http.Error(w, "confirmation text didn't match owner/repo", http.StatusBadRequest)
241 return
242 }
243 viewer := middleware.CurrentUserFromContext(r.Context())
244 if err := lifecycle.SoftDelete(r.Context(), h.lifecycleDeps(), viewer.ID, row.ID); err != nil {
245 h.lifecycleError(w, r, err)
246 return
247 }
248 http.Redirect(w, r, "/settings/repositories?notice=deleted", http.StatusSeeOther)
249 }
250
251 // transferAccept / Decline / Cancel act on a transfer ID. Recipient is
252 // always the actor for accept/decline; sender (or repo admin) for cancel.
253 func (h *Handlers) transferAccept(w http.ResponseWriter, r *http.Request) {
254 id := parseTransferID(w, r)
255 if id == 0 {
256 return
257 }
258 viewer := middleware.CurrentUserFromContext(r.Context())
259 if err := lifecycle.AcceptTransfer(r.Context(), h.lifecycleDeps(), viewer.ID, id); err != nil {
260 h.lifecycleError(w, r, err)
261 return
262 }
263 http.Redirect(w, r, "/transfers?notice=accepted", http.StatusSeeOther)
264 }
265
266 func (h *Handlers) transferDecline(w http.ResponseWriter, r *http.Request) {
267 id := parseTransferID(w, r)
268 if id == 0 {
269 return
270 }
271 viewer := middleware.CurrentUserFromContext(r.Context())
272 if err := lifecycle.DeclineTransfer(r.Context(), h.lifecycleDeps(), viewer.ID, id); err != nil {
273 h.lifecycleError(w, r, err)
274 return
275 }
276 http.Redirect(w, r, "/transfers?notice=declined", http.StatusSeeOther)
277 }
278
279 func (h *Handlers) transferCancel(w http.ResponseWriter, r *http.Request) {
280 id := parseTransferID(w, r)
281 if id == 0 {
282 return
283 }
284 // Verify the actor is allowed to cancel: must be repo admin on the
285 // repo this transfer belongs to.
286 row, err := h.rq.GetTransferRequest(r.Context(), h.d.Pool, id)
287 if err != nil {
288 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
289 return
290 }
291 repo, err := h.rq.GetRepoByID(r.Context(), h.d.Pool, row.RepoID)
292 if err != nil {
293 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
294 return
295 }
296 viewer := middleware.CurrentUserFromContext(r.Context())
297 actor := viewer.PolicyActor()
298 repoRef := policy.NewRepoRefFromRepo(repo)
299 if dec := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionRepoAdmin, repoRef); !dec.Allow {
300 h.d.Render.HTTPError(w, r, policy.Maybe404(dec, repoRef, actor), "")
301 return
302 }
303 if err := lifecycle.CancelTransfer(r.Context(), h.lifecycleDeps(), viewer.ID, id); err != nil {
304 h.lifecycleError(w, r, err)
305 return
306 }
307 http.Redirect(w, r, "/"+chi.URLParam(r, "owner")+"/"+chi.URLParam(r, "repo")+"/settings?notice=transfer-canceled", http.StatusSeeOther)
308 }
309
310 func (h *Handlers) transferInbox(w http.ResponseWriter, r *http.Request) {
311 viewer := middleware.CurrentUserFromContext(r.Context())
312 rows, err := h.rq.ListPendingTransfersForUser(r.Context(), h.d.Pool, viewer.ID)
313 if err != nil {
314 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
315 return
316 }
317 h.d.Render.RenderPage(w, r, "repo/transfers_inbox", map[string]any{
318 "Title": "Transfer inbox",
319 "CSRFToken": middleware.CSRFTokenForRequest(r),
320 "Pending": rows,
321 })
322 }
323
324 func (h *Handlers) restoreList(w http.ResponseWriter, r *http.Request) {
325 viewer := middleware.CurrentUserFromContext(r.Context())
326 rows, err := h.rq.ListSoftDeletedReposForOwner(r.Context(), h.d.Pool, pgtype.Int8{Int64: viewer.ID, Valid: true})
327 if err != nil {
328 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
329 return
330 }
331 h.d.Render.RenderPage(w, r, "repo/restore_list", map[string]any{
332 "Title": "Restore deleted repositories",
333 "CSRFToken": middleware.CSRFTokenForRequest(r),
334 "Repos": rows,
335 })
336 }
337
338 func (h *Handlers) repoRestore(w http.ResponseWriter, r *http.Request) {
339 idStr := chi.URLParam(r, "id")
340 repoID, err := strconv.ParseInt(idStr, 10, 64)
341 if err != nil || repoID <= 0 {
342 http.Error(w, "bad id", http.StatusBadRequest)
343 return
344 }
345 repo, err := h.rq.GetRepoByID(r.Context(), h.d.Pool, repoID)
346 if err != nil {
347 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
348 return
349 }
350 viewer := middleware.CurrentUserFromContext(r.Context())
351 if !repo.OwnerUserID.Valid || repo.OwnerUserID.Int64 != viewer.ID {
352 // Restore is owner-only; site-admin path is S34. Existence-leak
353 // guard: don't reveal a soft-deleted repo to non-owners.
354 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
355 return
356 }
357 if err := lifecycle.Restore(r.Context(), h.lifecycleDeps(), viewer.ID, repoID); err != nil {
358 h.lifecycleError(w, r, err)
359 return
360 }
361 http.Redirect(w, r, "/settings/repositories?notice=restored", http.StatusSeeOther)
362 }
363
364 // parseTransferID parses the chi URL param; writes a 400 and returns 0
365 // on malformed input.
366 func parseTransferID(w http.ResponseWriter, r *http.Request) int64 {
367 idStr := chi.URLParam(r, "id")
368 id, err := strconv.ParseInt(idStr, 10, 64)
369 if err != nil || id <= 0 {
370 http.Error(w, "bad id", http.StatusBadRequest)
371 return 0
372 }
373 return id
374 }
375
376 // lifecycleDeps assembles the orchestrator deps from the handler's deps.
377 func (h *Handlers) lifecycleDeps() lifecycle.Deps {
378 return lifecycle.Deps{
379 Pool: h.d.Pool,
380 RepoFS: h.d.RepoFS,
381 Audit: h.d.Audit,
382 Logger: h.d.Logger,
383 }
384 }
385
386 // lifecycleError surfaces the typed lifecycle errors as user-friendly
387 // HTTP responses. Specific cases get specific status codes; everything
388 // else falls back to 500.
389 func (h *Handlers) lifecycleError(w http.ResponseWriter, r *http.Request, err error) {
390 switch {
391 case errors.Is(err, lifecycle.ErrNameTaken),
392 errors.Is(err, lifecycle.ErrInvalidName),
393 errors.Is(err, lifecycle.ErrReservedName),
394 errors.Is(err, lifecycle.ErrSameName),
395 errors.Is(err, lifecycle.ErrInvalidVisibility),
396 errors.Is(err, lifecycle.ErrTransferToSelf):
397 http.Error(w, err.Error(), http.StatusBadRequest)
398 case errors.Is(err, lifecycle.ErrRenameRateLimited):
399 http.Error(w, "rename rate limit (5 per 30 days) exceeded", http.StatusTooManyRequests)
400 case errors.Is(err, lifecycle.ErrAlreadyArchived),
401 errors.Is(err, lifecycle.ErrNotArchived),
402 errors.Is(err, lifecycle.ErrAlreadyDeleted),
403 errors.Is(err, lifecycle.ErrNotDeleted):
404 http.Error(w, err.Error(), http.StatusConflict)
405 case errors.Is(err, lifecycle.ErrTransferTerminal),
406 errors.Is(err, lifecycle.ErrTransferExpired):
407 http.Error(w, "transfer no longer pending", http.StatusConflict)
408 case errors.Is(err, lifecycle.ErrPastGrace):
409 http.Error(w, "soft-delete grace expired", http.StatusGone)
410 case errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded):
411 http.Error(w, err.Error(), http.StatusPaymentRequired)
412 default:
413 h.d.Logger.WarnContext(r.Context(), "lifecycle: unexpected error", "error", err)
414 http.Error(w, "internal error", http.StatusInternalServerError)
415 }
416 }
417