Browse Source

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
Antoine GIRARD 2 years ago
parent
commit
d99f4ab003

File diff suppressed because it is too large
+ 176 - 0
integrations/api_repo_lfs_locks_test.go


+ 1 - 2
integrations/mysql.ini.tmpl

@@ -27,7 +27,7 @@ HTTP_PORT        = 3001
27 27
 ROOT_URL         = http://localhost:3001/
28 28
 DISABLE_SSH      = false
29 29
 SSH_PORT         = 22
30
-LFS_START_SERVER = false
30
+LFS_START_SERVER = true
31 31
 OFFLINE_MODE     = false
32 32
 
33 33
 [mailer]
@@ -65,4 +65,3 @@ LEVEL = Debug
65 65
 INSTALL_LOCK   = true
66 66
 SECRET_KEY     = 9pCviYTWSb
67 67
 INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0OTU1NTE2MTh9.hhSVGOANkaKk3vfCd2jDOIww4pUk0xtg9JRde5UogyQ
68
-

+ 1 - 1
integrations/pgsql.ini.tmpl

@@ -27,7 +27,7 @@ HTTP_PORT        = 3002
27 27
 ROOT_URL         = http://localhost:3002/
28 28
 DISABLE_SSH      = false
29 29
 SSH_PORT         = 22
30
-LFS_START_SERVER = false
30
+LFS_START_SERVER = true
31 31
 OFFLINE_MODE     = false
32 32
 
33 33
 [mailer]

+ 14 - 13
integrations/sqlite.ini

@@ -2,13 +2,13 @@ APP_NAME = Gitea: Git with a cup of tea
2 2
 RUN_MODE = prod
3 3
 
4 4
 [database]
5
-DB_TYPE  = sqlite3
6
-PATH     = :memory:
5
+DB_TYPE = sqlite3
6
+PATH    = :memory:
7 7
 
8 8
 [indexer]
9
-ISSUE_INDEXER_PATH = integrations/indexers-sqlite/issues.bleve
9
+ISSUE_INDEXER_PATH   = integrations/indexers-sqlite/issues.bleve
10 10
 REPO_INDEXER_ENABLED = true
11
-REPO_INDEXER_PATH = integrations/indexers-sqlite/repos.bleve
11
+REPO_INDEXER_PATH    = integrations/indexers-sqlite/repos.bleve
12 12
 
13 13
 [repository]
14 14
 ROOT = integrations/gitea-integration-sqlite/gitea-repositories
@@ -22,21 +22,22 @@ HTTP_PORT        = 3003
22 22
 ROOT_URL         = http://localhost:3003/
23 23
 DISABLE_SSH      = false
24 24
 SSH_PORT         = 22
25
-LFS_START_SERVER = false
25
+LFS_START_SERVER = true
26 26
 OFFLINE_MODE     = false
27
+LFS_JWT_SECRET   = Tv_MjmZuHqpIY6GFl12ebgkRAMt4RlWt0v4EHKSXO0w
27 28
 
28 29
 [mailer]
29 30
 ENABLED = false
30 31
 
31 32
 [service]
32
-REGISTER_EMAIL_CONFIRM     = false
33
-ENABLE_NOTIFY_MAIL         = false
34
-DISABLE_REGISTRATION       = false
35
-ENABLE_CAPTCHA             = false
36
-REQUIRE_SIGNIN_VIEW        = false
37
-DEFAULT_KEEP_EMAIL_PRIVATE = false
33
+REGISTER_EMAIL_CONFIRM            = false
34
+ENABLE_NOTIFY_MAIL                = false
35
+DISABLE_REGISTRATION              = false
36
+ENABLE_CAPTCHA                    = false
37
+REQUIRE_SIGNIN_VIEW               = false
38
+DEFAULT_KEEP_EMAIL_PRIVATE        = false
38 39
 DEFAULT_ALLOW_CREATE_ORGANIZATION = true
39
-NO_REPLY_ADDRESS           = noreply.example.org
40
+NO_REPLY_ADDRESS                  = noreply.example.org
40 41
 
41 42
 [picture]
42 43
 DISABLE_GRAVATAR        = false
@@ -46,7 +47,7 @@ ENABLE_FEDERATED_AVATAR = false
46 47
 PROVIDER = file
47 48
 
48 49
 [log]
49
-MODE = console,file
50
+MODE      = console,file
50 51
 ROOT_PATH = sqlite-log
51 52
 
52 53
 [log.console]

+ 57 - 0
models/error.go

@@ -506,6 +506,63 @@ func (err ErrLastOrgOwner) Error() string {
506 506
 	return fmt.Sprintf("user is the last member of owner team [uid: %d]", err.UID)
507 507
 }
508 508
 
509
+//.____   ____________________
510
+//|    |  \_   _____/   _____/
511
+//|    |   |    __) \_____  \
512
+//|    |___|     \  /        \
513
+//|_______ \___  / /_______  /
514
+//        \/   \/          \/
515
+
516
+// ErrLFSLockNotExist represents a "LFSLockNotExist" kind of error.
517
+type ErrLFSLockNotExist struct {
518
+	ID     int64
519
+	RepoID int64
520
+	Path   string
521
+}
522
+
523
+// IsErrLFSLockNotExist checks if an error is a ErrLFSLockNotExist.
524
+func IsErrLFSLockNotExist(err error) bool {
525
+	_, ok := err.(ErrLFSLockNotExist)
526
+	return ok
527
+}
528
+
529
+func (err ErrLFSLockNotExist) Error() string {
530
+	return fmt.Sprintf("lfs lock does not exist [id: %d, rid: %d, path: %s]", err.ID, err.RepoID, err.Path)
531
+}
532
+
533
+// ErrLFSLockUnauthorizedAction represents a "LFSLockUnauthorizedAction" kind of error.
534
+type ErrLFSLockUnauthorizedAction struct {
535
+	RepoID   int64
536
+	UserName string
537
+	Action   string
538
+}
539
+
540
+// IsErrLFSLockUnauthorizedAction checks if an error is a ErrLFSLockUnauthorizedAction.
541
+func IsErrLFSLockUnauthorizedAction(err error) bool {
542
+	_, ok := err.(ErrLFSLockUnauthorizedAction)
543
+	return ok
544
+}
545
+
546
+func (err ErrLFSLockUnauthorizedAction) Error() string {
547
+	return fmt.Sprintf("User %s doesn't have rigth to %s for lfs lock [rid: %d]", err.UserName, err.Action, err.RepoID)
548
+}
549
+
550
+// ErrLFSLockAlreadyExist represents a "LFSLockAlreadyExist" kind of error.
551
+type ErrLFSLockAlreadyExist struct {
552
+	RepoID int64
553
+	Path   string
554
+}
555
+
556
+// IsErrLFSLockAlreadyExist checks if an error is a ErrLFSLockAlreadyExist.
557
+func IsErrLFSLockAlreadyExist(err error) bool {
558
+	_, ok := err.(ErrLFSLockAlreadyExist)
559
+	return ok
560
+}
561
+
562
+func (err ErrLFSLockAlreadyExist) Error() string {
563
+	return fmt.Sprintf("lfs lock already exists [rid: %d, path: %s]", err.RepoID, err.Path)
564
+}
565
+
509 566
 // __________                           .__  __
510 567
 // \______   \ ____ ______   ____  _____|__|/  |_  ___________ ___.__.
511 568
 //  |       _// __ \\____ \ /  _ \/  ___/  \   __\/  _ \_  __ <   |  |

+ 146 - 0
models/lfs_lock.go

@@ -0,0 +1,146 @@
1
+// Copyright 2017 The Gitea Authors. All rights reserved.
2
+// Use of this source code is governed by a MIT-style
3
+// license that can be found in the LICENSE file.
4
+
5
+package models
6
+
7
+import (
8
+	"fmt"
9
+	"path"
10
+	"strconv"
11
+	"strings"
12
+	"time"
13
+
14
+	api "code.gitea.io/sdk/gitea"
15
+)
16
+
17
+// LFSLock represents a git lfs lock of repository.
18
+type LFSLock struct {
19
+	ID      int64     `xorm:"pk autoincr"`
20
+	RepoID  int64     `xorm:"INDEX NOT NULL"`
21
+	Owner   *User     `xorm:"-"`
22
+	OwnerID int64     `xorm:"INDEX NOT NULL"`
23
+	Path    string    `xorm:"TEXT"`
24
+	Created time.Time `xorm:"created"`
25
+}
26
+
27
+// BeforeInsert is invoked from XORM before inserting an object of this type.
28
+func (l *LFSLock) BeforeInsert() {
29
+	l.OwnerID = l.Owner.ID
30
+	l.Path = cleanPath(l.Path)
31
+}
32
+
33
+// AfterLoad is invoked from XORM after setting the values of all fields of this object.
34
+func (l *LFSLock) AfterLoad() {
35
+	l.Owner, _ = GetUserByID(l.OwnerID)
36
+}
37
+
38
+func cleanPath(p string) string {
39
+	return strings.ToLower(path.Clean(p))
40
+}
41
+
42
+// APIFormat convert a Release to lfs.LFSLock
43
+func (l *LFSLock) APIFormat() *api.LFSLock {
44
+	return &api.LFSLock{
45
+		ID:       strconv.FormatInt(l.ID, 10),
46
+		Path:     l.Path,
47
+		LockedAt: l.Created,
48
+		Owner: &api.LFSLockOwner{
49
+			Name: l.Owner.DisplayName(),
50
+		},
51
+	}
52
+}
53
+
54
+// CreateLFSLock creates a new lock.
55
+func CreateLFSLock(lock *LFSLock) (*LFSLock, error) {
56
+	err := CheckLFSAccessForRepo(lock.Owner, lock.RepoID, "create")
57
+	if err != nil {
58
+		return nil, err
59
+	}
60
+
61
+	l, err := GetLFSLock(lock.RepoID, lock.Path)
62
+	if err == nil {
63
+		return l, ErrLFSLockAlreadyExist{lock.RepoID, lock.Path}
64
+	}
65
+	if !IsErrLFSLockNotExist(err) {
66
+		return nil, err
67
+	}
68
+
69
+	_, err = x.InsertOne(lock)
70
+	return lock, err
71
+}
72
+
73
+// GetLFSLock returns release by given path.
74
+func GetLFSLock(repoID int64, path string) (*LFSLock, error) {
75
+	path = cleanPath(path)
76
+	rel := &LFSLock{RepoID: repoID, Path: path}
77
+	has, err := x.Get(rel)
78
+	if err != nil {
79
+		return nil, err
80
+	}
81
+	if !has {
82
+		return nil, ErrLFSLockNotExist{0, repoID, path}
83
+	}
84
+	return rel, nil
85
+}
86
+
87
+// GetLFSLockByID returns release by given id.
88
+func GetLFSLockByID(id int64) (*LFSLock, error) {
89
+	lock := new(LFSLock)
90
+	has, err := x.ID(id).Get(lock)
91
+	if err != nil {
92
+		return nil, err
93
+	} else if !has {
94
+		return nil, ErrLFSLockNotExist{id, 0, ""}
95
+	}
96
+	return lock, nil
97
+}
98
+
99
+// GetLFSLockByRepoID returns a list of locks of repository.
100
+func GetLFSLockByRepoID(repoID int64) (locks []*LFSLock, err error) {
101
+	err = x.Where("repo_id = ?", repoID).Find(&locks)
102
+	return
103
+}
104
+
105
+// DeleteLFSLockByID deletes a lock by given ID.
106
+func DeleteLFSLockByID(id int64, u *User, force bool) (*LFSLock, error) {
107
+	lock, err := GetLFSLockByID(id)
108
+	if err != nil {
109
+		return nil, err
110
+	}
111
+
112
+	err = CheckLFSAccessForRepo(u, lock.RepoID, "delete")
113
+	if err != nil {
114
+		return nil, err
115
+	}
116
+
117
+	if !force && u.ID != lock.OwnerID {
118
+		return nil, fmt.Errorf("user doesn't own lock and force flag is not set")
119
+	}
120
+
121
+	_, err = x.ID(id).Delete(new(LFSLock))
122
+	return lock, err
123
+}
124
+
125
+//CheckLFSAccessForRepo check needed access mode base on action
126
+func CheckLFSAccessForRepo(u *User, repoID int64, action string) error {
127
+	if u == nil {
128
+		return ErrLFSLockUnauthorizedAction{repoID, "undefined", action}
129
+	}
130
+	mode := AccessModeRead
131
+	if action == "create" || action == "delete" || action == "verify" {
132
+		mode = AccessModeWrite
133
+	}
134
+
135
+	repo, err := GetRepositoryByID(repoID)
136
+	if err != nil {
137
+		return err
138
+	}
139
+	has, err := HasAccess(u.ID, repo, mode)
140
+	if err != nil {
141
+		return err
142
+	} else if !has {
143
+		return ErrLFSLockUnauthorizedAction{repo.ID, u.DisplayName(), action}
144
+	}
145
+	return nil
146
+}

+ 1 - 0
models/models.go

@@ -117,6 +117,7 @@ func init() {
117 117
 		new(TrackedTime),
118 118
 		new(DeletedBranch),
119 119
 		new(RepoIndexerStatus),
120
+		new(LFSLock),
120 121
 	)
121 122
 
122 123
 	gonicNames := []string{"SSL", "UID"}

+ 236 - 0
modules/lfs/locks.go

@@ -0,0 +1,236 @@
1
+// Copyright 2017 The Gitea Authors. All rights reserved.
2
+// Use of this source code is governed by a MIT-style
3
+// license that can be found in the LICENSE file.
4
+
5
+package lfs
6
+
7
+import (
8
+	"encoding/json"
9
+	"strconv"
10
+
11
+	"code.gitea.io/gitea/models"
12
+	"code.gitea.io/gitea/modules/context"
13
+	"code.gitea.io/gitea/modules/setting"
14
+	api "code.gitea.io/sdk/gitea"
15
+
16
+	"gopkg.in/macaron.v1"
17
+)
18
+
19
+func checkRequest(req macaron.Request) int {
20
+	if !setting.LFS.StartServer {
21
+		return 404
22
+	}
23
+	if !MetaMatcher(req) || req.Header.Get("Content-Type") != metaMediaType {
24
+		return 400
25
+	}
26
+	return 200
27
+}
28
+
29
+func handleLockListOut(ctx *context.Context, lock *models.LFSLock, err error) {
30
+	if err != nil {
31
+		if models.IsErrLFSLockNotExist(err) {
32
+			ctx.JSON(200, api.LFSLockList{
33
+				Locks: []*api.LFSLock{},
34
+			})
35
+			return
36
+		}
37
+		ctx.JSON(500, api.LFSLockError{
38
+			Message: "unable to list locks : " + err.Error(),
39
+		})
40
+		return
41
+	}
42
+	if ctx.Repo.Repository.ID != lock.RepoID {
43
+		ctx.JSON(200, api.LFSLockList{
44
+			Locks: []*api.LFSLock{},
45
+		})
46
+		return
47
+	}
48
+	ctx.JSON(200, api.LFSLockList{
49
+		Locks: []*api.LFSLock{lock.APIFormat()},
50
+	})
51
+}
52
+
53
+// GetListLockHandler list locks
54
+func GetListLockHandler(ctx *context.Context) {
55
+	status := checkRequest(ctx.Req)
56
+	if status != 200 {
57
+		writeStatus(ctx, status)
58
+		return
59
+	}
60
+	ctx.Resp.Header().Set("Content-Type", metaMediaType)
61
+
62
+	err := models.CheckLFSAccessForRepo(ctx.User, ctx.Repo.Repository.ID, "list")
63
+	if err != nil {
64
+		if models.IsErrLFSLockUnauthorizedAction(err) {
65
+			ctx.JSON(403, api.LFSLockError{
66
+				Message: "You must have pull access to list locks : " + err.Error(),
67
+			})
68
+			return
69
+		}
70
+		ctx.JSON(500, api.LFSLockError{
71
+			Message: "unable to list lock : " + err.Error(),
72
+		})
73
+		return
74
+	}
75
+	//TODO handle query cursor and limit
76
+	id := ctx.Query("id")
77
+	if id != "" { //Case where we request a specific id
78
+		v, err := strconv.ParseInt(id, 10, 64)
79
+		if err != nil {
80
+			ctx.JSON(400, api.LFSLockError{
81
+				Message: "bad request : " + err.Error(),
82
+			})
83
+			return
84
+		}
85
+		lock, err := models.GetLFSLockByID(int64(v))
86
+		handleLockListOut(ctx, lock, err)
87
+		return
88
+	}
89
+
90
+	path := ctx.Query("path")
91
+	if path != "" { //Case where we request a specific id
92
+		lock, err := models.GetLFSLock(ctx.Repo.Repository.ID, path)
93
+		handleLockListOut(ctx, lock, err)
94
+		return
95
+	}
96
+
97
+	//If no query params path or id
98
+	lockList, err := models.GetLFSLockByRepoID(ctx.Repo.Repository.ID)
99
+	if err != nil {
100
+		ctx.JSON(500, api.LFSLockError{
101
+			Message: "unable to list locks : " + err.Error(),
102
+		})
103
+		return
104
+	}
105
+	lockListAPI := make([]*api.LFSLock, len(lockList))
106
+	for i, l := range lockList {
107
+		lockListAPI[i] = l.APIFormat()
108
+	}
109
+	ctx.JSON(200, api.LFSLockList{
110
+		Locks: lockListAPI,
111
+	})
112
+}
113
+
114
+// PostLockHandler create lock
115
+func PostLockHandler(ctx *context.Context) {
116
+	status := checkRequest(ctx.Req)
117
+	if status != 200 {
118
+		writeStatus(ctx, status)
119
+		return
120
+	}
121
+	ctx.Resp.Header().Set("Content-Type", metaMediaType)
122
+
123
+	var req api.LFSLockRequest
124
+	dec := json.NewDecoder(ctx.Req.Body().ReadCloser())
125
+	err := dec.Decode(&req)
126
+	if err != nil {
127
+		writeStatus(ctx, 400)
128
+		return
129
+	}
130
+
131
+	lock, err := models.CreateLFSLock(&models.LFSLock{
132
+		RepoID: ctx.Repo.Repository.ID,
133
+		Path:   req.Path,
134
+		Owner:  ctx.User,
135
+	})
136
+	if err != nil {
137
+		if models.IsErrLFSLockAlreadyExist(err) {
138
+			ctx.JSON(409, api.LFSLockError{
139
+				Lock:    lock.APIFormat(),
140
+				Message: "already created lock",
141
+			})
142
+			return
143
+		}
144
+		if models.IsErrLFSLockUnauthorizedAction(err) {
145
+			ctx.JSON(403, api.LFSLockError{
146
+				Message: "You must have push access to create locks : " + err.Error(),
147
+			})
148
+			return
149
+		}
150
+		ctx.JSON(500, api.LFSLockError{
151
+			Message: "internal server error : " + err.Error(),
152
+		})
153
+		return
154
+	}
155
+	ctx.JSON(201, api.LFSLockResponse{Lock: lock.APIFormat()})
156
+}
157
+
158
+// VerifyLockHandler list locks for verification
159
+func VerifyLockHandler(ctx *context.Context) {
160
+	status := checkRequest(ctx.Req)
161
+	if status != 200 {
162
+		writeStatus(ctx, status)
163
+		return
164
+	}
165
+
166
+	ctx.Resp.Header().Set("Content-Type", metaMediaType)
167
+
168
+	err := models.CheckLFSAccessForRepo(ctx.User, ctx.Repo.Repository.ID, "verify")
169
+	if err != nil {
170
+		if models.IsErrLFSLockUnauthorizedAction(err) {
171
+			ctx.JSON(403, api.LFSLockError{
172
+				Message: "You must have push access to verify locks : " + err.Error(),
173
+			})
174
+			return
175
+		}
176
+		ctx.JSON(500, api.LFSLockError{
177
+			Message: "unable to verify lock : " + err.Error(),
178
+		})
179
+		return
180
+	}
181
+
182
+	//TODO handle body json cursor and limit
183
+	lockList, err := models.GetLFSLockByRepoID(ctx.Repo.Repository.ID)
184
+	if err != nil {
185
+		ctx.JSON(500, api.LFSLockError{
186
+			Message: "unable to list locks : " + err.Error(),
187
+		})
188
+		return
189
+	}
190
+	lockOursListAPI := make([]*api.LFSLock, 0, len(lockList))
191
+	lockTheirsListAPI := make([]*api.LFSLock, 0, len(lockList))
192
+	for _, l := range lockList {
193
+		if l.Owner.ID == ctx.User.ID {
194
+			lockOursListAPI = append(lockOursListAPI, l.APIFormat())
195
+		} else {
196
+			lockTheirsListAPI = append(lockTheirsListAPI, l.APIFormat())
197
+		}
198
+	}
199
+	ctx.JSON(200, api.LFSLockListVerify{
200
+		Ours:   lockOursListAPI,
201
+		Theirs: lockTheirsListAPI,
202
+	})
203
+}
204
+
205
+// UnLockHandler delete locks
206
+func UnLockHandler(ctx *context.Context) {
207
+	status := checkRequest(ctx.Req)
208
+	if status != 200 {
209
+		writeStatus(ctx, status)
210
+		return
211
+	}
212
+	ctx.Resp.Header().Set("Content-Type", metaMediaType)
213
+
214
+	var req api.LFSLockDeleteRequest
215
+	dec := json.NewDecoder(ctx.Req.Body().ReadCloser())
216
+	err := dec.Decode(&req)
217
+	if err != nil {
218
+		writeStatus(ctx, 400)
219
+		return
220
+	}
221
+
222
+	lock, err := models.DeleteLFSLockByID(ctx.ParamsInt64("lid"), ctx.User, req.Force)
223
+	if err != nil {
224
+		if models.IsErrLFSLockUnauthorizedAction(err) {
225
+			ctx.JSON(403, api.LFSLockError{
226
+				Message: "You must have push access to delete locks : " + err.Error(),
227
+			})
228
+			return
229
+		}
230
+		ctx.JSON(500, api.LFSLockError{
231
+			Message: "unable to delete lock : " + err.Error(),
232
+		})
233
+		return
234
+	}
235
+	ctx.JSON(200, api.LFSLockResponse{Lock: lock.APIFormat()})
236
+}

+ 6 - 0
routers/routes/routes.go

@@ -685,6 +685,12 @@ func RegisterRoutes(m *macaron.Macaron) {
685 685
 				m.Any("/objects/:oid", lfs.ObjectOidHandler)
686 686
 				m.Post("/objects", lfs.PostHandler)
687 687
 				m.Post("/verify", lfs.VerifyHandler)
688
+				m.Group("/locks", func() {
689
+					m.Get("/", lfs.GetListLockHandler)
690
+					m.Post("/", lfs.PostLockHandler)
691
+					m.Post("/verify", lfs.VerifyLockHandler)
692
+					m.Post("/:lid/unlock", lfs.UnLockHandler)
693
+				}, context.RepoAssignment())
688 694
 				m.Any("/*", func(ctx *context.Context) {
689 695
 					ctx.Handle(404, "", nil)
690 696
 				})