internal/httpd/control.go

6821a6f76082b1e10ff899ff51021b11695c4ad6
gitbay/internal/httpd/control.go history · blame · raw

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}