internal/httpd/lfs.go
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}