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/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 {
 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
 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 {
 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 {
 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					handle, err := datastore.GetHandleByUserID(ctx, dbx, owner.ID)
 524					if err != nil {
 525						logger.Error("error getting lock owner handle", "err", err)
 526						renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
 527							Message: "internal server error",
 528						})
 529						return
 530					}
 531					lockOwner.Name = handle.Handle
 532				}
 533				errResp.Lock.Owner = lockOwner
 534			}
 535			renderJSON(w, http.StatusConflict, errResp)
 536			return
 537		}
 538		logger.Error("error creating lock", "err", err)
 539		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
 540			Message: "internal server error",
 541		})
 542		return
 543	}
 544
 545	lock, err := datastore.GetLFSLockForUserPath(ctx, dbx, repo.ID(), user.ID(), req.Path)
 546	if err != nil {
 547		logger.Error("error getting lock", "err", err)
 548		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
 549			Message: "internal server error",
 550		})
 551		return
 552	}
 553
 554	renderJSON(w, http.StatusCreated, lfs.LockResponse{
 555		Lock: lfs.Lock{
 556			ID:       strconv.FormatInt(lock.ID, 10),
 557			Path:     lock.Path,
 558			LockedAt: lock.CreatedAt,
 559			Owner: lfs.Owner{
 560				Name: user.Username(),
 561			},
 562		},
 563	})
 564}
 565
 566// GET: /<repo>.git/info/lfs/objects/locks
 567func serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) {
 568	accept := r.Header.Get("Accept")
 569	if !strings.HasPrefix(accept, lfs.MediaType) {
 570		renderNotAcceptable(w)
 571		return
 572	}
 573
 574	parseLocksQuery := func(values url.Values) (path string, id int64, cursor int, limit int, refspec string) {
 575		path = values.Get("path")
 576		idStr := values.Get("id")
 577		if idStr != "" {
 578			id, _ = strconv.ParseInt(idStr, 10, 64)
 579		}
 580		cursorStr := values.Get("cursor")
 581		if cursorStr != "" {
 582			cursor, _ = strconv.Atoi(cursorStr)
 583		}
 584		limitStr := values.Get("limit")
 585		if limitStr != "" {
 586			limit, _ = strconv.Atoi(limitStr)
 587		}
 588		refspec = values.Get("refspec")
 589		return
 590	}
 591
 592	ctx := r.Context()
 593	// TODO: respect refspec
 594	path, id, cursor, limit, _ := parseLocksQuery(r.URL.Query())
 595	if limit > 100 {
 596		limit = 100
 597	} else if limit <= 0 {
 598		limit = lfs.DefaultLocksLimit
 599	}
 600
 601	// cursor is the page number
 602	if cursor <= 0 {
 603		cursor = 1
 604	}
 605
 606	logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
 607	dbx := db.FromContext(ctx)
 608	datastore := store.FromContext(ctx)
 609	repo := proto.RepositoryFromContext(ctx)
 610	if repo == nil {
 611		logger.Error("error getting repository from context")
 612		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
 613			Message: "repository not found",
 614		})
 615		return
 616	}
 617
 618	if id > 0 {
 619		lock, err := datastore.GetLFSLockByID(ctx, dbx, id)
 620		if err != nil {
 621			if errors.Is(err, db.ErrRecordNotFound) {
 622				renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
 623					Message: "lock not found",
 624				})
 625				return
 626			}
 627			logger.Error("error getting lock", "err", err)
 628			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
 629				Message: "internal server error",
 630			})
 631			return
 632		}
 633
 634		owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
 635		if err != nil {
 636			logger.Error("error getting lock owner", "err", err)
 637			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
 638				Message: "internal server error",
 639			})
 640			return
 641		}
 642
 643		handle, err := datastore.GetHandleByUserID(ctx, dbx, owner.ID)
 644		if err != nil {
 645			logger.Error("error getting lock owner handle", "err", err)
 646			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
 647				Message: "internal server error",
 648			})
 649			return
 650		}
 651
 652		renderJSON(w, http.StatusOK, lfs.LockListResponse{
 653			Locks: []lfs.Lock{
 654				{
 655					ID:       strconv.FormatInt(lock.ID, 10),
 656					Path:     lock.Path,
 657					LockedAt: lock.CreatedAt,
 658					Owner: lfs.Owner{
 659						Name: handle.Handle,
 660					},
 661				},
 662			},
 663		})
 664		return
 665	} else if path != "" {
 666		lock, err := datastore.GetLFSLockForPath(ctx, dbx, repo.ID(), path)
 667		if err != nil {
 668			if errors.Is(err, db.ErrRecordNotFound) {
 669				renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
 670					Message: "lock not found",
 671				})
 672				return
 673			}
 674			logger.Error("error getting lock", "err", err)
 675			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
 676				Message: "internal server error",
 677			})
 678			return
 679		}
 680
 681		owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
 682		if err != nil {
 683			logger.Error("error getting lock owner", "err", err)
 684			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
 685				Message: "internal server error",
 686			})
 687			return
 688		}
 689
 690		handle, err := datastore.GetHandleByUserID(ctx, dbx, owner.ID)
 691		if err != nil {
 692			logger.Error("error getting lock owner handle", "err", err)
 693			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
 694				Message: "internal server error",
 695			})
 696			return
 697		}
 698
 699		renderJSON(w, http.StatusOK, lfs.LockListResponse{
 700			Locks: []lfs.Lock{
 701				{
 702					ID:       strconv.FormatInt(lock.ID, 10),
 703					Path:     lock.Path,
 704					LockedAt: lock.CreatedAt,
 705					Owner: lfs.Owner{
 706						Name: handle.Handle,
 707					},
 708				},
 709			},
 710		})
 711		return
 712	}
 713
 714	locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)
 715	if err != nil {
 716		logger.Error("error getting locks", "err", err)
 717		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
 718			Message: "internal server error",
 719		})
 720		return
 721	}
 722
 723	lockList := make([]lfs.Lock, len(locks))
 724	users := map[int64]userModel{}
 725	for i, lock := range locks {
 726		owner, ok := users[lock.UserID]
 727		if !ok {
 728			user, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
 729			if err != nil {
 730				logger.Error("error getting lock owner", "err", err)
 731				renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
 732					Message: "internal server error",
 733				})
 734				return
 735			}
 736			handle, err := datastore.GetHandleByUserID(ctx, dbx, user.ID)
 737			if err != nil {
 738				logger.Error("error getting lock owner handle", "err", err)
 739				renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
 740					Message: "internal server error",
 741				})
 742				return
 743			}
 744			users[lock.UserID] = userModel{User: user, Handle: handle}
 745		}
 746
 747		lockList[i] = lfs.Lock{
 748			ID:       strconv.FormatInt(lock.ID, 10),
 749			Path:     lock.Path,
 750			LockedAt: lock.CreatedAt,
 751			Owner: lfs.Owner{
 752				Name: owner.Handle.Handle,
 753			},
 754		}
 755	}
 756
 757	resp := lfs.LockListResponse{
 758		Locks: lockList,
 759	}
 760	if len(locks) == limit {
 761		resp.NextCursor = strconv.Itoa(cursor + 1)
 762	}
 763
 764	renderJSON(w, http.StatusOK, resp)
 765}
 766
 767type userModel struct {
 768	models.User
 769	models.Handle
 770}
 771
 772// POST: /<repo>.git/info/lfs/objects/locks/verify
 773func serviceLfsLocksVerify(w http.ResponseWriter, r *http.Request) {
 774	if !isLfs(r) {
 775		renderNotAcceptable(w)
 776		return
 777	}
 778
 779	ctx := r.Context()
 780	logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
 781	repo := proto.RepositoryFromContext(ctx)
 782	if repo == nil {
 783		logger.Error("error getting repository from context")
 784		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
 785			Message: "repository not found",
 786		})
 787		return
 788	}
 789
 790	var req lfs.LockVerifyRequest
 791	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 792		logger.Error("error decoding request", "err", err)
 793		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
 794			Message: "invalid request: " + err.Error(),
 795		})
 796		return
 797	}
 798
 799	// TODO: refspec
 800	cursor, _ := strconv.Atoi(req.Cursor)
 801	if cursor <= 0 {
 802		cursor = 1
 803	}
 804
 805	limit := req.Limit
 806	if limit > 100 {
 807		limit = 100
 808	} else if limit <= 0 {
 809		limit = lfs.DefaultLocksLimit
 810	}
 811
 812	dbx := db.FromContext(ctx)
 813	datastore := store.FromContext(ctx)
 814	user := proto.UserFromContext(ctx)
 815	ours := make([]lfs.Lock, 0)
 816	theirs := make([]lfs.Lock, 0)
 817
 818	var resp lfs.LockVerifyResponse
 819	locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)
 820	if err != nil {
 821		logger.Error("error getting locks", "err", err)
 822		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
 823			Message: "internal server error",
 824		})
 825		return
 826	}
 827
 828	users := map[int64]userModel{}
 829	for _, lock := range locks {
 830		owner, ok := users[lock.UserID]
 831		if !ok {
 832			user, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
 833			if err != nil {
 834				logger.Error("error getting lock owner", "err", err)
 835				renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
 836					Message: "internal server error",
 837				})
 838				return
 839			}
 840			handle, err := datastore.GetHandleByUserID(ctx, dbx, user.ID)
 841			if err != nil {
 842				logger.Error("error getting lock owner handle", "err", err)
 843				renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
 844					Message: "internal server error",
 845				})
 846				return
 847			}
 848			users[lock.UserID] = userModel{User: user, Handle: handle}
 849		}
 850
 851		l := lfs.Lock{
 852			ID:       strconv.FormatInt(lock.ID, 10),
 853			Path:     lock.Path,
 854			LockedAt: lock.CreatedAt,
 855			Owner: lfs.Owner{
 856				Name: owner.Handle.Handle,
 857			},
 858		}
 859
 860		if user != nil && user.ID() == lock.UserID {
 861			ours = append(ours, l)
 862		} else {
 863			theirs = append(theirs, l)
 864		}
 865	}
 866
 867	resp.Ours = ours
 868	resp.Theirs = theirs
 869
 870	if len(locks) == limit {
 871		resp.NextCursor = strconv.Itoa(cursor + 1)
 872	}
 873
 874	renderJSON(w, http.StatusOK, resp)
 875}
 876
 877// POST: /<repo>.git/info/lfs/objects/locks/:lockID/unlock
 878func serviceLfsLocksDelete(w http.ResponseWriter, r *http.Request) {
 879	if !isLfs(r) {
 880		renderNotAcceptable(w)
 881		return
 882	}
 883
 884	ctx := r.Context()
 885	logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
 886	lockIDStr := mux.Vars(r)["lock_id"]
 887	if lockIDStr == "" {
 888		logger.Error("error getting lock id")
 889		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
 890			Message: "invalid request",
 891		})
 892		return
 893	}
 894
 895	lockID, err := strconv.ParseInt(lockIDStr, 10, 64)
 896	if err != nil {
 897		logger.Error("error parsing lock id", "err", err)
 898		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
 899			Message: "invalid request",
 900		})
 901		return
 902	}
 903
 904	var req lfs.LockDeleteRequest
 905	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 906		logger.Error("error decoding request", "err", err)
 907		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
 908			Message: "invalid request: " + err.Error(),
 909		})
 910		return
 911	}
 912
 913	dbx := db.FromContext(ctx)
 914	datastore := store.FromContext(ctx)
 915	repo := proto.RepositoryFromContext(ctx)
 916	if repo == nil {
 917		logger.Error("error getting repository from context")
 918		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
 919			Message: "repository not found",
 920		})
 921		return
 922	}
 923
 924	// The lock being deleted
 925	lock, err := datastore.GetLFSLockByID(ctx, dbx, lockID)
 926	if err != nil {
 927		logger.Error("error getting lock", "err", err)
 928		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
 929			Message: "lock not found",
 930		})
 931		return
 932	}
 933
 934	owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
 935	if err != nil {
 936		logger.Error("error getting lock owner", "err", err)
 937		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
 938			Message: "internal server error",
 939		})
 940		return
 941	}
 942
 943	handle, err := datastore.GetHandleByUserID(ctx, dbx, owner.ID)
 944	if err != nil {
 945		logger.Error("error getting lock owner handle", "err", err)
 946		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
 947			Message: "internal server error",
 948		})
 949		return
 950	}
 951
 952	// Delete another user's lock
 953	l := lfs.Lock{
 954		ID:       strconv.FormatInt(lock.ID, 10),
 955		Path:     lock.Path,
 956		LockedAt: lock.CreatedAt,
 957		Owner: lfs.Owner{
 958			Name: handle.Handle,
 959		},
 960	}
 961	if req.Force {
 962		if err := datastore.DeleteLFSLock(ctx, dbx, repo.ID(), lockID); err != nil {
 963			logger.Error("error deleting lock", "err", err)
 964			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
 965				Message: "internal server error",
 966			})
 967			return
 968		}
 969
 970		renderJSON(w, http.StatusOK, l)
 971		return
 972	}
 973
 974	// Delete our own lock
 975	user := proto.UserFromContext(ctx)
 976	if user == nil {
 977		logger.Error("error getting user from context")
 978		renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
 979			Message: "unauthorized",
 980		})
 981		return
 982	}
 983
 984	if owner.ID != user.ID() {
 985		logger.Error("error deleting another user's lock")
 986		renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
 987			Message: "lock belongs to another user",
 988		})
 989		return
 990	}
 991
 992	if err := datastore.DeleteLFSLock(ctx, dbx, repo.ID(), lockID); err != nil {
 993		logger.Error("error deleting lock", "err", err)
 994		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
 995			Message: "internal server error",
 996		})
 997		return
 998	}
 999
1000	renderJSON(w, http.StatusOK, lfs.LockResponse{Lock: l})
1001}
1002
1003// renderJSON renders a JSON response with the given status code and value. It
1004// also sets the Content-Type header to the JSON LFS media type (application/vnd.git-lfs+json).
1005func renderJSON(w http.ResponseWriter, statusCode int, v interface{}) {
1006	hdrLfs(w)
1007	w.WriteHeader(statusCode)
1008	if err := json.NewEncoder(w).Encode(v); err != nil {
1009		log.Error("error encoding json", "err", err)
1010	}
1011}
1012
1013func renderNotAcceptable(w http.ResponseWriter) {
1014	renderStatus(http.StatusNotAcceptable)(w, nil)
1015}
1016
1017func isLfs(r *http.Request) bool {
1018	contentType := r.Header.Get("Content-Type")
1019	accept := r.Header.Get("Accept")
1020	return strings.HasPrefix(contentType, lfs.MediaType) && strings.HasPrefix(accept, lfs.MediaType)
1021}
1022
1023func isBinary(r *http.Request) bool {
1024	contentType := r.Header.Get("Content-Type")
1025	return strings.HasPrefix(contentType, "application/octet-stream")
1026}
1027
1028func hdrLfs(w http.ResponseWriter) {
1029	w.Header().Set("Content-Type", lfs.MediaType)
1030	w.Header().Set("Accept", lfs.MediaType)
1031}