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. Radicle side (RID, node, explorer) is inherited from the chosen series.</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 save. 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</label> 61 <input type="password" id="password" name="password" required autocomplete="off"> 62 63 <button type="submit">Add peer</button> 64 </form> 65</div> 66<p><a class="nav-link" href="/add-radicle-peer">Add a Radicle peer instead &rarr;</a></p> 67`)) 68 69var newRepoTmpl = template.Must(template.New("new-repo").Parse(` 70<h1>Start a new repo</h1> 71<p class="lead">Mirrors a repo on a new Forgejo instance and a new Radicle node.</p> 72<ol class="steps"> 73 <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> 74 <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> 75 <li>To replicate to other Radicle nodes: <code>rad seed &lt;rid&gt;</code> on each.</li> 76 <li>Fill in the form below.</li> 77</ol> 78{{if .Error}}<div class="notice bad">{{.Error}}</div>{{end}} 79<div class="card"> 80 <form method="POST" action="/new-repo"> 81 <label for="name">Repo name</label> 82 <input type="text" id="name" name="name" required maxlength="80" pattern="[a-zA-Z0-9._-]+" value="{{.Name}}"> 83 84 <div class="row"> 85 <div> 86 <label for="base_url">Forgejo base URL</label> 87 <input type="url" id="base_url" name="base_url" required value="{{.BaseURL}}" placeholder="https://git.example.org"> 88 </div> 89 <div> 90 <label for="owner">Owner</label> 91 <input type="text" id="owner" name="owner" required value="{{.Owner}}"> 92 </div> 93 </div> 94 <label for="repo">Repository</label> 95 <input type="text" id="repo" name="repo" required value="{{.Repo}}"> 96 <label for="token">Forgejo token</label> 97 <textarea id="token" name="token" required placeholder="scoped to write:repository + write:issue"></textarea> 98 <div class="hint">Got it from someone else safely? See <a href="/share/new">one-time share</a>.</div> 99 100 <label for="rid">Radicle RID</label> 101 <input type="text" id="rid" name="rid" required value="{{.RID}}" placeholder="rad:z..."> 102 <div class="row"> 103 <div> 104 <label for="rad_http">Radicle HTTP base URL</label> 105 <input type="url" id="rad_http" name="rad_http" required value="{{.RadHTTP}}" placeholder="https://seed.example.org"> 106 </div> 107 <div> 108 <label for="explorer">Explorer URL (optional)</label> 109 <input type="url" id="explorer" name="explorer" value="{{.Explorer}}" placeholder="https://app.radicle.xyz"> 110 </div> 111 </div> 112 113 <label>Sync scope</label> 114 <div class="checks"> 115 <label><input type="checkbox" name="sync_git" checked> git</label> 116 <label><input type="checkbox" name="sync_issues" checked> issues</label> 117 <label><input type="checkbox" name="sync_patches" checked> patches</label> 118 </div> 119 120 <label for="password">Admin password</label> 121 <input type="password" id="password" name="password" required autocomplete="off"> 122 123 <button type="submit">Start mirroring</button> 124 </form> 125</div> 126`)) 127 128var onboardOKTmpl = template.Must(template.New("onboard-ok").Parse(` 129<h1>{{.Title}}</h1> 130<div class="notice good">{{.Name}} saved.</div> 131<p><a class="nav-link" href="/">&larr; Back to the dashboard</a></p> 132`)) 133 134func (h *Handler) addPeer(w http.ResponseWriter, r *http.Request) { 135 switch r.Method { 136 case http.MethodGet: 137 h.renderAddPeer(w, map[string]any{"Series": seriesNames(h.cfg.Series())}) 138 case http.MethodPost: 139 h.submitAddPeer(w, r) 140 default: 141 http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 142 } 143} 144 145func seriesNames(s []SeriesInfo) []string { 146 out := make([]string, 0, len(s)) 147 for _, si := range s { 148 out = append(out, si.Name) 149 } 150 return out 151} 152 153func (h *Handler) renderAddPeer(w http.ResponseWriter, data map[string]any) { 154 render(w, "add a peer", "SELF-SERVICE ONBOARDING", renderFragment(addPeerTmpl, data)) 155} 156 157func (h *Handler) submitAddPeer(w http.ResponseWriter, r *http.Request) { 158 ip := clientIP(r) 159 if !h.passwordLimiter.allowed(ip) { 160 h.renderAddPeer(w, map[string]any{"Series": seriesNames(h.cfg.Series()), "Error": "too many attempts from your address — try again later"}) 161 return 162 } 163 if err := r.ParseForm(); err != nil { 164 h.renderAddPeer(w, map[string]any{"Series": seriesNames(h.cfg.Series()), "Error": "couldn't read the form"}) 165 return 166 } 167 form := formData(r, "series", "name", "base_url", "owner", "repo", "token", "password") 168 retry := func(msg string) { 169 h.renderAddPeer(w, map[string]any{ 170 "Series": seriesNames(h.cfg.Series()), "Error": msg, "SelectedSeries": form["series"], 171 "Name": form["name"], "BaseURL": form["base_url"], "Owner": form["owner"], "Repo": form["repo"], 172 }) 173 } 174 175 if !checkPassword(h.cfg, form["password"]) { 176 h.passwordLimiter.recordFailure(ip) 177 retry("wrong admin password") 178 return 179 } 180 if !validName.MatchString(form["name"]) { 181 retry("pair name must be letters, digits, dots, dashes or underscores only") 182 return 183 } 184 if form["token"] = strings.TrimSpace(r.FormValue("token")); form["token"] == "" { 185 retry("a Forgejo token is required") 186 return 187 } 188 series := findSeries(h.cfg.Series(), form["series"]) 189 if series == nil { 190 retry("pick a federation to join") 191 return 192 } 193 if err := validateForgejo(form["base_url"], form["owner"], form["repo"]); err != nil { 194 retry(err.Error()) 195 return 196 } 197 198 tokenFile, err := writeTokenFile(h.cfg.TokenDir, form["name"], form["token"]) 199 if err != nil { 200 retry("could not save the token: " + err.Error()) 201 return 202 } 203 204 _, err = h.cfg.Store.CreateDynamicRepo(state.DynamicRepo{ 205 Name: form["name"], Series: series.Name, 206 ForgejoBaseURL: form["base_url"], ForgejoOwner: form["owner"], ForgejoRepo: form["repo"], ForgejoTokenFile: tokenFile, 207 RadicleRID: series.RadicleRID, RadicleHTTPBaseURL: series.RadicleHTTPBaseURL, RadicleExplorerURL: series.RadicleExplorerURL, 208 RadicleRadHome: h.cfg.DefaultRadHome, 209 SyncGit: r.FormValue("sync_git") != "", SyncIssues: r.FormValue("sync_issues") != "", SyncPatches: r.FormValue("sync_patches") != "", 210 }) 211 if err != nil { 212 retry("could not save: " + err.Error()) 213 return 214 } 215 render(w, "peer added", "SELF-SERVICE ONBOARDING", renderFragment(onboardOKTmpl, map[string]string{"Title": "Peer added", "Name": form["name"]})) 216} 217 218func (h *Handler) newRepo(w http.ResponseWriter, r *http.Request) { 219 switch r.Method { 220 case http.MethodGet: 221 h.renderNewRepo(w, map[string]any{}) 222 case http.MethodPost: 223 h.submitNewRepo(w, r) 224 default: 225 http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 226 } 227} 228 229func (h *Handler) renderNewRepo(w http.ResponseWriter, data map[string]any) { 230 render(w, "start a new repo", "SELF-SERVICE ONBOARDING", renderFragment(newRepoTmpl, data)) 231} 232 233func (h *Handler) submitNewRepo(w http.ResponseWriter, r *http.Request) { 234 ip := clientIP(r) 235 if !h.passwordLimiter.allowed(ip) { 236 h.renderNewRepo(w, map[string]any{"Error": "too many attempts from your address — try again later"}) 237 return 238 } 239 if err := r.ParseForm(); err != nil { 240 h.renderNewRepo(w, map[string]any{"Error": "couldn't read the form"}) 241 return 242 } 243 form := formData(r, "name", "base_url", "owner", "repo", "token", "rid", "rad_http", "explorer", "password") 244 retry := func(msg string) { 245 h.renderNewRepo(w, map[string]any{ 246 "Error": msg, "Name": form["name"], "BaseURL": form["base_url"], "Owner": form["owner"], "Repo": form["repo"], 247 "RID": form["rid"], "RadHTTP": form["rad_http"], "Explorer": form["explorer"], 248 }) 249 } 250 251 if !checkPassword(h.cfg, form["password"]) { 252 h.passwordLimiter.recordFailure(ip) 253 retry("wrong admin password") 254 return 255 } 256 if !validName.MatchString(form["name"]) { 257 retry("repo name must be letters, digits, dots, dashes or underscores only") 258 return 259 } 260 if form["token"] = strings.TrimSpace(r.FormValue("token")); form["token"] == "" { 261 retry("a Forgejo token is required") 262 return 263 } 264 if err := validateForgejo(form["base_url"], form["owner"], form["repo"]); err != nil { 265 retry(err.Error()) 266 return 267 } 268 if form["rid"] == "" || !strings.HasPrefix(form["rid"], "rad:") { 269 retry("that doesn't look like a Radicle RID (expected rad:z...)") 270 return 271 } 272 if _, err := url.ParseRequestURI(form["rad_http"]); err != nil { 273 retry("the Radicle HTTP base URL isn't valid") 274 return 275 } 276 277 tokenFile, err := writeTokenFile(h.cfg.TokenDir, form["name"], form["token"]) 278 if err != nil { 279 retry("could not save the token: " + err.Error()) 280 return 281 } 282 283 _, err = h.cfg.Store.CreateDynamicRepo(state.DynamicRepo{ 284 Name: form["name"], Series: form["name"], 285 ForgejoBaseURL: form["base_url"], ForgejoOwner: form["owner"], ForgejoRepo: form["repo"], ForgejoTokenFile: tokenFile, 286 RadicleRID: form["rid"], RadicleHTTPBaseURL: form["rad_http"], RadicleExplorerURL: form["explorer"], 287 RadicleRadHome: h.cfg.DefaultRadHome, 288 SyncGit: r.FormValue("sync_git") != "", SyncIssues: r.FormValue("sync_issues") != "", SyncPatches: r.FormValue("sync_patches") != "", 289 }) 290 if err != nil { 291 retry("could not save: " + err.Error()) 292 return 293 } 294 render(w, "repo started", "SELF-SERVICE ONBOARDING", renderFragment(onboardOKTmpl, map[string]string{"Title": "Repo started", "Name": form["name"]})) 295} 296 297func formData(r *http.Request, fields ...string) map[string]string { 298 out := make(map[string]string, len(fields)) 299 for _, f := range fields { 300 out[f] = strings.TrimSpace(r.FormValue(f)) 301 } 302 return out 303} 304 305func findSeries(all []SeriesInfo, name string) *SeriesInfo { 306 for _, s := range all { 307 if s.Name == name { 308 return &s 309 } 310 } 311 return nil 312} 313 314func validateForgejo(baseURL, owner, repo string) error { 315 u, err := url.ParseRequestURI(baseURL) 316 if err != nil || u.Scheme != "https" || u.Host == "" { 317 return fmt.Errorf("the Forgejo base URL must be a valid https:// URL") 318 } 319 if owner == "" || repo == "" { 320 return fmt.Errorf("owner and repository are required") 321 } 322 return nil 323} 324 325// writeTokenFile saves a submitted token to its own chmod-600 file, 326// following the same convention as every config.yaml-declared pair. 327func writeTokenFile(dir, name, token string) (string, error) { 328 if err := os.MkdirAll(dir, 0o700); err != nil { 329 return "", err 330 } 331 path := filepath.Join(dir, name+".token") 332 if err := os.WriteFile(path, []byte(token+"\n"), 0o600); err != nil { 333 return "", err 334 } 335 return path, nil 336}