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

merged merged by cmc on 2026-09-03 15:59 UTC · krz/gitbay:web-writes-registry into main

3 files changed, +134 −151

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