internal/httpd/apiread.go

353f68a2e57ac692964b73ae1f9acd91fcdcae20
gitbay/internal/httpd/apiread.go history · blame · raw

116 lines · 3527 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	}
 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(s.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, " ") }