package admin import ( "fmt" "html/template" "net/http" "os" "strconv" "strings" ) // pendingRow is one submitted request awaiting approval, whichever of the // two underlying tables (dynamic_repo, pending_radicle_peer) it came // from — unified here so the review page shows one list. type pendingRow struct { ID int64 Type string // "peer" (dynamic_repo — a Forgejo peer or a new repo) or "radicle" Summary string Detail string } var pendingLoginTmpl = template.Must(template.New("pending-login").Parse(`

Pending requests

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.

{{if .Error}}
{{.Error}}
{{end}}
`)) var pendingListTmpl = template.Must(template.New("pending-list").Parse(`

Pending requests

{{if .Notice}}
{{.Notice}}
{{end}} {{if .Error}}
{{.Error}}
{{end}} {{if not .Rows}}

Nothing pending.

{{end}} {{range .Rows}}
{{.Summary}}
{{.Detail}}
{{end}} `)) func (h *Handler) pending(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: h.renderPendingLogin(w, "") case http.MethodPost: ip := clientIP(r) if !h.passwordLimiter.allowed(ip) { h.renderPendingLogin(w, "too many attempts from your address — try again later") return } if err := r.ParseForm(); err != nil { h.renderPendingLogin(w, "couldn't read the form") return } password := r.FormValue("password") if !checkPassword(h.cfg, password) { h.passwordLimiter.recordFailure(ip) h.renderPendingLogin(w, "wrong admin password") return } h.renderPendingList(w, password, "", "") default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (h *Handler) renderPendingLogin(w http.ResponseWriter, errMsg string) { render(w, "pending requests", "ADMIN", renderFragment(pendingLoginTmpl, map[string]string{"Error": errMsg})) } func (h *Handler) renderPendingList(w http.ResponseWriter, password, notice, errMsg string) { var rows []pendingRow dr, err := h.cfg.Store.PendingDynamicRepos() if err != nil && errMsg == "" { errMsg = err.Error() } for _, d := range dr { var scope []string if d.SyncGit { scope = append(scope, "git") } if d.SyncIssues { scope = append(scope, "issues") } if d.SyncPatches { scope = append(scope, "patches") } rows = append(rows, pendingRow{ ID: d.ID, Type: "peer", Summary: fmt.Sprintf("%s → series %s", d.Name, d.Series), Detail: fmt.Sprintf("%s/%s/%s · %s", d.ForgejoBaseURL, d.ForgejoOwner, d.ForgejoRepo, strings.Join(scope, "+")), }) } rp, err := h.cfg.Store.ListPendingRadiclePeers() if err != nil && errMsg == "" { errMsg = err.Error() } for _, p := range rp { rows = append(rows, pendingRow{ ID: p.ID, Type: "radicle", Summary: fmt.Sprintf("%s → series %s", p.NodeID, p.Series), Detail: p.Address, }) } render(w, "pending requests", "ADMIN", renderFragment(pendingListTmpl, map[string]any{ "Rows": rows, "Password": password, "Notice": notice, "Error": errMsg, })) } // pendingAction handles the shared shape of approve/reject: check the // password (re-submitted from the list page, not a session), find the // request, run the caller's effect, then re-render the list with the // result — approve/reject are the only two things that can happen here, // so there's no separate confirmation step. func (h *Handler) pendingAction(w http.ResponseWriter, r *http.Request, action func(id int64, kind string) (string, error)) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } ip := clientIP(r) if !h.passwordLimiter.allowed(ip) { h.renderPendingLogin(w, "too many attempts from your address — try again later") return } if err := r.ParseForm(); err != nil { h.renderPendingLogin(w, "couldn't read the form") return } password := r.FormValue("password") if !checkPassword(h.cfg, password) { h.passwordLimiter.recordFailure(ip) h.renderPendingLogin(w, "wrong admin password") return } id, err := strconv.ParseInt(r.FormValue("id"), 10, 64) if err != nil { h.renderPendingList(w, password, "", "invalid request id") return } notice, err := action(id, r.FormValue("type")) if err != nil { h.renderPendingList(w, password, "", err.Error()) return } h.renderPendingList(w, password, notice, "") } func (h *Handler) approvePending(w http.ResponseWriter, r *http.Request) { h.pendingAction(w, r, func(id int64, kind string) (string, error) { switch kind { case "peer": if err := h.cfg.Store.ApproveDynamicRepo(id); err != nil { return "", err } return "Approved — live within one sync interval, no restart.", nil case "radicle": p, err := h.cfg.Store.GetPendingRadiclePeer(id) if err != nil { return "", err } if p == nil { return "", fmt.Errorf("request not found — already handled?") } series := findSeries(h.cfg.Series(), p.Series) if series == nil { return "", fmt.Errorf("series %q no longer exists", p.Series) } if _, err := runRad(h.cfg.DefaultRadHome, "node", "connect", p.NodeID+"@"+p.Address); err != nil { return "", fmt.Errorf("could not connect to that node: %w", err) } if _, err := runRad(h.cfg.DefaultRadHome, "seed", series.RadicleRID); err != nil { return "", fmt.Errorf("connected, but could not seed the repository: %w", err) } if err := h.cfg.Store.DeletePendingRadiclePeer(id); err != nil { return "", err } return "Connected and seeding.", nil default: return "", fmt.Errorf("unknown request type %q", kind) } }) } func (h *Handler) rejectPending(w http.ResponseWriter, r *http.Request) { h.pendingAction(w, r, func(id int64, kind string) (string, error) { switch kind { case "peer": row, err := h.cfg.Store.GetDynamicRepo(id) if err != nil { return "", err } if row != nil && row.ForgejoTokenFile != "" { _ = os.Remove(row.ForgejoTokenFile) // best-effort — don't leave the submitted token behind } if err := h.cfg.Store.RejectDynamicRepo(id); err != nil { return "", err } return "Rejected.", nil case "radicle": if err := h.cfg.Store.DeletePendingRadiclePeer(id); err != nil { return "", err } return "Rejected.", nil default: return "", fmt.Errorf("unknown request type %q", kind) } }) }