internal/httpd/settings.go
375 lines · 11754 bytes
1package httpd
2
3import (
4 "fmt"
5 "net/http"
6 "net/url"
7 "slices"
8 "strings"
9
10 "gitbay.org/gitbay/internal/control"
11 "gitbay.org/gitbay/internal/gitutil"
12 "gitbay.org/gitbay/internal/store"
13)
14
15// Repository settings for repo admins. Every control dispatches the
16// command the CLI runs; the page only groups them. Delete and transfer
17// ask for the repository's path to be typed first.
18
19type settingsPage struct {
20 repoPage
21 Topics []string
22 Branches []gitutil.Ref
23 DepsEnabled bool
24 Deps control.DepsOut
25 Runners []store.RepoRunner
26 Access []accessRow
27 Hooks []hookRow
28 Deliveries []deliveryRow
29 Notice string
30 Saved bool
31 Reauth bool // Notice is the stale-session refusal: link to sign in
32 Submitted map[string]string
33}
34
35type accessRow struct {
36 User string `json:"user"`
37 Role string `json:"role"`
38 Source string `json:"source"`
39}
40
41type hookRow struct {
42 ID int64 `json:"id"`
43 URL string `json:"url"`
44 Events string `json:"events"`
45 Secret bool `json:"has_secret"`
46}
47
48type deliveryRow struct {
49 ID int64 `json:"id"`
50 URL string `json:"url"`
51 Event string `json:"event"`
52 Status string `json:"status"`
53 Attempts int `json:"attempts"`
54 LastStatus int `json:"last_status"`
55 LastError string `json:"last_error"`
56}
57
58func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.User) {
59 s.settingsFormWith(w, r, u, s.takeFlash(w, r), nil)
60}
61
62// settingsFormWith renders the page with the given notice. submitted is
63// nil on a plain GET; on a failed POST it carries the values the visitor
64// typed, so a rejected value is not silently dropped.
65func (s *Server) settingsFormWith(w http.ResponseWriter, r *http.Request, u store.User, notice string, submitted url.Values) {
66 repo, ok := s.repoForUser(w, r, u, policyCanAdmin)
67 if !ok {
68 return
69 }
70 p, ok := s.repoFor(w, r, "")
71 if !ok {
72 return
73 }
74 p.Tab = "settings"
75 topics, _ := s.st.ListTopics(repo.ID)
76 branches, _ := gitutil.Refs(p.Dir, "heads")
77 // The toggle's state comes from the store; what the last run found
78 // comes from the command, so the page shows the same report the CLI
79 // prints (#164).
80 var deps control.DepsOut
81 s.runControlInto(u, []string{"repo", "deps", "status", repo.Path()}, &deps)
82 var runners []store.RepoRunner
83 s.runControlInto(u, []string{"repo", "runner", "list", repo.Path()}, &runners)
84 var access []accessRow
85 s.runControlInto(u, []string{"repo", "access", "list", repo.Path()}, &access)
86 var hooks []hookRow
87 s.runControlInto(u, []string{"webhook", "list", repo.Path()}, &hooks)
88 var deliveries []deliveryRow
89 s.runControlInto(u, []string{"webhook", "deliveries", repo.Path(), "--limit", "20"}, &deliveries)
90 var subm map[string]string
91 if submitted != nil {
92 subm = map[string]string{
93 "description": submitted.Get("description"),
94 "website": submitted.Get("website"),
95 "topics": submitted.Get("topics"),
96 "key": submitted.Get("key"),
97 "user": submitted.Get("user"),
98 "role": submitted.Get("role"),
99 "url": submitted.Get("url"),
100 "events": submitted.Get("events"),
101 "name": submitted.Get("name"),
102 "new-owner": submitted.Get("new-owner"),
103 }
104 }
105 s.render(w, "settings.html", settingsPage{
106 repoPage: p, Topics: topics, Branches: branches,
107 DepsEnabled: deps.Enabled, Deps: deps,
108 Runners: runners,
109 Access: access, Hooks: hooks, Deliveries: deliveries,
110 Notice: notice,
111 Saved: strings.HasPrefix(notice, "Saved "),
112 Reauth: s.reauthNotice(w, notice, r.URL.Path),
113 Submitted: subm,
114 })
115}
116
117func (s *Server) settingsRedirect(w http.ResponseWriter, r *http.Request, msg string) {
118 dest := fmt.Sprintf("/%s/%s/settings", r.PathValue("owner"), r.PathValue("repo"))
119 s.setFlash(w, msg)
120 http.Redirect(w, r, dest, http.StatusSeeOther)
121}
122
123// settingsSubmit routes one form to its command. Keeping the mapping in
124// one place makes what the page can reach obvious.
125func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
126 row, ok := s.repoForUser(w, r, u, policyCanAdmin)
127 if !ok {
128 return
129 }
130 repo := r.PathValue("owner") + "/" + r.PathValue("repo")
131 v := func(k string) string { return strings.TrimSpace(r.FormValue(k)) }
132 field := r.FormValue("field")
133
134 var argv []string
135 switch field {
136 case "description":
137 argv = []string{"repo", "settings", "description", repo, v("description")}
138 case "website":
139 argv = []string{"repo", "settings", "website", repo, v("website")}
140 case "visibility":
141 argv = []string{"repo", "settings", "visibility", repo, v("visibility")}
142 case "default-branch":
143 argv = []string{"repo", "settings", "default-branch", repo, v("default-branch")}
144 case "git-daemon":
145 argv = []string{"repo", "settings", "git-daemon", repo, onOff(v("git-daemon"))}
146 case "require-checks":
147 argv = []string{"repo", "settings", "require-checks", repo, onOff(v("require-checks"))}
148 case "require-contexts":
149 argv = append([]string{"repo", "settings", "require-contexts", repo}, strings.Fields(v("contexts"))...)
150 case "require-resolved":
151 argv = []string{"repo", "settings", "require-resolved", repo, onOff(v("require-resolved"))}
152 case "require-codeowners":
153 argv = []string{"repo", "settings", "require-codeowners", repo, onOff(v("require-codeowners"))}
154 case "require-mr":
155 argv = []string{"repo", "settings", "require-mr", repo, onOff(v("require-mr"))}
156 case "require-signed":
157 argv = []string{"repo", "settings", "require-signed", repo, onOff(v("require-signed"))}
158 case "require-approvals":
159 argv = []string{"repo", "settings", "require-approvals", repo, v("approvals")}
160 case "protect":
161 argv = []string{"repo", "settings", "protect", repo, v("branch")}
162 case "unprotect":
163 argv = []string{"repo", "settings", "unprotect", repo, v("branch")}
164 case "protect-tag":
165 argv = []string{"repo", "settings", "protect-tag", repo, v("glob")}
166 case "unprotect-tag":
167 argv = []string{"repo", "settings", "unprotect-tag", repo, v("glob")}
168 case "deps":
169 verb := "disable"
170 if v("deps") == "on" {
171 verb = "enable"
172 }
173 argv = []string{"repo", "deps", verb, repo}
174 case "archive":
175 verb := "archive"
176 if v("archive") != "on" {
177 verb = "unarchive"
178 }
179 argv = []string{"repo", verb, repo}
180 case "topics":
181 want := map[string]bool{}
182 var order []string
183 for _, t := range strings.Split(v("topics"), ",") {
184 if t = strings.ToLower(strings.TrimSpace(t)); t != "" && !want[t] {
185 want[t] = true
186 order = append(order, t)
187 }
188 }
189 have, err := s.st.ListTopics(row.ID)
190 if err != nil {
191 s.settingsRedirect(w, r, err.Error())
192 return
193 }
194 var add, remove []string
195 for _, t := range order {
196 if !slices.Contains(have, t) {
197 add = append(add, t)
198 }
199 }
200 for _, t := range have {
201 if !want[t] {
202 remove = append(remove, t)
203 }
204 }
205 removed := false
206 if len(remove) > 0 {
207 if _, msg, ok := s.runControl(u, append([]string{"repo", "topics", "remove", repo}, remove...)); !ok {
208 s.settingsFormWith(w, r, u, msg, r.Form)
209 return
210 }
211 removed = true
212 }
213 if len(add) > 0 {
214 argv = append([]string{"repo", "topics", "add", repo}, add...)
215 } else {
216 s.settingsRedirect(w, r, "Saved the topics.")
217 return
218 }
219 if removed {
220 if _, msg, ok := s.runControl(u, argv); !ok {
221 s.settingsFormWith(w, r, u, "Removed "+strings.Join(remove, ", ")+"; "+msg, r.Form)
222 return
223 }
224 s.settingsRedirect(w, r, "Saved the "+fieldLabel(field)+".")
225 return
226 }
227 case "runner-add":
228 body := v("key")
229 if body == "" {
230 s.settingsRedirect(w, r, "paste the runner's public key")
231 return
232 }
233 msg, ok := s.runControlStdin(u, []string{"repo", "runner", "add", repo}, body+"\n")
234 if !ok {
235 s.settingsFormWith(w, r, u, msg, r.Form)
236 return
237 }
238 s.settingsRedirect(w, r, "Saved the runner.")
239 return
240 case "runner-remove":
241 argv = []string{"repo", "runner", "remove", repo, v("fingerprint")}
242 case "access-grant":
243 argv = []string{"repo", "access", "grant", repo, v("user"), v("role")}
244 case "access-revoke":
245 argv = []string{"repo", "access", "revoke", repo, v("user")}
246 case "webhook-add":
247 // The secret goes to the command on stdin and nowhere else: not
248 // argv, not the re-rendered form, not the notice.
249 argv = []string{"webhook", "add", repo, v("url")}
250 if ev := strings.ReplaceAll(v("events"), " ", ""); ev != "" {
251 argv = append(argv, "--events", ev)
252 }
253 var stdin string
254 if secret := strings.TrimRight(r.FormValue("secret"), "\r\n"); secret != "" {
255 argv = append(argv, "--secret", "-")
256 stdin = secret + "\n"
257 }
258 msg, ok := s.runControlStdin(u, argv, stdin)
259 if !ok {
260 s.settingsFormWith(w, r, u, msg, r.Form)
261 return
262 }
263 s.settingsRedirect(w, r, "Saved the webhook.")
264 return
265 case "webhook-remove":
266 argv = []string{"webhook", "remove", repo, v("id")}
267 case "webhook-redeliver":
268 if _, msg, ok := s.runControl(u, []string{"webhook", "redeliver", repo, v("delivery")}); !ok {
269 s.settingsFormWith(w, r, u, msg, r.Form)
270 return
271 }
272 s.settingsRedirect(w, r, "Queued the delivery again.")
273 return
274 case "rename":
275 if _, msg, ok := s.runControl(u, []string{"repo", "rename", repo, v("name")}); !ok {
276 s.settingsFormWith(w, r, u, msg, r.Form)
277 return
278 }
279 s.setFlash(w, "Saved the name.")
280 http.Redirect(w, r, "/"+r.PathValue("owner")+"/"+v("name")+"/settings", http.StatusSeeOther)
281 return
282 case "transfer":
283 if ok, msg := confirmed(r, repo); !ok {
284 s.settingsFormWith(w, r, u, msg, r.Form)
285 return
286 }
287 if _, msg, ok := s.runControl(u, []string{"repo", "transfer", repo, v("new-owner")}); !ok {
288 s.settingsFormWith(w, r, u, msg, r.Form)
289 return
290 }
291 s.setFlash(w, "Saved the owner: transferred to "+v("new-owner")+".")
292 http.Redirect(w, r, "/"+v("new-owner")+"/"+r.PathValue("repo")+"/settings", http.StatusSeeOther)
293 return
294 case "delete":
295 if ok, msg := confirmed(r, repo); !ok {
296 s.settingsFormWith(w, r, u, msg, r.Form)
297 return
298 }
299 if _, msg, ok := s.runControl(u, []string{"repo", "delete", repo, "--yes"}); !ok {
300 s.settingsFormWith(w, r, u, msg, r.Form)
301 return
302 }
303 s.setFlash(w, "Deleted "+repo+".")
304 http.Redirect(w, r, "/"+r.PathValue("owner"), http.StatusSeeOther)
305 return
306 default:
307 s.settingsRedirect(w, r, "unknown setting")
308 return
309 }
310
311 _, msg, ok := s.runControl(u, argv)
312 if ok {
313 s.settingsRedirect(w, r, "Saved the "+fieldLabel(field)+".")
314 return
315 }
316 s.settingsFormWith(w, r, u, msg, r.Form)
317}
318
319// fieldLabel names a settings field for the saved flash and, on
320// rejection, the error notice — lower case, matching the label beside
321// its control.
322func fieldLabel(field string) string {
323 switch field {
324 case "description":
325 return "description"
326 case "website":
327 return "website"
328 case "visibility":
329 return "visibility"
330 case "default-branch":
331 return "default branch"
332 case "git-daemon":
333 return "git:// serving"
334 case "require-checks":
335 return "required checks"
336 case "require-contexts":
337 return "required contexts"
338 case "require-approvals":
339 return "approvals"
340 case "require-resolved":
341 return "review threads"
342 case "require-codeowners":
343 return "CODEOWNERS"
344 case "require-mr":
345 return "merge request requirement"
346 case "require-signed":
347 return "signed commits"
348 case "protect", "unprotect":
349 return "protected branch"
350 case "protect-tag", "unprotect-tag":
351 return "protected tag"
352 case "deps":
353 return "dependency scanning"
354 case "archive":
355 return "archived state"
356 case "topics":
357 return "topics"
358 case "runner-add", "runner-remove":
359 return "runner"
360 case "access-grant", "access-revoke":
361 return "access"
362 case "webhook-remove":
363 return "webhook"
364 default:
365 return field
366 }
367}
368
369// onOff normalises a checkbox to the on|off the commands take.
370func onOff(v string) string {
371 if v == "on" || v == "true" {
372 return "on"
373 }
374 return "off"
375}