diff --git a/common/common.go b/common/common.go index 29427a00..e65a907a 100644 --- a/common/common.go +++ b/common/common.go @@ -87,8 +87,8 @@ var ( ErrGenericFailure = errors.New("failure") ErrQuotaExceeded = errors.New("denying write due to space limit") ErrSkipPermissionsCheck = errors.New("permission check skipped") - ErrConnectionDenied = errors.New("You are not allowed to connect") - ErrNoBinding = errors.New("No binding configured") + ErrConnectionDenied = errors.New("you are not allowed to connect") + ErrNoBinding = errors.New("no binding configured") errNoTransfer = errors.New("requested transfer not found") errTransferMismatch = errors.New("transfer mismatch") ) diff --git a/ftpd/ftpd_test.go b/ftpd/ftpd_test.go index e8c64c26..af8b7ef8 100644 --- a/ftpd/ftpd_test.go +++ b/ftpd/ftpd_test.go @@ -1395,7 +1395,7 @@ func TestUploadOverwriteVfolder(t *testing.T) { assert.NoError(t, err) } -func TestAllocate(t *testing.T) { +func TestAllocateAvailable(t *testing.T) { u := getTestUser() mappedPath := filepath.Join(os.TempDir(), "vdir") u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ @@ -1415,6 +1415,16 @@ func TestAllocate(t *testing.T) { assert.NoError(t, err) assert.Equal(t, ftp.StatusCommandOK, code) assert.Equal(t, "Done !", response) + + code, response, err = client.SendCustomCommand("AVBL /vdir") + assert.NoError(t, err) + assert.Equal(t, ftp.StatusFile, code) + assert.Equal(t, "110", response) + + code, _, err = client.SendCustomCommand("AVBL") + assert.NoError(t, err) + assert.Equal(t, ftp.StatusFile, code) + err = client.Quit() assert.NoError(t, err) } @@ -1442,6 +1452,12 @@ func TestAllocate(t *testing.T) { err = ftpUploadFile(testFilePath, testFileName, testFileSize, client, 0) assert.NoError(t, err) + + code, response, err = client.SendCustomCommand("AVBL") + assert.NoError(t, err) + assert.Equal(t, ftp.StatusFile, code) + assert.Equal(t, "1", response) + // we still have space in vdir code, response, err = client.SendCustomCommand("allo 50") assert.NoError(t, err) @@ -1475,10 +1491,38 @@ func TestAllocate(t *testing.T) { assert.Equal(t, ftp.StatusFileUnavailable, code) assert.Contains(t, response, common.ErrQuotaExceeded.Error()) + code, response, err = client.SendCustomCommand("AVBL") + assert.NoError(t, err) + assert.Equal(t, ftp.StatusFile, code) + assert.Equal(t, "100", response) + err = client.Quit() assert.NoError(t, err) } + user.QuotaSize = 50 + user, _, err = httpd.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + client, err = getFTPClient(user, false) + if assert.NoError(t, err) { + code, response, err := client.SendCustomCommand("AVBL") + assert.NoError(t, err) + assert.Equal(t, ftp.StatusFile, code) + assert.Equal(t, "0", response) + } + + user.QuotaSize = 1000 + user.Filters.MaxUploadFileSize = 1 + user, _, err = httpd.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + client, err = getFTPClient(user, false) + if assert.NoError(t, err) { + code, response, err := client.SendCustomCommand("AVBL") + assert.NoError(t, err) + assert.Equal(t, ftp.StatusFile, code) + assert.Equal(t, "1", response) + } + _, err = httpd.RemoveUser(user, http.StatusOK) assert.NoError(t, err) _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath}, http.StatusOK) @@ -1489,6 +1533,30 @@ func TestAllocate(t *testing.T) { assert.NoError(t, err) } +func TestAvailableUnsupportedFs(t *testing.T) { + u := getTestUser() + localUser, _, err := httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + sftpUser, _, err := httpd.AddUser(getTestSFTPUser(), http.StatusOK) + assert.NoError(t, err) + client, err := getFTPClient(sftpUser, false) + if assert.NoError(t, err) { + code, response, err := client.SendCustomCommand("AVBL") + assert.NoError(t, err) + assert.Equal(t, ftp.StatusFileUnavailable, code) + assert.Contains(t, response, "unable to get available size for this storage backend") + + err = client.Quit() + assert.NoError(t, err) + } + _, err = httpd.RemoveUser(sftpUser, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveUser(localUser, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(localUser.GetHomeDir()) + assert.NoError(t, err) +} + func TestChtimes(t *testing.T) { u := getTestUser() localUser, _, err := httpd.AddUser(u, http.StatusOK) diff --git a/ftpd/handler.go b/ftpd/handler.go index f47ac2a6..b2674346 100644 --- a/ftpd/handler.go +++ b/ftpd/handler.go @@ -49,7 +49,7 @@ func (c *Connection) Disconnect() error { return c.clientContext.Close() } -// GetCommand returns an empty string +// GetCommand returns the last received FTP command func (c *Connection) GetCommand() string { return c.clientContext.GetLastCommand() } @@ -209,7 +209,42 @@ func (c *Connection) Chtimes(name string, atime time.Time, mtime time.Time) erro return c.SetStat(p, name, &attrs) } -// AllocateSpace implements ClientDriverExtensionAllocate +// GetAvailableSpace implements ClientDriverExtensionAvailableSpace interface +func (c *Connection) GetAvailableSpace(dirName string) (int64, error) { + c.UpdateLastActivity() + + quotaResult := c.HasSpace(false, path.Join(dirName, "fakefile.txt")) + + if !quotaResult.HasSpace { + return 0, nil + } + + if quotaResult.AllowedSize == 0 { + // no quota restrictions + if c.User.Filters.MaxUploadFileSize > 0 { + return c.User.Filters.MaxUploadFileSize, nil + } + + p, err := c.Fs.ResolvePath(dirName) + if err != nil { + return 0, c.GetFsError(err) + } + + return c.Fs.GetAvailableDiskSize(p) + } + + // the available space is the minimum between MaxUploadFileSize, if setted, + // and quota allowed size + if c.User.Filters.MaxUploadFileSize > 0 { + if c.User.Filters.MaxUploadFileSize < quotaResult.AllowedSize { + return c.User.Filters.MaxUploadFileSize, nil + } + } + + return quotaResult.AllowedSize, nil +} + +// AllocateSpace implements ClientDriverExtensionAllocate interface func (c *Connection) AllocateSpace(size int) error { c.UpdateLastActivity() // check the max allowed file size first diff --git a/ftpd/internal_test.go b/ftpd/internal_test.go index c50155e6..81e6d8ad 100644 --- a/ftpd/internal_test.go +++ b/ftpd/internal_test.go @@ -342,6 +342,10 @@ func TestResolvePathErrors(t *testing.T) { if assert.Error(t, err) { assert.EqualError(t, err, common.ErrGenericFailure.Error()) } + _, err = connection.GetAvailableSpace("") + if assert.Error(t, err) { + assert.EqualError(t, err, common.ErrGenericFailure.Error()) + } } func TestUploadFileStatError(t *testing.T) { diff --git a/go.mod b/go.mod index c0ee67cc..df62fc74 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/rs/xid v1.2.1 github.com/rs/zerolog v1.20.0 github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/shirou/gopsutil/v3 v3.20.11 github.com/spf13/afero v1.5.1 github.com/spf13/cast v1.3.1 // indirect github.com/spf13/cobra v1.1.1 diff --git a/go.sum b/go.sum index 29a84f0a..aa913b6b 100644 --- a/go.sum +++ b/go.sum @@ -85,6 +85,7 @@ github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= +github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -208,6 +209,7 @@ github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= @@ -559,6 +561,8 @@ github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIH github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY= +github.com/shirou/gopsutil/v3 v3.20.11 h1:NeVf1K0cgxsWz+N3671ojRptdgzvp7BXL3KV21R0JnA= +github.com/shirou/gopsutil/v3 v3.20.11/go.mod h1:igHnfak0qnw1biGeI2qKQvu0ZkwvEkUcCLlYhZzdr/4= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= @@ -742,6 +746,7 @@ golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200828194041-157a740278f4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201024232916-9f70ab9862d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201202213521-69691e467435/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/vfs/azblobfs.go b/vfs/azblobfs.go index 49a89da8..9cdb3cce 100644 --- a/vfs/azblobfs.go +++ b/vfs/azblobfs.go @@ -701,6 +701,11 @@ func (*AzureBlobFs) Close() error { return nil } +// GetAvailableDiskSize return the available size for the specified path +func (*AzureBlobFs) GetAvailableDiskSize(dirName string) (int64, error) { + return 0, errStorageSizeUnavailable +} + func (fs *AzureBlobFs) isEqual(key string, virtualName string) bool { if key == virtualName { return true diff --git a/vfs/gcsfs.go b/vfs/gcsfs.go index 04681a23..fb4941a4 100644 --- a/vfs/gcsfs.go +++ b/vfs/gcsfs.go @@ -754,3 +754,8 @@ func (fs *GCSFs) GetMimeType(name string) (string, error) { func (fs *GCSFs) Close() error { return nil } + +// GetAvailableDiskSize return the available size for the specified path +func (*GCSFs) GetAvailableDiskSize(dirName string) (int64, error) { + return 0, errStorageSizeUnavailable +} diff --git a/vfs/osfs.go b/vfs/osfs.go index d277dcbd..fd217e80 100644 --- a/vfs/osfs.go +++ b/vfs/osfs.go @@ -12,6 +12,7 @@ import ( "github.com/eikenb/pipeat" "github.com/rs/xid" + "github.com/shirou/gopsutil/v3/disk" "github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/utils" @@ -477,3 +478,12 @@ func (fs *OsFs) GetMimeType(name string) (string, error) { func (*OsFs) Close() error { return nil } + +// GetAvailableDiskSize return the available size for the specified path +func (*OsFs) GetAvailableDiskSize(dirName string) (int64, error) { + usage, err := disk.Usage(dirName) + if err != nil { + return 0, err + } + return int64(usage.Free), nil +} diff --git a/vfs/s3fs.go b/vfs/s3fs.go index 3e46e130..25ac3b0d 100644 --- a/vfs/s3fs.go +++ b/vfs/s3fs.go @@ -703,3 +703,8 @@ func (fs *S3Fs) GetMimeType(name string) (string, error) { func (*S3Fs) Close() error { return nil } + +// GetAvailableDiskSize return the available size for the specified path +func (*S3Fs) GetAvailableDiskSize(dirName string) (int64, error) { + return 0, errStorageSizeUnavailable +} diff --git a/vfs/sftpfs.go b/vfs/sftpfs.go index 343a56a4..6698cec7 100644 --- a/vfs/sftpfs.go +++ b/vfs/sftpfs.go @@ -494,6 +494,11 @@ func (fs *SFTPFs) Close() error { return sshErr } +// GetAvailableDiskSize return the available size for the specified path +func (*SFTPFs) GetAvailableDiskSize(dirName string) (int64, error) { + return 0, errStorageSizeUnavailable +} + func (fs *SFTPFs) checkConnection() error { err := fs.closed() if err == nil { diff --git a/vfs/vfs.go b/vfs/vfs.go index 442e2bde..6b2d3ffd 100644 --- a/vfs/vfs.go +++ b/vfs/vfs.go @@ -22,7 +22,10 @@ import ( const dirMimeType = "inode/directory" -var validAzAccessTier = []string{"", "Archive", "Hot", "Cool"} +var ( + validAzAccessTier = []string{"", "Archive", "Hot", "Cool"} + errStorageSizeUnavailable = errors.New("unable to get available size for this storage backend") +) // Fs defines the interface for filesystem backends type Fs interface { @@ -57,6 +60,7 @@ type Fs interface { Join(elem ...string) string HasVirtualFolders() bool GetMimeType(name string) (string, error) + GetAvailableDiskSize(dirName string) (int64, error) Close() error }