tenseleyflow/shithub / 05bcb8a

Browse files

S16: danger-zone routes + transfer inbox + restore page + redirect lookup

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
05bcb8a6be9abc06d77fea27a363dc351bcd4064
Parents
9256e8c
Tree
ebde4e1

8 changed files

StatusFile+-
M internal/web/handlers/handlers.go 11 0
A internal/web/handlers/repo/lifecycle.go 371 0
A internal/web/handlers/repo/redirect.go 56 0
M internal/web/handlers/repo/repo.go 7 0
M internal/web/server.go 7 0
A internal/web/templates/repo/restore_list.html 21 0
A internal/web/templates/repo/settings.html 81 0
A internal/web/templates/repo/transfers_inbox.html 25 0
internal/web/handlers/handlers.gomodified
@@ -54,6 +54,11 @@ type Deps struct {
5454
 	// CSRF-protected group. Two-segment match doesn't collide with the
5555
 	// /{username} catch-all.
5656
 	RepoHomeMounter func(chi.Router)
57
+	// RepoLifecycleMounter, when non-nil, registers the danger-zone
58
+	// routes (rename, transfer, archive, visibility, delete, restore,
59
+	// transfer accept/decline/cancel, inbox). All routes are auth-
60
+	// required; the handler enforces policy.Can per route.
61
+	RepoLifecycleMounter func(chi.Router)
5762
 	// GitHTTPMounter, when non-nil, registers the smart-HTTP git routes
5863
 	// (`*.git/info/refs`, `git-upload-pack`, `git-receive-pack`). MUST
5964
 	// land in a route group that bypasses CSRF, response compression,
@@ -156,6 +161,12 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
156161
 		if deps.RepoHomeMounter != nil {
157162
 			deps.RepoHomeMounter(r)
158163
 		}
164
+		// Lifecycle danger-zone + transfers + restore. Order: after
165
+		// RepoHome so explicit settings paths are matched first, before
166
+		// Profile's /{username} catch-all.
167
+		if deps.RepoLifecycleMounter != nil {
168
+			deps.RepoLifecycleMounter(r)
169
+		}
159170
 		// Profile is registered LAST so /{username} doesn't shadow any
160171
 		// static top-level route.
161172
 		if deps.ProfileMounter != nil {
internal/web/handlers/repo/lifecycle.goadded
@@ -0,0 +1,371 @@
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/repos/lifecycle"
16
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
17
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
18
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
19
+)
20
+
21
+// MountLifecycle registers the repo settings danger-zone routes plus
22
+// the per-user inbox/restore views. Caller wraps with RequireUser so
23
+// every route below has a logged-in viewer in context. Routes:
24
+//
25
+//	GET  /{owner}/{repo}/settings              — danger zone view
26
+//	POST /{owner}/{repo}/settings/rename
27
+//	POST /{owner}/{repo}/settings/transfer
28
+//	POST /{owner}/{repo}/settings/archive
29
+//	POST /{owner}/{repo}/settings/unarchive
30
+//	POST /{owner}/{repo}/settings/visibility
31
+//	POST /{owner}/{repo}/settings/delete
32
+//	POST /transfers/{id}/accept
33
+//	POST /transfers/{id}/decline
34
+//	POST /transfers/{id}/cancel
35
+//	GET  /transfers                            — recipient inbox
36
+//	GET  /settings/repositories                — restore listing
37
+//	POST /settings/repositories/restore/{id}
38
+func (h *Handlers) MountLifecycle(r chi.Router) {
39
+	r.Get("/{owner}/{repo}/settings", h.repoSettings)
40
+	r.Post("/{owner}/{repo}/settings/rename", h.repoRename)
41
+	r.Post("/{owner}/{repo}/settings/transfer", h.repoTransferRequest)
42
+	r.Post("/{owner}/{repo}/settings/archive", h.repoArchive)
43
+	r.Post("/{owner}/{repo}/settings/unarchive", h.repoUnarchive)
44
+	r.Post("/{owner}/{repo}/settings/visibility", h.repoVisibility)
45
+	r.Post("/{owner}/{repo}/settings/delete", h.repoSoftDelete)
46
+	r.Post("/transfers/{id}/accept", h.transferAccept)
47
+	r.Post("/transfers/{id}/decline", h.transferDecline)
48
+	r.Post("/transfers/{id}/cancel", h.transferCancel)
49
+	r.Get("/transfers", h.transferInbox)
50
+	r.Get("/settings/repositories", h.restoreList)
51
+	r.Post("/settings/repositories/restore/{id}", h.repoRestore)
52
+}
53
+
54
+// loadRepoAndAuthorize is the common preamble for every settings-route
55
+// handler. Resolves owner+repo, applies policy.Can with the chosen
56
+// action, and either returns the row or writes the response.
57
+func (h *Handlers) loadRepoAndAuthorize(w http.ResponseWriter, r *http.Request, action policy.Action) (reposdb.Repo, usersdb.User, bool) {
58
+	ownerName := chi.URLParam(r, "owner")
59
+	repoName := chi.URLParam(r, "repo")
60
+	owner, err := h.uq.GetUserByUsername(r.Context(), h.d.Pool, ownerName)
61
+	if err != nil {
62
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
63
+		return reposdb.Repo{}, usersdb.User{}, false
64
+	}
65
+	row, err := h.rq.GetRepoByOwnerUserAndName(r.Context(), h.d.Pool, reposdb.GetRepoByOwnerUserAndNameParams{
66
+		OwnerUserID: pgtype.Int8{Int64: owner.ID, Valid: true},
67
+		Name:        repoName,
68
+	})
69
+	if err != nil {
70
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
71
+		return reposdb.Repo{}, usersdb.User{}, false
72
+	}
73
+	viewer := middleware.CurrentUserFromContext(r.Context())
74
+	actor := policy.UserActor(viewer.ID, viewer.Username, false, false)
75
+	repoRef := policy.NewRepoRefFromRepo(row)
76
+	if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, action, repoRef).Allow {
77
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
78
+		return reposdb.Repo{}, usersdb.User{}, false
79
+	}
80
+	return row, owner, true
81
+}
82
+
83
+// repoSettings renders the danger-zone view. Limited to owners (admin
84
+// action). The actual settings UI is S32; this is just the danger
85
+// zone S16 owns.
86
+func (h *Handlers) repoSettings(w http.ResponseWriter, r *http.Request) {
87
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoAdmin)
88
+	if !ok {
89
+		return
90
+	}
91
+	transfers, _ := h.rq.ListTransfersForRepo(r.Context(), h.d.Pool, row.ID)
92
+	h.d.Render.RenderPage(w, r, "repo/settings", map[string]any{
93
+		"Title":     "Settings · " + row.Name,
94
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
95
+		"Owner":     owner.Username,
96
+		"Repo":      row,
97
+		"Transfers": transfers,
98
+	})
99
+}
100
+
101
+func (h *Handlers) repoRename(w http.ResponseWriter, r *http.Request) {
102
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoAdmin)
103
+	if !ok {
104
+		return
105
+	}
106
+	newName := strings.ToLower(strings.TrimSpace(r.PostFormValue("new_name")))
107
+	viewer := middleware.CurrentUserFromContext(r.Context())
108
+	err := lifecycle.Rename(r.Context(), h.lifecycleDeps(), lifecycle.RenameParams{
109
+		ActorUserID: viewer.ID,
110
+		RepoID:      row.ID,
111
+		OwnerUserID: owner.ID,
112
+		OwnerName:   owner.Username,
113
+		OldName:     row.Name,
114
+		NewName:     newName,
115
+	})
116
+	if err != nil {
117
+		h.lifecycleError(w, r, err)
118
+		return
119
+	}
120
+	policy.InvalidateRepo(r.Context(), row.ID)
121
+	http.Redirect(w, r, "/"+owner.Username+"/"+newName+"/settings", http.StatusSeeOther)
122
+}
123
+
124
+func (h *Handlers) repoTransferRequest(w http.ResponseWriter, r *http.Request) {
125
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoAdmin)
126
+	if !ok {
127
+		return
128
+	}
129
+	target := strings.ToLower(strings.TrimSpace(r.PostFormValue("to_user")))
130
+	confirm := strings.TrimSpace(r.PostFormValue("confirm"))
131
+	if confirm != owner.Username+"/"+row.Name {
132
+		http.Error(w, "confirmation text didn't match owner/repo", http.StatusBadRequest)
133
+		return
134
+	}
135
+	to, err := h.uq.GetUserByUsername(r.Context(), h.d.Pool, target)
136
+	if err != nil {
137
+		http.Error(w, "recipient not found", http.StatusBadRequest)
138
+		return
139
+	}
140
+	viewer := middleware.CurrentUserFromContext(r.Context())
141
+	if _, err := lifecycle.RequestTransfer(r.Context(), h.lifecycleDeps(), lifecycle.TransferRequestParams{
142
+		ActorUserID: viewer.ID, RepoID: row.ID,
143
+		FromUserID: owner.ID, ToPrincipalKind: "user", ToPrincipalID: to.ID,
144
+	}); err != nil {
145
+		h.lifecycleError(w, r, err)
146
+		return
147
+	}
148
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings?notice=transfer-requested", http.StatusSeeOther)
149
+}
150
+
151
+func (h *Handlers) repoArchive(w http.ResponseWriter, r *http.Request) {
152
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoArchive)
153
+	if !ok {
154
+		return
155
+	}
156
+	viewer := middleware.CurrentUserFromContext(r.Context())
157
+	if err := lifecycle.Archive(r.Context(), h.lifecycleDeps(), viewer.ID, row.ID); err != nil {
158
+		h.lifecycleError(w, r, err)
159
+		return
160
+	}
161
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings?notice=archived", http.StatusSeeOther)
162
+}
163
+
164
+func (h *Handlers) repoUnarchive(w http.ResponseWriter, r *http.Request) {
165
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoArchive)
166
+	if !ok {
167
+		return
168
+	}
169
+	viewer := middleware.CurrentUserFromContext(r.Context())
170
+	if err := lifecycle.Unarchive(r.Context(), h.lifecycleDeps(), viewer.ID, row.ID); err != nil {
171
+		h.lifecycleError(w, r, err)
172
+		return
173
+	}
174
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings?notice=unarchived", http.StatusSeeOther)
175
+}
176
+
177
+func (h *Handlers) repoVisibility(w http.ResponseWriter, r *http.Request) {
178
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoVisibility)
179
+	if !ok {
180
+		return
181
+	}
182
+	to := strings.ToLower(strings.TrimSpace(r.PostFormValue("visibility")))
183
+	viewer := middleware.CurrentUserFromContext(r.Context())
184
+	if err := lifecycle.SetVisibility(r.Context(), h.lifecycleDeps(), viewer.ID, row.ID, to); err != nil {
185
+		h.lifecycleError(w, r, err)
186
+		return
187
+	}
188
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings?notice=visibility", http.StatusSeeOther)
189
+}
190
+
191
+func (h *Handlers) repoSoftDelete(w http.ResponseWriter, r *http.Request) {
192
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoDelete)
193
+	if !ok {
194
+		return
195
+	}
196
+	confirm := strings.TrimSpace(r.PostFormValue("confirm"))
197
+	if confirm != owner.Username+"/"+row.Name {
198
+		http.Error(w, "confirmation text didn't match owner/repo", http.StatusBadRequest)
199
+		return
200
+	}
201
+	viewer := middleware.CurrentUserFromContext(r.Context())
202
+	if err := lifecycle.SoftDelete(r.Context(), h.lifecycleDeps(), viewer.ID, row.ID); err != nil {
203
+		h.lifecycleError(w, r, err)
204
+		return
205
+	}
206
+	http.Redirect(w, r, "/settings/repositories?notice=deleted", http.StatusSeeOther)
207
+}
208
+
209
+// transferAccept / Decline / Cancel act on a transfer ID. Recipient is
210
+// always the actor for accept/decline; sender (or repo admin) for cancel.
211
+func (h *Handlers) transferAccept(w http.ResponseWriter, r *http.Request) {
212
+	id := parseTransferID(w, r)
213
+	if id == 0 {
214
+		return
215
+	}
216
+	viewer := middleware.CurrentUserFromContext(r.Context())
217
+	if err := lifecycle.AcceptTransfer(r.Context(), h.lifecycleDeps(), viewer.ID, id); err != nil {
218
+		h.lifecycleError(w, r, err)
219
+		return
220
+	}
221
+	http.Redirect(w, r, "/transfers?notice=accepted", http.StatusSeeOther)
222
+}
223
+
224
+func (h *Handlers) transferDecline(w http.ResponseWriter, r *http.Request) {
225
+	id := parseTransferID(w, r)
226
+	if id == 0 {
227
+		return
228
+	}
229
+	viewer := middleware.CurrentUserFromContext(r.Context())
230
+	if err := lifecycle.DeclineTransfer(r.Context(), h.lifecycleDeps(), viewer.ID, id); err != nil {
231
+		h.lifecycleError(w, r, err)
232
+		return
233
+	}
234
+	http.Redirect(w, r, "/transfers?notice=declined", http.StatusSeeOther)
235
+}
236
+
237
+func (h *Handlers) transferCancel(w http.ResponseWriter, r *http.Request) {
238
+	id := parseTransferID(w, r)
239
+	if id == 0 {
240
+		return
241
+	}
242
+	// Verify the actor is allowed to cancel: must be repo admin on the
243
+	// repo this transfer belongs to.
244
+	row, err := h.rq.GetTransferRequest(r.Context(), h.d.Pool, id)
245
+	if err != nil {
246
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
247
+		return
248
+	}
249
+	repo, err := h.rq.GetRepoByID(r.Context(), h.d.Pool, row.RepoID)
250
+	if err != nil {
251
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
252
+		return
253
+	}
254
+	viewer := middleware.CurrentUserFromContext(r.Context())
255
+	actor := policy.UserActor(viewer.ID, viewer.Username, false, false)
256
+	if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionRepoAdmin, policy.NewRepoRefFromRepo(repo)).Allow {
257
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
258
+		return
259
+	}
260
+	if err := lifecycle.CancelTransfer(r.Context(), h.lifecycleDeps(), viewer.ID, id); err != nil {
261
+		h.lifecycleError(w, r, err)
262
+		return
263
+	}
264
+	http.Redirect(w, r, "/"+chi.URLParam(r, "owner")+"/"+chi.URLParam(r, "repo")+"/settings?notice=transfer-canceled", http.StatusSeeOther)
265
+}
266
+
267
+func (h *Handlers) transferInbox(w http.ResponseWriter, r *http.Request) {
268
+	viewer := middleware.CurrentUserFromContext(r.Context())
269
+	rows, err := h.rq.ListPendingTransfersForUser(r.Context(), h.d.Pool, viewer.ID)
270
+	if err != nil {
271
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
272
+		return
273
+	}
274
+	h.d.Render.RenderPage(w, r, "repo/transfers_inbox", map[string]any{
275
+		"Title":     "Transfer inbox",
276
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
277
+		"Pending":   rows,
278
+	})
279
+}
280
+
281
+func (h *Handlers) restoreList(w http.ResponseWriter, r *http.Request) {
282
+	viewer := middleware.CurrentUserFromContext(r.Context())
283
+	rows, err := h.rq.ListSoftDeletedReposForOwner(r.Context(), h.d.Pool, pgtype.Int8{Int64: viewer.ID, Valid: true})
284
+	if err != nil {
285
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
286
+		return
287
+	}
288
+	h.d.Render.RenderPage(w, r, "repo/restore_list", map[string]any{
289
+		"Title":     "Restore deleted repositories",
290
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
291
+		"Repos":     rows,
292
+	})
293
+}
294
+
295
+func (h *Handlers) repoRestore(w http.ResponseWriter, r *http.Request) {
296
+	idStr := chi.URLParam(r, "id")
297
+	repoID, err := strconv.ParseInt(idStr, 10, 64)
298
+	if err != nil || repoID <= 0 {
299
+		http.Error(w, "bad id", http.StatusBadRequest)
300
+		return
301
+	}
302
+	repo, err := h.rq.GetRepoByID(r.Context(), h.d.Pool, repoID)
303
+	if err != nil {
304
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
305
+		return
306
+	}
307
+	viewer := middleware.CurrentUserFromContext(r.Context())
308
+	if !repo.OwnerUserID.Valid || repo.OwnerUserID.Int64 != viewer.ID {
309
+		// Restore is owner-only; site-admin path is S34. Existence-leak
310
+		// guard: don't reveal a soft-deleted repo to non-owners.
311
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
312
+		return
313
+	}
314
+	if err := lifecycle.Restore(r.Context(), h.lifecycleDeps(), viewer.ID, repoID); err != nil {
315
+		h.lifecycleError(w, r, err)
316
+		return
317
+	}
318
+	http.Redirect(w, r, "/settings/repositories?notice=restored", http.StatusSeeOther)
319
+}
320
+
321
+// parseTransferID parses the chi URL param; writes a 400 and returns 0
322
+// on malformed input.
323
+func parseTransferID(w http.ResponseWriter, r *http.Request) int64 {
324
+	idStr := chi.URLParam(r, "id")
325
+	id, err := strconv.ParseInt(idStr, 10, 64)
326
+	if err != nil || id <= 0 {
327
+		http.Error(w, "bad id", http.StatusBadRequest)
328
+		return 0
329
+	}
330
+	return id
331
+}
332
+
333
+// lifecycleDeps assembles the orchestrator deps from the handler's deps.
334
+func (h *Handlers) lifecycleDeps() lifecycle.Deps {
335
+	return lifecycle.Deps{
336
+		Pool:   h.d.Pool,
337
+		RepoFS: h.d.RepoFS,
338
+		Audit:  h.d.Audit,
339
+		Logger: h.d.Logger,
340
+	}
341
+}
342
+
343
+// lifecycleError surfaces the typed lifecycle errors as user-friendly
344
+// HTTP responses. Specific cases get specific status codes; everything
345
+// else falls back to 500.
346
+func (h *Handlers) lifecycleError(w http.ResponseWriter, r *http.Request, err error) {
347
+	switch {
348
+	case errors.Is(err, lifecycle.ErrNameTaken),
349
+		errors.Is(err, lifecycle.ErrInvalidName),
350
+		errors.Is(err, lifecycle.ErrReservedName),
351
+		errors.Is(err, lifecycle.ErrSameName),
352
+		errors.Is(err, lifecycle.ErrInvalidVisibility),
353
+		errors.Is(err, lifecycle.ErrTransferToSelf):
354
+		http.Error(w, err.Error(), http.StatusBadRequest)
355
+	case errors.Is(err, lifecycle.ErrRenameRateLimited):
356
+		http.Error(w, "rename rate limit (5 per 30 days) exceeded", http.StatusTooManyRequests)
357
+	case errors.Is(err, lifecycle.ErrAlreadyArchived),
358
+		errors.Is(err, lifecycle.ErrNotArchived),
359
+		errors.Is(err, lifecycle.ErrAlreadyDeleted),
360
+		errors.Is(err, lifecycle.ErrNotDeleted):
361
+		http.Error(w, err.Error(), http.StatusConflict)
362
+	case errors.Is(err, lifecycle.ErrTransferTerminal),
363
+		errors.Is(err, lifecycle.ErrTransferExpired):
364
+		http.Error(w, "transfer no longer pending", http.StatusConflict)
365
+	case errors.Is(err, lifecycle.ErrPastGrace):
366
+		http.Error(w, "soft-delete grace expired", http.StatusGone)
367
+	default:
368
+		h.d.Logger.WarnContext(r.Context(), "lifecycle: unexpected error", "error", err)
369
+		http.Error(w, "internal error", http.StatusInternalServerError)
370
+	}
371
+}
internal/web/handlers/repo/redirect.goadded
@@ -0,0 +1,56 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"net/http"
7
+	"strings"
8
+
9
+	"github.com/jackc/pgx/v5/pgtype"
10
+
11
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
12
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
13
+)
14
+
15
+// tryRedirect resolves a (stale_owner, stale_name) URL to its current
16
+// canonical form via repo_redirects. Returns "" when no redirect row
17
+// matches, in which case the caller should 404. The returned URL
18
+// preserves the request's RemainingPath so e.g. /old/repo/blob/x
19
+// becomes /new/repo/blob/x — handy when more granular routes ship.
20
+func (h *Handlers) tryRedirect(r *http.Request, ownerName, repoName string) string {
21
+	owner, err := h.uq.GetUserByUsername(r.Context(), h.d.Pool, ownerName)
22
+	if err != nil {
23
+		return ""
24
+	}
25
+	repoID, err := h.rq.LookupRedirectByUserOwner(r.Context(), h.d.Pool, reposdb.LookupRedirectByUserOwnerParams{
26
+		OldOwnerUserID: pgtype.Int8{Int64: owner.ID, Valid: true},
27
+		OldName:        repoName,
28
+	})
29
+	if err != nil {
30
+		return ""
31
+	}
32
+	row, err := h.rq.GetRepoByID(r.Context(), h.d.Pool, repoID)
33
+	if err != nil || row.DeletedAt.Valid {
34
+		// Repo gone for real; let the caller serve a clean 404 instead
35
+		// of redirecting into a 404. This keeps user agents from
36
+		// chasing dead links via a 301.
37
+		return ""
38
+	}
39
+	currentOwner := ""
40
+	if row.OwnerUserID.Valid {
41
+		if u, err := h.uq.GetUserByID(r.Context(), h.d.Pool, row.OwnerUserID.Int64); err == nil {
42
+			currentOwner = u.Username
43
+		}
44
+	}
45
+	if currentOwner == "" || row.Name == "" {
46
+		return ""
47
+	}
48
+	// Preserve any path tail beyond /{owner}/{repo} (rare today; will
49
+	// matter when blob/tree/issues subpaths land).
50
+	tail := strings.TrimPrefix(r.URL.Path, "/"+ownerName+"/"+repoName)
51
+	return "/" + currentOwner + "/" + row.Name + tail
52
+}
53
+
54
+// _ keeps usersdb imported even when only its types are referenced
55
+// indirectly via the handlers struct. Unused-import guard.
56
+var _ = usersdb.New
internal/web/handlers/repo/repo.gomodified
@@ -181,6 +181,13 @@ func (h *Handlers) repoHome(w http.ResponseWriter, r *http.Request) {
181181
 
182182
 	row, err := h.lookupRepoForViewer(r.Context(), owner, name, middleware.CurrentUserFromContext(r.Context()).ID)
183183
 	if err != nil {
184
+		// Maybe the (owner, name) is a stale name; look up the redirect
185
+		// table and 301 to the canonical URL so old bookmarks keep
186
+		// working. Authoritative miss after the redirect check 404s.
187
+		if newURL := h.tryRedirect(r, owner, name); newURL != "" {
188
+			http.Redirect(w, r, newURL, http.StatusMovedPermanently)
189
+			return
190
+		}
184191
 		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
185192
 		return
186193
 	}
internal/web/server.gomodified
@@ -186,6 +186,13 @@ func Run(ctx context.Context, opts Options) error {
186186
 			})
187187
 		}
188188
 		deps.RepoHomeMounter = repoH.MountRepoHome
189
+		// Lifecycle danger-zone routes — also auth-required.
190
+		deps.RepoLifecycleMounter = func(r chi.Router) {
191
+			r.Group(func(r chi.Router) {
192
+				r.Use(middleware.RequireUser)
193
+				repoH.MountLifecycle(r)
194
+			})
195
+		}
189196
 
190197
 		gitHTTPH, err := buildGitHTTPHandlers(cfg, pool, logger)
191198
 		if err != nil {
internal/web/templates/repo/restore_list.htmladded
@@ -0,0 +1,21 @@
1
+{{ define "page" -}}
2
+<section class="shithub-restore-list">
3
+  <h1>Restore deleted repositories</h1>
4
+  <p>Soft-deleted repositories can be restored within 7 days of deletion.</p>
5
+  {{ if .Repos }}
6
+  <ul>
7
+    {{ range .Repos }}
8
+    <li>
9
+      <strong>{{ .Name }}</strong> · deleted {{ relativeTime .DeletedAt.Time }}
10
+      <form method="POST" action="/settings/repositories/restore/{{ .ID }}" style="display:inline">
11
+        <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
12
+        <button type="submit" class="shithub-button shithub-button-primary">Restore</button>
13
+      </form>
14
+    </li>
15
+    {{ end }}
16
+  </ul>
17
+  {{ else }}
18
+  <p>No soft-deleted repositories.</p>
19
+  {{ end }}
20
+</section>
21
+{{- end }}
internal/web/templates/repo/settings.htmladded
@@ -0,0 +1,81 @@
1
+{{ define "page" -}}
2
+<section class="shithub-repo-settings">
3
+  <header>
4
+    <h1>Settings · <a href="/{{ .Owner }}/{{ .Repo.Name }}">{{ .Owner }}/{{ .Repo.Name }}</a></h1>
5
+    <p class="shithub-repo-settings-note">Danger-zone actions only. Full settings UI ships in a later sprint.</p>
6
+  </header>
7
+
8
+  <section class="shithub-danger-zone">
9
+    <h2>Rename</h2>
10
+    <form method="POST" action="/{{ .Owner }}/{{ .Repo.Name }}/settings/rename">
11
+      <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
12
+      <label>New name <input type="text" name="new_name" pattern="[a-z0-9](?:[a-z0-9._-]{0,98}[a-z0-9_])?" required></label>
13
+      <button type="submit" class="shithub-button shithub-button-primary">Rename</button>
14
+    </form>
15
+    <p class="shithub-hint">5 renames allowed per 30 days. Old URLs 301-redirect.</p>
16
+  </section>
17
+
18
+  <section class="shithub-danger-zone">
19
+    <h2>Visibility</h2>
20
+    <form method="POST" action="/{{ .Owner }}/{{ .Repo.Name }}/settings/visibility">
21
+      <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
22
+      <label><input type="radio" name="visibility" value="public" {{ if eq (printf "%s" .Repo.Visibility) "public" }}checked{{ end }}> Public</label>
23
+      <label><input type="radio" name="visibility" value="private" {{ if eq (printf "%s" .Repo.Visibility) "private" }}checked{{ end }}> Private</label>
24
+      <button type="submit" class="shithub-button">Update visibility</button>
25
+    </form>
26
+    <p class="shithub-hint">Existing clones already pulled stay where they are; visibility flips affect future clones.</p>
27
+  </section>
28
+
29
+  <section class="shithub-danger-zone">
30
+    <h2>Archive</h2>
31
+    {{ if .Repo.IsArchived }}
32
+    <form method="POST" action="/{{ .Owner }}/{{ .Repo.Name }}/settings/unarchive">
33
+      <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
34
+      <button type="submit" class="shithub-button">Unarchive</button>
35
+    </form>
36
+    {{ else }}
37
+    <form method="POST" action="/{{ .Owner }}/{{ .Repo.Name }}/settings/archive">
38
+      <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
39
+      <button type="submit" class="shithub-button">Archive (read-only)</button>
40
+    </form>
41
+    {{ end }}
42
+  </section>
43
+
44
+  <section class="shithub-danger-zone">
45
+    <h2>Transfer ownership</h2>
46
+    <form method="POST" action="/{{ .Owner }}/{{ .Repo.Name }}/settings/transfer">
47
+      <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
48
+      <label>Recipient username <input type="text" name="to_user" required></label>
49
+      <label>Type <code>{{ .Owner }}/{{ .Repo.Name }}</code> to confirm
50
+        <input type="text" name="confirm" required></label>
51
+      <button type="submit" class="shithub-button shithub-button-danger">Send transfer offer</button>
52
+    </form>
53
+    {{ if .Transfers }}
54
+    <h3>Transfer history</h3>
55
+    <ul>
56
+      {{ range .Transfers }}
57
+      <li>#{{ .ID }} · status {{ .Status }} · expires {{ relativeTime .ExpiresAt.Time }}
58
+        {{ if eq (printf "%s" .Status) "pending" }}
59
+        <form method="POST" action="/transfers/{{ .ID }}/cancel" style="display:inline">
60
+          <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
61
+          <button type="submit" class="shithub-button">Cancel</button>
62
+        </form>
63
+        {{ end }}
64
+      </li>
65
+      {{ end }}
66
+    </ul>
67
+    {{ end }}
68
+  </section>
69
+
70
+  <section class="shithub-danger-zone shithub-danger-zone-final">
71
+    <h2>Delete repository</h2>
72
+    <p>Soft-delete with 7-day grace. Until the grace expires you can restore from <a href="/settings/repositories">Restore deleted repositories</a>.</p>
73
+    <form method="POST" action="/{{ .Owner }}/{{ .Repo.Name }}/settings/delete">
74
+      <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
75
+      <label>Type <code>{{ .Owner }}/{{ .Repo.Name }}</code> to confirm
76
+        <input type="text" name="confirm" required></label>
77
+      <button type="submit" class="shithub-button shithub-button-danger">Delete</button>
78
+    </form>
79
+  </section>
80
+</section>
81
+{{- end }}
internal/web/templates/repo/transfers_inbox.htmladded
@@ -0,0 +1,25 @@
1
+{{ define "page" -}}
2
+<section class="shithub-transfer-inbox">
3
+  <h1>Transfer inbox</h1>
4
+  {{ if .Pending }}
5
+  <ul>
6
+    {{ range .Pending }}
7
+    <li>
8
+      Repo <code>#{{ .RepoID }}</code> offered by user <code>#{{ .FromUserID }}</code>
9
+      <span> · expires {{ relativeTime .ExpiresAt.Time }}</span>
10
+      <form method="POST" action="/transfers/{{ .ID }}/accept" style="display:inline">
11
+        <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
12
+        <button type="submit" class="shithub-button shithub-button-primary">Accept</button>
13
+      </form>
14
+      <form method="POST" action="/transfers/{{ .ID }}/decline" style="display:inline">
15
+        <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
16
+        <button type="submit" class="shithub-button">Decline</button>
17
+      </form>
18
+    </li>
19
+    {{ end }}
20
+  </ul>
21
+  {{ else }}
22
+  <p>No pending transfers.</p>
23
+  {{ end }}
24
+</section>
25
+{{- end }}