internal/httpd/lfs.go

bd5cf5d7d1f34fa780660fd7562b9ffd9746ee27
gitbay/internal/httpd/lfs.go history · blame · raw

268 lines · 8580 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/policy"
 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	return lfs.LocalStore{Root: lfs.RootFor(s.cfg.LFS.Root, s.cfg.Server.Root)}
 25}
 26
 27func (s *Server) lfsMaxObject() int64 {
 28	if s.cfg.LFS.MaxObjectBytes > 0 {
 29		return s.cfg.LFS.MaxObjectBytes
 30	}
 31	return 512 << 20
 32}
 33
 34func (s *Server) lfsSecret() ([]byte, error) {
 35	v, err := s.st.LFSSecret(lfs.NewSecret)
 36	return []byte(v), err
 37}
 38
 39// lfsAuth resolves what the request may do to the repo: "upload",
 40// "download", or "" for no access, and the key the grant rests on (0
 41// for none). A token is bound to the SSH key that obtained it and
 42// works only while that key is registered, unexpired and on an enabled
 43// account, and while the key still has the access its operation needs
 44// on the repo (#285). Without one, public repos allow anonymous
 45// download only.
 46func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) {
 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 "", 0
 52		}
 53		g, ok := lfs.Verify(secret, tok, time.Now())
 54		if !ok || g.RepoID != repo.ID {
 55			return "", 0
 56		}
 57		if g.KeyID == 0 {
 58			// Minted by an anonymous batch: worth what anonymous is.
 59			if g.Op == "download" && repo.Visibility == "public" {
 60				return "download", 0
 61			}
 62			return "", 0
 63		}
 64		live, err := s.st.LiveSSHKeys([]int64{g.KeyID})
 65		if err != nil || !live[g.KeyID] || !s.lfsKeyAllows(g.KeyID, repo, g.Op == "upload") {
 66			return "", 0
 67		}
 68		return g.Op, g.KeyID
 69	}
 70	if repo.Visibility == "public" {
 71		return "download", 0
 72	}
 73	return "", 0
 74}
 75
 76// lfsKeyAllows repeats git-lfs-authenticate's access check for the key
 77// now: a deploy key by its binding, any other key by its account's
 78// access narrowed by the key's scope.
 79func (s *Server) lfsKeyAllows(keyID int64, repo store.Repo, write bool) bool {
 80	key, err := s.st.SSHKeyByID(keyID)
 81	if err != nil {
 82		return false
 83	}
 84	if policy.IsDeployScope(key.Scope) {
 85		return policy.DeployScopeAllows(key.Scope, repo.ID, write)
 86	}
 87	user, err := s.st.UserByID(key.UserID)
 88	if err != nil {
 89		return false
 90	}
 91	grant, err := s.st.AccessRole(repo.ID, user.ID)
 92	if err != nil {
 93		return false
 94	}
 95	if !policy.CanRead(user, repo, grant) || !policy.ScopeAllowsGit(key.Scope, repo.Path(), write) {
 96		return false
 97	}
 98	return !write || policy.CanWrite(user, repo, grant)
 99}
100
101func lfsError(w http.ResponseWriter, code int, msg string) {
102	w.Header().Set("Content-Type", lfsMediaType)
103	w.WriteHeader(code)
104	json.NewEncoder(w).Encode(map[string]string{"message": msg})
105}
106
107type lfsBatchReq struct {
108	Operation string   `json:"operation"`
109	Transfers []string `json:"transfers"`
110	Objects   []struct {
111		OID  string `json:"oid"`
112		Size int64  `json:"size"`
113	} `json:"objects"`
114}
115
116type lfsAction struct {
117	Href      string            `json:"href"`
118	Header    map[string]string `json:"header,omitempty"`
119	ExpiresIn int               `json:"expires_in,omitempty"`
120}
121
122type lfsObject struct {
123	OID           string               `json:"oid"`
124	Size          int64                `json:"size"`
125	Authenticated bool                 `json:"authenticated,omitempty"`
126	Actions       map[string]lfsAction `json:"actions,omitempty"`
127	Error         *struct {
128		Code    int    `json:"code"`
129		Message string `json:"message"`
130	} `json:"error,omitempty"`
131}
132
133// lfsBatch answers POST /{owner}/{repo}/info/lfs/objects/batch.
134func (s *Server) lfsBatch(w http.ResponseWriter, r *http.Request) {
135	repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
136	if err != nil {
137		lfsError(w, http.StatusNotFound, "repository not found")
138		return
139	}
140	granted, keyID := s.lfsAuth(r, repo)
141	if granted == "" {
142		// Not naming whether the repo exists, per the enumeration rule.
143		lfsError(w, http.StatusNotFound, "repository not found")
144		return
145	}
146	var req lfsBatchReq
147	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
148		lfsError(w, http.StatusBadRequest, "bad batch request")
149		return
150	}
151	if req.Operation != "download" && req.Operation != "upload" {
152		lfsError(w, http.StatusBadRequest, "operation must be download or upload")
153		return
154	}
155	if req.Operation == "upload" && granted != "upload" {
156		lfsError(w, http.StatusForbidden, "upload requires write access (authenticate over SSH)")
157		return
158	}
159	if len(req.Objects) > 1000 {
160		lfsError(w, http.StatusUnprocessableEntity, "too many objects in one batch")
161		return
162	}
163
164	// The token in transfer hrefs is operation-scoped and freshly minted,
165	// so anonymous downloads work without the client sending one back.
166	secret, err := s.lfsSecret()
167	if err != nil {
168		lfsError(w, http.StatusInternalServerError, "lfs secret unavailable")
169		return
170	}
171	transferToken := lfs.Sign(secret, repo.ID, keyID, req.Operation, time.Now())
172	base := fmt.Sprintf("%s/%s/%s.git/info/lfs/objects",
173		strings.TrimSuffix(s.cfg.Server.SiteURL, "/"), repo.OwnerName, repo.Name)
174	authHeader := map[string]string{"Authorization": "Bearer " + transferToken}
175
176	blobs := s.lfsStore()
177	out := struct {
178		Transfer string      `json:"transfer"`
179		Objects  []lfsObject `json:"objects"`
180	}{Transfer: "basic"}
181	for _, o := range req.Objects {
182		obj := lfsObject{OID: o.OID, Size: o.Size, Authenticated: true}
183		switch {
184		case !lfs.OIDPat.MatchString(o.OID) || o.Size < 0:
185			obj.Error = &struct {
186				Code    int    `json:"code"`
187				Message string `json:"message"`
188			}{422, "malformed object"}
189		case req.Operation == "download":
190			if size, ok := blobs.Exists(o.OID); ok {
191				obj.Size = size
192				obj.Actions = map[string]lfsAction{"download": {
193					Href: base + "/" + o.OID, Header: authHeader, ExpiresIn: int(lfs.TokenTTL.Seconds()),
194				}}
195			} else {
196				obj.Error = &struct {
197					Code    int    `json:"code"`
198					Message string `json:"message"`
199				}{404, "object not found"}
200			}
201		default: // upload
202			if o.Size > s.lfsMaxObject() {
203				obj.Error = &struct {
204					Code    int    `json:"code"`
205					Message string `json:"message"`
206				}{422, fmt.Sprintf("object exceeds the %d byte limit", s.lfsMaxObject())}
207			} else if _, ok := blobs.Exists(o.OID); !ok {
208				// Present objects get no actions: the client skips them.
209				obj.Actions = map[string]lfsAction{"upload": {
210					Href: base + "/" + o.OID, Header: authHeader, ExpiresIn: int(lfs.TokenTTL.Seconds()),
211				}}
212			}
213		}
214		out.Objects = append(out.Objects, obj)
215	}
216	w.Header().Set("Content-Type", lfsMediaType)
217	json.NewEncoder(w).Encode(out)
218}
219
220// lfsDownload answers GET /{owner}/{repo}/info/lfs/objects/{oid}.
221func (s *Server) lfsDownload(w http.ResponseWriter, r *http.Request) {
222	repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
223	if err != nil {
224		lfsError(w, http.StatusNotFound, "not found")
225		return
226	}
227	if op, _ := s.lfsAuth(r, repo); op == "" {
228		lfsError(w, http.StatusNotFound, "not found")
229		return
230	}
231	rc, size, err := s.lfsStore().Get(r.PathValue("oid"))
232	if err != nil {
233		lfsError(w, http.StatusNotFound, "object not found")
234		return
235	}
236	defer rc.Close()
237	w.Header().Set("Content-Type", "application/octet-stream")
238	w.Header().Set("Content-Length", fmt.Sprint(size))
239	w.Header().Set("X-Content-Type-Options", "nosniff")
240	io.Copy(w, rc)
241}
242
243// lfsUpload answers PUT /{owner}/{repo}/info/lfs/objects/{oid}.
244func (s *Server) lfsUpload(w http.ResponseWriter, r *http.Request) {
245	repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
246	if err != nil {
247		lfsError(w, http.StatusNotFound, "not found")
248		return
249	}
250	if op, _ := s.lfsAuth(r, repo); op != "upload" {
251		lfsError(w, http.StatusNotFound, "not found")
252		return
253	}
254	oid := r.PathValue("oid")
255	if r.ContentLength < 0 || r.ContentLength > s.lfsMaxObject() {
256		lfsError(w, http.StatusRequestEntityTooLarge, "object too large or length unknown")
257		return
258	}
259	if _, ok := s.lfsStore().Exists(oid); ok {
260		w.WriteHeader(http.StatusOK) // already have it; idempotent
261		return
262	}
263	if err := s.lfsStore().Put(oid, r.Body, r.ContentLength); err != nil {
264		lfsError(w, http.StatusUnprocessableEntity, err.Error())
265		return
266	}
267	w.WriteHeader(http.StatusOK)
268}