summaryrefslogtreecommitdiffstats
path: root/modules/lfs
diff options
context:
space:
mode:
authorKN4CK3R <KN4CK3R@users.noreply.github.com>2021-04-09 00:25:57 +0200
committerGitHub <noreply@github.com>2021-04-08 18:25:57 -0400
commitc03e488e14fdaf1c0056952f40c5fc8124719a30 (patch)
tree22338add91196fad9f40f9a74033525ad8f591eb /modules/lfs
parentf544414a232c148d4baf2e9d807f6cbffed67928 (diff)
downloadgitea-c03e488e14fdaf1c0056952f40c5fc8124719a30.tar.gz
gitea-c03e488e14fdaf1c0056952f40c5fc8124719a30.zip
Add LFS Migration and Mirror (#14726)
* Implemented LFS client. * Implemented scanning for pointer files. * Implemented downloading of lfs files. * Moved model-dependent code into services. * Removed models dependency. Added TryReadPointerFromBuffer. * Migrated code from service to module. * Centralised storage creation. * Removed dependency from models. * Moved ContentStore into modules. * Share structs between server and client. * Moved method to services. * Implemented lfs download on clone. * Implemented LFS sync on clone and mirror update. * Added form fields. * Updated templates. * Fixed condition. * Use alternate endpoint. * Added missing methods. * Fixed typo and make linter happy. * Detached pointer parser from gogit dependency. * Fixed TestGetLFSRange test. * Added context to support cancellation. * Use ReadFull to probably read more data. * Removed duplicated code from models. * Moved scan implementation into pointer_scanner_nogogit. * Changed method name. * Added comments. * Added more/specific log/error messages. * Embedded lfs.Pointer into models.LFSMetaObject. * Moved code from models to module. * Moved code from models to module. * Moved code from models to module. * Reduced pointer usage. * Embedded type. * Use promoted fields. * Fixed unexpected eof. * Added unit tests. * Implemented migration of local file paths. * Show an error on invalid LFS endpoints. * Hide settings if not used. * Added LFS info to mirror struct. * Fixed comment. * Check LFS endpoint. * Manage LFS settings from mirror page. * Fixed selector. * Adjusted selector. * Added more tests. * Added local filesystem migration test. * Fixed typo. * Reset settings. * Added special windows path handling. * Added unit test for HTTPClient. * Added unit test for BasicTransferAdapter. * Moved into util package. * Test if LFS endpoint is allowed. * Added support for git:// * Just use a static placeholder as the displayed url may be invalid. * Reverted to original code. * Added "Advanced Settings". * Updated wording. * Added discovery info link. * Implemented suggestion. * Fixed missing format parameter. * Added Pointer.IsValid(). * Always remove model on error. * Added suggestions. * Use channel instead of array. * Update routers/repo/migrate.go * fmt Signed-off-by: Andrew Thornton <art27@cantab.net> Co-authored-by: zeripath <art27@cantab.net>
Diffstat (limited to 'modules/lfs')
-rw-r--r--modules/lfs/client.go24
-rw-r--r--modules/lfs/client_test.go23
-rw-r--r--modules/lfs/content_store.go65
-rw-r--r--modules/lfs/endpoint.go106
-rw-r--r--modules/lfs/endpoint_test.go75
-rw-r--r--modules/lfs/filesystem_client.go50
-rw-r--r--modules/lfs/http_client.go129
-rw-r--r--modules/lfs/http_client_test.go144
-rw-r--r--modules/lfs/locks.go348
-rw-r--r--modules/lfs/pointer.go123
-rw-r--r--modules/lfs/pointer_scanner_gogit.go64
-rw-r--r--modules/lfs/pointer_scanner_nogogit.go110
-rw-r--r--modules/lfs/pointer_test.go103
-rw-r--r--modules/lfs/pointers.go71
-rw-r--r--modules/lfs/server.go712
-rw-r--r--modules/lfs/shared.go69
-rw-r--r--modules/lfs/transferadapter.go58
-rw-r--r--modules/lfs/transferadapter_test.go78
18 files changed, 1195 insertions, 1157 deletions
diff --git a/modules/lfs/client.go b/modules/lfs/client.go
new file mode 100644
index 0000000000..ae35919d77
--- /dev/null
+++ b/modules/lfs/client.go
@@ -0,0 +1,24 @@
+// Copyright 2021 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 (
+ "context"
+ "io"
+ "net/url"
+)
+
+// Client is used to communicate with a LFS source
+type Client interface {
+ Download(ctx context.Context, oid string, size int64) (io.ReadCloser, error)
+}
+
+// NewClient creates a LFS client
+func NewClient(endpoint *url.URL) Client {
+ if endpoint.Scheme == "file" {
+ return newFilesystemClient(endpoint)
+ }
+ return newHTTPClient(endpoint)
+}
diff --git a/modules/lfs/client_test.go b/modules/lfs/client_test.go
new file mode 100644
index 0000000000..d4eb005469
--- /dev/null
+++ b/modules/lfs/client_test.go
@@ -0,0 +1,23 @@
+// Copyright 2021 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 (
+ "net/url"
+
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNewClient(t *testing.T) {
+ u, _ := url.Parse("file:///test")
+ c := NewClient(u)
+ assert.IsType(t, &FilesystemClient{}, c)
+
+ u, _ = url.Parse("https://test.com/lfs")
+ c = NewClient(u)
+ assert.IsType(t, &HTTPClient{}, c)
+}
diff --git a/modules/lfs/content_store.go b/modules/lfs/content_store.go
index 520caa4c99..9fa2c7e3b2 100644
--- a/modules/lfs/content_store.go
+++ b/modules/lfs/content_store.go
@@ -13,14 +13,15 @@ import (
"io"
"os"
- "code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/storage"
)
var (
- errHashMismatch = errors.New("Content hash does not match OID")
- errSizeMismatch = errors.New("Content size does not match")
+ // ErrHashMismatch occurs if the content has does not match OID
+ ErrHashMismatch = errors.New("Content hash does not match OID")
+ // ErrSizeMismatch occurs if the content size does not match
+ ErrSizeMismatch = errors.New("Content size does not match")
)
// ErrRangeNotSatisfiable represents an error which request range is not satisfiable.
@@ -28,61 +29,67 @@ type ErrRangeNotSatisfiable struct {
FromByte int64
}
-func (err ErrRangeNotSatisfiable) Error() string {
- return fmt.Sprintf("Requested range %d is not satisfiable", err.FromByte)
-}
-
// IsErrRangeNotSatisfiable returns true if the error is an ErrRangeNotSatisfiable
func IsErrRangeNotSatisfiable(err error) bool {
_, ok := err.(ErrRangeNotSatisfiable)
return ok
}
+func (err ErrRangeNotSatisfiable) Error() string {
+ return fmt.Sprintf("Requested range %d is not satisfiable", err.FromByte)
+}
+
// ContentStore provides a simple file system based storage.
type ContentStore struct {
storage.ObjectStorage
}
+// NewContentStore creates the default ContentStore
+func NewContentStore() *ContentStore {
+ contentStore := &ContentStore{ObjectStorage: storage.LFS}
+ return contentStore
+}
+
// Get takes a Meta object and retrieves the content from the store, returning
// it as an io.ReadSeekCloser.
-func (s *ContentStore) Get(meta *models.LFSMetaObject) (storage.Object, error) {
- f, err := s.Open(meta.RelativePath())
+func (s *ContentStore) Get(pointer Pointer) (storage.Object, error) {
+ f, err := s.Open(pointer.RelativePath())
if err != nil {
- log.Error("Whilst trying to read LFS OID[%s]: Unable to open Error: %v", meta.Oid, err)
+ log.Error("Whilst trying to read LFS OID[%s]: Unable to open Error: %v", pointer.Oid, err)
return nil, err
}
return f, err
}
// Put takes a Meta object and an io.Reader and writes the content to the store.
-func (s *ContentStore) Put(meta *models.LFSMetaObject, r io.Reader) error {
- p := meta.RelativePath()
+func (s *ContentStore) Put(pointer Pointer, r io.Reader) error {
+ p := pointer.RelativePath()
// Wrap the provided reader with an inline hashing and size checker
- wrappedRd := newHashingReader(meta.Size, meta.Oid, r)
+ wrappedRd := newHashingReader(pointer.Size, pointer.Oid, r)
// now pass the wrapped reader to Save - if there is a size mismatch or hash mismatch then
// the errors returned by the newHashingReader should percolate up to here
- written, err := s.Save(p, wrappedRd, meta.Size)
+ written, err := s.Save(p, wrappedRd, pointer.Size)
if err != nil {
- log.Error("Whilst putting LFS OID[%s]: Failed to copy to tmpPath: %s Error: %v", meta.Oid, p, err)
+ log.Error("Whilst putting LFS OID[%s]: Failed to copy to tmpPath: %s Error: %v", pointer.Oid, p, err)
return err
}
// This shouldn't happen but it is sensible to test
- if written != meta.Size {
+ if written != pointer.Size {
if err := s.Delete(p); err != nil {
- log.Error("Cleaning the LFS OID[%s] failed: %v", meta.Oid, err)
+ log.Error("Cleaning the LFS OID[%s] failed: %v", pointer.Oid, err)
}
- return errSizeMismatch
+ return ErrSizeMismatch
}
return nil
}
// Exists returns true if the object exists in the content store.
-func (s *ContentStore) Exists(meta *models.LFSMetaObject) (bool, error) {
- _, err := s.ObjectStorage.Stat(meta.RelativePath())
+func (s *ContentStore) Exists(pointer Pointer) (bool, error) {
+ _, err := s.ObjectStorage.Stat(pointer.RelativePath())
if err != nil {
if os.IsNotExist(err) {
return false, nil
@@ -93,19 +100,25 @@ func (s *ContentStore) Exists(meta *models.LFSMetaObject) (bool, error) {
}
// Verify returns true if the object exists in the content store and size is correct.
-func (s *ContentStore) Verify(meta *models.LFSMetaObject) (bool, error) {
- p := meta.RelativePath()
+func (s *ContentStore) Verify(pointer Pointer) (bool, error) {
+ p := pointer.RelativePath()
fi, err := s.ObjectStorage.Stat(p)
- if os.IsNotExist(err) || (err == nil && fi.Size() != meta.Size) {
+ if os.IsNotExist(err) || (err == nil && fi.Size() != pointer.Size) {
return false, nil
} else if err != nil {
- log.Error("Unable stat file: %s for LFS OID[%s] Error: %v", p, meta.Oid, err)
+ log.Error("Unable stat file: %s for LFS OID[%s] Error: %v", p, pointer.Oid, err)
return false, err
}
return true, nil
}
+// ReadMetaObject will read a models.LFSMetaObject and return a reader
+func ReadMetaObject(pointer Pointer) (io.ReadCloser, error) {
+ contentStore := NewContentStore()
+ return contentStore.Get(pointer)
+}
+
type hashingReader struct {
internal io.Reader
currentSize int64
@@ -127,12 +140,12 @@ func (r *hashingReader) Read(b []byte) (int, error) {
if err != nil && err == io.EOF {
if r.currentSize != r.expectedSize {
- return n, errSizeMismatch
+ return n, ErrSizeMismatch
}
shaStr := hex.EncodeToString(r.hash.Sum(nil))
if shaStr != r.expectedHash {
- return n, errHashMismatch
+ return n, ErrHashMismatch
}
}
diff --git a/modules/lfs/endpoint.go b/modules/lfs/endpoint.go
new file mode 100644
index 0000000000..add16ce9f1
--- /dev/null
+++ b/modules/lfs/endpoint.go
@@ -0,0 +1,106 @@
+// Copyright 2021 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 (
+ "fmt"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// DetermineEndpoint determines an endpoint from the clone url or uses the specified LFS url.
+func DetermineEndpoint(cloneurl, lfsurl string) *url.URL {
+ if len(lfsurl) > 0 {
+ return endpointFromURL(lfsurl)
+ }
+ return endpointFromCloneURL(cloneurl)
+}
+
+func endpointFromCloneURL(rawurl string) *url.URL {
+ ep := endpointFromURL(rawurl)
+ if ep == nil {
+ return ep
+ }
+
+ if strings.HasSuffix(ep.Path, "/") {
+ ep.Path = ep.Path[:len(ep.Path)-1]
+ }
+
+ if ep.Scheme == "file" {
+ return ep
+ }
+
+ if path.Ext(ep.Path) == ".git" {
+ ep.Path += "/info/lfs"
+ } else {
+ ep.Path += ".git/info/lfs"
+ }
+
+ return ep
+}
+
+func endpointFromURL(rawurl string) *url.URL {
+ if strings.HasPrefix(rawurl, "/") {
+ return endpointFromLocalPath(rawurl)
+ }
+
+ u, err := url.Parse(rawurl)
+ if err != nil {
+ log.Error("lfs.endpointFromUrl: %v", err)
+ return nil
+ }
+
+ switch u.Scheme {
+ case "http", "https":
+ return u
+ case "git":
+ u.Scheme = "https"
+ return u
+ case "file":
+ return u
+ default:
+ if _, err := os.Stat(rawurl); err == nil {
+ return endpointFromLocalPath(rawurl)
+ }
+
+ log.Error("lfs.endpointFromUrl: unknown url")
+ return nil
+ }
+}
+
+func endpointFromLocalPath(path string) *url.URL {
+ var slash string
+ if abs, err := filepath.Abs(path); err == nil {
+ if !strings.HasPrefix(abs, "/") {
+ slash = "/"
+ }
+ path = abs
+ }
+
+ var gitpath string
+ if filepath.Base(path) == ".git" {
+ gitpath = path
+ path = filepath.Dir(path)
+ } else {
+ gitpath = filepath.Join(path, ".git")
+ }
+
+ if _, err := os.Stat(gitpath); err == nil {
+ path = gitpath
+ } else if _, err := os.Stat(path); err != nil {
+ return nil
+ }
+
+ path = fmt.Sprintf("file://%s%s", slash, filepath.ToSlash(path))
+
+ u, _ := url.Parse(path)
+
+ return u
+}
diff --git a/modules/lfs/endpoint_test.go b/modules/lfs/endpoint_test.go
new file mode 100644
index 0000000000..a7e8b1bfb7
--- /dev/null
+++ b/modules/lfs/endpoint_test.go
@@ -0,0 +1,75 @@
+// Copyright 2021 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 (
+ "net/url"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func str2url(raw string) *url.URL {
+ u, _ := url.Parse(raw)
+ return u
+}
+
+func TestDetermineEndpoint(t *testing.T) {
+ // Test cases
+ var cases = []struct {
+ cloneurl string
+ lfsurl string
+ expected *url.URL
+ }{
+ // case 0
+ {
+ cloneurl: "",
+ lfsurl: "",
+ expected: nil,
+ },
+ // case 1
+ {
+ cloneurl: "https://git.com/repo",
+ lfsurl: "",
+ expected: str2url("https://git.com/repo.git/info/lfs"),
+ },
+ // case 2
+ {
+ cloneurl: "https://git.com/repo.git",
+ lfsurl: "",
+ expected: str2url("https://git.com/repo.git/info/lfs"),
+ },
+ // case 3
+ {
+ cloneurl: "",
+ lfsurl: "https://gitlfs.com/repo",
+ expected: str2url("https://gitlfs.com/repo"),
+ },
+ // case 4
+ {
+ cloneurl: "https://git.com/repo.git",
+ lfsurl: "https://gitlfs.com/repo",
+ expected: str2url("https://gitlfs.com/repo"),
+ },
+ // case 5
+ {
+ cloneurl: "git://git.com/repo.git",
+ lfsurl: "",
+ expected: str2url("https://git.com/repo.git/info/lfs"),
+ },
+ // case 6
+ {
+ cloneurl: "",
+ lfsurl: "git://gitlfs.com/repo",
+ expected: str2url("https://gitlfs.com/repo"),
+ },
+ }
+
+ for n, c := range cases {
+ ep := DetermineEndpoint(c.cloneurl, c.lfsurl)
+
+ assert.Equal(t, c.expected, ep, "case %d: error should match", n)
+ }
+}
diff --git a/modules/lfs/filesystem_client.go b/modules/lfs/filesystem_client.go
new file mode 100644
index 0000000000..3a51564a82
--- /dev/null
+++ b/modules/lfs/filesystem_client.go
@@ -0,0 +1,50 @@
+// Copyright 2021 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 (
+ "context"
+ "io"
+ "net/url"
+ "os"
+ "path/filepath"
+
+ "code.gitea.io/gitea/modules/util"
+)
+
+// FilesystemClient is used to read LFS data from a filesystem path
+type FilesystemClient struct {
+ lfsdir string
+}
+
+func newFilesystemClient(endpoint *url.URL) *FilesystemClient {
+ path, _ := util.FileURLToPath(endpoint)
+
+ lfsdir := filepath.Join(path, "lfs", "objects")
+
+ client := &FilesystemClient{lfsdir}
+
+ return client
+}
+
+func (c *FilesystemClient) objectPath(oid string) string {
+ return filepath.Join(c.lfsdir, oid[0:2], oid[2:4], oid)
+}
+
+// Download reads the specific LFS object from the target repository
+func (c *FilesystemClient) Download(ctx context.Context, oid string, size int64) (io.ReadCloser, error) {
+ objectPath := c.objectPath(oid)
+
+ if _, err := os.Stat(objectPath); os.IsNotExist(err) {
+ return nil, err
+ }
+
+ file, err := os.Open(objectPath)
+ if err != nil {
+ return nil, err
+ }
+
+ return file, nil
+}
diff --git a/modules/lfs/http_client.go b/modules/lfs/http_client.go
new file mode 100644
index 0000000000..fb45defda1
--- /dev/null
+++ b/modules/lfs/http_client.go
@@ -0,0 +1,129 @@
+// Copyright 2021 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 (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// HTTPClient is used to communicate with the LFS server
+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md
+type HTTPClient struct {
+ client *http.Client
+ endpoint string
+ transfers map[string]TransferAdapter
+}
+
+func newHTTPClient(endpoint *url.URL) *HTTPClient {
+ hc := &http.Client{}
+
+ client := &HTTPClient{
+ client: hc,
+ endpoint: strings.TrimSuffix(endpoint.String(), "/"),
+ transfers: make(map[string]TransferAdapter),
+ }
+
+ basic := &BasicTransferAdapter{hc}
+
+ client.transfers[basic.Name()] = basic
+
+ return client
+}
+
+func (c *HTTPClient) transferNames() []string {
+ keys := make([]string, len(c.transfers))
+
+ i := 0
+ for k := range c.transfers {
+ keys[i] = k
+ i++
+ }
+
+ return keys
+}
+
+func (c *HTTPClient) batch(ctx context.Context, operation string, objects []Pointer) (*BatchResponse, error) {
+ url := fmt.Sprintf("%s/objects/batch", c.endpoint)
+
+ request := &BatchRequest{operation, c.transferNames(), nil, objects}
+
+ payload := new(bytes.Buffer)
+ err := json.NewEncoder(payload).Encode(request)
+ if err != nil {
+ return nil, fmt.Errorf("lfs.HTTPClient.batch json.Encode: %w", err)
+ }
+
+ log.Trace("lfs.HTTPClient.batch NewRequestWithContext: %s", url)
+
+ req, err := http.NewRequestWithContext(ctx, "POST", url, payload)
+ if err != nil {
+ return nil, fmt.Errorf("lfs.HTTPClient.batch http.NewRequestWithContext: %w", err)
+ }
+ req.Header.Set("Content-type", MediaType)
+ req.Header.Set("Accept", MediaType)
+
+ res, err := c.client.Do(req)
+ if err != nil {
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ default:
+ }
+ return nil, fmt.Errorf("lfs.HTTPClient.batch http.Do: %w", err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("lfs.HTTPClient.batch: Unexpected servers response: %s", res.Status)
+ }
+
+ var response BatchResponse
+ err = json.NewDecoder(res.Body).Decode(&response)
+ if err != nil {
+ return nil, fmt.Errorf("lfs.HTTPClient.batch json.Decode: %w", err)
+ }
+
+ if len(response.Transfer) == 0 {
+ response.Transfer = "basic"
+ }
+
+ return &response, nil
+}
+
+// Download reads the specific LFS object from the LFS server
+func (c *HTTPClient) Download(ctx context.Context, oid string, size int64) (io.ReadCloser, error) {
+ var objects []Pointer
+ objects = append(objects, Pointer{oid, size})
+
+ result, err := c.batch(ctx, "download", objects)
+ if err != nil {
+ return nil, err
+ }
+
+ transferAdapter, ok := c.transfers[result.Transfer]
+ if !ok {
+ return nil, fmt.Errorf("lfs.HTTPClient.Download Transferadapter not found: %s", result.Transfer)
+ }
+
+ if len(result.Objects) == 0 {
+ return nil, errors.New("lfs.HTTPClient.Download: No objects in result")
+ }
+
+ content, err := transferAdapter.Download(ctx, result.Objects[0])
+ if err != nil {
+ return nil, err
+ }
+ return content, nil
+}
diff --git a/modules/lfs/http_client_test.go b/modules/lfs/http_client_test.go
new file mode 100644
index 0000000000..043aa0214e
--- /dev/null
+++ b/modules/lfs/http_client_test.go
@@ -0,0 +1,144 @@
+// Copyright 2021 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 (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+type RoundTripFunc func(req *http.Request) *http.Response
+
+func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return f(req), nil
+}
+
+type DummyTransferAdapter struct {
+}
+
+func (a *DummyTransferAdapter) Name() string {
+ return "dummy"
+}
+
+func (a *DummyTransferAdapter) Download(ctx context.Context, r *ObjectResponse) (io.ReadCloser, error) {
+ return ioutil.NopCloser(bytes.NewBufferString("dummy")), nil
+}
+
+func TestHTTPClientDownload(t *testing.T) {
+ oid := "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab041"
+ size := int64(6)
+
+ roundTripHandler := func(req *http.Request) *http.Response {
+ url := req.URL.String()
+ if strings.Contains(url, "status-not-ok") {
+ return &http.Response{StatusCode: http.StatusBadRequest}
+ }
+ if strings.Contains(url, "invalid-json-response") {
+ return &http.Response{StatusCode: http.StatusOK, Body: ioutil.NopCloser(bytes.NewBufferString("invalid json"))}
+ }
+ if strings.Contains(url, "valid-batch-request-download") {
+ assert.Equal(t, "POST", req.Method)
+ assert.Equal(t, MediaType, req.Header.Get("Content-type"), "case %s: error should match", url)
+ assert.Equal(t, MediaType, req.Header.Get("Accept"), "case %s: error should match", url)
+
+ var batchRequest BatchRequest
+ err := json.NewDecoder(req.Body).Decode(&batchRequest)
+ assert.NoError(t, err)
+
+ assert.Equal(t, "download", batchRequest.Operation)
+ assert.Equal(t, 1, len(batchRequest.Objects))
+ assert.Equal(t, oid, batchRequest.Objects[0].Oid)
+ assert.Equal(t, size, batchRequest.Objects[0].Size)
+
+ batchResponse := &BatchResponse{
+ Transfer: "dummy",
+ Objects: make([]*ObjectResponse, 1),
+ }
+
+ payload := new(bytes.Buffer)
+ json.NewEncoder(payload).Encode(batchResponse)
+
+ return &http.Response{StatusCode: http.StatusOK, Body: ioutil.NopCloser(payload)}
+ }
+ if strings.Contains(url, "invalid-response-no-objects") {
+ batchResponse := &BatchResponse{Transfer: "dummy"}
+
+ payload := new(bytes.Buffer)
+ json.NewEncoder(payload).Encode(batchResponse)
+
+ return &http.Response{StatusCode: http.StatusOK, Body: ioutil.NopCloser(payload)}
+ }
+ if strings.Contains(url, "unknown-transfer-adapter") {
+ batchResponse := &BatchResponse{Transfer: "unknown_adapter"}
+
+ payload := new(bytes.Buffer)
+ json.NewEncoder(payload).Encode(batchResponse)
+
+ return &http.Response{StatusCode: http.StatusOK, Body: ioutil.NopCloser(payload)}
+ }
+
+ t.Errorf("Unknown test case: %s", url)
+
+ return nil
+ }
+
+ hc := &http.Client{Transport: RoundTripFunc(roundTripHandler)}
+ dummy := &DummyTransferAdapter{}
+
+ var cases = []struct {
+ endpoint string
+ expectederror string
+ }{
+ // case 0
+ {
+ endpoint: "https://status-not-ok.io",
+ expectederror: "Unexpected servers response: ",
+ },
+ // case 1
+ {
+ endpoint: "https://invalid-json-response.io",
+ expectederror: "json.Decode: ",
+ },
+ // case 2
+ {
+ endpoint: "https://valid-batch-request-download.io",
+ expectederror: "",
+ },
+ // case 3
+ {
+ endpoint: "https://invalid-response-no-objects.io",
+ expectederror: "No objects in result",
+ },
+ // case 4
+ {
+ endpoint: "https://unknown-transfer-adapter.io",
+ expectederror: "Transferadapter not found: ",
+ },
+ }
+
+ for n, c := range cases {
+ client := &HTTPClient{
+ client: hc,
+ endpoint: c.endpoint,
+ transfers: make(map[string]TransferAdapter),
+ }
+ client.transfers["dummy"] = dummy
+
+ _, err := client.Download(context.Background(), oid, size)
+ if len(c.expectederror) > 0 {
+ assert.True(t, strings.Contains(err.Error(), c.expectederror), "case %d: '%s' should contain '%s'", n, err.Error(), c.expectederror)
+ } else {
+ assert.NoError(t, err, "case %d", n)
+ }
+ }
+}
diff --git a/modules/lfs/locks.go b/modules/lfs/locks.go
deleted file mode 100644
index eaa8305cb4..0000000000
--- a/modules/lfs/locks.go
+++ /dev/null
@@ -1,348 +0,0 @@
-// 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 (
- "net/http"
- "strconv"
- "strings"
-
- "code.gitea.io/gitea/models"
- "code.gitea.io/gitea/modules/context"
- "code.gitea.io/gitea/modules/convert"
- "code.gitea.io/gitea/modules/log"
- "code.gitea.io/gitea/modules/setting"
- api "code.gitea.io/gitea/modules/structs"
- jsoniter "github.com/json-iterator/go"
-)
-
-//checkIsValidRequest check if it a valid request in case of bad request it write the response to ctx.
-func checkIsValidRequest(ctx *context.Context) bool {
- if !setting.LFS.StartServer {
- log.Debug("Attempt to access LFS server but LFS server is disabled")
- writeStatus(ctx, http.StatusNotFound)
- return false
- }
- if !MetaMatcher(ctx.Req) {
- log.Info("Attempt access LOCKs without accepting the correct media type: %s", metaMediaType)
- writeStatus(ctx, http.StatusBadRequest)
- return false
- }
- if !ctx.IsSigned {
- user, _, _, err := parseToken(ctx.Req.Header.Get("Authorization"))
- if err != nil {
- ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
- writeStatus(ctx, http.StatusUnauthorized)
- return false
- }
- ctx.User = user
- }
- return true
-}
-
-func handleLockListOut(ctx *context.Context, repo *models.Repository, lock *models.LFSLock, err error) {
- if err != nil {
- if models.IsErrLFSLockNotExist(err) {
- ctx.JSON(http.StatusOK, api.LFSLockList{
- Locks: []*api.LFSLock{},
- })
- return
- }
- ctx.JSON(http.StatusInternalServerError, api.LFSLockError{
- Message: "unable to list locks : Internal Server Error",
- })
- return
- }
- if repo.ID != lock.RepoID {
- ctx.JSON(http.StatusOK, api.LFSLockList{
- Locks: []*api.LFSLock{},
- })
- return
- }
- ctx.JSON(http.StatusOK, api.LFSLockList{
- Locks: []*api.LFSLock{convert.ToLFSLock(lock)},
- })
-}
-
-// GetListLockHandler list locks
-func GetListLockHandler(ctx *context.Context) {
- if !checkIsValidRequest(ctx) {
- // Status is written in checkIsValidRequest
- return
- }
- ctx.Resp.Header().Set("Content-Type", metaMediaType)
-
- rv := unpack(ctx)
-
- repository, err := models.GetRepositoryByOwnerAndName(rv.User, rv.Repo)
- if err != nil {
- log.Debug("Could not find repository: %s/%s - %s", rv.User, rv.Repo, err)
- writeStatus(ctx, 404)
- return
- }
- repository.MustOwner()
-
- authenticated := authenticate(ctx, repository, rv.Authorization, false)
- if !authenticated {
- ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
- ctx.JSON(http.StatusUnauthorized, api.LFSLockError{
- Message: "You must have pull access to list locks",
- })
- return
- }
-
- cursor := ctx.QueryInt("cursor")
- if cursor < 0 {
- cursor = 0
- }
- limit := ctx.QueryInt("limit")
- if limit > setting.LFS.LocksPagingNum && setting.LFS.LocksPagingNum > 0 {
- limit = setting.LFS.LocksPagingNum
- } else if limit < 0 {
- limit = 0
- }
- 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(http.StatusBadRequest, api.LFSLockError{
- Message: "bad request : " + err.Error(),
- })
- return
- }
- lock, err := models.GetLFSLockByID(v)
- if err != nil && !models.IsErrLFSLockNotExist(err) {
- log.Error("Unable to get lock with ID[%s]: Error: %v", v, err)
- }
- handleLockListOut(ctx, repository, lock, err)
- return
- }
-
- path := ctx.Query("path")
- if path != "" { //Case where we request a specific id
- lock, err := models.GetLFSLock(repository, path)
- if err != nil && !models.IsErrLFSLockNotExist(err) {
- log.Error("Unable to get lock for repository %-v with path %s: Error: %v", repository, path, err)
- }
- handleLockListOut(ctx, repository, lock, err)
- return
- }
-
- //If no query params path or id
- lockList, err := models.GetLFSLockByRepoID(repository.ID, cursor, limit)
- if err != nil {
- log.Error("Unable to list locks for repository ID[%d]: Error: %v", repository.ID, err)
- ctx.JSON(http.StatusInternalServerError, api.LFSLockError{
- Message: "unable to list locks : Internal Server Error",
- })
- return
- }
- lockListAPI := make([]*api.LFSLock, len(lockList))
- next := ""
- for i, l := range lockList {
- lockListAPI[i] = convert.ToLFSLock(l)
- }
- if limit > 0 && len(lockList) == limit {
- next = strconv.Itoa(cursor + 1)
- }
- ctx.JSON(http.StatusOK, api.LFSLockList{
- Locks: lockListAPI,
- Next: next,
- })
-}
-
-// PostLockHandler create lock
-func PostLockHandler(ctx *context.Context) {
- if !checkIsValidRequest(ctx) {
- // Status is written in checkIsValidRequest
- return
- }
- ctx.Resp.Header().Set("Content-Type", metaMediaType)
-
- userName := ctx.Params("username")
- repoName := strings.TrimSuffix(ctx.Params("reponame"), ".git")
- authorization := ctx.Req.Header.Get("Authorization")
-
- repository, err := models.GetRepositoryByOwnerAndName(userName, repoName)
- if err != nil {
- log.Error("Unable to get repository: %s/%s Error: %v", userName, repoName, err)
- writeStatus(ctx, 404)
- return
- }
- repository.MustOwner()
-
- authenticated := authenticate(ctx, repository, authorization, true)
- if !authenticated {
- ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
- ctx.JSON(http.StatusUnauthorized, api.LFSLockError{
- Message: "You must have push access to create locks",
- })
- return
- }
-
- var req api.LFSLockRequest
- bodyReader := ctx.Req.Body
- defer bodyReader.Close()
- json := jsoniter.ConfigCompatibleWithStandardLibrary
- dec := json.NewDecoder(bodyReader)
- if err := dec.Decode(&req); err != nil {
- log.Warn("Failed to decode lock request as json. Error: %v", err)
- writeStatus(ctx, 400)
- return
- }
-
- lock, err := models.CreateLFSLock(&models.LFSLock{
- Repo: repository,
- Path: req.Path,
- Owner: ctx.User,
- })
- if err != nil {
- if models.IsErrLFSLockAlreadyExist(err) {
- ctx.JSON(http.StatusConflict, api.LFSLockError{
- Lock: convert.ToLFSLock(lock),
- Message: "already created lock",
- })
- return
- }
- if models.IsErrLFSUnauthorizedAction(err) {
- ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
- ctx.JSON(http.StatusUnauthorized, api.LFSLockError{
- Message: "You must have push access to create locks : " + err.Error(),
- })
- return
- }
- log.Error("Unable to CreateLFSLock in repository %-v at %s for user %-v: Error: %v", repository, req.Path, ctx.User, err)
- ctx.JSON(http.StatusInternalServerError, api.LFSLockError{
- Message: "internal server error : Internal Server Error",
- })
- return
- }
- ctx.JSON(http.StatusCreated, api.LFSLockResponse{Lock: convert.ToLFSLock(lock)})
-}
-
-// VerifyLockHandler list locks for verification
-func VerifyLockHandler(ctx *context.Context) {
- if !checkIsValidRequest(ctx) {
- // Status is written in checkIsValidRequest
- return
- }
- ctx.Resp.Header().Set("Content-Type", metaMediaType)
-
- userName := ctx.Params("username")
- repoName := strings.TrimSuffix(ctx.Params("reponame"), ".git")
- authorization := ctx.Req.Header.Get("Authorization")
-
- repository, err := models.GetRepositoryByOwnerAndName(userName, repoName)
- if err != nil {
- log.Error("Unable to get repository: %s/%s Error: %v", userName, repoName, err)
- writeStatus(ctx, 404)
- return
- }
- repository.MustOwner()
-
- authenticated := authenticate(ctx, repository, authorization, true)
- if !authenticated {
- ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
- ctx.JSON(http.StatusUnauthorized, api.LFSLockError{
- Message: "You must have push access to verify locks",
- })
- return
- }
-
- cursor := ctx.QueryInt("cursor")
- if cursor < 0 {
- cursor = 0
- }
- limit := ctx.QueryInt("limit")
- if limit > setting.LFS.LocksPagingNum && setting.LFS.LocksPagingNum > 0 {
- limit = setting.LFS.LocksPagingNum
- } else if limit < 0 {
- limit = 0
- }
- lockList, err := models.GetLFSLockByRepoID(repository.ID, cursor, limit)
- if err != nil {
- log.Error("Unable to list locks for repository ID[%d]: Error: %v", repository.ID, err)
- ctx.JSON(http.StatusInternalServerError, api.LFSLockError{
- Message: "unable to list locks : Internal Server Error",
- })
- return
- }
- next := ""
- if limit > 0 && len(lockList) == limit {
- next = strconv.Itoa(cursor + 1)
- }
- 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, convert.ToLFSLock(l))
- } else {
- lockTheirsListAPI = append(lockTheirsListAPI, convert.ToLFSLock(l))
- }
- }
- ctx.JSON(http.StatusOK, api.LFSLockListVerify{
- Ours: lockOursListAPI,
- Theirs: lockTheirsListAPI,
- Next: next,
- })
-}
-
-// UnLockHandler delete locks
-func UnLockHandler(ctx *context.Context) {
- if !checkIsValidRequest(ctx) {
- // Status is written in checkIsValidRequest
- return
- }
- ctx.Resp.Header().Set("Content-Type", metaMediaType)
-
- userName := ctx.Params("username")
- repoName := strings.TrimSuffix(ctx.Params("reponame"), ".git")
- authorization := ctx.Req.Header.Get("Authorization")
-
- repository, err := models.GetRepositoryByOwnerAndName(userName, repoName)
- if err != nil {
- log.Error("Unable to get repository: %s/%s Error: %v", userName, repoName, err)
- writeStatus(ctx, 404)
- return
- }
- repository.MustOwner()
-
- authenticated := authenticate(ctx, repository, authorization, true)
- if !authenticated {
- ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
- ctx.JSON(http.StatusUnauthorized, api.LFSLockError{
- Message: "You must have push access to delete locks",
- })
- return
- }
-
- var req api.LFSLockDeleteRequest
- bodyReader := ctx.Req.Body
- defer bodyReader.Close()
- json := jsoniter.ConfigCompatibleWithStandardLibrary
- dec := json.NewDecoder(bodyReader)
- if err := dec.Decode(&req); err != nil {
- log.Warn("Failed to decode lock request as json. Error: %v", err)
- writeStatus(ctx, 400)
- return
- }
-
- lock, err := models.DeleteLFSLockByID(ctx.ParamsInt64("lid"), ctx.User, req.Force)
- if err != nil {
- if models.IsErrLFSUnauthorizedAction(err) {
- ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
- ctx.JSON(http.StatusUnauthorized, api.LFSLockError{
- Message: "You must have push access to delete locks : " + err.Error(),
- })
- return
- }
- log.Error("Unable to DeleteLFSLockByID[%d] by user %-v with force %t: Error: %v", ctx.ParamsInt64("lid"), ctx.User, req.Force, err)
- ctx.JSON(http.StatusInternalServerError, api.LFSLockError{
- Message: "unable to delete lock : Internal Server Error",
- })
- return
- }
- ctx.JSON(http.StatusOK, api.LFSLockResponse{Lock: convert.ToLFSLock(lock)})
-}
diff --git a/modules/lfs/pointer.go b/modules/lfs/pointer.go
new file mode 100644
index 0000000000..975b5e7dc6
--- /dev/null
+++ b/modules/lfs/pointer.go
@@ -0,0 +1,123 @@
+// Copyright 2021 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 (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "path"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+const (
+ blobSizeCutoff = 1024
+
+ // MetaFileIdentifier is the string appearing at the first line of LFS pointer files.
+ // https://github.com/git-lfs/git-lfs/blob/master/docs/spec.md
+ MetaFileIdentifier = "version https://git-lfs.github.com/spec/v1"
+
+ // MetaFileOidPrefix appears in LFS pointer files on a line before the sha256 hash.
+ MetaFileOidPrefix = "oid sha256:"
+)
+
+var (
+ // ErrMissingPrefix occurs if the content lacks the LFS prefix
+ ErrMissingPrefix = errors.New("Content lacks the LFS prefix")
+
+ // ErrInvalidStructure occurs if the content has an invalid structure
+ ErrInvalidStructure = errors.New("Content has an invalid structure")
+
+ // ErrInvalidOIDFormat occurs if the oid has an invalid format
+ ErrInvalidOIDFormat = errors.New("OID has an invalid format")
+)
+
+// ReadPointer tries to read LFS pointer data from the reader
+func ReadPointer(reader io.Reader) (Pointer, error) {
+ buf := make([]byte, blobSizeCutoff)
+ n, err := io.ReadFull(reader, buf)
+ if err != nil && err != io.ErrUnexpectedEOF {
+ return Pointer{}, err
+ }
+ buf = buf[:n]
+
+ return ReadPointerFromBuffer(buf)
+}
+
+var oidPattern = regexp.MustCompile(`^[a-f\d]{64}$`)
+
+// ReadPointerFromBuffer will return a pointer if the provided byte slice is a pointer file or an error otherwise.
+func ReadPointerFromBuffer(buf []byte) (Pointer, error) {
+ var p Pointer
+
+ headString := string(buf)
+ if !strings.HasPrefix(headString, MetaFileIdentifier) {
+ return p, ErrMissingPrefix
+ }
+
+ splitLines := strings.Split(headString, "\n")
+ if len(splitLines) < 3 {
+ return p, ErrInvalidStructure
+ }
+
+ oid := strings.TrimPrefix(splitLines[1], MetaFileOidPrefix)
+ if len(oid) != 64 || !oidPattern.MatchString(oid) {
+ return p, ErrInvalidOIDFormat
+ }
+ size, err := strconv.ParseInt(strings.TrimPrefix(splitLines[2], "size "), 10, 64)
+ if err != nil {
+ return p, err
+ }
+
+ p.Oid = oid
+ p.Size = size
+
+ return p, nil
+}
+
+// IsValid checks if the pointer has a valid structure.
+// It doesn't check if the pointed-to-content exists.
+func (p Pointer) IsValid() bool {
+ if len(p.Oid) != 64 {
+ return false
+ }
+ if !oidPattern.MatchString(p.Oid) {
+ return false
+ }
+ if p.Size < 0 {
+ return false
+ }
+ return true
+}
+
+// StringContent returns the string representation of the pointer
+// https://github.com/git-lfs/git-lfs/blob/main/docs/spec.md#the-pointer
+func (p Pointer) StringContent() string {
+ return fmt.Sprintf("%s\n%s%s\nsize %d\n", MetaFileIdentifier, MetaFileOidPrefix, p.Oid, p.Size)
+}
+
+// RelativePath returns the relative storage path of the pointer
+func (p Pointer) RelativePath() string {
+ if len(p.Oid) < 5 {
+ return p.Oid
+ }
+
+ return path.Join(p.Oid[0:2], p.Oid[2:4], p.Oid[4:])
+}
+
+// GeneratePointer generates a pointer for arbitrary content
+func GeneratePointer(content io.Reader) (Pointer, error) {
+ h := sha256.New()
+ c, err := io.Copy(h, content)
+ if err != nil {
+ return Pointer{}, err
+ }
+ sum := h.Sum(nil)
+ return Pointer{Oid: hex.EncodeToString(sum), Size: c}, nil
+}
diff --git a/modules/lfs/pointer_scanner_gogit.go b/modules/lfs/pointer_scanner_gogit.go
new file mode 100644
index 0000000000..abd882990c
--- /dev/null
+++ b/modules/lfs/pointer_scanner_gogit.go
@@ -0,0 +1,64 @@
+// Copyright 2021 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.
+
+// +build gogit
+
+package lfs
+
+import (
+ "context"
+ "fmt"
+
+ "code.gitea.io/gitea/modules/git"
+
+ "github.com/go-git/go-git/v5/plumbing/object"
+)
+
+// SearchPointerBlobs scans the whole repository for LFS pointer files
+func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan chan<- PointerBlob, errChan chan<- error) {
+ gitRepo := repo.GoGitRepo()
+
+ err := func() error {
+ blobs, err := gitRepo.BlobObjects()
+ if err != nil {
+ return fmt.Errorf("lfs.SearchPointerBlobs BlobObjects: %w", err)
+ }
+
+ return blobs.ForEach(func(blob *object.Blob) error {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+
+ if blob.Size > blobSizeCutoff {
+ return nil
+ }
+
+ reader, err := blob.Reader()
+ if err != nil {
+ return fmt.Errorf("lfs.SearchPointerBlobs blob.Reader: %w", err)
+ }
+ defer reader.Close()
+
+ pointer, _ := ReadPointer(reader)
+ if pointer.IsValid() {
+ pointerChan <- PointerBlob{Hash: blob.Hash.String(), Pointer: pointer}
+ }
+
+ return nil
+ })
+ }()
+
+ if err != nil {
+ select {
+ case <-ctx.Done():
+ default:
+ errChan <- err
+ }
+ }
+
+ close(pointerChan)
+ close(errChan)
+}
diff --git a/modules/lfs/pointer_scanner_nogogit.go b/modules/lfs/pointer_scanner_nogogit.go
new file mode 100644
index 0000000000..28d4afba61
--- /dev/null
+++ b/modules/lfs/pointer_scanner_nogogit.go
@@ -0,0 +1,110 @@
+// Copyright 2021 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.
+
+// +build !gogit
+
+package lfs
+
+import (
+ "bufio"
+ "context"
+ "io"
+ "strconv"
+ "sync"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/git/pipeline"
+)
+
+// SearchPointerBlobs scans the whole repository for LFS pointer files
+func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan chan<- PointerBlob, errChan chan<- error) {
+ basePath := repo.Path
+
+ catFileCheckReader, catFileCheckWriter := io.Pipe()
+ shasToBatchReader, shasToBatchWriter := io.Pipe()
+ catFileBatchReader, catFileBatchWriter := io.Pipe()
+
+ wg := sync.WaitGroup{}
+ wg.Add(4)
+
+ // Create the go-routines in reverse order.
+
+ // 4. Take the output of cat-file --batch and check if each file in turn
+ // to see if they're pointers to files in the LFS store
+ go createPointerResultsFromCatFileBatch(ctx, catFileBatchReader, &wg, pointerChan)
+
+ // 3. Take the shas of the blobs and batch read them
+ go pipeline.CatFileBatch(shasToBatchReader, catFileBatchWriter, &wg, basePath)
+
+ // 2. From the provided objects restrict to blobs <=1k
+ go pipeline.BlobsLessThan1024FromCatFileBatchCheck(catFileCheckReader, shasToBatchWriter, &wg)
+
+ // 1. Run batch-check on all objects in the repository
+ if git.CheckGitVersionAtLeast("2.6.0") != nil {
+ revListReader, revListWriter := io.Pipe()
+ shasToCheckReader, shasToCheckWriter := io.Pipe()
+ wg.Add(2)
+ go pipeline.CatFileBatchCheck(shasToCheckReader, catFileCheckWriter, &wg, basePath)
+ go pipeline.BlobsFromRevListObjects(revListReader, shasToCheckWriter, &wg)
+ go pipeline.RevListAllObjects(revListWriter, &wg, basePath, errChan)
+ } else {
+ go pipeline.CatFileBatchCheckAllObjects(catFileCheckWriter, &wg, basePath, errChan)
+ }
+ wg.Wait()
+
+ close(pointerChan)
+ close(errChan)
+}
+
+func createPointerResultsFromCatFileBatch(ctx context.Context, catFileBatchReader *io.PipeReader, wg *sync.WaitGroup, pointerChan chan<- PointerBlob) {
+ defer wg.Done()
+ defer catFileBatchReader.Close()
+
+ bufferedReader := bufio.NewReader(catFileBatchReader)
+ buf := make([]byte, 1025)
+
+loop:
+ for {
+ select {
+ case <-ctx.Done():
+ break loop
+ default:
+ }
+
+ // File descriptor line: sha
+ sha, err := bufferedReader.ReadString(' ')
+ if err != nil {
+ _ = catFileBatchReader.CloseWithError(err)
+ break
+ }
+ // Throw away the blob
+ if _, err := bufferedReader.ReadString(' '); err != nil {
+ _ = catFileBatchReader.CloseWithError(err)
+ break
+ }
+ sizeStr, err := bufferedReader.ReadString('\n')
+ if err != nil {
+ _ = catFileBatchReader.CloseWithError(err)
+ break
+ }
+ size, err := strconv.Atoi(sizeStr[:len(sizeStr)-1])
+ if err != nil {
+ _ = catFileBatchReader.CloseWithError(err)
+ break
+ }
+ pointerBuf := buf[:size+1]
+ if _, err := io.ReadFull(bufferedReader, pointerBuf); err != nil {
+ _ = catFileBatchReader.CloseWithError(err)
+ break
+ }
+ pointerBuf = pointerBuf[:size]
+ // Now we need to check if the pointerBuf is an LFS pointer
+ pointer, _ := ReadPointerFromBuffer(pointerBuf)
+ if !pointer.IsValid() {
+ continue
+ }
+
+ pointerChan <- PointerBlob{Hash: sha, Pointer: pointer}
+ }
+}
diff --git a/modules/lfs/pointer_test.go b/modules/lfs/pointer_test.go
new file mode 100644
index 0000000000..0ed6df2c6d
--- /dev/null
+++ b/modules/lfs/pointer_test.go
@@ -0,0 +1,103 @@
+// Copyright 2021 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 (
+ "path"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestStringContent(t *testing.T) {
+ p := Pointer{Oid: "4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393", Size: 1234}
+ expected := "version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 1234\n"
+ assert.Equal(t, p.StringContent(), expected)
+}
+
+func TestRelativePath(t *testing.T) {
+ p := Pointer{Oid: "4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393"}
+ expected := path.Join("4d", "7a", "214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393")
+ assert.Equal(t, p.RelativePath(), expected)
+
+ p2 := Pointer{Oid: "4d7a"}
+ assert.Equal(t, p2.RelativePath(), "4d7a")
+}
+
+func TestIsValid(t *testing.T) {
+ p := Pointer{}
+ assert.False(t, p.IsValid())
+
+ p = Pointer{Oid: "123"}
+ assert.False(t, p.IsValid())
+
+ p = Pointer{Oid: "z4cb57646c54a297c9807697e80a30946f79a4b82cb079d2606847825b1812cc"}
+ assert.False(t, p.IsValid())
+
+ p = Pointer{Oid: "94cb57646c54a297c9807697e80a30946f79a4b82cb079d2606847825b1812cc"}
+ assert.True(t, p.IsValid())
+
+ p = Pointer{Oid: "94cb57646c54a297c9807697e80a30946f79a4b82cb079d2606847825b1812cc", Size: -1}
+ assert.False(t, p.IsValid())
+}
+
+func TestGeneratePointer(t *testing.T) {
+ p, err := GeneratePointer(strings.NewReader("Gitea"))
+ assert.NoError(t, err)
+ assert.True(t, p.IsValid())
+ assert.Equal(t, p.Oid, "94cb57646c54a297c9807697e80a30946f79a4b82cb079d2606847825b1812cc")
+ assert.Equal(t, p.Size, int64(5))
+}
+
+func TestReadPointerFromBuffer(t *testing.T) {
+ p, err := ReadPointerFromBuffer([]byte{})
+ assert.ErrorIs(t, err, ErrMissingPrefix)
+ assert.False(t, p.IsValid())
+
+ p, err = ReadPointerFromBuffer([]byte("test"))
+ assert.ErrorIs(t, err, ErrMissingPrefix)
+ assert.False(t, p.IsValid())
+
+ p, err = ReadPointerFromBuffer([]byte("version https://git-lfs.github.com/spec/v1\n"))
+ assert.ErrorIs(t, err, ErrInvalidStructure)
+ assert.False(t, p.IsValid())
+
+ p, err = ReadPointerFromBuffer([]byte("version https://git-lfs.github.com/spec/v1\noid sha256:4d7a\nsize 1234\n"))
+ assert.ErrorIs(t, err, ErrInvalidOIDFormat)
+ assert.False(t, p.IsValid())
+
+ p, err = ReadPointerFromBuffer([]byte("version https://git-lfs.github.com/spec/v1\noid sha256:4d7a2146z4ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 1234\n"))
+ assert.ErrorIs(t, err, ErrInvalidOIDFormat)
+ assert.False(t, p.IsValid())
+
+ p, err = ReadPointerFromBuffer([]byte("version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\ntest 1234\n"))
+ assert.Error(t, err)
+ assert.False(t, p.IsValid())
+
+ p, err = ReadPointerFromBuffer([]byte("version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize test\n"))
+ assert.Error(t, err)
+ assert.False(t, p.IsValid())
+
+ p, err = ReadPointerFromBuffer([]byte("version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 1234\n"))
+ assert.NoError(t, err)
+ assert.True(t, p.IsValid())
+ assert.Equal(t, p.Oid, "4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393")
+ assert.Equal(t, p.Size, int64(1234))
+
+ p, err = ReadPointerFromBuffer([]byte("version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 1234\ntest"))
+ assert.NoError(t, err)
+ assert.True(t, p.IsValid())
+ assert.Equal(t, p.Oid, "4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393")
+ assert.Equal(t, p.Size, int64(1234))
+}
+
+func TestReadPointer(t *testing.T) {
+ p, err := ReadPointer(strings.NewReader("version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 1234\n"))
+ assert.NoError(t, err)
+ assert.True(t, p.IsValid())
+ assert.Equal(t, p.Oid, "4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393")
+ assert.Equal(t, p.Size, int64(1234))
+}
diff --git a/modules/lfs/pointers.go b/modules/lfs/pointers.go
deleted file mode 100644
index 692c81f583..0000000000
--- a/modules/lfs/pointers.go
+++ /dev/null
@@ -1,71 +0,0 @@
-// Copyright 2019 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 (
- "io"
- "strconv"
- "strings"
-
- "code.gitea.io/gitea/models"
- "code.gitea.io/gitea/modules/base"
- "code.gitea.io/gitea/modules/setting"
- "code.gitea.io/gitea/modules/storage"
-)
-
-// ReadPointerFile will return a partially filled LFSMetaObject if the provided reader is a pointer file
-func ReadPointerFile(reader io.Reader) (*models.LFSMetaObject, *[]byte) {
- if !setting.LFS.StartServer {
- return nil, nil
- }
-
- buf := make([]byte, 1024)
- n, _ := reader.Read(buf)
- buf = buf[:n]
-
- if isTextFile := base.IsTextFile(buf); !isTextFile {
- return nil, nil
- }
-
- return IsPointerFile(&buf), &buf
-}
-
-// IsPointerFile will return a partially filled LFSMetaObject if the provided byte slice is a pointer file
-func IsPointerFile(buf *[]byte) *models.LFSMetaObject {
- if !setting.LFS.StartServer {
- return nil
- }
-
- headString := string(*buf)
- if !strings.HasPrefix(headString, models.LFSMetaFileIdentifier) {
- return nil
- }
-
- splitLines := strings.Split(headString, "\n")
- if len(splitLines) < 3 {
- return nil
- }
-
- oid := strings.TrimPrefix(splitLines[1], models.LFSMetaFileOidPrefix)
- size, err := strconv.ParseInt(strings.TrimPrefix(splitLines[2], "size "), 10, 64)
- if len(oid) != 64 || err != nil {
- return nil
- }
-
- contentStore := &ContentStore{ObjectStorage: storage.LFS}
- meta := &models.LFSMetaObject{Oid: oid, Size: size}
- exist, err := contentStore.Exists(meta)
- if err != nil || !exist {
- return nil
- }
-
- return meta
-}
-
-// ReadMetaObject will read a models.LFSMetaObject and return a reader
-func ReadMetaObject(meta *models.LFSMetaObject) (io.ReadCloser, error) {
- contentStore := &ContentStore{ObjectStorage: storage.LFS}
- return contentStore.Get(meta)
-}
diff --git a/modules/lfs/server.go b/modules/lfs/server.go
deleted file mode 100644
index f45423b851..0000000000
--- a/modules/lfs/server.go
+++ /dev/null
@@ -1,712 +0,0 @@
-// Copyright 2020 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/base64"
- "fmt"
- "io"
- "net/http"
- "path"
- "regexp"
- "strconv"
- "strings"
- "time"
-
- "code.gitea.io/gitea/models"
- "code.gitea.io/gitea/modules/context"
- "code.gitea.io/gitea/modules/log"
- "code.gitea.io/gitea/modules/setting"
- "code.gitea.io/gitea/modules/storage"
-
- "github.com/dgrijalva/jwt-go"
- jsoniter "github.com/json-iterator/go"
-)
-
-const (
- metaMediaType = "application/vnd.git-lfs+json"
-)
-
-// RequestVars contain variables from the HTTP request. Variables from routing, json body decoding, and
-// some headers are stored.
-type RequestVars struct {
- Oid string
- Size int64
- User string
- Password string
- Repo string
- Authorization string
-}
-
-// BatchVars contains multiple RequestVars processed in one batch operation.
-// https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md
-type BatchVars struct {
- Transfers []string `json:"transfers,omitempty"`
- Operation string `json:"operation"`
- Objects []*RequestVars `json:"objects"`
-}
-
-// BatchResponse contains multiple object metadata Representation structures
-// for use with the batch API.
-type BatchResponse struct {
- Transfer string `json:"transfer,omitempty"`
- Objects []*Representation `json:"objects"`
-}
-
-// Representation is object metadata as seen by clients of the lfs server.
-type Representation struct {
- Oid string `json:"oid"`
- Size int64 `json:"size"`
- Actions map[string]*link `json:"actions"`
- Error *ObjectError `json:"error,omitempty"`
-}
-
-// ObjectError defines the JSON structure returned to the client in case of an error
-type ObjectError struct {
- Code int `json:"code"`
- Message string `json:"message"`
-}
-
-// Claims is a JWT Token Claims
-type Claims struct {
- RepoID int64
- Op string
- UserID int64
- jwt.StandardClaims
-}
-
-// ObjectLink builds a URL linking to the object.
-func (v *RequestVars) ObjectLink() string {
- return setting.AppURL + path.Join(v.User, v.Repo+".git", "info/lfs/objects", v.Oid)
-}
-
-// VerifyLink builds a URL for verifying the object.
-func (v *RequestVars) VerifyLink() string {
- return setting.AppURL + path.Join(v.User, v.Repo+".git", "info/lfs/verify")
-}
-
-// link provides a structure used to build a hypermedia representation of an HTTP link.
-type link struct {
- Href string `json:"href"`
- Header map[string]string `json:"header,omitempty"`
- ExpiresAt time.Time `json:"expires_at,omitempty"`
-}
-
-var oidRegExp = regexp.MustCompile(`^[A-Fa-f0-9]+$`)
-
-func isOidValid(oid string) bool {
- return oidRegExp.MatchString(oid)
-}
-
-// ObjectOidHandler is the main request routing entry point into LFS server functions
-func ObjectOidHandler(ctx *context.Context) {
- if !setting.LFS.StartServer {
- log.Debug("Attempt to access LFS server but LFS server is disabled")
- writeStatus(ctx, 404)
- return
- }
-
- if ctx.Req.Method == "GET" || ctx.Req.Method == "HEAD" {
- if MetaMatcher(ctx.Req) {
- getMetaHandler(ctx)
- return
- }
-
- getContentHandler(ctx)
- return
- } else if ctx.Req.Method == "PUT" {
- PutHandler(ctx)
- return
- }
-
- log.Warn("Unhandled LFS method: %s for %s/%s OID[%s]", ctx.Req.Method, ctx.Params("username"), ctx.Params("reponame"), ctx.Params("oid"))
- writeStatus(ctx, 404)
-}
-
-func getAuthenticatedRepoAndMeta(ctx *context.Context, rv *RequestVars, requireWrite bool) (*models.LFSMetaObject, *models.Repository) {
- if !isOidValid(rv.Oid) {
- log.Info("Attempt to access invalid LFS OID[%s] in %s/%s", rv.Oid, rv.User, rv.Repo)
- writeStatus(ctx, 404)
- return nil, nil
- }
-
- repository, err := models.GetRepositoryByOwnerAndName(rv.User, rv.Repo)
- if err != nil {
- log.Error("Unable to get repository: %s/%s Error: %v", rv.User, rv.Repo, err)
- writeStatus(ctx, 404)
- return nil, nil
- }
-
- if !authenticate(ctx, repository, rv.Authorization, requireWrite) {
- requireAuth(ctx)
- return nil, nil
- }
-
- meta, err := repository.GetLFSMetaObjectByOid(rv.Oid)
- if err != nil {
- log.Error("Unable to get LFS OID[%s] Error: %v", rv.Oid, err)
- writeStatus(ctx, 404)
- return nil, nil
- }
-
- return meta, repository
-}
-
-// getContentHandler gets the content from the content store
-func getContentHandler(ctx *context.Context) {
- rv := unpack(ctx)
-
- meta, _ := getAuthenticatedRepoAndMeta(ctx, rv, false)
- if meta == nil {
- // Status already written in getAuthenticatedRepoAndMeta
- return
- }
-
- // Support resume download using Range header
- var fromByte, toByte int64
- toByte = meta.Size - 1
- statusCode := 200
- if rangeHdr := ctx.Req.Header.Get("Range"); rangeHdr != "" {
- regex := regexp.MustCompile(`bytes=(\d+)\-(\d*).*`)
- match := regex.FindStringSubmatch(rangeHdr)
- if len(match) > 1 {
- statusCode = 206
- fromByte, _ = strconv.ParseInt(match[1], 10, 32)
-
- if fromByte >= meta.Size {
- writeStatus(ctx, http.StatusRequestedRangeNotSatisfiable)
- return
- }
-
- if match[2] != "" {
- _toByte, _ := strconv.ParseInt(match[2], 10, 32)
- if _toByte >= fromByte && _toByte < toByte {
- toByte = _toByte
- }
- }
-
- ctx.Resp.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", fromByte, toByte, meta.Size-fromByte))
- ctx.Resp.Header().Set("Access-Control-Expose-Headers", "Content-Range")
- }
- }
-
- contentStore := &ContentStore{ObjectStorage: storage.LFS}
- content, err := contentStore.Get(meta)
- if err != nil {
- // Errors are logged in contentStore.Get
- writeStatus(ctx, http.StatusNotFound)
- return
- }
- defer content.Close()
-
- if fromByte > 0 {
- _, err = content.Seek(fromByte, io.SeekStart)
- if err != nil {
- log.Error("Whilst trying to read LFS OID[%s]: Unable to seek to %d Error: %v", meta.Oid, fromByte, err)
-
- writeStatus(ctx, http.StatusInternalServerError)
- return
- }
- }
-
- contentLength := toByte + 1 - fromByte
- ctx.Resp.Header().Set("Content-Length", strconv.FormatInt(contentLength, 10))
- ctx.Resp.Header().Set("Content-Type", "application/octet-stream")
-
- filename := ctx.Params("filename")
- if len(filename) > 0 {
- decodedFilename, err := base64.RawURLEncoding.DecodeString(filename)
- if err == nil {
- ctx.Resp.Header().Set("Content-Disposition", "attachment; filename=\""+string(decodedFilename)+"\"")
- ctx.Resp.Header().Set("Access-Control-Expose-Headers", "Content-Disposition")
- }
- }
-
- ctx.Resp.WriteHeader(statusCode)
- if written, err := io.CopyN(ctx.Resp, content, contentLength); err != nil {
- log.Error("Error whilst copying LFS OID[%s] to the response after %d bytes. Error: %v", meta.Oid, written, err)
- }
- logRequest(ctx.Req, statusCode)
-}
-
-// getMetaHandler retrieves metadata about the object
-func getMetaHandler(ctx *context.Context) {
- rv := unpack(ctx)
-
- meta, _ := getAuthenticatedRepoAndMeta(ctx, rv, false)
- if meta == nil {
- // Status already written in getAuthenticatedRepoAndMeta
- return
- }
-
- ctx.Resp.Header().Set("Content-Type", metaMediaType)
-
- if ctx.Req.Method == "GET" {
- json := jsoniter.ConfigCompatibleWithStandardLibrary
- enc := json.NewEncoder(ctx.Resp)
- if err := enc.Encode(Represent(rv, meta, true, false)); err != nil {
- log.Error("Failed to encode representation as json. Error: %v", err)
- }
- }
-
- logRequest(ctx.Req, 200)
-}
-
-// PostHandler instructs the client how to upload data
-func PostHandler(ctx *context.Context) {
- if !setting.LFS.StartServer {
- log.Debug("Attempt to access LFS server but LFS server is disabled")
- writeStatus(ctx, 404)
- return
- }
-
- if !MetaMatcher(ctx.Req) {
- log.Info("Attempt to POST without accepting the correct media type: %s", metaMediaType)
- writeStatus(ctx, 400)
- return
- }
-
- rv := unpack(ctx)
-
- repository, err := models.GetRepositoryByOwnerAndName(rv.User, rv.Repo)
- if err != nil {
- log.Error("Unable to get repository: %s/%s Error: %v", rv.User, rv.Repo, err)
- writeStatus(ctx, 404)
- return
- }
-
- if !authenticate(ctx, repository, rv.Authorization, true) {
- requireAuth(ctx)
- return
- }
-
- if !isOidValid(rv.Oid) {
- log.Info("Invalid LFS OID[%s] attempt to POST in %s/%s", rv.Oid, rv.User, rv.Repo)
- writeStatus(ctx, 404)
- return
- }
-
- if setting.LFS.MaxFileSize > 0 && rv.Size > setting.LFS.MaxFileSize {
- log.Info("Denied LFS OID[%s] upload of size %d to %s/%s because of LFS_MAX_FILE_SIZE=%d", rv.Oid, rv.Size, rv.User, rv.Repo, setting.LFS.MaxFileSize)
- writeStatus(ctx, 413)
- return
- }
-
- meta, err := models.NewLFSMetaObject(&models.LFSMetaObject{Oid: rv.Oid, Size: rv.Size, RepositoryID: repository.ID})
- if err != nil {
- log.Error("Unable to write LFS OID[%s] size %d meta object in %v/%v to database. Error: %v", rv.Oid, rv.Size, rv.User, rv.Repo, err)
- writeStatus(ctx, 404)
- return
- }
-
- ctx.Resp.Header().Set("Content-Type", metaMediaType)
-
- sentStatus := 202
- contentStore := &ContentStore{ObjectStorage: storage.LFS}
- exist, err := contentStore.Exists(meta)
- if err != nil {
- log.Error("Unable to check if LFS OID[%s] exist on %s / %s. Error: %v", rv.Oid, rv.User, rv.Repo, err)
- writeStatus(ctx, 500)
- return
- }
- if meta.Existing && exist {
- sentStatus = 200
- }
- ctx.Resp.WriteHeader(sentStatus)
-
- json := jsoniter.ConfigCompatibleWithStandardLibrary
- enc := json.NewEncoder(ctx.Resp)
- if err := enc.Encode(Represent(rv, meta, meta.Existing, true)); err != nil {
- log.Error("Failed to encode representation as json. Error: %v", err)
- }
- logRequest(ctx.Req, sentStatus)
-}
-
-// BatchHandler provides the batch api
-func BatchHandler(ctx *context.Context) {
- if !setting.LFS.StartServer {
- log.Debug("Attempt to access LFS server but LFS server is disabled")
- writeStatus(ctx, 404)
- return
- }
-
- if !MetaMatcher(ctx.Req) {
- log.Info("Attempt to BATCH without accepting the correct media type: %s", metaMediaType)
- writeStatus(ctx, 400)
- return
- }
-
- bv := unpackbatch(ctx)
-
- var responseObjects []*Representation
-
- // Create a response object
- for _, object := range bv.Objects {
- if !isOidValid(object.Oid) {
- log.Info("Invalid LFS OID[%s] attempt to BATCH in %s/%s", object.Oid, object.User, object.Repo)
- continue
- }
-
- repository, err := models.GetRepositoryByOwnerAndName(object.User, object.Repo)
- if err != nil {
- log.Error("Unable to get repository: %s/%s Error: %v", object.User, object.Repo, err)
- writeStatus(ctx, 404)
- return
- }
-
- requireWrite := false
- if bv.Operation == "upload" {
- requireWrite = true
- }
-
- if !authenticate(ctx, repository, object.Authorization, requireWrite) {
- requireAuth(ctx)
- return
- }
-
- contentStore := &ContentStore{ObjectStorage: storage.LFS}
-
- meta, err := repository.GetLFSMetaObjectByOid(object.Oid)
- if err == nil { // Object is found and exists
- exist, err := contentStore.Exists(meta)
- if err != nil {
- log.Error("Unable to check if LFS OID[%s] exist on %s / %s. Error: %v", object.Oid, object.User, object.Repo, err)
- writeStatus(ctx, 500)
- return
- }
- if exist {
- responseObjects = append(responseObjects, Represent(object, meta, true, false))
- continue
- }
- }
-
- if requireWrite && setting.LFS.MaxFileSize > 0 && object.Size > setting.LFS.MaxFileSize {
- log.Info("Denied LFS OID[%s] upload of size %d to %s/%s because of LFS_MAX_FILE_SIZE=%d", object.Oid, object.Size, object.User, object.Repo, setting.LFS.MaxFileSize)
- writeStatus(ctx, 413)
- return
- }
-
- // Object is not found
- meta, err = models.NewLFSMetaObject(&models.LFSMetaObject{Oid: object.Oid, Size: object.Size, RepositoryID: repository.ID})
- if err == nil {
- exist, err := contentStore.Exists(meta)
- if err != nil {
- log.Error("Unable to check if LFS OID[%s] exist on %s / %s. Error: %v", object.Oid, object.User, object.Repo, err)
- writeStatus(ctx, 500)
- return
- }
- responseObjects = append(responseObjects, Represent(object, meta, meta.Existing, !exist))
- } else {
- log.Error("Unable to write LFS OID[%s] size %d meta object in %v/%v to database. Error: %v", object.Oid, object.Size, object.User, object.Repo, err)
- }
- }
-
- ctx.Resp.Header().Set("Content-Type", metaMediaType)
-
- respobj := &BatchResponse{Objects: responseObjects}
-
- json := jsoniter.ConfigCompatibleWithStandardLibrary
- enc := json.NewEncoder(ctx.Resp)
- if err := enc.Encode(respobj); err != nil {
- log.Error("Failed to encode representation as json. Error: %v", err)
- }
- logRequest(ctx.Req, 200)
-}
-
-// PutHandler receives data from the client and puts it into the content store
-func PutHandler(ctx *context.Context) {
- rv := unpack(ctx)
-
- meta, repository := getAuthenticatedRepoAndMeta(ctx, rv, true)
- if meta == nil {
- // Status already written in getAuthenticatedRepoAndMeta
- return
- }
-
- contentStore := &ContentStore{ObjectStorage: storage.LFS}
- defer ctx.Req.Body.Close()
- if err := contentStore.Put(meta, ctx.Req.Body); err != nil {
- // Put will log the error itself
- ctx.Resp.WriteHeader(500)
- if err == errSizeMismatch || err == errHashMismatch {
- fmt.Fprintf(ctx.Resp, `{"message":"%s"}`, err)
- } else {
- fmt.Fprintf(ctx.Resp, `{"message":"Internal Server Error"}`)
- }
- if _, err = repository.RemoveLFSMetaObjectByOid(rv.Oid); err != nil {
- log.Error("Whilst removing metaobject for LFS OID[%s] due to preceding error there was another Error: %v", rv.Oid, err)
- }
- return
- }
-
- logRequest(ctx.Req, 200)
-}
-
-// VerifyHandler verify oid and its size from the content store
-func VerifyHandler(ctx *context.Context) {
- if !setting.LFS.StartServer {
- log.Debug("Attempt to access LFS server but LFS server is disabled")
- writeStatus(ctx, 404)
- return
- }
-
- if !MetaMatcher(ctx.Req) {
- log.Info("Attempt to VERIFY without accepting the correct media type: %s", metaMediaType)
- writeStatus(ctx, 400)
- return
- }
-
- rv := unpack(ctx)
-
- meta, _ := getAuthenticatedRepoAndMeta(ctx, rv, true)
- if meta == nil {
- // Status already written in getAuthenticatedRepoAndMeta
- return
- }
-
- contentStore := &ContentStore{ObjectStorage: storage.LFS}
- ok, err := contentStore.Verify(meta)
- if err != nil {
- // Error will be logged in Verify
- ctx.Resp.WriteHeader(500)
- fmt.Fprintf(ctx.Resp, `{"message":"Internal Server Error"}`)
- return
- }
- if !ok {
- writeStatus(ctx, 422)
- return
- }
-
- logRequest(ctx.Req, 200)
-}
-
-// Represent takes a RequestVars and Meta and turns it into a Representation suitable
-// for json encoding
-func Represent(rv *RequestVars, meta *models.LFSMetaObject, download, upload bool) *Representation {
- rep := &Representation{
- Oid: meta.Oid,
- Size: meta.Size,
- Actions: make(map[string]*link),
- }
-
- header := make(map[string]string)
-
- if rv.Authorization == "" {
- //https://github.com/github/git-lfs/issues/1088
- header["Authorization"] = "Authorization: Basic dummy"
- } else {
- header["Authorization"] = rv.Authorization
- }
-
- if download {
- rep.Actions["download"] = &link{Href: rv.ObjectLink(), Header: header}
- }
-
- if upload {
- rep.Actions["upload"] = &link{Href: rv.ObjectLink(), Header: header}
- }
-
- if upload && !download {
- // Force client side verify action while gitea lacks proper server side verification
- verifyHeader := make(map[string]string)
- for k, v := range header {
- verifyHeader[k] = v
- }
-
- // This is only needed to workaround https://github.com/git-lfs/git-lfs/issues/3662
- verifyHeader["Accept"] = metaMediaType
-
- rep.Actions["verify"] = &link{Href: rv.VerifyLink(), Header: verifyHeader}
- }
-
- return rep
-}
-
-// MetaMatcher provides a mux.MatcherFunc that only allows requests that contain
-// an Accept header with the metaMediaType
-func MetaMatcher(r *http.Request) bool {
- mediaParts := strings.Split(r.Header.Get("Accept"), ";")
- mt := mediaParts[0]
- return mt == metaMediaType
-}
-
-func unpack(ctx *context.Context) *RequestVars {
- r := ctx.Req
- rv := &RequestVars{
- User: ctx.Params("username"),
- Repo: strings.TrimSuffix(ctx.Params("reponame"), ".git"),
- Oid: ctx.Params("oid"),
- Authorization: r.Header.Get("Authorization"),
- }
-
- if r.Method == "POST" { // Maybe also check if +json
- var p RequestVars
- bodyReader := r.Body
- defer bodyReader.Close()
- json := jsoniter.ConfigCompatibleWithStandardLibrary
- dec := json.NewDecoder(bodyReader)
- err := dec.Decode(&p)
- if err != nil {
- // The error is logged as a WARN here because this may represent misbehaviour rather than a true error
- log.Warn("Unable to decode POST request vars for LFS OID[%s] in %s/%s: Error: %v", rv.Oid, rv.User, rv.Repo, err)
- return rv
- }
-
- rv.Oid = p.Oid
- rv.Size = p.Size
- }
-
- return rv
-}
-
-// TODO cheap hack, unify with unpack
-func unpackbatch(ctx *context.Context) *BatchVars {
-
- r := ctx.Req
- var bv BatchVars
-
- bodyReader := r.Body
- defer bodyReader.Close()
- json := jsoniter.ConfigCompatibleWithStandardLibrary
- dec := json.NewDecoder(bodyReader)
- err := dec.Decode(&bv)
- if err != nil {
- // The error is logged as a WARN here because this may represent misbehaviour rather than a true error
- log.Warn("Unable to decode BATCH request vars in %s/%s: Error: %v", ctx.Params("username"), strings.TrimSuffix(ctx.Params("reponame"), ".git"), err)
- return &bv
- }
-
- for i := 0; i < len(bv.Objects); i++ {
- bv.Objects[i].User = ctx.Params("username")
- bv.Objects[i].Repo = strings.TrimSuffix(ctx.Params("reponame"), ".git")
- bv.Objects[i].Authorization = r.Header.Get("Authorization")
- }
-
- return &bv
-}
-
-func writeStatus(ctx *context.Context, status int) {
- message := http.StatusText(status)
-
- mediaParts := strings.Split(ctx.Req.Header.Get("Accept"), ";")
- mt := mediaParts[0]
- if strings.HasSuffix(mt, "+json") {
- message = `{"message":"` + message + `"}`
- }
-
- ctx.Resp.WriteHeader(status)
- fmt.Fprint(ctx.Resp, message)
- logRequest(ctx.Req, status)
-}
-
-func logRequest(r *http.Request, status int) {
- log.Debug("LFS request - Method: %s, URL: %s, Status %d", r.Method, r.URL, status)
-}
-
-// authenticate uses the authorization string to determine whether
-// or not to proceed. This server assumes an HTTP Basic auth format.
-func authenticate(ctx *context.Context, repository *models.Repository, authorization string, requireWrite bool) bool {
- accessMode := models.AccessModeRead
- if requireWrite {
- accessMode = models.AccessModeWrite
- }
-
- // ctx.IsSigned is unnecessary here, this will be checked in perm.CanAccess
- perm, err := models.GetUserRepoPermission(repository, ctx.User)
- if err != nil {
- log.Error("Unable to GetUserRepoPermission for user %-v in repo %-v Error: %v", ctx.User, repository)
- return false
- }
-
- canRead := perm.CanAccess(accessMode, models.UnitTypeCode)
- if canRead {
- return true
- }
-
- user, repo, opStr, err := parseToken(authorization)
- if err != nil {
- // Most of these are Warn level - the true internal server errors are logged in parseToken already
- log.Warn("Authentication failure for provided token with Error: %v", err)
- return false
- }
- ctx.User = user
- if opStr == "basic" {
- perm, err = models.GetUserRepoPermission(repository, ctx.User)
- if err != nil {
- log.Error("Unable to GetUserRepoPermission for user %-v in repo %-v Error: %v", ctx.User, repository)
- return false
- }
- return perm.CanAccess(accessMode, models.UnitTypeCode)
- }
- if repository.ID == repo.ID {
- if requireWrite && opStr != "upload" {
- return false
- }
- return true
- }
- return false
-}
-
-func parseToken(authorization string) (*models.User, *models.Repository, string, error) {
- if authorization == "" {
- return nil, nil, "unknown", fmt.Errorf("No token")
- }
- if strings.HasPrefix(authorization, "Bearer ") {
- token, err := jwt.ParseWithClaims(authorization[7:], &Claims{}, func(t *jwt.Token) (interface{}, error) {
- if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
- return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
- }
- return setting.LFS.JWTSecretBytes, nil
- })
- if err != nil {
- // The error here is WARN level because it is caused by bad authorization rather than an internal server error
- return nil, nil, "unknown", err
- }
- claims, claimsOk := token.Claims.(*Claims)
- if !token.Valid || !claimsOk {
- return nil, nil, "unknown", fmt.Errorf("Token claim invalid")
- }
- r, err := models.GetRepositoryByID(claims.RepoID)
- if err != nil {
- log.Error("Unable to GetRepositoryById[%d]: Error: %v", claims.RepoID, err)
- return nil, nil, claims.Op, err
- }
- u, err := models.GetUserByID(claims.UserID)
- if err != nil {
- log.Error("Unable to GetUserById[%d]: Error: %v", claims.UserID, err)
- return nil, r, claims.Op, err
- }
- return u, r, claims.Op, nil
- }
-
- if strings.HasPrefix(authorization, "Basic ") {
- c, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authorization, "Basic "))
- if err != nil {
- return nil, nil, "basic", err
- }
- cs := string(c)
- i := strings.IndexByte(cs, ':')
- if i < 0 {
- return nil, nil, "basic", fmt.Errorf("Basic auth invalid")
- }
- user, password := cs[:i], cs[i+1:]
- u, err := models.GetUserByName(user)
- if err != nil {
- log.Error("Unable to GetUserByName[%d]: Error: %v", user, err)
- return nil, nil, "basic", err
- }
- if !u.IsPasswordSet() || !u.ValidatePassword(password) {
- return nil, nil, "basic", fmt.Errorf("Basic auth failed")
- }
- return u, nil, "basic", nil
- }
-
- return nil, nil, "unknown", fmt.Errorf("Token not found")
-}
-
-func requireAuth(ctx *context.Context) {
- ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
- writeStatus(ctx, 401)
-}
diff --git a/modules/lfs/shared.go b/modules/lfs/shared.go
new file mode 100644
index 0000000000..70b76d7512
--- /dev/null
+++ b/modules/lfs/shared.go
@@ -0,0 +1,69 @@
+// Copyright 2020 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 (
+ "time"
+)
+
+const (
+ // MediaType contains the media type for LFS server requests
+ MediaType = "application/vnd.git-lfs+json"
+)
+
+// BatchRequest contains multiple requests processed in one batch operation.
+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#requests
+type BatchRequest struct {
+ Operation string `json:"operation"`
+ Transfers []string `json:"transfers,omitempty"`
+ Ref *Reference `json:"ref,omitempty"`
+ Objects []Pointer `json:"objects"`
+}
+
+// Reference contains a git reference.
+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#ref-property
+type Reference struct {
+ Name string `json:"name"`
+}
+
+// Pointer contains LFS pointer data
+type Pointer struct {
+ Oid string `json:"oid" xorm:"UNIQUE(s) INDEX NOT NULL"`
+ Size int64 `json:"size" xorm:"NOT NULL"`
+}
+
+// BatchResponse contains multiple object metadata Representation structures
+// for use with the batch API.
+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#successful-responses
+type BatchResponse struct {
+ Transfer string `json:"transfer,omitempty"`
+ Objects []*ObjectResponse `json:"objects"`
+}
+
+// ObjectResponse is object metadata as seen by clients of the LFS server.
+type ObjectResponse struct {
+ Pointer
+ Actions map[string]*Link `json:"actions"`
+ Error *ObjectError `json:"error,omitempty"`
+}
+
+// Link provides a structure used to build a hypermedia representation of an HTTP link.
+type Link struct {
+ Href string `json:"href"`
+ Header map[string]string `json:"header,omitempty"`
+ ExpiresAt time.Time `json:"expires_at,omitempty"`
+}
+
+// ObjectError defines the JSON structure returned to the client in case of an error
+type ObjectError struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+}
+
+// PointerBlob associates a Git blob with a Pointer.
+type PointerBlob struct {
+ Hash string
+ Pointer
+}
diff --git a/modules/lfs/transferadapter.go b/modules/lfs/transferadapter.go
new file mode 100644
index 0000000000..ea3aff0000
--- /dev/null
+++ b/modules/lfs/transferadapter.go
@@ -0,0 +1,58 @@
+// Copyright 2021 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 (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+)
+
+// TransferAdapter represents an adapter for downloading/uploading LFS objects
+type TransferAdapter interface {
+ Name() string
+ Download(ctx context.Context, r *ObjectResponse) (io.ReadCloser, error)
+ //Upload(ctx context.Context, reader io.Reader) error
+}
+
+// BasicTransferAdapter implements the "basic" adapter
+type BasicTransferAdapter struct {
+ client *http.Client
+}
+
+// Name returns the name of the adapter
+func (a *BasicTransferAdapter) Name() string {
+ return "basic"
+}
+
+// Download reads the download location and downloads the data
+func (a *BasicTransferAdapter) Download(ctx context.Context, r *ObjectResponse) (io.ReadCloser, error) {
+ download, ok := r.Actions["download"]
+ if !ok {
+ return nil, errors.New("lfs.BasicTransferAdapter.Download: Action 'download' not found")
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "GET", download.Href, nil)
+ if err != nil {
+ return nil, fmt.Errorf("lfs.BasicTransferAdapter.Download http.NewRequestWithContext: %w", err)
+ }
+ for key, value := range download.Header {
+ req.Header.Set(key, value)
+ }
+
+ res, err := a.client.Do(req)
+ if err != nil {
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ default:
+ }
+ return nil, fmt.Errorf("lfs.BasicTransferAdapter.Download http.Do: %w", err)
+ }
+
+ return res.Body, nil
+}
diff --git a/modules/lfs/transferadapter_test.go b/modules/lfs/transferadapter_test.go
new file mode 100644
index 0000000000..0eabd3faee
--- /dev/null
+++ b/modules/lfs/transferadapter_test.go
@@ -0,0 +1,78 @@
+// Copyright 2021 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 (
+ "bytes"
+ "context"
+ "io/ioutil"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestBasicTransferAdapterName(t *testing.T) {
+ a := &BasicTransferAdapter{}
+
+ assert.Equal(t, "basic", a.Name())
+}
+
+func TestBasicTransferAdapterDownload(t *testing.T) {
+ roundTripHandler := func(req *http.Request) *http.Response {
+ url := req.URL.String()
+ if strings.Contains(url, "valid-download-request") {
+ assert.Equal(t, "GET", req.Method)
+ assert.Equal(t, "test-value", req.Header.Get("test-header"))
+
+ return &http.Response{StatusCode: http.StatusOK, Body: ioutil.NopCloser(bytes.NewBufferString("dummy"))}
+ }
+
+ t.Errorf("Unknown test case: %s", url)
+
+ return nil
+ }
+
+ hc := &http.Client{Transport: RoundTripFunc(roundTripHandler)}
+ a := &BasicTransferAdapter{hc}
+
+ var cases = []struct {
+ response *ObjectResponse
+ expectederror string
+ }{
+ // case 0
+ {
+ response: &ObjectResponse{},
+ expectederror: "Action 'download' not found",
+ },
+ // case 1
+ {
+ response: &ObjectResponse{
+ Actions: map[string]*Link{"upload": nil},
+ },
+ expectederror: "Action 'download' not found",
+ },
+ // case 2
+ {
+ response: &ObjectResponse{
+ Actions: map[string]*Link{"download": {
+ Href: "https://valid-download-request.io",
+ Header: map[string]string{"test-header": "test-value"},
+ }},
+ },
+ expectederror: "",
+ },
+ }
+
+ for n, c := range cases {
+ _, err := a.Download(context.Background(), c.response)
+ if len(c.expectederror) > 0 {
+ assert.True(t, strings.Contains(err.Error(), c.expectederror), "case %d: '%s' should contain '%s'", n, err.Error(), c.expectederror)
+ } else {
+ assert.NoError(t, err, "case %d", n)
+ }
+ }
+}