internal/httpd/api.go
164 lines · 5062 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 Packs: s.packs,
81 }
82 code := control.Dispatch(ctx, req.Argv)
83
84 status := statusForExit(code)
85
86 // Commands normally emit exactly one JSON envelope; inject exit_code.
87 // A few (mr diff, repo download) write raw bytes instead — wrap those.
88 var body map[string]any
89 if err := json.Unmarshal(stdout.Bytes(), &body); err != nil || body == nil {
90 body = map[string]any{
91 "protocol_version": protocol.Version,
92 "output": stdout.String(),
93 }
94 }
95 body["exit_code"] = code
96 if msg := strings.TrimSpace(stderr.String()); msg != "" {
97 body["stderr"] = msg
98 }
99 w.Header().Set("Content-Type", "application/json")
100 if ctx.Busy {
101 status = http.StatusServiceUnavailable
102 w.Header().Set("Retry-After", busyRetryAfter)
103 }
104 w.WriteHeader(status)
105 json.NewEncoder(w).Encode(body)
106}
107
108// statusForExit maps a command's exit code onto an HTTP status, shared by
109// both API surfaces so they cannot answer the same failure differently.
110// busyRetryAfter is the Retry-After on a 503 for a command a limiter
111// turned away.
112const busyRetryAfter = "60"
113
114func statusForExit(code int) int {
115 switch code {
116 case protocol.ExitOK:
117 return http.StatusOK
118 case protocol.ExitUsage:
119 return http.StatusBadRequest
120 case protocol.ExitNotFound:
121 return http.StatusNotFound
122 case protocol.ExitDenied:
123 return http.StatusForbidden
124 }
125 return http.StatusInternalServerError
126}
127
128// limitKey buckets an authenticated caller by account, so rotating tokens
129// buys no extra budget, and everyone else by peer address.
130func (s *Server) limitKey(r *http.Request, user store.User) string {
131 if user.ID != 0 {
132 return "u" + strconv.FormatInt(user.ID, 10)
133 }
134 return "ip" + s.clientIP(r)
135}
136
137// apiAuth resolves the bearer token; failures are uniform 401s.
138func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, store.APIToken, bool) {
139 token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
140 if !ok || token == "" {
141 w.Header().Set("WWW-Authenticate", `Bearer realm="gitbay api"`)
142 apiError(w, http.StatusUnauthorized, "missing bearer token; mint one over SSH: token create --name <n>")
143 return store.User{}, store.APIToken{}, false
144 }
145 user, tok, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(token)))
146 if err != nil {
147 if errors.Is(err, store.ErrNotFound) {
148 apiError(w, http.StatusUnauthorized, "invalid or expired token")
149 return store.User{}, store.APIToken{}, false
150 }
151 apiError(w, http.StatusInternalServerError, "internal error")
152 return store.User{}, store.APIToken{}, false
153 }
154 return user, tok, true
155}
156
157func apiError(w http.ResponseWriter, status int, msg string) {
158 w.Header().Set("Content-Type", "application/json")
159 w.WriteHeader(status)
160 json.NewEncoder(w).Encode(map[string]any{
161 "protocol_version": protocol.Version,
162 "error": msg,
163 })
164}