git_lfs.go

  1package web
  2
  3import (
  4	"encoding/json"
  5	"errors"
  6	"fmt"
  7	"io"
  8	"io/fs"
  9	"net/http"
 10	"net/url"
 11	"path"
 12	"path/filepath"
 13	"strconv"
 14	"strings"
 15
 16	"github.com/charmbracelet/log"
 17	"github.com/charmbracelet/soft-serve/server/access"
 18	"github.com/charmbracelet/soft-serve/server/backend"
 19	"github.com/charmbracelet/soft-serve/server/config"
 20	"github.com/charmbracelet/soft-serve/server/db"
 21	"github.com/charmbracelet/soft-serve/server/db/models"
 22	"github.com/charmbracelet/soft-serve/server/git"
 23	"github.com/charmbracelet/soft-serve/server/lfs"
 24	"github.com/charmbracelet/soft-serve/server/proto"
 25	"github.com/charmbracelet/soft-serve/server/storage"
 26	"github.com/charmbracelet/soft-serve/server/store"
 27	"goji.io/pat"
 28)
 29
 30// Place holder service to handle Git LFS requests.
 31const gitLfsService git.Service = "git-lfs-service"
 32
 33// serviceLfsBatch handles a Git LFS batch requests.
 34// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md
 35// TODO: support refname
 36// POST: /<repo>.git/info/lfs/objects/batch
 37func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
 38	ctx := r.Context()
 39	logger := log.FromContext(ctx).WithPrefix("http.lfs")
 40
 41	if !isLfs(r) {
 42		logger.Errorf("invalid content type: %s", r.Header.Get("Content-Type"))
 43		renderNotAcceptable(w)
 44		return
 45	}
 46
 47	var batchRequest lfs.BatchRequest
 48	defer r.Body.Close() // nolint: errcheck
 49	if err := json.NewDecoder(r.Body).Decode(&batchRequest); err != nil {
 50		logger.Errorf("error decoding json: %s", err)
 51		renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
 52			Message: "validation error in request: " + err.Error(),
 53		})
 54		return
 55	}
 56
 57	// We only accept basic transfers for now
 58	// Default to basic if no transfer is specified
 59	if len(batchRequest.Transfers) > 0 {
 60		var isBasic bool
 61		for _, t := range batchRequest.Transfers {
 62			if t == lfs.TransferBasic {
 63				isBasic = true
 64				break
 65			}
 66		}
 67
 68		if !isBasic {
 69			renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
 70				Message: "unsupported transfer",
 71			})
 72			return
 73		}
 74	}
 75
 76	if len(batchRequest.Objects) == 0 {
 77		renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
 78			Message: "no objects found",
 79		})
 80		return
 81	}
 82
 83	name := pat.Param(r, "repo")
 84	repo := proto.RepositoryFromContext(ctx)
 85	if repo == nil {
 86		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
 87			Message: "repository not found",
 88		})
 89		return
 90	}
 91
 92	cfg := config.FromContext(ctx)
 93	dbx := db.FromContext(ctx)
 94	datastore := store.FromContext(ctx)
 95	// TODO: support S3 storage
 96	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
 97
 98	baseHref := fmt.Sprintf("%s/%s/info/lfs/objects/basic", cfg.HTTP.PublicURL, name+".git")
 99
100	var batchResponse lfs.BatchResponse
101	batchResponse.Transfer = lfs.TransferBasic
102	batchResponse.HashAlgo = lfs.HashAlgorithmSHA256
103
104	objects := make([]*lfs.ObjectResponse, 0, len(batchRequest.Objects))
105	// XXX: We don't support objects TTL for now, probably implement that with
106	// S3 using object "expires_at" & "expires_in"
107	switch batchRequest.Operation {
108	case lfs.OperationDownload:
109		for _, o := range batchRequest.Objects {
110			exist, err := strg.Exists(path.Join("objects", o.RelativePath()))
111			if err != nil && !errors.Is(err, fs.ErrNotExist) {
112				logger.Error("error getting object stat", "oid", o.Oid, "repo", name, "err", err)
113				renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
114					Message: "internal server error",
115				})
116				return
117			}
118
119			obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), o.Oid)
120			if err != nil && !errors.Is(err, db.ErrRecordNotFound) {
121				logger.Error("error getting object from database", "oid", o.Oid, "repo", name, "err", err)
122				renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
123					Message: "internal server error",
124				})
125				return
126			}
127
128			if !exist {
129				objects = append(objects, &lfs.ObjectResponse{
130					Pointer: o,
131					Error: &lfs.ObjectError{
132						Code:    http.StatusNotFound,
133						Message: "object not found",
134					},
135				})
136			} else if obj.Size != o.Size {
137				objects = append(objects, &lfs.ObjectResponse{
138					Pointer: o,
139					Error: &lfs.ObjectError{
140						Code:    http.StatusUnprocessableEntity,
141						Message: "size mismatch",
142					},
143				})
144			} else if o.IsValid() {
145				download := &lfs.Link{
146					Href: fmt.Sprintf("%s/%s", baseHref, o.Oid),
147				}
148				if auth := r.Header.Get("Authorization"); auth != "" {
149					download.Header = map[string]string{
150						"Authorization": auth,
151					}
152				}
153
154				objects = append(objects, &lfs.ObjectResponse{
155					Pointer: o,
156					Actions: map[string]*lfs.Link{
157						lfs.ActionDownload: download,
158					},
159				})
160
161				// If the object doesn't exist in the database, create it
162				if exist && obj.ID == 0 {
163					if err := datastore.CreateLFSObject(ctx, dbx, repo.ID(), o.Oid, o.Size); err != nil {
164						logger.Error("error creating object in datastore", "oid", o.Oid, "repo", name, "err", err)
165						renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
166							Message: "internal server error",
167						})
168						return
169					}
170				}
171			} else {
172				logger.Error("invalid object", "oid", o.Oid, "repo", name)
173				objects = append(objects, &lfs.ObjectResponse{
174					Pointer: o,
175					Error: &lfs.ObjectError{
176						Code:    http.StatusUnprocessableEntity,
177						Message: "invalid object",
178					},
179				})
180			}
181		}
182	case lfs.OperationUpload:
183		// Check authorization
184		accessLevel := access.FromContext(ctx)
185		if accessLevel < access.ReadWriteAccess {
186			askCredentials(w, r)
187			renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
188				Message: "credentials needed",
189			})
190			return
191		}
192
193		// Object upload logic happens in the "basic" API route
194		for _, o := range batchRequest.Objects {
195			if !o.IsValid() {
196				objects = append(objects, &lfs.ObjectResponse{
197					Pointer: o,
198					Error: &lfs.ObjectError{
199						Code:    http.StatusUnprocessableEntity,
200						Message: "invalid object",
201					},
202				})
203			} else {
204				upload := &lfs.Link{
205					Href: fmt.Sprintf("%s/%s", baseHref, o.Oid),
206					Header: map[string]string{
207						// NOTE: git-lfs v2.5.0 sets the Content-Type based on the uploaded file.
208						// This ensures that the client always uses the designated value for the header.
209						"Content-Type": "application/octet-stream",
210					},
211				}
212				verify := &lfs.Link{
213					Href: fmt.Sprintf("%s/verify", baseHref),
214				}
215				if auth := r.Header.Get("Authorization"); auth != "" {
216					upload.Header["Authorization"] = auth
217					verify.Header = map[string]string{
218						"Authorization": auth,
219					}
220				}
221
222				objects = append(objects, &lfs.ObjectResponse{
223					Pointer: o,
224					Actions: map[string]*lfs.Link{
225						lfs.ActionUpload: upload,
226						// Verify uploaded objects
227						// https://github.com/git-lfs/git-lfs/blob/main/docs/api/basic-transfers.md#verification
228						lfs.ActionVerify: verify,
229					},
230				})
231			}
232		}
233	default:
234		renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
235			Message: "unsupported operation",
236		})
237		return
238	}
239
240	batchResponse.Objects = objects
241	renderJSON(w, http.StatusOK, batchResponse)
242}
243
244// serviceLfsBasic implements Git LFS basic transfer API
245// https://github.com/git-lfs/git-lfs/blob/main/docs/api/basic-transfers.md
246func serviceLfsBasic(w http.ResponseWriter, r *http.Request) {
247	switch r.Method {
248	case http.MethodGet:
249		serviceLfsBasicDownload(w, r)
250	case http.MethodPut:
251		serviceLfsBasicUpload(w, r)
252	}
253}
254
255// GET: /<repo>.git/info/lfs/objects/basic/<oid>
256func serviceLfsBasicDownload(w http.ResponseWriter, r *http.Request) {
257	ctx := r.Context()
258	oid := pat.Param(r, "oid")
259	repo := proto.RepositoryFromContext(ctx)
260	cfg := config.FromContext(ctx)
261	logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
262	datastore := store.FromContext(ctx)
263	dbx := db.FromContext(ctx)
264	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
265
266	obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), oid)
267	if err != nil && !errors.Is(err, db.ErrRecordNotFound) {
268		logger.Error("error getting object from database", "oid", oid, "repo", repo.Name(), "err", err)
269		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
270			Message: "internal server error",
271		})
272		return
273	}
274
275	pointer := lfs.Pointer{Oid: oid}
276	f, err := strg.Open(path.Join("objects", pointer.RelativePath()))
277	if err != nil {
278		logger.Error("error opening object", "oid", oid, "err", err)
279		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
280			Message: "object not found",
281		})
282		return
283	}
284
285	w.Header().Set("Content-Type", "application/octet-stream")
286	w.Header().Set("Content-Length", strconv.FormatInt(obj.Size, 10))
287	defer f.Close() // nolint: errcheck
288	if _, err := io.Copy(w, f); err != nil {
289		logger.Error("error copying object to response", "oid", oid, "err", err)
290		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
291			Message: "internal server error",
292		})
293		return
294	}
295}
296
297// PUT: /<repo>.git/info/lfs/objects/basic/<oid>
298func serviceLfsBasicUpload(w http.ResponseWriter, r *http.Request) {
299	if !isBinary(r) {
300		renderJSON(w, http.StatusUnsupportedMediaType, lfs.ErrorResponse{
301			Message: "invalid content type",
302		})
303		return
304	}
305
306	ctx := r.Context()
307	oid := pat.Param(r, "oid")
308	cfg := config.FromContext(ctx)
309	be := backend.FromContext(ctx)
310	dbx := db.FromContext(ctx)
311	datastore := store.FromContext(ctx)
312	logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
313	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
314	name := pat.Param(r, "repo")
315
316	defer r.Body.Close() // nolint: errcheck
317	repo, err := be.Repository(ctx, name)
318	if err != nil {
319		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
320			Message: "repository not found",
321		})
322		return
323	}
324
325	// NOTE: Git LFS client will retry uploading the same object if there was a
326	// partial error, so we need to skip existing objects.
327	if _, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), oid); err == nil {
328		// Object exists, skip request
329		io.Copy(io.Discard, r.Body) // nolint: errcheck
330		renderStatus(http.StatusOK)(w, nil)
331		return
332	} else if !errors.Is(err, db.ErrRecordNotFound) {
333		logger.Error("error getting object", "oid", oid, "err", err)
334		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
335			Message: "internal server error",
336		})
337		return
338	}
339
340	pointer := lfs.Pointer{Oid: oid}
341	if _, err := strg.Put(path.Join("objects", pointer.RelativePath()), r.Body); err != nil {
342		logger.Error("error writing object", "oid", oid, "err", err)
343		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
344			Message: "internal server error",
345		})
346		return
347	}
348
349	size, err := strconv.ParseInt(r.Header.Get("Content-Length"), 10, 64)
350	if err != nil {
351		logger.Error("error parsing content length", "err", err)
352		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
353			Message: "invalid content length",
354		})
355		return
356	}
357
358	if err := datastore.CreateLFSObject(ctx, dbx, repo.ID(), oid, size); err != nil {
359		logger.Error("error creating object", "oid", oid, "err", err)
360		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
361			Message: "internal server error",
362		})
363		return
364	}
365
366	renderStatus(http.StatusOK)(w, nil)
367}
368
369// POST: /<repo>.git/info/lfs/objects/basic/verify
370func serviceLfsBasicVerify(w http.ResponseWriter, r *http.Request) {
371	if !isLfs(r) {
372		renderNotAcceptable(w)
373		return
374	}
375
376	var pointer lfs.Pointer
377	ctx := r.Context()
378	logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
379	repo := proto.RepositoryFromContext(ctx)
380	if repo == nil {
381		logger.Error("error getting repository from context")
382		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
383			Message: "repository not found",
384		})
385		return
386	}
387
388	defer r.Body.Close() // nolint: errcheck
389	if err := json.NewDecoder(r.Body).Decode(&pointer); err != nil {
390		logger.Error("error decoding json", "err", err)
391		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
392			Message: "invalid request: " + err.Error(),
393		})
394		return
395	}
396
397	cfg := config.FromContext(ctx)
398	dbx := db.FromContext(ctx)
399	datastore := store.FromContext(ctx)
400	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
401	if stat, err := strg.Stat(path.Join("objects", pointer.RelativePath())); err == nil {
402		// Verify object is in the database.
403		obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), pointer.Oid)
404		if err != nil {
405			if errors.Is(err, db.ErrRecordNotFound) {
406				logger.Error("object not found", "oid", pointer.Oid)
407				renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
408					Message: "object not found",
409				})
410				return
411			}
412			logger.Error("error getting object", "oid", pointer.Oid, "err", err)
413			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
414				Message: "internal server error",
415			})
416			return
417		}
418
419		if obj.Size != pointer.Size {
420			renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
421				Message: "object size mismatch",
422			})
423			return
424		}
425
426		if pointer.IsValid() && stat.Size() == pointer.Size {
427			renderStatus(http.StatusOK)(w, nil)
428			return
429		}
430	} else if errors.Is(err, fs.ErrNotExist) {
431		logger.Error("file not found", "oid", pointer.Oid)
432		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
433			Message: "object not found",
434		})
435		return
436	} else {
437		logger.Error("error getting object", "oid", pointer.Oid, "err", err)
438		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
439			Message: "internal server error",
440		})
441		return
442	}
443}
444
445func serviceLfsLocks(w http.ResponseWriter, r *http.Request) {
446	switch r.Method {
447	case http.MethodGet:
448		serviceLfsLocksGet(w, r)
449	case http.MethodPost:
450		serviceLfsLocksCreate(w, r)
451	default:
452		renderMethodNotAllowed(w, r)
453	}
454}
455
456// POST: /<repo>.git/info/lfs/objects/locks
457func serviceLfsLocksCreate(w http.ResponseWriter, r *http.Request) {
458	if !isLfs(r) {
459		renderNotAcceptable(w)
460		return
461	}
462
463	ctx := r.Context()
464	logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
465
466	var req lfs.LockCreateRequest
467	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
468		logger.Error("error decoding json", "err", err)
469		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
470			Message: "invalid request: " + err.Error(),
471		})
472		return
473	}
474
475	repo := proto.RepositoryFromContext(ctx)
476	if repo == nil {
477		logger.Error("error getting repository from context")
478		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
479			Message: "repository not found",
480		})
481		return
482	}
483
484	user := proto.UserFromContext(ctx)
485	if user == nil {
486		logger.Error("error getting user from context")
487		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
488			Message: "user not found",
489		})
490		return
491	}
492
493	dbx := db.FromContext(ctx)
494	datastore := store.FromContext(ctx)
495	if err := datastore.CreateLFSLockForUser(ctx, dbx, repo.ID(), user.ID(), req.Path, req.Ref.Name); err != nil {
496		err = db.WrapError(err)
497		if errors.Is(err, db.ErrDuplicateKey) {
498			errResp := lfs.LockResponse{
499				ErrorResponse: lfs.ErrorResponse{
500					Message: "lock already exists",
501				},
502			}
503			lock, err := datastore.GetLFSLockForUserPath(ctx, dbx, repo.ID(), user.ID(), req.Path)
504			if err == nil {
505				errResp.Lock = lfs.Lock{
506					ID:       strconv.FormatInt(lock.ID, 10),
507					Path:     lock.Path,
508					LockedAt: lock.CreatedAt,
509				}
510				lockOwner := lfs.Owner{
511					Name: user.Username(),
512				}
513				if lock.UserID != user.ID() {
514					owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
515					if err != nil {
516						logger.Error("error getting lock owner", "err", err)
517						renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
518							Message: "internal server error",
519						})
520						return
521					}
522					lockOwner.Name = owner.Username
523				}
524				errResp.Lock.Owner = lockOwner
525			}
526			renderJSON(w, http.StatusConflict, errResp)
527			return
528		}
529		logger.Error("error creating lock", "err", err)
530		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
531			Message: "internal server error",
532		})
533		return
534	}
535
536	lock, err := datastore.GetLFSLockForUserPath(ctx, dbx, repo.ID(), user.ID(), req.Path)
537	if err != nil {
538		logger.Error("error getting lock", "err", err)
539		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
540			Message: "internal server error",
541		})
542		return
543	}
544
545	renderJSON(w, http.StatusCreated, lfs.LockResponse{
546		Lock: lfs.Lock{
547			ID:       strconv.FormatInt(lock.ID, 10),
548			Path:     lock.Path,
549			LockedAt: lock.CreatedAt,
550			Owner: lfs.Owner{
551				Name: user.Username(),
552			},
553		},
554	})
555}
556
557// GET: /<repo>.git/info/lfs/objects/locks
558func serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) {
559	accept := r.Header.Get("Accept")
560	if !strings.HasPrefix(accept, lfs.MediaType) {
561		renderNotAcceptable(w)
562		return
563	}
564
565	parseLocksQuery := func(values url.Values) (path string, id int64, cursor int, limit int, refspec string) {
566		path = values.Get("path")
567		idStr := values.Get("id")
568		if idStr != "" {
569			id, _ = strconv.ParseInt(idStr, 10, 64)
570		}
571		cursorStr := values.Get("cursor")
572		if cursorStr != "" {
573			cursor, _ = strconv.Atoi(cursorStr)
574		}
575		limitStr := values.Get("limit")
576		if limitStr != "" {
577			limit, _ = strconv.Atoi(limitStr)
578		}
579		refspec = values.Get("refspec")
580		return
581	}
582
583	ctx := r.Context()
584	// TODO: respect refspec
585	path, id, cursor, limit, _ := parseLocksQuery(r.URL.Query())
586	if limit > 100 {
587		limit = 100
588	} else if limit <= 0 {
589		limit = lfs.DefaultLocksLimit
590	}
591
592	// cursor is the page number
593	if cursor <= 0 {
594		cursor = 1
595	}
596
597	logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
598	dbx := db.FromContext(ctx)
599	datastore := store.FromContext(ctx)
600	repo := proto.RepositoryFromContext(ctx)
601	if repo == nil {
602		logger.Error("error getting repository from context")
603		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
604			Message: "repository not found",
605		})
606		return
607	}
608
609	if id > 0 {
610		lock, err := datastore.GetLFSLockByID(ctx, dbx, id)
611		if err != nil {
612			if errors.Is(err, db.ErrRecordNotFound) {
613				renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
614					Message: "lock not found",
615				})
616				return
617			}
618			logger.Error("error getting lock", "err", err)
619			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
620				Message: "internal server error",
621			})
622			return
623		}
624
625		owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
626		if err != nil {
627			logger.Error("error getting lock owner", "err", err)
628			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
629				Message: "internal server error",
630			})
631			return
632		}
633
634		renderJSON(w, http.StatusOK, lfs.LockListResponse{
635			Locks: []lfs.Lock{
636				{
637					ID:       strconv.FormatInt(lock.ID, 10),
638					Path:     lock.Path,
639					LockedAt: lock.CreatedAt,
640					Owner: lfs.Owner{
641						Name: owner.Username,
642					},
643				},
644			},
645		})
646		return
647	} else if path != "" {
648		lock, err := datastore.GetLFSLockForPath(ctx, dbx, repo.ID(), path)
649		if err != nil {
650			if errors.Is(err, db.ErrRecordNotFound) {
651				renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
652					Message: "lock not found",
653				})
654				return
655			}
656			logger.Error("error getting lock", "err", err)
657			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
658				Message: "internal server error",
659			})
660			return
661		}
662
663		owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
664		if err != nil {
665			logger.Error("error getting lock owner", "err", err)
666			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
667				Message: "internal server error",
668			})
669			return
670		}
671
672		renderJSON(w, http.StatusOK, lfs.LockListResponse{
673			Locks: []lfs.Lock{
674				{
675					ID:       strconv.FormatInt(lock.ID, 10),
676					Path:     lock.Path,
677					LockedAt: lock.CreatedAt,
678					Owner: lfs.Owner{
679						Name: owner.Username,
680					},
681				},
682			},
683		})
684		return
685	} else {
686		locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)
687		if err != nil {
688			logger.Error("error getting locks", "err", err)
689			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
690				Message: "internal server error",
691			})
692			return
693		}
694
695		lockList := make([]lfs.Lock, len(locks))
696		users := map[int64]models.User{}
697		for i, lock := range locks {
698			owner, ok := users[lock.UserID]
699			if !ok {
700				owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID)
701				if err != nil {
702					logger.Error("error getting lock owner", "err", err)
703					renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
704						Message: "internal server error",
705					})
706					return
707				}
708				users[lock.UserID] = owner
709			}
710
711			lockList[i] = lfs.Lock{
712				ID:       strconv.FormatInt(lock.ID, 10),
713				Path:     lock.Path,
714				LockedAt: lock.CreatedAt,
715				Owner: lfs.Owner{
716					Name: owner.Username,
717				},
718			}
719		}
720
721		resp := lfs.LockListResponse{
722			Locks: lockList,
723		}
724		if len(locks) == limit {
725			resp.NextCursor = strconv.Itoa(cursor + 1)
726		}
727
728		renderJSON(w, http.StatusOK, resp)
729		return
730	}
731}
732
733// POST: /<repo>.git/info/lfs/objects/locks/verify
734func serviceLfsLocksVerify(w http.ResponseWriter, r *http.Request) {
735	if !isLfs(r) {
736		renderNotAcceptable(w)
737		return
738	}
739
740	ctx := r.Context()
741	logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
742	repo := proto.RepositoryFromContext(ctx)
743	if repo == nil {
744		logger.Error("error getting repository from context")
745		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
746			Message: "repository not found",
747		})
748		return
749	}
750
751	var req lfs.LockVerifyRequest
752	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
753		logger.Error("error decoding request", "err", err)
754		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
755			Message: "invalid request: " + err.Error(),
756		})
757		return
758	}
759
760	// TODO: refspec
761	cursor, _ := strconv.Atoi(req.Cursor)
762	if cursor <= 0 {
763		cursor = 1
764	}
765
766	limit := req.Limit
767	if limit > 100 {
768		limit = 100
769	} else if limit <= 0 {
770		limit = lfs.DefaultLocksLimit
771	}
772
773	dbx := db.FromContext(ctx)
774	datastore := store.FromContext(ctx)
775	user := proto.UserFromContext(ctx)
776	ours := make([]lfs.Lock, 0)
777	theirs := make([]lfs.Lock, 0)
778
779	var resp lfs.LockVerifyResponse
780	locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)
781	if err != nil {
782		logger.Error("error getting locks", "err", err)
783		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
784			Message: "internal server error",
785		})
786		return
787	}
788
789	users := map[int64]models.User{}
790	for _, lock := range locks {
791		owner, ok := users[lock.UserID]
792		if !ok {
793			owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID)
794			if err != nil {
795				logger.Error("error getting lock owner", "err", err)
796				renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
797					Message: "internal server error",
798				})
799				return
800			}
801			users[lock.UserID] = owner
802		}
803
804		l := lfs.Lock{
805			ID:       strconv.FormatInt(lock.ID, 10),
806			Path:     lock.Path,
807			LockedAt: lock.CreatedAt,
808			Owner: lfs.Owner{
809				Name: owner.Username,
810			},
811		}
812
813		if user != nil && user.ID() == lock.UserID {
814			ours = append(ours, l)
815		} else {
816			theirs = append(theirs, l)
817		}
818	}
819
820	resp.Ours = ours
821	resp.Theirs = theirs
822
823	if len(locks) == limit {
824		resp.NextCursor = strconv.Itoa(cursor + 1)
825	}
826
827	renderJSON(w, http.StatusOK, resp)
828}
829
830// POST: /<repo>.git/info/lfs/objects/locks/:lockID/unlock
831func serviceLfsLocksDelete(w http.ResponseWriter, r *http.Request) {
832	if !isLfs(r) {
833		renderNotAcceptable(w)
834		return
835	}
836
837	ctx := r.Context()
838	logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
839	lockIDStr := pat.Param(r, "lock_id")
840	if lockIDStr == "" {
841		logger.Error("error getting lock id")
842		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
843			Message: "invalid request",
844		})
845		return
846	}
847
848	lockID, err := strconv.ParseInt(lockIDStr, 10, 64)
849	if err != nil {
850		logger.Error("error parsing lock id", "err", err)
851		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
852			Message: "invalid request",
853		})
854		return
855	}
856
857	var req lfs.LockDeleteRequest
858	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
859		logger.Error("error decoding request", "err", err)
860		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
861			Message: "invalid request: " + err.Error(),
862		})
863		return
864	}
865
866	dbx := db.FromContext(ctx)
867	datastore := store.FromContext(ctx)
868	repo := proto.RepositoryFromContext(ctx)
869	if repo == nil {
870		logger.Error("error getting repository from context")
871		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
872			Message: "repository not found",
873		})
874		return
875	}
876
877	// The lock being deleted
878	lock, err := datastore.GetLFSLockByID(ctx, dbx, lockID)
879	if err != nil {
880		logger.Error("error getting lock", "err", err)
881		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
882			Message: "lock not found",
883		})
884		return
885	}
886
887	owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
888	if err != nil {
889		logger.Error("error getting lock owner", "err", err)
890		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
891			Message: "internal server error",
892		})
893		return
894	}
895
896	// Delete another user's lock
897	l := lfs.Lock{
898		ID:       strconv.FormatInt(lock.ID, 10),
899		Path:     lock.Path,
900		LockedAt: lock.CreatedAt,
901		Owner: lfs.Owner{
902			Name: owner.Username,
903		},
904	}
905	if req.Force {
906		if err := datastore.DeleteLFSLock(ctx, dbx, repo.ID(), lockID); err != nil {
907			logger.Error("error deleting lock", "err", err)
908			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
909				Message: "internal server error",
910			})
911			return
912		}
913
914		renderJSON(w, http.StatusOK, l)
915		return
916	}
917
918	// Delete our own lock
919	user := proto.UserFromContext(ctx)
920	if user == nil {
921		logger.Error("error getting user from context")
922		renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
923			Message: "unauthorized",
924		})
925		return
926	}
927
928	if owner.ID != user.ID() {
929		logger.Error("error deleting another user's lock")
930		renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
931			Message: "lock belongs to another user",
932		})
933		return
934	}
935
936	if err := datastore.DeleteLFSLock(ctx, dbx, repo.ID(), lockID); err != nil {
937		logger.Error("error deleting lock", "err", err)
938		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
939			Message: "internal server error",
940		})
941		return
942	}
943
944	renderJSON(w, http.StatusOK, lfs.LockResponse{Lock: l})
945}
946
947// renderJSON renders a JSON response with the given status code and value. It
948// also sets the Content-Type header to the JSON LFS media type (application/vnd.git-lfs+json).
949func renderJSON(w http.ResponseWriter, statusCode int, v interface{}) {
950	hdrLfs(w)
951	w.WriteHeader(statusCode)
952	if err := json.NewEncoder(w).Encode(v); err != nil {
953		log.Error("error encoding json", "err", err)
954	}
955}
956
957func renderNotAcceptable(w http.ResponseWriter) {
958	renderStatus(http.StatusNotAcceptable)(w, nil)
959}
960
961func isLfs(r *http.Request) bool {
962	contentType := r.Header.Get("Content-Type")
963	accept := r.Header.Get("Accept")
964	return strings.HasPrefix(contentType, lfs.MediaType) && strings.HasPrefix(accept, lfs.MediaType)
965}
966
967func isBinary(r *http.Request) bool {
968	contentType := r.Header.Get("Content-Type")
969	return strings.HasPrefix(contentType, "application/octet-stream")
970}
971
972func hdrLfs(w http.ResponseWriter) {
973	w.Header().Set("Content-Type", lfs.MediaType)
974	w.Header().Set("Accept", lfs.MediaType)
975}