From d99f4ab0035631aacc44739af0a936e59daf83b7 Mon Sep 17 00:00:00 2001
From: Antoine GIRARD <sapk@users.noreply.github.com>
Date: Tue, 28 Nov 2017 21:58:37 +0100
Subject: [PATCH] Git LFS lock api (#2938)

* Implement routes

* move to api/sdk and create model

* Implement add + list

* List return 200 empty list no 404

* Add verify lfs lock api

* Add delete and start implementing auth control

* Revert to code.gitea.io/sdk/gitea vendor

* Apply needed check for all lfs locks route

* Add simple tests

* fix lint

* Improve tests

* Add delete test + fix

* Add lfs ascii header

* Various fixes from review + remove useless code + add more corner case testing

* Remove repo link since only id is needed.

Save a little of memory and cpu time.

* Improve tests

* Use TEXT column format for path + test

* fix mispell

* Use NewRequestWithJSON for POST tests

* Clean path

* Improve DB format

* Revert uniquess repoid+path

* (Re)-setup uniqueness + max path length

* Fixed TEXT in place of VARCHAR

* Settle back to maximum VARCHAR(3072)

* Let place for repoid in key

* Let place for repoid in key

* Let place for repoid in key

* Revert back
---
 integrations/api_repo_lfs_locks_test.go | 176 ++++++++++++++++++
 integrations/mysql.ini.tmpl             |   3 +-
 integrations/pgsql.ini.tmpl             |   2 +-
 integrations/sqlite.ini                 |  27 +--
 models/error.go                         |  57 ++++++
 models/lfs_lock.go                      | 146 +++++++++++++++
 models/models.go                        |   1 +
 modules/lfs/locks.go                    | 236 ++++++++++++++++++++++++
 routers/routes/routes.go                |   6 +
 9 files changed, 638 insertions(+), 16 deletions(-)
 create mode 100644 integrations/api_repo_lfs_locks_test.go
 create mode 100644 models/lfs_lock.go
 create mode 100644 modules/lfs/locks.go

diff --git a/integrations/api_repo_lfs_locks_test.go b/integrations/api_repo_lfs_locks_test.go
new file mode 100644
index 0000000000..9fd796b6c5
--- /dev/null
+++ b/integrations/api_repo_lfs_locks_test.go
@@ -0,0 +1,176 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package integrations
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+	"time"
+
+	"code.gitea.io/gitea/models"
+	"code.gitea.io/gitea/modules/setting"
+	api "code.gitea.io/sdk/gitea"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAPILFSLocksNotStarted(t *testing.T) {
+	prepareTestEnv(t)
+	setting.LFS.StartServer = false
+	user := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User)
+	repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository)
+
+	req := NewRequestf(t, "GET", "/%s/%s/info/lfs/locks", user.Name, repo.Name)
+	MakeRequest(t, req, http.StatusNotFound)
+	req = NewRequestf(t, "POST", "/%s/%s/info/lfs/locks", user.Name, repo.Name)
+	MakeRequest(t, req, http.StatusNotFound)
+	req = NewRequestf(t, "GET", "/%s/%s/info/lfs/locks/verify", user.Name, repo.Name)
+	MakeRequest(t, req, http.StatusNotFound)
+	req = NewRequestf(t, "GET", "/%s/%s/info/lfs/locks/10/unlock", user.Name, repo.Name)
+	MakeRequest(t, req, http.StatusNotFound)
+}
+
+func TestAPILFSLocksNotLogin(t *testing.T) {
+	prepareTestEnv(t)
+	setting.LFS.StartServer = true
+	user := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User)
+	repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository)
+
+	req := NewRequestf(t, "GET", "/%s/%s/info/lfs/locks", user.Name, repo.Name)
+	req.Header.Set("Accept", "application/vnd.git-lfs+json")
+	req.Header.Set("Content-Type", "application/vnd.git-lfs+json")
+	resp := MakeRequest(t, req, http.StatusForbidden)
+	var lfsLockError api.LFSLockError
+	DecodeJSON(t, resp, &lfsLockError)
+	assert.Equal(t, "You must have pull access to list locks : User undefined doesn't have rigth to list for lfs lock [rid: 1]", lfsLockError.Message)
+}
+
+func TestAPILFSLocksLogged(t *testing.T) {
+	prepareTestEnv(t)
+	setting.LFS.StartServer = true
+	user2 := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) //in org 3
+	user4 := models.AssertExistsAndLoadBean(t, &models.User{ID: 4}).(*models.User) //in org 3
+
+	repo1 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository)
+	repo3 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 3}).(*models.Repository) // own by org 3
+
+	tests := []struct {
+		user       *models.User
+		repo       *models.Repository
+		path       string
+		httpResult int
+		addTime    []int
+	}{
+		{user: user2, repo: repo1, path: "foo/bar.zip", httpResult: http.StatusCreated, addTime: []int{0}},
+		{user: user2, repo: repo1, path: "path/test", httpResult: http.StatusCreated, addTime: []int{0}},
+		{user: user2, repo: repo1, path: "path/test", httpResult: http.StatusConflict},
+		{user: user2, repo: repo1, path: "Foo/BaR.zip", httpResult: http.StatusConflict},
+		{user: user2, repo: repo1, path: "Foo/Test/../subFOlder/../Relative/../BaR.zip", httpResult: http.StatusConflict},
+		{user: user4, repo: repo1, path: "FoO/BaR.zip", httpResult: http.StatusForbidden},
+		{user: user4, repo: repo1, path: "path/test-user4", httpResult: http.StatusForbidden},
+		{user: user2, repo: repo1, path: "patH/Test-user4", httpResult: http.StatusCreated, addTime: []int{0}},
+		{user: user2, repo: repo1, path: "some/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/path", httpResult: http.StatusCreated, addTime: []int{0}},
+
+		{user: user2, repo: repo3, path: "test/foo/bar.zip", httpResult: http.StatusCreated, addTime: []int{1, 2}},
+		{user: user4, repo: repo3, path: "test/foo/bar.zip", httpResult: http.StatusConflict},
+		{user: user4, repo: repo3, path: "test/foo/bar.bin", httpResult: http.StatusCreated, addTime: []int{1, 2}},
+	}
+
+	resultsTests := []struct {
+		user        *models.User
+		repo        *models.Repository
+		totalCount  int
+		oursCount   int
+		theirsCount int
+		locksOwners []*models.User
+		locksTimes  []time.Time
+	}{
+		{user: user2, repo: repo1, totalCount: 4, oursCount: 4, theirsCount: 0, locksOwners: []*models.User{user2, user2, user2, user2}, locksTimes: []time.Time{}},
+		{user: user2, repo: repo3, totalCount: 2, oursCount: 1, theirsCount: 1, locksOwners: []*models.User{user2, user4}, locksTimes: []time.Time{}},
+		{user: user4, repo: repo3, totalCount: 2, oursCount: 1, theirsCount: 1, locksOwners: []*models.User{user2, user4}, locksTimes: []time.Time{}},
+	}
+
+	deleteTests := []struct {
+		user   *models.User
+		repo   *models.Repository
+		lockID string
+	}{}
+
+	//create locks
+	for _, test := range tests {
+		session := loginUser(t, test.user.Name)
+		req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s/info/lfs/locks", test.repo.FullName()), map[string]string{"path": test.path})
+		req.Header.Set("Accept", "application/vnd.git-lfs+json")
+		req.Header.Set("Content-Type", "application/vnd.git-lfs+json")
+		session.MakeRequest(t, req, test.httpResult)
+		if len(test.addTime) > 0 {
+			for _, id := range test.addTime {
+				resultsTests[id].locksTimes = append(resultsTests[id].locksTimes, time.Now())
+			}
+		}
+	}
+
+	//check creation
+	for _, test := range resultsTests {
+		session := loginUser(t, test.user.Name)
+		req := NewRequestf(t, "GET", "/%s/info/lfs/locks", test.repo.FullName())
+		req.Header.Set("Accept", "application/vnd.git-lfs+json")
+		req.Header.Set("Content-Type", "application/vnd.git-lfs+json")
+		resp := session.MakeRequest(t, req, http.StatusOK)
+		var lfsLocks api.LFSLockList
+		DecodeJSON(t, resp, &lfsLocks)
+		assert.Len(t, lfsLocks.Locks, test.totalCount)
+		for i, lock := range lfsLocks.Locks {
+			assert.EqualValues(t, test.locksOwners[i].DisplayName(), lock.Owner.Name)
+			assert.WithinDuration(t, test.locksTimes[i], lock.LockedAt, 1*time.Second)
+		}
+
+		req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s/info/lfs/locks/verify", test.repo.FullName()), map[string]string{})
+		req.Header.Set("Accept", "application/vnd.git-lfs+json")
+		req.Header.Set("Content-Type", "application/vnd.git-lfs+json")
+		resp = session.MakeRequest(t, req, http.StatusOK)
+		var lfsLocksVerify api.LFSLockListVerify
+		DecodeJSON(t, resp, &lfsLocksVerify)
+		assert.Len(t, lfsLocksVerify.Ours, test.oursCount)
+		assert.Len(t, lfsLocksVerify.Theirs, test.theirsCount)
+		for _, lock := range lfsLocksVerify.Ours {
+			assert.EqualValues(t, test.user.DisplayName(), lock.Owner.Name)
+			deleteTests = append(deleteTests, struct {
+				user   *models.User
+				repo   *models.Repository
+				lockID string
+			}{test.user, test.repo, lock.ID})
+		}
+		for _, lock := range lfsLocksVerify.Theirs {
+			assert.NotEqual(t, test.user.DisplayName(), lock.Owner.Name)
+		}
+	}
+
+	//remove all locks
+	for _, test := range deleteTests {
+		session := loginUser(t, test.user.Name)
+		req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s/info/lfs/locks/%s/unlock", test.repo.FullName(), test.lockID), map[string]string{})
+		req.Header.Set("Accept", "application/vnd.git-lfs+json")
+		req.Header.Set("Content-Type", "application/vnd.git-lfs+json")
+		resp := session.MakeRequest(t, req, http.StatusOK)
+		var lfsLockRep api.LFSLockResponse
+		DecodeJSON(t, resp, &lfsLockRep)
+		assert.Equal(t, test.lockID, lfsLockRep.Lock.ID)
+		assert.Equal(t, test.user.DisplayName(), lfsLockRep.Lock.Owner.Name)
+	}
+
+	// check that we don't have any lock
+	for _, test := range resultsTests {
+		session := loginUser(t, test.user.Name)
+		req := NewRequestf(t, "GET", "/%s/info/lfs/locks", test.repo.FullName())
+		req.Header.Set("Accept", "application/vnd.git-lfs+json")
+		req.Header.Set("Content-Type", "application/vnd.git-lfs+json")
+		resp := session.MakeRequest(t, req, http.StatusOK)
+		var lfsLocks api.LFSLockList
+		DecodeJSON(t, resp, &lfsLocks)
+		assert.Len(t, lfsLocks.Locks, 0)
+	}
+}
diff --git a/integrations/mysql.ini.tmpl b/integrations/mysql.ini.tmpl
index fb1a4f5810..e01362607b 100644
--- a/integrations/mysql.ini.tmpl
+++ b/integrations/mysql.ini.tmpl
@@ -27,7 +27,7 @@ HTTP_PORT        = 3001
 ROOT_URL         = http://localhost:3001/
 DISABLE_SSH      = false
 SSH_PORT         = 22
-LFS_START_SERVER = false
+LFS_START_SERVER = true
 OFFLINE_MODE     = false
 
 [mailer]
@@ -65,4 +65,3 @@ LEVEL = Debug
 INSTALL_LOCK   = true
 SECRET_KEY     = 9pCviYTWSb
 INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0OTU1NTE2MTh9.hhSVGOANkaKk3vfCd2jDOIww4pUk0xtg9JRde5UogyQ
-
diff --git a/integrations/pgsql.ini.tmpl b/integrations/pgsql.ini.tmpl
index 01916af9ac..853d6fb3be 100644
--- a/integrations/pgsql.ini.tmpl
+++ b/integrations/pgsql.ini.tmpl
@@ -27,7 +27,7 @@ HTTP_PORT        = 3002
 ROOT_URL         = http://localhost:3002/
 DISABLE_SSH      = false
 SSH_PORT         = 22
-LFS_START_SERVER = false
+LFS_START_SERVER = true
 OFFLINE_MODE     = false
 
 [mailer]
diff --git a/integrations/sqlite.ini b/integrations/sqlite.ini
index 8a3a5356b4..b3462a19b4 100644
--- a/integrations/sqlite.ini
+++ b/integrations/sqlite.ini
@@ -2,13 +2,13 @@ APP_NAME = Gitea: Git with a cup of tea
 RUN_MODE = prod
 
 [database]
-DB_TYPE  = sqlite3
-PATH     = :memory:
+DB_TYPE = sqlite3
+PATH    = :memory:
 
 [indexer]
-ISSUE_INDEXER_PATH = integrations/indexers-sqlite/issues.bleve
+ISSUE_INDEXER_PATH   = integrations/indexers-sqlite/issues.bleve
 REPO_INDEXER_ENABLED = true
-REPO_INDEXER_PATH = integrations/indexers-sqlite/repos.bleve
+REPO_INDEXER_PATH    = integrations/indexers-sqlite/repos.bleve
 
 [repository]
 ROOT = integrations/gitea-integration-sqlite/gitea-repositories
@@ -22,21 +22,22 @@ HTTP_PORT        = 3003
 ROOT_URL         = http://localhost:3003/
 DISABLE_SSH      = false
 SSH_PORT         = 22
-LFS_START_SERVER = false
+LFS_START_SERVER = true
 OFFLINE_MODE     = false
+LFS_JWT_SECRET   = Tv_MjmZuHqpIY6GFl12ebgkRAMt4RlWt0v4EHKSXO0w
 
 [mailer]
 ENABLED = false
 
 [service]
-REGISTER_EMAIL_CONFIRM     = false
-ENABLE_NOTIFY_MAIL         = false
-DISABLE_REGISTRATION       = false
-ENABLE_CAPTCHA             = false
-REQUIRE_SIGNIN_VIEW        = false
-DEFAULT_KEEP_EMAIL_PRIVATE = false
+REGISTER_EMAIL_CONFIRM            = false
+ENABLE_NOTIFY_MAIL                = false
+DISABLE_REGISTRATION              = false
+ENABLE_CAPTCHA                    = false
+REQUIRE_SIGNIN_VIEW               = false
+DEFAULT_KEEP_EMAIL_PRIVATE        = false
 DEFAULT_ALLOW_CREATE_ORGANIZATION = true
-NO_REPLY_ADDRESS           = noreply.example.org
+NO_REPLY_ADDRESS                  = noreply.example.org
 
 [picture]
 DISABLE_GRAVATAR        = false
@@ -46,7 +47,7 @@ ENABLE_FEDERATED_AVATAR = false
 PROVIDER = file
 
 [log]
-MODE = console,file
+MODE      = console,file
 ROOT_PATH = sqlite-log
 
 [log.console]
diff --git a/models/error.go b/models/error.go
index 7ea4e9e2f2..50d9cba171 100644
--- a/models/error.go
+++ b/models/error.go
@@ -506,6 +506,63 @@ func (err ErrLastOrgOwner) Error() string {
 	return fmt.Sprintf("user is the last member of owner team [uid: %d]", err.UID)
 }
 
+//.____   ____________________
+//|    |  \_   _____/   _____/
+//|    |   |    __) \_____  \
+//|    |___|     \  /        \
+//|_______ \___  / /_______  /
+//        \/   \/          \/
+
+// ErrLFSLockNotExist represents a "LFSLockNotExist" kind of error.
+type ErrLFSLockNotExist struct {
+	ID     int64
+	RepoID int64
+	Path   string
+}
+
+// IsErrLFSLockNotExist checks if an error is a ErrLFSLockNotExist.
+func IsErrLFSLockNotExist(err error) bool {
+	_, ok := err.(ErrLFSLockNotExist)
+	return ok
+}
+
+func (err ErrLFSLockNotExist) Error() string {
+	return fmt.Sprintf("lfs lock does not exist [id: %d, rid: %d, path: %s]", err.ID, err.RepoID, err.Path)
+}
+
+// ErrLFSLockUnauthorizedAction represents a "LFSLockUnauthorizedAction" kind of error.
+type ErrLFSLockUnauthorizedAction struct {
+	RepoID   int64
+	UserName string
+	Action   string
+}
+
+// IsErrLFSLockUnauthorizedAction checks if an error is a ErrLFSLockUnauthorizedAction.
+func IsErrLFSLockUnauthorizedAction(err error) bool {
+	_, ok := err.(ErrLFSLockUnauthorizedAction)
+	return ok
+}
+
+func (err ErrLFSLockUnauthorizedAction) Error() string {
+	return fmt.Sprintf("User %s doesn't have rigth to %s for lfs lock [rid: %d]", err.UserName, err.Action, err.RepoID)
+}
+
+// ErrLFSLockAlreadyExist represents a "LFSLockAlreadyExist" kind of error.
+type ErrLFSLockAlreadyExist struct {
+	RepoID int64
+	Path   string
+}
+
+// IsErrLFSLockAlreadyExist checks if an error is a ErrLFSLockAlreadyExist.
+func IsErrLFSLockAlreadyExist(err error) bool {
+	_, ok := err.(ErrLFSLockAlreadyExist)
+	return ok
+}
+
+func (err ErrLFSLockAlreadyExist) Error() string {
+	return fmt.Sprintf("lfs lock already exists [rid: %d, path: %s]", err.RepoID, err.Path)
+}
+
 // __________                           .__  __
 // \______   \ ____ ______   ____  _____|__|/  |_  ___________ ___.__.
 //  |       _// __ \\____ \ /  _ \/  ___/  \   __\/  _ \_  __ <   |  |
diff --git a/models/lfs_lock.go b/models/lfs_lock.go
new file mode 100644
index 0000000000..83811bc7bd
--- /dev/null
+++ b/models/lfs_lock.go
@@ -0,0 +1,146 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package models
+
+import (
+	"fmt"
+	"path"
+	"strconv"
+	"strings"
+	"time"
+
+	api "code.gitea.io/sdk/gitea"
+)
+
+// LFSLock represents a git lfs lock of repository.
+type LFSLock struct {
+	ID      int64     `xorm:"pk autoincr"`
+	RepoID  int64     `xorm:"INDEX NOT NULL"`
+	Owner   *User     `xorm:"-"`
+	OwnerID int64     `xorm:"INDEX NOT NULL"`
+	Path    string    `xorm:"TEXT"`
+	Created time.Time `xorm:"created"`
+}
+
+// BeforeInsert is invoked from XORM before inserting an object of this type.
+func (l *LFSLock) BeforeInsert() {
+	l.OwnerID = l.Owner.ID
+	l.Path = cleanPath(l.Path)
+}
+
+// AfterLoad is invoked from XORM after setting the values of all fields of this object.
+func (l *LFSLock) AfterLoad() {
+	l.Owner, _ = GetUserByID(l.OwnerID)
+}
+
+func cleanPath(p string) string {
+	return strings.ToLower(path.Clean(p))
+}
+
+// APIFormat convert a Release to lfs.LFSLock
+func (l *LFSLock) APIFormat() *api.LFSLock {
+	return &api.LFSLock{
+		ID:       strconv.FormatInt(l.ID, 10),
+		Path:     l.Path,
+		LockedAt: l.Created,
+		Owner: &api.LFSLockOwner{
+			Name: l.Owner.DisplayName(),
+		},
+	}
+}
+
+// CreateLFSLock creates a new lock.
+func CreateLFSLock(lock *LFSLock) (*LFSLock, error) {
+	err := CheckLFSAccessForRepo(lock.Owner, lock.RepoID, "create")
+	if err != nil {
+		return nil, err
+	}
+
+	l, err := GetLFSLock(lock.RepoID, lock.Path)
+	if err == nil {
+		return l, ErrLFSLockAlreadyExist{lock.RepoID, lock.Path}
+	}
+	if !IsErrLFSLockNotExist(err) {
+		return nil, err
+	}
+
+	_, err = x.InsertOne(lock)
+	return lock, err
+}
+
+// GetLFSLock returns release by given path.
+func GetLFSLock(repoID int64, path string) (*LFSLock, error) {
+	path = cleanPath(path)
+	rel := &LFSLock{RepoID: repoID, Path: path}
+	has, err := x.Get(rel)
+	if err != nil {
+		return nil, err
+	}
+	if !has {
+		return nil, ErrLFSLockNotExist{0, repoID, path}
+	}
+	return rel, nil
+}
+
+// GetLFSLockByID returns release by given id.
+func GetLFSLockByID(id int64) (*LFSLock, error) {
+	lock := new(LFSLock)
+	has, err := x.ID(id).Get(lock)
+	if err != nil {
+		return nil, err
+	} else if !has {
+		return nil, ErrLFSLockNotExist{id, 0, ""}
+	}
+	return lock, nil
+}
+
+// GetLFSLockByRepoID returns a list of locks of repository.
+func GetLFSLockByRepoID(repoID int64) (locks []*LFSLock, err error) {
+	err = x.Where("repo_id = ?", repoID).Find(&locks)
+	return
+}
+
+// DeleteLFSLockByID deletes a lock by given ID.
+func DeleteLFSLockByID(id int64, u *User, force bool) (*LFSLock, error) {
+	lock, err := GetLFSLockByID(id)
+	if err != nil {
+		return nil, err
+	}
+
+	err = CheckLFSAccessForRepo(u, lock.RepoID, "delete")
+	if err != nil {
+		return nil, err
+	}
+
+	if !force && u.ID != lock.OwnerID {
+		return nil, fmt.Errorf("user doesn't own lock and force flag is not set")
+	}
+
+	_, err = x.ID(id).Delete(new(LFSLock))
+	return lock, err
+}
+
+//CheckLFSAccessForRepo check needed access mode base on action
+func CheckLFSAccessForRepo(u *User, repoID int64, action string) error {
+	if u == nil {
+		return ErrLFSLockUnauthorizedAction{repoID, "undefined", action}
+	}
+	mode := AccessModeRead
+	if action == "create" || action == "delete" || action == "verify" {
+		mode = AccessModeWrite
+	}
+
+	repo, err := GetRepositoryByID(repoID)
+	if err != nil {
+		return err
+	}
+	has, err := HasAccess(u.ID, repo, mode)
+	if err != nil {
+		return err
+	} else if !has {
+		return ErrLFSLockUnauthorizedAction{repo.ID, u.DisplayName(), action}
+	}
+	return nil
+}
diff --git a/models/models.go b/models/models.go
index 836a14db5a..8a3850b6ff 100644
--- a/models/models.go
+++ b/models/models.go
@@ -117,6 +117,7 @@ func init() {
 		new(TrackedTime),
 		new(DeletedBranch),
 		new(RepoIndexerStatus),
+		new(LFSLock),
 	)
 
 	gonicNames := []string{"SSL", "UID"}
diff --git a/modules/lfs/locks.go b/modules/lfs/locks.go
new file mode 100644
index 0000000000..38d290e13b
--- /dev/null
+++ b/modules/lfs/locks.go
@@ -0,0 +1,236 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package lfs
+
+import (
+	"encoding/json"
+	"strconv"
+
+	"code.gitea.io/gitea/models"
+	"code.gitea.io/gitea/modules/context"
+	"code.gitea.io/gitea/modules/setting"
+	api "code.gitea.io/sdk/gitea"
+
+	"gopkg.in/macaron.v1"
+)
+
+func checkRequest(req macaron.Request) int {
+	if !setting.LFS.StartServer {
+		return 404
+	}
+	if !MetaMatcher(req) || req.Header.Get("Content-Type") != metaMediaType {
+		return 400
+	}
+	return 200
+}
+
+func handleLockListOut(ctx *context.Context, lock *models.LFSLock, err error) {
+	if err != nil {
+		if models.IsErrLFSLockNotExist(err) {
+			ctx.JSON(200, api.LFSLockList{
+				Locks: []*api.LFSLock{},
+			})
+			return
+		}
+		ctx.JSON(500, api.LFSLockError{
+			Message: "unable to list locks : " + err.Error(),
+		})
+		return
+	}
+	if ctx.Repo.Repository.ID != lock.RepoID {
+		ctx.JSON(200, api.LFSLockList{
+			Locks: []*api.LFSLock{},
+		})
+		return
+	}
+	ctx.JSON(200, api.LFSLockList{
+		Locks: []*api.LFSLock{lock.APIFormat()},
+	})
+}
+
+// GetListLockHandler list locks
+func GetListLockHandler(ctx *context.Context) {
+	status := checkRequest(ctx.Req)
+	if status != 200 {
+		writeStatus(ctx, status)
+		return
+	}
+	ctx.Resp.Header().Set("Content-Type", metaMediaType)
+
+	err := models.CheckLFSAccessForRepo(ctx.User, ctx.Repo.Repository.ID, "list")
+	if err != nil {
+		if models.IsErrLFSLockUnauthorizedAction(err) {
+			ctx.JSON(403, api.LFSLockError{
+				Message: "You must have pull access to list locks : " + err.Error(),
+			})
+			return
+		}
+		ctx.JSON(500, api.LFSLockError{
+			Message: "unable to list lock : " + err.Error(),
+		})
+		return
+	}
+	//TODO handle query cursor and limit
+	id := ctx.Query("id")
+	if id != "" { //Case where we request a specific id
+		v, err := strconv.ParseInt(id, 10, 64)
+		if err != nil {
+			ctx.JSON(400, api.LFSLockError{
+				Message: "bad request : " + err.Error(),
+			})
+			return
+		}
+		lock, err := models.GetLFSLockByID(int64(v))
+		handleLockListOut(ctx, lock, err)
+		return
+	}
+
+	path := ctx.Query("path")
+	if path != "" { //Case where we request a specific id
+		lock, err := models.GetLFSLock(ctx.Repo.Repository.ID, path)
+		handleLockListOut(ctx, lock, err)
+		return
+	}
+
+	//If no query params path or id
+	lockList, err := models.GetLFSLockByRepoID(ctx.Repo.Repository.ID)
+	if err != nil {
+		ctx.JSON(500, api.LFSLockError{
+			Message: "unable to list locks : " + err.Error(),
+		})
+		return
+	}
+	lockListAPI := make([]*api.LFSLock, len(lockList))
+	for i, l := range lockList {
+		lockListAPI[i] = l.APIFormat()
+	}
+	ctx.JSON(200, api.LFSLockList{
+		Locks: lockListAPI,
+	})
+}
+
+// PostLockHandler create lock
+func PostLockHandler(ctx *context.Context) {
+	status := checkRequest(ctx.Req)
+	if status != 200 {
+		writeStatus(ctx, status)
+		return
+	}
+	ctx.Resp.Header().Set("Content-Type", metaMediaType)
+
+	var req api.LFSLockRequest
+	dec := json.NewDecoder(ctx.Req.Body().ReadCloser())
+	err := dec.Decode(&req)
+	if err != nil {
+		writeStatus(ctx, 400)
+		return
+	}
+
+	lock, err := models.CreateLFSLock(&models.LFSLock{
+		RepoID: ctx.Repo.Repository.ID,
+		Path:   req.Path,
+		Owner:  ctx.User,
+	})
+	if err != nil {
+		if models.IsErrLFSLockAlreadyExist(err) {
+			ctx.JSON(409, api.LFSLockError{
+				Lock:    lock.APIFormat(),
+				Message: "already created lock",
+			})
+			return
+		}
+		if models.IsErrLFSLockUnauthorizedAction(err) {
+			ctx.JSON(403, api.LFSLockError{
+				Message: "You must have push access to create locks : " + err.Error(),
+			})
+			return
+		}
+		ctx.JSON(500, api.LFSLockError{
+			Message: "internal server error : " + err.Error(),
+		})
+		return
+	}
+	ctx.JSON(201, api.LFSLockResponse{Lock: lock.APIFormat()})
+}
+
+// VerifyLockHandler list locks for verification
+func VerifyLockHandler(ctx *context.Context) {
+	status := checkRequest(ctx.Req)
+	if status != 200 {
+		writeStatus(ctx, status)
+		return
+	}
+
+	ctx.Resp.Header().Set("Content-Type", metaMediaType)
+
+	err := models.CheckLFSAccessForRepo(ctx.User, ctx.Repo.Repository.ID, "verify")
+	if err != nil {
+		if models.IsErrLFSLockUnauthorizedAction(err) {
+			ctx.JSON(403, api.LFSLockError{
+				Message: "You must have push access to verify locks : " + err.Error(),
+			})
+			return
+		}
+		ctx.JSON(500, api.LFSLockError{
+			Message: "unable to verify lock : " + err.Error(),
+		})
+		return
+	}
+
+	//TODO handle body json cursor and limit
+	lockList, err := models.GetLFSLockByRepoID(ctx.Repo.Repository.ID)
+	if err != nil {
+		ctx.JSON(500, api.LFSLockError{
+			Message: "unable to list locks : " + err.Error(),
+		})
+		return
+	}
+	lockOursListAPI := make([]*api.LFSLock, 0, len(lockList))
+	lockTheirsListAPI := make([]*api.LFSLock, 0, len(lockList))
+	for _, l := range lockList {
+		if l.Owner.ID == ctx.User.ID {
+			lockOursListAPI = append(lockOursListAPI, l.APIFormat())
+		} else {
+			lockTheirsListAPI = append(lockTheirsListAPI, l.APIFormat())
+		}
+	}
+	ctx.JSON(200, api.LFSLockListVerify{
+		Ours:   lockOursListAPI,
+		Theirs: lockTheirsListAPI,
+	})
+}
+
+// UnLockHandler delete locks
+func UnLockHandler(ctx *context.Context) {
+	status := checkRequest(ctx.Req)
+	if status != 200 {
+		writeStatus(ctx, status)
+		return
+	}
+	ctx.Resp.Header().Set("Content-Type", metaMediaType)
+
+	var req api.LFSLockDeleteRequest
+	dec := json.NewDecoder(ctx.Req.Body().ReadCloser())
+	err := dec.Decode(&req)
+	if err != nil {
+		writeStatus(ctx, 400)
+		return
+	}
+
+	lock, err := models.DeleteLFSLockByID(ctx.ParamsInt64("lid"), ctx.User, req.Force)
+	if err != nil {
+		if models.IsErrLFSLockUnauthorizedAction(err) {
+			ctx.JSON(403, api.LFSLockError{
+				Message: "You must have push access to delete locks : " + err.Error(),
+			})
+			return
+		}
+		ctx.JSON(500, api.LFSLockError{
+			Message: "unable to delete lock : " + err.Error(),
+		})
+		return
+	}
+	ctx.JSON(200, api.LFSLockResponse{Lock: lock.APIFormat()})
+}
diff --git a/routers/routes/routes.go b/routers/routes/routes.go
index 9a42ef68d3..2945c31476 100644
--- a/routers/routes/routes.go
+++ b/routers/routes/routes.go
@@ -685,6 +685,12 @@ func RegisterRoutes(m *macaron.Macaron) {
 				m.Any("/objects/:oid", lfs.ObjectOidHandler)
 				m.Post("/objects", lfs.PostHandler)
 				m.Post("/verify", lfs.VerifyHandler)
+				m.Group("/locks", func() {
+					m.Get("/", lfs.GetListLockHandler)
+					m.Post("/", lfs.PostLockHandler)
+					m.Post("/verify", lfs.VerifyLockHandler)
+					m.Post("/:lid/unlock", lfs.UnLockHandler)
+				}, context.RepoAssignment())
 				m.Any("/*", func(ctx *context.Context) {
 					ctx.Handle(404, "", nil)
 				})