internal/httpd/lfs.go
219 lines · 7000 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/store"
13)
14
15// Git LFS server: the batch API plus basic-transfer endpoints. SSH clients
16// arrive with a token minted by git-lfs-authenticate; anonymous HTTPS
17// clients may download from public repositories, mirroring the smart-http
18// read-only rule. Uploads always require an upload token.
19
20const lfsMediaType = "application/vnd.git-lfs+json"
21
22func (s *Server) lfsStore() lfs.BlobStore {
23 return lfs.LocalStore{Root: lfs.RootFor(s.cfg.LFS.Root, s.cfg.Server.Root)}
24}
25
26func (s *Server) lfsMaxObject() int64 {
27 if s.cfg.LFS.MaxObjectBytes > 0 {
28 return s.cfg.LFS.MaxObjectBytes
29 }
30 return 512 << 20
31}
32
33func (s *Server) lfsSecret() ([]byte, error) {
34 v, err := s.st.LFSSecret(lfs.NewSecret)
35 return []byte(v), err
36}
37
38// lfsAuth resolves what the request may do to the repo: "upload",
39// "download", or "" for no access. Tokens are repo-scoped; without one,
40// public repos allow anonymous download only.
41func (s *Server) lfsAuth(r *http.Request, repo store.Repo) string {
42 auth := r.Header.Get("Authorization")
43 if tok, ok := strings.CutPrefix(auth, "Bearer "); ok {
44 secret, err := s.lfsSecret()
45 if err != nil {
46 return ""
47 }
48 repoID, op, ok := lfs.Verify(secret, tok, time.Now())
49 if !ok || repoID != repo.ID {
50 return ""
51 }
52 return op
53 }
54 if repo.Visibility == "public" {
55 return "download"
56 }
57 return ""
58}
59
60func lfsError(w http.ResponseWriter, code int, msg string) {
61 w.Header().Set("Content-Type", lfsMediaType)
62 w.WriteHeader(code)
63 json.NewEncoder(w).Encode(map[string]string{"message": msg})
64}
65
66type lfsBatchReq struct {
67 Operation string `json:"operation"`
68 Transfers []string `json:"transfers"`
69 Objects []struct {
70 OID string `json:"oid"`
71 Size int64 `json:"size"`
72 } `json:"objects"`
73}
74
75type lfsAction struct {
76 Href string `json:"href"`
77 Header map[string]string `json:"header,omitempty"`
78 ExpiresIn int `json:"expires_in,omitempty"`
79}
80
81type lfsObject struct {
82 OID string `json:"oid"`
83 Size int64 `json:"size"`
84 Authenticated bool `json:"authenticated,omitempty"`
85 Actions map[string]lfsAction `json:"actions,omitempty"`
86 Error *struct {
87 Code int `json:"code"`
88 Message string `json:"message"`
89 } `json:"error,omitempty"`
90}
91
92// lfsBatch answers POST /{owner}/{repo}/info/lfs/objects/batch.
93func (s *Server) lfsBatch(w http.ResponseWriter, r *http.Request) {
94 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
95 if err != nil {
96 lfsError(w, http.StatusNotFound, "repository not found")
97 return
98 }
99 granted := s.lfsAuth(r, repo)
100 if granted == "" {
101 // Not naming whether the repo exists, per the enumeration rule.
102 lfsError(w, http.StatusNotFound, "repository not found")
103 return
104 }
105 var req lfsBatchReq
106 if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
107 lfsError(w, http.StatusBadRequest, "bad batch request")
108 return
109 }
110 if req.Operation != "download" && req.Operation != "upload" {
111 lfsError(w, http.StatusBadRequest, "operation must be download or upload")
112 return
113 }
114 if req.Operation == "upload" && granted != "upload" {
115 lfsError(w, http.StatusForbidden, "upload requires write access (authenticate over SSH)")
116 return
117 }
118 if len(req.Objects) > 1000 {
119 lfsError(w, http.StatusUnprocessableEntity, "too many objects in one batch")
120 return
121 }
122
123 // The token in transfer hrefs is operation-scoped and freshly minted,
124 // so anonymous downloads work without the client sending one back.
125 secret, err := s.lfsSecret()
126 if err != nil {
127 lfsError(w, http.StatusInternalServerError, "lfs secret unavailable")
128 return
129 }
130 transferToken := lfs.Sign(secret, repo.ID, req.Operation, time.Now())
131 base := fmt.Sprintf("%s/%s/%s.git/info/lfs/objects",
132 strings.TrimSuffix(s.cfg.Server.SiteURL, "/"), repo.OwnerName, repo.Name)
133 authHeader := map[string]string{"Authorization": "Bearer " + transferToken}
134
135 blobs := s.lfsStore()
136 out := struct {
137 Transfer string `json:"transfer"`
138 Objects []lfsObject `json:"objects"`
139 }{Transfer: "basic"}
140 for _, o := range req.Objects {
141 obj := lfsObject{OID: o.OID, Size: o.Size, Authenticated: true}
142 switch {
143 case !lfs.OIDPat.MatchString(o.OID) || o.Size < 0:
144 obj.Error = &struct {
145 Code int `json:"code"`
146 Message string `json:"message"`
147 }{422, "malformed object"}
148 case req.Operation == "download":
149 if size, ok := blobs.Exists(o.OID); ok {
150 obj.Size = size
151 obj.Actions = map[string]lfsAction{"download": {
152 Href: base + "/" + o.OID, Header: authHeader, ExpiresIn: int(lfs.TokenTTL.Seconds()),
153 }}
154 } else {
155 obj.Error = &struct {
156 Code int `json:"code"`
157 Message string `json:"message"`
158 }{404, "object not found"}
159 }
160 default: // upload
161 if o.Size > s.lfsMaxObject() {
162 obj.Error = &struct {
163 Code int `json:"code"`
164 Message string `json:"message"`
165 }{422, fmt.Sprintf("object exceeds the %d byte limit", s.lfsMaxObject())}
166 } else if _, ok := blobs.Exists(o.OID); !ok {
167 // Present objects get no actions: the client skips them.
168 obj.Actions = map[string]lfsAction{"upload": {
169 Href: base + "/" + o.OID, Header: authHeader, ExpiresIn: int(lfs.TokenTTL.Seconds()),
170 }}
171 }
172 }
173 out.Objects = append(out.Objects, obj)
174 }
175 w.Header().Set("Content-Type", lfsMediaType)
176 json.NewEncoder(w).Encode(out)
177}
178
179// lfsDownload answers GET /{owner}/{repo}/info/lfs/objects/{oid}.
180func (s *Server) lfsDownload(w http.ResponseWriter, r *http.Request) {
181 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
182 if err != nil || s.lfsAuth(r, repo) == "" {
183 lfsError(w, http.StatusNotFound, "not found")
184 return
185 }
186 rc, size, err := s.lfsStore().Get(r.PathValue("oid"))
187 if err != nil {
188 lfsError(w, http.StatusNotFound, "object not found")
189 return
190 }
191 defer rc.Close()
192 w.Header().Set("Content-Type", "application/octet-stream")
193 w.Header().Set("Content-Length", fmt.Sprint(size))
194 w.Header().Set("X-Content-Type-Options", "nosniff")
195 io.Copy(w, rc)
196}
197
198// lfsUpload answers PUT /{owner}/{repo}/info/lfs/objects/{oid}.
199func (s *Server) lfsUpload(w http.ResponseWriter, r *http.Request) {
200 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
201 if err != nil || s.lfsAuth(r, repo) != "upload" {
202 lfsError(w, http.StatusNotFound, "not found")
203 return
204 }
205 oid := r.PathValue("oid")
206 if r.ContentLength < 0 || r.ContentLength > s.lfsMaxObject() {
207 lfsError(w, http.StatusRequestEntityTooLarge, "object too large or length unknown")
208 return
209 }
210 if _, ok := s.lfsStore().Exists(oid); ok {
211 w.WriteHeader(http.StatusOK) // already have it; idempotent
212 return
213 }
214 if err := s.lfsStore().Put(oid, r.Body, r.ContentLength); err != nil {
215 lfsError(w, http.StatusUnprocessableEntity, err.Error())
216 return
217 }
218 w.WriteHeader(http.StatusOK)
219}