Federated via graft
13 kB
337 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 — not 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
33 <label for="name">Pair 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 <div class="hint">Create this repo empty on the peer — no auto-init. An auto-init commit diverges from the federation's history and blocks the sync.</div>
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. Not kept in this form, not logged. From a third party: use <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">Active within one sync interval. No restart.</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">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</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}} saved. Active within one sync interval. No restart.</div>
132<p><a class="nav-link" href="/">← 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}