internal/httpd/settings.go

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

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