internal/httpd/control.go
177 lines · 5154 bytes
1package httpd
2
3import (
4 "bytes"
5 "encoding/json"
6 "strings"
7
8 "gitbay.org/gitbay/internal/control"
9 "gitbay.org/gitbay/internal/gitutil"
10 "gitbay.org/gitbay/internal/protocol"
11 "gitbay.org/gitbay/internal/store"
12)
13
14// runControl executes a control command as the browser session's user,
15// through the same registry the CLI and the JSON API reach. Web writes
16// never reimplement command logic — merge gates, review rules, and audit
17// entries stay in one place — so the surfaces cannot drift apart.
18//
19// ViaAPI is set, which refuses SSHOnly commands: anything whose input is a
20// credential (secrets, mirror tokens, session minting) stays on SSH.
21func (s *Server) runControl(u store.User, argv []string) (out string, msg string, ok bool) {
22 var stdout, stderr bytes.Buffer
23 ctx := &control.Ctx{
24 User: u,
25 Source: "web",
26 Scope: "full",
27 Store: s.st,
28 Cfg: s.cfg,
29 Stdin: strings.NewReader(""),
30 Stdout: &stdout,
31 Stderr: &stderr,
32 ViaAPI: true,
33 }
34 code := control.Dispatch(ctx, argv)
35 m := strings.TrimSpace(stderr.String())
36 if m == "" {
37 m = strings.TrimSpace(stdout.String())
38 }
39 return stdout.String(), m, code == protocol.ExitOK
40}
41
42// runControlStdin is runControl for the handful of commands whose input
43// arrives on stdin. Public keys are the only such input the web accepts:
44// they are not secret, and pasting one into a browser is how people who
45// have not set up the CLI get their first key registered. Secrets, tokens
46// and mirror credentials remain SSHOnly and are refused by the dispatcher.
47func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg string, ok bool) {
48 var stdout, stderr bytes.Buffer
49 ctx := &control.Ctx{
50 User: u,
51 Source: "web",
52 Scope: "full",
53 Store: s.st,
54 Cfg: s.cfg,
55 Stdin: strings.NewReader(stdin),
56 Stdout: &stdout,
57 Stderr: &stderr,
58 ViaAPI: true,
59 }
60 code := control.Dispatch(ctx, argv)
61 m := strings.TrimSpace(stderr.String())
62 if m == "" {
63 m = strings.TrimSpace(stdout.String())
64 }
65 return m, code == protocol.ExitOK
66}
67
68// runControlJSON runs a command in JSON mode and returns its data object.
69// In JSON mode a failure is an envelope carrying the message rather than
70// stderr text, so both paths are read from the same envelope.
71func (s *Server) runControlJSON(u store.User, argv []string) (data map[string]any, msg string, ok bool) {
72 var stdout, stderr bytes.Buffer
73 ctx := &control.Ctx{
74 User: u,
75 Source: "web",
76 Scope: "full",
77 Store: s.st,
78 Cfg: s.cfg,
79 Stdin: strings.NewReader(""),
80 Stdout: &stdout,
81 Stderr: &stderr,
82 JSON: true,
83 ViaAPI: true,
84 }
85 code := control.Dispatch(ctx, argv)
86 var env struct {
87 Data map[string]any `json:"data"`
88 Error string `json:"error"`
89 }
90 json.Unmarshal(stdout.Bytes(), &env)
91 if code != protocol.ExitOK {
92 m := env.Error
93 if m == "" {
94 m = strings.TrimSpace(stderr.String())
95 }
96 if m == "" {
97 m = "the command failed"
98 }
99 return nil, m, false
100 }
101 return env.Data, "", true
102}
103
104// authorNames maps commit author addresses to account names for one
105// request. A commit carries whatever name git was configured with; when
106// the address is a verified address here, the account's own name is the
107// truthful one to show, and it links somewhere.
108type authorNames struct {
109 st *store.Store
110 cache map[string]string
111}
112
113func (s *Server) authorNames() *authorNames {
114 return &authorNames{st: s.st, cache: map[string]string{}}
115}
116
117// name returns the account name for an address, or the commit's own
118// author name when no account has verified it.
119func (a *authorNames) name(email, fallback string) string {
120 if email == "" {
121 return fallback
122 }
123 if got, ok := a.cache[email]; ok {
124 if got == "" {
125 return fallback
126 }
127 return got
128 }
129 name, _ := a.st.UsernameByVerifiedEmail(email)
130 a.cache[email] = name
131 if name == "" {
132 return fallback
133 }
134 return name
135}
136
137// account returns the account name behind an address, if any, so callers
138// can link the displayed name to a profile.
139func (a *authorNames) account(email string) (string, bool) {
140 if email == "" {
141 return "", false
142 }
143 if got, ok := a.cache[email]; ok {
144 return got, got != ""
145 }
146 name, _ := a.st.UsernameByVerifiedEmail(email)
147 a.cache[email] = name
148 return name, name != ""
149}
150
151// namedCommit is a listing commit plus the account behind its author
152// address, when there is one, so the name can link to a profile.
153type namedCommit struct {
154 gitutil.EntryCommit
155 User string
156}
157
158// namedCommits rewrites listing authors to account names where the
159// address is verified here.
160func (s *Server) namedCommits(m map[string]gitutil.EntryCommit) map[string]namedCommit {
161 names := s.authorNames()
162 out := make(map[string]namedCommit, len(m))
163 for k, c := range m {
164 user, _ := names.account(c.Email)
165 c.Author = names.name(c.Email, c.Author)
166 out[k] = namedCommit{EntryCommit: c, User: user}
167 }
168 return out
169}
170
171// namedTip does the same for the single commit above a tree listing.
172func (s *Server) namedTip(c gitutil.EntryCommit) namedCommit {
173 names := s.authorNames()
174 user, _ := names.account(c.Email)
175 c.Author = names.name(c.Email, c.Author)
176 return namedCommit{EntryCommit: c, User: user}
177}