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>
This commit is contained in:
parent
f544414a23
commit
c03e488e14
75 changed files with 2159 additions and 711 deletions
24
modules/lfs/client.go
Normal file
24
modules/lfs/client.go
Normal file
|
@ -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)
|
||||
}
|
23
modules/lfs/client_test.go
Normal file
23
modules/lfs/client_test.go
Normal file
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
106
modules/lfs/endpoint.go
Normal file
106
modules/lfs/endpoint.go
Normal file
|
@ -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
|
||||
}
|
75
modules/lfs/endpoint_test.go
Normal file
75
modules/lfs/endpoint_test.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
50
modules/lfs/filesystem_client.go
Normal file
50
modules/lfs/filesystem_client.go
Normal file
|
@ -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
|
||||
}
|
129
modules/lfs/http_client.go
Normal file
129
modules/lfs/http_client.go
Normal file
|
@ -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
|
||||
}
|
144
modules/lfs/http_client_test.go
Normal file
144
modules/lfs/http_client_test.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)})
|
||||
}
|
123
modules/lfs/pointer.go
Normal file
123
modules/lfs/pointer.go
Normal file
|
@ -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
|
||||
}
|
64
modules/lfs/pointer_scanner_gogit.go
Normal file
64
modules/lfs/pointer_scanner_gogit.go
Normal file
|
@ -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)
|
||||
}
|
110
modules/lfs/pointer_scanner_nogogit.go
Normal file
110
modules/lfs/pointer_scanner_nogogit.go
Normal file
|
@ -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}
|
||||
}
|
||||
}
|
103
modules/lfs/pointer_test.go
Normal file
103
modules/lfs/pointer_test.go
Normal file
|
@ -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))
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
69
modules/lfs/shared.go
Normal file
69
modules/lfs/shared.go
Normal file
|
@ -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
|
||||
}
|
58
modules/lfs/transferadapter.go
Normal file
58
modules/lfs/transferadapter.go
Normal file
|
@ -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
|
||||
}
|
78
modules/lfs/transferadapter_test.go
Normal file
78
modules/lfs/transferadapter_test.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -20,6 +20,8 @@ type MigrateOptions struct {
|
|||
// required: true
|
||||
RepoName string `json:"repo_name" binding:"Required"`
|
||||
Mirror bool `json:"mirror"`
|
||||
LFS bool `json:"lfs"`
|
||||
LFSEndpoint string `json:"lfs_endpoint"`
|
||||
Private bool `json:"private"`
|
||||
Description string `json:"description"`
|
||||
OriginalURL string
|
||||
|
|
|
@ -116,6 +116,8 @@ func (g *GiteaLocalUploader) CreateRepo(repo *base.Repository, opts base.Migrate
|
|||
OriginalURL: repo.OriginalURL,
|
||||
GitServiceType: opts.GitServiceType,
|
||||
Mirror: repo.IsMirror,
|
||||
LFS: opts.LFS,
|
||||
LFSEndpoint: opts.LFSEndpoint,
|
||||
CloneAddr: repo.CloneURL,
|
||||
Private: repo.IsPrivate,
|
||||
Wiki: opts.Wiki,
|
||||
|
|
|
@ -104,6 +104,12 @@ func MigrateRepository(ctx context.Context, doer *models.User, ownerName string,
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if opts.LFS && len(opts.LFSEndpoint) > 0 {
|
||||
err := IsMigrateURLAllowed(opts.LFSEndpoint, doer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
downloader, err := newDownloader(ctx, ownerName, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
@ -18,7 +18,6 @@ import (
|
|||
"code.gitea.io/gitea/modules/log"
|
||||
repo_module "code.gitea.io/gitea/modules/repository"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/storage"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
|
||||
stdcharset "golang.org/x/net/html/charset"
|
||||
|
@ -70,30 +69,29 @@ func detectEncodingAndBOM(entry *git.TreeEntry, repo *models.Repository) (string
|
|||
buf = buf[:n]
|
||||
|
||||
if setting.LFS.StartServer {
|
||||
meta := lfs.IsPointerFile(&buf)
|
||||
if meta != nil {
|
||||
meta, err = repo.GetLFSMetaObjectByOid(meta.Oid)
|
||||
pointer, _ := lfs.ReadPointerFromBuffer(buf)
|
||||
if pointer.IsValid() {
|
||||
meta, err := repo.GetLFSMetaObjectByOid(pointer.Oid)
|
||||
if err != nil && err != models.ErrLFSObjectNotExist {
|
||||
// return default
|
||||
return "UTF-8", false
|
||||
}
|
||||
}
|
||||
if meta != nil {
|
||||
dataRc, err := lfs.ReadMetaObject(meta)
|
||||
if err != nil {
|
||||
// return default
|
||||
return "UTF-8", false
|
||||
if meta != nil {
|
||||
dataRc, err := lfs.ReadMetaObject(pointer)
|
||||
if err != nil {
|
||||
// return default
|
||||
return "UTF-8", false
|
||||
}
|
||||
defer dataRc.Close()
|
||||
buf = make([]byte, 1024)
|
||||
n, err = dataRc.Read(buf)
|
||||
if err != nil {
|
||||
// return default
|
||||
return "UTF-8", false
|
||||
}
|
||||
buf = buf[:n]
|
||||
}
|
||||
defer dataRc.Close()
|
||||
buf = make([]byte, 1024)
|
||||
n, err = dataRc.Read(buf)
|
||||
if err != nil {
|
||||
// return default
|
||||
return "UTF-8", false
|
||||
}
|
||||
buf = buf[:n]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
encoding, err := charset.DetectEncoding(buf)
|
||||
|
@ -387,12 +385,12 @@ func CreateOrUpdateRepoFile(repo *models.Repository, doer *models.User, opts *Up
|
|||
|
||||
if filename2attribute2info[treePath] != nil && filename2attribute2info[treePath]["filter"] == "lfs" {
|
||||
// OK so we are supposed to LFS this data!
|
||||
oid, err := models.GenerateLFSOid(strings.NewReader(opts.Content))
|
||||
pointer, err := lfs.GeneratePointer(strings.NewReader(opts.Content))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lfsMetaObject = &models.LFSMetaObject{Oid: oid, Size: int64(len(opts.Content)), RepositoryID: repo.ID}
|
||||
content = lfsMetaObject.Pointer()
|
||||
lfsMetaObject = &models.LFSMetaObject{Pointer: pointer, RepositoryID: repo.ID}
|
||||
content = pointer.StringContent()
|
||||
}
|
||||
}
|
||||
// Add the object to the database
|
||||
|
@ -435,13 +433,13 @@ func CreateOrUpdateRepoFile(repo *models.Repository, doer *models.User, opts *Up
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contentStore := &lfs.ContentStore{ObjectStorage: storage.LFS}
|
||||
exist, err := contentStore.Exists(lfsMetaObject)
|
||||
contentStore := lfs.NewContentStore()
|
||||
exist, err := contentStore.Exists(lfsMetaObject.Pointer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exist {
|
||||
if err := contentStore.Put(lfsMetaObject, strings.NewReader(opts.Content)); err != nil {
|
||||
if err := contentStore.Put(lfsMetaObject.Pointer, strings.NewReader(opts.Content)); err != nil {
|
||||
if _, err2 := repo.RemoveLFSMetaObjectByOid(lfsMetaObject.Oid); err2 != nil {
|
||||
return nil, fmt.Errorf("Error whilst removing failed inserted LFS object %s: %v (Prev Error: %v)", lfsMetaObject.Oid, err2, err)
|
||||
}
|
||||
|
|
|
@ -14,7 +14,6 @@ import (
|
|||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/lfs"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/storage"
|
||||
)
|
||||
|
||||
// UploadRepoFileOptions contains the uploaded repository file options
|
||||
|
@ -137,7 +136,7 @@ func UploadRepoFiles(repo *models.Repository, doer *models.User, opts *UploadRep
|
|||
|
||||
// OK now we can insert the data into the store - there's no way to clean up the store
|
||||
// once it's in there, it's in there.
|
||||
contentStore := &lfs.ContentStore{ObjectStorage: storage.LFS}
|
||||
contentStore := lfs.NewContentStore()
|
||||
for _, info := range infos {
|
||||
if err := uploadToLFSContentStore(info, contentStore); err != nil {
|
||||
return cleanUpAfterFailure(&infos, t, err)
|
||||
|
@ -163,18 +162,14 @@ func copyUploadedLFSFileIntoRepository(info *uploadInfo, filename2attribute2info
|
|||
if setting.LFS.StartServer && filename2attribute2info[info.upload.Name] != nil && filename2attribute2info[info.upload.Name]["filter"] == "lfs" {
|
||||
// Handle LFS
|
||||
// FIXME: Inefficient! this should probably happen in models.Upload
|
||||
oid, err := models.GenerateLFSOid(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileInfo, err := file.Stat()
|
||||
pointer, err := lfs.GeneratePointer(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info.lfsMetaObject = &models.LFSMetaObject{Oid: oid, Size: fileInfo.Size(), RepositoryID: t.repo.ID}
|
||||
info.lfsMetaObject = &models.LFSMetaObject{Pointer: pointer, RepositoryID: t.repo.ID}
|
||||
|
||||
if objectHash, err = t.HashObject(strings.NewReader(info.lfsMetaObject.Pointer())); err != nil {
|
||||
if objectHash, err = t.HashObject(strings.NewReader(pointer.StringContent())); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if objectHash, err = t.HashObject(file); err != nil {
|
||||
|
@ -189,7 +184,7 @@ func uploadToLFSContentStore(info uploadInfo, contentStore *lfs.ContentStore) er
|
|||
if info.lfsMetaObject == nil {
|
||||
return nil
|
||||
}
|
||||
exist, err := contentStore.Exists(info.lfsMetaObject)
|
||||
exist, err := contentStore.Exists(info.lfsMetaObject.Pointer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -202,7 +197,7 @@ func uploadToLFSContentStore(info uploadInfo, contentStore *lfs.ContentStore) er
|
|||
defer file.Close()
|
||||
// FIXME: Put regenerates the hash and copies the file over.
|
||||
// I guess this strictly ensures the soundness of the store but this is inefficient.
|
||||
if err := contentStore.Put(info.lfsMetaObject, file); err != nil {
|
||||
if err := contentStore.Put(info.lfsMetaObject.Pointer, file); err != nil {
|
||||
// OK Now we need to cleanup
|
||||
// Can't clean up the store, once uploaded there they're there.
|
||||
return err
|
||||
|
|
|
@ -7,12 +7,14 @@ package repository
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/lfs"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
migration "code.gitea.io/gitea/modules/migrations/base"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
@ -120,6 +122,13 @@ func MigrateRepositoryGitData(ctx context.Context, u *models.User, repo *models.
|
|||
log.Error("Failed to synchronize tags to releases for repository: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if opts.LFS {
|
||||
ep := lfs.DetermineEndpoint(opts.CloneAddr, opts.LFSEndpoint)
|
||||
if err = StoreMissingLfsObjectsInRepository(ctx, repo, gitRepo, ep); err != nil {
|
||||
log.Error("Failed to store missing LFS objects for repository: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err = repo.UpdateSize(models.DefaultDBContext()); err != nil {
|
||||
|
@ -132,6 +141,10 @@ func MigrateRepositoryGitData(ctx context.Context, u *models.User, repo *models.
|
|||
Interval: setting.Mirror.DefaultInterval,
|
||||
EnablePrune: true,
|
||||
NextUpdateUnix: timeutil.TimeStampNow().AddDuration(setting.Mirror.DefaultInterval),
|
||||
LFS: opts.LFS,
|
||||
}
|
||||
if opts.LFS {
|
||||
mirrorModel.LFSEndpoint = opts.LFSEndpoint
|
||||
}
|
||||
|
||||
if opts.MirrorInterval != "" {
|
||||
|
@ -300,3 +313,76 @@ func PushUpdateAddTag(repo *models.Repository, gitRepo *git.Repository, tagName
|
|||
|
||||
return models.SaveOrUpdateTag(repo, &rel)
|
||||
}
|
||||
|
||||
// StoreMissingLfsObjectsInRepository downloads missing LFS objects
|
||||
func StoreMissingLfsObjectsInRepository(ctx context.Context, repo *models.Repository, gitRepo *git.Repository, endpoint *url.URL) error {
|
||||
client := lfs.NewClient(endpoint)
|
||||
contentStore := lfs.NewContentStore()
|
||||
|
||||
pointerChan := make(chan lfs.PointerBlob)
|
||||
errChan := make(chan error, 1)
|
||||
go lfs.SearchPointerBlobs(ctx, gitRepo, pointerChan, errChan)
|
||||
|
||||
err := func() error {
|
||||
for pointerBlob := range pointerChan {
|
||||
meta, err := models.NewLFSMetaObject(&models.LFSMetaObject{Pointer: pointerBlob.Pointer, RepositoryID: repo.ID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("StoreMissingLfsObjectsInRepository models.NewLFSMetaObject: %w", err)
|
||||
}
|
||||
if meta.Existing {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Trace("StoreMissingLfsObjectsInRepository: LFS OID[%s] not present in repository %s", pointerBlob.Oid, repo.FullName())
|
||||
|
||||
err = func() error {
|
||||
exist, err := contentStore.Exists(pointerBlob.Pointer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("StoreMissingLfsObjectsInRepository contentStore.Exists: %w", err)
|
||||
}
|
||||
if !exist {
|
||||
if setting.LFS.MaxFileSize > 0 && pointerBlob.Size > setting.LFS.MaxFileSize {
|
||||
log.Info("LFS OID[%s] download denied because of LFS_MAX_FILE_SIZE=%d < size %d", pointerBlob.Oid, setting.LFS.MaxFileSize, pointerBlob.Size)
|
||||
return nil
|
||||
}
|
||||
|
||||
stream, err := client.Download(ctx, pointerBlob.Oid, pointerBlob.Size)
|
||||
if err != nil {
|
||||
return fmt.Errorf("StoreMissingLfsObjectsInRepository: LFS OID[%s] failed to download: %w", pointerBlob.Oid, err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
if err := contentStore.Put(pointerBlob.Pointer, stream); err != nil {
|
||||
return fmt.Errorf("StoreMissingLfsObjectsInRepository LFS OID[%s] contentStore.Put: %w", pointerBlob.Oid, err)
|
||||
}
|
||||
} else {
|
||||
log.Trace("StoreMissingLfsObjectsInRepository: LFS OID[%s] already present in content store", pointerBlob.Oid)
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
if _, err2 := repo.RemoveLFSMetaObjectByOid(meta.Oid); err2 != nil {
|
||||
log.Error("StoreMissingLfsObjectsInRepository RemoveLFSMetaObjectByOid[Oid: %s]: %w", meta.Oid, err2)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err, has := <-errChan
|
||||
if has {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -260,6 +260,8 @@ type MigrateRepoOptions struct {
|
|||
AuthToken string `json:"auth_token"`
|
||||
|
||||
Mirror bool `json:"mirror"`
|
||||
LFS bool `json:"lfs"`
|
||||
LFSEndpoint string `json:"lfs_endpoint"`
|
||||
Private bool `json:"private"`
|
||||
Description string `json:"description" binding:"MaxSize(255)"`
|
||||
Wiki bool `json:"wiki"`
|
||||
|
|
|
@ -6,9 +6,12 @@ package util
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
@ -150,3 +153,23 @@ func StatDir(rootPath string, includeDir ...bool) ([]string, error) {
|
|||
}
|
||||
return statDir(rootPath, "", isIncludeDir, false, false)
|
||||
}
|
||||
|
||||
// FileURLToPath extracts the path informations from a file://... url.
|
||||
func FileURLToPath(u *url.URL) (string, error) {
|
||||
if u.Scheme != "file" {
|
||||
return "", errors.New("URL scheme is not 'file': " + u.String())
|
||||
}
|
||||
|
||||
path := u.Path
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// If it looks like there's a Windows drive letter at the beginning, strip off the leading slash.
|
||||
re := regexp.MustCompile("/[A-Za-z]:/")
|
||||
if re.MatchString(path) {
|
||||
return path[1:], nil
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
|
58
modules/util/path_test.go
Normal file
58
modules/util/path_test.go
Normal file
|
@ -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 util
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFileURLToPath(t *testing.T) {
|
||||
var cases = []struct {
|
||||
url string
|
||||
expected string
|
||||
haserror bool
|
||||
windows bool
|
||||
}{
|
||||
// case 0
|
||||
{
|
||||
url: "",
|
||||
haserror: true,
|
||||
},
|
||||
// case 1
|
||||
{
|
||||
url: "http://test.io",
|
||||
haserror: true,
|
||||
},
|
||||
// case 2
|
||||
{
|
||||
url: "file:///path",
|
||||
expected: "/path",
|
||||
},
|
||||
// case 3
|
||||
{
|
||||
url: "file:///C:/path",
|
||||
expected: "C:/path",
|
||||
windows: true,
|
||||
},
|
||||
}
|
||||
|
||||
for n, c := range cases {
|
||||
if c.windows && runtime.GOOS != "windows" {
|
||||
continue
|
||||
}
|
||||
u, _ := url.Parse(c.url)
|
||||
p, err := FileURLToPath(u)
|
||||
if c.haserror {
|
||||
assert.Error(t, err, "case %d: should return error", n)
|
||||
} else {
|
||||
assert.NoError(t, err, "case %d: should not return error", n)
|
||||
assert.Equal(t, c.expected, p, "case %d: should be equal", n)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue