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