1package web23import (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"1516 "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)2829// serviceLfsBatch handles a Git LFS batch requests.30// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md31// TODO: support refname32// POST: /<repo>.git/info/lfs/objects/batch33func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {34 ctx := r.Context()35 logger := log.FromContext(ctx).WithPrefix("http.lfs")3637 if !isLfs(r) {38 logger.Errorf("invalid content type: %s", r.Header.Get("Content-Type"))39 renderNotAcceptable(w)40 return41 }4243 var batchRequest lfs.BatchRequest44 defer r.Body.Close() // nolint: errcheck45 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 return51 }5253 // We only accept basic transfers for now54 // Default to basic if no transfer is specified55 if len(batchRequest.Transfers) > 0 {56 var isBasic bool57 for _, t := range batchRequest.Transfers {58 if t == lfs.TransferBasic {59 isBasic = true60 break61 }62 }6364 if !isBasic {65 renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{66 Message: "unsupported transfer",67 })68 return69 }70 }7172 if len(batchRequest.Objects) == 0 {73 renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{74 Message: "no objects found",75 })76 return77 }7879 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 return86 }8788 cfg := config.FromContext(ctx)89 dbx := db.FromContext(ctx)90 datastore := store.FromContext(ctx)91 // TODO: support S3 storage92 repoID := strconv.FormatInt(repo.ID(), 10)93 strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID))9495 baseHref := fmt.Sprintf("%s/%s/info/lfs/objects/basic", cfg.HTTP.PublicURL, name+".git")9697 var batchResponse lfs.BatchResponse98 batchResponse.Transfer = lfs.TransferBasic99 batchResponse.HashAlgo = lfs.HashAlgorithmSHA256100101 objects := make([]*lfs.ObjectResponse, 0, len(batchRequest.Objects))102 // XXX: We don't support objects TTL for now, probably implement that with103 // 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 return114 }115116 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 return123 }124125 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 }150151 objects = append(objects, &lfs.ObjectResponse{152 Pointer: o,153 Actions: map[string]*lfs.Link{154 lfs.ActionDownload: download,155 },156 })157158 // If the object doesn't exist in the database, create it159 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 return166 }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 authorization181 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 return188 }189190 // Object upload logic happens in the "basic" API route191 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"] = auth214 verify.Header = map[string]string{215 "Authorization": auth,216 }217 }218219 objects = append(objects, &lfs.ObjectResponse{220 Pointer: o,221 Actions: map[string]*lfs.Link{222 lfs.ActionUpload: upload,223 // Verify uploaded objects224 // https://github.com/git-lfs/git-lfs/blob/main/docs/api/basic-transfers.md#verification225 lfs.ActionVerify: verify,226 },227 })228 }229 }230 default:231 renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{232 Message: "unsupported operation",233 })234 return235 }236237 batchResponse.Objects = objects238 renderJSON(w, http.StatusOK, batchResponse)239}240241// serviceLfsBasic implements Git LFS basic transfer API242// https://github.com/git-lfs/git-lfs/blob/main/docs/api/basic-transfers.md243func 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}251252// 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))263264 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 return271 }272273 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 return281 }282283 w.Header().Set("Content-Type", "application/octet-stream")284 w.Header().Set("Content-Length", strconv.FormatInt(obj.Size, 10))285 defer f.Close() // nolint: errcheck286 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 return292 }293}294295// 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 return302 }303304 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"]315316 defer r.Body.Close() // nolint: errcheck317 repo, err := be.Repository(ctx, name)318 if err != nil {319 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{320 Message: "repository not found",321 })322 return323 }324325 // NOTE: Git LFS client will retry uploading the same object if there was a326 // 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 request329 io.Copy(io.Discard, r.Body) // nolint: errcheck330 renderStatus(http.StatusOK)(w, nil)331 return332 } 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 return338 }339340 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 return347 }348349 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 return356 }357358 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 return364 }365366 renderStatus(http.StatusOK)(w, nil)367}368369// POST: /<repo>.git/info/lfs/objects/basic/verify370func serviceLfsBasicVerify(w http.ResponseWriter, r *http.Request) {371 if !isLfs(r) {372 renderNotAcceptable(w)373 return374 }375376 var pointer lfs.Pointer377 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 return386 }387388 defer r.Body.Close() // nolint: errcheck389 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 return395 }396397 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 return412 }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 return418 }419420 if obj.Size != pointer.Size {421 renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{422 Message: "object size mismatch",423 })424 return425 }426427 if pointer.IsValid() && stat.Size() == pointer.Size {428 renderStatus(http.StatusOK)(w, nil)429 return430 }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 return437 } 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 return443 }444}445446func 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}456457// POST: /<repo>.git/info/lfs/objects/locks458func serviceLfsLocksCreate(w http.ResponseWriter, r *http.Request) {459 if !isLfs(r) {460 renderNotAcceptable(w)461 return462 }463464 ctx := r.Context()465 logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")466467 var req lfs.LockCreateRequest468 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 return474 }475476 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 return483 }484485 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 return492 }493494 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 return522 }523 lockOwner.Name = owner.Username524 }525 errResp.Lock.Owner = lockOwner526 }527 renderJSON(w, http.StatusConflict, errResp)528 return529 }530 logger.Error("error creating lock", "err", err)531 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{532 Message: "internal server error",533 })534 return535 }536537 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 return544 }545546 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}557558// GET: /<repo>.git/info/lfs/objects/locks559func serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) {560 accept := r.Header.Get("Accept")561 if !strings.HasPrefix(accept, lfs.MediaType) {562 renderNotAcceptable(w)563 return564 }565566 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 return582 }583584 ctx := r.Context()585 // TODO: respect refspec586 path, id, cursor, limit, _ := parseLocksQuery(r.URL.Query())587 if limit > 100 {588 limit = 100589 } else if limit <= 0 {590 limit = lfs.DefaultLocksLimit591 }592593 // cursor is the page number594 if cursor <= 0 {595 cursor = 1596 }597598 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 return608 }609610 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 return618 }619 logger.Error("error getting lock", "err", err)620 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{621 Message: "internal server error",622 })623 return624 }625626 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 return633 }634635 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 return648 } 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 return656 }657 logger.Error("error getting lock", "err", err)658 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{659 Message: "internal server error",660 })661 return662 }663664 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 return671 }672673 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 return686 }687688 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 return695 }696697 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 return709 }710 users[lock.UserID] = owner711 }712713 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 }722723 resp := lfs.LockListResponse{724 Locks: lockList,725 }726 if len(locks) == limit {727 resp.NextCursor = strconv.Itoa(cursor + 1)728 }729730 renderJSON(w, http.StatusOK, resp)731}732733// POST: /<repo>.git/info/lfs/objects/locks/verify734func serviceLfsLocksVerify(w http.ResponseWriter, r *http.Request) {735 if !isLfs(r) {736 renderNotAcceptable(w)737 return738 }739740 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 return749 }750751 var req lfs.LockVerifyRequest752 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 return758 }759760 // TODO: refspec761 cursor, _ := strconv.Atoi(req.Cursor)762 if cursor <= 0 {763 cursor = 1764 }765766 limit := req.Limit767 if limit > 100 {768 limit = 100769 } else if limit <= 0 {770 limit = lfs.DefaultLocksLimit771 }772773 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)778779 var resp lfs.LockVerifyResponse780 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 return787 }788789 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 return800 }801 users[lock.UserID] = owner802 }803804 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 }812813 if user != nil && user.ID() == lock.UserID {814 ours = append(ours, l)815 } else {816 theirs = append(theirs, l)817 }818 }819820 resp.Ours = ours821 resp.Theirs = theirs822823 if len(locks) == limit {824 resp.NextCursor = strconv.Itoa(cursor + 1)825 }826827 renderJSON(w, http.StatusOK, resp)828}829830// POST: /<repo>.git/info/lfs/objects/locks/:lockID/unlock831func serviceLfsLocksDelete(w http.ResponseWriter, r *http.Request) {832 if !isLfs(r) {833 renderNotAcceptable(w)834 return835 }836837 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 return846 }847848 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 return855 }856857 var req lfs.LockDeleteRequest858 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 return864 }865866 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 return875 }876877 // The lock being deleted878 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 return885 }886887 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 return894 }895896 // Delete another user's lock897 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 return912 }913914 renderJSON(w, http.StatusOK, l)915 return916 }917918 // Delete our own lock919 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 return926 }927928 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 return934 }935936 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 return942 }943944 renderJSON(w, http.StatusOK, lfs.LockResponse{Lock: l})945}946947// renderJSON renders a JSON response with the given status code and value. It948// 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}956957func renderNotAcceptable(w http.ResponseWriter) {958 renderStatus(http.StatusNotAcceptable)(w, nil)959}960961func 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}966967func isBinary(r *http.Request) bool {968 contentType := r.Header.Get("Content-Type")969 return strings.HasPrefix(contentType, "application/octet-stream")970}971972func hdrLfs(w http.ResponseWriter) {973 w.Header().Set("Content-Type", lfs.MediaType)974 w.Header().Set("Accept", lfs.MediaType)975}