Commit 8a7afd05e0

8a7afd05e08dcadb8f7ae2c51a46166dfae1faa9

parent: f7e43e4e28

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 05:30 UTC

web: access, webhooks, rename, transfer, delete and import pages

Repository settings gains access grants with effective access, webhooks
with deliveries and redeliver (secret from the form body, passed on
stdin), rename, and transfer and delete behind a typed path. /new gains
an import form. Parity rows updated.

Ref #296

Layout: unified · split

.gitbay/wiki/Parity.org +10 −11
@@ -203,11 +203,11 @@ rather than the one the web page shows.
203| protected tags | yes | yes | yes | 203| protected tags | yes | yes | yes |
204| require codeowners | yes | yes | yes | 204| require codeowners | yes | yes | yes |
205| require contexts | yes | yes | no | 205| require contexts | yes | yes | no |
206| access grants | yes | no | yes | 206| access grants | yes | yes | yes |
207| effective access | yes | no | yes | 207| effective access | yes | yes | yes |
208| webhooks | yes | no | yes | 208| webhooks | yes | yes | yes |
209| runners attach, list, detach | yes | yes | n/a | 209| runners attach, list, detach | yes | yes | n/a |
210| import from a remote | yes | no | yes | 210| import from a remote | yes | yes | yes |
211| topics, website | yes | yes | yes | 211| topics, website | yes | yes | yes |
212| visibility | yes | yes | yes | 212| visibility | yes | yes | yes |
213| archive (read-only flag) | yes | yes | yes | 213| archive (read-only flag) | yes | yes | yes |
@@ -231,8 +231,8 @@ rather than the one the web page shows.
231| job image (ci.yml) | yes | n/a | n/a | 231| job image (ci.yml) | yes | n/a | n/a |
232| dependency checks on/off | yes | yes | yes | 232| dependency checks on/off | yes | yes | yes |
233| dependency status | yes | yes | yes | 233| dependency status | yes | yes | yes |
234| delete, transfer | yes | no | no | 234| delete, transfer | yes | yes | no |
235| rename | yes | no | yes | 235| rename | yes | yes | yes |
236| release delete | yes | yes | yes | 236| release delete | yes | yes | yes |
237| release asset add | yes | no | n/a | 237| release asset add | yes | no | n/a |
238| release asset remove | yes | no | yes | 238| release asset remove | yes | no | yes |
@@ -490,11 +490,10 @@ so =gitbay mr apply-suggestion= makes and signs it in a clone with the
490user's own git signing configuration and pushes it. The web shows that 490user's own git signing configuration and pushes it. The web shows that
491command in place of the button. 491command in place of the button.
492 492
493Deleting or transferring a repository stays CLI-only on purpose, as 493Deleting an organization and pruning merge request heads (=admin mr
494does deleting an organization and pruning merge request heads (=admin 494prune=) stay CLI-only for now. Deleting or transferring a repository is a
495mr prune=): each removes or moves what clone URLs point at, and wants a 495settings section on the web and asks for the repository's path to be
496typed command rather than a button. Renaming is the exception: the iOS 496typed first.
497client offers it, and the web has no page yet.
498 497
499=n/a= means a surface cannot usefully carry the capability at all — 498=n/a= means a surface cannot usefully carry the capability at all —
500see the archive note above. 499see the archive note above.
.gitbay/wiki/Users.org +4
@@ -219,6 +219,10 @@ and =fork_of= when it is a
219fork whose parent you can read, so a client draws a toggle rather than 219fork whose parent you can read, so a client draws a toggle rather than
220two blind buttons. Absent means none. 220two blind buttons. Absent means none.
221 221
222On the web, the repository's settings page carries access, webhooks,
223rename, transfer and delete, and =/new= imports from a remote. Delete and
224transfer ask for the repository's path to be typed.
225
222Pushing is SSH-only. Public repositories are anonymously readable over 226Pushing is SSH-only. Public repositories are anonymously readable over
223HTTPS (and =git://= where enabled); private repositories exist only over 227HTTPS (and =git://= where enabled); private repositories exist only over
224SSH and answer "not found" to everyone without access. 228SSH and answer "not found" to everyone without access.
CHANGELOG.org +5
@@ -6,6 +6,11 @@ anything beyond "replace the binary and restart" is needed.
6 6
7* Unreleased 7* Unreleased
8 8
9- The repository settings page gains access grants and effective access,
10 webhooks (add, remove, deliveries, redeliver; the secret is a form
11 field passed on stdin and never shown again), rename, transfer and
12 delete with typed confirmation. =/new= gains an import form for
13 =repo import= (#296).
9- =web diff set unified|split= and a Diff layout control on the account 14- =web diff set unified|split= and a Diff layout control on the account
10 page choose how the merge request, commit and compare pages draw a 15 page choose how the merge request, commit and compare pages draw a
11 diff; =?layout=split|unified= overrides it per request. The split 16 diff; =?layout=split|unified= overrides it per request. The split
e2e/webhookweb_test.go added +52
@@ -0,0 +1,52 @@
1package e2e
2
3import (
4 "crypto/hmac"
5 "crypto/sha256"
6 "encoding/hex"
7 "net/url"
8 "strings"
9 "testing"
10)
11
12// TestWebhookSecretFromTheSettingsPage adds a webhook from the settings
13// page with a secret. The secret signs deliveries, and appears nowhere on
14// the resulting pages or in the command's listing (#296).
15func TestWebhookSecretFromTheSettingsPage(t *testing.T) {
16 t.Parallel()
17 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n[webhooks]\nallow_local = true\n")
18 aliceKey := inst.newKey(t, "alice")
19 inst.admin(t, "admin", "user", "create", "alice",
20 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
21 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/proj"); code != 0 {
22 t.Fatalf("repo create: %s", errOut)
23 }
24 alice := inst.login(t, aliceKey)
25 recv := startHookReceiver(t)
26 const secret = "form-secret-9d2f"
27
28 status, body := browserPost(t, alice, inst.base()+"/alice/proj/settings", url.Values{
29 "field": {"webhook-add"}, "url": {"http://" + recv.addr + "/hook"},
30 "events": {"issue.created"}, "secret": {secret},
31 })
32 if status != 200 || strings.Contains(body, secret) || !strings.Contains(body, "signed") {
33 t.Fatalf("add: %d\n%s", status, body)
34 }
35 if _, body = browserGet(t, alice, inst.base()+"/alice/proj/settings"); strings.Contains(body, secret) {
36 t.Fatal("secret on the settings page")
37 }
38 out, _, _ := inst.ssh(t, aliceKey, "", "webhook", "list", "alice/proj", "--json")
39 if strings.Contains(out, secret) {
40 t.Fatalf("secret in webhook list: %s", out)
41 }
42
43 if _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/proj", "--title", "'hook me'"); code != 0 {
44 t.Fatalf("issue create: %s", errOut)
45 }
46 h := recv.waitN(t, 1)[0]
47 mac := hmac.New(sha256.New, []byte(secret))
48 mac.Write(h.body)
49 if h.signature != "sha256="+hex.EncodeToString(mac.Sum(nil)) {
50 t.Fatalf("HMAC mismatch: %s", h.signature)
51 }
52}
internal/httpd/accounts.go +18
@@ -233,6 +233,24 @@ func (s *Server) newSubmit(w http.ResponseWriter, r *http.Request, u store.User)
233 owner = u.Username 233 owner = u.Username
234 } 234 }
235 name := r.FormValue("name") 235 name := r.FormValue("name")
236 if r.FormValue("field") == "import" {
237 // The token, if any, reaches the command on stdin only.
238 argv := []string{"repo", "import", owner + "/" + name, "--from", strings.TrimSpace(r.FormValue("from"))}
239 if r.FormValue("visibility") == "private" {
240 argv = append(argv, "--private")
241 }
242 var stdin string
243 if tok := strings.TrimSpace(r.FormValue("token")); tok != "" {
244 argv = append(argv, "--token-stdin")
245 stdin = tok + "\n"
246 }
247 if msg, ok := s.runControlStdin(u, argv, stdin); !ok {
248 s.renderNewRepo(w, u, msg)
249 return
250 }
251 http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther)
252 return
253 }
236 argv := []string{"repo", "create", owner + "/" + name} 254 argv := []string{"repo", "create", owner + "/" + name}
237 if r.FormValue("visibility") == "private" { 255 if r.FormValue("visibility") == "private" {
238 argv = append(argv, "--private") 256 argv = append(argv, "--private")
internal/httpd/settings.go +108 −4
@@ -13,9 +13,8 @@ import (
13) 13)
14 14
15// Repository settings for repo admins. Every control dispatches the 15// Repository settings for repo admins. Every control dispatches the
16// command the CLI runs; the page only groups them. Destructive lifecycle 16// command the CLI runs; the page only groups them. Delete and transfer
17// — delete and transfer — stays on the CLI, where a typed confirmation 17// ask for the repository's path to be typed first.
18// is the norm.
19 18
20type settingsPage struct { 19type settingsPage struct {
21 repoPage 20 repoPage
@@ -24,12 +23,38 @@ type settingsPage struct {
24 DepsEnabled bool 23 DepsEnabled bool
25 Deps control.DepsOut 24 Deps control.DepsOut
26 Runners []store.RepoRunner 25 Runners []store.RepoRunner
26 Access []accessRow
27 Hooks []hookRow
28 Deliveries []deliveryRow
27 Notice string 29 Notice string
28 Saved bool 30 Saved bool
29 Reauth bool // Notice is the stale-session refusal: link to sign in 31 Reauth bool // Notice is the stale-session refusal: link to sign in
30 Submitted map[string]string 32 Submitted map[string]string
31} 33}
32 34
35type accessRow struct {
36 User string `json:"user"`
37 Role string `json:"role"`
38 Source string `json:"source"`
39}
40
41type hookRow struct {
42 ID int64 `json:"id"`
43 URL string `json:"url"`
44 Events string `json:"events"`
45 Secret bool `json:"has_secret"`
46}
47
48type deliveryRow struct {
49 ID int64 `json:"id"`
50 URL string `json:"url"`
51 Event string `json:"event"`
52 Status string `json:"status"`
53 Attempts int `json:"attempts"`
54 LastStatus int `json:"last_status"`
55 LastError string `json:"last_error"`
56}
57
33func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.User) { 58func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.User) {
34 s.settingsFormWith(w, r, u, s.takeFlash(w, r), nil) 59 s.settingsFormWith(w, r, u, s.takeFlash(w, r), nil)
35} 60}
@@ -56,6 +81,12 @@ func (s *Server) settingsFormWith(w http.ResponseWriter, r *http.Request, u stor
56 s.runControlInto(u, []string{"repo", "deps", "status", repo.Path()}, &deps) 81 s.runControlInto(u, []string{"repo", "deps", "status", repo.Path()}, &deps)
57 var runners []store.RepoRunner 82 var runners []store.RepoRunner
58 s.runControlInto(u, []string{"repo", "runner", "list", repo.Path()}, &runners) 83 s.runControlInto(u, []string{"repo", "runner", "list", repo.Path()}, &runners)
84 var access []accessRow
85 s.runControlInto(u, []string{"repo", "access", "list", repo.Path()}, &access)
86 var hooks []hookRow
87 s.runControlInto(u, []string{"webhook", "list", repo.Path()}, &hooks)
88 var deliveries []deliveryRow
89 s.runControlInto(u, []string{"webhook", "deliveries", repo.Path(), "--limit", "20"}, &deliveries)
59 var subm map[string]string 90 var subm map[string]string
60 if submitted != nil { 91 if submitted != nil {
61 subm = map[string]string{ 92 subm = map[string]string{
@@ -63,12 +94,19 @@ func (s *Server) settingsFormWith(w http.ResponseWriter, r *http.Request, u stor
63 "website": submitted.Get("website"), 94 "website": submitted.Get("website"),
64 "topics": submitted.Get("topics"), 95 "topics": submitted.Get("topics"),
65 "key": submitted.Get("key"), 96 "key": submitted.Get("key"),
97 "user": submitted.Get("user"),
98 "role": submitted.Get("role"),
99 "url": submitted.Get("url"),
100 "events": submitted.Get("events"),
101 "name": submitted.Get("name"),
102 "new-owner": submitted.Get("new-owner"),
66 } 103 }
67 } 104 }
68 s.render(w, "settings.html", settingsPage{ 105 s.render(w, "settings.html", settingsPage{
69 repoPage: p, Topics: topics, Branches: branches, 106 repoPage: p, Topics: topics, Branches: branches,
70 DepsEnabled: deps.Enabled, Deps: deps, 107 DepsEnabled: deps.Enabled, Deps: deps,
71 Runners: runners, 108 Runners: runners,
109 Access: access, Hooks: hooks, Deliveries: deliveries,
72 Notice: notice, 110 Notice: notice,
73 Saved: strings.HasPrefix(notice, "Saved "), 111 Saved: strings.HasPrefix(notice, "Saved "),
74 Reauth: s.reauthNotice(w, notice, r.URL.Path), 112 Reauth: s.reauthNotice(w, notice, r.URL.Path),
@@ -201,6 +239,68 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.
201 return 239 return
202 case "runner-remove": 240 case "runner-remove":
203 argv = []string{"repo", "runner", "remove", repo, v("fingerprint")} 241 argv = []string{"repo", "runner", "remove", repo, v("fingerprint")}
242 case "access-grant":
243 argv = []string{"repo", "access", "grant", repo, v("user"), v("role")}
244 case "access-revoke":
245 argv = []string{"repo", "access", "revoke", repo, v("user")}
246 case "webhook-add":
247 // The secret goes to the command on stdin and nowhere else: not
248 // argv, not the re-rendered form, not the notice.
249 argv = []string{"webhook", "add", repo, v("url")}
250 if ev := strings.ReplaceAll(v("events"), " ", ""); ev != "" {
251 argv = append(argv, "--events", ev)
252 }
253 var stdin string
254 if secret := strings.TrimRight(r.FormValue("secret"), "\r\n"); secret != "" {
255 argv = append(argv, "--secret", "-")
256 stdin = secret + "\n"
257 }
258 msg, ok := s.runControlStdin(u, argv, stdin)
259 if !ok {
260 s.settingsFormWith(w, r, u, msg, r.Form)
261 return
262 }
263 s.settingsRedirect(w, r, "Saved the webhook.")
264 return
265 case "webhook-remove":
266 argv = []string{"webhook", "remove", repo, v("id")}
267 case "webhook-redeliver":
268 if _, msg, ok := s.runControl(u, []string{"webhook", "redeliver", repo, v("delivery")}); !ok {
269 s.settingsFormWith(w, r, u, msg, r.Form)
270 return
271 }
272 s.settingsRedirect(w, r, "Queued the delivery again.")
273 return
274 case "rename":
275 if _, msg, ok := s.runControl(u, []string{"repo", "rename", repo, v("name")}); !ok {
276 s.settingsFormWith(w, r, u, msg, r.Form)
277 return
278 }
279 s.setFlash(w, "Saved the name.")
280 http.Redirect(w, r, "/"+r.PathValue("owner")+"/"+v("name")+"/settings", http.StatusSeeOther)
281 return
282 case "transfer":
283 if ok, msg := confirmed(r, repo); !ok {
284 s.settingsFormWith(w, r, u, msg, r.Form)
285 return
286 }
287 if _, msg, ok := s.runControl(u, []string{"repo", "transfer", repo, v("new-owner")}); !ok {
288 s.settingsFormWith(w, r, u, msg, r.Form)
289 return
290 }
291 http.Redirect(w, r, "/"+v("new-owner")+"/"+r.PathValue("repo"), http.StatusSeeOther)
292 return
293 case "delete":
294 if ok, msg := confirmed(r, repo); !ok {
295 s.settingsFormWith(w, r, u, msg, r.Form)
296 return
297 }
298 if _, msg, ok := s.runControl(u, []string{"repo", "delete", repo, "--yes"}); !ok {
299 s.settingsFormWith(w, r, u, msg, r.Form)
300 return
301 }
302 http.Redirect(w, r, "/"+r.PathValue("owner"), http.StatusSeeOther)
303 return
204 default: 304 default:
205 s.settingsRedirect(w, r, "unknown setting") 305 s.settingsRedirect(w, r, "unknown setting")
206 return 306 return
@@ -255,6 +355,10 @@ func fieldLabel(field string) string {
255 return "topics" 355 return "topics"
256 case "runner-add", "runner-remove": 356 case "runner-add", "runner-remove":
257 return "runner" 357 return "runner"
358 case "access-grant", "access-revoke":
359 return "access"
360 case "webhook-remove":
361 return "webhook"
258 default: 362 default:
259 return field 363 return field
260 } 364 }
internal/httpd/settingsparity_test.go added +268
@@ -0,0 +1,268 @@
1package httpd
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "net/url"
7 "os"
8 "strings"
9 "testing"
10 "time"
11
12 "gitbay.org/gitbay/internal/config"
13 "gitbay.org/gitbay/internal/control"
14 "gitbay.org/gitbay/internal/gitutil"
15 "gitbay.org/gitbay/internal/store"
16)
17
18type settingsEnv struct {
19 s *Server
20 st *store.Store
21 alice store.User
22 bob store.User
23 repo store.Repo
24}
25
26func newSettingsEnv(t *testing.T) *settingsEnv {
27 t.Helper()
28 st, err := store.Open(":memory:")
29 if err != nil {
30 t.Fatal(err)
31 }
32 t.Cleanup(func() { st.Close() })
33 if err := st.MigrateUp(); err != nil {
34 t.Fatal(err)
35 }
36 aid, _ := st.CreateUser("alice", false)
37 bid, _ := st.CreateUser("bob", false)
38 if _, err := st.CreateRepo("user", aid, "app", "public"); err != nil {
39 t.Fatal(err)
40 }
41 repo, err := st.RepoByPath("alice/app")
42 if err != nil {
43 t.Fatal(err)
44 }
45 if err := st.GrantAccess(repo.ID, bid, "read"); err != nil {
46 t.Fatal(err)
47 }
48 cfg := config.Default()
49 cfg.Web.Mode = "accounts"
50 cfg.Server.Root = t.TempDir()
51 cfg.Webhooks.AllowLocal = true
52 if err := gitutil.InitBare(control.RepoDir(cfg.Server.Root, "alice", "app"), "main", t.TempDir()); err != nil {
53 t.Fatal(err)
54 }
55 now := time.Now()
56 return &settingsEnv{
57 s: New(cfg, st, nil), st: st, repo: repo,
58 alice: store.User{ID: aid, Username: "alice", SignedInAt: now},
59 bob: store.User{ID: bid, Username: "bob", SignedInAt: now},
60 }
61}
62
63func (e *settingsEnv) post(u store.User, form url.Values) *httptest.ResponseRecorder {
64 req := httptest.NewRequest("POST", "/alice/app/settings", strings.NewReader(form.Encode()))
65 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
66 req.SetPathValue("owner", "alice")
67 req.SetPathValue("repo", "app")
68 rr := httptest.NewRecorder()
69 e.s.settingsSubmit(rr, req, u)
70 return rr
71}
72
73func (e *settingsEnv) page(u store.User) *httptest.ResponseRecorder {
74 req := httptest.NewRequest("GET", "/alice/app/settings", nil)
75 req.SetPathValue("owner", "alice")
76 req.SetPathValue("repo", "app")
77 rr := httptest.NewRecorder()
78 e.s.settingsForm(rr, req, u)
79 return rr
80}
81
82func TestSettingsAccessGrantRevoke(t *testing.T) {
83 e := newSettingsEnv(t)
84 cid, _ := e.st.CreateUser("carol", false)
85 rr := e.post(e.alice, url.Values{"field": {"access-grant"}, "user": {"carol"}, "role": {"write"}})
86 if rr.Code != http.StatusSeeOther {
87 t.Fatalf("grant: %d %s", rr.Code, rr.Body.String())
88 }
89 if role, _ := e.st.AccessRole(e.repo.ID, cid); role != "write" {
90 t.Fatalf("role %q", role)
91 }
92 body := e.page(e.alice).Body.String()
93 for _, want := range []string{"carol", "direct", `value="access-revoke"`, "owner"} {
94 if !strings.Contains(body, want) {
95 t.Errorf("page lacks %q", want)
96 }
97 }
98 rr = e.post(e.alice, url.Values{"field": {"access-revoke"}, "user": {"carol"}})
99 if rr.Code != http.StatusSeeOther {
100 t.Fatalf("revoke: %d %s", rr.Code, rr.Body.String())
101 }
102 if role, _ := e.st.AccessRole(e.repo.ID, cid); role != "" {
103 t.Fatalf("still has %q", role)
104 }
105}
106
107func TestSettingsAccessRefusalShown(t *testing.T) {
108 e := newSettingsEnv(t)
109 rr := e.post(e.alice, url.Values{"field": {"access-grant"}, "user": {"nobody"}, "role": {"read"}})
110 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "no such user &#34;nobody&#34;") {
111 t.Fatalf("%d %s", rr.Code, rr.Body.String())
112 }
113}
114
115func TestSettingsWebhookSecretStaysOffThePage(t *testing.T) {
116 e := newSettingsEnv(t)
117 const secret = "s3cr3t-value-xyz"
118 rr := e.post(e.alice, url.Values{"field": {"webhook-add"}, "url": {"http://127.0.0.1:9/hook"},
119 "events": {"push"}, "secret": {secret}})
120 if rr.Code != http.StatusSeeOther {
121 t.Fatalf("add: %d %s", rr.Code, rr.Body.String())
122 }
123 hooks, _ := e.st.ListWebhooks(e.repo.ID)
124 if len(hooks) != 1 || hooks[0].Secret != secret || hooks[0].Events != "push" {
125 t.Fatalf("stored %+v", hooks)
126 }
127 if strings.Contains(rr.Header().Get("Location"), secret) || strings.Contains(strings.Join(rr.Header().Values("Set-Cookie"), ";"), secret) {
128 t.Fatal("secret in redirect or flash")
129 }
130 body := e.page(e.alice).Body.String()
131 if strings.Contains(body, secret) || !strings.Contains(body, "signed") || !strings.Contains(body, "127.0.0.1:9/hook") {
132 t.Fatalf("page: %s", body)
133 }
134
135 // A refused add re-renders the form without the secret.
136 rr = e.post(e.alice, url.Values{"field": {"webhook-add"}, "url": {"ftp://x"}, "events": {"push"}, "secret": {secret}})
137 if rr.Code != http.StatusOK || strings.Contains(rr.Body.String(), secret) {
138 t.Fatalf("refusal: %d, secret echoed: %v", rr.Code, strings.Contains(rr.Body.String(), secret))
139 }
140 if !strings.Contains(rr.Body.String(), `role="alert"`) {
141 t.Fatal("no error shown")
142 }
143
144 rr = e.post(e.alice, url.Values{"field": {"webhook-remove"}, "id": {"1"}})
145 if rr.Code != http.StatusSeeOther {
146 t.Fatalf("remove: %d %s", rr.Code, rr.Body.String())
147 }
148 if hooks, _ := e.st.ListWebhooks(e.repo.ID); len(hooks) != 0 {
149 t.Fatalf("still %+v", hooks)
150 }
151}
152
153func TestSettingsWebhookRedeliver(t *testing.T) {
154 e := newSettingsEnv(t)
155 rr := e.post(e.alice, url.Values{"field": {"webhook-redeliver"}, "delivery": {"99"}})
156 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "no delivery 99") {
157 t.Fatalf("%d %s", rr.Code, rr.Body.String())
158 }
159}
160
161func TestSettingsRename(t *testing.T) {
162 e := newSettingsEnv(t)
163 rr := e.post(e.alice, url.Values{"field": {"rename"}, "name": {"Bad Name"}})
164 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `role="alert"`) {
165 t.Fatalf("refusal: %d", rr.Code)
166 }
167 rr = e.post(e.alice, url.Values{"field": {"rename"}, "name": {"tool"}})
168 if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/alice/tool/settings" {
169 t.Fatalf("%d %q", rr.Code, rr.Header().Get("Location"))
170 }
171 if _, err := os.Stat(control.RepoDir(e.s.cfg.Server.Root, "alice", "tool")); err != nil {
172 t.Fatal(err)
173 }
174}
175
176func TestSettingsDeleteNeedsTypedPath(t *testing.T) {
177 e := newSettingsEnv(t)
178 rr := e.post(e.alice, url.Values{"field": {"delete"}, "confirm": {"app"}})
179 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "type alice/app to confirm") {
180 t.Fatalf("%d %s", rr.Code, rr.Body.String())
181 }
182 if _, err := e.st.RepoByPath("alice/app"); err != nil {
183 t.Fatal("deleted without confirmation")
184 }
185 rr = e.post(e.alice, url.Values{"field": {"delete"}, "confirm": {"alice/app"}})
186 if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/alice" {
187 t.Fatalf("%d %q", rr.Code, rr.Header().Get("Location"))
188 }
189 if _, err := e.st.RepoByPath("alice/app"); err == nil {
190 t.Fatal("not deleted")
191 }
192}
193
194func TestSettingsTransfer(t *testing.T) {
195 e := newSettingsEnv(t)
196 if _, msg, ok := e.s.runControl(e.alice, []string{"org", "create", "krz"}); !ok {
197 t.Fatal(msg)
198 }
199 rr := e.post(e.alice, url.Values{"field": {"transfer"}, "new-owner": {"krz"}})
200 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "type alice/app to confirm") {
201 t.Fatalf("unconfirmed: %d", rr.Code)
202 }
203 rr = e.post(e.alice, url.Values{"field": {"transfer"}, "new-owner": {"nowhere"}, "confirm": {"alice/app"}})
204 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "cannot transfer to &#34;nowhere&#34;") {
205 t.Fatalf("refusal: %d %s", rr.Code, rr.Body.String())
206 }
207 rr = e.post(e.alice, url.Values{"field": {"transfer"}, "new-owner": {"krz"}, "confirm": {"alice/app"}})
208 if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/krz/app" {
209 t.Fatalf("%d %q", rr.Code, rr.Header().Get("Location"))
210 }
211 if _, err := e.st.RepoByPath("krz/app"); err != nil {
212 t.Fatal(err)
213 }
214}
215
216// Only a repository admin reaches the page or the forms; a reader is
217// refused before any command runs.
218func TestSettingsNonAdminSeesNoForms(t *testing.T) {
219 e := newSettingsEnv(t)
220 if rr := e.page(e.bob); rr.Code != http.StatusForbidden || strings.Contains(rr.Body.String(), "webhook-add") {
221 t.Fatalf("page: %d", rr.Code)
222 }
223 for _, form := range []url.Values{
224 {"field": {"webhook-add"}, "url": {"http://127.0.0.1:9/h"}},
225 {"field": {"access-grant"}, "user": {"bob"}, "role": {"admin"}},
226 {"field": {"delete"}, "confirm": {"alice/app"}},
227 {"field": {"rename"}, "name": {"x"}},
228 } {
229 if rr := e.post(e.bob, form); rr.Code != http.StatusForbidden {
230 t.Errorf("%v: %d", form, rr.Code)
231 }
232 }
233 if hooks, _ := e.st.ListWebhooks(e.repo.ID); len(hooks) != 0 {
234 t.Fatal("a reader added a webhook")
235 }
236 if role, _ := e.st.AccessRole(e.repo.ID, e.bob.ID); role != "read" {
237 t.Fatalf("role became %q", role)
238 }
239 if _, err := e.st.RepoByPath("alice/app"); err != nil {
240 t.Fatal("a reader deleted it")
241 }
242 body := e.page(e.alice).Body.String()
243 for _, want := range []string{`value="webhook-add"`, `value="access-grant"`, `value="rename"`, `value="transfer"`, `value="delete"`} {
244 if !strings.Contains(body, want) {
245 t.Errorf("admin page lacks %s", want)
246 }
247 }
248}
249
250func TestNewImportRefusalShown(t *testing.T) {
251 e := newSettingsEnv(t)
252 form := url.Values{"field": {"import"}, "owner": {"alice"}, "name": {"copy"},
253 "from": {"https://user:pw@example.org/r.git"}, "token": {"tok-abc"}}
254 req := httptest.NewRequest("POST", "/new", strings.NewReader(form.Encode()))
255 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
256 rr := httptest.NewRecorder()
257 e.s.newSubmit(rr, req, e.alice)
258 body := rr.Body.String()
259 if rr.Code != http.StatusOK || !strings.Contains(body, "do not embed credentials in the URL") {
260 t.Fatalf("%d %s", rr.Code, body)
261 }
262 if strings.Contains(body, "tok-abc") {
263 t.Fatal("token echoed")
264 }
265 if !strings.Contains(body, `name="field" value="import"`) {
266 t.Fatal("import form missing")
267 }
268}
internal/web/static/style.css +2 −2
@@ -787,7 +787,7 @@ form.setform {
787} 787}
788form.setform label { margin-top: 6px; } 788form.setform label { margin-top: 6px; }
789form.setform .hint { margin: 2px 0 0; } 789form.setform .hint { margin: 2px 0 0; }
790form.setform input[type="text"], form.setform select { width: 100%; } 790form.setform input[type="text"], form.setform input[type="password"], form.setform select { width: 100%; }
791/* a control narrower than its column is pushed to the column's end, 791/* a control narrower than its column is pushed to the column's end,
792 which is where a full-width input's own right edge lands */ 792 which is where a full-width input's own right edge lands */
793form.setform .check { justify-content: flex-end; } 793form.setform .check { justify-content: flex-end; }
@@ -796,7 +796,7 @@ form.setform .num input[type="number"] { width: auto; }
796/* the third column takes the leftover width; the button keeps its own */ 796/* the third column takes the leftover width; the button keeps its own */
797form.setform > button, form.setform > .btngroup { justify-self: start; } 797form.setform > button, form.setform > .btngroup { justify-self: start; }
798form.setform.stack { grid-template-columns: 1fr; } 798form.setform.stack { grid-template-columns: 1fr; }
799form.setform.stack textarea, form.setform.stack select { width: 100%; max-width: 48rem; } 799form.setform.stack textarea, form.setform.stack select, form.setform.stack input[type="text"], form.setform.stack input[type="password"] { width: 100%; max-width: 48rem; }
800ul.protlist { list-style: none; margin: var(--sp-2) 0; padding: 0; } 800ul.protlist { list-style: none; margin: var(--sp-2) 0; padding: 0; }
801ul.protlist li { 801ul.protlist li {
802 display: flex; 802 display: flex;
internal/web/templates/new.html +21
@@ -21,6 +21,27 @@
21<p><button type="submit">Create repository</button></p> 21<p><button type="submit">Create repository</button></p>
22</form> 22</form>
23 23
24<h2 id="import">Import</h2>
25<p class="meta">Copies a repository from another host, over http or https. A private source needs an access token; it is sent to the remote for this one fetch and not stored.</p>
26<form method="post" action="/new" autocomplete="off">
27<input type="hidden" name="field" value="import">
28<p><label>Owner <select name="owner">
29 <option value="{{.Viewer}}">{{.Viewer}}</option>
30 {{range .Orgs}}<option value="{{.}}">{{.}}</option>{{end}}
31</select></label>
32<label>/ Name <input name="name" required maxlength="63" pattern="[a-z0-9][a-z0-9._\-]{0,62}" autocomplete="off" spellcheck="false"></label></p>
33<p><label>From <input type="text" name="from" required placeholder="https://github.com/owner/repo.git" spellcheck="false" size="48"></label></p>
34<p><label>Access token <input type="password" name="token" autocomplete="new-password"></label> <span class="hint">Optional.</span></p>
35<fieldset class="segmented">
36 <legend>Visibility</legend>
37 <div class="options">
38 <label><input type="radio" name="visibility" value="public" checked><span>Public</span></label>
39 <label><input type="radio" name="visibility" value="private"><span>Private</span></label>
40 </div>
41</fieldset>
42<p><button type="submit">Import repository</button></p>
43</form>
44
24<h2 id="org">Organization</h2> 45<h2 id="org">Organization</h2>
25<p class="meta">An organization owns repositories, labels and milestones, and 46<p class="meta">An organization owns repositories, labels and milestones, and
26grants access through teams. You are its first admin.</p> 47grants access through teams. You are its first admin.</p>
internal/web/templates/settings.html +83 −2
@@ -12,6 +12,7 @@
12 <ul> 12 <ul>
13 <li><a href="#identity">Identity</a></li> 13 <li><a href="#identity">Identity</a></li>
14 <li><a href="#access">Access</a></li> 14 <li><a href="#access">Access</a></li>
15 <li><a href="#webhooks">Webhooks</a></li>
15 <li><a href="#gates">Merge gates</a></li> 16 <li><a href="#gates">Merge gates</a></li>
16 <li><a href="#branches">Protected branches</a></li> 17 <li><a href="#branches">Protected branches</a></li>
17 <li><a href="#tags">Protected tags</a></li> 18 <li><a href="#tags">Protected tags</a></li>
@@ -66,6 +67,68 @@
66 <div class="check"><input type="checkbox" id="git-daemon" name="git-daemon" value="on"{{if .Repo.Settings.GitDaemon}} checked{{end}}></div> 67 <div class="check"><input type="checkbox" id="git-daemon" name="git-daemon" value="on"{{if .Repo.Settings.GitDaemon}} checked{{end}}></div>
67 <div><button type="submit" class="btn">Save</button></div> 68 <div><button type="submit" class="btn">Save</button></div>
68</form> 69</form>
70<h3>Who can reach this repository</h3>
71{{if .Access}}<div class="tablewrap"><table class="keys">
72<thead><tr class="cols"><th scope="col">user</th><th scope="col">role</th><th scope="col">via</th><th scope="col"><span class="vh">actions</span></th></tr></thead>
73<tbody>{{range .Access}}<tr>
74 <td class="mono"><a href="/{{.User}}">{{.User}}</a></td>
75 <td>{{.Role}}</td>
76 <td>{{.Source}}</td>
77 <td class="act">{{if eq .Source "direct"}}<form method="post" action="{{$base}}" class="inline">
78 <input type="hidden" name="field" value="access-revoke">
79 <input type="hidden" name="user" value="{{.User}}">
80 <button type="submit" class="danger">Revoke</button>
81 </form>{{end}}</td>
82</tr>{{end}}</tbody></table></div>
83{{else}}<p class="meta">Nobody else can reach it.</p>{{end}}
84<form method="post" action="{{$base}}" class="setform">
85 <input type="hidden" name="field" value="access-grant">
86 <div><label for="grant-user">Grant access</label><p class="hint">An account name and a role. A grant adds to what an organization or team already gives.</p></div>
87 <div><input type="text" id="grant-user" name="user" value="{{index .Submitted "user"}}" autocomplete="off" spellcheck="false" placeholder="username">
88 <select name="role" aria-label="Role">{{$role := index .Submitted "role"}}<option value="read"{{if eq $role "read"}} selected{{end}}>read</option><option value="write"{{if eq $role "write"}} selected{{end}}>write</option><option value="admin"{{if eq $role "admin"}} selected{{end}}>admin</option></select></div>
89 <div><button type="submit" class="btn">Grant</button></div>
90</form>
91</section>
92
93<section id="webhooks"><h2>Webhooks</h2>
94{{if .Hooks}}
95<ul class="protlist">
96{{range .Hooks}}<li><span class="mono">#{{.ID}}</span> <code>{{.URL}}</code> <span class="meta">{{.Events}}{{if .Secret}}, signed{{end}}</span>
97 <form method="post" action="{{$base}}" class="inline">
98 <input type="hidden" name="field" value="webhook-remove">
99 <input type="hidden" name="id" value="{{.ID}}">
100 <button type="submit" class="danger">Remove</button>
101 </form></li>
102{{end}}
103</ul>
104{{else}}<p class="meta">No webhooks.</p>{{end}}
105<form method="post" action="{{$base}}" class="setform stack" autocomplete="off">
106 <input type="hidden" name="field" value="webhook-add">
107 <label for="hook-url">Add a webhook</label>
108 <input type="text" id="hook-url" name="url" value="{{index .Submitted "url"}}" placeholder="https://ci.example.org/hook" spellcheck="false">
109 <label for="hook-events">Events</label>
110 <p class="hint">Comma separated, or <code>*</code> for all.</p>
111 <input type="text" id="hook-events" name="events" value="{{or (index .Submitted "events") "*"}}" spellcheck="false">
112 <label for="hook-secret">Secret</label>
113 <p class="hint">Optional. Signs each delivery; it is not shown again.</p>
114 <input type="password" id="hook-secret" name="secret" value="" autocomplete="new-password">
115 <button type="submit" class="btn">Add</button>
116</form>
117<h3>Recent deliveries</h3>
118{{if .Deliveries}}<div class="tablewrap"><table class="keys">
119<thead><tr class="cols"><th scope="col">id</th><th scope="col">event</th><th scope="col">url</th><th scope="col">state</th><th scope="col"><span class="vh">actions</span></th></tr></thead>
120<tbody>{{range .Deliveries}}<tr>
121 <td class="mono">{{.ID}}</td>
122 <td>{{.Event}}</td>
123 <td class="mono">{{.URL}}</td>
124 <td>{{.Status}} ({{.Attempts}} attempts){{if .LastError}}<br><span class="meta">{{.LastError}}</span>{{end}}</td>
125 <td class="act"><form method="post" action="{{$base}}" class="inline">
126 <input type="hidden" name="field" value="webhook-redeliver">
127 <input type="hidden" name="delivery" value="{{.ID}}">
128 <button type="submit" class="btn">Redeliver</button>
129 </form></td>
130</tr>{{end}}</tbody></table></div>
131{{else}}<p class="meta">Nothing delivered yet.</p>{{end}}
69</section> 132</section>
70 133
71<section id="gates"><h2>Merge gates</h2> 134<section id="gates"><h2>Merge gates</h2>
@@ -204,8 +267,26 @@
204 <div class="check"><input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}></div> 267 <div class="check"><input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}></div>
205 <div><button type="submit" {{if .Repo.Settings.Archived}}class="btn"{{else}}class="danger"{{end}}>Save</button></div> 268 <div><button type="submit" {{if .Repo.Settings.Archived}}class="btn"{{else}}class="danger"{{end}}>Save</button></div>
206</form> 269</form>
207<p class="meta">Deleting or transferring a repository is a CLI operation: 270<form method="post" action="{{$base}}" class="setform">
208<code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p> 271 <input type="hidden" name="field" value="rename">
272 <div><label for="rename">Name</label><p class="hint">Clone URLs change. The old path stops resolving.</p></div>
273 <div><input type="text" id="rename" name="name" value="{{or (index .Submitted "name") .Repo.Name}}" autocomplete="off" spellcheck="false"></div>
274 <div><button type="submit" class="btn">Rename</button></div>
275</form>
276{{$path := printf "%s/%s" .Repo.OwnerName .Repo.Name}}
277<form method="post" action="{{$base}}" class="setform stack">
278 <input type="hidden" name="field" value="transfer">
279 <label for="new-owner">Transfer</label>
280 <p class="hint">Move it to your own account or an organization you administer. Clone URLs change.</p>
281 <input type="text" id="new-owner" name="new-owner" value="{{index .Submitted "new-owner"}}" placeholder="new owner" autocomplete="off" spellcheck="false">
282 <p>{{template "confirmfield" $path}} <button type="submit" class="danger">Transfer</button></p>
283</form>
284<form method="post" action="{{$base}}" class="setform stack">
285 <input type="hidden" name="field" value="delete">
286 <span class="fieldname">Delete</span>
287 <p class="hint">Removes the repository, its issues and merge requests. Cannot be undone.</p>
288 <p>{{template "confirmfield" $path}} <button type="submit" class="danger">Delete repository</button></p>
289</form>
209</section> 290</section>
210</div> 291</div>
211</div> 292</div>