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}