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