Commit 467699a1ed

467699a1ed1e29f30e110aa41eefc8c1d59138a0

parent: bc49092e23

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

cmc <hello@cleberg.net> · 2026-09-04 00:06 UTC

web: form actions answer by exit code

The action handlers discarded a command's exit code and redirected
with the message whatever the cause, so an issue that does not exist
came back as the page with a note in the URL. runControlCode keeps
the code, and done finishes the action by it: the 404 page for
not-found, and back to the page with the message for anything else,
since a refusal is feedback on the page a person was looking at,
whether a merge gate, a permission they lack, or a field they got
wrong. Twelve handlers converted.

Closes #106

Layout: unified · split

e2e/webwrites_test.go +6
@@ -50,6 +50,12 @@ func TestWebWritesGoThroughRegistry(t *testing.T) {
50 t.Fatalf("repo created past the quota from the web: exit %d, want 3", code) 50 t.Fatalf("repo created past the quota from the web: exit %d, want 3", code)
51 } 51 }
52 52
53 // A form action on an issue that does not exist is the 404 page, not a
54 // redirect carrying a message (#106).
55 if status, _ := browserPost(t, browser, inst.base()+"/alice/first/issues/999/state", url.Values{"action": {"close"}}); status != 404 {
56 t.Fatalf("closing a missing issue from the web: %d, want 404", status)
57 }
58
53 // An archived repository refuses a comment from the web as it does 59 // An archived repository refuses a comment from the web as it does
54 // over ssh. 60 // over ssh.
55 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "archive", "alice/first"); code != 0 { 61 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "archive", "alice/first"); code != 0 {
internal/httpd/control.go +35 −4
@@ -20,6 +20,14 @@ import (
20// ViaAPI is set, which refuses SSHOnly commands: anything whose input is a 20// ViaAPI is set, which refuses SSHOnly commands: anything whose input is a
21// credential (secrets, mirror tokens, session minting) stays on SSH. 21// credential (secrets, mirror tokens, session minting) stays on SSH.
22func (s *Server) runControl(u store.User, argv []string) (out string, msg string, ok bool) { 22func (s *Server) runControl(u store.User, argv []string) (out string, msg string, ok bool) {
23 out, msg, code := s.runControlCode(u, argv)
24 return out, msg, code == protocol.ExitOK
25}
26
27// runControlCode is runControl with the exit code, for handlers that
28// answer a form: not-found and denied deserve their own statuses rather
29// than a redirect carrying the message (#106).
30func (s *Server) runControlCode(u store.User, argv []string) (out string, msg string, code int) {
23 var stdout, stderr bytes.Buffer 31 var stdout, stderr bytes.Buffer
24 ctx := &control.Ctx{ 32 ctx := &control.Ctx{
25 User: u, 33 User: u,
@@ -32,12 +40,30 @@ func (s *Server) runControl(u store.User, argv []string) (out string, msg string
32 Stderr: &stderr, 40 Stderr: &stderr,
33 ViaAPI: true, 41 ViaAPI: true,
34 } 42 }
35 code := control.Dispatch(ctx, argv) 43 code = control.Dispatch(ctx, argv)
36 m := strings.TrimSpace(stderr.String()) 44 m := strings.TrimSpace(stderr.String())
37 if m == "" { 45 if m == "" {
38 m = strings.TrimSpace(stdout.String()) 46 m = strings.TrimSpace(stdout.String())
39 } 47 }
40 return stdout.String(), m, code == protocol.ExitOK 48 return stdout.String(), m, code
49}
50
51// done finishes a form action by exit code: back to the page on success,
52// the 404 page when the thing does not exist, and back to the page with
53// the message for anything else. A refusal is feedback on the page a
54// person was looking at, whether it is a merge gate, a permission they
55// lack, or a field they got wrong; only a thing that does not exist has
56// no page to go back to.
57func (s *Server) done(w http.ResponseWriter, r *http.Request, code int, msg string,
58 redirect func(http.ResponseWriter, *http.Request, string)) {
59 switch code {
60 case protocol.ExitOK:
61 redirect(w, r, "")
62 case protocol.ExitNotFound:
63 s.notFound(w, r)
64 default:
65 redirect(w, r, msg)
66 }
41} 67}
42 68
43// runControlStdin is runControl for the handful of commands whose input 69// runControlStdin is runControl for the handful of commands whose input
@@ -46,6 +72,11 @@ func (s *Server) runControl(u store.User, argv []string) (out string, msg string
46// tokens and mirror credentials remain SSHOnly and are refused by the 72// tokens and mirror credentials remain SSHOnly and are refused by the
47// dispatcher. 73// dispatcher.
48func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg string, ok bool) { 74func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg string, ok bool) {
75 msg, code := s.runControlStdinCode(u, argv, stdin)
76 return msg, code == protocol.ExitOK
77}
78
79func (s *Server) runControlStdinCode(u store.User, argv []string, stdin string) (msg string, code int) {
49 var stdout, stderr bytes.Buffer 80 var stdout, stderr bytes.Buffer
50 ctx := &control.Ctx{ 81 ctx := &control.Ctx{
51 User: u, 82 User: u,
@@ -58,12 +89,12 @@ func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg
58 Stderr: &stderr, 89 Stderr: &stderr,
59 ViaAPI: true, 90 ViaAPI: true,
60 } 91 }
61 code := control.Dispatch(ctx, argv) 92 code = control.Dispatch(ctx, argv)
62 m := strings.TrimSpace(stderr.String()) 93 m := strings.TrimSpace(stderr.String())
63 if m == "" { 94 if m == "" {
64 m = strings.TrimSpace(stdout.String()) 95 m = strings.TrimSpace(stdout.String())
65 } 96 }
66 return m, code == protocol.ExitOK 97 return m, code
67} 98}
68 99
69// runControlInto runs a command in JSON mode and decodes its data into 100// runControlInto runs a command in JSON mode and decodes its data into
internal/httpd/issueactions.go +8 −20
@@ -36,11 +36,8 @@ func (s *Server) issueStateSubmit(w http.ResponseWriter, r *http.Request, u stor
36 if r.FormValue("action") == "reopen" { 36 if r.FormValue("action") == "reopen" {
37 verb = "reopen" 37 verb = "reopen"
38 } 38 }
39 _, msg, ok := s.runControl(u, issueArgs(r, verb)) 39 _, msg, code := s.runControlCode(u, issueArgs(r, verb))
40 if ok { 40 s.done(w, r, code, msg, s.issueRedirect)
41 msg = ""
42 }
43 s.issueRedirect(w, r, msg)
44} 41}
45 42
46// fieldArgs turns a space-separated form value into repeated flags, the 43// fieldArgs turns a space-separated form value into repeated flags, the
@@ -59,11 +56,8 @@ func (s *Server) issueLabelSubmit(w http.ResponseWriter, r *http.Request, u stor
59 s.issueRedirect(w, r, "name at least one label") 56 s.issueRedirect(w, r, "name at least one label")
60 return 57 return
61 } 58 }
62 _, msg, ok := s.runControl(u, issueArgs(r, "label", args...)) 59 _, msg, code := s.runControlCode(u, issueArgs(r, "label", args...))
63 if ok { 60 s.done(w, r, code, msg, s.issueRedirect)
64 msg = ""
65 }
66 s.issueRedirect(w, r, msg)
67} 61}
68 62
69func (s *Server) issueAssignSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 63func (s *Server) issueAssignSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
@@ -72,11 +66,8 @@ func (s *Server) issueAssignSubmit(w http.ResponseWriter, r *http.Request, u sto
72 s.issueRedirect(w, r, "name at least one person") 66 s.issueRedirect(w, r, "name at least one person")
73 return 67 return
74 } 68 }
75 _, msg, ok := s.runControl(u, issueArgs(r, "assign", args...)) 69 _, msg, code := s.runControlCode(u, issueArgs(r, "assign", args...))
76 if ok { 70 s.done(w, r, code, msg, s.issueRedirect)
77 msg = ""
78 }
79 s.issueRedirect(w, r, msg)
80} 71}
81 72
82func (s *Server) issueMilestoneSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 73func (s *Server) issueMilestoneSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
@@ -84,9 +75,6 @@ func (s *Server) issueMilestoneSubmit(w http.ResponseWriter, r *http.Request, u
84 if title == "" { 75 if title == "" {
85 title = "none" 76 title = "none"
86 } 77 }
87 _, msg, ok := s.runControl(u, issueArgs(r, "milestone", title)) 78 _, msg, code := s.runControlCode(u, issueArgs(r, "milestone", title))
88 if ok { 79 s.done(w, r, code, msg, s.issueRedirect)
89 msg = ""
90 }
91 s.issueRedirect(w, r, msg)
92} 80}
internal/httpd/mractions.go +12 −30
@@ -59,11 +59,8 @@ func (s *Server) mrReviewSubmit(w http.ResponseWriter, r *http.Request, u store.
59 s.mrRedirect(w, r, "pick approve, request changes, or comment") 59 s.mrRedirect(w, r, "pick approve, request changes, or comment")
60 return 60 return
61 } 61 }
62 _, msg, ok := s.runControl(u, mrArgs(r, "review", flag)) 62 _, msg, code := s.runControlCode(u, mrArgs(r, "review", flag))
63 if ok { 63 s.done(w, r, code, msg, s.mrRedirect)
64 msg = ""
65 }
66 s.mrRedirect(w, r, msg)
67} 64}
68 65
69func (s *Server) mrMergeSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 66func (s *Server) mrMergeSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
@@ -71,19 +68,13 @@ func (s *Server) mrMergeSubmit(w http.ResponseWriter, r *http.Request, u store.U
71 if st := strings.TrimSpace(r.FormValue("strategy")); st != "" && st != "auto" { 68 if st := strings.TrimSpace(r.FormValue("strategy")); st != "" && st != "auto" {
72 args = append(args, "--strategy", st) 69 args = append(args, "--strategy", st)
73 } 70 }
74 _, msg, ok := s.runControl(u, mrArgs(r, "merge", args...)) 71 _, msg, code := s.runControlCode(u, mrArgs(r, "merge", args...))
75 if ok { 72 s.done(w, r, code, msg, s.mrRedirect)
76 msg = ""
77 }
78 s.mrRedirect(w, r, msg)
79} 73}
80 74
81func (s *Server) mrCloseSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 75func (s *Server) mrCloseSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
82 _, msg, ok := s.runControl(u, mrArgs(r, "close")) 76 _, msg, code := s.runControlCode(u, mrArgs(r, "close"))
83 if ok { 77 s.done(w, r, code, msg, s.mrRedirect)
84 msg = ""
85 }
86 s.mrRedirect(w, r, msg)
87} 78}
88 79
89// mrDiffCommentSubmit opens a review thread on a diff line, or replies to 80// mrDiffCommentSubmit opens a review thread on a diff line, or replies to
@@ -114,11 +105,8 @@ func (s *Server) mrDiffCommentSubmit(w http.ResponseWriter, r *http.Request, u s
114 extra = append(extra, "--old") 105 extra = append(extra, "--old")
115 } 106 }
116 } 107 }
117 msg, ok := s.runControlStdin(u, mrArgs(r, "diff-comment", append(extra, "--file", "-")...), body) 108 msg, code := s.runControlStdinCode(u, mrArgs(r, "diff-comment", append(extra, "--file", "-")...), body)
118 if ok { 109 s.done(w, r, code, msg, s.mrDiffRedirect)
119 msg = ""
120 }
121 s.mrDiffRedirect(w, r, msg)
122} 110}
123 111
124// mrRetargetSubmit moves the merge request onto another branch. 112// mrRetargetSubmit moves the merge request onto another branch.
@@ -128,11 +116,8 @@ func (s *Server) mrRetargetSubmit(w http.ResponseWriter, r *http.Request, u stor
128 s.mrRedirect(w, r, "pick a branch to retarget onto") 116 s.mrRedirect(w, r, "pick a branch to retarget onto")
129 return 117 return
130 } 118 }
131 _, msg, ok := s.runControl(u, mrArgs(r, "retarget", target)) 119 _, msg, code := s.runControlCode(u, mrArgs(r, "retarget", target))
132 if ok { 120 s.done(w, r, code, msg, s.mrRedirect)
133 msg = ""
134 }
135 s.mrRedirect(w, r, msg)
136} 121}
137 122
138// mrThreadSubmit resolves or reopens one review thread. 123// mrThreadSubmit resolves or reopens one review thread.
@@ -146,11 +131,8 @@ func (s *Server) mrThreadSubmit(w http.ResponseWriter, r *http.Request, u store.
146 s.mrRedirect(w, r, "bad thread id") 131 s.mrRedirect(w, r, "bad thread id")
147 return 132 return
148 } 133 }
149 _, msg, ok := s.runControl(u, mrArgs(r, verb, id)) 134 _, msg, code := s.runControlCode(u, mrArgs(r, verb, id))
150 if ok { 135 s.done(w, r, code, msg, s.mrRedirect)
151 msg = ""
152 }
153 s.mrRedirect(w, r, msg)
154} 136}
155 137
156// mrNewPage is the create form: branches to choose from, plus whatever 138// mrNewPage is the create form: branches to choose from, plus whatever
internal/httpd/releaseactions.go +4 −10
@@ -45,11 +45,8 @@ func (s *Server) releaseSubmit(w http.ResponseWriter, r *http.Request, u store.U
45 if notes != "" || verb == "edit" { 45 if notes != "" || verb == "edit" {
46 argv = append(argv, "--notes", notes) 46 argv = append(argv, "--notes", notes)
47 } 47 }
48 _, msg, ok := s.runControl(u, argv) 48 _, msg, code := s.runControlCode(u, argv)
49 if ok { 49 s.done(w, r, code, msg, func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "releases", msg) })
50 msg = ""
51 }
52 s.backTo(w, r, "releases", msg)
53} 50}
54 51
55func (s *Server) buildTriggerSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 52func (s *Server) buildTriggerSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
@@ -59,9 +56,6 @@ func (s *Server) buildTriggerSubmit(w http.ResponseWriter, r *http.Request, u st
59 s.backTo(w, r, "builds", "pick a job") 56 s.backTo(w, r, "builds", "pick a job")
60 return 57 return
61 } 58 }
62 _, msg, ok := s.runControl(u, []string{"build", "trigger", repo, job}) 59 _, msg, code := s.runControlCode(u, []string{"build", "trigger", repo, job})
63 if ok { 60 s.done(w, r, code, msg, func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "builds", msg) })
64 msg = ""
65 }
66 s.backTo(w, r, "builds", msg)
67} 61}