try to make the web admin more user friendly

removed all the textarea with fields separated using "::".
This should, hopefully, improve user experience
This commit is contained in:
Nicola Murino 2021-05-23 22:02:01 +02:00
parent 02bb09ec01
commit 50e441849a
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
21 changed files with 1156 additions and 582 deletions

View file

@ -85,6 +85,17 @@ var (
errNoMatchingVirtualFolder = errors.New("no matching virtual folder found")
)
// DirectoryPermissions defines permissions for a directory path
type DirectoryPermissions struct {
Path string
Permissions []string
}
// HasPerm returns true if the directory has the specified permissions
func (d *DirectoryPermissions) HasPerm(perm string) bool {
return utils.IsStringInSlice(perm, d.Permissions)
}
// PatternsFilter defines filters based on shell like patterns.
// These restrictions do not apply to files listing for performance reasons, so
// a denied file cannot be downloaded/overwritten/renamed but will still be
@ -106,6 +117,24 @@ type PatternsFilter struct {
DeniedPatterns []string `json:"denied_patterns,omitempty"`
}
// GetCommaSeparatedPatterns returns the first non empty patterns list comma separated
func (p *PatternsFilter) GetCommaSeparatedPatterns() string {
if len(p.DeniedPatterns) > 0 {
return strings.Join(p.DeniedPatterns, ",")
}
return strings.Join(p.AllowedPatterns, ",")
}
// IsDenied returns true if the patterns has one or more denied patterns
func (p *PatternsFilter) IsDenied() bool {
return len(p.DeniedPatterns) > 0
}
// IsAllowed returns true if the patterns has one or more allowed patterns
func (p *PatternsFilter) IsAllowed() bool {
return len(p.AllowedPatterns) > 0
}
// HooksFilter defines user specific overrides for global hooks
type HooksFilter struct {
ExternalAuthDisabled bool `json:"external_auth_disabled"`
@ -328,6 +357,22 @@ func (u *User) hideConfidentialData() {
}
}
// GetSubDirPermissions returns permissions for sub directories
func (u *User) GetSubDirPermissions() []DirectoryPermissions {
var result []DirectoryPermissions
for k, v := range u.Permissions {
if k == "/" {
continue
}
dirPerms := DirectoryPermissions{
Path: k,
Permissions: v,
}
result = append(result, dirPerms)
}
return result
}
// PrepareForRendering prepares a user for rendering.
// It hides confidential data and set to nil the empty secrets
// so they are not serialized
@ -754,6 +799,28 @@ func (u *User) GetAllowedLoginMethods() []string {
return allowedMethods
}
// GetFlatFilePatterns returns file patterns as flat list
// duplicating a path if it has both allowed and denied patterns
func (u *User) GetFlatFilePatterns() []PatternsFilter {
var result []PatternsFilter
for _, pattern := range u.Filters.FilePatterns {
if len(pattern.AllowedPatterns) > 0 {
result = append(result, PatternsFilter{
Path: pattern.Path,
AllowedPatterns: pattern.AllowedPatterns,
})
}
if len(pattern.DeniedPatterns) > 0 {
result = append(result, PatternsFilter{
Path: pattern.Path,
DeniedPatterns: pattern.DeniedPatterns,
})
}
}
return result
}
// IsFileAllowed returns true if the specified file is allowed by the file restrictions filters
func (u *User) IsFileAllowed(virtualPath string) bool {
return u.isFilePatternAllowed(virtualPath)

View file

@ -129,9 +129,10 @@ You can find more details about Data At Rest Encryption [here](../dare.md).
SFTPGo supports per directory virtual permissions. For each user you have to specify global permissions and then override them on a per-directory basis.
Take a look at the following screen.
Take a look at the following screens.
![Virtual permissions](./img/virtual-permissions.png)
![Per-directory permissions](./img/dir-permissions.png)
This user has full access as default (`*`), can only list and download from `/read-only` path and has no permissions at all for the `/subdir` path.
@ -176,7 +177,7 @@ From the web admin interface click `Folders` and then the `+` icon.
To create a local folder you need to specify a `Name` and an `Absolute path`. For other backends you have to specify the backend type and its credentials, this is the same procedure already detailed for creating users with cloud backends.
Suppose we created two folders name `localfolder` and `minio` as you can see in the following screen.
Suppose we created two virtual folders name `localfolder` and `minio` as you can see in the following screen.
![Folders](./img/folders.png)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -245,7 +245,7 @@ func createCSRFToken() string {
func verifyCSRFToken(tokenString string) error {
token, err := jwtauth.VerifyToken(csrfTokenAuth, tokenString)
if err != nil || token == nil {
logger.Debug(logSender, "", "error validating CSRF: %v", err)
logger.Debug(logSender, "", "error validating CSRF token %#v: %v", tokenString, err)
return fmt.Errorf("unable to verify form token: %v", err)
}

View file

@ -49,6 +49,7 @@ const (
defaultUsername = "test_user"
defaultPassword = "test_password"
testPubKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC03jj0D+djk7pxIf/0OhrxrchJTRZklofJ1NoIu4752Sq02mdXmarMVsqJ1cAjV5LBVy3D1F5U6XW4rppkXeVtd04Pxb09ehtH0pRRPaoHHlALiJt8CoMpbKYMA8b3KXPPriGxgGomvtU2T2RMURSwOZbMtpsugfjYSWenyYX+VORYhylWnSXL961LTyC21ehd6d6QnW9G7E5hYMITMY9TuQZz3bROYzXiTsgN0+g6Hn7exFQp50p45StUMfV/SftCMdCxlxuyGny2CrN/vfjO7xxOo2uv7q1qm10Q46KPWJQv+pgZ/OfL+EDjy07n5QVSKHlbx+2nT4Q0EgOSQaCTYwn3YjtABfIxWwgAFdyj6YlPulCL22qU4MYhDcA6PSBwDdf8hvxBfvsiHdM+JcSHvv8/VeJhk6CmnZxGY0fxBupov27z3yEO8nAg8k+6PaUiW1MSUfuGMF/ktB8LOstXsEPXSszuyXiOv4DaryOXUiSn7bmRqKcEFlJusO6aZP0= nicola@p1"
testPubKey1 = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCd60+/j+y8f0tLftihWV1YN9RSahMI9btQMDIMqts/jeNbD8jgoogM3nhF7KxfcaMKURuD47KC4Ey6iAJUJ0sWkSNNxOcIYuvA+5MlspfZDsa8Ag76Fe1vyz72WeHMHMeh/hwFo2TeIeIXg480T1VI6mzfDrVp2GzUx0SS0dMsQBjftXkuVR8YOiOwMCAH2a//M1OrvV7d/NBk6kBN0WnuIBb2jKm15PAA7+jQQG7tzwk2HedNH3jeL5GH31xkSRwlBczRK0xsCQXehAlx6cT/e/s44iJcJTHfpPKoSk6UAhPJYe7Z1QnuoawY9P9jQaxpyeImBZxxUEowhjpj2avBxKdRGBVK8R7EL8tSOeLbhdyWe5Mwc1+foEbq9Zz5j5Kd+hn3Wm1UnsGCrXUUUoZp1jnlNl0NakCto+5KmqnT9cHxaY+ix2RLUWAZyVFlRq71OYux1UHJnEJPiEI1/tr4jFBSL46qhQZv/TfpkfVW8FLz0lErfqu0gQEZnNHr3Fc= nicola@p1"
defaultTokenAuthUser = "admin"
defaultTokenAuthPass = "password"
altAdminUsername = "newTestAdmin"
@ -4842,6 +4843,7 @@ func TestWebClientChangePubKeys(t *testing.T) {
assert.NoError(t, err)
form := make(url.Values)
form.Set("public_keys", testPubKey)
form.Add("public_keys", testPubKey1)
// no csrf token
req, _ := http.NewRequest(http.MethodPost, webChangeClientKeysPath, bytes.NewBuffer([]byte(form.Encode())))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@ -4860,7 +4862,7 @@ func TestWebClientChangePubKeys(t *testing.T) {
user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
assert.NoError(t, err)
assert.Len(t, user.PublicKeys, 1)
assert.Len(t, user.PublicKeys, 2)
form.Set("public_keys", "invalid")
req, _ = http.NewRequest(http.MethodPost, webChangeClientKeysPath, bytes.NewBuffer([]byte(form.Encode())))
@ -5899,10 +5901,28 @@ func TestWebUserAddMock(t *testing.T) {
form.Set("status", strconv.Itoa(user.Status))
form.Set("expiration_date", "")
form.Set("permissions", "*")
form.Set("sub_dirs_permissions", " /subdir::list ,download ")
form.Set("virtual_folders", fmt.Sprintf(" /vdir:: %v :: 2 :: 1024", folderName))
form.Set("allowed_patterns", "/dir2::*.jpg,*.png\n/dir1::*.png")
form.Set("denied_patterns", "/dir1::*.zip\n/dir3::*.rar\n/dir2::*.mkv")
form.Set("sub_perm_path0", "/subdir")
form.Set("sub_perm_permissions0", "list")
form.Add("sub_perm_permissions0", "download")
form.Set("vfolder_path", " /vdir")
form.Set("vfolder_name", folderName)
form.Set("vfolder_quota_size", "1024")
form.Set("vfolder_quota_files", "2")
form.Set("pattern_path0", "/dir2")
form.Set("patterns0", "*.jpg,*.png")
form.Set("pattern_type0", "allowed")
form.Set("pattern_path1", "/dir1")
form.Set("patterns1", "*.png")
form.Set("pattern_type1", "allowed")
form.Set("pattern_path2", "/dir1")
form.Set("patterns2", "*.zip")
form.Set("pattern_type2", "denied")
form.Set("pattern_path3", "/dir3")
form.Set("patterns3", "*.rar")
form.Set("pattern_type3", "denied")
form.Set("pattern_path4", "/dir2")
form.Set("patterns4", "*.mkv")
form.Set("pattern_type4", "denied")
form.Set("additional_info", user.AdditionalInfo)
form.Set("description", user.Description)
form.Add("hooks", "external_auth_disabled")
@ -5915,6 +5935,7 @@ func TestWebUserAddMock(t *testing.T) {
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
form.Set("public_keys", testPubKey)
form.Add("public_keys", testPubKey1)
form.Set("uid", strconv.FormatInt(int64(user.UID), 10))
form.Set("gid", "a")
b, contentType, _ = getMultipartFormData(form, "", "")
@ -6077,6 +6098,7 @@ func TestWebUserAddMock(t *testing.T) {
} else {
assert.Fail(t, "user permissions must contain /somedir", "actual: %v", newUser.Permissions)
}
assert.Len(t, newUser.PublicKeys, 2)
assert.Len(t, newUser.VirtualFolders, 1)
for _, v := range newUser.VirtualFolders {
assert.Equal(t, v.VirtualPath, "/vdir")
@ -6154,12 +6176,16 @@ func TestWebUserUpdateMock(t *testing.T) {
form.Set("upload_bandwidth", "0")
form.Set("download_bandwidth", "0")
form.Set("permissions", "*")
form.Set("sub_dirs_permissions", "/otherdir :: list ,upload ")
form.Set("sub_perm_path0", "/otherdir")
form.Set("sub_perm_permissions0", "list")
form.Add("sub_perm_permissions0", "upload")
form.Set("status", strconv.Itoa(user.Status))
form.Set("expiration_date", "2020-01-01 00:00:00")
form.Set("allowed_ip", " 192.168.1.3/32, 192.168.2.0/24 ")
form.Set("denied_ip", " 10.0.0.2/32 ")
form.Set("denied_patterns", "/dir1::*.zip")
form.Set("pattern_path0", "/dir1")
form.Set("patterns0", "*.zip")
form.Set("pattern_type0", "denied")
form.Set("ssh_login_methods", dataprovider.SSHLoginMethodKeyboardInteractive)
form.Set("denied_protocols", common.ProtocolFTP)
form.Set("max_upload_file_size", "100")
@ -6355,14 +6381,24 @@ func TestUserTemplateWithFoldersMock(t *testing.T) {
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("fs_provider", "0")
form.Set("max_upload_file_size", "0")
form.Set("description", "desc %username% %password%")
form.Set("virtual_folders", "/vdir%username%::"+folder.Name+"::-1::-1")
form.Set("users", "auser1::password1\nauser2::password2::"+testPubKey+"\nauser1::password")
form.Set("vfolder_path", "/vdir%username%")
form.Set("vfolder_name", folder.Name)
form.Set("vfolder_quota_size", "-1")
form.Set("vfolder_quota_files", "-1")
form.Add("tpl_username", "auser1")
form.Add("tpl_password", "password1")
form.Add("tpl_public_keys", " ")
form.Add("tpl_username", "auser2")
form.Add("tpl_password", "password2")
form.Add("tpl_public_keys", testPubKey)
form.Add("tpl_username", "auser1")
form.Add("tpl_password", "password")
form.Add("tpl_public_keys", "")
b, contentType, _ := getMultipartFormData(form, "", "")
req, _ := http.NewRequest(http.MethodPost, path.Join(webTemplateUser), &b)
setJWTCookieForReq(req, token)
@ -6443,7 +6479,6 @@ func TestUserTemplateMock(t *testing.T) {
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", "")
@ -6478,7 +6513,9 @@ func TestUserTemplateMock(t *testing.T) {
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
form.Set("users", "user1::password1::invalid-pkey")
form.Set("tpl_username", "user1")
form.Set("tpl_password", "password1")
form.Set("tpl_public_keys", "invalid-pkey")
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, webTemplateUser, &b)
setJWTCookieForReq(req, token)
@ -6487,7 +6524,9 @@ func TestUserTemplateMock(t *testing.T) {
checkResponseCode(t, http.StatusBadRequest, rr)
require.Contains(t, rr.Body.String(), "Error validating user")
form.Set("users", "user1:password1")
form.Set("tpl_username", "user1")
form.Set("tpl_password", " ")
form.Set("tpl_public_keys", "")
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, webTemplateUser, &b)
setJWTCookieForReq(req, token)
@ -6496,7 +6535,15 @@ func TestUserTemplateMock(t *testing.T) {
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::::")
form.Set("tpl_username", "user1")
form.Set("tpl_password", "password1")
form.Set("tpl_public_keys", " ")
form.Add("tpl_username", "user2")
form.Add("tpl_password", "password2")
form.Add("tpl_public_keys", testPubKey)
form.Add("tpl_username", "user3")
form.Add("tpl_password", "")
form.Add("tpl_public_keys", "")
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, webTemplateUser, &b)
setJWTCookieForReq(req, token)
@ -6552,9 +6599,13 @@ func TestFolderTemplateMock(t *testing.T) {
form.Set("name", folderName)
form.Set("mapped_path", mappedPath)
form.Set("description", "desc folder %name%")
form.Set("folders", "folder1\nfolder2\nfolder3\nfolder1\n\n\n")
contentType := "application/x-www-form-urlencoded"
req, _ := http.NewRequest(http.MethodPost, webTemplateFolder, bytes.NewBuffer([]byte(form.Encode())))
form.Add("tpl_foldername", "folder1")
form.Add("tpl_foldername", "folder2")
form.Add("tpl_foldername", "folder3")
form.Add("tpl_foldername", "folder1 ")
form.Add("tpl_foldername", " ")
b, contentType, _ := getMultipartFormData(form, "", "")
req, _ := http.NewRequest(http.MethodPost, webTemplateFolder, &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr := executeRequest(req)
@ -6562,7 +6613,8 @@ func TestFolderTemplateMock(t *testing.T) {
assert.Contains(t, rr.Body.String(), "unable to verify form token")
form.Set(csrfFormToken, csrfToken)
req, _ = http.NewRequest(http.MethodPost, webTemplateFolder+"?param=p%C3%AO%GG", bytes.NewBuffer([]byte(form.Encode())))
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, webTemplateFolder+"?param=p%C3%AO%GG", &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
@ -6572,7 +6624,8 @@ func TestFolderTemplateMock(t *testing.T) {
folder1 := "folder1"
folder2 := "folder2"
folder3 := "folder3"
req, _ = http.NewRequest(http.MethodPost, webTemplateFolder, bytes.NewBuffer([]byte(form.Encode())))
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, webTemplateFolder, &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
@ -6601,7 +6654,8 @@ func TestFolderTemplateMock(t *testing.T) {
form.Set("s3_access_secret", "pwd%name%")
form.Set("s3_key_prefix", "base/%name%")
req, _ = http.NewRequest(http.MethodPost, webTemplateFolder, bytes.NewBuffer([]byte(form.Encode())))
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, webTemplateFolder, &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
@ -6610,7 +6664,8 @@ func TestFolderTemplateMock(t *testing.T) {
form.Set("s3_upload_part_size", "5")
form.Set("s3_upload_concurrency", "4")
req, _ = http.NewRequest(http.MethodPost, webTemplateFolder, bytes.NewBuffer([]byte(form.Encode())))
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, webTemplateFolder, &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
@ -6641,17 +6696,19 @@ func TestFolderTemplateMock(t *testing.T) {
require.Equal(t, "pwd"+folder3, dump.Folders[2].FsConfig.S3Config.AccessSecret.GetPayload())
require.Equal(t, "base/"+folder3+"/", dump.Folders[2].FsConfig.S3Config.KeyPrefix)
form.Set("folders", "\n\n\n")
req, _ = http.NewRequest(http.MethodPost, webTemplateFolder, bytes.NewBuffer([]byte(form.Encode())))
form.Set("tpl_foldername", " ")
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, webTemplateFolder, &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
assert.Contains(t, rr.Body.String(), "No folders to export")
form.Set("folders", "name")
form.Set("tpl_foldername", "name")
form.Set("mapped_path", "relative-path")
req, _ = http.NewRequest(http.MethodPost, webTemplateFolder, bytes.NewBuffer([]byte(form.Encode())))
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, webTemplateFolder, &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
@ -6698,7 +6755,6 @@ func TestWebUserS3Mock(t *testing.T) {
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", "")
@ -6711,8 +6767,12 @@ func TestWebUserS3Mock(t *testing.T) {
form.Set("s3_storage_class", user.FsConfig.S3Config.StorageClass)
form.Set("s3_endpoint", user.FsConfig.S3Config.Endpoint)
form.Set("s3_key_prefix", user.FsConfig.S3Config.KeyPrefix)
form.Set("allowed_patterns", "/dir1::*.jpg,*.png")
form.Set("denied_patterns", "/dir2::*.zip")
form.Set("pattern_path0", "/dir1")
form.Set("patterns0", "*.jpg,*.png")
form.Set("pattern_type0", "allowed")
form.Set("pattern_path1", "/dir2")
form.Set("patterns1", "*.zip")
form.Set("pattern_type1", "denied")
form.Set("max_upload_file_size", "0")
form.Set("description", user.Description)
form.Add("hooks", "pre_login_disabled")
@ -6847,7 +6907,6 @@ func TestWebUserGCSMock(t *testing.T) {
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", "")
@ -6856,7 +6915,9 @@ func TestWebUserGCSMock(t *testing.T) {
form.Set("gcs_bucket", user.FsConfig.GCSConfig.Bucket)
form.Set("gcs_storage_class", user.FsConfig.GCSConfig.StorageClass)
form.Set("gcs_key_prefix", user.FsConfig.GCSConfig.KeyPrefix)
form.Set("allowed_patterns", "/dir1::*.jpg,*.png")
form.Set("pattern_path0", "/dir1")
form.Set("patterns0", "*.jpg,*.png")
form.Set("pattern_type0", "allowed")
form.Set("max_upload_file_size", "0")
b, contentType, _ := getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
@ -6890,7 +6951,12 @@ func TestWebUserGCSMock(t *testing.T) {
assert.Equal(t, user.FsConfig.GCSConfig.Bucket, updateUser.FsConfig.GCSConfig.Bucket)
assert.Equal(t, user.FsConfig.GCSConfig.StorageClass, updateUser.FsConfig.GCSConfig.StorageClass)
assert.Equal(t, user.FsConfig.GCSConfig.KeyPrefix, updateUser.FsConfig.GCSConfig.KeyPrefix)
if assert.Len(t, updateUser.Filters.FilePatterns, 1) {
assert.Equal(t, "/dir1", updateUser.Filters.FilePatterns[0].Path)
assert.Len(t, updateUser.Filters.FilePatterns[0].AllowedPatterns, 2)
assert.Contains(t, updateUser.Filters.FilePatterns[0].AllowedPatterns, "*.png")
assert.Contains(t, updateUser.Filters.FilePatterns[0].AllowedPatterns, "*.jpg")
}
form.Set("gcs_auto_credentials", "on")
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
@ -6950,7 +7016,6 @@ func TestWebUserAzureBlobMock(t *testing.T) {
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", "")
@ -6963,8 +7028,12 @@ func TestWebUserAzureBlobMock(t *testing.T) {
form.Set("az_endpoint", user.FsConfig.AzBlobConfig.Endpoint)
form.Set("az_key_prefix", user.FsConfig.AzBlobConfig.KeyPrefix)
form.Set("az_use_emulator", "checked")
form.Set("allowed_patterns", "/dir1::*.jpg,*.png")
form.Set("denied_patterns", "/dir2::*.zip")
form.Set("pattern_path0", "/dir1")
form.Set("patterns0", "*.jpg,*.png")
form.Set("pattern_type0", "allowed")
form.Set("pattern_path1", "/dir2")
form.Set("patterns1", "*.zip")
form.Set("pattern_type1", "denied")
form.Set("max_upload_file_size", "0")
// test invalid az_upload_part_size
form.Set("az_upload_part_size", "a")
@ -7066,15 +7135,18 @@ func TestWebUserCryptMock(t *testing.T) {
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", "4")
form.Set("crypt_passphrase", "")
form.Set("allowed_patterns", "/dir1::*.jpg,*.png")
form.Set("denied_patterns", "/dir2::*.zip")
form.Set("pattern_path0", "/dir1")
form.Set("patterns0", "*.jpg,*.png")
form.Set("pattern_type0", "allowed")
form.Set("pattern_path1", "/dir2")
form.Set("patterns1", "*.zip")
form.Set("pattern_type1", "denied")
form.Set("max_upload_file_size", "0")
// passphrase cannot be empty
b, contentType, _ := getMultipartFormData(form, "", "")
@ -7165,15 +7237,18 @@ func TestWebUserSFTPFsMock(t *testing.T) {
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", "5")
form.Set("crypt_passphrase", "")
form.Set("allowed_patterns", "/dir1::*.jpg,*.png")
form.Set("denied_patterns", "/dir2::*.zip")
form.Set("pattern_path0", "/dir1")
form.Set("patterns0", "*.jpg,*.png")
form.Set("pattern_type0", "allowed")
form.Set("pattern_path1", "/dir2")
form.Set("patterns1", "*.zip")
form.Set("pattern_type1", "denied")
form.Set("max_upload_file_size", "0")
// empty sftpconfig
b, contentType, _ := getMultipartFormData(form, "", "")

View file

@ -1,10 +1,7 @@
package httpd
import (
"path"
"strings"
"github.com/drakkan/sftpgo/utils"
)
const (
@ -38,32 +35,3 @@ func getSliceFromDelimitedValues(values, delimiter string) []string {
}
return result
}
func getListFromPostFields(value string) map[string][]string {
result := make(map[string][]string)
for _, cleaned := range getSliceFromDelimitedValues(value, "\n") {
if strings.Contains(cleaned, "::") {
dirExts := strings.Split(cleaned, "::")
if len(dirExts) > 1 {
dir := dirExts[0]
dir = path.Clean(strings.TrimSpace(dir))
exts := []string{}
for _, e := range strings.Split(dirExts[1], ",") {
cleanedExt := strings.TrimSpace(e)
if cleanedExt != "" {
exts = append(exts, cleanedExt)
}
}
if dir != "" {
if _, ok := result[dir]; ok {
result[dir] = append(result[dir], exts...)
} else {
result[dir] = exts
}
result[dir] = utils.RemoveDuplicates(result[dir])
}
}
}
}
return result
}

View file

@ -137,6 +137,7 @@ type userPage struct {
RootDirPerms []string
RedactedSecret string
Mode userPageMode
VirtualFolders []vfs.BaseVirtualFolder
}
type adminPage struct {
@ -388,6 +389,10 @@ func renderAddUpdateAdminPage(w http.ResponseWriter, r *http.Request, admin *dat
}
func renderUserPage(w http.ResponseWriter, r *http.Request, user *dataprovider.User, mode userPageMode, error string) {
folders, err := getWebVirtualFolders(w, r, defaultQueryLimit)
if err != nil {
return
}
user.SetEmptySecretsIfNil()
var title, currentURL string
switch mode {
@ -415,6 +420,7 @@ func renderUserPage(w http.ResponseWriter, r *http.Request, user *dataprovider.U
ValidProtocols: dataprovider.ValidProtocols,
WebClientOptions: dataprovider.WebClientOptions,
RootDirPerms: user.GetPermissionsForPath("/"),
VirtualFolders: folders,
}
renderAdminTemplate(w, templateUser, data)
}
@ -446,9 +452,13 @@ func renderFolderPage(w http.ResponseWriter, r *http.Request, folder vfs.BaseVir
func getFoldersForTemplate(r *http.Request) []string {
var res []string
formValue := r.Form.Get("folders")
folderNames := r.Form["tpl_foldername"]
folders := make(map[string]bool)
for _, name := range getSliceFromDelimitedValues(formValue, "\n") {
for _, name := range folderNames {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if _, ok := folders[name]; ok {
continue
}
@ -460,17 +470,20 @@ func getFoldersForTemplate(r *http.Request) []string {
func getUsersForTemplate(r *http.Request) []userTemplateFields {
var res []userTemplateFields
formValue := r.Form.Get("users")
tplUsernames := r.Form["tpl_username"]
tplPasswords := r.Form["tpl_password"]
tplPublicKeys := r.Form["tpl_public_keys"]
users := make(map[string]bool)
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])
for idx, username := range tplUsernames {
username = strings.TrimSpace(username)
password := ""
publicKey := ""
if len(tplPasswords) > idx {
password = strings.TrimSpace(tplPasswords[idx])
}
if len(tplPublicKeys) > idx {
publicKey = strings.TrimSpace(tplPublicKeys[idx])
}
if username == "" || (password == "" && publicKey == "") {
continue
@ -486,14 +499,48 @@ func getUsersForTemplate(r *http.Request) []userTemplateFields {
PublicKey: publicKey,
})
}
}
}
return res
}
func getVirtualFoldersFromPostFields(r *http.Request) []vfs.VirtualFolder {
var virtualFolders []vfs.VirtualFolder
formValue := r.Form.Get("virtual_folders")
folderPaths := r.Form["vfolder_path"]
folderNames := r.Form["vfolder_name"]
folderQuotaSizes := r.Form["vfolder_quota_size"]
folderQuotaFiles := r.Form["vfolder_quota_files"]
for idx, p := range folderPaths {
p = strings.TrimSpace(p)
name := ""
if len(folderNames) > idx {
name = folderNames[idx]
}
if p != "" && name != "" {
vfolder := vfs.VirtualFolder{
BaseVirtualFolder: vfs.BaseVirtualFolder{
Name: name,
},
VirtualPath: p,
QuotaFiles: -1,
QuotaSize: -1,
}
if len(folderQuotaSizes) > idx {
quotaSize, err := strconv.ParseInt(strings.TrimSpace(folderQuotaSizes[idx]), 10, 64)
if err == nil {
vfolder.QuotaSize = quotaSize
}
}
if len(folderQuotaFiles) > idx {
quotaFiles, err := strconv.Atoi(strings.TrimSpace(folderQuotaFiles[idx]))
if err == nil {
vfolder.QuotaFiles = quotaFiles
}
}
virtualFolders = append(virtualFolders, vfolder)
}
}
/*formValue := r.Form.Get("virtual_folders")
for _, cleaned := range getSliceFromDelimitedValues(formValue, "\n") {
if strings.Contains(cleaned, "::") {
mapping := strings.Split(cleaned, "::")
@ -521,49 +568,58 @@ func getVirtualFoldersFromPostFields(r *http.Request) []vfs.VirtualFolder {
virtualFolders = append(virtualFolders, vfolder)
}
}
}
}*/
return virtualFolders
}
func getUserPermissionsFromPostFields(r *http.Request) map[string][]string {
permissions := make(map[string][]string)
permissions["/"] = r.Form["permissions"]
subDirsPermsValue := r.Form.Get("sub_dirs_permissions")
for _, cleaned := range getSliceFromDelimitedValues(subDirsPermsValue, "\n") {
if strings.Contains(cleaned, "::") {
dirPerms := strings.Split(cleaned, "::")
if len(dirPerms) > 1 {
dir := dirPerms[0]
dir = strings.TrimSpace(dir)
perms := []string{}
for _, p := range strings.Split(dirPerms[1], ",") {
cleanedPerm := strings.TrimSpace(p)
if cleanedPerm != "" {
perms = append(perms, cleanedPerm)
}
}
if dir != "" {
permissions[dir] = perms
}
for k := range r.Form {
if strings.HasPrefix(k, "sub_perm_path") {
p := strings.TrimSpace(r.Form.Get(k))
if p != "" {
idx := strings.TrimPrefix(k, "sub_perm_path")
permissions[p] = r.Form[fmt.Sprintf("sub_perm_permissions%v", idx)]
}
}
}
return permissions
}
func getFilePatternsFromPostField(valueAllowed, valuesDenied string) []dataprovider.PatternsFilter {
func getFilePatternsFromPostField(r *http.Request) []dataprovider.PatternsFilter {
var result []dataprovider.PatternsFilter
allowedPatterns := getListFromPostFields(valueAllowed)
deniedPatterns := getListFromPostFields(valuesDenied)
allowedPatterns := make(map[string][]string)
deniedPatterns := make(map[string][]string)
for k := range r.Form {
if strings.HasPrefix(k, "pattern_path") {
p := strings.TrimSpace(r.Form.Get(k))
idx := strings.TrimPrefix(k, "pattern_path")
filters := strings.TrimSpace(r.Form.Get(fmt.Sprintf("patterns%v", idx)))
filters = strings.ReplaceAll(filters, " ", "")
patternType := r.Form.Get(fmt.Sprintf("pattern_type%v", idx))
if p != "" && filters != "" {
if patternType == "allowed" {
allowedPatterns[p] = append(allowedPatterns[p], strings.Split(filters, ",")...)
} else {
deniedPatterns[p] = append(deniedPatterns[p], strings.Split(filters, ",")...)
}
}
}
}
for dirAllowed, allowPatterns := range allowedPatterns {
filter := dataprovider.PatternsFilter{
Path: dirAllowed,
AllowedPatterns: allowPatterns,
AllowedPatterns: utils.RemoveDuplicates(allowPatterns),
}
for dirDenied, denPatterns := range deniedPatterns {
if dirAllowed == dirDenied {
filter.DeniedPatterns = denPatterns
filter.DeniedPatterns = utils.RemoveDuplicates(denPatterns)
break
}
}
@ -593,7 +649,7 @@ func getFiltersFromUserPostFields(r *http.Request) dataprovider.UserFilters {
filters.DeniedIP = getSliceFromDelimitedValues(r.Form.Get("denied_ip"), ",")
filters.DeniedLoginMethods = r.Form["ssh_login_methods"]
filters.DeniedProtocols = r.Form["denied_protocols"]
filters.FilePatterns = getFilePatternsFromPostField(r.Form.Get("allowed_patterns"), r.Form.Get("denied_patterns"))
filters.FilePatterns = getFilePatternsFromPostField(r)
filters.TLSUsername = dataprovider.TLSUsername(r.Form.Get("tls_username"))
filters.WebClient = r.Form["web_client_options"]
hooks := r.Form["hooks"]
@ -885,8 +941,6 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
if err != nil {
return user, err
}
publicKeysFormValue := r.Form.Get("public_keys")
publicKeys := getSliceFromDelimitedValues(publicKeysFormValue, "\n")
uid, err := strconv.Atoi(r.Form.Get("uid"))
if err != nil {
return user, err
@ -935,7 +989,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
user = dataprovider.User{
Username: r.Form.Get("username"),
Password: r.Form.Get("password"),
PublicKeys: publicKeys,
PublicKeys: r.Form["public_keys"],
HomeDir: r.Form.Get("home_dir"),
VirtualFolders: getVirtualFoldersFromPostFields(r),
UID: uid,
@ -1226,7 +1280,7 @@ func handleWebTemplateFolderGet(w http.ResponseWriter, r *http.Request) {
func handleWebTemplateFolderPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
templateFolder := vfs.BaseVirtualFolder{}
err := r.ParseForm()
err := r.ParseMultipartForm(maxRequestSize)
if err != nil {
renderMessagePage(w, r, "Error parsing folders fields", "", http.StatusBadRequest, err, "")
return
@ -1527,6 +1581,22 @@ func handleWebUpdateFolderPost(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, webFoldersPath, http.StatusSeeOther)
}
func getWebVirtualFolders(w http.ResponseWriter, r *http.Request, limit int) ([]vfs.BaseVirtualFolder, error) {
folders := make([]vfs.BaseVirtualFolder, 0, limit)
for {
f, err := dataprovider.GetFolders(limit, len(folders), dataprovider.OrderASC)
if err != nil {
renderInternalServerErrorPage(w, r, err)
return folders, err
}
folders = append(folders, f...)
if len(f) < limit {
break
}
}
return folders, nil
}
func handleWebGetFolders(w http.ResponseWriter, r *http.Request) {
limit := defaultQueryLimit
if _, ok := r.URL.Query()["qlimit"]; ok {
@ -1536,18 +1606,10 @@ func handleWebGetFolders(w http.ResponseWriter, r *http.Request) {
limit = defaultQueryLimit
}
}
folders := make([]vfs.BaseVirtualFolder, 0, limit)
for {
f, err := dataprovider.GetFolders(limit, len(folders), dataprovider.OrderASC)
folders, err := getWebVirtualFolders(w, r, limit)
if err != nil {
renderInternalServerErrorPage(w, r, err)
return
}
folders = append(folders, f...)
if len(f) < limit {
break
}
}
data := foldersPage{
basePage: getBasePageData(pageFoldersTitle, webFoldersPath, r),

View file

@ -418,14 +418,14 @@ func (c *sshCommand) isSystemCommandAllowed() error {
for _, f := range c.connection.User.Filters.FilePatterns {
if f.Path == sshDestPath {
c.connection.Log(logger.LevelDebug,
"command %#v is not allowed inside folders with files patterns filters %#v user %#v",
"command %#v is not allowed inside folders with file patterns filters %#v user %#v",
c.command, sshDestPath, c.connection.User.Username)
return errUnsupportedConfig
}
if len(sshDestPath) > len(f.Path) {
if strings.HasPrefix(sshDestPath, f.Path+"/") || f.Path == "/" {
c.connection.Log(logger.LevelDebug,
"command %#v is not allowed it includes folders with files patterns filters %#v user %#v",
"command %#v is not allowed it includes folders with file patterns filters %#v user %#v",
c.command, sshDestPath, c.connection.User.Username)
return errUnsupportedConfig
}
@ -433,7 +433,7 @@ func (c *sshCommand) isSystemCommandAllowed() error {
if len(sshDestPath) < len(f.Path) {
if strings.HasPrefix(sshDestPath+"/", f.Path) || sshDestPath == "/" {
c.connection.Log(logger.LevelDebug,
"command %#v is not allowed inside folder with files extensions filters %#v user %#v",
"command %#v is not allowed inside folder with file patterns filters %#v user %#v",
c.command, sshDestPath, c.connection.User.Username)
return errUnsupportedConfig
}

View file

@ -29,14 +29,31 @@
{{end}}
<form id="folder_form" enctype="multipart/form-data" action="{{.CurrentURL}}" method="POST" autocomplete="off" {{if eq .Mode 3}}target="_blank"{{end}}>
{{if eq .Mode 3}}
<div class="card bg-light mb-3">
<div class="card-header">
Folders
</div>
<div class="card-body">
<div class="form-group row">
<label for="idFolders" class="col-sm-2 col-form-label">Folders</label>
<div class="col-sm-10">
<textarea class="form-control" id="idFolders" name="folders" rows="5" required
aria-describedby="foldersHelpBlock"></textarea>
<small id="foldersHelpBlock" class="form-text text-muted">
Specify the folder names, one for line.
</small>
<div class="col-md-12 form_field_tpl_folders_outer">
<div class="row form_field_tpl_folder_outer_row">
<div class="form-group col-md-10">
<input type="text" class="form-control" id="idTplFolder0" name="tpl_foldername" placeholder="Folder name" maxlength="255">
</div>
<div class="form-group col-md-2">
<button class="btn btn-circle btn-danger remove_tpl_folder_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
<div class="row mx-1">
<button type="button" class="btn btn-secondary add_new_tpl_folder_field_btn">
<i class="fas fa-plus"></i> Add new folder name
</button>
</div>
</div>
</div>
<input type="hidden" name="name" id="idFolderName" value="{{.Folder.Name}}">
@ -83,6 +100,30 @@
<script type="text/javascript">
$(document).ready(function () {
onFilesystemChanged('{{.Folder.FsConfig.Provider}}');
$("body").on("click", ".add_new_tpl_folder_field_btn", function () {
var index = $(".form_field_tpl_folders_outer").find(".form_field_tpl_folder_outer_row").length;
while (document.getElementById("idTplFolder"+index) != null){
index++;
}
$(".form_field_tpl_folders_outer").append(`
<div class="row form_field_tpl_folder_outer_row">
<div class="form-group col-md-10">
<input type="text" class="form-control" id="idTplFolder${index}" name="tpl_foldername" placeholder="Folder name" maxlength="255">
</div>
<div class="form-group col-md-2">
<button class="btn btn-circle btn-danger remove_tpl_folder_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
});
$("body").on("click", ".remove_tpl_folder_btn_frm_field", function () {
$(this).closest(".form_field_tpl_folder_outer_row").remove();
});
});
{{template "fsjs"}}

View file

@ -1,5 +1,7 @@
{{define "fshtml"}}
<div class="form-group row">
<div class="card bg-light mb-3">
<div class="card-body pb-1">
<div class="form-group row">
<label for="idFilesystem" class="col-sm-2 col-form-label">Storage</label>
<div class="col-sm-10">
<select class="form-control" id="idFilesystem" name="fs_provider"
@ -12,9 +14,9 @@
<option value="5" {{if eq .Provider 5 }}selected{{end}}>SFTP</option>
</select>
</div>
</div>
</div>
<div class="form-group row s3">
<div class="form-group row s3">
<label for="idS3Bucket" class="col-sm-2 col-form-label">Bucket</label>
<div class="col-sm-3">
<input type="text" class="form-control" id="idS3Bucket" name="s3_bucket" placeholder=""
@ -26,9 +28,9 @@
<input type="text" class="form-control" id="idS3Region" name="s3_region" placeholder=""
value="{{.S3Config.Region}}" maxlength="255">
</div>
</div>
</div>
<div class="form-group row s3">
<div class="form-group row s3">
<label for="idS3AccessKey" class="col-sm-2 col-form-label">Access Key</label>
<div class="col-sm-3">
<input type="text" class="form-control" id="idS3AccessKey" name="s3_access_key" placeholder=""
@ -37,14 +39,13 @@
<div class="col-sm-2"></div>
<label for="idS3AccessSecret" class="col-sm-2 col-form-label">Access Secret</label>
<div class="col-sm-3">
<input type="password" class="form-control" id="idS3AccessSecret" name="s3_access_secret"
placeholder=""
<input type="password" class="form-control" id="idS3AccessSecret" name="s3_access_secret" placeholder=""
value="{{if .S3Config.AccessSecret.IsEncrypted}}{{.RedactedSecret}}{{else}}{{.S3Config.AccessSecret.GetPayload}}{{end}}"
maxlength="1000">
</div>
</div>
</div>
<div class="form-group row s3">
<div class="form-group row s3">
<label for="idS3StorageClass" class="col-sm-2 col-form-label">Storage Class</label>
<div class="col-sm-3">
<input type="text" class="form-control" id="idS3StorageClass" name="s3_storage_class" placeholder=""
@ -56,14 +57,13 @@
<input type="text" class="form-control" id="idS3Endpoint" name="s3_endpoint" placeholder=""
value="{{.S3Config.Endpoint}}" maxlength="255">
</div>
</div>
</div>
<div class="form-group row s3">
<div class="form-group row s3">
<label for="idS3PartSize" class="col-sm-2 col-form-label">UL Part Size (MB)</label>
<div class="col-sm-3">
<input type="number" class="form-control" id="idS3PartSize" name="s3_upload_part_size"
placeholder="" value="{{.S3Config.UploadPartSize}}"
aria-describedby="S3PartSizeHelpBlock">
<input type="number" class="form-control" id="idS3PartSize" name="s3_upload_part_size" placeholder=""
value="{{.S3Config.UploadPartSize}}" aria-describedby="S3PartSizeHelpBlock">
<small id="S3PartSizeHelpBlock" class="form-text text-muted">
The buffer size for multipart uploads. Zero means the default (5 MB). Minimum is 5
</small>
@ -78,29 +78,28 @@
How many parts are uploaded in parallel. Zero means the default (2)
</small>
</div>
</div>
</div>
<div class="form-group row s3">
<div class="form-group row s3">
<label for="idS3KeyPrefix" class="col-sm-2 col-form-label">Key Prefix</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idS3KeyPrefix" name="s3_key_prefix" placeholder=""
value="{{.S3Config.KeyPrefix}}" maxlength="255"
aria-describedby="S3KeyPrefixHelpBlock">
value="{{.S3Config.KeyPrefix}}" maxlength="255" aria-describedby="S3KeyPrefixHelpBlock">
<small id="S3KeyPrefixHelpBlock" class="form-text text-muted">
Similar to a chroot for local filesystem. Cannot start with "/". Example: "somedir/subdir/".
</small>
</div>
</div>
</div>
<div class="form-group row gcs">
<div class="form-group row gcs">
<label for="idGCSBucket" class="col-sm-2 col-form-label">Bucket</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idGCSBucket" name="gcs_bucket" placeholder=""
value="{{.GCSConfig.Bucket}}" maxlength="255">
</div>
</div>
</div>
<div class="form-group row gcs">
<div class="form-group row gcs">
<label for="idGCSCredentialFile" class="col-sm-2 col-form-label">Credentials file</label>
<div class="col-sm-4">
<input type="file" class="form-control-file" id="idGCSCredentialFile" name="gcs_credential_file"
@ -112,32 +111,31 @@
<div class="col-sm-1"></div>
<label for="idGCSStorageClass" class="col-sm-2 col-form-label">Storage Class</label>
<div class="col-sm-3">
<input type="text" class="form-control" id="idGCSStorageClass" name="gcs_storage_class"
placeholder="" value="{{.GCSConfig.StorageClass}}" maxlength="255">
<input type="text" class="form-control" id="idGCSStorageClass" name="gcs_storage_class" placeholder=""
value="{{.GCSConfig.StorageClass}}" maxlength="255">
</div>
</div>
</div>
<div class="form-group gcs">
<div class="form-group gcs">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="idGCSAutoCredentials"
name="gcs_auto_credentials" {{if gt .GCSConfig.AutomaticCredentials 0}}checked{{end}}>
<input type="checkbox" class="form-check-input" id="idGCSAutoCredentials" name="gcs_auto_credentials"
{{if gt .GCSConfig.AutomaticCredentials 0}}checked{{end}}>
<label for="idGCSAutoCredentials" class="form-check-label">Automatic credentials</label>
</div>
</div>
</div>
<div class="form-group row gcs">
<div class="form-group row gcs">
<label for="idGCSKeyPrefix" class="col-sm-2 col-form-label">Key Prefix</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idGCSKeyPrefix" name="gcs_key_prefix" placeholder=""
value="{{.GCSConfig.KeyPrefix}}" maxlength="255"
aria-describedby="GCSKeyPrefixHelpBlock">
value="{{.GCSConfig.KeyPrefix}}" maxlength="255" aria-describedby="GCSKeyPrefixHelpBlock">
<small id="GCSKeyPrefixHelpBlock" class="form-text text-muted">
Similar to a chroot for local filesystem. Cannot start with "/". Example: "somedir/subdir/".
</small>
</div>
</div>
</div>
<div class="form-group row azblob">
<div class="form-group row azblob">
<label for="idAzContainer" class="col-sm-2 col-form-label">Container</label>
<div class="col-sm-3">
<input type="text" class="form-control" id="idAzContainer" name="az_container" placeholder=""
@ -149,50 +147,50 @@
<input type="text" class="form-control" id="idAzAccountName" name="az_account_name" placeholder=""
value="{{.AzBlobConfig.AccountName}}" maxlength="255">
</div>
</div>
</div>
<div class="form-group row azblob">
<div class="form-group row azblob">
<label for="idAzAccountKey" class="col-sm-2 col-form-label">Account Key</label>
<div class="col-sm-10">
<input type="password" class="form-control" id="idAzAccountKey" name="az_account_key" placeholder=""
value="{{if .AzBlobConfig.AccountKey.IsEncrypted}}{{.RedactedSecret}}{{else}}{{.AzBlobConfig.AccountKey.GetPayload}}{{end}}"
maxlength="1000">
</div>
</div>
</div>
<div class="form-group row azblob">
<div class="form-group row azblob">
<label for="idAzSASURL" class="col-sm-2 col-form-label">SAS URL</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idAzSASURL" name="az_sas_url" placeholder=""
value="{{.AzBlobConfig.SASURL}}" maxlength="255">
</div>
</div>
<div class="form-group row azblob">
</div>
<div class="form-group row azblob">
<label for="idAzEndpoint" class="col-sm-2 col-form-label">Endpoint</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idAzEndpoint" name="az_endpoint" placeholder=""
value="{{.AzBlobConfig.Endpoint}}" maxlength="255">
</div>
</div>
</div>
<div class="form-group row azblob">
<div class="form-group row azblob">
<label for="idAzAccessTier" class="col-sm-2 col-form-label">Access Tier</label>
<div class="col-sm-10">
<select class="form-control" id="idAzAccessTier" name="az_access_tier">
<option value="" {{if eq .AzBlobConfig.AccessTier "" }}selected{{end}}>Default</option>
<option value="Hot" {{if eq .AzBlobConfig.AccessTier "Hot" }}selected{{end}}>Hot</option>
<option value="Cool" {{if eq .AzBlobConfig.AccessTier "Cool" }}selected{{end}}>Cool</option>
<option value="Archive" {{if eq .AzBlobConfig.AccessTier "Archive"}}selected{{end}}>Archive</option>
<option value="Archive" {{if eq .AzBlobConfig.AccessTier "Archive" }}selected{{end}}>Archive
</option>
</select>
</div>
</div>
</div>
<div class="form-group row azblob">
<div class="form-group row azblob">
<label for="idAzPartSize" class="col-sm-2 col-form-label">UL Part Size (MB)</label>
<div class="col-sm-3">
<input type="number" class="form-control" id="idAzPartSize" name="az_upload_part_size"
placeholder="" value="{{.AzBlobConfig.UploadPartSize}}"
aria-describedby="AzPartSizeHelpBlock">
<input type="number" class="form-control" id="idAzPartSize" name="az_upload_part_size" placeholder=""
value="{{.AzBlobConfig.UploadPartSize}}" aria-describedby="AzPartSizeHelpBlock">
<small id="AzPartSizeHelpBlock" class="form-text text-muted">
The buffer size for multipart uploads. Zero means the default (4 MB)
</small>
@ -207,28 +205,28 @@
How many parts are uploaded in parallel. Zero means the default (2)
</small>
</div>
</div>
</div>
<div class="form-group row azblob">
<div class="form-group row azblob">
<label for="idAzKeyPrefix" class="col-sm-2 col-form-label">Key Prefix</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idAzKeyPrefix" name="az_key_prefix" placeholder=""
value="{{.AzBlobConfig.KeyPrefix}}" maxlength="255"
aria-describedby="AzKeyPrefixHelpBlock">
value="{{.AzBlobConfig.KeyPrefix}}" maxlength="255" aria-describedby="AzKeyPrefixHelpBlock">
<small id="AzKeyPrefixHelpBlock" class="form-text text-muted">
Similar to a chroot for local filesystem. Cannot start with "/". Example: "somedir/subdir/".
</small>
</div>
</div>
</div>
<div class="form-group azblob">
<div class="form-group azblob">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="idUseEmulator" name="az_use_emulator" {{if .AzBlobConfig.UseEmulator}}checked{{end}}>
<input type="checkbox" class="form-check-input" id="idUseEmulator" name="az_use_emulator" {{if
.AzBlobConfig.UseEmulator}}checked{{end}}>
<label for="idUseEmulator" class="form-check-label">Use Azure Blob emulator</label>
</div>
</div>
</div>
<div class="form-group row crypt">
<div class="form-group row crypt">
<label for="idCryptPassphrase" class="col-sm-2 col-form-label">Passphrase</label>
<div class="col-sm-10">
<input type="password" class="form-control" id="idCryptPassphrase" name="crypt_passphrase"
@ -236,9 +234,9 @@
value="{{if .CryptConfig.Passphrase.IsEncrypted}}{{.RedactedSecret}}{{else}}{{.CryptConfig.Passphrase.GetPayload}}{{end}}"
maxlength="1000">
</div>
</div>
</div>
<div class="form-group row sftp">
<div class="form-group row sftp">
<label for="idSFTPEndpoint" class="col-sm-2 col-form-label">Endpoint</label>
<div class="col-sm-3">
<input type="text" class="form-control" id="idSFTPEndpoint" name="sftp_endpoint" placeholder=""
@ -256,9 +254,9 @@
A buffer size > 0 enables concurrent transfers
</small>
</div>
</div>
</div>
<div class="form-group row sftp">
<div class="form-group row sftp">
<label for="idSFTPUsername" class="col-sm-2 col-form-label">Username</label>
<div class="col-sm-3">
<input type="text" class="form-control" id="idSFTPUsername" name="sftp_username" placeholder=""
@ -271,17 +269,17 @@
value="{{if .SFTPConfig.Password.IsEncrypted}}{{.RedactedSecret}}{{else}}{{.SFTPConfig.Password.GetPayload}}{{end}}"
maxlength="1000">
</div>
</div>
</div>
<div class="form-group row sftp">
<div class="form-group row sftp">
<label for="idSFTPPrivateKey" class="col-sm-2 col-form-label">Private key</label>
<div class="col-sm-10">
<textarea type="password" class="form-control" id="idSFTPPrivateKey" name="sftp_private_key"
rows="3">{{if .SFTPConfig.PrivateKey.IsEncrypted}}{{.RedactedSecret}}{{else}}{{.SFTPConfig.PrivateKey.GetPayload}}{{end}}</textarea>
</div>
</div>
</div>
<div class="form-group row sftp">
<div class="form-group row sftp">
<label for="idSFTPFingerprints" class="col-sm-2 col-form-label">Fingerprints</label>
<div class="col-sm-10">
<textarea class="form-control" id="idSFTPFingerprints" name="sftp_fingerprints" rows="3"
@ -291,25 +289,27 @@
empty any host key will be accepted: this is a security risk!
</small>
</div>
</div>
</div>
<div class="form-group row sftp">
<div class="form-group row sftp">
<label for="idSFTPPrefix" class="col-sm-2 col-form-label">Prefix</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idSFTPPrefix" name="sftp_prefix" placeholder=""
value="{{.SFTPConfig.Prefix}}" maxlength="255"
aria-describedby="SFTPPrefixHelpBlock">
value="{{.SFTPConfig.Prefix}}" maxlength="255" aria-describedby="SFTPPrefixHelpBlock">
<small id="SFTPPrefixHelpBlock" class="form-text text-muted">
Similar to a chroot for local filesystem. Example: "/somedir/subdir".
</small>
</div>
</div>
</div>
<div class="form-group sftp">
<div class="form-group sftp">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="idDisableConcurrentReads" name="sftp_disable_concurrent_reads" {{if .SFTPConfig.DisableCouncurrentReads}}checked{{end}}>
<input type="checkbox" class="form-check-input" id="idDisableConcurrentReads"
name="sftp_disable_concurrent_reads" {{if .SFTPConfig.DisableCouncurrentReads}}checked{{end}}>
<label for="idDisableConcurrentReads" class="form-check-label">Disable concurrent reads</label>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -40,14 +40,39 @@
{{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="card bg-light mb-3">
<div class="card-header">
Users
</div>
<div class="card-body">
<h6 class="card-title mb-4">For each user set the username and at least one of the password and public key</h6>
<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 class="col-md-12 form_field_tpl_users_outer">
<div class="row form_field_tpl_user_outer_row">
<div class="form-group col-md-3">
<input type="text" class="form-control" id="idTplUsername0" name="tpl_username" placeholder="Username" maxlength="255">
</div>
<div class="form-group col-md-3">
<input type="password" class="form-control" id="idTplPassword0" name="tpl_password" placeholder="Password" maxlength="255">
</div>
<div class="form-group col-md-5">
<textarea class="form-control" id="idTplPublicKey0" name="tpl_public_keys" rows="5"
placeholder="Paste your public key here"></textarea>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_tpl_user_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
<div class="row mx-1">
<button type="button" class="btn btn-secondary add_new_tpl_user_field_btn">
<i class="fas fa-plus"></i> Add new user
</button>
</div>
</div>
</div>
<input type="hidden" name="username" id="idUsername" value="{{.User.Username}}">
@ -100,14 +125,46 @@
</div>
</div>
<div class="card bg-light mb-3">
<div class="card-header">
Public keys
</div>
<div class="card-body">
<div class="form-group row">
<label for="idPublicKeys" class="col-sm-2 col-form-label">Public keys</label>
<div class="col-sm-10">
<textarea class="form-control" id="idPublicKeys" name="public_keys" rows="3"
aria-describedby="pkHelpBlock">{{range .User.PublicKeys}}{{.}}&#10;{{end}}</textarea>
<small id="pkHelpBlock" class="form-text text-muted">
One public key or SSH user certificate per line
</small>
<div class="col-md-12 form_field_pk_outer">
{{range $idx, $val := .User.PublicKeys}}
<div class="row form_field_pk_outer_row">
<div class="form-group col-md-11">
<textarea class="form-control" id="idPublicKey{{$idx}}" name="public_keys" rows="3"
placeholder="Paste your public key here">{{$val}}</textarea>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_pk_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{else}}
<div class="row form_field_pk_outer_row">
<div class="form-group col-md-11">
<textarea class="form-control" id="idPublicKey0" name="public_keys" rows="3"
placeholder="Paste your public key here"></textarea>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_pk_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{end}}
</div>
</div>
<div class="row mx-1">
<button type="button" class="btn btn-secondary add_new_pk_field_btn">
<i class="fas fa-plus"></i> Add new public key
</button>
</div>
</div>
</div>
{{end}}
@ -125,69 +182,6 @@
</div>
</div>
<div class="form-group row">
<label for="idProtocols" class="col-sm-2 col-form-label">Denied protocols</label>
<div class="col-sm-10">
<select class="form-control" id="idProtocols" name="denied_protocols" multiple>
{{range $protocol := .ValidProtocols}}
<option value="{{$protocol}}" {{range $p :=$.User.Filters.DeniedProtocols }}{{if eq $p $protocol}}selected{{end}}{{end}}>{{$protocol}}
</option>
{{end}}
</select>
</div>
</div>
<div class="form-group row">
<label for="idLoginMethods" class="col-sm-2 col-form-label">Denied login methods</label>
<div class="col-sm-10">
<select class="form-control" id="idLoginMethods" name="ssh_login_methods" multiple>
{{range $method := .ValidLoginMethods}}
<option value="{{$method}}" {{range $m :=$.User.Filters.DeniedLoginMethods }}{{if eq $m $method}}selected{{end}}{{end}}>{{$method}}
</option>
{{end}}
</select>
</div>
</div>
<div class="form-group row">
<label for="idPermissions" class="col-sm-2 col-form-label">Permissions</label>
<div class="col-sm-10">
<select class="form-control" id="idPermissions" name="permissions" required multiple>
{{range $validPerm := .ValidPerms}}
<option value="{{$validPerm}}" {{range $perm :=$.RootDirPerms }}{{if eq $perm $validPerm}}selected{{end}}{{end}}>{{$validPerm}}
</option>
{{end}}
</select>
</div>
</div>
<div class="form-group row">
<label for="idSubDirsPermissions" class="col-sm-2 col-form-label">Sub dirs permissions</label>
<div class="col-sm-10">
<textarea class="form-control" id="idSubDirsPermissions" name="sub_dirs_permissions" rows="3"
aria-describedby="subDirsHelpBlock">{{range $dir, $perms := .User.Permissions -}}
{{if ne $dir "/" -}}
{{$dir}}::{{range $index, $p := $perms}}{{if $index}},{{end}}{{$p}}{{end}}&#10;
{{- end}}
{{- end}}</textarea>
<small id="subDirsHelpBlock" class="form-text text-muted">
One exposed virtual directory path per line as /dir::perms, for example /somedir::list,download
</small>
</div>
</div>
<div class="form-group row">
<label for="idWebClient" class="col-sm-2 col-form-label">Web client</label>
<div class="col-sm-10">
<select class="form-control" id="idWebClient" name="web_client_options" multiple>
{{range $option := .WebClientOptions}}
<option value="{{$option}}" {{range $p :=$.User.Filters.WebClient }}{{if eq $p $option}}selected{{end}}{{end}}>{{$option}}
</option>
{{end}}
</select>
</div>
</div>
<div class="form-group row">
<label for="idHomeDir" class="col-sm-2 col-form-label">Home Dir</label>
<div class="col-sm-10">
@ -197,29 +191,172 @@
</div>
<div class="form-group row">
<label for="idVirtualFolders" class="col-sm-2 col-form-label">Virtual folders</label>
<label for="idPermissions" class="col-sm-2 col-form-label">Permissions</label>
<div class="col-sm-10">
<textarea class="form-control" id="idVirtualFolders" name="virtual_folders" rows="3"
aria-describedby="vfHelpBlock">{{range $index, $mapping := .User.VirtualFolders -}}
{{$mapping.VirtualPath}}::{{$mapping.Name}}::{{$mapping.QuotaFiles}}::{{$mapping.QuotaSize}}&#10;
{{- end}}</textarea>
<small id="vfHelpBlock" class="form-text text-muted">
One mapping per line as vpath::folder-name::[quota_files]::[quota_size(bytes)], for example
/vdir::afolder or /vdir::afolder::10::104857600. Quota -1 means included inside user quota.
<select class="form-control" id="idPermissions" name="permissions" required multiple>
{{range $validPerm := .ValidPerms}}
<option value="{{$validPerm}}" {{range $perm :=$.RootDirPerms }}{{if eq $perm $validPerm}}selected{{end}}{{end}}>{{$validPerm}}</option>
{{end}}
</select>
</div>
</div>
{{template "fshtml" .User.FsConfig}}
{{if .VirtualFolders}}
<div class="card bg-light mb-3">
<div class="card-header">
Virtual folders
</div>
<div class="card-body">
<h6 class="card-title mb-4">Quota -1 means included within user quota, 0 unlimited. Don't set -1 for shared folders</h6>
<div class="form-group row">
<div class="col-md-12 form_field_vfolders_outer">
{{range $idx, $val := .User.VirtualFolders}}
<div class="row form_field_vfolder_outer_row">
<div class="form-group col-md-3">
<input type="text" class="form-control" id="idVolderPath{{$idx}}" name="vfolder_path" placeholder="mount path, i.e. /vfolder" value="{{$val.VirtualPath}}" maxlength="255">
</div>
<div class="form-group col-md-3">
<select class="form-control" id="idVfolderName{{$idx}}" name="vfolder_name">
<option value=""></option>
{{range $.VirtualFolders}}
<option value="{{.Name}}" {{if eq $val.Name .Name}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
<div class="form-group col-md-3">
<input type="number" class="form-control" id="idVfolderQuotaSize{{$idx}}" name="vfolder_quota_size"
value="{{$val.QuotaSize}}" min="-1" aria-describedby="vqsHelpBlock{{$idx}}">
<small id="vqsHelpBlock{{$idx}}" class="form-text text-muted">
Quota size (bytes)
</small>
</div>
<div class="form-group col-md-2">
<input type="number" class="form-control" id="idVfolderQuotaFiles{{$idx}}" name="vfolder_quota_files"
value="{{$val.QuotaFiles}}" min="-1" aria-describedby="vqfHelpBlock{{$idx}}">
<small id="vqfHelpBlock{{$idx}}" class="form-text text-muted">
Quota files
</small>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_vfolder_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{else}}
<div class="row form_field_vfolder_outer_row">
<div class="form-group col-md-3">
<input type="text" class="form-control" id="idVolderPath0" name="vfolder_path" placeholder="mount path, i.e. /vfolder" value="" maxlength="255">
</div>
<div class="form-group col-md-3">
<select class="form-control" id="idVfolderName0" name="vfolder_name">
<option value=""></option>
{{range .VirtualFolders}}
<option value="{{.Name}}">{{.Name}}</option>
{{end}}
</select>
</div>
<div class="form-group col-md-3">
<input type="number" class="form-control" id="idVfolderQuotaSize0" name="vfolder_quota_size"
value="" min="-1" aria-describedby="vqsHelpBlock0">
<small id="vqsHelpBlock0" class="form-text text-muted">
Quota size (bytes)
</small>
</div>
<div class="form-group col-md-2">
<input type="number" class="form-control" id="idVfolderQuotaFiles0" name="vfolder_quota_files"
value="" min="-1" aria-describedby="vqfHelpBlock0">
<small id="vqfHelpBlock0" class="form-text text-muted">
Quota files
</small>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_vfolder_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{end}}
</div>
</div>
<div class="row mx-1">
<button type="button" class="btn btn-secondary add_new_vfolder_field_btn">
<i class="fas fa-plus"></i> Add new virtual folder
</button>
</div>
</div>
</div>
{{end}}
<div class="card bg-light mb-3">
<div class="card-header">
Per-directory permissions
</div>
<div class="card-body">
<div class="form-group row">
<div class="col-md-12 form_field_dirperms_outer">
{{range $idx, $dirPerms := .User.GetSubDirPermissions -}}
<div class="row form_field_dirperms_outer_row">
<div class="form-group col-md-8">
<input type="text" class="form-control" id="idSubDirPermsPath{{$idx}}" name="sub_perm_path{{$idx}}" placeholder="directory path, i.e. /dir" value="{{$dirPerms.Path}}" maxlength="255">
</div>
<div class="form-group col-md-3">
<select class="form-control" id="idSubDirPermissions{{$idx}}" name="sub_perm_permissions{{$idx}}" multiple>
{{range $validPerm := $.ValidPerms}}
<option value="{{$validPerm}}" {{range $perm := $dirPerms.Permissions }}{{if eq $perm $validPerm}}selected{{end}}{{end}}>{{$validPerm}}</option>
{{end}}
</select>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_dirperms_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{else}}
<div class="row form_field_dirperms_outer_row">
<div class="form-group col-md-8">
<input type="text" class="form-control" id="idSubDirPermsPath0" name="sub_perm_path0" placeholder="directory path, i.e. /dir" value="" maxlength="255">
</div>
<div class="form-group col-md-3">
<select class="form-control" id="idSubDirPermissions0" name="sub_perm_permissions0" multiple>
{{range $validPerm := .ValidPerms}}
<option value="{{$validPerm}}">{{$validPerm}}</option>
{{end}}
</select>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_dirperms_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{end}}
</div>
</div>
<div class="row mx-1">
<button type="button" class="btn btn-secondary add_new_dirperms_field_btn">
<i class="fas fa-plus"></i> Add new directory permissions
</button>
</div>
</div>
</div>
<div class="form-group">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="idDisableFsChecks" name="disable_fs_checks"
{{if .User.Filters.DisableFsChecks}}checked{{end}} aria-describedby="disableFsChecksHelpBlock">
<label for="idDisableFsChecks" class="form-check-label">Disable filesystem checks</label>
<small id="disableFsChecksHelpBlock" class="form-text text-muted">
Disable checks for existence and automatic creation of home directory and virtual folders
</small>
</div>
</div>
<div class="form-group row">
<label for="idQuotaFiles" class="col-sm-2 col-form-label">Quota files</label>
<div class="col-sm-3">
<input type="number" class="form-control" id="idQuotaFiles" name="quota_files" placeholder=""
value="{{.User.QuotaFiles}}" min="0" aria-describedby="qfHelpBlock">
<small id="qfHelpBlock" class="form-text text-muted">
0 means no limit
</small>
</div>
<div class="col-sm-2"></div>
<label for="idQuotaSize" class="col-sm-2 col-form-label">Quota size (bytes)</label>
<div class="col-sm-3">
<input type="number" class="form-control" id="idQuotaSize" name="quota_size" placeholder=""
@ -228,6 +365,15 @@
0 means no limit
</small>
</div>
<div class="col-sm-2"></div>
<label for="idQuotaFiles" class="col-sm-2 col-form-label">Quota files</label>
<div class="col-sm-3">
<input type="number" class="form-control" id="idQuotaFiles" name="quota_files" placeholder=""
value="{{.User.QuotaFiles}}" min="0" aria-describedby="qfHelpBlock">
<small id="qfHelpBlock" class="form-text text-muted">
0 means no limit
</small>
</div>
</div>
<div class="form-group row">
@ -285,6 +431,30 @@
</div>
</div>
<div class="form-group row">
<label for="idProtocols" class="col-sm-2 col-form-label">Denied protocols</label>
<div class="col-sm-10">
<select class="form-control" id="idProtocols" name="denied_protocols" multiple>
{{range $protocol := .ValidProtocols}}
<option value="{{$protocol}}" {{range $p :=$.User.Filters.DeniedProtocols }}{{if eq $p $protocol}}selected{{end}}{{end}}>{{$protocol}}
</option>
{{end}}
</select>
</div>
</div>
<div class="form-group row">
<label for="idLoginMethods" class="col-sm-2 col-form-label">Denied login methods</label>
<div class="col-sm-10">
<select class="form-control" id="idLoginMethods" name="ssh_login_methods" multiple>
{{range $method := .ValidLoginMethods}}
<option value="{{$method}}" {{range $m :=$.User.Filters.DeniedLoginMethods }}{{if eq $m $method}}selected{{end}}{{end}}>{{$method}}
</option>
{{end}}
</select>
</div>
</div>
<div class="form-group row">
<label for="idDeniedIP" class="col-sm-2 col-form-label">Denied IP/Mask</label>
<div class="col-sm-10">
@ -307,35 +477,75 @@
</div>
</div>
<div class="card bg-light mb-3">
<div class="card-header">
Per-directory file patterns
</div>
<div class="card-body">
<h6 class="card-title mb-4">Comma separated denied or allowed files, based on shell patterns</h6>
<div class="form-group row">
<label for="idFilePatternsDenied" class="col-sm-2 col-form-label">Denied file patterns</label>
<div class="col-sm-10">
<textarea class="form-control" id="idFilePatternsDenied" name="denied_patterns" rows="3"
aria-describedby="deniedPatternsHelpBlock">{{range $index, $filter := .User.Filters.FilePatterns -}}
{{if $filter.DeniedPatterns -}}
{{$filter.Path}}::{{range $idx, $p := $filter.DeniedPatterns}}{{if $idx}},{{end}}{{$p}}{{end}}&#10;
{{- end}}
{{- end}}</textarea>
<small id="deniedPatternsHelpBlock" class="form-text text-muted">
One exposed virtual directory per line as /dir::pattern1,pattern2, for example
/subdir::*.zip,*.rar
</small>
<div class="col-md-12 form_field_patterns_outer">
{{range $idx, $pattern := .User.GetFlatFilePatterns -}}
<div class="row form_field_patterns_outer_row">
<div class="form-group col-md-4">
<input type="text" class="form-control" id="idPatternPath{{$idx}}" name="pattern_path{{$idx}}" placeholder="directory path, i.e. /dir" value="{{$pattern.Path}}" maxlength="255">
</div>
<div class="form-group col-md-5">
<input type="text" class="form-control" id="idPatterns{{$idx}}" name="patterns{{$idx}}" placeholder="*.zip,?.txt" value="{{$pattern.GetCommaSeparatedPatterns}}" maxlength="255">
</div>
<div class="form-group col-md-2">
<select class="form-control" id="idPatternType{{$idx}}" name="pattern_type{{$idx}}">
<option value="denied" {{if $pattern.IsDenied}}selected{{end}}>Denied</option>
<option value="allowed" {{if $pattern.IsAllowed}}selected{{end}}>Allowed</option>
</select>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_pattern_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{else}}
<div class="row form_field_patterns_outer_row">
<div class="form-group col-md-4">
<input type="text" class="form-control" id="idPatternPath0" name="pattern_path0" placeholder="directory path, i.e. /dir" value="" maxlength="255">
</div>
<div class="form-group col-md-5">
<input type="text" class="form-control" id="idPatterns0" name="patterns0" placeholder="*.zip,?.txt" value="" maxlength="255">
</div>
<div class="form-group col-md-2">
<select class="form-control" id="idPatternType0" name="pattern_type0">
<option value="denied">Denied</option>
<option value="allowed">Allowed</option>
</select>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_pattern_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{end}}
</div>
</div>
<div class="row mx-1">
<button type="button" class="btn btn-secondary add_new_pattern_field_btn">
<i class="fas fa-plus"></i> Add new file pattern
</button>
</div>
</div>
</div>
<div class="form-group row">
<label for="idFilePatternsAllowed" class="col-sm-2 col-form-label">Allowed file patterns</label>
<label for="idWebClient" class="col-sm-2 col-form-label">Web client</label>
<div class="col-sm-10">
<textarea class="form-control" id="idFilePatternsAllowed" name="allowed_patterns" rows="3"
aria-describedby="allowedPatternsHelpBlock">{{range $index, $filter := .User.Filters.FilePatterns -}}
{{if $filter.AllowedPatterns -}}
{{$filter.Path}}::{{range $idx, $p := $filter.AllowedPatterns}}{{if $idx}},{{end}}{{$p}}{{end}}&#10;
{{- end}}
{{- end}}</textarea>
<small id="allowedPatternsHelpBlock" class="form-text text-muted">
One exposed virtual directory per line as /dir::pattern1,pattern2, for example
/somedir::*.jpg,*.png
</small>
<select class="form-control" id="idWebClient" name="web_client_options" multiple>
{{range $option := .WebClientOptions}}
<option value="{{$option}}" {{range $p :=$.User.Filters.WebClient }}{{if eq $p $option}}selected{{end}}{{end}}>{{$option}}
</option>
{{end}}
</select>
</div>
</div>
@ -356,19 +566,6 @@
</div>
</div>
<div class="form-group">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="idDisableFsChecks" name="disable_fs_checks"
{{if .User.Filters.DisableFsChecks}}checked{{end}} aria-describedby="disableFsChecksHelpBlock">
<label for="idDisableFsChecks" class="form-check-label">Disable filesystem checks</label>
<small id="disableFsChecksHelpBlock" class="form-text text-muted">
Disable checks for existence and automatic creation of home directory and virtual folders
</small>
</div>
</div>
{{template "fshtml" .User.FsConfig}}
<div class="form-group row">
<label for="idAdditionalInfo" class="col-sm-2 col-form-label">Additional info</label>
<div class="col-sm-10">
@ -440,6 +637,169 @@
onFilesystemChanged('{{.User.FsConfig.Provider}}');
$("body").on("click", ".add_new_pk_field_btn", function () {
var index = $(".form_field_pk_outer").find(".form_field_pk_outer_row").length;
while (document.getElementById("idPublicKey"+index) != null){
index++;
}
$(".form_field_pk_outer").append(`
<div class="row form_field_pk_outer_row">
<div class="form-group col-md-11">
<textarea class="form-control" id="idPublicKey${index}" name="public_keys" rows="3"
placeholder="Paste your public key here"></textarea>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_pk_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
});
$("body").on("click", ".remove_pk_btn_frm_field", function () {
$(this).closest(".form_field_pk_outer_row").remove();
});
$("body").on("click", ".add_new_dirperms_field_btn", function () {
var index = $(".form_field_dirperms_outer").find(".form_field_dirperms_outer_row").length;
while (document.getElementById("idSubDirPermsPath"+index) != null){
index++;
}
$(".form_field_dirperms_outer").append(`
<div class="row form_field_dirperms_outer_row">
<div class="form-group col-md-8">
<input type="text" class="form-control" id="idSubDirPermsPath${index}" name="sub_perm_path${index}" placeholder="directory path, i.e. /dir" value="" maxlength="255">
</div>
<div class="form-group col-md-3">
<select class="form-control" id="idSubDirPermissions${index}" name="sub_perm_permissions${index}" multiple>
</select>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_dirperms_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
{{range .ValidPerms}}
$("#idSubDirPermissions"+index).append($('<option>').val('{{.}}').text('{{.}}'));
{{end}}
});
$("body").on("click", ".remove_dirperms_btn_frm_field", function () {
$(this).closest(".form_field_dirperms_outer_row").remove();
});
$("body").on("click", ".add_new_vfolder_field_btn", function () {
var index = $(".form_field_vfolders_outer").find(".form_field_vfolder_outer_row").length;
while (document.getElementById("idVolderPath"+index) != null){
index++;
}
$(".form_field_vfolders_outer").append(`
<div class="row form_field_vfolder_outer_row">
<div class="form-group col-md-3">
<input type="text" class="form-control" id="idVolderPath${index}" name="vfolder_path" placeholder="mount path, i.e. /vfolder" value="" maxlength="255">
</div>
<div class="form-group col-md-3">
<select class="form-control" id="idVfolderName${index}" name="vfolder_name">
<option value=""></option>
</select>
</div>
<div class="form-group col-md-3">
<input type="number" class="form-control" id="idVfolderQuotaSize${index}" name="vfolder_quota_size"
value="" min="-1" aria-describedby="vqsHelpBlock${index}">
<small id="vqsHelpBlock${index}" class="form-text text-muted">
Quota size (bytes)
</small>
</div>
<div class="form-group col-md-2">
<input type="number" class="form-control" id="idVfolderQuotaFiles${index}" name="vfolder_quota_files"
value="" min="-1" aria-describedby="vqfHelpBlock${index}">
<small id="vqfHelpBlock${index}" class="form-text text-muted">
Quota files
</small>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_vfolder_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
{{range .VirtualFolders}}
$("#idVfolderName"+index).append($('<option>').val('{{.Name}}').text('{{.Name}}'));
{{end}}
});
$("body").on("click", ".remove_vfolder_btn_frm_field", function () {
$(this).closest(".form_field_vfolder_outer_row").remove();
});
$("body").on("click", ".add_new_pattern_field_btn", function () {
var index = $(".form_field_patterns_outer").find(".form_field_patterns_outer_row").length;
while (document.getElementById("idPatternPath"+index) != null){
index++;
}
$(".form_field_patterns_outer").append(`
<div class="row form_field_patterns_outer_row">
<div class="form-group col-md-4">
<input type="text" class="form-control" id="idPatternPath${index}" name="pattern_path${index}" placeholder="directory path, i.e. /dir" value="" maxlength="255">
</div>
<div class="form-group col-md-5">
<input type="text" class="form-control" id="idPatterns${index}" name="patterns${index}" placeholder="*.zip,?.txt" value="" maxlength="255">
</div>
<div class="form-group col-md-2">
<select class="form-control" id="idPatternType${index}" name="pattern_type${index}">
<option value="denied">Denied</option>
<option value="allowed">Allowed</option>
</select>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_pattern_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
});
$("body").on("click", ".remove_pattern_btn_frm_field", function () {
$(this).closest(".form_field_patterns_outer_row").remove();
});
$("body").on("click", ".add_new_tpl_user_field_btn", function () {
var index = $(".form_field_tpl_users_outer").find(".form_field_tpl_user_outer_row").length;
while (document.getElementById("idTplUsername"+index) != null){
index++;
}
$(".form_field_tpl_users_outer").append(`
<div class="row form_field_tpl_user_outer_row">
<div class="form-group col-md-3">
<input type="text" class="form-control" id="idTplUsername${index}" name="tpl_username" placeholder="Username" maxlength="255">
</div>
<div class="form-group col-md-3">
<input type="password" class="form-control" id="idTplPassword${index}" name="tpl_password" placeholder="Password" maxlength="255">
</div>
<div class="form-group col-md-5">
<textarea class="form-control" id="idTplPublicKey${index}" name="tpl_public_keys" rows="5"
placeholder="Paste your public key here"></textarea>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_tpl_user_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
});
$("body").on("click", ".remove_tpl_user_btn_frm_field", function () {
$(this).closest(".form_field_tpl_user_outer_row").remove();
});
});
{{template "fsjs"}}

View file

@ -62,7 +62,7 @@
placeholder="Paste your public key here">{{$val}}</textarea>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_pk_btn_frm_field" {{if eq $idx 0}}disabled{{end}}>
<button class="btn btn-circle btn-danger remove_pk_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
@ -102,6 +102,9 @@
$(document).ready(function () {
$("body").on("click", ".add_new_pk_field_btn", function () {
var index = $(".form_field_pk_outer").find(".form_field_pk_outer_row").length;
while (document.getElementById("idPublicKey"+index) != null){
index++;
}
$(".form_field_pk_outer").append(`
<div class="row form_field_pk_outer_row">
<div class="form-group col-md-11">
@ -109,15 +112,12 @@
placeholder="Paste your public key here"></textarea>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_pk_btn_frm_field" disabled>
<button class="btn btn-circle btn-danger remove_pk_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
$(".form_field_pk_outer").find(".remove_pk_btn_frm_field:not(:first)").prop("disabled", false);
$(".form_field_pk_outer").find(".remove_pk_btn_frm_field").first().prop("disabled", true);
});
$("body").on("click", ".remove_pk_btn_frm_field", function () {