internal/httpd/control.go

81bb0d14080e45ffcd6ca844cbe506ce1f10e22a
gitbay/internal/httpd/control.go history · blame · raw

237 lines · 6883 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	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(""),
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  map[string]any `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		if m == "" {
148			m = "the command failed"
149		}
150		return nil, m, false
151	}
152	return env.Data, "", true
153}
154
155// authorNames maps commit author addresses to account names for one
156// request. A commit carries whatever name git was configured with; when
157// the address is a verified address here, the account's own name is the
158// truthful one to show, and it links somewhere.
159type authorNames struct {
160	st    *store.Store
161	cache map[string]string
162}
163
164func (s *Server) authorNames() *authorNames {
165	return &authorNames{st: s.st, cache: map[string]string{}}
166}
167
168// name returns the account name for an address, or the commit's own
169// author name when no account has verified it.
170func (a *authorNames) name(email, fallback string) string {
171	if email == "" {
172		return fallback
173	}
174	if got, ok := a.cache[email]; ok {
175		if got == "" {
176			return fallback
177		}
178		return got
179	}
180	name, _ := a.st.UsernameByVerifiedEmail(email)
181	a.cache[email] = name
182	if name == "" {
183		return fallback
184	}
185	return name
186}
187
188// account returns the account name behind an address, if any, so callers
189// can link the displayed name to a profile.
190func (a *authorNames) account(email string) (string, bool) {
191	if email == "" {
192		return "", false
193	}
194	if got, ok := a.cache[email]; ok {
195		return got, got != ""
196	}
197	name, _ := a.st.UsernameByVerifiedEmail(email)
198	a.cache[email] = name
199	return name, name != ""
200}
201
202// namedCommit is a listing commit plus the account behind its author
203// address, when there is one, so the name can link to a profile.
204type namedCommit struct {
205	gitutil.EntryCommit
206	User string
207}
208
209// namedCommits rewrites listing authors to account names where the
210// address is verified here.
211func (s *Server) namedCommits(m map[string]gitutil.EntryCommit) map[string]namedCommit {
212	names := s.authorNames()
213	out := make(map[string]namedCommit, len(m))
214	for k, c := range m {
215		user, _ := names.account(c.Email)
216		c.Author = names.name(c.Email, c.Author)
217		out[k] = namedCommit{EntryCommit: c, User: user}
218	}
219	return out
220}
221
222// namedTip does the same for the single commit above a tree listing.
223func (s *Server) namedTip(c gitutil.EntryCommit) namedCommit {
224	names := s.authorNames()
225	user, _ := names.account(c.Email)
226	c.Author = names.name(c.Email, c.Author)
227	return namedCommit{EntryCommit: c, User: user}
228}
229
230// webViewer is the account behind a page request, or the zero user when
231// the instance serves the web without accounts.
232func (s *Server) webViewer(r *http.Request) store.User {
233	if s.cfg.Web.Mode != "accounts" {
234		return store.User{}
235	}
236	return s.viewer(r)
237}