瀏覽代碼

allow placeholders for add/update users and folders

remove session token for S3, a temporary token is useless for our usage

Signed-off-by: Nicola Murino <[email protected]>
Nicola Murino 3 年之前
父節點
當前提交
ca32cd5e0e

+ 3 - 3
cmd/portable.go

@@ -43,7 +43,7 @@ var (
 	portableS3Region                   string
 	portableS3AccessKey                string
 	portableS3AccessSecret             string
-	portableS3SessionToken             string
+	portableS3RoleARN                  string
 	portableS3Endpoint                 string
 	portableS3StorageClass             string
 	portableS3ACL                      string
@@ -175,7 +175,7 @@ Please take a look at the usage below to customize the serving parameters`,
 								Bucket:            portableS3Bucket,
 								Region:            portableS3Region,
 								AccessKey:         portableS3AccessKey,
-								SessionToken:      portableS3SessionToken,
+								RoleARN:           portableS3RoleARN,
 								Endpoint:          portableS3Endpoint,
 								StorageClass:      portableS3StorageClass,
 								ACL:               portableS3ACL,
@@ -301,7 +301,7 @@ sftpfs => SFTP (legacy: 5)`)
 	portableCmd.Flags().StringVar(&portableS3Region, "s3-region", "", "")
 	portableCmd.Flags().StringVar(&portableS3AccessKey, "s3-access-key", "", "")
 	portableCmd.Flags().StringVar(&portableS3AccessSecret, "s3-access-secret", "", "")
-	portableCmd.Flags().StringVar(&portableS3SessionToken, "s3-session-token", "", "")
+	portableCmd.Flags().StringVar(&portableS3RoleARN, "s3-role-arn", "", "")
 	portableCmd.Flags().StringVar(&portableS3Endpoint, "s3-endpoint", "", "")
 	portableCmd.Flags().StringVar(&portableS3StorageClass, "s3-storage-class", "", "")
 	portableCmd.Flags().StringVar(&portableS3ACL, "s3-acl", "", "")

+ 2 - 2
docs/portable-mode.md

@@ -93,7 +93,7 @@ Flags:
                                         virtual folder identified by this
                                         prefix and its contents
       --s3-region string
-      --s3-session-token string
+      --s3-role-arn string
       --s3-storage-class string
       --s3-upload-concurrency int       How many parts are uploaded in
                                         parallel (default 2)
@@ -125,7 +125,7 @@ Flags:
   -c, --ssh-commands strings            SSH commands to enable.
                                         "*" means any supported SSH command
                                         including scp
-                                         (default [md5sum,sha1sum,cd,pwd,scp])
+                                         (default [md5sum,sha1sum,sha256sum,cd,pwd,scp])
       --start-directory string          Alternate start directory.
                                         This is a virtual path not a filesystem
                                         path (default "/")

+ 2 - 2
go.mod

@@ -46,8 +46,8 @@ require (
 	github.com/robfig/cron/v3 v3.0.1
 	github.com/rs/cors v1.8.2
 	github.com/rs/xid v1.4.0
-	github.com/rs/zerolog v1.26.2-0.20220227173336-263b0bde3672
-	github.com/sftpgo/sdk v0.1.1-0.20220323191209-5d4ff81576b4
+	github.com/rs/zerolog v1.26.2-0.20220312163309-e9344a8c507b
+	github.com/sftpgo/sdk v0.1.1-0.20220327080604-3c0f878c8c37
 	github.com/shirou/gopsutil/v3 v3.22.2
 	github.com/spf13/afero v1.8.2
 	github.com/spf13/cobra v1.4.0

+ 4 - 4
go.sum

@@ -656,14 +656,14 @@ github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY=
 github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
 github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
 github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
-github.com/rs/zerolog v1.26.2-0.20220227173336-263b0bde3672 h1:8tqGbO3HWm9kqGZxc8YLAND7QGJNppiwq+kMTpn8oOk=
-github.com/rs/zerolog v1.26.2-0.20220227173336-263b0bde3672/go.mod h1:7frBqO0oezxmnO7GF86FY++uy8I0Tk/If5ni1G9Qc0U=
+github.com/rs/zerolog v1.26.2-0.20220312163309-e9344a8c507b h1:72Plc168SB6g5i9cOEPaCuMK01bKNyniHnCpqPnX0Cg=
+github.com/rs/zerolog v1.26.2-0.20220312163309-e9344a8c507b/go.mod h1:7frBqO0oezxmnO7GF86FY++uy8I0Tk/If5ni1G9Qc0U=
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
-github.com/sftpgo/sdk v0.1.1-0.20220323191209-5d4ff81576b4 h1:zpu89DMnl3d5Bu3YlvQuu3/KsjkhERgvqgqz+Lnn4CY=
-github.com/sftpgo/sdk v0.1.1-0.20220323191209-5d4ff81576b4/go.mod h1:m5J7DH8unhD5RUsREFRiidP8zgBjup0+iQaxQnYHJOM=
+github.com/sftpgo/sdk v0.1.1-0.20220327080604-3c0f878c8c37 h1:ESruo35Pb9cCgaGslAmw6leGhzeL0pLzD6o+z9gsZeQ=
+github.com/sftpgo/sdk v0.1.1-0.20220327080604-3c0f878c8c37/go.mod h1:m5J7DH8unhD5RUsREFRiidP8zgBjup0+iQaxQnYHJOM=
 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=

+ 111 - 7
httpd/httpd_test.go

@@ -1906,7 +1906,6 @@ func TestUserRedactedPassword(t *testing.T) {
 	u.FsConfig.S3Config.Bucket = "b"
 	u.FsConfig.S3Config.Region = "eu-west-1"
 	u.FsConfig.S3Config.AccessKey = "access-key"
-	u.FsConfig.S3Config.SessionToken = "session token"
 	u.FsConfig.S3Config.RoleARN = "myRoleARN"
 	u.FsConfig.S3Config.AccessSecret = kms.NewSecret(sdkkms.SecretStatusRedacted, "access-secret", "", "")
 	u.FsConfig.S3Config.Endpoint = "http://127.0.0.1:9000/path?k=m"
@@ -2685,7 +2684,6 @@ func TestUserS3Config(t *testing.T) {
 	user.FsConfig.S3Config.Region = "us-east-1" //nolint:goconst
 	user.FsConfig.S3Config.AccessKey = "Server-Access-Key"
 	user.FsConfig.S3Config.AccessSecret = kms.NewPlainSecret("Server-Access-Secret")
-	user.FsConfig.S3Config.SessionToken = "Session token"
 	user.FsConfig.S3Config.RoleARN = "myRoleARN"
 	user.FsConfig.S3Config.Endpoint = "http://127.0.0.1:9000"
 	user.FsConfig.S3Config.UploadPartSize = 8
@@ -15470,6 +15468,117 @@ func TestUserTemplateMock(t *testing.T) {
 	require.True(t, user2.Filters.DisableFsChecks)
 }
 
+func TestUserPlaceholders(t *testing.T) {
+	token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
+	assert.NoError(t, err)
+	csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+	assert.NoError(t, err)
+	u := getTestUser()
+	u.HomeDir = filepath.Join(os.TempDir(), "%username%_%password%")
+	form := make(url.Values)
+	form.Set(csrfFormToken, csrfToken)
+	form.Set("username", u.Username)
+	form.Set("home_dir", u.HomeDir)
+	form.Set("password", u.Password)
+	form.Set("status", strconv.Itoa(u.Status))
+	form.Set("expiration_date", "")
+	form.Set("permissions", "*")
+	form.Set("public_keys", testPubKey)
+	form.Add("public_keys", testPubKey1)
+	form.Set("uid", "0")
+	form.Set("gid", "0")
+	form.Set("max_sessions", "0")
+	form.Set("quota_size", "0")
+	form.Set("quota_files", "0")
+	form.Set("upload_bandwidth", "0")
+	form.Set("download_bandwidth", "0")
+	form.Set("total_data_transfer", "0")
+	form.Set("upload_data_transfer", "0")
+	form.Set("download_data_transfer", "0")
+	form.Set("external_auth_cache_time", "0")
+	form.Set("max_upload_file_size", "0")
+	b, contentType, _ := getMultipartFormData(form, "", "")
+	req, _ := http.NewRequest(http.MethodPost, webUserPath, &b)
+	setJWTCookieForReq(req, token)
+	req.Header.Set("Content-Type", contentType)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusSeeOther, rr)
+
+	user, _, err := httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Equal(t, filepath.Join(os.TempDir(), fmt.Sprintf("%v_%v", defaultUsername, defaultPassword)), user.HomeDir)
+
+	dbUser, err := dataprovider.UserExists(defaultUsername)
+	assert.NoError(t, err)
+	assert.True(t, dbUser.IsPasswordHashed())
+	hashedPwd := dbUser.Password
+
+	form.Set("password", redactedSecret)
+	b, contentType, _ = getMultipartFormData(form, "", "")
+	req, err = http.NewRequest(http.MethodPost, path.Join(webUserPath, defaultUsername), &b)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, token)
+	req.Header.Set("Content-Type", contentType)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusSeeOther, rr)
+
+	user, _, err = httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Equal(t, filepath.Join(os.TempDir(), defaultUsername+"_%password%"), user.HomeDir)
+	// check that the password was unchanged
+	dbUser, err = dataprovider.UserExists(defaultUsername)
+	assert.NoError(t, err)
+	assert.True(t, dbUser.IsPasswordHashed())
+	assert.Equal(t, hashedPwd, dbUser.Password)
+
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+}
+
+func TestFolderPlaceholders(t *testing.T) {
+	token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
+	assert.NoError(t, err)
+	csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+	assert.NoError(t, err)
+	folderName := "folderName"
+	form := make(url.Values)
+	form.Set("name", folderName)
+	form.Set("mapped_path", filepath.Join(os.TempDir(), "%name%"))
+	form.Set("description", "desc folder %name%")
+	form.Set(csrfFormToken, csrfToken)
+	b, contentType, _ := getMultipartFormData(form, "", "")
+	req, err := http.NewRequest(http.MethodPost, webFolderPath, &b)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, token)
+	req.Header.Set("Content-Type", contentType)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusSeeOther, rr)
+
+	folderGet, _, err := httpdtest.GetFolderByName(folderName, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Equal(t, filepath.Join(os.TempDir(), folderName), folderGet.MappedPath)
+	assert.Equal(t, fmt.Sprintf("desc folder %v", folderName), folderGet.Description)
+
+	form.Set("mapped_path", filepath.Join(os.TempDir(), "%name%_%name%"))
+	b, contentType, _ = getMultipartFormData(form, "", "")
+	req, err = http.NewRequest(http.MethodPost, path.Join(webFolderPath, folderName), &b)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, token)
+	req.Header.Set("Content-Type", contentType)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusSeeOther, rr)
+
+	folderGet, _, err = httpdtest.GetFolderByName(folderName, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Equal(t, filepath.Join(os.TempDir(), fmt.Sprintf("%v_%v", folderName, folderName)), folderGet.MappedPath)
+	assert.Equal(t, fmt.Sprintf("desc folder %v", folderName), folderGet.Description)
+
+	_, err = httpdtest.RemoveFolder(vfs.BaseVirtualFolder{Name: folderName}, http.StatusOK)
+	assert.NoError(t, err)
+}
+
 func TestFolderSaveFromTemplateMock(t *testing.T) {
 	folder1 := "f1"
 	folder2 := "f2"
@@ -15677,7 +15786,6 @@ func TestWebUserS3Mock(t *testing.T) {
 	user.FsConfig.S3Config.Region = "eu-west-1"
 	user.FsConfig.S3Config.AccessKey = "access-key"
 	user.FsConfig.S3Config.AccessSecret = kms.NewPlainSecret("access-secret")
-	user.FsConfig.S3Config.SessionToken = "new session token"
 	user.FsConfig.S3Config.RoleARN = "arn:aws:iam::123456789012:user/Development/product_1234/*"
 	user.FsConfig.S3Config.Endpoint = "http://127.0.0.1:9000/path?a=b"
 	user.FsConfig.S3Config.StorageClass = "Standard"
@@ -15717,7 +15825,6 @@ func TestWebUserS3Mock(t *testing.T) {
 	form.Set("s3_region", user.FsConfig.S3Config.Region)
 	form.Set("s3_access_key", user.FsConfig.S3Config.AccessKey)
 	form.Set("s3_access_secret", user.FsConfig.S3Config.AccessSecret.GetPayload())
-	form.Set("s3_session_token", user.FsConfig.S3Config.SessionToken)
 	form.Set("s3_role_arn", user.FsConfig.S3Config.RoleARN)
 	form.Set("s3_storage_class", user.FsConfig.S3Config.StorageClass)
 	form.Set("s3_acl", user.FsConfig.S3Config.ACL)
@@ -15808,7 +15915,6 @@ func TestWebUserS3Mock(t *testing.T) {
 	assert.Equal(t, updateUser.FsConfig.S3Config.Bucket, user.FsConfig.S3Config.Bucket)
 	assert.Equal(t, updateUser.FsConfig.S3Config.Region, user.FsConfig.S3Config.Region)
 	assert.Equal(t, updateUser.FsConfig.S3Config.AccessKey, user.FsConfig.S3Config.AccessKey)
-	assert.Equal(t, updateUser.FsConfig.S3Config.SessionToken, user.FsConfig.S3Config.SessionToken)
 	assert.Equal(t, updateUser.FsConfig.S3Config.RoleARN, user.FsConfig.S3Config.RoleARN)
 	assert.Equal(t, updateUser.FsConfig.S3Config.StorageClass, user.FsConfig.S3Config.StorageClass)
 	assert.Equal(t, updateUser.FsConfig.S3Config.ACL, user.FsConfig.S3Config.ACL)
@@ -16577,7 +16683,6 @@ func TestS3WebFolderMock(t *testing.T) {
 	assert.Equal(t, S3Bucket, folder.FsConfig.S3Config.Bucket)
 	assert.Equal(t, S3Region, folder.FsConfig.S3Config.Region)
 	assert.Equal(t, S3AccessKey, folder.FsConfig.S3Config.AccessKey)
-	assert.Equal(t, S3SessionToken, folder.FsConfig.S3Config.SessionToken)
 	assert.NotEmpty(t, folder.FsConfig.S3Config.AccessSecret.GetPayload())
 	assert.Equal(t, S3Endpoint, folder.FsConfig.S3Config.Endpoint)
 	assert.Equal(t, S3StorageClass, folder.FsConfig.S3Config.StorageClass)
@@ -16626,7 +16731,6 @@ func TestS3WebFolderMock(t *testing.T) {
 	assert.Equal(t, S3Bucket, folder.FsConfig.S3Config.Bucket)
 	assert.Equal(t, S3Region, folder.FsConfig.S3Config.Region)
 	assert.Equal(t, S3AccessKey, folder.FsConfig.S3Config.AccessKey)
-	assert.Equal(t, S3SessionToken, folder.FsConfig.S3Config.SessionToken)
 	assert.Equal(t, S3RoleARN, folder.FsConfig.S3Config.RoleARN)
 	assert.NotEmpty(t, folder.FsConfig.S3Config.AccessSecret.GetPayload())
 	assert.Equal(t, S3Endpoint, folder.FsConfig.S3Config.Endpoint)

+ 52 - 29
httpd/webadmin.go

@@ -235,9 +235,9 @@ type messagePage struct {
 }
 
 type userTemplateFields struct {
-	Username  string
-	Password  string
-	PublicKey string
+	Username   string
+	Password   string
+	PublicKeys []string
 }
 
 func loadAdminTemplates(templatesPath string) {
@@ -714,9 +714,9 @@ func getUsersForTemplate(r *http.Request) []userTemplateFields {
 
 		users[username] = true
 		res = append(res, userTemplateFields{
-			Username:  username,
-			Password:  password,
-			PublicKey: publicKey,
+			Username:   username,
+			Password:   password,
+			PublicKeys: []string{publicKey},
 		})
 	}
 
@@ -982,7 +982,6 @@ func getS3Config(r *http.Request) (vfs.S3FsConfig, error) {
 	config.Bucket = r.Form.Get("s3_bucket")
 	config.Region = r.Form.Get("s3_region")
 	config.AccessKey = r.Form.Get("s3_access_key")
-	config.SessionToken = strings.TrimSpace(r.Form.Get("s3_session_token"))
 	config.RoleARN = r.Form.Get("s3_role_arn")
 	config.AccessSecret = getSecretFromFormField(r, "s3_access_secret")
 	config.Endpoint = r.Form.Get("s3_endpoint")
@@ -1224,14 +1223,13 @@ func getSFTPFsFromTemplate(fsConfig vfs.SFTPFsConfig, replacements map[string]st
 func getUserFromTemplate(user dataprovider.User, template userTemplateFields) dataprovider.User {
 	user.Username = template.Username
 	user.Password = template.Password
-	user.PublicKeys = nil
-	if template.PublicKey != "" {
-		user.PublicKeys = append(user.PublicKeys, template.PublicKey)
-	}
+	user.PublicKeys = template.PublicKeys
 	replacements := make(map[string]string)
 	replacements["%username%"] = user.Username
-	user.Password = replacePlaceholders(user.Password, replacements)
-	replacements["%password%"] = user.Password
+	if user.Password != "" && !user.IsPasswordHashed() {
+		user.Password = replacePlaceholders(user.Password, replacements)
+		replacements["%password%"] = user.Password
+	}
 
 	user.HomeDir = replacePlaceholders(user.HomeDir, replacements)
 	var vfolders []vfs.VirtualFolder
@@ -1263,19 +1261,31 @@ func getUserFromTemplate(user dataprovider.User, template userTemplateFields) da
 func getTransferLimits(r *http.Request) (int64, int64, int64, error) {
 	dataTransferUL, err := strconv.ParseInt(r.Form.Get("upload_data_transfer"), 10, 64)
 	if err != nil {
-		return 0, 0, 0, err
+		return 0, 0, 0, fmt.Errorf("invalid upload data transfer: %w", err)
 	}
 	dataTransferDL, err := strconv.ParseInt(r.Form.Get("download_data_transfer"), 10, 64)
 	if err != nil {
-		return 0, 0, 0, err
+		return 0, 0, 0, fmt.Errorf("invalid download data transfer: %w", err)
 	}
 	dataTransferTotal, err := strconv.ParseInt(r.Form.Get("total_data_transfer"), 10, 64)
 	if err != nil {
-		return 0, 0, 0, err
+		return 0, 0, 0, fmt.Errorf("invalid total data transfer: %w", err)
 	}
 	return dataTransferUL, dataTransferDL, dataTransferTotal, nil
 }
 
+func getQuotaLimits(r *http.Request) (int64, int, error) {
+	quotaSize, err := strconv.ParseInt(r.Form.Get("quota_size"), 10, 64)
+	if err != nil {
+		return 0, 0, fmt.Errorf("invalid quota size: %w", err)
+	}
+	quotaFiles, err := strconv.Atoi(r.Form.Get("quota_files"))
+	if err != nil {
+		return 0, 0, fmt.Errorf("invalid quota files: %w", err)
+	}
+	return quotaSize, quotaFiles, nil
+}
+
 func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
 	var user dataprovider.User
 	err := r.ParseMultipartForm(maxRequestSize)
@@ -1285,31 +1295,27 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
 	defer r.MultipartForm.RemoveAll() //nolint:errcheck
 	uid, err := strconv.Atoi(r.Form.Get("uid"))
 	if err != nil {
-		return user, err
+		return user, fmt.Errorf("invalid uid: %w", err)
 	}
 	gid, err := strconv.Atoi(r.Form.Get("gid"))
 	if err != nil {
-		return user, err
+		return user, fmt.Errorf("invalid uid: %w", err)
 	}
 	maxSessions, err := strconv.Atoi(r.Form.Get("max_sessions"))
 	if err != nil {
-		return user, err
+		return user, fmt.Errorf("invalid max sessions: %w", err)
 	}
-	quotaSize, err := strconv.ParseInt(r.Form.Get("quota_size"), 10, 64)
-	if err != nil {
-		return user, err
-	}
-	quotaFiles, err := strconv.Atoi(r.Form.Get("quota_files"))
+	quotaSize, quotaFiles, err := getQuotaLimits(r)
 	if err != nil {
 		return user, err
 	}
 	bandwidthUL, err := strconv.ParseInt(r.Form.Get("upload_bandwidth"), 10, 64)
 	if err != nil {
-		return user, err
+		return user, fmt.Errorf("invalid upload bandwidth: %w", err)
 	}
 	bandwidthDL, err := strconv.ParseInt(r.Form.Get("download_bandwidth"), 10, 64)
 	if err != nil {
-		return user, err
+		return user, fmt.Errorf("invalid download bandwidth: %w", err)
 	}
 	dataTransferUL, dataTransferDL, dataTransferTotal, err := getTransferLimits(r)
 	if err != nil {
@@ -1317,7 +1323,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
 	}
 	status, err := strconv.Atoi(r.Form.Get("status"))
 	if err != nil {
-		return user, err
+		return user, fmt.Errorf("invalid status: %w", err)
 	}
 	expirationDateMillis := int64(0)
 	expirationDateString := r.Form.Get("expiration_date")
@@ -1366,6 +1372,9 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
 		FsConfig:       fsConfig,
 	}
 	maxFileSize, err := strconv.ParseInt(r.Form.Get("max_upload_file_size"), 10, 64)
+	if err != nil {
+		return user, fmt.Errorf("invalid max upload file size: %w", err)
+	}
 	user.Filters.MaxUploadFileSize = maxFileSize
 	return user, err
 }
@@ -1912,6 +1921,11 @@ func (s *httpdServer) handleWebAddUserPost(w http.ResponseWriter, r *http.Reques
 		s.renderForbiddenPage(w, r, err.Error())
 		return
 	}
+	user = getUserFromTemplate(user, userTemplateFields{
+		Username:   user.Username,
+		Password:   user.Password,
+		PublicKeys: user.PublicKeys,
+	})
 	err = dataprovider.AddUser(&user, claims.Username, ipAddr)
 	if err == nil {
 		http.Redirect(w, r, webUsersPath, http.StatusSeeOther)
@@ -1958,6 +1972,12 @@ func (s *httpdServer) handleWebUpdateUserPost(w http.ResponseWriter, r *http.Req
 		user.FsConfig.AzBlobConfig.SASURL, user.FsConfig.GCSConfig.Credentials, user.FsConfig.CryptConfig.Passphrase,
 		user.FsConfig.SFTPConfig.Password, user.FsConfig.SFTPConfig.PrivateKey)
 
+	updatedUser = getUserFromTemplate(updatedUser, userTemplateFields{
+		Username:   updatedUser.Username,
+		Password:   updatedUser.Password,
+		PublicKeys: updatedUser.PublicKeys,
+	})
+
 	err = dataprovider.UpdateUser(&updatedUser, claims.Username, ipAddr)
 	if err == nil {
 		if len(r.Form.Get("disconnect")) > 0 {
@@ -2017,6 +2037,7 @@ func (s *httpdServer) handleWebAddFolderPost(w http.ResponseWriter, r *http.Requ
 		return
 	}
 	folder.FsConfig = fsConfig
+	folder = getFolderFromTemplate(folder, folder.Name)
 
 	err = dataprovider.AddFolder(&folder)
 	if err == nil {
@@ -2073,7 +2094,7 @@ func (s *httpdServer) handleWebUpdateFolderPost(w http.ResponseWriter, r *http.R
 		s.renderFolderPage(w, r, folder, folderPageModeUpdate, err.Error())
 		return
 	}
-	updatedFolder := &vfs.BaseVirtualFolder{
+	updatedFolder := vfs.BaseVirtualFolder{
 		MappedPath:  r.Form.Get("mapped_path"),
 		Description: r.Form.Get("description"),
 	}
@@ -2085,7 +2106,9 @@ func (s *httpdServer) handleWebUpdateFolderPost(w http.ResponseWriter, r *http.R
 		folder.FsConfig.AzBlobConfig.SASURL, folder.FsConfig.GCSConfig.Credentials, folder.FsConfig.CryptConfig.Passphrase,
 		folder.FsConfig.SFTPConfig.Password, folder.FsConfig.SFTPConfig.PrivateKey)
 
-	err = dataprovider.UpdateFolder(updatedFolder, folder.Users, claims.Username, ipAddr)
+	updatedFolder = getFolderFromTemplate(updatedFolder, updatedFolder.Name)
+
+	err = dataprovider.UpdateFolder(&updatedFolder, folder.Users, claims.Username, ipAddr)
 	if err != nil {
 		s.renderFolderPage(w, r, folder, folderPageModeUpdate, err.Error())
 		return

+ 0 - 3
httpdtest/httpdtest.go

@@ -1275,9 +1275,6 @@ func compareS3Config(expected *vfs.Filesystem, actual *vfs.Filesystem) error { /
 	if expected.S3Config.AccessKey != actual.S3Config.AccessKey {
 		return errors.New("fs S3 access key mismatch")
 	}
-	if expected.S3Config.SessionToken != actual.S3Config.SessionToken {
-		return errors.New("fs S3 session token mismatch")
-	}
 	if expected.S3Config.RoleARN != actual.S3Config.RoleARN {
 		return errors.New("fs S3 role ARN mismatch")
 	}

+ 1 - 3
openapi/openapi.yaml

@@ -4729,11 +4729,9 @@ components:
           type: string
         access_secret:
           $ref: '#/components/schemas/Secret'
-        session_token:
-          type: string
         role_arn:
           type: string
-          description: 'IAM Role ARN to assume'
+          description: 'Optional IAM Role ARN to assume'
         endpoint:
           type: string
           description: optional endpoint

+ 1 - 1
telemetry/telemetry.go

@@ -33,7 +33,7 @@ var (
 
 // Conf telemetry server configuration.
 type Conf struct {
-	// The port used for serving HTTP requests. 0 disable the HTTP server. Default: 10000
+	// The port used for serving HTTP requests. 0 disable the HTTP server. Default: 0
 	BindPort int `json:"bind_port" mapstructure:"bind_port"`
 	// The address to listen on. A blank value means listen on all available network interfaces. Default: "127.0.0.1"
 	BindAddress string `json:"bind_address" mapstructure:"bind_address"`

+ 1 - 1
templates/webadmin/folder.html

@@ -23,7 +23,7 @@
                 <ul>
                     <li><span class="text-success">%name%</span> will be replaced with the specified folder name</li>
                 </ul>
-                The generated folders file can be imported from the "Maintenance" section.
+                The generated folders can be saved or exported. Exported folders can be imported from the "Maintenance" section of this SFTPGo instance or another.
             </div>
         </div>
         {{end}}

+ 35 - 20
templates/webadmin/fsconfig.html

@@ -70,13 +70,19 @@
             <label for="idS3StorageClass" class="col-sm-2 col-form-label">Storage Class</label>
             <div class="col-sm-3">
                 <input type="text" class="form-control" id="idS3StorageClass" name="s3_storage_class" placeholder=""
-                    value="{{.S3Config.StorageClass}}" maxlength="255">
+                    value="{{.S3Config.StorageClass}}" maxlength="255" aria-describedby="S3StorageClassHelpBlock">
+                <small id="S3StorageClassHelpBlock" class="form-text text-muted">
+                    Leave blank for default
+                </small>
             </div>
             <div class="col-sm-2"></div>
             <label for="idS3Endpoint" class="col-sm-2 col-form-label">Endpoint</label>
             <div class="col-sm-3">
                 <input type="text" class="form-control" id="idS3Endpoint" name="s3_endpoint" placeholder=""
-                    value="{{.S3Config.Endpoint}}" maxlength="255">
+                    value="{{.S3Config.Endpoint}}" maxlength="512" aria-describedby="S3EndpointHelpBlock">
+                    <small id="S3EndpointHelpBlock" class="form-text text-muted">
+                        For AWS S3, leave blank to use the default endpoint for the specified region
+                    </small>
             </div>
         </div>
 
@@ -150,7 +156,7 @@
                 <input type="text" class="form-control" id="idS3RoleARN" name="s3_role_arn" placeholder=""
                     value="{{.S3Config.RoleARN}}" aria-describedby="S3RoleARNHelpBlock">
                 <small id="S3RoleARNHelpBlock" class="form-text text-muted">
-                    IAM Role ARN to assume
+                    Optional IAM Role ARN to assume
                 </small>
             </div>
         </div>
@@ -161,7 +167,7 @@
                 <input type="text" class="form-control" id="idS3ACL" name="s3_acl" placeholder=""
                     value="{{.S3Config.ACL}}" maxlength="255" aria-describedby="S3ACLHelpBlock">
                 <small id="S3ACLHelpBlock" class="form-text text-muted">
-                    ACL for uploaded objects. For more info take a look <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl" target="_blank">here</a>
+                    ACL for uploaded objects. Leave blank for default. For more info take a look <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl" target="_blank">here</a>
                 </small>
             </div>
         </div>
@@ -177,20 +183,14 @@
             </div>
         </div>
 
-        <div class="form-group row fsconfig fsconfig-s3fs">
-            <label for="idS3SessionToken" class="col-sm-2 col-form-label">Session token</label>
-            <div class="col-sm-10">
-                <textarea class="form-control" id="idS3SessionToken" name="s3_session_token"
-                    rows="3">{{.S3Config.SessionToken}}</textarea>
-            </div>
-        </div>
-
-
         <div class="form-group fsconfig fsconfig-s3fs">
             <div class="form-check">
                 <input type="checkbox" class="form-check-input" id="idS3ForcePathStyle" name="s3_force_path_style"
                     {{if .S3Config.ForcePathStyle}}checked{{end}}>
-                <label for="idS3ForcePathStyle" class="form-check-label">Use path-style addressing, i.e., "`endpoint`/BUCKET/KEY"</label>
+                <label for="idS3ForcePathStyle" class="form-check-label" aria-describedby="S3PathStyleHelpBlock">Use path-style addressing, i.e., "`endpoint`/BUCKET/KEY"</label>
+                <small id="S3PathStyleHelpBlock" class="form-text text-muted">
+                    It is required for some compatible S3 backends
+                </small>
             </div>
         </div>
 
@@ -215,15 +215,21 @@
             <label for="idGCSStorageClass" class="col-sm-2 col-form-label">Storage Class</label>
             <div class="col-sm-3">
                 <input type="text" class="form-control" id="idGCSStorageClass" name="gcs_storage_class" placeholder=""
-                    value="{{.GCSConfig.StorageClass}}" maxlength="255">
+                    value="{{.GCSConfig.StorageClass}}" maxlength="255" aria-describedby="GCSStorageClassHelpBlock">
+                <small id="GCSStorageClassHelpBlock" class="form-text text-muted">
+                    Leave blank for default
+                </small>
             </div>
         </div>
 
         <div class="form-group fsconfig fsconfig-gcsfs">
             <div class="form-check">
                 <input type="checkbox" class="form-check-input" id="idGCSAutoCredentials" name="gcs_auto_credentials"
-                    {{if gt .GCSConfig.AutomaticCredentials 0}}checked{{end}}>
+                    aria-describedby="GCSAutoCredentialsHelpBlock" {{if gt .GCSConfig.AutomaticCredentials 0}}checked{{end}}>
                 <label for="idGCSAutoCredentials" class="form-check-label">Automatic credentials</label>
+                <small id="GCSAutoCredentialsHelpBlock" class="form-text text-muted">
+                    Use default application credentials or credentials from environment
+                </small>
             </div>
         </div>
 
@@ -242,7 +248,7 @@
                 <input type="text" class="form-control" id="idGCSACL" name="gcs_acl" placeholder=""
                     value="{{.GCSConfig.ACL}}" maxlength="255" aria-describedby="GCSACLHelpBlock">
                 <small id="GCSACLHelpBlock" class="form-text text-muted">
-                    ACL for uploaded objects. For more info refer to the JSON API <a href="https://cloud.google.com/storage/docs/access-control/lists#predefined-acl" target="_blank">here</a>
+                    ACL for uploaded objects. Leave blank for default. For more info refer to the JSON API <a href="https://cloud.google.com/storage/docs/access-control/lists#predefined-acl" target="_blank">here</a>
                 </small>
             </div>
         </div>
@@ -272,15 +278,21 @@
         <div class="form-group row fsconfig fsconfig-azblobfs">
             <label for="idAzSASURL" class="col-sm-2 col-form-label">SAS URL</label>
             <div class="col-sm-10">
-                <input type="password" class="form-control" id="idAzSASURL" name="az_sas_url" placeholder=""
+                <input type="password" class="form-control" id="idAzSASURL" name="az_sas_url" placeholder="" aria-describedby="AzSASURLHelpBlock"
                     value="{{if .AzBlobConfig.SASURL.IsEncrypted}}{{.RedactedSecret}}{{else}}{{.AzBlobConfig.SASURL.GetPayload}}{{end}}">
+                <small id="AzSASURLHelpBlock" class="form-text text-muted">
+                    Shared Access Signature URL can be used instead of account name/key
+                </small>
             </div>
         </div>
         <div class="form-group row fsconfig fsconfig-azblobfs">
             <label for="idAzEndpoint" class="col-sm-2 col-form-label">Endpoint</label>
             <div class="col-sm-10">
                 <input type="text" class="form-control" id="idAzEndpoint" name="az_endpoint" placeholder=""
-                    value="{{.AzBlobConfig.Endpoint}}" maxlength="255">
+                    aria-describedby="AzEndpointHelpBlock" value="{{.AzBlobConfig.Endpoint}}" maxlength="512">
+                <small id="AzEndpointHelpBlock" class="form-text text-muted">
+                    Optional endpoint
+                </small>
             </div>
         </div>
 
@@ -362,8 +374,11 @@
             <label for="idCryptPassphrase" class="col-sm-2 col-form-label">Passphrase</label>
             <div class="col-sm-10">
                 <input type="password" class="form-control" id="idCryptPassphrase" name="crypt_passphrase"
-                    placeholder=""
+                    placeholder="" aria-describedby="CryptPassphraseHelpBlock"
                     value="{{if .CryptConfig.Passphrase.IsEncrypted}}{{.RedactedSecret}}{{else}}{{.CryptConfig.Passphrase.GetPayload}}{{end}}">
+                <small id="CryptPassphraseHelpBlock" class="form-text text-muted">
+                    Passphrase to derive the per-object encryption key
+                </small>
             </div>
         </div>
 

+ 3 - 1
templates/webadmin/user.html

@@ -30,7 +30,9 @@
                     <li><span class="text-success">%username%</span> will be replaced with the specified username</li>
                     <li><span class="text-success">%password%</span> will be replaced with the specified password</li>
                 </ul>
-                The generated users file can be imported from the "Maintenance" section.
+                They will be replaced, with the specified username and password, in the paths and credentials of the configured storage backend.
+                <br>
+                The generated users can be saved or exported. Exported users can be imported from the "Maintenance" section of this SFTPGo instance or another.
                 {{if .User.Username}}
                 <br>
                 Please note that no credentials were copied from user "{{.User.Username}}", you have to set them explicitly.

+ 0 - 1
vfs/filesystem.go

@@ -244,7 +244,6 @@ func (f *Filesystem) GetACopy() Filesystem {
 				Bucket:              f.S3Config.Bucket,
 				Region:              f.S3Config.Region,
 				AccessKey:           f.S3Config.AccessKey,
-				SessionToken:        f.S3Config.SessionToken,
 				RoleARN:             f.S3Config.RoleARN,
 				Endpoint:            f.S3Config.Endpoint,
 				StorageClass:        f.S3Config.StorageClass,

+ 1 - 2
vfs/s3fs.go

@@ -92,8 +92,7 @@ func NewS3Fs(connectionID, localTempDir, mountPath string, s3Config S3FsConfig)
 			return fs, err
 		}
 		awsConfig.Credentials = aws.NewCredentialsCache(
-			credentials.NewStaticCredentialsProvider(fs.config.AccessKey, fs.config.AccessSecret.GetPayload(),
-				fs.config.SessionToken))
+			credentials.NewStaticCredentialsProvider(fs.config.AccessKey, fs.config.AccessSecret.GetPayload(), ""))
 	}
 	if fs.config.Endpoint != "" {
 		endpointResolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {

+ 0 - 3
vfs/vfs.go

@@ -173,9 +173,6 @@ func (c *S3FsConfig) isEqual(other *S3FsConfig) bool {
 	if c.AccessKey != other.AccessKey {
 		return false
 	}
-	if c.SessionToken != other.SessionToken {
-		return false
-	}
 	if c.RoleARN != other.RoleARN {
 		return false
 	}