tenseleyflow/shithub / 65c5e48

Browse files

S34: repos list/view/force-archive/force-delete

Authored by espadonne
SHA
65c5e489c6bc1a8d46206ed976bfb6ea22007c58
Parents
80204e1
Tree
2717664

1 changed file

StatusFile+-
A internal/web/handlers/admin/repos.go 136 0
internal/web/handlers/admin/repos.goadded
@@ -0,0 +1,136 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package admin
4
+
5
+import (
6
+	"net/http"
7
+	"strconv"
8
+	"strings"
9
+
10
+	"github.com/go-chi/chi/v5"
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+
13
+	admindb "github.com/tenseleyFlow/shithub/internal/admin/sqlc"
14
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
15
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
16
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
17
+)
18
+
19
+// reposList renders the paginated, filterable repo list.
20
+func (h *Handlers) reposList(w http.ResponseWriter, r *http.Request) {
21
+	q := r.URL.Query()
22
+	prefix := strings.TrimSpace(q.Get("q"))
23
+	deletedOnly := q.Get("deleted") == "1"
24
+	archivedOnly := q.Get("archived") == "1"
25
+	visibility := q.Get("visibility")
26
+	const perPage = 50
27
+	page, _ := strconv.Atoi(q.Get("page"))
28
+	if page < 1 {
29
+		page = 1
30
+	}
31
+	params := admindb.ListReposForAdminParams{
32
+		NamePrefix:   pgtype.Text{String: prefix, Valid: prefix != ""},
33
+		DeletedOnly:  pgtype.Bool{Bool: true, Valid: deletedOnly},
34
+		ArchivedOnly: pgtype.Bool{Bool: true, Valid: archivedOnly},
35
+		Limit:        perPage,
36
+		Offset:       int32((page - 1) * perPage),
37
+	}
38
+	if visibility == "public" || visibility == "private" {
39
+		params.VisibilityFilter = admindb.NullRepoVisibility{
40
+			RepoVisibility: admindb.RepoVisibility(visibility), Valid: true,
41
+		}
42
+	}
43
+	rows, _ := h.aq.ListReposForAdmin(r.Context(), h.d.Pool, params)
44
+	h.d.Render.RenderPage(w, r, "admin/repos_list", map[string]any{
45
+		"Title":        "Repositories",
46
+		"CSRFToken":    middleware.CSRFTokenForRequest(r),
47
+		"AdminActive":  "repos",
48
+		"Repos":        rows,
49
+		"Q":            prefix,
50
+		"DeletedOnly":  deletedOnly,
51
+		"ArchivedOnly": archivedOnly,
52
+		"Visibility":   visibility,
53
+		"Page":         page,
54
+		"NextPage":     page + 1,
55
+		"PrevPage":     page - 1,
56
+		"HasMore":      len(rows) == perPage,
57
+		"Notice":       adminNotice(q.Get("notice")),
58
+	})
59
+}
60
+
61
+// repoView renders the per-repo admin page with action buttons.
62
+func (h *Handlers) repoView(w http.ResponseWriter, r *http.Request) {
63
+	row, ok := h.loadRepo(w, r)
64
+	if !ok {
65
+		return
66
+	}
67
+	notice := r.URL.Query().Get("notice")
68
+	h.d.Render.RenderPage(w, r, "admin/repo_view", map[string]any{
69
+		"Title":       row.Name + " · admin",
70
+		"CSRFToken":   middleware.CSRFTokenForRequest(r),
71
+		"AdminActive": "repos",
72
+		"Repo":        row,
73
+		"Notice":      adminNotice(notice),
74
+	})
75
+}
76
+
77
+// repoForceArchive flips is_archived without owner consent. Used for
78
+// emergency takedown — the normal Archive flow is owner-only.
79
+func (h *Handlers) repoForceArchive(w http.ResponseWriter, r *http.Request) {
80
+	row, ok := h.loadRepo(w, r)
81
+	if !ok {
82
+		return
83
+	}
84
+	if _, err := h.d.Pool.Exec(r.Context(),
85
+		`UPDATE repos SET is_archived = NOT is_archived, archived_at = CASE WHEN is_archived THEN NULL ELSE now() END WHERE id = $1`,
86
+		row.ID); err != nil {
87
+		http.Error(w, "force-archive failed", http.StatusInternalServerError)
88
+		return
89
+	}
90
+	h.recordAdminAction(r, audit.ActionAdminRepoForceArchived, audit.TargetRepo, row.ID, nil)
91
+	http.Redirect(w, r, "/admin/repos/"+strconv.FormatInt(row.ID, 10)+"?notice=saved", http.StatusSeeOther)
92
+}
93
+
94
+// repoForceDelete bypasses the soft-delete grace by setting deleted_at
95
+// to a time well past the grace window, then hard-delete sweeps it on
96
+// the next lifecycle:sweep tick.
97
+func (h *Handlers) repoForceDelete(w http.ResponseWriter, r *http.Request) {
98
+	row, ok := h.loadRepo(w, r)
99
+	if !ok {
100
+		return
101
+	}
102
+	if err := r.ParseForm(); err != nil {
103
+		http.Error(w, "form parse", http.StatusBadRequest)
104
+		return
105
+	}
106
+	confirm := strings.TrimSpace(r.PostFormValue("confirm"))
107
+	if confirm != row.Name {
108
+		http.Error(w, "confirmation text didn't match the repo name", http.StatusBadRequest)
109
+		return
110
+	}
111
+	if _, err := h.d.Pool.Exec(r.Context(),
112
+		`UPDATE repos SET deleted_at = now() - interval '1 year' WHERE id = $1`,
113
+		row.ID); err != nil {
114
+		http.Error(w, "force-delete failed", http.StatusInternalServerError)
115
+		return
116
+	}
117
+	h.recordAdminAction(r, audit.ActionAdminRepoForceDeleted, audit.TargetRepo, row.ID,
118
+		map[string]any{"name": row.Name})
119
+	http.Redirect(w, r, "/admin/repos?notice=saved", http.StatusSeeOther)
120
+}
121
+
122
+// loadRepo parses {id} and fetches.
123
+func (h *Handlers) loadRepo(w http.ResponseWriter, r *http.Request) (reposdb.Repo, bool) {
124
+	idStr := chi.URLParam(r, "id")
125
+	id, err := strconv.ParseInt(idStr, 10, 64)
126
+	if err != nil || id <= 0 {
127
+		http.Error(w, "bad id", http.StatusBadRequest)
128
+		return reposdb.Repo{}, false
129
+	}
130
+	row, err := reposdb.New().GetRepoByID(r.Context(), h.d.Pool, id)
131
+	if err != nil {
132
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
133
+		return reposdb.Repo{}, false
134
+	}
135
+	return row, true
136
+}