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

merged merged by cmc on 2026-09-29 06:15 UTC · krz/gitbay:web-parity-296a into main

13 files changed, +629 −32

Layout: unified · split

.gitbay/wiki/Parity.org +10 −11
@@ -203,11 +203,11 @@ rather than the one the web page shows.
203203| protected tags | yes | yes | yes |
204204| require codeowners | yes | yes | yes |
205205| 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 |
209209| runners attach, list, detach | yes | yes | n/a |
210| import from a remote | yes | no | yes |
210| import from a remote | yes | yes | yes |
211211| topics, website | yes | yes | yes |
212212| visibility | yes | yes | yes |
213213| archive (read-only flag) | yes | yes | yes |
@@ -231,8 +231,8 @@ rather than the one the web page shows.
231231| job image (ci.yml) | yes | n/a | n/a |
232232| dependency checks on/off | yes | yes | yes |
233233| 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 |
236236| release delete | yes | yes | yes |
237237| release asset add | yes | no | n/a |
238238| release asset remove | yes | no | yes |
@@ -490,11 +490,10 @@ so =gitbay mr apply-suggestion= makes and signs it in a clone with the
490490user's own git signing configuration and pushes it. The web shows that
491491command in place of the button.
492492
493Deleting or transferring a repository stays CLI-only on purpose, as
494does deleting an organization and pruning merge request heads (=admin
495mr prune=): each removes or moves what clone URLs point at, and wants a
496typed command rather than a button. Renaming is the exception: the iOS
497client offers it, and the web has no page yet.
493Deleting an organization and pruning merge request heads (=admin mr
494prune=) stay CLI-only for now. Deleting or transferring a repository is a
495settings section on the web and asks for the repository's path to be
496typed first.
498497
499498=n/a= means a surface cannot usefully carry the capability at all —
500499see the archive note above.
.gitbay/wiki/Users.org +5
@@ -219,6 +219,11 @@ and =fork_of= when it is a
219219fork whose parent you can read, so a client draws a toggle rather than
220220two blind buttons. Absent means none.
221221
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. Large imports belong
225on the CLI, where progress is visible.
226
222227Pushing is SSH-only. Public repositories are anonymously readable over
223228HTTPS (and =git://= where enabled); private repositories exist only over
224229SSH and answer "not found" to everyone without access.
CHANGELOG.org +5
@@ -6,6 +6,11 @@ anything beyond "replace the binary and restart" is needed.
66
77* Unreleased
88
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).
914- =web diff set unified|split= and a Diff layout control on the account
1015 page choose how the merge request, commit and compare pages draw a
1116 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/control/reauth_test.go +2
@@ -122,8 +122,10 @@ func TestNeedsRecentSignInSet(t *testing.T) {
122122 "org team grant",
123123 "pgp add",
124124 "repo access grant",
125 "repo delete",
125126 "repo deploy-key add",
126127 "repo mirror add",
128 "repo rename",
127129 "repo runner add",
128130 "repo secret set",
129131 "repo settings visibility",
internal/control/repo.go +8 −6
@@ -56,13 +56,15 @@ func init() {
5656 Examples: []string{"repo transfer krz/gitbay krazywarez"},
5757 Run: runRepoTransfer})
5858 register(Command{Path: []string{"repo", "rename"},
59 Summary: "rename a repository",
60 Usage: "repo rename <owner/name> <new-name> (clone URLs change)",
61 Examples: []string{"repo rename krz/gitbay forge"},
62 Run: runRepoRename})
59 NeedsRecentSignIn: true,
60 Summary: "rename a repository",
61 Usage: "repo rename <owner/name> <new-name> (clone URLs change)",
62 Examples: []string{"repo rename krz/gitbay forge"},
63 Run: runRepoRename})
6364 register(Command{Path: []string{"repo", "delete"},
64 Summary: "delete a repository",
65 Usage: "repo delete <owner/name> --yes",
65 NeedsRecentSignIn: true,
66 Summary: "delete a repository",
67 Usage: "repo delete <owner/name> --yes",
6668 Flags: []Flag{
6769 {"--yes", "", "confirm the permanent delete", ""},
6870 },
internal/httpd/accounts.go +32 −7
@@ -5,6 +5,7 @@ import (
55 "html/template"
66 "log"
77 "net/http"
8 "net/url"
89 "path"
910 "slices"
1011 "strconv"
@@ -203,16 +204,22 @@ func (s *Server) adminOrgs(u store.User) []string {
203204 return out
204205}
205206
206func (s *Server) renderNewRepo(w http.ResponseWriter, u store.User, errMsg string) {
207func (s *Server) renderNewRepo(w http.ResponseWriter, u store.User, errMsg string, submitted url.Values) {
208 // A refused import keeps what was typed, except the token.
209 subm := map[string]string{
210 "owner": submitted.Get("owner"), "name": submitted.Get("name"),
211 "from": submitted.Get("from"), "visibility": submitted.Get("visibility"),
212 }
207213 s.render(w, "new.html", struct {
208214 basePage
209 Orgs []string
210 Error string
211 }{s.baseFor(u), s.adminOrgs(u), errMsg})
215 Orgs []string
216 Error string
217 Submitted map[string]string
218 }{s.baseFor(u), s.adminOrgs(u), errMsg, subm})
212219}
213220
214221func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) {
215 s.renderNewRepo(w, u, "")
222 s.renderNewRepo(w, u, "", nil)
216223}
217224
218225// newSubmit creates a repository or an organization: /new carries both
@@ -222,7 +229,7 @@ func (s *Server) newSubmit(w http.ResponseWriter, r *http.Request, u store.User)
222229 if r.FormValue("field") == "org-create" {
223230 name := strings.TrimSpace(r.FormValue("name"))
224231 if _, msg, ok := s.runControl(u, []string{"org", "create", name}); !ok {
225 s.renderNewRepo(w, u, msg)
232 s.renderNewRepo(w, u, msg, nil)
226233 return
227234 }
228235 http.Redirect(w, r, "/"+name, http.StatusSeeOther)
@@ -233,12 +240,30 @@ func (s *Server) newSubmit(w http.ResponseWriter, r *http.Request, u store.User)
233240 owner = u.Username
234241 }
235242 name := r.FormValue("name")
243 if r.FormValue("field") == "import" {
244 // The token, if any, reaches the command on stdin only.
245 argv := []string{"repo", "import", owner + "/" + name, "--from", strings.TrimSpace(r.FormValue("from"))}
246 if r.FormValue("visibility") == "private" {
247 argv = append(argv, "--private")
248 }
249 var stdin string
250 if tok := strings.TrimSpace(r.FormValue("token")); tok != "" {
251 argv = append(argv, "--token-stdin")
252 stdin = tok + "\n"
253 }
254 if msg, ok := s.runControlStdin(u, argv, stdin); !ok {
255 s.renderNewRepo(w, u, msg, r.Form)
256 return
257 }
258 http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther)
259 return
260 }
236261 argv := []string{"repo", "create", owner + "/" + name}
237262 if r.FormValue("visibility") == "private" {
238263 argv = append(argv, "--private")
239264 }
240265 if _, msg, ok := s.runControl(u, argv); !ok {
241 s.renderNewRepo(w, u, msg)
266 s.renderNewRepo(w, u, msg, nil)
242267 return
243268 }
244269 http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther)
internal/httpd/settings.go +110 −4
@@ -13,9 +13,8 @@ import (
1313)
1414
1515// 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.
1918
2019type settingsPage struct {
2120 repoPage
@@ -24,12 +23,38 @@ type settingsPage struct {
2423 DepsEnabled bool
2524 Deps control.DepsOut
2625 Runners []store.RepoRunner
26 Access []accessRow
27 Hooks []hookRow
28 Deliveries []deliveryRow
2729 Notice string
2830 Saved bool
2931 Reauth bool // Notice is the stale-session refusal: link to sign in
3032 Submitted map[string]string
3133}
3234
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
3358func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.User) {
3459 s.settingsFormWith(w, r, u, s.takeFlash(w, r), nil)
3560}
@@ -56,6 +81,12 @@ func (s *Server) settingsFormWith(w http.ResponseWriter, r *http.Request, u stor
5681 s.runControlInto(u, []string{"repo", "deps", "status", repo.Path()}, &deps)
5782 var runners []store.RepoRunner
5883 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)
5990 var subm map[string]string
6091 if submitted != nil {
6192 subm = map[string]string{
@@ -63,12 +94,19 @@ func (s *Server) settingsFormWith(w http.ResponseWriter, r *http.Request, u stor
6394 "website": submitted.Get("website"),
6495 "topics": submitted.Get("topics"),
6596 "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"),
66103 }
67104 }
68105 s.render(w, "settings.html", settingsPage{
69106 repoPage: p, Topics: topics, Branches: branches,
70107 DepsEnabled: deps.Enabled, Deps: deps,
71 Runners: runners,
108 Runners: runners,
109 Access: access, Hooks: hooks, Deliveries: deliveries,
72110 Notice: notice,
73111 Saved: strings.HasPrefix(notice, "Saved "),
74112 Reauth: s.reauthNotice(w, notice, r.URL.Path),
@@ -201,6 +239,70 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.
201239 return
202240 case "runner-remove":
203241 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 s.setFlash(w, "Saved the owner: transferred to "+v("new-owner")+".")
292 http.Redirect(w, r, "/"+v("new-owner")+"/"+r.PathValue("repo")+"/settings", http.StatusSeeOther)
293 return
294 case "delete":
295 if ok, msg := confirmed(r, repo); !ok {
296 s.settingsFormWith(w, r, u, msg, r.Form)
297 return
298 }
299 if _, msg, ok := s.runControl(u, []string{"repo", "delete", repo, "--yes"}); !ok {
300 s.settingsFormWith(w, r, u, msg, r.Form)
301 return
302 }
303 s.setFlash(w, "Deleted "+repo+".")
304 http.Redirect(w, r, "/"+r.PathValue("owner"), http.StatusSeeOther)
305 return
204306 default:
205307 s.settingsRedirect(w, r, "unknown setting")
206308 return
@@ -255,6 +357,10 @@ func fieldLabel(field string) string {
255357 return "topics"
256358 case "runner-add", "runner-remove":
257359 return "runner"
360 case "access-grant", "access-revoke":
361 return "access"
362 case "webhook-remove":
363 return "webhook"
258364 default:
259365 return field
260366 }
internal/httpd/settingsparity_test.go added +298
@@ -0,0 +1,298 @@
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/settings" {
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}
269
270// A session older than the reauth window cannot delete or rename: the
271// form comes back with the refusal and nothing changes.
272func TestSettingsDeleteRenameNeedRecentSignIn(t *testing.T) {
273 e := newSettingsEnv(t)
274 stale := e.alice
275 stale.SignedInAt = time.Now().Add(-control.ReauthWindow - time.Minute)
276 rr := e.post(stale, url.Values{"field": {"delete"}, "confirm": {"alice/app"}})
277 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Sign in again") {
278 t.Fatalf("delete: %d", rr.Code)
279 }
280 if _, err := e.st.RepoByPath("alice/app"); err != nil {
281 t.Fatal("a stale session deleted the repository")
282 }
283 rr = e.post(stale, url.Values{"field": {"rename"}, "name": {"tool"}})
284 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Sign in again") {
285 t.Fatalf("rename: %d", rr.Code)
286 }
287 if _, err := e.st.RepoByPath("alice/app"); err != nil {
288 t.Fatal("a stale session renamed the repository")
289 }
290}
291
292func TestSettingsDeleteTransferFlash(t *testing.T) {
293 e := newSettingsEnv(t)
294 rr := e.post(e.alice, url.Values{"field": {"delete"}, "confirm": {"alice/app"}})
295 if c := strings.Join(rr.Header().Values("Set-Cookie"), ";"); !strings.Contains(c, "Deleted") {
296 t.Fatalf("no flash: %s", c)
297 }
298}
internal/web/static/style.css +2 −2
@@ -787,7 +787,7 @@ form.setform {
787787}
788788form.setform label { margin-top: 6px; }
789789form.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%; }
791791/* a control narrower than its column is pushed to the column's end,
792792 which is where a full-width input's own right edge lands */
793793form.setform .check { justify-content: flex-end; }
@@ -796,7 +796,7 @@ form.setform .num input[type="number"] { width: auto; }
796796/* the third column takes the leftover width; the button keeps its own */
797797form.setform > button, form.setform > .btngroup { justify-self: start; }
798798form.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; }
800800ul.protlist { list-style: none; margin: var(--sp-2) 0; padding: 0; }
801801ul.protlist li {
802802 display: flex;
internal/web/templates/new.html +21
@@ -21,6 +21,27 @@
2121<p><button type="submit">Create repository</button></p>
2222</form>
2323
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 {{$o := index .Submitted "owner"}}<option value="{{.Viewer}}">{{.Viewer}}</option>
30 {{range .Orgs}}<option value="{{.}}"{{if eq . $o}} selected{{end}}>{{.}}</option>{{end}}
31</select></label>
32<label>/ Name <input name="name" value="{{index .Submitted "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" value="{{index .Submitted "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"{{if ne (index .Submitted "visibility") "private"}} checked{{end}}><span>Public</span></label>
39 <label><input type="radio" name="visibility" value="private"{{if eq (index .Submitted "visibility") "private"}} checked{{end}}><span>Private</span></label>
40 </div>
41</fieldset>
42<p><button type="submit">Import repository</button></p>
43</form>
44
2445<h2 id="org">Organization</h2>
2546<p class="meta">An organization owns repositories, labels and milestones, and
2647grants access through teams. You are its first admin.</p>
internal/web/templates/owner.html +1
@@ -9,6 +9,7 @@
99{{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Name}}">{{.Name}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}}
1010{{if and (eq .Kind "org") .Repos}}<p class="meta"><a href="/{{.Owner}}/-/labels">labels</a> · <a href="/{{.Owner}}/-/milestones">milestones</a></p>{{end}}
1111</section>
12{{if and .Notice (ne .Tab "snippets") (ne .Tab "people")}}<p class="notice" role="status">{{.Notice}}</p>{{end}}
1213{{/* The profile is sections rather than one stack: a long About used to
1314 push the repositories off the bottom of the page (#242). About is
1415 the bare /{owner}; the rest hang off /-/. A tab nobody may open is
internal/web/templates/settings.html +83 −2
@@ -12,6 +12,7 @@
1212 <ul>
1313 <li><a href="#identity">Identity</a></li>
1414 <li><a href="#access">Access</a></li>
15 <li><a href="#webhooks">Webhooks</a></li>
1516 <li><a href="#gates">Merge gates</a></li>
1617 <li><a href="#branches">Protected branches</a></li>
1718 <li><a href="#tags">Protected tags</a></li>
@@ -66,6 +67,68 @@
6667 <div class="check"><input type="checkbox" id="git-daemon" name="git-daemon" value="on"{{if .Repo.Settings.GitDaemon}} checked{{end}}></div>
6768 <div><button type="submit" class="btn">Save</button></div>
6869</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}}
69132</section>
70133
71134<section id="gates"><h2>Merge gates</h2>
@@ -204,8 +267,26 @@
204267 <div class="check"><input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}></div>
205268 <div><button type="submit" {{if .Repo.Settings.Archived}}class="btn"{{else}}class="danger"{{end}}>Save</button></div>
206269</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>
209290</section>
210291</div>
211292</div>