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