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">A new Forgejo instance joining a repo that's already mirrored elsewhere. It reuses the federation's existing Radicle side automatically — Radicle already replicates to it — so nothing about Radicle needs to be entered here.</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 <div class="hint">The RID, Radicle node and explorer this peer joins are inherited from the series you pick.</div> 33 34 <label for="name">Pair name</label> 35 <input type="text" id="name" name="name" required maxlength="80" pattern="[a-zA-Z0-9._-]+" value="{{.Name}}" placeholder="e.g. federation-x-gitadmin"> 36 37 <div class="row"> 38 <div> 39 <label for="base_url">Forgejo base URL</label> 40 <input type="url" id="base_url" name="base_url" required value="{{.BaseURL}}" placeholder="https://git.example.org"> 41 </div> 42 <div> 43 <label for="owner">Owner</label> 44 <input type="text" id="owner" name="owner" required value="{{.Owner}}"> 45 </div> 46 </div> 47 <label for="repo">Repository</label> 48 <input type="text" id="repo" name="repo" required value="{{.Repo}}"> 49 50 <label for="token">Forgejo token</label> 51 <textarea id="token" name="token" required placeholder="scoped to write:repository + write:issue"></textarea> 52 <div class="hint">Written to its own chmod-600 file on save — never kept in this form or logged. Got it from someone else safely? See <a href="/share/new">one-time share</a>.</div> 53 54 <label>Sync scope</label> 55 <div class="checks"> 56 <label><input type="checkbox" name="sync_git" checked> git</label> 57 <label><input type="checkbox" name="sync_issues" checked> issues</label> 58 <label><input type="checkbox" name="sync_patches" checked> patches</label> 59 </div> 60 61 <label for="password">Admin password</label> 62 <input type="password" id="password" name="password" required autocomplete="off"> 63 <div class="hint">Confirms this addition is authorized. Active within one sync interval once accepted.</div> 64 65 <button type="submit">Add peer</button> 66 </form> 67</div> 68`)) 69 70var newRepoTmpl = template.Must(template.New("new-repo").Parse(` 71<h1>Start a new repo</h1> 72<p class="lead">Mirroring a repo graft has never seen before, on both a Forgejo instance and a Radicle node.</p> 73<ol class="steps"> 74 <li>On your Forgejo instance, create the repo (give it one initial commit) and generate a token scoped to <code>write:repository</code> + <code>write:issue</code>.</li> 75 <li>Clone it locally and run <code>rad init --name &lt;repo&gt; --default-branch &lt;branch&gt; --public</code> — note the RID it prints (<code>rad:z...</code>).</li> 76 <li>If other Radicle nodes should also carry it, tell them to <code>rad seed &lt;rid&gt;</code>.</li> 77 <li>Fill in what you just created 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</label> 122 <input type="password" id="password" name="password" required autocomplete="off"> 123 124 <button type="submit">Start mirroring</button> 125 </form> 126</div> 127`)) 128 129var onboardOKTmpl = template.Must(template.New("onboard-ok").Parse(` 130<h1>{{.Title}}</h1> 131<div class="notice good">{{.Name}} is saved and will start mirroring within the next sync interval — no restart needed.</div> 132<p><a class="nav-link" href="/">&larr; Back to the dashboard</a></p> 133`)) 134 135func (h *Handler) addPeer(w http.ResponseWriter, r *http.Request) { 136 switch r.Method { 137 case http.MethodGet: 138 h.renderAddPeer(w, map[string]any{"Series": seriesNames(h.cfg.Series())}) 139 case http.MethodPost: 140 h.submitAddPeer(w, r) 141 default: 142 http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 143 } 144} 145 146func seriesNames(s []SeriesInfo) []string { 147 out := make([]string, 0, len(s)) 148 for _, si := range s { 149 out = append(out, si.Name) 150 } 151 return out 152} 153 154func (h *Handler) renderAddPeer(w http.ResponseWriter, data map[string]any) { 155 render(w, "add a peer", "SELF-SERVICE ONBOARDING", renderFragment(addPeerTmpl, data)) 156} 157 158func (h *Handler) submitAddPeer(w http.ResponseWriter, r *http.Request) { 159 ip := clientIP(r) 160 if !h.passwordLimiter.allowed(ip) { 161 h.renderAddPeer(w, map[string]any{"Series": seriesNames(h.cfg.Series()), "Error": "too many attempts from your address — try again later"}) 162 return 163 } 164 if err := r.ParseForm(); err != nil { 165 h.renderAddPeer(w, map[string]any{"Series": seriesNames(h.cfg.Series()), "Error": "couldn't read the form"}) 166 return 167 } 168 form := formData(r, "series", "name", "base_url", "owner", "repo", "token", "password") 169 retry := func(msg string) { 170 h.renderAddPeer(w, map[string]any{ 171 "Series": seriesNames(h.cfg.Series()), "Error": msg, "SelectedSeries": form["series"], 172 "Name": form["name"], "BaseURL": form["base_url"], "Owner": form["owner"], "Repo": form["repo"], 173 }) 174 } 175 176 if !checkPassword(h.cfg, form["password"]) { 177 h.passwordLimiter.recordFailure(ip) 178 retry("wrong admin password") 179 return 180 } 181 if !validName.MatchString(form["name"]) { 182 retry("pair name must be letters, digits, dots, dashes or underscores only") 183 return 184 } 185 if form["token"] = strings.TrimSpace(r.FormValue("token")); form["token"] == "" { 186 retry("a Forgejo token is required") 187 return 188 } 189 series := findSeries(h.cfg.Series(), form["series"]) 190 if series == nil { 191 retry("pick a federation to join") 192 return 193 } 194 if err := validateForgejo(form["base_url"], form["owner"], form["repo"]); err != nil { 195 retry(err.Error()) 196 return 197 } 198 199 tokenFile, err := writeTokenFile(h.cfg.TokenDir, form["name"], form["token"]) 200 if err != nil { 201 retry("could not save the token: " + err.Error()) 202 return 203 } 204 205 _, err = h.cfg.Store.CreateDynamicRepo(state.DynamicRepo{ 206 Name: form["name"], Series: series.Name, 207 ForgejoBaseURL: form["base_url"], ForgejoOwner: form["owner"], ForgejoRepo: form["repo"], ForgejoTokenFile: tokenFile, 208 RadicleRID: series.RadicleRID, RadicleHTTPBaseURL: series.RadicleHTTPBaseURL, RadicleExplorerURL: series.RadicleExplorerURL, 209 RadicleRadHome: h.cfg.DefaultRadHome, 210 SyncGit: r.FormValue("sync_git") != "", SyncIssues: r.FormValue("sync_issues") != "", SyncPatches: r.FormValue("sync_patches") != "", 211 }) 212 if err != nil { 213 retry("could not save: " + err.Error()) 214 return 215 } 216 render(w, "peer added", "SELF-SERVICE ONBOARDING", renderFragment(onboardOKTmpl, map[string]string{"Title": "Peer added", "Name": form["name"]})) 217} 218 219func (h *Handler) newRepo(w http.ResponseWriter, r *http.Request) { 220 switch r.Method { 221 case http.MethodGet: 222 h.renderNewRepo(w, map[string]any{}) 223 case http.MethodPost: 224 h.submitNewRepo(w, r) 225 default: 226 http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 227 } 228} 229 230func (h *Handler) renderNewRepo(w http.ResponseWriter, data map[string]any) { 231 render(w, "start a new repo", "SELF-SERVICE ONBOARDING", renderFragment(newRepoTmpl, data)) 232} 233 234func (h *Handler) submitNewRepo(w http.ResponseWriter, r *http.Request) { 235 ip := clientIP(r) 236 if !h.passwordLimiter.allowed(ip) { 237 h.renderNewRepo(w, map[string]any{"Error": "too many attempts from your address — try again later"}) 238 return 239 } 240 if err := r.ParseForm(); err != nil { 241 h.renderNewRepo(w, map[string]any{"Error": "couldn't read the form"}) 242 return 243 } 244 form := formData(r, "name", "base_url", "owner", "repo", "token", "rid", "rad_http", "explorer", "password") 245 retry := func(msg string) { 246 h.renderNewRepo(w, map[string]any{ 247 "Error": msg, "Name": form["name"], "BaseURL": form["base_url"], "Owner": form["owner"], "Repo": form["repo"], 248 "RID": form["rid"], "RadHTTP": form["rad_http"], "Explorer": form["explorer"], 249 }) 250 } 251 252 if !checkPassword(h.cfg, form["password"]) { 253 h.passwordLimiter.recordFailure(ip) 254 retry("wrong admin password") 255 return 256 } 257 if !validName.MatchString(form["name"]) { 258 retry("repo name must be letters, digits, dots, dashes or underscores only") 259 return 260 } 261 if form["token"] = strings.TrimSpace(r.FormValue("token")); form["token"] == "" { 262 retry("a Forgejo token is required") 263 return 264 } 265 if err := validateForgejo(form["base_url"], form["owner"], form["repo"]); err != nil { 266 retry(err.Error()) 267 return 268 } 269 if form["rid"] == "" || !strings.HasPrefix(form["rid"], "rad:") { 270 retry("that doesn't look like a Radicle RID (expected rad:z...)") 271 return 272 } 273 if _, err := url.ParseRequestURI(form["rad_http"]); err != nil { 274 retry("the Radicle HTTP base URL isn't valid") 275 return 276 } 277 278 tokenFile, err := writeTokenFile(h.cfg.TokenDir, form["name"], form["token"]) 279 if err != nil { 280 retry("could not save the token: " + err.Error()) 281 return 282 } 283 284 _, err = h.cfg.Store.CreateDynamicRepo(state.DynamicRepo{ 285 Name: form["name"], Series: form["name"], 286 ForgejoBaseURL: form["base_url"], ForgejoOwner: form["owner"], ForgejoRepo: form["repo"], ForgejoTokenFile: tokenFile, 287 RadicleRID: form["rid"], RadicleHTTPBaseURL: form["rad_http"], RadicleExplorerURL: form["explorer"], 288 RadicleRadHome: h.cfg.DefaultRadHome, 289 SyncGit: r.FormValue("sync_git") != "", SyncIssues: r.FormValue("sync_issues") != "", SyncPatches: r.FormValue("sync_patches") != "", 290 }) 291 if err != nil { 292 retry("could not save: " + err.Error()) 293 return 294 } 295 render(w, "repo started", "SELF-SERVICE ONBOARDING", renderFragment(onboardOKTmpl, map[string]string{"Title": "Repo started", "Name": form["name"]})) 296} 297 298func formData(r *http.Request, fields ...string) map[string]string { 299 out := make(map[string]string, len(fields)) 300 for _, f := range fields { 301 out[f] = strings.TrimSpace(r.FormValue(f)) 302 } 303 return out 304} 305 306func findSeries(all []SeriesInfo, name string) *SeriesInfo { 307 for _, s := range all { 308 if s.Name == name { 309 return &s 310 } 311 } 312 return nil 313} 314 315func validateForgejo(baseURL, owner, repo string) error { 316 u, err := url.ParseRequestURI(baseURL) 317 if err != nil || u.Scheme != "https" || u.Host == "" { 318 return fmt.Errorf("the Forgejo base URL must be a valid https:// URL") 319 } 320 if owner == "" || repo == "" { 321 return fmt.Errorf("owner and repository are required") 322 } 323 return nil 324} 325 326// writeTokenFile saves a submitted token to its own chmod-600 file, 327// following the same convention as every config.yaml-declared pair. 328func writeTokenFile(dir, name, token string) (string, error) { 329 if err := os.MkdirAll(dir, 0o700); err != nil { 330 return "", err 331 } 332 path := filepath.Join(dir, name+".token") 333 if err := os.WriteFile(path, []byte(token+"\n"), 0o600); err != nil { 334 return "", err 335 } 336 return path, nil 337}