krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

main: internal/httpd/api.go · raw

  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		Scope:    "full", // key scopes are an SSH concept; token scope is below
 54		Store:    s.st,
 55		Cfg:      s.cfg,
 56		Stdin:    strings.NewReader(req.Stdin),
 57		Stdout:   &stdout,
 58		Stderr:   &stderr,
 59		JSON:     true,
 60		ViaAPI:   true,
 61		ReadOnly: scope == "read",
 62	}
 63	code := control.Dispatch(ctx, req.Argv)
 64
 65	status := map[int]int{
 66		protocol.ExitOK:       http.StatusOK,
 67		protocol.ExitUsage:    http.StatusBadRequest,
 68		protocol.ExitNotFound: http.StatusNotFound,
 69		protocol.ExitDenied:   http.StatusForbidden,
 70	}[code]
 71	if status == 0 {
 72		status = http.StatusInternalServerError
 73	}
 74
 75	// Commands normally emit exactly one JSON envelope; inject exit_code.
 76	// A few (mr diff, help) write raw text instead — wrap those.
 77	var body map[string]any
 78	if err := json.Unmarshal(stdout.Bytes(), &body); err != nil || body == nil {
 79		body = map[string]any{
 80			"protocol_version": protocol.Version,
 81			"output":           stdout.String(),
 82		}
 83	}
 84	body["exit_code"] = code
 85	if msg := strings.TrimSpace(stderr.String()); msg != "" {
 86		body["stderr"] = msg
 87	}
 88	w.Header().Set("Content-Type", "application/json")
 89	w.WriteHeader(status)
 90	json.NewEncoder(w).Encode(body)
 91}
 92
 93// apiAuth resolves the bearer token; failures are uniform 401s.
 94func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, string, bool) {
 95	token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
 96	if !ok || token == "" {
 97		w.Header().Set("WWW-Authenticate", `Bearer realm="gitbay api"`)
 98		apiError(w, http.StatusUnauthorized, "missing bearer token; mint one over SSH: token create --name <n>")
 99		return store.User{}, "", false
100	}
101	user, scope, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(token)))
102	if err != nil {
103		if errors.Is(err, store.ErrNotFound) {
104			apiError(w, http.StatusUnauthorized, "invalid or expired token")
105			return store.User{}, "", false
106		}
107		apiError(w, http.StatusInternalServerError, "internal error")
108		return store.User{}, "", false
109	}
110	return user, scope, true
111}
112
113func apiError(w http.ResponseWriter, status int, msg string) {
114	w.Header().Set("Content-Type", "application/json")
115	w.WriteHeader(status)
116	json.NewEncoder(w).Encode(map[string]any{
117		"protocol_version": protocol.Version,
118		"error":            msg,
119	})
120}