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