mirror of
https://github.com/drakkan/sftpgo.git
synced 2024-11-22 07:30:25 +00:00
web ui: allow to create multiple users from a template
This commit is contained in:
parent
5fcbf2528f
commit
54321c5240
14 changed files with 532 additions and 86 deletions
|
@ -397,7 +397,7 @@ func (p *BoltProvider) userExists(username string) (User, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *BoltProvider) addUser(user *User) error {
|
func (p *BoltProvider) addUser(user *User) error {
|
||||||
err := validateUser(user)
|
err := ValidateUser(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -437,7 +437,7 @@ func (p *BoltProvider) addUser(user *User) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *BoltProvider) updateUser(user *User) error {
|
func (p *BoltProvider) updateUser(user *User) error {
|
||||||
err := validateUser(user)
|
err := ValidateUser(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -1003,7 +1003,7 @@ func updateV4BoltCompatUser(dbHandle *bolt.DB, user compatUserV4) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateV4BoltUser(dbHandle *bolt.DB, user User) error {
|
func updateV4BoltUser(dbHandle *bolt.DB, user User) error {
|
||||||
err := validateUser(&user)
|
err := ValidateUser(&user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -1323,7 +1323,9 @@ func validateFolder(folder *vfs.BaseVirtualFolder) error {
|
||||||
return nil
|
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()
|
user.SetEmptySecretsIfNil()
|
||||||
buildUserHomeDir(user)
|
buildUserHomeDir(user)
|
||||||
if err := validateBaseParams(user); err != nil {
|
if err := validateBaseParams(user); err != nil {
|
||||||
|
|
|
@ -185,7 +185,7 @@ func (p *MemoryProvider) addUser(user *User) error {
|
||||||
if p.dbHandle.isClosed {
|
if p.dbHandle.isClosed {
|
||||||
return errMemoryProviderClosed
|
return errMemoryProviderClosed
|
||||||
}
|
}
|
||||||
err := validateUser(user)
|
err := ValidateUser(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -211,7 +211,7 @@ func (p *MemoryProvider) updateUser(user *User) error {
|
||||||
if p.dbHandle.isClosed {
|
if p.dbHandle.isClosed {
|
||||||
return errMemoryProviderClosed
|
return errMemoryProviderClosed
|
||||||
}
|
}
|
||||||
err := validateUser(user)
|
err := ValidateUser(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -306,7 +306,7 @@ func sqlCommonUpdateLastLogin(username string, dbHandle *sql.DB) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func sqlCommonAddUser(user *User, dbHandle *sql.DB) error {
|
func sqlCommonAddUser(user *User, dbHandle *sql.DB) error {
|
||||||
err := validateUser(user)
|
err := ValidateUser(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -360,7 +360,7 @@ func sqlCommonAddUser(user *User, dbHandle *sql.DB) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func sqlCommonUpdateUser(user *User, dbHandle *sql.DB) error {
|
func sqlCommonUpdateUser(user *User, dbHandle *sql.DB) error {
|
||||||
err := validateUser(user)
|
err := ValidateUser(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
// DecryptSecrets tries to decrypts kms secrets
|
||||||
func (u *User) DecryptSecrets() error {
|
func (u *User) DecryptSecrets() error {
|
||||||
switch u.FsConfig.Provider {
|
switch u.FsConfig.Provider {
|
||||||
|
@ -801,12 +811,12 @@ func (u *User) GetExpirationDateAsString() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllowedIPAsString returns the allowed IP as comma separated 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, ",")
|
return strings.Join(u.Filters.AllowedIP, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDeniedIPAsString returns the denied IP as comma separated string
|
// 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, ",")
|
return strings.Join(u.Filters.DeniedIP, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -60,6 +60,7 @@ const (
|
||||||
webScanVFolderPath = "/web/folder-quota-scans"
|
webScanVFolderPath = "/web/folder-quota-scans"
|
||||||
webQuotaScanPath = "/web/quota-scans"
|
webQuotaScanPath = "/web/quota-scans"
|
||||||
webChangeAdminPwdPath = "/web/changepwd/admin"
|
webChangeAdminPwdPath = "/web/changepwd/admin"
|
||||||
|
webTemplateUser = "/web/template/user"
|
||||||
webStaticFilesPath = "/static"
|
webStaticFilesPath = "/static"
|
||||||
// MaxRestoreSize defines the max size for the loaddata input file
|
// MaxRestoreSize defines the max size for the loaddata input file
|
||||||
MaxRestoreSize = 10485760 // 10 MB
|
MaxRestoreSize = 10485760 // 10 MB
|
||||||
|
|
|
@ -76,6 +76,7 @@ const (
|
||||||
webMaintenancePath = "/web/maintenance"
|
webMaintenancePath = "/web/maintenance"
|
||||||
webRestorePath = "/web/restore"
|
webRestorePath = "/web/restore"
|
||||||
webChangeAdminPwdPath = "/web/changepwd/admin"
|
webChangeAdminPwdPath = "/web/changepwd/admin"
|
||||||
|
webTemplateUser = "/web/template/user"
|
||||||
httpBaseURL = "http://127.0.0.1:8081"
|
httpBaseURL = "http://127.0.0.1:8081"
|
||||||
configDir = ".."
|
configDir = ".."
|
||||||
httpsCert = `-----BEGIN CERTIFICATE-----
|
httpsCert = `-----BEGIN CERTIFICATE-----
|
||||||
|
@ -2214,11 +2215,16 @@ func TestProviderErrors(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
err = os.Remove(backupFilePath)
|
err = os.Remove(backupFilePath)
|
||||||
assert.NoError(t, err)
|
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)
|
assert.NoError(t, err)
|
||||||
setJWTCookieForReq(req, testServerToken)
|
setJWTCookieForReq(req, testServerToken)
|
||||||
rr := executeRequest(req)
|
rr := executeRequest(req)
|
||||||
checkResponseCode(t, http.StatusInternalServerError, rr)
|
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, "")
|
err = config.LoadConfig(configDir, "")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
providerConf := config.GetProviderConf()
|
providerConf := config.GetProviderConf()
|
||||||
|
@ -4439,49 +4445,163 @@ func TestWebUserUpdateMock(t *testing.T) {
|
||||||
checkResponseCode(t, http.StatusOK, rr)
|
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) {
|
func TestRenderWebCloneUserMock(t *testing.T) {
|
||||||
token, err := getJWTTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
|
token, err := getJWTTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
|
user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
|
||||||
assert.NoError(t, err)
|
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)
|
assert.NoError(t, err)
|
||||||
setJWTCookieForReq(req, token)
|
setJWTCookieForReq(req, token)
|
||||||
rr := executeRequest(req)
|
rr := executeRequest(req)
|
||||||
checkResponseCode(t, http.StatusOK, rr)
|
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)
|
assert.NoError(t, err)
|
||||||
setJWTCookieForReq(req, token)
|
setJWTCookieForReq(req, token)
|
||||||
rr = executeRequest(req)
|
rr = executeRequest(req)
|
||||||
checkResponseCode(t, http.StatusNotFound, rr)
|
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)
|
_, err = httpdtest.RemoveUser(user, http.StatusOK)
|
||||||
assert.NoError(t, err)
|
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) {
|
func TestWebUserS3Mock(t *testing.T) {
|
||||||
token, err := getJWTTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
|
token, err := getJWTTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
|
@ -29,7 +29,9 @@ import (
|
||||||
|
|
||||||
"github.com/drakkan/sftpgo/common"
|
"github.com/drakkan/sftpgo/common"
|
||||||
"github.com/drakkan/sftpgo/dataprovider"
|
"github.com/drakkan/sftpgo/dataprovider"
|
||||||
|
"github.com/drakkan/sftpgo/kms"
|
||||||
"github.com/drakkan/sftpgo/utils"
|
"github.com/drakkan/sftpgo/utils"
|
||||||
|
"github.com/drakkan/sftpgo/vfs"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -705,3 +707,51 @@ func TestVerifyTLSConnection(t *testing.T) {
|
||||||
|
|
||||||
certMgr = oldCertMgr
|
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())
|
||||||
|
}
|
||||||
|
|
|
@ -369,6 +369,9 @@ func (s *httpdServer) initializeRouter() {
|
||||||
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(webMaintenancePath, handleWebMaintenance)
|
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(webMaintenancePath, handleWebMaintenance)
|
||||||
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(webBackupPath, dumpData)
|
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(webBackupPath, dumpData)
|
||||||
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Post(webRestorePath, handleWebRestore)
|
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) {
|
router.Group(func(router chi.Router) {
|
||||||
|
|
284
httpd/web.go
284
httpd/web.go
|
@ -13,6 +13,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
|
||||||
"github.com/drakkan/sftpgo/common"
|
"github.com/drakkan/sftpgo/common"
|
||||||
"github.com/drakkan/sftpgo/dataprovider"
|
"github.com/drakkan/sftpgo/dataprovider"
|
||||||
"github.com/drakkan/sftpgo/kms"
|
"github.com/drakkan/sftpgo/kms"
|
||||||
|
@ -21,6 +23,14 @@ import (
|
||||||
"github.com/drakkan/sftpgo/vfs"
|
"github.com/drakkan/sftpgo/vfs"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type userPageMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
userPageModeAdd userPageMode = iota + 1
|
||||||
|
userPageModeUpdate
|
||||||
|
userPageModeTemplate
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
templateBase = "base.html"
|
templateBase = "base.html"
|
||||||
templateUsers = "users.html"
|
templateUsers = "users.html"
|
||||||
|
@ -62,6 +72,7 @@ type basePage struct {
|
||||||
CurrentURL string
|
CurrentURL string
|
||||||
UsersURL string
|
UsersURL string
|
||||||
UserURL string
|
UserURL string
|
||||||
|
UserTemplateURL string
|
||||||
AdminsURL string
|
AdminsURL string
|
||||||
AdminURL string
|
AdminURL string
|
||||||
QuotaScanURL string
|
QuotaScanURL string
|
||||||
|
@ -110,7 +121,7 @@ type statusPage struct {
|
||||||
|
|
||||||
type userPage struct {
|
type userPage struct {
|
||||||
basePage
|
basePage
|
||||||
User dataprovider.User
|
User *dataprovider.User
|
||||||
RootPerms []string
|
RootPerms []string
|
||||||
Error string
|
Error string
|
||||||
ValidPerms []string
|
ValidPerms []string
|
||||||
|
@ -118,7 +129,7 @@ type userPage struct {
|
||||||
ValidProtocols []string
|
ValidProtocols []string
|
||||||
RootDirPerms []string
|
RootDirPerms []string
|
||||||
RedactedSecret string
|
RedactedSecret string
|
||||||
IsAdd bool
|
Mode userPageMode
|
||||||
}
|
}
|
||||||
|
|
||||||
type adminPage struct {
|
type adminPage struct {
|
||||||
|
@ -158,6 +169,12 @@ type loginPage struct {
|
||||||
Error string
|
Error string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type userTemplateFields struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
PublicKey string
|
||||||
|
}
|
||||||
|
|
||||||
func loadTemplates(templatesPath string) {
|
func loadTemplates(templatesPath string) {
|
||||||
usersPaths := []string{
|
usersPaths := []string{
|
||||||
filepath.Join(templatesPath, templateBase),
|
filepath.Join(templatesPath, templateBase),
|
||||||
|
@ -239,6 +256,7 @@ func getBasePageData(title, currentURL string, r *http.Request) basePage {
|
||||||
CurrentURL: currentURL,
|
CurrentURL: currentURL,
|
||||||
UsersURL: webUsersPath,
|
UsersURL: webUsersPath,
|
||||||
UserURL: webUserPath,
|
UserURL: webUserPath,
|
||||||
|
UserTemplateURL: webTemplateUser,
|
||||||
AdminsURL: webAdminsPath,
|
AdminsURL: webAdminsPath,
|
||||||
AdminURL: webAdminPath,
|
AdminURL: webAdminPath,
|
||||||
FoldersURL: webFoldersPath,
|
FoldersURL: webFoldersPath,
|
||||||
|
@ -337,27 +355,23 @@ func renderAddUpdateAdminPage(w http.ResponseWriter, r *http.Request, admin *dat
|
||||||
renderTemplate(w, templateAdmin, data)
|
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()
|
user.SetEmptySecretsIfNil()
|
||||||
data := userPage{
|
var title, currentURL string
|
||||||
basePage: getBasePageData("Add a new user", webUserPath, r),
|
switch mode {
|
||||||
IsAdd: true,
|
case userPageModeAdd:
|
||||||
Error: error,
|
title = "Add a new user"
|
||||||
User: user,
|
currentURL = webUserPath
|
||||||
ValidPerms: dataprovider.ValidPerms,
|
case userPageModeUpdate:
|
||||||
ValidSSHLoginMethods: dataprovider.ValidSSHLoginMethods,
|
title = "Update user"
|
||||||
ValidProtocols: dataprovider.ValidProtocols,
|
currentURL = fmt.Sprintf("%v/%v", webUserPath, url.PathEscape(user.Username))
|
||||||
RootDirPerms: user.GetPermissionsForPath("/"),
|
case userPageModeTemplate:
|
||||||
RedactedSecret: redactedSecret,
|
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{
|
data := userPage{
|
||||||
basePage: getBasePageData("Update user", fmt.Sprintf("%v/%v", webUserPath, url.PathEscape(user.Username)), r),
|
basePage: getBasePageData(title, currentURL, r),
|
||||||
IsAdd: false,
|
Mode: mode,
|
||||||
Error: error,
|
Error: error,
|
||||||
User: user,
|
User: user,
|
||||||
ValidPerms: dataprovider.ValidPerms,
|
ValidPerms: dataprovider.ValidPerms,
|
||||||
|
@ -378,6 +392,33 @@ func renderAddFolderPage(w http.ResponseWriter, r *http.Request, folder vfs.Base
|
||||||
renderTemplate(w, templateFolder, data)
|
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 {
|
func getVirtualFoldersFromPostFields(r *http.Request) []vfs.VirtualFolder {
|
||||||
var virtualFolders []vfs.VirtualFolder
|
var virtualFolders []vfs.VirtualFolder
|
||||||
formValue := r.Form.Get("virtual_folders")
|
formValue := r.Form.Get("virtual_folders")
|
||||||
|
@ -429,7 +470,7 @@ func getUserPermissionsFromPostFields(r *http.Request) map[string][]string {
|
||||||
perms = append(perms, cleanedPerm)
|
perms = append(perms, cleanedPerm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(dir) > 0 {
|
if dir != "" {
|
||||||
permissions[dir] = perms
|
permissions[dir] = perms
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -442,7 +483,7 @@ func getSliceFromDelimitedValues(values, delimiter string) []string {
|
||||||
result := []string{}
|
result := []string{}
|
||||||
for _, v := range strings.Split(values, delimiter) {
|
for _, v := range strings.Split(values, delimiter) {
|
||||||
cleaned := strings.TrimSpace(v)
|
cleaned := strings.TrimSpace(v)
|
||||||
if len(cleaned) > 0 {
|
if cleaned != "" {
|
||||||
result = append(result, cleaned)
|
result = append(result, cleaned)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -708,6 +749,128 @@ func getAdminFromPostFields(r *http.Request) (dataprovider.Admin, error) {
|
||||||
return admin, nil
|
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) {
|
func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
|
||||||
var user dataprovider.User
|
var user dataprovider.User
|
||||||
err := r.ParseMultipartForm(maxRequestSize)
|
err := r.ParseMultipartForm(maxRequestSize)
|
||||||
|
@ -1004,18 +1167,13 @@ func handleGetWebUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
renderTemplate(w, templateUsers, data)
|
renderTemplate(w, templateUsers, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleWebAddUserGet(w http.ResponseWriter, r *http.Request) {
|
func handleWebTemplateUserGet(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Query().Get("cloneFrom") != "" {
|
if r.URL.Query().Get("from") != "" {
|
||||||
username := r.URL.Query().Get("cloneFrom")
|
username := r.URL.Query().Get("from")
|
||||||
user, err := dataprovider.UserExists(username)
|
user, err := dataprovider.UserExists(username)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
user.ID = 0
|
user.SetEmptySecrets()
|
||||||
user.Username = ""
|
renderUserPage(w, r, &user, userPageModeTemplate, "")
|
||||||
if err := user.DecryptSecrets(); err != nil {
|
|
||||||
renderInternalServerErrorPage(w, r, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
renderAddUserPage(w, r, user, "")
|
|
||||||
} else if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
|
} else if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
|
||||||
renderNotFoundPage(w, r, err)
|
renderNotFoundPage(w, r, err)
|
||||||
} else {
|
} else {
|
||||||
|
@ -1023,7 +1181,57 @@ func handleWebAddUserGet(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
user := dataprovider.User{Status: 1}
|
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")
|
username := getURLParam(r, "username")
|
||||||
user, err := dataprovider.UserExists(username)
|
user, err := dataprovider.UserExists(username)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
renderUpdateUserPage(w, r, user, "")
|
renderUserPage(w, r, &user, userPageModeUpdate, "")
|
||||||
} else if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
|
} else if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
|
||||||
renderNotFoundPage(w, r, err)
|
renderNotFoundPage(w, r, err)
|
||||||
} else {
|
} else {
|
||||||
|
@ -1043,14 +1251,14 @@ func handleWebAddUserPost(w http.ResponseWriter, r *http.Request) {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
|
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
|
||||||
user, err := getUserFromPostFields(r)
|
user, err := getUserFromPostFields(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
renderAddUserPage(w, r, user, err.Error())
|
renderUserPage(w, r, &user, userPageModeAdd, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err = dataprovider.AddUser(&user)
|
err = dataprovider.AddUser(&user)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
http.Redirect(w, r, webUsersPath, http.StatusSeeOther)
|
http.Redirect(w, r, webUsersPath, http.StatusSeeOther)
|
||||||
} else {
|
} 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)
|
updatedUser, err := getUserFromPostFields(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
renderUpdateUserPage(w, r, user, err.Error())
|
renderUserPage(w, r, &user, userPageModeUpdate, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
updatedUser.ID = user.ID
|
updatedUser.ID = user.ID
|
||||||
|
@ -1087,7 +1295,7 @@ func handleWebUpdateUserPost(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, webUsersPath, http.StatusSeeOther)
|
http.Redirect(w, r, webUsersPath, http.StatusSeeOther)
|
||||||
} else {
|
} else {
|
||||||
renderUpdateUserPage(w, r, user, err.Error())
|
renderUserPage(w, r, &user, userPageModeUpdate, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -89,7 +89,7 @@ function deleteAction() {
|
||||||
var table = $('#dataTable').DataTable();
|
var table = $('#dataTable').DataTable();
|
||||||
table.button('delete:name').enable(false);
|
table.button('delete:name').enable(false);
|
||||||
var folderPath = table.row({ selected: true }).data()[0];
|
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');
|
$('#deleteModal').modal('hide');
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: path,
|
url: path,
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
{{define "page_body"}}
|
{{define "page_body"}}
|
||||||
<div class="card shadow mb-4">
|
<div class="card shadow mb-4">
|
||||||
<div class="card-header py-3">
|
<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>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{{if .Error}}
|
{{if .Error}}
|
||||||
|
@ -20,7 +20,7 @@
|
||||||
<input type="file" class="form-control-file" id="idBackupFile" name="backup_file"
|
<input type="file" class="form-control-file" id="idBackupFile" name="backup_file"
|
||||||
aria-describedby="BackupFileHelpBlock">
|
aria-describedby="BackupFileHelpBlock">
|
||||||
<small id="BackupFileHelpBlock" class="form-text text-muted">
|
<small id="BackupFileHelpBlock" class="form-text text-muted">
|
||||||
Restore data from a JSON backup file
|
Import data from a JSON backup file
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -44,7 +44,7 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
<!-- Page Heading -->
|
<!-- Page Heading -->
|
||||||
<div class="card shadow mb-4">
|
<div class="card shadow mb-4">
|
||||||
<div class="card-header py-3">
|
<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>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{{if .Error}}
|
{{if .Error}}
|
||||||
|
@ -19,14 +19,47 @@
|
||||||
<div class="card-body text-form-error">{{.Error}}</div>
|
<div class="card-body text-form-error">{{.Error}}</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{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">
|
<div class="form-group row">
|
||||||
<label for="idUsername" class="col-sm-2 col-form-label">Username</label>
|
<label for="idUsername" class="col-sm-2 col-form-label">Username</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
<input type="text" class="form-control" id="idUsername" name="username" placeholder=""
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
<label for="idStatus" class="col-sm-2 col-form-label">Status</label>
|
<label for="idStatus" class="col-sm-2 col-form-label">Status</label>
|
||||||
|
@ -48,12 +81,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{if ne .Mode 3}}
|
||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
<label for="idPassword" class="col-sm-2 col-form-label">Password</label>
|
<label for="idPassword" class="col-sm-2 col-form-label">Password</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
<input type="password" class="form-control" id="idPassword" name="password" placeholder="" {{if not .IsAdd}}aria-describedby="pwdHelpBlock" {{end}}>
|
<input type="password" class="form-control" id="idPassword" name="password" placeholder="" {{if eq .Mode 2}}aria-describedby="pwdHelpBlock" {{end}}>
|
||||||
{{if not .IsAdd}}
|
{{if eq .Mode 2}}
|
||||||
<small id="pwdHelpBlock" class="form-text text-muted">
|
<small id="pwdHelpBlock" class="form-text text-muted">
|
||||||
If empty the current password will not be changed
|
If empty the current password will not be changed
|
||||||
</small>
|
</small>
|
||||||
|
@ -71,7 +104,7 @@
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
<label for="idProtocols" class="col-sm-2 col-form-label">Denied protocols</label>
|
<label for="idProtocols" class="col-sm-2 col-form-label">Denied protocols</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
|
@ -612,7 +645,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if not .IsAdd}}
|
{{if eq .Mode 2}}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input type="checkbox" class="form-check-input" id="idDisconnect" name="disconnect"
|
<input type="checkbox" class="form-check-input" id="idDisconnect" name="disconnect"
|
||||||
|
@ -626,7 +659,7 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<input type="hidden" name="expiration_date" id="hidden_start_datetime" value="">
|
<input type="hidden" name="expiration_date" id="hidden_start_datetime" value="">
|
||||||
<button type="submit" class="btn btn-primary float-right mt-3 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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -153,12 +153,27 @@
|
||||||
name: 'clone',
|
name: 'clone',
|
||||||
action: function (e, dt, node, config) {
|
action: function (e, dt, node, config) {
|
||||||
var username = dt.row({ selected: true }).data()[1];
|
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;
|
window.location.href = path;
|
||||||
},
|
},
|
||||||
enabled: false
|
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 = {
|
$.fn.dataTable.ext.buttons.delete = {
|
||||||
text: 'Delete',
|
text: 'Delete',
|
||||||
name: 'delete',
|
name: 'delete',
|
||||||
|
@ -240,6 +255,10 @@
|
||||||
table.button().add(0,'quota_scan');
|
table.button().add(0,'quota_scan');
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .LoggedAdmin.HasPermission "manage_system"}}
|
||||||
|
table.button().add(0,'template');
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .LoggedAdmin.HasPermission "del_users"}}
|
{{if .LoggedAdmin.HasPermission "del_users"}}
|
||||||
table.button().add(0,'delete');
|
table.button().add(0,'delete');
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
Loading…
Reference in a new issue