internal/httpd/control.go

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

300 lines · 9238 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: "web",
 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:   "web",
 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	var stdout, stderr bytes.Buffer
105	ctx := &control.Ctx{
106		User:   u,
107		Source: "web",
108		Scope:  "full",
109		Store:  s.st,
110		Cfg:    s.cfg,
111		Stdin:  strings.NewReader(stdin),
112		Stdout: &stdout,
113		Stderr: &stderr,
114		ViaAPI: true,
115	}
116	code = control.Dispatch(ctx, argv)
117	m := strings.TrimSpace(stderr.String())
118	if m == "" {
119		m = strings.TrimSpace(stdout.String())
120	}
121	return m, code
122}
123
124// runControlInto runs a command in JSON mode and decodes its data into
125// target. Read handlers use it so the web renders exactly what the CLI
126// and the API return, rather than reaching past the registry into git.
127func (s *Server) runControlInto(u store.User, argv []string, target any) (msg string, ok bool) {
128	code, msg := s.dispatchInto(u, argv, target)
129	return msg, code == protocol.ExitOK
130}
131
132// runControlIntoCode is runControlInto for handlers that have to tell
133// "no such thing" from "that failed": a profile page 404s on the first
134// and errors on the second.
135func (s *Server) runControlIntoCode(u store.User, argv []string, target any) (code int, msg string) {
136	return s.dispatchInto(u, argv, target)
137}
138
139func (s *Server) dispatchInto(u store.User, argv []string, target any) (int, string) {
140	return s.dispatchIntoStdin(u, argv, "", target)
141}
142
143// dispatchIntoStdin is dispatchInto with a body on stdin, decoding the
144// command's named payload rather than a map (#126).
145func (s *Server) dispatchIntoStdin(u store.User, argv []string, stdin string, target any) (int, string) {
146	var stdout, stderr bytes.Buffer
147	ctx := &control.Ctx{
148		User:   u,
149		Source: "web",
150		Scope:  "full",
151		Store:  s.st,
152		Cfg:    s.cfg,
153		Stdin:  strings.NewReader(stdin),
154		Stdout: &stdout,
155		Stderr: &stderr,
156		JSON:   true,
157		ViaAPI: true,
158	}
159	code := control.Dispatch(ctx, argv)
160	var env struct {
161		Data  json.RawMessage `json:"data"`
162		Error string          `json:"error"`
163	}
164	json.Unmarshal(stdout.Bytes(), &env)
165	if code != protocol.ExitOK {
166		m := env.Error
167		if m == "" {
168			m = strings.TrimSpace(stderr.String())
169		}
170		return code, m
171	}
172	if len(env.Data) > 0 {
173		if err := json.Unmarshal(env.Data, target); err != nil {
174			return protocol.ExitFailure, "unreadable response"
175		}
176	}
177	return protocol.ExitOK, ""
178}
179
180// dispatchJSON runs a command in JSON mode with stdin and returns its exit
181// code and, on failure, the message. In JSON mode a failure is an envelope
182// carrying the message rather than stderr text, so both paths are read
183// from the same envelope. A handler that wants the payload uses
184// runControlInto, which decodes into the command's own type instead of a
185// map nothing type-checks.
186func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code int, msg string) {
187	var stdout, stderr bytes.Buffer
188	ctx := &control.Ctx{
189		User:   u,
190		Source: "web",
191		Scope:  "full",
192		Store:  s.st,
193		Cfg:    s.cfg,
194		Stdin:  strings.NewReader(stdin),
195		Stdout: &stdout,
196		Stderr: &stderr,
197		JSON:   true,
198		ViaAPI: true,
199	}
200	code = control.Dispatch(ctx, argv)
201	var env struct {
202		Error string `json:"error"`
203	}
204	json.Unmarshal(stdout.Bytes(), &env)
205	if code != protocol.ExitOK {
206		m := env.Error
207		if m == "" {
208			m = strings.TrimSpace(stderr.String())
209		}
210		if m == "" {
211			m = "the command failed"
212		}
213		return code, m
214	}
215	return code, ""
216}
217
218// authorNames maps commit author addresses to account names for one
219// request. A commit carries whatever name git was configured with; when
220// the address is a verified address here, the account's own name is the
221// truthful one to show, and it links somewhere.
222type authorNames struct {
223	st    *store.Store
224	cache map[string]string
225}
226
227func (s *Server) authorNames() *authorNames {
228	return &authorNames{st: s.st, cache: map[string]string{}}
229}
230
231// name returns the account name for an address, or the commit's own
232// author name when no account has verified it.
233func (a *authorNames) name(email, fallback string) string {
234	if email == "" {
235		return fallback
236	}
237	if got, ok := a.cache[email]; ok {
238		if got == "" {
239			return fallback
240		}
241		return got
242	}
243	name, _ := a.st.UsernameByVerifiedEmail(email)
244	a.cache[email] = name
245	if name == "" {
246		return fallback
247	}
248	return name
249}
250
251// account returns the account name behind an address, if any, so callers
252// can link the displayed name to a profile.
253func (a *authorNames) account(email string) (string, bool) {
254	if email == "" {
255		return "", false
256	}
257	if got, ok := a.cache[email]; ok {
258		return got, got != ""
259	}
260	name, _ := a.st.UsernameByVerifiedEmail(email)
261	a.cache[email] = name
262	return name, name != ""
263}
264
265// namedCommit is a listing commit plus the account behind its author
266// address, when there is one, so the name can link to a profile.
267type namedCommit struct {
268	gitutil.EntryCommit
269	User string
270}
271
272// namedCommits rewrites listing authors to account names where the
273// address is verified here.
274func (s *Server) namedCommits(m map[string]gitutil.EntryCommit) map[string]namedCommit {
275	names := s.authorNames()
276	out := make(map[string]namedCommit, len(m))
277	for k, c := range m {
278		user, _ := names.account(c.Email)
279		c.Author = names.name(c.Email, c.Author)
280		out[k] = namedCommit{EntryCommit: c, User: user}
281	}
282	return out
283}
284
285// namedTip does the same for the single commit above a tree listing.
286func (s *Server) namedTip(c gitutil.EntryCommit) namedCommit {
287	names := s.authorNames()
288	user, _ := names.account(c.Email)
289	c.Author = names.name(c.Email, c.Author)
290	return namedCommit{EntryCommit: c, User: user}
291}
292
293// webViewer is the account behind a page request, or the zero user when
294// the instance serves the web without accounts.
295func (s *Server) webViewer(r *http.Request) store.User {
296	if s.cfg.Web.Mode != "accounts" {
297		return store.User{}
298	}
299	return s.viewer(r)
300}