Parent directory

git_lfs.go

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