Federated via graft
0

Configure Feed

Select the types of activity you want to include in your feed.

Add request-and-approve onboarding, alongside the instant self-service path

Two mechanisms now coexist on the same forms, chosen by whether the
optional admin password field is filled in:

- Left blank: the submission becomes a pending request (dynamic_repo /
pending_radicle_peer, approved=0) that does nothing on its own — no
sync, no rad node connect, nothing touches f1/f2 or any Radicle node.
A stranger can propose a peer without ever knowing the admin password.
- Filled in and correct: unchanged from before — goes live immediately
(approved right away for a Forgejo peer/new repo; rad node connect +
rad seed run immediately for a Radicle peer). Still the right tool for
handing the password to a trusted group (a class, a workshop) who
should self-serve without a review step.
- Filled in and wrong: rejected outright, never silently downgraded to
a pending request — an oracle for guessing the password would defeat
the point of the existing rate limiter.

New /admin/pending: the only place the password gates anything now
*for the request path* — a stateless, no-session review list (every
Approve/Reject is its own password-carrying POST, consistent with how
/share and the rest of the admin surface already work) covering both
dynamic_repo requests and pending Radicle peer requests in one list.
Rejecting a Forgejo/repo request also deletes its already-written token
file, so a rejected submission leaves nothing behind.

dynamic_repo gets an `approved` column; UnmaterializedDynamicRepos and
the startup reload (AllDynamicRepos) now both filter on it, so a
pending-but-unapproved row can never be picked up by the sync loop by
accident. New pending_radicle_peer table for the Radicle-peer request
path, which needed no ongoing-pair storage at all — approval just runs
the two rad commands once and the row is gone.

Verified: 9 new functional tests (submission needs no password; wrong
password rejects outright rather than queuing; the pending review page
requires the password and never leaks data without it; approve/reject
only take effect with the correct password; approving activates a row;
rejecting removes it and its token file; the optional-password instant
path still works exactly as before; a Radicle approval fails gracefully
with no real node reachable) plus a live round trip against production
(submit with no password, confirm it's queued and invisible without
the admin password, reject it, confirm it's gone) and a Playwright pass
confirming zero CSP violations on the new review page.

+473 -85
+11 -4
internal/admin/admin.go
··· 67 67 Series func() []SeriesInfo 68 68 } 69 69 70 - // Handler serves every admin-facing route. Two independent rate limiters: 71 - // a wrong admin password and a wrong share passcode are different secrets 72 - // with different scopes, so a lockout on one never blocks the other. 70 + // Handler serves every admin-facing route. Three independent rate 71 + // limiters: a wrong admin password (used only on the /admin/pending 72 + // review page now), a wrong share passcode, and a plain volume cap on 73 + // onboarding submissions (which need no password at all — anyone can 74 + // propose a peer, only an admin approval activates it) are different 75 + // concerns, so a lockout on one never blocks the others. 73 76 type Handler struct { 74 77 cfg Config 75 78 passwordLimiter *rateLimiter 76 79 shareLimiter *rateLimiter 80 + submitLimiter *rateLimiter 77 81 } 78 82 79 83 func NewHandler(cfg Config) *Handler { 80 - return &Handler{cfg: cfg, passwordLimiter: newRateLimiter(), shareLimiter: newRateLimiter()} 84 + return &Handler{cfg: cfg, passwordLimiter: newRateLimiter(), shareLimiter: newRateLimiter(), submitLimiter: newRateLimiter()} 81 85 } 82 86 83 87 func (h *Handler) Register(mux *http.ServeMux) { ··· 86 90 mux.HandleFunc("/add-peer", h.addPeer) 87 91 mux.HandleFunc("/add-radicle-peer", h.addRadiclePeer) 88 92 mux.HandleFunc("/new-repo", h.newRepo) 93 + mux.HandleFunc("/admin/pending", h.pending) 94 + mux.HandleFunc("/admin/pending/approve", h.approvePending) 95 + mux.HandleFunc("/admin/pending/reject", h.rejectPending) 89 96 } 90 97 91 98 // checkPassword compares in constant time, so a wrong guess never takes
+63 -26
internal/admin/onboard.go
··· 48 48 49 49 <label for="token">Forgejo token</label> 50 50 <textarea id="token" name="token" required placeholder="scoped to write:repository + write:issue"></textarea> 51 - <div class="hint">Written to its own chmod-600 file on save. Not kept in this form, not logged. From a third party: use <a href="/share/new">one-time share</a>.</div> 51 + <div class="hint">Written to its own chmod-600 file on submit. Not kept in this form, not logged. From a third party: use <a href="/share/new">one-time share</a>.</div> 52 52 53 53 <label>Sync scope</label> 54 54 <div class="checks"> ··· 57 57 <label><input type="checkbox" name="sync_patches" checked> patches</label> 58 58 </div> 59 59 60 - <label for="password">Admin password</label> 61 - <input type="password" id="password" name="password" required autocomplete="off"> 60 + <label for="password">Admin password (optional)</label> 61 + <input type="password" id="password" name="password" autocomplete="off"> 62 + <div class="hint">Know it? Enter it to go live immediately. Leave it blank to submit for the graft admin to review and approve instead.</div> 62 63 63 - <button type="submit">Add peer</button> 64 + <button type="submit">Submit</button> 64 65 </form> 65 66 </div> 66 67 <p><a class="nav-link" href="/add-radicle-peer">Add a Radicle peer instead &rarr;</a></p> ··· 117 118 <label><input type="checkbox" name="sync_patches" checked> patches</label> 118 119 </div> 119 120 120 - <label for="password">Admin password</label> 121 - <input type="password" id="password" name="password" required autocomplete="off"> 121 + <label for="password">Admin password (optional)</label> 122 + <input type="password" id="password" name="password" autocomplete="off"> 123 + <div class="hint">Know it? Enter it to go live immediately. Leave it blank to submit for the graft admin to review and approve instead.</div> 122 124 123 - <button type="submit">Start mirroring</button> 125 + <button type="submit">Submit</button> 124 126 </form> 125 127 </div> 126 128 `)) 127 129 128 130 var onboardOKTmpl = template.Must(template.New("onboard-ok").Parse(` 129 131 <h1>{{.Title}}</h1> 130 - <div class="notice good">{{.Name}} saved.</div> 132 + {{if .Approved}} 133 + <div class="notice good">{{.Name}} is live within one sync interval — no restart.</div> 134 + {{else}} 135 + <div class="notice good">{{.Name}} submitted. Waiting on the graft admin to review and approve it.</div> 136 + {{end}} 131 137 <p><a class="nav-link" href="/">&larr; Back to the dashboard</a></p> 132 138 `)) 133 139 ··· 156 162 157 163 func (h *Handler) submitAddPeer(w http.ResponseWriter, r *http.Request) { 158 164 ip := clientIP(r) 159 - if !h.passwordLimiter.allowed(ip) { 160 - h.renderAddPeer(w, map[string]any{"Series": seriesNames(h.cfg.Series()), "Error": "too many attempts from your address — try again later"}) 165 + if !h.submitLimiter.allowed(ip) { 166 + h.renderAddPeer(w, map[string]any{"Series": seriesNames(h.cfg.Series()), "Error": "too many submissions from your address — try again later"}) 161 167 return 162 168 } 163 169 if err := r.ParseForm(); err != nil { ··· 171 177 "Name": form["name"], "BaseURL": form["base_url"], "Owner": form["owner"], "Repo": form["repo"], 172 178 }) 173 179 } 180 + h.submitLimiter.recordFailure(ip) // counts every submission attempt, not just invalid ones — bounds queue-flooding volume 174 181 175 - if !checkPassword(h.cfg, form["password"]) { 176 - h.passwordLimiter.recordFailure(ip) 177 - retry("wrong admin password") 178 - return 179 - } 180 182 if !validName.MatchString(form["name"]) { 181 183 retry("pair name must be letters, digits, dots, dashes or underscores only") 182 184 return ··· 194 196 retry(err.Error()) 195 197 return 196 198 } 199 + // The password is optional: given and correct, this goes live right 200 + // away (the "hand out the password for a class/workshop" path); 201 + // given and wrong, reject outright — never silently fall back to the 202 + // approval queue, which would make wrong-password attempts 203 + // indistinguishable from a real submission; left blank, it queues for 204 + // the admin to review, same as a stranger submitting with no password 205 + // at all. 206 + autoApprove := false 207 + if form["password"] != "" { 208 + if !checkPassword(h.cfg, form["password"]) { 209 + h.passwordLimiter.recordFailure(ip) 210 + retry("wrong admin password") 211 + return 212 + } 213 + autoApprove = true 214 + } 197 215 198 216 tokenFile, err := writeTokenFile(h.cfg.TokenDir, form["name"], form["token"]) 199 217 if err != nil { ··· 201 219 return 202 220 } 203 221 204 - _, err = h.cfg.Store.CreateDynamicRepo(state.DynamicRepo{ 222 + id, err := h.cfg.Store.CreateDynamicRepo(state.DynamicRepo{ 205 223 Name: form["name"], Series: series.Name, 206 224 ForgejoBaseURL: form["base_url"], ForgejoOwner: form["owner"], ForgejoRepo: form["repo"], ForgejoTokenFile: tokenFile, 207 225 RadicleRID: series.RadicleRID, RadicleHTTPBaseURL: series.RadicleHTTPBaseURL, RadicleExplorerURL: series.RadicleExplorerURL, 208 226 RadicleRadHome: h.cfg.DefaultRadHome, 209 227 SyncGit: r.FormValue("sync_git") != "", SyncIssues: r.FormValue("sync_issues") != "", SyncPatches: r.FormValue("sync_patches") != "", 210 228 }) 229 + if err == nil && autoApprove { 230 + err = h.cfg.Store.ApproveDynamicRepo(id) 231 + } 211 232 if err != nil { 212 233 retry("could not save: " + err.Error()) 213 234 return 214 235 } 215 - render(w, "peer added", "SELF-SERVICE ONBOARDING", renderFragment(onboardOKTmpl, map[string]string{"Title": "Peer added", "Name": form["name"]})) 236 + title := "Peer submitted" 237 + if autoApprove { 238 + title = "Peer added" 239 + } 240 + render(w, title, "SELF-SERVICE ONBOARDING", renderFragment(onboardOKTmpl, map[string]any{"Title": title, "Name": form["name"], "Approved": autoApprove})) 216 241 } 217 242 218 243 func (h *Handler) newRepo(w http.ResponseWriter, r *http.Request) { ··· 232 257 233 258 func (h *Handler) submitNewRepo(w http.ResponseWriter, r *http.Request) { 234 259 ip := clientIP(r) 235 - if !h.passwordLimiter.allowed(ip) { 236 - h.renderNewRepo(w, map[string]any{"Error": "too many attempts from your address — try again later"}) 260 + if !h.submitLimiter.allowed(ip) { 261 + h.renderNewRepo(w, map[string]any{"Error": "too many submissions from your address — try again later"}) 237 262 return 238 263 } 239 264 if err := r.ParseForm(); err != nil { ··· 247 272 "RID": form["rid"], "RadHTTP": form["rad_http"], "Explorer": form["explorer"], 248 273 }) 249 274 } 275 + h.submitLimiter.recordFailure(ip) 250 276 251 - if !checkPassword(h.cfg, form["password"]) { 252 - h.passwordLimiter.recordFailure(ip) 253 - retry("wrong admin password") 254 - return 255 - } 256 277 if !validName.MatchString(form["name"]) { 257 278 retry("repo name must be letters, digits, dots, dashes or underscores only") 258 279 return ··· 272 293 if _, err := url.ParseRequestURI(form["rad_http"]); err != nil { 273 294 retry("the Radicle HTTP base URL isn't valid") 274 295 return 296 + } 297 + autoApprove := false 298 + if form["password"] != "" { 299 + if !checkPassword(h.cfg, form["password"]) { 300 + h.passwordLimiter.recordFailure(ip) 301 + retry("wrong admin password") 302 + return 303 + } 304 + autoApprove = true 275 305 } 276 306 277 307 tokenFile, err := writeTokenFile(h.cfg.TokenDir, form["name"], form["token"]) ··· 280 310 return 281 311 } 282 312 283 - _, err = h.cfg.Store.CreateDynamicRepo(state.DynamicRepo{ 313 + id, err := h.cfg.Store.CreateDynamicRepo(state.DynamicRepo{ 284 314 Name: form["name"], Series: form["name"], 285 315 ForgejoBaseURL: form["base_url"], ForgejoOwner: form["owner"], ForgejoRepo: form["repo"], ForgejoTokenFile: tokenFile, 286 316 RadicleRID: form["rid"], RadicleHTTPBaseURL: form["rad_http"], RadicleExplorerURL: form["explorer"], 287 317 RadicleRadHome: h.cfg.DefaultRadHome, 288 318 SyncGit: r.FormValue("sync_git") != "", SyncIssues: r.FormValue("sync_issues") != "", SyncPatches: r.FormValue("sync_patches") != "", 289 319 }) 320 + if err == nil && autoApprove { 321 + err = h.cfg.Store.ApproveDynamicRepo(id) 322 + } 290 323 if err != nil { 291 324 retry("could not save: " + err.Error()) 292 325 return 293 326 } 294 - render(w, "repo started", "SELF-SERVICE ONBOARDING", renderFragment(onboardOKTmpl, map[string]string{"Title": "Repo started", "Name": form["name"]})) 327 + title := "Repo submitted" 328 + if autoApprove { 329 + title = "Repo started" 330 + } 331 + render(w, title, "SELF-SERVICE ONBOARDING", renderFragment(onboardOKTmpl, map[string]any{"Title": title, "Name": form["name"], "Approved": autoApprove})) 295 332 } 296 333 297 334 func formData(r *http.Request, fields ...string) map[string]string {
+236
internal/admin/pending.go
··· 1 + package admin 2 + 3 + import ( 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. 15 + type 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 + 22 + var 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 + 35 + var 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 + 62 + func (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 + 88 + func (h *Handler) renderPendingLogin(w http.ResponseWriter, errMsg string) { 89 + render(w, "pending requests", "ADMIN", renderFragment(pendingLoginTmpl, map[string]string{"Error": errMsg})) 90 + } 91 + 92 + func (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. 139 + func (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 + 172 + func (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 + 210 + func (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 + }
+26 -15
internal/admin/radicle_peer.go
··· 55 55 <label for="address">Address</label> 56 56 <input type="text" id="address" name="address" required value="{{.Address}}" placeholder="host:8776"> 57 57 58 - <label for="password">Admin password</label> 59 - <input type="password" id="password" name="password" required autocomplete="off"> 58 + <label for="password">Admin password (optional)</label> 59 + <input type="password" id="password" name="password" autocomplete="off"> 60 + <div class="hint">Know it? Enter it to connect &amp; seed immediately. Leave it blank to submit for the graft admin to review and approve instead.</div> 60 61 61 - <button type="submit">Connect &amp; seed</button> 62 + <button type="submit">Submit</button> 62 63 </form> 63 64 </div> 64 65 <p><a class="nav-link" href="/add-peer">Add a Forgejo peer instead &rarr;</a></p> ··· 81 82 82 83 func (h *Handler) submitAddRadiclePeer(w http.ResponseWriter, r *http.Request) { 83 84 ip := clientIP(r) 84 - if !h.passwordLimiter.allowed(ip) { 85 - h.renderAddRadiclePeer(w, map[string]any{"Series": seriesNames(h.cfg.Series()), "Error": "too many attempts from your address — try again later"}) 85 + if !h.submitLimiter.allowed(ip) { 86 + h.renderAddRadiclePeer(w, map[string]any{"Series": seriesNames(h.cfg.Series()), "Error": "too many submissions from your address — try again later"}) 86 87 return 87 88 } 88 89 if err := r.ParseForm(); err != nil { ··· 96 97 "NodeID": form["node_id"], "Address": form["address"], 97 98 }) 98 99 } 100 + h.submitLimiter.recordFailure(ip) 99 101 100 - if !checkPassword(h.cfg, form["password"]) { 101 - h.passwordLimiter.recordFailure(ip) 102 - retry("wrong admin password") 103 - return 104 - } 105 102 series := findSeries(h.cfg.Series(), form["series"]) 106 103 if series == nil { 107 104 retry("pick a federation to join") ··· 116 113 return 117 114 } 118 115 119 - if _, err := runRad(h.cfg.DefaultRadHome, "node", "connect", form["node_id"]+"@"+form["address"]); err != nil { 120 - retry("could not connect to that node: " + err.Error()) 116 + if form["password"] != "" { 117 + if !checkPassword(h.cfg, form["password"]) { 118 + h.passwordLimiter.recordFailure(ip) 119 + retry("wrong admin password") 120 + return 121 + } 122 + if _, err := runRad(h.cfg.DefaultRadHome, "node", "connect", form["node_id"]+"@"+form["address"]); err != nil { 123 + retry("could not connect to that node: " + err.Error()) 124 + return 125 + } 126 + if _, err := runRad(h.cfg.DefaultRadHome, "seed", series.RadicleRID); err != nil { 127 + retry("connected, but could not seed the repository: " + err.Error()) 128 + return 129 + } 130 + render(w, "radicle peer added", "SELF-SERVICE ONBOARDING", renderFragment(onboardOKTmpl, map[string]any{"Title": "Radicle peer added", "Name": form["node_id"], "Approved": true})) 121 131 return 122 132 } 123 - if _, err := runRad(h.cfg.DefaultRadHome, "seed", series.RadicleRID); err != nil { 124 - retry("connected, but could not seed the repository: " + err.Error()) 133 + 134 + if _, err := h.cfg.Store.CreatePendingRadiclePeer(series.Name, form["node_id"], form["address"]); err != nil { 135 + retry("could not save: " + err.Error()) 125 136 return 126 137 } 127 138 128 - render(w, "radicle peer added", "SELF-SERVICE ONBOARDING", renderFragment(onboardOKTmpl, map[string]string{"Title": "Radicle peer added", "Name": form["node_id"]})) 139 + render(w, "radicle peer submitted", "SELF-SERVICE ONBOARDING", renderFragment(onboardOKTmpl, map[string]any{"Title": "Radicle peer submitted", "Name": form["node_id"], "Approved": false})) 129 140 }
+136 -40
internal/state/store.go
··· 185 185 series TEXT PRIMARY KEY, 186 186 cursor TEXT NOT NULL DEFAULT '' 187 187 ); 188 + 189 + -- A submitted "add a Radicle peer" request awaiting admin approval. 190 + -- Unlike dynamic_repo this never becomes an ongoing sync pair — approval 191 + -- just runs rad node connect + rad seed once, then the row is gone. 192 + CREATE TABLE IF NOT EXISTS pending_radicle_peer ( 193 + id INTEGER PRIMARY KEY AUTOINCREMENT, 194 + series TEXT NOT NULL, 195 + node_id TEXT NOT NULL, 196 + address TEXT NOT NULL, 197 + created_at TEXT NOT NULL 198 + ); 188 199 `) 189 200 if err != nil { 190 201 return err ··· 199 210 `ALTER TABLE activity_log ADD COLUMN forgejo_id INTEGER NOT NULL DEFAULT 0`, 200 211 `ALTER TABLE activity_log ADD COLUMN radicle_id TEXT NOT NULL DEFAULT ''`, 201 212 `ALTER TABLE activity_log ADD COLUMN origin TEXT NOT NULL DEFAULT ''`, 213 + `ALTER TABLE dynamic_repo ADD COLUMN approved INTEGER NOT NULL DEFAULT 0`, 202 214 } { 203 215 if _, err := db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { 204 216 return err ··· 628 640 SyncIssues bool 629 641 SyncPatches bool 630 642 Materialized bool 643 + Approved bool 631 644 } 632 645 633 - // CreateDynamicRepo inserts a newly-submitted repo pair, active as soon 634 - // as the next sync pass picks it up. Fails if the name is already taken — 635 - // the same uniqueness graft would need across config.yaml pairs anyway. 646 + // CreateDynamicRepo inserts a newly-submitted repo pair as an unapproved 647 + // request — it never joins the sync loop until ApproveDynamicRepo is 648 + // called. Fails if the name is already taken — the same uniqueness 649 + // graft would need across config.yaml pairs anyway. 636 650 func (s *Store) CreateDynamicRepo(r DynamicRepo) (int64, error) { 637 651 res, err := s.db.Exec(` 638 652 INSERT INTO dynamic_repo ( 639 653 name, series, forgejo_base_url, forgejo_owner, forgejo_repo, forgejo_token_file, 640 654 radicle_rid, radicle_http_base_url, radicle_explorer_url, radicle_rad_home, 641 - sync_git, sync_issues, sync_patches, created_at, materialized 642 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) 655 + sync_git, sync_issues, sync_patches, created_at, materialized, approved 656 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0) 643 657 `, r.Name, r.Series, r.ForgejoBaseURL, r.ForgejoOwner, r.ForgejoRepo, r.ForgejoTokenFile, 644 658 r.RadicleRID, r.RadicleHTTPBaseURL, r.RadicleExplorerURL, r.RadicleRadHome, 645 659 boolToInt(r.SyncGit), boolToInt(r.SyncIssues), boolToInt(r.SyncPatches), time.Now().UTC().Format(time.RFC3339)) ··· 649 663 return res.LastInsertId() 650 664 } 651 665 652 - // UnmaterializedDynamicRepos returns every dynamic_repo row the running 653 - // daemon hasn't yet turned into a live RepoSyncer. 654 - func (s *Store) UnmaterializedDynamicRepos() ([]DynamicRepo, error) { 655 - rows, err := s.db.Query(` 656 - SELECT id, name, series, forgejo_base_url, forgejo_owner, forgejo_repo, forgejo_token_file, 666 + const dynamicRepoColumns = `id, name, series, forgejo_base_url, forgejo_owner, forgejo_repo, forgejo_token_file, 657 667 radicle_rid, radicle_http_base_url, radicle_explorer_url, radicle_rad_home, 658 - sync_git, sync_issues, sync_patches 659 - FROM dynamic_repo WHERE materialized = 0 660 - `) 661 - if err != nil { 662 - return nil, err 663 - } 668 + sync_git, sync_issues, sync_patches, materialized, approved` 669 + 670 + func scanDynamicRepoRows(rows *sql.Rows) ([]DynamicRepo, error) { 664 671 defer rows.Close() 665 672 var out []DynamicRepo 666 673 for rows.Next() { 667 674 var r DynamicRepo 668 - var g, i, p int 675 + var g, i, p, m, a int 669 676 if err := rows.Scan(&r.ID, &r.Name, &r.Series, &r.ForgejoBaseURL, &r.ForgejoOwner, &r.ForgejoRepo, &r.ForgejoTokenFile, 670 - &r.RadicleRID, &r.RadicleHTTPBaseURL, &r.RadicleExplorerURL, &r.RadicleRadHome, &g, &i, &p); err != nil { 677 + &r.RadicleRID, &r.RadicleHTTPBaseURL, &r.RadicleExplorerURL, &r.RadicleRadHome, &g, &i, &p, &m, &a); err != nil { 671 678 return nil, err 672 679 } 673 - r.SyncGit, r.SyncIssues, r.SyncPatches = g != 0, i != 0, p != 0 680 + r.SyncGit, r.SyncIssues, r.SyncPatches, r.Materialized, r.Approved = g != 0, i != 0, p != 0, m != 0, a != 0 674 681 out = append(out, r) 675 682 } 676 683 return out, rows.Err() 677 684 } 678 685 686 + // PendingDynamicRepos returns every submitted peer/repo request still 687 + // awaiting admin approval. 688 + func (s *Store) PendingDynamicRepos() ([]DynamicRepo, error) { 689 + rows, err := s.db.Query(`SELECT ` + dynamicRepoColumns + ` FROM dynamic_repo WHERE approved = 0`) 690 + if err != nil { 691 + return nil, err 692 + } 693 + return scanDynamicRepoRows(rows) 694 + } 695 + 696 + // GetDynamicRepo looks up one row by id, e.g. to recover its token file 697 + // path before deleting a rejected request. 698 + func (s *Store) GetDynamicRepo(id int64) (*DynamicRepo, error) { 699 + rows, err := s.db.Query(`SELECT `+dynamicRepoColumns+` FROM dynamic_repo WHERE id = ?`, id) 700 + if err != nil { 701 + return nil, err 702 + } 703 + out, err := scanDynamicRepoRows(rows) 704 + if err != nil || len(out) == 0 { 705 + return nil, err 706 + } 707 + return &out[0], nil 708 + } 709 + 710 + // ApproveDynamicRepo marks a pending request approved — the next sync 711 + // pass turns it into a live RepoSyncer (see UnmaterializedDynamicRepos). 712 + func (s *Store) ApproveDynamicRepo(id int64) error { 713 + _, err := s.db.Exec(`UPDATE dynamic_repo SET approved = 1 WHERE id = ?`, id) 714 + return err 715 + } 716 + 717 + // RejectDynamicRepo permanently removes a pending request. 718 + func (s *Store) RejectDynamicRepo(id int64) error { 719 + _, err := s.db.Exec(`DELETE FROM dynamic_repo WHERE id = ?`, id) 720 + return err 721 + } 722 + 723 + // UnmaterializedDynamicRepos returns every approved dynamic_repo row the 724 + // running daemon hasn't yet turned into a live RepoSyncer. 725 + func (s *Store) UnmaterializedDynamicRepos() ([]DynamicRepo, error) { 726 + rows, err := s.db.Query(`SELECT ` + dynamicRepoColumns + ` FROM dynamic_repo WHERE approved = 1 AND materialized = 0`) 727 + if err != nil { 728 + return nil, err 729 + } 730 + return scanDynamicRepoRows(rows) 731 + } 732 + 679 733 // MarkDynamicRepoMaterialized flips a row so it's never re-registered on 680 734 // a later pass. 681 735 func (s *Store) MarkDynamicRepoMaterialized(id int64) error { ··· 683 737 return err 684 738 } 685 739 686 - // AllDynamicRepos returns every dynamic_repo row regardless of 740 + // AllDynamicRepos returns every approved dynamic_repo row regardless of 687 741 // materialization state — used at startup to rebuild the full set of 688 742 // live pairs after a restart, since these rows persist across it but 689 - // config.yaml doesn't know about them. 743 + // config.yaml doesn't know about them. Never includes a row still 744 + // awaiting approval. 690 745 func (s *Store) AllDynamicRepos() ([]DynamicRepo, error) { 691 - rows, err := s.db.Query(` 692 - SELECT id, name, series, forgejo_base_url, forgejo_owner, forgejo_repo, forgejo_token_file, 693 - radicle_rid, radicle_http_base_url, radicle_explorer_url, radicle_rad_home, 694 - sync_git, sync_issues, sync_patches 695 - FROM dynamic_repo 696 - `) 746 + rows, err := s.db.Query(`SELECT ` + dynamicRepoColumns + ` FROM dynamic_repo WHERE approved = 1`) 697 747 if err != nil { 698 748 return nil, err 699 749 } 700 - defer rows.Close() 701 - var out []DynamicRepo 702 - for rows.Next() { 703 - var r DynamicRepo 704 - var g, i, p int 705 - if err := rows.Scan(&r.ID, &r.Name, &r.Series, &r.ForgejoBaseURL, &r.ForgejoOwner, &r.ForgejoRepo, &r.ForgejoTokenFile, 706 - &r.RadicleRID, &r.RadicleHTTPBaseURL, &r.RadicleExplorerURL, &r.RadicleRadHome, &g, &i, &p); err != nil { 707 - return nil, err 708 - } 709 - r.SyncGit, r.SyncIssues, r.SyncPatches = g != 0, i != 0, p != 0 710 - out = append(out, r) 711 - } 712 - return out, rows.Err() 750 + return scanDynamicRepoRows(rows) 713 751 } 714 752 715 753 func boolToInt(b bool) int { ··· 780 818 `, series, cursor) 781 819 return err 782 820 } 821 + 822 + // PendingRadiclePeer is one submitted "add a Radicle peer" request 823 + // awaiting admin approval. 824 + type PendingRadiclePeer struct { 825 + ID int64 826 + Series string 827 + NodeID string 828 + Address string 829 + } 830 + 831 + // CreatePendingRadiclePeer records a submitted request. 832 + func (s *Store) CreatePendingRadiclePeer(series, nodeID, address string) (int64, error) { 833 + res, err := s.db.Exec(`INSERT INTO pending_radicle_peer (series, node_id, address, created_at) VALUES (?, ?, ?, ?)`, 834 + series, nodeID, address, time.Now().UTC().Format(time.RFC3339)) 835 + if err != nil { 836 + return 0, err 837 + } 838 + return res.LastInsertId() 839 + } 840 + 841 + // ListPendingRadiclePeers returns every request awaiting approval. 842 + func (s *Store) ListPendingRadiclePeers() ([]PendingRadiclePeer, error) { 843 + rows, err := s.db.Query(`SELECT id, series, node_id, address FROM pending_radicle_peer`) 844 + if err != nil { 845 + return nil, err 846 + } 847 + defer rows.Close() 848 + var out []PendingRadiclePeer 849 + for rows.Next() { 850 + var p PendingRadiclePeer 851 + if err := rows.Scan(&p.ID, &p.Series, &p.NodeID, &p.Address); err != nil { 852 + return nil, err 853 + } 854 + out = append(out, p) 855 + } 856 + return out, rows.Err() 857 + } 858 + 859 + // GetPendingRadiclePeer looks up one request by id. 860 + func (s *Store) GetPendingRadiclePeer(id int64) (*PendingRadiclePeer, error) { 861 + row := s.db.QueryRow(`SELECT id, series, node_id, address FROM pending_radicle_peer WHERE id = ?`, id) 862 + var p PendingRadiclePeer 863 + err := row.Scan(&p.ID, &p.Series, &p.NodeID, &p.Address) 864 + if err == sql.ErrNoRows { 865 + return nil, nil 866 + } 867 + if err != nil { 868 + return nil, err 869 + } 870 + return &p, nil 871 + } 872 + 873 + // DeletePendingRadiclePeer removes a request — called after it's been 874 + // approved and acted on, or rejected. 875 + func (s *Store) DeletePendingRadiclePeer(id int64) error { 876 + _, err := s.db.Exec(`DELETE FROM pending_radicle_peer WHERE id = ?`, id) 877 + return err 878 + }
+1
internal/status/status.go
··· 956 956 <footer> 957 957 {{if .SourceURL}}<a href="{{.SourceURL}}" target="_blank" rel="noopener">{{.SourceURL}}</a>{{end}} 958 958 <a href="/share/new">Share a secret, once &rarr;</a> 959 + <a href="/admin/pending">Pending requests &rarr;</a> 959 960 <span>GPLv3</span> 960 961 </footer> 961 962 </body>