Federated via graft
14 kB
373 lines
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 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 →</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 <repo> --default-branch <branch> --public</code>. Note the RID (<code>rad:z...</code>).</li>
76 <li>To replicate to other Radicle nodes: <code>rad seed <rid></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="/">← 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}