浏览代码

add support for a start directory

Fixes #705

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 3 年之前
父节点
当前提交
5c2fd8d52a

+ 10 - 10
.github/workflows/development.yml

@@ -20,12 +20,12 @@ jobs:
             upload-coverage: false
             upload-coverage: false
 
 
     steps:
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
         with:
         with:
           fetch-depth: 0
           fetch-depth: 0
 
 
       - name: Set up Go
       - name: Set up Go
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
         with:
           go-version: ${{ matrix.go }}
           go-version: ${{ matrix.go }}
 
 
@@ -218,10 +218,10 @@ jobs:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
     steps:
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
 
 
       - name: Set up Go
       - name: Set up Go
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
         with:
           go-version: 1.17
           go-version: 1.17
 
 
@@ -274,10 +274,10 @@ jobs:
           - 3307:3306
           - 3307:3306
 
 
     steps:
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
 
 
       - name: Set up Go
       - name: Set up Go
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
         with:
           go-version: 1.17
           go-version: 1.17
 
 
@@ -345,12 +345,12 @@ jobs:
             go: latest
             go: latest
             go-arch: arm7
             go-arch: arm7
     steps:
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
         with:
         with:
           fetch-depth: 0
           fetch-depth: 0
       - name: Set up Go
       - name: Set up Go
         if: ${{ matrix.arch == 'amd64' }}
         if: ${{ matrix.arch == 'amd64' }}
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
         with:
           go-version: ${{ matrix.go }}
           go-version: ${{ matrix.go }}
 
 
@@ -449,10 +449,10 @@ jobs:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     steps:
     steps:
       - name: Set up Go
       - name: Set up Go
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
         with:
           go-version: 1.17
           go-version: 1.17
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
       - name: Run golangci-lint
       - name: Run golangci-lint
         uses: golangci/golangci-lint-action@v3
         uses: golangci/golangci-lint-action@v3
         with:
         with:

+ 1 - 1
.github/workflows/docker.yml

@@ -30,7 +30,7 @@ jobs:
             optional_deps: false
             optional_deps: false
     steps:
     steps:
       - name: Checkout
       - name: Checkout
-        uses: actions/checkout@v2
+        uses: actions/checkout@v3
 
 
       - name: Gather image information
       - name: Gather image information
         id: info
         id: info

+ 8 - 8
.github/workflows/release.yml

@@ -5,16 +5,16 @@ on:
     tags: 'v*'
     tags: 'v*'
 
 
 env:
 env:
-  GO_VERSION: 1.17.5
+  GO_VERSION: 1.17.7
 
 
 jobs:
 jobs:
   prepare-sources-with-deps:
   prepare-sources-with-deps:
     name: Prepare sources with deps
     name: Prepare sources with deps
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     steps:
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
       - name: Set up Go
       - name: Set up Go
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
         with:
           go-version: ${{ env.GO_VERSION }}
           go-version: ${{ env.GO_VERSION }}
 
 
@@ -45,9 +45,9 @@ jobs:
         os: [macos-10.15, windows-2019]
         os: [macos-10.15, windows-2019]
 
 
     steps:
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
       - name: Set up Go
       - name: Set up Go
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
         with:
           go-version: ${{ env.GO_VERSION }}
           go-version: ${{ env.GO_VERSION }}
 
 
@@ -283,10 +283,10 @@ jobs:
             tar-arch: armv7
             tar-arch: armv7
 
 
     steps:
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
       - name: Set up Go
       - name: Set up Go
         if: ${{ matrix.arch == 'amd64' }}
         if: ${{ matrix.arch == 'amd64' }}
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
         with:
           go-version: ${{ env.GO_VERSION }}
           go-version: ${{ env.GO_VERSION }}
 
 
@@ -467,7 +467,7 @@ jobs:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
     steps:
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
       - name: Get versions
       - name: Get versions
         id: get_version
         id: get_version
         run: |
         run: |

+ 6 - 1
cmd/portable.go

@@ -29,6 +29,7 @@ var (
 	portableAdvertiseCredentials       bool
 	portableAdvertiseCredentials       bool
 	portableUsername                   string
 	portableUsername                   string
 	portablePassword                   string
 	portablePassword                   string
+	portableStartDir                   string
 	portableLogFile                    string
 	portableLogFile                    string
 	portableLogVerbose                 bool
 	portableLogVerbose                 bool
 	portableLogUTCTime                 bool
 	portableLogUTCTime                 bool
@@ -163,7 +164,8 @@ Please take a look at the usage below to customize the serving parameters`,
 					},
 					},
 					Filters: dataprovider.UserFilters{
 					Filters: dataprovider.UserFilters{
 						BaseUserFilters: sdk.BaseUserFilters{
 						BaseUserFilters: sdk.BaseUserFilters{
-							FilePatterns: parsePatternsFilesFilters(),
+							FilePatterns:   parsePatternsFilesFilters(),
+							StartDirectory: portableStartDir,
 						},
 						},
 					},
 					},
 					FsConfig: vfs.Filesystem{
 					FsConfig: vfs.Filesystem{
@@ -246,6 +248,9 @@ func init() {
 This can be an absolute path or a path
 This can be an absolute path or a path
 relative to the current directory
 relative to the current directory
 `)
 `)
+	portableCmd.Flags().StringVar(&portableStartDir, "start-directory", "/", `Alternate start directory.
+This is a virtual path not a filesystem
+path`)
 	portableCmd.Flags().IntVarP(&portableSFTPDPort, "sftpd-port", "s", 0, `0 means a random unprivileged port,
 	portableCmd.Flags().IntVarP(&portableSFTPDPort, "sftpd-port", "s", 0, `0 means a random unprivileged port,
 < 0 disabled`)
 < 0 disabled`)
 	portableCmd.Flags().IntVar(&portableFTPDPort, "ftpd-port", -1, `0 means a random unprivileged port,
 	portableCmd.Flags().IntVar(&portableFTPDPort, "ftpd-port", -1, `0 means a random unprivileged port,

+ 1 - 0
common/common_test.go

@@ -884,6 +884,7 @@ func TestGetTLSVersion(t *testing.T) {
 func TestCleanPath(t *testing.T) {
 func TestCleanPath(t *testing.T) {
 	assert.Equal(t, "/", util.CleanPath("/"))
 	assert.Equal(t, "/", util.CleanPath("/"))
 	assert.Equal(t, "/", util.CleanPath("."))
 	assert.Equal(t, "/", util.CleanPath("."))
+	assert.Equal(t, "/", util.CleanPath(""))
 	assert.Equal(t, "/", util.CleanPath("/."))
 	assert.Equal(t, "/", util.CleanPath("/."))
 	assert.Equal(t, "/", util.CleanPath("/a/.."))
 	assert.Equal(t, "/", util.CleanPath("/a/.."))
 	assert.Equal(t, "/a", util.CleanPath("/a/"))
 	assert.Equal(t, "/a", util.CleanPath("/a/"))

+ 13 - 3
dataprovider/dataprovider.go

@@ -2021,6 +2021,18 @@ func validateTransferLimitsFilter(user *User) error {
 	return nil
 	return nil
 }
 }
 
 
+func updateFiltersValues(user *User) {
+	if !user.HasExternalAuth() {
+		user.Filters.ExternalAuthCacheTime = 0
+	}
+	if user.Filters.StartDirectory != "" {
+		user.Filters.StartDirectory = util.CleanPath(user.Filters.StartDirectory)
+		if user.Filters.StartDirectory == "/" {
+			user.Filters.StartDirectory = ""
+		}
+	}
+}
+
 func validateFilters(user *User) error {
 func validateFilters(user *User) error {
 	checkEmptyFiltersStruct(user)
 	checkEmptyFiltersStruct(user)
 	if err := validateIPFilters(user); err != nil {
 	if err := validateIPFilters(user); err != nil {
@@ -2061,9 +2073,7 @@ func validateFilters(user *User) error {
 			return util.NewValidationError(fmt.Sprintf("invalid web client options %#v", opts))
 			return util.NewValidationError(fmt.Sprintf("invalid web client options %#v", opts))
 		}
 		}
 	}
 	}
-	if !user.HasExternalAuth() {
-		user.Filters.ExternalAuthCacheTime = 0
-	}
+	updateFiltersValues(user)
 
 
 	return validateFiltersPatternExtensions(user)
 	return validateFiltersPatternExtensions(user)
 }
 }

+ 46 - 1
dataprovider/user.go

@@ -219,6 +219,13 @@ func (u *User) CheckFsRoot(connectionID string) error {
 		return err
 		return err
 	}
 	}
 	fs.CheckRootPath(u.Username, u.GetUID(), u.GetGID())
 	fs.CheckRootPath(u.Username, u.GetUID(), u.GetGID())
+	if u.Filters.StartDirectory != "" {
+		err = u.checkDirWithParents(u.Filters.StartDirectory, connectionID)
+		if err != nil {
+			logger.Warn(logSender, connectionID, "could not create start directory %#v, err: %v",
+				u.Filters.StartDirectory, err)
+		}
+	}
 	for idx := range u.VirtualFolders {
 	for idx := range u.VirtualFolders {
 		v := &u.VirtualFolders[idx]
 		v := &u.VirtualFolders[idx]
 		fs, err = u.GetFilesystemForPath(v.VirtualPath, connectionID)
 		fs, err = u.GetFilesystemForPath(v.VirtualPath, connectionID)
@@ -234,6 +241,23 @@ func (u *User) CheckFsRoot(connectionID string) error {
 	return nil
 	return nil
 }
 }
 
 
+// GetCleanedPath returns a clean POSIX absolute path using the user start directory as base
+// if the provided rawVirtualPath is relative
+func (u *User) GetCleanedPath(rawVirtualPath string) string {
+	if u.Filters.StartDirectory != "" {
+		if !path.IsAbs(rawVirtualPath) {
+			var b strings.Builder
+
+			b.Grow(len(u.Filters.StartDirectory) + 1 + len(rawVirtualPath))
+			b.WriteString(u.Filters.StartDirectory)
+			b.WriteString("/")
+			b.WriteString(rawVirtualPath)
+			return util.CleanPath(b.String())
+		}
+	}
+	return util.CleanPath(rawVirtualPath)
+}
+
 // isFsEqual returns true if the fs has the same configuration
 // isFsEqual returns true if the fs has the same configuration
 func (u *User) isFsEqual(other *User) bool {
 func (u *User) isFsEqual(other *User) bool {
 	if u.FsConfig.Provider == sdk.LocalFilesystemProvider && u.GetHomeDir() != other.GetHomeDir() {
 	if u.FsConfig.Provider == sdk.LocalFilesystemProvider && u.GetHomeDir() != other.GetHomeDir() {
@@ -242,6 +266,9 @@ func (u *User) isFsEqual(other *User) bool {
 	if !u.FsConfig.IsEqual(&other.FsConfig) {
 	if !u.FsConfig.IsEqual(&other.FsConfig) {
 		return false
 		return false
 	}
 	}
+	if u.Filters.StartDirectory != other.Filters.StartDirectory {
+		return false
+	}
 	if len(u.VirtualFolders) != len(other.VirtualFolders) {
 	if len(u.VirtualFolders) != len(other.VirtualFolders) {
 		return false
 		return false
 	}
 	}
@@ -586,13 +613,30 @@ func (u *User) GetVirtualFoldersInPath(virtualPath string) map[string]bool {
 		}
 		}
 	}
 	}
 
 
+	if u.Filters.StartDirectory != "" {
+		dirsForPath := util.GetDirsForVirtualPath(u.Filters.StartDirectory)
+		for index := range dirsForPath {
+			d := dirsForPath[index]
+			if d == "/" {
+				continue
+			}
+			if path.Dir(d) == virtualPath {
+				result[d] = true
+			}
+		}
+	}
+
 	return result
 	return result
 }
 }
 
 
+func (u *User) hasVirtualDirs() bool {
+	return len(u.VirtualFolders) > 0 || u.Filters.StartDirectory != ""
+}
+
 // FilterListDir adds virtual folders and remove hidden items from the given files list
 // FilterListDir adds virtual folders and remove hidden items from the given files list
 func (u *User) FilterListDir(dirContents []os.FileInfo, virtualPath string) []os.FileInfo {
 func (u *User) FilterListDir(dirContents []os.FileInfo, virtualPath string) []os.FileInfo {
 	filter := u.getPatternsFilterForPath(virtualPath)
 	filter := u.getPatternsFilterForPath(virtualPath)
-	if len(u.VirtualFolders) == 0 && filter.DenyPolicy != sdk.DenyPolicyHide {
+	if !u.hasVirtualDirs() && filter.DenyPolicy != sdk.DenyPolicyHide {
 		return dirContents
 		return dirContents
 	}
 	}
 
 
@@ -1395,6 +1439,7 @@ func (u *User) getACopy() User {
 	filters.Hooks.PreLoginDisabled = u.Filters.Hooks.PreLoginDisabled
 	filters.Hooks.PreLoginDisabled = u.Filters.Hooks.PreLoginDisabled
 	filters.Hooks.CheckPasswordDisabled = u.Filters.Hooks.CheckPasswordDisabled
 	filters.Hooks.CheckPasswordDisabled = u.Filters.Hooks.CheckPasswordDisabled
 	filters.DisableFsChecks = u.Filters.DisableFsChecks
 	filters.DisableFsChecks = u.Filters.DisableFsChecks
+	filters.StartDirectory = u.Filters.StartDirectory
 	filters.AllowAPIKeyAuth = u.Filters.AllowAPIKeyAuth
 	filters.AllowAPIKeyAuth = u.Filters.AllowAPIKeyAuth
 	filters.ExternalAuthCacheTime = u.Filters.ExternalAuthCacheTime
 	filters.ExternalAuthCacheTime = u.Filters.ExternalAuthCacheTime
 	filters.WebClient = make([]string, len(u.Filters.WebClient))
 	filters.WebClient = make([]string, len(u.Filters.WebClient))

+ 1 - 1
docs/defender.md

@@ -2,7 +2,7 @@
 
 
 The built-in `defender` allows you to configure an auto-blocking policy for SFTPGo and thus helps to prevent DoS (Denial of Service) and brute force password guessing.
 The built-in `defender` allows you to configure an auto-blocking policy for SFTPGo and thus helps to prevent DoS (Denial of Service) and brute force password guessing.
 
 
-If enabled it will protect SFTP, FTP and WebDAV services and it will automatically block hosts (IP addresses) that continually fail to log in or attempt to connect.
+If enabled it will protect SFTP, HTTP, FTP and WebDAV services and it will automatically block hosts (IP addresses) that continually fail to log in or attempt to connect.
 
 
 You can configure a score for the following events:
 You can configure a score for the following events:
 
 

+ 3 - 0
docs/portable-mode.md

@@ -126,6 +126,9 @@ Flags:
                                         "*" means any supported SSH command
                                         "*" means any supported SSH command
                                         including scp
                                         including scp
                                          (default [md5sum,sha1sum,cd,pwd,scp])
                                          (default [md5sum,sha1sum,cd,pwd,scp])
+      --start-directory string          Alternate start directory.
+                                        This is a virtual path not a filesystem
+                                        path (default "/")
   -u, --username string                 Leave empty to use an auto generated
   -u, --username string                 Leave empty to use an auto generated
                                         value
                                         value
       --webdav-cert string              Path to the certificate file for WebDAV
       --webdav-cert string              Path to the certificate file for WebDAV

+ 62 - 0
ftpd/ftpd_test.go

@@ -597,6 +597,68 @@ func TestBasicFTPHandling(t *testing.T) {
 		50*time.Millisecond)
 		50*time.Millisecond)
 }
 }
 
 
+func TestStartDirectory(t *testing.T) {
+	startDir := "/start/dir"
+	u := getTestUser()
+	u.Filters.StartDirectory = startDir
+	localUser, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	u = getTestSFTPUser()
+	u.Filters.StartDirectory = startDir
+	sftpUser, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	for _, user := range []dataprovider.User{localUser, sftpUser} {
+		client, err := getFTPClient(user, true, nil)
+		if assert.NoError(t, err) {
+			currentDir, err := client.CurrentDir()
+			assert.NoError(t, err)
+			assert.Equal(t, startDir, currentDir)
+
+			testFilePath := filepath.Join(homeBasePath, testFileName)
+			testFileSize := int64(65535)
+			err = createTestFile(testFilePath, testFileSize)
+			assert.NoError(t, err)
+			err = ftpUploadFile(testFilePath, testFileName, testFileSize, client, 0)
+			assert.NoError(t, err)
+			localDownloadPath := filepath.Join(homeBasePath, testDLFileName)
+			err = ftpDownloadFile(testFileName, localDownloadPath, testFileSize, client, 0)
+			assert.NoError(t, err)
+			entries, err := client.List(".")
+			assert.NoError(t, err)
+			assert.Len(t, entries, 3)
+
+			entries, err = client.List("/")
+			assert.NoError(t, err)
+			assert.Len(t, entries, 2)
+
+			err = client.ChangeDirToParent()
+			assert.NoError(t, err)
+			currentDir, err = client.CurrentDir()
+			assert.NoError(t, err)
+			assert.Equal(t, path.Dir(startDir), currentDir)
+			err = client.ChangeDirToParent()
+			assert.NoError(t, err)
+			currentDir, err = client.CurrentDir()
+			assert.NoError(t, err)
+			assert.Equal(t, "/", currentDir)
+
+			err = os.Remove(testFilePath)
+			assert.NoError(t, err)
+			err = os.Remove(localDownloadPath)
+			assert.NoError(t, err)
+			err = client.Quit()
+			assert.NoError(t, err)
+		}
+	}
+
+	_, err = httpdtest.RemoveUser(sftpUser, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveUser(localUser, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(localUser.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestMultiFactorAuth(t *testing.T) {
 func TestMultiFactorAuth(t *testing.T) {
 	u := getTestUser()
 	u := getTestUser()
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)

+ 2 - 0
ftpd/internal_test.go

@@ -262,6 +262,8 @@ func (cc mockFTPClientContext) Path() string {
 	return ""
 	return ""
 }
 }
 
 
+func (cc mockFTPClientContext) SetPath(name string) {}
+
 func (cc mockFTPClientContext) SetDebug(debug bool) {}
 func (cc mockFTPClientContext) SetDebug(debug bool) {}
 
 
 func (cc mockFTPClientContext) Debug() bool {
 func (cc mockFTPClientContext) Debug() bool {

+ 9 - 0
ftpd/server.go

@@ -201,6 +201,7 @@ func (s *Server) AuthUser(cc ftpserver.ClientContext, username, password string)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
+	setStartDirectory(user.Filters.StartDirectory, cc)
 	connection.Log(logger.LevelInfo, "User %#v logged in with %#v from ip %#v", user.Username, loginMethod, ipAddr)
 	connection.Log(logger.LevelInfo, "User %#v logged in with %#v from ip %#v", user.Username, loginMethod, ipAddr)
 	dataprovider.UpdateLastLogin(&user)
 	dataprovider.UpdateLastLogin(&user)
 	return connection, nil
 	return connection, nil
@@ -246,6 +247,7 @@ func (s *Server) VerifyConnection(cc ftpserver.ClientContext, user string, tlsCo
 					if err != nil {
 					if err != nil {
 						return nil, err
 						return nil, err
 					}
 					}
+					setStartDirectory(dbUser.Filters.StartDirectory, cc)
 					connection.Log(logger.LevelInfo, "User id: %d, logged in with FTP using a TLS certificate, username: %#v, home_dir: %#v remote addr: %#v",
 					connection.Log(logger.LevelInfo, "User id: %d, logged in with FTP using a TLS certificate, username: %#v, home_dir: %#v remote addr: %#v",
 						dbUser.ID, dbUser.Username, dbUser.HomeDir, ipAddr)
 						dbUser.ID, dbUser.Username, dbUser.HomeDir, ipAddr)
 					dataprovider.UpdateLastLogin(&dbUser)
 					dataprovider.UpdateLastLogin(&dbUser)
@@ -367,6 +369,13 @@ func (s *Server) validateUser(user dataprovider.User, cc ftpserver.ClientContext
 	return connection, nil
 	return connection, nil
 }
 }
 
 
+func setStartDirectory(startDirectory string, cc ftpserver.ClientContext) {
+	if startDirectory == "" {
+		return
+	}
+	cc.SetPath(startDirectory)
+}
+
 func updateLoginMetrics(user *dataprovider.User, ip, loginMethod string, err error) {
 func updateLoginMetrics(user *dataprovider.User, ip, loginMethod string, err error) {
 	metric.AddLoginAttempt(loginMethod)
 	metric.AddLoginAttempt(loginMethod)
 	if err != nil && err != common.ErrInternalFailure {
 	if err != nil && err != common.ErrInternalFailure {

+ 9 - 9
go.mod

@@ -8,11 +8,11 @@ require (
 	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.3.0
 	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.3.0
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
 	github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
-	github.com/aws/aws-sdk-go v1.43.7
+	github.com/aws/aws-sdk-go v1.43.10
 	github.com/cockroachdb/cockroach-go/v2 v2.2.8
 	github.com/cockroachdb/cockroach-go/v2 v2.2.8
 	github.com/coreos/go-oidc/v3 v3.1.0
 	github.com/coreos/go-oidc/v3 v3.1.0
 	github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001
 	github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001
-	github.com/fclairamb/ftpserverlib v0.17.1-0.20220212161409-5157f18d716f
+	github.com/fclairamb/ftpserverlib v0.17.1-0.20220302132530-f366fc1586cb
 	github.com/fclairamb/go-log v0.2.0
 	github.com/fclairamb/go-log v0.2.0
 	github.com/go-chi/chi/v5 v5.0.8-0.20220103230436-7dbe9a0bd10f
 	github.com/go-chi/chi/v5 v5.0.8-0.20220103230436-7dbe9a0bd10f
 	github.com/go-chi/jwtauth/v5 v5.0.2
 	github.com/go-chi/jwtauth/v5 v5.0.2
@@ -27,22 +27,22 @@ require (
 	github.com/hashicorp/go-retryablehttp v0.7.0
 	github.com/hashicorp/go-retryablehttp v0.7.0
 	github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
 	github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
 	github.com/klauspost/compress v1.14.4
 	github.com/klauspost/compress v1.14.4
-	github.com/lestrrat-go/jwx v1.2.19
+	github.com/lestrrat-go/jwx v1.2.20
 	github.com/lib/pq v1.10.4
 	github.com/lib/pq v1.10.4
 	github.com/lithammer/shortuuid/v3 v3.0.7
 	github.com/lithammer/shortuuid/v3 v3.0.7
 	github.com/mattn/go-sqlite3 v1.14.12
 	github.com/mattn/go-sqlite3 v1.14.12
 	github.com/mhale/smtpd v0.8.0
 	github.com/mhale/smtpd v0.8.0
 	github.com/minio/sio v0.3.0
 	github.com/minio/sio v0.3.0
 	github.com/otiai10/copy v1.7.0
 	github.com/otiai10/copy v1.7.0
-	github.com/pires/go-proxyproto v0.6.1
-	github.com/pkg/sftp v1.13.5-0.20220119192800-7d25d533c9a3
+	github.com/pires/go-proxyproto v0.6.2
+	github.com/pkg/sftp v1.13.5-0.20220303113417-dcfc1d5e4162
 	github.com/pquerna/otp v1.3.0
 	github.com/pquerna/otp v1.3.0
 	github.com/prometheus/client_golang v1.12.1
 	github.com/prometheus/client_golang v1.12.1
 	github.com/rs/cors v1.8.2
 	github.com/rs/cors v1.8.2
 	github.com/rs/xid v1.3.0
 	github.com/rs/xid v1.3.0
 	github.com/rs/zerolog v1.26.2-0.20220227173336-263b0bde3672
 	github.com/rs/zerolog v1.26.2-0.20220227173336-263b0bde3672
-	github.com/sftpgo/sdk v0.1.1-0.20220228183957-d7251ba29961
-	github.com/shirou/gopsutil/v3 v3.22.1
+	github.com/sftpgo/sdk v0.1.1-0.20220303113613-e279f0a57712
+	github.com/shirou/gopsutil/v3 v3.22.2
 	github.com/spf13/afero v1.8.1
 	github.com/spf13/afero v1.8.1
 	github.com/spf13/cobra v1.3.0
 	github.com/spf13/cobra v1.3.0
 	github.com/spf13/viper v1.10.1
 	github.com/spf13/viper v1.10.1
@@ -67,7 +67,7 @@ require (
 require (
 require (
 	cloud.google.com/go v0.100.2 // indirect
 	cloud.google.com/go v0.100.2 // indirect
 	cloud.google.com/go/compute v1.5.0 // indirect
 	cloud.google.com/go/compute v1.5.0 // indirect
-	cloud.google.com/go/iam v0.2.0 // indirect
+	cloud.google.com/go/iam v0.3.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v0.9.1 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v0.9.1 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/boombuler/barcode v1.0.1 // indirect
 	github.com/boombuler/barcode v1.0.1 // indirect
@@ -130,7 +130,7 @@ require (
 	golang.org/x/tools v0.1.9 // indirect
 	golang.org/x/tools v0.1.9 // indirect
 	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
 	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20220228155957-1da8797a5878 // indirect
+	google.golang.org/genproto v0.0.0-20220302033224-9aa15565e42a // indirect
 	google.golang.org/grpc v1.44.0 // indirect
 	google.golang.org/grpc v1.44.0 // indirect
 	google.golang.org/protobuf v1.27.1 // indirect
 	google.golang.org/protobuf v1.27.1 // indirect
 	gopkg.in/ini.v1 v1.66.4 // indirect
 	gopkg.in/ini.v1 v1.66.4 // indirect

+ 18 - 18
go.sum

@@ -55,8 +55,8 @@ cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1
 cloud.google.com/go/firestore v1.5.0/go.mod h1:c4nNYR1qdq7eaZ+jSc5fonrQN2k3M7sWATcYTiakjEo=
 cloud.google.com/go/firestore v1.5.0/go.mod h1:c4nNYR1qdq7eaZ+jSc5fonrQN2k3M7sWATcYTiakjEo=
 cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
 cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
 cloud.google.com/go/iam v0.1.1/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9jVMw=
 cloud.google.com/go/iam v0.1.1/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9jVMw=
-cloud.google.com/go/iam v0.2.0 h1:Ouq6qif4mZdXkb3SiFMpxvu0JQJB1Yid9TsZ23N6hg8=
-cloud.google.com/go/iam v0.2.0/go.mod h1:BCK88+tmjAwnZYfOSizmKCTSFjJHCa18t3DpdGEY13Y=
+cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc=
+cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY=
 cloud.google.com/go/kms v0.1.0 h1:VXAb5OzejDcyhFzIDeZ5n5AUdlsFnCyexuascIwWMj0=
 cloud.google.com/go/kms v0.1.0 h1:VXAb5OzejDcyhFzIDeZ5n5AUdlsFnCyexuascIwWMj0=
 cloud.google.com/go/kms v0.1.0/go.mod h1:8Qp8PCAypHg4FdmlyW1QRAv09BGQ9Uzh7JnmIZxPk+c=
 cloud.google.com/go/kms v0.1.0/go.mod h1:8Qp8PCAypHg4FdmlyW1QRAv09BGQ9Uzh7JnmIZxPk+c=
 cloud.google.com/go/monitoring v0.1.0/go.mod h1:Hpm3XfzJv+UTiXzCG5Ffp0wijzHTC7Cv4eR7o3x/fEE=
 cloud.google.com/go/monitoring v0.1.0/go.mod h1:Hpm3XfzJv+UTiXzCG5Ffp0wijzHTC7Cv4eR7o3x/fEE=
@@ -144,8 +144,8 @@ github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgI
 github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0=
 github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0=
 github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
 github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
-github.com/aws/aws-sdk-go v1.43.7 h1:Gbs53KxXJWbO3txoVkevf56bhdDFqRisl7MQQ6581vc=
-github.com/aws/aws-sdk-go v1.43.7/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
+github.com/aws/aws-sdk-go v1.43.10 h1:lFX6gzTBltYBnlJBjd2DWRCmqn2CbTcs6PW99/Dme7k=
+github.com/aws/aws-sdk-go v1.43.10/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
 github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
 github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
 github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY=
 github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY=
 github.com/aws/aws-sdk-go-v2/credentials v1.4.0/go.mod h1:dgGR+Qq7Wjcd4AOAW5Rf5Tnv3+x7ed6kETXyS9WCuAY=
 github.com/aws/aws-sdk-go-v2/credentials v1.4.0/go.mod h1:dgGR+Qq7Wjcd4AOAW5Rf5Tnv3+x7ed6kETXyS9WCuAY=
@@ -245,8 +245,8 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv
 github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
 github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
 github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
 github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
 github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
 github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
-github.com/fclairamb/ftpserverlib v0.17.1-0.20220212161409-5157f18d716f h1:75ugogj/lKTVyDHTm0c5zgA16Fpfo/xiNpo8D/zn+TA=
-github.com/fclairamb/ftpserverlib v0.17.1-0.20220212161409-5157f18d716f/go.mod h1:1y0ShfZWIRcgU0mVJaCjEYIu2+g37cRHgDIT8jemeO0=
+github.com/fclairamb/ftpserverlib v0.17.1-0.20220302132530-f366fc1586cb h1:2gBRfMEhjADP8KN88nmq3Py8+vsXhdXyocfETy8gmaI=
+github.com/fclairamb/ftpserverlib v0.17.1-0.20220302132530-f366fc1586cb/go.mod h1:RpiJGed4zOypZ2uy2xnujfTQvveToG6VQRhap7ke4x4=
 github.com/fclairamb/go-log v0.2.0 h1:HzeOyomBVd0tEVLdIK0bBZr0j3xNip+zE1OqC1i5kbM=
 github.com/fclairamb/go-log v0.2.0 h1:HzeOyomBVd0tEVLdIK0bBZr0j3xNip+zE1OqC1i5kbM=
 github.com/fclairamb/go-log v0.2.0/go.mod h1:sd5oPNsxdVKRgWI8fVke99GXONszE3bsni2JxQMz8RU=
 github.com/fclairamb/go-log v0.2.0/go.mod h1:sd5oPNsxdVKRgWI8fVke99GXONszE3bsni2JxQMz8RU=
 github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
 github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
@@ -546,8 +546,8 @@ github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++
 github.com/lestrrat-go/iter v1.0.1 h1:q8faalr2dY6o8bV45uwrxq12bRa1ezKrB6oM9FUgN4A=
 github.com/lestrrat-go/iter v1.0.1 h1:q8faalr2dY6o8bV45uwrxq12bRa1ezKrB6oM9FUgN4A=
 github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc=
 github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc=
 github.com/lestrrat-go/jwx v1.2.6/go.mod h1:tJuGuAI3LC71IicTx82Mz1n3w9woAs2bYJZpkjJQ5aU=
 github.com/lestrrat-go/jwx v1.2.6/go.mod h1:tJuGuAI3LC71IicTx82Mz1n3w9woAs2bYJZpkjJQ5aU=
-github.com/lestrrat-go/jwx v1.2.19 h1:qxxLmAXNwZpTTvjc4PH21nT7I4wPK6lVv3lVNcZPnUk=
-github.com/lestrrat-go/jwx v1.2.19/go.mod h1:bWTBO7IHHVMtNunM8so9MT8wD+euEY1PzGEyCnuI2qM=
+github.com/lestrrat-go/jwx v1.2.20 h1:ckMNlG0MqCcVp7LnD5FN2+459ndm7SW3vryE79Dz9nk=
+github.com/lestrrat-go/jwx v1.2.20/go.mod h1:tLE1XszaFgd7zaS5wHe4NxA+XVhu7xgdRvDpNyi3kNM=
 github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4=
 github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4=
 github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
 github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
 github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@@ -636,16 +636,16 @@ github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FI
 github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
 github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
 github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM=
 github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM=
 github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
 github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
-github.com/pires/go-proxyproto v0.6.1 h1:EBupykFmo22SDjv4fQVQd2J9NOoLPmyZA/15ldOGkPw=
-github.com/pires/go-proxyproto v0.6.1/go.mod h1:Odh9VFOZJCf9G8cLW5o435Xf1J95Jw9Gw5rnCjcwzAY=
+github.com/pires/go-proxyproto v0.6.2 h1:KAZ7UteSOt6urjme6ZldyFm4wDe/z0ZUP0Yv0Dos0d8=
+github.com/pires/go-proxyproto v0.6.2/go.mod h1:Odh9VFOZJCf9G8cLW5o435Xf1J95Jw9Gw5rnCjcwzAY=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
 github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
 github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
 github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
-github.com/pkg/sftp v1.13.5-0.20220119192800-7d25d533c9a3 h1:gyvzmVdk4vso+w4gt8x2YtMdbAGSyX5KnekiEsbDLvQ=
-github.com/pkg/sftp v1.13.5-0.20220119192800-7d25d533c9a3/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg=
+github.com/pkg/sftp v1.13.5-0.20220303113417-dcfc1d5e4162 h1:uJSlAAzEUQq5tpfK+SWIIx/3UJ4EpjAYuMqZpKYrmw4=
+github.com/pkg/sftp v1.13.5-0.20220303113417-dcfc1d5e4162/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
@@ -700,10 +700,10 @@ github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdh
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
-github.com/sftpgo/sdk v0.1.1-0.20220228183957-d7251ba29961 h1:XpSoX58U9KR5qbexs3VUBZvgcRogjgbALWzQO4TIZKo=
-github.com/sftpgo/sdk v0.1.1-0.20220228183957-d7251ba29961/go.mod h1:zqCRMcwS28IViwekJHNkFu4GqSfyVmOQTlh8h3icAXE=
-github.com/shirou/gopsutil/v3 v3.22.1 h1:33y31Q8J32+KstqPfscvFwBlNJ6xLaBy4xqBXzlYV5w=
-github.com/shirou/gopsutil/v3 v3.22.1/go.mod h1:WapW1AOOPlHyXr+yOyw3uYx36enocrtSoSBy0L5vUHY=
+github.com/sftpgo/sdk v0.1.1-0.20220303113613-e279f0a57712 h1:+Rgx0SgsDnFSI5JBwL4mcCH2lkx3yKhLWcQnf0s2JKE=
+github.com/sftpgo/sdk v0.1.1-0.20220303113613-e279f0a57712/go.mod h1:zqCRMcwS28IViwekJHNkFu4GqSfyVmOQTlh8h3icAXE=
+github.com/shirou/gopsutil/v3 v3.22.2 h1:wCrArWFkHYIdDxx/FSfF5RB4dpJYW6t7rcp3+zL8uks=
+github.com/shirou/gopsutil/v3 v3.22.2/go.mod h1:WapW1AOOPlHyXr+yOyw3uYx36enocrtSoSBy0L5vUHY=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
@@ -1190,8 +1190,8 @@ google.golang.org/genproto v0.0.0-20220211171837-173942840c17/go.mod h1:kGP+zUP2
 google.golang.org/genproto v0.0.0-20220216160803-4663080d8bc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
 google.golang.org/genproto v0.0.0-20220216160803-4663080d8bc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
 google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
 google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
 google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
 google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
-google.golang.org/genproto v0.0.0-20220228155957-1da8797a5878 h1:gERY0VtsF9UyyyCsPSjRk9/RWlcKSa/Gw/aenR/5z48=
-google.golang.org/genproto v0.0.0-20220228155957-1da8797a5878/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
+google.golang.org/genproto v0.0.0-20220302033224-9aa15565e42a h1:uqouglH745GoGeZ1YFZbPBiu961tgi/9Qm5jaorajjQ=
+google.golang.org/genproto v0.0.0-20220302033224-9aa15565e42a/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
 google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=

+ 12 - 12
httpd/api_http_user.go

@@ -55,7 +55,7 @@ func readUserFolder(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 	defer common.Connections.Remove(connection.GetID())
 
 
-	name := util.CleanPath(r.URL.Query().Get("path"))
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	contents, err := connection.ReadDir(name)
 	contents, err := connection.ReadDir(name)
 	if err != nil {
 	if err != nil {
 		sendAPIResponse(w, r, err, "Unable to get directory contents", getMappedStatusCode(err))
 		sendAPIResponse(w, r, err, "Unable to get directory contents", getMappedStatusCode(err))
@@ -73,7 +73,7 @@ func createUserDir(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 	defer common.Connections.Remove(connection.GetID())
 
 
-	name := util.CleanPath(r.URL.Query().Get("path"))
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	if getBoolQueryParam(r, "mkdir_parents") {
 	if getBoolQueryParam(r, "mkdir_parents") {
 		if err = connection.CheckParentDirs(path.Dir(name)); err != nil {
 		if err = connection.CheckParentDirs(path.Dir(name)); err != nil {
 			sendAPIResponse(w, r, err, "Error checking parent directories", getMappedStatusCode(err))
 			sendAPIResponse(w, r, err, "Error checking parent directories", getMappedStatusCode(err))
@@ -97,8 +97,8 @@ func renameUserDir(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 	defer common.Connections.Remove(connection.GetID())
 
 
-	oldName := util.CleanPath(r.URL.Query().Get("path"))
-	newName := util.CleanPath(r.URL.Query().Get("target"))
+	oldName := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
+	newName := connection.User.GetCleanedPath(r.URL.Query().Get("target"))
 	err = connection.Rename(oldName, newName)
 	err = connection.Rename(oldName, newName)
 	if err != nil {
 	if err != nil {
 		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to rename directory %#v to %#v", oldName, newName),
 		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to rename directory %#v to %#v", oldName, newName),
@@ -117,7 +117,7 @@ func deleteUserDir(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 	defer common.Connections.Remove(connection.GetID())
 
 
-	name := util.CleanPath(r.URL.Query().Get("path"))
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	err = connection.RemoveDir(name)
 	err = connection.RemoveDir(name)
 	if err != nil {
 	if err != nil {
 		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to delete directory %#v", name), getMappedStatusCode(err))
 		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to delete directory %#v", name), getMappedStatusCode(err))
@@ -135,7 +135,7 @@ func getUserFile(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 	defer common.Connections.Remove(connection.GetID())
 
 
-	name := util.CleanPath(r.URL.Query().Get("path"))
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	if name == "/" {
 	if name == "/" {
 		sendAPIResponse(w, r, nil, "Please set the path to a valid file", http.StatusBadRequest)
 		sendAPIResponse(w, r, nil, "Please set the path to a valid file", http.StatusBadRequest)
 		return
 		return
@@ -186,7 +186,7 @@ func setFileDirMetadata(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 	defer common.Connections.Remove(connection.GetID())
 
 
-	name := util.CleanPath(r.URL.Query().Get("path"))
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	attrs := common.StatAttributes{
 	attrs := common.StatAttributes{
 		Flags: common.StatAttrTimes,
 		Flags: common.StatAttrTimes,
 		Atime: util.GetTimeFromMsecSinceEpoch(mTime),
 		Atime: util.GetTimeFromMsecSinceEpoch(mTime),
@@ -217,7 +217,7 @@ func uploadUserFile(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 	defer common.Connections.Remove(connection.GetID())
 
 
-	filePath := util.CleanPath(r.URL.Query().Get("path"))
+	filePath := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	if getBoolQueryParam(r, "mkdir_parents") {
 	if getBoolQueryParam(r, "mkdir_parents") {
 		if err = connection.CheckParentDirs(path.Dir(filePath)); err != nil {
 		if err = connection.CheckParentDirs(path.Dir(filePath)); err != nil {
 			sendAPIResponse(w, r, err, "Error checking parent directories", getMappedStatusCode(err))
 			sendAPIResponse(w, r, err, "Error checking parent directories", getMappedStatusCode(err))
@@ -279,7 +279,7 @@ func uploadUserFiles(w http.ResponseWriter, r *http.Request) {
 	connection.RemoveTransfer(t)
 	connection.RemoveTransfer(t)
 	defer r.MultipartForm.RemoveAll() //nolint:errcheck
 	defer r.MultipartForm.RemoveAll() //nolint:errcheck
 
 
-	parentDir := util.CleanPath(r.URL.Query().Get("path"))
+	parentDir := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	files := r.MultipartForm.File["filenames"]
 	files := r.MultipartForm.File["filenames"]
 	if len(files) == 0 {
 	if len(files) == 0 {
 		sendAPIResponse(w, r, nil, "No files uploaded!", http.StatusBadRequest)
 		sendAPIResponse(w, r, nil, "No files uploaded!", http.StatusBadRequest)
@@ -339,8 +339,8 @@ func renameUserFile(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 	defer common.Connections.Remove(connection.GetID())
 
 
-	oldName := util.CleanPath(r.URL.Query().Get("path"))
-	newName := util.CleanPath(r.URL.Query().Get("target"))
+	oldName := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
+	newName := connection.User.GetCleanedPath(r.URL.Query().Get("target"))
 	err = connection.Rename(oldName, newName)
 	err = connection.Rename(oldName, newName)
 	if err != nil {
 	if err != nil {
 		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to rename file %#v to %#v", oldName, newName),
 		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to rename file %#v to %#v", oldName, newName),
@@ -359,7 +359,7 @@ func deleteUserFile(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 	defer common.Connections.Remove(connection.GetID())
 
 
-	name := util.CleanPath(r.URL.Query().Get("path"))
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	fs, p, err := connection.GetFsAndResolvedPath(name)
 	fs, p, err := connection.GetFsAndResolvedPath(name)
 	if err != nil {
 	if err != nil {
 		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to delete file %#v", name), getMappedStatusCode(err))
 		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to delete file %#v", name), getMappedStatusCode(err))

+ 2 - 2
httpd/api_utils.go

@@ -217,7 +217,7 @@ func renderCompressedFiles(w http.ResponseWriter, conn *Connection, baseDir stri
 	wr := zip.NewWriter(w)
 	wr := zip.NewWriter(w)
 
 
 	for _, file := range files {
 	for _, file := range files {
-		fullPath := path.Join(baseDir, file)
+		fullPath := util.CleanPath(path.Join(baseDir, file))
 		if err := addZipEntry(wr, conn, fullPath, baseDir); err != nil {
 		if err := addZipEntry(wr, conn, fullPath, baseDir); err != nil {
 			if share != nil {
 			if share != nil {
 				dataprovider.UpdateShareLastUse(share, -1) //nolint:errcheck
 				dataprovider.UpdateShareLastUse(share, -1) //nolint:errcheck
@@ -252,7 +252,7 @@ func addZipEntry(wr *zip.Writer, conn *Connection, entryPath, baseDir string) er
 			return err
 			return err
 		}
 		}
 		for _, info := range contents {
 		for _, info := range contents {
-			fullPath := path.Join(entryPath, info.Name())
+			fullPath := util.CleanPath(path.Join(entryPath, info.Name()))
 			if err := addZipEntry(wr, conn, fullPath, baseDir); err != nil {
 			if err := addZipEntry(wr, conn, fullPath, baseDir); err != nil {
 				return err
 				return err
 			}
 			}

+ 0 - 3
httpd/handler.go

@@ -62,7 +62,6 @@ func (c *Connection) GetCommand() string {
 func (c *Connection) Stat(name string, mode int) (os.FileInfo, error) {
 func (c *Connection) Stat(name string, mode int) (os.FileInfo, error) {
 	c.UpdateLastActivity()
 	c.UpdateLastActivity()
 
 
-	name = util.CleanPath(name)
 	if !c.User.HasPerm(dataprovider.PermListItems, path.Dir(name)) {
 	if !c.User.HasPerm(dataprovider.PermListItems, path.Dir(name)) {
 		return nil, c.GetPermissionDeniedError()
 		return nil, c.GetPermissionDeniedError()
 	}
 	}
@@ -78,7 +77,6 @@ func (c *Connection) Stat(name string, mode int) (os.FileInfo, error) {
 func (c *Connection) ReadDir(name string) ([]os.FileInfo, error) {
 func (c *Connection) ReadDir(name string) ([]os.FileInfo, error) {
 	c.UpdateLastActivity()
 	c.UpdateLastActivity()
 
 
-	name = util.CleanPath(name)
 	return c.ListDir(name)
 	return c.ListDir(name)
 }
 }
 
 
@@ -91,7 +89,6 @@ func (c *Connection) getFileReader(name string, offset int64, method string) (io
 		return nil, c.GetReadQuotaExceededError()
 		return nil, c.GetReadQuotaExceededError()
 	}
 	}
 
 
-	name = util.CleanPath(name)
 	if !c.User.HasPerm(dataprovider.PermDownload, path.Dir(name)) {
 	if !c.User.HasPerm(dataprovider.PermDownload, path.Dir(name)) {
 		return nil, c.GetPermissionDeniedError()
 		return nil, c.GetPermissionDeniedError()
 	}
 	}

+ 142 - 0
httpd/httpd_test.go

@@ -11314,6 +11314,146 @@ func TestWebFilesAPI(t *testing.T) {
 	checkResponseCode(t, http.StatusNotFound, rr)
 	checkResponseCode(t, http.StatusNotFound, rr)
 }
 }
 
 
+func TestStartDirectory(t *testing.T) {
+	u := getTestUser()
+	u.Filters.StartDirectory = "/start/dir"
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	webAPIToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+	webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+
+	filename := "file1.txt"
+	body := new(bytes.Buffer)
+	writer := multipart.NewWriter(body)
+	part1, err := writer.CreateFormFile("filenames", filename)
+	assert.NoError(t, err)
+	_, err = part1.Write([]byte("test content"))
+	assert.NoError(t, err)
+	err = writer.Close()
+	assert.NoError(t, err)
+	reader := bytes.NewReader(body.Bytes())
+	req, err := http.NewRequest(http.MethodPost, userFilesPath, reader)
+	assert.NoError(t, err)
+	req.Header.Add("Content-Type", writer.FormDataContentType())
+	setBearerForReq(req, webAPIToken)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusCreated, rr)
+	// check we have 2 files in the defined start dir
+	req, err = http.NewRequest(http.MethodGet, userDirsPath, nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	var contents []map[string]interface{}
+	err = json.NewDecoder(rr.Body).Decode(&contents)
+	assert.NoError(t, err)
+	if assert.Len(t, contents, 1) {
+		assert.Equal(t, filename, contents[0]["name"].(string))
+	}
+	req, err = http.NewRequest(http.MethodPost, userUploadFilePath+"?path=file2.txt",
+		bytes.NewBuffer([]byte("single upload content")))
+	assert.NoError(t, err)
+	req.Header.Add("Content-Type", writer.FormDataContentType())
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusCreated, rr)
+
+	req, err = http.NewRequest(http.MethodPost, userDirsPath+"?path=testdir", nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusCreated, rr)
+
+	req, err = http.NewRequest(http.MethodPatch, userDirsPath+"?path=testdir&target=testdir1", nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	req, err = http.NewRequest(http.MethodPost, userDirsPath+"?path=%2Ftestdirroot", nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusCreated, rr)
+
+	req, err = http.NewRequest(http.MethodGet, userDirsPath+"?path="+url.QueryEscape(u.Filters.StartDirectory), nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	contents = nil
+	err = json.NewDecoder(rr.Body).Decode(&contents)
+	assert.NoError(t, err)
+	assert.Len(t, contents, 3)
+
+	req, err = http.NewRequest(http.MethodGet, userFilesPath+"?path="+filename, nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	req, err = http.NewRequest(http.MethodGet, userFilesPath+"?path=%2F"+filename, nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+
+	req, err = http.NewRequest(http.MethodPatch, userFilesPath+"?path="+filename+"&target="+filename+"_rename", nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	req, err = http.NewRequest(http.MethodDelete, userDirsPath+"?path=testdir1", nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	req, err = http.NewRequest(http.MethodGet, userDirsPath, nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	contents = nil
+	err = json.NewDecoder(rr.Body).Decode(&contents)
+	assert.NoError(t, err)
+	assert.Len(t, contents, 2)
+
+	req, err = http.NewRequest(http.MethodGet, webClientDirsPath, nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	contents = nil
+	err = json.NewDecoder(rr.Body).Decode(&contents)
+	assert.NoError(t, err)
+	assert.Len(t, contents, 2)
+
+	req, err = http.NewRequest(http.MethodDelete, userFilesPath+"?path="+filename+"_rename", nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	req, err = http.NewRequest(http.MethodGet, userDirsPath+"?path="+url.QueryEscape(u.Filters.StartDirectory), nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	contents = nil
+	err = json.NewDecoder(rr.Body).Decode(&contents)
+	assert.NoError(t, err)
+	assert.Len(t, contents, 1)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestWebFilesTransferQuotaLimits(t *testing.T) {
 func TestWebFilesTransferQuotaLimits(t *testing.T) {
 	u := getTestUser()
 	u := getTestUser()
 	u.UploadDataTransfer = 1
 	u.UploadDataTransfer = 1
@@ -13947,6 +14087,7 @@ func TestWebUserAddMock(t *testing.T) {
 	form.Set("disable_fs_checks", "checked")
 	form.Set("disable_fs_checks", "checked")
 	form.Set("total_data_transfer", "0")
 	form.Set("total_data_transfer", "0")
 	form.Set("external_auth_cache_time", "0")
 	form.Set("external_auth_cache_time", "0")
+	form.Set("start_directory", "start/dir")
 	b, contentType, _ := getMultipartFormData(form, "", "")
 	b, contentType, _ := getMultipartFormData(form, "", "")
 	// test invalid url escape
 	// test invalid url escape
 	req, _ = http.NewRequest(http.MethodPost, webUserPath+"?a=%2", &b)
 	req, _ = http.NewRequest(http.MethodPost, webUserPath+"?a=%2", &b)
@@ -14228,6 +14369,7 @@ func TestWebUserAddMock(t *testing.T) {
 	assert.True(t, newUser.Filters.DisableFsChecks)
 	assert.True(t, newUser.Filters.DisableFsChecks)
 	assert.False(t, newUser.Filters.AllowAPIKeyAuth)
 	assert.False(t, newUser.Filters.AllowAPIKeyAuth)
 	assert.Equal(t, user.Email, newUser.Email)
 	assert.Equal(t, user.Email, newUser.Email)
+	assert.Equal(t, "/start/dir", newUser.Filters.StartDirectory)
 	assert.True(t, util.IsStringInSlice(testPubKey, newUser.PublicKeys))
 	assert.True(t, util.IsStringInSlice(testPubKey, newUser.PublicKeys))
 	if val, ok := newUser.Permissions["/subdir"]; ok {
 	if val, ok := newUser.Permissions["/subdir"]; ok {
 		assert.True(t, util.IsStringInSlice(dataprovider.PermListItems, val))
 		assert.True(t, util.IsStringInSlice(dataprovider.PermListItems, val))

+ 1 - 0
httpd/webadmin.go

@@ -948,6 +948,7 @@ func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error)
 	filters.DisableFsChecks = len(r.Form.Get("disable_fs_checks")) > 0
 	filters.DisableFsChecks = len(r.Form.Get("disable_fs_checks")) > 0
 	filters.AllowAPIKeyAuth = len(r.Form.Get("allow_api_key_auth")) > 0
 	filters.AllowAPIKeyAuth = len(r.Form.Get("allow_api_key_auth")) > 0
 	filters.ExternalAuthCacheTime, err = strconv.ParseInt(r.Form.Get("external_auth_cache_time"), 10, 64)
 	filters.ExternalAuthCacheTime, err = strconv.ParseInt(r.Form.Get("external_auth_cache_time"), 10, 64)
+	filters.StartDirectory = r.Form.Get("start_directory")
 	return filters, err
 	return filters, err
 }
 }
 
 

+ 4 - 15
httpd/webclient.go

@@ -598,11 +598,7 @@ func handleWebClientDownloadZip(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 	defer common.Connections.Remove(connection.GetID())
 
 
-	name := "/"
-	if _, ok := r.URL.Query()["path"]; ok {
-		name = util.CleanPath(r.URL.Query().Get("path"))
-	}
-
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	files := r.URL.Query().Get("files")
 	files := r.URL.Query().Get("files")
 	var filesList []string
 	var filesList []string
 	err = json.Unmarshal([]byte(files), &filesList)
 	err = json.Unmarshal([]byte(files), &filesList)
@@ -742,11 +738,7 @@ func (s *httpdServer) handleClientGetDirContents(w http.ResponseWriter, r *http.
 	common.Connections.Add(connection)
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 	defer common.Connections.Remove(connection.GetID())
 
 
-	name := "/"
-	if _, ok := r.URL.Query()["path"]; ok {
-		name = util.CleanPath(r.URL.Query().Get("path"))
-	}
-
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	contents, err := connection.ReadDir(name)
 	contents, err := connection.ReadDir(name)
 	if err != nil {
 	if err != nil {
 		sendAPIResponse(w, r, err, "Unable to get directory contents", getMappedStatusCode(err))
 		sendAPIResponse(w, r, err, "Unable to get directory contents", getMappedStatusCode(err))
@@ -820,10 +812,7 @@ func (s *httpdServer) handleClientGetFiles(w http.ResponseWriter, r *http.Reques
 	common.Connections.Add(connection)
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 	defer common.Connections.Remove(connection.GetID())
 
 
-	name := "/"
-	if _, ok := r.URL.Query()["path"]; ok {
-		name = util.CleanPath(r.URL.Query().Get("path"))
-	}
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	var info os.FileInfo
 	var info os.FileInfo
 	if name == "/" {
 	if name == "/" {
 		info = vfs.NewFileInfo(name, true, 0, time.Now(), false)
 		info = vfs.NewFileInfo(name, true, 0, time.Now(), false)
@@ -880,7 +869,7 @@ func handleClientEditFile(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 	defer common.Connections.Remove(connection.GetID())
 
 
-	name := util.CleanPath(r.URL.Query().Get("path"))
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	info, err := connection.Stat(name, 0)
 	info, err := connection.Stat(name, 0)
 	if err != nil {
 	if err != nil {
 		renderClientMessagePage(w, r, fmt.Sprintf("Unable to stat file %#v", name), "",
 		renderClientMessagePage(w, r, fmt.Sprintf("Unable to stat file %#v", name), "",

+ 7 - 0
httpdtest/httpdtest.go

@@ -1484,6 +1484,10 @@ func compareUserFilterSubStructs(expected *dataprovider.User, actual *dataprovid
 			return errors.New("web client options contents mismatch")
 			return errors.New("web client options contents mismatch")
 		}
 		}
 	}
 	}
+	return compareUserFiltersEqualFields(expected, actual)
+}
+
+func compareUserFiltersEqualFields(expected *dataprovider.User, actual *dataprovider.User) error {
 	if expected.Filters.Hooks.ExternalAuthDisabled != actual.Filters.Hooks.ExternalAuthDisabled {
 	if expected.Filters.Hooks.ExternalAuthDisabled != actual.Filters.Hooks.ExternalAuthDisabled {
 		return errors.New("external_auth_disabled hook mismatch")
 		return errors.New("external_auth_disabled hook mismatch")
 	}
 	}
@@ -1496,6 +1500,9 @@ func compareUserFilterSubStructs(expected *dataprovider.User, actual *dataprovid
 	if expected.Filters.DisableFsChecks != actual.Filters.DisableFsChecks {
 	if expected.Filters.DisableFsChecks != actual.Filters.DisableFsChecks {
 		return errors.New("disable_fs_checks mismatch")
 		return errors.New("disable_fs_checks mismatch")
 	}
 	}
+	if expected.Filters.StartDirectory != actual.Filters.StartDirectory {
+		return errors.New("start_directory mismatch")
+	}
 	return nil
 	return nil
 }
 }
 
 

+ 6 - 3
openapi/openapi.yaml

@@ -211,7 +211,7 @@ paths:
       parameters:
       parameters:
         - in: query
         - in: query
           name: path
           name: path
-          description: Path to the folder to read. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir". If empty or missing the root folder is assumed
+          description: Path to the folder to read. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir". If empty or missing the user's start directory is assumed. If relative, the user's start directory is used as the base
           schema:
           schema:
             type: string
             type: string
       responses:
       responses:
@@ -3632,7 +3632,7 @@ paths:
       parameters:
       parameters:
         - in: query
         - in: query
           name: path
           name: path
-          description: Path to the folder to read. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir". If empty or missing the root folder is assumed
+          description: Path to the folder to read. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir". If empty or missing the user's start directory is assumed. If relative, the user's start directory is used as the base
           schema:
           schema:
             type: string
             type: string
       responses:
       responses:
@@ -3664,7 +3664,7 @@ paths:
       parameters:
       parameters:
         - in: query
         - in: query
           name: path
           name: path
-          description: Path to the folder to read. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir". If empty or missing the root folder is assumed
+          description: Path to the folder to read. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir". If empty or missing the user's start directory is assumed. If relative, the user's start directory is used as the base
           schema:
           schema:
             type: string
             type: string
       responses:
       responses:
@@ -4679,6 +4679,9 @@ components:
         external_auth_cache_time:
         external_auth_cache_time:
           type: integer
           type: integer
           description: 'Defines the cache time, in seconds, for users authenticated using an external auth hook. 0 means no cache'
           description: 'Defines the cache time, in seconds, for users authenticated using an external auth hook. 0 means no cache'
+        start_directory:
+          type: string
+          description: 'Specifies an alternate starting directory. If not set, the default is "/". This option is supported for SFTP/SCP, FTP and HTTP (WebClient/REST API) protocols. Relative paths will use this directory as base.'
       description: Additional user options
       description: Additional user options
     Secret:
     Secret:
       type: object
       type: object

+ 2 - 1
sftpd/internal_test.go

@@ -404,7 +404,8 @@ func TestSSHCommandPath(t *testing.T) {
 		ReadError:    nil,
 		ReadError:    nil,
 	}
 	}
 	connection := &Connection{
 	connection := &Connection{
-		channel: &mockSSHChannel,
+		channel:        &mockSSHChannel,
+		BaseConnection: common.NewBaseConnection("", common.ProtocolSSH, "", "", dataprovider.User{}),
 	}
 	}
 	sshCommand := sshCommand{
 	sshCommand := sshCommand{
 		command:    "test",
 		command:    "test",

+ 2 - 1
sftpd/server.go

@@ -553,7 +553,8 @@ func (c *Configuration) handleSftpConnection(channel ssh.Channel, connection *Co
 	defer common.Connections.Remove(connection.GetID())
 	defer common.Connections.Remove(connection.GetID())
 
 
 	// Create the server instance for the channel using the handler we created above.
 	// Create the server instance for the channel using the handler we created above.
-	server := sftp.NewRequestServer(channel, c.createHandlers(connection), sftp.WithRSAllocator())
+	server := sftp.NewRequestServer(channel, c.createHandlers(connection), sftp.WithRSAllocator(),
+		sftp.WithStartDirectory(connection.User.Filters.StartDirectory))
 
 
 	defer server.Close()
 	defer server.Close()
 	if err := server.Serve(); err == io.EOF {
 	if err := server.Serve(); err == io.EOF {

+ 95 - 0
sftpd/sftpd_test.go

@@ -538,6 +538,68 @@ func TestBasicSFTPFsHandling(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
 
 
+func TestStartDirectory(t *testing.T) {
+	usePubKey := false
+	startDir := "/st@ rt/dir"
+	u := getTestUser(usePubKey)
+	u.Filters.StartDirectory = startDir
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	conn, client, err := getSftpClient(user, usePubKey)
+	if assert.NoError(t, err) {
+		defer conn.Close()
+		defer client.Close()
+
+		currentDir, err := client.Getwd()
+		assert.NoError(t, err)
+		assert.Equal(t, startDir, currentDir)
+
+		entries, err := client.ReadDir(".")
+		assert.NoError(t, err)
+		assert.Len(t, entries, 0)
+
+		testFilePath := filepath.Join(homeBasePath, testFileName)
+		testFileSize := int64(65535)
+		err = createTestFile(testFilePath, testFileSize)
+		assert.NoError(t, err)
+		err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
+		assert.NoError(t, err)
+		localDownloadPath := filepath.Join(homeBasePath, testDLFileName)
+		err = sftpDownloadFile(testFileName, localDownloadPath, testFileSize, client)
+		assert.NoError(t, err)
+		_, err = client.Stat(testFileName)
+		assert.NoError(t, err)
+		err = client.Rename(testFileName, testFileName+"_rename")
+		assert.NoError(t, err)
+
+		entries, err = client.ReadDir(".")
+		assert.NoError(t, err)
+		assert.Len(t, entries, 1)
+
+		currentDir, err = client.RealPath("..")
+		assert.NoError(t, err)
+		assert.Equal(t, path.Dir(startDir), currentDir)
+
+		currentDir, err = client.RealPath("../..")
+		assert.NoError(t, err)
+		assert.Equal(t, "/", currentDir)
+
+		currentDir, err = client.RealPath("../../..")
+		assert.NoError(t, err)
+		assert.Equal(t, "/", currentDir)
+
+		err = os.Remove(testFilePath)
+		assert.NoError(t, err)
+		err = os.Remove(localDownloadPath)
+		assert.NoError(t, err)
+	}
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestFolderPrefix(t *testing.T) {
 func TestFolderPrefix(t *testing.T) {
 	usePubKey := true
 	usePubKey := true
 	u := getTestUser(usePubKey)
 	u := getTestUser(usePubKey)
@@ -9184,6 +9246,39 @@ func TestSCPRecursive(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
 
 
+func TestSCPStartDirectory(t *testing.T) {
+	usePubKey := true
+	startDir := "/sta rt/dir"
+	u := getTestUser(usePubKey)
+	u.Filters.StartDirectory = startDir
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+
+	testFileSize := int64(131072)
+	testFilePath := filepath.Join(homeBasePath, testFileName)
+	localPath := filepath.Join(homeBasePath, "scp_download.dat")
+	remoteUpPath := fmt.Sprintf("%v@127.0.0.1:", user.Username)
+	remoteDownPath := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, testFileName)
+	err = createTestFile(testFilePath, testFileSize)
+	assert.NoError(t, err)
+	err = scpUpload(testFilePath, remoteUpPath, false, false)
+	assert.NoError(t, err)
+	err = scpDownload(localPath, remoteDownPath, false, false)
+	assert.NoError(t, err)
+	// check that the file is in the start directory
+	_, err = os.Stat(filepath.Join(user.HomeDir, startDir, testFileName))
+	assert.NoError(t, err)
+
+	err = os.Remove(testFilePath)
+	assert.NoError(t, err)
+	err = os.Remove(localPath)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestSCPPatternsFilter(t *testing.T) {
 func TestSCPPatternsFilter(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")

+ 4 - 4
sftpd/ssh_cmd.go

@@ -531,7 +531,7 @@ func (c *sshCommand) getDestPath() string {
 	if len(c.args) == 0 {
 	if len(c.args) == 0 {
 		return ""
 		return ""
 	}
 	}
-	return cleanCommandPath(c.args[len(c.args)-1])
+	return c.cleanCommandPath(c.args[len(c.args)-1])
 }
 }
 
 
 // for the supported commands, the destination path, if any, is the second-last argument
 // for the supported commands, the destination path, if any, is the second-last argument
@@ -539,13 +539,13 @@ func (c *sshCommand) getSourcePath() string {
 	if len(c.args) < 2 {
 	if len(c.args) < 2 {
 		return ""
 		return ""
 	}
 	}
-	return cleanCommandPath(c.args[len(c.args)-2])
+	return c.cleanCommandPath(c.args[len(c.args)-2])
 }
 }
 
 
-func cleanCommandPath(name string) string {
+func (c *sshCommand) cleanCommandPath(name string) string {
 	name = strings.Trim(name, "'")
 	name = strings.Trim(name, "'")
 	name = strings.Trim(name, "\"")
 	name = strings.Trim(name, "\"")
-	result := util.CleanPath(name)
+	result := c.connection.User.GetCleanedPath(name)
 	if strings.HasSuffix(name, "/") && !strings.HasSuffix(result, "/") {
 	if strings.HasSuffix(name, "/") && !strings.HasSuffix(result, "/") {
 		result += "/"
 		result += "/"
 	}
 	}

+ 11 - 0
templates/webadmin/user.html

@@ -821,6 +821,17 @@
                     <div id="collapseAdvanced" class="collapse" aria-labelledby="headingTwo" data-parent="#accordionUser">
                     <div id="collapseAdvanced" class="collapse" aria-labelledby="headingTwo" data-parent="#accordionUser">
                         <div class="card-body">
                         <div class="card-body">
 
 
+                            <div class="form-group row">
+                                <label for="idStartDirectory" class="col-sm-2 col-form-label">Start directory</label>
+                                <div class="col-sm-10">
+                                    <input type="text" class="form-control" id="idStartDirectory" name="start_directory" placeholder=""
+                                        value="{{.User.Filters.StartDirectory}}" aria-describedby="startDirHelpBlock">
+                                    <small id="startDirHelpBlock" class="form-text text-muted">
+                                        Alternate start directory to use instead of "/". Supported for SFTP/FTP/HTTP
+                                    </small>
+                                </div>
+                            </div>
+
                             <div class="form-group row">
                             <div class="form-group row">
                                 <label for="idTLSUsername" class="col-sm-2 col-form-label">TLS username</label>
                                 <label for="idTLSUsername" class="col-sm-2 col-form-label">TLS username</label>
                                 <div class="col-sm-10">
                                 <div class="col-sm-10">

+ 1 - 1
vfs/vfs.go

@@ -83,7 +83,7 @@ type Fs interface {
 	IsUploadResumeSupported() bool
 	IsUploadResumeSupported() bool
 	IsAtomicUploadSupported() bool
 	IsAtomicUploadSupported() bool
 	CheckRootPath(username string, uid int, gid int) bool
 	CheckRootPath(username string, uid int, gid int) bool
-	ResolvePath(sftpPath string) (string, error)
+	ResolvePath(virtualPath string) (string, error)
 	IsNotExist(err error) bool
 	IsNotExist(err error) bool
 	IsPermission(err error) bool
 	IsPermission(err error) bool
 	IsNotSupported(err error) bool
 	IsNotSupported(err error) bool