Parcourir la source

sftpd: add support for excluding virtual folders from user quota limit

Fixes #110
Nicola Murino il y a 5 ans
Parent
commit
3f75d46a16

+ 3 - 2
dataprovider/dataprovider.go

@@ -652,8 +652,9 @@ func validateVirtualFolders(user *User) error {
 				v.MappedPath, user.GetHomeDir())}
 		}
 		virtualFolders = append(virtualFolders, vfs.VirtualFolder{
-			VirtualPath: cleanedVPath,
-			MappedPath:  cleanedMPath,
+			VirtualPath:      cleanedVPath,
+			MappedPath:       cleanedMPath,
+			ExcludeFromQuota: v.ExcludeFromQuota,
 		})
 		for k, virtual := range mappedPaths {
 			if isMappedDirOverlapped(k, cleanedMPath) {

+ 16 - 0
dataprovider/user.go

@@ -191,6 +191,22 @@ func (u *User) GetPermissionsForPath(p string) []string {
 	return permissions
 }
 
+// IsFileExcludedFromQuota returns true if the file must be excluded from quota usage
+func (u *User) IsFileExcludedFromQuota(sftpPath string) bool {
+	if len(u.VirtualFolders) == 0 || u.FsConfig.Provider != 0 {
+		return false
+	}
+	dirsForPath := utils.GetDirsForSFTPPath(path.Dir(sftpPath))
+	for _, val := range dirsForPath {
+		for _, v := range u.VirtualFolders {
+			if v.VirtualPath == val {
+				return v.ExcludeFromQuota
+			}
+		}
+	}
+	return false
+}
+
 // AddVirtualDirs adds virtual folders, if defined, to the given files list
 func (u *User) AddVirtualDirs(list []os.FileInfo, sftpPath string) []os.FileInfo {
 	if len(u.VirtualFolders) == 0 {

+ 1 - 1
docs/account.md

@@ -8,7 +8,7 @@ For each account, the following properties can be configured:
 - `status` 1 means "active", 0 "inactive". An inactive account cannot login.
 - `expiration_date` expiration date as unix timestamp in milliseconds. An expired account cannot login. 0 means no expiration.
 - `home_dir` the user cannot upload or download files outside this directory. Must be an absolute path. A local home directory is required for Cloud Storage Backends too: in this case it will store temporary files.
-- `virtual_folders` list of mappings between virtual SFTP/SCP paths and local filesystem paths outside the user home directory. The specified paths must be absolute and the virtual path cannot be "/", it must be a sub directory. The parent directory for the specified virtual path must exist. SFTPGo will try to automatically create any missing parent directory for the configured virtual folders at user login
+- `virtual_folders` list of mappings between virtual SFTP/SCP paths and local filesystem paths outside the user home directory. The specified paths must be absolute and the virtual path cannot be "/", it must be a sub directory. The parent directory for the specified virtual path must exist. SFTPGo will try to automatically create any missing parent directory for the configured virtual folders at user login. For each mapping you can configure if the folder will be included or not in user quota limit.
 - `uid`, `gid`. If SFTPGo runs as root system user then the created files and directories will be assigned to this system uid/gid. Ignored on windows or if SFTPGo runs as non root user: in this case files and directories for all SFTP users will be owned by the system user that runs SFTPGo.
 - `max_sessions` maximum concurrent sessions. 0 means unlimited.
 - `quota_size` maximum size allowed as bytes. 0 means unlimited.

+ 7 - 9
httpd/httpd_test.go

@@ -183,7 +183,7 @@ func TestInitialization(t *testing.T) {
 	}
 	err = httpd.ReloadTLSCertificate()
 	if err != nil {
-		t.Error("realoding TLS Certificate must return nil error if no certificate is configured")
+		t.Error("reloading TLS Certificate must return nil error if no certificate is configured")
 	}
 }
 
@@ -628,8 +628,9 @@ func TestUpdateUser(t *testing.T) {
 		MappedPath:  filepath.Join(os.TempDir(), "mapped_dir1"),
 	})
 	user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{
-		VirtualPath: "/vdir12/subdir",
-		MappedPath:  filepath.Join(os.TempDir(), "mapped_dir2"),
+		VirtualPath:      "/vdir12/subdir",
+		MappedPath:       filepath.Join(os.TempDir(), "mapped_dir2"),
+		ExcludeFromQuota: true,
 	})
 	user, _, err = httpd.UpdateUser(user, http.StatusOK)
 	if err != nil {
@@ -1793,7 +1794,7 @@ func TestWebUserAddMock(t *testing.T) {
 	form.Set("expiration_date", "")
 	form.Set("permissions", "*")
 	form.Set("sub_dirs_permissions", " /subdir::list ,download ")
-	form.Set("virtual_folders", fmt.Sprintf(" /vdir:: %v ", mappedDir))
+	form.Set("virtual_folders", fmt.Sprintf(" /vdir:: %v ::1", mappedDir))
 	form.Set("allowed_extensions", "/dir1::.jpg,.png")
 	form.Set("denied_extensions", "/dir1::.zip")
 	b, contentType, _ := getMultipartFormData(form, "", "")
@@ -1899,10 +1900,7 @@ func TestWebUserAddMock(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr.Code)
 	var users []dataprovider.User
-	err := render.DecodeJSON(rr.Body, &users)
-	if err != nil {
-		t.Errorf("Error decoding users: %v", err)
-	}
+	render.DecodeJSON(rr.Body, &users)
 	if len(users) != 1 {
 		t.Errorf("1 user is expected, actual: %v", len(users))
 	}
@@ -1928,7 +1926,7 @@ func TestWebUserAddMock(t *testing.T) {
 	}
 	vfolderFoumd := false
 	for _, v := range newUser.VirtualFolders {
-		if v.VirtualPath == "/vdir" && v.MappedPath == mappedDir {
+		if v.VirtualPath == "/vdir" && v.MappedPath == mappedDir && v.ExcludeFromQuota == true {
 			vfolderFoumd = true
 		}
 	}

+ 5 - 1
httpd/schema/openapi.yaml

@@ -2,7 +2,7 @@ openapi: 3.0.1
 info:
   title: SFTPGo
   description: 'SFTPGo REST API'
-  version: 1.8.4
+  version: 1.8.5
 
 servers:
 - url: /api/v1
@@ -1077,6 +1077,10 @@ components:
           type: string
         mapped_path:
           type: string
+        exclude_from_quota:
+          type: boolean
+          nullable: true
+          description: This folder will be excluded from user quota
       required:
         - virtual_path
         - mapped_path

+ 9 - 2
httpd/web.go

@@ -196,10 +196,17 @@ func getVirtualFoldersFromPostFields(r *http.Request) []vfs.VirtualFolder {
 		if strings.Contains(cleaned, "::") {
 			mapping := strings.Split(cleaned, "::")
 			if len(mapping) > 1 {
-				virtualFolders = append(virtualFolders, vfs.VirtualFolder{
+				vfolder := vfs.VirtualFolder{
 					VirtualPath: strings.TrimSpace(mapping[0]),
 					MappedPath:  strings.TrimSpace(mapping[1]),
-				})
+				}
+				if len(mapping) > 2 {
+					excludeFromQuota, err := strconv.Atoi(strings.TrimSpace(mapping[2]))
+					if err == nil {
+						vfolder.ExcludeFromQuota = (excludeFromQuota > 0)
+					}
+				}
+				virtualFolders = append(virtualFolders, vfolder)
 			}
 		}
 	}

+ 5 - 1
scripts/README.md

@@ -140,7 +140,7 @@ Output:
 Command:
 
 ```
-python sftpgo_api_cli.py update-user 9576 test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 0 --gid 33 --max-sessions 3 --quota-size 0 --quota-files 4 --permissions "*" --subdirs-permissions "/dir1::list,download,create_symlinks" --upload-bandwidth 90 --download-bandwidth 80 --status 1 --expiration-date "" --allowed-ip "" --denied-ip "192.168.1.0/24" --denied-login-methods "" --fs local --virtual-folders "/vdir1::/tmp/mapped1" "/vdir2::/tmp/mapped2" --allowed-extensions "" --denied-extensions ""
+python sftpgo_api_cli.py update-user 9576 test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 0 --gid 33 --max-sessions 3 --quota-size 0 --quota-files 4 --permissions "*" --subdirs-permissions "/dir1::list,download,create_symlinks" --upload-bandwidth 90 --download-bandwidth 80 --status 1 --expiration-date "" --allowed-ip "" --denied-ip "192.168.1.0/24" --denied-login-methods "" --fs local --virtual-folders "/vdir1::/tmp/mapped1" "/vdir2::/tmp/mapped2::1" --allowed-extensions "" --denied-extensions ""
 ```
 
 Output:
@@ -203,10 +203,12 @@ Output:
   "username": "test_username",
   "virtual_folders": [
     {
+      "exclude_from_quota": false,
       "mapped_path": "/tmp/mapped1",
       "virtual_path": "/vdir1"
     },
     {
+      "exclude_from_quota": true,
       "mapped_path": "/tmp/mapped2",
       "virtual_path": "/vdir2"
     }
@@ -265,10 +267,12 @@ Output:
     "username": "test_username",
     "virtual_folders": [
       {
+        "exclude_from_quota": false,
         "mapped_path": "/tmp/mapped1",
         "virtual_path": "/vdir1"
       },
       {
+        "exclude_from_quota": true,
         "mapped_path": "/tmp/mapped2",
         "virtual_path": "/vdir2"
       }

+ 10 - 2
scripts/sftpgo_api_cli.py

@@ -110,12 +110,19 @@ class SFTPGoApiRequests:
 			if '::' in f:
 				vpath = ''
 				mapped_path = ''
+				exclude_from_quota = False
 				values = f.split('::')
 				if len(values) > 1:
 					vpath = values[0]
 					mapped_path = values[1]
+				if len(values) > 2:
+					try:
+						exclude_from_quota = int(values[2]) > 0
+					except:
+						pass
 				if vpath and mapped_path:
-					result.append({"virtual_path":vpath, "mapped_path":mapped_path})
+					result.append({"virtual_path":vpath, "mapped_path":mapped_path,
+								"exclude_from_quota":exclude_from_quota})
 		return result
 
 	def buildPermissions(self, root_perms, subdirs_perms):
@@ -508,7 +515,8 @@ def addCommonUserArguments(parser):
 	parser.add_argument('--subdirs-permissions', type=str, nargs='*', default=[], help='Permissions for subdirs. '
 					+'For example: "/somedir::list,download" "/otherdir/subdir::*" Default: %(default)s')
 	parser.add_argument('--virtual-folders', type=str, nargs='*', default=[], help='Virtual folder mapping. For example: '
-					+'"/vpath::/home/adir" "/vpath::C:\adir", ignored for non local filesystems. Default: %(default)s')
+					+'"/vpath::/home/adir" "/vpath::C:\adir::1". If the optional third argument is > 0 the virtual '
+					+'folder will be excluded from user quota. Ignored for non local filesystems. Default: %(default)s')
 	parser.add_argument('-U', '--upload-bandwidth', type=int, default=0,
 					help='Maximum upload bandwidth as KB/s, 0 means unlimited. Default: %(default)s')
 	parser.add_argument('-D', '--download-bandwidth', type=int, default=0,

+ 70 - 62
sftpd/handler.go

@@ -75,25 +75,26 @@ func (c Connection) Fileread(request *sftp.Request) (io.ReaderAt, error) {
 	c.Log(logger.LevelDebug, logSender, "fileread requested for path: %#v", p)
 
 	transfer := Transfer{
-		file:           file,
-		readerAt:       r,
-		writerAt:       nil,
-		cancelFn:       cancelFn,
-		path:           p,
-		start:          time.Now(),
-		bytesSent:      0,
-		bytesReceived:  0,
-		user:           c.User,
-		connectionID:   c.ID,
-		transferType:   transferDownload,
-		lastActivity:   time.Now(),
-		isNewFile:      false,
-		protocol:       c.protocol,
-		transferError:  nil,
-		isFinished:     false,
-		minWriteOffset: 0,
-		expectedSize:   fi.Size(),
-		lock:           new(sync.Mutex),
+		file:                file,
+		readerAt:            r,
+		writerAt:            nil,
+		cancelFn:            cancelFn,
+		path:                p,
+		start:               time.Now(),
+		bytesSent:           0,
+		bytesReceived:       0,
+		user:                c.User,
+		connectionID:        c.ID,
+		transferType:        transferDownload,
+		lastActivity:        time.Now(),
+		isNewFile:           false,
+		protocol:            c.protocol,
+		transferError:       nil,
+		isFinished:          false,
+		minWriteOffset:      0,
+		expectedSize:        fi.Size(),
+		isExcludedFromQuota: c.User.IsFileExcludedFromQuota(request.Filepath),
+		lock:                new(sync.Mutex),
 	}
 	addTransfer(&transfer)
 	return &transfer, nil
@@ -123,7 +124,7 @@ func (c Connection) Filewrite(request *sftp.Request) (io.WriterAt, error) {
 		if !c.User.HasPerm(dataprovider.PermUpload, path.Dir(request.Filepath)) {
 			return nil, sftp.ErrSSHFxPermissionDenied
 		}
-		return c.handleSFTPUploadToNewFile(p, filePath)
+		return c.handleSFTPUploadToNewFile(p, filePath, c.User.IsFileExcludedFromQuota(request.Filepath))
 	}
 
 	if statErr != nil {
@@ -141,7 +142,8 @@ func (c Connection) Filewrite(request *sftp.Request) (io.WriterAt, error) {
 		return nil, sftp.ErrSSHFxPermissionDenied
 	}
 
-	return c.handleSFTPUploadToExistingFile(request.Pflags(), p, filePath, stat.Size())
+	return c.handleSFTPUploadToExistingFile(request.Pflags(), p, filePath, stat.Size(),
+		c.User.IsFileExcludedFromQuota(request.Filepath))
 }
 
 // Filecmd hander for basic SFTP system calls related to files, but not anything to do with reading
@@ -437,14 +439,16 @@ func (c Connection) handleSFTPRemove(filePath string, request *sftp.Request) err
 
 	logger.CommandLog(removeLogSender, filePath, "", c.User.Username, "", c.ID, c.protocol, -1, -1, "", "", "")
 	if fi.Mode()&os.ModeSymlink != os.ModeSymlink {
-		dataprovider.UpdateUserQuota(dataProvider, c.User, -1, -size, false) //nolint:errcheck
+		if !c.User.IsFileExcludedFromQuota(request.Filepath) {
+			dataprovider.UpdateUserQuota(dataProvider, c.User, -1, -size, false) //nolint:errcheck
+		}
 	}
 	go executeAction(newActionNotification(c.User, operationDelete, filePath, "", "", fi.Size(), nil)) //nolint:errcheck
 
 	return sftp.ErrSSHFxOk
 }
 
-func (c Connection) handleSFTPUploadToNewFile(requestPath, filePath string) (io.WriterAt, error) {
+func (c Connection) handleSFTPUploadToNewFile(requestPath, filePath string, isExcludedFromQuota bool) (io.WriterAt, error) {
 	if !c.hasSpace(true) {
 		c.Log(logger.LevelInfo, logSender, "denying file write due to space limit")
 		return nil, sftp.ErrSSHFxFailure
@@ -459,31 +463,32 @@ func (c Connection) handleSFTPUploadToNewFile(requestPath, filePath string) (io.
 	vfs.SetPathPermissions(c.fs, filePath, c.User.GetUID(), c.User.GetGID())
 
 	transfer := Transfer{
-		file:           file,
-		writerAt:       w,
-		readerAt:       nil,
-		cancelFn:       cancelFn,
-		path:           requestPath,
-		start:          time.Now(),
-		bytesSent:      0,
-		bytesReceived:  0,
-		user:           c.User,
-		connectionID:   c.ID,
-		transferType:   transferUpload,
-		lastActivity:   time.Now(),
-		isNewFile:      true,
-		protocol:       c.protocol,
-		transferError:  nil,
-		isFinished:     false,
-		minWriteOffset: 0,
-		lock:           new(sync.Mutex),
+		file:                file,
+		writerAt:            w,
+		readerAt:            nil,
+		cancelFn:            cancelFn,
+		path:                requestPath,
+		start:               time.Now(),
+		bytesSent:           0,
+		bytesReceived:       0,
+		user:                c.User,
+		connectionID:        c.ID,
+		transferType:        transferUpload,
+		lastActivity:        time.Now(),
+		isNewFile:           true,
+		protocol:            c.protocol,
+		transferError:       nil,
+		isFinished:          false,
+		minWriteOffset:      0,
+		isExcludedFromQuota: isExcludedFromQuota,
+		lock:                new(sync.Mutex),
 	}
 	addTransfer(&transfer)
 	return &transfer, nil
 }
 
 func (c Connection) handleSFTPUploadToExistingFile(pflags sftp.FileOpenFlags, requestPath, filePath string,
-	fileSize int64) (io.WriterAt, error) {
+	fileSize int64, isExcludedFromQuota bool) (io.WriterAt, error) {
 	var err error
 	if !c.hasSpace(false) {
 		c.Log(logger.LevelInfo, logSender, "denying file write due to space limit")
@@ -520,7 +525,9 @@ func (c Connection) handleSFTPUploadToExistingFile(pflags sftp.FileOpenFlags, re
 		minWriteOffset = fileSize
 	} else {
 		if vfs.IsLocalOsFs(c.fs) {
-			dataprovider.UpdateUserQuota(dataProvider, c.User, 0, -fileSize, false) //nolint:errcheck
+			if !isExcludedFromQuota {
+				dataprovider.UpdateUserQuota(dataProvider, c.User, 0, -fileSize, false) //nolint:errcheck
+			}
 		} else {
 			initialSize = fileSize
 		}
@@ -529,25 +536,26 @@ func (c Connection) handleSFTPUploadToExistingFile(pflags sftp.FileOpenFlags, re
 	vfs.SetPathPermissions(c.fs, filePath, c.User.GetUID(), c.User.GetGID())
 
 	transfer := Transfer{
-		file:           file,
-		writerAt:       w,
-		readerAt:       nil,
-		cancelFn:       cancelFn,
-		path:           requestPath,
-		start:          time.Now(),
-		bytesSent:      0,
-		bytesReceived:  0,
-		user:           c.User,
-		connectionID:   c.ID,
-		transferType:   transferUpload,
-		lastActivity:   time.Now(),
-		isNewFile:      false,
-		protocol:       c.protocol,
-		transferError:  nil,
-		isFinished:     false,
-		minWriteOffset: minWriteOffset,
-		initialSize:    initialSize,
-		lock:           new(sync.Mutex),
+		file:                file,
+		writerAt:            w,
+		readerAt:            nil,
+		cancelFn:            cancelFn,
+		path:                requestPath,
+		start:               time.Now(),
+		bytesSent:           0,
+		bytesReceived:       0,
+		user:                c.User,
+		connectionID:        c.ID,
+		transferType:        transferUpload,
+		lastActivity:        time.Now(),
+		isNewFile:           false,
+		protocol:            c.protocol,
+		transferError:       nil,
+		isFinished:          false,
+		minWriteOffset:      minWriteOffset,
+		initialSize:         initialSize,
+		isExcludedFromQuota: isExcludedFromQuota,
+		lock:                new(sync.Mutex),
 	}
 	addTransfer(&transfer)
 	return &transfer, nil

+ 6 - 6
sftpd/internal_test.go

@@ -423,7 +423,7 @@ func TestMockFsErrors(t *testing.T) {
 	flags.Write = true
 	flags.Trunc = false
 	flags.Append = true
-	_, err = c.handleSFTPUploadToExistingFile(flags, testfile, testfile, 0)
+	_, err = c.handleSFTPUploadToExistingFile(flags, testfile, testfile, 0, false)
 	if err != sftp.ErrSSHFxOpUnsupported {
 		t.Errorf("unexpected error: %v", err)
 	}
@@ -439,12 +439,12 @@ func TestUploadFiles(t *testing.T) {
 	var flags sftp.FileOpenFlags
 	flags.Write = true
 	flags.Trunc = true
-	_, err := c.handleSFTPUploadToExistingFile(flags, "missing_path", "other_missing_path", 0)
+	_, err := c.handleSFTPUploadToExistingFile(flags, "missing_path", "other_missing_path", 0, false)
 	if err == nil {
 		t.Errorf("upload to existing file must fail if one or both paths are invalid")
 	}
 	uploadMode = uploadModeStandard
-	_, err = c.handleSFTPUploadToExistingFile(flags, "missing_path", "other_missing_path", 0)
+	_, err = c.handleSFTPUploadToExistingFile(flags, "missing_path", "other_missing_path", 0, false)
 	if err == nil {
 		t.Errorf("upload to existing file must fail if one or both paths are invalid")
 	}
@@ -452,14 +452,14 @@ func TestUploadFiles(t *testing.T) {
 	if runtime.GOOS == "windows" {
 		missingFile = "missing\\relative\\file.txt"
 	}
-	_, err = c.handleSFTPUploadToNewFile(".", missingFile)
+	_, err = c.handleSFTPUploadToNewFile(".", missingFile, false)
 	if err == nil {
 		t.Errorf("upload new file in missing path must fail")
 	}
 	c.fs = newMockOsFs(nil, nil, false, "123", os.TempDir())
 	f, _ := ioutil.TempFile("", "temp")
 	f.Close()
-	_, err = c.handleSFTPUploadToExistingFile(flags, f.Name(), f.Name(), 123)
+	_, err = c.handleSFTPUploadToExistingFile(flags, f.Name(), f.Name(), 123, false)
 	if err != nil {
 		t.Errorf("unexpected error: %v", err)
 	}
@@ -1437,7 +1437,7 @@ func TestSCPErrorsMockFs(t *testing.T) {
 	if err != errFake {
 		t.Errorf("unexpected error: %v", err)
 	}
-	err = scpCommand.handleUploadFile(testfile, testfile, 0, false, 4)
+	err = scpCommand.handleUploadFile(testfile, testfile, 0, false, 4, false)
 	if err != nil {
 		t.Errorf("unexpected error: %v", err)
 	}

+ 27 - 23
sftpd/scp.go

@@ -187,7 +187,8 @@ func (c *scpCommand) getUploadFileData(sizeToRead int64, transfer *Transfer) err
 	return c.sendConfirmationMessage()
 }
 
-func (c *scpCommand) handleUploadFile(requestPath, filePath string, sizeToRead int64, isNewFile bool, fileSize int64) error {
+func (c *scpCommand) handleUploadFile(requestPath, filePath string, sizeToRead int64, isNewFile bool, fileSize int64,
+	isExcludedFromQuota bool) error {
 	if !c.connection.hasSpace(true) {
 		err := fmt.Errorf("denying file write due to space limit")
 		c.connection.Log(logger.LevelWarn, logSenderSCP, "error uploading file: %#v, err: %v", filePath, err)
@@ -198,7 +199,9 @@ func (c *scpCommand) handleUploadFile(requestPath, filePath string, sizeToRead i
 	initialSize := int64(0)
 	if !isNewFile {
 		if vfs.IsLocalOsFs(c.connection.fs) {
-			dataprovider.UpdateUserQuota(dataProvider, c.connection.User, 0, -fileSize, false) //nolint:errcheck
+			if !isExcludedFromQuota {
+				dataprovider.UpdateUserQuota(dataProvider, c.connection.User, 0, -fileSize, false) //nolint:errcheck
+			}
 		} else {
 			initialSize = fileSize
 		}
@@ -213,25 +216,26 @@ func (c *scpCommand) handleUploadFile(requestPath, filePath string, sizeToRead i
 	vfs.SetPathPermissions(c.connection.fs, filePath, c.connection.User.GetUID(), c.connection.User.GetGID())
 
 	transfer := Transfer{
-		file:           file,
-		readerAt:       nil,
-		writerAt:       w,
-		cancelFn:       cancelFn,
-		path:           requestPath,
-		start:          time.Now(),
-		bytesSent:      0,
-		bytesReceived:  0,
-		user:           c.connection.User,
-		connectionID:   c.connection.ID,
-		transferType:   transferUpload,
-		lastActivity:   time.Now(),
-		isNewFile:      isNewFile,
-		protocol:       c.connection.protocol,
-		transferError:  nil,
-		isFinished:     false,
-		minWriteOffset: 0,
-		initialSize:    initialSize,
-		lock:           new(sync.Mutex),
+		file:                file,
+		readerAt:            nil,
+		writerAt:            w,
+		cancelFn:            cancelFn,
+		path:                requestPath,
+		start:               time.Now(),
+		bytesSent:           0,
+		bytesReceived:       0,
+		user:                c.connection.User,
+		connectionID:        c.connection.ID,
+		transferType:        transferUpload,
+		lastActivity:        time.Now(),
+		isNewFile:           isNewFile,
+		protocol:            c.connection.protocol,
+		transferError:       nil,
+		isFinished:          false,
+		minWriteOffset:      0,
+		initialSize:         initialSize,
+		isExcludedFromQuota: isExcludedFromQuota,
+		lock:                new(sync.Mutex),
 	}
 	addTransfer(&transfer)
 
@@ -265,7 +269,7 @@ func (c *scpCommand) handleUpload(uploadFilePath string, sizeToRead int64) error
 			c.sendErrorMessage(errPermission)
 			return errPermission
 		}
-		return c.handleUploadFile(p, filePath, sizeToRead, true, 0)
+		return c.handleUploadFile(p, filePath, sizeToRead, true, 0, c.connection.User.IsFileExcludedFromQuota(uploadFilePath))
 	}
 
 	if statErr != nil {
@@ -297,7 +301,7 @@ func (c *scpCommand) handleUpload(uploadFilePath string, sizeToRead int64) error
 		}
 	}
 
-	return c.handleUploadFile(p, filePath, sizeToRead, false, stat.Size())
+	return c.handleUploadFile(p, filePath, sizeToRead, false, stat.Size(), c.connection.User.IsFileExcludedFromQuota(uploadFilePath))
 }
 
 func (c *scpCommand) sendDownloadProtocolMessages(dirPath string, stat os.FileInfo) error {

+ 150 - 8
sftpd/sftpd_test.go

@@ -2449,15 +2449,13 @@ func TestVirtualFoldersQuota(t *testing.T) {
 		MappedPath:  mappedPath1,
 	})
 	u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
-		VirtualPath: vdirPath2,
-		MappedPath:  mappedPath2,
+		VirtualPath:      vdirPath2,
+		MappedPath:       mappedPath2,
+		ExcludeFromQuota: true,
 	})
 	os.MkdirAll(mappedPath1, 0777)
 	os.MkdirAll(mappedPath2, 0777)
-	user, _, err := httpd.AddUser(u, http.StatusOK)
-	if err != nil {
-		t.Errorf("unable to add user: %v", err)
-	}
+	user, _, _ := httpd.AddUser(u, http.StatusOK)
 	client, err := getSftpClient(user, usePubKey)
 	if err != nil {
 		t.Errorf("unable to create sftp client: %v", err)
@@ -2482,8 +2480,26 @@ func TestVirtualFoldersQuota(t *testing.T) {
 		if err != nil {
 			t.Errorf("file upload error: %v", err)
 		}
-		expectedQuotaFiles := 3
-		expectedQuotaSize := testFileSize * 3
+		err = sftpUploadFile(testFilePath, path.Join(vdirPath2, testFileName), testFileSize, client)
+		if err != nil {
+			t.Errorf("file upload error: %v", err)
+		}
+		expectedQuotaFiles := 2
+		expectedQuotaSize := testFileSize * 2
+		user, _, err = httpd.GetUserByID(user.ID, http.StatusOK)
+		if err != nil {
+			t.Errorf("error getting user: %v", err)
+		}
+		if expectedQuotaFiles != user.UsedQuotaFiles {
+			t.Errorf("quota files does not match, expected: %v, actual: %v", expectedQuotaFiles, user.UsedQuotaFiles)
+		}
+		if expectedQuotaSize != user.UsedQuotaSize {
+			t.Errorf("quota size does not match, expected: %v, actual: %v", expectedQuotaSize, user.UsedQuotaSize)
+		}
+		err = client.Remove(path.Join(vdirPath2, testFileName))
+		if err != nil {
+			t.Errorf("unexpected error removing uploaded file: %v", err)
+		}
 		user, _, err = httpd.GetUserByID(user.ID, http.StatusOK)
 		if err != nil {
 			t.Errorf("error getting user: %v", err)
@@ -3878,6 +3894,52 @@ func TestResolveVirtualPaths(t *testing.T) {
 	}
 }
 
+func TestVirtualFoldersExcludeQuota(t *testing.T) {
+	user := getTestUser(true)
+	mappedPath := filepath.Join(os.TempDir(), "vdir")
+	vdirPath := "/vdir/sub"
+	vSubDirPath := path.Join(vdirPath, "subdir", "subdir")
+	vSubDir1Path := path.Join(vSubDirPath, "subdir", "subdir")
+	user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{
+		VirtualPath:      vdirPath,
+		MappedPath:       mappedPath,
+		ExcludeFromQuota: false,
+	})
+	user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{
+		VirtualPath:      vSubDir1Path,
+		MappedPath:       mappedPath,
+		ExcludeFromQuota: false,
+	})
+	user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{
+		VirtualPath:      vSubDirPath,
+		MappedPath:       mappedPath,
+		ExcludeFromQuota: true,
+	})
+
+	if user.IsFileExcludedFromQuota("/file") {
+		t.Errorf("unexpected file excluded from quota")
+	}
+	if user.IsFileExcludedFromQuota(path.Join(vdirPath, "file")) {
+		t.Errorf("unexpected file excluded from quota")
+	}
+	if !user.IsFileExcludedFromQuota(path.Join(vSubDirPath, "file")) {
+		t.Errorf("unexpected file included in quota")
+	}
+	if !user.IsFileExcludedFromQuota(path.Join(vSubDir1Path, "..", "file")) {
+		t.Errorf("unexpected file included in quota")
+	}
+	if user.IsFileExcludedFromQuota(path.Join(vSubDir1Path, "file")) {
+		t.Errorf("unexpected file excluded from quota")
+	}
+	if user.IsFileExcludedFromQuota(path.Join(vSubDirPath, "..", "file")) {
+		t.Errorf("unexpected file excluded from quota")
+	}
+	// we check the parent dir for a file
+	if user.IsFileExcludedFromQuota(vSubDirPath) {
+		t.Errorf("unexpected file excluded from quota")
+	}
+}
+
 func TestUserPerms(t *testing.T) {
 	user := getTestUser(true)
 	user.Permissions = make(map[string][]string)
@@ -4719,6 +4781,86 @@ func TestSCPVirtualFolders(t *testing.T) {
 	os.RemoveAll(mappedPath)
 }
 
+func TestSCPVirtualFoldersQuota(t *testing.T) {
+	if len(scpPath) == 0 {
+		t.Skip("scp command not found, unable to execute this test")
+	}
+	usePubKey := true
+	u := getTestUser(usePubKey)
+	u.QuotaFiles = 100
+	mappedPath1 := filepath.Join(os.TempDir(), "vdir1")
+	vdirPath1 := "/vdir1"
+	mappedPath2 := filepath.Join(os.TempDir(), "vdir2")
+	vdirPath2 := "/vdir2"
+	u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
+		VirtualPath: vdirPath1,
+		MappedPath:  mappedPath1,
+	})
+	u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
+		VirtualPath:      vdirPath2,
+		MappedPath:       mappedPath2,
+		ExcludeFromQuota: true,
+	})
+	os.MkdirAll(mappedPath1, 0777)
+	os.MkdirAll(mappedPath2, 0777)
+	user, _, err := httpd.AddUser(u, http.StatusOK)
+	if err != nil {
+		t.Errorf("unable to add user: %v", err)
+	}
+	testFileName := "test_file.dat"
+	testBaseDirName := "test_dir"
+	testBaseDirPath := filepath.Join(homeBasePath, testBaseDirName)
+	testBaseDirDownName := "test_dir_down"
+	testBaseDirDownPath := filepath.Join(homeBasePath, testBaseDirDownName)
+	testFilePath := filepath.Join(homeBasePath, testBaseDirName, testFileName)
+	testFilePath1 := filepath.Join(homeBasePath, testBaseDirName, testBaseDirName, testFileName)
+	testFileSize := int64(131074)
+	createTestFile(testFilePath, testFileSize)
+	createTestFile(testFilePath1, testFileSize)
+	remoteDownPath1 := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, path.Join("/", vdirPath1))
+	remoteUpPath1 := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, vdirPath1)
+	remoteDownPath2 := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, path.Join("/", vdirPath2))
+	remoteUpPath2 := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, vdirPath2)
+	err = scpUpload(testBaseDirPath, remoteUpPath1, true, false)
+	if err != nil {
+		t.Errorf("error uploading dir via scp: %v", err)
+	}
+	err = scpDownload(testBaseDirDownPath, remoteDownPath1, true, true)
+	if err != nil {
+		t.Errorf("error downloading dir via scp: %v", err)
+	}
+	err = scpUpload(testBaseDirPath, remoteUpPath2, true, false)
+	if err != nil {
+		t.Errorf("error uploading dir via scp: %v", err)
+	}
+	err = scpDownload(testBaseDirDownPath, remoteDownPath2, true, true)
+	if err != nil {
+		t.Errorf("error downloading dir via scp: %v", err)
+	}
+	expectedQuotaFiles := 2
+	expectedQuotaSize := testFileSize * 2
+	user, _, err = httpd.GetUserByID(user.ID, http.StatusOK)
+	if err != nil {
+		t.Errorf("error getting user: %v", err)
+	}
+	if expectedQuotaFiles != user.UsedQuotaFiles {
+		t.Errorf("quota files does not match, expected: %v, actual: %v", expectedQuotaFiles, user.UsedQuotaFiles)
+	}
+	if expectedQuotaSize != user.UsedQuotaSize {
+		t.Errorf("quota size does not match, expected: %v, actual: %v", expectedQuotaSize, user.UsedQuotaSize)
+	}
+
+	_, err = httpd.RemoveUser(user, http.StatusOK)
+	if err != nil {
+		t.Errorf("unable to remove user: %v", err)
+	}
+	os.RemoveAll(testBaseDirPath)
+	os.RemoveAll(testBaseDirDownPath)
+	os.RemoveAll(user.GetHomeDir())
+	os.RemoveAll(mappedPath1)
+	os.RemoveAll(mappedPath1)
+}
+
 func TestSCPPermsSubDirs(t *testing.T) {
 	if len(scpPath) == 0 {
 		t.Skip("scp command not found, unable to execute this test")

+ 24 - 20
sftpd/transfer.go

@@ -26,26 +26,27 @@ var (
 // Transfer contains the transfer details for an upload or a download.
 // It implements the io Reader and Writer interface to handle files downloads and uploads
 type Transfer struct {
-	file           *os.File
-	writerAt       *pipeat.PipeWriterAt
-	readerAt       *pipeat.PipeReaderAt
-	cancelFn       func()
-	path           string
-	start          time.Time
-	bytesSent      int64
-	bytesReceived  int64
-	user           dataprovider.User
-	connectionID   string
-	transferType   int
-	lastActivity   time.Time
-	protocol       string
-	transferError  error
-	minWriteOffset int64
-	expectedSize   int64
-	initialSize    int64
-	lock           *sync.Mutex
-	isNewFile      bool
-	isFinished     bool
+	file                *os.File
+	writerAt            *pipeat.PipeWriterAt
+	readerAt            *pipeat.PipeReaderAt
+	cancelFn            func()
+	path                string
+	start               time.Time
+	bytesSent           int64
+	bytesReceived       int64
+	user                dataprovider.User
+	connectionID        string
+	transferType        int
+	lastActivity        time.Time
+	protocol            string
+	transferError       error
+	minWriteOffset      int64
+	expectedSize        int64
+	initialSize         int64
+	lock                *sync.Mutex
+	isNewFile           bool
+	isFinished          bool
+	isExcludedFromQuota bool
 }
 
 // TransferError is called if there is an unexpected error.
@@ -184,6 +185,9 @@ func (t *Transfer) updateQuota(numFiles int) bool {
 	if t.file == nil && t.transferError != nil {
 		return false
 	}
+	if t.isExcludedFromQuota {
+		return false
+	}
 	if t.transferType == transferUpload && (numFiles != 0 || t.bytesReceived > 0) {
 		dataprovider.UpdateUserQuota(dataProvider, t.user, numFiles, t.bytesReceived-t.initialSize, false) //nolint:errcheck
 		return true

+ 2 - 2
templates/user.html

@@ -124,10 +124,10 @@
         <div class="col-sm-10">
             <textarea class="form-control" id="idVirtualFolders" name="virtual_folders" rows="3"
                 aria-describedby="vfHelpBlock">{{range $index, $mapping := .User.VirtualFolders -}}
-                {{$mapping.VirtualPath}}::{{$mapping.MappedPath}}&#10;
+                {{$mapping.VirtualPath}}::{{$mapping.MappedPath}}{{if $mapping.ExcludeFromQuota}}::1{{end}}&#10;
                 {{- end}}</textarea>
             <small id="vfHelpBlock" class="form-text text-muted">
-                One mapping per line as vpath::path, for example /vdir::/home/adir or /vdir::C:\adir, ignored for non local filesystems
+                One mapping per line as vpath::path::[exclude_from_quota], for example /vdir::/home/adir or /vdir::C:\adir::1, ignored for non local filesystems
             </small>
         </div>
     </div>

+ 3 - 0
vfs/osfs.go

@@ -174,6 +174,9 @@ func (fs OsFs) CheckRootPath(username string, uid int, gid int) bool {
 func (fs OsFs) ScanRootDirContents() (int, int64, error) {
 	numFiles, size, err := fs.getDirSize(fs.rootDir)
 	for _, v := range fs.virtualFolders {
+		if v.ExcludeFromQuota {
+			continue
+		}
 		num, s, err := fs.getDirSize(v.MappedPath)
 		if err != nil {
 			if fs.IsNotExist(err) {

+ 2 - 0
vfs/vfs.go

@@ -52,6 +52,8 @@ type Fs interface {
 type VirtualFolder struct {
 	VirtualPath string `json:"virtual_path"`
 	MappedPath  string `json:"mapped_path"`
+	// This folder will be excluded from user quota
+	ExcludeFromQuota bool `json:"exclude_from_quota"`
 }
 
 // IsDirectory checks if a path exists and is a directory