Commit 01ced7ef9e

01ced7ef9eff83ca1b088335758fb1a7ecf6ecca

parent: b15d84acd6

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-03 15:39 UTC

web: repo create, issue and MR create, edit and comment dispatch the command

Six handlers called the store directly, so from a browser the per-account
repository quota, the archived-repository refusal, participant
notifications, the stored body format and the audit entry were all
skipped. They now run repo create, issue create, issue edit, mr edit,
issue comment and mr comment through the registry with bodies on stdin,
and map the exit code to the HTTP status. Labels on create and edit go
through issue label, which enforces write access itself.

TestWebWritesGoThroughRegistry: a second repository past the quota is
refused on the form, and an archived repository refuses a web comment.

Closes #93

Layout: unified · split

e2e/webwrites_test.go added +66
@@ -0,0 +1,66 @@
1package e2e
2
3import (
4 "encoding/json"
5 "net/url"
6 "strings"
7 "testing"
8)
9
10// Web writes dispatch the command the CLI runs, so a rule the command
11// layer enforces holds from a browser too. Repo create used to call the
12// store directly and skip the per-account quota; issue comments skipped
13// the archived check (#93).
14func TestWebWritesGoThroughRegistry(t *testing.T) {
15 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n[limits]\nmax_repos_per_user = 1\n")
16 aliceKey := inst.newKey(t, "alice")
17 inst.admin(t, "admin", "user", "create", "alice",
18 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
19 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/first"); code != 0 {
20 t.Fatalf("repo create: %s", errOut)
21 }
22 if _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/first", "--title", "one"); code != 0 {
23 t.Fatalf("issue create: %s", errOut)
24 }
25
26 out, errOut, code := inst.ssh(t, aliceKey, "", "web", "login", "--json")
27 if code != 0 {
28 t.Fatalf("web login: %s", errOut)
29 }
30 var env struct {
31 Data struct {
32 URL string `json:"url"`
33 } `json:"data"`
34 }
35 if err := json.Unmarshal([]byte(out), &env); err != nil {
36 t.Fatalf("web login JSON: %v\n%s", err, out)
37 }
38 browser := newBrowser(t)
39 if status, _ := browserGet(t, browser, inst.base()+env.Data.URL[strings.Index(env.Data.URL, "/login"):]); status != 200 {
40 t.Fatalf("login: %d", status)
41 }
42
43 // The quota holds on the web: the form comes back with the refusal and
44 // no repository exists.
45 status, body := browserPost(t, browser, inst.base()+"/new", url.Values{"name": {"second"}, "visibility": {"public"}})
46 if status != 200 || !strings.Contains(body, "role=\"alert\"") {
47 t.Fatalf("second repo over quota was not refused on the form: %d\n%s", status, body)
48 }
49 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "show", "alice/second"); code != 3 {
50 t.Fatalf("repo created past the quota from the web: exit %d, want 3", code)
51 }
52
53 // An archived repository refuses a comment from the web as it does
54 // over ssh.
55 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "archive", "alice/first"); code != 0 {
56 t.Fatalf("repo archive: %s", errOut)
57 }
58 status, _ = browserPost(t, browser, inst.base()+"/alice/first/issues/1/comment", url.Values{"body": {"late"}})
59 if status/100 != 4 {
60 t.Fatalf("comment on an archived repo from the web: %d, want 4xx", status)
61 }
62 show, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/first", "1")
63 if strings.Contains(show, "late") {
64 t.Fatalf("archived repo took a web comment:\n%s", show)
65 }
66}
internal/httpd/accounts.go +56 −147
@@ -4,7 +4,6 @@ import (
44 "fmt"
55 "net/http"
66 "slices"
7 "strconv"
87 "strings"
98 "time"
109
@@ -13,6 +12,7 @@ import (
1312 "gitbay.org/gitbay/internal/control"
1413 "gitbay.org/gitbay/internal/gitutil"
1514 "gitbay.org/gitbay/internal/policy"
15 "gitbay.org/gitbay/internal/protocol"
1616 "gitbay.org/gitbay/internal/store"
1717)
1818
@@ -133,44 +133,17 @@ func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.Use
133133}
134134
135135func (s *Server) newRepoSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
136 name := r.FormValue("name")
137 visibility := "public"
138 if r.FormValue("visibility") == "private" {
139 visibility = "private"
140 }
141 fail := func(msg string) { s.renderNewRepo(w, u, msg) }
142 if err := policy.ValidateName(name); err != nil {
143 fail(err.Error())
144 return
145 }
146 // Owner: yourself, or an org you admin — same rule as repo create.
147136 owner := r.FormValue("owner")
148 ownerKind, ownerID := "user", u.ID
149137 if owner == "" {
150138 owner = u.Username
151139 }
152 if owner != u.Username {
153 org, err := s.st.OrgByName(owner)
154 if err != nil {
155 fail("no such organization")
156 return
157 }
158 role, _ := s.st.OrgRole(org.ID, u.ID)
159 if role != "admin" {
160 fail("only admins of " + owner + " can create repositories there")
161 return
162 }
163 ownerKind, ownerID = "org", org.ID
164 }
165 id, err := s.st.CreateRepo(ownerKind, ownerID, name, visibility)
166 if err != nil {
167 fail(err.Error())
168 return
140 name := r.FormValue("name")
141 argv := []string{"repo", "create", owner + "/" + name}
142 if r.FormValue("visibility") == "private" {
143 argv = append(argv, "--private")
169144 }
170 dir := control.RepoDir(s.cfg.Server.Root, owner, name)
171 if err := gitutil.InitBare(dir, "main", control.HooksDir(s.cfg.Server.Root)); err != nil {
172 s.st.DeleteRepo(id)
173 fail("initializing repository failed")
145 if _, msg, ok := s.runControl(u, argv); !ok {
146 s.renderNewRepo(w, u, msg)
174147 return
175148 }
176149 http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther)
@@ -288,155 +261,91 @@ func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store
288261 }{p, body, tplName, templates})
289262}
290263
264// Issue and merge request writes run the command the CLI runs, so the
265// archived check, notifications, body format and the audit entry have one
266// implementation. Bodies travel on stdin, the way --file - does.
267
291268func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
292 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
293 if !ok {
294 return
295 }
269 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
296270 title := strings.TrimSpace(r.FormValue("title"))
297 if title == "" {
298 http.Error(w, "title required", http.StatusBadRequest)
271 code, data, msg := s.dispatchJSON(u, []string{"issue", "create", repoPath, "--title", title, "--file", "-"}, r.FormValue("body"))
272 if code != protocol.ExitOK {
273 http.Error(w, msg, statusForExit(code))
299274 return
300275 }
301 n, err := s.st.CreateIssue(repo.ID, u.ID, title, r.FormValue("body"), "md")
302 if err != nil {
303 http.Error(w, "internal error", http.StatusInternalServerError)
304 return
305 }
306 s.st.RecordEvent(repo.ID, u.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n))
307 // Labels need write access, matching the SSH rule; ignored otherwise.
308 if labels := strings.Fields(r.FormValue("labels")); len(labels) > 0 {
309 grant, _ := s.st.AccessRole(repo.ID, u.ID)
310 if policy.CanWrite(u, repo, grant) {
311 if iss, err := s.st.IssueByNumber(repo.ID, n); err == nil {
312 for _, l := range labels {
313 s.st.SetIssueLabel(repo.ID, iss.ID, l, true)
314 }
315 }
316 }
276 n := int64(data["number"].(float64))
277 // Labels need write access, matching the SSH rule; the command refuses
278 // otherwise and the issue stands without them.
279 if args := fieldArgs("--add", r.FormValue("labels")); len(args) > 0 {
280 s.runControl(u, append([]string{"issue", "label", repoPath, fmt.Sprint(n)}, args...))
317281 }
318 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
282 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repoPath, n), http.StatusSeeOther)
319283}
320284
321285// issueEditSubmit edits title/body (author or write) and, with write
322286// access, replaces the label set.
323287func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
324 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
325 if !ok {
326 return
327 }
328 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
329 iss, err := s.st.IssueByNumber(repo.ID, n)
330 if err != nil {
331 http.NotFound(w, r)
332 return
333 }
334 grant, _ := s.st.AccessRole(repo.ID, u.ID)
335 canWrite := policy.CanWrite(u, repo, grant)
336 if iss.Author != u.Username && !canWrite {
337 http.Error(w, "only the author or users with write access can edit", http.StatusForbidden)
338 return
339 }
288 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
289 n := r.PathValue("n")
340290 title := strings.TrimSpace(r.FormValue("title"))
341 if title == "" {
342 http.Error(w, "title required", http.StatusBadRequest)
291 code, _, msg := s.dispatchJSON(u, []string{"issue", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
292 if code != protocol.ExitOK {
293 http.Error(w, msg, statusForExit(code))
343294 return
344295 }
345 body := r.FormValue("body")
346 if err := s.st.UpdateIssueText(iss.ID, &title, &body, nil); err != nil {
347 http.Error(w, "internal error", http.StatusInternalServerError)
348 return
296 var cur struct {
297 Labels []string `json:"labels"`
349298 }
350 if canWrite {
299 if _, ok := s.runControlInto(u, []string{"issue", "show", repoPath, n}, &cur); ok {
351300 want := strings.Fields(r.FormValue("labels"))
352 for _, l := range iss.Labels {
301 var args []string
302 for _, l := range cur.Labels {
353303 if !slices.Contains(want, l) {
354 s.st.SetIssueLabel(repo.ID, iss.ID, l, false)
304 args = append(args, "--remove", l)
355305 }
356306 }
357307 for _, l := range want {
358 s.st.SetIssueLabel(repo.ID, iss.ID, l, true)
308 if !slices.Contains(cur.Labels, l) {
309 args = append(args, "--add", l)
310 }
311 }
312 if len(args) > 0 {
313 s.runControl(u, append([]string{"issue", "label", repoPath, n}, args...))
359314 }
360315 }
361 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
316 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%s", repoPath, n), http.StatusSeeOther)
362317}
363318
364319// mrEditSubmit edits an MR's title/body (author or write).
365320func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
366 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
367 if !ok {
368 return
369 }
370 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
371 m, err := s.st.MRByNumber(repo.ID, n)
372 if err != nil {
373 http.NotFound(w, r)
374 return
375 }
376 grant, _ := s.st.AccessRole(repo.ID, u.ID)
377 if m.Author != u.Username && !policy.CanWrite(u, repo, grant) {
378 http.Error(w, "only the author or users with write access can edit", http.StatusForbidden)
379 return
380 }
321 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
322 n := r.PathValue("n")
381323 title := strings.TrimSpace(r.FormValue("title"))
382 if title == "" {
383 http.Error(w, "title required", http.StatusBadRequest)
324 code, _, msg := s.dispatchJSON(u, []string{"mr", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
325 if code != protocol.ExitOK {
326 http.Error(w, msg, statusForExit(code))
384327 return
385328 }
386 body := r.FormValue("body")
387 if err := s.st.UpdateMRText(m.ID, &title, &body, nil); err != nil {
388 http.Error(w, "internal error", http.StatusInternalServerError)
389 return
390 }
391 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther)
329 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%s", repoPath, n), http.StatusSeeOther)
392330}
393331
394332func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
395 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
396 if !ok {
397 return
398 }
399 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
400 iss, err := s.st.IssueByNumber(repo.ID, n)
401 if err != nil {
402 http.NotFound(w, r)
403 return
404 }
405 body := strings.TrimSpace(r.FormValue("body"))
406 if body == "" {
407 http.Error(w, "empty comment", http.StatusBadRequest)
408 return
409 }
410 if err := s.st.AddIssueComment(iss.ID, u.ID, body, "md"); err != nil {
411 http.Error(w, "internal error", http.StatusInternalServerError)
412 return
413 }
414 s.st.RecordEvent(repo.ID, u.ID, "issue.commented", fmt.Sprintf(`{"number":%d}`, n))
415 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
333 s.commentSubmit(w, r, u, "issue", "issues")
416334}
417335
418336func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
419 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
420 if !ok {
421 return
422 }
423 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
424 m, err := s.st.MRByNumber(repo.ID, n)
425 if err != nil {
426 http.NotFound(w, r)
427 return
428 }
429 body := strings.TrimSpace(r.FormValue("body"))
430 if body == "" {
431 http.Error(w, "empty comment", http.StatusBadRequest)
432 return
433 }
434 if err := s.st.AddMRComment(m.ID, u.ID, body, "md"); err != nil {
435 http.Error(w, "internal error", http.StatusInternalServerError)
337 s.commentSubmit(w, r, u, "mr", "mrs")
338}
339
340func (s *Server) commentSubmit(w http.ResponseWriter, r *http.Request, u store.User, noun, segment string) {
341 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
342 n := r.PathValue("n")
343 code, _, msg := s.dispatchJSON(u, []string{noun, "comment", repoPath, n, "--file", "-"}, strings.TrimSpace(r.FormValue("body")))
344 if code != protocol.ExitOK {
345 http.Error(w, msg, statusForExit(code))
436346 return
437347 }
438 s.st.RecordEvent(repo.ID, u.ID, "mr.commented", fmt.Sprintf(`{"number":%d}`, n))
439 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther)
348 http.Redirect(w, r, fmt.Sprintf("/%s/%s/%s", repoPath, segment, n), http.StatusSeeOther)
440349}
441350
442351type editPage struct {
internal/httpd/control.go +12 −4
@@ -120,6 +120,14 @@ func (s *Server) dispatchInto(u store.User, argv []string, target any) (int, str
120120// In JSON mode a failure is an envelope carrying the message rather than
121121// stderr text, so both paths are read from the same envelope.
122122func (s *Server) runControlJSON(u store.User, argv []string) (data map[string]any, msg string, ok bool) {
123 code, data, msg := s.dispatchJSON(u, argv, "")
124 return data, msg, code == protocol.ExitOK
125}
126
127// dispatchJSON runs a command in JSON mode with stdin, and returns its
128// exit code with the decoded data or the failure message. Handlers that
129// answer a form use the code to pick an HTTP status.
130func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code int, data map[string]any, msg string) {
123131 var stdout, stderr bytes.Buffer
124132 ctx := &control.Ctx{
125133 User: u,
@@ -127,13 +135,13 @@ func (s *Server) runControlJSON(u store.User, argv []string) (data map[string]an
127135 Scope: "full",
128136 Store: s.st,
129137 Cfg: s.cfg,
130 Stdin: strings.NewReader(""),
138 Stdin: strings.NewReader(stdin),
131139 Stdout: &stdout,
132140 Stderr: &stderr,
133141 JSON: true,
134142 ViaAPI: true,
135143 }
136 code := control.Dispatch(ctx, argv)
144 code = control.Dispatch(ctx, argv)
137145 var env struct {
138146 Data map[string]any `json:"data"`
139147 Error string `json:"error"`
@@ -147,9 +155,9 @@ func (s *Server) runControlJSON(u store.User, argv []string) (data map[string]an
147155 if m == "" {
148156 m = "the command failed"
149157 }
150 return nil, m, false
158 return code, nil, m
151159 }
152 return env.Data, "", true
160 return code, env.Data, ""
153161}
154162
155163// authorNames maps commit author addresses to account names for one