internal/httpd/control.go
245 lines · 7296 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 var stdout, stderr bytes.Buffer
24 ctx := &control.Ctx{
25 User: u,
26 Source: "web",
27 Scope: "full",
28 Store: s.st,
29 Cfg: s.cfg,
30 Stdin: strings.NewReader(""),
31 Stdout: &stdout,
32 Stderr: &stderr,
33 ViaAPI: true,
34 }
35 code := control.Dispatch(ctx, argv)
36 m := strings.TrimSpace(stderr.String())
37 if m == "" {
38 m = strings.TrimSpace(stdout.String())
39 }
40 return stdout.String(), m, code == protocol.ExitOK
41}
42
43// runControlStdin is runControl for the handful of commands whose input
44// arrives on stdin: public keys, and review comment bodies. Neither is
45// secret, and both are prose or paste rather than a flag value. Secrets,
46// tokens and mirror credentials remain SSHOnly and are refused by the
47// dispatcher.
48func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg string, ok bool) {
49 var stdout, stderr bytes.Buffer
50 ctx := &control.Ctx{
51 User: u,
52 Source: "web",
53 Scope: "full",
54 Store: s.st,
55 Cfg: s.cfg,
56 Stdin: strings.NewReader(stdin),
57 Stdout: &stdout,
58 Stderr: &stderr,
59 ViaAPI: true,
60 }
61 code := control.Dispatch(ctx, argv)
62 m := strings.TrimSpace(stderr.String())
63 if m == "" {
64 m = strings.TrimSpace(stdout.String())
65 }
66 return m, code == protocol.ExitOK
67}
68
69// runControlInto runs a command in JSON mode and decodes its data into
70// target. Read handlers use it so the web renders exactly what the CLI
71// and the API return, rather than reaching past the registry into git.
72func (s *Server) runControlInto(u store.User, argv []string, target any) (msg string, ok bool) {
73 code, msg := s.dispatchInto(u, argv, target)
74 return msg, code == protocol.ExitOK
75}
76
77// runControlIntoCode is runControlInto for handlers that have to tell
78// "no such thing" from "that failed": a profile page 404s on the first
79// and errors on the second.
80func (s *Server) runControlIntoCode(u store.User, argv []string, target any) (code int, msg string) {
81 return s.dispatchInto(u, argv, target)
82}
83
84func (s *Server) dispatchInto(u store.User, argv []string, target any) (int, string) {
85 var stdout, stderr bytes.Buffer
86 ctx := &control.Ctx{
87 User: u,
88 Source: "web",
89 Scope: "full",
90 Store: s.st,
91 Cfg: s.cfg,
92 Stdin: strings.NewReader(""),
93 Stdout: &stdout,
94 Stderr: &stderr,
95 JSON: true,
96 ViaAPI: true,
97 }
98 code := control.Dispatch(ctx, argv)
99 var env struct {
100 Data json.RawMessage `json:"data"`
101 Error string `json:"error"`
102 }
103 json.Unmarshal(stdout.Bytes(), &env)
104 if code != protocol.ExitOK {
105 m := env.Error
106 if m == "" {
107 m = strings.TrimSpace(stderr.String())
108 }
109 return code, m
110 }
111 if len(env.Data) > 0 {
112 if err := json.Unmarshal(env.Data, target); err != nil {
113 return protocol.ExitFailure, "unreadable response"
114 }
115 }
116 return protocol.ExitOK, ""
117}
118
119// runControlJSON runs a command in JSON mode and returns its data object.
120// In JSON mode a failure is an envelope carrying the message rather than
121// stderr text, so both paths are read from the same envelope.
122func (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) {
131 var stdout, stderr bytes.Buffer
132 ctx := &control.Ctx{
133 User: u,
134 Source: "web",
135 Scope: "full",
136 Store: s.st,
137 Cfg: s.cfg,
138 Stdin: strings.NewReader(stdin),
139 Stdout: &stdout,
140 Stderr: &stderr,
141 JSON: true,
142 ViaAPI: true,
143 }
144 code = control.Dispatch(ctx, argv)
145 var env struct {
146 Data map[string]any `json:"data"`
147 Error string `json:"error"`
148 }
149 json.Unmarshal(stdout.Bytes(), &env)
150 if code != protocol.ExitOK {
151 m := env.Error
152 if m == "" {
153 m = strings.TrimSpace(stderr.String())
154 }
155 if m == "" {
156 m = "the command failed"
157 }
158 return code, nil, m
159 }
160 return code, env.Data, ""
161}
162
163// authorNames maps commit author addresses to account names for one
164// request. A commit carries whatever name git was configured with; when
165// the address is a verified address here, the account's own name is the
166// truthful one to show, and it links somewhere.
167type authorNames struct {
168 st *store.Store
169 cache map[string]string
170}
171
172func (s *Server) authorNames() *authorNames {
173 return &authorNames{st: s.st, cache: map[string]string{}}
174}
175
176// name returns the account name for an address, or the commit's own
177// author name when no account has verified it.
178func (a *authorNames) name(email, fallback string) string {
179 if email == "" {
180 return fallback
181 }
182 if got, ok := a.cache[email]; ok {
183 if got == "" {
184 return fallback
185 }
186 return got
187 }
188 name, _ := a.st.UsernameByVerifiedEmail(email)
189 a.cache[email] = name
190 if name == "" {
191 return fallback
192 }
193 return name
194}
195
196// account returns the account name behind an address, if any, so callers
197// can link the displayed name to a profile.
198func (a *authorNames) account(email string) (string, bool) {
199 if email == "" {
200 return "", false
201 }
202 if got, ok := a.cache[email]; ok {
203 return got, got != ""
204 }
205 name, _ := a.st.UsernameByVerifiedEmail(email)
206 a.cache[email] = name
207 return name, name != ""
208}
209
210// namedCommit is a listing commit plus the account behind its author
211// address, when there is one, so the name can link to a profile.
212type namedCommit struct {
213 gitutil.EntryCommit
214 User string
215}
216
217// namedCommits rewrites listing authors to account names where the
218// address is verified here.
219func (s *Server) namedCommits(m map[string]gitutil.EntryCommit) map[string]namedCommit {
220 names := s.authorNames()
221 out := make(map[string]namedCommit, len(m))
222 for k, c := range m {
223 user, _ := names.account(c.Email)
224 c.Author = names.name(c.Email, c.Author)
225 out[k] = namedCommit{EntryCommit: c, User: user}
226 }
227 return out
228}
229
230// namedTip does the same for the single commit above a tree listing.
231func (s *Server) namedTip(c gitutil.EntryCommit) namedCommit {
232 names := s.authorNames()
233 user, _ := names.account(c.Email)
234 c.Author = names.name(c.Email, c.Author)
235 return namedCommit{EntryCommit: c, User: user}
236}
237
238// webViewer is the account behind a page request, or the zero user when
239// the instance serves the web without accounts.
240func (s *Server) webViewer(r *http.Request) store.User {
241 if s.cfg.Web.Mode != "accounts" {
242 return store.User{}
243 }
244 return s.viewer(r)
245}