krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
repo-descriptions: 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}