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/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 { //nolint:nestif
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,gosec
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 { //nolint:nestif
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 { //nolint:nestif
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 //nolint:nestif // Complex LFS lock handling requires nested conditions
611 if id > 0 {
612 lock, err := datastore.GetLFSLockByID(ctx, dbx, id)
613 if err != nil {
614 if errors.Is(err, db.ErrRecordNotFound) {
615 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
616 Message: "lock not found",
617 })
618 return
619 }
620 logger.Error("error getting lock", "err", err)
621 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
622 Message: "internal server error",
623 })
624 return
625 }
626
627 owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
628 if err != nil {
629 logger.Error("error getting lock owner", "err", err)
630 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
631 Message: "internal server error",
632 })
633 return
634 }
635
636 renderJSON(w, http.StatusOK, lfs.LockListResponse{
637 Locks: []lfs.Lock{
638 {
639 ID: strconv.FormatInt(lock.ID, 10),
640 Path: lock.Path,
641 LockedAt: lock.CreatedAt,
642 Owner: lfs.Owner{
643 Name: owner.Username,
644 },
645 },
646 },
647 })
648 return
649 } else if path != "" {
650 lock, err := datastore.GetLFSLockForPath(ctx, dbx, repo.ID(), path)
651 if err != nil {
652 if errors.Is(err, db.ErrRecordNotFound) {
653 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
654 Message: "lock not found",
655 })
656 return
657 }
658 logger.Error("error getting lock", "err", err)
659 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
660 Message: "internal server error",
661 })
662 return
663 }
664
665 owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
666 if err != nil {
667 logger.Error("error getting lock owner", "err", err)
668 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
669 Message: "internal server error",
670 })
671 return
672 }
673
674 renderJSON(w, http.StatusOK, lfs.LockListResponse{
675 Locks: []lfs.Lock{
676 {
677 ID: strconv.FormatInt(lock.ID, 10),
678 Path: lock.Path,
679 LockedAt: lock.CreatedAt,
680 Owner: lfs.Owner{
681 Name: owner.Username,
682 },
683 },
684 },
685 })
686 return
687 }
688
689 locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)
690 if err != nil {
691 logger.Error("error getting locks", "err", err)
692 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
693 Message: "internal server error",
694 })
695 return
696 }
697
698 lockList := make([]lfs.Lock, len(locks))
699 users := map[int64]models.User{}
700 for i, lock := range locks {
701 owner, ok := users[lock.UserID]
702 if !ok {
703 owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID)
704 if err != nil {
705 logger.Error("error getting lock owner", "err", err)
706 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
707 Message: "internal server error",
708 })
709 return
710 }
711 users[lock.UserID] = owner
712 }
713
714 lockList[i] = lfs.Lock{
715 ID: strconv.FormatInt(lock.ID, 10),
716 Path: lock.Path,
717 LockedAt: lock.CreatedAt,
718 Owner: lfs.Owner{
719 Name: owner.Username,
720 },
721 }
722 }
723
724 resp := lfs.LockListResponse{
725 Locks: lockList,
726 }
727 if len(locks) == limit {
728 resp.NextCursor = strconv.Itoa(cursor + 1)
729 }
730
731 renderJSON(w, http.StatusOK, resp)
732}
733
734// POST: /<repo>.git/info/lfs/objects/locks/verify.
735func serviceLfsLocksVerify(w http.ResponseWriter, r *http.Request) {
736 if !isLfs(r) {
737 renderNotAcceptable(w)
738 return
739 }
740
741 ctx := r.Context()
742 logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
743 repo := proto.RepositoryFromContext(ctx)
744 if repo == nil {
745 logger.Error("error getting repository from context")
746 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
747 Message: "repository not found",
748 })
749 return
750 }
751
752 var req lfs.LockVerifyRequest
753 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
754 logger.Error("error decoding request", "err", err)
755 renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
756 Message: "invalid request: " + err.Error(),
757 })
758 return
759 }
760
761 // TODO: refspec
762 cursor, _ := strconv.Atoi(req.Cursor)
763 if cursor <= 0 {
764 cursor = 1
765 }
766
767 limit := req.Limit
768 if limit > 100 {
769 limit = 100
770 } else if limit <= 0 {
771 limit = lfs.DefaultLocksLimit
772 }
773
774 dbx := db.FromContext(ctx)
775 datastore := store.FromContext(ctx)
776 user := proto.UserFromContext(ctx)
777 ours := make([]lfs.Lock, 0)
778 theirs := make([]lfs.Lock, 0)
779
780 var resp lfs.LockVerifyResponse
781 locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)
782 if err != nil {
783 logger.Error("error getting locks", "err", err)
784 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
785 Message: "internal server error",
786 })
787 return
788 }
789
790 users := map[int64]models.User{}
791 for _, lock := range locks {
792 owner, ok := users[lock.UserID]
793 if !ok {
794 owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID)
795 if err != nil {
796 logger.Error("error getting lock owner", "err", err)
797 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
798 Message: "internal server error",
799 })
800 return
801 }
802 users[lock.UserID] = owner
803 }
804
805 l := lfs.Lock{
806 ID: strconv.FormatInt(lock.ID, 10),
807 Path: lock.Path,
808 LockedAt: lock.CreatedAt,
809 Owner: lfs.Owner{
810 Name: owner.Username,
811 },
812 }
813
814 if user != nil && user.ID() == lock.UserID {
815 ours = append(ours, l)
816 } else {
817 theirs = append(theirs, l)
818 }
819 }
820
821 resp.Ours = ours
822 resp.Theirs = theirs
823
824 if len(locks) == limit {
825 resp.NextCursor = strconv.Itoa(cursor + 1)
826 }
827
828 renderJSON(w, http.StatusOK, resp)
829}
830
831// POST: /<repo>.git/info/lfs/objects/locks/:lockID/unlock.
832func serviceLfsLocksDelete(w http.ResponseWriter, r *http.Request) {
833 if !isLfs(r) {
834 renderNotAcceptable(w)
835 return
836 }
837
838 ctx := r.Context()
839 logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
840 lockIDStr := mux.Vars(r)["lock_id"]
841 if lockIDStr == "" {
842 logger.Error("error getting lock id")
843 renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
844 Message: "invalid request",
845 })
846 return
847 }
848
849 lockID, err := strconv.ParseInt(lockIDStr, 10, 64)
850 if err != nil {
851 logger.Error("error parsing lock id", "err", err)
852 renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
853 Message: "invalid request",
854 })
855 return
856 }
857
858 var req lfs.LockDeleteRequest
859 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
860 logger.Error("error decoding request", "err", err)
861 renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
862 Message: "invalid request: " + err.Error(),
863 })
864 return
865 }
866
867 dbx := db.FromContext(ctx)
868 datastore := store.FromContext(ctx)
869 repo := proto.RepositoryFromContext(ctx)
870 if repo == nil {
871 logger.Error("error getting repository from context")
872 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
873 Message: "repository not found",
874 })
875 return
876 }
877
878 // The lock being deleted
879 lock, err := datastore.GetLFSLockByID(ctx, dbx, lockID)
880 if err != nil {
881 logger.Error("error getting lock", "err", err)
882 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
883 Message: "lock not found",
884 })
885 return
886 }
887
888 owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
889 if err != nil {
890 logger.Error("error getting lock owner", "err", err)
891 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
892 Message: "internal server error",
893 })
894 return
895 }
896
897 // Delete another user's lock
898 l := lfs.Lock{
899 ID: strconv.FormatInt(lock.ID, 10),
900 Path: lock.Path,
901 LockedAt: lock.CreatedAt,
902 Owner: lfs.Owner{
903 Name: owner.Username,
904 },
905 }
906 if req.Force {
907 if err := datastore.DeleteLFSLock(ctx, dbx, repo.ID(), lockID); err != nil {
908 logger.Error("error deleting lock", "err", err)
909 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
910 Message: "internal server error",
911 })
912 return
913 }
914
915 renderJSON(w, http.StatusOK, l)
916 return
917 }
918
919 // Delete our own lock
920 user := proto.UserFromContext(ctx)
921 if user == nil {
922 logger.Error("error getting user from context")
923 renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
924 Message: "unauthorized",
925 })
926 return
927 }
928
929 if owner.ID != user.ID() {
930 logger.Error("error deleting another user's lock")
931 renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
932 Message: "lock belongs to another user",
933 })
934 return
935 }
936
937 if err := datastore.DeleteLFSLock(ctx, dbx, repo.ID(), lockID); err != nil {
938 logger.Error("error deleting lock", "err", err)
939 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
940 Message: "internal server error",
941 })
942 return
943 }
944
945 renderJSON(w, http.StatusOK, lfs.LockResponse{Lock: l})
946}
947
948// renderJSON renders a JSON response with the given status code and value. It
949// also sets the Content-Type header to the JSON LFS media type (application/vnd.git-lfs+json).
950func renderJSON(w http.ResponseWriter, statusCode int, v interface{}) {
951 hdrLfs(w)
952 w.WriteHeader(statusCode)
953 if err := json.NewEncoder(w).Encode(v); err != nil {
954 log.Error("error encoding json", "err", err)
955 }
956}
957
958func renderNotAcceptable(w http.ResponseWriter) {
959 renderStatus(http.StatusNotAcceptable)(w, nil)
960}
961
962func isLfs(r *http.Request) bool {
963 contentType := r.Header.Get("Content-Type")
964 accept := r.Header.Get("Accept")
965 return strings.HasPrefix(contentType, lfs.MediaType) && strings.HasPrefix(accept, lfs.MediaType)
966}
967
968func isBinary(r *http.Request) bool {
969 contentType := r.Header.Get("Content-Type")
970 return strings.HasPrefix(contentType, "application/octet-stream")
971}
972
973func hdrLfs(w http.ResponseWriter) {
974 w.Header().Set("Content-Type", lfs.MediaType)
975 w.Header().Set("Accept", lfs.MediaType)
976}