Federated via graft
0

Configure Feed

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

1package admin 2 3import ( 4 "fmt" 5 "html/template" 6 "net/http" 7 "net/url" 8 "os" 9 "path/filepath" 10 "regexp" 11 "strings" 12 13 "graft/internal/state" 14) 15 16// validName matches what's safe to use both as a token filename and as a 17// dynamic_repo primary key — deliberately narrow rather than trying to 18// escape whatever isn't. 19var validName = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,80}$`) 20 21var addPeerTmpl = template.Must(template.New("add-peer").Parse(` 22<h1>Add a peer to an existing federation</h1> 23<p class="lead">Joins a Forgejo instance to a repo already mirrored elsewhere.</p> 24{{if .Error}}<div class="notice bad">{{.Error}}</div>{{end}} 25<div class="card"> 26 <form method="POST" action="/add-peer"> 27 <label for="series">Federation (series)</label> 28 <select id="series" name="series" required> 29 <option value="">— choose —</option> 30 {{range .Series}}<option value="{{.}}" {{if eq . $.SelectedSeries}}selected{{end}}>{{.}}</option>{{end}} 31 </select> 32 33 <label for="name">Peer name</label> 34 <input type="text" id="name" name="name" required maxlength="80" pattern="[a-zA-Z0-9._-]+" value="{{.Name}}" placeholder="e.g. federation-x-gitadmin"> 35 36 <div class="row"> 37 <div> 38 <label for="base_url">Forgejo base URL</label> 39 <input type="url" id="base_url" name="base_url" required value="{{.BaseURL}}" placeholder="https://git.example.org"> 40 </div> 41 <div> 42 <label for="owner">Owner</label> 43 <input type="text" id="owner" name="owner" required value="{{.Owner}}"> 44 </div> 45 </div> 46 <label for="repo">Repository</label> 47 <input type="text" id="repo" name="repo" required value="{{.Repo}}"> 48 49 <label for="token">Forgejo token</label> 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 submit. Not kept in this form, not logged. From a third party: use <a href="/share/new">one-time share</a>.</div> 52 53 <label>Sync scope</label> 54 <div class="checks"> 55 <label><input type="checkbox" name="sync_git" checked> git</label> 56 <label><input type="checkbox" name="sync_issues" checked> issues</label> 57 <label><input type="checkbox" name="sync_patches" checked> patches</label> 58 </div> 59 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> 63 64 <button type="submit">Submit</button> 65 </form> 66</div> 67<p><a class="nav-link" href="/add-radicle-peer">Add a Radicle peer instead &rarr;</a></p> 68`)) 69 70var newRepoTmpl = template.Must(template.New("new-repo").Parse(` 71<h1>Start a new repo</h1> 72<p class="lead">Mirrors a repo on a new Forgejo instance and a new Radicle node.</p> 73<ol class="steps"> 74 <li>On the Forgejo instance: create the repo with one initial commit. Generate a token scoped to <code>write:repository</code> + <code>write:issue</code>.</li> 75 <li>Clone it locally. Run <code>rad init --name &lt;repo&gt; --default-branch &lt;branch&gt; --public</code>. Note the RID (<code>rad:z...</code>).</li> 76 <li>To replicate to other Radicle nodes: <code>rad seed &lt;rid&gt;</code> on each.</li> 77 <li>Fill in the form below.</li> 78</ol> 79{{if .Error}}<div class="notice bad">{{.Error}}</div>{{end}} 80<div class="card"> 81 <form method="POST" action="/new-repo"> 82 <label for="name">Repo name</label> 83 <input type="text" id="name" name="name" required maxlength="80" pattern="[a-zA-Z0-9._-]+" value="{{.Name}}"> 84 85 <div class="row"> 86 <div> 87 <label for="base_url">Forgejo base URL</label> 88 <input type="url" id="base_url" name="base_url" required value="{{.BaseURL}}" placeholder="https://git.example.org"> 89 </div> 90 <div> 91 <label for="owner">Owner</label> 92 <input type="text" id="owner" name="owner" required value="{{.Owner}}"> 93 </div> 94 </div> 95 <label for="repo">Repository</label> 96 <input type="text" id="repo" name="repo" required value="{{.Repo}}"> 97 <label for="token">Forgejo token</label> 98 <textarea id="token" name="token" required placeholder="scoped to write:repository + write:issue"></textarea> 99 <div class="hint">Got it from someone else safely? See <a href="/share/new">one-time share</a>.</div> 100 101 <label for="rid">Radicle RID</label> 102 <input type="text" id="rid" name="rid" required value="{{.RID}}" placeholder="rad:z..."> 103 <div class="row"> 104 <div> 105 <label for="rad_http">Radicle HTTP base URL</label> 106 <input type="url" id="rad_http" name="rad_http" required value="{{.RadHTTP}}" placeholder="https://seed.example.org"> 107 </div> 108 <div> 109 <label for="explorer">Explorer URL (optional)</label> 110 <input type="url" id="explorer" name="explorer" value="{{.Explorer}}" placeholder="https://app.radicle.xyz"> 111 </div> 112 </div> 113 114 <label>Sync scope</label> 115 <div class="checks"> 116 <label><input type="checkbox" name="sync_git" checked> git</label> 117 <label><input type="checkbox" name="sync_issues" checked> issues</label> 118 <label><input type="checkbox" name="sync_patches" checked> patches</label> 119 </div> 120 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> 124 125 <button type="submit">Submit</button> 126 </form> 127</div> 128`)) 129 130var onboardOKTmpl = template.Must(template.New("onboard-ok").Parse(` 131<h1>{{.Title}}</h1> 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}} 137<p><a class="nav-link" href="/">&larr; Back to the dashboard</a></p> 138`)) 139 140func (h *Handler) addPeer(w http.ResponseWriter, r *http.Request) { 141 switch r.Method { 142 case http.MethodGet: 143 h.renderAddPeer(w, map[string]any{"Series": seriesNames(h.cfg.Series())}) 144 case http.MethodPost: 145 h.submitAddPeer(w, r) 146 default: 147 http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 148 } 149} 150 151func seriesNames(s []SeriesInfo) []string { 152 out := make([]string, 0, len(s)) 153 for _, si := range s { 154 out = append(out, si.Name) 155 } 156 return out 157} 158 159func (h *Handler) renderAddPeer(w http.ResponseWriter, data map[string]any) { 160 render(w, "add a peer", "SELF-SERVICE ONBOARDING", renderFragment(addPeerTmpl, data)) 161} 162 163func (h *Handler) submitAddPeer(w http.ResponseWriter, r *http.Request) { 164 ip := clientIP(r) 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"}) 167 return 168 } 169 if err := r.ParseForm(); err != nil { 170 h.renderAddPeer(w, map[string]any{"Series": seriesNames(h.cfg.Series()), "Error": "couldn't read the form"}) 171 return 172 } 173 form := formData(r, "series", "name", "base_url", "owner", "repo", "token", "password") 174 retry := func(msg string) { 175 h.renderAddPeer(w, map[string]any{ 176 "Series": seriesNames(h.cfg.Series()), "Error": msg, "SelectedSeries": form["series"], 177 "Name": form["name"], "BaseURL": form["base_url"], "Owner": form["owner"], "Repo": form["repo"], 178 }) 179 } 180 h.submitLimiter.recordFailure(ip) // counts every submission attempt, not just invalid ones — bounds queue-flooding volume 181 182 if !validName.MatchString(form["name"]) { 183 retry("pair name must be letters, digits, dots, dashes or underscores only") 184 return 185 } 186 if form["token"] = strings.TrimSpace(r.FormValue("token")); form["token"] == "" { 187 retry("a Forgejo token is required") 188 return 189 } 190 series := findSeries(h.cfg.Series(), form["series"]) 191 if series == nil { 192 retry("pick a federation to join") 193 return 194 } 195 if err := validateForgejo(form["base_url"], form["owner"], form["repo"]); err != nil { 196 retry(err.Error()) 197 return 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 } 215 216 tokenFile, err := writeTokenFile(h.cfg.TokenDir, form["name"], form["token"]) 217 if err != nil { 218 retry("could not save the token: " + err.Error()) 219 return 220 } 221 222 id, err := h.cfg.Store.CreateDynamicRepo(state.DynamicRepo{ 223 Name: form["name"], Series: series.Name, 224 ForgejoBaseURL: form["base_url"], ForgejoOwner: form["owner"], ForgejoRepo: form["repo"], ForgejoTokenFile: tokenFile, 225 RadicleRID: series.RadicleRID, RadicleHTTPBaseURL: series.RadicleHTTPBaseURL, RadicleExplorerURL: series.RadicleExplorerURL, 226 RadicleRadHome: h.cfg.DefaultRadHome, 227 SyncGit: r.FormValue("sync_git") != "", SyncIssues: r.FormValue("sync_issues") != "", SyncPatches: r.FormValue("sync_patches") != "", 228 }) 229 if err == nil && autoApprove { 230 err = h.cfg.Store.ApproveDynamicRepo(id) 231 } 232 if err != nil { 233 retry("could not save: " + err.Error()) 234 return 235 } 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})) 241} 242 243func (h *Handler) newRepo(w http.ResponseWriter, r *http.Request) { 244 switch r.Method { 245 case http.MethodGet: 246 h.renderNewRepo(w, map[string]any{}) 247 case http.MethodPost: 248 h.submitNewRepo(w, r) 249 default: 250 http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 251 } 252} 253 254func (h *Handler) renderNewRepo(w http.ResponseWriter, data map[string]any) { 255 render(w, "start a new repo", "SELF-SERVICE ONBOARDING", renderFragment(newRepoTmpl, data)) 256} 257 258func (h *Handler) submitNewRepo(w http.ResponseWriter, r *http.Request) { 259 ip := clientIP(r) 260 if !h.submitLimiter.allowed(ip) { 261 h.renderNewRepo(w, map[string]any{"Error": "too many submissions from your address — try again later"}) 262 return 263 } 264 if err := r.ParseForm(); err != nil { 265 h.renderNewRepo(w, map[string]any{"Error": "couldn't read the form"}) 266 return 267 } 268 form := formData(r, "name", "base_url", "owner", "repo", "token", "rid", "rad_http", "explorer", "password") 269 retry := func(msg string) { 270 h.renderNewRepo(w, map[string]any{ 271 "Error": msg, "Name": form["name"], "BaseURL": form["base_url"], "Owner": form["owner"], "Repo": form["repo"], 272 "RID": form["rid"], "RadHTTP": form["rad_http"], "Explorer": form["explorer"], 273 }) 274 } 275 h.submitLimiter.recordFailure(ip) 276 277 if !validName.MatchString(form["name"]) { 278 retry("repo name must be letters, digits, dots, dashes or underscores only") 279 return 280 } 281 if form["token"] = strings.TrimSpace(r.FormValue("token")); form["token"] == "" { 282 retry("a Forgejo token is required") 283 return 284 } 285 if err := validateForgejo(form["base_url"], form["owner"], form["repo"]); err != nil { 286 retry(err.Error()) 287 return 288 } 289 if form["rid"] == "" || !strings.HasPrefix(form["rid"], "rad:") { 290 retry("that doesn't look like a Radicle RID (expected rad:z...)") 291 return 292 } 293 if _, err := url.ParseRequestURI(form["rad_http"]); err != nil { 294 retry("the Radicle HTTP base URL isn't valid") 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 305 } 306 307 tokenFile, err := writeTokenFile(h.cfg.TokenDir, form["name"], form["token"]) 308 if err != nil { 309 retry("could not save the token: " + err.Error()) 310 return 311 } 312 313 id, err := h.cfg.Store.CreateDynamicRepo(state.DynamicRepo{ 314 Name: form["name"], Series: form["name"], 315 ForgejoBaseURL: form["base_url"], ForgejoOwner: form["owner"], ForgejoRepo: form["repo"], ForgejoTokenFile: tokenFile, 316 RadicleRID: form["rid"], RadicleHTTPBaseURL: form["rad_http"], RadicleExplorerURL: form["explorer"], 317 RadicleRadHome: h.cfg.DefaultRadHome, 318 SyncGit: r.FormValue("sync_git") != "", SyncIssues: r.FormValue("sync_issues") != "", SyncPatches: r.FormValue("sync_patches") != "", 319 }) 320 if err == nil && autoApprove { 321 err = h.cfg.Store.ApproveDynamicRepo(id) 322 } 323 if err != nil { 324 retry("could not save: " + err.Error()) 325 return 326 } 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})) 332} 333 334func formData(r *http.Request, fields ...string) map[string]string { 335 out := make(map[string]string, len(fields)) 336 for _, f := range fields { 337 out[f] = strings.TrimSpace(r.FormValue(f)) 338 } 339 return out 340} 341 342func findSeries(all []SeriesInfo, name string) *SeriesInfo { 343 for _, s := range all { 344 if s.Name == name { 345 return &s 346 } 347 } 348 return nil 349} 350 351func validateForgejo(baseURL, owner, repo string) error { 352 u, err := url.ParseRequestURI(baseURL) 353 if err != nil || u.Scheme != "https" || u.Host == "" { 354 return fmt.Errorf("the Forgejo base URL must be a valid https:// URL") 355 } 356 if owner == "" || repo == "" { 357 return fmt.Errorf("owner and repository are required") 358 } 359 return nil 360} 361 362// writeTokenFile saves a submitted token to its own chmod-600 file, 363// following the same convention as every config.yaml-declared pair. 364func writeTokenFile(dir, name, token string) (string, error) { 365 if err := os.MkdirAll(dir, 0o700); err != nil { 366 return "", err 367 } 368 path := filepath.Join(dir, name+".token") 369 if err := os.WriteFile(path, []byte(token+"\n"), 0o600); err != nil { 370 return "", err 371 } 372 return path, nil 373}