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	log "github.com/charmbracelet/log/v2"
 17	"github.com/charmbracelet/soft-serve/pkg/access"
 18	"github.com/charmbracelet/soft-serve/pkg/backend"
 19	"github.com/charmbracelet/soft-serve/pkg/config"
 20	"github.com/charmbracelet/soft-serve/pkg/db"
 21	"github.com/charmbracelet/soft-serve/pkg/db/models"
 22	"github.com/charmbracelet/soft-serve/pkg/lfs"
 23	"github.com/charmbracelet/soft-serve/pkg/proto"
 24	"github.com/charmbracelet/soft-serve/pkg/storage"
 25	"github.com/charmbracelet/soft-serve/pkg/store"
 26	"github.com/gorilla/mux"
 27)
 28
 29// serviceLfsBatch handles a Git LFS batch requests.
 30// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md
 31// TODO: support refname
 32// POST: /<repo>.git/info/lfs/objects/batch
 33func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
 34	ctx := r.Context()
 35	logger := log.FromContext(ctx).WithPrefix("http.lfs")
 36
 37	if !isLfs(r) {
 38		logger.Errorf("invalid content type: %s", r.Header.Get("Content-Type"))
 39		renderNotAcceptable(w)
 40		return
 41	}
 42
 43	var batchRequest lfs.BatchRequest
 44	defer r.Body.Close() //nolint: errcheck
 45	if err := json.NewDecoder(r.Body).Decode(&batchRequest); err != nil {
 46		logger.Errorf("error decoding json: %s", err)
 47		renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
 48			Message: "validation error in request: " + err.Error(),
 49		})
 50		return
 51	}
 52
 53	// We only accept basic transfers for now
 54	// Default to basic if no transfer is specified
 55	if len(batchRequest.Transfers) > 0 {
 56		var isBasic bool
 57		for _, t := range batchRequest.Transfers {
 58			if t == lfs.TransferBasic {
 59				isBasic = true
 60				break
 61			}
 62		}
 63
 64		if !isBasic {
 65			renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
 66				Message: "unsupported transfer",
 67			})
 68			return
 69		}
 70	}
 71
 72	if len(batchRequest.Objects) == 0 {
 73		renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
 74			Message: "no objects found",
 75		})
 76		return
 77	}
 78
 79	name := mux.Vars(r)["repo"]
 80	repo := proto.RepositoryFromContext(ctx)
 81	if repo == nil {
 82		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
 83			Message: "repository not found",
 84		})
 85		return
 86	}
 87
 88	cfg := config.FromContext(ctx)
 89	dbx := db.FromContext(ctx)
 90	datastore := store.FromContext(ctx)
 91	// TODO: support S3 storage
 92	repoID := strconv.FormatInt(repo.ID(), 10)
 93	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID))
 94
 95	baseHref := fmt.Sprintf("%s/%s/info/lfs/objects/basic", cfg.HTTP.PublicURL, name+".git")
 96
 97	var batchResponse lfs.BatchResponse
 98	batchResponse.Transfer = lfs.TransferBasic
 99	batchResponse.HashAlgo = lfs.HashAlgorithmSHA256
100
101	objects := make([]*lfs.ObjectResponse, 0, len(batchRequest.Objects))
102	// XXX: We don't support objects TTL for now, probably implement that with
103	// S3 using object "expires_at" & "expires_in"
104	switch batchRequest.Operation {
105	case lfs.OperationDownload:
106		for _, o := range batchRequest.Objects {
107			exist, err := strg.Exists(path.Join("objects", o.RelativePath()))
108			if err != nil && !errors.Is(err, fs.ErrNotExist) {
109				logger.Error("error getting object stat", "oid", o.Oid, "repo", name, "err", err)
110				renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
111					Message: "internal server error",
112				})
113				return
114			}
115
116			obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), o.Oid)
117			if err != nil && !errors.Is(err, db.ErrRecordNotFound) {
118				logger.Error("error getting object from database", "oid", o.Oid, "repo", name, "err", err)
119				renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
120					Message: "internal server error",
121				})
122				return
123			}
124
125			if !exist {
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					lockOwner.Name = owner.Username
524				}
525				errResp.Lock.Owner = lockOwner
526			}
527			renderJSON(w, http.StatusConflict, errResp)
528			return
529		}
530		logger.Error("error creating lock", "err", err)
531		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
532			Message: "internal server error",
533		})
534		return
535	}
536
537	lock, err := datastore.GetLFSLockForUserPath(ctx, dbx, repo.ID(), user.ID(), req.Path)
538	if err != nil {
539		logger.Error("error getting lock", "err", err)
540		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
541			Message: "internal server error",
542		})
543		return
544	}
545
546	renderJSON(w, http.StatusCreated, lfs.LockResponse{
547		Lock: lfs.Lock{
548			ID:       strconv.FormatInt(lock.ID, 10),
549			Path:     lock.Path,
550			LockedAt: lock.CreatedAt,
551			Owner: lfs.Owner{
552				Name: user.Username(),
553			},
554		},
555	})
556}
557
558// GET: /<repo>.git/info/lfs/objects/locks
559func serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) {
560	accept := r.Header.Get("Accept")
561	if !strings.HasPrefix(accept, lfs.MediaType) {
562		renderNotAcceptable(w)
563		return
564	}
565
566	parseLocksQuery := func(values url.Values) (path string, id int64, cursor int, limit int, refspec string) {
567		path = values.Get("path")
568		idStr := values.Get("id")
569		if idStr != "" {
570			id, _ = strconv.ParseInt(idStr, 10, 64)
571		}
572		cursorStr := values.Get("cursor")
573		if cursorStr != "" {
574			cursor, _ = strconv.Atoi(cursorStr)
575		}
576		limitStr := values.Get("limit")
577		if limitStr != "" {
578			limit, _ = strconv.Atoi(limitStr)
579		}
580		refspec = values.Get("refspec")
581		return
582	}
583
584	ctx := r.Context()
585	// TODO: respect refspec
586	path, id, cursor, limit, _ := parseLocksQuery(r.URL.Query())
587	if limit > 100 {
588		limit = 100
589	} else if limit <= 0 {
590		limit = lfs.DefaultLocksLimit
591	}
592
593	// cursor is the page number
594	if cursor <= 0 {
595		cursor = 1
596	}
597
598	logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
599	dbx := db.FromContext(ctx)
600	datastore := store.FromContext(ctx)
601	repo := proto.RepositoryFromContext(ctx)
602	if repo == nil {
603		logger.Error("error getting repository from context")
604		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
605			Message: "repository not found",
606		})
607		return
608	}
609
610	if id > 0 {
611		lock, err := datastore.GetLFSLockByID(ctx, dbx, id)
612		if err != nil {
613			if errors.Is(err, db.ErrRecordNotFound) {
614				renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
615					Message: "lock not found",
616				})
617				return
618			}
619			logger.Error("error getting lock", "err", err)
620			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
621				Message: "internal server error",
622			})
623			return
624		}
625
626		owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
627		if err != nil {
628			logger.Error("error getting lock owner", "err", err)
629			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
630				Message: "internal server error",
631			})
632			return
633		}
634
635		renderJSON(w, http.StatusOK, lfs.LockListResponse{
636			Locks: []lfs.Lock{
637				{
638					ID:       strconv.FormatInt(lock.ID, 10),
639					Path:     lock.Path,
640					LockedAt: lock.CreatedAt,
641					Owner: lfs.Owner{
642						Name: owner.Username,
643					},
644				},
645			},
646		})
647		return
648	} else if path != "" {
649		lock, err := datastore.GetLFSLockForPath(ctx, dbx, repo.ID(), path)
650		if err != nil {
651			if errors.Is(err, db.ErrRecordNotFound) {
652				renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
653					Message: "lock not found",
654				})
655				return
656			}
657			logger.Error("error getting lock", "err", err)
658			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
659				Message: "internal server error",
660			})
661			return
662		}
663
664		owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
665		if err != nil {
666			logger.Error("error getting lock owner", "err", err)
667			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
668				Message: "internal server error",
669			})
670			return
671		}
672
673		renderJSON(w, http.StatusOK, lfs.LockListResponse{
674			Locks: []lfs.Lock{
675				{
676					ID:       strconv.FormatInt(lock.ID, 10),
677					Path:     lock.Path,
678					LockedAt: lock.CreatedAt,
679					Owner: lfs.Owner{
680						Name: owner.Username,
681					},
682				},
683			},
684		})
685		return
686	}
687
688	locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)
689	if err != nil {
690		logger.Error("error getting locks", "err", err)
691		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
692			Message: "internal server error",
693		})
694		return
695	}
696
697	lockList := make([]lfs.Lock, len(locks))
698	users := map[int64]models.User{}
699	for i, lock := range locks {
700		owner, ok := users[lock.UserID]
701		if !ok {
702			owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID)
703			if err != nil {
704				logger.Error("error getting lock owner", "err", err)
705				renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
706					Message: "internal server error",
707				})
708				return
709			}
710			users[lock.UserID] = owner
711		}
712
713		lockList[i] = lfs.Lock{
714			ID:       strconv.FormatInt(lock.ID, 10),
715			Path:     lock.Path,
716			LockedAt: lock.CreatedAt,
717			Owner: lfs.Owner{
718				Name: owner.Username,
719			},
720		}
721	}
722
723	resp := lfs.LockListResponse{
724		Locks: lockList,
725	}
726	if len(locks) == limit {
727		resp.NextCursor = strconv.Itoa(cursor + 1)
728	}
729
730	renderJSON(w, http.StatusOK, resp)
731}
732
733// POST: /<repo>.git/info/lfs/objects/locks/verify
734func serviceLfsLocksVerify(w http.ResponseWriter, r *http.Request) {
735	if !isLfs(r) {
736		renderNotAcceptable(w)
737		return
738	}
739
740	ctx := r.Context()
741	logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
742	repo := proto.RepositoryFromContext(ctx)
743	if repo == nil {
744		logger.Error("error getting repository from context")
745		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
746			Message: "repository not found",
747		})
748		return
749	}
750
751	var req lfs.LockVerifyRequest
752	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
753		logger.Error("error decoding request", "err", err)
754		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
755			Message: "invalid request: " + err.Error(),
756		})
757		return
758	}
759
760	// TODO: refspec
761	cursor, _ := strconv.Atoi(req.Cursor)
762	if cursor <= 0 {
763		cursor = 1
764	}
765
766	limit := req.Limit
767	if limit > 100 {
768		limit = 100
769	} else if limit <= 0 {
770		limit = lfs.DefaultLocksLimit
771	}
772
773	dbx := db.FromContext(ctx)
774	datastore := store.FromContext(ctx)
775	user := proto.UserFromContext(ctx)
776	ours := make([]lfs.Lock, 0)
777	theirs := make([]lfs.Lock, 0)
778
779	var resp lfs.LockVerifyResponse
780	locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)
781	if err != nil {
782		logger.Error("error getting locks", "err", err)
783		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
784			Message: "internal server error",
785		})
786		return
787	}
788
789	users := map[int64]models.User{}
790	for _, lock := range locks {
791		owner, ok := users[lock.UserID]
792		if !ok {
793			owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID)
794			if err != nil {
795				logger.Error("error getting lock owner", "err", err)
796				renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
797					Message: "internal server error",
798				})
799				return
800			}
801			users[lock.UserID] = owner
802		}
803
804		l := lfs.Lock{
805			ID:       strconv.FormatInt(lock.ID, 10),
806			Path:     lock.Path,
807			LockedAt: lock.CreatedAt,
808			Owner: lfs.Owner{
809				Name: owner.Username,
810			},
811		}
812
813		if user != nil && user.ID() == lock.UserID {
814			ours = append(ours, l)
815		} else {
816			theirs = append(theirs, l)
817		}
818	}
819
820	resp.Ours = ours
821	resp.Theirs = theirs
822
823	if len(locks) == limit {
824		resp.NextCursor = strconv.Itoa(cursor + 1)
825	}
826
827	renderJSON(w, http.StatusOK, resp)
828}
829
830// POST: /<repo>.git/info/lfs/objects/locks/:lockID/unlock
831func serviceLfsLocksDelete(w http.ResponseWriter, r *http.Request) {
832	if !isLfs(r) {
833		renderNotAcceptable(w)
834		return
835	}
836
837	ctx := r.Context()
838	logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
839	lockIDStr := mux.Vars(r)["lock_id"]
840	if lockIDStr == "" {
841		logger.Error("error getting lock id")
842		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
843			Message: "invalid request",
844		})
845		return
846	}
847
848	lockID, err := strconv.ParseInt(lockIDStr, 10, 64)
849	if err != nil {
850		logger.Error("error parsing lock id", "err", err)
851		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
852			Message: "invalid request",
853		})
854		return
855	}
856
857	var req lfs.LockDeleteRequest
858	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
859		logger.Error("error decoding request", "err", err)
860		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
861			Message: "invalid request: " + err.Error(),
862		})
863		return
864	}
865
866	dbx := db.FromContext(ctx)
867	datastore := store.FromContext(ctx)
868	repo := proto.RepositoryFromContext(ctx)
869	if repo == nil {
870		logger.Error("error getting repository from context")
871		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
872			Message: "repository not found",
873		})
874		return
875	}
876
877	// The lock being deleted
878	lock, err := datastore.GetLFSLockByID(ctx, dbx, lockID)
879	if err != nil {
880		logger.Error("error getting lock", "err", err)
881		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
882			Message: "lock not found",
883		})
884		return
885	}
886
887	owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
888	if err != nil {
889		logger.Error("error getting lock owner", "err", err)
890		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
891			Message: "internal server error",
892		})
893		return
894	}
895
896	// Delete another user's lock
897	l := lfs.Lock{
898		ID:       strconv.FormatInt(lock.ID, 10),
899		Path:     lock.Path,
900		LockedAt: lock.CreatedAt,
901		Owner: lfs.Owner{
902			Name: owner.Username,
903		},
904	}
905	if req.Force {
906		if err := datastore.DeleteLFSLock(ctx, dbx, repo.ID(), lockID); err != nil {
907			logger.Error("error deleting lock", "err", err)
908			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
909				Message: "internal server error",
910			})
911			return
912		}
913
914		renderJSON(w, http.StatusOK, l)
915		return
916	}
917
918	// Delete our own lock
919	user := proto.UserFromContext(ctx)
920	if user == nil {
921		logger.Error("error getting user from context")
922		renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
923			Message: "unauthorized",
924		})
925		return
926	}
927
928	if owner.ID != user.ID() {
929		logger.Error("error deleting another user's lock")
930		renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
931			Message: "lock belongs to another user",
932		})
933		return
934	}
935
936	if err := datastore.DeleteLFSLock(ctx, dbx, repo.ID(), lockID); err != nil {
937		logger.Error("error deleting lock", "err", err)
938		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
939			Message: "internal server error",
940		})
941		return
942	}
943
944	renderJSON(w, http.StatusOK, lfs.LockResponse{Lock: l})
945}
946
947// renderJSON renders a JSON response with the given status code and value. It
948// also sets the Content-Type header to the JSON LFS media type (application/vnd.git-lfs+json).
949func renderJSON(w http.ResponseWriter, statusCode int, v interface{}) {
950	hdrLfs(w)
951	w.WriteHeader(statusCode)
952	if err := json.NewEncoder(w).Encode(v); err != nil {
953		log.Error("error encoding json", "err", err)
954	}
955}
956
957func renderNotAcceptable(w http.ResponseWriter) {
958	renderStatus(http.StatusNotAcceptable)(w, nil)
959}
960
961func isLfs(r *http.Request) bool {
962	contentType := r.Header.Get("Content-Type")
963	accept := r.Header.Get("Accept")
964	return strings.HasPrefix(contentType, lfs.MediaType) && strings.HasPrefix(accept, lfs.MediaType)
965}
966
967func isBinary(r *http.Request) bool {
968	contentType := r.Header.Get("Content-Type")
969	return strings.HasPrefix(contentType, "application/octet-stream")
970}
971
972func hdrLfs(w http.ResponseWriter) {
973	w.Header().Set("Content-Type", lfs.MediaType)
974	w.Header().Set("Accept", lfs.MediaType)
975}