internal/httpd/apiread.go
128 lines · 3837 bytes
3 symbols in this file
1package httpd
2
3import (
4 "bytes"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "net/http"
9 "strings"
10
11 "gitbay.org/gitbay/internal/control"
12 "gitbay.org/gitbay/internal/protocol"
13)
14
15// apiRead is the conditional-request half of the API: the same commands as
16// /api/v1/cmd, reached with GET so a response can carry an ETag and a
17// client can revalidate instead of refetching. A phone on a slow network
18// re-renders a screen for a 304 rather than a full body.
19//
20// It dispatches the same registry — no second implementation, no chance of
21// the two surfaces disagreeing — and admits only commands the registry
22// marks ReadOnly, so a GET can never mutate.
23//
24// GET /api/v1/read?argv=repo&argv=tree&argv=owner/name
25func (s *Server) apiRead(w http.ResponseWriter, r *http.Request) {
26 user, _, ok := s.apiAuth(w, r)
27 if !ok {
28 return
29 }
30 argv := r.URL.Query()["argv"]
31 if len(argv) == 0 {
32 apiError(w, http.StatusBadRequest, "argv is required: ?argv=repo&argv=show&argv=owner/name")
33 return
34 }
35 cmd, _, found := control.Lookup(argv)
36 if !found {
37 apiError(w, http.StatusNotFound, "unknown command "+argv[0])
38 return
39 }
40 if !cmd.ReadOnly {
41 // Not 405: the command exists, it is simply not a read. Saying so
42 // is more useful than implying the URL is wrong.
43 apiError(w, http.StatusBadRequest,
44 joinArgv(cmd.Path)+" changes state; POST it to /api/v1/cmd")
45 return
46 }
47 if allowed, wait := s.apiLimit.allow(s.limitKey(r, user), false); !allowed {
48 tooManyRequests(w, wait)
49 return
50 }
51
52 var stdout, stderr bytes.Buffer
53 ctx := &control.Ctx{
54 User: user,
55 Source: "api",
56 Scope: "full",
57 Store: s.st,
58 Cfg: s.cfg,
59 Stdin: strings.NewReader(""),
60 Stdout: &stdout,
61 Stderr: &stderr,
62 JSON: true,
63 ViaAPI: true,
64 ReadOnly: true,
65 Done: s.until(r),
66 Stopping: s.stopping,
67 Packs: s.packs,
68 }
69 code := control.Dispatch(ctx, argv)
70
71 var body map[string]any
72 if err := json.Unmarshal(stdout.Bytes(), &body); err != nil || body == nil {
73 body = map[string]any{"protocol_version": protocol.Version, "output": stdout.String()}
74 }
75 body["exit_code"] = code
76 if msg := strings.TrimSpace(stderr.String()); msg != "" {
77 body["stderr"] = msg
78 }
79 payload, err := json.Marshal(body)
80 if err != nil {
81 apiError(w, http.StatusInternalServerError, "internal error")
82 return
83 }
84
85 if ctx.Busy {
86 // Not cacheable: the next try may succeed.
87 w.Header().Set("Content-Type", "application/json")
88 w.Header().Set("Retry-After", busyRetryAfter)
89 w.WriteHeader(http.StatusServiceUnavailable)
90 w.Write(payload)
91 return
92 }
93
94 // Responses are authorized per account, so the ETag is salted with the
95 // caller: two users asking the same question may get different answers,
96 // and neither should ever be served the other's.
97 sum := sha256.Sum256(append([]byte(s.limitKey(r, user)+"\x00"), payload...))
98 etag := `"` + hex.EncodeToString(sum[:16]) + `"`
99
100 // private keeps this out of shared caches; no-cache requires a
101 // revalidation rather than forbidding storage, which is what makes the
102 // 304 worth having.
103 w.Header().Set("Cache-Control", "private, no-cache")
104 w.Header().Set("ETag", etag)
105 w.Header().Set("Content-Type", "application/json")
106 if match := r.Header.Get("If-None-Match"); match != "" && etagMatches(match, etag) {
107 w.WriteHeader(http.StatusNotModified)
108 return
109 }
110
111 status := statusForExit(code)
112 w.WriteHeader(status)
113 w.Write(payload)
114}
115
116// etagMatches handles the comma-separated If-None-Match list, and the weak
117// prefix a cache may add.
118func etagMatches(header, etag string) bool {
119 for _, candidate := range strings.Split(header, ",") {
120 candidate = strings.TrimSpace(candidate)
121 if candidate == "*" || strings.TrimPrefix(candidate, "W/") == etag {
122 return true
123 }
124 }
125 return false
126}
127
128func joinArgv(path []string) string { return strings.Join(path, " ") }