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}