Ver Fonte

web ui: allow to create multiple users from a template

Nicola Murino há 4 anos atrás
pai
commit
54321c5240

+ 3 - 3
dataprovider/bolt.go

@@ -397,7 +397,7 @@ func (p *BoltProvider) userExists(username string) (User, error) {
 }
 
 func (p *BoltProvider) addUser(user *User) error {
-	err := validateUser(user)
+	err := ValidateUser(user)
 	if err != nil {
 		return err
 	}
@@ -437,7 +437,7 @@ func (p *BoltProvider) addUser(user *User) error {
 }
 
 func (p *BoltProvider) updateUser(user *User) error {
-	err := validateUser(user)
+	err := ValidateUser(user)
 	if err != nil {
 		return err
 	}
@@ -1003,7 +1003,7 @@ func updateV4BoltCompatUser(dbHandle *bolt.DB, user compatUserV4) error {
 }
 
 func updateV4BoltUser(dbHandle *bolt.DB, user User) error {
-	err := validateUser(&user)
+	err := ValidateUser(&user)
 	if err != nil {
 		return err
 	}

+ 3 - 1
dataprovider/dataprovider.go

@@ -1323,7 +1323,9 @@ func validateFolder(folder *vfs.BaseVirtualFolder) error {
 	return nil
 }
 
-func validateUser(user *User) error {
+// ValidateUser returns an error if the user is not valid
+// FIXME: this should be defined as User struct method
+func ValidateUser(user *User) error {
 	user.SetEmptySecretsIfNil()
 	buildUserHomeDir(user)
 	if err := validateBaseParams(user); err != nil {

+ 2 - 2
dataprovider/memory.go

@@ -185,7 +185,7 @@ func (p *MemoryProvider) addUser(user *User) error {
 	if p.dbHandle.isClosed {
 		return errMemoryProviderClosed
 	}
-	err := validateUser(user)
+	err := ValidateUser(user)
 	if err != nil {
 		return err
 	}
@@ -211,7 +211,7 @@ func (p *MemoryProvider) updateUser(user *User) error {
 	if p.dbHandle.isClosed {
 		return errMemoryProviderClosed
 	}
-	err := validateUser(user)
+	err := ValidateUser(user)
 	if err != nil {
 		return err
 	}

+ 2 - 2
dataprovider/sqlcommon.go

@@ -306,7 +306,7 @@ func sqlCommonUpdateLastLogin(username string, dbHandle *sql.DB) error {
 }
 
 func sqlCommonAddUser(user *User, dbHandle *sql.DB) error {
-	err := validateUser(user)
+	err := ValidateUser(user)
 	if err != nil {
 		return err
 	}
@@ -360,7 +360,7 @@ func sqlCommonAddUser(user *User, dbHandle *sql.DB) error {
 }
 
 func sqlCommonUpdateUser(user *User, dbHandle *sql.DB) error {
-	err := validateUser(user)
+	err := ValidateUser(user)
 	if err != nil {
 		return err
 	}

+ 12 - 2
dataprovider/user.go

@@ -261,6 +261,16 @@ func (u *User) HideConfidentialData() {
 	}
 }
 
+// SetEmptySecrets sets to empty any user secret
+func (u *User) SetEmptySecrets() {
+	u.FsConfig.S3Config.AccessSecret = kms.NewEmptySecret()
+	u.FsConfig.GCSConfig.Credentials = kms.NewEmptySecret()
+	u.FsConfig.AzBlobConfig.AccountKey = kms.NewEmptySecret()
+	u.FsConfig.CryptConfig.Passphrase = kms.NewEmptySecret()
+	u.FsConfig.SFTPConfig.Password = kms.NewEmptySecret()
+	u.FsConfig.SFTPConfig.PrivateKey = kms.NewEmptySecret()
+}
+
 // DecryptSecrets tries to decrypts kms secrets
 func (u *User) DecryptSecrets() error {
 	switch u.FsConfig.Provider {
@@ -801,12 +811,12 @@ func (u *User) GetExpirationDateAsString() string {
 }
 
 // GetAllowedIPAsString returns the allowed IP as comma separated string
-func (u User) GetAllowedIPAsString() string {
+func (u *User) GetAllowedIPAsString() string {
 	return strings.Join(u.Filters.AllowedIP, ",")
 }
 
 // GetDeniedIPAsString returns the denied IP as comma separated string
-func (u User) GetDeniedIPAsString() string {
+func (u *User) GetDeniedIPAsString() string {
 	return strings.Join(u.Filters.DeniedIP, ",")
 }
 

+ 1 - 0
httpd/httpd.go

@@ -60,6 +60,7 @@ const (
 	webScanVFolderPath        = "/web/folder-quota-scans"
 	webQuotaScanPath          = "/web/quota-scans"
 	webChangeAdminPwdPath     = "/web/changepwd/admin"
+	webTemplateUser           = "/web/template/user"
 	webStaticFilesPath        = "/static"
 	// MaxRestoreSize defines the max size for the loaddata input file
 	MaxRestoreSize = 10485760 // 10 MB

+ 144 - 24
httpd/httpd_test.go

@@ -76,6 +76,7 @@ const (
 	webMaintenancePath        = "/web/maintenance"
 	webRestorePath            = "/web/restore"
 	webChangeAdminPwdPath     = "/web/changepwd/admin"
+	webTemplateUser           = "/web/template/user"
 	httpBaseURL               = "http://127.0.0.1:8081"
 	configDir                 = ".."
 	httpsCert                 = `-----BEGIN CERTIFICATE-----
@@ -2214,11 +2215,16 @@ func TestProviderErrors(t *testing.T) {
 	assert.NoError(t, err)
 	err = os.Remove(backupFilePath)
 	assert.NoError(t, err)
-	req, err := http.NewRequest(http.MethodGet, webUserPath+"?cloneFrom=user", nil)
+	req, err := http.NewRequest(http.MethodGet, webUserPath+"?clone-from=user", nil)
 	assert.NoError(t, err)
 	setJWTCookieForReq(req, testServerToken)
 	rr := executeRequest(req)
 	checkResponseCode(t, http.StatusInternalServerError, rr)
+	req, err = http.NewRequest(http.MethodGet, webTemplateUser+"?from=auser", nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, testServerToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusInternalServerError, rr)
 	err = config.LoadConfig(configDir, "")
 	assert.NoError(t, err)
 	providerConf := config.GetProviderConf()
@@ -4439,49 +4445,163 @@ func TestWebUserUpdateMock(t *testing.T) {
 	checkResponseCode(t, http.StatusOK, rr)
 }
 
-func TestRenderWebCloneUserMock(t *testing.T) {
+func TestRenderUserTemplateMock(t *testing.T) {
 	token, err := getJWTTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
 	assert.NoError(t, err)
+	req, err := http.NewRequest(http.MethodGet, webTemplateUser, nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, token)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	assert.NoError(t, err)
 
-	req, err := http.NewRequest(http.MethodGet, webUserPath+fmt.Sprintf("?cloneFrom=%v", user.Username), nil)
+	req, err = http.NewRequest(http.MethodGet, webTemplateUser+fmt.Sprintf("?from=%v", user.Username), nil)
 	assert.NoError(t, err)
 	setJWTCookieForReq(req, token)
-	rr := executeRequest(req)
+	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 
-	req, err = http.NewRequest(http.MethodGet, webUserPath+fmt.Sprintf("?cloneFrom=%v", altAdminPassword), nil)
+	req, err = http.NewRequest(http.MethodGet, webTemplateUser+"?from=unknown", nil)
 	assert.NoError(t, err)
 	setJWTCookieForReq(req, token)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
 
-	if config.GetProviderConf().Driver != "memory" {
-		user.FsConfig = dataprovider.Filesystem{
-			Provider: dataprovider.CryptedFilesystemProvider,
-			CryptConfig: vfs.CryptFsConfig{
-				Passphrase: kms.NewPlainSecret("secret"),
-			},
-		}
-		err = user.FsConfig.CryptConfig.Passphrase.Encrypt()
-		assert.NoError(t, err)
-		user.FsConfig.CryptConfig.Passphrase.SetStatus(kms.SecretStatusAWS)
-		user.Password = defaultPassword
-		err = dataprovider.UpdateUser(&user)
-		assert.NoError(t, err)
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+}
 
-		req, err = http.NewRequest(http.MethodGet, webUserPath+fmt.Sprintf("?cloneFrom=%v", user.Username), nil)
-		assert.NoError(t, err)
-		setJWTCookieForReq(req, token)
-		rr = executeRequest(req)
-		checkResponseCode(t, http.StatusInternalServerError, rr)
-	}
+func TestRenderWebCloneUserMock(t *testing.T) {
+	token, err := getJWTTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
+	assert.NoError(t, err)
+	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+	assert.NoError(t, err)
+
+	req, err := http.NewRequest(http.MethodGet, webUserPath+fmt.Sprintf("?clone-from=%v", user.Username), nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, token)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	req, err = http.NewRequest(http.MethodGet, webUserPath+fmt.Sprintf("?clone-from=%v", altAdminPassword), nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
 
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
 	assert.NoError(t, err)
 }
 
+func TestUserTemplateMock(t *testing.T) {
+	token, err := getJWTTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
+	assert.NoError(t, err)
+	user := getTestUser()
+	user.FsConfig.Provider = dataprovider.S3FilesystemProvider
+	user.FsConfig.S3Config.Bucket = "test"
+	user.FsConfig.S3Config.Region = "eu-central-1"
+	user.FsConfig.S3Config.AccessKey = "%username%"
+	user.FsConfig.S3Config.KeyPrefix = "somedir/subdir/"
+	user.FsConfig.S3Config.UploadPartSize = 5
+	user.FsConfig.S3Config.UploadConcurrency = 4
+	form := make(url.Values)
+	form.Set("username", user.Username)
+	form.Set("home_dir", filepath.Join(os.TempDir(), "%username%"))
+	form.Set("uid", "0")
+	form.Set("gid", strconv.FormatInt(int64(user.GID), 10))
+	form.Set("max_sessions", strconv.FormatInt(int64(user.MaxSessions), 10))
+	form.Set("quota_size", strconv.FormatInt(user.QuotaSize, 10))
+	form.Set("quota_files", strconv.FormatInt(int64(user.QuotaFiles), 10))
+	form.Set("upload_bandwidth", "0")
+	form.Set("download_bandwidth", "0")
+	form.Set("permissions", "*")
+	form.Set("sub_dirs_permissions", "")
+	form.Set("status", strconv.Itoa(user.Status))
+	form.Set("expiration_date", "2020-01-01 00:00:00")
+	form.Set("allowed_ip", "")
+	form.Set("denied_ip", "")
+	form.Set("fs_provider", "1")
+	form.Set("s3_bucket", user.FsConfig.S3Config.Bucket)
+	form.Set("s3_region", user.FsConfig.S3Config.Region)
+	form.Set("s3_access_key", "%username%")
+	form.Set("s3_access_secret", "%password%")
+	form.Set("s3_key_prefix", "base/%username%")
+	form.Set("allowed_extensions", "/dir1::.jpg,.png")
+	form.Set("denied_extensions", "/dir2::.zip")
+	form.Set("max_upload_file_size", "0")
+	// test invalid s3_upload_part_size
+	form.Set("s3_upload_part_size", "a")
+	b, contentType, _ := getMultipartFormData(form, "", "")
+	req, _ := http.NewRequest(http.MethodPost, path.Join(webTemplateUser), &b)
+	setJWTCookieForReq(req, token)
+	req.Header.Set("Content-Type", contentType)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	form.Set("s3_upload_part_size", strconv.FormatInt(user.FsConfig.S3Config.UploadPartSize, 10))
+	form.Set("s3_upload_concurrency", strconv.Itoa(user.FsConfig.S3Config.UploadConcurrency))
+
+	b, contentType, _ = getMultipartFormData(form, "", "")
+	req, _ = http.NewRequest(http.MethodPost, path.Join(webTemplateUser), &b)
+	setJWTCookieForReq(req, token)
+	req.Header.Set("Content-Type", contentType)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+
+	form.Set("users", "user1::password1::invalid-pkey")
+	b, contentType, _ = getMultipartFormData(form, "", "")
+	req, _ = http.NewRequest(http.MethodPost, path.Join(webTemplateUser), &b)
+	setJWTCookieForReq(req, token)
+	req.Header.Set("Content-Type", contentType)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	require.Contains(t, rr.Body.String(), "Error validating user")
+
+	form.Set("users", "user1:password1")
+	b, contentType, _ = getMultipartFormData(form, "", "")
+	req, _ = http.NewRequest(http.MethodPost, path.Join(webTemplateUser), &b)
+	setJWTCookieForReq(req, token)
+	req.Header.Set("Content-Type", contentType)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	require.Contains(t, rr.Body.String(), "No valid users found, export is not possible")
+
+	form.Set("users", "user1::password1\nuser2::password2::"+testPubKey+"\nuser3::::")
+	b, contentType, _ = getMultipartFormData(form, "", "")
+	req, _ = http.NewRequest(http.MethodPost, path.Join(webTemplateUser), &b)
+	setJWTCookieForReq(req, token)
+	req.Header.Set("Content-Type", contentType)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	var dump dataprovider.BackupData
+	err = json.Unmarshal(rr.Body.Bytes(), &dump)
+	require.NoError(t, err)
+	require.Len(t, dump.Users, 2)
+	user1 := dump.Users[0]
+	user2 := dump.Users[1]
+	require.Equal(t, "user1", user1.Username)
+	require.Equal(t, dataprovider.S3FilesystemProvider, user1.FsConfig.Provider)
+	require.Equal(t, "user2", user2.Username)
+	require.Equal(t, dataprovider.S3FilesystemProvider, user2.FsConfig.Provider)
+	require.Len(t, user2.PublicKeys, 1)
+	require.Equal(t, filepath.Join(os.TempDir(), user1.Username), user1.HomeDir)
+	require.Equal(t, filepath.Join(os.TempDir(), user2.Username), user2.HomeDir)
+	require.Equal(t, user1.Username, user1.FsConfig.S3Config.AccessKey)
+	require.Equal(t, user2.Username, user2.FsConfig.S3Config.AccessKey)
+	require.Equal(t, path.Join("base", user1.Username)+"/", user1.FsConfig.S3Config.KeyPrefix)
+	require.Equal(t, path.Join("base", user2.Username)+"/", user2.FsConfig.S3Config.KeyPrefix)
+	require.True(t, user1.FsConfig.S3Config.AccessSecret.IsEncrypted())
+	err = user1.FsConfig.S3Config.AccessSecret.Decrypt()
+	require.NoError(t, err)
+	require.Equal(t, "password1", user1.FsConfig.S3Config.AccessSecret.GetPayload())
+	require.True(t, user2.FsConfig.S3Config.AccessSecret.IsEncrypted())
+	err = user2.FsConfig.S3Config.AccessSecret.Decrypt()
+	require.NoError(t, err)
+	require.Equal(t, "password2", user2.FsConfig.S3Config.AccessSecret.GetPayload())
+}
+
 func TestWebUserS3Mock(t *testing.T) {
 	token, err := getJWTTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
 	assert.NoError(t, err)

+ 50 - 0
httpd/internal_test.go

@@ -29,7 +29,9 @@ import (
 
 	"github.com/drakkan/sftpgo/common"
 	"github.com/drakkan/sftpgo/dataprovider"
+	"github.com/drakkan/sftpgo/kms"
 	"github.com/drakkan/sftpgo/utils"
+	"github.com/drakkan/sftpgo/vfs"
 )
 
 const (
@@ -705,3 +707,51 @@ func TestVerifyTLSConnection(t *testing.T) {
 
 	certMgr = oldCertMgr
 }
+
+func TestGetUserFromTemplate(t *testing.T) {
+	user := dataprovider.User{
+		Status: 1,
+	}
+	user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{
+		BaseVirtualFolder: vfs.BaseVirtualFolder{
+			MappedPath: "dir%username%",
+		},
+	})
+
+	username := "userTemplate"
+	password := "pwdTemplate"
+	templateFields := userTemplateFields{
+		Username: username,
+		Password: password,
+	}
+
+	userTemplate := getUserFromTemplate(user, templateFields)
+	require.Len(t, userTemplate.VirtualFolders, 1)
+	require.Equal(t, "dir"+username, userTemplate.VirtualFolders[0].MappedPath)
+
+	user.FsConfig.Provider = dataprovider.CryptedFilesystemProvider
+	user.FsConfig.CryptConfig.Passphrase = kms.NewPlainSecret("%password%")
+	userTemplate = getUserFromTemplate(user, templateFields)
+	require.Equal(t, password, userTemplate.FsConfig.CryptConfig.Passphrase.GetPayload())
+
+	user.FsConfig.Provider = dataprovider.GCSFilesystemProvider
+	user.FsConfig.GCSConfig.KeyPrefix = "%username%%password%"
+	userTemplate = getUserFromTemplate(user, templateFields)
+	require.Equal(t, username+password, userTemplate.FsConfig.GCSConfig.KeyPrefix)
+
+	user.FsConfig.Provider = dataprovider.AzureBlobFilesystemProvider
+	user.FsConfig.AzBlobConfig.KeyPrefix = "a%username%"
+	user.FsConfig.AzBlobConfig.AccountKey = kms.NewPlainSecret("pwd%password%%username%")
+	userTemplate = getUserFromTemplate(user, templateFields)
+	require.Equal(t, "a"+username, userTemplate.FsConfig.AzBlobConfig.KeyPrefix)
+	require.Equal(t, "pwd"+password+username, userTemplate.FsConfig.AzBlobConfig.AccountKey.GetPayload())
+
+	user.FsConfig.Provider = dataprovider.SFTPFilesystemProvider
+	user.FsConfig.SFTPConfig.Prefix = "%username%"
+	user.FsConfig.SFTPConfig.Username = "sftp_%username%"
+	user.FsConfig.SFTPConfig.Password = kms.NewPlainSecret("sftp%password%")
+	userTemplate = getUserFromTemplate(user, templateFields)
+	require.Equal(t, username, userTemplate.FsConfig.SFTPConfig.Prefix)
+	require.Equal(t, "sftp_"+username, userTemplate.FsConfig.SFTPConfig.Username)
+	require.Equal(t, "sftp"+password, userTemplate.FsConfig.SFTPConfig.Password.GetPayload())
+}

+ 3 - 0
httpd/server.go

@@ -369,6 +369,9 @@ func (s *httpdServer) initializeRouter() {
 				router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(webMaintenancePath, handleWebMaintenance)
 				router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(webBackupPath, dumpData)
 				router.With(checkPerm(dataprovider.PermAdminManageSystem)).Post(webRestorePath, handleWebRestore)
+				router.With(checkPerm(dataprovider.PermAdminManageSystem), s.refreshCookie).
+					Get(webTemplateUser, handleWebTemplateUserGet)
+				router.With(checkPerm(dataprovider.PermAdminManageSystem)).Post(webTemplateUser, handleWebTemplateUserPost)
 			})
 
 			router.Group(func(router chi.Router) {

+ 243 - 35
httpd/web.go

@@ -13,6 +13,8 @@ import (
 	"strings"
 	"time"
 
+	"github.com/go-chi/render"
+
 	"github.com/drakkan/sftpgo/common"
 	"github.com/drakkan/sftpgo/dataprovider"
 	"github.com/drakkan/sftpgo/kms"
@@ -21,6 +23,14 @@ import (
 	"github.com/drakkan/sftpgo/vfs"
 )
 
+type userPageMode int
+
+const (
+	userPageModeAdd userPageMode = iota + 1
+	userPageModeUpdate
+	userPageModeTemplate
+)
+
 const (
 	templateBase         = "base.html"
 	templateUsers        = "users.html"
@@ -62,6 +72,7 @@ type basePage struct {
 	CurrentURL         string
 	UsersURL           string
 	UserURL            string
+	UserTemplateURL    string
 	AdminsURL          string
 	AdminURL           string
 	QuotaScanURL       string
@@ -110,7 +121,7 @@ type statusPage struct {
 
 type userPage struct {
 	basePage
-	User                 dataprovider.User
+	User                 *dataprovider.User
 	RootPerms            []string
 	Error                string
 	ValidPerms           []string
@@ -118,7 +129,7 @@ type userPage struct {
 	ValidProtocols       []string
 	RootDirPerms         []string
 	RedactedSecret       string
-	IsAdd                bool
+	Mode                 userPageMode
 }
 
 type adminPage struct {
@@ -158,6 +169,12 @@ type loginPage struct {
 	Error      string
 }
 
+type userTemplateFields struct {
+	Username  string
+	Password  string
+	PublicKey string
+}
+
 func loadTemplates(templatesPath string) {
 	usersPaths := []string{
 		filepath.Join(templatesPath, templateBase),
@@ -239,6 +256,7 @@ func getBasePageData(title, currentURL string, r *http.Request) basePage {
 		CurrentURL:         currentURL,
 		UsersURL:           webUsersPath,
 		UserURL:            webUserPath,
+		UserTemplateURL:    webTemplateUser,
 		AdminsURL:          webAdminsPath,
 		AdminURL:           webAdminPath,
 		FoldersURL:         webFoldersPath,
@@ -337,27 +355,23 @@ func renderAddUpdateAdminPage(w http.ResponseWriter, r *http.Request, admin *dat
 	renderTemplate(w, templateAdmin, data)
 }
 
-func renderAddUserPage(w http.ResponseWriter, r *http.Request, user dataprovider.User, error string) {
+func renderUserPage(w http.ResponseWriter, r *http.Request, user *dataprovider.User, mode userPageMode, error string) {
 	user.SetEmptySecretsIfNil()
-	data := userPage{
-		basePage:             getBasePageData("Add a new user", webUserPath, r),
-		IsAdd:                true,
-		Error:                error,
-		User:                 user,
-		ValidPerms:           dataprovider.ValidPerms,
-		ValidSSHLoginMethods: dataprovider.ValidSSHLoginMethods,
-		ValidProtocols:       dataprovider.ValidProtocols,
-		RootDirPerms:         user.GetPermissionsForPath("/"),
-		RedactedSecret:       redactedSecret,
+	var title, currentURL string
+	switch mode {
+	case userPageModeAdd:
+		title = "Add a new user"
+		currentURL = webUserPath
+	case userPageModeUpdate:
+		title = "Update user"
+		currentURL = fmt.Sprintf("%v/%v", webUserPath, url.PathEscape(user.Username))
+	case userPageModeTemplate:
+		title = "User template"
+		currentURL = webTemplateUser
 	}
-	renderTemplate(w, templateUser, data)
-}
-
-func renderUpdateUserPage(w http.ResponseWriter, r *http.Request, user dataprovider.User, error string) {
-	user.SetEmptySecretsIfNil()
 	data := userPage{
-		basePage:             getBasePageData("Update user", fmt.Sprintf("%v/%v", webUserPath, url.PathEscape(user.Username)), r),
-		IsAdd:                false,
+		basePage:             getBasePageData(title, currentURL, r),
+		Mode:                 mode,
 		Error:                error,
 		User:                 user,
 		ValidPerms:           dataprovider.ValidPerms,
@@ -378,6 +392,33 @@ func renderAddFolderPage(w http.ResponseWriter, r *http.Request, folder vfs.Base
 	renderTemplate(w, templateFolder, data)
 }
 
+func getUsersForTemplate(r *http.Request) []userTemplateFields {
+	var res []userTemplateFields
+	formValue := r.Form.Get("users")
+	for _, cleaned := range getSliceFromDelimitedValues(formValue, "\n") {
+		if strings.Contains(cleaned, "::") {
+			mapping := strings.Split(cleaned, "::")
+			if len(mapping) > 1 {
+				username := strings.TrimSpace(mapping[0])
+				password := strings.TrimSpace(mapping[1])
+				var publicKey string
+				if len(mapping) > 2 {
+					publicKey = strings.TrimSpace(mapping[2])
+				}
+				if username == "" || (password == "" && publicKey == "") {
+					continue
+				}
+				res = append(res, userTemplateFields{
+					Username:  username,
+					Password:  password,
+					PublicKey: publicKey,
+				})
+			}
+		}
+	}
+	return res
+}
+
 func getVirtualFoldersFromPostFields(r *http.Request) []vfs.VirtualFolder {
 	var virtualFolders []vfs.VirtualFolder
 	formValue := r.Form.Get("virtual_folders")
@@ -429,7 +470,7 @@ func getUserPermissionsFromPostFields(r *http.Request) map[string][]string {
 						perms = append(perms, cleanedPerm)
 					}
 				}
-				if len(dir) > 0 {
+				if dir != "" {
 					permissions[dir] = perms
 				}
 			}
@@ -442,7 +483,7 @@ func getSliceFromDelimitedValues(values, delimiter string) []string {
 	result := []string{}
 	for _, v := range strings.Split(values, delimiter) {
 		cleaned := strings.TrimSpace(v)
-		if len(cleaned) > 0 {
+		if cleaned != "" {
 			result = append(result, cleaned)
 		}
 	}
@@ -708,6 +749,128 @@ func getAdminFromPostFields(r *http.Request) (dataprovider.Admin, error) {
 	return admin, nil
 }
 
+func replacePlaceholders(field string, replacements map[string]string) string {
+	for k, v := range replacements {
+		field = strings.ReplaceAll(field, k, v)
+	}
+	return field
+}
+
+func getCryptFsFromTemplate(fsConfig vfs.CryptFsConfig, replacements map[string]string) vfs.CryptFsConfig {
+	if fsConfig.Passphrase != nil {
+		if fsConfig.Passphrase.IsPlain() {
+			payload := replacePlaceholders(fsConfig.Passphrase.GetPayload(), replacements)
+			fsConfig.Passphrase = kms.NewPlainSecret(payload)
+		}
+	}
+	return fsConfig
+}
+
+func getS3FsFromTemplate(fsConfig vfs.S3FsConfig, replacements map[string]string) vfs.S3FsConfig {
+	fsConfig.KeyPrefix = replacePlaceholders(fsConfig.KeyPrefix, replacements)
+	fsConfig.AccessKey = replacePlaceholders(fsConfig.AccessKey, replacements)
+	if fsConfig.AccessSecret != nil && fsConfig.AccessSecret.IsPlain() {
+		payload := replacePlaceholders(fsConfig.AccessSecret.GetPayload(), replacements)
+		fsConfig.AccessSecret = kms.NewPlainSecret(payload)
+	}
+	return fsConfig
+}
+
+func getGCSFsFromTemplate(fsConfig vfs.GCSFsConfig, replacements map[string]string) vfs.GCSFsConfig {
+	fsConfig.KeyPrefix = replacePlaceholders(fsConfig.KeyPrefix, replacements)
+	return fsConfig
+}
+
+func getAzBlobFsFromTemplate(fsConfig vfs.AzBlobFsConfig, replacements map[string]string) vfs.AzBlobFsConfig {
+	fsConfig.KeyPrefix = replacePlaceholders(fsConfig.KeyPrefix, replacements)
+	fsConfig.AccountName = replacePlaceholders(fsConfig.AccountName, replacements)
+	if fsConfig.AccountKey != nil && fsConfig.AccountKey.IsPlain() {
+		payload := replacePlaceholders(fsConfig.AccountKey.GetPayload(), replacements)
+		fsConfig.AccountKey = kms.NewPlainSecret(payload)
+	}
+	return fsConfig
+}
+
+func getSFTPFsFromTemplate(fsConfig vfs.SFTPFsConfig, replacements map[string]string) vfs.SFTPFsConfig {
+	fsConfig.Prefix = replacePlaceholders(fsConfig.Prefix, replacements)
+	fsConfig.Username = replacePlaceholders(fsConfig.Username, replacements)
+	if fsConfig.Password != nil && fsConfig.Password.IsPlain() {
+		payload := replacePlaceholders(fsConfig.Password.GetPayload(), replacements)
+		fsConfig.Password = kms.NewPlainSecret(payload)
+	}
+	return fsConfig
+}
+
+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)
+	}
+	replacements := make(map[string]string)
+	replacements["%username%"] = user.Username
+	user.Password = replacePlaceholders(user.Password, replacements)
+	replacements["%password%"] = user.Password
+
+	user.HomeDir = replacePlaceholders(user.HomeDir, replacements)
+	var vfolders []vfs.VirtualFolder
+	for _, vfolder := range user.VirtualFolders {
+		vfolder.MappedPath = replacePlaceholders(vfolder.MappedPath, replacements)
+		vfolders = append(vfolders, vfolder)
+	}
+	user.VirtualFolders = vfolders
+	user.AdditionalInfo = replacePlaceholders(user.AdditionalInfo, replacements)
+
+	switch user.FsConfig.Provider {
+	case dataprovider.CryptedFilesystemProvider:
+		user.FsConfig.CryptConfig = getCryptFsFromTemplate(user.FsConfig.CryptConfig, replacements)
+	case dataprovider.S3FilesystemProvider:
+		user.FsConfig.S3Config = getS3FsFromTemplate(user.FsConfig.S3Config, replacements)
+	case dataprovider.GCSFilesystemProvider:
+		user.FsConfig.GCSConfig = getGCSFsFromTemplate(user.FsConfig.GCSConfig, replacements)
+	case dataprovider.AzureBlobFilesystemProvider:
+		user.FsConfig.AzBlobConfig = getAzBlobFsFromTemplate(user.FsConfig.AzBlobConfig, replacements)
+	case dataprovider.SFTPFilesystemProvider:
+		user.FsConfig.SFTPConfig = getSFTPFsFromTemplate(user.FsConfig.SFTPConfig, replacements)
+	}
+
+	return user
+}
+
+/*func decryptSecretsForTemplateUser(user *dataprovider.User) error {
+	user.SetEmptySecretsIfNil()
+	switch user.FsConfig.Provider {
+	case dataprovider.CryptedFilesystemProvider:
+		if user.FsConfig.CryptConfig.Passphrase.IsEncrypted() {
+			return user.FsConfig.CryptConfig.Passphrase.Decrypt()
+		}
+	case dataprovider.S3FilesystemProvider:
+		if user.FsConfig.S3Config.AccessSecret.IsEncrypted() {
+			return user.FsConfig.S3Config.AccessSecret.Decrypt()
+		}
+	case dataprovider.GCSFilesystemProvider:
+		if user.FsConfig.GCSConfig.Credentials.IsEncrypted() {
+			return user.FsConfig.GCSConfig.Credentials.Decrypt()
+		}
+	case dataprovider.AzureBlobFilesystemProvider:
+		if user.FsConfig.AzBlobConfig.AccountKey.IsEncrypted() {
+			return user.FsConfig.AzBlobConfig.AccountKey.Decrypt()
+		}
+	case dataprovider.SFTPFilesystemProvider:
+		if user.FsConfig.SFTPConfig.Password.IsEncrypted() {
+			if err := user.FsConfig.SFTPConfig.Password.Decrypt(); err != nil {
+				return err
+			}
+		}
+		if user.FsConfig.SFTPConfig.PrivateKey.IsEncrypted() {
+			return user.FsConfig.SFTPConfig.PrivateKey.Decrypt()
+		}
+	}
+
+	return nil
+}*/
+
 func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
 	var user dataprovider.User
 	err := r.ParseMultipartForm(maxRequestSize)
@@ -1004,18 +1167,63 @@ func handleGetWebUsers(w http.ResponseWriter, r *http.Request) {
 	renderTemplate(w, templateUsers, data)
 }
 
+func handleWebTemplateUserGet(w http.ResponseWriter, r *http.Request) {
+	if r.URL.Query().Get("from") != "" {
+		username := r.URL.Query().Get("from")
+		user, err := dataprovider.UserExists(username)
+		if err == nil {
+			user.SetEmptySecrets()
+			renderUserPage(w, r, &user, userPageModeTemplate, "")
+		} else if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
+			renderNotFoundPage(w, r, err)
+		} else {
+			renderInternalServerErrorPage(w, r, err)
+		}
+	} else {
+		user := dataprovider.User{Status: 1}
+		renderUserPage(w, r, &user, userPageModeTemplate, "")
+	}
+}
+
+func handleWebTemplateUserPost(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	templateUser, err := getUserFromPostFields(r)
+	if err != nil {
+		renderMessagePage(w, r, "Error parsing user fields", "", http.StatusBadRequest, err, "")
+		return
+	}
+
+	var dump dataprovider.BackupData
+	dump.Version = dataprovider.DumpVersion
+
+	userTmplFields := getUsersForTemplate(r)
+	for _, tmpl := range userTmplFields {
+		u := getUserFromTemplate(templateUser, tmpl)
+		if err := dataprovider.ValidateUser(&u); err != nil {
+			renderMessagePage(w, r, fmt.Sprintf("Error validating user %#v", u.Username), "", http.StatusBadRequest, err, "")
+			return
+		}
+		dump.Users = append(dump.Users, u)
+	}
+
+	if len(dump.Users) == 0 {
+		renderMessagePage(w, r, "No users to export", "No valid users found, export is not possible", http.StatusBadRequest, nil, "")
+		return
+	}
+
+	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"sftpgo-%v-users-from-template.json\"", len(dump.Users)))
+	render.JSON(w, r, dump)
+}
+
 func handleWebAddUserGet(w http.ResponseWriter, r *http.Request) {
-	if r.URL.Query().Get("cloneFrom") != "" {
-		username := r.URL.Query().Get("cloneFrom")
+	if r.URL.Query().Get("clone-from") != "" {
+		username := r.URL.Query().Get("clone-from")
 		user, err := dataprovider.UserExists(username)
 		if err == nil {
 			user.ID = 0
 			user.Username = ""
-			if err := user.DecryptSecrets(); err != nil {
-				renderInternalServerErrorPage(w, r, err)
-				return
-			}
-			renderAddUserPage(w, r, user, "")
+			user.SetEmptySecrets()
+			renderUserPage(w, r, &user, userPageModeAdd, "")
 		} else if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
 			renderNotFoundPage(w, r, err)
 		} else {
@@ -1023,7 +1231,7 @@ func handleWebAddUserGet(w http.ResponseWriter, r *http.Request) {
 		}
 	} else {
 		user := dataprovider.User{Status: 1}
-		renderAddUserPage(w, r, user, "")
+		renderUserPage(w, r, &user, userPageModeAdd, "")
 	}
 }
 
@@ -1031,7 +1239,7 @@ func handleWebUpdateUserGet(w http.ResponseWriter, r *http.Request) {
 	username := getURLParam(r, "username")
 	user, err := dataprovider.UserExists(username)
 	if err == nil {
-		renderUpdateUserPage(w, r, user, "")
+		renderUserPage(w, r, &user, userPageModeUpdate, "")
 	} else if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
 		renderNotFoundPage(w, r, err)
 	} else {
@@ -1043,14 +1251,14 @@ func handleWebAddUserPost(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	user, err := getUserFromPostFields(r)
 	if err != nil {
-		renderAddUserPage(w, r, user, err.Error())
+		renderUserPage(w, r, &user, userPageModeAdd, err.Error())
 		return
 	}
 	err = dataprovider.AddUser(&user)
 	if err == nil {
 		http.Redirect(w, r, webUsersPath, http.StatusSeeOther)
 	} else {
-		renderAddUserPage(w, r, user, err.Error())
+		renderUserPage(w, r, &user, userPageModeAdd, err.Error())
 	}
 }
 
@@ -1067,7 +1275,7 @@ func handleWebUpdateUserPost(w http.ResponseWriter, r *http.Request) {
 	}
 	updatedUser, err := getUserFromPostFields(r)
 	if err != nil {
-		renderUpdateUserPage(w, r, user, err.Error())
+		renderUserPage(w, r, &user, userPageModeUpdate, err.Error())
 		return
 	}
 	updatedUser.ID = user.ID
@@ -1087,7 +1295,7 @@ func handleWebUpdateUserPost(w http.ResponseWriter, r *http.Request) {
 		}
 		http.Redirect(w, r, webUsersPath, http.StatusSeeOther)
 	} else {
-		renderUpdateUserPage(w, r, user, err.Error())
+		renderUserPage(w, r, &user, userPageModeUpdate, err.Error())
 	}
 }
 

+ 1 - 1
templates/folders.html

@@ -89,7 +89,7 @@ function deleteAction() {
         var table = $('#dataTable').DataTable();
         table.button('delete:name').enable(false);
         var folderPath = table.row({ selected: true }).data()[0];
-        var path = '{{.FolderURL}}' + "?folder_path=" + encodeURIComponent(folderPath);
+        var path = '{{.FolderURL}}' + "?folder-path=" + encodeURIComponent(folderPath);
         $('#deleteModal').modal('hide');
         $.ajax({
             url: path,

+ 3 - 3
templates/maintenance.html

@@ -5,7 +5,7 @@
 {{define "page_body"}}
 <div class="card shadow mb-4">
     <div class="card-header py-3">
-        <h6 class="m-0 font-weight-bold text-primary">Restore</h6>
+        <h6 class="m-0 font-weight-bold text-primary">Import</h6>
     </div>
     <div class="card-body">
         {{if .Error}}
@@ -20,7 +20,7 @@
                     <input type="file" class="form-control-file" id="idBackupFile" name="backup_file"
                         aria-describedby="BackupFileHelpBlock">
                     <small id="BackupFileHelpBlock" class="form-text text-muted">
-                        Restore data from a JSON backup file
+                        Import data from a JSON backup file
                     </small>
                 </div>
             </div>
@@ -44,7 +44,7 @@
                     </select>
                 </div>
             </div>
-            <button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">Restore</button>
+            <button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">Import</button>
         </form>
     </div>
 </div>

+ 42 - 9
templates/user.html

@@ -11,7 +11,7 @@
 <!-- Page Heading -->
 <div class="card shadow mb-4">
     <div class="card-header py-3">
-        <h6 class="m-0 font-weight-bold text-primary">{{if .IsAdd}}Add a new user{{else}}Edit user{{end}}</h6>
+        <h6 class="m-0 font-weight-bold text-primary">{{.Title}}</h6>
     </div>
     <div class="card-body">
         {{if .Error}}
@@ -19,14 +19,47 @@
             <div class="card-body text-form-error">{{.Error}}</div>
         </div>
         {{end}}
-        <form id="user_form" enctype="multipart/form-data" action="{{.CurrentURL}}" method="POST" autocomplete="off">
+        {{if eq .Mode 3}}
+        <div class="card mb-4 border-left-info">
+            <div class="card-body">
+                Generate a data provider independent JSON file to create new users or update existing ones.
+                <br>
+                The following placeholders are supported:
+                <br><br>
+                <ul>
+                    <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.
+                {{if .User.Username}}
+                <br>
+                Please note that no credentials were copied from user "{{.User.Username}}", you have to set them explicitly.
+                {{end}}
+            </div>
+        </div>
+        {{end}}
+        <form id="user_form" enctype="multipart/form-data" action="{{.CurrentURL}}" method="POST" autocomplete="off" {{if eq .Mode 3}}target="_blank"{{end}}>
+            {{if eq .Mode 3}}
+            <div class="form-group row">
+                <label for="idUsers" class="col-sm-2 col-form-label">Users</label>
+                <div class="col-sm-10">
+                    <textarea class="form-control" id="idUsers" name="users" rows="5" required
+                        aria-describedby="usersHelpBlock"></textarea>
+                    <small id="usersHelpBlock" class="form-text text-muted">
+                        Specify the username and at least one of the password and public key. Each line must be username::password::public-key
+                    </small>
+                </div>
+            </div>
+            <input type="hidden" name="username" id="idUsername" value="{{.User.Username}}">
+            {{else}}
             <div class="form-group row">
                 <label for="idUsername" class="col-sm-2 col-form-label">Username</label>
                 <div class="col-sm-10">
                     <input type="text" class="form-control" id="idUsername" name="username" placeholder=""
-                        value="{{.User.Username}}" maxlength="255" autocomplete="nope" required {{if not .IsAdd}}readonly{{end}}>
+                        value="{{.User.Username}}" maxlength="255" autocomplete="nope" required {{if ge .Mode 2}}readonly{{end}}>
                 </div>
             </div>
+            {{end}}
 
             <div class="form-group row">
                 <label for="idStatus" class="col-sm-2 col-form-label">Status</label>
@@ -48,12 +81,12 @@
                     </div>
                 </div>
             </div>
-
+            {{if ne .Mode 3}}
             <div class="form-group row">
                 <label for="idPassword" class="col-sm-2 col-form-label">Password</label>
                 <div class="col-sm-10">
-                    <input type="password" class="form-control" id="idPassword" name="password" placeholder="" {{if not .IsAdd}}aria-describedby="pwdHelpBlock" {{end}}>
-                    {{if not .IsAdd}}
+                    <input type="password" class="form-control" id="idPassword" name="password" placeholder="" {{if eq .Mode 2}}aria-describedby="pwdHelpBlock" {{end}}>
+                    {{if eq .Mode 2}}
                     <small id="pwdHelpBlock" class="form-text text-muted">
                         If empty the current password will not be changed
                     </small>
@@ -71,7 +104,7 @@
                     </small>
                 </div>
             </div>
-
+            {{end}}
             <div class="form-group row">
                 <label for="idProtocols" class="col-sm-2 col-form-label">Denied protocols</label>
                 <div class="col-sm-10">
@@ -612,7 +645,7 @@
                 </div>
             </div>
 
-            {{if not .IsAdd}}
+            {{if eq .Mode 2}}
             <div class="form-group">
                 <div class="form-check">
                     <input type="checkbox" class="form-check-input" id="idDisconnect" name="disconnect"
@@ -626,7 +659,7 @@
             {{end}}
 
             <input type="hidden" name="expiration_date" id="hidden_start_datetime" value="">
-            <button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">Submit</button>
+            <button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">{{if eq .Mode 3}}Generate and export users{{else}}Submit{{end}}</button>
         </form>
     </div>
 </div>

+ 20 - 1
templates/users.html

@@ -153,12 +153,27 @@
             name: 'clone',
             action: function (e, dt, node, config) {
                 var username = dt.row({ selected: true }).data()[1];
-                var path = '{{.UserURL}}' + "?cloneFrom=" + encodeURIComponent(username);
+                var path = '{{.UserURL}}' + "?clone-from=" + encodeURIComponent(username);
                 window.location.href = path;
             },
             enabled: false
         };
 
+        $.fn.dataTable.ext.buttons.template = {
+            text: 'Template',
+            name: 'template',
+            action: function (e, dt, node, config) {
+                var selectedRows = table.rows({ selected: true }).count();
+                if (selectedRows == 1){
+                    var username = dt.row({ selected: true }).data()[1];
+                    var path = '{{.UserTemplateURL}}' + "?from=" + encodeURIComponent(username);
+                    window.location.href = path;
+                } else {
+                    window.location.href = '{{.UserTemplateURL}}';
+                }
+            }
+        };
+
         $.fn.dataTable.ext.buttons.delete = {
             text: 'Delete',
             name: 'delete',
@@ -240,6 +255,10 @@
         table.button().add(0,'quota_scan');
         {{end}}
 
+        {{if .LoggedAdmin.HasPermission "manage_system"}}
+        table.button().add(0,'template');
+        {{end}}
+
         {{if .LoggedAdmin.HasPermission "del_users"}}
         table.button().add(0,'delete');
         {{end}}