internal/httpd/apiread.go
116 lines · 3523 bytes
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(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 }
66 code := control.Dispatch(ctx, argv)
67
68 var body map[string]any
69 if err := json.Unmarshal(stdout.Bytes(), &body); err != nil || body == nil {
70 body = map[string]any{"protocol_version": protocol.Version, "output": stdout.String()}
71 }
72 body["exit_code"] = code
73 if msg := strings.TrimSpace(stderr.String()); msg != "" {
74 body["stderr"] = msg
75 }
76 payload, err := json.Marshal(body)
77 if err != nil {
78 apiError(w, http.StatusInternalServerError, "internal error")
79 return
80 }
81
82 // Responses are authorized per account, so the ETag is salted with the
83 // caller: two users asking the same question may get different answers,
84 // and neither should ever be served the other's.
85 sum := sha256.Sum256(append([]byte(limitKey(r, user)+"\x00"), payload...))
86 etag := `"` + hex.EncodeToString(sum[:16]) + `"`
87
88 // private keeps this out of shared caches; no-cache requires a
89 // revalidation rather than forbidding storage, which is what makes the
90 // 304 worth having.
91 w.Header().Set("Cache-Control", "private, no-cache")
92 w.Header().Set("ETag", etag)
93 w.Header().Set("Content-Type", "application/json")
94 if match := r.Header.Get("If-None-Match"); match != "" && etagMatches(match, etag) {
95 w.WriteHeader(http.StatusNotModified)
96 return
97 }
98
99 status := statusForExit(code)
100 w.WriteHeader(status)
101 w.Write(payload)
102}
103
104// etagMatches handles the comma-separated If-None-Match list, and the weak
105// prefix a cache may add.
106func etagMatches(header, etag string) bool {
107 for _, candidate := range strings.Split(header, ",") {
108 candidate = strings.TrimSpace(candidate)
109 if candidate == "*" || strings.TrimPrefix(candidate, "W/") == etag {
110 return true
111 }
112 }
113 return false
114}
115
116func joinArgv(path []string) string { return strings.Join(path, " ") }