internal/httpd/settings.go

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

261 lines · 7767 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. Destructive lifecycle
 17// — delete and transfer — stays on the CLI, where a typed confirmation
 18// is the norm.
 19
 20type settingsPage struct {
 21	repoPage
 22	Topics      []string
 23	Branches    []gitutil.Ref
 24	DepsEnabled bool
 25	Deps        control.DepsOut
 26	Runners     []store.RepoRunner
 27	Notice      string
 28	Saved       bool
 29	Submitted   map[string]string
 30}
 31
 32func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.User) {
 33	s.settingsFormWith(w, r, u, s.takeFlash(w, r), nil)
 34}
 35
 36// settingsFormWith renders the page with the given notice. submitted is
 37// nil on a plain GET; on a failed POST it carries the values the visitor
 38// typed, so a rejected value is not silently dropped.
 39func (s *Server) settingsFormWith(w http.ResponseWriter, r *http.Request, u store.User, notice string, submitted url.Values) {
 40	repo, ok := s.repoForUser(w, r, u, policyCanAdmin)
 41	if !ok {
 42		return
 43	}
 44	p, ok := s.repoFor(w, r, "")
 45	if !ok {
 46		return
 47	}
 48	p.Tab = "settings"
 49	topics, _ := s.st.ListTopics(repo.ID)
 50	branches, _ := gitutil.Refs(p.Dir, "heads")
 51	// The toggle's state comes from the store; what the last run found
 52	// comes from the command, so the page shows the same report the CLI
 53	// prints (#164).
 54	var deps control.DepsOut
 55	s.runControlInto(u, []string{"repo", "deps", "status", repo.Path()}, &deps)
 56	var runners []store.RepoRunner
 57	s.runControlInto(u, []string{"repo", "runner", "list", repo.Path()}, &runners)
 58	var subm map[string]string
 59	if submitted != nil {
 60		subm = map[string]string{
 61			"description": submitted.Get("description"),
 62			"website":     submitted.Get("website"),
 63			"topics":      submitted.Get("topics"),
 64		}
 65	}
 66	s.render(w, "settings.html", settingsPage{
 67		repoPage: p, Topics: topics, Branches: branches,
 68		DepsEnabled: deps.Enabled, Deps: deps,
 69		Runners:   runners,
 70		Notice:    notice,
 71		Saved:     strings.HasPrefix(notice, "Saved "),
 72		Submitted: subm,
 73	})
 74}
 75
 76func (s *Server) settingsRedirect(w http.ResponseWriter, r *http.Request, msg string) {
 77	dest := fmt.Sprintf("/%s/%s/settings", r.PathValue("owner"), r.PathValue("repo"))
 78	s.setFlash(w, msg)
 79	http.Redirect(w, r, dest, http.StatusSeeOther)
 80}
 81
 82// settingsSubmit routes one form to its command. Keeping the mapping in
 83// one place makes what the page can reach obvious.
 84func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
 85	row, ok := s.repoForUser(w, r, u, policyCanAdmin)
 86	if !ok {
 87		return
 88	}
 89	repo := r.PathValue("owner") + "/" + r.PathValue("repo")
 90	v := func(k string) string { return strings.TrimSpace(r.FormValue(k)) }
 91	field := r.FormValue("field")
 92
 93	var argv []string
 94	switch field {
 95	case "description":
 96		argv = []string{"repo", "settings", "description", repo, v("description")}
 97	case "website":
 98		argv = []string{"repo", "settings", "website", repo, v("website")}
 99	case "visibility":
100		argv = []string{"repo", "settings", "visibility", repo, v("visibility")}
101	case "default-branch":
102		argv = []string{"repo", "settings", "default-branch", repo, v("default-branch")}
103	case "git-daemon":
104		argv = []string{"repo", "settings", "git-daemon", repo, onOff(v("git-daemon"))}
105	case "require-checks":
106		argv = []string{"repo", "settings", "require-checks", repo, onOff(v("require-checks"))}
107	case "require-resolved":
108		argv = []string{"repo", "settings", "require-resolved", repo, onOff(v("require-resolved"))}
109	case "require-codeowners":
110		argv = []string{"repo", "settings", "require-codeowners", repo, onOff(v("require-codeowners"))}
111	case "require-mr":
112		argv = []string{"repo", "settings", "require-mr", repo, onOff(v("require-mr"))}
113	case "require-signed":
114		argv = []string{"repo", "settings", "require-signed", repo, onOff(v("require-signed"))}
115	case "require-approvals":
116		argv = []string{"repo", "settings", "require-approvals", repo, v("approvals")}
117	case "protect":
118		argv = []string{"repo", "settings", "protect", repo, v("branch")}
119	case "unprotect":
120		argv = []string{"repo", "settings", "unprotect", repo, v("branch")}
121	case "protect-tag":
122		argv = []string{"repo", "settings", "protect-tag", repo, v("glob")}
123	case "unprotect-tag":
124		argv = []string{"repo", "settings", "unprotect-tag", repo, v("glob")}
125	case "deps":
126		verb := "disable"
127		if v("deps") == "on" {
128			verb = "enable"
129		}
130		argv = []string{"repo", "deps", verb, repo}
131	case "archive":
132		verb := "archive"
133		if v("archive") != "on" {
134			verb = "unarchive"
135		}
136		argv = []string{"repo", verb, repo}
137	case "topics":
138		want := map[string]bool{}
139		var order []string
140		for _, t := range strings.Split(v("topics"), ",") {
141			if t = strings.ToLower(strings.TrimSpace(t)); t != "" && !want[t] {
142				want[t] = true
143				order = append(order, t)
144			}
145		}
146		have, err := s.st.ListTopics(row.ID)
147		if err != nil {
148			s.settingsRedirect(w, r, err.Error())
149			return
150		}
151		var add, remove []string
152		for _, t := range order {
153			if !slices.Contains(have, t) {
154				add = append(add, t)
155			}
156		}
157		for _, t := range have {
158			if !want[t] {
159				remove = append(remove, t)
160			}
161		}
162		removed := false
163		if len(remove) > 0 {
164			if _, msg, ok := s.runControl(u, append([]string{"repo", "topics", "remove", repo}, remove...)); !ok {
165				s.settingsFormWith(w, r, u, msg, r.Form)
166				return
167			}
168			removed = true
169		}
170		if len(add) > 0 {
171			argv = append([]string{"repo", "topics", "add", repo}, add...)
172		} else {
173			s.settingsRedirect(w, r, "Saved the topics.")
174			return
175		}
176		if removed {
177			if _, msg, ok := s.runControl(u, argv); !ok {
178				s.settingsFormWith(w, r, u, "Removed "+strings.Join(remove, ", ")+"; "+msg, r.Form)
179				return
180			}
181			s.settingsRedirect(w, r, "Saved the "+fieldLabel(field)+".")
182			return
183		}
184	case "runner-add":
185		body := v("key")
186		if body == "" {
187			s.settingsRedirect(w, r, "paste the runner's public key")
188			return
189		}
190		msg, ok := s.runControlStdin(u, []string{"repo", "runner", "add", repo}, body+"\n")
191		if ok {
192			msg = ""
193		}
194		s.settingsRedirect(w, r, msg)
195		return
196	case "runner-remove":
197		argv = []string{"repo", "runner", "remove", repo, v("fingerprint")}
198	default:
199		s.settingsRedirect(w, r, "unknown setting")
200		return
201	}
202
203	_, msg, ok := s.runControl(u, argv)
204	if ok {
205		s.settingsRedirect(w, r, "Saved the "+fieldLabel(field)+".")
206		return
207	}
208	s.settingsFormWith(w, r, u, msg, r.Form)
209}
210
211// fieldLabel names a settings field for the saved flash and, on
212// rejection, the error notice — lower case, matching the label beside
213// its control.
214func fieldLabel(field string) string {
215	switch field {
216	case "description":
217		return "description"
218	case "website":
219		return "website"
220	case "visibility":
221		return "visibility"
222	case "default-branch":
223		return "default branch"
224	case "git-daemon":
225		return "git:// serving"
226	case "require-checks":
227		return "required checks"
228	case "require-approvals":
229		return "approvals"
230	case "require-resolved":
231		return "review threads"
232	case "require-codeowners":
233		return "CODEOWNERS"
234	case "require-mr":
235		return "merge request requirement"
236	case "require-signed":
237		return "signed commits"
238	case "protect", "unprotect":
239		return "protected branch"
240	case "protect-tag", "unprotect-tag":
241		return "protected tag"
242	case "deps":
243		return "dependency scanning"
244	case "archive":
245		return "archived state"
246	case "topics":
247		return "topics"
248	case "runner-add", "runner-remove":
249		return "runner"
250	default:
251		return field
252	}
253}
254
255// onOff normalises a checkbox to the on|off the commands take.
256func onOff(v string) string {
257	if v == "on" || v == "true" {
258		return "on"
259	}
260	return "off"
261}