Federated via graft
7.6 kB
236 lines
1package admin
2
3import (
4 "fmt"
5 "html/template"
6 "net/http"
7 "os"
8 "strconv"
9 "strings"
10)
11
12// pendingRow is one submitted request awaiting approval, whichever of the
13// two underlying tables (dynamic_repo, pending_radicle_peer) it came
14// from — unified here so the review page shows one list.
15type pendingRow struct {
16 ID int64
17 Type string // "peer" (dynamic_repo — a Forgejo peer or a new repo) or "radicle"
18 Summary string
19 Detail string
20}
21
22var pendingLoginTmpl = template.Must(template.New("pending-login").Parse(`
23<h1>Pending requests</h1>
24<p class="lead">Review peer and repo requests submitted through the onboarding forms. Approving is the only step — every field was already filled in by whoever submitted the request.</p>
25{{if .Error}}<div class="notice bad">{{.Error}}</div>{{end}}
26<div class="card">
27 <form method="POST" action="/admin/pending">
28 <label for="password">Admin password</label>
29 <input type="password" id="password" name="password" required autocomplete="off" autofocus>
30 <button type="submit">View pending requests</button>
31 </form>
32</div>
33`))
34
35var pendingListTmpl = template.Must(template.New("pending-list").Parse(`
36<h1>Pending requests</h1>
37{{if .Notice}}<div class="notice good">{{.Notice}}</div>{{end}}
38{{if .Error}}<div class="notice bad">{{.Error}}</div>{{end}}
39{{if not .Rows}}<p class="empty">Nothing pending.</p>{{end}}
40{{range .Rows}}
41<div class="card">
42 <div><b>{{.Summary}}</b></div>
43 <div class="hint">{{.Detail}}</div>
44 <div class="row" style="margin-top:.75rem; gap:.5rem;">
45 <form method="POST" action="/admin/pending/approve">
46 <input type="hidden" name="id" value="{{.ID}}">
47 <input type="hidden" name="type" value="{{.Type}}">
48 <input type="hidden" name="password" value="{{$.Password}}">
49 <button type="submit">Approve</button>
50 </form>
51 <form method="POST" action="/admin/pending/reject">
52 <input type="hidden" name="id" value="{{.ID}}">
53 <input type="hidden" name="type" value="{{.Type}}">
54 <input type="hidden" name="password" value="{{$.Password}}">
55 <button type="submit">Reject</button>
56 </form>
57 </div>
58</div>
59{{end}}
60`))
61
62func (h *Handler) pending(w http.ResponseWriter, r *http.Request) {
63 switch r.Method {
64 case http.MethodGet:
65 h.renderPendingLogin(w, "")
66 case http.MethodPost:
67 ip := clientIP(r)
68 if !h.passwordLimiter.allowed(ip) {
69 h.renderPendingLogin(w, "too many attempts from your address — try again later")
70 return
71 }
72 if err := r.ParseForm(); err != nil {
73 h.renderPendingLogin(w, "couldn't read the form")
74 return
75 }
76 password := r.FormValue("password")
77 if !checkPassword(h.cfg, password) {
78 h.passwordLimiter.recordFailure(ip)
79 h.renderPendingLogin(w, "wrong admin password")
80 return
81 }
82 h.renderPendingList(w, password, "", "")
83 default:
84 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
85 }
86}
87
88func (h *Handler) renderPendingLogin(w http.ResponseWriter, errMsg string) {
89 render(w, "pending requests", "ADMIN", renderFragment(pendingLoginTmpl, map[string]string{"Error": errMsg}))
90}
91
92func (h *Handler) renderPendingList(w http.ResponseWriter, password, notice, errMsg string) {
93 var rows []pendingRow
94
95 dr, err := h.cfg.Store.PendingDynamicRepos()
96 if err != nil && errMsg == "" {
97 errMsg = err.Error()
98 }
99 for _, d := range dr {
100 var scope []string
101 if d.SyncGit {
102 scope = append(scope, "git")
103 }
104 if d.SyncIssues {
105 scope = append(scope, "issues")
106 }
107 if d.SyncPatches {
108 scope = append(scope, "patches")
109 }
110 rows = append(rows, pendingRow{
111 ID: d.ID, Type: "peer",
112 Summary: fmt.Sprintf("%s → series %s", d.Name, d.Series),
113 Detail: fmt.Sprintf("%s/%s/%s · %s", d.ForgejoBaseURL, d.ForgejoOwner, d.ForgejoRepo, strings.Join(scope, "+")),
114 })
115 }
116
117 rp, err := h.cfg.Store.ListPendingRadiclePeers()
118 if err != nil && errMsg == "" {
119 errMsg = err.Error()
120 }
121 for _, p := range rp {
122 rows = append(rows, pendingRow{
123 ID: p.ID, Type: "radicle",
124 Summary: fmt.Sprintf("%s → series %s", p.NodeID, p.Series),
125 Detail: p.Address,
126 })
127 }
128
129 render(w, "pending requests", "ADMIN", renderFragment(pendingListTmpl, map[string]any{
130 "Rows": rows, "Password": password, "Notice": notice, "Error": errMsg,
131 }))
132}
133
134// pendingAction handles the shared shape of approve/reject: check the
135// password (re-submitted from the list page, not a session), find the
136// request, run the caller's effect, then re-render the list with the
137// result — approve/reject are the only two things that can happen here,
138// so there's no separate confirmation step.
139func (h *Handler) pendingAction(w http.ResponseWriter, r *http.Request, action func(id int64, kind string) (string, error)) {
140 if r.Method != http.MethodPost {
141 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
142 return
143 }
144 ip := clientIP(r)
145 if !h.passwordLimiter.allowed(ip) {
146 h.renderPendingLogin(w, "too many attempts from your address — try again later")
147 return
148 }
149 if err := r.ParseForm(); err != nil {
150 h.renderPendingLogin(w, "couldn't read the form")
151 return
152 }
153 password := r.FormValue("password")
154 if !checkPassword(h.cfg, password) {
155 h.passwordLimiter.recordFailure(ip)
156 h.renderPendingLogin(w, "wrong admin password")
157 return
158 }
159 id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
160 if err != nil {
161 h.renderPendingList(w, password, "", "invalid request id")
162 return
163 }
164 notice, err := action(id, r.FormValue("type"))
165 if err != nil {
166 h.renderPendingList(w, password, "", err.Error())
167 return
168 }
169 h.renderPendingList(w, password, notice, "")
170}
171
172func (h *Handler) approvePending(w http.ResponseWriter, r *http.Request) {
173 h.pendingAction(w, r, func(id int64, kind string) (string, error) {
174 switch kind {
175 case "peer":
176 if err := h.cfg.Store.ApproveDynamicRepo(id); err != nil {
177 return "", err
178 }
179 return "Approved — live within one sync interval, no restart.", nil
180
181 case "radicle":
182 p, err := h.cfg.Store.GetPendingRadiclePeer(id)
183 if err != nil {
184 return "", err
185 }
186 if p == nil {
187 return "", fmt.Errorf("request not found — already handled?")
188 }
189 series := findSeries(h.cfg.Series(), p.Series)
190 if series == nil {
191 return "", fmt.Errorf("series %q no longer exists", p.Series)
192 }
193 if _, err := runRad(h.cfg.DefaultRadHome, "node", "connect", p.NodeID+"@"+p.Address); err != nil {
194 return "", fmt.Errorf("could not connect to that node: %w", err)
195 }
196 if _, err := runRad(h.cfg.DefaultRadHome, "seed", series.RadicleRID); err != nil {
197 return "", fmt.Errorf("connected, but could not seed the repository: %w", err)
198 }
199 if err := h.cfg.Store.DeletePendingRadiclePeer(id); err != nil {
200 return "", err
201 }
202 return "Connected and seeding.", nil
203
204 default:
205 return "", fmt.Errorf("unknown request type %q", kind)
206 }
207 })
208}
209
210func (h *Handler) rejectPending(w http.ResponseWriter, r *http.Request) {
211 h.pendingAction(w, r, func(id int64, kind string) (string, error) {
212 switch kind {
213 case "peer":
214 row, err := h.cfg.Store.GetDynamicRepo(id)
215 if err != nil {
216 return "", err
217 }
218 if row != nil && row.ForgejoTokenFile != "" {
219 _ = os.Remove(row.ForgejoTokenFile) // best-effort — don't leave the submitted token behind
220 }
221 if err := h.cfg.Store.RejectDynamicRepo(id); err != nil {
222 return "", err
223 }
224 return "Rejected.", nil
225
226 case "radicle":
227 if err := h.cfg.Store.DeletePendingRadiclePeer(id); err != nil {
228 return "", err
229 }
230 return "Rejected.", nil
231
232 default:
233 return "", fmt.Errorf("unknown request type %q", kind)
234 }
235 })
236}