internal/httpd/control.go

e7b249b38d2d85e868196130ce690fc43088d9d2
gitbay/internal/httpd/control.go history · blame · raw

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}