internal/httpd/control.go

v1.40.1
gitbay/internal/httpd/control.go history · blame · raw

306 lines · 9582 bytes

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