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}}
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.
- On the Forgejo instance: create the repo with one initial commit. Generate a token scoped to
write:repository + write:issue.
- Clone it locally. Run
rad init --name <repo> --default-branch <branch> --public. Note the RID (rad:z...).
- To replicate to other Radicle nodes:
rad seed <rid> on each.
- Fill in the form below.
{{if .Error}}{{.Error}}
{{end}}
`))
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
}