internal/httpd/settings.go
267 lines · 8053 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-contexts":
109 argv = append([]string{"repo", "settings", "require-contexts", repo}, strings.Fields(v("contexts"))...)
110 case "require-resolved":
111 argv = []string{"repo", "settings", "require-resolved", repo, onOff(v("require-resolved"))}
112 case "require-codeowners":
113 argv = []string{"repo", "settings", "require-codeowners", repo, onOff(v("require-codeowners"))}
114 case "require-mr":
115 argv = []string{"repo", "settings", "require-mr", repo, onOff(v("require-mr"))}
116 case "require-signed":
117 argv = []string{"repo", "settings", "require-signed", repo, onOff(v("require-signed"))}
118 case "require-approvals":
119 argv = []string{"repo", "settings", "require-approvals", repo, v("approvals")}
120 case "protect":
121 argv = []string{"repo", "settings", "protect", repo, v("branch")}
122 case "unprotect":
123 argv = []string{"repo", "settings", "unprotect", repo, v("branch")}
124 case "protect-tag":
125 argv = []string{"repo", "settings", "protect-tag", repo, v("glob")}
126 case "unprotect-tag":
127 argv = []string{"repo", "settings", "unprotect-tag", repo, v("glob")}
128 case "deps":
129 verb := "disable"
130 if v("deps") == "on" {
131 verb = "enable"
132 }
133 argv = []string{"repo", "deps", verb, repo}
134 case "archive":
135 verb := "archive"
136 if v("archive") != "on" {
137 verb = "unarchive"
138 }
139 argv = []string{"repo", verb, repo}
140 case "topics":
141 want := map[string]bool{}
142 var order []string
143 for _, t := range strings.Split(v("topics"), ",") {
144 if t = strings.ToLower(strings.TrimSpace(t)); t != "" && !want[t] {
145 want[t] = true
146 order = append(order, t)
147 }
148 }
149 have, err := s.st.ListTopics(row.ID)
150 if err != nil {
151 s.settingsRedirect(w, r, err.Error())
152 return
153 }
154 var add, remove []string
155 for _, t := range order {
156 if !slices.Contains(have, t) {
157 add = append(add, t)
158 }
159 }
160 for _, t := range have {
161 if !want[t] {
162 remove = append(remove, t)
163 }
164 }
165 removed := false
166 if len(remove) > 0 {
167 if _, msg, ok := s.runControl(u, append([]string{"repo", "topics", "remove", repo}, remove...)); !ok {
168 s.settingsFormWith(w, r, u, msg, r.Form)
169 return
170 }
171 removed = true
172 }
173 if len(add) > 0 {
174 argv = append([]string{"repo", "topics", "add", repo}, add...)
175 } else {
176 s.settingsRedirect(w, r, "Saved the topics.")
177 return
178 }
179 if removed {
180 if _, msg, ok := s.runControl(u, argv); !ok {
181 s.settingsFormWith(w, r, u, "Removed "+strings.Join(remove, ", ")+"; "+msg, r.Form)
182 return
183 }
184 s.settingsRedirect(w, r, "Saved the "+fieldLabel(field)+".")
185 return
186 }
187 case "runner-add":
188 body := v("key")
189 if body == "" {
190 s.settingsRedirect(w, r, "paste the runner's public key")
191 return
192 }
193 msg, ok := s.runControlStdin(u, []string{"repo", "runner", "add", repo}, body+"\n")
194 if !ok {
195 s.settingsFormWith(w, r, u, msg, r.Form)
196 return
197 }
198 s.settingsRedirect(w, r, "Saved the runner.")
199 return
200 case "runner-remove":
201 argv = []string{"repo", "runner", "remove", repo, v("fingerprint")}
202 default:
203 s.settingsRedirect(w, r, "unknown setting")
204 return
205 }
206
207 _, msg, ok := s.runControl(u, argv)
208 if ok {
209 s.settingsRedirect(w, r, "Saved the "+fieldLabel(field)+".")
210 return
211 }
212 s.settingsFormWith(w, r, u, msg, r.Form)
213}
214
215// fieldLabel names a settings field for the saved flash and, on
216// rejection, the error notice — lower case, matching the label beside
217// its control.
218func fieldLabel(field string) string {
219 switch field {
220 case "description":
221 return "description"
222 case "website":
223 return "website"
224 case "visibility":
225 return "visibility"
226 case "default-branch":
227 return "default branch"
228 case "git-daemon":
229 return "git:// serving"
230 case "require-checks":
231 return "required checks"
232 case "require-contexts":
233 return "required contexts"
234 case "require-approvals":
235 return "approvals"
236 case "require-resolved":
237 return "review threads"
238 case "require-codeowners":
239 return "CODEOWNERS"
240 case "require-mr":
241 return "merge request requirement"
242 case "require-signed":
243 return "signed commits"
244 case "protect", "unprotect":
245 return "protected branch"
246 case "protect-tag", "unprotect-tag":
247 return "protected tag"
248 case "deps":
249 return "dependency scanning"
250 case "archive":
251 return "archived state"
252 case "topics":
253 return "topics"
254 case "runner-add", "runner-remove":
255 return "runner"
256 default:
257 return field
258 }
259}
260
261// onOff normalises a checkbox to the on|off the commands take.
262func onOff(v string) string {
263 if v == "on" || v == "true" {
264 return "on"
265 }
266 return "off"
267}