1package web
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "io"
8 "io/fs"
9 "net/http"
10 "path"
11 "path/filepath"
12 "strconv"
13
14 "github.com/charmbracelet/log"
15 "github.com/charmbracelet/soft-serve/server/backend"
16 "github.com/charmbracelet/soft-serve/server/config"
17 "github.com/charmbracelet/soft-serve/server/db"
18 "github.com/charmbracelet/soft-serve/server/lfs"
19 "github.com/charmbracelet/soft-serve/server/storage"
20 "github.com/charmbracelet/soft-serve/server/store"
21 "goji.io/pat"
22)
23
24// serviceLfsBatch handles a Git LFS batch requests.
25// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md
26// TODO: support refname & authentication
27// POST: /<repo>.git/info/lfs/objects/batch
28func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
29 if r.Header.Get("Content-Type") != lfs.MediaType {
30 renderNotAcceptable(w)
31 return
32 }
33
34 var batchRequest lfs.BatchRequest
35 ctx := r.Context()
36 logger := log.FromContext(ctx).WithPrefix("http.lfs")
37
38 defer r.Body.Close() // nolint: errcheck
39 if err := json.NewDecoder(r.Body).Decode(&batchRequest); err != nil {
40 logger.Errorf("error decoding json: %s", err)
41 return
42 }
43
44 // We only accept basic transfers for now
45 // Default to basic if no transfer is specified
46 if len(batchRequest.Transfers) > 0 {
47 var isBasic bool
48 for _, t := range batchRequest.Transfers {
49 if t == lfs.TransferBasic {
50 isBasic = true
51 break
52 }
53 }
54
55 if !isBasic {
56 renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
57 Message: "unsupported transfer",
58 })
59 return
60 }
61 }
62
63 be := backend.FromContext(ctx)
64 name := pat.Param(r, "repo")
65 repo, err := be.Repository(ctx, name)
66 if err != nil {
67 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
68 Message: "repository not found",
69 })
70 return
71 }
72
73 cfg := config.FromContext(ctx)
74 dbx := db.FromContext(ctx)
75 datastore := store.FromContext(ctx)
76 // TODO: support S3 storage
77 strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
78
79 baseHref := fmt.Sprintf("%s/%s/info/lfs/objects/basic", cfg.HTTP.PublicURL, name+".git")
80
81 var batchResponse lfs.BatchResponse
82 batchResponse.Transfer = lfs.TransferBasic
83 batchResponse.HashAlgo = lfs.HashAlgorithmSHA256
84
85 objects := make([]*lfs.ObjectResponse, 0, len(batchRequest.Objects))
86 // XXX: We don't support objects TTL for now, probably implement that with
87 // S3 using object "expires_at" & "expires_in"
88 switch batchRequest.Operation {
89 case lfs.OperationDownload:
90 for _, o := range batchRequest.Objects {
91 stat, err := strg.Stat(path.Join("objects", o.RelativePath()))
92 if err != nil && !errors.Is(err, fs.ErrNotExist) {
93 logger.Error("error getting object stat", "oid", o.Oid, "repo", name, "err", err)
94 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
95 Message: "internal server error",
96 })
97 return
98 }
99
100 obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), o.Oid)
101 if err != nil && !errors.Is(err, db.ErrRecordNotFound) {
102 logger.Error("error getting object from database", "oid", o.Oid, "repo", name, "err", err)
103 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
104 Message: "internal server error",
105 })
106 return
107 }
108
109 if stat == nil {
110 objects = append(objects, &lfs.ObjectResponse{
111 Pointer: o,
112 Error: &lfs.ObjectError{
113 Code: http.StatusNotFound,
114 Message: "object not found",
115 },
116 })
117 } else if stat.Size() != o.Size {
118 objects = append(objects, &lfs.ObjectResponse{
119 Pointer: o,
120 Error: &lfs.ObjectError{
121 Code: http.StatusUnprocessableEntity,
122 Message: "size mismatch",
123 },
124 })
125 } else if o.IsValid() {
126 objects = append(objects, &lfs.ObjectResponse{
127 Pointer: o,
128 Actions: map[string]*lfs.Link{
129 lfs.ActionDownload: {
130 Href: fmt.Sprintf("%s/%s", baseHref, o.Oid),
131 },
132 },
133 })
134
135 // If the object doesn't exist in the database, create it
136 if stat != nil && obj.ID == 0 {
137 if err := datastore.CreateLFSObject(ctx, dbx, repo.ID(), o.Oid, stat.Size()); err != nil {
138 logger.Error("error creating object in datastore", "oid", o.Oid, "repo", name, "err", err)
139 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
140 Message: "internal server error",
141 })
142 return
143 }
144 }
145 } else {
146 objects = append(objects, &lfs.ObjectResponse{
147 Pointer: o,
148 Error: &lfs.ObjectError{
149 Code: http.StatusUnprocessableEntity,
150 Message: "invalid object",
151 },
152 })
153 }
154 }
155 case lfs.OperationUpload:
156 // Object upload logic happens in the "basic" API route
157 for _, o := range batchRequest.Objects {
158 if !o.IsValid() {
159 objects = append(objects, &lfs.ObjectResponse{
160 Pointer: o,
161 Error: &lfs.ObjectError{
162 Code: http.StatusUnprocessableEntity,
163 Message: "invalid object",
164 },
165 })
166 } else {
167 objects = append(objects, &lfs.ObjectResponse{
168 Pointer: o,
169 Actions: map[string]*lfs.Link{
170 lfs.ActionUpload: {
171 Href: fmt.Sprintf("%s/%s", baseHref, o.Oid),
172 },
173 // Verify uploaded objects
174 // https://github.com/git-lfs/git-lfs/blob/main/docs/api/basic-transfers.md#verification
175 lfs.ActionVerify: {
176 Href: fmt.Sprintf("%s/verify", baseHref),
177 },
178 },
179 })
180 }
181 }
182 default:
183 renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
184 Message: "unsupported operation",
185 })
186 return
187 }
188
189 batchResponse.Objects = objects
190 renderJSON(w, http.StatusOK, batchResponse)
191}
192
193// serviceLfsBasic implements Git LFS basic transfer API
194// https://github.com/git-lfs/git-lfs/blob/main/docs/api/basic-transfers.md
195func serviceLfsBasic(w http.ResponseWriter, r *http.Request) {
196 switch r.Method {
197 case http.MethodGet:
198 serviceLfsBasicDownload(w, r)
199 case http.MethodPut:
200 serviceLfsBasicUpload(w, r)
201 }
202}
203
204// GET: /<repo>.git/info/lfs/objects/basic/<oid>
205func serviceLfsBasicDownload(w http.ResponseWriter, r *http.Request) {
206 ctx := r.Context()
207 oid := pat.Param(r, "oid")
208 cfg := config.FromContext(ctx)
209 logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
210 strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
211
212 obj, err := strg.Open(path.Join("objects", oid))
213 if err != nil {
214 logger.Error("error opening object", "oid", oid, "err", err)
215 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
216 Message: "object not found",
217 })
218 return
219 }
220
221 stat, err := obj.Stat()
222 if err != nil {
223 logger.Error("error getting object stat", "oid", oid, "err", err)
224 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
225 Message: "internal server error",
226 })
227 return
228 }
229
230 defer obj.Close() // nolint: errcheck
231 if _, err := io.Copy(w, obj); err != nil {
232 logger.Error("error copying object to response", "oid", oid, "err", err)
233 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
234 Message: "internal server error",
235 })
236 return
237 }
238
239 w.Header().Set("Content-Type", "application/octet-stream")
240 w.Header().Set("Content-Length", strconv.FormatInt(stat.Size(), 10))
241 renderStatus(http.StatusOK)(w, nil)
242}
243
244// PUT: /<repo>.git/info/lfs/objects/basic/<oid>
245func serviceLfsBasicUpload(w http.ResponseWriter, r *http.Request) {
246 if r.Header.Get("Content-Type") != "application/octet-stream" {
247 renderJSON(w, http.StatusUnsupportedMediaType, lfs.ErrorResponse{
248 Message: "invalid content type",
249 })
250 return
251 }
252
253 ctx := r.Context()
254 oid := pat.Param(r, "oid")
255 cfg := config.FromContext(ctx)
256 be := backend.FromContext(ctx)
257 dbx := db.FromContext(ctx)
258 datastore := store.FromContext(ctx)
259 logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
260 strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
261 name := pat.Param(r, "repo")
262
263 defer r.Body.Close() // nolint: errcheck
264 repo, err := be.Repository(ctx, name)
265 if err != nil {
266 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
267 Message: "repository not found",
268 })
269 return
270 }
271
272 // NOTE: Git LFS client will retry uploading the same object if there was a
273 // partial error, so we need to skip existing objects.
274 if _, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), oid); err == nil {
275 // Object exists, skip request
276 io.Copy(io.Discard, r.Body) // nolint: errcheck
277 renderStatus(http.StatusOK)(w, nil)
278 return
279 } else if !errors.Is(err, db.ErrRecordNotFound) {
280 logger.Error("error getting object", "oid", oid, "err", err)
281 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
282 Message: "internal server error",
283 })
284 return
285 }
286
287 if err := strg.Put(path.Join("objects", oid), r.Body); err != nil {
288 logger.Error("error writing object", "oid", oid, "err", err)
289 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
290 Message: "internal server error",
291 })
292 return
293 }
294
295 size, err := strconv.ParseInt(r.Header.Get("Content-Length"), 10, 64)
296 if err != nil {
297 logger.Error("error parsing content length", "err", err)
298 renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
299 Message: "invalid content length",
300 })
301 return
302 }
303
304 if err := datastore.CreateLFSObject(ctx, dbx, repo.ID(), oid, size); err != nil {
305 logger.Error("error creating object", "oid", oid, "err", err)
306 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
307 Message: "internal server error",
308 })
309 return
310 }
311
312 renderStatus(http.StatusOK)(w, nil)
313}
314
315// POST: /<repo>.git/info/lfs/objects/basic/verify
316func serviceLfsBasicVerify(w http.ResponseWriter, r *http.Request) {
317 var pointer lfs.Pointer
318 ctx := r.Context()
319 logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
320 be := backend.FromContext(ctx)
321 name := pat.Param(r, "repo")
322 repo, err := be.Repository(ctx, name)
323 if err != nil {
324 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
325 Message: "repository not found",
326 })
327 return
328 }
329
330 defer r.Body.Close() // nolint: errcheck
331 if err := json.NewDecoder(r.Body).Decode(&pointer); err != nil {
332 logger.Error("error decoding json", "err", err)
333 renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
334 Message: "invalid json",
335 })
336 return
337 }
338
339 cfg := config.FromContext(ctx)
340 dbx := db.FromContext(ctx)
341 datastore := store.FromContext(ctx)
342 strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
343 if stat, err := strg.Stat(path.Join("objects", pointer.Oid)); err == nil {
344 // Verify object is in the database.
345 if _, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), pointer.Oid); err != nil {
346 if errors.Is(err, db.ErrRecordNotFound) {
347 // Create missing object.
348 if err := datastore.CreateLFSObject(ctx, dbx, repo.ID(), pointer.Oid, stat.Size()); err != nil {
349 logger.Error("error creating object", "oid", pointer.Oid, "err", err)
350 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
351 Message: "internal server error",
352 })
353 return
354 }
355 } else {
356 logger.Error("error getting object", "oid", pointer.Oid, "err", err)
357 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
358 Message: "internal server error",
359 })
360 return
361 }
362 }
363
364 if pointer.IsValid() && stat.Size() == pointer.Size {
365 renderStatus(http.StatusOK)(w, nil)
366 return
367 }
368 } else if errors.Is(err, fs.ErrNotExist) {
369 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
370 Message: "object not found",
371 })
372 return
373 } else {
374 logger.Error("error getting object", "oid", pointer.Oid, "err", err)
375 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
376 Message: "internal server error",
377 })
378 return
379 }
380}
381
382// POST: /<repo>.git/info/lfs/objects/locks
383func serviceLfsLocksCreate(w http.ResponseWriter, r *http.Request) {
384 if r.Header.Get("Content-Type") != lfs.MediaType {
385 renderNotAcceptable(w)
386 return
387 }
388
389 panic("not implemented")
390}
391
392// renderJSON renders a JSON response with the given status code and value. It
393// also sets the Content-Type header to the JSON LFS media type (application/vnd.git-lfs+json).
394func renderJSON(w http.ResponseWriter, statusCode int, v interface{}) {
395 w.Header().Set("Content-Type", lfs.MediaType)
396 renderStatus(statusCode)(w, nil)
397 if err := json.NewEncoder(w).Encode(v); err != nil {
398 log.Error("error encoding json", "err", err)
399 }
400}
401
402func renderNotAcceptable(w http.ResponseWriter) {
403 renderStatus(http.StatusNotAcceptable)(w, nil)
404}
405
406func hdrLfs(w http.ResponseWriter) {
407 w.Header().Set("Content-Type", lfs.MediaType)
408 w.Header().Set("Accept", lfs.MediaType)
409}