internal/httpd/lfs.go

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

282 lines · 9111 bytes

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