internal/httpd/lfs.go

61564b7c32807deb349e28f2b5c6909cb4143870
gitbay/internal/httpd/lfs.go history · blame · raw

219 lines · 7000 bytes

  1package httpd
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"io"
  7	"net/http"
  8	"strings"
  9	"time"
 10
 11	"gitbay.org/gitbay/internal/lfs"
 12	"gitbay.org/gitbay/internal/store"
 13)
 14
 15// Git LFS server: the batch API plus basic-transfer endpoints. SSH clients
 16// arrive with a token minted by git-lfs-authenticate; anonymous HTTPS
 17// clients may download from public repositories, mirroring the smart-http
 18// read-only rule. Uploads always require an upload token.
 19
 20const lfsMediaType = "application/vnd.git-lfs+json"
 21
 22func (s *Server) lfsStore() lfs.BlobStore {
 23	return lfs.LocalStore{Root: lfs.RootFor(s.cfg.LFS.Root, s.cfg.Server.Root)}
 24}
 25
 26func (s *Server) lfsMaxObject() int64 {
 27	if s.cfg.LFS.MaxObjectBytes > 0 {
 28		return s.cfg.LFS.MaxObjectBytes
 29	}
 30	return 512 << 20
 31}
 32
 33func (s *Server) lfsSecret() ([]byte, error) {
 34	v, err := s.st.LFSSecret(lfs.NewSecret)
 35	return []byte(v), err
 36}
 37
 38// lfsAuth resolves what the request may do to the repo: "upload",
 39// "download", or "" for no access. Tokens are repo-scoped; without one,
 40// public repos allow anonymous download only.
 41func (s *Server) lfsAuth(r *http.Request, repo store.Repo) string {
 42	auth := r.Header.Get("Authorization")
 43	if tok, ok := strings.CutPrefix(auth, "Bearer "); ok {
 44		secret, err := s.lfsSecret()
 45		if err != nil {
 46			return ""
 47		}
 48		repoID, op, ok := lfs.Verify(secret, tok, time.Now())
 49		if !ok || repoID != repo.ID {
 50			return ""
 51		}
 52		return op
 53	}
 54	if repo.Visibility == "public" {
 55		return "download"
 56	}
 57	return ""
 58}
 59
 60func lfsError(w http.ResponseWriter, code int, msg string) {
 61	w.Header().Set("Content-Type", lfsMediaType)
 62	w.WriteHeader(code)
 63	json.NewEncoder(w).Encode(map[string]string{"message": msg})
 64}
 65
 66type lfsBatchReq struct {
 67	Operation string   `json:"operation"`
 68	Transfers []string `json:"transfers"`
 69	Objects   []struct {
 70		OID  string `json:"oid"`
 71		Size int64  `json:"size"`
 72	} `json:"objects"`
 73}
 74
 75type lfsAction struct {
 76	Href      string            `json:"href"`
 77	Header    map[string]string `json:"header,omitempty"`
 78	ExpiresIn int               `json:"expires_in,omitempty"`
 79}
 80
 81type lfsObject struct {
 82	OID           string               `json:"oid"`
 83	Size          int64                `json:"size"`
 84	Authenticated bool                 `json:"authenticated,omitempty"`
 85	Actions       map[string]lfsAction `json:"actions,omitempty"`
 86	Error         *struct {
 87		Code    int    `json:"code"`
 88		Message string `json:"message"`
 89	} `json:"error,omitempty"`
 90}
 91
 92// lfsBatch answers POST /{owner}/{repo}/info/lfs/objects/batch.
 93func (s *Server) lfsBatch(w http.ResponseWriter, r *http.Request) {
 94	repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
 95	if err != nil {
 96		lfsError(w, http.StatusNotFound, "repository not found")
 97		return
 98	}
 99	granted := s.lfsAuth(r, repo)
100	if granted == "" {
101		// Not naming whether the repo exists, per the enumeration rule.
102		lfsError(w, http.StatusNotFound, "repository not found")
103		return
104	}
105	var req lfsBatchReq
106	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
107		lfsError(w, http.StatusBadRequest, "bad batch request")
108		return
109	}
110	if req.Operation != "download" && req.Operation != "upload" {
111		lfsError(w, http.StatusBadRequest, "operation must be download or upload")
112		return
113	}
114	if req.Operation == "upload" && granted != "upload" {
115		lfsError(w, http.StatusForbidden, "upload requires write access (authenticate over SSH)")
116		return
117	}
118	if len(req.Objects) > 1000 {
119		lfsError(w, http.StatusUnprocessableEntity, "too many objects in one batch")
120		return
121	}
122
123	// The token in transfer hrefs is operation-scoped and freshly minted,
124	// so anonymous downloads work without the client sending one back.
125	secret, err := s.lfsSecret()
126	if err != nil {
127		lfsError(w, http.StatusInternalServerError, "lfs secret unavailable")
128		return
129	}
130	transferToken := lfs.Sign(secret, repo.ID, req.Operation, time.Now())
131	base := fmt.Sprintf("%s/%s/%s.git/info/lfs/objects",
132		strings.TrimSuffix(s.cfg.Server.SiteURL, "/"), repo.OwnerName, repo.Name)
133	authHeader := map[string]string{"Authorization": "Bearer " + transferToken}
134
135	blobs := s.lfsStore()
136	out := struct {
137		Transfer string      `json:"transfer"`
138		Objects  []lfsObject `json:"objects"`
139	}{Transfer: "basic"}
140	for _, o := range req.Objects {
141		obj := lfsObject{OID: o.OID, Size: o.Size, Authenticated: true}
142		switch {
143		case !lfs.OIDPat.MatchString(o.OID) || o.Size < 0:
144			obj.Error = &struct {
145				Code    int    `json:"code"`
146				Message string `json:"message"`
147			}{422, "malformed object"}
148		case req.Operation == "download":
149			if size, ok := blobs.Exists(o.OID); ok {
150				obj.Size = size
151				obj.Actions = map[string]lfsAction{"download": {
152					Href: base + "/" + o.OID, Header: authHeader, ExpiresIn: int(lfs.TokenTTL.Seconds()),
153				}}
154			} else {
155				obj.Error = &struct {
156					Code    int    `json:"code"`
157					Message string `json:"message"`
158				}{404, "object not found"}
159			}
160		default: // upload
161			if o.Size > s.lfsMaxObject() {
162				obj.Error = &struct {
163					Code    int    `json:"code"`
164					Message string `json:"message"`
165				}{422, fmt.Sprintf("object exceeds the %d byte limit", s.lfsMaxObject())}
166			} else if _, ok := blobs.Exists(o.OID); !ok {
167				// Present objects get no actions: the client skips them.
168				obj.Actions = map[string]lfsAction{"upload": {
169					Href: base + "/" + o.OID, Header: authHeader, ExpiresIn: int(lfs.TokenTTL.Seconds()),
170				}}
171			}
172		}
173		out.Objects = append(out.Objects, obj)
174	}
175	w.Header().Set("Content-Type", lfsMediaType)
176	json.NewEncoder(w).Encode(out)
177}
178
179// lfsDownload answers GET /{owner}/{repo}/info/lfs/objects/{oid}.
180func (s *Server) lfsDownload(w http.ResponseWriter, r *http.Request) {
181	repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
182	if err != nil || s.lfsAuth(r, repo) == "" {
183		lfsError(w, http.StatusNotFound, "not found")
184		return
185	}
186	rc, size, err := s.lfsStore().Get(r.PathValue("oid"))
187	if err != nil {
188		lfsError(w, http.StatusNotFound, "object not found")
189		return
190	}
191	defer rc.Close()
192	w.Header().Set("Content-Type", "application/octet-stream")
193	w.Header().Set("Content-Length", fmt.Sprint(size))
194	w.Header().Set("X-Content-Type-Options", "nosniff")
195	io.Copy(w, rc)
196}
197
198// lfsUpload answers PUT /{owner}/{repo}/info/lfs/objects/{oid}.
199func (s *Server) lfsUpload(w http.ResponseWriter, r *http.Request) {
200	repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
201	if err != nil || s.lfsAuth(r, repo) != "upload" {
202		lfsError(w, http.StatusNotFound, "not found")
203		return
204	}
205	oid := r.PathValue("oid")
206	if r.ContentLength < 0 || r.ContentLength > s.lfsMaxObject() {
207		lfsError(w, http.StatusRequestEntityTooLarge, "object too large or length unknown")
208		return
209	}
210	if _, ok := s.lfsStore().Exists(oid); ok {
211		w.WriteHeader(http.StatusOK) // already have it; idempotent
212		return
213	}
214	if err := s.lfsStore().Put(oid, r.Body, r.ContentLength); err != nil {
215		lfsError(w, http.StatusUnprocessableEntity, err.Error())
216		return
217	}
218	w.WriteHeader(http.StatusOK)
219}