internal/httpd/lfs.go

20b06b791bf4bdd23b633bbfde819d5d8564751a
gitbay/internal/httpd/lfs.go history · blame · raw

224 lines · 7068 bytes

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