From 54321c524044559cbe30008c9e527cbd3f56eae0 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Mon, 25 Jan 2021 21:31:33 +0100 Subject: [PATCH] web ui: allow to create multiple users from a template --- dataprovider/bolt.go | 6 +- dataprovider/dataprovider.go | 4 +- dataprovider/memory.go | 4 +- dataprovider/sqlcommon.go | 4 +- dataprovider/user.go | 14 +- httpd/httpd.go | 1 + httpd/httpd_test.go | 168 ++++++++++++++++++--- httpd/internal_test.go | 50 ++++++ httpd/server.go | 3 + httpd/web.go | 284 ++++++++++++++++++++++++++++++----- templates/folders.html | 2 +- templates/maintenance.html | 6 +- templates/user.html | 51 +++++-- templates/users.html | 21 ++- 14 files changed, 532 insertions(+), 86 deletions(-) diff --git a/dataprovider/bolt.go b/dataprovider/bolt.go index e6e76f64..c1ee25f4 100644 --- a/dataprovider/bolt.go +++ b/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 } diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index d5722f62..cdbd5ac1 100644 --- a/dataprovider/dataprovider.go +++ b/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 { diff --git a/dataprovider/memory.go b/dataprovider/memory.go index 197fd442..82680e60 100644 --- a/dataprovider/memory.go +++ b/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 } diff --git a/dataprovider/sqlcommon.go b/dataprovider/sqlcommon.go index 42c8aacc..928538a3 100644 --- a/dataprovider/sqlcommon.go +++ b/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 } diff --git a/dataprovider/user.go b/dataprovider/user.go index c3a4b6c5..d4fbf9b1 100644 --- a/dataprovider/user.go +++ b/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, ",") } diff --git a/httpd/httpd.go b/httpd/httpd.go index a23cbe49..05db231a 100644 --- a/httpd/httpd.go +++ b/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 diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index 13bc9391..5a1da291 100644 --- a/httpd/httpd_test.go +++ b/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 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, webTemplateUser+fmt.Sprintf("?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, webTemplateUser+"?from=unknown", 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 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("?cloneFrom=%v", user.Username), nil) + 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("?cloneFrom=%v", altAdminPassword), nil) + 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) - 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) - - 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) - } - _, 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) diff --git a/httpd/internal_test.go b/httpd/internal_test.go index 84a5732c..30abafc4 100644 --- a/httpd/internal_test.go +++ b/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()) +} diff --git a/httpd/server.go b/httpd/server.go index c86e22d9..773efe16 100644 --- a/httpd/server.go +++ b/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) { diff --git a/httpd/web.go b/httpd/web.go index 779c193b..de6d0490 100644 --- a/httpd/web.go +++ b/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,13 @@ func handleGetWebUsers(w http.ResponseWriter, r *http.Request) { renderTemplate(w, templateUsers, data) } -func handleWebAddUserGet(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("cloneFrom") != "" { - username := r.URL.Query().Get("cloneFrom") +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.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, userPageModeTemplate, "") } else if _, ok := err.(*dataprovider.RecordNotFoundError); ok { renderNotFoundPage(w, r, err) } else { @@ -1023,7 +1181,57 @@ func handleWebAddUserGet(w http.ResponseWriter, r *http.Request) { } } else { user := dataprovider.User{Status: 1} - renderAddUserPage(w, r, user, "") + 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("clone-from") != "" { + username := r.URL.Query().Get("clone-from") + user, err := dataprovider.UserExists(username) + if err == nil { + user.ID = 0 + user.Username = "" + user.SetEmptySecrets() + renderUserPage(w, r, &user, userPageModeAdd, "") + } 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, 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()) } } diff --git a/templates/folders.html b/templates/folders.html index 21b31aa6..9904cd91 100644 --- a/templates/folders.html +++ b/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, diff --git a/templates/maintenance.html b/templates/maintenance.html index 0767c7e8..39d0556b 100644 --- a/templates/maintenance.html +++ b/templates/maintenance.html @@ -5,7 +5,7 @@ {{define "page_body"}}
-
Restore
+
Import
{{if .Error}} @@ -20,7 +20,7 @@ - Restore data from a JSON backup file + Import data from a JSON backup file
@@ -44,7 +44,7 @@ - + diff --git a/templates/user.html b/templates/user.html index bc67c4df..4968834a 100644 --- a/templates/user.html +++ b/templates/user.html @@ -11,7 +11,7 @@
-
{{if .IsAdd}}Add a new user{{else}}Edit user{{end}}
+
{{.Title}}
{{if .Error}} @@ -19,14 +19,47 @@
{{.Error}}
{{end}} -
+ {{if eq .Mode 3}} +
+
+ Generate a data provider independent JSON file to create new users or update existing ones. +
+ The following placeholders are supported: +

+
    +
  • %username% will be replaced with the specified username
  • +
  • %password% will be replaced with the specified password
  • +
+ The generated users file can be imported from the "Maintenance" section. + {{if .User.Username}} +
+ Please note that no credentials were copied from user "{{.User.Username}}", you have to set them explicitly. + {{end}} +
+
+ {{end}} + + {{if eq .Mode 3}} +
+ +
+ + + Specify the username and at least one of the password and public key. Each line must be username::password::public-key + +
+
+ + {{else}}
+ value="{{.User.Username}}" maxlength="255" autocomplete="nope" required {{if ge .Mode 2}}readonly{{end}}>
+ {{end}}
@@ -48,12 +81,12 @@
- + {{if ne .Mode 3}}
- - {{if not .IsAdd}} + + {{if eq .Mode 2}} If empty the current password will not be changed @@ -71,7 +104,7 @@
- + {{end}}
@@ -612,7 +645,7 @@
- {{if not .IsAdd}} + {{if eq .Mode 2}}
- +
diff --git a/templates/users.html b/templates/users.html index e9dae679..5fc15a72 100644 --- a/templates/users.html +++ b/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}}