internal/httpd/api.go

3bcdce33fb9a2309312854331359d376171c7368
gitbay/internal/httpd/api.go history · blame · raw

155 lines · 4819 bytes

  1package httpd
  2
  3import (
  4	"bytes"
  5	"encoding/json"
  6	"errors"
  7	"io"
  8	"net/http"
  9	"strconv"
 10	"strings"
 11
 12	"gitbay.org/gitbay/internal/control"
 13	"gitbay.org/gitbay/internal/protocol"
 14	"gitbay.org/gitbay/internal/store"
 15)
 16
 17// apiRequest is the wire form of one command invocation. argv is real
 18// argv — no shell, no tokenizer, no quoting rules.
 19type apiRequest struct {
 20	Argv  []string `json:"argv"`
 21	Stdin string   `json:"stdin,omitempty"`
 22}
 23
 24const maxAPIBody = 1 << 20
 25
 26// apiCmd fronts the same control-command registry the SSH dispatcher uses:
 27// every command, current and future, is reachable here with identical
 28// semantics. Exit codes map onto HTTP statuses; the body is the command's
 29// JSON envelope with exit_code added.
 30func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) {
 31	user, tok, ok := s.apiAuth(w, r)
 32	if !ok {
 33		return
 34	}
 35
 36	var req apiRequest
 37	if err := json.NewDecoder(io.LimitReader(r.Body, maxAPIBody)).Decode(&req); err != nil {
 38		apiError(w, http.StatusBadRequest, "body must be JSON: {\"argv\": [...], \"stdin\": \"...\"}")
 39		return
 40	}
 41	if len(req.Argv) == 0 {
 42		apiError(w, http.StatusBadRequest, "argv is required")
 43		return
 44	}
 45	switch req.Argv[0] {
 46	case "git-upload-pack", "git-receive-pack", "git-upload-archive":
 47		apiError(w, http.StatusBadRequest, "git transport does not run over the JSON API; use git with an SSH remote")
 48		return
 49	}
 50
 51	// Rate limit after auth so the bucket follows the token rather than the
 52	// network, but before dispatch so a rejected call costs nothing beyond
 53	// the lookup. A write draws on a separate, smaller budget.
 54	write := true
 55	if cmd, _, ok := control.Lookup(req.Argv); ok {
 56		write = !cmd.ReadOnly
 57	}
 58	if allowed, wait := s.apiLimit.allow(s.limitKey(r, user), write); !allowed {
 59		tooManyRequests(w, wait)
 60		return
 61	}
 62
 63	var stdout, stderr bytes.Buffer
 64	ctx := &control.Ctx{
 65		User:     user,
 66		Source:   "api",
 67		Scope:    "full", // key scopes are an SSH concept; token scope is below
 68		Store:    s.st,
 69		Cfg:      s.cfg,
 70		Stdin:    strings.NewReader(req.Stdin),
 71		Stdout:   &stdout,
 72		Stderr:   &stderr,
 73		JSON:     true,
 74		ViaAPI:   true,
 75		ReadOnly: tok.Scope == "read",
 76		TokenID:  tok.ID,
 77		Expires:  tok.ExpiresAt,
 78		Done:     s.until(r),
 79		Stopping: s.stopping,
 80	}
 81	code := control.Dispatch(ctx, req.Argv)
 82
 83	status := statusForExit(code)
 84
 85	// Commands normally emit exactly one JSON envelope; inject exit_code.
 86	// A few (mr diff, repo download) write raw bytes instead — wrap those.
 87	var body map[string]any
 88	if err := json.Unmarshal(stdout.Bytes(), &body); err != nil || body == nil {
 89		body = map[string]any{
 90			"protocol_version": protocol.Version,
 91			"output":           stdout.String(),
 92		}
 93	}
 94	body["exit_code"] = code
 95	if msg := strings.TrimSpace(stderr.String()); msg != "" {
 96		body["stderr"] = msg
 97	}
 98	w.Header().Set("Content-Type", "application/json")
 99	w.WriteHeader(status)
100	json.NewEncoder(w).Encode(body)
101}
102
103// statusForExit maps a command's exit code onto an HTTP status, shared by
104// both API surfaces so they cannot answer the same failure differently.
105func statusForExit(code int) int {
106	switch code {
107	case protocol.ExitOK:
108		return http.StatusOK
109	case protocol.ExitUsage:
110		return http.StatusBadRequest
111	case protocol.ExitNotFound:
112		return http.StatusNotFound
113	case protocol.ExitDenied:
114		return http.StatusForbidden
115	}
116	return http.StatusInternalServerError
117}
118
119// limitKey buckets an authenticated caller by account, so rotating tokens
120// buys no extra budget, and everyone else by peer address.
121func (s *Server) limitKey(r *http.Request, user store.User) string {
122	if user.ID != 0 {
123		return "u" + strconv.FormatInt(user.ID, 10)
124	}
125	return "ip" + s.clientIP(r)
126}
127
128// apiAuth resolves the bearer token; failures are uniform 401s.
129func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, store.APIToken, bool) {
130	token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
131	if !ok || token == "" {
132		w.Header().Set("WWW-Authenticate", `Bearer realm="gitbay api"`)
133		apiError(w, http.StatusUnauthorized, "missing bearer token; mint one over SSH: token create --name <n>")
134		return store.User{}, store.APIToken{}, false
135	}
136	user, tok, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(token)))
137	if err != nil {
138		if errors.Is(err, store.ErrNotFound) {
139			apiError(w, http.StatusUnauthorized, "invalid or expired token")
140			return store.User{}, store.APIToken{}, false
141		}
142		apiError(w, http.StatusInternalServerError, "internal error")
143		return store.User{}, store.APIToken{}, false
144	}
145	return user, tok, true
146}
147
148func apiError(w http.ResponseWriter, status int, msg string) {
149	w.Header().Set("Content-Type", "application/json")
150	w.WriteHeader(status)
151	json.NewEncoder(w).Encode(map[string]any{
152		"protocol_version": protocol.Version,
153		"error":            msg,
154	})
155}