package httpd import ( "bytes" "encoding/json" "io" "net/http" "strings" "gitbay.org/gitbay/internal/control" "gitbay.org/gitbay/internal/gitutil" "gitbay.org/gitbay/internal/protocol" "gitbay.org/gitbay/internal/store" ) // runControl executes a control command as the browser session's user, // through the same registry the CLI and the JSON API reach. Web writes // never reimplement command logic — merge gates, review rules, and audit // entries stay in one place — so the surfaces cannot drift apart. // // ViaAPI is set, which marks the request as one that arrived over HTTP. // Nothing is held back from that door any more (#234): what a caller may // do is the account's rights and its credential's scope, decided in one // place for every surface. func (s *Server) runControl(u store.User, argv []string) (out string, msg string, ok bool) { out, msg, code := s.runControlCode(u, argv) return out, msg, code == protocol.ExitOK } // runControlCode is runControl with the exit code, for handlers that // answer a form: not-found and denied deserve their own statuses rather // than a redirect carrying the message (#106). func (s *Server) runControlCode(u store.User, argv []string) (out string, msg string, code int) { var stdout, stderr bytes.Buffer ctx := &control.Ctx{ User: u, Source: "web", Scope: "full", Store: s.st, Cfg: s.cfg, Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: &stderr, ViaAPI: true, } code = control.Dispatch(ctx, argv) m := strings.TrimSpace(stderr.String()) if m == "" { m = strings.TrimSpace(stdout.String()) } return stdout.String(), m, code } // runControlStream runs a command whose output is written as it is // produced: stdout goes to out, and done ends the command when the // request does. msg is stderr. func (s *Server) runControlStream(u store.User, argv []string, out io.Writer, done <-chan struct{}) (msg string, code int) { var stderr bytes.Buffer ctx := &control.Ctx{ User: u, Source: "web", Scope: "full", Store: s.st, Cfg: s.cfg, Stdin: strings.NewReader(""), Stdout: out, Stderr: &stderr, ViaAPI: true, Done: done, Stopping: s.stopping, } code = control.Dispatch(ctx, argv) return strings.TrimSpace(stderr.String()), code } // done finishes a form action by exit code: back to the page on success, // the 404 page when the thing does not exist, and back to the page with // the message for anything else. A refusal is feedback on the page a // person was looking at, whether it is a merge gate, a permission they // lack, or a field they got wrong; only a thing that does not exist has // no page to go back to. func (s *Server) done(w http.ResponseWriter, r *http.Request, code int, msg string, redirect func(http.ResponseWriter, *http.Request, string)) { switch code { case protocol.ExitOK: redirect(w, r, "") case protocol.ExitNotFound: s.notFound(w, r) default: redirect(w, r, msg) } } // runControlStdin is runControl for the handful of commands whose input // arrives on stdin: public keys, and review comment bodies. Stdin is // also where a secret goes when one is set through this path, since // argv is world-readable in /proc and the audit log keeps flag values. func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg string, ok bool) { msg, code := s.runControlStdinCode(u, argv, stdin) return msg, code == protocol.ExitOK } func (s *Server) runControlStdinCode(u store.User, argv []string, stdin string) (msg string, code int) { var stdout, stderr bytes.Buffer ctx := &control.Ctx{ User: u, Source: "web", Scope: "full", Store: s.st, Cfg: s.cfg, Stdin: strings.NewReader(stdin), Stdout: &stdout, Stderr: &stderr, ViaAPI: true, } code = control.Dispatch(ctx, argv) m := strings.TrimSpace(stderr.String()) if m == "" { m = strings.TrimSpace(stdout.String()) } return m, code } // runControlInto runs a command in JSON mode and decodes its data into // target. Read handlers use it so the web renders exactly what the CLI // and the API return, rather than reaching past the registry into git. func (s *Server) runControlInto(u store.User, argv []string, target any) (msg string, ok bool) { code, msg := s.dispatchInto(u, argv, target) return msg, code == protocol.ExitOK } // runControlIntoCode is runControlInto for handlers that have to tell // "no such thing" from "that failed": a profile page 404s on the first // and errors on the second. func (s *Server) runControlIntoCode(u store.User, argv []string, target any) (code int, msg string) { return s.dispatchInto(u, argv, target) } func (s *Server) dispatchInto(u store.User, argv []string, target any) (int, string) { return s.dispatchIntoStdin(u, argv, "", target) } // dispatchIntoStdin is dispatchInto with a body on stdin, decoding the // command's named payload rather than a map (#126). func (s *Server) dispatchIntoStdin(u store.User, argv []string, stdin string, target any) (int, string) { var stdout, stderr bytes.Buffer ctx := &control.Ctx{ User: u, Source: "web", Scope: "full", Store: s.st, Cfg: s.cfg, Stdin: strings.NewReader(stdin), Stdout: &stdout, Stderr: &stderr, JSON: true, ViaAPI: true, } code := control.Dispatch(ctx, argv) var env struct { Data json.RawMessage `json:"data"` Error string `json:"error"` } json.Unmarshal(stdout.Bytes(), &env) if code != protocol.ExitOK { m := env.Error if m == "" { m = strings.TrimSpace(stderr.String()) } return code, m } if len(env.Data) > 0 { if err := json.Unmarshal(env.Data, target); err != nil { return protocol.ExitFailure, "unreadable response" } } return protocol.ExitOK, "" } // dispatchJSON runs a command in JSON mode with stdin and returns its exit // code and, on failure, the message. In JSON mode a failure is an envelope // carrying the message rather than stderr text, so both paths are read // from the same envelope. A handler that wants the payload uses // runControlInto, which decodes into the command's own type instead of a // map nothing type-checks. func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code int, msg string) { var stdout, stderr bytes.Buffer ctx := &control.Ctx{ User: u, Source: "web", Scope: "full", Store: s.st, Cfg: s.cfg, Stdin: strings.NewReader(stdin), Stdout: &stdout, Stderr: &stderr, JSON: true, ViaAPI: true, } code = control.Dispatch(ctx, argv) var env struct { Error string `json:"error"` } json.Unmarshal(stdout.Bytes(), &env) if code != protocol.ExitOK { m := env.Error if m == "" { m = strings.TrimSpace(stderr.String()) } if m == "" { m = "the command failed" } return code, m } return code, "" } // authorNames maps commit author addresses to account names for one // request. A commit carries whatever name git was configured with; when // the address is a verified address here, the account's own name is the // truthful one to show, and it links somewhere. type authorNames struct { st *store.Store cache map[string]string } func (s *Server) authorNames() *authorNames { return &authorNames{st: s.st, cache: map[string]string{}} } // name returns the account name for an address, or the commit's own // author name when no account has verified it. func (a *authorNames) name(email, fallback string) string { if email == "" { return fallback } if got, ok := a.cache[email]; ok { if got == "" { return fallback } return got } name, _ := a.st.UsernameByVerifiedEmail(email) a.cache[email] = name if name == "" { return fallback } return name } // account returns the account name behind an address, if any, so callers // can link the displayed name to a profile. func (a *authorNames) account(email string) (string, bool) { if email == "" { return "", false } if got, ok := a.cache[email]; ok { return got, got != "" } name, _ := a.st.UsernameByVerifiedEmail(email) a.cache[email] = name return name, name != "" } // namedCommit is a listing commit plus the account behind its author // address, when there is one, so the name can link to a profile. type namedCommit struct { gitutil.EntryCommit User string } // namedCommits rewrites listing authors to account names where the // address is verified here. func (s *Server) namedCommits(m map[string]gitutil.EntryCommit) map[string]namedCommit { names := s.authorNames() out := make(map[string]namedCommit, len(m)) for k, c := range m { user, _ := names.account(c.Email) c.Author = names.name(c.Email, c.Author) out[k] = namedCommit{EntryCommit: c, User: user} } return out } // namedTip does the same for the single commit above a tree listing. func (s *Server) namedTip(c gitutil.EntryCommit) namedCommit { names := s.authorNames() user, _ := names.account(c.Email) c.Author = names.name(c.Email, c.Author) return namedCommit{EntryCommit: c, User: user} } // webViewer is the account behind a page request, or the zero user when // the instance serves the web without accounts. func (s *Server) webViewer(r *http.Request) store.User { if s.cfg.Web.Mode != "accounts" { return store.User{} } return s.viewer(r) }