internal/httpd/control.go
276 lines · 8511 bytes
1package httpd
2
3import (
4 "bytes"
5 "encoding/json"
6 "net/http"
7 "strings"
8
9 "gitbay.org/gitbay/internal/control"
10 "gitbay.org/gitbay/internal/gitutil"
11 "gitbay.org/gitbay/internal/protocol"
12 "gitbay.org/gitbay/internal/store"
13)
14
15// runControl executes a control command as the browser session's user,
16// through the same registry the CLI and the JSON API reach. Web writes
17// never reimplement command logic — merge gates, review rules, and audit
18// entries stay in one place — so the surfaces cannot drift apart.
19//
20// ViaAPI is set, which refuses SSHOnly commands: anything whose input is a
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) {
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) {
31 var stdout, stderr bytes.Buffer
32 ctx := &control.Ctx{
33 User: u,
34 Source: "web",
35 Scope: "full",
36 Store: s.st,
37 Cfg: s.cfg,
38 Stdin: strings.NewReader(""),
39 Stdout: &stdout,
40 Stderr: &stderr,
41 ViaAPI: true,
42 }
43 code = control.Dispatch(ctx, argv)
44 m := strings.TrimSpace(stderr.String())
45 if m == "" {
46 m = strings.TrimSpace(stdout.String())
47 }
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 }
67}
68
69// runControlStdin is runControl for the handful of commands whose input
70// arrives on stdin: public keys, and review comment bodies. Neither is
71// secret, and both are prose or paste rather than a flag value. Secrets,
72// tokens and mirror credentials remain SSHOnly and are refused by the
73// dispatcher.
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) {
80 var stdout, stderr bytes.Buffer
81 ctx := &control.Ctx{
82 User: u,
83 Source: "web",
84 Scope: "full",
85 Store: s.st,
86 Cfg: s.cfg,
87 Stdin: strings.NewReader(stdin),
88 Stdout: &stdout,
89 Stderr: &stderr,
90 ViaAPI: true,
91 }
92 code = control.Dispatch(ctx, argv)
93 m := strings.TrimSpace(stderr.String())
94 if m == "" {
95 m = strings.TrimSpace(stdout.String())
96 }
97 return m, code
98}
99
100// runControlInto runs a command in JSON mode and decodes its data into
101// target. Read handlers use it so the web renders exactly what the CLI
102// and the API return, rather than reaching past the registry into git.
103func (s *Server) runControlInto(u store.User, argv []string, target any) (msg string, ok bool) {
104 code, msg := s.dispatchInto(u, argv, target)
105 return msg, code == protocol.ExitOK
106}
107
108// runControlIntoCode is runControlInto for handlers that have to tell
109// "no such thing" from "that failed": a profile page 404s on the first
110// and errors on the second.
111func (s *Server) runControlIntoCode(u store.User, argv []string, target any) (code int, msg string) {
112 return s.dispatchInto(u, argv, target)
113}
114
115func (s *Server) dispatchInto(u store.User, argv []string, target any) (int, string) {
116 var stdout, stderr bytes.Buffer
117 ctx := &control.Ctx{
118 User: u,
119 Source: "web",
120 Scope: "full",
121 Store: s.st,
122 Cfg: s.cfg,
123 Stdin: strings.NewReader(""),
124 Stdout: &stdout,
125 Stderr: &stderr,
126 JSON: true,
127 ViaAPI: true,
128 }
129 code := control.Dispatch(ctx, argv)
130 var env struct {
131 Data json.RawMessage `json:"data"`
132 Error string `json:"error"`
133 }
134 json.Unmarshal(stdout.Bytes(), &env)
135 if code != protocol.ExitOK {
136 m := env.Error
137 if m == "" {
138 m = strings.TrimSpace(stderr.String())
139 }
140 return code, m
141 }
142 if len(env.Data) > 0 {
143 if err := json.Unmarshal(env.Data, target); err != nil {
144 return protocol.ExitFailure, "unreadable response"
145 }
146 }
147 return protocol.ExitOK, ""
148}
149
150// runControlJSON runs a command in JSON mode and returns its data object.
151// In JSON mode a failure is an envelope carrying the message rather than
152// stderr text, so both paths are read from the same envelope.
153func (s *Server) runControlJSON(u store.User, argv []string) (data map[string]any, msg string, ok bool) {
154 code, data, msg := s.dispatchJSON(u, argv, "")
155 return data, msg, code == protocol.ExitOK
156}
157
158// dispatchJSON runs a command in JSON mode with stdin, and returns its
159// exit code with the decoded data or the failure message. Handlers that
160// answer a form use the code to pick an HTTP status.
161func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code int, data map[string]any, msg string) {
162 var stdout, stderr bytes.Buffer
163 ctx := &control.Ctx{
164 User: u,
165 Source: "web",
166 Scope: "full",
167 Store: s.st,
168 Cfg: s.cfg,
169 Stdin: strings.NewReader(stdin),
170 Stdout: &stdout,
171 Stderr: &stderr,
172 JSON: true,
173 ViaAPI: true,
174 }
175 code = control.Dispatch(ctx, argv)
176 var env struct {
177 Data map[string]any `json:"data"`
178 Error string `json:"error"`
179 }
180 json.Unmarshal(stdout.Bytes(), &env)
181 if code != protocol.ExitOK {
182 m := env.Error
183 if m == "" {
184 m = strings.TrimSpace(stderr.String())
185 }
186 if m == "" {
187 m = "the command failed"
188 }
189 return code, nil, m
190 }
191 return code, env.Data, ""
192}
193
194// authorNames maps commit author addresses to account names for one
195// request. A commit carries whatever name git was configured with; when
196// the address is a verified address here, the account's own name is the
197// truthful one to show, and it links somewhere.
198type authorNames struct {
199 st *store.Store
200 cache map[string]string
201}
202
203func (s *Server) authorNames() *authorNames {
204 return &authorNames{st: s.st, cache: map[string]string{}}
205}
206
207// name returns the account name for an address, or the commit's own
208// author name when no account has verified it.
209func (a *authorNames) name(email, fallback string) string {
210 if email == "" {
211 return fallback
212 }
213 if got, ok := a.cache[email]; ok {
214 if got == "" {
215 return fallback
216 }
217 return got
218 }
219 name, _ := a.st.UsernameByVerifiedEmail(email)
220 a.cache[email] = name
221 if name == "" {
222 return fallback
223 }
224 return name
225}
226
227// account returns the account name behind an address, if any, so callers
228// can link the displayed name to a profile.
229func (a *authorNames) account(email string) (string, bool) {
230 if email == "" {
231 return "", false
232 }
233 if got, ok := a.cache[email]; ok {
234 return got, got != ""
235 }
236 name, _ := a.st.UsernameByVerifiedEmail(email)
237 a.cache[email] = name
238 return name, name != ""
239}
240
241// namedCommit is a listing commit plus the account behind its author
242// address, when there is one, so the name can link to a profile.
243type namedCommit struct {
244 gitutil.EntryCommit
245 User string
246}
247
248// namedCommits rewrites listing authors to account names where the
249// address is verified here.
250func (s *Server) namedCommits(m map[string]gitutil.EntryCommit) map[string]namedCommit {
251 names := s.authorNames()
252 out := make(map[string]namedCommit, len(m))
253 for k, c := range m {
254 user, _ := names.account(c.Email)
255 c.Author = names.name(c.Email, c.Author)
256 out[k] = namedCommit{EntryCommit: c, User: user}
257 }
258 return out
259}
260
261// namedTip does the same for the single commit above a tree listing.
262func (s *Server) namedTip(c gitutil.EntryCommit) namedCommit {
263 names := s.authorNames()
264 user, _ := names.account(c.Email)
265 c.Author = names.name(c.Email, c.Author)
266 return namedCommit{EntryCommit: c, User: user}
267}
268
269// webViewer is the account behind a page request, or the zero user when
270// the instance serves the web without accounts.
271func (s *Server) webViewer(r *http.Request) store.User {
272 if s.cfg.Web.Mode != "accounts" {
273 return store.User{}
274 }
275 return s.viewer(r)
276}