internal/httpd/settings.go
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}