package admin import ( "fmt" "html/template" "net/http" "net/url" "os" "path/filepath" "regexp" "strings" "graft/internal/state" ) // validName matches what's safe to use both as a token filename and as a // dynamic_repo primary key — deliberately narrow rather than trying to // escape whatever isn't. var validName = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,80}$`) var addPeerTmpl = template.Must(template.New("add-peer").Parse(`

Add a peer to an existing federation

Joins a Forgejo instance to a repo already mirrored elsewhere.

{{if .Error}}
{{.Error}}
{{end}}
Written to its own chmod-600 file on submit. Not kept in this form, not logged. From a third party: use one-time share.
Know it? Enter it to go live immediately. Leave it blank to submit for the graft admin to review and approve instead.

Add a Radicle peer instead →

`)) var newRepoTmpl = template.Must(template.New("new-repo").Parse(`

Start a new repo

Mirrors a repo on a new Forgejo instance and a new Radicle node.

  1. On the Forgejo instance: create the repo with one initial commit. Generate a token scoped to write:repository + write:issue.
  2. Clone it locally. Run rad init --name <repo> --default-branch <branch> --public. Note the RID (rad:z...).
  3. To replicate to other Radicle nodes: rad seed <rid> on each.
  4. Fill in the form below.
{{if .Error}}
{{.Error}}
{{end}}
Got it from someone else safely? See one-time share.
Know it? Enter it to go live immediately. Leave it blank to submit for the graft admin to review and approve instead.
`)) var onboardOKTmpl = template.Must(template.New("onboard-ok").Parse(`

{{.Title}}

{{if .Approved}}
{{.Name}} is live within one sync interval — no restart.
{{else}}
{{.Name}} submitted. Waiting on the graft admin to review and approve it.
{{end}}

← Back to the dashboard

`)) func (h *Handler) addPeer(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: h.renderAddPeer(w, map[string]any{"Series": seriesNames(h.cfg.Series())}) case http.MethodPost: h.submitAddPeer(w, r) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func seriesNames(s []SeriesInfo) []string { out := make([]string, 0, len(s)) for _, si := range s { out = append(out, si.Name) } return out } func (h *Handler) renderAddPeer(w http.ResponseWriter, data map[string]any) { render(w, "add a peer", "SELF-SERVICE ONBOARDING", renderFragment(addPeerTmpl, data)) } func (h *Handler) submitAddPeer(w http.ResponseWriter, r *http.Request) { ip := clientIP(r) if !h.submitLimiter.allowed(ip) { h.renderAddPeer(w, map[string]any{"Series": seriesNames(h.cfg.Series()), "Error": "too many submissions from your address — try again later"}) return } if err := r.ParseForm(); err != nil { h.renderAddPeer(w, map[string]any{"Series": seriesNames(h.cfg.Series()), "Error": "couldn't read the form"}) return } form := formData(r, "series", "name", "base_url", "owner", "repo", "token", "password") retry := func(msg string) { h.renderAddPeer(w, map[string]any{ "Series": seriesNames(h.cfg.Series()), "Error": msg, "SelectedSeries": form["series"], "Name": form["name"], "BaseURL": form["base_url"], "Owner": form["owner"], "Repo": form["repo"], }) } h.submitLimiter.recordFailure(ip) // counts every submission attempt, not just invalid ones — bounds queue-flooding volume if !validName.MatchString(form["name"]) { retry("pair name must be letters, digits, dots, dashes or underscores only") return } if form["token"] = strings.TrimSpace(r.FormValue("token")); form["token"] == "" { retry("a Forgejo token is required") return } series := findSeries(h.cfg.Series(), form["series"]) if series == nil { retry("pick a federation to join") return } if err := validateForgejo(form["base_url"], form["owner"], form["repo"]); err != nil { retry(err.Error()) return } // The password is optional: given and correct, this goes live right // away (the "hand out the password for a class/workshop" path); // given and wrong, reject outright — never silently fall back to the // approval queue, which would make wrong-password attempts // indistinguishable from a real submission; left blank, it queues for // the admin to review, same as a stranger submitting with no password // at all. autoApprove := false if form["password"] != "" { if !checkPassword(h.cfg, form["password"]) { h.passwordLimiter.recordFailure(ip) retry("wrong admin password") return } autoApprove = true } tokenFile, err := writeTokenFile(h.cfg.TokenDir, form["name"], form["token"]) if err != nil { retry("could not save the token: " + err.Error()) return } id, err := h.cfg.Store.CreateDynamicRepo(state.DynamicRepo{ Name: form["name"], Series: series.Name, ForgejoBaseURL: form["base_url"], ForgejoOwner: form["owner"], ForgejoRepo: form["repo"], ForgejoTokenFile: tokenFile, RadicleRID: series.RadicleRID, RadicleHTTPBaseURL: series.RadicleHTTPBaseURL, RadicleExplorerURL: series.RadicleExplorerURL, RadicleRadHome: h.cfg.DefaultRadHome, SyncGit: r.FormValue("sync_git") != "", SyncIssues: r.FormValue("sync_issues") != "", SyncPatches: r.FormValue("sync_patches") != "", }) if err == nil && autoApprove { err = h.cfg.Store.ApproveDynamicRepo(id) } if err != nil { retry("could not save: " + err.Error()) return } title := "Peer submitted" if autoApprove { title = "Peer added" } render(w, title, "SELF-SERVICE ONBOARDING", renderFragment(onboardOKTmpl, map[string]any{"Title": title, "Name": form["name"], "Approved": autoApprove})) } func (h *Handler) newRepo(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: h.renderNewRepo(w, map[string]any{}) case http.MethodPost: h.submitNewRepo(w, r) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (h *Handler) renderNewRepo(w http.ResponseWriter, data map[string]any) { render(w, "start a new repo", "SELF-SERVICE ONBOARDING", renderFragment(newRepoTmpl, data)) } func (h *Handler) submitNewRepo(w http.ResponseWriter, r *http.Request) { ip := clientIP(r) if !h.submitLimiter.allowed(ip) { h.renderNewRepo(w, map[string]any{"Error": "too many submissions from your address — try again later"}) return } if err := r.ParseForm(); err != nil { h.renderNewRepo(w, map[string]any{"Error": "couldn't read the form"}) return } form := formData(r, "name", "base_url", "owner", "repo", "token", "rid", "rad_http", "explorer", "password") retry := func(msg string) { h.renderNewRepo(w, map[string]any{ "Error": msg, "Name": form["name"], "BaseURL": form["base_url"], "Owner": form["owner"], "Repo": form["repo"], "RID": form["rid"], "RadHTTP": form["rad_http"], "Explorer": form["explorer"], }) } h.submitLimiter.recordFailure(ip) if !validName.MatchString(form["name"]) { retry("repo name must be letters, digits, dots, dashes or underscores only") return } if form["token"] = strings.TrimSpace(r.FormValue("token")); form["token"] == "" { retry("a Forgejo token is required") return } if err := validateForgejo(form["base_url"], form["owner"], form["repo"]); err != nil { retry(err.Error()) return } if form["rid"] == "" || !strings.HasPrefix(form["rid"], "rad:") { retry("that doesn't look like a Radicle RID (expected rad:z...)") return } if _, err := url.ParseRequestURI(form["rad_http"]); err != nil { retry("the Radicle HTTP base URL isn't valid") return } autoApprove := false if form["password"] != "" { if !checkPassword(h.cfg, form["password"]) { h.passwordLimiter.recordFailure(ip) retry("wrong admin password") return } autoApprove = true } tokenFile, err := writeTokenFile(h.cfg.TokenDir, form["name"], form["token"]) if err != nil { retry("could not save the token: " + err.Error()) return } id, err := h.cfg.Store.CreateDynamicRepo(state.DynamicRepo{ Name: form["name"], Series: form["name"], ForgejoBaseURL: form["base_url"], ForgejoOwner: form["owner"], ForgejoRepo: form["repo"], ForgejoTokenFile: tokenFile, RadicleRID: form["rid"], RadicleHTTPBaseURL: form["rad_http"], RadicleExplorerURL: form["explorer"], RadicleRadHome: h.cfg.DefaultRadHome, SyncGit: r.FormValue("sync_git") != "", SyncIssues: r.FormValue("sync_issues") != "", SyncPatches: r.FormValue("sync_patches") != "", }) if err == nil && autoApprove { err = h.cfg.Store.ApproveDynamicRepo(id) } if err != nil { retry("could not save: " + err.Error()) return } title := "Repo submitted" if autoApprove { title = "Repo started" } render(w, title, "SELF-SERVICE ONBOARDING", renderFragment(onboardOKTmpl, map[string]any{"Title": title, "Name": form["name"], "Approved": autoApprove})) } func formData(r *http.Request, fields ...string) map[string]string { out := make(map[string]string, len(fields)) for _, f := range fields { out[f] = strings.TrimSpace(r.FormValue(f)) } return out } func findSeries(all []SeriesInfo, name string) *SeriesInfo { for _, s := range all { if s.Name == name { return &s } } return nil } func validateForgejo(baseURL, owner, repo string) error { u, err := url.ParseRequestURI(baseURL) if err != nil || u.Scheme != "https" || u.Host == "" { return fmt.Errorf("the Forgejo base URL must be a valid https:// URL") } if owner == "" || repo == "" { return fmt.Errorf("owner and repository are required") } return nil } // writeTokenFile saves a submitted token to its own chmod-600 file, // following the same convention as every config.yaml-declared pair. func writeTokenFile(dir, name, token string) (string, error) { if err := os.MkdirAll(dir, 0o700); err != nil { return "", err } path := filepath.Join(dir, name+".token") if err := os.WriteFile(path, []byte(token+"\n"), 0o600); err != nil { return "", err } return path, nil }