Browse Source

lfsutil: add `Storager` interface and local storage (#6083)

* Add Storager interface

* Add tests

* Add back note

* Add tests for basic protocol routes

* Fix lint errors
ᴜɴᴋɴᴡᴏɴ 4 years ago
parent
commit
9a5b227f3e

+ 3 - 1
conf/app.ini

@@ -264,7 +264,9 @@ HOST =
 ACCESS_CONTROL_ALLOW_ORIGIN =
 
 [lfs]
-; The root path to store LFS objects.
+; The storage backend for uploading new objects.
+STORAGE = local
+; The root path to store LFS objects on local file system.
 OBJECTS_PATH = data/lfs-objects
 
 [attachment]

File diff suppressed because it is too large
+ 1 - 1
internal/assets/conf/conf_gen.go


+ 8 - 5
internal/conf/static.go

@@ -193,11 +193,6 @@ var (
 		AccessControlAllowOrigin string
 	}
 
-	// LFS settings
-	LFS struct {
-		ObjectsPath string
-	}
-
 	// Attachment settings
 	Attachment struct {
 		Enabled      bool
@@ -417,6 +412,14 @@ type DatabaseOpts struct {
 // Database settings
 var Database DatabaseOpts
 
+type LFSOpts struct {
+	Storage     string
+	ObjectsPath string
+}
+
+// LFS settings
+var LFS LFSOpts
+
 type i18nConf struct {
 	Langs     []string          `delim:","`
 	Names     []string          `delim:","`

+ 4 - 42
internal/db/lfs.go

@@ -6,15 +6,10 @@ package db
 
 import (
 	"fmt"
-	"io"
-	"os"
-	"path/filepath"
 	"time"
 
 	"github.com/jinzhu/gorm"
-	"github.com/pkg/errors"
 
-	"gogs.io/gogs/internal/conf"
 	"gogs.io/gogs/internal/errutil"
 	"gogs.io/gogs/internal/lfsutil"
 )
@@ -23,8 +18,8 @@ import (
 //
 // NOTE: All methods are sorted in alphabetical order.
 type LFSStore interface {
-	// CreateObject streams io.ReadCloser to target storage and creates a record in database.
-	CreateObject(repoID int64, oid lfsutil.OID, rc io.ReadCloser, storage lfsutil.Storage) error
+	// CreateObject creates a LFS object record in database.
+	CreateObject(repoID int64, oid lfsutil.OID, size int64, storage lfsutil.Storage) error
 	// GetObjectByOID returns the LFS object with given OID. It returns ErrLFSObjectNotExist
 	// when not found.
 	GetObjectByOID(repoID int64, oid lfsutil.OID) (*LFSObject, error)
@@ -48,44 +43,11 @@ type LFSObject struct {
 	CreatedAt time.Time       `gorm:"NOT NULL"`
 }
 
-func (db *lfs) CreateObject(repoID int64, oid lfsutil.OID, rc io.ReadCloser, storage lfsutil.Storage) error {
-	if storage != lfsutil.StorageLocal {
-		return errors.New("only local storage is supported")
-	}
-
-	var ioerr error
-	fpath := lfsutil.StorageLocalPath(conf.LFS.ObjectsPath, oid)
-	defer func() {
-		rc.Close()
-
-		// NOTE: Only remove the file if there is an IO error, it is OK to
-		// leave the file when the whole operation failed with a DB error,
-		// a retry on client side can safely overwrite the same file as OID
-		// is seen as unique to every file.
-		if ioerr != nil {
-			_ = os.Remove(fpath)
-		}
-	}()
-
-	ioerr = os.MkdirAll(filepath.Dir(fpath), os.ModePerm)
-	if ioerr != nil {
-		return errors.Wrap(ioerr, "create directories")
-	}
-	w, ioerr := os.Create(fpath)
-	if ioerr != nil {
-		return errors.Wrap(ioerr, "create file")
-	}
-	defer w.Close()
-
-	written, ioerr := io.Copy(w, rc)
-	if ioerr != nil {
-		return errors.Wrap(ioerr, "copy file")
-	}
-
+func (db *lfs) CreateObject(repoID int64, oid lfsutil.OID, size int64, storage lfsutil.Storage) error {
 	object := &LFSObject{
 		RepoID:  repoID,
 		OID:     oid,
-		Size:    written,
+		Size:    size,
 		Storage: storage,
 	}
 	return db.DB.Create(object).Error

+ 3 - 4
internal/db/mocks.go

@@ -5,7 +5,6 @@
 package db
 
 import (
-	"io"
 	"testing"
 
 	"gogs.io/gogs/internal/lfsutil"
@@ -39,13 +38,13 @@ func SetMockAccessTokensStore(t *testing.T, mock AccessTokensStore) {
 var _ LFSStore = (*MockLFSStore)(nil)
 
 type MockLFSStore struct {
-	MockCreateObject     func(repoID int64, oid lfsutil.OID, rc io.ReadCloser, storage lfsutil.Storage) error
+	MockCreateObject     func(repoID int64, oid lfsutil.OID, size int64, storage lfsutil.Storage) error
 	MockGetObjectByOID   func(repoID int64, oid lfsutil.OID) (*LFSObject, error)
 	MockGetObjectsByOIDs func(repoID int64, oids ...lfsutil.OID) ([]*LFSObject, error)
 }
 
-func (m *MockLFSStore) CreateObject(repoID int64, oid lfsutil.OID, rc io.ReadCloser, storage lfsutil.Storage) error {
-	return m.MockCreateObject(repoID, oid, rc, storage)
+func (m *MockLFSStore) CreateObject(repoID int64, oid lfsutil.OID, size int64, storage lfsutil.Storage) error {
+	return m.MockCreateObject(repoID, oid, size, storage)
 }
 
 func (m *MockLFSStore) GetObjectByOID(repoID int64, oid lfsutil.OID) (*LFSObject, error) {

+ 4 - 0
internal/lfsutil/oid.go

@@ -5,6 +5,8 @@
 package lfsutil
 
 import (
+	"github.com/pkg/errors"
+
 	"gogs.io/gogs/internal/lazyregexp"
 )
 
@@ -15,6 +17,8 @@ type OID string
 // Spec: https://github.com/git-lfs/git-lfs/blob/master/docs/spec.md
 var oidRe = lazyregexp.New("^[a-f0-9]{64}$")
 
+var ErrInvalidOID = errors.New("OID is not valid")
+
 // ValidOID returns true if given oid is valid.
 func ValidOID(oid OID) bool {
 	return oidRe.MatchString(string(oid))

+ 88 - 5
internal/lfsutil/storage.go

@@ -5,9 +5,31 @@
 package lfsutil
 
 import (
+	"io"
+	"os"
 	"path/filepath"
+
+	"github.com/pkg/errors"
+
+	"gogs.io/gogs/internal/osutil"
 )
 
+var ErrObjectNotExist = errors.New("Object does not exist")
+
+// Storager is an storage backend for uploading and downloading LFS objects.
+type Storager interface {
+	// Storage returns the name of the storage backend.
+	Storage() Storage
+	// Upload reads content from the io.ReadCloser and uploads as given oid.
+	// The reader is closed once upload is finished. ErrInvalidOID is returned
+	// if the given oid is not valid.
+	Upload(oid OID, rc io.ReadCloser) (int64, error)
+	// Download streams content of given oid to the io.Writer. It is caller's
+	// responsibility the close the writer when needed. ErrObjectNotExist is
+	// returned if the given oid does not exist.
+	Download(oid OID, w io.Writer) error
+}
+
 // Storage is the storage type of an LFS object.
 type Storage string
 
@@ -15,12 +37,73 @@ const (
 	StorageLocal Storage = "local"
 )
 
-// StorageLocalPath returns computed file path for storing object on local file system.
-// It returns empty string if given "oid" isn't valid.
-func StorageLocalPath(root string, oid OID) string {
-	if !ValidOID(oid) {
+var _ Storager = (*LocalStorage)(nil)
+
+// LocalStorage is a LFS storage backend on local file system.
+type LocalStorage struct {
+	// The root path for storing LFS objects.
+	Root string
+}
+
+func (s *LocalStorage) Storage() Storage {
+	return StorageLocal
+}
+
+func (s *LocalStorage) storagePath(oid OID) string {
+	if len(oid) < 2 {
 		return ""
 	}
 
-	return filepath.Join(root, string(oid[0]), string(oid[1]), string(oid))
+	return filepath.Join(s.Root, string(oid[0]), string(oid[1]), string(oid))
+}
+
+func (s *LocalStorage) Upload(oid OID, rc io.ReadCloser) (int64, error) {
+	if !ValidOID(oid) {
+		return 0, ErrInvalidOID
+	}
+
+	var err error
+	fpath := s.storagePath(oid)
+	defer func() {
+		rc.Close()
+
+		if err != nil {
+			_ = os.Remove(fpath)
+		}
+	}()
+
+	err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm)
+	if err != nil {
+		return 0, errors.Wrap(err, "create directories")
+	}
+	w, err := os.Create(fpath)
+	if err != nil {
+		return 0, errors.Wrap(err, "create file")
+	}
+	defer w.Close()
+
+	written, err := io.Copy(w, rc)
+	if err != nil {
+		return 0, errors.Wrap(err, "copy file")
+	}
+	return written, nil
+}
+
+func (s *LocalStorage) Download(oid OID, w io.Writer) error {
+	fpath := s.storagePath(oid)
+	if !osutil.IsFile(fpath) {
+		return ErrObjectNotExist
+	}
+
+	r, err := os.Open(fpath)
+	if err != nil {
+		return errors.Wrap(err, "open file")
+	}
+	defer r.Close()
+
+	_, err = io.Copy(w, r)
+	if err != nil {
+		return errors.Wrap(err, "copy file")
+	}
+	return nil
 }

+ 98 - 7
internal/lfsutil/storage_test.go

@@ -5,39 +5,130 @@
 package lfsutil
 
 import (
+	"bytes"
+	"io/ioutil"
+	"os"
+	"path/filepath"
 	"runtime"
+	"strings"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 )
 
-func TestStorageLocalPath(t *testing.T) {
+func TestLocalStorage_storagePath(t *testing.T) {
 	if runtime.GOOS == "windows" {
 		t.Skip("Skipping testing on Windows")
 		return
 	}
 
+	s := &LocalStorage{
+		Root: "/lfs-objects",
+	}
+
 	tests := []struct {
 		name    string
-		root    string
 		oid     OID
 		expPath string
 	}{
 		{
-			name: "invalid oid",
-			oid:  OID("bad_oid"),
+			name: "empty oid",
+			oid:  "",
 		},
 
 		{
 			name:    "valid oid",
-			root:    "/lfs-objects",
-			oid:     OID("ef797c8118f02dfb649607dd5d3f8c7623048c9c063d532cc95c5ed7a898a64f"),
+			oid:     "ef797c8118f02dfb649607dd5d3f8c7623048c9c063d532cc95c5ed7a898a64f",
 			expPath: "/lfs-objects/e/f/ef797c8118f02dfb649607dd5d3f8c7623048c9c063d532cc95c5ed7a898a64f",
 		},
 	}
 	for _, test := range tests {
 		t.Run(test.name, func(t *testing.T) {
-			assert.Equal(t, test.expPath, StorageLocalPath(test.root, test.oid))
+			assert.Equal(t, test.expPath, s.storagePath(test.oid))
+		})
+	}
+}
+
+func TestLocalStorage_Upload(t *testing.T) {
+	s := &LocalStorage{
+		Root: filepath.Join(os.TempDir(), "lfs-objects"),
+	}
+	t.Cleanup(func() {
+		_ = os.RemoveAll(s.Root)
+	})
+
+	tests := []struct {
+		name       string
+		oid        OID
+		content    string
+		expWritten int64
+		expErr     error
+	}{
+		{
+			name:   "invalid oid",
+			oid:    "bad_oid",
+			expErr: ErrInvalidOID,
+		},
+
+		{
+			name:       "valid oid",
+			oid:        "ef797c8118f02dfb649607dd5d3f8c7623048c9c063d532cc95c5ed7a898a64f",
+			content:    "Hello world!",
+			expWritten: 12,
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			written, err := s.Upload(test.oid, ioutil.NopCloser(strings.NewReader(test.content)))
+			assert.Equal(t, test.expWritten, written)
+			assert.Equal(t, test.expErr, err)
+		})
+	}
+}
+
+func TestLocalStorage_Download(t *testing.T) {
+	oid := OID("ef797c8118f02dfb649607dd5d3f8c7623048c9c063d532cc95c5ed7a898a64f")
+	s := &LocalStorage{
+		Root: filepath.Join(os.TempDir(), "lfs-objects"),
+	}
+	t.Cleanup(func() {
+		_ = os.RemoveAll(s.Root)
+	})
+
+	fpath := s.storagePath(oid)
+	err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm)
+	if err != nil {
+		t.Fatal(err)
+	}
+	err = ioutil.WriteFile(fpath, []byte("Hello world!"), os.ModePerm)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	tests := []struct {
+		name       string
+		oid        OID
+		expContent string
+		expErr     error
+	}{
+		{
+			name:   "object not exists",
+			oid:    "bad_oid",
+			expErr: ErrObjectNotExist,
+		},
+
+		{
+			name:       "valid oid",
+			oid:        oid,
+			expContent: "Hello world!",
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			var buf bytes.Buffer
+			err := s.Download(test.oid, &buf)
+			assert.Equal(t, test.expContent, buf.String())
+			assert.Equal(t, test.expErr, err)
 		})
 	}
 }

+ 45 - 15
internal/route/lfs/basic.go

@@ -9,13 +9,11 @@ import (
 	"io"
 	"io/ioutil"
 	"net/http"
-	"os"
 	"strconv"
 
 	"gopkg.in/macaron.v1"
 	log "unknwon.dev/clog/v2"
 
-	"gogs.io/gogs/internal/conf"
 	"gogs.io/gogs/internal/db"
 	"gogs.io/gogs/internal/lfsutil"
 	"gogs.io/gogs/internal/strutil"
@@ -27,8 +25,25 @@ const (
 	basicOperationDownload = "download"
 )
 
+type basicHandler struct {
+	// The default storage backend for uploading new objects.
+	defaultStorage lfsutil.Storage
+	// The list of available storage backends to access objects.
+	storagers map[lfsutil.Storage]lfsutil.Storager
+}
+
+// DefaultStorager returns the default storage backend.
+func (h *basicHandler) DefaultStorager() lfsutil.Storager {
+	return h.storagers[h.defaultStorage]
+}
+
+// Storager returns the given storage backend.
+func (h *basicHandler) Storager(storage lfsutil.Storage) lfsutil.Storager {
+	return h.storagers[storage]
+}
+
 // GET /{owner}/{repo}.git/info/lfs/object/basic/{oid}
-func serveBasicDownload(c *macaron.Context, repo *db.Repository, oid lfsutil.OID) {
+func (h *basicHandler) serveDownload(c *macaron.Context, repo *db.Repository, oid lfsutil.OID) {
 	object, err := db.LFS.GetObjectByOID(repo.ID, oid)
 	if err != nil {
 		if db.IsErrLFSObjectNotExist(err) {
@@ -42,28 +57,26 @@ func serveBasicDownload(c *macaron.Context, repo *db.Repository, oid lfsutil.OID
 		return
 	}
 
-	fpath := lfsutil.StorageLocalPath(conf.LFS.ObjectsPath, object.OID)
-	r, err := os.Open(fpath)
-	if err != nil {
+	s := h.Storager(object.Storage)
+	if s == nil {
 		internalServerError(c.Resp)
-		log.Error("Failed to open object file [path: %s]: %v", fpath, err)
+		log.Error("Failed to locate the object [repo_id: %d, oid: %s]: storage %q not found", object.RepoID, object.OID, object.Storage)
 		return
 	}
-	defer r.Close()
 
 	c.Header().Set("Content-Type", "application/octet-stream")
 	c.Header().Set("Content-Length", strconv.FormatInt(object.Size, 10))
 	c.Status(http.StatusOK)
 
-	_, err = io.Copy(c.Resp, r)
+	err = s.Download(object.OID, c.Resp)
 	if err != nil {
-		log.Error("Failed to copy object file: %v", err)
+		log.Error("Failed to download object [oid: %s]: %v", object.OID, err)
 		return
 	}
 }
 
 // PUT /{owner}/{repo}.git/info/lfs/object/basic/{oid}
-func serveBasicUpload(c *macaron.Context, repo *db.Repository, oid lfsutil.OID) {
+func (h *basicHandler) serveUpload(c *macaron.Context, repo *db.Repository, oid lfsutil.OID) {
 	// NOTE: LFS client will retry upload the same object if there was a partial failure,
 	// therefore we would like to skip ones that already exist.
 	_, err := db.LFS.GetObjectByOID(repo.ID, oid)
@@ -79,8 +92,25 @@ func serveBasicUpload(c *macaron.Context, repo *db.Repository, oid lfsutil.OID)
 		return
 	}
 
-	err = db.LFS.CreateObject(repo.ID, oid, c.Req.Request.Body, lfsutil.StorageLocal)
+	s := h.DefaultStorager()
+	written, err := s.Upload(oid, c.Req.Request.Body)
+	if err != nil {
+		if err == lfsutil.ErrInvalidOID {
+			responseJSON(c.Resp, http.StatusBadRequest, responseError{
+				Message: err.Error(),
+			})
+		} else {
+			internalServerError(c.Resp)
+			log.Error("Failed to upload object [storage: %s, oid: %s]: %v", s.Storage(), oid, err)
+		}
+		return
+	}
+
+	err = db.LFS.CreateObject(repo.ID, oid, written, s.Storage())
 	if err != nil {
+		// NOTE: It is OK to leave the file when the whole operation failed
+		// with a DB error, a retry on client side can safely overwrite the
+		// same file as OID is seen as unique to every file.
 		internalServerError(c.Resp)
 		log.Error("Failed to create object [repo_id: %d, oid: %s]: %v", repo.ID, oid, err)
 		return
@@ -91,7 +121,7 @@ func serveBasicUpload(c *macaron.Context, repo *db.Repository, oid lfsutil.OID)
 }
 
 // POST /{owner}/{repo}.git/info/lfs/object/basic/verify
-func serveBasicVerify(c *macaron.Context, repo *db.Repository) {
+func (h *basicHandler) serveVerify(c *macaron.Context, repo *db.Repository) {
 	var request basicVerifyRequest
 	defer c.Req.Request.Body.Close()
 	err := json.NewDecoder(c.Req.Request.Body).Decode(&request)
@@ -109,7 +139,7 @@ func serveBasicVerify(c *macaron.Context, repo *db.Repository) {
 		return
 	}
 
-	object, err := db.LFS.GetObjectByOID(repo.ID, lfsutil.OID(request.Oid))
+	object, err := db.LFS.GetObjectByOID(repo.ID, request.Oid)
 	if err != nil {
 		if db.IsErrLFSObjectNotExist(err) {
 			responseJSON(c.Resp, http.StatusNotFound, responseError{
@@ -123,7 +153,7 @@ func serveBasicVerify(c *macaron.Context, repo *db.Repository) {
 	}
 
 	if object.Size != request.Size {
-		responseJSON(c.Resp, http.StatusNotFound, responseError{
+		responseJSON(c.Resp, http.StatusBadRequest, responseError{
 			Message: "Object size mismatch",
 		})
 		return

+ 288 - 0
internal/route/lfs/basic_test.go

@@ -0,0 +1,288 @@
+// Copyright 2020 The Gogs 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"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"gopkg.in/macaron.v1"
+
+	"gogs.io/gogs/internal/db"
+	"gogs.io/gogs/internal/lfsutil"
+)
+
+var _ lfsutil.Storager = (*mockStorage)(nil)
+
+// mockStorage is a in-memory storage for LFS objects.
+type mockStorage struct {
+	buf *bytes.Buffer
+}
+
+func (s *mockStorage) Storage() lfsutil.Storage {
+	return "memory"
+}
+
+func (s *mockStorage) Upload(oid lfsutil.OID, rc io.ReadCloser) (int64, error) {
+	defer rc.Close()
+	return io.Copy(s.buf, rc)
+}
+
+func (s *mockStorage) Download(oid lfsutil.OID, w io.Writer) error {
+	_, err := io.Copy(w, s.buf)
+	return err
+}
+
+func Test_basicHandler_serveDownload(t *testing.T) {
+	s := &mockStorage{}
+	basic := &basicHandler{
+		defaultStorage: s.Storage(),
+		storagers: map[lfsutil.Storage]lfsutil.Storager{
+			s.Storage(): s,
+		},
+	}
+
+	m := macaron.New()
+	m.Use(macaron.Renderer())
+	m.Use(func(c *macaron.Context) {
+		c.Map(&db.Repository{Name: "repo"})
+		c.Map(lfsutil.OID("ef797c8118f02dfb649607dd5d3f8c7623048c9c063d532cc95c5ed7a898a64f"))
+	})
+	m.Get("/", basic.serveDownload)
+
+	tests := []struct {
+		name          string
+		content       string
+		mockLFSStore  *db.MockLFSStore
+		expStatusCode int
+		expHeader     http.Header
+		expBody       string
+	}{
+		{
+			name: "object does not exist",
+			mockLFSStore: &db.MockLFSStore{
+				MockGetObjectByOID: func(repoID int64, oid lfsutil.OID) (*db.LFSObject, error) {
+					return nil, db.ErrLFSObjectNotExist{}
+				},
+			},
+			expStatusCode: http.StatusNotFound,
+			expHeader: http.Header{
+				"Content-Type": []string{"application/vnd.git-lfs+json"},
+			},
+			expBody: `{"message":"Object does not exist"}` + "\n",
+		},
+		{
+			name: "storage not found",
+			mockLFSStore: &db.MockLFSStore{
+				MockGetObjectByOID: func(repoID int64, oid lfsutil.OID) (*db.LFSObject, error) {
+					return &db.LFSObject{Storage: "bad_storage"}, nil
+				},
+			},
+			expStatusCode: http.StatusInternalServerError,
+			expHeader: http.Header{
+				"Content-Type": []string{"application/vnd.git-lfs+json"},
+			},
+			expBody: `{"message":"Internal server error"}` + "\n",
+		},
+
+		{
+			name:    "object exists",
+			content: "Hello world!",
+			mockLFSStore: &db.MockLFSStore{
+				MockGetObjectByOID: func(repoID int64, oid lfsutil.OID) (*db.LFSObject, error) {
+					return &db.LFSObject{
+						Size:    12,
+						Storage: s.Storage(),
+					}, nil
+				},
+			},
+			expStatusCode: http.StatusOK,
+			expHeader: http.Header{
+				"Content-Type":   []string{"application/octet-stream"},
+				"Content-Length": []string{"12"},
+			},
+			expBody: "Hello world!",
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			db.SetMockLFSStore(t, test.mockLFSStore)
+
+			s.buf = bytes.NewBufferString(test.content)
+
+			r, err := http.NewRequest("GET", "/", nil)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			rr := httptest.NewRecorder()
+			m.ServeHTTP(rr, r)
+
+			resp := rr.Result()
+			assert.Equal(t, test.expStatusCode, resp.StatusCode)
+			assert.Equal(t, test.expHeader, resp.Header)
+
+			body, err := ioutil.ReadAll(resp.Body)
+			if err != nil {
+				t.Fatal(err)
+			}
+			assert.Equal(t, test.expBody, string(body))
+		})
+	}
+}
+
+func Test_basicHandler_serveUpload(t *testing.T) {
+	s := &mockStorage{buf: &bytes.Buffer{}}
+	basic := &basicHandler{
+		defaultStorage: s.Storage(),
+		storagers: map[lfsutil.Storage]lfsutil.Storager{
+			s.Storage(): s,
+		},
+	}
+
+	m := macaron.New()
+	m.Use(macaron.Renderer())
+	m.Use(func(c *macaron.Context) {
+		c.Map(&db.Repository{Name: "repo"})
+		c.Map(lfsutil.OID("ef797c8118f02dfb649607dd5d3f8c7623048c9c063d532cc95c5ed7a898a64f"))
+	})
+	m.Put("/", basic.serveUpload)
+
+	tests := []struct {
+		name          string
+		mockLFSStore  *db.MockLFSStore
+		expStatusCode int
+		expBody       string
+	}{
+		{
+			name: "object already exists",
+			mockLFSStore: &db.MockLFSStore{
+				MockGetObjectByOID: func(repoID int64, oid lfsutil.OID) (*db.LFSObject, error) {
+					return &db.LFSObject{}, nil
+				},
+			},
+			expStatusCode: http.StatusOK,
+		},
+		{
+			name: "new object",
+			mockLFSStore: &db.MockLFSStore{
+				MockGetObjectByOID: func(repoID int64, oid lfsutil.OID) (*db.LFSObject, error) {
+					return nil, db.ErrLFSObjectNotExist{}
+				},
+				MockCreateObject: func(repoID int64, oid lfsutil.OID, size int64, storage lfsutil.Storage) error {
+					return nil
+				},
+			},
+			expStatusCode: http.StatusOK,
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			db.SetMockLFSStore(t, test.mockLFSStore)
+
+			r, err := http.NewRequest("PUT", "/", strings.NewReader("Hello world!"))
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			rr := httptest.NewRecorder()
+			m.ServeHTTP(rr, r)
+
+			resp := rr.Result()
+			assert.Equal(t, test.expStatusCode, resp.StatusCode)
+
+			body, err := ioutil.ReadAll(resp.Body)
+			if err != nil {
+				t.Fatal(err)
+			}
+			assert.Equal(t, test.expBody, string(body))
+		})
+	}
+}
+
+func Test_basicHandler_serveVerify(t *testing.T) {
+	m := macaron.New()
+	m.Use(macaron.Renderer())
+	m.Use(func(c *macaron.Context) {
+		c.Map(&db.Repository{Name: "repo"})
+	})
+	m.Post("/", (&basicHandler{}).serveVerify)
+
+	tests := []struct {
+		name          string
+		body          string
+		mockLFSStore  *db.MockLFSStore
+		expStatusCode int
+		expBody       string
+	}{
+		{
+			name:          "invalid oid",
+			body:          `{"oid": "bad_oid"}`,
+			expStatusCode: http.StatusBadRequest,
+			expBody:       `{"message":"Invalid oid"}` + "\n",
+		},
+		{
+			name: "object does not exist",
+			body: `{"oid":"ef797c8118f02dfb649607dd5d3f8c7623048c9c063d532cc95c5ed7a898a64f"}`,
+			mockLFSStore: &db.MockLFSStore{
+				MockGetObjectByOID: func(repoID int64, oid lfsutil.OID) (*db.LFSObject, error) {
+					return nil, db.ErrLFSObjectNotExist{}
+				},
+			},
+			expStatusCode: http.StatusNotFound,
+			expBody:       `{"message":"Object does not exist"}` + "\n",
+		},
+		{
+			name: "object size mismatch",
+			body: `{"oid":"ef797c8118f02dfb649607dd5d3f8c7623048c9c063d532cc95c5ed7a898a64f"}`,
+			mockLFSStore: &db.MockLFSStore{
+				MockGetObjectByOID: func(repoID int64, oid lfsutil.OID) (*db.LFSObject, error) {
+					return &db.LFSObject{Size: 12}, nil
+				},
+			},
+			expStatusCode: http.StatusBadRequest,
+			expBody:       `{"message":"Object size mismatch"}` + "\n",
+		},
+
+		{
+			name: "object exists",
+			body: `{"oid":"ef797c8118f02dfb649607dd5d3f8c7623048c9c063d532cc95c5ed7a898a64f", "size":12}`,
+			mockLFSStore: &db.MockLFSStore{
+				MockGetObjectByOID: func(repoID int64, oid lfsutil.OID) (*db.LFSObject, error) {
+					return &db.LFSObject{Size: 12}, nil
+				},
+			},
+			expStatusCode: http.StatusOK,
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			db.SetMockLFSStore(t, test.mockLFSStore)
+
+			r, err := http.NewRequest("POST", "/", strings.NewReader(test.body))
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			rr := httptest.NewRecorder()
+			m.ServeHTTP(rr, r)
+
+			resp := rr.Result()
+			assert.Equal(t, test.expStatusCode, resp.StatusCode)
+
+			body, err := ioutil.ReadAll(resp.Body)
+			if err != nil {
+				t.Fatal(err)
+			}
+			assert.Equal(t, test.expBody, string(body))
+		})
+	}
+}

+ 1 - 0
internal/route/lfs/batch_test.go

@@ -23,6 +23,7 @@ func Test_serveBatch(t *testing.T) {
 	conf.SetMockServer(t, conf.ServerOpts{
 		ExternalURL: "https://gogs.example.com/",
 	})
+
 	m := macaron.New()
 	m.Use(func(c *macaron.Context) {
 		c.Map(&db.User{Name: "owner"})

+ 30 - 0
internal/route/lfs/main_test.go

@@ -0,0 +1,30 @@
+// Copyright 2020 The Gogs 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 (
+	"flag"
+	"fmt"
+	"os"
+	"testing"
+
+	log "unknwon.dev/clog/v2"
+
+	"gogs.io/gogs/internal/testutil"
+)
+
+func TestMain(m *testing.M) {
+	flag.Parse()
+	if !testing.Verbose() {
+		// Remove the primary logger and register a noop logger.
+		log.Remove(log.DefaultConsoleName)
+		err := log.New("noop", testutil.InitNoopLogger)
+		if err != nil {
+			fmt.Println(err)
+			os.Exit(1)
+		}
+	}
+	os.Exit(m.Run())
+}

+ 11 - 3
internal/route/lfs/route.go

@@ -13,6 +13,7 @@ import (
 	log "unknwon.dev/clog/v2"
 
 	"gogs.io/gogs/internal/authutil"
+	"gogs.io/gogs/internal/conf"
 	"gogs.io/gogs/internal/db"
 	"gogs.io/gogs/internal/lfsutil"
 )
@@ -26,10 +27,17 @@ func RegisterRoutes(r *macaron.Router) {
 	r.Group("", func() {
 		r.Post("/objects/batch", authorize(db.AccessModeRead), verifyAccept, verifyContentTypeJSON, serveBatch)
 		r.Group("/objects/basic", func() {
+
+			basic := &basicHandler{
+				defaultStorage: lfsutil.Storage(conf.LFS.Storage),
+				storagers: map[lfsutil.Storage]lfsutil.Storager{
+					lfsutil.StorageLocal: &lfsutil.LocalStorage{Root: conf.LFS.ObjectsPath},
+				},
+			}
 			r.Combo("/:oid", verifyOID()).
-				Get(authorize(db.AccessModeRead), serveBasicDownload).
-				Put(authorize(db.AccessModeWrite), verifyContentTypeStream, serveBasicUpload)
-			r.Post("/verify", authorize(db.AccessModeWrite), verifyAccept, verifyContentTypeJSON, serveBasicVerify)
+				Get(authorize(db.AccessModeRead), basic.serveDownload).
+				Put(authorize(db.AccessModeWrite), verifyContentTypeStream, basic.serveUpload)
+			r.Post("/verify", authorize(db.AccessModeWrite), verifyAccept, verifyContentTypeJSON, basic.serveVerify)
 		})
 	}, authenticate())
 }

+ 31 - 0
internal/testutil/noop_logger.go

@@ -0,0 +1,31 @@
+// Copyright 2020 The Gogs 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 testutil
+
+import (
+	log "unknwon.dev/clog/v2"
+)
+
+var _ log.Logger = (*noopLogger)(nil)
+
+// noopLogger is a placeholder logger that logs nothing.
+type noopLogger struct{}
+
+func (l *noopLogger) Name() string {
+	return "noop"
+}
+
+func (l *noopLogger) Level() log.Level {
+	return log.LevelTrace
+}
+
+func (l *noopLogger) Write(log.Messager) error {
+	return nil
+}
+
+// InitNoopLogger is a init function to initialize a noop logger.
+var InitNoopLogger = func(name string, vs ...interface{}) (log.Logger, error) {
+	return &noopLogger{}, nil
+}

Some files were not shown because too many files changed in this diff