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