Commit 8a7afd05e0
Verified · cmc
Layout: unified · split
.gitbay/wiki/Parity.org +10 −11
| @@ -203,11 +203,11 @@ rather than the one the web page shows. | ||
| 203 | 203 | | protected tags | yes | yes | yes | |
| 204 | 204 | | require codeowners | yes | yes | yes | |
| 205 | 205 | | require contexts | yes | yes | no | |
| 206 | | access grants | yes | no | yes | | |
| 207 | | effective access | yes | no | yes | | |
| 208 | | webhooks | yes | no | yes | | |
| 206 | | access grants | yes | yes | yes | | |
| 207 | | effective access | yes | yes | yes | | |
| 208 | | webhooks | yes | yes | yes | | |
| 209 | 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 | 211 | | topics, website | yes | yes | yes | |
| 212 | 212 | | visibility | yes | yes | yes | |
| 213 | 213 | | archive (read-only flag) | yes | yes | yes | |
| @@ -231,8 +231,8 @@ rather than the one the web page shows. | ||
| 231 | 231 | | job image (ci.yml) | yes | n/a | n/a | |
| 232 | 232 | | dependency checks on/off | yes | yes | yes | |
| 233 | 233 | | dependency status | yes | yes | yes | |
| 234 | | delete, transfer | yes | no | no | | |
| 235 | | rename | yes | no | yes | | |
| 234 | | delete, transfer | yes | yes | no | | |
| 235 | | rename | yes | yes | yes | | |
| 236 | 236 | | release delete | yes | yes | yes | |
| 237 | 237 | | release asset add | yes | no | n/a | |
| 238 | 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 | ||
| 490 | 490 | user's own git signing configuration and pushes it. The web shows that |
| 491 | 491 | command in place of the button. |
| 492 | 492 | |
| 493 | Deleting or transferring a repository stays CLI-only on purpose, as | |
| 494 | does deleting an organization and pruning merge request heads (=admin | |
| 495 | mr prune=): each removes or moves what clone URLs point at, and wants a | |
| 496 | typed command rather than a button. Renaming is the exception: the iOS | |
| 497 | client offers it, and the web has no page yet. | |
| 493 | Deleting an organization and pruning merge request heads (=admin mr | |
| 494 | prune=) stay CLI-only for now. Deleting or transferring a repository is a | |
| 495 | settings section on the web and asks for the repository's path to be | |
| 496 | typed first. | |
| 498 | 497 | |
| 499 | 498 | =n/a= means a surface cannot usefully carry the capability at all — |
| 500 | 499 | see the archive note above. |
.gitbay/wiki/Users.org +4
| @@ -219,6 +219,10 @@ and =fork_of= when it is a | ||
| 219 | 219 | fork whose parent you can read, so a client draws a toggle rather than |
| 220 | 220 | two blind buttons. Absent means none. |
| 221 | 221 | |
| 222 | On the web, the repository's settings page carries access, webhooks, | |
| 223 | rename, transfer and delete, and =/new= imports from a remote. Delete and | |
| 224 | transfer ask for the repository's path to be typed. | |
| 225 | ||
| 222 | 226 | Pushing is SSH-only. Public repositories are anonymously readable over |
| 223 | 227 | HTTPS (and =git://= where enabled); private repositories exist only over |
| 224 | 228 | SSH 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 | 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 | 14 | - =web diff set unified|split= and a Diff layout control on the account |
| 10 | 15 | page choose how the merge request, commit and compare pages draw a |
| 11 | 16 | diff; =?layout=split|unified= overrides it per request. The split |
e2e/webhookweb_test.go added +52
| @@ -0,0 +1,52 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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). | |
| 15 | func 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 | 233 | owner = u.Username |
| 234 | 234 | } |
| 235 | 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 | 254 | argv := []string{"repo", "create", owner + "/" + name} |
| 237 | 255 | if r.FormValue("visibility") == "private" { |
| 238 | 256 | argv = append(argv, "--private") |
internal/httpd/settings.go +108 −4
| @@ -13,9 +13,8 @@ import ( | ||
| 13 | 13 | ) |
| 14 | 14 | |
| 15 | 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. | |
| 16 | // command the CLI runs; the page only groups them. Delete and transfer | |
| 17 | // ask for the repository's path to be typed first. | |
| 19 | 18 | |
| 20 | 19 | type settingsPage struct { |
| 21 | 20 | repoPage |
| @@ -24,12 +23,38 @@ type settingsPage struct { | ||
| 24 | 23 | DepsEnabled bool |
| 25 | 24 | Deps control.DepsOut |
| 26 | 25 | Runners []store.RepoRunner |
| 26 | Access []accessRow | |
| 27 | Hooks []hookRow | |
| 28 | Deliveries []deliveryRow | |
| 27 | 29 | Notice string |
| 28 | 30 | Saved bool |
| 29 | 31 | Reauth bool // Notice is the stale-session refusal: link to sign in |
| 30 | 32 | Submitted map[string]string |
| 31 | 33 | } |
| 32 | 34 | |
| 35 | type accessRow struct { | |
| 36 | User string `json:"user"` | |
| 37 | Role string `json:"role"` | |
| 38 | Source string `json:"source"` | |
| 39 | } | |
| 40 | ||
| 41 | type 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 | ||
| 48 | type 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 | ||
| 33 | 58 | func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.User) { |
| 34 | 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 | 81 | s.runControlInto(u, []string{"repo", "deps", "status", repo.Path()}, &deps) |
| 57 | 82 | var runners []store.RepoRunner |
| 58 | 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 | 90 | var subm map[string]string |
| 60 | 91 | if submitted != nil { |
| 61 | 92 | subm = map[string]string{ |
| @@ -63,12 +94,19 @@ func (s *Server) settingsFormWith(w http.ResponseWriter, r *http.Request, u stor | ||
| 63 | 94 | "website": submitted.Get("website"), |
| 64 | 95 | "topics": submitted.Get("topics"), |
| 65 | 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 | 105 | s.render(w, "settings.html", settingsPage{ |
| 69 | 106 | repoPage: p, Topics: topics, Branches: branches, |
| 70 | 107 | DepsEnabled: deps.Enabled, Deps: deps, |
| 71 | Runners: runners, | |
| 108 | Runners: runners, | |
| 109 | Access: access, Hooks: hooks, Deliveries: deliveries, | |
| 72 | 110 | Notice: notice, |
| 73 | 111 | Saved: strings.HasPrefix(notice, "Saved "), |
| 74 | 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 | 239 | return |
| 202 | 240 | case "runner-remove": |
| 203 | 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 | 304 | default: |
| 205 | 305 | s.settingsRedirect(w, r, "unknown setting") |
| 206 | 306 | return |
| @@ -255,6 +355,10 @@ func fieldLabel(field string) string { | ||
| 255 | 355 | return "topics" |
| 256 | 356 | case "runner-add", "runner-remove": |
| 257 | 357 | return "runner" |
| 358 | case "access-grant", "access-revoke": | |
| 359 | return "access" | |
| 360 | case "webhook-remove": | |
| 361 | return "webhook" | |
| 258 | 362 | default: |
| 259 | 363 | return field |
| 260 | 364 | } |
internal/httpd/settingsparity_test.go added +268
| @@ -0,0 +1,268 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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 | ||
| 18 | type settingsEnv struct { | |
| 19 | s *Server | |
| 20 | st *store.Store | |
| 21 | alice store.User | |
| 22 | bob store.User | |
| 23 | repo store.Repo | |
| 24 | } | |
| 25 | ||
| 26 | func 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 | ||
| 63 | func (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 | ||
| 73 | func (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 | ||
| 82 | func 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 | ||
| 107 | func 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 "nobody"") { | |
| 111 | t.Fatalf("%d %s", rr.Code, rr.Body.String()) | |
| 112 | } | |
| 113 | } | |
| 114 | ||
| 115 | func 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 | ||
| 153 | func 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 | ||
| 161 | func 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 | ||
| 176 | func 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 | ||
| 194 | func 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 "nowhere"") { | |
| 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. | |
| 218 | func 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 | ||
| 250 | func 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 | } |
| 788 | 788 | form.setform label { margin-top: 6px; } |
| 789 | 789 | form.setform .hint { margin: 2px 0 0; } |
| 790 | form.setform input[type="text"], form.setform select { width: 100%; } | |
| 790 | form.setform input[type="text"], form.setform input[type="password"], form.setform select { width: 100%; } | |
| 791 | 791 | /* a control narrower than its column is pushed to the column's end, |
| 792 | 792 | which is where a full-width input's own right edge lands */ |
| 793 | 793 | form.setform .check { justify-content: flex-end; } |
| @@ -796,7 +796,7 @@ form.setform .num input[type="number"] { width: auto; } | ||
| 796 | 796 | /* the third column takes the leftover width; the button keeps its own */ |
| 797 | 797 | form.setform > button, form.setform > .btngroup { justify-self: start; } |
| 798 | 798 | form.setform.stack { grid-template-columns: 1fr; } |
| 799 | form.setform.stack textarea, form.setform.stack select { width: 100%; max-width: 48rem; } | |
| 799 | form.setform.stack textarea, form.setform.stack select, form.setform.stack input[type="text"], form.setform.stack input[type="password"] { width: 100%; max-width: 48rem; } | |
| 800 | 800 | ul.protlist { list-style: none; margin: var(--sp-2) 0; padding: 0; } |
| 801 | 801 | ul.protlist li { |
| 802 | 802 | display: flex; |
internal/web/templates/new.html +21
| @@ -21,6 +21,27 @@ | ||
| 21 | 21 | <p><button type="submit">Create repository</button></p> |
| 22 | 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 | 45 | <h2 id="org">Organization</h2> |
| 25 | 46 | <p class="meta">An organization owns repositories, labels and milestones, and |
| 26 | 47 | grants access through teams. You are its first admin.</p> |
internal/web/templates/settings.html +83 −2
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | <ul> |
| 13 | 13 | <li><a href="#identity">Identity</a></li> |
| 14 | 14 | <li><a href="#access">Access</a></li> |
| 15 | <li><a href="#webhooks">Webhooks</a></li> | |
| 15 | 16 | <li><a href="#gates">Merge gates</a></li> |
| 16 | 17 | <li><a href="#branches">Protected branches</a></li> |
| 17 | 18 | <li><a href="#tags">Protected tags</a></li> |
| @@ -66,6 +67,68 @@ | ||
| 66 | 67 | <div class="check"><input type="checkbox" id="git-daemon" name="git-daemon" value="on"{{if .Repo.Settings.GitDaemon}} checked{{end}}></div> |
| 67 | 68 | <div><button type="submit" class="btn">Save</button></div> |
| 68 | 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 | 132 | </section> |
| 70 | 133 | |
| 71 | 134 | <section id="gates"><h2>Merge gates</h2> |
| @@ -204,8 +267,26 @@ | ||
| 204 | 267 | <div class="check"><input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}></div> |
| 205 | 268 | <div><button type="submit" {{if .Repo.Settings.Archived}}class="btn"{{else}}class="danger"{{end}}>Save</button></div> |
| 206 | 269 | </form> |
| 207 | <p class="meta">Deleting or transferring a repository is a CLI operation: | |
| 208 | <code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p> | |
| 270 | <form method="post" action="{{$base}}" class="setform"> | |
| 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 | 290 | </section> |
| 210 | 291 | </div> |
| 211 | 292 | </div> |