internal/httpd/control.go

8bae67052c75bf6469522c4b6501791f820de5e3
gitbay/internal/httpd/control.go history · blame · raw

282 lines · 8797 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	out, msg, code := s.runControlCode(u, argv)
 24	return out, msg, code == protocol.ExitOK
 25}
 26
 27// runControlCode is runControl with the exit code, for handlers that
 28// answer a form: not-found and denied deserve their own statuses rather
 29// than a redirect carrying the message (#106).
 30func (s *Server) runControlCode(u store.User, argv []string) (out string, msg string, code int) {
 31	var stdout, stderr bytes.Buffer
 32	ctx := &control.Ctx{
 33		User:   u,
 34		Source: "web",
 35		Scope:  "full",
 36		Store:  s.st,
 37		Cfg:    s.cfg,
 38		Stdin:  strings.NewReader(""),
 39		Stdout: &stdout,
 40		Stderr: &stderr,
 41		ViaAPI: true,
 42	}
 43	code = control.Dispatch(ctx, argv)
 44	m := strings.TrimSpace(stderr.String())
 45	if m == "" {
 46		m = strings.TrimSpace(stdout.String())
 47	}
 48	return stdout.String(), m, code
 49}
 50
 51// done finishes a form action by exit code: back to the page on success,
 52// the 404 page when the thing does not exist, and back to the page with
 53// the message for anything else. A refusal is feedback on the page a
 54// person was looking at, whether it is a merge gate, a permission they
 55// lack, or a field they got wrong; only a thing that does not exist has
 56// no page to go back to.
 57func (s *Server) done(w http.ResponseWriter, r *http.Request, code int, msg string,
 58	redirect func(http.ResponseWriter, *http.Request, string)) {
 59	switch code {
 60	case protocol.ExitOK:
 61		redirect(w, r, "")
 62	case protocol.ExitNotFound:
 63		s.notFound(w, r)
 64	default:
 65		redirect(w, r, msg)
 66	}
 67}
 68
 69// runControlStdin is runControl for the handful of commands whose input
 70// arrives on stdin: public keys, and review comment bodies. Neither is
 71// secret, and both are prose or paste rather than a flag value. Secrets,
 72// tokens and mirror credentials remain SSHOnly and are refused by the
 73// dispatcher.
 74func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg string, ok bool) {
 75	msg, code := s.runControlStdinCode(u, argv, stdin)
 76	return msg, code == protocol.ExitOK
 77}
 78
 79func (s *Server) runControlStdinCode(u store.User, argv []string, stdin string) (msg string, code int) {
 80	var stdout, stderr bytes.Buffer
 81	ctx := &control.Ctx{
 82		User:   u,
 83		Source: "web",
 84		Scope:  "full",
 85		Store:  s.st,
 86		Cfg:    s.cfg,
 87		Stdin:  strings.NewReader(stdin),
 88		Stdout: &stdout,
 89		Stderr: &stderr,
 90		ViaAPI: true,
 91	}
 92	code = control.Dispatch(ctx, argv)
 93	m := strings.TrimSpace(stderr.String())
 94	if m == "" {
 95		m = strings.TrimSpace(stdout.String())
 96	}
 97	return m, code
 98}
 99
100// runControlInto runs a command in JSON mode and decodes its data into
101// target. Read handlers use it so the web renders exactly what the CLI
102// and the API return, rather than reaching past the registry into git.
103func (s *Server) runControlInto(u store.User, argv []string, target any) (msg string, ok bool) {
104	code, msg := s.dispatchInto(u, argv, target)
105	return msg, code == protocol.ExitOK
106}
107
108// runControlIntoCode is runControlInto for handlers that have to tell
109// "no such thing" from "that failed": a profile page 404s on the first
110// and errors on the second.
111func (s *Server) runControlIntoCode(u store.User, argv []string, target any) (code int, msg string) {
112	return s.dispatchInto(u, argv, target)
113}
114
115func (s *Server) dispatchInto(u store.User, argv []string, target any) (int, string) {
116	return s.dispatchIntoStdin(u, argv, "", target)
117}
118
119// dispatchIntoStdin is dispatchInto with a body on stdin, decoding the
120// command's named payload rather than a map (#126).
121func (s *Server) dispatchIntoStdin(u store.User, argv []string, stdin string, target any) (int, string) {
122	var stdout, stderr bytes.Buffer
123	ctx := &control.Ctx{
124		User:   u,
125		Source: "web",
126		Scope:  "full",
127		Store:  s.st,
128		Cfg:    s.cfg,
129		Stdin:  strings.NewReader(stdin),
130		Stdout: &stdout,
131		Stderr: &stderr,
132		JSON:   true,
133		ViaAPI: true,
134	}
135	code := control.Dispatch(ctx, argv)
136	var env struct {
137		Data  json.RawMessage `json:"data"`
138		Error string          `json:"error"`
139	}
140	json.Unmarshal(stdout.Bytes(), &env)
141	if code != protocol.ExitOK {
142		m := env.Error
143		if m == "" {
144			m = strings.TrimSpace(stderr.String())
145		}
146		return code, m
147	}
148	if len(env.Data) > 0 {
149		if err := json.Unmarshal(env.Data, target); err != nil {
150			return protocol.ExitFailure, "unreadable response"
151		}
152	}
153	return protocol.ExitOK, ""
154}
155
156// runControlJSON runs a command in JSON mode and returns its data object.
157// In JSON mode a failure is an envelope carrying the message rather than
158// stderr text, so both paths are read from the same envelope.
159func (s *Server) runControlJSON(u store.User, argv []string) (data map[string]any, msg string, ok bool) {
160	code, data, msg := s.dispatchJSON(u, argv, "")
161	return data, msg, code == protocol.ExitOK
162}
163
164// dispatchJSON runs a command in JSON mode with stdin, and returns its
165// exit code with the decoded data or the failure message. Handlers that
166// answer a form use the code to pick an HTTP status.
167func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code int, data map[string]any, msg string) {
168	var stdout, stderr bytes.Buffer
169	ctx := &control.Ctx{
170		User:   u,
171		Source: "web",
172		Scope:  "full",
173		Store:  s.st,
174		Cfg:    s.cfg,
175		Stdin:  strings.NewReader(stdin),
176		Stdout: &stdout,
177		Stderr: &stderr,
178		JSON:   true,
179		ViaAPI: true,
180	}
181	code = control.Dispatch(ctx, argv)
182	var env struct {
183		Data  map[string]any `json:"data"`
184		Error string         `json:"error"`
185	}
186	json.Unmarshal(stdout.Bytes(), &env)
187	if code != protocol.ExitOK {
188		m := env.Error
189		if m == "" {
190			m = strings.TrimSpace(stderr.String())
191		}
192		if m == "" {
193			m = "the command failed"
194		}
195		return code, nil, m
196	}
197	return code, env.Data, ""
198}
199
200// authorNames maps commit author addresses to account names for one
201// request. A commit carries whatever name git was configured with; when
202// the address is a verified address here, the account's own name is the
203// truthful one to show, and it links somewhere.
204type authorNames struct {
205	st    *store.Store
206	cache map[string]string
207}
208
209func (s *Server) authorNames() *authorNames {
210	return &authorNames{st: s.st, cache: map[string]string{}}
211}
212
213// name returns the account name for an address, or the commit's own
214// author name when no account has verified it.
215func (a *authorNames) name(email, fallback string) string {
216	if email == "" {
217		return fallback
218	}
219	if got, ok := a.cache[email]; ok {
220		if got == "" {
221			return fallback
222		}
223		return got
224	}
225	name, _ := a.st.UsernameByVerifiedEmail(email)
226	a.cache[email] = name
227	if name == "" {
228		return fallback
229	}
230	return name
231}
232
233// account returns the account name behind an address, if any, so callers
234// can link the displayed name to a profile.
235func (a *authorNames) account(email string) (string, bool) {
236	if email == "" {
237		return "", false
238	}
239	if got, ok := a.cache[email]; ok {
240		return got, got != ""
241	}
242	name, _ := a.st.UsernameByVerifiedEmail(email)
243	a.cache[email] = name
244	return name, name != ""
245}
246
247// namedCommit is a listing commit plus the account behind its author
248// address, when there is one, so the name can link to a profile.
249type namedCommit struct {
250	gitutil.EntryCommit
251	User string
252}
253
254// namedCommits rewrites listing authors to account names where the
255// address is verified here.
256func (s *Server) namedCommits(m map[string]gitutil.EntryCommit) map[string]namedCommit {
257	names := s.authorNames()
258	out := make(map[string]namedCommit, len(m))
259	for k, c := range m {
260		user, _ := names.account(c.Email)
261		c.Author = names.name(c.Email, c.Author)
262		out[k] = namedCommit{EntryCommit: c, User: user}
263	}
264	return out
265}
266
267// namedTip does the same for the single commit above a tree listing.
268func (s *Server) namedTip(c gitutil.EntryCommit) namedCommit {
269	names := s.authorNames()
270	user, _ := names.account(c.Email)
271	c.Author = names.name(c.Email, c.Author)
272	return namedCommit{EntryCommit: c, User: user}
273}
274
275// webViewer is the account behind a page request, or the zero user when
276// the instance serves the web without accounts.
277func (s *Server) webViewer(r *http.Request) store.User {
278	if s.cfg.Web.Mode != "accounts" {
279		return store.User{}
280	}
281	return s.viewer(r)
282}