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