internal/httpd/control.go

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

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