diff --git a/config/config.go b/config/config.go
index cd07acd9..4a8e067f 100644
--- a/config/config.go
+++ b/config/config.go
@@ -210,6 +210,7 @@ func Init() {
},
UpdateMode: 0,
PreferDatabaseCredentials: false,
+ SkipNaturalKeysValidation: false,
},
HTTPDConfig: httpd.Conf{
Bindings: []httpd.Binding{defaultHTTPDBinding},
@@ -889,6 +890,7 @@ func setViperDefaults() {
viper.SetDefault("data_provider.password_hashing.argon2_options.iterations", globalConf.ProviderConf.PasswordHashing.Argon2Options.Iterations)
viper.SetDefault("data_provider.password_hashing.argon2_options.parallelism", globalConf.ProviderConf.PasswordHashing.Argon2Options.Parallelism)
viper.SetDefault("data_provider.update_mode", globalConf.ProviderConf.UpdateMode)
+ viper.SetDefault("data_provider.skip_natural_keys_validation", globalConf.ProviderConf.SkipNaturalKeysValidation)
viper.SetDefault("httpd.templates_path", globalConf.HTTPDConfig.TemplatesPath)
viper.SetDefault("httpd.static_files_path", globalConf.HTTPDConfig.StaticFilesPath)
viper.SetDefault("httpd.backups_path", globalConf.HTTPDConfig.BackupsPath)
diff --git a/dataprovider/admin.go b/dataprovider/admin.go
index b648544e..1efb3377 100644
--- a/dataprovider/admin.go
+++ b/dataprovider/admin.go
@@ -62,6 +62,17 @@ type Admin struct {
AdditionalInfo string `json:"additional_info,omitempty"`
}
+func (a *Admin) checkPassword() error {
+ if a.Password != "" && !strings.HasPrefix(a.Password, argonPwdPrefix) {
+ pwd, err := argon2id.CreateHash(a.Password, argon2Params)
+ if err != nil {
+ return err
+ }
+ a.Password = pwd
+ }
+ return nil
+}
+
func (a *Admin) validate() error {
if a.Username == "" {
return &ValidationError{err: "username is mandatory"}
@@ -69,15 +80,11 @@ func (a *Admin) validate() error {
if a.Password == "" {
return &ValidationError{err: "please set a password"}
}
- if !usernameRegex.MatchString(a.Username) {
+ if !config.SkipNaturalKeysValidation && !usernameRegex.MatchString(a.Username) {
return &ValidationError{err: fmt.Sprintf("username %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~", a.Username)}
}
- if a.Password != "" && !strings.HasPrefix(a.Password, argonPwdPrefix) {
- pwd, err := argon2id.CreateHash(a.Password, argon2Params)
- if err != nil {
- return err
- }
- a.Password = pwd
+ if err := a.checkPassword(); err != nil {
+ return err
}
a.Permissions = utils.RemoveDuplicates(a.Permissions)
if len(a.Permissions) == 0 {
diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go
index bf6b161c..add82d7c 100644
--- a/dataprovider/dataprovider.go
+++ b/dataprovider/dataprovider.go
@@ -271,6 +271,10 @@ type Config struct {
// Cloud Storage) should be stored in the database instead of in the directory specified by
// CredentialsPath.
PreferDatabaseCredentials bool `json:"prefer_database_credentials" mapstructure:"prefer_database_credentials"`
+ // SkipNaturalKeysValidation allows to use any UTF-8 character for natural keys as username, admin name,
+ // folder name. These keys are used in URIs for REST API and Web admin. By default only unreserved URI
+ // characters are allowed: ALPHA / DIGIT / "-" / "." / "_" / "~".
+ SkipNaturalKeysValidation bool `json:"skip_natural_keys_validation" mapstructure:"skip_natural_keys_validation"`
}
// BackupData defines the structure for the backup/restore files
@@ -1362,7 +1366,7 @@ func validateBaseParams(user *User) error {
if user.Username == "" {
return &ValidationError{err: "username is mandatory"}
}
- if !usernameRegex.MatchString(user.Username) {
+ if !config.SkipNaturalKeysValidation && !usernameRegex.MatchString(user.Username) {
return &ValidationError{err: fmt.Sprintf("username %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~",
user.Username)}
}
@@ -1395,7 +1399,7 @@ func ValidateFolder(folder *vfs.BaseVirtualFolder) error {
if folder.Name == "" {
return &ValidationError{err: "folder name is mandatory"}
}
- if !usernameRegex.MatchString(folder.Name) {
+ if !config.SkipNaturalKeysValidation && !usernameRegex.MatchString(folder.Name) {
return &ValidationError{err: fmt.Sprintf("folder name %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~",
folder.Name)}
}
diff --git a/docs/full-configuration.md b/docs/full-configuration.md
index 7c2a6e34..40fad2c8 100644
--- a/docs/full-configuration.md
+++ b/docs/full-configuration.md
@@ -188,6 +188,7 @@ The configuration file contains the following sections:
- `iterations`, unsigned integer. The number of iterations over the memory. Default: 1.
- `parallelism`. unsigned 8 bit integer. The number of threads (or lanes) used by the algorithm. Default: 2.
- `update_mode`, integer. Defines how the database will be initialized/updated. 0 means automatically. 1 means manually using the initprovider sub-command.
+ - `skip_natural_keys_validation`, boolean. If `true` you can use any UTF-8 character for natural keys as username, admin name, folder name. These keys are used in URIs for REST API and Web admin. If `false` only unreserved URI characters are allowed: ALPHA / DIGIT / "-" / "." / "_" / "~". Default: `false`.
- **"httpd"**, the configuration for the HTTP server used to serve REST API and to expose the built-in web interface
- `bindings`, list of structs. Each struct has the following fields:
- `port`, integer. The port used for serving HTTP requests. Default: 8080.
diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go
index 79284747..16ada1f5 100644
--- a/httpd/httpd_test.go
+++ b/httpd/httpd_test.go
@@ -2228,6 +2228,65 @@ func TestCloseConnectionAfterUserUpdateDelete(t *testing.T) {
assert.Len(t, common.Connections.GetStats(), 0)
}
+func TestSkipNaturalKeysValidation(t *testing.T) {
+ err := dataprovider.Close()
+ assert.NoError(t, err)
+ err = config.LoadConfig(configDir, "")
+ assert.NoError(t, err)
+ providerConf := config.GetProviderConf()
+ providerConf.SkipNaturalKeysValidation = true
+ err = dataprovider.Initialize(providerConf, configDir, true)
+ assert.NoError(t, err)
+
+ u := getTestUser()
+ u.Username = "user@example.com"
+ user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+ assert.NoError(t, err)
+ user.AdditionalInfo = "info"
+ user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+ assert.NoError(t, err)
+ user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveUser(user, http.StatusOK)
+ assert.NoError(t, err)
+
+ a := getTestAdmin()
+ a.Username = "admin@example.com"
+ admin, _, err := httpdtest.AddAdmin(a, http.StatusCreated)
+ assert.NoError(t, err)
+ admin.Email = admin.Username
+ admin, _, err = httpdtest.UpdateAdmin(admin, http.StatusOK)
+ assert.NoError(t, err)
+ admin, _, err = httpdtest.GetAdminByUsername(admin.Username, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
+ assert.NoError(t, err)
+
+ f := vfs.BaseVirtualFolder{
+ Name: "文件夹",
+ MappedPath: filepath.Clean(os.TempDir()),
+ }
+ folder, resp, err := httpdtest.AddFolder(f, http.StatusCreated)
+ assert.NoError(t, err, string(resp))
+ folder, resp, err = httpdtest.UpdateFolder(folder, http.StatusOK)
+ assert.NoError(t, err, string(resp))
+ folder, resp, err = httpdtest.GetFolderByName(folder.Name, http.StatusOK)
+ assert.NoError(t, err, string(resp))
+ _, err = httpdtest.RemoveFolder(folder, http.StatusOK)
+ assert.NoError(t, err)
+
+ err = dataprovider.Close()
+ assert.NoError(t, err)
+ err = config.LoadConfig(configDir, "")
+ assert.NoError(t, err)
+ providerConf = config.GetProviderConf()
+ providerConf.CredentialsPath = credentialsPath
+ err = os.RemoveAll(credentialsPath)
+ assert.NoError(t, err)
+ err = dataprovider.Initialize(providerConf, configDir, true)
+ assert.NoError(t, err)
+}
+
func TestUserBaseDir(t *testing.T) {
err := dataprovider.Close()
assert.NoError(t, err)
diff --git a/sftpgo.json b/sftpgo.json
index 44b52b2d..0d041c5c 100644
--- a/sftpgo.json
+++ b/sftpgo.json
@@ -146,7 +146,8 @@
"parallelism": 2
}
},
- "update_mode": 0
+ "update_mode": 0,
+ "skip_natural_keys_validation": false
},
"httpd": {
"bindings": [
diff --git a/templates/admins.html b/templates/admins.html
index bed10fa5..8d1b6a99 100644
--- a/templates/admins.html
+++ b/templates/admins.html
@@ -93,7 +93,7 @@
var table = $('#dataTable').DataTable();
table.button('delete:name').enable(false);
var username = table.row({ selected: true }).data()[1];
- var path = '{{.AdminURL}}' + "/" + username;
+ var path = '{{.AdminURL}}' + "/" + fixedEncodeURIComponent(username);
$('#deleteModal').modal('hide');
$.ajax({
url: path,
@@ -137,8 +137,8 @@
name: 'edit',
action: function (e, dt, node, config) {
var username = dt.row({ selected: true }).data()[1];
- var path = '{{.AdminURL}}' + "/" + username;
- window.location.href = encodeURI(path);
+ var path = '{{.AdminURL}}' + "/" + fixedEncodeURIComponent(username);
+ window.location.href = path;
},
enabled: false
};
diff --git a/templates/base.html b/templates/base.html
index 5a0f038a..e9e8aff5 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -221,6 +221,14 @@
+
+
{{block "extra_js" .}}{{end}}
diff --git a/templates/folders.html b/templates/folders.html
index 677bab61..dcd815b1 100644
--- a/templates/folders.html
+++ b/templates/folders.html
@@ -91,10 +91,10 @@ function deleteAction() {
var table = $('#dataTable').DataTable();
table.button('delete:name').enable(false);
var folderName = table.row({ selected: true }).data()[0];
- var path = '{{.FolderURL}}' + "/" + folderName;
+ var path = '{{.FolderURL}}' + "/" + fixedEncodeURIComponent(folderName);
$('#deleteModal').modal('hide');
$.ajax({
- url: encodeURI(path),
+ url: path,
type: 'DELETE',
dataType: 'json',
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
@@ -135,8 +135,8 @@ function deleteAction() {
name: 'edit',
action: function (e, dt, node, config) {
var folderName = table.row({ selected: true }).data()[0];
- var path = '{{.FolderURL}}' + "/" + folderName;
- window.location.href = encodeURI(path);
+ var path = '{{.FolderURL}}' + "/" + fixedEncodeURIComponent(folderName);
+ window.location.href = path;
},
enabled: false
};
@@ -148,7 +148,7 @@ function deleteAction() {
var selectedRows = table.rows({ selected: true }).count();
if (selectedRows == 1){
var folderName = table.row({ selected: true }).data()[0];
- var path = '{{.FolderTemplateURL}}' + "?from=" + encodeURIComponent(folderName);
+ var path = '{{.FolderTemplateURL}}' + "?from=" + fixedEncodeURIComponent(folderName);
window.location.href = path;
} else {
window.location.href = '{{.FolderTemplateURL}}';
diff --git a/templates/users.html b/templates/users.html
index d2298e8e..a6922916 100644
--- a/templates/users.html
+++ b/templates/users.html
@@ -99,10 +99,10 @@
var table = $('#dataTable').DataTable();
table.button('delete:name').enable(false);
var username = table.row({ selected: true }).data()[1];
- var path = '{{.UserURL}}' + "/" + username;
+ var path = '{{.UserURL}}' + "/" + fixedEncodeURIComponent(username);
$('#deleteModal').modal('hide');
$.ajax({
- url: encodeURI(path),
+ url: path,
type: 'DELETE',
dataType: 'json',
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
@@ -143,8 +143,8 @@
name: 'edit',
action: function (e, dt, node, config) {
var username = dt.row({ selected: true }).data()[1];
- var path = '{{.UserURL}}' + "/" + username;
- window.location.href = encodeURI(path);
+ var path = '{{.UserURL}}' + "/" + fixedEncodeURIComponent(username);
+ window.location.href = path;
},
enabled: false
};
@@ -154,7 +154,7 @@
name: 'clone',
action: function (e, dt, node, config) {
var username = dt.row({ selected: true }).data()[1];
- var path = '{{.UserURL}}' + "?clone-from=" + encodeURIComponent(username);
+ var path = '{{.UserURL}}' + "?clone-from=" + fixedEncodeURIComponent(username);
window.location.href = path;
},
enabled: false
@@ -167,7 +167,7 @@
var selectedRows = table.rows({ selected: true }).count();
if (selectedRows == 1){
var username = dt.row({ selected: true }).data()[1];
- var path = '{{.UserTemplateURL}}' + "?from=" + encodeURIComponent(username);
+ var path = '{{.UserTemplateURL}}' + "?from=" + fixedEncodeURIComponent(username);
window.location.href = path;
} else {
window.location.href = '{{.UserTemplateURL}}';