internal/httpd/control.go
300 lines · 9238 bytes
1package httpd
2
3import (
4 "bytes"
5 "encoding/json"
6 "io"
7 "net/http"
8 "strings"
9
10 "gitbay.org/gitbay/internal/control"
11 "gitbay.org/gitbay/internal/gitutil"
12 "gitbay.org/gitbay/internal/protocol"
13 "gitbay.org/gitbay/internal/store"
14)
15
16// runControl executes a control command as the browser session's user,
17// through the same registry the CLI and the JSON API reach. Web writes
18// never reimplement command logic — merge gates, review rules, and audit
19// entries stay in one place — so the surfaces cannot drift apart.
20//
21// ViaAPI is set, which marks the request as one that arrived over HTTP.
22// Nothing is held back from that door any more (#234): what a caller may
23// do is the account's rights and its credential's scope, decided in one
24// place for every surface.
25func (s *Server) runControl(u store.User, argv []string) (out string, msg string, ok bool) {
26 out, msg, code := s.runControlCode(u, argv)
27 return out, msg, code == protocol.ExitOK
28}
29
30// runControlCode is runControl with the exit code, for handlers that
31// answer a form: not-found and denied deserve their own statuses rather
32// than a redirect carrying the message (#106).
33func (s *Server) runControlCode(u store.User, argv []string) (out string, msg string, code int) {
34 var stdout, stderr bytes.Buffer
35 ctx := &control.Ctx{
36 User: u,
37 Source: "web",
38 Scope: "full",
39 Store: s.st,
40 Cfg: s.cfg,
41 Stdin: strings.NewReader(""),
42 Stdout: &stdout,
43 Stderr: &stderr,
44 ViaAPI: true,
45 }
46 code = control.Dispatch(ctx, argv)
47 m := strings.TrimSpace(stderr.String())
48 if m == "" {
49 m = strings.TrimSpace(stdout.String())
50 }
51 return stdout.String(), m, code
52}
53
54// runControlStream runs a command whose output is written as it is
55// produced: stdout goes to out, and done ends the command when the
56// request does. msg is stderr.
57func (s *Server) runControlStream(u store.User, argv []string, out io.Writer, done <-chan struct{}) (msg string, code int) {
58 var stderr bytes.Buffer
59 ctx := &control.Ctx{
60 User: u,
61 Source: "web",
62 Scope: "full",
63 Store: s.st,
64 Cfg: s.cfg,
65 Stdin: strings.NewReader(""),
66 Stdout: out,
67 Stderr: &stderr,
68 ViaAPI: true,
69 Done: done,
70 Stopping: s.stopping,
71 }
72 code = control.Dispatch(ctx, argv)
73 return strings.TrimSpace(stderr.String()), code
74}
75
76// done finishes a form action by exit code: back to the page on success,
77// the 404 page when the thing does not exist, and back to the page with
78// the message for anything else. A refusal is feedback on the page a
79// person was looking at, whether it is a merge gate, a permission they
80// lack, or a field they got wrong; only a thing that does not exist has
81// no page to go back to.
82func (s *Server) done(w http.ResponseWriter, r *http.Request, code int, msg string,
83 redirect func(http.ResponseWriter, *http.Request, string)) {
84 switch code {
85 case protocol.ExitOK:
86 redirect(w, r, "")
87 case protocol.ExitNotFound:
88 s.notFound(w, r)
89 default:
90 redirect(w, r, msg)
91 }
92}
93
94// runControlStdin is runControl for the handful of commands whose input
95// arrives on stdin: public keys, and review comment bodies. Stdin is
96// also where a secret goes when one is set through this path, since
97// argv is world-readable in /proc and the audit log keeps flag values.
98func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg string, ok bool) {
99 msg, code := s.runControlStdinCode(u, argv, stdin)
100 return msg, code == protocol.ExitOK
101}
102
103func (s *Server) runControlStdinCode(u store.User, argv []string, stdin string) (msg string, code int) {
104 var stdout, stderr bytes.Buffer
105 ctx := &control.Ctx{
106 User: u,
107 Source: "web",
108 Scope: "full",
109 Store: s.st,
110 Cfg: s.cfg,
111 Stdin: strings.NewReader(stdin),
112 Stdout: &stdout,
113 Stderr: &stderr,
114 ViaAPI: true,
115 }
116 code = control.Dispatch(ctx, argv)
117 m := strings.TrimSpace(stderr.String())
118 if m == "" {
119 m = strings.TrimSpace(stdout.String())
120 }
121 return m, code
122}
123
124// runControlInto runs a command in JSON mode and decodes its data into
125// target. Read handlers use it so the web renders exactly what the CLI
126// and the API return, rather than reaching past the registry into git.
127func (s *Server) runControlInto(u store.User, argv []string, target any) (msg string, ok bool) {
128 code, msg := s.dispatchInto(u, argv, target)
129 return msg, code == protocol.ExitOK
130}
131
132// runControlIntoCode is runControlInto for handlers that have to tell
133// "no such thing" from "that failed": a profile page 404s on the first
134// and errors on the second.
135func (s *Server) runControlIntoCode(u store.User, argv []string, target any) (code int, msg string) {
136 return s.dispatchInto(u, argv, target)
137}
138
139func (s *Server) dispatchInto(u store.User, argv []string, target any) (int, string) {
140 return s.dispatchIntoStdin(u, argv, "", target)
141}
142
143// dispatchIntoStdin is dispatchInto with a body on stdin, decoding the
144// command's named payload rather than a map (#126).
145func (s *Server) dispatchIntoStdin(u store.User, argv []string, stdin string, target any) (int, string) {
146 var stdout, stderr bytes.Buffer
147 ctx := &control.Ctx{
148 User: u,
149 Source: "web",
150 Scope: "full",
151 Store: s.st,
152 Cfg: s.cfg,
153 Stdin: strings.NewReader(stdin),
154 Stdout: &stdout,
155 Stderr: &stderr,
156 JSON: true,
157 ViaAPI: true,
158 }
159 code := control.Dispatch(ctx, argv)
160 var env struct {
161 Data json.RawMessage `json:"data"`
162 Error string `json:"error"`
163 }
164 json.Unmarshal(stdout.Bytes(), &env)
165 if code != protocol.ExitOK {
166 m := env.Error
167 if m == "" {
168 m = strings.TrimSpace(stderr.String())
169 }
170 return code, m
171 }
172 if len(env.Data) > 0 {
173 if err := json.Unmarshal(env.Data, target); err != nil {
174 return protocol.ExitFailure, "unreadable response"
175 }
176 }
177 return protocol.ExitOK, ""
178}
179
180// dispatchJSON runs a command in JSON mode with stdin and returns its exit
181// code and, on failure, the message. In JSON mode a failure is an envelope
182// carrying the message rather than stderr text, so both paths are read
183// from the same envelope. A handler that wants the payload uses
184// runControlInto, which decodes into the command's own type instead of a
185// map nothing type-checks.
186func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code int, msg string) {
187 var stdout, stderr bytes.Buffer
188 ctx := &control.Ctx{
189 User: u,
190 Source: "web",
191 Scope: "full",
192 Store: s.st,
193 Cfg: s.cfg,
194 Stdin: strings.NewReader(stdin),
195 Stdout: &stdout,
196 Stderr: &stderr,
197 JSON: true,
198 ViaAPI: true,
199 }
200 code = control.Dispatch(ctx, argv)
201 var env struct {
202 Error string `json:"error"`
203 }
204 json.Unmarshal(stdout.Bytes(), &env)
205 if code != protocol.ExitOK {
206 m := env.Error
207 if m == "" {
208 m = strings.TrimSpace(stderr.String())
209 }
210 if m == "" {
211 m = "the command failed"
212 }
213 return code, m
214 }
215 return code, ""
216}
217
218// authorNames maps commit author addresses to account names for one
219// request. A commit carries whatever name git was configured with; when
220// the address is a verified address here, the account's own name is the
221// truthful one to show, and it links somewhere.
222type authorNames struct {
223 st *store.Store
224 cache map[string]string
225}
226
227func (s *Server) authorNames() *authorNames {
228 return &authorNames{st: s.st, cache: map[string]string{}}
229}
230
231// name returns the account name for an address, or the commit's own
232// author name when no account has verified it.
233func (a *authorNames) name(email, fallback string) string {
234 if email == "" {
235 return fallback
236 }
237 if got, ok := a.cache[email]; ok {
238 if got == "" {
239 return fallback
240 }
241 return got
242 }
243 name, _ := a.st.UsernameByVerifiedEmail(email)
244 a.cache[email] = name
245 if name == "" {
246 return fallback
247 }
248 return name
249}
250
251// account returns the account name behind an address, if any, so callers
252// can link the displayed name to a profile.
253func (a *authorNames) account(email string) (string, bool) {
254 if email == "" {
255 return "", false
256 }
257 if got, ok := a.cache[email]; ok {
258 return got, got != ""
259 }
260 name, _ := a.st.UsernameByVerifiedEmail(email)
261 a.cache[email] = name
262 return name, name != ""
263}
264
265// namedCommit is a listing commit plus the account behind its author
266// address, when there is one, so the name can link to a profile.
267type namedCommit struct {
268 gitutil.EntryCommit
269 User string
270}
271
272// namedCommits rewrites listing authors to account names where the
273// address is verified here.
274func (s *Server) namedCommits(m map[string]gitutil.EntryCommit) map[string]namedCommit {
275 names := s.authorNames()
276 out := make(map[string]namedCommit, len(m))
277 for k, c := range m {
278 user, _ := names.account(c.Email)
279 c.Author = names.name(c.Email, c.Author)
280 out[k] = namedCommit{EntryCommit: c, User: user}
281 }
282 return out
283}
284
285// namedTip does the same for the single commit above a tree listing.
286func (s *Server) namedTip(c gitutil.EntryCommit) namedCommit {
287 names := s.authorNames()
288 user, _ := names.account(c.Email)
289 c.Author = names.name(c.Email, c.Author)
290 return namedCommit{EntryCommit: c, User: user}
291}
292
293// webViewer is the account behind a page request, or the zero user when
294// the instance serves the web without accounts.
295func (s *Server) webViewer(r *http.Request) store.User {
296 if s.cfg.Web.Mode != "accounts" {
297 return store.User{}
298 }
299 return s.viewer(r)
300}