internal/httpd/control.go
225 lines · 6434 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 are the only such input the web accepts:
45// they are not secret, and pasting one into a browser is how people who
46// have not set up the CLI get their first key registered. Secrets, tokens
47// and mirror credentials remain SSHOnly and are refused by the 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 var stdout, stderr bytes.Buffer
74 ctx := &control.Ctx{
75 User: u,
76 Source: "web",
77 Scope: "full",
78 Store: s.st,
79 Cfg: s.cfg,
80 Stdin: strings.NewReader(""),
81 Stdout: &stdout,
82 Stderr: &stderr,
83 JSON: true,
84 ViaAPI: true,
85 }
86 code := control.Dispatch(ctx, argv)
87 var env struct {
88 Data json.RawMessage `json:"data"`
89 Error string `json:"error"`
90 }
91 json.Unmarshal(stdout.Bytes(), &env)
92 if code != protocol.ExitOK {
93 m := env.Error
94 if m == "" {
95 m = strings.TrimSpace(stderr.String())
96 }
97 return m, false
98 }
99 if len(env.Data) > 0 {
100 if err := json.Unmarshal(env.Data, target); err != nil {
101 return "unreadable response", false
102 }
103 }
104 return "", true
105}
106
107// runControlJSON runs a command in JSON mode and returns its data object.
108// In JSON mode a failure is an envelope carrying the message rather than
109// stderr text, so both paths are read from the same envelope.
110func (s *Server) runControlJSON(u store.User, argv []string) (data map[string]any, msg string, ok bool) {
111 var stdout, stderr bytes.Buffer
112 ctx := &control.Ctx{
113 User: u,
114 Source: "web",
115 Scope: "full",
116 Store: s.st,
117 Cfg: s.cfg,
118 Stdin: strings.NewReader(""),
119 Stdout: &stdout,
120 Stderr: &stderr,
121 JSON: true,
122 ViaAPI: true,
123 }
124 code := control.Dispatch(ctx, argv)
125 var env struct {
126 Data map[string]any `json:"data"`
127 Error string `json:"error"`
128 }
129 json.Unmarshal(stdout.Bytes(), &env)
130 if code != protocol.ExitOK {
131 m := env.Error
132 if m == "" {
133 m = strings.TrimSpace(stderr.String())
134 }
135 if m == "" {
136 m = "the command failed"
137 }
138 return nil, m, false
139 }
140 return env.Data, "", true
141}
142
143// authorNames maps commit author addresses to account names for one
144// request. A commit carries whatever name git was configured with; when
145// the address is a verified address here, the account's own name is the
146// truthful one to show, and it links somewhere.
147type authorNames struct {
148 st *store.Store
149 cache map[string]string
150}
151
152func (s *Server) authorNames() *authorNames {
153 return &authorNames{st: s.st, cache: map[string]string{}}
154}
155
156// name returns the account name for an address, or the commit's own
157// author name when no account has verified it.
158func (a *authorNames) name(email, fallback string) string {
159 if email == "" {
160 return fallback
161 }
162 if got, ok := a.cache[email]; ok {
163 if got == "" {
164 return fallback
165 }
166 return got
167 }
168 name, _ := a.st.UsernameByVerifiedEmail(email)
169 a.cache[email] = name
170 if name == "" {
171 return fallback
172 }
173 return name
174}
175
176// account returns the account name behind an address, if any, so callers
177// can link the displayed name to a profile.
178func (a *authorNames) account(email string) (string, bool) {
179 if email == "" {
180 return "", false
181 }
182 if got, ok := a.cache[email]; ok {
183 return got, got != ""
184 }
185 name, _ := a.st.UsernameByVerifiedEmail(email)
186 a.cache[email] = name
187 return name, name != ""
188}
189
190// namedCommit is a listing commit plus the account behind its author
191// address, when there is one, so the name can link to a profile.
192type namedCommit struct {
193 gitutil.EntryCommit
194 User string
195}
196
197// namedCommits rewrites listing authors to account names where the
198// address is verified here.
199func (s *Server) namedCommits(m map[string]gitutil.EntryCommit) map[string]namedCommit {
200 names := s.authorNames()
201 out := make(map[string]namedCommit, len(m))
202 for k, c := range m {
203 user, _ := names.account(c.Email)
204 c.Author = names.name(c.Email, c.Author)
205 out[k] = namedCommit{EntryCommit: c, User: user}
206 }
207 return out
208}
209
210// namedTip does the same for the single commit above a tree listing.
211func (s *Server) namedTip(c gitutil.EntryCommit) namedCommit {
212 names := s.authorNames()
213 user, _ := names.account(c.Email)
214 c.Author = names.name(c.Email, c.Author)
215 return namedCommit{EntryCommit: c, User: user}
216}
217
218// webViewer is the account behind a page request, or the zero user when
219// the instance serves the web without accounts.
220func (s *Server) webViewer(r *http.Request) store.User {
221 if s.cfg.Web.Mode != "accounts" {
222 return store.User{}
223 }
224 return s.viewer(r)
225}