internal/httpd/api.go

eec36526d2721d98ac0d9446ba4e2975d16b5d8b
gitbay/internal/httpd/api.go history · blame · raw

121 lines · 3719 bytes

  1package httpd
  2
  3import (
  4	"bytes"
  5	"encoding/json"
  6	"errors"
  7	"io"
  8	"net/http"
  9	"strings"
 10
 11	"gitbay.org/gitbay/internal/control"
 12	"gitbay.org/gitbay/internal/protocol"
 13	"gitbay.org/gitbay/internal/store"
 14)
 15
 16// apiRequest is the wire form of one command invocation. argv is real
 17// argv — no shell, no tokenizer, no quoting rules.
 18type apiRequest struct {
 19	Argv  []string `json:"argv"`
 20	Stdin string   `json:"stdin,omitempty"`
 21}
 22
 23const maxAPIBody = 1 << 20
 24
 25// apiCmd fronts the same control-command registry the SSH dispatcher uses:
 26// every command, current and future, is reachable here with identical
 27// semantics. Exit codes map onto HTTP statuses; the body is the command's
 28// JSON envelope with exit_code added.
 29func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) {
 30	user, scope, ok := s.apiAuth(w, r)
 31	if !ok {
 32		return
 33	}
 34
 35	var req apiRequest
 36	if err := json.NewDecoder(io.LimitReader(r.Body, maxAPIBody)).Decode(&req); err != nil {
 37		apiError(w, http.StatusBadRequest, "body must be JSON: {\"argv\": [...], \"stdin\": \"...\"}")
 38		return
 39	}
 40	if len(req.Argv) == 0 {
 41		apiError(w, http.StatusBadRequest, "argv is required")
 42		return
 43	}
 44	switch req.Argv[0] {
 45	case "git-upload-pack", "git-receive-pack", "git-upload-archive":
 46		apiError(w, http.StatusBadRequest, "git transport does not run over the JSON API; use git with an SSH remote")
 47		return
 48	}
 49
 50	var stdout, stderr bytes.Buffer
 51	ctx := &control.Ctx{
 52		User:     user,
 53		Source:   "api",
 54		Scope:    "full", // key scopes are an SSH concept; token scope is below
 55		Store:    s.st,
 56		Cfg:      s.cfg,
 57		Stdin:    strings.NewReader(req.Stdin),
 58		Stdout:   &stdout,
 59		Stderr:   &stderr,
 60		JSON:     true,
 61		ViaAPI:   true,
 62		ReadOnly: scope == "read",
 63	}
 64	code := control.Dispatch(ctx, req.Argv)
 65
 66	status := map[int]int{
 67		protocol.ExitOK:       http.StatusOK,
 68		protocol.ExitUsage:    http.StatusBadRequest,
 69		protocol.ExitNotFound: http.StatusNotFound,
 70		protocol.ExitDenied:   http.StatusForbidden,
 71	}[code]
 72	if status == 0 {
 73		status = http.StatusInternalServerError
 74	}
 75
 76	// Commands normally emit exactly one JSON envelope; inject exit_code.
 77	// A few (mr diff, help) write raw text instead — wrap those.
 78	var body map[string]any
 79	if err := json.Unmarshal(stdout.Bytes(), &body); err != nil || body == nil {
 80		body = map[string]any{
 81			"protocol_version": protocol.Version,
 82			"output":           stdout.String(),
 83		}
 84	}
 85	body["exit_code"] = code
 86	if msg := strings.TrimSpace(stderr.String()); msg != "" {
 87		body["stderr"] = msg
 88	}
 89	w.Header().Set("Content-Type", "application/json")
 90	w.WriteHeader(status)
 91	json.NewEncoder(w).Encode(body)
 92}
 93
 94// apiAuth resolves the bearer token; failures are uniform 401s.
 95func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, string, bool) {
 96	token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
 97	if !ok || token == "" {
 98		w.Header().Set("WWW-Authenticate", `Bearer realm="gitbay api"`)
 99		apiError(w, http.StatusUnauthorized, "missing bearer token; mint one over SSH: token create --name <n>")
100		return store.User{}, "", false
101	}
102	user, scope, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(token)))
103	if err != nil {
104		if errors.Is(err, store.ErrNotFound) {
105			apiError(w, http.StatusUnauthorized, "invalid or expired token")
106			return store.User{}, "", false
107		}
108		apiError(w, http.StatusInternalServerError, "internal error")
109		return store.User{}, "", false
110	}
111	return user, scope, true
112}
113
114func apiError(w http.ResponseWriter, status int, msg string) {
115	w.Header().Set("Content-Type", "application/json")
116	w.WriteHeader(status)
117	json.NewEncoder(w).Encode(map[string]any{
118		"protocol_version": protocol.Version,
119		"error":            msg,
120	})
121}