| 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 |