Browse Source

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

Fixes #110
Nicola Murino 5 years ago
parent
commit
3f75d46a16

+ 3 - 2
dataprovider/dataprovider.go

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

+ 16 - 0
dataprovider/user.go

@@ -191,6 +191,22 @@ func (u *User) GetPermissionsForPath(p string) []string {
 	return permissions
 	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
 // AddVirtualDirs adds virtual folders, if defined, to the given files list
 func (u *User) AddVirtualDirs(list []os.FileInfo, sftpPath string) []os.FileInfo {
 func (u *User) AddVirtualDirs(list []os.FileInfo, sftpPath string) []os.FileInfo {
 	if len(u.VirtualFolders) == 0 {
 	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.
 - `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.
 - `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.
 - `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.
 - `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.
 - `max_sessions` maximum concurrent sessions. 0 means unlimited.
 - `quota_size` maximum size allowed as bytes. 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()
 	err = httpd.ReloadTLSCertificate()
 	if err != nil {
 	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"),
 		MappedPath:  filepath.Join(os.TempDir(), "mapped_dir1"),
 	})
 	})
 	user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{
 	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)
 	user, _, err = httpd.UpdateUser(user, http.StatusOK)
 	if err != nil {
 	if err != nil {
@@ -1793,7 +1794,7 @@ func TestWebUserAddMock(t *testing.T) {
 	form.Set("expiration_date", "")
 	form.Set("expiration_date", "")
 	form.Set("permissions", "*")
 	form.Set("permissions", "*")
 	form.Set("sub_dirs_permissions", " /subdir::list ,download ")
 	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("allowed_extensions", "/dir1::.jpg,.png")
 	form.Set("denied_extensions", "/dir1::.zip")
 	form.Set("denied_extensions", "/dir1::.zip")
 	b, contentType, _ := getMultipartFormData(form, "", "")
 	b, contentType, _ := getMultipartFormData(form, "", "")
@@ -1899,10 +1900,7 @@ func TestWebUserAddMock(t *testing.T) {
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr.Code)
 	checkResponseCode(t, http.StatusOK, rr.Code)
 	var users []dataprovider.User
 	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 {
 	if len(users) != 1 {
 		t.Errorf("1 user is expected, actual: %v", len(users))
 		t.Errorf("1 user is expected, actual: %v", len(users))
 	}
 	}
@@ -1928,7 +1926,7 @@ func TestWebUserAddMock(t *testing.T) {
 	}
 	}
 	vfolderFoumd := false
 	vfolderFoumd := false
 	for _, v := range newUser.VirtualFolders {
 	for _, v := range newUser.VirtualFolders {
-		if v.VirtualPath == "/vdir" && v.MappedPath == mappedDir {
+		if v.VirtualPath == "/vdir" && v.MappedPath == mappedDir && v.ExcludeFromQuota == true {
 			vfolderFoumd = true
 			vfolderFoumd = true
 		}
 		}
 	}
 	}

+ 5 - 1
httpd/schema/openapi.yaml

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

+ 9 - 2
httpd/web.go

@@ -196,10 +196,17 @@ func getVirtualFoldersFromPostFields(r *http.Request) []vfs.VirtualFolder {
 		if strings.Contains(cleaned, "::") {
 		if strings.Contains(cleaned, "::") {
 			mapping := strings.Split(cleaned, "::")
 			mapping := strings.Split(cleaned, "::")
 			if len(mapping) > 1 {
 			if len(mapping) > 1 {
-				virtualFolders = append(virtualFolders, vfs.VirtualFolder{
+				vfolder := vfs.VirtualFolder{
 					VirtualPath: strings.TrimSpace(mapping[0]),
 					VirtualPath: strings.TrimSpace(mapping[0]),
 					MappedPath:  strings.TrimSpace(mapping[1]),
 					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:
 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:
 Output:
@@ -203,10 +203,12 @@ Output:
   "username": "test_username",
   "username": "test_username",
   "virtual_folders": [
   "virtual_folders": [
     {
     {
+      "exclude_from_quota": false,
       "mapped_path": "/tmp/mapped1",
       "mapped_path": "/tmp/mapped1",
       "virtual_path": "/vdir1"
       "virtual_path": "/vdir1"
     },
     },
     {
     {
+      "exclude_from_quota": true,
       "mapped_path": "/tmp/mapped2",
       "mapped_path": "/tmp/mapped2",
       "virtual_path": "/vdir2"
       "virtual_path": "/vdir2"
     }
     }
@@ -265,10 +267,12 @@ Output:
     "username": "test_username",
     "username": "test_username",
     "virtual_folders": [
     "virtual_folders": [
       {
       {
+        "exclude_from_quota": false,
         "mapped_path": "/tmp/mapped1",
         "mapped_path": "/tmp/mapped1",
         "virtual_path": "/vdir1"
         "virtual_path": "/vdir1"
       },
       },
       {
       {
+        "exclude_from_quota": true,
         "mapped_path": "/tmp/mapped2",
         "mapped_path": "/tmp/mapped2",
         "virtual_path": "/vdir2"
         "virtual_path": "/vdir2"
       }
       }

+ 10 - 2
scripts/sftpgo_api_cli.py

@@ -110,12 +110,19 @@ class SFTPGoApiRequests:
 			if '::' in f:
 			if '::' in f:
 				vpath = ''
 				vpath = ''
 				mapped_path = ''
 				mapped_path = ''
+				exclude_from_quota = False
 				values = f.split('::')
 				values = f.split('::')
 				if len(values) > 1:
 				if len(values) > 1:
 					vpath = values[0]
 					vpath = values[0]
 					mapped_path = values[1]
 					mapped_path = values[1]
+				if len(values) > 2:
+					try:
+						exclude_from_quota = int(values[2]) > 0
+					except:
+						pass
 				if vpath and mapped_path:
 				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
 		return result
 
 
 	def buildPermissions(self, root_perms, subdirs_perms):
 	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. '
 	parser.add_argument('--subdirs-permissions', type=str, nargs='*', default=[], help='Permissions for subdirs. '
 					+'For example: "/somedir::list,download" "/otherdir/subdir::*" Default: %(default)s')
 					+'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: '
 	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,
 	parser.add_argument('-U', '--upload-bandwidth', type=int, default=0,
 					help='Maximum upload bandwidth as KB/s, 0 means unlimited. Default: %(default)s')
 					help='Maximum upload bandwidth as KB/s, 0 means unlimited. Default: %(default)s')
 	parser.add_argument('-D', '--download-bandwidth', type=int, default=0,
 	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)
 	c.Log(logger.LevelDebug, logSender, "fileread requested for path: %#v", p)
 
 
 	transfer := Transfer{
 	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)
 	addTransfer(&transfer)
 	return &transfer, nil
 	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)) {
 		if !c.User.HasPerm(dataprovider.PermUpload, path.Dir(request.Filepath)) {
 			return nil, sftp.ErrSSHFxPermissionDenied
 			return nil, sftp.ErrSSHFxPermissionDenied
 		}
 		}
-		return c.handleSFTPUploadToNewFile(p, filePath)
+		return c.handleSFTPUploadToNewFile(p, filePath, c.User.IsFileExcludedFromQuota(request.Filepath))
 	}
 	}
 
 
 	if statErr != nil {
 	if statErr != nil {
@@ -141,7 +142,8 @@ func (c Connection) Filewrite(request *sftp.Request) (io.WriterAt, error) {
 		return nil, sftp.ErrSSHFxPermissionDenied
 		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
 // 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, "", "", "")
 	logger.CommandLog(removeLogSender, filePath, "", c.User.Username, "", c.ID, c.protocol, -1, -1, "", "", "")
 	if fi.Mode()&os.ModeSymlink != os.ModeSymlink {
 	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
 	go executeAction(newActionNotification(c.User, operationDelete, filePath, "", "", fi.Size(), nil)) //nolint:errcheck
 
 
 	return sftp.ErrSSHFxOk
 	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) {
 	if !c.hasSpace(true) {
 		c.Log(logger.LevelInfo, logSender, "denying file write due to space limit")
 		c.Log(logger.LevelInfo, logSender, "denying file write due to space limit")
 		return nil, sftp.ErrSSHFxFailure
 		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())
 	vfs.SetPathPermissions(c.fs, filePath, c.User.GetUID(), c.User.GetGID())
 
 
 	transfer := Transfer{
 	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)
 	addTransfer(&transfer)
 	return &transfer, nil
 	return &transfer, nil
 }
 }
 
 
 func (c Connection) handleSFTPUploadToExistingFile(pflags sftp.FileOpenFlags, requestPath, filePath string,
 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
 	var err error
 	if !c.hasSpace(false) {
 	if !c.hasSpace(false) {
 		c.Log(logger.LevelInfo, logSender, "denying file write due to space limit")
 		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
 		minWriteOffset = fileSize
 	} else {
 	} else {
 		if vfs.IsLocalOsFs(c.fs) {
 		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 {
 		} else {
 			initialSize = fileSize
 			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())
 	vfs.SetPathPermissions(c.fs, filePath, c.User.GetUID(), c.User.GetGID())
 
 
 	transfer := Transfer{
 	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)
 	addTransfer(&transfer)
 	return &transfer, nil
 	return &transfer, nil

+ 6 - 6
sftpd/internal_test.go

@@ -423,7 +423,7 @@ func TestMockFsErrors(t *testing.T) {
 	flags.Write = true
 	flags.Write = true
 	flags.Trunc = false
 	flags.Trunc = false
 	flags.Append = true
 	flags.Append = true
-	_, err = c.handleSFTPUploadToExistingFile(flags, testfile, testfile, 0)
+	_, err = c.handleSFTPUploadToExistingFile(flags, testfile, testfile, 0, false)
 	if err != sftp.ErrSSHFxOpUnsupported {
 	if err != sftp.ErrSSHFxOpUnsupported {
 		t.Errorf("unexpected error: %v", err)
 		t.Errorf("unexpected error: %v", err)
 	}
 	}
@@ -439,12 +439,12 @@ func TestUploadFiles(t *testing.T) {
 	var flags sftp.FileOpenFlags
 	var flags sftp.FileOpenFlags
 	flags.Write = true
 	flags.Write = true
 	flags.Trunc = 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 {
 	if err == nil {
 		t.Errorf("upload to existing file must fail if one or both paths are invalid")
 		t.Errorf("upload to existing file must fail if one or both paths are invalid")
 	}
 	}
 	uploadMode = uploadModeStandard
 	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 {
 	if err == nil {
 		t.Errorf("upload to existing file must fail if one or both paths are invalid")
 		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" {
 	if runtime.GOOS == "windows" {
 		missingFile = "missing\\relative\\file.txt"
 		missingFile = "missing\\relative\\file.txt"
 	}
 	}
-	_, err = c.handleSFTPUploadToNewFile(".", missingFile)
+	_, err = c.handleSFTPUploadToNewFile(".", missingFile, false)
 	if err == nil {
 	if err == nil {
 		t.Errorf("upload new file in missing path must fail")
 		t.Errorf("upload new file in missing path must fail")
 	}
 	}
 	c.fs = newMockOsFs(nil, nil, false, "123", os.TempDir())
 	c.fs = newMockOsFs(nil, nil, false, "123", os.TempDir())
 	f, _ := ioutil.TempFile("", "temp")
 	f, _ := ioutil.TempFile("", "temp")
 	f.Close()
 	f.Close()
-	_, err = c.handleSFTPUploadToExistingFile(flags, f.Name(), f.Name(), 123)
+	_, err = c.handleSFTPUploadToExistingFile(flags, f.Name(), f.Name(), 123, false)
 	if err != nil {
 	if err != nil {
 		t.Errorf("unexpected error: %v", err)
 		t.Errorf("unexpected error: %v", err)
 	}
 	}
@@ -1437,7 +1437,7 @@ func TestSCPErrorsMockFs(t *testing.T) {
 	if err != errFake {
 	if err != errFake {
 		t.Errorf("unexpected error: %v", err)
 		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 {
 	if err != nil {
 		t.Errorf("unexpected error: %v", err)
 		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()
 	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) {
 	if !c.connection.hasSpace(true) {
 		err := fmt.Errorf("denying file write due to space limit")
 		err := fmt.Errorf("denying file write due to space limit")
 		c.connection.Log(logger.LevelWarn, logSenderSCP, "error uploading file: %#v, err: %v", filePath, err)
 		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)
 	initialSize := int64(0)
 	if !isNewFile {
 	if !isNewFile {
 		if vfs.IsLocalOsFs(c.connection.fs) {
 		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 {
 		} else {
 			initialSize = fileSize
 			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())
 	vfs.SetPathPermissions(c.connection.fs, filePath, c.connection.User.GetUID(), c.connection.User.GetGID())
 
 
 	transfer := Transfer{
 	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)
 	addTransfer(&transfer)
 
 
@@ -265,7 +269,7 @@ func (c *scpCommand) handleUpload(uploadFilePath string, sizeToRead int64) error
 			c.sendErrorMessage(errPermission)
 			c.sendErrorMessage(errPermission)
 			return 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 {
 	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 {
 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,
 		MappedPath:  mappedPath1,
 	})
 	})
 	u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
 	u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
-		VirtualPath: vdirPath2,
-		MappedPath:  mappedPath2,
+		VirtualPath:      vdirPath2,
+		MappedPath:       mappedPath2,
+		ExcludeFromQuota: true,
 	})
 	})
 	os.MkdirAll(mappedPath1, 0777)
 	os.MkdirAll(mappedPath1, 0777)
 	os.MkdirAll(mappedPath2, 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)
 	client, err := getSftpClient(user, usePubKey)
 	if err != nil {
 	if err != nil {
 		t.Errorf("unable to create sftp client: %v", err)
 		t.Errorf("unable to create sftp client: %v", err)
@@ -2482,8 +2480,26 @@ func TestVirtualFoldersQuota(t *testing.T) {
 		if err != nil {
 		if err != nil {
 			t.Errorf("file upload error: %v", err)
 			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)
 		user, _, err = httpd.GetUserByID(user.ID, http.StatusOK)
 		if err != nil {
 		if err != nil {
 			t.Errorf("error getting user: %v", err)
 			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) {
 func TestUserPerms(t *testing.T) {
 	user := getTestUser(true)
 	user := getTestUser(true)
 	user.Permissions = make(map[string][]string)
 	user.Permissions = make(map[string][]string)
@@ -4719,6 +4781,86 @@ func TestSCPVirtualFolders(t *testing.T) {
 	os.RemoveAll(mappedPath)
 	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) {
 func TestSCPPermsSubDirs(t *testing.T) {
 	if len(scpPath) == 0 {
 	if len(scpPath) == 0 {
 		t.Skip("scp command not found, unable to execute this test")
 		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.
 // 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
 // It implements the io Reader and Writer interface to handle files downloads and uploads
 type Transfer struct {
 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.
 // 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 {
 	if t.file == nil && t.transferError != nil {
 		return false
 		return false
 	}
 	}
+	if t.isExcludedFromQuota {
+		return false
+	}
 	if t.transferType == transferUpload && (numFiles != 0 || t.bytesReceived > 0) {
 	if t.transferType == transferUpload && (numFiles != 0 || t.bytesReceived > 0) {
 		dataprovider.UpdateUserQuota(dataProvider, t.user, numFiles, t.bytesReceived-t.initialSize, false) //nolint:errcheck
 		dataprovider.UpdateUserQuota(dataProvider, t.user, numFiles, t.bytesReceived-t.initialSize, false) //nolint:errcheck
 		return true
 		return true

+ 2 - 2
templates/user.html

@@ -124,10 +124,10 @@
         <div class="col-sm-10">
         <div class="col-sm-10">
             <textarea class="form-control" id="idVirtualFolders" name="virtual_folders" rows="3"
             <textarea class="form-control" id="idVirtualFolders" name="virtual_folders" rows="3"
                 aria-describedby="vfHelpBlock">{{range $index, $mapping := .User.VirtualFolders -}}
                 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>
                 {{- end}}</textarea>
             <small id="vfHelpBlock" class="form-text text-muted">
             <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>
             </small>
         </div>
         </div>
     </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) {
 func (fs OsFs) ScanRootDirContents() (int, int64, error) {
 	numFiles, size, err := fs.getDirSize(fs.rootDir)
 	numFiles, size, err := fs.getDirSize(fs.rootDir)
 	for _, v := range fs.virtualFolders {
 	for _, v := range fs.virtualFolders {
+		if v.ExcludeFromQuota {
+			continue
+		}
 		num, s, err := fs.getDirSize(v.MappedPath)
 		num, s, err := fs.getDirSize(v.MappedPath)
 		if err != nil {
 		if err != nil {
 			if fs.IsNotExist(err) {
 			if fs.IsNotExist(err) {

+ 2 - 0
vfs/vfs.go

@@ -52,6 +52,8 @@ type Fs interface {
 type VirtualFolder struct {
 type VirtualFolder struct {
 	VirtualPath string `json:"virtual_path"`
 	VirtualPath string `json:"virtual_path"`
 	MappedPath  string `json:"mapped_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
 // IsDirectory checks if a path exists and is a directory