internal/httpd/settings.go

v1.40.1
gitbay/internal/httpd/settings.go history · blame · raw

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}