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/access"
18 "github.com/charmbracelet/soft-serve/server/backend"
19 "github.com/charmbracelet/soft-serve/server/config"
20 "github.com/charmbracelet/soft-serve/server/db"
21 "github.com/charmbracelet/soft-serve/server/db/models"
22 "github.com/charmbracelet/soft-serve/server/git"
23 "github.com/charmbracelet/soft-serve/server/lfs"
24 "github.com/charmbracelet/soft-serve/server/proto"
25 "github.com/charmbracelet/soft-serve/server/storage"
26 "github.com/charmbracelet/soft-serve/server/store"
27 "goji.io/pat"
28)
29
30// Place holder service to handle Git LFS requests.
31const gitLfsService git.Service = "git-lfs-service"
32
33// serviceLfsBatch handles a Git LFS batch requests.
34// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md
35// TODO: support refname
36// POST: /<repo>.git/info/lfs/objects/batch
37func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
38 ctx := r.Context()
39 logger := log.FromContext(ctx).WithPrefix("http.lfs")
40
41 if !isLfs(r) {
42 logger.Errorf("invalid content type: %s", r.Header.Get("Content-Type"))
43 renderNotAcceptable(w)
44 return
45 }
46
47 var batchRequest lfs.BatchRequest
48 defer r.Body.Close() // nolint: errcheck
49 if err := json.NewDecoder(r.Body).Decode(&batchRequest); err != nil {
50 logger.Errorf("error decoding json: %s", err)
51 renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
52 Message: "validation error in request: " + err.Error(),
53 })
54 return
55 }
56
57 // We only accept basic transfers for now
58 // Default to basic if no transfer is specified
59 if len(batchRequest.Transfers) > 0 {
60 var isBasic bool
61 for _, t := range batchRequest.Transfers {
62 if t == lfs.TransferBasic {
63 isBasic = true
64 break
65 }
66 }
67
68 if !isBasic {
69 renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
70 Message: "unsupported transfer",
71 })
72 return
73 }
74 }
75
76 if len(batchRequest.Objects) == 0 {
77 renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
78 Message: "no objects found",
79 })
80 return
81 }
82
83 name := pat.Param(r, "repo")
84 repo := proto.RepositoryFromContext(ctx)
85 if repo == nil {
86 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
87 Message: "repository not found",
88 })
89 return
90 }
91
92 cfg := config.FromContext(ctx)
93 dbx := db.FromContext(ctx)
94 datastore := store.FromContext(ctx)
95 // TODO: support S3 storage
96 strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
97
98 baseHref := fmt.Sprintf("%s/%s/info/lfs/objects/basic", cfg.HTTP.PublicURL, name+".git")
99
100 var batchResponse lfs.BatchResponse
101 batchResponse.Transfer = lfs.TransferBasic
102 batchResponse.HashAlgo = lfs.HashAlgorithmSHA256
103
104 objects := make([]*lfs.ObjectResponse, 0, len(batchRequest.Objects))
105 // XXX: We don't support objects TTL for now, probably implement that with
106 // S3 using object "expires_at" & "expires_in"
107 switch batchRequest.Operation {
108 case lfs.OperationDownload:
109 for _, o := range batchRequest.Objects {
110 exist, err := strg.Exists(path.Join("objects", o.RelativePath()))
111 if err != nil && !errors.Is(err, fs.ErrNotExist) {
112 logger.Error("error getting object stat", "oid", o.Oid, "repo", name, "err", err)
113 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
114 Message: "internal server error",
115 })
116 return
117 }
118
119 obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), o.Oid)
120 if err != nil && !errors.Is(err, db.ErrRecordNotFound) {
121 logger.Error("error getting object from database", "oid", o.Oid, "repo", name, "err", err)
122 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
123 Message: "internal server error",
124 })
125 return
126 }
127
128 if !exist {
129 objects = append(objects, &lfs.ObjectResponse{
130 Pointer: o,
131 Error: &lfs.ObjectError{
132 Code: http.StatusNotFound,
133 Message: "object not found",
134 },
135 })
136 } else if obj.Size != o.Size {
137 objects = append(objects, &lfs.ObjectResponse{
138 Pointer: o,
139 Error: &lfs.ObjectError{
140 Code: http.StatusUnprocessableEntity,
141 Message: "size mismatch",
142 },
143 })
144 } else if o.IsValid() {
145 download := &lfs.Link{
146 Href: fmt.Sprintf("%s/%s", baseHref, o.Oid),
147 }
148 if auth := r.Header.Get("Authorization"); auth != "" {
149 download.Header = map[string]string{
150 "Authorization": auth,
151 }
152 }
153
154 objects = append(objects, &lfs.ObjectResponse{
155 Pointer: o,
156 Actions: map[string]*lfs.Link{
157 lfs.ActionDownload: download,
158 },
159 })
160
161 // If the object doesn't exist in the database, create it
162 if exist && obj.ID == 0 {
163 if err := datastore.CreateLFSObject(ctx, dbx, repo.ID(), o.Oid, o.Size); err != nil {
164 logger.Error("error creating object in datastore", "oid", o.Oid, "repo", name, "err", err)
165 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
166 Message: "internal server error",
167 })
168 return
169 }
170 }
171 } else {
172 logger.Error("invalid object", "oid", o.Oid, "repo", name)
173 objects = append(objects, &lfs.ObjectResponse{
174 Pointer: o,
175 Error: &lfs.ObjectError{
176 Code: http.StatusUnprocessableEntity,
177 Message: "invalid object",
178 },
179 })
180 }
181 }
182 case lfs.OperationUpload:
183 // Check authorization
184 accessLevel := access.FromContext(ctx)
185 if accessLevel < access.ReadWriteAccess {
186 askCredentials(w, r)
187 renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
188 Message: "credentials needed",
189 })
190 return
191 }
192
193 // Object upload logic happens in the "basic" API route
194 for _, o := range batchRequest.Objects {
195 if !o.IsValid() {
196 objects = append(objects, &lfs.ObjectResponse{
197 Pointer: o,
198 Error: &lfs.ObjectError{
199 Code: http.StatusUnprocessableEntity,
200 Message: "invalid object",
201 },
202 })
203 } else {
204 upload := &lfs.Link{
205 Href: fmt.Sprintf("%s/%s", baseHref, o.Oid),
206 Header: map[string]string{
207 // NOTE: git-lfs v2.5.0 sets the Content-Type based on the uploaded file.
208 // This ensures that the client always uses the designated value for the header.
209 "Content-Type": "application/octet-stream",
210 },
211 }
212 verify := &lfs.Link{
213 Href: fmt.Sprintf("%s/verify", baseHref),
214 }
215 if auth := r.Header.Get("Authorization"); auth != "" {
216 upload.Header["Authorization"] = auth
217 verify.Header = map[string]string{
218 "Authorization": auth,
219 }
220 }
221
222 objects = append(objects, &lfs.ObjectResponse{
223 Pointer: o,
224 Actions: map[string]*lfs.Link{
225 lfs.ActionUpload: upload,
226 // Verify uploaded objects
227 // https://github.com/git-lfs/git-lfs/blob/main/docs/api/basic-transfers.md#verification
228 lfs.ActionVerify: verify,
229 },
230 })
231 }
232 }
233 default:
234 renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
235 Message: "unsupported operation",
236 })
237 return
238 }
239
240 batchResponse.Objects = objects
241 renderJSON(w, http.StatusOK, batchResponse)
242}
243
244// serviceLfsBasic implements Git LFS basic transfer API
245// https://github.com/git-lfs/git-lfs/blob/main/docs/api/basic-transfers.md
246func serviceLfsBasic(w http.ResponseWriter, r *http.Request) {
247 switch r.Method {
248 case http.MethodGet:
249 serviceLfsBasicDownload(w, r)
250 case http.MethodPut:
251 serviceLfsBasicUpload(w, r)
252 }
253}
254
255// GET: /<repo>.git/info/lfs/objects/basic/<oid>
256func serviceLfsBasicDownload(w http.ResponseWriter, r *http.Request) {
257 ctx := r.Context()
258 oid := pat.Param(r, "oid")
259 repo := proto.RepositoryFromContext(ctx)
260 cfg := config.FromContext(ctx)
261 logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
262 datastore := store.FromContext(ctx)
263 dbx := db.FromContext(ctx)
264 strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
265
266 obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), oid)
267 if err != nil && !errors.Is(err, db.ErrRecordNotFound) {
268 logger.Error("error getting object from database", "oid", oid, "repo", repo.Name(), "err", err)
269 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
270 Message: "internal server error",
271 })
272 return
273 }
274
275 pointer := lfs.Pointer{Oid: oid}
276 f, err := strg.Open(path.Join("objects", pointer.RelativePath()))
277 if err != nil {
278 logger.Error("error opening object", "oid", oid, "err", err)
279 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
280 Message: "object not found",
281 })
282 return
283 }
284
285 w.Header().Set("Content-Type", "application/octet-stream")
286 w.Header().Set("Content-Length", strconv.FormatInt(obj.Size, 10))
287 defer f.Close() // nolint: errcheck
288 if _, err := io.Copy(w, f); err != nil {
289 logger.Error("error copying object to response", "oid", oid, "err", err)
290 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
291 Message: "internal server error",
292 })
293 return
294 }
295}
296
297// PUT: /<repo>.git/info/lfs/objects/basic/<oid>
298func serviceLfsBasicUpload(w http.ResponseWriter, r *http.Request) {
299 if !isBinary(r) {
300 renderJSON(w, http.StatusUnsupportedMediaType, lfs.ErrorResponse{
301 Message: "invalid content type",
302 })
303 return
304 }
305
306 ctx := r.Context()
307 oid := pat.Param(r, "oid")
308 cfg := config.FromContext(ctx)
309 be := backend.FromContext(ctx)
310 dbx := db.FromContext(ctx)
311 datastore := store.FromContext(ctx)
312 logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
313 strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
314 name := pat.Param(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 strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
401 if stat, err := strg.Stat(path.Join("objects", pointer.RelativePath())); err == nil {
402 // Verify object is in the database.
403 obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), pointer.Oid)
404 if err != nil {
405 if errors.Is(err, db.ErrRecordNotFound) {
406 logger.Error("object not found", "oid", pointer.Oid)
407 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
408 Message: "object not found",
409 })
410 return
411 }
412 logger.Error("error getting object", "oid", pointer.Oid, "err", err)
413 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
414 Message: "internal server error",
415 })
416 return
417 }
418
419 if obj.Size != pointer.Size {
420 renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
421 Message: "object size mismatch",
422 })
423 return
424 }
425
426 if pointer.IsValid() && stat.Size() == pointer.Size {
427 renderStatus(http.StatusOK)(w, nil)
428 return
429 }
430 } else if errors.Is(err, fs.ErrNotExist) {
431 logger.Error("file not found", "oid", pointer.Oid)
432 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
433 Message: "object not found",
434 })
435 return
436 } else {
437 logger.Error("error getting object", "oid", pointer.Oid, "err", err)
438 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
439 Message: "internal server error",
440 })
441 return
442 }
443}
444
445func serviceLfsLocks(w http.ResponseWriter, r *http.Request) {
446 switch r.Method {
447 case http.MethodGet:
448 serviceLfsLocksGet(w, r)
449 case http.MethodPost:
450 serviceLfsLocksCreate(w, r)
451 default:
452 renderMethodNotAllowed(w, r)
453 }
454}
455
456// POST: /<repo>.git/info/lfs/objects/locks
457func serviceLfsLocksCreate(w http.ResponseWriter, r *http.Request) {
458 if !isLfs(r) {
459 renderNotAcceptable(w)
460 return
461 }
462
463 ctx := r.Context()
464 logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
465
466 var req lfs.LockCreateRequest
467 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
468 logger.Error("error decoding json", "err", err)
469 renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
470 Message: "invalid request: " + err.Error(),
471 })
472 return
473 }
474
475 repo := proto.RepositoryFromContext(ctx)
476 if repo == nil {
477 logger.Error("error getting repository from context")
478 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
479 Message: "repository not found",
480 })
481 return
482 }
483
484 user := proto.UserFromContext(ctx)
485 if user == nil {
486 logger.Error("error getting user from context")
487 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
488 Message: "user not found",
489 })
490 return
491 }
492
493 dbx := db.FromContext(ctx)
494 datastore := store.FromContext(ctx)
495 if err := datastore.CreateLFSLockForUser(ctx, dbx, repo.ID(), user.ID(), req.Path, req.Ref.Name); err != nil {
496 err = db.WrapError(err)
497 if errors.Is(err, db.ErrDuplicateKey) {
498 errResp := lfs.LockResponse{
499 ErrorResponse: lfs.ErrorResponse{
500 Message: "lock already exists",
501 },
502 }
503 lock, err := datastore.GetLFSLockForUserPath(ctx, dbx, repo.ID(), user.ID(), req.Path)
504 if err == nil {
505 errResp.Lock = lfs.Lock{
506 ID: strconv.FormatInt(lock.ID, 10),
507 Path: lock.Path,
508 LockedAt: lock.CreatedAt,
509 }
510 lockOwner := lfs.Owner{
511 Name: user.Username(),
512 }
513 if lock.UserID != user.ID() {
514 owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
515 if err != nil {
516 logger.Error("error getting lock owner", "err", err)
517 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
518 Message: "internal server error",
519 })
520 return
521 }
522 lockOwner.Name = owner.Username
523 }
524 errResp.Lock.Owner = lockOwner
525 }
526 renderJSON(w, http.StatusConflict, errResp)
527 return
528 }
529 logger.Error("error creating lock", "err", err)
530 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
531 Message: "internal server error",
532 })
533 return
534 }
535
536 lock, err := datastore.GetLFSLockForUserPath(ctx, dbx, repo.ID(), user.ID(), req.Path)
537 if err != nil {
538 logger.Error("error getting lock", "err", err)
539 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
540 Message: "internal server error",
541 })
542 return
543 }
544
545 renderJSON(w, http.StatusCreated, lfs.LockResponse{
546 Lock: lfs.Lock{
547 ID: strconv.FormatInt(lock.ID, 10),
548 Path: lock.Path,
549 LockedAt: lock.CreatedAt,
550 Owner: lfs.Owner{
551 Name: user.Username(),
552 },
553 },
554 })
555}
556
557// GET: /<repo>.git/info/lfs/objects/locks
558func serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) {
559 accept := r.Header.Get("Accept")
560 if !strings.HasPrefix(accept, lfs.MediaType) {
561 renderNotAcceptable(w)
562 return
563 }
564
565 parseLocksQuery := func(values url.Values) (path string, id int64, cursor int, limit int, refspec string) {
566 path = values.Get("path")
567 idStr := values.Get("id")
568 if idStr != "" {
569 id, _ = strconv.ParseInt(idStr, 10, 64)
570 }
571 cursorStr := values.Get("cursor")
572 if cursorStr != "" {
573 cursor, _ = strconv.Atoi(cursorStr)
574 }
575 limitStr := values.Get("limit")
576 if limitStr != "" {
577 limit, _ = strconv.Atoi(limitStr)
578 }
579 refspec = values.Get("refspec")
580 return
581 }
582
583 ctx := r.Context()
584 // TODO: respect refspec
585 path, id, cursor, limit, _ := parseLocksQuery(r.URL.Query())
586 if limit > 100 {
587 limit = 100
588 } else if limit <= 0 {
589 limit = lfs.DefaultLocksLimit
590 }
591
592 // cursor is the page number
593 if cursor <= 0 {
594 cursor = 1
595 }
596
597 logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
598 dbx := db.FromContext(ctx)
599 datastore := store.FromContext(ctx)
600 repo := proto.RepositoryFromContext(ctx)
601 if repo == nil {
602 logger.Error("error getting repository from context")
603 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
604 Message: "repository not found",
605 })
606 return
607 }
608
609 if id > 0 {
610 lock, err := datastore.GetLFSLockByID(ctx, dbx, id)
611 if err != nil {
612 if errors.Is(err, db.ErrRecordNotFound) {
613 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
614 Message: "lock not found",
615 })
616 return
617 }
618 logger.Error("error getting lock", "err", err)
619 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
620 Message: "internal server error",
621 })
622 return
623 }
624
625 owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
626 if err != nil {
627 logger.Error("error getting lock owner", "err", err)
628 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
629 Message: "internal server error",
630 })
631 return
632 }
633
634 renderJSON(w, http.StatusOK, lfs.LockListResponse{
635 Locks: []lfs.Lock{
636 {
637 ID: strconv.FormatInt(lock.ID, 10),
638 Path: lock.Path,
639 LockedAt: lock.CreatedAt,
640 Owner: lfs.Owner{
641 Name: owner.Username,
642 },
643 },
644 },
645 })
646 return
647 } else if path != "" {
648 lock, err := datastore.GetLFSLockForPath(ctx, dbx, repo.ID(), path)
649 if err != nil {
650 if errors.Is(err, db.ErrRecordNotFound) {
651 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
652 Message: "lock not found",
653 })
654 return
655 }
656 logger.Error("error getting lock", "err", err)
657 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
658 Message: "internal server error",
659 })
660 return
661 }
662
663 owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
664 if err != nil {
665 logger.Error("error getting lock owner", "err", err)
666 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
667 Message: "internal server error",
668 })
669 return
670 }
671
672 renderJSON(w, http.StatusOK, lfs.LockListResponse{
673 Locks: []lfs.Lock{
674 {
675 ID: strconv.FormatInt(lock.ID, 10),
676 Path: lock.Path,
677 LockedAt: lock.CreatedAt,
678 Owner: lfs.Owner{
679 Name: owner.Username,
680 },
681 },
682 },
683 })
684 return
685 } else {
686 locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)
687 if err != nil {
688 logger.Error("error getting locks", "err", err)
689 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
690 Message: "internal server error",
691 })
692 return
693 }
694
695 lockList := make([]lfs.Lock, len(locks))
696 users := map[int64]models.User{}
697 for i, lock := range locks {
698 owner, ok := users[lock.UserID]
699 if !ok {
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 users[lock.UserID] = owner
709 }
710
711 lockList[i] = lfs.Lock{
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 resp := lfs.LockListResponse{
722 Locks: lockList,
723 }
724 if len(locks) == limit {
725 resp.NextCursor = strconv.Itoa(cursor + 1)
726 }
727
728 renderJSON(w, http.StatusOK, resp)
729 return
730 }
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 := pat.Param(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}