Browse Source

S3: add support for serving virtual folders

inside the same bucket each user can be assigned to a virtual folder.
This is similar to a chroot directory for local filesystem
Nicola Murino 5 years ago
parent
commit
4463421028

+ 9 - 2
README.md

@@ -419,8 +419,13 @@ The HTTP request has a 15 seconds timeout.
 
 
 ## S3 Compabible Object Storage backends
 ## S3 Compabible Object Storage backends
 
 
-Each user can be mapped with an S3-Compatible bucket, this way the mapped bucket is exposed over SFTP/SCP.
-SFTPGo uses multipart uploads and parallel downloads for storing and retrieving files from S3 and automatically try to create the mapped bucket if it does not exists.
+Each user can be mapped with an S3-Compatible bucket or a bucket virtual folder, this way the mapped bucket/virtual folder is exposed over SFTP/SCP.
+
+Specifying a different `key_prefix` you can assign different virtual folders of the same bucket to different users. This is similar to a chroot directory for local filesystem. The virtual folder identified by `key_prefix` does not need to be pre-created.
+
+SFTPGo uses multipart uploads and parallel downloads for storing and retrieving files from S3.
+
+SFTPGo tries to automatically create the mapped bucket if it does not exists but it's a better idea to pre-create the bucket and to assign to it the wanted options such as automatic encryption and authorizations.
 
 
 Some SFTP commands doesn't work over S3:
 Some SFTP commands doesn't work over S3:
 
 
@@ -465,6 +470,7 @@ Flags:
       --s3-access-secret string
       --s3-access-secret string
       --s3-bucket string
       --s3-bucket string
       --s3-endpoint string
       --s3-endpoint string
+      --s3-key-prefix string      Allows to restrict access to the virtual folder identified by this prefix and its contents
       --s3-region string
       --s3-region string
       --s3-storage-class string
       --s3-storage-class string
   -s, --sftpd-port int            0 means a random non privileged port
   -s, --sftpd-port int            0 means a random non privileged port
@@ -522,6 +528,7 @@ For each account the following properties can be configured:
 - `s3_access_secret`, required for S3 filesystem. It is stored encrypted (AES-256-GCM)
 - `s3_access_secret`, required for S3 filesystem. It is stored encrypted (AES-256-GCM)
 - `s3_endpoint`, specifies s3 endpoint (server) different from AWS
 - `s3_endpoint`, specifies s3 endpoint (server) different from AWS
 - `s3_storage_class`
 - `s3_storage_class`
+- `s3_key_prefix`, allows to restrict access to the virtual folder identified by this prefix and its contents
 
 
 These properties are stored inside the data provider.
 These properties are stored inside the data provider.
 
 

+ 4 - 0
cmd/portable.go

@@ -28,6 +28,7 @@ var (
 	portableS3AccessSecret       string
 	portableS3AccessSecret       string
 	portableS3Endpoint           string
 	portableS3Endpoint           string
 	portableS3StorageClass       string
 	portableS3StorageClass       string
+	portableS3KeyPrefix          string
 	portableCmd                  = &cobra.Command{
 	portableCmd                  = &cobra.Command{
 		Use:   "portable",
 		Use:   "portable",
 		Short: "Serve a single directory",
 		Short: "Serve a single directory",
@@ -70,6 +71,7 @@ Please take a look at the usage below to customize the serving parameters`,
 							AccessSecret: portableS3AccessSecret,
 							AccessSecret: portableS3AccessSecret,
 							Endpoint:     portableS3Endpoint,
 							Endpoint:     portableS3Endpoint,
 							StorageClass: portableS3StorageClass,
 							StorageClass: portableS3StorageClass,
+							KeyPrefix:    portableS3KeyPrefix,
 						},
 						},
 					},
 					},
 				},
 				},
@@ -105,5 +107,7 @@ func init() {
 	portableCmd.Flags().StringVar(&portableS3AccessSecret, "s3-access-secret", "", "")
 	portableCmd.Flags().StringVar(&portableS3AccessSecret, "s3-access-secret", "", "")
 	portableCmd.Flags().StringVar(&portableS3Endpoint, "s3-endpoint", "", "")
 	portableCmd.Flags().StringVar(&portableS3Endpoint, "s3-endpoint", "", "")
 	portableCmd.Flags().StringVar(&portableS3StorageClass, "s3-storage-class", "", "")
 	portableCmd.Flags().StringVar(&portableS3StorageClass, "s3-storage-class", "", "")
+	portableCmd.Flags().StringVar(&portableS3KeyPrefix, "s3-key-prefix", "", "Allows to restrict access to the virtual folder "+
+		"identified by this prefix and its contents")
 	rootCmd.AddCommand(portableCmd)
 	rootCmd.AddCommand(portableCmd)
 }
 }

+ 12 - 12
dataprovider/dataprovider.go

@@ -413,19 +413,19 @@ func buildUserHomeDir(user *User) {
 
 
 func validatePermissions(user *User) error {
 func validatePermissions(user *User) error {
 	if len(user.Permissions) == 0 {
 	if len(user.Permissions) == 0 {
-		return &ValidationError{err: "Please grant some permissions to this user"}
+		return &ValidationError{err: "please grant some permissions to this user"}
 	}
 	}
 	permissions := make(map[string][]string)
 	permissions := make(map[string][]string)
 	if _, ok := user.Permissions["/"]; !ok {
 	if _, ok := user.Permissions["/"]; !ok {
-		return &ValidationError{err: fmt.Sprintf("Permissions for the root dir \"/\" must be set")}
+		return &ValidationError{err: fmt.Sprintf("permissions for the root dir \"/\" must be set")}
 	}
 	}
 	for dir, perms := range user.Permissions {
 	for dir, perms := range user.Permissions {
 		if len(perms) == 0 {
 		if len(perms) == 0 {
-			return &ValidationError{err: fmt.Sprintf("No permissions granted for the directory: %#v", dir)}
+			return &ValidationError{err: fmt.Sprintf("no permissions granted for the directory: %#v", dir)}
 		}
 		}
 		for _, p := range perms {
 		for _, p := range perms {
 			if !utils.IsStringInSlice(p, ValidPerms) {
 			if !utils.IsStringInSlice(p, ValidPerms) {
-				return &ValidationError{err: fmt.Sprintf("Invalid permission: %#v", p)}
+				return &ValidationError{err: fmt.Sprintf("invalid permission: %#v", p)}
 			}
 			}
 		}
 		}
 		cleanedDir := filepath.ToSlash(path.Clean(dir))
 		cleanedDir := filepath.ToSlash(path.Clean(dir))
@@ -433,7 +433,7 @@ func validatePermissions(user *User) error {
 			cleanedDir = strings.TrimSuffix(cleanedDir, "/")
 			cleanedDir = strings.TrimSuffix(cleanedDir, "/")
 		}
 		}
 		if !path.IsAbs(cleanedDir) {
 		if !path.IsAbs(cleanedDir) {
-			return &ValidationError{err: fmt.Sprintf("Cannot set permissions for non absolute path: %#v", dir)}
+			return &ValidationError{err: fmt.Sprintf("cannot set permissions for non absolute path: %#v", dir)}
 		}
 		}
 		if utils.IsStringInSlice(PermAny, perms) {
 		if utils.IsStringInSlice(PermAny, perms) {
 			permissions[cleanedDir] = []string{PermAny}
 			permissions[cleanedDir] = []string{PermAny}
@@ -452,7 +452,7 @@ func validatePublicKeys(user *User) error {
 	for i, k := range user.PublicKeys {
 	for i, k := range user.PublicKeys {
 		_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k))
 		_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k))
 		if err != nil {
 		if err != nil {
-			return &ValidationError{err: fmt.Sprintf("Could not parse key nr. %d: %s", i, err)}
+			return &ValidationError{err: fmt.Sprintf("could not parse key nr. %d: %s", i, err)}
 		}
 		}
 	}
 	}
 	return nil
 	return nil
@@ -468,13 +468,13 @@ func validateFilters(user *User) error {
 	for _, IPMask := range user.Filters.DeniedIP {
 	for _, IPMask := range user.Filters.DeniedIP {
 		_, _, err := net.ParseCIDR(IPMask)
 		_, _, err := net.ParseCIDR(IPMask)
 		if err != nil {
 		if err != nil {
-			return &ValidationError{err: fmt.Sprintf("Could not parse denied IP/Mask %#v : %v", IPMask, err)}
+			return &ValidationError{err: fmt.Sprintf("could not parse denied IP/Mask %#v : %v", IPMask, err)}
 		}
 		}
 	}
 	}
 	for _, IPMask := range user.Filters.AllowedIP {
 	for _, IPMask := range user.Filters.AllowedIP {
 		_, _, err := net.ParseCIDR(IPMask)
 		_, _, err := net.ParseCIDR(IPMask)
 		if err != nil {
 		if err != nil {
-			return &ValidationError{err: fmt.Sprintf("Could not parse allowed IP/Mask %#v : %v", IPMask, err)}
+			return &ValidationError{err: fmt.Sprintf("could not parse allowed IP/Mask %#v : %v", IPMask, err)}
 		}
 		}
 	}
 	}
 	return nil
 	return nil
@@ -484,13 +484,13 @@ func validateFilesystemConfig(user *User) error {
 	if user.FsConfig.Provider == 1 {
 	if user.FsConfig.Provider == 1 {
 		err := vfs.ValidateS3FsConfig(&user.FsConfig.S3Config)
 		err := vfs.ValidateS3FsConfig(&user.FsConfig.S3Config)
 		if err != nil {
 		if err != nil {
-			return &ValidationError{err: fmt.Sprintf("Could not validate s3config: %v", err)}
+			return &ValidationError{err: fmt.Sprintf("could not validate s3config: %v", err)}
 		}
 		}
 		vals := strings.Split(user.FsConfig.S3Config.AccessSecret, "$")
 		vals := strings.Split(user.FsConfig.S3Config.AccessSecret, "$")
 		if !strings.HasPrefix(user.FsConfig.S3Config.AccessSecret, "$aes$") || len(vals) != 4 {
 		if !strings.HasPrefix(user.FsConfig.S3Config.AccessSecret, "$aes$") || len(vals) != 4 {
 			accessSecret, err := utils.EncryptData(user.FsConfig.S3Config.AccessSecret)
 			accessSecret, err := utils.EncryptData(user.FsConfig.S3Config.AccessSecret)
 			if err != nil {
 			if err != nil {
-				return &ValidationError{err: fmt.Sprintf("Could encrypt s3 access secret: %v", err)}
+				return &ValidationError{err: fmt.Sprintf("could not encrypt s3 access secret: %v", err)}
 			}
 			}
 			user.FsConfig.S3Config.AccessSecret = accessSecret
 			user.FsConfig.S3Config.AccessSecret = accessSecret
 		}
 		}
@@ -504,10 +504,10 @@ func validateFilesystemConfig(user *User) error {
 func validateUser(user *User) error {
 func validateUser(user *User) error {
 	buildUserHomeDir(user)
 	buildUserHomeDir(user)
 	if len(user.Username) == 0 || len(user.HomeDir) == 0 {
 	if len(user.Username) == 0 || len(user.HomeDir) == 0 {
-		return &ValidationError{err: "Mandatory parameters missing"}
+		return &ValidationError{err: "mandatory parameters missing"}
 	}
 	}
 	if len(user.Password) == 0 && len(user.PublicKeys) == 0 {
 	if len(user.Password) == 0 && len(user.PublicKeys) == 0 {
-		return &ValidationError{err: "Please set a password or at least a public_key"}
+		return &ValidationError{err: "please set a password or at least a public_key"}
 	}
 	}
 	if !filepath.IsAbs(user.HomeDir) {
 	if !filepath.IsAbs(user.HomeDir) {
 		return &ValidationError{err: fmt.Sprintf("home_dir must be an absolute path, actual value: %v", user.HomeDir)}
 		return &ValidationError{err: fmt.Sprintf("home_dir must be an absolute path, actual value: %v", user.HomeDir)}

+ 1 - 0
dataprovider/user.go

@@ -408,6 +408,7 @@ func (u *User) getACopy() User {
 			AccessSecret: u.FsConfig.S3Config.AccessSecret,
 			AccessSecret: u.FsConfig.S3Config.AccessSecret,
 			Endpoint:     u.FsConfig.S3Config.Endpoint,
 			Endpoint:     u.FsConfig.S3Config.Endpoint,
 			StorageClass: u.FsConfig.S3Config.StorageClass,
 			StorageClass: u.FsConfig.S3Config.StorageClass,
+			KeyPrefix:    u.FsConfig.S3Config.KeyPrefix,
 		},
 		},
 	}
 	}
 
 

+ 4 - 0
httpd/api_utils.go

@@ -435,6 +435,10 @@ func compareUserFsConfig(expected *dataprovider.User, actual *dataprovider.User)
 	if expected.FsConfig.S3Config.StorageClass != actual.FsConfig.S3Config.StorageClass {
 	if expected.FsConfig.S3Config.StorageClass != actual.FsConfig.S3Config.StorageClass {
 		return errors.New("S3 storage class mismatch")
 		return errors.New("S3 storage class mismatch")
 	}
 	}
+	if expected.FsConfig.S3Config.KeyPrefix != actual.FsConfig.S3Config.KeyPrefix &&
+		expected.FsConfig.S3Config.KeyPrefix+"/" != actual.FsConfig.S3Config.KeyPrefix {
+		return errors.New("S3 key prefix mismatch")
+	}
 	return nil
 	return nil
 }
 }
 
 

+ 17 - 0
httpd/httpd_test.go

@@ -250,6 +250,17 @@ func TestAddUserInvalidFsConfig(t *testing.T) {
 	if err != nil {
 	if err != nil {
 		t.Errorf("unexpected error adding user with invalid fs config: %v", err)
 		t.Errorf("unexpected error adding user with invalid fs config: %v", err)
 	}
 	}
+	u.FsConfig.S3Config.Bucket = "test"
+	u.FsConfig.S3Config.Region = "eu-west-1"
+	u.FsConfig.S3Config.AccessKey = "access-key"
+	u.FsConfig.S3Config.AccessSecret = "access-secret"
+	u.FsConfig.S3Config.Endpoint = "http://127.0.0.1:9000/path?a=b"
+	u.FsConfig.S3Config.StorageClass = "Standard"
+	u.FsConfig.S3Config.KeyPrefix = "/somedir/subdir/"
+	_, _, err = httpd.AddUser(u, http.StatusBadRequest)
+	if err != nil {
+		t.Errorf("unexpected error adding user with invalid fs config: %v", err)
+	}
 }
 }
 
 
 func TestUserPublicKey(t *testing.T) {
 func TestUserPublicKey(t *testing.T) {
@@ -341,6 +352,7 @@ func TestUserS3Config(t *testing.T) {
 	user.FsConfig.S3Config.Region = "us-east-1"
 	user.FsConfig.S3Config.Region = "us-east-1"
 	user.FsConfig.S3Config.AccessKey = "Server-Access-Key1"
 	user.FsConfig.S3Config.AccessKey = "Server-Access-Key1"
 	user.FsConfig.S3Config.Endpoint = "http://localhost:9000"
 	user.FsConfig.S3Config.Endpoint = "http://localhost:9000"
+	user.FsConfig.S3Config.KeyPrefix = "somedir/subdir"
 	user, _, err = httpd.UpdateUser(user, http.StatusOK)
 	user, _, err = httpd.UpdateUser(user, http.StatusOK)
 	if err != nil {
 	if err != nil {
 		t.Errorf("unable to update user: %v", err)
 		t.Errorf("unable to update user: %v", err)
@@ -1467,6 +1479,7 @@ func TestWebUserS3Mock(t *testing.T) {
 	user.FsConfig.S3Config.AccessSecret = "access-secret"
 	user.FsConfig.S3Config.AccessSecret = "access-secret"
 	user.FsConfig.S3Config.Endpoint = "http://127.0.0.1:9000/path?a=b"
 	user.FsConfig.S3Config.Endpoint = "http://127.0.0.1:9000/path?a=b"
 	user.FsConfig.S3Config.StorageClass = "Standard"
 	user.FsConfig.S3Config.StorageClass = "Standard"
+	user.FsConfig.S3Config.KeyPrefix = "somedir/subdir/"
 	form := make(url.Values)
 	form := make(url.Values)
 	form.Set("username", user.Username)
 	form.Set("username", user.Username)
 	form.Set("home_dir", user.HomeDir)
 	form.Set("home_dir", user.HomeDir)
@@ -1490,6 +1503,7 @@ func TestWebUserS3Mock(t *testing.T) {
 	form.Set("s3_access_secret", user.FsConfig.S3Config.AccessSecret)
 	form.Set("s3_access_secret", user.FsConfig.S3Config.AccessSecret)
 	form.Set("s3_storage_class", user.FsConfig.S3Config.StorageClass)
 	form.Set("s3_storage_class", user.FsConfig.S3Config.StorageClass)
 	form.Set("s3_endpoint", user.FsConfig.S3Config.Endpoint)
 	form.Set("s3_endpoint", user.FsConfig.S3Config.Endpoint)
+	form.Set("s3_key_prefix", user.FsConfig.S3Config.KeyPrefix)
 	req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), strings.NewReader(form.Encode()))
 	req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), strings.NewReader(form.Encode()))
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	rr = executeRequest(req)
 	rr = executeRequest(req)
@@ -1530,6 +1544,9 @@ func TestWebUserS3Mock(t *testing.T) {
 	if updateUser.FsConfig.S3Config.Endpoint != user.FsConfig.S3Config.Endpoint {
 	if updateUser.FsConfig.S3Config.Endpoint != user.FsConfig.S3Config.Endpoint {
 		t.Error("s3 endpoint mismatch")
 		t.Error("s3 endpoint mismatch")
 	}
 	}
+	if updateUser.FsConfig.S3Config.KeyPrefix != user.FsConfig.S3Config.KeyPrefix {
+		t.Error("s3 key prefix mismatch")
+	}
 	req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil)
 	req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr.Code)
 	checkResponseCode(t, http.StatusOK, rr.Code)

+ 6 - 0
httpd/internal_test.go

@@ -274,6 +274,12 @@ func TestCompareUserFsConfig(t *testing.T) {
 	if err == nil {
 	if err == nil {
 		t.Errorf("S3 storage class does not match")
 		t.Errorf("S3 storage class does not match")
 	}
 	}
+	expected.FsConfig.S3Config.StorageClass = ""
+	expected.FsConfig.S3Config.KeyPrefix = "somedir/subdir"
+	err = compareUserFsConfig(expected, actual)
+	if err == nil {
+		t.Errorf("S3 key prefix does not match")
+	}
 }
 }
 
 
 func TestApiCallsWithBadURL(t *testing.T) {
 func TestApiCallsWithBadURL(t *testing.T) {

+ 4 - 0
httpd/schema/openapi.yaml

@@ -732,6 +732,10 @@ components:
           description: optional endpoint
           description: optional endpoint
         storage_class:
         storage_class:
           type: string
           type: string
+        key_prefix:
+          type: string
+          description: key_prefix is similar to a chroot directory for a local filesystem. If specified the SFTP user will only see contents that starts with this prefix and so you can restrict access to a specific virtual folder. The prefix, if not empty, must not start with "/" and must end with "/". If empty the whole bucket contents will be available
+          example: folder/subfolder/
       required:
       required:
         - bucket
         - bucket
         - region
         - region

+ 1 - 0
httpd/web.go

@@ -238,6 +238,7 @@ func getFsConfigFromUserPostFields(r *http.Request) dataprovider.Filesystem {
 		fs.S3Config.AccessSecret = r.Form.Get("s3_access_secret")
 		fs.S3Config.AccessSecret = r.Form.Get("s3_access_secret")
 		fs.S3Config.Endpoint = r.Form.Get("s3_endpoint")
 		fs.S3Config.Endpoint = r.Form.Get("s3_endpoint")
 		fs.S3Config.StorageClass = r.Form.Get("s3_storage_class")
 		fs.S3Config.StorageClass = r.Form.Get("s3_storage_class")
+		fs.S3Config.KeyPrefix = r.Form.Get("s3_key_prefix")
 	}
 	}
 	return fs
 	return fs
 }
 }

+ 2 - 1
scripts/README.md

@@ -44,7 +44,7 @@ Let's see a sample usage for each REST API.
 Command:
 Command:
 
 
 ```
 ```
-python sftpgo_api_cli.py add-user test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 33 --gid 1000 --max-sessions 2 --quota-size 0 --quota-files 3 --permissions "list" "download" "upload" "delete" "rename" "create_dirs" "overwrite" --subdirs-permissions "/dir1:list,download" "/dir2:*" --upload-bandwidth 100 --download-bandwidth 60 --status 0 --expiration-date 2019-01-01 --allowed-ip "192.168.1.1/32" --fs S3 --s3-bucket test --s3-region eu-west-1 --s3-access-key accesskey --s3-access-secret secret --s3-endpoint "http://127.0.0.1:9000" --s3-storage-class Standard
+python sftpgo_api_cli.py add-user test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 33 --gid 1000 --max-sessions 2 --quota-size 0 --quota-files 3 --permissions "list" "download" "upload" "delete" "rename" "create_dirs" "overwrite" --subdirs-permissions "/dir1:list,download" "/dir2:*" --upload-bandwidth 100 --download-bandwidth 60 --status 0 --expiration-date 2019-01-01 --allowed-ip "192.168.1.1/32" --fs S3 --s3-bucket test --s3-region eu-west-1 --s3-access-key accesskey --s3-access-secret secret --s3-endpoint "http://127.0.0.1:9000" --s3-storage-class Standard --s3-key-prefix "vfolder/"
 ```
 ```
 
 
 Output:
 Output:
@@ -60,6 +60,7 @@ Output:
       "access_secret": "$aes$6c088ba12b0b261247c8cf331c46d9260b8e58002957d89ad1c0495e3af665cd0227",
       "access_secret": "$aes$6c088ba12b0b261247c8cf331c46d9260b8e58002957d89ad1c0495e3af665cd0227",
       "bucket": "test",
       "bucket": "test",
       "endpoint": "http://127.0.0.1:9000",
       "endpoint": "http://127.0.0.1:9000",
+      "key_prefix": "vfolder/",
       "region": "eu-west-1",
       "region": "eu-west-1",
       "storage_class": "Standard"
       "storage_class": "Standard"
     }
     }

+ 18 - 10
scripts/sftpgo_api_cli.py

@@ -73,7 +73,8 @@ class SFTPGoApiRequests:
 	def buildUserObject(self, user_id=0, username="", password="", public_keys=[], home_dir="", uid=0, gid=0,
 	def buildUserObject(self, user_id=0, username="", password="", public_keys=[], home_dir="", uid=0, gid=0,
 					max_sessions=0, quota_size=0, quota_files=0, permissions={}, upload_bandwidth=0, download_bandwidth=0,
 					max_sessions=0, quota_size=0, quota_files=0, permissions={}, upload_bandwidth=0, download_bandwidth=0,
 					status=1, expiration_date=0, allowed_ip=[], denied_ip=[], fs_provider='local', s3_bucket='',
 					status=1, expiration_date=0, allowed_ip=[], denied_ip=[], fs_provider='local', s3_bucket='',
-					s3_region='', s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class=''):
+					s3_region='', s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class='',
+					s3_key_prefix=''):
 		user = {"id":user_id, "username":username, "uid":uid, "gid":gid,
 		user = {"id":user_id, "username":username, "uid":uid, "gid":gid,
 			"max_sessions":max_sessions, "quota_size":quota_size, "quota_files":quota_files,
 			"max_sessions":max_sessions, "quota_size":quota_size, "quota_files":quota_files,
 			"upload_bandwidth":upload_bandwidth, "download_bandwidth":download_bandwidth,
 			"upload_bandwidth":upload_bandwidth, "download_bandwidth":download_bandwidth,
@@ -92,7 +93,8 @@ class SFTPGoApiRequests:
 		if allowed_ip or denied_ip:
 		if allowed_ip or denied_ip:
 			user.update({"filters":self.buildFilters(allowed_ip, denied_ip)})
 			user.update({"filters":self.buildFilters(allowed_ip, denied_ip)})
 		user.update({"filesystem":self.buildFsConfig(fs_provider, s3_bucket, s3_region, s3_access_key,
 		user.update({"filesystem":self.buildFsConfig(fs_provider, s3_bucket, s3_region, s3_access_key,
-														s3_access_secret, s3_endpoint, s3_storage_class)})
+														s3_access_secret, s3_endpoint, s3_storage_class,
+														s3_key_prefix)})
 		return user
 		return user
 
 
 	def buildPermissions(self, root_perms, subdirs_perms):
 	def buildPermissions(self, root_perms, subdirs_perms):
@@ -127,11 +129,12 @@ class SFTPGoApiRequests:
 		return filters
 		return filters
 
 
 	def buildFsConfig(self, fs_provider, s3_bucket, s3_region, s3_access_key, s3_access_secret, s3_endpoint,
 	def buildFsConfig(self, fs_provider, s3_bucket, s3_region, s3_access_key, s3_access_secret, s3_endpoint,
-					s3_storage_class):
+					s3_storage_class, s3_key_prefix):
 		fs_config = {'provider':0}
 		fs_config = {'provider':0}
 		if fs_provider == 'S3':
 		if fs_provider == 'S3':
 			s3config = {'bucket':s3_bucket, 'region':s3_region, 'access_key':s3_access_key, 'access_secret':
 			s3config = {'bucket':s3_bucket, 'region':s3_region, 'access_key':s3_access_key, 'access_secret':
-					s3_access_secret, 'endpoint':s3_endpoint, 'storage_class':s3_storage_class}
+					s3_access_secret, 'endpoint':s3_endpoint, 'storage_class':s3_storage_class, 'key_prefix':
+					s3_key_prefix}
 			fs_config.update({'provider':1, 's3config':s3config})
 			fs_config.update({'provider':1, 's3config':s3config})
 		return fs_config
 		return fs_config
 
 
@@ -147,22 +150,23 @@ class SFTPGoApiRequests:
 	def addUser(self, username="", password="", public_keys="", home_dir="", uid=0, gid=0, max_sessions=0, quota_size=0,
 	def addUser(self, username="", password="", public_keys="", home_dir="", uid=0, gid=0, max_sessions=0, quota_size=0,
 			quota_files=0, perms=[], upload_bandwidth=0, download_bandwidth=0, status=1, expiration_date=0,
 			quota_files=0, perms=[], upload_bandwidth=0, download_bandwidth=0, status=1, expiration_date=0,
 			subdirs_permissions=[], allowed_ip=[], denied_ip=[], fs_provider='local', s3_bucket='', s3_region='',
 			subdirs_permissions=[], allowed_ip=[], denied_ip=[], fs_provider='local', s3_bucket='', s3_region='',
-			s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class=''):
+			s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class='', s3_key_prefix=''):
 		u = self.buildUserObject(0, username, password, public_keys, home_dir, uid, gid, max_sessions,
 		u = self.buildUserObject(0, username, password, public_keys, home_dir, uid, gid, max_sessions,
 			quota_size, quota_files, self.buildPermissions(perms, subdirs_permissions), upload_bandwidth, download_bandwidth,
 			quota_size, quota_files, self.buildPermissions(perms, subdirs_permissions), upload_bandwidth, download_bandwidth,
 			status, expiration_date, allowed_ip, denied_ip, fs_provider, s3_bucket, s3_region,
 			status, expiration_date, allowed_ip, denied_ip, fs_provider, s3_bucket, s3_region,
-			s3_access_key, s3_access_secret, s3_endpoint, s3_storage_class)
+			s3_access_key, s3_access_secret, s3_endpoint, s3_storage_class, s3_key_prefix)
 		r = requests.post(self.userPath, json=u, auth=self.auth, verify=self.verify)
 		r = requests.post(self.userPath, json=u, auth=self.auth, verify=self.verify)
 		self.printResponse(r)
 		self.printResponse(r)
 
 
 	def updateUser(self, user_id, username="", password="", public_keys="", home_dir="", uid=0, gid=0, max_sessions=0,
 	def updateUser(self, user_id, username="", password="", public_keys="", home_dir="", uid=0, gid=0, max_sessions=0,
 				quota_size=0, quota_files=0, perms=[], upload_bandwidth=0, download_bandwidth=0, status=1,
 				quota_size=0, quota_files=0, perms=[], upload_bandwidth=0, download_bandwidth=0, status=1,
 				expiration_date=0, subdirs_permissions=[], allowed_ip=[], denied_ip=[], fs_provider='local',
 				expiration_date=0, subdirs_permissions=[], allowed_ip=[], denied_ip=[], fs_provider='local',
-				s3_bucket='', s3_region='', s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class=''):
+				s3_bucket='', s3_region='', s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class='',
+				s3_key_prefix=''):
 		u = self.buildUserObject(user_id, username, password, public_keys, home_dir, uid, gid, max_sessions,
 		u = self.buildUserObject(user_id, username, password, public_keys, home_dir, uid, gid, max_sessions,
 			quota_size, quota_files, self.buildPermissions(perms, subdirs_permissions), upload_bandwidth, download_bandwidth,
 			quota_size, quota_files, self.buildPermissions(perms, subdirs_permissions), upload_bandwidth, download_bandwidth,
 			status, expiration_date, allowed_ip, denied_ip, fs_provider, s3_bucket, s3_region, s3_access_key,
 			status, expiration_date, allowed_ip, denied_ip, fs_provider, s3_bucket, s3_region, s3_access_key,
-			s3_access_secret, s3_endpoint, s3_storage_class)
+			s3_access_secret, s3_endpoint, s3_storage_class, s3_key_prefix)
 		r = requests.put(urlparse.urljoin(self.userPath, "user/" + str(user_id)), json=u, auth=self.auth, verify=self.verify)
 		r = requests.put(urlparse.urljoin(self.userPath, "user/" + str(user_id)), json=u, auth=self.auth, verify=self.verify)
 		self.printResponse(r)
 		self.printResponse(r)
 
 
@@ -419,6 +423,9 @@ def addCommonUserArguments(parser):
 	parser.add_argument('--fs', type=str, default='local', choices=['local', 'S3'],
 	parser.add_argument('--fs', type=str, default='local', choices=['local', 'S3'],
 					help='Filesystem provider. Default: %(default)s')
 					help='Filesystem provider. Default: %(default)s')
 	parser.add_argument('--s3-bucket', type=str, default='', help='Default: %(default)s')
 	parser.add_argument('--s3-bucket', type=str, default='', help='Default: %(default)s')
+	parser.add_argument('--s3-key-prefix', type=str, default='', help='Virtual root directory. If non empty only this ' +
+					'directory and its contents will be available. Cannot start with "/". For example "folder/subfolder/".' +
+					' Default: %(default)s')
 	parser.add_argument('--s3-region', type=str, default='', help='Default: %(default)s')
 	parser.add_argument('--s3-region', type=str, default='', help='Default: %(default)s')
 	parser.add_argument('--s3-access-key', type=str, default='', help='Default: %(default)s')
 	parser.add_argument('--s3-access-key', type=str, default='', help='Default: %(default)s')
 	parser.add_argument('--s3-access-secret', type=str, default='', help='Default: %(default)s')
 	parser.add_argument('--s3-access-secret', type=str, default='', help='Default: %(default)s')
@@ -527,13 +534,14 @@ if __name__ == '__main__':
 				args.quota_size, args.quota_files, args.permissions, args.upload_bandwidth, args.download_bandwidth,
 				args.quota_size, args.quota_files, args.permissions, args.upload_bandwidth, args.download_bandwidth,
 				args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date), args.subdirs_permissions, args.allowed_ip,
 				args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date), args.subdirs_permissions, args.allowed_ip,
 				args.denied_ip, args.fs, args.s3_bucket, args.s3_region, args.s3_access_key, args.s3_access_secret,
 				args.denied_ip, args.fs, args.s3_bucket, args.s3_region, args.s3_access_key, args.s3_access_secret,
-				args.s3_endpoint, args.s3_storage_class)
+				args.s3_endpoint, args.s3_storage_class, args.s3_key_prefix)
 	elif args.command == 'update-user':
 	elif args.command == 'update-user':
 		api.updateUser(args.id, args.username, args.password, args.public_keys, args.home_dir, args.uid, args.gid,
 		api.updateUser(args.id, args.username, args.password, args.public_keys, args.home_dir, args.uid, args.gid,
 					args.max_sessions, args.quota_size, args.quota_files, args.permissions, args.upload_bandwidth,
 					args.max_sessions, args.quota_size, args.quota_files, args.permissions, args.upload_bandwidth,
 					args.download_bandwidth, args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date),
 					args.download_bandwidth, args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date),
 					args.subdirs_permissions, args.allowed_ip, args.denied_ip, args.fs, args.s3_bucket, args.s3_region,
 					args.subdirs_permissions, args.allowed_ip, args.denied_ip, args.fs, args.s3_bucket, args.s3_region,
-					args.s3_access_key, args.s3_access_secret, args.s3_endpoint, args.s3_storage_class)
+					args.s3_access_key, args.s3_access_secret, args.s3_endpoint, args.s3_storage_class,
+					args.s3_key_prefix)
 	elif args.command == 'delete-user':
 	elif args.command == 'delete-user':
 		api.deleteUser(args.id)
 		api.deleteUser(args.id)
 	elif args.command == 'get-users':
 	elif args.command == 'get-users':

+ 3 - 3
sftpd/handler.go

@@ -302,7 +302,7 @@ func (c Connection) handleSFTPSetstat(filePath string, request *sftp.Request) er
 }
 }
 
 
 func (c Connection) handleSFTPRename(sourcePath string, targetPath string, request *sftp.Request) error {
 func (c Connection) handleSFTPRename(sourcePath string, targetPath string, request *sftp.Request) error {
-	if c.fs.GetRelativePath(sourcePath, c.User.GetHomeDir()) == "/" {
+	if c.fs.GetRelativePath(sourcePath) == "/" {
 		c.Log(logger.LevelWarn, logSender, "renaming root dir is not allowed")
 		c.Log(logger.LevelWarn, logSender, "renaming root dir is not allowed")
 		return sftp.ErrSSHFxPermissionDenied
 		return sftp.ErrSSHFxPermissionDenied
 	}
 	}
@@ -319,7 +319,7 @@ func (c Connection) handleSFTPRename(sourcePath string, targetPath string, reque
 }
 }
 
 
 func (c Connection) handleSFTPRmdir(dirPath string, request *sftp.Request) error {
 func (c Connection) handleSFTPRmdir(dirPath string, request *sftp.Request) error {
-	if c.fs.GetRelativePath(dirPath, c.User.GetHomeDir()) == "/" {
+	if c.fs.GetRelativePath(dirPath) == "/" {
 		c.Log(logger.LevelWarn, logSender, "removing root dir is not allowed")
 		c.Log(logger.LevelWarn, logSender, "removing root dir is not allowed")
 		return sftp.ErrSSHFxPermissionDenied
 		return sftp.ErrSSHFxPermissionDenied
 	}
 	}
@@ -348,7 +348,7 @@ func (c Connection) handleSFTPRmdir(dirPath string, request *sftp.Request) error
 }
 }
 
 
 func (c Connection) handleSFTPSymlink(sourcePath string, targetPath string, request *sftp.Request) error {
 func (c Connection) handleSFTPSymlink(sourcePath string, targetPath string, request *sftp.Request) error {
-	if c.fs.GetRelativePath(sourcePath, c.User.GetHomeDir()) == "/" {
+	if c.fs.GetRelativePath(sourcePath) == "/" {
 		c.Log(logger.LevelWarn, logSender, "symlinking root dir is not allowed")
 		c.Log(logger.LevelWarn, logSender, "symlinking root dir is not allowed")
 		return sftp.ErrSSHFxPermissionDenied
 		return sftp.ErrSSHFxPermissionDenied
 	}
 	}

+ 1 - 1
sftpd/scp.go

@@ -325,7 +325,7 @@ func (c *scpCommand) handleRecursiveDownload(dirPath string, stat os.FileInfo) e
 		}
 		}
 		var dirs []string
 		var dirs []string
 		for _, file := range files {
 		for _, file := range files {
-			filePath := c.connection.fs.GetRelativePath(c.connection.fs.Join(dirPath, file.Name()), c.connection.User.GetHomeDir())
+			filePath := c.connection.fs.GetRelativePath(c.connection.fs.Join(dirPath, file.Name()))
 			if file.Mode().IsRegular() || file.Mode()&os.ModeSymlink == os.ModeSymlink {
 			if file.Mode().IsRegular() || file.Mode()&os.ModeSymlink == os.ModeSymlink {
 				err = c.handleDownload(filePath)
 				err = c.handleDownload(filePath)
 				if err != nil {
 				if err != nil {

+ 1 - 1
sftpd/sftpd.go

@@ -307,7 +307,7 @@ func GetConnectionsStats() []ConnectionStatus {
 					StartTime:     utils.GetTimeAsMsSinceEpoch(t.start),
 					StartTime:     utils.GetTimeAsMsSinceEpoch(t.start),
 					Size:          size,
 					Size:          size,
 					LastActivity:  utils.GetTimeAsMsSinceEpoch(t.lastActivity),
 					LastActivity:  utils.GetTimeAsMsSinceEpoch(t.lastActivity),
-					Path:          c.fs.GetRelativePath(t.path, c.User.GetHomeDir()),
+					Path:          c.fs.GetRelativePath(t.path),
 				}
 				}
 				conn.Transfers = append(conn.Transfers, connTransfer)
 				conn.Transfers = append(conn.Transfers, connTransfer)
 			}
 			}

+ 111 - 50
sftpd/sftpd_test.go

@@ -2904,57 +2904,118 @@ func TestRootDirCommands(t *testing.T) {
 
 
 func TestRelativePaths(t *testing.T) {
 func TestRelativePaths(t *testing.T) {
 	user := getTestUser(true)
 	user := getTestUser(true)
-	path := filepath.Join(user.HomeDir, "/")
-	fs := vfs.NewOsFs("", user.GetHomeDir())
-	rel := fs.GetRelativePath(path, user.GetHomeDir())
-	if rel != "/" {
-		t.Errorf("Unexpected relative path: %v", rel)
-	}
-	path = filepath.Join(user.HomeDir, "//")
-	rel = fs.GetRelativePath(path, user.GetHomeDir())
-	if rel != "/" {
-		t.Errorf("Unexpected relative path: %v", rel)
-	}
-	path = filepath.Join(user.HomeDir, "../..")
-	rel = fs.GetRelativePath(path, user.GetHomeDir())
-	if rel != "/" {
-		t.Errorf("Unexpected relative path: %v", rel)
-	}
-	path = filepath.Join(user.HomeDir, "../../../../../")
-	rel = fs.GetRelativePath(path, user.GetHomeDir())
-	if rel != "/" {
-		t.Errorf("Unexpected relative path: %v", rel)
-	}
-	path = filepath.Join(user.HomeDir, "/..")
-	rel = fs.GetRelativePath(path, user.GetHomeDir())
-	if rel != "/" {
-		t.Errorf("Unexpected relative path: %v", rel)
-	}
-	path = filepath.Join(user.HomeDir, "/../../../..")
-	rel = fs.GetRelativePath(path, user.GetHomeDir())
-	if rel != "/" {
-		t.Errorf("Unexpected relative path: %v", rel)
-	}
-	path = filepath.Join(user.HomeDir, "")
-	rel = fs.GetRelativePath(path, user.GetHomeDir())
-	if rel != "/" {
-		t.Errorf("Unexpected relative path: %v", rel)
-	}
-	path = filepath.Join(user.HomeDir, ".")
-	rel = fs.GetRelativePath(path, user.GetHomeDir())
-	if rel != "/" {
-		t.Errorf("Unexpected relative path: %v", rel)
-	}
-	path = filepath.Join(user.HomeDir, "somedir")
-	rel = fs.GetRelativePath(path, user.GetHomeDir())
-	if rel != "/somedir" {
-		t.Errorf("Unexpected relative path: %v", rel)
-	}
-	path = filepath.Join(user.HomeDir, "/somedir/subdir")
-	rel = fs.GetRelativePath(path, user.GetHomeDir())
-	if rel != "/somedir/subdir" {
-		t.Errorf("Unexpected relative path: %v", rel)
+	var path, rel string
+	filesystems := []vfs.Fs{vfs.NewOsFs("", user.GetHomeDir())}
+	s3config := vfs.S3FsConfig{
+		KeyPrefix: strings.TrimPrefix(user.GetHomeDir(), "/") + "/",
+	}
+	s3fs, _ := vfs.NewS3Fs("", user.GetHomeDir(), s3config)
+	filesystems = append(filesystems, s3fs)
+	for _, fs := range filesystems {
+		path = filepath.Join(user.HomeDir, "/")
+		rel = fs.GetRelativePath(path)
+		if rel != "/" {
+			t.Errorf("Unexpected relative path: %v", rel)
+		}
+		path = filepath.Join(user.HomeDir, "//")
+		rel = fs.GetRelativePath(path)
+		if rel != "/" {
+			t.Errorf("Unexpected relative path: %v", rel)
+		}
+		path = filepath.Join(user.HomeDir, "../..")
+		rel = fs.GetRelativePath(path)
+		if rel != "/" {
+			t.Errorf("Unexpected relative path: %v path: %v", rel, path)
+		}
+		path = filepath.Join(user.HomeDir, "../../../../../")
+		rel = fs.GetRelativePath(path)
+		if rel != "/" {
+			t.Errorf("Unexpected relative path: %v", rel)
+		}
+		path = filepath.Join(user.HomeDir, "/..")
+		rel = fs.GetRelativePath(path)
+		if rel != "/" {
+			t.Errorf("Unexpected relative path: %v path: %v", rel, path)
+		}
+		path = filepath.Join(user.HomeDir, "/../../../..")
+		rel = fs.GetRelativePath(path)
+		if rel != "/" {
+			t.Errorf("Unexpected relative path: %v", rel)
+		}
+		path = filepath.Join(user.HomeDir, "")
+		rel = fs.GetRelativePath(path)
+		if rel != "/" {
+			t.Errorf("Unexpected relative path: %v", rel)
+		}
+		path = filepath.Join(user.HomeDir, ".")
+		rel = fs.GetRelativePath(path)
+		if rel != "/" {
+			t.Errorf("Unexpected relative path: %v", rel)
+		}
+		path = filepath.Join(user.HomeDir, "somedir")
+		rel = fs.GetRelativePath(path)
+		if rel != "/somedir" {
+			t.Errorf("Unexpected relative path: %v", rel)
+		}
+		path = filepath.Join(user.HomeDir, "/somedir/subdir")
+		rel = fs.GetRelativePath(path)
+		if rel != "/somedir/subdir" {
+			t.Errorf("Unexpected relative path: %v", rel)
+		}
+	}
+}
+
+func TestResolvePaths(t *testing.T) {
+	user := getTestUser(true)
+	var path, resolved string
+	var err error
+	filesystems := []vfs.Fs{vfs.NewOsFs("", user.GetHomeDir())}
+	s3config := vfs.S3FsConfig{
+		KeyPrefix: strings.TrimPrefix(user.GetHomeDir(), "/") + "/",
+	}
+	os.MkdirAll(user.GetHomeDir(), 0777)
+	s3fs, _ := vfs.NewS3Fs("", user.GetHomeDir(), s3config)
+	filesystems = append(filesystems, s3fs)
+	for _, fs := range filesystems {
+		path = "/"
+		resolved, _ = fs.ResolvePath(filepath.ToSlash(path))
+		if resolved != fs.Join(user.GetHomeDir(), "/") {
+			t.Errorf("Unexpected resolved path: %v for: %v, fs: %v", resolved, path, fs.Name())
+		}
+		path = "."
+		resolved, _ = fs.ResolvePath(filepath.ToSlash(path))
+		if resolved != fs.Join(user.GetHomeDir(), "/") {
+			t.Errorf("Unexpected resolved path: %v for: %v, fs: %v", resolved, path, fs.Name())
+		}
+		path = "test/sub"
+		resolved, _ = fs.ResolvePath(filepath.ToSlash(path))
+		if resolved != fs.Join(user.GetHomeDir(), "/test/sub") {
+			t.Errorf("Unexpected resolved path: %v for: %v, fs: %v", resolved, path, fs.Name())
+		}
+		path = "../test/sub"
+		resolved, err = fs.ResolvePath(filepath.ToSlash(path))
+		if fs.Name() == "osfs" {
+			if err == nil {
+				t.Errorf("Unexpected resolved path: %v for: %v, fs: %v", resolved, path, fs.Name())
+			}
+		} else {
+			if resolved != fs.Join(user.GetHomeDir(), "/test/sub") && err == nil {
+				t.Errorf("Unexpected resolved path: %v for: %v, fs: %v", resolved, path, fs.Name())
+			}
+		}
+		path = "../../../test/../sub"
+		resolved, err = fs.ResolvePath(filepath.ToSlash(path))
+		if fs.Name() == "osfs" {
+			if err == nil {
+				t.Errorf("Unexpected resolved path: %v for: %v, fs: %v", resolved, path, fs.Name())
+			}
+		} else {
+			if resolved != fs.Join(user.GetHomeDir(), "/sub") && err == nil {
+				t.Errorf("Unexpected resolved path: %v for: %v, fs: %v", resolved, path, fs.Name())
+			}
+		}
 	}
 	}
+	os.RemoveAll(user.GetHomeDir())
 }
 }
 
 
 func TestUserPerms(t *testing.T) {
 func TestUserPerms(t *testing.T) {

+ 11 - 0
templates/user.html

@@ -243,6 +243,17 @@
         </div>
         </div>
     </div>
     </div>
 
 
+    <div class="form-group row">
+        <label for="idS3KeyPrefix" class="col-sm-2 col-form-label">S3 Key Prefix</label>
+        <div class="col-sm-10">
+            <input type="text" class="form-control" id="idS3KeyPrefix" name="s3_key_prefix" placeholder=""
+                value="{{.User.FsConfig.S3Config.KeyPrefix}}" maxlength="255" aria-describedby="S3KeyPrefixHelpBlock">
+            <small id="S3KeyPrefixHelpBlock" class="form-text text-muted">
+                Similar to a chroot for local filesystem. Cannot start with "/". Example: "somedir/subdir/".
+            </small>
+        </div>
+    </div>
+
     <input type="hidden" name="expiration_date" id="hidden_start_datetime" value="">
     <input type="hidden" name="expiration_date" id="hidden_start_datetime" value="">
     <button type="submit" class="btn btn-primary float-right mt-3 mb-5 px-5 px-3">Submit</button>
     <button type="submit" class="btn btn-primary float-right mt-3 mb-5 px-5 px-3">Submit</button>
 </form>
 </form>

+ 2 - 2
vfs/osfs.go

@@ -180,8 +180,8 @@ func (OsFs) GetAtomicUploadPath(name string) string {
 
 
 // GetRelativePath returns the path for a file relative to the user's home dir.
 // GetRelativePath returns the path for a file relative to the user's home dir.
 // This is the path as seen by SFTP users
 // This is the path as seen by SFTP users
-func (OsFs) GetRelativePath(name, rootPath string) string {
-	rel, err := filepath.Rel(rootPath, filepath.Clean(name))
+func (fs OsFs) GetRelativePath(name string) string {
+	rel, err := filepath.Rel(fs.rootDir, filepath.Clean(name))
 	if err != nil {
 	if err != nil {
 		return ""
 		return ""
 	}
 	}

+ 25 - 6
vfs/s3fs.go

@@ -22,7 +22,14 @@ import (
 
 
 // S3FsConfig defines the configuration for S3fs
 // S3FsConfig defines the configuration for S3fs
 type S3FsConfig struct {
 type S3FsConfig struct {
-	Bucket       string `json:"bucket,omitempty"`
+	Bucket string `json:"bucket,omitempty"`
+	// KeyPrefix is similar to a chroot directory for local filesystem.
+	// If specified the SFTP user will only see contents that starts with
+	// this prefix and so you can restrict access to a specific virtual
+	// folder. The prefix, if not empty, must not start with "/" and must
+	// end with "/".
+	//If empty the whole bucket contents will be available
+	KeyPrefix    string `json:"key_prefix,omitempty"`
 	Region       string `json:"region,omitempty"`
 	Region       string `json:"region,omitempty"`
 	AccessKey    string `json:"access_key,omitempty"`
 	AccessKey    string `json:"access_key,omitempty"`
 	AccessSecret string `json:"access_secret,omitempty"`
 	AccessSecret string `json:"access_secret,omitempty"`
@@ -95,6 +102,9 @@ func (fs S3Fs) Stat(name string) (os.FileInfo, error) {
 		}
 		}
 		return NewS3FileInfo(name, true, 0, time.Time{}), nil
 		return NewS3FileInfo(name, true, 0, time.Time{}), nil
 	}
 	}
+	if "/"+fs.config.KeyPrefix == name+"/" {
+		return NewS3FileInfo(name, true, 0, time.Time{}), nil
+	}
 	prefix := path.Dir(name)
 	prefix := path.Dir(name)
 	if prefix == "/" || prefix == "." {
 	if prefix == "/" || prefix == "." {
 		prefix = ""
 		prefix = ""
@@ -403,7 +413,7 @@ func (fs S3Fs) ScanRootDirContents() (int, int64, error) {
 	defer cancelFn()
 	defer cancelFn()
 	err := fs.svc.ListObjectsV2PagesWithContext(ctx, &s3.ListObjectsV2Input{
 	err := fs.svc.ListObjectsV2PagesWithContext(ctx, &s3.ListObjectsV2Input{
 		Bucket: aws.String(fs.config.Bucket),
 		Bucket: aws.String(fs.config.Bucket),
-		Prefix: aws.String(""),
+		Prefix: aws.String(fs.config.KeyPrefix),
 	}, func(page *s3.ListObjectsV2Output, lastPage bool) bool {
 	}, func(page *s3.ListObjectsV2Output, lastPage bool) bool {
 		for _, fileObject := range page.Contents {
 		for _, fileObject := range page.Contents {
 			numFiles++
 			numFiles++
@@ -423,14 +433,20 @@ func (S3Fs) GetAtomicUploadPath(name string) string {
 
 
 // GetRelativePath returns the path for a file relative to the user's home dir.
 // GetRelativePath returns the path for a file relative to the user's home dir.
 // This is the path as seen by SFTP users
 // This is the path as seen by SFTP users
-func (S3Fs) GetRelativePath(name, rootPath string) string {
-	rel := name
-	if name == "." {
+func (fs S3Fs) GetRelativePath(name string) string {
+	rel := path.Clean(name)
+	if rel == "." {
 		rel = ""
 		rel = ""
 	}
 	}
 	if !strings.HasPrefix(rel, "/") {
 	if !strings.HasPrefix(rel, "/") {
 		return "/" + rel
 		return "/" + rel
 	}
 	}
+	if len(fs.config.KeyPrefix) > 0 {
+		if !strings.HasPrefix(rel, "/"+fs.config.KeyPrefix) {
+			rel = "/"
+		}
+		rel = path.Clean("/" + strings.TrimPrefix(rel, "/"+fs.config.KeyPrefix))
+	}
 	return rel
 	return rel
 }
 }
 
 
@@ -441,7 +457,10 @@ func (S3Fs) Join(elem ...string) string {
 
 
 // ResolvePath returns the matching filesystem path for the specified sftp path
 // ResolvePath returns the matching filesystem path for the specified sftp path
 func (fs S3Fs) ResolvePath(sftpPath string) (string, error) {
 func (fs S3Fs) ResolvePath(sftpPath string) (string, error) {
-	return sftpPath, nil
+	if !path.IsAbs(sftpPath) {
+		sftpPath = path.Clean("/" + sftpPath)
+	}
+	return fs.Join("/", fs.config.KeyPrefix, sftpPath), nil
 }
 }
 
 
 func (fs *S3Fs) resolve(name *string, prefix string) (string, bool) {
 func (fs *S3Fs) resolve(name *string, prefix string) (string, bool) {

+ 12 - 1
vfs/vfs.go

@@ -4,7 +4,9 @@ package vfs
 import (
 import (
 	"errors"
 	"errors"
 	"os"
 	"os"
+	"path"
 	"runtime"
 	"runtime"
+	"strings"
 	"time"
 	"time"
 
 
 	"github.com/drakkan/sftpgo/logger"
 	"github.com/drakkan/sftpgo/logger"
@@ -36,7 +38,7 @@ type Fs interface {
 	IsPermission(err error) bool
 	IsPermission(err error) bool
 	ScanRootDirContents() (int, int64, error)
 	ScanRootDirContents() (int, int64, error)
 	GetAtomicUploadPath(name string) string
 	GetAtomicUploadPath(name string) string
-	GetRelativePath(name, rootPath string) string
+	GetRelativePath(name string) string
 	Join(elem ...string) string
 	Join(elem ...string) string
 }
 }
 
 
@@ -80,6 +82,15 @@ func ValidateS3FsConfig(config *S3FsConfig) error {
 	if len(config.AccessSecret) == 0 {
 	if len(config.AccessSecret) == 0 {
 		return errors.New("access_secret cannot be empty")
 		return errors.New("access_secret cannot be empty")
 	}
 	}
+	if len(config.KeyPrefix) > 0 {
+		if strings.HasPrefix(config.KeyPrefix, "/") {
+			return errors.New("key_prefix cannot start with /")
+		}
+		config.KeyPrefix = path.Clean(config.KeyPrefix)
+		if !strings.HasSuffix(config.KeyPrefix, "/") {
+			config.KeyPrefix += "/"
+		}
+	}
 	return nil
 	return nil
 }
 }