Federated via graft
0

Configure Feed

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

graft-source / internal / admin / pending.go
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}