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