internal/httpd/settings.go
263 lines · 7866 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 "key": submitted.Get("key"),
65 }
66 }
67 s.render(w, "settings.html", settingsPage{
68 repoPage: p, Topics: topics, Branches: branches,
69 DepsEnabled: deps.Enabled, Deps: deps,
70 Runners: runners,
71 Notice: notice,
72 Saved: strings.HasPrefix(notice, "Saved "),
73 Submitted: subm,
74 })
75}
76
77func (s *Server) settingsRedirect(w http.ResponseWriter, r *http.Request, msg string) {
78 dest := fmt.Sprintf("/%s/%s/settings", r.PathValue("owner"), r.PathValue("repo"))
79 s.setFlash(w, msg)
80 http.Redirect(w, r, dest, http.StatusSeeOther)
81}
82
83// settingsSubmit routes one form to its command. Keeping the mapping in
84// one place makes what the page can reach obvious.
85func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
86 row, ok := s.repoForUser(w, r, u, policyCanAdmin)
87 if !ok {
88 return
89 }
90 repo := r.PathValue("owner") + "/" + r.PathValue("repo")
91 v := func(k string) string { return strings.TrimSpace(r.FormValue(k)) }
92 field := r.FormValue("field")
93
94 var argv []string
95 switch field {
96 case "description":
97 argv = []string{"repo", "settings", "description", repo, v("description")}
98 case "website":
99 argv = []string{"repo", "settings", "website", repo, v("website")}
100 case "visibility":
101 argv = []string{"repo", "settings", "visibility", repo, v("visibility")}
102 case "default-branch":
103 argv = []string{"repo", "settings", "default-branch", repo, v("default-branch")}
104 case "git-daemon":
105 argv = []string{"repo", "settings", "git-daemon", repo, onOff(v("git-daemon"))}
106 case "require-checks":
107 argv = []string{"repo", "settings", "require-checks", repo, onOff(v("require-checks"))}
108 case "require-resolved":
109 argv = []string{"repo", "settings", "require-resolved", repo, onOff(v("require-resolved"))}
110 case "require-codeowners":
111 argv = []string{"repo", "settings", "require-codeowners", repo, onOff(v("require-codeowners"))}
112 case "require-mr":
113 argv = []string{"repo", "settings", "require-mr", repo, onOff(v("require-mr"))}
114 case "require-signed":
115 argv = []string{"repo", "settings", "require-signed", repo, onOff(v("require-signed"))}
116 case "require-approvals":
117 argv = []string{"repo", "settings", "require-approvals", repo, v("approvals")}
118 case "protect":
119 argv = []string{"repo", "settings", "protect", repo, v("branch")}
120 case "unprotect":
121 argv = []string{"repo", "settings", "unprotect", repo, v("branch")}
122 case "protect-tag":
123 argv = []string{"repo", "settings", "protect-tag", repo, v("glob")}
124 case "unprotect-tag":
125 argv = []string{"repo", "settings", "unprotect-tag", repo, v("glob")}
126 case "deps":
127 verb := "disable"
128 if v("deps") == "on" {
129 verb = "enable"
130 }
131 argv = []string{"repo", "deps", verb, repo}
132 case "archive":
133 verb := "archive"
134 if v("archive") != "on" {
135 verb = "unarchive"
136 }
137 argv = []string{"repo", verb, repo}
138 case "topics":
139 want := map[string]bool{}
140 var order []string
141 for _, t := range strings.Split(v("topics"), ",") {
142 if t = strings.ToLower(strings.TrimSpace(t)); t != "" && !want[t] {
143 want[t] = true
144 order = append(order, t)
145 }
146 }
147 have, err := s.st.ListTopics(row.ID)
148 if err != nil {
149 s.settingsRedirect(w, r, err.Error())
150 return
151 }
152 var add, remove []string
153 for _, t := range order {
154 if !slices.Contains(have, t) {
155 add = append(add, t)
156 }
157 }
158 for _, t := range have {
159 if !want[t] {
160 remove = append(remove, t)
161 }
162 }
163 removed := false
164 if len(remove) > 0 {
165 if _, msg, ok := s.runControl(u, append([]string{"repo", "topics", "remove", repo}, remove...)); !ok {
166 s.settingsFormWith(w, r, u, msg, r.Form)
167 return
168 }
169 removed = true
170 }
171 if len(add) > 0 {
172 argv = append([]string{"repo", "topics", "add", repo}, add...)
173 } else {
174 s.settingsRedirect(w, r, "Saved the topics.")
175 return
176 }
177 if removed {
178 if _, msg, ok := s.runControl(u, argv); !ok {
179 s.settingsFormWith(w, r, u, "Removed "+strings.Join(remove, ", ")+"; "+msg, r.Form)
180 return
181 }
182 s.settingsRedirect(w, r, "Saved the "+fieldLabel(field)+".")
183 return
184 }
185 case "runner-add":
186 body := v("key")
187 if body == "" {
188 s.settingsRedirect(w, r, "paste the runner's public key")
189 return
190 }
191 msg, ok := s.runControlStdin(u, []string{"repo", "runner", "add", repo}, body+"\n")
192 if !ok {
193 s.settingsFormWith(w, r, u, msg, r.Form)
194 return
195 }
196 s.settingsRedirect(w, r, "Saved the runner.")
197 return
198 case "runner-remove":
199 argv = []string{"repo", "runner", "remove", repo, v("fingerprint")}
200 default:
201 s.settingsRedirect(w, r, "unknown setting")
202 return
203 }
204
205 _, msg, ok := s.runControl(u, argv)
206 if ok {
207 s.settingsRedirect(w, r, "Saved the "+fieldLabel(field)+".")
208 return
209 }
210 s.settingsFormWith(w, r, u, msg, r.Form)
211}
212
213// fieldLabel names a settings field for the saved flash and, on
214// rejection, the error notice — lower case, matching the label beside
215// its control.
216func fieldLabel(field string) string {
217 switch field {
218 case "description":
219 return "description"
220 case "website":
221 return "website"
222 case "visibility":
223 return "visibility"
224 case "default-branch":
225 return "default branch"
226 case "git-daemon":
227 return "git:// serving"
228 case "require-checks":
229 return "required checks"
230 case "require-approvals":
231 return "approvals"
232 case "require-resolved":
233 return "review threads"
234 case "require-codeowners":
235 return "CODEOWNERS"
236 case "require-mr":
237 return "merge request requirement"
238 case "require-signed":
239 return "signed commits"
240 case "protect", "unprotect":
241 return "protected branch"
242 case "protect-tag", "unprotect-tag":
243 return "protected tag"
244 case "deps":
245 return "dependency scanning"
246 case "archive":
247 return "archived state"
248 case "topics":
249 return "topics"
250 case "runner-add", "runner-remove":
251 return "runner"
252 default:
253 return field
254 }
255}
256
257// onOff normalises a checkbox to the on|off the commands take.
258func onOff(v string) string {
259 if v == "on" || v == "true" {
260 return "on"
261 }
262 return "off"
263}