internal/httpd/apiread.go

v1.36.0
gitbay/internal/httpd/apiread.go history · blame · raw

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