Skip to content

Commit 2e7ccec

Browse files
fabian-zlunny
authored andcommitted
Git LFS support v2 (#122)
* Import github.com/git-lfs/lfs-test-server as lfs module base Imported commit is 3968aac269a77b73924649b9412ae03f7ccd3198 Removed: Dockerfile CONTRIBUTING.md mgmt* script/ vendor/ kvlogger.go .dockerignore .gitignore README.md * Remove config, add JWT support from github.com/mgit-at/lfs-test-server Imported commit f0cdcc5a01599c5a955dc1bbf683bb4acecdba83 * Add LFS settings * Add LFS meta object model * Add LFS routes and initialization * Import github.com/dgrijalva/jwt-go into vendor/ * Adapt LFS module: handlers, routing, meta store * Move LFS routes to /user/repo/info/lfs/* * Add request header checks to LFS BatchHandler / PostHandler * Implement LFS basic authentication * Rework JWT secret generation / load * Implement LFS SSH token authentication with JWT Specification: https://github.com/github/git-lfs/tree/master/docs/api * Integrate LFS settings into install process * Remove LFS objects when repository is deleted Only removes objects from content store when deleted repo is the only referencing repository * Make LFS module stateless Fixes bug where LFS would not work after installation without restarting Gitea * Change 500 'Internal Server Error' to 400 'Bad Request' * Change sql query to xorm call * Remove unneeded type from LFS module * Change internal imports to code.gitea.io/gitea/ * Add Gitea authors copyright * Change basic auth realm to "gitea-lfs" * Add unique indexes to LFS model * Use xorm count function in LFS check on repository delete * Return io.ReadCloser from content store and close after usage * Add LFS info to runWeb() * Export LFS content store base path * LFS file download from UI * Work around git-lfs client issue with unauthenticated requests Returning a dummy Authorization header for unauthenticated requests lets git-lfs client skip asking for auth credentials See: git-lfs/git-lfs#1088 * Fix unauthenticated UI downloads from public repositories * Authentication check order, Finish LFS file view logic * Ignore LFS hooks if installed for current OS user Fixes Gitea UI actions for repositories tracking LFS files. Checks for minimum needed git version by parsing the semantic version string. * Hide LFS metafile diff from commit view, marking as binary * Show LFS notice if file in commit view is tracked * Add notbefore/nbf JWT claim * Correct lint suggestions - comments for structs and functions - Add comments to LFS model - Function comment for GetRandomBytesAsBase64 - LFS server function comments and lint variable suggestion * Move secret generation code out of conditional Ensures no LFS code may run with an empty secret * Do not hand out JWT tokens if LFS server support is disabled
1 parent 4b7594d commit 2e7ccec

37 files changed

+2632
-11
lines changed

cmd/serve.go

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package cmd
77

88
import (
99
"crypto/tls"
10+
"encoding/json"
1011
"fmt"
1112
"os"
1213
"os/exec"
@@ -21,12 +22,14 @@ import (
2122
"code.gitea.io/gitea/modules/log"
2223
"code.gitea.io/gitea/modules/setting"
2324
"github.com/Unknwon/com"
25+
"github.com/dgrijalva/jwt-go"
2426
gouuid "github.com/satori/go.uuid"
2527
"github.com/urfave/cli"
2628
)
2729

2830
const (
29-
accessDenied = "Repository does not exist or you do not have access"
31+
accessDenied = "Repository does not exist or you do not have access"
32+
lfsAuthenticateVerb = "git-lfs-authenticate"
3033
)
3134

3235
// CmdServ represents the available serv sub-command.
@@ -73,6 +76,7 @@ var (
7376
"git-upload-pack": models.AccessModeRead,
7477
"git-upload-archive": models.AccessModeRead,
7578
"git-receive-pack": models.AccessModeWrite,
79+
lfsAuthenticateVerb: models.AccessModeNone,
7680
}
7781
)
7882

@@ -161,6 +165,21 @@ func runServ(c *cli.Context) error {
161165
}
162166

163167
verb, args := parseCmd(cmd)
168+
169+
var lfsVerb string
170+
if verb == lfsAuthenticateVerb {
171+
172+
if !setting.LFS.StartServer {
173+
fail("Unknown git command", "LFS authentication request over SSH denied, LFS support is disabled")
174+
}
175+
176+
if strings.Contains(args, " ") {
177+
argsSplit := strings.SplitN(args, " ", 2)
178+
args = strings.TrimSpace(argsSplit[0])
179+
lfsVerb = strings.TrimSpace(argsSplit[1])
180+
}
181+
}
182+
164183
repoPath := strings.ToLower(strings.Trim(args, "'"))
165184
rr := strings.SplitN(repoPath, "/", 2)
166185
if len(rr) != 2 {
@@ -196,6 +215,14 @@ func runServ(c *cli.Context) error {
196215
fail("Unknown git command", "Unknown git command %s", verb)
197216
}
198217

218+
if verb == lfsAuthenticateVerb {
219+
if lfsVerb == "upload" {
220+
requestedMode = models.AccessModeWrite
221+
} else {
222+
requestedMode = models.AccessModeRead
223+
}
224+
}
225+
199226
// Prohibit push to mirror repositories.
200227
if requestedMode > models.AccessModeRead && repo.IsMirror {
201228
fail("mirror repository is read-only", "")
@@ -261,6 +288,41 @@ func runServ(c *cli.Context) error {
261288
}
262289
}
263290

291+
//LFS token authentication
292+
293+
if verb == lfsAuthenticateVerb {
294+
295+
url := fmt.Sprintf("%s%s/%s.git/info/lfs", setting.AppURL, repoUser.Name, repo.Name)
296+
297+
now := time.Now()
298+
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
299+
"repo": repo.ID,
300+
"op": lfsVerb,
301+
"exp": now.Add(5 * time.Minute).Unix(),
302+
"nbf": now.Unix(),
303+
})
304+
305+
// Sign and get the complete encoded token as a string using the secret
306+
tokenString, err := token.SignedString(setting.LFS.JWTSecretBytes)
307+
if err != nil {
308+
fail("Internal error", "Failed to sign JWT token: %v", err)
309+
}
310+
311+
tokenAuthentication := &models.LFSTokenResponse{
312+
Header: make(map[string]string),
313+
Href: url,
314+
}
315+
tokenAuthentication.Header["Authorization"] = fmt.Sprintf("Bearer %s", tokenString)
316+
317+
enc := json.NewEncoder(os.Stdout)
318+
err = enc.Encode(tokenAuthentication)
319+
if err != nil {
320+
fail("Internal error", "Failed to encode LFS json response: %v", err)
321+
}
322+
323+
return nil
324+
}
325+
264326
uuid := gouuid.NewV4().String()
265327
os.Setenv("GITEA_UUID", uuid)
266328
// Keep the old env variable name for backward compability

cmd/web.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"code.gitea.io/gitea/models"
1818
"code.gitea.io/gitea/modules/auth"
1919
"code.gitea.io/gitea/modules/context"
20+
"code.gitea.io/gitea/modules/lfs"
2021
"code.gitea.io/gitea/modules/log"
2122
"code.gitea.io/gitea/modules/options"
2223
"code.gitea.io/gitea/modules/public"
@@ -29,6 +30,7 @@ import (
2930
"code.gitea.io/gitea/routers/org"
3031
"code.gitea.io/gitea/routers/repo"
3132
"code.gitea.io/gitea/routers/user"
33+
3234
"github.com/go-macaron/binding"
3335
"github.com/go-macaron/cache"
3436
"github.com/go-macaron/captcha"
@@ -564,6 +566,12 @@ func runWeb(ctx *cli.Context) error {
564566
}, ignSignIn, context.RepoAssignment(true), context.RepoRef())
565567

566568
m.Group("/:reponame", func() {
569+
m.Group("/info/lfs", func() {
570+
m.Post("/objects/batch", lfs.BatchHandler)
571+
m.Get("/objects/:oid/:filename", lfs.ObjectOidHandler)
572+
m.Any("/objects/:oid", lfs.ObjectOidHandler)
573+
m.Post("/objects", lfs.PostHandler)
574+
}, ignSignInAndCsrf)
567575
m.Any("/*", ignSignInAndCsrf, repo.HTTP)
568576
m.Head("/tasks/trigger", repo.TriggerTask)
569577
})
@@ -600,6 +608,10 @@ func runWeb(ctx *cli.Context) error {
600608
}
601609
log.Info("Listen: %v://%s%s", setting.Protocol, listenAddr, setting.AppSubURL)
602610

611+
if setting.LFS.StartServer {
612+
log.Info("LFS server enabled")
613+
}
614+
603615
var err error
604616
switch setting.Protocol {
605617
case setting.HTTP:

models/git_diff.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ type DiffFile struct {
200200
IsCreated bool
201201
IsDeleted bool
202202
IsBin bool
203+
IsLFSFile bool
203204
IsRenamed bool
204205
IsSubmodule bool
205206
Sections []*DiffSection
@@ -245,6 +246,7 @@ func ParsePatch(maxLines, maxLineCharacteres, maxFiles int, reader io.Reader) (*
245246
leftLine, rightLine int
246247
lineCount int
247248
curFileLinesCount int
249+
curFileLFSPrefix bool
248250
)
249251

250252
input := bufio.NewReader(reader)
@@ -268,6 +270,28 @@ func ParsePatch(maxLines, maxLineCharacteres, maxFiles int, reader io.Reader) (*
268270
continue
269271
}
270272

273+
trimLine := strings.Trim(line, "+- ")
274+
275+
if trimLine == LFSMetaFileIdentifier {
276+
curFileLFSPrefix = true
277+
}
278+
279+
if curFileLFSPrefix && strings.HasPrefix(trimLine, LFSMetaFileOidPrefix) {
280+
oid := strings.TrimPrefix(trimLine, LFSMetaFileOidPrefix)
281+
282+
if len(oid) == 64 {
283+
m := &LFSMetaObject{Oid: oid}
284+
count, err := x.Count(m)
285+
286+
if err == nil && count > 0 {
287+
curFile.IsBin = true
288+
curFile.IsLFSFile = true
289+
curSection.Lines = nil
290+
break
291+
}
292+
}
293+
}
294+
271295
curFileLinesCount++
272296
lineCount++
273297

@@ -354,6 +378,7 @@ func ParsePatch(maxLines, maxLineCharacteres, maxFiles int, reader io.Reader) (*
354378
break
355379
}
356380
curFileLinesCount = 0
381+
curFileLFSPrefix = false
357382

358383
// Check file diff type and is submodule.
359384
for {

models/lfs.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package models
2+
3+
import (
4+
"errors"
5+
"github.com/go-xorm/xorm"
6+
"time"
7+
)
8+
9+
// LFSMetaObject stores metadata for LFS tracked files.
10+
type LFSMetaObject struct {
11+
ID int64 `xorm:"pk autoincr"`
12+
Oid string `xorm:"UNIQUE(s) INDEX NOT NULL"`
13+
Size int64 `xorm:"NOT NULL"`
14+
RepositoryID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"`
15+
Existing bool `xorm:"-"`
16+
Created time.Time `xorm:"-"`
17+
CreatedUnix int64
18+
}
19+
20+
// LFSTokenResponse defines the JSON structure in which the JWT token is stored.
21+
// This structure is fetched via SSH and passed by the Git LFS client to the server
22+
// endpoint for authorization.
23+
type LFSTokenResponse struct {
24+
Header map[string]string `json:"header"`
25+
Href string `json:"href"`
26+
}
27+
28+
var (
29+
// ErrLFSObjectNotExist is returned from lfs models functions in order
30+
// to differentiate between database and missing object errors.
31+
ErrLFSObjectNotExist = errors.New("LFS Meta object does not exist")
32+
)
33+
34+
const (
35+
// LFSMetaFileIdentifier is the string appearing at the first line of LFS pointer files.
36+
// https://github.com/git-lfs/git-lfs/blob/master/docs/spec.md
37+
LFSMetaFileIdentifier = "version https://git-lfs.github.com/spec/v1"
38+
39+
// LFSMetaFileOidPrefix appears in LFS pointer files on a line before the sha256 hash.
40+
LFSMetaFileOidPrefix = "oid sha256:"
41+
)
42+
43+
// NewLFSMetaObject stores a given populated LFSMetaObject structure in the database
44+
// if it is not already present.
45+
func NewLFSMetaObject(m *LFSMetaObject) (*LFSMetaObject, error) {
46+
var err error
47+
48+
has, err := x.Get(m)
49+
if err != nil {
50+
return nil, err
51+
}
52+
53+
if has {
54+
m.Existing = true
55+
return m, nil
56+
}
57+
58+
sess := x.NewSession()
59+
defer sessionRelease(sess)
60+
if err = sess.Begin(); err != nil {
61+
return nil, err
62+
}
63+
64+
if _, err = sess.Insert(m); err != nil {
65+
return nil, err
66+
}
67+
68+
return m, sess.Commit()
69+
}
70+
71+
// GetLFSMetaObjectByOid selects a LFSMetaObject entry from database by its OID.
72+
// It may return ErrLFSObjectNotExist or a database error. If the error is nil,
73+
// the returned pointer is a valid LFSMetaObject.
74+
func GetLFSMetaObjectByOid(oid string) (*LFSMetaObject, error) {
75+
if len(oid) == 0 {
76+
return nil, ErrLFSObjectNotExist
77+
}
78+
79+
m := &LFSMetaObject{Oid: oid}
80+
has, err := x.Get(m)
81+
if err != nil {
82+
return nil, err
83+
} else if !has {
84+
return nil, ErrLFSObjectNotExist
85+
}
86+
return m, nil
87+
}
88+
89+
// RemoveLFSMetaObjectByOid removes a LFSMetaObject entry from database by its OID.
90+
// It may return ErrLFSObjectNotExist or a database error.
91+
func RemoveLFSMetaObjectByOid(oid string) error {
92+
if len(oid) == 0 {
93+
return ErrLFSObjectNotExist
94+
}
95+
96+
sess := x.NewSession()
97+
defer sessionRelease(sess)
98+
if err := sess.Begin(); err != nil {
99+
return err
100+
}
101+
102+
m := &LFSMetaObject{Oid: oid}
103+
104+
if _, err := sess.Delete(m); err != nil {
105+
return err
106+
}
107+
108+
return sess.Commit()
109+
}
110+
111+
// BeforeInsert sets the time at which the LFSMetaObject was created.
112+
func (m *LFSMetaObject) BeforeInsert() {
113+
m.CreatedUnix = time.Now().Unix()
114+
}
115+
116+
// AfterSet stores the LFSMetaObject creation time in the database as local time.
117+
func (m *LFSMetaObject) AfterSet(colName string, _ xorm.Cell) {
118+
switch colName {
119+
case "created_unix":
120+
m.Created = time.Unix(m.CreatedUnix, 0).Local()
121+
}
122+
}

models/models.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ func init() {
7979
new(Mirror), new(Release), new(LoginSource), new(Webhook),
8080
new(UpdateTask), new(HookTask),
8181
new(Team), new(OrgUser), new(TeamUser), new(TeamRepo),
82-
new(Notice), new(EmailAddress))
82+
new(Notice), new(EmailAddress), new(LFSMetaObject))
8383

8484
gonicNames := []string{"SSL", "UID"}
8585
for _, name := range gonicNames {

0 commit comments

Comments
 (0)