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}