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