Bläddra i källkod

add groups support

Using groups simplifies the administration of multiple accounts by
letting you assign settings once to a group, instead of multiple
times to each individual user.

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 3 år sedan
förälder
incheckning
504cd3efda
53 ändrade filer med 6664 tillägg och 1026 borttagningar
  1. 1 0
      README.md
  2. 0 1
      cmd/revertprovider.go
  3. 5 0
      cmd/startsubsys.go
  4. 1 0
      dataprovider/actions.go
  5. 5 4
      dataprovider/admin.go
  6. 451 117
      dataprovider/bolt.go
  7. 13 3
      dataprovider/cacheduser.go
  8. 421 140
      dataprovider/dataprovider.go
  9. 228 0
      dataprovider/group.go
  10. 385 24
      dataprovider/memory.go
  11. 138 18
      dataprovider/mysql.go
  12. 141 18
      dataprovider/pgsql.go
  13. 746 52
      dataprovider/sqlcommon.go
  14. 148 20
      dataprovider/sqlite.go
  15. 179 8
      dataprovider/sqlqueries.go
  16. 273 76
      dataprovider/user.go
  17. 41 0
      docs/groups.md
  18. 38 1
      ftpd/ftpd_test.go
  19. 1 1
      go.mod
  20. 2 2
      go.sum
  21. 6 5
      httpd/api_folder.go
  22. 134 0
      httpd/api_group.go
  23. 9 9
      httpd/api_http_user.go
  24. 41 20
      httpd/api_maintenance.go
  25. 1 1
      httpd/api_metadata.go
  26. 3 3
      httpd/api_quota.go
  27. 1 1
      httpd/api_retention.go
  28. 3 3
      httpd/api_shares.go
  29. 26 26
      httpd/api_utils.go
  30. 7 0
      httpd/httpd.go
  31. 848 13
      httpd/httpd_test.go
  32. 25 0
      httpd/internal_test.go
  33. 1 1
      httpd/middleware.go
  34. 21 9
      httpd/server.go
  35. 401 70
      httpd/webadmin.go
  36. 11 11
      httpd/webclient.go
  37. 251 56
      httpdtest/httpdtest.go
  38. 288 9
      openapi/openapi.yaml
  39. 4 0
      service/service.go
  40. 1 1
      sftpd/server.go
  41. 43 0
      sftpd/sftpd_test.go
  42. 5 0
      static/vendor/bootstrap-select/css/bootstrap-select.min.css
  43. 7 0
      static/vendor/bootstrap-select/js/bootstrap-select.min.js
  44. 8 0
      templates/webadmin/base.html
  45. 7 2
      templates/webadmin/folder.html
  46. 6 4
      templates/webadmin/fsconfig.html
  47. 754 0
      templates/webadmin/group.html
  48. 193 0
      templates/webadmin/groups.html
  49. 217 0
      templates/webadmin/sharedcomponents.html
  50. 112 290
      templates/webadmin/user.html
  51. 6 5
      util/util.go
  52. 5 0
      vfs/folder.go
  53. 2 2
      webdavd/internal_test.go

+ 1 - 0
README.md

@@ -27,6 +27,7 @@ Several storage backends are supported: local filesystem, encrypted local filesy
 - Partial authentication. You can configure multi-step authentication requiring, for example, the user password after successful public key authentication.
 - Partial authentication. You can configure multi-step authentication requiring, for example, the user password after successful public key authentication.
 - Per-user authentication methods.
 - Per-user authentication methods.
 - [Two-factor authentication](./docs/howto/two-factor-authentication.md) based on time-based one time passwords (RFC 6238) which works with Authy, Google Authenticator and other compatible apps.
 - [Two-factor authentication](./docs/howto/two-factor-authentication.md) based on time-based one time passwords (RFC 6238) which works with Authy, Google Authenticator and other compatible apps.
+- Simplified user administrations using [groups](./docs/groups.md).
 - Custom authentication via external programs/HTTP API.
 - Custom authentication via external programs/HTTP API.
 - Web Client and Web Admin user interfaces support [OpenID Connect](https://openid.net/connect/) authentication and so they can be integrated with identity providers such as [Keycloak](https://www.keycloak.org/). You can find more details [here](./docs/oidc.md).
 - Web Client and Web Admin user interfaces support [OpenID Connect](https://openid.net/connect/) authentication and so they can be integrated with identity providers such as [Keycloak](https://www.keycloak.org/). You can find more details [here](./docs/oidc.md).
 - [Data At Rest Encryption](./docs/dare.md).
 - [Data At Rest Encryption](./docs/dare.md).

+ 0 - 1
cmd/revertprovider.go

@@ -58,7 +58,6 @@ Please take a look at the usage below to customize the options.`,
 func init() {
 func init() {
 	addConfigFlags(revertProviderCmd)
 	addConfigFlags(revertProviderCmd)
 	revertProviderCmd.Flags().IntVar(&revertProviderTargetVersion, "to-version", 15, `15 means the version supported in v2.2.x`)
 	revertProviderCmd.Flags().IntVar(&revertProviderTargetVersion, "to-version", 15, `15 means the version supported in v2.2.x`)
-	revertProviderCmd.MarkFlagRequired("to-version") //nolint:errcheck
 
 
 	rootCmd.AddCommand(revertProviderCmd)
 	rootCmd.AddCommand(revertProviderCmd)
 }
 }

+ 5 - 0
cmd/startsubsys.go

@@ -140,6 +140,11 @@ Command-line flags should be specified in the Subsystem declaration.
 					os.Exit(1)
 					os.Exit(1)
 				}
 				}
 			}
 			}
+			err = user.LoadAndApplyGroupSettings()
+			if err != nil {
+				logger.Error(logSender, connectionID, "unable to apply group settings for user %#v: %v", username, err)
+				os.Exit(1)
+			}
 			err = sftpd.ServeSubSystemConnection(&user, connectionID, os.Stdin, os.Stdout)
 			err = sftpd.ServeSubSystemConnection(&user, connectionID, os.Stdin, os.Stdout)
 			if err != nil && err != io.EOF {
 			if err != nil && err != io.EOF {
 				logger.Warn(logSender, connectionID, "serving subsystem finished with error: %v", err)
 				logger.Warn(logSender, connectionID, "serving subsystem finished with error: %v", err)

+ 1 - 0
dataprovider/actions.go

@@ -29,6 +29,7 @@ const (
 
 
 const (
 const (
 	actionObjectUser   = "user"
 	actionObjectUser   = "user"
+	actionObjectGroup  = "group"
 	actionObjectAdmin  = "admin"
 	actionObjectAdmin  = "admin"
 	actionObjectAPIKey = "api_key"
 	actionObjectAPIKey = "api_key"
 	actionObjectShare  = "share"
 	actionObjectShare  = "share"

+ 5 - 4
dataprovider/admin.go

@@ -32,6 +32,7 @@ const (
 	PermAdminCloseConnections = "close_conns"
 	PermAdminCloseConnections = "close_conns"
 	PermAdminViewServerStatus = "view_status"
 	PermAdminViewServerStatus = "view_status"
 	PermAdminManageAdmins     = "manage_admins"
 	PermAdminManageAdmins     = "manage_admins"
+	PermAdminManageGroups     = "manage_groups"
 	PermAdminManageAPIKeys    = "manage_apikeys"
 	PermAdminManageAPIKeys    = "manage_apikeys"
 	PermAdminQuotaScans       = "quota_scans"
 	PermAdminQuotaScans       = "quota_scans"
 	PermAdminManageSystem     = "manage_system"
 	PermAdminManageSystem     = "manage_system"
@@ -45,10 +46,10 @@ const (
 var (
 var (
 	emailRegex      = regexp.MustCompile("^(?:(?:(?:(?:[a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+(?:\\.([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+)*)|(?:(?:\\x22)(?:(?:(?:(?:\\x20|\\x09)*(?:\\x0d\\x0a))?(?:\\x20|\\x09)+)?(?:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]|\\x21|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(?:(?:[\\x01-\\x09\\x0b\\x0c\\x0d-\\x7f]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}]))))*(?:(?:(?:\\x20|\\x09)*(?:\\x0d\\x0a))?(\\x20|\\x09)+)?(?:\\x22))))@(?:(?:(?:[a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(?:(?:[a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])(?:[a-zA-Z]|\\d|-|\\.|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*(?:[a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.)+(?:(?:[a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(?:(?:[a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])(?:[a-zA-Z]|\\d|-|\\.|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*(?:[a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.?$")
 	emailRegex      = regexp.MustCompile("^(?:(?:(?:(?:[a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+(?:\\.([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+)*)|(?:(?:\\x22)(?:(?:(?:(?:\\x20|\\x09)*(?:\\x0d\\x0a))?(?:\\x20|\\x09)+)?(?:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]|\\x21|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(?:(?:[\\x01-\\x09\\x0b\\x0c\\x0d-\\x7f]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}]))))*(?:(?:(?:\\x20|\\x09)*(?:\\x0d\\x0a))?(\\x20|\\x09)+)?(?:\\x22))))@(?:(?:(?:[a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(?:(?:[a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])(?:[a-zA-Z]|\\d|-|\\.|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*(?:[a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.)+(?:(?:[a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(?:(?:[a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])(?:[a-zA-Z]|\\d|-|\\.|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*(?:[a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.?$")
 	validAdminPerms = []string{PermAdminAny, PermAdminAddUsers, PermAdminChangeUsers, PermAdminDeleteUsers,
 	validAdminPerms = []string{PermAdminAny, PermAdminAddUsers, PermAdminChangeUsers, PermAdminDeleteUsers,
-		PermAdminViewUsers, PermAdminViewConnections, PermAdminCloseConnections, PermAdminViewServerStatus,
-		PermAdminManageAdmins, PermAdminManageAPIKeys, PermAdminQuotaScans, PermAdminManageSystem,
-		PermAdminManageDefender, PermAdminViewDefender, PermAdminRetentionChecks, PermAdminMetadataChecks,
-		PermAdminViewEvents}
+		PermAdminViewUsers, PermAdminManageGroups, PermAdminViewConnections, PermAdminCloseConnections,
+		PermAdminViewServerStatus, PermAdminManageAdmins, PermAdminManageAPIKeys, PermAdminQuotaScans,
+		PermAdminManageSystem, PermAdminManageDefender, PermAdminViewDefender, PermAdminRetentionChecks,
+		PermAdminMetadataChecks, PermAdminViewEvents}
 )
 )
 
 
 // AdminTOTPConfig defines the time-based one time password configuration
 // AdminTOTPConfig defines the time-based one time password configuration

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 451 - 117
dataprovider/bolt.go


+ 13 - 3
dataprovider/cacheduser.go

@@ -62,7 +62,11 @@ func (cache *usersCache) updateLastLogin(username string) {
 
 
 // swapWebDAVUser updates an existing cached user with the specified one
 // swapWebDAVUser updates an existing cached user with the specified one
 // preserving the lock fs if possible
 // preserving the lock fs if possible
-func (cache *usersCache) swap(user *User) {
+// FIXME: this could be racy in rare cases
+func (cache *usersCache) swap(userRef *User) {
+	user := userRef.getACopy()
+	err := user.LoadAndApplyGroupSettings()
+
 	cache.Lock()
 	cache.Lock()
 	defer cache.Unlock()
 	defer cache.Unlock()
 
 
@@ -74,11 +78,17 @@ func (cache *usersCache) swap(user *User) {
 			delete(cache.users, user.Username)
 			delete(cache.users, user.Username)
 			return
 			return
 		}
 		}
-		if cachedUser.User.isFsEqual(user) {
+		if err != nil {
+			providerLog(logger.LevelDebug, "unable to load group settings, for user %#v, removing from cache, err :%v",
+				user.Username, err)
+			delete(cache.users, user.Username)
+			return
+		}
+		if cachedUser.User.isFsEqual(&user) {
 			// the updated user has the same fs as the cached one, we can preserve the lock filesystem
 			// the updated user has the same fs as the cached one, we can preserve the lock filesystem
 			providerLog(logger.LevelDebug, "current password and fs unchanged for for user %#v, swap cached one",
 			providerLog(logger.LevelDebug, "current password and fs unchanged for for user %#v, swap cached one",
 				user.Username)
 				user.Username)
-			cachedUser.User = *user
+			cachedUser.User = user
 			cache.users[user.Username] = cachedUser
 			cache.users[user.Username] = cachedUser
 		} else {
 		} else {
 			// filesystem changed, the cached user is no longer valid
 			// filesystem changed, the cached user is no longer valid

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 421 - 140
dataprovider/dataprovider.go


+ 228 - 0
dataprovider/group.go

@@ -0,0 +1,228 @@
+package dataprovider
+
+import (
+	"encoding/json"
+	"fmt"
+	"path/filepath"
+	"strings"
+
+	"github.com/sftpgo/sdk"
+
+	"github.com/drakkan/sftpgo/v2/logger"
+	"github.com/drakkan/sftpgo/v2/plugin"
+	"github.com/drakkan/sftpgo/v2/util"
+	"github.com/drakkan/sftpgo/v2/vfs"
+)
+
+// GroupUserSettings defines the settings to apply to users
+type GroupUserSettings struct {
+	sdk.BaseGroupUserSettings
+	// Filesystem configuration details
+	FsConfig vfs.Filesystem `json:"filesystem"`
+}
+
+// Group defines an SFTPGo group.
+// Groups are used to easily configure similar users
+type Group struct {
+	sdk.BaseGroup
+	// settings to apply to users for whom this is a primary group
+	UserSettings GroupUserSettings `json:"user_settings,omitempty"`
+	// Mapping between virtual paths and virtual folders
+	VirtualFolders []vfs.VirtualFolder `json:"virtual_folders,omitempty"`
+}
+
+// GetPermissions returns the permissions as list
+func (g *Group) GetPermissions() []sdk.DirectoryPermissions {
+	result := make([]sdk.DirectoryPermissions, 0, len(g.UserSettings.Permissions))
+	for k, v := range g.UserSettings.Permissions {
+		result = append(result, sdk.DirectoryPermissions{
+			Path:        k,
+			Permissions: v,
+		})
+	}
+	return result
+}
+
+// GetAllowedIPAsString returns the allowed IP as comma separated string
+func (g *Group) GetAllowedIPAsString() string {
+	return strings.Join(g.UserSettings.Filters.AllowedIP, ",")
+}
+
+// GetDeniedIPAsString returns the denied IP as comma separated string
+func (g *Group) GetDeniedIPAsString() string {
+	return strings.Join(g.UserSettings.Filters.DeniedIP, ",")
+}
+
+// HasExternalAuth returns true if the external authentication is globally enabled
+// and it is not disabled for this group
+func (g *Group) HasExternalAuth() bool {
+	if g.UserSettings.Filters.Hooks.ExternalAuthDisabled {
+		return false
+	}
+	if config.ExternalAuthHook != "" {
+		return true
+	}
+	return plugin.Handler.HasAuthenticators()
+}
+
+// SetEmptySecretsIfNil sets the secrets to empty if nil
+func (g *Group) SetEmptySecretsIfNil() {
+	g.UserSettings.FsConfig.SetEmptySecretsIfNil()
+	for idx := range g.VirtualFolders {
+		vfolder := &g.VirtualFolders[idx]
+		vfolder.FsConfig.SetEmptySecretsIfNil()
+	}
+}
+
+// PrepareForRendering prepares a group for rendering.
+// It hides confidential data and set to nil the empty secrets
+// so they are not serialized
+func (g *Group) PrepareForRendering() {
+	g.UserSettings.FsConfig.HideConfidentialData()
+	g.UserSettings.FsConfig.SetNilSecretsIfEmpty()
+	for idx := range g.VirtualFolders {
+		folder := &g.VirtualFolders[idx]
+		folder.PrepareForRendering()
+	}
+}
+
+// RenderAsJSON implements the renderer interface used within plugins
+func (g *Group) RenderAsJSON(reload bool) ([]byte, error) {
+	if reload {
+		group, err := provider.groupExists(g.Name)
+		if err != nil {
+			providerLog(logger.LevelError, "unable to reload group before rendering as json: %v", err)
+			return nil, err
+		}
+		group.PrepareForRendering()
+		return json.Marshal(group)
+	}
+	g.PrepareForRendering()
+	return json.Marshal(g)
+}
+
+// GetEncryptionAdditionalData returns the additional data to use for AEAD
+func (g *Group) GetEncryptionAdditionalData() string {
+	return fmt.Sprintf("group_%v", g.Name)
+}
+
+// GetGCSCredentialsFilePath returns the path for GCS credentials
+func (g *Group) GetGCSCredentialsFilePath() string {
+	return filepath.Join(credentialsDirPath, "groups", fmt.Sprintf("%v_gcs_credentials.json", g.Name))
+}
+
+// HasRedactedSecret returns true if the user has a redacted secret
+func (g *Group) hasRedactedSecret() bool {
+	for idx := range g.VirtualFolders {
+		folder := &g.VirtualFolders[idx]
+		if folder.HasRedactedSecret() {
+			return true
+		}
+	}
+
+	return g.UserSettings.FsConfig.HasRedactedSecret()
+}
+
+func (g *Group) validate() error {
+	g.SetEmptySecretsIfNil()
+	if g.Name == "" {
+		return util.NewValidationError("name is mandatory")
+	}
+	if config.NamingRules&1 == 0 && !usernameRegex.MatchString(g.Name) {
+		return util.NewValidationError(fmt.Sprintf("name %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~", g.Name))
+	}
+	if g.hasRedactedSecret() {
+		return util.NewValidationError("cannot save a user with a redacted secret")
+	}
+	vfolders, err := validateAssociatedVirtualFolders(g.VirtualFolders)
+	if err != nil {
+		return err
+	}
+	g.VirtualFolders = vfolders
+	return g.validateUserSettings()
+}
+
+func (g *Group) validateUserSettings() error {
+	if g.UserSettings.HomeDir != "" {
+		g.UserSettings.HomeDir = filepath.Clean(g.UserSettings.HomeDir)
+		if !filepath.IsAbs(g.UserSettings.HomeDir) {
+			return util.NewValidationError(fmt.Sprintf("home_dir must be an absolute path, actual value: %v",
+				g.UserSettings.HomeDir))
+		}
+	}
+	if err := g.UserSettings.FsConfig.Validate(g); err != nil {
+		return err
+	}
+	if err := saveGCSCredentials(&g.UserSettings.FsConfig, g); err != nil {
+		return err
+	}
+	if g.UserSettings.TotalDataTransfer > 0 {
+		// if a total data transfer is defined we reset the separate upload and download limits
+		g.UserSettings.UploadDataTransfer = 0
+		g.UserSettings.DownloadDataTransfer = 0
+	}
+	if len(g.UserSettings.Permissions) > 0 {
+		permissions, err := validateUserPermissions(g.UserSettings.Permissions)
+		if err != nil {
+			return err
+		}
+		g.UserSettings.Permissions = permissions
+	}
+	if err := validateBaseFilters(&g.UserSettings.Filters); err != nil {
+		return err
+	}
+	if !g.HasExternalAuth() {
+		g.UserSettings.Filters.ExternalAuthCacheTime = 0
+	}
+	g.UserSettings.Filters.UserType = ""
+	return nil
+}
+
+func (g *Group) getACopy() Group {
+	users := make([]string, len(g.Users))
+	copy(users, g.Users)
+	virtualFolders := make([]vfs.VirtualFolder, 0, len(g.VirtualFolders))
+	for idx := range g.VirtualFolders {
+		vfolder := g.VirtualFolders[idx].GetACopy()
+		virtualFolders = append(virtualFolders, vfolder)
+	}
+	permissions := make(map[string][]string)
+	for k, v := range g.UserSettings.Permissions {
+		perms := make([]string, len(v))
+		copy(perms, v)
+		permissions[k] = perms
+	}
+
+	return Group{
+		BaseGroup: sdk.BaseGroup{
+			ID:          g.ID,
+			Name:        g.Name,
+			Description: g.Description,
+			CreatedAt:   g.CreatedAt,
+			UpdatedAt:   g.UpdatedAt,
+			Users:       users,
+		},
+		UserSettings: GroupUserSettings{
+			BaseGroupUserSettings: sdk.BaseGroupUserSettings{
+				HomeDir:              g.UserSettings.HomeDir,
+				MaxSessions:          g.UserSettings.MaxSessions,
+				QuotaSize:            g.UserSettings.QuotaSize,
+				QuotaFiles:           g.UserSettings.QuotaFiles,
+				Permissions:          permissions,
+				UploadBandwidth:      g.UserSettings.UploadBandwidth,
+				DownloadBandwidth:    g.UserSettings.DownloadBandwidth,
+				UploadDataTransfer:   g.UserSettings.UploadDataTransfer,
+				DownloadDataTransfer: g.UserSettings.DownloadDataTransfer,
+				TotalDataTransfer:    g.UserSettings.TotalDataTransfer,
+				Filters:              copyBaseUserFilters(g.UserSettings.Filters),
+			},
+			FsConfig: g.UserSettings.FsConfig.GetACopy(),
+		},
+		VirtualFolders: virtualFolders,
+	}
+}
+
+// GetUsersAsString returns the list of users as comma separated string
+func (g *Group) GetUsersAsString() string {
+	return strings.Join(g.Users, ",")
+}

+ 385 - 24
dataprovider/memory.go

@@ -28,6 +28,10 @@ type memoryProviderHandle struct {
 	usernames []string
 	usernames []string
 	// map for users, username is the key
 	// map for users, username is the key
 	users map[string]User
 	users map[string]User
+	// slice with ordered group names
+	groupnames []string
+	// map for group, group name is the key
+	groups map[string]Group
 	// map for virtual folders, folder name is the key
 	// map for virtual folders, folder name is the key
 	vfolders map[string]vfs.BaseVirtualFolder
 	vfolders map[string]vfs.BaseVirtualFolder
 	// slice with ordered folder names
 	// slice with ordered folder names
@@ -64,6 +68,8 @@ func initializeMemoryProvider(basePath string) {
 			isClosed:        false,
 			isClosed:        false,
 			usernames:       []string{},
 			usernames:       []string{},
 			users:           make(map[string]User),
 			users:           make(map[string]User),
+			groupnames:      []string{},
+			groups:          make(map[string]Group),
 			vfolders:        make(map[string]vfs.BaseVirtualFolder),
 			vfolders:        make(map[string]vfs.BaseVirtualFolder),
 			vfoldersNames:   []string{},
 			vfoldersNames:   []string{},
 			admins:          make(map[string]Admin),
 			admins:          make(map[string]Admin),
@@ -299,7 +305,12 @@ func (p *MemoryProvider) addUser(user *User) error {
 	user.LastLogin = 0
 	user.LastLogin = 0
 	user.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 	user.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 	user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 	user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
-	user.VirtualFolders = p.joinVirtualFoldersFields(user)
+	user.VirtualFolders = p.joinUserVirtualFoldersFields(user)
+	for idx := range user.Groups {
+		if err = p.addUserFromGroupMapping(user.Username, user.Groups[idx].Name); err != nil {
+			return err
+		}
+	}
 	p.dbHandle.users[user.Username] = user.getACopy()
 	p.dbHandle.users[user.Username] = user.getACopy()
 	p.dbHandle.usernames = append(p.dbHandle.usernames, user.Username)
 	p.dbHandle.usernames = append(p.dbHandle.usernames, user.Username)
 	sort.Strings(p.dbHandle.usernames)
 	sort.Strings(p.dbHandle.usernames)
@@ -325,9 +336,19 @@ func (p *MemoryProvider) updateUser(user *User) error {
 		return err
 		return err
 	}
 	}
 	for _, oldFolder := range u.VirtualFolders {
 	for _, oldFolder := range u.VirtualFolders {
-		p.removeUserFromFolderMapping(oldFolder.Name, u.Username)
+		p.removeRelationFromFolderMapping(oldFolder.Name, u.Username, "")
+	}
+	for idx := range u.Groups {
+		if err = p.removeUserFromGroupMapping(u.Username, u.Groups[idx].Name); err != nil {
+			return err
+		}
+	}
+	user.VirtualFolders = p.joinUserVirtualFoldersFields(user)
+	for idx := range user.Groups {
+		if err = p.addUserFromGroupMapping(user.Username, user.Groups[idx].Name); err != nil {
+			return err
+		}
 	}
 	}
-	user.VirtualFolders = p.joinVirtualFoldersFields(user)
 	user.LastQuotaUpdate = u.LastQuotaUpdate
 	user.LastQuotaUpdate = u.LastQuotaUpdate
 	user.UsedQuotaSize = u.UsedQuotaSize
 	user.UsedQuotaSize = u.UsedQuotaSize
 	user.UsedQuotaFiles = u.UsedQuotaFiles
 	user.UsedQuotaFiles = u.UsedQuotaFiles
@@ -342,7 +363,7 @@ func (p *MemoryProvider) updateUser(user *User) error {
 	return nil
 	return nil
 }
 }
 
 
-func (p *MemoryProvider) deleteUser(user *User) error {
+func (p *MemoryProvider) deleteUser(user User) error {
 	p.dbHandle.Lock()
 	p.dbHandle.Lock()
 	defer p.dbHandle.Unlock()
 	defer p.dbHandle.Unlock()
 	if p.dbHandle.isClosed {
 	if p.dbHandle.isClosed {
@@ -353,7 +374,12 @@ func (p *MemoryProvider) deleteUser(user *User) error {
 		return err
 		return err
 	}
 	}
 	for _, oldFolder := range u.VirtualFolders {
 	for _, oldFolder := range u.VirtualFolders {
-		p.removeUserFromFolderMapping(oldFolder.Name, u.Username)
+		p.removeRelationFromFolderMapping(oldFolder.Name, u.Username, "")
+	}
+	for idx := range u.Groups {
+		if err = p.removeUserFromGroupMapping(u.Username, u.Groups[idx].Name); err != nil {
+			return err
+		}
 	}
 	}
 	delete(p.dbHandle.users, user.Username)
 	delete(p.dbHandle.users, user.Username)
 	// this could be more efficient
 	// this could be more efficient
@@ -433,9 +459,21 @@ func (p *MemoryProvider) getUsersForQuotaCheck(toFetch map[string]bool) ([]User,
 		if val, ok := toFetch[username]; ok {
 		if val, ok := toFetch[username]; ok {
 			u := p.dbHandle.users[username]
 			u := p.dbHandle.users[username]
 			user := u.getACopy()
 			user := u.getACopy()
+			if len(user.Groups) > 0 {
+				groupMapping := make(map[string]Group)
+				for idx := range user.Groups {
+					group, err := p.groupExistsInternal(user.Groups[idx].Name)
+					if err != nil {
+						continue
+					}
+					groupMapping[group.Name] = group
+				}
+				user.applyGroupSettings(groupMapping)
+			}
 			if val {
 			if val {
 				p.addVirtualFoldersToUser(&user)
 				p.addVirtualFoldersToUser(&user)
 			}
 			}
+			user.SetEmptySecretsIfNil()
 			user.PrepareForRendering()
 			user.PrepareForRendering()
 			users = append(users, user)
 			users = append(users, user)
 		}
 		}
@@ -512,6 +550,13 @@ func (p *MemoryProvider) userExistsInternal(username string) (User, error) {
 	return User{}, util.NewRecordNotFoundError(fmt.Sprintf("username %#v does not exist", username))
 	return User{}, util.NewRecordNotFoundError(fmt.Sprintf("username %#v does not exist", username))
 }
 }
 
 
+func (p *MemoryProvider) groupExistsInternal(name string) (Group, error) {
+	if val, ok := p.dbHandle.groups[name]; ok {
+		return val.getACopy(), nil
+	}
+	return Group{}, util.NewRecordNotFoundError(fmt.Sprintf("group %#v does not exist", name))
+}
+
 func (p *MemoryProvider) addAdmin(admin *Admin) error {
 func (p *MemoryProvider) addAdmin(admin *Admin) error {
 	p.dbHandle.Lock()
 	p.dbHandle.Lock()
 	defer p.dbHandle.Unlock()
 	defer p.dbHandle.Unlock()
@@ -558,7 +603,7 @@ func (p *MemoryProvider) updateAdmin(admin *Admin) error {
 	return nil
 	return nil
 }
 }
 
 
-func (p *MemoryProvider) deleteAdmin(admin *Admin) error {
+func (p *MemoryProvider) deleteAdmin(admin Admin) error {
 	p.dbHandle.Lock()
 	p.dbHandle.Lock()
 	defer p.dbHandle.Unlock()
 	defer p.dbHandle.Unlock()
 	if p.dbHandle.isClosed {
 	if p.dbHandle.isClosed {
@@ -680,6 +725,192 @@ func (p *MemoryProvider) updateFolderQuota(name string, filesAdd int, sizeAdd in
 	return nil
 	return nil
 }
 }
 
 
+func (p *MemoryProvider) getGroups(limit, offset int, order string, minimal bool) ([]Group, error) {
+	p.dbHandle.Lock()
+	defer p.dbHandle.Unlock()
+	if p.dbHandle.isClosed {
+		return nil, errMemoryProviderClosed
+	}
+	if limit <= 0 {
+		return nil, nil
+	}
+	groups := make([]Group, 0, limit)
+	itNum := 0
+	if order == OrderASC {
+		for _, name := range p.dbHandle.groupnames {
+			itNum++
+			if itNum <= offset {
+				continue
+			}
+			g := p.dbHandle.groups[name]
+			group := g.getACopy()
+			p.addVirtualFoldersToGroup(&group)
+			group.PrepareForRendering()
+			groups = append(groups, group)
+			if len(groups) >= limit {
+				break
+			}
+		}
+	} else {
+		for i := len(p.dbHandle.groupnames) - 1; i >= 0; i-- {
+			itNum++
+			if itNum <= offset {
+				continue
+			}
+			name := p.dbHandle.groupnames[i]
+			g := p.dbHandle.groups[name]
+			group := g.getACopy()
+			p.addVirtualFoldersToGroup(&group)
+			group.PrepareForRendering()
+			groups = append(groups, group)
+			if len(groups) >= limit {
+				break
+			}
+		}
+	}
+	return groups, nil
+}
+
+func (p *MemoryProvider) getGroupsWithNames(names []string) ([]Group, error) {
+	p.dbHandle.Lock()
+	defer p.dbHandle.Unlock()
+	if p.dbHandle.isClosed {
+		return nil, errMemoryProviderClosed
+	}
+	groups := make([]Group, 0, len(names))
+	for _, name := range names {
+		if val, ok := p.dbHandle.groups[name]; ok {
+			group := val.getACopy()
+			p.addVirtualFoldersToGroup(&group)
+			groups = append(groups, group)
+		}
+	}
+
+	return groups, nil
+}
+
+func (p *MemoryProvider) getUsersInGroups(names []string) ([]string, error) {
+	p.dbHandle.Lock()
+	defer p.dbHandle.Unlock()
+	if p.dbHandle.isClosed {
+		return nil, errMemoryProviderClosed
+	}
+	var users []string
+	for _, name := range names {
+		if val, ok := p.dbHandle.groups[name]; ok {
+			group := val.getACopy()
+			users = append(users, group.Users...)
+		}
+	}
+
+	return users, nil
+}
+
+func (p *MemoryProvider) groupExists(name string) (Group, error) {
+	p.dbHandle.Lock()
+	defer p.dbHandle.Unlock()
+	if p.dbHandle.isClosed {
+		return Group{}, errMemoryProviderClosed
+	}
+	group, err := p.groupExistsInternal(name)
+	if err != nil {
+		return group, err
+	}
+	p.addVirtualFoldersToGroup(&group)
+	return group, nil
+}
+
+func (p *MemoryProvider) addGroup(group *Group) error {
+	if err := group.validate(); err != nil {
+		return err
+	}
+	p.dbHandle.Lock()
+	defer p.dbHandle.Unlock()
+	if p.dbHandle.isClosed {
+		return errMemoryProviderClosed
+	}
+
+	_, err := p.groupExistsInternal(group.Name)
+	if err == nil {
+		return fmt.Errorf("group %#v already exists", group.Name)
+	}
+	group.ID = p.getNextGroupID()
+	group.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+	group.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+	group.VirtualFolders = p.joinGroupVirtualFoldersFields(group)
+	p.dbHandle.groups[group.Name] = group.getACopy()
+	p.dbHandle.groupnames = append(p.dbHandle.groupnames, group.Name)
+	sort.Strings(p.dbHandle.groupnames)
+	return nil
+}
+
+func (p *MemoryProvider) updateGroup(group *Group) error {
+	if err := group.validate(); err != nil {
+		return err
+	}
+	p.dbHandle.Lock()
+	defer p.dbHandle.Unlock()
+	if p.dbHandle.isClosed {
+		return errMemoryProviderClosed
+	}
+	g, err := p.groupExistsInternal(group.Name)
+	if err != nil {
+		return err
+	}
+	for _, oldFolder := range g.VirtualFolders {
+		p.removeRelationFromFolderMapping(oldFolder.Name, "", g.Name)
+	}
+	group.VirtualFolders = p.joinGroupVirtualFoldersFields(group)
+	group.CreatedAt = g.CreatedAt
+	group.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+	group.ID = g.ID
+	p.dbHandle.groups[group.Name] = group.getACopy()
+	return nil
+}
+
+func (p *MemoryProvider) deleteGroup(group Group) error {
+	p.dbHandle.Lock()
+	defer p.dbHandle.Unlock()
+	if p.dbHandle.isClosed {
+		return errMemoryProviderClosed
+	}
+	g, err := p.groupExistsInternal(group.Name)
+	if err != nil {
+		return err
+	}
+	if len(g.Users) > 0 {
+		return util.NewValidationError(fmt.Sprintf("the group %#v is referenced, it cannot be removed", group.Name))
+	}
+	for _, oldFolder := range g.VirtualFolders {
+		p.removeRelationFromFolderMapping(oldFolder.Name, "", g.Name)
+	}
+	delete(p.dbHandle.groups, group.Name)
+	// this could be more efficient
+	p.dbHandle.groupnames = make([]string, 0, len(p.dbHandle.groups))
+	for name := range p.dbHandle.groups {
+		p.dbHandle.groupnames = append(p.dbHandle.groupnames, name)
+	}
+	sort.Strings(p.dbHandle.groupnames)
+	return nil
+}
+
+func (p *MemoryProvider) dumpGroups() ([]Group, error) {
+	p.dbHandle.Lock()
+	defer p.dbHandle.Unlock()
+	groups := make([]Group, 0, len(p.dbHandle.groups))
+	var err error
+	if p.dbHandle.isClosed {
+		return groups, errMemoryProviderClosed
+	}
+	for _, name := range p.dbHandle.groupnames {
+		g := p.dbHandle.groups[name]
+		group := g.getACopy()
+		p.addVirtualFoldersToGroup(&group)
+		groups = append(groups, group)
+	}
+	return groups, err
+}
+
 func (p *MemoryProvider) getUsedFolderQuota(name string) (int, int64, error) {
 func (p *MemoryProvider) getUsedFolderQuota(name string) (int, int64, error) {
 	p.dbHandle.Lock()
 	p.dbHandle.Lock()
 	defer p.dbHandle.Unlock()
 	defer p.dbHandle.Unlock()
@@ -694,11 +925,70 @@ func (p *MemoryProvider) getUsedFolderQuota(name string) (int, int64, error) {
 	return folder.UsedQuotaFiles, folder.UsedQuotaSize, err
 	return folder.UsedQuotaFiles, folder.UsedQuotaSize, err
 }
 }
 
 
-func (p *MemoryProvider) joinVirtualFoldersFields(user *User) []vfs.VirtualFolder {
+func (p *MemoryProvider) joinGroupVirtualFoldersFields(group *Group) []vfs.VirtualFolder {
+	var folders []vfs.VirtualFolder
+	for idx := range group.VirtualFolders {
+		folder := &group.VirtualFolders[idx]
+		f, err := p.addOrUpdateFolderInternal(&folder.BaseVirtualFolder, "", group.Name, 0, 0, 0)
+		if err == nil {
+			folder.BaseVirtualFolder = f
+			folders = append(folders, *folder)
+		}
+	}
+	return folders
+}
+
+func (p *MemoryProvider) addVirtualFoldersToGroup(group *Group) {
+	if len(group.VirtualFolders) > 0 {
+		var folders []vfs.VirtualFolder
+		for idx := range group.VirtualFolders {
+			folder := &group.VirtualFolders[idx]
+			baseFolder, err := p.folderExistsInternal(folder.Name)
+			if err != nil {
+				continue
+			}
+			folder.BaseVirtualFolder = baseFolder.GetACopy()
+			folders = append(folders, *folder)
+		}
+		group.VirtualFolders = folders
+	}
+}
+
+func (p *MemoryProvider) addUserFromGroupMapping(username, groupname string) error {
+	g, err := p.groupExistsInternal(groupname)
+	if err != nil {
+		return err
+	}
+	if !util.IsStringInSlice(username, g.Users) {
+		g.Users = append(g.Users, username)
+		p.dbHandle.groups[groupname] = g
+	}
+	return nil
+}
+
+func (p *MemoryProvider) removeUserFromGroupMapping(username, groupname string) error {
+	g, err := p.groupExistsInternal(groupname)
+	if err != nil {
+		return err
+	}
+	if util.IsStringInSlice(username, g.Users) {
+		var users []string
+		for _, u := range g.Users {
+			if u != username {
+				users = append(users, u)
+			}
+		}
+		g.Users = users
+		p.dbHandle.groups[groupname] = g
+	}
+	return nil
+}
+
+func (p *MemoryProvider) joinUserVirtualFoldersFields(user *User) []vfs.VirtualFolder {
 	var folders []vfs.VirtualFolder
 	var folders []vfs.VirtualFolder
 	for idx := range user.VirtualFolders {
 	for idx := range user.VirtualFolders {
 		folder := &user.VirtualFolders[idx]
 		folder := &user.VirtualFolders[idx]
-		f, err := p.addOrUpdateFolderInternal(&folder.BaseVirtualFolder, user.Username, 0, 0, 0)
+		f, err := p.addOrUpdateFolderInternal(&folder.BaseVirtualFolder, user.Username, "", 0, 0, 0)
 		if err == nil {
 		if err == nil {
 			folder.BaseVirtualFolder = f
 			folder.BaseVirtualFolder = f
 			folders = append(folders, *folder)
 			folders = append(folders, *folder)
@@ -723,16 +1013,27 @@ func (p *MemoryProvider) addVirtualFoldersToUser(user *User) {
 	}
 	}
 }
 }
 
 
-func (p *MemoryProvider) removeUserFromFolderMapping(folderName, username string) {
+func (p *MemoryProvider) removeRelationFromFolderMapping(folderName, username, groupname string) {
 	folder, err := p.folderExistsInternal(folderName)
 	folder, err := p.folderExistsInternal(folderName)
 	if err == nil {
 	if err == nil {
-		var usernames []string
-		for _, user := range folder.Users {
-			if user != username {
-				usernames = append(usernames, user)
+		if username != "" {
+			var usernames []string
+			for _, user := range folder.Users {
+				if user != username {
+					usernames = append(usernames, user)
+				}
 			}
 			}
+			folder.Users = usernames
+		}
+		if groupname != "" {
+			var groups []string
+			for _, group := range folder.Groups {
+				if group != groupname {
+					groups = append(groups, group)
+				}
+			}
+			folder.Groups = groups
 		}
 		}
-		folder.Users = usernames
 		p.dbHandle.vfolders[folder.Name] = folder
 		p.dbHandle.vfolders[folder.Name] = folder
 	}
 	}
 }
 }
@@ -745,18 +1046,21 @@ func (p *MemoryProvider) updateFoldersMappingInternal(folder vfs.BaseVirtualFold
 	}
 	}
 }
 }
 
 
-func (p *MemoryProvider) addOrUpdateFolderInternal(baseFolder *vfs.BaseVirtualFolder, username string, usedQuotaSize int64,
-	usedQuotaFiles int, lastQuotaUpdate int64) (vfs.BaseVirtualFolder, error,
-) {
+func (p *MemoryProvider) addOrUpdateFolderInternal(baseFolder *vfs.BaseVirtualFolder, username, groupname string,
+	usedQuotaSize int64, usedQuotaFiles int, lastQuotaUpdate int64,
+) (vfs.BaseVirtualFolder, error) {
 	folder, err := p.folderExistsInternal(baseFolder.Name)
 	folder, err := p.folderExistsInternal(baseFolder.Name)
 	if err == nil {
 	if err == nil {
 		// exists
 		// exists
 		folder.MappedPath = baseFolder.MappedPath
 		folder.MappedPath = baseFolder.MappedPath
 		folder.Description = baseFolder.Description
 		folder.Description = baseFolder.Description
 		folder.FsConfig = baseFolder.FsConfig.GetACopy()
 		folder.FsConfig = baseFolder.FsConfig.GetACopy()
-		if !util.IsStringInSlice(username, folder.Users) {
+		if username != "" && !util.IsStringInSlice(username, folder.Users) {
 			folder.Users = append(folder.Users, username)
 			folder.Users = append(folder.Users, username)
 		}
 		}
+		if groupname != "" && !util.IsStringInSlice(groupname, folder.Groups) {
+			folder.Groups = append(folder.Groups, groupname)
+		}
 		p.updateFoldersMappingInternal(folder)
 		p.updateFoldersMappingInternal(folder)
 		return folder, nil
 		return folder, nil
 	}
 	}
@@ -766,7 +1070,12 @@ func (p *MemoryProvider) addOrUpdateFolderInternal(baseFolder *vfs.BaseVirtualFo
 		folder.UsedQuotaSize = usedQuotaSize
 		folder.UsedQuotaSize = usedQuotaSize
 		folder.UsedQuotaFiles = usedQuotaFiles
 		folder.UsedQuotaFiles = usedQuotaFiles
 		folder.LastQuotaUpdate = lastQuotaUpdate
 		folder.LastQuotaUpdate = lastQuotaUpdate
-		folder.Users = []string{username}
+		if username != "" {
+			folder.Users = []string{username}
+		}
+		if groupname != "" {
+			folder.Groups = []string{groupname}
+		}
 		p.updateFoldersMappingInternal(folder)
 		p.updateFoldersMappingInternal(folder)
 		return folder, nil
 		return folder, nil
 	}
 	}
@@ -780,7 +1089,7 @@ func (p *MemoryProvider) folderExistsInternal(name string) (vfs.BaseVirtualFolde
 	return vfs.BaseVirtualFolder{}, util.NewRecordNotFoundError(fmt.Sprintf("folder %#v does not exist", name))
 	return vfs.BaseVirtualFolder{}, util.NewRecordNotFoundError(fmt.Sprintf("folder %#v does not exist", name))
 }
 }
 
 
-func (p *MemoryProvider) getFolders(limit, offset int, order string) ([]vfs.BaseVirtualFolder, error) {
+func (p *MemoryProvider) getFolders(limit, offset int, order string, minimal bool) ([]vfs.BaseVirtualFolder, error) {
 	folders := make([]vfs.BaseVirtualFolder, 0, limit)
 	folders := make([]vfs.BaseVirtualFolder, 0, limit)
 	var err error
 	var err error
 	p.dbHandle.Lock()
 	p.dbHandle.Lock()
@@ -902,7 +1211,7 @@ func (p *MemoryProvider) updateFolder(folder *vfs.BaseVirtualFolder) error {
 	return nil
 	return nil
 }
 }
 
 
-func (p *MemoryProvider) deleteFolder(folder *vfs.BaseVirtualFolder) error {
+func (p *MemoryProvider) deleteFolder(folder vfs.BaseVirtualFolder) error {
 	p.dbHandle.Lock()
 	p.dbHandle.Lock()
 	defer p.dbHandle.Unlock()
 	defer p.dbHandle.Unlock()
 	if p.dbHandle.isClosed {
 	if p.dbHandle.isClosed {
@@ -927,6 +1236,20 @@ func (p *MemoryProvider) deleteFolder(folder *vfs.BaseVirtualFolder) error {
 			p.dbHandle.users[user.Username] = user
 			p.dbHandle.users[user.Username] = user
 		}
 		}
 	}
 	}
+	for _, groupname := range folder.Groups {
+		group, err := p.groupExistsInternal(groupname)
+		if err == nil {
+			var folders []vfs.VirtualFolder
+			for idx := range group.VirtualFolders {
+				groupFolder := &group.VirtualFolders[idx]
+				if folder.Name != groupFolder.Name {
+					folders = append(folders, *groupFolder)
+				}
+			}
+			group.VirtualFolders = folders
+			p.dbHandle.groups[group.Name] = group
+		}
+	}
 	delete(p.dbHandle.vfolders, folder.Name)
 	delete(p.dbHandle.vfolders, folder.Name)
 	p.dbHandle.vfoldersNames = []string{}
 	p.dbHandle.vfoldersNames = []string{}
 	for name := range p.dbHandle.vfolders {
 	for name := range p.dbHandle.vfolders {
@@ -1022,7 +1345,7 @@ func (p *MemoryProvider) updateAPIKey(apiKey *APIKey) error {
 	return nil
 	return nil
 }
 }
 
 
-func (p *MemoryProvider) deleteAPIKey(apiKey *APIKey) error {
+func (p *MemoryProvider) deleteAPIKey(apiKey APIKey) error {
 	p.dbHandle.Lock()
 	p.dbHandle.Lock()
 	defer p.dbHandle.Unlock()
 	defer p.dbHandle.Unlock()
 	if p.dbHandle.isClosed {
 	if p.dbHandle.isClosed {
@@ -1249,7 +1572,7 @@ func (p *MemoryProvider) updateShare(share *Share) error {
 	return nil
 	return nil
 }
 }
 
 
-func (p *MemoryProvider) deleteShare(share *Share) error {
+func (p *MemoryProvider) deleteShare(share Share) error {
 	p.dbHandle.Lock()
 	p.dbHandle.Lock()
 	defer p.dbHandle.Unlock()
 	defer p.dbHandle.Unlock()
 	if p.dbHandle.isClosed {
 	if p.dbHandle.isClosed {
@@ -1430,6 +1753,16 @@ func (p *MemoryProvider) getNextAdminID() int64 {
 	return nextID
 	return nextID
 }
 }
 
 
+func (p *MemoryProvider) getNextGroupID() int64 {
+	nextID := int64(1)
+	for _, g := range p.dbHandle.groups {
+		if g.ID >= nextID {
+			nextID = g.ID + 1
+		}
+	}
+	return nextID
+}
+
 func (p *MemoryProvider) clear() {
 func (p *MemoryProvider) clear() {
 	p.dbHandle.Lock()
 	p.dbHandle.Lock()
 	defer p.dbHandle.Unlock()
 	defer p.dbHandle.Unlock()
@@ -1482,6 +1815,10 @@ func (p *MemoryProvider) reloadConfig() error {
 		return err
 		return err
 	}
 	}
 
 
+	if err := p.restoreGroups(&dump); err != nil {
+		return err
+	}
+
 	if err := p.restoreUsers(&dump); err != nil {
 	if err := p.restoreUsers(&dump); err != nil {
 		return err
 		return err
 	}
 	}
@@ -1573,6 +1910,30 @@ func (p *MemoryProvider) restoreAdmins(dump *BackupData) error {
 	return nil
 	return nil
 }
 }
 
 
+func (p *MemoryProvider) restoreGroups(dump *BackupData) error {
+	for _, group := range dump.Groups {
+		group := group // pin
+		group.Name = config.convertName(group.Name)
+		g, err := p.groupExists(group.Name)
+		if err == nil {
+			group.ID = g.ID
+			err = UpdateGroup(&group, g.Users, ActionExecutorSystem, "")
+			if err != nil {
+				providerLog(logger.LevelError, "error updating group %#v: %v", group.Name, err)
+				return err
+			}
+		} else {
+			group.Users = nil
+			err = AddGroup(&group, ActionExecutorSystem, "")
+			if err != nil {
+				providerLog(logger.LevelError, "error adding group %#v: %v", group.Name, err)
+				return err
+			}
+		}
+	}
+	return nil
+}
+
 func (p *MemoryProvider) restoreFolders(dump *BackupData) error {
 func (p *MemoryProvider) restoreFolders(dump *BackupData) error {
 	for _, folder := range dump.Folders {
 	for _, folder := range dump.Folders {
 		folder := folder // pin
 		folder := folder // pin
@@ -1580,7 +1941,7 @@ func (p *MemoryProvider) restoreFolders(dump *BackupData) error {
 		f, err := p.getFolderByName(folder.Name)
 		f, err := p.getFolderByName(folder.Name)
 		if err == nil {
 		if err == nil {
 			folder.ID = f.ID
 			folder.ID = f.ID
-			err = UpdateFolder(&folder, f.Users, ActionExecutorSystem, "")
+			err = UpdateFolder(&folder, f.Users, f.Groups, ActionExecutorSystem, "")
 			if err != nil {
 			if err != nil {
 				providerLog(logger.LevelError, "error updating folder %#v: %v", folder.Name, err)
 				providerLog(logger.LevelError, "error updating folder %#v: %v", folder.Name, err)
 				return err
 				return err

+ 138 - 18
dataprovider/mysql.go

@@ -24,10 +24,14 @@ import (
 const (
 const (
 	mysqlResetSQL = "DROP TABLE IF EXISTS `{{api_keys}}` CASCADE;" +
 	mysqlResetSQL = "DROP TABLE IF EXISTS `{{api_keys}}` CASCADE;" +
 		"DROP TABLE IF EXISTS `{{folders_mapping}}` CASCADE;" +
 		"DROP TABLE IF EXISTS `{{folders_mapping}}` CASCADE;" +
+		"DROP TABLE IF EXISTS `{{users_folders_mapping}}` CASCADE;" +
+		"DROP TABLE IF EXISTS `{{users_groups_mapping}}` CASCADE;" +
+		"DROP TABLE IF EXISTS `{{groups_folders_mapping}}` CASCADE;" +
 		"DROP TABLE IF EXISTS `{{admins}}` CASCADE;" +
 		"DROP TABLE IF EXISTS `{{admins}}` CASCADE;" +
 		"DROP TABLE IF EXISTS `{{folders}}` CASCADE;" +
 		"DROP TABLE IF EXISTS `{{folders}}` CASCADE;" +
 		"DROP TABLE IF EXISTS `{{shares}}` CASCADE;" +
 		"DROP TABLE IF EXISTS `{{shares}}` CASCADE;" +
 		"DROP TABLE IF EXISTS `{{users}}` CASCADE;" +
 		"DROP TABLE IF EXISTS `{{users}}` CASCADE;" +
+		"DROP TABLE IF EXISTS `{{groups}}` CASCADE;" +
 		"DROP TABLE IF EXISTS `{{defender_events}}` CASCADE;" +
 		"DROP TABLE IF EXISTS `{{defender_events}}` CASCADE;" +
 		"DROP TABLE IF EXISTS `{{defender_hosts}}` CASCADE;" +
 		"DROP TABLE IF EXISTS `{{defender_hosts}}` CASCADE;" +
 		"DROP TABLE IF EXISTS `{{active_transfers}}` CASCADE;" +
 		"DROP TABLE IF EXISTS `{{active_transfers}}` CASCADE;" +
@@ -101,6 +105,53 @@ const (
 		"ALTER TABLE `{{users}}` DROP COLUMN `total_data_transfer`;" +
 		"ALTER TABLE `{{users}}` DROP COLUMN `total_data_transfer`;" +
 		"ALTER TABLE `{{users}}` DROP COLUMN `download_data_transfer`;" +
 		"ALTER TABLE `{{users}}` DROP COLUMN `download_data_transfer`;" +
 		"DROP TABLE `{{active_transfers}}` CASCADE;"
 		"DROP TABLE `{{active_transfers}}` CASCADE;"
+	mysqlV17SQL = "CREATE TABLE `{{groups}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, " +
+		"`name` varchar(255) NOT NULL UNIQUE, `description` varchar(512) NULL, `created_at` bigint NOT NULL, " +
+		"`updated_at` bigint NOT NULL, `user_settings` longtext NULL);" +
+		"CREATE TABLE `{{groups_folders_mapping}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, " +
+		"`group_id` integer NOT NULL, `folder_id` integer NOT NULL, " +
+		"`virtual_path` longtext NOT NULL, `quota_size` bigint NOT NULL, `quota_files` integer NOT NULL);" +
+		"CREATE TABLE `{{users_groups_mapping}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, " +
+		"`user_id` integer NOT NULL, `group_id` integer NOT NULL, `group_type` integer NOT NULL);" +
+		"ALTER TABLE `{{folders_mapping}}` DROP FOREIGN KEY `{{prefix}}folders_mapping_folder_id_fk_folders_id`;" +
+		"ALTER TABLE `{{folders_mapping}}` DROP FOREIGN KEY `{{prefix}}folders_mapping_user_id_fk_users_id`;" +
+		"ALTER TABLE `{{folders_mapping}}` DROP INDEX `{{prefix}}unique_mapping`;" +
+		"RENAME TABLE `{{folders_mapping}}` TO `{{users_folders_mapping}}`;" +
+		"ALTER TABLE `{{users_folders_mapping}}` ADD CONSTRAINT `{{prefix}}unique_user_folder_mapping` " +
+		"UNIQUE (`user_id`, `folder_id`);" +
+		"ALTER TABLE `{{users_folders_mapping}}` ADD CONSTRAINT `{{prefix}}users_folders_mapping_user_id_fk_users_id` " +
+		"FOREIGN KEY (`user_id`) REFERENCES `{{users}}` (`id`) ON DELETE CASCADE;" +
+		"ALTER TABLE `{{users_folders_mapping}}` ADD CONSTRAINT `{{prefix}}users_folders_mapping_folder_id_fk_folders_id` " +
+		"FOREIGN KEY (`folder_id`) REFERENCES `{{folders}}` (`id`) ON DELETE CASCADE;" +
+		"ALTER TABLE `{{users_groups_mapping}}` ADD CONSTRAINT `{{prefix}}unique_user_group_mapping` UNIQUE (`user_id`, `group_id`);" +
+		"ALTER TABLE `{{groups_folders_mapping}}` ADD CONSTRAINT `{{prefix}}unique_group_folder_mapping` UNIQUE (`group_id`, `folder_id`);" +
+		"ALTER TABLE `{{users_groups_mapping}}` ADD CONSTRAINT `{{prefix}}users_groups_mapping_group_id_fk_groups_id` " +
+		"FOREIGN KEY (`group_id`) REFERENCES `{{groups}}` (`id`) ON DELETE NO ACTION;" +
+		"ALTER TABLE `{{users_groups_mapping}}` ADD CONSTRAINT `{{prefix}}users_groups_mapping_user_id_fk_users_id` " +
+		"FOREIGN KEY (`user_id`) REFERENCES `{{users}}` (`id`) ON DELETE CASCADE;" +
+		"ALTER TABLE `{{groups_folders_mapping}}` ADD CONSTRAINT `{{prefix}}groups_folders_mapping_folder_id_fk_folders_id` " +
+		"FOREIGN KEY (`folder_id`) REFERENCES `{{folders}}` (`id`) ON DELETE CASCADE;" +
+		"ALTER TABLE `{{groups_folders_mapping}}` ADD CONSTRAINT `{{prefix}}groups_folders_mapping_group_id_fk_groups_id` " +
+		"FOREIGN KEY (`group_id`) REFERENCES `{{groups}}` (`id`) ON DELETE CASCADE;" +
+		"CREATE INDEX `{{prefix}}groups_updated_at_idx` ON `{{groups}}` (`updated_at`);"
+	mysqlV17DownSQL = "ALTER TABLE `{{groups_folders_mapping}}` DROP FOREIGN KEY `{{prefix}}groups_folders_mapping_group_id_fk_groups_id`;" +
+		"ALTER TABLE `{{groups_folders_mapping}}` DROP FOREIGN KEY `{{prefix}}groups_folders_mapping_folder_id_fk_folders_id`;" +
+		"ALTER TABLE `{{users_groups_mapping}}` DROP FOREIGN KEY `{{prefix}}users_groups_mapping_user_id_fk_users_id`;" +
+		"ALTER TABLE `{{users_groups_mapping}}` DROP FOREIGN KEY `{{prefix}}users_groups_mapping_group_id_fk_groups_id`;" +
+		"ALTER TABLE `{{groups_folders_mapping}}` DROP INDEX `{{prefix}}unique_group_folder_mapping`;" +
+		"ALTER TABLE `{{users_groups_mapping}}` DROP INDEX `{{prefix}}unique_user_group_mapping`;" +
+		"DROP TABLE `{{users_groups_mapping}}` CASCADE;" +
+		"DROP TABLE `{{groups_folders_mapping}}` CASCADE;" +
+		"DROP TABLE `{{groups}}` CASCADE;" +
+		"ALTER TABLE `{{users_folders_mapping}}` DROP FOREIGN KEY `{{prefix}}users_folders_mapping_folder_id_fk_folders_id`;" +
+		"ALTER TABLE `{{users_folders_mapping}}` DROP FOREIGN KEY `{{prefix}}users_folders_mapping_user_id_fk_users_id`;" +
+		"ALTER TABLE `{{users_folders_mapping}}` DROP INDEX `{{prefix}}unique_user_folder_mapping`;" +
+		"RENAME TABLE `{{users_folders_mapping}}` TO `{{folders_mapping}}`;" +
+		"ALTER TABLE `{{folders_mapping}}` ADD CONSTRAINT `{{prefix}}unique_mapping` UNIQUE (`user_id`, `folder_id`);" +
+		"ALTER TABLE `{{folders_mapping}}` ADD CONSTRAINT `{{prefix}}folders_mapping_user_id_fk_users_id` " +
+		"FOREIGN KEY (`user_id`) REFERENCES `{{users}}` (`id`) ON DELETE CASCADE;" +
+		"ALTER TABLE `{{folders_mapping}}` ADD CONSTRAINT `{{prefix}}folders_mapping_folder_id_fk_folders_id` " +
+		"FOREIGN KEY (`folder_id`) REFERENCES `{{folders}}` (`id`) ON DELETE CASCADE;"
 )
 )
 
 
 // MySQLProvider defines the auth provider for MySQL/MariaDB database
 // MySQLProvider defines the auth provider for MySQL/MariaDB database
@@ -243,7 +294,7 @@ func (p *MySQLProvider) updateUser(user *User) error {
 	return sqlCommonUpdateUser(user, p.dbHandle)
 	return sqlCommonUpdateUser(user, p.dbHandle)
 }
 }
 
 
-func (p *MySQLProvider) deleteUser(user *User) error {
+func (p *MySQLProvider) deleteUser(user User) error {
 	return sqlCommonDeleteUser(user, p.dbHandle)
 	return sqlCommonDeleteUser(user, p.dbHandle)
 }
 }
 
 
@@ -271,8 +322,8 @@ func (p *MySQLProvider) dumpFolders() ([]vfs.BaseVirtualFolder, error) {
 	return sqlCommonDumpFolders(p.dbHandle)
 	return sqlCommonDumpFolders(p.dbHandle)
 }
 }
 
 
-func (p *MySQLProvider) getFolders(limit, offset int, order string) ([]vfs.BaseVirtualFolder, error) {
-	return sqlCommonGetFolders(limit, offset, order, p.dbHandle)
+func (p *MySQLProvider) getFolders(limit, offset int, order string, minimal bool) ([]vfs.BaseVirtualFolder, error) {
+	return sqlCommonGetFolders(limit, offset, order, minimal, p.dbHandle)
 }
 }
 
 
 func (p *MySQLProvider) getFolderByName(name string) (vfs.BaseVirtualFolder, error) {
 func (p *MySQLProvider) getFolderByName(name string) (vfs.BaseVirtualFolder, error) {
@@ -289,7 +340,7 @@ func (p *MySQLProvider) updateFolder(folder *vfs.BaseVirtualFolder) error {
 	return sqlCommonUpdateFolder(folder, p.dbHandle)
 	return sqlCommonUpdateFolder(folder, p.dbHandle)
 }
 }
 
 
-func (p *MySQLProvider) deleteFolder(folder *vfs.BaseVirtualFolder) error {
+func (p *MySQLProvider) deleteFolder(folder vfs.BaseVirtualFolder) error {
 	return sqlCommonDeleteFolder(folder, p.dbHandle)
 	return sqlCommonDeleteFolder(folder, p.dbHandle)
 }
 }
 
 
@@ -301,6 +352,38 @@ func (p *MySQLProvider) getUsedFolderQuota(name string) (int, int64, error) {
 	return sqlCommonGetFolderUsedQuota(name, p.dbHandle)
 	return sqlCommonGetFolderUsedQuota(name, p.dbHandle)
 }
 }
 
 
+func (p *MySQLProvider) getGroups(limit, offset int, order string, minimal bool) ([]Group, error) {
+	return sqlCommonGetGroups(limit, offset, order, minimal, p.dbHandle)
+}
+
+func (p *MySQLProvider) getGroupsWithNames(names []string) ([]Group, error) {
+	return sqlCommonGetGroupsWithNames(names, p.dbHandle)
+}
+
+func (p *MySQLProvider) getUsersInGroups(names []string) ([]string, error) {
+	return sqlCommonGetUsersInGroups(names, p.dbHandle)
+}
+
+func (p *MySQLProvider) groupExists(name string) (Group, error) {
+	return sqlCommonGetGroupByName(name, p.dbHandle)
+}
+
+func (p *MySQLProvider) addGroup(group *Group) error {
+	return sqlCommonAddGroup(group, p.dbHandle)
+}
+
+func (p *MySQLProvider) updateGroup(group *Group) error {
+	return sqlCommonUpdateGroup(group, p.dbHandle)
+}
+
+func (p *MySQLProvider) deleteGroup(group Group) error {
+	return sqlCommonDeleteGroup(group, p.dbHandle)
+}
+
+func (p *MySQLProvider) dumpGroups() ([]Group, error) {
+	return sqlCommonDumpGroups(p.dbHandle)
+}
+
 func (p *MySQLProvider) adminExists(username string) (Admin, error) {
 func (p *MySQLProvider) adminExists(username string) (Admin, error) {
 	return sqlCommonGetAdminByUsername(username, p.dbHandle)
 	return sqlCommonGetAdminByUsername(username, p.dbHandle)
 }
 }
@@ -313,7 +396,7 @@ func (p *MySQLProvider) updateAdmin(admin *Admin) error {
 	return sqlCommonUpdateAdmin(admin, p.dbHandle)
 	return sqlCommonUpdateAdmin(admin, p.dbHandle)
 }
 }
 
 
-func (p *MySQLProvider) deleteAdmin(admin *Admin) error {
+func (p *MySQLProvider) deleteAdmin(admin Admin) error {
 	return sqlCommonDeleteAdmin(admin, p.dbHandle)
 	return sqlCommonDeleteAdmin(admin, p.dbHandle)
 }
 }
 
 
@@ -341,7 +424,7 @@ func (p *MySQLProvider) updateAPIKey(apiKey *APIKey) error {
 	return sqlCommonUpdateAPIKey(apiKey, p.dbHandle)
 	return sqlCommonUpdateAPIKey(apiKey, p.dbHandle)
 }
 }
 
 
-func (p *MySQLProvider) deleteAPIKey(apiKey *APIKey) error {
+func (p *MySQLProvider) deleteAPIKey(apiKey APIKey) error {
 	return sqlCommonDeleteAPIKey(apiKey, p.dbHandle)
 	return sqlCommonDeleteAPIKey(apiKey, p.dbHandle)
 }
 }
 
 
@@ -369,7 +452,7 @@ func (p *MySQLProvider) updateShare(share *Share) error {
 	return sqlCommonUpdateShare(share, p.dbHandle)
 	return sqlCommonUpdateShare(share, p.dbHandle)
 }
 }
 
 
-func (p *MySQLProvider) deleteShare(share *Share) error {
+func (p *MySQLProvider) deleteShare(share Share) error {
 	return sqlCommonDeleteShare(share, p.dbHandle)
 	return sqlCommonDeleteShare(share, p.dbHandle)
 }
 }
 
 
@@ -487,6 +570,8 @@ func (p *MySQLProvider) migrateDatabase() error {
 		return err
 		return err
 	case version == 15:
 	case version == 15:
 		return updateMySQLDatabaseFromV15(p.dbHandle)
 		return updateMySQLDatabaseFromV15(p.dbHandle)
+	case version == 16:
+		return updateMySQLDatabaseFromV16(p.dbHandle)
 	default:
 	default:
 		if version > sqlDatabaseVersion {
 		if version > sqlDatabaseVersion {
 			providerLog(logger.LevelError, "database version %v is newer than the supported one: %v", version,
 			providerLog(logger.LevelError, "database version %v is newer than the supported one: %v", version,
@@ -511,27 +596,34 @@ func (p *MySQLProvider) revertDatabase(targetVersion int) error {
 	switch dbVersion.Version {
 	switch dbVersion.Version {
 	case 16:
 	case 16:
 		return downgradeMySQLDatabaseFromV16(p.dbHandle)
 		return downgradeMySQLDatabaseFromV16(p.dbHandle)
+	case 17:
+		return downgradeMySQLDatabaseFromV17(p.dbHandle)
 	default:
 	default:
 		return fmt.Errorf("database version not handled: %v", dbVersion.Version)
 		return fmt.Errorf("database version not handled: %v", dbVersion.Version)
 	}
 	}
 }
 }
 
 
 func (p *MySQLProvider) resetDatabase() error {
 func (p *MySQLProvider) resetDatabase() error {
-	sql := strings.ReplaceAll(mysqlResetSQL, "{{schema_version}}", sqlTableSchemaVersion)
-	sql = strings.ReplaceAll(sql, "{{admins}}", sqlTableAdmins)
-	sql = strings.ReplaceAll(sql, "{{folders}}", sqlTableFolders)
-	sql = strings.ReplaceAll(sql, "{{users}}", sqlTableUsers)
-	sql = strings.ReplaceAll(sql, "{{folders_mapping}}", sqlTableFoldersMapping)
-	sql = strings.ReplaceAll(sql, "{{api_keys}}", sqlTableAPIKeys)
-	sql = strings.ReplaceAll(sql, "{{shares}}", sqlTableShares)
-	sql = strings.ReplaceAll(sql, "{{defender_events}}", sqlTableDefenderEvents)
-	sql = strings.ReplaceAll(sql, "{{defender_hosts}}", sqlTableDefenderHosts)
-	sql = strings.ReplaceAll(sql, "{{active_transfers}}", sqlTableActiveTransfers)
+	sql := sqlReplaceAll(mysqlResetSQL)
 	return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, strings.Split(sql, ";"), 0)
 	return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, strings.Split(sql, ";"), 0)
 }
 }
 
 
 func updateMySQLDatabaseFromV15(dbHandle *sql.DB) error {
 func updateMySQLDatabaseFromV15(dbHandle *sql.DB) error {
-	return updateMySQLDatabaseFrom15To16(dbHandle)
+	if err := updateMySQLDatabaseFrom15To16(dbHandle); err != nil {
+		return err
+	}
+	return updateMySQLDatabaseFromV16(dbHandle)
+}
+
+func updateMySQLDatabaseFromV16(dbHandle *sql.DB) error {
+	return updateMySQLDatabaseFrom16To17(dbHandle)
+}
+
+func downgradeMySQLDatabaseFromV17(dbHandle *sql.DB) error {
+	if err := downgradeMySQLDatabaseFrom17To16(dbHandle); err != nil {
+		return err
+	}
+	return downgradeMySQLDatabaseFromV16(dbHandle)
 }
 }
 
 
 func downgradeMySQLDatabaseFromV16(dbHandle *sql.DB) error {
 func downgradeMySQLDatabaseFromV16(dbHandle *sql.DB) error {
@@ -547,6 +639,20 @@ func updateMySQLDatabaseFrom15To16(dbHandle *sql.DB) error {
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 16)
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 16)
 }
 }
 
 
+func updateMySQLDatabaseFrom16To17(dbHandle *sql.DB) error {
+	logger.InfoToConsole("updating database version: 16 -> 17")
+	providerLog(logger.LevelInfo, "updating database version: 16 -> 17")
+	sql := strings.ReplaceAll(mysqlV17SQL, "{{users}}", sqlTableUsers)
+	sql = strings.ReplaceAll(sql, "{{groups}}", sqlTableGroups)
+	sql = strings.ReplaceAll(sql, "{{folders}}", sqlTableFolders)
+	sql = strings.ReplaceAll(sql, "{{folders_mapping}}", sqlTableFoldersMapping)
+	sql = strings.ReplaceAll(sql, "{{users_folders_mapping}}", sqlTableUsersFoldersMapping)
+	sql = strings.ReplaceAll(sql, "{{users_groups_mapping}}", sqlTableUsersGroupsMapping)
+	sql = strings.ReplaceAll(sql, "{{groups_folders_mapping}}", sqlTableGroupsFoldersMapping)
+	sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 17)
+}
+
 func downgradeMySQLDatabaseFrom16To15(dbHandle *sql.DB) error {
 func downgradeMySQLDatabaseFrom16To15(dbHandle *sql.DB) error {
 	logger.InfoToConsole("downgrading database version: 16 -> 15")
 	logger.InfoToConsole("downgrading database version: 16 -> 15")
 	providerLog(logger.LevelInfo, "downgrading database version: 16 -> 15")
 	providerLog(logger.LevelInfo, "downgrading database version: 16 -> 15")
@@ -554,3 +660,17 @@ func downgradeMySQLDatabaseFrom16To15(dbHandle *sql.DB) error {
 	sql = strings.ReplaceAll(sql, "{{active_transfers}}", sqlTableActiveTransfers)
 	sql = strings.ReplaceAll(sql, "{{active_transfers}}", sqlTableActiveTransfers)
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 15)
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 15)
 }
 }
+
+func downgradeMySQLDatabaseFrom17To16(dbHandle *sql.DB) error {
+	logger.InfoToConsole("downgrading database version: 17 -> 16")
+	providerLog(logger.LevelInfo, "downgrading database version: 17 -> 16")
+	sql := strings.ReplaceAll(mysqlV17DownSQL, "{{users}}", sqlTableUsers)
+	sql = strings.ReplaceAll(sql, "{{groups}}", sqlTableGroups)
+	sql = strings.ReplaceAll(sql, "{{folders}}", sqlTableFolders)
+	sql = strings.ReplaceAll(sql, "{{folders_mapping}}", sqlTableFoldersMapping)
+	sql = strings.ReplaceAll(sql, "{{users_folders_mapping}}", sqlTableUsersFoldersMapping)
+	sql = strings.ReplaceAll(sql, "{{users_groups_mapping}}", sqlTableUsersGroupsMapping)
+	sql = strings.ReplaceAll(sql, "{{groups_folders_mapping}}", sqlTableGroupsFoldersMapping)
+	sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 16)
+}

+ 141 - 18
dataprovider/pgsql.go

@@ -23,10 +23,14 @@ import (
 const (
 const (
 	pgsqlResetSQL = `DROP TABLE IF EXISTS "{{api_keys}}" CASCADE;
 	pgsqlResetSQL = `DROP TABLE IF EXISTS "{{api_keys}}" CASCADE;
 DROP TABLE IF EXISTS "{{folders_mapping}}" CASCADE;
 DROP TABLE IF EXISTS "{{folders_mapping}}" CASCADE;
+DROP TABLE IF EXISTS "{{users_folders_mapping}}" CASCADE;
+DROP TABLE IF EXISTS "{{users_groups_mapping}}" CASCADE;
+DROP TABLE IF EXISTS "{{groups_folders_mapping}}" CASCADE;
 DROP TABLE IF EXISTS "{{admins}}" CASCADE;
 DROP TABLE IF EXISTS "{{admins}}" CASCADE;
 DROP TABLE IF EXISTS "{{folders}}" CASCADE;
 DROP TABLE IF EXISTS "{{folders}}" CASCADE;
 DROP TABLE IF EXISTS "{{shares}}" CASCADE;
 DROP TABLE IF EXISTS "{{shares}}" CASCADE;
 DROP TABLE IF EXISTS "{{users}}" CASCADE;
 DROP TABLE IF EXISTS "{{users}}" CASCADE;
+DROP TABLE IF EXISTS "{{groups}}" CASCADE;
 DROP TABLE IF EXISTS "{{defender_events}}" CASCADE;
 DROP TABLE IF EXISTS "{{defender_events}}" CASCADE;
 DROP TABLE IF EXISTS "{{defender_hosts}}" CASCADE;
 DROP TABLE IF EXISTS "{{defender_hosts}}" CASCADE;
 DROP TABLE IF EXISTS "{{active_transfers}}" CASCADE;
 DROP TABLE IF EXISTS "{{active_transfers}}" CASCADE;
@@ -113,6 +117,46 @@ ALTER TABLE "{{users}}" DROP COLUMN "upload_data_transfer" CASCADE;
 ALTER TABLE "{{users}}" DROP COLUMN "total_data_transfer" CASCADE;
 ALTER TABLE "{{users}}" DROP COLUMN "total_data_transfer" CASCADE;
 ALTER TABLE "{{users}}" DROP COLUMN "download_data_transfer" CASCADE;
 ALTER TABLE "{{users}}" DROP COLUMN "download_data_transfer" CASCADE;
 DROP TABLE "{{active_transfers}}" CASCADE;
 DROP TABLE "{{active_transfers}}" CASCADE;
+`
+	pgsqlV17SQL = `CREATE TABLE "{{groups}}" ("id" serial NOT NULL PRIMARY KEY, "name" varchar(255) NOT NULL UNIQUE,
+"description" varchar(512) NULL, "created_at" bigint NOT NULL, "updated_at" bigint NOT NULL, "user_settings" text NULL);
+CREATE TABLE "{{groups_folders_mapping}}" ("id" serial NOT NULL PRIMARY KEY, "group_id" integer NOT NULL,
+"folder_id" integer NOT NULL, "virtual_path" text NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL);
+CREATE TABLE "{{users_groups_mapping}}" ("id" serial NOT NULL PRIMARY KEY, "user_id" integer NOT NULL,
+"group_id" integer NOT NULL, "group_type" integer NOT NULL);
+DROP INDEX "{{prefix}}folders_mapping_folder_id_idx";
+DROP INDEX "{{prefix}}folders_mapping_user_id_idx";
+ALTER TABLE "{{folders_mapping}}" DROP CONSTRAINT "{{prefix}}unique_mapping";
+ALTER TABLE "{{folders_mapping}}" RENAME TO "{{users_folders_mapping}}";
+ALTER TABLE "{{users_folders_mapping}}" ADD CONSTRAINT "{{prefix}}unique_user_folder_mapping" UNIQUE ("user_id", "folder_id");
+CREATE INDEX "{{prefix}}users_folders_mapping_folder_id_idx" ON "{{users_folders_mapping}}" ("folder_id");
+CREATE INDEX "{{prefix}}users_folders_mapping_user_id_idx" ON "{{users_folders_mapping}}" ("user_id");
+ALTER TABLE "{{users_groups_mapping}}" ADD CONSTRAINT "{{prefix}}unique_user_group_mapping" UNIQUE ("user_id", "group_id");
+ALTER TABLE "{{groups_folders_mapping}}" ADD CONSTRAINT "{{prefix}}unique_group_folder_mapping" UNIQUE ("group_id", "folder_id");
+CREATE INDEX "{{prefix}}users_groups_mapping_group_id_idx" ON "{{users_groups_mapping}}" ("group_id");
+ALTER TABLE "{{users_groups_mapping}}" ADD CONSTRAINT "{{prefix}}users_groups_mapping_group_id_fk_groups_id"
+FOREIGN KEY ("group_id") REFERENCES "{{groups}}" ("id") MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION;
+CREATE INDEX "{{prefix}}users_groups_mapping_user_id_idx" ON "{{users_groups_mapping}}" ("user_id");
+ALTER TABLE "{{users_groups_mapping}}" ADD CONSTRAINT "{{prefix}}users_groups_mapping_user_id_fk_users_id"
+FOREIGN KEY ("user_id") REFERENCES "{{users}}" ("id") MATCH SIMPLE ON UPDATE NO ACTION ON DELETE CASCADE;
+CREATE INDEX "{{prefix}}groups_folders_mapping_folder_id_idx" ON "{{groups_folders_mapping}}" ("folder_id");
+ALTER TABLE "{{groups_folders_mapping}}" ADD CONSTRAINT "{{prefix}}groups_folders_mapping_folder_id_fk_folders_id"
+FOREIGN KEY ("folder_id") REFERENCES "{{folders}}" ("id") MATCH SIMPLE ON UPDATE NO ACTION ON DELETE CASCADE;
+CREATE INDEX "{{prefix}}groups_folders_mapping_group_id_idx" ON "{{groups_folders_mapping}}" ("group_id");
+ALTER TABLE "{{groups_folders_mapping}}" ADD CONSTRAINT "{{prefix}}groups_folders_mapping_group_id_fk_groups_id"
+FOREIGN KEY ("group_id") REFERENCES "groups" ("id") MATCH SIMPLE ON UPDATE NO ACTION ON DELETE CASCADE;
+CREATE INDEX "{{prefix}}groups_updated_at_idx" ON "{{groups}}" ("updated_at");
+`
+	pgsqlV17DownSQL = `DROP TABLE "{{users_groups_mapping}}" CASCADE;
+DROP TABLE "{{groups_folders_mapping}}" CASCADE;
+DROP TABLE "{{groups}}" CASCADE;
+DROP INDEX "{{prefix}}users_folders_mapping_folder_id_idx";
+DROP INDEX "{{prefix}}users_folders_mapping_user_id_idx";
+ALTER TABLE "{{users_folders_mapping}}" DROP CONSTRAINT "{{prefix}}unique_user_folder_mapping";
+ALTER TABLE "{{users_folders_mapping}}" RENAME TO "{{folders_mapping}}";
+ALTER TABLE "{{folders_mapping}}" ADD CONSTRAINT "{{prefix}}unique_mapping" UNIQUE ("user_id", "folder_id");
+CREATE INDEX "{{prefix}}folders_mapping_folder_id_idx" ON "{{folders_mapping}}" ("folder_id");
+CREATE INDEX "{{prefix}}folders_mapping_user_id_idx" ON "{{folders_mapping}}" ("user_id");
 `
 `
 )
 )
 
 
@@ -219,7 +263,7 @@ func (p *PGSQLProvider) updateUser(user *User) error {
 	return sqlCommonUpdateUser(user, p.dbHandle)
 	return sqlCommonUpdateUser(user, p.dbHandle)
 }
 }
 
 
-func (p *PGSQLProvider) deleteUser(user *User) error {
+func (p *PGSQLProvider) deleteUser(user User) error {
 	return sqlCommonDeleteUser(user, p.dbHandle)
 	return sqlCommonDeleteUser(user, p.dbHandle)
 }
 }
 
 
@@ -247,8 +291,8 @@ func (p *PGSQLProvider) dumpFolders() ([]vfs.BaseVirtualFolder, error) {
 	return sqlCommonDumpFolders(p.dbHandle)
 	return sqlCommonDumpFolders(p.dbHandle)
 }
 }
 
 
-func (p *PGSQLProvider) getFolders(limit, offset int, order string) ([]vfs.BaseVirtualFolder, error) {
-	return sqlCommonGetFolders(limit, offset, order, p.dbHandle)
+func (p *PGSQLProvider) getFolders(limit, offset int, order string, minimal bool) ([]vfs.BaseVirtualFolder, error) {
+	return sqlCommonGetFolders(limit, offset, order, minimal, p.dbHandle)
 }
 }
 
 
 func (p *PGSQLProvider) getFolderByName(name string) (vfs.BaseVirtualFolder, error) {
 func (p *PGSQLProvider) getFolderByName(name string) (vfs.BaseVirtualFolder, error) {
@@ -265,7 +309,7 @@ func (p *PGSQLProvider) updateFolder(folder *vfs.BaseVirtualFolder) error {
 	return sqlCommonUpdateFolder(folder, p.dbHandle)
 	return sqlCommonUpdateFolder(folder, p.dbHandle)
 }
 }
 
 
-func (p *PGSQLProvider) deleteFolder(folder *vfs.BaseVirtualFolder) error {
+func (p *PGSQLProvider) deleteFolder(folder vfs.BaseVirtualFolder) error {
 	return sqlCommonDeleteFolder(folder, p.dbHandle)
 	return sqlCommonDeleteFolder(folder, p.dbHandle)
 }
 }
 
 
@@ -277,6 +321,38 @@ func (p *PGSQLProvider) getUsedFolderQuota(name string) (int, int64, error) {
 	return sqlCommonGetFolderUsedQuota(name, p.dbHandle)
 	return sqlCommonGetFolderUsedQuota(name, p.dbHandle)
 }
 }
 
 
+func (p *PGSQLProvider) getGroups(limit, offset int, order string, minimal bool) ([]Group, error) {
+	return sqlCommonGetGroups(limit, offset, order, minimal, p.dbHandle)
+}
+
+func (p *PGSQLProvider) getGroupsWithNames(names []string) ([]Group, error) {
+	return sqlCommonGetGroupsWithNames(names, p.dbHandle)
+}
+
+func (p *PGSQLProvider) getUsersInGroups(names []string) ([]string, error) {
+	return sqlCommonGetUsersInGroups(names, p.dbHandle)
+}
+
+func (p *PGSQLProvider) groupExists(name string) (Group, error) {
+	return sqlCommonGetGroupByName(name, p.dbHandle)
+}
+
+func (p *PGSQLProvider) addGroup(group *Group) error {
+	return sqlCommonAddGroup(group, p.dbHandle)
+}
+
+func (p *PGSQLProvider) updateGroup(group *Group) error {
+	return sqlCommonUpdateGroup(group, p.dbHandle)
+}
+
+func (p *PGSQLProvider) deleteGroup(group Group) error {
+	return sqlCommonDeleteGroup(group, p.dbHandle)
+}
+
+func (p *PGSQLProvider) dumpGroups() ([]Group, error) {
+	return sqlCommonDumpGroups(p.dbHandle)
+}
+
 func (p *PGSQLProvider) adminExists(username string) (Admin, error) {
 func (p *PGSQLProvider) adminExists(username string) (Admin, error) {
 	return sqlCommonGetAdminByUsername(username, p.dbHandle)
 	return sqlCommonGetAdminByUsername(username, p.dbHandle)
 }
 }
@@ -289,7 +365,7 @@ func (p *PGSQLProvider) updateAdmin(admin *Admin) error {
 	return sqlCommonUpdateAdmin(admin, p.dbHandle)
 	return sqlCommonUpdateAdmin(admin, p.dbHandle)
 }
 }
 
 
-func (p *PGSQLProvider) deleteAdmin(admin *Admin) error {
+func (p *PGSQLProvider) deleteAdmin(admin Admin) error {
 	return sqlCommonDeleteAdmin(admin, p.dbHandle)
 	return sqlCommonDeleteAdmin(admin, p.dbHandle)
 }
 }
 
 
@@ -317,7 +393,7 @@ func (p *PGSQLProvider) updateAPIKey(apiKey *APIKey) error {
 	return sqlCommonUpdateAPIKey(apiKey, p.dbHandle)
 	return sqlCommonUpdateAPIKey(apiKey, p.dbHandle)
 }
 }
 
 
-func (p *PGSQLProvider) deleteAPIKey(apiKey *APIKey) error {
+func (p *PGSQLProvider) deleteAPIKey(apiKey APIKey) error {
 	return sqlCommonDeleteAPIKey(apiKey, p.dbHandle)
 	return sqlCommonDeleteAPIKey(apiKey, p.dbHandle)
 }
 }
 
 
@@ -345,7 +421,7 @@ func (p *PGSQLProvider) updateShare(share *Share) error {
 	return sqlCommonUpdateShare(share, p.dbHandle)
 	return sqlCommonUpdateShare(share, p.dbHandle)
 }
 }
 
 
-func (p *PGSQLProvider) deleteShare(share *Share) error {
+func (p *PGSQLProvider) deleteShare(share Share) error {
 	return sqlCommonDeleteShare(share, p.dbHandle)
 	return sqlCommonDeleteShare(share, p.dbHandle)
 }
 }
 
 
@@ -469,6 +545,8 @@ func (p *PGSQLProvider) migrateDatabase() error {
 		return err
 		return err
 	case version == 15:
 	case version == 15:
 		return updatePGSQLDatabaseFromV15(p.dbHandle)
 		return updatePGSQLDatabaseFromV15(p.dbHandle)
+	case version == 16:
+		return updatePGSQLDatabaseFromV16(p.dbHandle)
 	default:
 	default:
 		if version > sqlDatabaseVersion {
 		if version > sqlDatabaseVersion {
 			providerLog(logger.LevelError, "database version %v is newer than the supported one: %v", version,
 			providerLog(logger.LevelError, "database version %v is newer than the supported one: %v", version,
@@ -493,27 +571,34 @@ func (p *PGSQLProvider) revertDatabase(targetVersion int) error {
 	switch dbVersion.Version {
 	switch dbVersion.Version {
 	case 16:
 	case 16:
 		return downgradePGSQLDatabaseFromV16(p.dbHandle)
 		return downgradePGSQLDatabaseFromV16(p.dbHandle)
+	case 17:
+		return downgradePGSQLDatabaseFromV17(p.dbHandle)
 	default:
 	default:
 		return fmt.Errorf("database version not handled: %v", dbVersion.Version)
 		return fmt.Errorf("database version not handled: %v", dbVersion.Version)
 	}
 	}
 }
 }
 
 
 func (p *PGSQLProvider) resetDatabase() error {
 func (p *PGSQLProvider) resetDatabase() error {
-	sql := strings.ReplaceAll(pgsqlResetSQL, "{{schema_version}}", sqlTableSchemaVersion)
-	sql = strings.ReplaceAll(sql, "{{admins}}", sqlTableAdmins)
-	sql = strings.ReplaceAll(sql, "{{folders}}", sqlTableFolders)
-	sql = strings.ReplaceAll(sql, "{{users}}", sqlTableUsers)
-	sql = strings.ReplaceAll(sql, "{{folders_mapping}}", sqlTableFoldersMapping)
-	sql = strings.ReplaceAll(sql, "{{api_keys}}", sqlTableAPIKeys)
-	sql = strings.ReplaceAll(sql, "{{shares}}", sqlTableShares)
-	sql = strings.ReplaceAll(sql, "{{defender_events}}", sqlTableDefenderEvents)
-	sql = strings.ReplaceAll(sql, "{{defender_hosts}}", sqlTableDefenderHosts)
-	sql = strings.ReplaceAll(sql, "{{active_transfers}}", sqlTableActiveTransfers)
+	sql := sqlReplaceAll(pgsqlResetSQL)
 	return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, []string{sql}, 0)
 	return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, []string{sql}, 0)
 }
 }
 
 
 func updatePGSQLDatabaseFromV15(dbHandle *sql.DB) error {
 func updatePGSQLDatabaseFromV15(dbHandle *sql.DB) error {
-	return updatePGSQLDatabaseFrom15To16(dbHandle)
+	if err := updatePGSQLDatabaseFrom15To16(dbHandle); err != nil {
+		return err
+	}
+	return updatePGSQLDatabaseFromV16(dbHandle)
+}
+
+func updatePGSQLDatabaseFromV16(dbHandle *sql.DB) error {
+	return updatePGSQLDatabaseFrom16To17(dbHandle)
+}
+
+func downgradePGSQLDatabaseFromV17(dbHandle *sql.DB) error {
+	if err := downgradePGSQLDatabaseFrom17To16(dbHandle); err != nil {
+		return err
+	}
+	return downgradePGSQLDatabaseFromV16(dbHandle)
 }
 }
 
 
 func downgradePGSQLDatabaseFromV16(dbHandle *sql.DB) error {
 func downgradePGSQLDatabaseFromV16(dbHandle *sql.DB) error {
@@ -529,6 +614,25 @@ func updatePGSQLDatabaseFrom15To16(dbHandle *sql.DB) error {
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 16)
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 16)
 }
 }
 
 
+func updatePGSQLDatabaseFrom16To17(dbHandle *sql.DB) error {
+	logger.InfoToConsole("updating database version: 16 -> 17")
+	providerLog(logger.LevelInfo, "updating database version: 16 -> 17")
+	sql := pgsqlV17SQL
+	if config.Driver == CockroachDataProviderName {
+		sql = strings.ReplaceAll(sql, `ALTER TABLE "{{folders_mapping}}" DROP CONSTRAINT "{{prefix}}unique_mapping";`,
+			`DROP INDEX "{{prefix}}unique_mapping" CASCADE;`)
+	}
+	sql = strings.ReplaceAll(sql, "{{groups}}", sqlTableGroups)
+	sql = strings.ReplaceAll(sql, "{{users}}", sqlTableUsers)
+	sql = strings.ReplaceAll(sql, "{{folders}}", sqlTableFolders)
+	sql = strings.ReplaceAll(sql, "{{folders_mapping}}", sqlTableFoldersMapping)
+	sql = strings.ReplaceAll(sql, "{{users_folders_mapping}}", sqlTableUsersFoldersMapping)
+	sql = strings.ReplaceAll(sql, "{{users_groups_mapping}}", sqlTableUsersGroupsMapping)
+	sql = strings.ReplaceAll(sql, "{{groups_folders_mapping}}", sqlTableGroupsFoldersMapping)
+	sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 17)
+}
+
 func downgradePGSQLDatabaseFrom16To15(dbHandle *sql.DB) error {
 func downgradePGSQLDatabaseFrom16To15(dbHandle *sql.DB) error {
 	logger.InfoToConsole("downgrading database version: 16 -> 15")
 	logger.InfoToConsole("downgrading database version: 16 -> 15")
 	providerLog(logger.LevelInfo, "downgrading database version: 16 -> 15")
 	providerLog(logger.LevelInfo, "downgrading database version: 16 -> 15")
@@ -536,3 +640,22 @@ func downgradePGSQLDatabaseFrom16To15(dbHandle *sql.DB) error {
 	sql = strings.ReplaceAll(sql, "{{active_transfers}}", sqlTableActiveTransfers)
 	sql = strings.ReplaceAll(sql, "{{active_transfers}}", sqlTableActiveTransfers)
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 15)
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 15)
 }
 }
+
+func downgradePGSQLDatabaseFrom17To16(dbHandle *sql.DB) error {
+	logger.InfoToConsole("downgrading database version: 17 -> 16")
+	providerLog(logger.LevelInfo, "downgrading database version: 17 -> 16")
+	sql := pgsqlV17DownSQL
+	if config.Driver == CockroachDataProviderName {
+		sql = strings.ReplaceAll(sql, `ALTER TABLE "{{users_folders_mapping}}" DROP CONSTRAINT "{{prefix}}unique_user_folder_mapping";`,
+			`DROP INDEX "{{prefix}}unique_user_folder_mapping" CASCADE;`)
+	}
+	sql = strings.ReplaceAll(sql, "{{groups}}", sqlTableGroups)
+	sql = strings.ReplaceAll(sql, "{{users}}", sqlTableUsers)
+	sql = strings.ReplaceAll(sql, "{{folders}}", sqlTableFolders)
+	sql = strings.ReplaceAll(sql, "{{folders_mapping}}", sqlTableFoldersMapping)
+	sql = strings.ReplaceAll(sql, "{{users_folders_mapping}}", sqlTableUsersFoldersMapping)
+	sql = strings.ReplaceAll(sql, "{{users_groups_mapping}}", sqlTableUsersGroupsMapping)
+	sql = strings.ReplaceAll(sql, "{{groups_folders_mapping}}", sqlTableGroupsFoldersMapping)
+	sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 16)
+}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 746 - 52
dataprovider/sqlcommon.go


+ 148 - 20
dataprovider/sqlite.go

@@ -25,10 +25,14 @@ import (
 const (
 const (
 	sqliteResetSQL = `DROP TABLE IF EXISTS "{{api_keys}}";
 	sqliteResetSQL = `DROP TABLE IF EXISTS "{{api_keys}}";
 DROP TABLE IF EXISTS "{{folders_mapping}}";
 DROP TABLE IF EXISTS "{{folders_mapping}}";
+DROP TABLE IF EXISTS "{{users_folders_mapping}}";
+DROP TABLE IF EXISTS "{{users_groups_mapping}}";
+DROP TABLE IF EXISTS "{{groups_folders_mapping}}";
 DROP TABLE IF EXISTS "{{admins}}";
 DROP TABLE IF EXISTS "{{admins}}";
 DROP TABLE IF EXISTS "{{folders}}";
 DROP TABLE IF EXISTS "{{folders}}";
 DROP TABLE IF EXISTS "{{shares}}";
 DROP TABLE IF EXISTS "{{shares}}";
 DROP TABLE IF EXISTS "{{users}}";
 DROP TABLE IF EXISTS "{{users}}";
+DROP TABLE IF EXISTS "{{groups}}";
 DROP TABLE IF EXISTS "{{defender_events}}";
 DROP TABLE IF EXISTS "{{defender_events}}";
 DROP TABLE IF EXISTS "{{defender_hosts}}";
 DROP TABLE IF EXISTS "{{defender_hosts}}";
 DROP TABLE IF EXISTS "{{active_transfers}}";
 DROP TABLE IF EXISTS "{{active_transfers}}";
@@ -101,6 +105,49 @@ ALTER TABLE "{{users}}" DROP COLUMN "upload_data_transfer";
 ALTER TABLE "{{users}}" DROP COLUMN "total_data_transfer";
 ALTER TABLE "{{users}}" DROP COLUMN "total_data_transfer";
 ALTER TABLE "{{users}}" DROP COLUMN "download_data_transfer";
 ALTER TABLE "{{users}}" DROP COLUMN "download_data_transfer";
 DROP TABLE "{{active_transfers}}";
 DROP TABLE "{{active_transfers}}";
+`
+	sqliteV17SQL = `CREATE TABLE "{{groups}}" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "name" varchar(255) NOT NULL UNIQUE,
+"description" varchar(512) NULL, "created_at" bigint NOT NULL, "updated_at" bigint NOT NULL, "user_settings" text NULL);
+CREATE TABLE "{{groups_folders_mapping}}" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
+"folder_id" integer NOT NULL REFERENCES "{{folders}}" ("id") ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
+"group_id" integer NOT NULL REFERENCES "{{groups}}" ("id") ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
+"virtual_path" text NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL,
+CONSTRAINT "{{prefix}}unique_group_folder_mapping" UNIQUE ("group_id", "folder_id"));
+CREATE TABLE "{{users_groups_mapping}}" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
+"user_id" integer NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
+"group_id" integer NOT NULL REFERENCES "groups" ("id") ON DELETE NO ACTION,
+"group_type" integer NOT NULL, CONSTRAINT "{{prefix}}unique_user_group_mapping" UNIQUE ("user_id", "group_id"));
+CREATE TABLE "new__folders_mapping" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
+"user_id" integer NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
+"folder_id" integer NOT NULL REFERENCES "folders" ("id") ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
+"virtual_path" text NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL,
+CONSTRAINT "{{prefix}}unique_user_folder_mapping" UNIQUE ("user_id", "folder_id"));
+INSERT INTO "new__folders_mapping" ("id", "virtual_path", "quota_size", "quota_files", "folder_id", "user_id") SELECT "id",
+"virtual_path", "quota_size", "quota_files", "folder_id", "user_id" FROM "{{folders_mapping}}";
+DROP TABLE "{{folders_mapping}}";
+ALTER TABLE "new__folders_mapping" RENAME TO "{{users_folders_mapping}}";
+CREATE INDEX "{{prefix}}groups_updated_at_idx" ON "{{groups}}" ("updated_at");
+CREATE INDEX "{{prefix}}users_folders_mapping_folder_id_idx" ON "{{users_folders_mapping}}" ("folder_id");
+CREATE INDEX "{{prefix}}users_folders_mapping_user_id_idx" ON "{{users_folders_mapping}}" ("user_id");
+CREATE INDEX "{{prefix}}users_groups_mapping_group_id_idx" ON "{{users_groups_mapping}}" ("group_id");
+CREATE INDEX "{{prefix}}users_groups_mapping_user_id_idx" ON "{{users_groups_mapping}}" ("user_id");
+CREATE INDEX "{{prefix}}groups_folders_mapping_folder_id_idx" ON "{{groups_folders_mapping}}" ("folder_id");
+CREATE INDEX "{{prefix}}groups_folders_mapping_group_id_idx" ON "{{groups_folders_mapping}}" ("group_id");
+`
+	sqliteV17DownSQL = `DROP TABLE "{{users_groups_mapping}}";
+DROP TABLE "{{groups_folders_mapping}}";
+DROP TABLE "{{groups}}";
+CREATE TABLE "new__folders_mapping" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
+"user_id" integer NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
+"folder_id" integer NOT NULL REFERENCES "folders" ("id") ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
+"virtual_path" text NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL,
+CONSTRAINT "{{prefix}}unique_folder_mapping" UNIQUE ("user_id", "folder_id"));
+INSERT INTO "new__folders_mapping" ("id", "virtual_path", "quota_size", "quota_files", "folder_id", "user_id") SELECT "id",
+"virtual_path", "quota_size", "quota_files", "folder_id", "user_id" FROM "{{users_folders_mapping}}";
+DROP TABLE "{{users_folders_mapping}}";
+ALTER TABLE "new__folders_mapping" RENAME TO "{{folders_mapping}}";
+CREATE INDEX "{{prefix}}folders_mapping_folder_id_idx" ON "{{folders_mapping}}" ("folder_id");
+CREATE INDEX "{{prefix}}folders_mapping_user_id_idx" ON "{{folders_mapping}}" ("user_id");
 `
 `
 )
 )
 
 
@@ -193,7 +240,7 @@ func (p *SQLiteProvider) updateUser(user *User) error {
 	return sqlCommonUpdateUser(user, p.dbHandle)
 	return sqlCommonUpdateUser(user, p.dbHandle)
 }
 }
 
 
-func (p *SQLiteProvider) deleteUser(user *User) error {
+func (p *SQLiteProvider) deleteUser(user User) error {
 	return sqlCommonDeleteUser(user, p.dbHandle)
 	return sqlCommonDeleteUser(user, p.dbHandle)
 }
 }
 
 
@@ -222,8 +269,8 @@ func (p *SQLiteProvider) dumpFolders() ([]vfs.BaseVirtualFolder, error) {
 	return sqlCommonDumpFolders(p.dbHandle)
 	return sqlCommonDumpFolders(p.dbHandle)
 }
 }
 
 
-func (p *SQLiteProvider) getFolders(limit, offset int, order string) ([]vfs.BaseVirtualFolder, error) {
-	return sqlCommonGetFolders(limit, offset, order, p.dbHandle)
+func (p *SQLiteProvider) getFolders(limit, offset int, order string, minimal bool) ([]vfs.BaseVirtualFolder, error) {
+	return sqlCommonGetFolders(limit, offset, order, minimal, p.dbHandle)
 }
 }
 
 
 func (p *SQLiteProvider) getFolderByName(name string) (vfs.BaseVirtualFolder, error) {
 func (p *SQLiteProvider) getFolderByName(name string) (vfs.BaseVirtualFolder, error) {
@@ -240,7 +287,7 @@ func (p *SQLiteProvider) updateFolder(folder *vfs.BaseVirtualFolder) error {
 	return sqlCommonUpdateFolder(folder, p.dbHandle)
 	return sqlCommonUpdateFolder(folder, p.dbHandle)
 }
 }
 
 
-func (p *SQLiteProvider) deleteFolder(folder *vfs.BaseVirtualFolder) error {
+func (p *SQLiteProvider) deleteFolder(folder vfs.BaseVirtualFolder) error {
 	return sqlCommonDeleteFolder(folder, p.dbHandle)
 	return sqlCommonDeleteFolder(folder, p.dbHandle)
 }
 }
 
 
@@ -252,6 +299,38 @@ func (p *SQLiteProvider) getUsedFolderQuota(name string) (int, int64, error) {
 	return sqlCommonGetFolderUsedQuota(name, p.dbHandle)
 	return sqlCommonGetFolderUsedQuota(name, p.dbHandle)
 }
 }
 
 
+func (p *SQLiteProvider) getGroups(limit, offset int, order string, minimal bool) ([]Group, error) {
+	return sqlCommonGetGroups(limit, offset, order, minimal, p.dbHandle)
+}
+
+func (p *SQLiteProvider) getGroupsWithNames(names []string) ([]Group, error) {
+	return sqlCommonGetGroupsWithNames(names, p.dbHandle)
+}
+
+func (p *SQLiteProvider) getUsersInGroups(names []string) ([]string, error) {
+	return sqlCommonGetUsersInGroups(names, p.dbHandle)
+}
+
+func (p *SQLiteProvider) groupExists(name string) (Group, error) {
+	return sqlCommonGetGroupByName(name, p.dbHandle)
+}
+
+func (p *SQLiteProvider) addGroup(group *Group) error {
+	return sqlCommonAddGroup(group, p.dbHandle)
+}
+
+func (p *SQLiteProvider) updateGroup(group *Group) error {
+	return sqlCommonUpdateGroup(group, p.dbHandle)
+}
+
+func (p *SQLiteProvider) deleteGroup(group Group) error {
+	return sqlCommonDeleteGroup(group, p.dbHandle)
+}
+
+func (p *SQLiteProvider) dumpGroups() ([]Group, error) {
+	return sqlCommonDumpGroups(p.dbHandle)
+}
+
 func (p *SQLiteProvider) adminExists(username string) (Admin, error) {
 func (p *SQLiteProvider) adminExists(username string) (Admin, error) {
 	return sqlCommonGetAdminByUsername(username, p.dbHandle)
 	return sqlCommonGetAdminByUsername(username, p.dbHandle)
 }
 }
@@ -264,7 +343,7 @@ func (p *SQLiteProvider) updateAdmin(admin *Admin) error {
 	return sqlCommonUpdateAdmin(admin, p.dbHandle)
 	return sqlCommonUpdateAdmin(admin, p.dbHandle)
 }
 }
 
 
-func (p *SQLiteProvider) deleteAdmin(admin *Admin) error {
+func (p *SQLiteProvider) deleteAdmin(admin Admin) error {
 	return sqlCommonDeleteAdmin(admin, p.dbHandle)
 	return sqlCommonDeleteAdmin(admin, p.dbHandle)
 }
 }
 
 
@@ -292,7 +371,7 @@ func (p *SQLiteProvider) updateAPIKey(apiKey *APIKey) error {
 	return sqlCommonUpdateAPIKey(apiKey, p.dbHandle)
 	return sqlCommonUpdateAPIKey(apiKey, p.dbHandle)
 }
 }
 
 
-func (p *SQLiteProvider) deleteAPIKey(apiKey *APIKey) error {
+func (p *SQLiteProvider) deleteAPIKey(apiKey APIKey) error {
 	return sqlCommonDeleteAPIKey(apiKey, p.dbHandle)
 	return sqlCommonDeleteAPIKey(apiKey, p.dbHandle)
 }
 }
 
 
@@ -320,7 +399,7 @@ func (p *SQLiteProvider) updateShare(share *Share) error {
 	return sqlCommonUpdateShare(share, p.dbHandle)
 	return sqlCommonUpdateShare(share, p.dbHandle)
 }
 }
 
 
-func (p *SQLiteProvider) deleteShare(share *Share) error {
+func (p *SQLiteProvider) deleteShare(share Share) error {
 	return sqlCommonDeleteShare(share, p.dbHandle)
 	return sqlCommonDeleteShare(share, p.dbHandle)
 }
 }
 
 
@@ -438,6 +517,8 @@ func (p *SQLiteProvider) migrateDatabase() error {
 		return err
 		return err
 	case version == 15:
 	case version == 15:
 		return updateSQLiteDatabaseFromV15(p.dbHandle)
 		return updateSQLiteDatabaseFromV15(p.dbHandle)
+	case version == 16:
+		return updateSQLiteDatabaseFromV16(p.dbHandle)
 	default:
 	default:
 		if version > sqlDatabaseVersion {
 		if version > sqlDatabaseVersion {
 			providerLog(logger.LevelError, "database version %v is newer than the supported one: %v", version,
 			providerLog(logger.LevelError, "database version %v is newer than the supported one: %v", version,
@@ -462,33 +543,40 @@ func (p *SQLiteProvider) revertDatabase(targetVersion int) error {
 	switch dbVersion.Version {
 	switch dbVersion.Version {
 	case 16:
 	case 16:
 		return downgradeSQLiteDatabaseFromV16(p.dbHandle)
 		return downgradeSQLiteDatabaseFromV16(p.dbHandle)
+	case 17:
+		return downgradeSQLiteDatabaseFromV17(p.dbHandle)
 	default:
 	default:
 		return fmt.Errorf("database version not handled: %v", dbVersion.Version)
 		return fmt.Errorf("database version not handled: %v", dbVersion.Version)
 	}
 	}
 }
 }
 
 
 func (p *SQLiteProvider) resetDatabase() error {
 func (p *SQLiteProvider) resetDatabase() error {
-	sql := strings.ReplaceAll(sqliteResetSQL, "{{schema_version}}", sqlTableSchemaVersion)
-	sql = strings.ReplaceAll(sql, "{{admins}}", sqlTableAdmins)
-	sql = strings.ReplaceAll(sql, "{{folders}}", sqlTableFolders)
-	sql = strings.ReplaceAll(sql, "{{users}}", sqlTableUsers)
-	sql = strings.ReplaceAll(sql, "{{folders_mapping}}", sqlTableFoldersMapping)
-	sql = strings.ReplaceAll(sql, "{{api_keys}}", sqlTableAPIKeys)
-	sql = strings.ReplaceAll(sql, "{{shares}}", sqlTableShares)
-	sql = strings.ReplaceAll(sql, "{{defender_events}}", sqlTableDefenderEvents)
-	sql = strings.ReplaceAll(sql, "{{defender_hosts}}", sqlTableDefenderHosts)
-	sql = strings.ReplaceAll(sql, "{{active_transfers}}", sqlTableActiveTransfers)
+	sql := sqlReplaceAll(sqliteResetSQL)
 	return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, []string{sql}, 0)
 	return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, []string{sql}, 0)
 }
 }
 
 
 func updateSQLiteDatabaseFromV15(dbHandle *sql.DB) error {
 func updateSQLiteDatabaseFromV15(dbHandle *sql.DB) error {
-	return updateSQLiteDatabaseFrom15To16(dbHandle)
+	if err := updateSQLiteDatabaseFrom15To16(dbHandle); err != nil {
+		return err
+	}
+	return updateSQLiteDatabaseFromV16(dbHandle)
+}
+
+func updateSQLiteDatabaseFromV16(dbHandle *sql.DB) error {
+	return updateSQLiteDatabaseFrom16To17(dbHandle)
 }
 }
 
 
 func downgradeSQLiteDatabaseFromV16(dbHandle *sql.DB) error {
 func downgradeSQLiteDatabaseFromV16(dbHandle *sql.DB) error {
 	return downgradeSQLiteDatabaseFrom16To15(dbHandle)
 	return downgradeSQLiteDatabaseFrom16To15(dbHandle)
 }
 }
 
 
+func downgradeSQLiteDatabaseFromV17(dbHandle *sql.DB) error {
+	if err := downgradeSQLiteDatabaseFrom17To16(dbHandle); err != nil {
+		return err
+	}
+	return downgradeSQLiteDatabaseFromV16(dbHandle)
+}
+
 func updateSQLiteDatabaseFrom15To16(dbHandle *sql.DB) error {
 func updateSQLiteDatabaseFrom15To16(dbHandle *sql.DB) error {
 	logger.InfoToConsole("updating database version: 15 -> 16")
 	logger.InfoToConsole("updating database version: 15 -> 16")
 	providerLog(logger.LevelInfo, "updating database version: 15 -> 16")
 	providerLog(logger.LevelInfo, "updating database version: 15 -> 16")
@@ -498,6 +586,26 @@ func updateSQLiteDatabaseFrom15To16(dbHandle *sql.DB) error {
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 16)
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 16)
 }
 }
 
 
+func updateSQLiteDatabaseFrom16To17(dbHandle *sql.DB) error {
+	logger.InfoToConsole("updating database version: 16 -> 17")
+	providerLog(logger.LevelInfo, "updating database version: 16 -> 17")
+	if err := setPragmaFK(dbHandle, "OFF"); err != nil {
+		return err
+	}
+	sql := strings.ReplaceAll(sqliteV17SQL, "{{users}}", sqlTableUsers)
+	sql = strings.ReplaceAll(sql, "{{groups}}", sqlTableGroups)
+	sql = strings.ReplaceAll(sql, "{{folders}}", sqlTableFolders)
+	sql = strings.ReplaceAll(sql, "{{folders_mapping}}", sqlTableFoldersMapping)
+	sql = strings.ReplaceAll(sql, "{{users_folders_mapping}}", sqlTableUsersFoldersMapping)
+	sql = strings.ReplaceAll(sql, "{{users_groups_mapping}}", sqlTableUsersGroupsMapping)
+	sql = strings.ReplaceAll(sql, "{{groups_folders_mapping}}", sqlTableGroupsFoldersMapping)
+	sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix)
+	if err := sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 17); err != nil {
+		return err
+	}
+	return setPragmaFK(dbHandle, "ON")
+}
+
 func downgradeSQLiteDatabaseFrom16To15(dbHandle *sql.DB) error {
 func downgradeSQLiteDatabaseFrom16To15(dbHandle *sql.DB) error {
 	logger.InfoToConsole("downgrading database version: 16 -> 15")
 	logger.InfoToConsole("downgrading database version: 16 -> 15")
 	providerLog(logger.LevelInfo, "downgrading database version: 16 -> 15")
 	providerLog(logger.LevelInfo, "downgrading database version: 16 -> 15")
@@ -506,7 +614,27 @@ func downgradeSQLiteDatabaseFrom16To15(dbHandle *sql.DB) error {
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 15)
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 15)
 }
 }
 
 
-/*func setPragmaFK(dbHandle *sql.DB, value string) error {
+func downgradeSQLiteDatabaseFrom17To16(dbHandle *sql.DB) error {
+	logger.InfoToConsole("downgrading database version: 17 -> 16")
+	providerLog(logger.LevelInfo, "downgrading database version: 17 -> 16")
+	if err := setPragmaFK(dbHandle, "OFF"); err != nil {
+		return err
+	}
+	sql := strings.ReplaceAll(sqliteV17DownSQL, "{{groups}}", sqlTableGroups)
+	sql = strings.ReplaceAll(sql, "{{users}}", sqlTableUsers)
+	sql = strings.ReplaceAll(sql, "{{folders}}", sqlTableFolders)
+	sql = strings.ReplaceAll(sql, "{{folders_mapping}}", sqlTableFoldersMapping)
+	sql = strings.ReplaceAll(sql, "{{users_folders_mapping}}", sqlTableUsersFoldersMapping)
+	sql = strings.ReplaceAll(sql, "{{users_groups_mapping}}", sqlTableUsersGroupsMapping)
+	sql = strings.ReplaceAll(sql, "{{groups_folders_mapping}}", sqlTableGroupsFoldersMapping)
+	sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix)
+	if err := sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 16); err != nil {
+		return err
+	}
+	return setPragmaFK(dbHandle, "ON")
+}
+
+func setPragmaFK(dbHandle *sql.DB, value string) error {
 	ctx, cancel := context.WithTimeout(context.Background(), longSQLQueryTimeout)
 	ctx, cancel := context.WithTimeout(context.Background(), longSQLQueryTimeout)
 	defer cancel()
 	defer cancel()
 
 
@@ -514,4 +642,4 @@ func downgradeSQLiteDatabaseFrom16To15(dbHandle *sql.DB) error {
 
 
 	_, err := dbHandle.ExecContext(ctx, sql)
 	_, err := dbHandle.ExecContext(ctx, sql)
 	return err
 	return err
-}*/
+}

+ 179 - 8
dataprovider/sqlqueries.go

@@ -18,6 +18,7 @@ const (
 	selectAPIKeyFields = "key_id,name,api_key,scope,created_at,updated_at,last_use_at,expires_at,description,user_id,admin_id"
 	selectAPIKeyFields = "key_id,name,api_key,scope,created_at,updated_at,last_use_at,expires_at,description,user_id,admin_id"
 	selectShareFields  = "s.share_id,s.name,s.description,s.scope,s.paths,u.username,s.created_at,s.updated_at,s.last_use_at," +
 	selectShareFields  = "s.share_id,s.name,s.description,s.scope,s.paths,u.username,s.created_at,s.updated_at,s.last_use_at," +
 		"s.expires_at,s.password,s.max_tokens,s.used_tokens,s.allow_from"
 		"s.expires_at,s.password,s.max_tokens,s.used_tokens,s.allow_from"
+	selectGroupFields = "id,name,description,created_at,updated_at,user_settings"
 )
 )
 
 
 func getSQLPlaceholders() []string {
 func getSQLPlaceholders() []string {
@@ -105,6 +106,78 @@ func getDefenderEventsCleanupQuery() string {
 	return fmt.Sprintf(`DELETE FROM %v WHERE date_time < %v`, sqlTableDefenderEvents, sqlPlaceholders[0])
 	return fmt.Sprintf(`DELETE FROM %v WHERE date_time < %v`, sqlTableDefenderEvents, sqlPlaceholders[0])
 }
 }
 
 
+func getGroupByNameQuery() string {
+	return fmt.Sprintf(`SELECT %s FROM %s WHERE name = %s`, selectGroupFields, sqlTableGroups, sqlPlaceholders[0])
+}
+
+func getGroupsQuery(order string, minimal bool) string {
+	var fieldSelection string
+	if minimal {
+		fieldSelection = "id,name"
+	} else {
+		fieldSelection = selectGroupFields
+	}
+	return fmt.Sprintf(`SELECT %s FROM %s ORDER BY name %s LIMIT %v OFFSET %v`, fieldSelection, sqlTableGroups,
+		order, sqlPlaceholders[0], sqlPlaceholders[1])
+}
+
+func getGroupsWithNamesQuery(numArgs int) string {
+	var sb strings.Builder
+	for idx := 0; idx < numArgs; idx++ {
+		if sb.Len() == 0 {
+			sb.WriteString("(")
+		} else {
+			sb.WriteString(",")
+		}
+		sb.WriteString(sqlPlaceholders[idx])
+	}
+	if sb.Len() > 0 {
+		sb.WriteString(")")
+	} else {
+		sb.WriteString("('')")
+	}
+	return fmt.Sprintf(`SELECT %s FROM %s WHERE name in %s`, selectGroupFields, sqlTableGroups, sb.String())
+}
+
+func getUsersInGroupsQuery(numArgs int) string {
+	var sb strings.Builder
+	for idx := 0; idx < numArgs; idx++ {
+		if sb.Len() == 0 {
+			sb.WriteString("(")
+		} else {
+			sb.WriteString(",")
+		}
+		sb.WriteString(sqlPlaceholders[idx])
+	}
+	if sb.Len() > 0 {
+		sb.WriteString(")")
+	} else {
+		sb.WriteString("('')")
+	}
+	return fmt.Sprintf(`SELECT username FROM %s WHERE id IN (SELECT user_id from %s WHERE group_id IN (SELECT id FROM %s WHERE name IN (%s)))`,
+		sqlTableUsers, sqlTableUsersGroupsMapping, sqlTableGroups, sb.String())
+}
+
+func getDumpGroupsQuery() string {
+	return fmt.Sprintf(`SELECT %s FROM %s`, selectGroupFields, sqlTableGroups)
+}
+
+func getAddGroupQuery() string {
+	return fmt.Sprintf(`INSERT INTO %s (name,description,created_at,updated_at,user_settings)
+		VALUES (%v,%v,%v,%v,%v)`, sqlTableGroups, sqlPlaceholders[0], sqlPlaceholders[1],
+		sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4])
+}
+
+func getUpdateGroupQuery() string {
+	return fmt.Sprintf(`UPDATE %s SET description=%v,user_settings=%v,updated_at=%v
+		WHERE name = %s`, sqlTableGroups, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2],
+		sqlPlaceholders[3])
+}
+
+func getDeleteGroupQuery() string {
+	return fmt.Sprintf(`DELETE FROM %s WHERE name = %s`, sqlTableGroups, sqlPlaceholders[0])
+}
+
 func getAdminByUsernameQuery() string {
 func getAdminByUsernameQuery() string {
 	return fmt.Sprintf(`SELECT %v FROM %v WHERE username = %v`, selectAdminFields, sqlTableAdmins, sqlPlaceholders[0])
 	return fmt.Sprintf(`SELECT %v FROM %v WHERE username = %v`, selectAdminFields, sqlTableAdmins, sqlPlaceholders[0])
 }
 }
@@ -396,19 +469,48 @@ func getDeleteFolderQuery() string {
 	return fmt.Sprintf(`DELETE FROM %v WHERE id = %v`, sqlTableFolders, sqlPlaceholders[0])
 	return fmt.Sprintf(`DELETE FROM %v WHERE id = %v`, sqlTableFolders, sqlPlaceholders[0])
 }
 }
 
 
-func getClearFolderMappingQuery() string {
-	return fmt.Sprintf(`DELETE FROM %v WHERE user_id = (SELECT id FROM %v WHERE username = %v)`, sqlTableFoldersMapping,
+func getClearUserGroupMappingQuery() string {
+	return fmt.Sprintf(`DELETE FROM %v WHERE user_id = (SELECT id FROM %v WHERE username = %v)`, sqlTableUsersGroupsMapping,
 		sqlTableUsers, sqlPlaceholders[0])
 		sqlTableUsers, sqlPlaceholders[0])
 }
 }
 
 
-func getAddFolderMappingQuery() string {
+func getAddUserGroupMappingQuery() string {
+	return fmt.Sprintf(`INSERT INTO %v (user_id,group_id,group_type) VALUES ((SELECT id FROM %v WHERE username = %v),
+		(SELECT id FROM %v WHERE name = %v),%v)`,
+		sqlTableUsersGroupsMapping, sqlTableUsers, sqlPlaceholders[0], sqlTableGroups, sqlPlaceholders[1], sqlPlaceholders[2])
+}
+
+func getClearGroupFolderMappingQuery() string {
+	return fmt.Sprintf(`DELETE FROM %v WHERE group_id = (SELECT id FROM %v WHERE name = %v)`, sqlTableGroupsFoldersMapping,
+		sqlTableGroups, sqlPlaceholders[0])
+}
+
+func getAddGroupFolderMappingQuery() string {
+	return fmt.Sprintf(`INSERT INTO %v (virtual_path,quota_size,quota_files,folder_id,group_id)
+		VALUES (%v,%v,%v,(SELECT id FROM %v WHERE name = %v),(SELECT id FROM %v WHERE name = %v))`,
+		sqlTableGroupsFoldersMapping, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlTableFolders,
+		sqlPlaceholders[3], sqlTableGroups, sqlPlaceholders[4])
+}
+
+func getClearUserFolderMappingQuery() string {
+	return fmt.Sprintf(`DELETE FROM %v WHERE user_id = (SELECT id FROM %v WHERE username = %v)`, sqlTableUsersFoldersMapping,
+		sqlTableUsers, sqlPlaceholders[0])
+}
+
+func getAddUserFolderMappingQuery() string {
 	return fmt.Sprintf(`INSERT INTO %v (virtual_path,quota_size,quota_files,folder_id,user_id)
 	return fmt.Sprintf(`INSERT INTO %v (virtual_path,quota_size,quota_files,folder_id,user_id)
-		VALUES (%v,%v,%v,%v,(SELECT id FROM %v WHERE username = %v))`, sqlTableFoldersMapping, sqlPlaceholders[0],
+		VALUES (%v,%v,%v,%v,(SELECT id FROM %v WHERE username = %v))`, sqlTableUsersFoldersMapping, sqlPlaceholders[0],
 		sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlTableUsers, sqlPlaceholders[4])
 		sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlTableUsers, sqlPlaceholders[4])
 }
 }
 
 
-func getFoldersQuery(order string) string {
-	return fmt.Sprintf(`SELECT %v FROM %v ORDER BY name %v LIMIT %v OFFSET %v`, selectFolderFields, sqlTableFolders,
+func getFoldersQuery(order string, minimal bool) string {
+	var fieldSelection string
+	if minimal {
+		fieldSelection = "id,name"
+	} else {
+		fieldSelection = selectFolderFields
+	}
+	return fmt.Sprintf(`SELECT %v FROM %v ORDER BY name %v LIMIT %v OFFSET %v`, fieldSelection, sqlTableFolders,
 		order, sqlPlaceholders[0], sqlPlaceholders[1])
 		order, sqlPlaceholders[0], sqlPlaceholders[1])
 }
 }
 
 
@@ -426,6 +528,23 @@ func getQuotaFolderQuery() string {
 		sqlPlaceholders[0])
 		sqlPlaceholders[0])
 }
 }
 
 
+func getRelatedGroupsForUsersQuery(users []User) string {
+	var sb strings.Builder
+	for _, u := range users {
+		if sb.Len() == 0 {
+			sb.WriteString("(")
+		} else {
+			sb.WriteString(",")
+		}
+		sb.WriteString(strconv.FormatInt(u.ID, 10))
+	}
+	if sb.Len() > 0 {
+		sb.WriteString(")")
+	}
+	return fmt.Sprintf(`SELECT g.name,ug.group_type,ug.user_id FROM %v g INNER JOIN %v ug ON g.id = ug.group_id WHERE
+		ug.user_id IN %v ORDER BY ug.user_id`, sqlTableGroups, sqlTableUsersGroupsMapping, sb.String())
+}
+
 func getRelatedFoldersForUsersQuery(users []User) string {
 func getRelatedFoldersForUsersQuery(users []User) string {
 	var sb strings.Builder
 	var sb strings.Builder
 	for _, u := range users {
 	for _, u := range users {
@@ -441,7 +560,7 @@ func getRelatedFoldersForUsersQuery(users []User) string {
 	}
 	}
 	return fmt.Sprintf(`SELECT f.id,f.name,f.path,f.used_quota_size,f.used_quota_files,f.last_quota_update,fm.virtual_path,
 	return fmt.Sprintf(`SELECT f.id,f.name,f.path,f.used_quota_size,f.used_quota_files,f.last_quota_update,fm.virtual_path,
 		fm.quota_size,fm.quota_files,fm.user_id,f.filesystem,f.description FROM %v f INNER JOIN %v fm ON f.id = fm.folder_id WHERE
 		fm.quota_size,fm.quota_files,fm.user_id,f.filesystem,f.description FROM %v f INNER JOIN %v fm ON f.id = fm.folder_id WHERE
-		fm.user_id IN %v ORDER BY fm.user_id`, sqlTableFolders, sqlTableFoldersMapping, sb.String())
+		fm.user_id IN %v ORDER BY fm.user_id`, sqlTableFolders, sqlTableUsersFoldersMapping, sb.String())
 }
 }
 
 
 func getRelatedUsersForFoldersQuery(folders []vfs.BaseVirtualFolder) string {
 func getRelatedUsersForFoldersQuery(folders []vfs.BaseVirtualFolder) string {
@@ -458,7 +577,59 @@ func getRelatedUsersForFoldersQuery(folders []vfs.BaseVirtualFolder) string {
 		sb.WriteString(")")
 		sb.WriteString(")")
 	}
 	}
 	return fmt.Sprintf(`SELECT fm.folder_id,u.username FROM %v fm INNER JOIN %v u ON fm.user_id = u.id
 	return fmt.Sprintf(`SELECT fm.folder_id,u.username FROM %v fm INNER JOIN %v u ON fm.user_id = u.id
-		WHERE fm.folder_id IN %v ORDER BY fm.folder_id`, sqlTableFoldersMapping, sqlTableUsers, sb.String())
+		WHERE fm.folder_id IN %v ORDER BY fm.folder_id`, sqlTableUsersFoldersMapping, sqlTableUsers, sb.String())
+}
+
+func getRelatedGroupsForFoldersQuery(folders []vfs.BaseVirtualFolder) string {
+	var sb strings.Builder
+	for _, f := range folders {
+		if sb.Len() == 0 {
+			sb.WriteString("(")
+		} else {
+			sb.WriteString(",")
+		}
+		sb.WriteString(strconv.FormatInt(f.ID, 10))
+	}
+	if sb.Len() > 0 {
+		sb.WriteString(")")
+	}
+	return fmt.Sprintf(`SELECT fm.folder_id,g.name FROM %v fm INNER JOIN %v g ON fm.group_id = g.id
+		WHERE fm.folder_id IN %v ORDER BY fm.folder_id`, sqlTableGroupsFoldersMapping, sqlTableGroups, sb.String())
+}
+
+func getRelatedUsersForGroupsQuery(groups []Group) string {
+	var sb strings.Builder
+	for _, g := range groups {
+		if sb.Len() == 0 {
+			sb.WriteString("(")
+		} else {
+			sb.WriteString(",")
+		}
+		sb.WriteString(strconv.FormatInt(g.ID, 10))
+	}
+	if sb.Len() > 0 {
+		sb.WriteString(")")
+	}
+	return fmt.Sprintf(`SELECT um.group_id,u.username FROM %v um INNER JOIN %v u ON um.user_id = u.id
+		WHERE um.group_id IN %v ORDER BY um.group_id`, sqlTableUsersGroupsMapping, sqlTableUsers, sb.String())
+}
+
+func getRelatedFoldersForGroupsQuery(groups []Group) string {
+	var sb strings.Builder
+	for _, g := range groups {
+		if sb.Len() == 0 {
+			sb.WriteString("(")
+		} else {
+			sb.WriteString(",")
+		}
+		sb.WriteString(strconv.FormatInt(g.ID, 10))
+	}
+	if sb.Len() > 0 {
+		sb.WriteString(")")
+	}
+	return fmt.Sprintf(`SELECT f.id,f.name,f.path,f.used_quota_size,f.used_quota_files,f.last_quota_update,fm.virtual_path,
+		fm.quota_size,fm.quota_files,fm.group_id,f.filesystem,f.description FROM %s f INNER JOIN %s fm ON f.id = fm.folder_id WHERE
+		fm.group_id IN %v ORDER BY fm.group_id`, sqlTableFolders, sqlTableGroupsFoldersMapping, sb.String())
 }
 }
 
 
 func getActiveTransfersQuery() string {
 func getActiveTransfersQuery() string {

+ 273 - 76
dataprovider/user.go

@@ -123,8 +123,12 @@ type User struct {
 	VirtualFolders []vfs.VirtualFolder `json:"virtual_folders,omitempty"`
 	VirtualFolders []vfs.VirtualFolder `json:"virtual_folders,omitempty"`
 	// Filesystem configuration details
 	// Filesystem configuration details
 	FsConfig vfs.Filesystem `json:"filesystem"`
 	FsConfig vfs.Filesystem `json:"filesystem"`
+	// groups associated with this user
+	Groups []sdk.GroupMapping `json:"groups,omitempty"`
 	// we store the filesystem here using the base path as key.
 	// we store the filesystem here using the base path as key.
 	fsCache map[string]vfs.Fs `json:"-"`
 	fsCache map[string]vfs.Fs `json:"-"`
+	// true if group settings are already applied for this user
+	groupSettingsApplied bool `json:"-"`
 }
 }
 
 
 // GetFilesystem returns the base filesystem for this user
 // GetFilesystem returns the base filesystem for this user
@@ -453,6 +457,9 @@ func (u *User) HasBufferedSFTP(name string) bool {
 
 
 func (u *User) getForbiddenSFTPSelfUsers(username string) ([]string, error) {
 func (u *User) getForbiddenSFTPSelfUsers(username string) ([]string, error) {
 	sftpUser, err := UserExists(username)
 	sftpUser, err := UserExists(username)
+	if err == nil {
+		err = sftpUser.LoadAndApplyGroupSettings()
+	}
 	if err == nil {
 	if err == nil {
 		// we don't allow local nested SFTP folders
 		// we don't allow local nested SFTP folders
 		var forbiddens []string
 		var forbiddens []string
@@ -912,30 +919,6 @@ func (u *User) GetAllowedLoginMethods() []string {
 	return allowedMethods
 	return allowedMethods
 }
 }
 
 
-// GetFlatFilePatterns returns file patterns as flat list
-// duplicating a path if it has both allowed and denied patterns
-func (u *User) GetFlatFilePatterns() []sdk.PatternsFilter {
-	var result []sdk.PatternsFilter
-
-	for _, pattern := range u.Filters.FilePatterns {
-		if len(pattern.AllowedPatterns) > 0 {
-			result = append(result, sdk.PatternsFilter{
-				Path:            pattern.Path,
-				AllowedPatterns: pattern.AllowedPatterns,
-				DenyPolicy:      pattern.DenyPolicy,
-			})
-		}
-		if len(pattern.DeniedPatterns) > 0 {
-			result = append(result, sdk.PatternsFilter{
-				Path:           pattern.Path,
-				DeniedPatterns: pattern.DeniedPatterns,
-				DenyPolicy:     pattern.DenyPolicy,
-			})
-		}
-	}
-	return result
-}
-
 func (u *User) getPatternsFilterForPath(virtualPath string) sdk.PatternsFilter {
 func (u *User) getPatternsFilterForPath(virtualPath string) sdk.PatternsFilter {
 	var filter sdk.PatternsFilter
 	var filter sdk.PatternsFilter
 	if len(u.Filters.FilePatterns) == 0 {
 	if len(u.Filters.FilePatterns) == 0 {
@@ -1204,7 +1187,7 @@ func (u *User) GetGID() int {
 
 
 // GetHomeDir returns the shortest path name equivalent to the user's home directory
 // GetHomeDir returns the shortest path name equivalent to the user's home directory
 func (u *User) GetHomeDir() string {
 func (u *User) GetHomeDir() string {
-	return filepath.Clean(u.HomeDir)
+	return u.HomeDir
 }
 }
 
 
 // HasRecentActivity returns true if the last user login is recent and so we can skip some expensive checks
 // HasRecentActivity returns true if the last user login is recent and so we can skip some expensive checks
@@ -1448,6 +1431,256 @@ func (u *User) SetEmptySecretsIfNil() {
 	}
 	}
 }
 }
 
 
+func (u *User) hasMainDataTransferLimits() bool {
+	return u.UploadDataTransfer > 0 || u.DownloadDataTransfer > 0 || u.TotalDataTransfer > 0
+}
+
+// HasPrimaryGroup returns true if the user has the specified primary group
+func (u *User) HasPrimaryGroup(name string) bool {
+	for _, g := range u.Groups {
+		if g.Name == name {
+			return g.Type == sdk.GroupTypePrimary
+		}
+	}
+	return false
+}
+
+// HasSecondaryGroup returns true if the user has the specified secondary group
+func (u *User) HasSecondaryGroup(name string) bool {
+	for _, g := range u.Groups {
+		if g.Name == name {
+			return g.Type == sdk.GroupTypeSecondary
+		}
+	}
+	return false
+}
+
+func (u *User) applyGroupSettings(groupsMapping map[string]Group) {
+	if len(u.Groups) == 0 {
+		return
+	}
+	if u.groupSettingsApplied {
+		return
+	}
+	for _, g := range u.Groups {
+		if g.Type == sdk.GroupTypePrimary {
+			if group, ok := groupsMapping[g.Name]; ok {
+				u.mergeWithPrimaryGroup(group)
+			} else {
+				providerLog(logger.LevelError, "mapping not found for user %s, group %s", u.Username, g.Name)
+			}
+			break
+		}
+	}
+	for _, g := range u.Groups {
+		if g.Type == sdk.GroupTypeSecondary {
+			if group, ok := groupsMapping[g.Name]; ok {
+				u.mergeAdditiveProperties(group, sdk.GroupTypeSecondary)
+			} else {
+				providerLog(logger.LevelError, "mapping not found for user %s, group %s", u.Username, g.Name)
+			}
+		}
+	}
+	u.removeDuplicatesAfterGroupMerge()
+}
+
+// LoadAndApplyGroupSettings update the user by loading and applying the group settings
+func (u *User) LoadAndApplyGroupSettings() error {
+	if len(u.Groups) == 0 {
+		return nil
+	}
+	if u.groupSettingsApplied {
+		return nil
+	}
+	names := make([]string, 0, len(u.Groups))
+	var primaryGroupName string
+	for _, g := range u.Groups {
+		if g.Type == sdk.GroupTypePrimary {
+			primaryGroupName = g.Name
+		}
+		names = append(names, g.Name)
+	}
+	groups, err := provider.getGroupsWithNames(names)
+	if err != nil {
+		return fmt.Errorf("unable to get groups: %w", err)
+	}
+	// make sure to always merge with the primary group first
+	for idx, g := range groups {
+		if g.Name == primaryGroupName {
+			u.mergeWithPrimaryGroup(g)
+			lastIdx := len(groups) - 1
+			groups[idx] = groups[lastIdx]
+			groups = groups[:lastIdx]
+			break
+		}
+	}
+	for _, g := range groups {
+		u.mergeAdditiveProperties(g, sdk.GroupTypeSecondary)
+	}
+	u.removeDuplicatesAfterGroupMerge()
+	return nil
+}
+
+func (u *User) replacePlaceholder(value string) string {
+	if value == "" {
+		return value
+	}
+	return strings.ReplaceAll(value, "%username%", u.Username)
+}
+
+func (u *User) replaceFsConfigPlaceholders() {
+	switch u.FsConfig.Provider {
+	case sdk.S3FilesystemProvider:
+		u.FsConfig.S3Config.KeyPrefix = u.replacePlaceholder(u.FsConfig.S3Config.KeyPrefix)
+	case sdk.GCSFilesystemProvider:
+		u.FsConfig.GCSConfig.KeyPrefix = u.replacePlaceholder(u.FsConfig.GCSConfig.KeyPrefix)
+	case sdk.AzureBlobFilesystemProvider:
+		u.FsConfig.AzBlobConfig.KeyPrefix = u.replacePlaceholder(u.FsConfig.AzBlobConfig.KeyPrefix)
+	case sdk.SFTPFilesystemProvider:
+		u.FsConfig.SFTPConfig.Username = u.replacePlaceholder(u.FsConfig.SFTPConfig.Username)
+		u.FsConfig.SFTPConfig.Prefix = u.replacePlaceholder(u.FsConfig.SFTPConfig.Prefix)
+	}
+}
+
+func (u *User) mergeWithPrimaryGroup(group Group) {
+	if group.UserSettings.HomeDir != "" {
+		u.HomeDir = u.replacePlaceholder(group.UserSettings.HomeDir)
+	}
+	if group.UserSettings.FsConfig.Provider != 0 {
+		u.FsConfig = group.UserSettings.FsConfig
+		u.replaceFsConfigPlaceholders()
+	}
+	if u.MaxSessions == 0 {
+		u.MaxSessions = group.UserSettings.MaxSessions
+	}
+	if u.QuotaSize == 0 {
+		u.QuotaSize = group.UserSettings.QuotaSize
+	}
+	if u.QuotaFiles == 0 {
+		u.QuotaFiles = group.UserSettings.QuotaFiles
+	}
+	if u.UploadBandwidth == 0 {
+		u.UploadBandwidth = group.UserSettings.UploadBandwidth
+	}
+	if u.DownloadBandwidth == 0 {
+		u.DownloadBandwidth = group.UserSettings.DownloadBandwidth
+	}
+	if !u.hasMainDataTransferLimits() {
+		u.UploadDataTransfer = group.UserSettings.UploadDataTransfer
+		u.DownloadDataTransfer = group.UserSettings.DownloadDataTransfer
+		u.TotalDataTransfer = group.UserSettings.TotalDataTransfer
+	}
+	u.mergePrimaryGroupFilters(group.UserSettings.Filters)
+	u.mergeAdditiveProperties(group, sdk.GroupTypePrimary)
+}
+
+func (u *User) mergePrimaryGroupFilters(filters sdk.BaseUserFilters) {
+	if u.Filters.MaxUploadFileSize == 0 {
+		u.Filters.MaxUploadFileSize = filters.MaxUploadFileSize
+	}
+	if u.Filters.TLSUsername == "" || u.Filters.TLSUsername == sdk.TLSUsernameNone {
+		u.Filters.TLSUsername = filters.TLSUsername
+	}
+	if !u.Filters.Hooks.CheckPasswordDisabled {
+		u.Filters.Hooks.CheckPasswordDisabled = filters.Hooks.CheckPasswordDisabled
+	}
+	if !u.Filters.Hooks.PreLoginDisabled {
+		u.Filters.Hooks.PreLoginDisabled = filters.Hooks.PreLoginDisabled
+	}
+	if !u.Filters.Hooks.ExternalAuthDisabled {
+		u.Filters.Hooks.ExternalAuthDisabled = filters.Hooks.ExternalAuthDisabled
+	}
+	if !u.Filters.DisableFsChecks {
+		u.Filters.DisableFsChecks = filters.DisableFsChecks
+	}
+	if !u.Filters.AllowAPIKeyAuth {
+		u.Filters.AllowAPIKeyAuth = filters.AllowAPIKeyAuth
+	}
+	if u.Filters.ExternalAuthCacheTime == 0 {
+		u.Filters.ExternalAuthCacheTime = filters.ExternalAuthCacheTime
+	}
+	if u.Filters.StartDirectory == "" {
+		u.Filters.StartDirectory = u.replacePlaceholder(filters.StartDirectory)
+	}
+}
+
+func (u *User) mergeAdditiveProperties(group Group, groupType int) {
+	u.mergeVirtualFolders(group, groupType)
+	u.mergePermissions(group, groupType)
+	u.mergeFilePatterns(group, groupType)
+	u.Filters.BandwidthLimits = append(u.Filters.BandwidthLimits, group.UserSettings.Filters.BandwidthLimits...)
+	u.Filters.DataTransferLimits = append(u.Filters.DataTransferLimits, group.UserSettings.Filters.DataTransferLimits...)
+	u.Filters.AllowedIP = append(u.Filters.AllowedIP, group.UserSettings.Filters.AllowedIP...)
+	u.Filters.DeniedIP = append(u.Filters.DeniedIP, group.UserSettings.Filters.DeniedIP...)
+	u.Filters.DeniedLoginMethods = append(u.Filters.DeniedLoginMethods, group.UserSettings.Filters.DeniedLoginMethods...)
+	u.Filters.DeniedProtocols = append(u.Filters.DeniedProtocols, group.UserSettings.Filters.DeniedProtocols...)
+	u.Filters.WebClient = append(u.Filters.WebClient, group.UserSettings.Filters.WebClient...)
+	u.Filters.TwoFactorAuthProtocols = append(u.Filters.TwoFactorAuthProtocols, group.UserSettings.Filters.TwoFactorAuthProtocols...)
+}
+
+func (u *User) mergeVirtualFolders(group Group, groupType int) {
+	if len(group.VirtualFolders) > 0 {
+		folderPaths := make(map[string]bool)
+		for _, folder := range u.VirtualFolders {
+			folderPaths[folder.VirtualPath] = true
+		}
+		for _, folder := range group.VirtualFolders {
+			if folder.VirtualPath == "/" && groupType != sdk.GroupTypePrimary {
+				continue
+			}
+			folder.VirtualPath = u.replacePlaceholder(folder.VirtualPath)
+			if _, ok := folderPaths[folder.VirtualPath]; !ok {
+				folder.MappedPath = u.replacePlaceholder(folder.MappedPath)
+				u.VirtualFolders = append(u.VirtualFolders, folder)
+			}
+		}
+	}
+}
+
+func (u *User) mergePermissions(group Group, groupType int) {
+	for k, v := range group.UserSettings.Permissions {
+		if k == "/" {
+			if groupType == sdk.GroupTypePrimary {
+				u.Permissions[k] = v
+			} else {
+				continue
+			}
+		}
+		k = u.replacePlaceholder(k)
+		if _, ok := u.Permissions[k]; !ok {
+			u.Permissions[k] = v
+		}
+	}
+}
+
+func (u *User) mergeFilePatterns(group Group, groupType int) {
+	if len(group.UserSettings.Filters.FilePatterns) > 0 {
+		patternPaths := make(map[string]bool)
+		for _, pattern := range u.Filters.FilePatterns {
+			patternPaths[pattern.Path] = true
+		}
+		for _, pattern := range group.UserSettings.Filters.FilePatterns {
+			if pattern.Path == "/" && groupType != sdk.GroupTypePrimary {
+				continue
+			}
+			pattern.Path = u.replacePlaceholder(pattern.Path)
+			if _, ok := patternPaths[pattern.Path]; !ok {
+				u.Filters.FilePatterns = append(u.Filters.FilePatterns, pattern)
+			}
+		}
+	}
+}
+
+func (u *User) removeDuplicatesAfterGroupMerge() {
+	u.Filters.AllowedIP = util.RemoveDuplicates(u.Filters.AllowedIP)
+	u.Filters.DeniedIP = util.RemoveDuplicates(u.Filters.DeniedIP)
+	u.Filters.DeniedLoginMethods = util.RemoveDuplicates(u.Filters.DeniedLoginMethods)
+	u.Filters.DeniedProtocols = util.RemoveDuplicates(u.Filters.DeniedProtocols)
+	u.Filters.WebClient = util.RemoveDuplicates(u.Filters.WebClient)
+	u.Filters.TwoFactorAuthProtocols = util.RemoveDuplicates(u.Filters.TwoFactorAuthProtocols)
+	u.groupSettingsApplied = true
+}
+
 func (u *User) getACopy() User {
 func (u *User) getACopy() User {
 	u.SetEmptySecretsIfNil()
 	u.SetEmptySecretsIfNil()
 	pubKeys := make([]string, len(u.PublicKeys))
 	pubKeys := make([]string, len(u.PublicKeys))
@@ -1457,42 +1690,27 @@ func (u *User) getACopy() User {
 		vfolder := u.VirtualFolders[idx].GetACopy()
 		vfolder := u.VirtualFolders[idx].GetACopy()
 		virtualFolders = append(virtualFolders, vfolder)
 		virtualFolders = append(virtualFolders, vfolder)
 	}
 	}
+	groups := make([]sdk.GroupMapping, 0, len(u.Groups))
+	for _, g := range u.Groups {
+		groups = append(groups, sdk.GroupMapping{
+			Name: g.Name,
+			Type: g.Type,
+		})
+	}
 	permissions := make(map[string][]string)
 	permissions := make(map[string][]string)
 	for k, v := range u.Permissions {
 	for k, v := range u.Permissions {
 		perms := make([]string, len(v))
 		perms := make([]string, len(v))
 		copy(perms, v)
 		copy(perms, v)
 		permissions[k] = perms
 		permissions[k] = perms
 	}
 	}
-	filters := UserFilters{}
-	filters.MaxUploadFileSize = u.Filters.MaxUploadFileSize
-	filters.TLSUsername = u.Filters.TLSUsername
-	filters.UserType = u.Filters.UserType
+	filters := UserFilters{
+		BaseUserFilters: copyBaseUserFilters(u.Filters.BaseUserFilters),
+	}
 	filters.TOTPConfig.Enabled = u.Filters.TOTPConfig.Enabled
 	filters.TOTPConfig.Enabled = u.Filters.TOTPConfig.Enabled
 	filters.TOTPConfig.ConfigName = u.Filters.TOTPConfig.ConfigName
 	filters.TOTPConfig.ConfigName = u.Filters.TOTPConfig.ConfigName
 	filters.TOTPConfig.Secret = u.Filters.TOTPConfig.Secret.Clone()
 	filters.TOTPConfig.Secret = u.Filters.TOTPConfig.Secret.Clone()
 	filters.TOTPConfig.Protocols = make([]string, len(u.Filters.TOTPConfig.Protocols))
 	filters.TOTPConfig.Protocols = make([]string, len(u.Filters.TOTPConfig.Protocols))
 	copy(filters.TOTPConfig.Protocols, u.Filters.TOTPConfig.Protocols)
 	copy(filters.TOTPConfig.Protocols, u.Filters.TOTPConfig.Protocols)
-	filters.AllowedIP = make([]string, len(u.Filters.AllowedIP))
-	copy(filters.AllowedIP, u.Filters.AllowedIP)
-	filters.DeniedIP = make([]string, len(u.Filters.DeniedIP))
-	copy(filters.DeniedIP, u.Filters.DeniedIP)
-	filters.DeniedLoginMethods = make([]string, len(u.Filters.DeniedLoginMethods))
-	copy(filters.DeniedLoginMethods, u.Filters.DeniedLoginMethods)
-	filters.FilePatterns = make([]sdk.PatternsFilter, len(u.Filters.FilePatterns))
-	copy(filters.FilePatterns, u.Filters.FilePatterns)
-	filters.DeniedProtocols = make([]string, len(u.Filters.DeniedProtocols))
-	copy(filters.DeniedProtocols, u.Filters.DeniedProtocols)
-	filters.TwoFactorAuthProtocols = make([]string, len(u.Filters.TwoFactorAuthProtocols))
-	copy(filters.TwoFactorAuthProtocols, u.Filters.TwoFactorAuthProtocols)
-	filters.Hooks.ExternalAuthDisabled = u.Filters.Hooks.ExternalAuthDisabled
-	filters.Hooks.PreLoginDisabled = u.Filters.Hooks.PreLoginDisabled
-	filters.Hooks.CheckPasswordDisabled = u.Filters.Hooks.CheckPasswordDisabled
-	filters.DisableFsChecks = u.Filters.DisableFsChecks
-	filters.StartDirectory = u.Filters.StartDirectory
-	filters.AllowAPIKeyAuth = u.Filters.AllowAPIKeyAuth
-	filters.ExternalAuthCacheTime = u.Filters.ExternalAuthCacheTime
-	filters.WebClient = make([]string, len(u.Filters.WebClient))
-	copy(filters.WebClient, u.Filters.WebClient)
 	filters.RecoveryCodes = make([]RecoveryCode, 0, len(u.Filters.RecoveryCodes))
 	filters.RecoveryCodes = make([]RecoveryCode, 0, len(u.Filters.RecoveryCodes))
 	for _, code := range u.Filters.RecoveryCodes {
 	for _, code := range u.Filters.RecoveryCodes {
 		if code.Secret == nil {
 		if code.Secret == nil {
@@ -1503,29 +1721,6 @@ func (u *User) getACopy() User {
 			Used:   code.Used,
 			Used:   code.Used,
 		})
 		})
 	}
 	}
-	filters.BandwidthLimits = make([]sdk.BandwidthLimit, 0, len(u.Filters.BandwidthLimits))
-	for _, limit := range u.Filters.BandwidthLimits {
-		bwLimit := sdk.BandwidthLimit{
-			UploadBandwidth:   limit.UploadBandwidth,
-			DownloadBandwidth: limit.DownloadBandwidth,
-			Sources:           make([]string, 0, len(limit.Sources)),
-		}
-		bwLimit.Sources = make([]string, len(limit.Sources))
-		copy(bwLimit.Sources, limit.Sources)
-		filters.BandwidthLimits = append(filters.BandwidthLimits, bwLimit)
-	}
-	filters.DataTransferLimits = make([]sdk.DataTransferLimit, 0, len(u.Filters.DataTransferLimits))
-	for _, limit := range u.Filters.DataTransferLimits {
-		dtLimit := sdk.DataTransferLimit{
-			UploadDataTransfer:   limit.UploadDataTransfer,
-			DownloadDataTransfer: limit.DownloadDataTransfer,
-			TotalDataTransfer:    limit.TotalDataTransfer,
-			Sources:              make([]string, 0, len(limit.Sources)),
-		}
-		dtLimit.Sources = make([]string, len(limit.Sources))
-		copy(dtLimit.Sources, limit.Sources)
-		filters.DataTransferLimits = append(filters.DataTransferLimits, dtLimit)
-	}
 
 
 	return User{
 	return User{
 		BaseUser: sdk.BaseUser{
 		BaseUser: sdk.BaseUser{
@@ -1559,9 +1754,11 @@ func (u *User) getACopy() User {
 			CreatedAt:                u.CreatedAt,
 			CreatedAt:                u.CreatedAt,
 			UpdatedAt:                u.UpdatedAt,
 			UpdatedAt:                u.UpdatedAt,
 		},
 		},
-		Filters:        filters,
-		VirtualFolders: virtualFolders,
-		FsConfig:       u.FsConfig.GetACopy(),
+		Filters:              filters,
+		VirtualFolders:       virtualFolders,
+		Groups:               groups,
+		FsConfig:             u.FsConfig.GetACopy(),
+		groupSettingsApplied: u.groupSettingsApplied,
 	}
 	}
 }
 }
 
 

+ 41 - 0
docs/groups.md

@@ -0,0 +1,41 @@
+# Groups
+
+Using groups simplifies the administration of multiple accounts by letting you assign settings once to a group, instead of multiple times to each individual user.
+
+SFTPGo supports two types of groups:
+
+- primary groups
+- secondary groups
+
+A user can be a member of a primary group and many secondary groups. Depending on the group type, the settings are inherited differently.
+
+The following settings are inherited from the primary group:
+
+- home dir, if set for the group will replace the one defined for the user. The `%username%` placeholder is replaced with the username
+- filesystem config, if the provider set for the group is different from the "local provider" will replace the one defined for the user. The `%username%` placeholder is replaced with the username within the defined "prefix" and the "username" for the SFTP filesystem config
+- max sessions, quota size/files, upload/download bandwidth, upload/download/total data transfer, max upload size, external auth cache time: if they are set to `0` for the user they are replaced with the value set for the group, if different from `0`
+- TLS username, check password hook disabled, pre-login hook disabled, external auth hook disabled, filesystem checks disabled, allow API key authentication: if they are not set for the user they are replaced with the value set for the group
+- starting directory, if the user does not have a starting directory set, the value set for the group is used, if any. The `%username%` placeholder is replaced with the username
+
+The following settings are inherited from the primary and secondary groups:
+
+- virtual folders, file patterns, permissions: they are added to the user configuration if the user does not already have a setting for the configured path. The `/` path is ignored for secondary groups. The `%username%` placeholder is replaced with the username within the virtual path.
+- per-source bandwidth limits
+- per-source data transfer limits
+- allowed/denied IPs
+- denied login methods and protocols
+- two factor auth protocols
+- web client/REST API permissions
+
+The settings from the primary group are always merged first.
+
+The final settings are a combination of the user settings and the group ones.
+For example you can define the following groups:
+
+- "group1", it has a virtual directory to mount on `/vdir1`
+- "group2", it has a virtual directory to mount on `/vdir2`
+- "group3", it has a virtual directory to mount on `/vdir3`
+
+If you define users with a virtual directory to mount on `/vdir` and make them member of all the above groups, they will have virtual directories mounted on `/vdir`, `/vdir1`, `/vdir2`, `/vdir3`. If users already have a virtual directory to mount on `/vdir1`, the group's one will be ignored.
+
+Please note that if the same virtual path is set in more than one secondary group the behavior is undefined. For example if a user is a member of two secondary groups and each secondary group defines a virtual folder to mount on the `/vdir2` path, the virtual folder mounted on `/vdir2` may change with every login.

+ 38 - 1
ftpd/ftpd_test.go

@@ -782,6 +782,11 @@ func TestLoginExternalAuth(t *testing.T) {
 	providerConf.ExternalAuthScope = 0
 	providerConf.ExternalAuthScope = 0
 	err = dataprovider.Initialize(providerConf, configDir, true)
 	err = dataprovider.Initialize(providerConf, configDir, true)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
+	g := getTestGroup()
+	g.UserSettings.Filters.DeniedProtocols = []string{common.ProtocolFTP}
+	group, _, err := httpdtest.AddGroup(g, http.StatusCreated)
+	assert.NoError(t, err)
+
 	client, err := getFTPClient(u, true, nil)
 	client, err := getFTPClient(u, true, nil)
 	if assert.NoError(t, err) {
 	if assert.NoError(t, err) {
 		err = checkBasicFTP(client)
 		err = checkBasicFTP(client)
@@ -789,11 +794,32 @@ func TestLoginExternalAuth(t *testing.T) {
 		err := client.Quit()
 		err := client.Quit()
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 	}
 	}
+	u.Groups = []sdk.GroupMapping{
+		{
+			Name: group.Name,
+			Type: sdk.GroupTypePrimary,
+		},
+	}
+	err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm)
+	assert.NoError(t, err)
+	_, err = getFTPClient(u, true, nil)
+	if !assert.Error(t, err) {
+		err := client.Quit()
+		assert.NoError(t, err)
+	} else {
+		assert.Contains(t, err.Error(), "protocol FTP is not allowed")
+	}
+
+	u.Groups = nil
+	err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm)
+	assert.NoError(t, err)
 	u.Username = defaultUsername + "1"
 	u.Username = defaultUsername + "1"
 	client, err = getFTPClient(u, true, nil)
 	client, err = getFTPClient(u, true, nil)
 	if !assert.Error(t, err) {
 	if !assert.Error(t, err) {
 		err := client.Quit()
 		err := client.Quit()
 		assert.NoError(t, err)
 		assert.NoError(t, err)
+	} else {
+		assert.Contains(t, err.Error(), "invalid credentials")
 	}
 	}
 
 
 	user, _, err := httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
 	user, _, err := httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
@@ -803,6 +829,8 @@ func TestLoginExternalAuth(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	err = os.RemoveAll(user.GetHomeDir())
 	err = os.RemoveAll(user.GetHomeDir())
 	assert.NoError(t, err)
 	assert.NoError(t, err)
+	_, err = httpdtest.RemoveGroup(group, http.StatusOK)
+	assert.NoError(t, err)
 
 
 	err = dataprovider.Close()
 	err = dataprovider.Close()
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -2920,7 +2948,7 @@ func TestClientCertificateAndPwdAuth(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
 
 
-func TestExternatAuthWithClientCert(t *testing.T) {
+func TestExternalAuthWithClientCert(t *testing.T) {
 	if runtime.GOOS == osWindows {
 	if runtime.GOOS == osWindows {
 		t.Skip("this test is not available on Windows")
 		t.Skip("this test is not available on Windows")
 	}
 	}
@@ -3313,6 +3341,15 @@ func waitNoConnections() {
 	}
 	}
 }
 }
 
 
+func getTestGroup() dataprovider.Group {
+	return dataprovider.Group{
+		BaseGroup: sdk.BaseGroup{
+			Name:        "test_group",
+			Description: "test group description",
+		},
+	}
+}
+
 func getTestUser() dataprovider.User {
 func getTestUser() dataprovider.User {
 	user := dataprovider.User{
 	user := dataprovider.User{
 		BaseUser: sdk.BaseUser{
 		BaseUser: sdk.BaseUser{

+ 1 - 1
go.mod

@@ -50,7 +50,7 @@ require (
 	github.com/rs/cors v1.8.2
 	github.com/rs/cors v1.8.2
 	github.com/rs/xid v1.4.0
 	github.com/rs/xid v1.4.0
 	github.com/rs/zerolog v1.26.2-0.20220312163309-e9344a8c507b
 	github.com/rs/zerolog v1.26.2-0.20220312163309-e9344a8c507b
-	github.com/sftpgo/sdk v0.1.1-0.20220412171743-453880fab5f7
+	github.com/sftpgo/sdk v0.1.1-0.20220425123921-2f843a49e012
 	github.com/shirou/gopsutil/v3 v3.22.3
 	github.com/shirou/gopsutil/v3 v3.22.3
 	github.com/spf13/afero v1.8.2
 	github.com/spf13/afero v1.8.2
 	github.com/spf13/cobra v1.4.0
 	github.com/spf13/cobra v1.4.0

+ 2 - 2
go.sum

@@ -688,8 +688,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
-github.com/sftpgo/sdk v0.1.1-0.20220412171743-453880fab5f7 h1:y1N2hPOqO1sTCwvtlKWrAiLBLOfThPuE17Tvju1wohs=
-github.com/sftpgo/sdk v0.1.1-0.20220412171743-453880fab5f7/go.mod h1:m5J7DH8unhD5RUsREFRiidP8zgBjup0+iQaxQnYHJOM=
+github.com/sftpgo/sdk v0.1.1-0.20220425123921-2f843a49e012 h1:PkryXZIb/Ncl64ZYej8WKZ0QXlqyuu+CG0IG0GEo3do=
+github.com/sftpgo/sdk v0.1.1-0.20220425123921-2f843a49e012/go.mod h1:m5J7DH8unhD5RUsREFRiidP8zgBjup0+iQaxQnYHJOM=
 github.com/shirou/gopsutil/v3 v3.22.3 h1:UebRzEomgMpv61e3hgD1tGooqX5trFbdU/ehphbHd00=
 github.com/shirou/gopsutil/v3 v3.22.3 h1:UebRzEomgMpv61e3hgD1tGooqX5trFbdU/ehphbHd00=
 github.com/shirou/gopsutil/v3 v3.22.3/go.mod h1:D01hZJ4pVHPpCTZ3m3T2+wDF2YAGfd+H4ifUguaQzHM=
 github.com/shirou/gopsutil/v3 v3.22.3/go.mod h1:D01hZJ4pVHPpCTZ3m3T2+wDF2YAGfd+H4ifUguaQzHM=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=

+ 6 - 5
httpd/api_folder.go

@@ -18,12 +18,12 @@ func getFolders(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	folders, err := dataprovider.GetFolders(limit, offset, order)
-	if err == nil {
-		render.JSON(w, r, folders)
-	} else {
+	folders, err := dataprovider.GetFolders(limit, offset, order, false)
+	if err != nil {
 		sendAPIResponse(w, r, err, "", http.StatusInternalServerError)
 		sendAPIResponse(w, r, err, "", http.StatusInternalServerError)
+		return
 	}
 	}
+	render.JSON(w, r, folders)
 }
 }
 
 
 func addFolder(w http.ResponseWriter, r *http.Request) {
 func addFolder(w http.ResponseWriter, r *http.Request) {
@@ -57,6 +57,7 @@ func updateFolder(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 	users := folder.Users
 	users := folder.Users
+	groups := folder.Groups
 	folderID := folder.ID
 	folderID := folder.ID
 	name = folder.Name
 	name = folder.Name
 	currentS3AccessSecret := folder.FsConfig.S3Config.AccessSecret
 	currentS3AccessSecret := folder.FsConfig.S3Config.AccessSecret
@@ -82,7 +83,7 @@ func updateFolder(w http.ResponseWriter, r *http.Request) {
 	folder.FsConfig.SetEmptySecretsIfNil()
 	folder.FsConfig.SetEmptySecretsIfNil()
 	updateEncryptedSecrets(&folder.FsConfig, currentS3AccessSecret, currentAzAccountKey, currentAzSASUrl, currentGCSCredentials,
 	updateEncryptedSecrets(&folder.FsConfig, currentS3AccessSecret, currentAzAccountKey, currentAzSASUrl, currentGCSCredentials,
 		currentCryptoPassphrase, currentSFTPPassword, currentSFTPKey)
 		currentCryptoPassphrase, currentSFTPPassword, currentSFTPKey)
-	err = dataprovider.UpdateFolder(&folder, users, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr))
+	err = dataprovider.UpdateFolder(&folder, users, groups, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr))
 	if err != nil {
 	if err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
 		return

+ 134 - 0
httpd/api_group.go

@@ -0,0 +1,134 @@
+package httpd
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/go-chi/render"
+
+	"github.com/drakkan/sftpgo/v2/dataprovider"
+	"github.com/drakkan/sftpgo/v2/util"
+	"github.com/drakkan/sftpgo/v2/vfs"
+)
+
+func getGroups(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	limit, offset, order, err := getSearchFilters(w, r)
+	if err != nil {
+		return
+	}
+
+	groups, err := dataprovider.GetGroups(limit, offset, order, false)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", http.StatusInternalServerError)
+		return
+	}
+	render.JSON(w, r, groups)
+}
+
+func addGroup(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
+		return
+	}
+	var group dataprovider.Group
+	err = render.DecodeJSON(r.Body, &group)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
+		return
+	}
+	err = dataprovider.AddGroup(&group, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr))
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	renderGroup(w, r, group.Name, http.StatusCreated)
+}
+
+func updateGroup(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
+		return
+	}
+
+	name := getURLParam(r, "name")
+	group, err := dataprovider.GroupExists(name)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	users := group.Users
+	groupID := group.ID
+	name = group.Name
+	currentS3AccessSecret := group.UserSettings.FsConfig.S3Config.AccessSecret
+	currentAzAccountKey := group.UserSettings.FsConfig.AzBlobConfig.AccountKey
+	currentAzSASUrl := group.UserSettings.FsConfig.AzBlobConfig.SASURL
+	currentGCSCredentials := group.UserSettings.FsConfig.GCSConfig.Credentials
+	currentCryptoPassphrase := group.UserSettings.FsConfig.CryptConfig.Passphrase
+	currentSFTPPassword := group.UserSettings.FsConfig.SFTPConfig.Password
+	currentSFTPKey := group.UserSettings.FsConfig.SFTPConfig.PrivateKey
+
+	group.UserSettings.FsConfig.S3Config = vfs.S3FsConfig{}
+	group.UserSettings.FsConfig.AzBlobConfig = vfs.AzBlobFsConfig{}
+	group.UserSettings.FsConfig.GCSConfig = vfs.GCSFsConfig{}
+	group.UserSettings.FsConfig.CryptConfig = vfs.CryptFsConfig{}
+	group.UserSettings.FsConfig.SFTPConfig = vfs.SFTPFsConfig{}
+	err = render.DecodeJSON(r.Body, &group)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
+		return
+	}
+	group.ID = groupID
+	group.Name = name
+	group.UserSettings.FsConfig.SetEmptySecretsIfNil()
+	updateEncryptedSecrets(&group.UserSettings.FsConfig, currentS3AccessSecret, currentAzAccountKey, currentAzSASUrl,
+		currentGCSCredentials, currentCryptoPassphrase, currentSFTPPassword, currentSFTPKey)
+	err = dataprovider.UpdateGroup(&group, users, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr))
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	sendAPIResponse(w, r, nil, "Group updated", http.StatusOK)
+}
+
+func renderGroup(w http.ResponseWriter, r *http.Request, name string, status int) {
+	group, err := dataprovider.GroupExists(name)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	group.PrepareForRendering()
+	if status != http.StatusOK {
+		ctx := context.WithValue(r.Context(), render.StatusCtxKey, status)
+		render.JSON(w, r.WithContext(ctx), group)
+	} else {
+		render.JSON(w, r, group)
+	}
+}
+
+func getGroupByName(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	name := getURLParam(r, "name")
+	renderGroup(w, r, name, http.StatusOK)
+}
+
+func deleteGroup(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
+		return
+	}
+	name := getURLParam(r, "name")
+	err = dataprovider.DeleteGroup(name, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr))
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	sendAPIResponse(w, r, err, "Group deleted", http.StatusOK)
+}

+ 9 - 9
httpd/api_http_user.go

@@ -26,7 +26,7 @@ func getUserConnection(w http.ResponseWriter, r *http.Request) (*Connection, err
 		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
 		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
 		return nil, fmt.Errorf("invalid token claims %w", err)
 		return nil, fmt.Errorf("invalid token claims %w", err)
 	}
 	}
-	user, err := dataprovider.UserExists(claims.Username)
+	user, err := dataprovider.GetUserWithGroupSettings(claims.Username)
 	if err != nil {
 	if err != nil {
 		sendAPIResponse(w, r, nil, "Unable to retrieve your user", getRespStatus(err))
 		sendAPIResponse(w, r, nil, "Unable to retrieve your user", getRespStatus(err))
 		return nil, err
 		return nil, err
@@ -461,22 +461,22 @@ func updateUserProfile(w http.ResponseWriter, r *http.Request) {
 		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
 		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
 		return
 		return
 	}
 	}
-	user, err := dataprovider.UserExists(claims.Username)
+	user, userMerged, err := dataprovider.GetUserVariants(claims.Username)
 	if err != nil {
 	if err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
 		return
 	}
 	}
-	if !user.CanManagePublicKeys() && !user.CanChangeAPIKeyAuth() && !user.CanChangeInfo() {
+	if !userMerged.CanManagePublicKeys() && !userMerged.CanChangeAPIKeyAuth() && !userMerged.CanChangeInfo() {
 		sendAPIResponse(w, r, nil, "You are not allowed to change anything", http.StatusForbidden)
 		sendAPIResponse(w, r, nil, "You are not allowed to change anything", http.StatusForbidden)
 		return
 		return
 	}
 	}
-	if user.CanManagePublicKeys() {
+	if userMerged.CanManagePublicKeys() {
 		user.PublicKeys = req.PublicKeys
 		user.PublicKeys = req.PublicKeys
 	}
 	}
-	if user.CanChangeAPIKeyAuth() {
+	if userMerged.CanChangeAPIKeyAuth() {
 		user.Filters.AllowAPIKeyAuth = req.AllowAPIKeyAuth
 		user.Filters.AllowAPIKeyAuth = req.AllowAPIKeyAuth
 	}
 	}
-	if user.CanChangeInfo() {
+	if userMerged.CanChangeInfo() {
 		user.Email = req.Email
 		user.Email = req.Email
 		user.Description = req.Description
 		user.Description = req.Description
 	}
 	}
@@ -518,14 +518,14 @@ func doChangeUserPassword(r *http.Request, currentPassword, newPassword, confirm
 	if err != nil || claims.Username == "" {
 	if err != nil || claims.Username == "" {
 		return errors.New("invalid token claims")
 		return errors.New("invalid token claims")
 	}
 	}
-	user, err := dataprovider.CheckUserAndPass(claims.Username, currentPassword, util.GetIPFromRemoteAddress(r.RemoteAddr),
+	_, err = dataprovider.CheckUserAndPass(claims.Username, currentPassword, util.GetIPFromRemoteAddress(r.RemoteAddr),
 		getProtocolFromRequest(r))
 		getProtocolFromRequest(r))
 	if err != nil {
 	if err != nil {
 		return util.NewValidationError("current password does not match")
 		return util.NewValidationError("current password does not match")
 	}
 	}
-	user.Password = newPassword
 
 
-	return dataprovider.UpdateUser(&user, dataprovider.ActionExecutorSelf, util.GetIPFromRemoteAddress(r.RemoteAddr))
+	return dataprovider.UpdateUserPassword(claims.Username, newPassword, dataprovider.ActionExecutorSelf,
+		util.GetIPFromRemoteAddress(r.RemoteAddr))
 }
 }
 
 
 func setModificationTimeFromHeader(r *http.Request, c *Connection, filePath string) {
 func setModificationTimeFromHeader(r *http.Request, c *Connection, filePath string) {

+ 41 - 20
httpd/api_maintenance.go

@@ -163,13 +163,17 @@ func loadData(w http.ResponseWriter, r *http.Request) {
 func restoreBackup(content []byte, inputFile string, scanQuota, mode int, executor, ipAddress string) error {
 func restoreBackup(content []byte, inputFile string, scanQuota, mode int, executor, ipAddress string) error {
 	dump, err := dataprovider.ParseDumpData(content)
 	dump, err := dataprovider.ParseDumpData(content)
 	if err != nil {
 	if err != nil {
-		return util.NewValidationError(fmt.Sprintf("Unable to parse backup content: %v", err))
+		return util.NewValidationError(fmt.Sprintf("unable to parse backup content: %v", err))
 	}
 	}
 
 
 	if err = RestoreFolders(dump.Folders, inputFile, mode, scanQuota, executor, ipAddress); err != nil {
 	if err = RestoreFolders(dump.Folders, inputFile, mode, scanQuota, executor, ipAddress); err != nil {
 		return err
 		return err
 	}
 	}
 
 
+	if err = RestoreGroups(dump.Groups, inputFile, mode, executor, ipAddress); err != nil {
+		return err
+	}
+
 	if err = RestoreUsers(dump.Users, inputFile, mode, scanQuota, executor, ipAddress); err != nil {
 	if err = RestoreUsers(dump.Users, inputFile, mode, scanQuota, executor, ipAddress); err != nil {
 		return err
 		return err
 	}
 	}
@@ -229,12 +233,12 @@ func RestoreFolders(folders []vfs.BaseVirtualFolder, inputFile string, mode, sca
 			}
 			}
 			folder.ID = f.ID
 			folder.ID = f.ID
 			folder.Name = f.Name
 			folder.Name = f.Name
-			err = dataprovider.UpdateFolder(&folder, f.Users, executor, ipAddress)
-			logger.Debug(logSender, "", "restoring existing folder: %+v, dump file: %#v, error: %v", folder, inputFile, err)
+			err = dataprovider.UpdateFolder(&folder, f.Users, f.Groups, executor, ipAddress)
+			logger.Debug(logSender, "", "restoring existing folder %#v, dump file: %#v, error: %v", folder.Name, inputFile, err)
 		} else {
 		} else {
 			folder.Users = nil
 			folder.Users = nil
 			err = dataprovider.AddFolder(&folder)
 			err = dataprovider.AddFolder(&folder)
-			logger.Debug(logSender, "", "adding new folder: %+v, dump file: %#v, error: %v", folder, inputFile, err)
+			logger.Debug(logSender, "", "adding new folder %#v, dump file: %#v, error: %v", folder.Name, inputFile, err)
 		}
 		}
 		if err != nil {
 		if err != nil {
 			return fmt.Errorf("unable to restore folder %#v: %w", folder.Name, err)
 			return fmt.Errorf("unable to restore folder %#v: %w", folder.Name, err)
@@ -264,12 +268,10 @@ func RestoreShares(shares []dataprovider.Share, inputFile string, mode int, exec
 			}
 			}
 			share.ID = s.ID
 			share.ID = s.ID
 			err = dataprovider.UpdateShare(&share, executor, ipAddress)
 			err = dataprovider.UpdateShare(&share, executor, ipAddress)
-			share.Password = redactedSecret
-			logger.Debug(logSender, "", "restoring existing share: %+v, dump file: %#v, error: %v", share, inputFile, err)
+			logger.Debug(logSender, "", "restoring existing share %#v, dump file: %#v, error: %v", share.ShareID, inputFile, err)
 		} else {
 		} else {
 			err = dataprovider.AddShare(&share, executor, ipAddress)
 			err = dataprovider.AddShare(&share, executor, ipAddress)
-			share.Password = redactedSecret
-			logger.Debug(logSender, "", "adding new share: %+v, dump file: %#v, error: %v", share, inputFile, err)
+			logger.Debug(logSender, "", "adding new share %#v, dump file: %#v, error: %v", share.ShareID, inputFile, err)
 		}
 		}
 		if err != nil {
 		if err != nil {
 			return fmt.Errorf("unable to restore share %#v: %w", share.ShareID, err)
 			return fmt.Errorf("unable to restore share %#v: %w", share.ShareID, err)
@@ -294,12 +296,10 @@ func RestoreAPIKeys(apiKeys []dataprovider.APIKey, inputFile string, mode int, e
 			}
 			}
 			apiKey.ID = k.ID
 			apiKey.ID = k.ID
 			err = dataprovider.UpdateAPIKey(&apiKey, executor, ipAddress)
 			err = dataprovider.UpdateAPIKey(&apiKey, executor, ipAddress)
-			apiKey.Key = redactedSecret
-			logger.Debug(logSender, "", "restoring existing API key: %+v, dump file: %#v, error: %v", apiKey, inputFile, err)
+			logger.Debug(logSender, "", "restoring existing API key %#v, dump file: %#v, error: %v", apiKey.KeyID, inputFile, err)
 		} else {
 		} else {
 			err = dataprovider.AddAPIKey(&apiKey, executor, ipAddress)
 			err = dataprovider.AddAPIKey(&apiKey, executor, ipAddress)
-			apiKey.Key = redactedSecret
-			logger.Debug(logSender, "", "adding new API key: %+v, dump file: %#v, error: %v", apiKey, inputFile, err)
+			logger.Debug(logSender, "", "adding new API key %#v, dump file: %#v, error: %v", apiKey.KeyID, inputFile, err)
 		}
 		}
 		if err != nil {
 		if err != nil {
 			return fmt.Errorf("unable to restore API key %#v: %w", apiKey.KeyID, err)
 			return fmt.Errorf("unable to restore API key %#v: %w", apiKey.KeyID, err)
@@ -321,12 +321,10 @@ func RestoreAdmins(admins []dataprovider.Admin, inputFile string, mode int, exec
 			admin.ID = a.ID
 			admin.ID = a.ID
 			admin.Username = a.Username
 			admin.Username = a.Username
 			err = dataprovider.UpdateAdmin(&admin, executor, ipAddress)
 			err = dataprovider.UpdateAdmin(&admin, executor, ipAddress)
-			admin.Password = redactedSecret
-			logger.Debug(logSender, "", "restoring existing admin: %+v, dump file: %#v, error: %v", admin, inputFile, err)
+			logger.Debug(logSender, "", "restoring existing admin %#v, dump file: %#v, error: %v", admin.Username, inputFile, err)
 		} else {
 		} else {
 			err = dataprovider.AddAdmin(&admin, executor, ipAddress)
 			err = dataprovider.AddAdmin(&admin, executor, ipAddress)
-			admin.Password = redactedSecret
-			logger.Debug(logSender, "", "adding new admin: %+v, dump file: %#v, error: %v", admin, inputFile, err)
+			logger.Debug(logSender, "", "adding new admin %#v, dump file: %#v, error: %v", admin.Username, inputFile, err)
 		}
 		}
 		if err != nil {
 		if err != nil {
 			return fmt.Errorf("unable to restore admin %#v: %w", admin.Username, err)
 			return fmt.Errorf("unable to restore admin %#v: %w", admin.Username, err)
@@ -336,6 +334,31 @@ func RestoreAdmins(admins []dataprovider.Admin, inputFile string, mode int, exec
 	return nil
 	return nil
 }
 }
 
 
+// RestoreGroups restores the specified groups
+func RestoreGroups(groups []dataprovider.Group, inputFile string, mode int, executor, ipAddress string) error {
+	for _, group := range groups {
+		group := group // pin
+		g, err := dataprovider.GroupExists(group.Name)
+		if err == nil {
+			if mode == 1 {
+				logger.Debug(logSender, "", "loaddata mode 1, existing group %#v not updated", g.Name)
+				continue
+			}
+			group.ID = g.ID
+			group.Name = g.Name
+			err = dataprovider.UpdateGroup(&group, g.Users, executor, ipAddress)
+			logger.Debug(logSender, "", "restoring existing group: %#v, dump file: %#v, error: %v", group.Name, inputFile, err)
+		} else {
+			err = dataprovider.AddGroup(&group, executor, ipAddress)
+			logger.Debug(logSender, "", "adding new group: %#v, dump file: %#v, error: %v", group.Name, inputFile, err)
+		}
+		if err != nil {
+			return fmt.Errorf("unable to restore group %#v: %w", group.Name, err)
+		}
+	}
+	return nil
+}
+
 // RestoreUsers restores the specified users
 // RestoreUsers restores the specified users
 func RestoreUsers(users []dataprovider.User, inputFile string, mode, scanQuota int, executor, ipAddress string) error {
 func RestoreUsers(users []dataprovider.User, inputFile string, mode, scanQuota int, executor, ipAddress string) error {
 	for _, user := range users {
 	for _, user := range users {
@@ -349,15 +372,13 @@ func RestoreUsers(users []dataprovider.User, inputFile string, mode, scanQuota i
 			user.ID = u.ID
 			user.ID = u.ID
 			user.Username = u.Username
 			user.Username = u.Username
 			err = dataprovider.UpdateUser(&user, executor, ipAddress)
 			err = dataprovider.UpdateUser(&user, executor, ipAddress)
-			user.Password = redactedSecret
-			logger.Debug(logSender, "", "restoring existing user: %+v, dump file: %#v, error: %v", user, inputFile, err)
+			logger.Debug(logSender, "", "restoring existing user: %#v, dump file: %#v, error: %v", user.Username, inputFile, err)
 			if mode == 2 && err == nil {
 			if mode == 2 && err == nil {
 				disconnectUser(user.Username)
 				disconnectUser(user.Username)
 			}
 			}
 		} else {
 		} else {
 			err = dataprovider.AddUser(&user, executor, ipAddress)
 			err = dataprovider.AddUser(&user, executor, ipAddress)
-			user.Password = redactedSecret
-			logger.Debug(logSender, "", "adding new user: %+v, dump file: %#v, error: %v", user, inputFile, err)
+			logger.Debug(logSender, "", "adding new user: %#v, dump file: %#v, error: %v", user.Username, inputFile, err)
 		}
 		}
 		if err != nil {
 		if err != nil {
 			return fmt.Errorf("unable to restore user %#v: %w", user.Username, err)
 			return fmt.Errorf("unable to restore user %#v: %w", user.Username, err)

+ 1 - 1
httpd/api_metadata.go

@@ -82,7 +82,7 @@ func getMetadataChecks(w http.ResponseWriter, r *http.Request) {
 func startMetadataCheck(w http.ResponseWriter, r *http.Request) {
 func startMetadataCheck(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 
 
-	user, err := dataprovider.UserExists(getURLParam(r, "username"))
+	user, err := dataprovider.GetUserWithGroupSettings(getURLParam(r, "username"))
 	if err != nil {
 	if err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
 		return

+ 3 - 3
httpd/api_quota.go

@@ -141,7 +141,7 @@ func updateUserTransferQuotaUsage(w http.ResponseWriter, r *http.Request) {
 		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
 		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
 		return
 		return
 	}
 	}
-	user, err := dataprovider.UserExists(getURLParam(r, "username"))
+	user, err := dataprovider.GetUserWithGroupSettings(getURLParam(r, "username"))
 	if err != nil {
 	if err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
 		return
@@ -171,7 +171,7 @@ func doUpdateUserQuotaUsage(w http.ResponseWriter, r *http.Request, username str
 		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
 		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
 		return
 		return
 	}
 	}
-	user, err := dataprovider.UserExists(username)
+	user, err := dataprovider.GetUserWithGroupSettings(username)
 	if err != nil {
 	if err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
 		return
@@ -228,7 +228,7 @@ func doStartUserQuotaScan(w http.ResponseWriter, r *http.Request, username strin
 		sendAPIResponse(w, r, nil, "Quota tracking is disabled!", http.StatusForbidden)
 		sendAPIResponse(w, r, nil, "Quota tracking is disabled!", http.StatusForbidden)
 		return
 		return
 	}
 	}
-	user, err := dataprovider.UserExists(username)
+	user, err := dataprovider.GetUserWithGroupSettings(username)
 	if err != nil {
 	if err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
 		return

+ 1 - 1
httpd/api_retention.go

@@ -18,7 +18,7 @@ func getRetentionChecks(w http.ResponseWriter, r *http.Request) {
 func startRetentionCheck(w http.ResponseWriter, r *http.Request) {
 func startRetentionCheck(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	username := getURLParam(r, "username")
 	username := getURLParam(r, "username")
-	user, err := dataprovider.UserExists(username)
+	user, err := dataprovider.GetUserWithGroupSettings(username)
 	if err != nil {
 	if err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
 		return

+ 3 - 3
httpd/api_shares.go

@@ -406,7 +406,7 @@ func (s *httpdServer) checkPublicShare(w http.ResponseWriter, r *http.Request, s
 			return share, nil, dataprovider.ErrInvalidCredentials
 			return share, nil, dataprovider.ErrInvalidCredentials
 		}
 		}
 	}
 	}
-	user, err := dataprovider.UserExists(share.Username)
+	user, err := dataprovider.GetUserWithGroupSettings(share.Username)
 	if err != nil {
 	if err != nil {
 		renderError(err, "", getRespStatus(err))
 		renderError(err, "", getRespStatus(err))
 		return share, nil, err
 		return share, nil, err
@@ -428,7 +428,7 @@ func (s *httpdServer) checkPublicShare(w http.ResponseWriter, r *http.Request, s
 
 
 func validateBrowsableShare(share dataprovider.Share, connection *Connection) error {
 func validateBrowsableShare(share dataprovider.Share, connection *Connection) error {
 	if len(share.Paths) != 1 {
 	if len(share.Paths) != 1 {
-		return util.NewValidationError("A share with multiple paths is not browsable")
+		return util.NewValidationError("a share with multiple paths is not browsable")
 	}
 	}
 	basePath := share.Paths[0]
 	basePath := share.Paths[0]
 	info, err := connection.Stat(basePath, 0)
 	info, err := connection.Stat(basePath, 0)
@@ -436,7 +436,7 @@ func validateBrowsableShare(share dataprovider.Share, connection *Connection) er
 		return fmt.Errorf("unable to check the share directory: %w", err)
 		return fmt.Errorf("unable to check the share directory: %w", err)
 	}
 	}
 	if !info.IsDir() {
 	if !info.IsDir() {
-		return util.NewValidationError("The shared object is not a directory and so it is not browsable")
+		return util.NewValidationError("the shared object is not a directory and so it is not browsable")
 	}
 	}
 	return nil
 	return nil
 }
 }

+ 26 - 26
httpd/api_utils.go

@@ -529,19 +529,19 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
 	var user dataprovider.User
 	var user dataprovider.User
 
 
 	if username == "" {
 	if username == "" {
-		return util.NewValidationError("Username is mandatory")
+		return util.NewValidationError("username is mandatory")
 	}
 	}
 	if isAdmin {
 	if isAdmin {
 		admin, err = dataprovider.AdminExists(username)
 		admin, err = dataprovider.AdminExists(username)
 		email = admin.Email
 		email = admin.Email
 		subject = fmt.Sprintf("Email Verification Code for admin %#v", username)
 		subject = fmt.Sprintf("Email Verification Code for admin %#v", username)
 	} else {
 	} else {
-		user, err = dataprovider.UserExists(username)
+		user, err = dataprovider.GetUserWithGroupSettings(username)
 		email = user.Email
 		email = user.Email
 		subject = fmt.Sprintf("Email Verification Code for user %#v", username)
 		subject = fmt.Sprintf("Email Verification Code for user %#v", username)
 		if err == nil {
 		if err == nil {
 			if !isUserAllowedToResetPassword(r, &user) {
 			if !isUserAllowedToResetPassword(r, &user) {
-				return util.NewValidationError("You are not allowed to reset your password")
+				return util.NewValidationError("you are not allowed to reset your password")
 			}
 			}
 		}
 		}
 	}
 	}
@@ -584,47 +584,47 @@ func handleResetPassword(r *http.Request, code, newPassword string, isAdmin bool
 	var err error
 	var err error
 
 
 	if newPassword == "" {
 	if newPassword == "" {
-		return &admin, &user, util.NewValidationError("Please set a password")
+		return &admin, &user, util.NewValidationError("please set a password")
 	}
 	}
 	if code == "" {
 	if code == "" {
-		return &admin, &user, util.NewValidationError("Please set a confirmation code")
+		return &admin, &user, util.NewValidationError("please set a confirmation code")
 	}
 	}
 	c, ok := resetCodes.Load(code)
 	c, ok := resetCodes.Load(code)
 	if !ok {
 	if !ok {
-		return &admin, &user, util.NewValidationError("Confirmation code not found")
+		return &admin, &user, util.NewValidationError("confirmation code not found")
 	}
 	}
 	resetCode := c.(*resetCode)
 	resetCode := c.(*resetCode)
 	if resetCode.IsAdmin != isAdmin {
 	if resetCode.IsAdmin != isAdmin {
-		return &admin, &user, util.NewValidationError("Invalid confirmation code")
+		return &admin, &user, util.NewValidationError("invalid confirmation code")
 	}
 	}
 	if isAdmin {
 	if isAdmin {
 		admin, err = dataprovider.AdminExists(resetCode.Username)
 		admin, err = dataprovider.AdminExists(resetCode.Username)
 		if err != nil {
 		if err != nil {
-			return &admin, &user, util.NewValidationError("Unable to associate the confirmation code with an existing admin")
+			return &admin, &user, util.NewValidationError("unable to associate the confirmation code with an existing admin")
 		}
 		}
 		admin.Password = newPassword
 		admin.Password = newPassword
-		err = dataprovider.UpdateAdmin(&admin, admin.Username, util.GetIPFromRemoteAddress(r.RemoteAddr))
+		err = dataprovider.UpdateAdmin(&admin, dataprovider.ActionExecutorSelf, util.GetIPFromRemoteAddress(r.RemoteAddr))
 		if err != nil {
 		if err != nil {
-			return &admin, &user, util.NewGenericError(fmt.Sprintf("Unable to set the new password: %v", err))
+			return &admin, &user, util.NewGenericError(fmt.Sprintf("unable to set the new password: %v", err))
 		}
 		}
-	} else {
-		user, err = dataprovider.UserExists(resetCode.Username)
-		if err != nil {
-			return &admin, &user, util.NewValidationError("Unable to associate the confirmation code with an existing user")
-		}
-		if err == nil {
-			if !isUserAllowedToResetPassword(r, &user) {
-				return &admin, &user, util.NewValidationError("You are not allowed to reset your password")
-			}
-		}
-		user.Password = newPassword
-		err = dataprovider.UpdateUser(&user, user.Username, util.GetIPFromRemoteAddress(r.RemoteAddr))
-		if err != nil {
-			return &admin, &user, util.NewGenericError(fmt.Sprintf("Unable to set the new password: %v", err))
+		resetCodes.Delete(code)
+		return &admin, &user, nil
+	}
+	user, err = dataprovider.GetUserWithGroupSettings(resetCode.Username)
+	if err != nil {
+		return &admin, &user, util.NewValidationError("Unable to associate the confirmation code with an existing user")
+	}
+	if err == nil {
+		if !isUserAllowedToResetPassword(r, &user) {
+			return &admin, &user, util.NewValidationError("you are not allowed to reset your password")
 		}
 		}
 	}
 	}
-	resetCodes.Delete(code)
-	return &admin, &user, nil
+	err = dataprovider.UpdateUserPassword(user.Username, newPassword, dataprovider.ActionExecutorSelf,
+		util.GetIPFromRemoteAddress(r.RemoteAddr))
+	if err == nil {
+		resetCodes.Delete(code)
+	}
+	return &admin, &user, err
 }
 }
 
 
 func isUserAllowedToResetPassword(r *http.Request, user *dataprovider.User) bool {
 func isUserAllowedToResetPassword(r *http.Request, user *dataprovider.User) bool {

+ 7 - 0
httpd/httpd.go

@@ -42,6 +42,7 @@ const (
 	userPath                              = "/api/v2/users"
 	userPath                              = "/api/v2/users"
 	versionPath                           = "/api/v2/version"
 	versionPath                           = "/api/v2/version"
 	folderPath                            = "/api/v2/folders"
 	folderPath                            = "/api/v2/folders"
+	groupPath                             = "/api/v2/groups"
 	serverStatusPath                      = "/api/v2/status"
 	serverStatusPath                      = "/api/v2/status"
 	dumpDataPath                          = "/api/v2/dumpdata"
 	dumpDataPath                          = "/api/v2/dumpdata"
 	loadDataPath                          = "/api/v2/loaddata"
 	loadDataPath                          = "/api/v2/loaddata"
@@ -101,6 +102,8 @@ const (
 	webConnectionsPathDefault             = "/web/admin/connections"
 	webConnectionsPathDefault             = "/web/admin/connections"
 	webFoldersPathDefault                 = "/web/admin/folders"
 	webFoldersPathDefault                 = "/web/admin/folders"
 	webFolderPathDefault                  = "/web/admin/folder"
 	webFolderPathDefault                  = "/web/admin/folder"
+	webGroupsPathDefault                  = "/web/admin/groups"
+	webGroupPathDefault                   = "/web/admin/group"
 	webStatusPathDefault                  = "/web/admin/status"
 	webStatusPathDefault                  = "/web/admin/status"
 	webAdminsPathDefault                  = "/web/admin/managers"
 	webAdminsPathDefault                  = "/web/admin/managers"
 	webAdminPathDefault                   = "/web/admin/manager"
 	webAdminPathDefault                   = "/web/admin/manager"
@@ -180,6 +183,8 @@ var (
 	webConnectionsPath             string
 	webConnectionsPath             string
 	webFoldersPath                 string
 	webFoldersPath                 string
 	webFolderPath                  string
 	webFolderPath                  string
+	webGroupsPath                  string
+	webGroupPath                   string
 	webStatusPath                  string
 	webStatusPath                  string
 	webAdminsPath                  string
 	webAdminsPath                  string
 	webAdminPath                   string
 	webAdminPath                   string
@@ -764,6 +769,8 @@ func updateWebAdminURLs(baseURL string) {
 	webConnectionsPath = path.Join(baseURL, webConnectionsPathDefault)
 	webConnectionsPath = path.Join(baseURL, webConnectionsPathDefault)
 	webFoldersPath = path.Join(baseURL, webFoldersPathDefault)
 	webFoldersPath = path.Join(baseURL, webFoldersPathDefault)
 	webFolderPath = path.Join(baseURL, webFolderPathDefault)
 	webFolderPath = path.Join(baseURL, webFolderPathDefault)
+	webGroupsPath = path.Join(baseURL, webGroupsPathDefault)
+	webGroupPath = path.Join(baseURL, webGroupPathDefault)
 	webStatusPath = path.Join(baseURL, webStatusPathDefault)
 	webStatusPath = path.Join(baseURL, webStatusPathDefault)
 	webAdminsPath = path.Join(baseURL, webAdminsPathDefault)
 	webAdminsPath = path.Join(baseURL, webAdminsPathDefault)
 	webAdminPath = path.Join(baseURL, webAdminPathDefault)
 	webAdminPath = path.Join(baseURL, webAdminPathDefault)

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 848 - 13
httpd/httpd_test.go


+ 25 - 0
httpd/internal_test.go

@@ -593,11 +593,36 @@ func TestInvalidToken(t *testing.T) {
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	assert.Contains(t, rr.Body.String(), "Invalid token claims")
 	assert.Contains(t, rr.Body.String(), "Invalid token claims")
 
 
+	rr = httptest.NewRecorder()
+	addGroup(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
+	rr = httptest.NewRecorder()
+	updateGroup(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
+	rr = httptest.NewRecorder()
+	deleteGroup(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
 	rr = httptest.NewRecorder()
 	rr = httptest.NewRecorder()
 	server.handleWebAddAdminPost(rr, req)
 	server.handleWebAddAdminPost(rr, req)
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	assert.Contains(t, rr.Body.String(), "invalid token claims")
 	assert.Contains(t, rr.Body.String(), "invalid token claims")
 
 
+	rr = httptest.NewRecorder()
+	server.handleWebAddGroupPost(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "invalid token claims")
+
+	rr = httptest.NewRecorder()
+	server.handleWebUpdateGroupPost(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "invalid token claims")
+
 	rr = httptest.NewRecorder()
 	rr = httptest.NewRecorder()
 	server.handleWebClientTwoFactorRecoveryPost(rr, req)
 	server.handleWebClientTwoFactorRecoveryPost(rr, req)
 	assert.Equal(t, http.StatusNotFound, rr.Code)
 	assert.Equal(t, http.StatusNotFound, rr.Code)

+ 1 - 1
httpd/middleware.go

@@ -418,7 +418,7 @@ func authenticateUserWithAPIKey(username, keyID string, tokenAuth *jwtauth.JWTAu
 	if err := common.Config.ExecutePostConnectHook(ipAddr, protocol); err != nil {
 	if err := common.Config.ExecutePostConnectHook(ipAddr, protocol); err != nil {
 		return err
 		return err
 	}
 	}
-	user, err := dataprovider.UserExists(username)
+	user, err := dataprovider.GetUserWithGroupSettings(username)
 	if err != nil {
 	if err != nil {
 		updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}},
 		updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}},
 			dataprovider.LoginMethodPassword, ipAddr, err)
 			dataprovider.LoginMethodPassword, ipAddr, err)

+ 21 - 9
httpd/server.go

@@ -260,10 +260,6 @@ func (s *httpdServer) handleWebClientPasswordResetPost(w http.ResponseWriter, r
 	}
 	}
 	_, user, err := handleResetPassword(r, r.Form.Get("code"), r.Form.Get("password"), false)
 	_, user, err := handleResetPassword(r, r.Form.Get("code"), r.Form.Get("password"), false)
 	if err != nil {
 	if err != nil {
-		if e, ok := err.(*util.ValidationError); ok {
-			s.renderClientResetPwdPage(w, e.GetErrorString(), ipAddr)
-			return
-		}
 		s.renderClientResetPwdPage(w, err.Error(), ipAddr)
 		s.renderClientResetPwdPage(w, err.Error(), ipAddr)
 		return
 		return
 	}
 	}
@@ -305,12 +301,12 @@ func (s *httpdServer) handleWebClientTwoFactorRecoveryPost(w http.ResponseWriter
 		s.renderClientTwoFactorRecoveryPage(w, err.Error(), ipAddr)
 		s.renderClientTwoFactorRecoveryPage(w, err.Error(), ipAddr)
 		return
 		return
 	}
 	}
-	user, err := dataprovider.UserExists(username)
+	user, userMerged, err := dataprovider.GetUserVariants(username)
 	if err != nil {
 	if err != nil {
 		s.renderClientTwoFactorRecoveryPage(w, "Invalid credentials", ipAddr)
 		s.renderClientTwoFactorRecoveryPage(w, "Invalid credentials", ipAddr)
 		return
 		return
 	}
 	}
-	if !user.Filters.TOTPConfig.Enabled || !util.IsStringInSlice(common.ProtocolHTTP, user.Filters.TOTPConfig.Protocols) {
+	if !userMerged.Filters.TOTPConfig.Enabled || !util.IsStringInSlice(common.ProtocolHTTP, userMerged.Filters.TOTPConfig.Protocols) {
 		s.renderClientTwoFactorPage(w, "Two factory authentication is not enabled", ipAddr)
 		s.renderClientTwoFactorPage(w, "Two factory authentication is not enabled", ipAddr)
 		return
 		return
 	}
 	}
@@ -332,7 +328,7 @@ func (s *httpdServer) handleWebClientTwoFactorRecoveryPost(w http.ResponseWriter
 				return
 				return
 			}
 			}
 			connectionID := fmt.Sprintf("%v_%v", getProtocolFromRequest(r), xid.New().String())
 			connectionID := fmt.Sprintf("%v_%v", getProtocolFromRequest(r), xid.New().String())
-			s.loginUser(w, r, &user, connectionID, ipAddr, true,
+			s.loginUser(w, r, &userMerged, connectionID, ipAddr, true,
 				s.renderClientTwoFactorRecoveryPage)
 				s.renderClientTwoFactorRecoveryPage)
 			return
 			return
 		}
 		}
@@ -362,7 +358,7 @@ func (s *httpdServer) handleWebClientTwoFactorPost(w http.ResponseWriter, r *htt
 		s.renderClientTwoFactorPage(w, err.Error(), ipAddr)
 		s.renderClientTwoFactorPage(w, err.Error(), ipAddr)
 		return
 		return
 	}
 	}
-	user, err := dataprovider.UserExists(username)
+	user, err := dataprovider.GetUserWithGroupSettings(username)
 	if err != nil {
 	if err != nil {
 		s.renderClientTwoFactorPage(w, "Invalid credentials", ipAddr)
 		s.renderClientTwoFactorPage(w, "Invalid credentials", ipAddr)
 		return
 		return
@@ -912,7 +908,7 @@ func (s *httpdServer) checkCookieExpiration(w http.ResponseWriter, r *http.Reque
 }
 }
 
 
 func (s *httpdServer) refreshClientToken(w http.ResponseWriter, r *http.Request, tokenClaims jwtTokenClaims) {
 func (s *httpdServer) refreshClientToken(w http.ResponseWriter, r *http.Request, tokenClaims jwtTokenClaims) {
-	user, err := dataprovider.UserExists(tokenClaims.Username)
+	user, err := dataprovider.GetUserWithGroupSettings(tokenClaims.Username)
 	if err != nil {
 	if err != nil {
 		return
 		return
 	}
 	}
@@ -1211,6 +1207,11 @@ func (s *httpdServer) initializeRouter() {
 		router.With(s.checkPerm(dataprovider.PermAdminAddUsers)).Post(folderPath, addFolder)
 		router.With(s.checkPerm(dataprovider.PermAdminAddUsers)).Post(folderPath, addFolder)
 		router.With(s.checkPerm(dataprovider.PermAdminChangeUsers)).Put(folderPath+"/{name}", updateFolder)
 		router.With(s.checkPerm(dataprovider.PermAdminChangeUsers)).Put(folderPath+"/{name}", updateFolder)
 		router.With(s.checkPerm(dataprovider.PermAdminDeleteUsers)).Delete(folderPath+"/{name}", deleteFolder)
 		router.With(s.checkPerm(dataprovider.PermAdminDeleteUsers)).Delete(folderPath+"/{name}", deleteFolder)
+		router.With(s.checkPerm(dataprovider.PermAdminManageGroups)).Get(groupPath, getGroups)
+		router.With(s.checkPerm(dataprovider.PermAdminManageGroups)).Get(groupPath+"/{name}", getGroupByName)
+		router.With(s.checkPerm(dataprovider.PermAdminManageGroups)).Post(groupPath, addGroup)
+		router.With(s.checkPerm(dataprovider.PermAdminManageGroups)).Put(groupPath+"/{name}", updateGroup)
+		router.With(s.checkPerm(dataprovider.PermAdminManageGroups)).Delete(groupPath+"/{name}", deleteGroup)
 		router.With(s.checkPerm(dataprovider.PermAdminManageSystem)).Get(dumpDataPath, dumpData)
 		router.With(s.checkPerm(dataprovider.PermAdminManageSystem)).Get(dumpDataPath, dumpData)
 		router.With(s.checkPerm(dataprovider.PermAdminManageSystem)).Get(loadDataPath, loadData)
 		router.With(s.checkPerm(dataprovider.PermAdminManageSystem)).Get(loadDataPath, loadData)
 		router.With(s.checkPerm(dataprovider.PermAdminManageSystem)).Post(loadDataPath, loadDataFromRequest)
 		router.With(s.checkPerm(dataprovider.PermAdminManageSystem)).Post(loadDataPath, loadDataFromRequest)
@@ -1519,6 +1520,17 @@ func (s *httpdServer) setupWebAdminRoutes() {
 			router.With(s.checkPerm(dataprovider.PermAdminAddUsers)).Post(webUserPath, s.handleWebAddUserPost)
 			router.With(s.checkPerm(dataprovider.PermAdminAddUsers)).Post(webUserPath, s.handleWebAddUserPost)
 			router.With(s.checkPerm(dataprovider.PermAdminChangeUsers)).Post(webUserPath+"/{username}",
 			router.With(s.checkPerm(dataprovider.PermAdminChangeUsers)).Post(webUserPath+"/{username}",
 				s.handleWebUpdateUserPost)
 				s.handleWebUpdateUserPost)
+			router.With(s.checkPerm(dataprovider.PermAdminManageGroups), s.refreshCookie).
+				Get(webGroupsPath, s.handleWebGetGroups)
+			router.With(s.checkPerm(dataprovider.PermAdminManageGroups), s.refreshCookie).
+				Get(webGroupPath, s.handleWebAddGroupGet)
+			router.With(s.checkPerm(dataprovider.PermAdminManageGroups)).Post(webGroupPath, s.handleWebAddGroupPost)
+			router.With(s.checkPerm(dataprovider.PermAdminManageGroups), s.refreshCookie).
+				Get(webGroupPath+"/{name}", s.handleWebUpdateGroupGet)
+			router.With(s.checkPerm(dataprovider.PermAdminManageGroups)).Post(webGroupPath+"/{name}",
+				s.handleWebUpdateGroupPost)
+			router.With(s.checkPerm(dataprovider.PermAdminManageGroups), verifyCSRFHeader).
+				Delete(webGroupPath+"/{name}", deleteGroup)
 			router.With(s.checkPerm(dataprovider.PermAdminViewConnections), s.refreshCookie).
 			router.With(s.checkPerm(dataprovider.PermAdminViewConnections), s.refreshCookie).
 				Get(webConnectionsPath, s.handleWebGetConnections)
 				Get(webConnectionsPath, s.handleWebGetConnections)
 			router.With(s.checkPerm(dataprovider.PermAdminViewUsers), s.refreshCookie).
 			router.With(s.checkPerm(dataprovider.PermAdminViewUsers), s.refreshCookie).

+ 401 - 70
httpd/webadmin.go

@@ -43,40 +43,51 @@ const (
 	folderPageModeTemplate
 	folderPageModeTemplate
 )
 )
 
 
+type groupPageMode int
+
+const (
+	groupPageModeAdd groupPageMode = iota + 1
+	groupPageModeUpdate
+)
+
 const (
 const (
-	templateAdminDir     = "webadmin"
-	templateBase         = "base.html"
-	templateBaseLogin    = "baselogin.html"
-	templateFsConfig     = "fsconfig.html"
-	templateUsers        = "users.html"
-	templateUser         = "user.html"
-	templateAdmins       = "admins.html"
-	templateAdmin        = "admin.html"
-	templateConnections  = "connections.html"
-	templateFolders      = "folders.html"
-	templateFolder       = "folder.html"
-	templateMessage      = "message.html"
-	templateStatus       = "status.html"
-	templateLogin        = "login.html"
-	templateDefender     = "defender.html"
-	templateProfile      = "profile.html"
-	templateChangePwd    = "changepassword.html"
-	templateMaintenance  = "maintenance.html"
-	templateMFA          = "mfa.html"
-	templateSetup        = "adminsetup.html"
-	pageUsersTitle       = "Users"
-	pageAdminsTitle      = "Admins"
-	pageConnectionsTitle = "Connections"
-	pageStatusTitle      = "Status"
-	pageFoldersTitle     = "Folders"
-	pageProfileTitle     = "My profile"
-	pageChangePwdTitle   = "Change password"
-	pageMaintenanceTitle = "Maintenance"
-	pageDefenderTitle    = "Defender"
-	pageForgotPwdTitle   = "SFTPGo Admin - Forgot password"
-	pageResetPwdTitle    = "SFTPGo Admin - Reset password"
-	pageSetupTitle       = "Create first admin user"
-	defaultQueryLimit    = 500
+	templateAdminDir         = "webadmin"
+	templateBase             = "base.html"
+	templateBaseLogin        = "baselogin.html"
+	templateFsConfig         = "fsconfig.html"
+	templateSharedComponents = "sharedcomponents.html"
+	templateUsers            = "users.html"
+	templateUser             = "user.html"
+	templateAdmins           = "admins.html"
+	templateAdmin            = "admin.html"
+	templateConnections      = "connections.html"
+	templateGroups           = "groups.html"
+	templateGroup            = "group.html"
+	templateFolders          = "folders.html"
+	templateFolder           = "folder.html"
+	templateMessage          = "message.html"
+	templateStatus           = "status.html"
+	templateLogin            = "login.html"
+	templateDefender         = "defender.html"
+	templateProfile          = "profile.html"
+	templateChangePwd        = "changepassword.html"
+	templateMaintenance      = "maintenance.html"
+	templateMFA              = "mfa.html"
+	templateSetup            = "adminsetup.html"
+	pageUsersTitle           = "Users"
+	pageAdminsTitle          = "Admins"
+	pageConnectionsTitle     = "Connections"
+	pageStatusTitle          = "Status"
+	pageFoldersTitle         = "Folders"
+	pageGroupsTitle          = "Groups"
+	pageProfileTitle         = "My profile"
+	pageChangePwdTitle       = "Change password"
+	pageMaintenanceTitle     = "Maintenance"
+	pageDefenderTitle        = "Defender"
+	pageForgotPwdTitle       = "SFTPGo Admin - Forgot password"
+	pageResetPwdTitle        = "SFTPGo Admin - Reset password"
+	pageSetupTitle           = "Create first admin user"
+	defaultQueryLimit        = 500
 )
 )
 
 
 var (
 var (
@@ -93,6 +104,8 @@ type basePage struct {
 	AdminURL           string
 	AdminURL           string
 	QuotaScanURL       string
 	QuotaScanURL       string
 	ConnectionsURL     string
 	ConnectionsURL     string
+	GroupsURL          string
+	GroupURL           string
 	FoldersURL         string
 	FoldersURL         string
 	FolderURL          string
 	FolderURL          string
 	FolderTemplateURL  string
 	FolderTemplateURL  string
@@ -109,6 +122,7 @@ type basePage struct {
 	AdminsTitle        string
 	AdminsTitle        string
 	ConnectionsTitle   string
 	ConnectionsTitle   string
 	FoldersTitle       string
 	FoldersTitle       string
+	GroupsTitle        string
 	StatusTitle        string
 	StatusTitle        string
 	MaintenanceTitle   string
 	MaintenanceTitle   string
 	DefenderTitle      string
 	DefenderTitle      string
@@ -135,6 +149,11 @@ type foldersPage struct {
 	Folders []vfs.BaseVirtualFolder
 	Folders []vfs.BaseVirtualFolder
 }
 }
 
 
+type groupsPage struct {
+	basePage
+	Groups []dataprovider.Group
+}
+
 type connectionsPage struct {
 type connectionsPage struct {
 	basePage
 	basePage
 	Connections []common.ConnectionStatus
 	Connections []common.ConnectionStatus
@@ -148,6 +167,7 @@ type statusPage struct {
 type fsWrapper struct {
 type fsWrapper struct {
 	vfs.Filesystem
 	vfs.Filesystem
 	IsUserPage      bool
 	IsUserPage      bool
+	IsGroupPage     bool
 	HasUsersBaseDir bool
 	HasUsersBaseDir bool
 	DirPath         string
 	DirPath         string
 }
 }
@@ -166,6 +186,7 @@ type userPage struct {
 	RedactedSecret     string
 	RedactedSecret     string
 	Mode               userPageMode
 	Mode               userPageMode
 	VirtualFolders     []vfs.BaseVirtualFolder
 	VirtualFolders     []vfs.BaseVirtualFolder
+	Groups             []dataprovider.Group
 	CanImpersonate     bool
 	CanImpersonate     bool
 	FsWrapper          fsWrapper
 	FsWrapper          fsWrapper
 }
 }
@@ -228,6 +249,20 @@ type folderPage struct {
 	FsWrapper fsWrapper
 	FsWrapper fsWrapper
 }
 }
 
 
+type groupPage struct {
+	basePage
+	Group              *dataprovider.Group
+	Error              string
+	Mode               groupPageMode
+	ValidPerms         []string
+	ValidLoginMethods  []string
+	ValidProtocols     []string
+	TwoFactorProtocols []string
+	WebClientOptions   []string
+	VirtualFolders     []vfs.BaseVirtualFolder
+	FsWrapper          fsWrapper
+}
+
 type messagePage struct {
 type messagePage struct {
 	basePage
 	basePage
 	Error   string
 	Error   string
@@ -247,6 +282,7 @@ func loadAdminTemplates(templatesPath string) {
 	}
 	}
 	userPaths := []string{
 	userPaths := []string{
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
+		filepath.Join(templatesPath, templateAdminDir, templateSharedComponents),
 		filepath.Join(templatesPath, templateAdminDir, templateFsConfig),
 		filepath.Join(templatesPath, templateAdminDir, templateFsConfig),
 		filepath.Join(templatesPath, templateAdminDir, templateUser),
 		filepath.Join(templatesPath, templateAdminDir, templateUser),
 	}
 	}
@@ -283,6 +319,16 @@ func loadAdminTemplates(templatesPath string) {
 		filepath.Join(templatesPath, templateAdminDir, templateFsConfig),
 		filepath.Join(templatesPath, templateAdminDir, templateFsConfig),
 		filepath.Join(templatesPath, templateAdminDir, templateFolder),
 		filepath.Join(templatesPath, templateAdminDir, templateFolder),
 	}
 	}
+	groupsPaths := []string{
+		filepath.Join(templatesPath, templateAdminDir, templateBase),
+		filepath.Join(templatesPath, templateAdminDir, templateGroups),
+	}
+	groupPaths := []string{
+		filepath.Join(templatesPath, templateAdminDir, templateBase),
+		filepath.Join(templatesPath, templateAdminDir, templateFsConfig),
+		filepath.Join(templatesPath, templateAdminDir, templateSharedComponents),
+		filepath.Join(templatesPath, templateAdminDir, templateGroup),
+	}
 	statusPaths := []string{
 	statusPaths := []string{
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateStatus),
 		filepath.Join(templatesPath, templateAdminDir, templateStatus),
@@ -336,6 +382,8 @@ func loadAdminTemplates(templatesPath string) {
 	adminTmpl := util.LoadTemplate(nil, adminPaths...)
 	adminTmpl := util.LoadTemplate(nil, adminPaths...)
 	connectionsTmpl := util.LoadTemplate(nil, connectionsPaths...)
 	connectionsTmpl := util.LoadTemplate(nil, connectionsPaths...)
 	messageTmpl := util.LoadTemplate(nil, messagePaths...)
 	messageTmpl := util.LoadTemplate(nil, messagePaths...)
+	groupsTmpl := util.LoadTemplate(nil, groupsPaths...)
+	groupTmpl := util.LoadTemplate(fsBaseTpl, groupPaths...)
 	foldersTmpl := util.LoadTemplate(nil, foldersPaths...)
 	foldersTmpl := util.LoadTemplate(nil, foldersPaths...)
 	folderTmpl := util.LoadTemplate(fsBaseTpl, folderPaths...)
 	folderTmpl := util.LoadTemplate(fsBaseTpl, folderPaths...)
 	statusTmpl := util.LoadTemplate(nil, statusPaths...)
 	statusTmpl := util.LoadTemplate(nil, statusPaths...)
@@ -357,6 +405,8 @@ func loadAdminTemplates(templatesPath string) {
 	adminTemplates[templateAdmin] = adminTmpl
 	adminTemplates[templateAdmin] = adminTmpl
 	adminTemplates[templateConnections] = connectionsTmpl
 	adminTemplates[templateConnections] = connectionsTmpl
 	adminTemplates[templateMessage] = messageTmpl
 	adminTemplates[templateMessage] = messageTmpl
+	adminTemplates[templateGroups] = groupsTmpl
+	adminTemplates[templateGroup] = groupTmpl
 	adminTemplates[templateFolders] = foldersTmpl
 	adminTemplates[templateFolders] = foldersTmpl
 	adminTemplates[templateFolder] = folderTmpl
 	adminTemplates[templateFolder] = folderTmpl
 	adminTemplates[templateStatus] = statusTmpl
 	adminTemplates[templateStatus] = statusTmpl
@@ -386,6 +436,8 @@ func (s *httpdServer) getBasePageData(title, currentURL string, r *http.Request)
 		UserTemplateURL:    webTemplateUser,
 		UserTemplateURL:    webTemplateUser,
 		AdminsURL:          webAdminsPath,
 		AdminsURL:          webAdminsPath,
 		AdminURL:           webAdminPath,
 		AdminURL:           webAdminPath,
+		GroupsURL:          webGroupsPath,
+		GroupURL:           webGroupPath,
 		FoldersURL:         webFoldersPath,
 		FoldersURL:         webFoldersPath,
 		FolderURL:          webFolderPath,
 		FolderURL:          webFolderPath,
 		FolderTemplateURL:  webTemplateFolder,
 		FolderTemplateURL:  webTemplateFolder,
@@ -404,6 +456,7 @@ func (s *httpdServer) getBasePageData(title, currentURL string, r *http.Request)
 		AdminsTitle:        pageAdminsTitle,
 		AdminsTitle:        pageAdminsTitle,
 		ConnectionsTitle:   pageConnectionsTitle,
 		ConnectionsTitle:   pageConnectionsTitle,
 		FoldersTitle:       pageFoldersTitle,
 		FoldersTitle:       pageFoldersTitle,
+		GroupsTitle:        pageGroupsTitle,
 		StatusTitle:        pageStatusTitle,
 		StatusTitle:        pageStatusTitle,
 		MaintenanceTitle:   pageMaintenanceTitle,
 		MaintenanceTitle:   pageMaintenanceTitle,
 		DefenderTitle:      pageDefenderTitle,
 		DefenderTitle:      pageDefenderTitle,
@@ -595,7 +648,11 @@ func (s *httpdServer) renderAddUpdateAdminPage(w http.ResponseWriter, r *http.Re
 func (s *httpdServer) renderUserPage(w http.ResponseWriter, r *http.Request, user *dataprovider.User,
 func (s *httpdServer) renderUserPage(w http.ResponseWriter, r *http.Request, user *dataprovider.User,
 	mode userPageMode, error string,
 	mode userPageMode, error string,
 ) {
 ) {
-	folders, err := s.getWebVirtualFolders(w, r, defaultQueryLimit)
+	folders, err := s.getWebVirtualFolders(w, r, defaultQueryLimit, true)
+	if err != nil {
+		return
+	}
+	groups, err := s.getWebGroups(w, r, defaultQueryLimit, true)
 	if err != nil {
 	if err != nil {
 		return
 		return
 	}
 	}
@@ -628,10 +685,12 @@ func (s *httpdServer) renderUserPage(w http.ResponseWriter, r *http.Request, use
 		WebClientOptions:   sdk.WebClientOptions,
 		WebClientOptions:   sdk.WebClientOptions,
 		RootDirPerms:       user.GetPermissionsForPath("/"),
 		RootDirPerms:       user.GetPermissionsForPath("/"),
 		VirtualFolders:     folders,
 		VirtualFolders:     folders,
+		Groups:             groups,
 		CanImpersonate:     os.Getuid() == 0,
 		CanImpersonate:     os.Getuid() == 0,
 		FsWrapper: fsWrapper{
 		FsWrapper: fsWrapper{
 			Filesystem:      user.FsConfig,
 			Filesystem:      user.FsConfig,
 			IsUserPage:      true,
 			IsUserPage:      true,
+			IsGroupPage:     false,
 			HasUsersBaseDir: dataprovider.HasUsersBaseDir(),
 			HasUsersBaseDir: dataprovider.HasUsersBaseDir(),
 			DirPath:         user.HomeDir,
 			DirPath:         user.HomeDir,
 		},
 		},
@@ -639,7 +698,52 @@ func (s *httpdServer) renderUserPage(w http.ResponseWriter, r *http.Request, use
 	renderAdminTemplate(w, templateUser, data)
 	renderAdminTemplate(w, templateUser, data)
 }
 }
 
 
-func (s *httpdServer) renderFolderPage(w http.ResponseWriter, r *http.Request, folder vfs.BaseVirtualFolder, mode folderPageMode, error string) {
+func (s *httpdServer) renderGroupPage(w http.ResponseWriter, r *http.Request, group dataprovider.Group,
+	mode groupPageMode, error string,
+) {
+	folders, err := s.getWebVirtualFolders(w, r, defaultQueryLimit, true)
+	if err != nil {
+		return
+	}
+	group.SetEmptySecretsIfNil()
+	group.UserSettings.FsConfig.RedactedSecret = redactedSecret
+	var title, currentURL string
+	switch mode {
+	case groupPageModeAdd:
+		title = "Add a new group"
+		currentURL = webGroupPath
+	case groupPageModeUpdate:
+		title = "Update group"
+		currentURL = fmt.Sprintf("%v/%v", webGroupPath, url.PathEscape(group.Name))
+	}
+	group.UserSettings.FsConfig.RedactedSecret = redactedSecret
+	group.UserSettings.FsConfig.SetEmptySecretsIfNil()
+
+	data := groupPage{
+		basePage:           s.getBasePageData(title, currentURL, r),
+		Error:              error,
+		Group:              &group,
+		Mode:               mode,
+		ValidPerms:         dataprovider.ValidPerms,
+		ValidLoginMethods:  dataprovider.ValidLoginMethods,
+		ValidProtocols:     dataprovider.ValidProtocols,
+		TwoFactorProtocols: dataprovider.MFAProtocols,
+		WebClientOptions:   sdk.WebClientOptions,
+		VirtualFolders:     folders,
+		FsWrapper: fsWrapper{
+			Filesystem:      group.UserSettings.FsConfig,
+			IsUserPage:      false,
+			IsGroupPage:     true,
+			HasUsersBaseDir: false,
+			DirPath:         group.UserSettings.HomeDir,
+		},
+	}
+	renderAdminTemplate(w, templateGroup, data)
+}
+
+func (s *httpdServer) renderFolderPage(w http.ResponseWriter, r *http.Request, folder vfs.BaseVirtualFolder,
+	mode folderPageMode, error string,
+) {
 	var title, currentURL string
 	var title, currentURL string
 	switch mode {
 	switch mode {
 	case folderPageModeAdd:
 	case folderPageModeAdd:
@@ -663,6 +767,7 @@ func (s *httpdServer) renderFolderPage(w http.ResponseWriter, r *http.Request, f
 		FsWrapper: fsWrapper{
 		FsWrapper: fsWrapper{
 			Filesystem:      folder.FsConfig,
 			Filesystem:      folder.FsConfig,
 			IsUserPage:      false,
 			IsUserPage:      false,
+			IsGroupPage:     false,
 			HasUsersBaseDir: false,
 			HasUsersBaseDir: false,
 			DirPath:         folder.MappedPath,
 			DirPath:         folder.MappedPath,
 		},
 		},
@@ -763,9 +868,8 @@ func getVirtualFoldersFromPostFields(r *http.Request) []vfs.VirtualFolder {
 	return virtualFolders
 	return virtualFolders
 }
 }
 
 
-func getUserPermissionsFromPostFields(r *http.Request) map[string][]string {
+func getSubDirPermissionsFromPostFields(r *http.Request) map[string][]string {
 	permissions := make(map[string][]string)
 	permissions := make(map[string][]string)
-	permissions["/"] = r.Form["permissions"]
 
 
 	for k := range r.Form {
 	for k := range r.Form {
 		if strings.HasPrefix(k, "sub_perm_path") {
 		if strings.HasPrefix(k, "sub_perm_path") {
@@ -780,6 +884,13 @@ func getUserPermissionsFromPostFields(r *http.Request) map[string][]string {
 	return permissions
 	return permissions
 }
 }
 
 
+func getUserPermissionsFromPostFields(r *http.Request) map[string][]string {
+	permissions := getSubDirPermissionsFromPostFields(r)
+	permissions["/"] = r.Form["permissions"]
+
+	return permissions
+}
+
 func getDataTransferLimitsFromPostFields(r *http.Request) ([]sdk.DataTransferLimit, error) {
 func getDataTransferLimitsFromPostFields(r *http.Request) ([]sdk.DataTransferLimit, error) {
 	var result []sdk.DataTransferLimit
 	var result []sdk.DataTransferLimit
 
 
@@ -928,6 +1039,27 @@ func getFilePatternsFromPostField(r *http.Request) []sdk.PatternsFilter {
 	return result
 	return result
 }
 }
 
 
+func getGroupsFromUserPostFields(r *http.Request) []sdk.GroupMapping {
+	var groups []sdk.GroupMapping
+
+	primaryGroup := r.Form.Get("primary_group")
+	if primaryGroup != "" {
+		groups = append(groups, sdk.GroupMapping{
+			Name: primaryGroup,
+			Type: sdk.GroupTypePrimary,
+		})
+	}
+	secondaryGroups := r.Form["secondary_groups"]
+	for _, name := range secondaryGroups {
+		groups = append(groups, sdk.GroupMapping{
+			Name: name,
+			Type: sdk.GroupTypeSecondary,
+		})
+	}
+
+	return groups
+}
+
 func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error) {
 func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error) {
 	var filters sdk.BaseUserFilters
 	var filters sdk.BaseUserFilters
 	bwLimits, err := getBandwidthLimitsFromPostFields(r)
 	bwLimits, err := getBandwidthLimitsFromPostFields(r)
@@ -938,6 +1070,10 @@ func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error)
 	if err != nil {
 	if err != nil {
 		return filters, err
 		return filters, err
 	}
 	}
+	maxFileSize, err := strconv.ParseInt(r.Form.Get("max_upload_file_size"), 10, 64)
+	if err != nil {
+		return filters, fmt.Errorf("invalid max upload file size: %w", err)
+	}
 	filters.BandwidthLimits = bwLimits
 	filters.BandwidthLimits = bwLimits
 	filters.DataTransferLimits = dtLimits
 	filters.DataTransferLimits = dtLimits
 	filters.AllowedIP = getSliceFromDelimitedValues(r.Form.Get("allowed_ip"), ",")
 	filters.AllowedIP = getSliceFromDelimitedValues(r.Form.Get("allowed_ip"), ",")
@@ -960,9 +1096,13 @@ func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error)
 	}
 	}
 	filters.DisableFsChecks = len(r.Form.Get("disable_fs_checks")) > 0
 	filters.DisableFsChecks = len(r.Form.Get("disable_fs_checks")) > 0
 	filters.AllowAPIKeyAuth = len(r.Form.Get("allow_api_key_auth")) > 0
 	filters.AllowAPIKeyAuth = len(r.Form.Get("allow_api_key_auth")) > 0
-	filters.ExternalAuthCacheTime, err = strconv.ParseInt(r.Form.Get("external_auth_cache_time"), 10, 64)
 	filters.StartDirectory = r.Form.Get("start_directory")
 	filters.StartDirectory = r.Form.Get("start_directory")
-	return filters, err
+	filters.MaxUploadFileSize = maxFileSize
+	filters.ExternalAuthCacheTime, err = strconv.ParseInt(r.Form.Get("external_auth_cache_time"), 10, 64)
+	if err != nil {
+		return filters, fmt.Errorf("invalid external auth cache time: %w", err)
+	}
+	return filters, nil
 }
 }
 
 
 func getSecretFromFormField(r *http.Request, field string) *kms.Secret {
 func getSecretFromFormField(r *http.Request, field string) *kms.Secret {
@@ -990,27 +1130,30 @@ func getS3Config(r *http.Request) (vfs.S3FsConfig, error) {
 	config.KeyPrefix = r.Form.Get("s3_key_prefix")
 	config.KeyPrefix = r.Form.Get("s3_key_prefix")
 	config.UploadPartSize, err = strconv.ParseInt(r.Form.Get("s3_upload_part_size"), 10, 64)
 	config.UploadPartSize, err = strconv.ParseInt(r.Form.Get("s3_upload_part_size"), 10, 64)
 	if err != nil {
 	if err != nil {
-		return config, err
+		return config, fmt.Errorf("invalid s3 upload part size: %w", err)
 	}
 	}
 	config.UploadConcurrency, err = strconv.Atoi(r.Form.Get("s3_upload_concurrency"))
 	config.UploadConcurrency, err = strconv.Atoi(r.Form.Get("s3_upload_concurrency"))
 	if err != nil {
 	if err != nil {
-		return config, err
+		return config, fmt.Errorf("invalid s3 upload concurrency: %w", err)
 	}
 	}
 	config.DownloadPartSize, err = strconv.ParseInt(r.Form.Get("s3_download_part_size"), 10, 64)
 	config.DownloadPartSize, err = strconv.ParseInt(r.Form.Get("s3_download_part_size"), 10, 64)
 	if err != nil {
 	if err != nil {
-		return config, err
+		return config, fmt.Errorf("invalid s3 download part size: %w", err)
 	}
 	}
 	config.DownloadConcurrency, err = strconv.Atoi(r.Form.Get("s3_download_concurrency"))
 	config.DownloadConcurrency, err = strconv.Atoi(r.Form.Get("s3_download_concurrency"))
 	if err != nil {
 	if err != nil {
-		return config, err
+		return config, fmt.Errorf("invalid s3 download concurrency: %w", err)
 	}
 	}
 	config.ForcePathStyle = r.Form.Get("s3_force_path_style") != ""
 	config.ForcePathStyle = r.Form.Get("s3_force_path_style") != ""
 	config.DownloadPartMaxTime, err = strconv.Atoi(r.Form.Get("s3_download_part_max_time"))
 	config.DownloadPartMaxTime, err = strconv.Atoi(r.Form.Get("s3_download_part_max_time"))
 	if err != nil {
 	if err != nil {
-		return config, err
+		return config, fmt.Errorf("invalid s3 download part max time: %w", err)
 	}
 	}
 	config.UploadPartMaxTime, err = strconv.Atoi(r.Form.Get("s3_upload_part_max_time"))
 	config.UploadPartMaxTime, err = strconv.Atoi(r.Form.Get("s3_upload_part_max_time"))
-	return config, err
+	if err != nil {
+		return config, fmt.Errorf("invalid s3 upload part max time: %w", err)
+	}
+	return config, nil
 }
 }
 
 
 func getGCSConfig(r *http.Request) (vfs.GCSFsConfig, error) {
 func getGCSConfig(r *http.Request) (vfs.GCSFsConfig, error) {
@@ -1059,7 +1202,10 @@ func getSFTPConfig(r *http.Request) (vfs.SFTPFsConfig, error) {
 	config.Prefix = r.Form.Get("sftp_prefix")
 	config.Prefix = r.Form.Get("sftp_prefix")
 	config.DisableCouncurrentReads = len(r.Form.Get("sftp_disable_concurrent_reads")) > 0
 	config.DisableCouncurrentReads = len(r.Form.Get("sftp_disable_concurrent_reads")) > 0
 	config.BufferSize, err = strconv.ParseInt(r.Form.Get("sftp_buffer_size"), 10, 64)
 	config.BufferSize, err = strconv.ParseInt(r.Form.Get("sftp_buffer_size"), 10, 64)
-	return config, err
+	if err != nil {
+		return config, fmt.Errorf("invalid SFTP buffer size: %w", err)
+	}
+	return config, nil
 }
 }
 
 
 func getAzureConfig(r *http.Request) (vfs.AzBlobFsConfig, error) {
 func getAzureConfig(r *http.Request) (vfs.AzBlobFsConfig, error) {
@@ -1075,18 +1221,21 @@ func getAzureConfig(r *http.Request) (vfs.AzBlobFsConfig, error) {
 	config.UseEmulator = len(r.Form.Get("az_use_emulator")) > 0
 	config.UseEmulator = len(r.Form.Get("az_use_emulator")) > 0
 	config.UploadPartSize, err = strconv.ParseInt(r.Form.Get("az_upload_part_size"), 10, 64)
 	config.UploadPartSize, err = strconv.ParseInt(r.Form.Get("az_upload_part_size"), 10, 64)
 	if err != nil {
 	if err != nil {
-		return config, err
+		return config, fmt.Errorf("invalid azure upload part size: %w", err)
 	}
 	}
 	config.UploadConcurrency, err = strconv.Atoi(r.Form.Get("az_upload_concurrency"))
 	config.UploadConcurrency, err = strconv.Atoi(r.Form.Get("az_upload_concurrency"))
 	if err != nil {
 	if err != nil {
-		return config, err
+		return config, fmt.Errorf("invalid azure upload concurrency: %w", err)
 	}
 	}
 	config.DownloadPartSize, err = strconv.ParseInt(r.Form.Get("az_download_part_size"), 10, 64)
 	config.DownloadPartSize, err = strconv.ParseInt(r.Form.Get("az_download_part_size"), 10, 64)
 	if err != nil {
 	if err != nil {
-		return config, err
+		return config, fmt.Errorf("invalid azure download part size: %w", err)
 	}
 	}
 	config.DownloadConcurrency, err = strconv.Atoi(r.Form.Get("az_download_concurrency"))
 	config.DownloadConcurrency, err = strconv.Atoi(r.Form.Get("az_download_concurrency"))
-	return config, err
+	if err != nil {
+		return config, fmt.Errorf("invalid azure download concurrency: %w", err)
+	}
+	return config, nil
 }
 }
 
 
 func getFsConfigFromPostFields(r *http.Request) (vfs.Filesystem, error) {
 func getFsConfigFromPostFields(r *http.Request) (vfs.Filesystem, error) {
@@ -1287,7 +1436,7 @@ func getQuotaLimits(r *http.Request) (int64, int, error) {
 }
 }
 
 
 func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
 func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
-	var user dataprovider.User
+	user := dataprovider.User{}
 	err := r.ParseMultipartForm(maxRequestSize)
 	err := r.ParseMultipartForm(maxRequestSize)
 	if err != nil {
 	if err != nil {
 		return user, err
 		return user, err
@@ -1370,13 +1519,71 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
 		},
 		},
 		VirtualFolders: getVirtualFoldersFromPostFields(r),
 		VirtualFolders: getVirtualFoldersFromPostFields(r),
 		FsConfig:       fsConfig,
 		FsConfig:       fsConfig,
+		Groups:         getGroupsFromUserPostFields(r),
 	}
 	}
-	maxFileSize, err := strconv.ParseInt(r.Form.Get("max_upload_file_size"), 10, 64)
+	return user, nil
+}
+
+func getGroupFromPostFields(r *http.Request) (dataprovider.Group, error) {
+	group := dataprovider.Group{}
+	err := r.ParseMultipartForm(maxRequestSize)
+	if err != nil {
+		return group, err
+	}
+	defer r.MultipartForm.RemoveAll() //nolint:errcheck
+
+	maxSessions, err := strconv.Atoi(r.Form.Get("max_sessions"))
+	if err != nil {
+		return group, fmt.Errorf("invalid max sessions: %w", err)
+	}
+	quotaSize, quotaFiles, err := getQuotaLimits(r)
+	if err != nil {
+		return group, err
+	}
+	bandwidthUL, err := strconv.ParseInt(r.Form.Get("upload_bandwidth"), 10, 64)
+	if err != nil {
+		return group, fmt.Errorf("invalid upload bandwidth: %w", err)
+	}
+	bandwidthDL, err := strconv.ParseInt(r.Form.Get("download_bandwidth"), 10, 64)
+	if err != nil {
+		return group, fmt.Errorf("invalid download bandwidth: %w", err)
+	}
+	dataTransferUL, dataTransferDL, dataTransferTotal, err := getTransferLimits(r)
 	if err != nil {
 	if err != nil {
-		return user, fmt.Errorf("invalid max upload file size: %w", err)
+		return group, err
 	}
 	}
-	user.Filters.MaxUploadFileSize = maxFileSize
-	return user, err
+	fsConfig, err := getFsConfigFromPostFields(r)
+	if err != nil {
+		return group, err
+	}
+	filters, err := getFiltersFromUserPostFields(r)
+	if err != nil {
+		return group, err
+	}
+	group = dataprovider.Group{
+		BaseGroup: sdk.BaseGroup{
+			Name:        r.Form.Get("name"),
+			Description: r.Form.Get("description"),
+		},
+		UserSettings: dataprovider.GroupUserSettings{
+			BaseGroupUserSettings: sdk.BaseGroupUserSettings{
+				HomeDir:              r.Form.Get("home_dir"),
+				MaxSessions:          maxSessions,
+				QuotaSize:            quotaSize,
+				QuotaFiles:           quotaFiles,
+				Permissions:          getSubDirPermissionsFromPostFields(r),
+				UploadBandwidth:      bandwidthUL,
+				DownloadBandwidth:    bandwidthDL,
+				UploadDataTransfer:   dataTransferUL,
+				DownloadDataTransfer: dataTransferDL,
+				TotalDataTransfer:    dataTransferTotal,
+				Filters:              filters,
+			},
+			FsConfig: fsConfig,
+		},
+		VirtualFolders: getVirtualFoldersFromPostFields(r),
+	}
+	return group, nil
 }
 }
 
 
 func (s *httpdServer) handleWebAdminForgotPwd(w http.ResponseWriter, r *http.Request) {
 func (s *httpdServer) handleWebAdminForgotPwd(w http.ResponseWriter, r *http.Request) {
@@ -1927,11 +2134,11 @@ func (s *httpdServer) handleWebAddUserPost(w http.ResponseWriter, r *http.Reques
 		PublicKeys: user.PublicKeys,
 		PublicKeys: user.PublicKeys,
 	})
 	})
 	err = dataprovider.AddUser(&user, claims.Username, ipAddr)
 	err = dataprovider.AddUser(&user, claims.Username, ipAddr)
-	if err == nil {
-		http.Redirect(w, r, webUsersPath, http.StatusSeeOther)
-	} else {
+	if err != nil {
 		s.renderUserPage(w, r, &user, userPageModeAdd, err.Error())
 		s.renderUserPage(w, r, &user, userPageModeAdd, err.Error())
+		return
 	}
 	}
+	http.Redirect(w, r, webUsersPath, http.StatusSeeOther)
 }
 }
 
 
 func (s *httpdServer) handleWebUpdateUserPost(w http.ResponseWriter, r *http.Request) {
 func (s *httpdServer) handleWebUpdateUserPost(w http.ResponseWriter, r *http.Request) {
@@ -1979,14 +2186,14 @@ func (s *httpdServer) handleWebUpdateUserPost(w http.ResponseWriter, r *http.Req
 	})
 	})
 
 
 	err = dataprovider.UpdateUser(&updatedUser, claims.Username, ipAddr)
 	err = dataprovider.UpdateUser(&updatedUser, claims.Username, ipAddr)
-	if err == nil {
-		if r.Form.Get("disconnect") != "" {
-			disconnectUser(user.Username)
-		}
-		http.Redirect(w, r, webUsersPath, http.StatusSeeOther)
-	} else {
-		s.renderUserPage(w, r, &user, userPageModeUpdate, err.Error())
+	if err != nil {
+		s.renderUserPage(w, r, &updatedUser, userPageModeUpdate, err.Error())
+		return
+	}
+	if r.Form.Get("disconnect") != "" {
+		disconnectUser(user.Username)
 	}
 	}
+	http.Redirect(w, r, webUsersPath, http.StatusSeeOther)
 }
 }
 
 
 func (s *httpdServer) handleWebGetStatus(w http.ResponseWriter, r *http.Request) {
 func (s *httpdServer) handleWebGetStatus(w http.ResponseWriter, r *http.Request) {
@@ -2108,18 +2315,18 @@ func (s *httpdServer) handleWebUpdateFolderPost(w http.ResponseWriter, r *http.R
 
 
 	updatedFolder = getFolderFromTemplate(updatedFolder, updatedFolder.Name)
 	updatedFolder = getFolderFromTemplate(updatedFolder, updatedFolder.Name)
 
 
-	err = dataprovider.UpdateFolder(&updatedFolder, folder.Users, claims.Username, ipAddr)
+	err = dataprovider.UpdateFolder(&updatedFolder, folder.Users, folder.Groups, claims.Username, ipAddr)
 	if err != nil {
 	if err != nil {
-		s.renderFolderPage(w, r, folder, folderPageModeUpdate, err.Error())
+		s.renderFolderPage(w, r, updatedFolder, folderPageModeUpdate, err.Error())
 		return
 		return
 	}
 	}
 	http.Redirect(w, r, webFoldersPath, http.StatusSeeOther)
 	http.Redirect(w, r, webFoldersPath, http.StatusSeeOther)
 }
 }
 
 
-func (s *httpdServer) getWebVirtualFolders(w http.ResponseWriter, r *http.Request, limit int) ([]vfs.BaseVirtualFolder, error) {
+func (s *httpdServer) getWebVirtualFolders(w http.ResponseWriter, r *http.Request, limit int, minimal bool) ([]vfs.BaseVirtualFolder, error) {
 	folders := make([]vfs.BaseVirtualFolder, 0, limit)
 	folders := make([]vfs.BaseVirtualFolder, 0, limit)
 	for {
 	for {
-		f, err := dataprovider.GetFolders(limit, len(folders), dataprovider.OrderASC)
+		f, err := dataprovider.GetFolders(limit, len(folders), dataprovider.OrderASC, minimal)
 		if err != nil {
 		if err != nil {
 			s.renderInternalServerErrorPage(w, r, err)
 			s.renderInternalServerErrorPage(w, r, err)
 			return folders, err
 			return folders, err
@@ -2142,7 +2349,7 @@ func (s *httpdServer) handleWebGetFolders(w http.ResponseWriter, r *http.Request
 			limit = defaultQueryLimit
 			limit = defaultQueryLimit
 		}
 		}
 	}
 	}
-	folders, err := s.getWebVirtualFolders(w, r, limit)
+	folders, err := s.getWebVirtualFolders(w, r, limit, false)
 	if err != nil {
 	if err != nil {
 		return
 		return
 	}
 	}
@@ -2153,3 +2360,127 @@ func (s *httpdServer) handleWebGetFolders(w http.ResponseWriter, r *http.Request
 	}
 	}
 	renderAdminTemplate(w, templateFolders, data)
 	renderAdminTemplate(w, templateFolders, data)
 }
 }
+
+func (s *httpdServer) getWebGroups(w http.ResponseWriter, r *http.Request, limit int, minimal bool) ([]dataprovider.Group, error) {
+	groups := make([]dataprovider.Group, 0, limit)
+	for {
+		f, err := dataprovider.GetGroups(limit, len(groups), dataprovider.OrderASC, minimal)
+		if err != nil {
+			s.renderInternalServerErrorPage(w, r, err)
+			return groups, err
+		}
+		groups = append(groups, f...)
+		if len(f) < limit {
+			break
+		}
+	}
+	return groups, nil
+}
+
+func (s *httpdServer) handleWebGetGroups(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	limit := defaultQueryLimit
+	if _, ok := r.URL.Query()["qlimit"]; ok {
+		var err error
+		limit, err = strconv.Atoi(r.URL.Query().Get("qlimit"))
+		if err != nil {
+			limit = defaultQueryLimit
+		}
+	}
+	groups, err := s.getWebGroups(w, r, limit, false)
+	if err != nil {
+		return
+	}
+
+	data := groupsPage{
+		basePage: s.getBasePageData(pageGroupsTitle, webGroupsPath, r),
+		Groups:   groups,
+	}
+	renderAdminTemplate(w, templateGroups, data)
+}
+
+func (s *httpdServer) handleWebAddGroupGet(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	s.renderGroupPage(w, r, dataprovider.Group{}, groupPageModeAdd, "")
+}
+
+func (s *httpdServer) handleWebAddGroupPost(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		s.renderBadRequestPage(w, r, errors.New("invalid token claims"))
+		return
+	}
+	group, err := getGroupFromPostFields(r)
+	if err != nil {
+		s.renderGroupPage(w, r, group, groupPageModeAdd, err.Error())
+		return
+	}
+	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
+	if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+		s.renderForbiddenPage(w, r, err.Error())
+		return
+	}
+	err = dataprovider.AddGroup(&group, claims.Username, ipAddr)
+	if err != nil {
+		s.renderGroupPage(w, r, group, groupPageModeAdd, err.Error())
+		return
+	}
+	http.Redirect(w, r, webGroupsPath, http.StatusSeeOther)
+}
+
+func (s *httpdServer) handleWebUpdateGroupGet(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	name := getURLParam(r, "name")
+	group, err := dataprovider.GroupExists(name)
+	if err == nil {
+		s.renderGroupPage(w, r, group, groupPageModeUpdate, "")
+	} else if _, ok := err.(*util.RecordNotFoundError); ok {
+		s.renderNotFoundPage(w, r, err)
+	} else {
+		s.renderInternalServerErrorPage(w, r, err)
+	}
+}
+
+func (s *httpdServer) handleWebUpdateGroupPost(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		s.renderBadRequestPage(w, r, errors.New("invalid token claims"))
+		return
+	}
+	name := getURLParam(r, "name")
+	group, err := dataprovider.GroupExists(name)
+	if _, ok := err.(*util.RecordNotFoundError); ok {
+		s.renderNotFoundPage(w, r, err)
+		return
+	} else if err != nil {
+		s.renderInternalServerErrorPage(w, r, err)
+		return
+	}
+	updatedGroup, err := getGroupFromPostFields(r)
+	if err != nil {
+		s.renderGroupPage(w, r, group, groupPageModeUpdate, err.Error())
+		return
+	}
+	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
+	if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+		s.renderForbiddenPage(w, r, err.Error())
+		return
+	}
+	updatedGroup.ID = group.ID
+	updatedGroup.Name = group.Name
+	updatedGroup.SetEmptySecretsIfNil()
+
+	updateEncryptedSecrets(&updatedGroup.UserSettings.FsConfig, group.UserSettings.FsConfig.S3Config.AccessSecret,
+		group.UserSettings.FsConfig.AzBlobConfig.AccountKey, group.UserSettings.FsConfig.AzBlobConfig.SASURL,
+		group.UserSettings.FsConfig.GCSConfig.Credentials, group.UserSettings.FsConfig.CryptConfig.Passphrase,
+		group.UserSettings.FsConfig.SFTPConfig.Password, group.UserSettings.FsConfig.SFTPConfig.PrivateKey)
+
+	err = dataprovider.UpdateGroup(&updatedGroup, group.Users, claims.Username, ipAddr)
+	if err != nil {
+		s.renderGroupPage(w, r, updatedGroup, groupPageModeUpdate, err.Error())
+		return
+	}
+	http.Redirect(w, r, webGroupsPath, http.StatusSeeOther)
+}

+ 11 - 11
httpd/webclient.go

@@ -556,7 +556,7 @@ func (s *httpdServer) renderClientProfilePage(w http.ResponseWriter, r *http.Req
 		baseClientPage: s.getBaseClientPageData(pageClientProfileTitle, webClientProfilePath, r),
 		baseClientPage: s.getBaseClientPageData(pageClientProfileTitle, webClientProfilePath, r),
 		Error:          error,
 		Error:          error,
 	}
 	}
-	user, err := dataprovider.UserExists(data.LoggedUser.Username)
+	user, userMerged, err := dataprovider.GetUserVariants(data.LoggedUser.Username)
 	if err != nil {
 	if err != nil {
 		s.renderClientInternalServerErrorPage(w, r, err)
 		s.renderClientInternalServerErrorPage(w, r, err)
 		return
 		return
@@ -565,7 +565,7 @@ func (s *httpdServer) renderClientProfilePage(w http.ResponseWriter, r *http.Req
 	data.AllowAPIKeyAuth = user.Filters.AllowAPIKeyAuth
 	data.AllowAPIKeyAuth = user.Filters.AllowAPIKeyAuth
 	data.Email = user.Email
 	data.Email = user.Email
 	data.Description = user.Description
 	data.Description = user.Description
-	data.CanSubmit = user.CanChangeAPIKeyAuth() || user.CanManagePublicKeys() || user.CanChangeInfo()
+	data.CanSubmit = userMerged.CanChangeAPIKeyAuth() || userMerged.CanManagePublicKeys() || userMerged.CanChangeInfo()
 	renderClientTemplate(w, templateClientProfile, data)
 	renderClientTemplate(w, templateClientProfile, data)
 }
 }
 
 
@@ -586,7 +586,7 @@ func (s *httpdServer) handleWebClientDownloadZip(w http.ResponseWriter, r *http.
 		return
 		return
 	}
 	}
 
 
-	user, err := dataprovider.UserExists(claims.Username)
+	user, err := dataprovider.GetUserWithGroupSettings(claims.Username)
 	if err != nil {
 	if err != nil {
 		s.renderClientMessagePage(w, r, "Unable to retrieve your user", "", getRespStatus(err), nil, "")
 		s.renderClientMessagePage(w, r, "Unable to retrieve your user", "", getRespStatus(err), nil, "")
 		return
 		return
@@ -735,7 +735,7 @@ func (s *httpdServer) handleClientGetDirContents(w http.ResponseWriter, r *http.
 		return
 		return
 	}
 	}
 
 
-	user, err := dataprovider.UserExists(claims.Username)
+	user, err := dataprovider.GetUserWithGroupSettings(claims.Username)
 	if err != nil {
 	if err != nil {
 		sendAPIResponse(w, r, nil, "Unable to retrieve your user", getRespStatus(err))
 		sendAPIResponse(w, r, nil, "Unable to retrieve your user", getRespStatus(err))
 		return
 		return
@@ -812,7 +812,7 @@ func (s *httpdServer) handleClientGetFiles(w http.ResponseWriter, r *http.Reques
 		return
 		return
 	}
 	}
 
 
-	user, err := dataprovider.UserExists(claims.Username)
+	user, err := dataprovider.GetUserWithGroupSettings(claims.Username)
 	if err != nil {
 	if err != nil {
 		s.renderClientMessagePage(w, r, "Unable to retrieve your user", "", getRespStatus(err), nil, "")
 		s.renderClientMessagePage(w, r, "Unable to retrieve your user", "", getRespStatus(err), nil, "")
 		return
 		return
@@ -872,7 +872,7 @@ func (s *httpdServer) handleClientEditFile(w http.ResponseWriter, r *http.Reques
 		return
 		return
 	}
 	}
 
 
-	user, err := dataprovider.UserExists(claims.Username)
+	user, err := dataprovider.GetUserWithGroupSettings(claims.Username)
 	if err != nil {
 	if err != nil {
 		s.renderClientMessagePage(w, r, "Unable to retrieve your user", "", getRespStatus(err), nil, "")
 		s.renderClientMessagePage(w, r, "Unable to retrieve your user", "", getRespStatus(err), nil, "")
 		return
 		return
@@ -1120,22 +1120,22 @@ func (s *httpdServer) handleWebClientProfilePost(w http.ResponseWriter, r *http.
 		s.renderClientForbiddenPage(w, r, "Invalid token claims")
 		s.renderClientForbiddenPage(w, r, "Invalid token claims")
 		return
 		return
 	}
 	}
-	user, err := dataprovider.UserExists(claims.Username)
+	user, userMerged, err := dataprovider.GetUserVariants(claims.Username)
 	if err != nil {
 	if err != nil {
 		s.renderClientProfilePage(w, r, err.Error())
 		s.renderClientProfilePage(w, r, err.Error())
 		return
 		return
 	}
 	}
-	if !user.CanManagePublicKeys() && !user.CanChangeAPIKeyAuth() && !user.CanChangeInfo() {
+	if !userMerged.CanManagePublicKeys() && !userMerged.CanChangeAPIKeyAuth() && !userMerged.CanChangeInfo() {
 		s.renderClientForbiddenPage(w, r, "You are not allowed to change anything")
 		s.renderClientForbiddenPage(w, r, "You are not allowed to change anything")
 		return
 		return
 	}
 	}
-	if user.CanManagePublicKeys() {
+	if userMerged.CanManagePublicKeys() {
 		user.PublicKeys = r.Form["public_keys"]
 		user.PublicKeys = r.Form["public_keys"]
 	}
 	}
-	if user.CanChangeAPIKeyAuth() {
+	if userMerged.CanChangeAPIKeyAuth() {
 		user.Filters.AllowAPIKeyAuth = len(r.Form.Get("allow_api_key_auth")) > 0
 		user.Filters.AllowAPIKeyAuth = len(r.Form.Get("allow_api_key_auth")) > 0
 	}
 	}
-	if user.CanChangeInfo() {
+	if userMerged.CanChangeInfo() {
 		user.Email = r.Form.Get("email")
 		user.Email = r.Form.Get("email")
 		user.Description = r.Form.Get("description")
 		user.Description = r.Form.Get("description")
 	}
 	}

+ 251 - 56
httpdtest/httpdtest.go

@@ -15,6 +15,7 @@ import (
 	"strings"
 	"strings"
 
 
 	"github.com/go-chi/render"
 	"github.com/go-chi/render"
+	"github.com/sftpgo/sdk"
 
 
 	"github.com/drakkan/sftpgo/v2/common"
 	"github.com/drakkan/sftpgo/v2/common"
 	"github.com/drakkan/sftpgo/v2/dataprovider"
 	"github.com/drakkan/sftpgo/v2/dataprovider"
@@ -33,6 +34,7 @@ const (
 	quotaScanPath         = "/api/v2/quotas/users/scans"
 	quotaScanPath         = "/api/v2/quotas/users/scans"
 	quotaScanVFolderPath  = "/api/v2/quotas/folders/scans"
 	quotaScanVFolderPath  = "/api/v2/quotas/folders/scans"
 	userPath              = "/api/v2/users"
 	userPath              = "/api/v2/users"
+	groupPath             = "/api/v2/groups"
 	versionPath           = "/api/v2/version"
 	versionPath           = "/api/v2/version"
 	folderPath            = "/api/v2/folders"
 	folderPath            = "/api/v2/folders"
 	serverStatusPath      = "/api/v2/status"
 	serverStatusPath      = "/api/v2/status"
@@ -244,6 +246,115 @@ func GetUsers(limit, offset int64, expectedStatusCode int) ([]dataprovider.User,
 	return users, body, err
 	return users, body, err
 }
 }
 
 
+// AddGroup adds a new group and checks the received HTTP Status code against expectedStatusCode.
+func AddGroup(group dataprovider.Group, expectedStatusCode int) (dataprovider.Group, []byte, error) {
+	var newGroup dataprovider.Group
+	var body []byte
+	asJSON, _ := json.Marshal(group)
+	resp, err := sendHTTPRequest(http.MethodPost, buildURLRelativeToBase(groupPath), bytes.NewBuffer(asJSON),
+		"application/json", getDefaultToken())
+	if err != nil {
+		return newGroup, body, err
+	}
+	defer resp.Body.Close()
+	err = checkResponse(resp.StatusCode, expectedStatusCode)
+	if expectedStatusCode != http.StatusCreated {
+		body, _ = getResponseBody(resp)
+		return newGroup, body, err
+	}
+	if err == nil {
+		err = render.DecodeJSON(resp.Body, &newGroup)
+	} else {
+		body, _ = getResponseBody(resp)
+	}
+	if err == nil {
+		err = checkGroup(group, newGroup)
+	}
+	return newGroup, body, err
+}
+
+// UpdateGroup updates an existing group and checks the received HTTP Status code against expectedStatusCode
+func UpdateGroup(group dataprovider.Group, expectedStatusCode int) (dataprovider.Group, []byte, error) {
+	var newGroup dataprovider.Group
+	var body []byte
+
+	asJSON, _ := json.Marshal(group)
+	resp, err := sendHTTPRequest(http.MethodPut, buildURLRelativeToBase(groupPath, url.PathEscape(group.Name)),
+		bytes.NewBuffer(asJSON), "application/json", getDefaultToken())
+	if err != nil {
+		return newGroup, body, err
+	}
+	defer resp.Body.Close()
+	body, _ = getResponseBody(resp)
+	err = checkResponse(resp.StatusCode, expectedStatusCode)
+	if expectedStatusCode != http.StatusOK {
+		return newGroup, body, err
+	}
+	if err == nil {
+		newGroup, body, err = GetGroupByName(group.Name, expectedStatusCode)
+	}
+	if err == nil {
+		err = checkGroup(group, newGroup)
+	}
+	return newGroup, body, err
+}
+
+// RemoveGroup removes an existing group and checks the received HTTP Status code against expectedStatusCode.
+func RemoveGroup(group dataprovider.Group, expectedStatusCode int) ([]byte, error) {
+	var body []byte
+	resp, err := sendHTTPRequest(http.MethodDelete, buildURLRelativeToBase(groupPath, url.PathEscape(group.Name)),
+		nil, "", getDefaultToken())
+	if err != nil {
+		return body, err
+	}
+	defer resp.Body.Close()
+	body, _ = getResponseBody(resp)
+	return body, checkResponse(resp.StatusCode, expectedStatusCode)
+}
+
+// GetGroupByName gets a group by name and checks the received HTTP Status code against expectedStatusCode.
+func GetGroupByName(name string, expectedStatusCode int) (dataprovider.Group, []byte, error) {
+	var group dataprovider.Group
+	var body []byte
+	resp, err := sendHTTPRequest(http.MethodGet, buildURLRelativeToBase(groupPath, url.PathEscape(name)),
+		nil, "", getDefaultToken())
+	if err != nil {
+		return group, body, err
+	}
+	defer resp.Body.Close()
+	err = checkResponse(resp.StatusCode, expectedStatusCode)
+	if err == nil && expectedStatusCode == http.StatusOK {
+		err = render.DecodeJSON(resp.Body, &group)
+	} else {
+		body, _ = getResponseBody(resp)
+	}
+	return group, body, err
+}
+
+// GetGroups returns a list of groups and checks the received HTTP Status code against expectedStatusCode.
+// The number of results can be limited specifying a limit.
+// Some results can be skipped specifying an offset.
+func GetGroups(limit, offset int64, expectedStatusCode int) ([]dataprovider.Group, []byte, error) {
+	var groups []dataprovider.Group
+	var body []byte
+	url, err := addLimitAndOffsetQueryParams(buildURLRelativeToBase(groupPath), limit, offset)
+	if err != nil {
+		return groups, body, err
+	}
+	resp, err := sendHTTPRequest(http.MethodGet, url.String(), nil, "", getDefaultToken())
+	if err != nil {
+		return groups, body, err
+	}
+	defer resp.Body.Close()
+	err = checkResponse(resp.StatusCode, expectedStatusCode)
+	if err == nil && expectedStatusCode == http.StatusOK {
+		err = render.DecodeJSON(resp.Body, &groups)
+	} else {
+		body, _ = getResponseBody(resp)
+	}
+	return groups, body, err
+}
+
 // AddAdmin adds a new admin and checks the received HTTP Status code against expectedStatusCode.
 // AddAdmin adds a new admin and checks the received HTTP Status code against expectedStatusCode.
 func AddAdmin(admin dataprovider.Admin, expectedStatusCode int) (dataprovider.Admin, []byte, error) {
 func AddAdmin(admin dataprovider.Admin, expectedStatusCode int) (dataprovider.Admin, []byte, error) {
 	var newAdmin dataprovider.Admin
 	var newAdmin dataprovider.Admin
@@ -1043,6 +1154,35 @@ func getResponseBody(resp *http.Response) ([]byte, error) {
 	return io.ReadAll(resp.Body)
 	return io.ReadAll(resp.Body)
 }
 }
 
 
+func checkGroup(expected dataprovider.Group, actual dataprovider.Group) error {
+	if expected.ID <= 0 {
+		if actual.ID <= 0 {
+			return errors.New("actual group ID must be > 0")
+		}
+	} else {
+		if actual.ID != expected.ID {
+			return errors.New("group ID mismatch")
+		}
+	}
+	if dataprovider.ConvertName(expected.Name) != actual.Name {
+		return errors.New("name mismatch")
+	}
+	if expected.Description != actual.Description {
+		return errors.New("description mismatch")
+	}
+	if err := compareEqualGroupSettingsFields(expected.UserSettings.BaseGroupUserSettings,
+		actual.UserSettings.BaseGroupUserSettings); err != nil {
+		return err
+	}
+	if err := compareVirtualFolders(expected.VirtualFolders, actual.VirtualFolders); err != nil {
+		return err
+	}
+	if err := compareUserFilters(expected.UserSettings.Filters, actual.UserSettings.Filters); err != nil {
+		return err
+	}
+	return compareFsConfig(&expected.UserSettings.FsConfig, &actual.UserSettings.FsConfig)
+}
+
 func checkFolder(expected *vfs.BaseVirtualFolder, actual *vfs.BaseVirtualFolder) error {
 func checkFolder(expected *vfs.BaseVirtualFolder, actual *vfs.BaseVirtualFolder) error {
 	if expected.ID <= 0 {
 	if expected.ID <= 0 {
 		if actual.ID <= 0 {
 		if actual.ID <= 0 {
@@ -1185,27 +1325,30 @@ func checkUser(expected *dataprovider.User, actual *dataprovider.User) error {
 	if expected.Email != actual.Email {
 	if expected.Email != actual.Email {
 		return errors.New("email mismatch")
 		return errors.New("email mismatch")
 	}
 	}
-	if err := compareUserPermissions(expected, actual); err != nil {
+	if err := compareUserPermissions(expected.Permissions, actual.Permissions); err != nil {
 		return err
 		return err
 	}
 	}
-	if err := compareUserFilters(expected, actual); err != nil {
+	if err := compareUserFilters(expected.Filters.BaseUserFilters, actual.Filters.BaseUserFilters); err != nil {
 		return err
 		return err
 	}
 	}
 	if err := compareFsConfig(&expected.FsConfig, &actual.FsConfig); err != nil {
 	if err := compareFsConfig(&expected.FsConfig, &actual.FsConfig); err != nil {
 		return err
 		return err
 	}
 	}
-	if err := compareUserVirtualFolders(expected, actual); err != nil {
+	if err := compareUserGroups(expected, actual); err != nil {
+		return err
+	}
+	if err := compareVirtualFolders(expected.VirtualFolders, actual.VirtualFolders); err != nil {
 		return err
 		return err
 	}
 	}
 	return compareEqualsUserFields(expected, actual)
 	return compareEqualsUserFields(expected, actual)
 }
 }
 
 
-func compareUserPermissions(expected *dataprovider.User, actual *dataprovider.User) error {
-	if len(expected.Permissions) != len(actual.Permissions) {
+func compareUserPermissions(expected map[string][]string, actual map[string][]string) error {
+	if len(expected) != len(actual) {
 		return errors.New("permissions mismatch")
 		return errors.New("permissions mismatch")
 	}
 	}
-	for dir, perms := range expected.Permissions {
-		if actualPerms, ok := actual.Permissions[dir]; ok {
+	for dir, perms := range expected {
+		if actualPerms, ok := actual[dir]; ok {
 			for _, v := range actualPerms {
 			for _, v := range actualPerms {
 				if !util.IsStringInSlice(v, perms) {
 				if !util.IsStringInSlice(v, perms) {
 					return errors.New("permissions contents mismatch")
 					return errors.New("permissions contents mismatch")
@@ -1218,13 +1361,34 @@ func compareUserPermissions(expected *dataprovider.User, actual *dataprovider.Us
 	return nil
 	return nil
 }
 }
 
 
-func compareUserVirtualFolders(expected *dataprovider.User, actual *dataprovider.User) error {
-	if len(actual.VirtualFolders) != len(expected.VirtualFolders) {
+func compareUserGroups(expected *dataprovider.User, actual *dataprovider.User) error {
+	if len(actual.Groups) != len(expected.Groups) {
+		return errors.New("groups len mismatch")
+	}
+	for _, g := range actual.Groups {
+		found := false
+		for _, g1 := range expected.Groups {
+			if g1.Name == g.Name {
+				found = true
+				if g1.Type != g.Type {
+					return fmt.Errorf("type mismatch for group %s", g.Name)
+				}
+			}
+		}
+		if !found {
+			return errors.New("groups mismatch")
+		}
+	}
+	return nil
+}
+
+func compareVirtualFolders(expected []vfs.VirtualFolder, actual []vfs.VirtualFolder) error {
+	if len(actual) != len(expected) {
 		return errors.New("virtual folders len mismatch")
 		return errors.New("virtual folders len mismatch")
 	}
 	}
-	for _, v := range actual.VirtualFolders {
+	for _, v := range actual {
 		found := false
 		found := false
-		for _, v1 := range expected.VirtualFolders {
+		for _, v1 := range expected {
 			if path.Clean(v.VirtualPath) == path.Clean(v1.VirtualPath) {
 			if path.Clean(v.VirtualPath) == path.Clean(v1.VirtualPath) {
 				if err := checkFolder(&v1.BaseVirtualFolder, &v.BaseVirtualFolder); err != nil {
 				if err := checkFolder(&v1.BaseVirtualFolder, &v.BaseVirtualFolder); err != nil {
 					return err
 					return err
@@ -1455,80 +1619,80 @@ func checkEncryptedSecret(expected, actual *kms.Secret) error {
 	return nil
 	return nil
 }
 }
 
 
-func compareUserFilterSubStructs(expected *dataprovider.User, actual *dataprovider.User) error {
-	for _, IPMask := range expected.Filters.AllowedIP {
-		if !util.IsStringInSlice(IPMask, actual.Filters.AllowedIP) {
+func compareUserFilterSubStructs(expected sdk.BaseUserFilters, actual sdk.BaseUserFilters) error {
+	for _, IPMask := range expected.AllowedIP {
+		if !util.IsStringInSlice(IPMask, actual.AllowedIP) {
 			return errors.New("allowed IP contents mismatch")
 			return errors.New("allowed IP contents mismatch")
 		}
 		}
 	}
 	}
-	for _, IPMask := range expected.Filters.DeniedIP {
-		if !util.IsStringInSlice(IPMask, actual.Filters.DeniedIP) {
+	for _, IPMask := range expected.DeniedIP {
+		if !util.IsStringInSlice(IPMask, actual.DeniedIP) {
 			return errors.New("denied IP contents mismatch")
 			return errors.New("denied IP contents mismatch")
 		}
 		}
 	}
 	}
-	for _, method := range expected.Filters.DeniedLoginMethods {
-		if !util.IsStringInSlice(method, actual.Filters.DeniedLoginMethods) {
+	for _, method := range expected.DeniedLoginMethods {
+		if !util.IsStringInSlice(method, actual.DeniedLoginMethods) {
 			return errors.New("denied login methods contents mismatch")
 			return errors.New("denied login methods contents mismatch")
 		}
 		}
 	}
 	}
-	for _, protocol := range expected.Filters.DeniedProtocols {
-		if !util.IsStringInSlice(protocol, actual.Filters.DeniedProtocols) {
+	for _, protocol := range expected.DeniedProtocols {
+		if !util.IsStringInSlice(protocol, actual.DeniedProtocols) {
 			return errors.New("denied protocols contents mismatch")
 			return errors.New("denied protocols contents mismatch")
 		}
 		}
 	}
 	}
-	for _, options := range expected.Filters.WebClient {
-		if !util.IsStringInSlice(options, actual.Filters.WebClient) {
+	for _, options := range expected.WebClient {
+		if !util.IsStringInSlice(options, actual.WebClient) {
 			return errors.New("web client options contents mismatch")
 			return errors.New("web client options contents mismatch")
 		}
 		}
 	}
 	}
 	return compareUserFiltersEqualFields(expected, actual)
 	return compareUserFiltersEqualFields(expected, actual)
 }
 }
 
 
-func compareUserFiltersEqualFields(expected *dataprovider.User, actual *dataprovider.User) error {
-	if expected.Filters.Hooks.ExternalAuthDisabled != actual.Filters.Hooks.ExternalAuthDisabled {
+func compareUserFiltersEqualFields(expected sdk.BaseUserFilters, actual sdk.BaseUserFilters) error {
+	if expected.Hooks.ExternalAuthDisabled != actual.Hooks.ExternalAuthDisabled {
 		return errors.New("external_auth_disabled hook mismatch")
 		return errors.New("external_auth_disabled hook mismatch")
 	}
 	}
-	if expected.Filters.Hooks.PreLoginDisabled != actual.Filters.Hooks.PreLoginDisabled {
+	if expected.Hooks.PreLoginDisabled != actual.Hooks.PreLoginDisabled {
 		return errors.New("pre_login_disabled hook mismatch")
 		return errors.New("pre_login_disabled hook mismatch")
 	}
 	}
-	if expected.Filters.Hooks.CheckPasswordDisabled != actual.Filters.Hooks.CheckPasswordDisabled {
+	if expected.Hooks.CheckPasswordDisabled != actual.Hooks.CheckPasswordDisabled {
 		return errors.New("check_password_disabled hook mismatch")
 		return errors.New("check_password_disabled hook mismatch")
 	}
 	}
-	if expected.Filters.DisableFsChecks != actual.Filters.DisableFsChecks {
+	if expected.DisableFsChecks != actual.DisableFsChecks {
 		return errors.New("disable_fs_checks mismatch")
 		return errors.New("disable_fs_checks mismatch")
 	}
 	}
-	if expected.Filters.StartDirectory != actual.Filters.StartDirectory {
+	if expected.StartDirectory != actual.StartDirectory {
 		return errors.New("start_directory mismatch")
 		return errors.New("start_directory mismatch")
 	}
 	}
 	return nil
 	return nil
 }
 }
 
 
-func compareUserFilters(expected *dataprovider.User, actual *dataprovider.User) error {
-	if len(expected.Filters.AllowedIP) != len(actual.Filters.AllowedIP) {
+func compareUserFilters(expected sdk.BaseUserFilters, actual sdk.BaseUserFilters) error {
+	if len(expected.AllowedIP) != len(actual.AllowedIP) {
 		return errors.New("allowed IP mismatch")
 		return errors.New("allowed IP mismatch")
 	}
 	}
-	if len(expected.Filters.DeniedIP) != len(actual.Filters.DeniedIP) {
+	if len(expected.DeniedIP) != len(actual.DeniedIP) {
 		return errors.New("denied IP mismatch")
 		return errors.New("denied IP mismatch")
 	}
 	}
-	if len(expected.Filters.DeniedLoginMethods) != len(actual.Filters.DeniedLoginMethods) {
+	if len(expected.DeniedLoginMethods) != len(actual.DeniedLoginMethods) {
 		return errors.New("denied login methods mismatch")
 		return errors.New("denied login methods mismatch")
 	}
 	}
-	if len(expected.Filters.DeniedProtocols) != len(actual.Filters.DeniedProtocols) {
+	if len(expected.DeniedProtocols) != len(actual.DeniedProtocols) {
 		return errors.New("denied protocols mismatch")
 		return errors.New("denied protocols mismatch")
 	}
 	}
-	if expected.Filters.MaxUploadFileSize != actual.Filters.MaxUploadFileSize {
+	if expected.MaxUploadFileSize != actual.MaxUploadFileSize {
 		return errors.New("max upload file size mismatch")
 		return errors.New("max upload file size mismatch")
 	}
 	}
-	if expected.Filters.TLSUsername != actual.Filters.TLSUsername {
+	if expected.TLSUsername != actual.TLSUsername {
 		return errors.New("TLSUsername mismatch")
 		return errors.New("TLSUsername mismatch")
 	}
 	}
-	if len(expected.Filters.WebClient) != len(actual.Filters.WebClient) {
+	if len(expected.WebClient) != len(actual.WebClient) {
 		return errors.New("WebClient filter mismatch")
 		return errors.New("WebClient filter mismatch")
 	}
 	}
-	if expected.Filters.AllowAPIKeyAuth != actual.Filters.AllowAPIKeyAuth {
+	if expected.AllowAPIKeyAuth != actual.AllowAPIKeyAuth {
 		return errors.New("allow_api_key_auth mismatch")
 		return errors.New("allow_api_key_auth mismatch")
 	}
 	}
-	if expected.Filters.ExternalAuthCacheTime != actual.Filters.ExternalAuthCacheTime {
+	if expected.ExternalAuthCacheTime != actual.ExternalAuthCacheTime {
 		return errors.New("external_auth_cache_time mismatch")
 		return errors.New("external_auth_cache_time mismatch")
 	}
 	}
 	if err := compareUserFilterSubStructs(expected, actual); err != nil {
 	if err := compareUserFilterSubStructs(expected, actual); err != nil {
@@ -1555,21 +1719,21 @@ func checkFilterMatch(expected []string, actual []string) bool {
 	return true
 	return true
 }
 }
 
 
-func compareUserDataTransferLimitFilters(expected *dataprovider.User, actual *dataprovider.User) error {
-	if len(expected.Filters.DataTransferLimits) != len(actual.Filters.DataTransferLimits) {
+func compareUserDataTransferLimitFilters(expected sdk.BaseUserFilters, actual sdk.BaseUserFilters) error {
+	if len(expected.DataTransferLimits) != len(actual.DataTransferLimits) {
 		return errors.New("data transfer limits filters mismatch")
 		return errors.New("data transfer limits filters mismatch")
 	}
 	}
-	for idx, l := range expected.Filters.DataTransferLimits {
-		if actual.Filters.DataTransferLimits[idx].UploadDataTransfer != l.UploadDataTransfer {
+	for idx, l := range expected.DataTransferLimits {
+		if actual.DataTransferLimits[idx].UploadDataTransfer != l.UploadDataTransfer {
 			return errors.New("data transfer limit upload_data_transfer mismatch")
 			return errors.New("data transfer limit upload_data_transfer mismatch")
 		}
 		}
-		if actual.Filters.DataTransferLimits[idx].DownloadDataTransfer != l.DownloadDataTransfer {
+		if actual.DataTransferLimits[idx].DownloadDataTransfer != l.DownloadDataTransfer {
 			return errors.New("data transfer limit download_data_transfer mismatch")
 			return errors.New("data transfer limit download_data_transfer mismatch")
 		}
 		}
-		if actual.Filters.DataTransferLimits[idx].TotalDataTransfer != l.TotalDataTransfer {
+		if actual.DataTransferLimits[idx].TotalDataTransfer != l.TotalDataTransfer {
 			return errors.New("data transfer limit total_data_transfer mismatch")
 			return errors.New("data transfer limit total_data_transfer mismatch")
 		}
 		}
-		for _, source := range actual.Filters.DataTransferLimits[idx].Sources {
+		for _, source := range actual.DataTransferLimits[idx].Sources {
 			if !util.IsStringInSlice(source, l.Sources) {
 			if !util.IsStringInSlice(source, l.Sources) {
 				return errors.New("data transfer limit source mismatch")
 				return errors.New("data transfer limit source mismatch")
 			}
 			}
@@ -1579,22 +1743,22 @@ func compareUserDataTransferLimitFilters(expected *dataprovider.User, actual *da
 	return nil
 	return nil
 }
 }
 
 
-func compareUserBandwidthLimitFilters(expected *dataprovider.User, actual *dataprovider.User) error {
-	if len(expected.Filters.BandwidthLimits) != len(actual.Filters.BandwidthLimits) {
+func compareUserBandwidthLimitFilters(expected sdk.BaseUserFilters, actual sdk.BaseUserFilters) error {
+	if len(expected.BandwidthLimits) != len(actual.BandwidthLimits) {
 		return errors.New("bandwidth limits filters mismatch")
 		return errors.New("bandwidth limits filters mismatch")
 	}
 	}
 
 
-	for idx, l := range expected.Filters.BandwidthLimits {
-		if actual.Filters.BandwidthLimits[idx].UploadBandwidth != l.UploadBandwidth {
+	for idx, l := range expected.BandwidthLimits {
+		if actual.BandwidthLimits[idx].UploadBandwidth != l.UploadBandwidth {
 			return errors.New("bandwidth filters upload_bandwidth mismatch")
 			return errors.New("bandwidth filters upload_bandwidth mismatch")
 		}
 		}
-		if actual.Filters.BandwidthLimits[idx].DownloadBandwidth != l.DownloadBandwidth {
+		if actual.BandwidthLimits[idx].DownloadBandwidth != l.DownloadBandwidth {
 			return errors.New("bandwidth filters download_bandwidth mismatch")
 			return errors.New("bandwidth filters download_bandwidth mismatch")
 		}
 		}
-		if len(actual.Filters.BandwidthLimits[idx].Sources) != len(l.Sources) {
+		if len(actual.BandwidthLimits[idx].Sources) != len(l.Sources) {
 			return errors.New("bandwidth filters sources mismatch")
 			return errors.New("bandwidth filters sources mismatch")
 		}
 		}
-		for _, source := range actual.Filters.BandwidthLimits[idx].Sources {
+		for _, source := range actual.BandwidthLimits[idx].Sources {
 			if !util.IsStringInSlice(source, l.Sources) {
 			if !util.IsStringInSlice(source, l.Sources) {
 				return errors.New("bandwidth filters source mismatch")
 				return errors.New("bandwidth filters source mismatch")
 			}
 			}
@@ -1604,13 +1768,13 @@ func compareUserBandwidthLimitFilters(expected *dataprovider.User, actual *datap
 	return nil
 	return nil
 }
 }
 
 
-func compareUserFilePatternsFilters(expected *dataprovider.User, actual *dataprovider.User) error {
-	if len(expected.Filters.FilePatterns) != len(actual.Filters.FilePatterns) {
+func compareUserFilePatternsFilters(expected sdk.BaseUserFilters, actual sdk.BaseUserFilters) error {
+	if len(expected.FilePatterns) != len(actual.FilePatterns) {
 		return errors.New("file patterns mismatch")
 		return errors.New("file patterns mismatch")
 	}
 	}
-	for _, f := range expected.Filters.FilePatterns {
+	for _, f := range expected.FilePatterns {
 		found := false
 		found := false
-		for _, f1 := range actual.Filters.FilePatterns {
+		for _, f1 := range actual.FilePatterns {
 			if path.Clean(f.Path) == path.Clean(f1.Path) && f.DenyPolicy == f1.DenyPolicy {
 			if path.Clean(f.Path) == path.Clean(f1.Path) && f.DenyPolicy == f1.DenyPolicy {
 				if !checkFilterMatch(f.AllowedPatterns, f1.AllowedPatterns) ||
 				if !checkFilterMatch(f.AllowedPatterns, f1.AllowedPatterns) ||
 					!checkFilterMatch(f.DeniedPatterns, f1.DeniedPatterns) {
 					!checkFilterMatch(f.DeniedPatterns, f1.DeniedPatterns) {
@@ -1626,6 +1790,37 @@ func compareUserFilePatternsFilters(expected *dataprovider.User, actual *datapro
 	return nil
 	return nil
 }
 }
 
 
+func compareEqualGroupSettingsFields(expected sdk.BaseGroupUserSettings, actual sdk.BaseGroupUserSettings) error {
+	if expected.HomeDir != actual.HomeDir {
+		return errors.New("home dir mismatch")
+	}
+	if expected.MaxSessions != actual.MaxSessions {
+		return errors.New("MaxSessions mismatch")
+	}
+	if expected.QuotaSize != actual.QuotaSize {
+		return errors.New("QuotaSize mismatch")
+	}
+	if expected.QuotaFiles != actual.QuotaFiles {
+		return errors.New("QuotaFiles mismatch")
+	}
+	if expected.UploadBandwidth != actual.UploadBandwidth {
+		return errors.New("UploadBandwidth mismatch")
+	}
+	if expected.DownloadBandwidth != actual.DownloadBandwidth {
+		return errors.New("DownloadBandwidth mismatch")
+	}
+	if expected.UploadDataTransfer != actual.UploadDataTransfer {
+		return errors.New("upload_data_transfer mismatch")
+	}
+	if expected.DownloadDataTransfer != actual.DownloadDataTransfer {
+		return errors.New("download_data_transfer mismatch")
+	}
+	if expected.TotalDataTransfer != actual.TotalDataTransfer {
+		return errors.New("total_data_transfer mismatch")
+	}
+	return compareUserPermissions(expected.Permissions, actual.Permissions)
+}
+
 func compareEqualsUserFields(expected *dataprovider.User, actual *dataprovider.User) error {
 func compareEqualsUserFields(expected *dataprovider.User, actual *dataprovider.User) error {
 	if dataprovider.ConvertName(expected.Username) != actual.Username {
 	if dataprovider.ConvertName(expected.Username) != actual.Username {
 		return errors.New("username mismatch")
 		return errors.New("username mismatch")

+ 288 - 9
openapi/openapi.yaml

@@ -9,6 +9,7 @@ tags:
   - name: defender
   - name: defender
   - name: quota
   - name: quota
   - name: folders
   - name: folders
+  - name: groups
   - name: users
   - name: users
   - name: data retention
   - name: data retention
   - name: events
   - name: events
@@ -22,6 +23,7 @@ info:
     Several storage backends are supported and they are configurable per-user, so you can serve a local directory for a user and an S3 bucket (or part of it) for another one.
     Several storage backends are supported and they are configurable per-user, so you can serve a local directory for a user and an S3 bucket (or part of it) for another one.
     SFTPGo also supports virtual folders, a virtual folder can use any of the supported storage backends. So you can have, for example, an S3 user that exposes a Google Cloud Storage bucket (or part of it) on a specified path and an encrypted local filesystem on another one.
     SFTPGo also supports virtual folders, a virtual folder can use any of the supported storage backends. So you can have, for example, an S3 user that exposes a Google Cloud Storage bucket (or part of it) on a specified path and an encrypted local filesystem on another one.
     Virtual folders can be private or shared among multiple users, for shared virtual folders you can define different quota limits for each user.
     Virtual folders can be private or shared among multiple users, for shared virtual folders you can define different quota limits for each user.
+    SFTPGo supports groups to simplify the administration of multiple accounts by letting you assign settings once to a group, instead of multiple times to each individual user.
     The SFTPGo WebClient allows end users to change their credentials, browse and manage their files in the browser and setup two-factor authentication which works with Authy, Google Authenticator and other compatible apps.
     The SFTPGo WebClient allows end users to change their credentials, browse and manage their files in the browser and setup two-factor authentication which works with Authy, Google Authenticator and other compatible apps.
     From the WebClient each authorized user can also create HTTP/S links to externally share files and folders securely, by setting limits to the number of downloads/uploads, protecting the share with a password, limiting access by source IP address, setting an automatic expiration date.
     From the WebClient each authorized user can also create HTTP/S links to externally share files and folders securely, by setting limits to the number of downloads/uploads, protecting the share with a password, limiting access by source IP address, setting an automatic expiration date.
   version: 2.2.2-dev
   version: 2.2.2-dev
@@ -1696,7 +1698,7 @@ paths:
         - in: query
         - in: query
           name: order
           name: order
           required: false
           required: false
-          description: Ordering folders by path. Default ASC
+          description: Ordering folders by name. Default ASC
           schema:
           schema:
             type: string
             type: string
             enum:
             enum:
@@ -1844,6 +1846,181 @@ paths:
           $ref: '#/components/responses/InternalServerError'
           $ref: '#/components/responses/InternalServerError'
         default:
         default:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
+  /groups:
+    get:
+      tags:
+        - groups
+      summary: Get groups
+      description: Returns an array with one or more groups
+      operationId: get_groups
+      parameters:
+        - in: query
+          name: offset
+          schema:
+            type: integer
+            minimum: 0
+            default: 0
+          required: false
+        - in: query
+          name: limit
+          schema:
+            type: integer
+            minimum: 1
+            maximum: 500
+            default: 100
+          required: false
+          description: 'The maximum number of items to return. Max value is 500, default is 100'
+        - in: query
+          name: order
+          required: false
+          description: Ordering groups by name. Default ASC
+          schema:
+            type: string
+            enum:
+              - ASC
+              - DESC
+            example: ASC
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/Group'
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+    post:
+      tags:
+        - groups
+      summary: Add group
+      operationId: add_group
+      description: Adds a new group
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/Group'
+      responses:
+        '201':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Group'
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+  '/groups/{name}':
+    parameters:
+      - name: name
+        in: path
+        description: group name
+        required: true
+        schema:
+          type: string
+    get:
+      tags:
+        - groups
+      summary: Find groups by name
+      description: Returns the group with the given name if it exists.
+      operationId: get_group_by_name
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Group'
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '404':
+          $ref: '#/components/responses/NotFound'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+    put:
+      tags:
+        - groups
+      summary: Update group
+      description: Updates an existing group
+      operationId: update_group
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/Group'
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+              example:
+                message: User updated
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '404':
+          $ref: '#/components/responses/NotFound'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+    delete:
+      tags:
+        - groups
+      summary: Delete
+      description: Deletes an existing group
+      operationId: delete_group
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+              example:
+                message: User deleted
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '404':
+          $ref: '#/components/responses/NotFound'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
   /events/fs:
   /events/fs:
     get:
     get:
       tags:
       tags:
@@ -4606,7 +4783,7 @@ components:
         total_data_transfer:
         total_data_transfer:
           type: integer
           type: integer
           description: 'Maximum total data transfer as MB. 0 means unlimited. You can set a total data transfer instead of the individual values for uploads and downloads'
           description: 'Maximum total data transfer as MB. 0 means unlimited. You can set a total data transfer instead of the individual values for uploads and downloads'
-    UserFilters:
+    BaseUserFilters:
       type: object
       type: object
       properties:
       properties:
         allowed_ip:
         allowed_ip:
@@ -4665,12 +4842,6 @@ components:
           description: 'API key authentication allows to impersonate this user with an API key'
           description: 'API key authentication allows to impersonate this user with an API key'
         user_type:
         user_type:
           $ref: '#/components/schemas/UserType'
           $ref: '#/components/schemas/UserType'
-        totp_config:
-          $ref: '#/components/schemas/UserTOTPConfig'
-        recovery_codes:
-          type: array
-          items:
-            $ref: '#/components/schemas/RecoveryCode'
         bandwidth_limits:
         bandwidth_limits:
           type: array
           type: array
           items:
           items:
@@ -4691,6 +4862,17 @@ components:
             $ref: '#/components/schemas/MFAProtocols'
             $ref: '#/components/schemas/MFAProtocols'
           description: 'Defines protocols that require two factor authentication'
           description: 'Defines protocols that require two factor authentication'
       description: Additional user options
       description: Additional user options
+    UserFilters:
+      allOf:
+        - $ref: '#/components/schemas/BaseUserFilters'
+        - type: object
+          properties:
+            totp_config:
+              $ref: '#/components/schemas/UserTOTPConfig'
+            recovery_codes:
+              type: array
+              items:
+                $ref: '#/components/schemas/RecoveryCode'
     Secret:
     Secret:
       type: object
       type: object
       properties:
       properties:
@@ -4984,7 +5166,7 @@ components:
           type: array
           type: array
           items:
           items:
             $ref: '#/components/schemas/VirtualFolder'
             $ref: '#/components/schemas/VirtualFolder'
-          description: mapping between virtual SFTPGo paths and filesystem paths outside the user home directory. Supported for local filesystem only. If one or more of the specified folders are not inside the dataprovider they will be automatically created. You have to create the folder on the filesystem yourself
+          description: mapping between virtual SFTPGo paths and virtual folders. If one or more of the specified folders are not inside the dataprovider they will be automatically created. You have to create the folder on the filesystem yourself
         uid:
         uid:
           type: integer
           type: integer
           format: int32
           format: int32
@@ -5070,6 +5252,10 @@ components:
         additional_info:
         additional_info:
           type: string
           type: string
           description: Free form text field for external systems
           description: Free form text field for external systems
+        groups:
+          type: array
+          items:
+            $ref: '#/components/schemas/GroupMapping'
         oidc_custom_fields:
         oidc_custom_fields:
           type: object
           type: object
           additionalProperties: true
           additionalProperties: true
@@ -5652,6 +5838,95 @@ components:
           example:
           example:
             - 192.0.2.0/24
             - 192.0.2.0/24
             - '2001:db8::/32'
             - '2001:db8::/32'
+    GroupUserSettings:
+      type: object
+      properties:
+        home_dir:
+          type: string
+        max_sessions:
+          type: integer
+          format: int32
+        quota_size:
+          type: integer
+          format: int64
+        quota_files:
+          type: integer
+          format: int32
+        permissions:
+          type: object
+          items:
+            $ref: '#/components/schemas/DirPermissions'
+          minItems: 1
+          example:
+            /:
+              - '*'
+            /somedir:
+              - list
+              - download
+        upload_bandwidth:
+          type: integer
+          description: 'Maximum upload bandwidth as KB/s'
+        download_bandwidth:
+          type: integer
+          description: 'Maximum download bandwidth as KB/s'
+        upload_data_transfer:
+          type: integer
+          description: 'Maximum data transfer allowed for uploads as MB'
+        download_data_transfer:
+          type: integer
+          description: 'Maximum data transfer allowed for downloads as MB'
+        total_data_transfer:
+          type: integer
+          description: 'Maximum total data transfer as MB'
+        filters:
+          $ref: '#/components/schemas/BaseUserFilters'
+    Group:
+      type: object
+      properties:
+        id:
+          type: integer
+          format: int32
+          minimum: 1
+        name:
+          type: string
+          description: name is unique
+        description:
+          type: string
+          description: 'optional description'
+        created_at:
+          type: integer
+          format: int64
+          description: creation time as unix timestamp in milliseconds
+        updated_at:
+          type: integer
+          format: int64
+          description: last update time as unix timestamp in milliseconds
+        user_settings:
+          $ref: '#/components/schemas/GroupUserSettings'
+        virtual_folders:
+          type: array
+          items:
+            $ref: '#/components/schemas/VirtualFolder'
+          description: mapping between virtual SFTPGo paths and folders
+        users:
+          type: array
+          items:
+            type: string
+          description: list of usernames associated with this group
+    GroupMapping:
+      type: object
+      properties:
+        name:
+          type: string
+          description: group name
+        type:
+          enum:
+            - 1
+            - 2
+          description: |
+            Group type:
+              * `1` - Primary group
+              * `2` - Secondaru group
     BackupData:
     BackupData:
       type: object
       type: object
       properties:
       properties:
@@ -5663,6 +5938,10 @@ components:
           type: array
           type: array
           items:
           items:
             $ref: '#/components/schemas/BaseVirtualFolder'
             $ref: '#/components/schemas/BaseVirtualFolder'
+        groups:
+          type: array
+          items:
+            $ref: '#/components/schemas/Group'
         admins:
         admins:
           type: array
           type: array
           items:
           items:

+ 4 - 0
service/service.go

@@ -308,6 +308,10 @@ func (s *Service) restoreDump(dump *dataprovider.BackupData) error {
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("unable to restore folders from file %#v: %v", s.LoadDataFrom, err)
 		return fmt.Errorf("unable to restore folders from file %#v: %v", s.LoadDataFrom, err)
 	}
 	}
+	err = httpd.RestoreGroups(dump.Groups, s.LoadDataFrom, s.LoadDataMode, dataprovider.ActionExecutorSystem, "")
+	if err != nil {
+		return fmt.Errorf("unable to restore groups from file %#v: %v", s.LoadDataFrom, err)
+	}
 	err = httpd.RestoreUsers(dump.Users, s.LoadDataFrom, s.LoadDataMode, s.LoadDataQuotaScan, dataprovider.ActionExecutorSystem, "")
 	err = httpd.RestoreUsers(dump.Users, s.LoadDataFrom, s.LoadDataMode, s.LoadDataQuotaScan, dataprovider.ActionExecutorSystem, "")
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("unable to restore users from file %#v: %v", s.LoadDataFrom, err)
 		return fmt.Errorf("unable to restore users from file %#v: %v", s.LoadDataFrom, err)

+ 1 - 1
sftpd/server.go

@@ -222,7 +222,7 @@ func (c *Configuration) getServerConfig() *ssh.ServerConfig {
 		},
 		},
 		NextAuthMethodsCallback: func(conn ssh.ConnMetadata) []string {
 		NextAuthMethodsCallback: func(conn ssh.ConnMetadata) []string {
 			var nextMethods []string
 			var nextMethods []string
-			user, err := dataprovider.UserExists(conn.User())
+			user, err := dataprovider.GetUserWithGroupSettings(conn.User())
 			if err == nil {
 			if err == nil {
 				nextMethods = user.GetNextAuthMethods(conn.PartialSuccessMethods(), c.PasswordAuthentication)
 				nextMethods = user.GetNextAuthMethods(conn.PartialSuccessMethods(), c.PasswordAuthentication)
 			}
 			}

+ 43 - 0
sftpd/sftpd_test.go

@@ -450,6 +450,7 @@ func TestInitialization(t *testing.T) {
 	assert.ErrorIs(t, err, os.ErrNotExist)
 	assert.ErrorIs(t, err, os.ErrNotExist)
 
 
 	err = createTestFile(revokeUserCerts, 10*1024*1024)
 	err = createTestFile(revokeUserCerts, 10*1024*1024)
+	assert.NoError(t, err)
 	sftpdConf.RevokedUserCertsFile = revokeUserCerts
 	sftpdConf.RevokedUserCertsFile = revokeUserCerts
 	err = sftpdConf.Initialize(configDir)
 	err = sftpdConf.Initialize(configDir)
 	assert.Error(t, err)
 	assert.Error(t, err)
@@ -606,6 +607,39 @@ func TestBasicSFTPFsHandling(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
 
 
+func TestGroupSettingsOverride(t *testing.T) {
+	usePubKey := true
+	g := getTestGroup()
+	g.UserSettings.Filters.StartDirectory = "/%username%"
+	group, _, err := httpdtest.AddGroup(g, http.StatusCreated)
+	assert.NoError(t, err)
+	u := getTestUser(usePubKey)
+	u.Groups = []sdk.GroupMapping{
+		{
+			Name: group.Name,
+			Type: sdk.GroupTypePrimary,
+		},
+	}
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	conn, client, err := getSftpClient(user, usePubKey)
+	if assert.NoError(t, err) {
+		defer conn.Close()
+		defer client.Close()
+
+		currentDir, err := client.Getwd()
+		assert.NoError(t, err)
+		assert.Equal(t, "/"+user.Username, currentDir)
+	}
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveGroup(group, http.StatusOK)
+	assert.NoError(t, err)
+}
+
 func TestStartDirectory(t *testing.T) {
 func TestStartDirectory(t *testing.T) {
 	usePubKey := false
 	usePubKey := false
 	startDir := "/st@ rt/dir"
 	startDir := "/st@ rt/dir"
@@ -10297,6 +10331,15 @@ func waitTCPListening(address string) {
 	}
 	}
 }
 }
 
 
+func getTestGroup() dataprovider.Group {
+	return dataprovider.Group{
+		BaseGroup: sdk.BaseGroup{
+			Name:        "test_group",
+			Description: "test group description",
+		},
+	}
+}
+
 func getTestUser(usePubKey bool) dataprovider.User {
 func getTestUser(usePubKey bool) dataprovider.User {
 	user := dataprovider.User{
 	user := dataprovider.User{
 		BaseUser: sdk.BaseUser{
 		BaseUser: sdk.BaseUser{

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 5 - 0
static/vendor/bootstrap-select/css/bootstrap-select.min.css


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 7 - 0
static/vendor/bootstrap-select/js/bootstrap-select.min.js


+ 8 - 0
templates/webadmin/base.html

@@ -93,6 +93,14 @@
             </li>
             </li>
             {{end}}
             {{end}}
 
 
+            {{ if .LoggedAdmin.HasPermission "manage_groups"}}
+            <li class="nav-item {{if eq .CurrentURL .GroupsURL}}active{{end}}">
+                <a class="nav-link" href="{{.GroupsURL}}">
+                    <i class="fas fa-user-friends"></i>
+                    <span>{{.GroupsTitle}}</span></a>
+            </li>
+            {{end}}
+
             {{ if .LoggedAdmin.HasPermission "view_conns"}}
             {{ if .LoggedAdmin.HasPermission "view_conns"}}
             <li class="nav-item {{if eq .CurrentURL .ConnectionsURL}}active{{end}}">
             <li class="nav-item {{if eq .CurrentURL .ConnectionsURL}}active{{end}}">
                 <a class="nav-link" href="{{.ConnectionsURL}}">
                 <a class="nav-link" href="{{.ConnectionsURL}}">

+ 7 - 2
templates/webadmin/folder.html

@@ -2,6 +2,10 @@
 
 
 {{define "title"}}{{.Title}}{{end}}
 {{define "title"}}{{.Title}}{{end}}
 
 
+{{define "extra_css"}}
+<link href="{{.StaticURL}}/vendor/bootstrap-select/css/bootstrap-select.min.css" rel="stylesheet">
+{{end}}
+
 {{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">
@@ -92,6 +96,7 @@
 {{end}}
 {{end}}
 
 
 {{define "extra_js"}}
 {{define "extra_js"}}
+<script src="{{.StaticURL}}/vendor/bootstrap-select/js/bootstrap-select.min.js"></script>
 <script type="text/javascript">
 <script type="text/javascript">
     $(document).ready(function () {
     $(document).ready(function () {
         onFilesystemChanged('{{.Folder.FsConfig.Provider.Name}}');
         onFilesystemChanged('{{.Folder.FsConfig.Provider.Name}}');
@@ -120,7 +125,7 @@
         });
         });
 
 
     });
     });
-
-    {{template "fsjs"}}
 </script>
 </script>
+
+{{template "fsjs"}}
 {{end}}
 {{end}}

+ 6 - 4
templates/webadmin/fsconfig.html

@@ -7,7 +7,7 @@
         <div class="form-group row">
         <div class="form-group row">
             <label for="idFilesystem" class="col-sm-2 col-form-label">Storage</label>
             <label for="idFilesystem" class="col-sm-2 col-form-label">Storage</label>
             <div class="col-sm-10">
             <div class="col-sm-10">
-                <select class="form-control" id="idFilesystem" name="fs_provider"
+                <select class="form-control selectpicker" id="idFilesystem" name="fs_provider"
                     onchange="onFilesystemChanged(this.value)">
                     onchange="onFilesystemChanged(this.value)">
                     {{ range ListFSProviders }}
                     {{ range ListFSProviders }}
                     <option value="{{.Name}}" {{if eq . $.Provider }}selected{{end}}>{{.ShortInfo}}</option>
                     <option value="{{.Name}}" {{if eq . $.Provider }}selected{{end}}>{{.ShortInfo}}</option>
@@ -15,14 +15,14 @@
                 </select>
                 </select>
             </div>
             </div>
         </div>
         </div>
-        {{if .IsUserPage}}
+        {{if or .IsUserPage .IsGroupPage}}
         <div class="form-group row">
         <div class="form-group row">
             <label for="idHomeDir" class="col-sm-2 col-form-label">Home Dir</label>
             <label for="idHomeDir" class="col-sm-2 col-form-label">Home Dir</label>
             <div class="col-sm-10">
             <div class="col-sm-10">
                 <input type="text" class="form-control" id="idHomeDir" name="home_dir" placeholder="Absolute path to a local directory"
                 <input type="text" class="form-control" id="idHomeDir" name="home_dir" placeholder="Absolute path to a local directory"
                     value="{{.DirPath}}" aria-describedby="homeDirHelpBlock">
                     value="{{.DirPath}}" aria-describedby="homeDirHelpBlock">
                 <small id="homeDirHelpBlock" class="form-text text-muted">
                 <small id="homeDirHelpBlock" class="form-text text-muted">
-                    {{if not .DirPath}}{{if .HasUsersBaseDir}}Leave blank for an appropriate default{{else}}Required for local storage providers. For non-local filesystems it will store temporary files, you can leave blank for an appropriate default{{end}}{{end}}
+                    {{if not .DirPath}}{{if .HasUsersBaseDir}}Leave blank for an appropriate default{{else}}{{if .IsGroupPage}}Leave blank and the storage to "local" to not override the root filesystem{{else}}Required for local storage providers. For non-local filesystems it will store temporary files, you can leave blank for an appropriate default{{end}}{{end}}{{end}}
                 </small>
                 </small>
             </div>
             </div>
         </div>
         </div>
@@ -299,7 +299,7 @@
         <div class="form-group row fsconfig fsconfig-azblobfs">
         <div class="form-group row fsconfig fsconfig-azblobfs">
             <label for="idAzAccessTier" class="col-sm-2 col-form-label">Access Tier</label>
             <label for="idAzAccessTier" class="col-sm-2 col-form-label">Access Tier</label>
             <div class="col-sm-10">
             <div class="col-sm-10">
-                <select class="form-control" id="idAzAccessTier" name="az_access_tier">
+                <select class="form-control selectpicker" id="idAzAccessTier" name="az_access_tier">
                     <option value="" {{if eq .AzBlobConfig.AccessTier "" }}selected{{end}}>Default</option>
                     <option value="" {{if eq .AzBlobConfig.AccessTier "" }}selected{{end}}>Default</option>
                     <option value="Hot" {{if eq .AzBlobConfig.AccessTier "Hot" }}selected{{end}}>Hot</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="Cool" {{if eq .AzBlobConfig.AccessTier "Cool" }}selected{{end}}>Cool</option>
@@ -459,6 +459,7 @@
 {{end}}
 {{end}}
 
 
 {{define "fsjs"}}
 {{define "fsjs"}}
+<script type="text/javascript">
     function onFilesystemChanged(val){
     function onFilesystemChanged(val){
         // each fsconfig form-group has the 'fsconfig' css class
         // each fsconfig form-group has the 'fsconfig' css class
         // as well as a 'fsconfig-{name}' class where name is the FilesystemProvider.Name
         // as well as a 'fsconfig-{name}' class where name is the FilesystemProvider.Name
@@ -466,4 +467,5 @@
         $('.form-group.fsconfig').hide();
         $('.form-group.fsconfig').hide();
         $('.form-group.fsconfig-'+val).show();
         $('.form-group.fsconfig-'+val).show();
     }
     }
+</script>
 {{end}}
 {{end}}

+ 754 - 0
templates/webadmin/group.html

@@ -0,0 +1,754 @@
+{{template "base" .}}
+
+{{define "title"}}{{.Title}}{{end}}
+
+{{define "extra_css"}}
+<link href="{{.StaticURL}}/vendor/bootstrap-select/css/bootstrap-select.min.css" rel="stylesheet">
+{{end}}
+
+{{define "page_body"}}
+<!-- Page Heading -->
+<div class="card shadow mb-4">
+    <div class="card-header py-3">
+        <h6 class="m-0 font-weight-bold text-primary">{{.Title}}</h6>
+    </div>
+    <div class="card-body">
+        {{if .Error}}
+        <div class="card mb-4 border-left-warning">
+            <div class="card-body text-form-error">{{.Error}}</div>
+        </div>
+        {{end}}
+        <div class="card mb-4 border-left-info">
+            <div class="card-body">
+                The <span class="text-success">%username%</span> placeholder will be replaced with the username of the associated users.
+            </div>
+        </div>
+        <form id="group_form" enctype="multipart/form-data" action="{{.CurrentURL}}" method="POST" autocomplete="off">
+            <div class="form-group row">
+                <label for="idGroupName" class="col-sm-2 col-form-label">Name</label>
+                <div class="col-sm-10">
+                    <input type="text" class="form-control" id="idGroupName" name="name" placeholder=""
+                        value="{{.Group.Name}}" maxlength="255" autocomplete="nope" required {{if eq .Mode 2}}readonly{{end}}>
+                </div>
+            </div>
+            <div class="form-group row">
+                <label for="idDescription" class="col-sm-2 col-form-label">Description</label>
+                <div class="col-sm-10">
+                    <input type="text" class="form-control" id="idDescription" name="description" placeholder=""
+                        value="{{.Group.Description}}" maxlength="255" aria-describedby="descriptionHelpBlock">
+                    <small id="descriptionHelpBlock" class="form-text text-muted">
+                        Optional description
+                    </small>
+                </div>
+            </div>
+
+            {{template "fshtml" .FsWrapper}}
+            {{if .VirtualFolders}}
+            <div class="card bg-light mb-3">
+                <div class="card-header">
+                    <b>Virtual folders</b>
+                </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 := .Group.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 selectpicker" data-live-search="true" 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 selectpicker" data-live-search="true" 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="accordion" id="accordionUser">
+                <div class="card">
+                    <div class="card-header" id="headingPermissions">
+                        <h2 class="mb-0">
+                            <button class="btn btn-link btn-block text-left" type="button" data-toggle="collapse"
+                                data-target="#collapsePermissions" aria-expanded="true" aria-controls="collapsePermissions">
+                                <h6 class="m-0 font-weight-bold text-primary">ACLs</h6>
+                            </button>
+                        </h2>
+                    </div>
+
+                    <div id="collapsePermissions" class="collapse" aria-labelledby="headingPermissions" data-parent="#accordionUser">
+                        <div class="card-body">
+                            <div class="card bg-light mb-3">
+                                <div class="card-header">
+                                    <b>Per-directory permissions</b>
+                                </div>
+                                <div class="card-body">
+                                    <div class="form-group row">
+                                        <div class="col-md-12 form_field_dirperms_outer">
+                                            {{range $idx, $dirPerms := .Group.GetPermissions -}}
+                                            <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 selectpicker" 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 selectpicker" 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="card bg-light mb-3">
+                                <div class="card-header">
+                                    <b>Per-directory pattern restrictions</b>
+                                </div>
+                                <div class="card-body">
+                                    <h6 class="card-title mb-4">Comma separated denied or allowed files/directories, based on shell patterns.</h6>
+                                    <p class="card-text">Denied entries are visible in directory listing by default, you can hide them by setting the "Hidden" policy, but please be aware that this may cause performance issues for large directories</p>
+                                    <div class="form-group row">
+                                        <div class="col-md-12 form_field_patterns_outer">
+                                            {{range $idx, $pattern := .Group.UserSettings.Filters.GetFlatFilePatterns -}}
+                                            <div class="row form_field_patterns_outer_row">
+                                                <div class="form-group col-md-3">
+                                                    <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-4">
+                                                    <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 selectpicker" 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-2">
+                                                    <select class="form-control selectpicker" id="idPatternPolicy{{$idx}}" name="pattern_policy{{$idx}}">
+                                                        <option value="0" {{if eq $pattern.DenyPolicy 0}}selected{{end}}>Visible</option>
+                                                        <option value="1" {{if eq $pattern.DenyPolicy 1}}selected{{end}}>Hidden</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-3">
+                                                    <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-4">
+                                                    <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 selectpicker" id="idPatternType0" name="pattern_type0">
+                                                        <option value="denied">Denied</option>
+                                                        <option value="allowed">Allowed</option>
+                                                    </select>
+                                                </div>
+                                                <div class="form-group col-md-2">
+                                                    <select class="form-control selectpicker" id="idPatternPolicy0" name="pattern_policy0">
+                                                        <option value="0">Visible</option>
+                                                        <option value="1">Hidden</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="idMaxSessions" class="col-sm-2 col-form-label">Max sessions</label>
+                                <div class="col-sm-10">
+                                    <input type="number" class="form-control" id="idMaxSessions" name="max_sessions" placeholder=""
+                                        value="{{.Group.UserSettings.MaxSessions}}" min="0" aria-describedby="sessionsHelpBlock">
+                                    <small id="sessionsHelpBlock" class="form-text text-muted">
+                                        Maximun number of concurrent sessions. 0 means no limit
+                                    </small>
+                                </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 selectpicker" id="idProtocols" name="denied_protocols" multiple>
+                                        {{range $protocol := .ValidProtocols}}
+                                        <option value="{{$protocol}}" {{range $p :=$.Group.UserSettings.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 selectpicker" id="idLoginMethods" name="denied_login_methods" multiple aria-describedby="deniedLoginMethodsHelpBlock">
+                                        {{range $method := .ValidLoginMethods}}
+                                        <option value="{{$method}}" {{range $m :=$.Group.UserSettings.Filters.DeniedLoginMethods }}{{if eq $m $method}}selected{{end}}{{end}}>{{$method}}
+                                        </option>
+                                        {{end}}
+                                    </select>
+                                    <small id="deniedLoginMethodsHelpBlock" class="form-text text-muted">
+                                        "password" is valid for all supported protocols, "password-over-SSH" only for SSH/SFTP/SCP
+                                    </small>
+                                </div>
+                            </div>
+
+                            <div class="form-group row">
+                                <label for="idTwoFactorProtocols" class="col-sm-2 col-form-label">Require two-factor auth for</label>
+                                <div class="col-sm-10">
+                                    <select class="form-control selectpicker" id="idTwoFactorProtocols" name="required_two_factor_protocols" multiple>
+                                        {{range $protocol := .TwoFactorProtocols}}
+                                        <option value="{{$protocol}}" {{range $p :=$.Group.UserSettings.Filters.TwoFactorAuthProtocols }}{{if eq $p $protocol}}selected{{end}}{{end}}>{{$protocol}}
+                                        </option>
+                                        {{end}}
+                                    </select>
+                                </div>
+                            </div>
+
+                            <div class="form-group row">
+                                <label for="idWebClient" class="col-sm-2 col-form-label">Web client/REST API</label>
+                                <div class="col-sm-10">
+                                    <select class="form-control selectpicker" id="idWebClient" name="web_client_options" multiple>
+                                        {{range $option := .WebClientOptions}}
+                                        <option value="{{$option}}" {{range $p :=$.Group.UserSettings.Filters.WebClient }}{{if eq $p $option}}selected{{end}}{{end}}>{{$option}}
+                                        </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">
+                                    <textarea class="form-control" id="idDeniedIP" name="denied_ip" rows="3" placeholder=""
+                                        aria-describedby="deniedIPHelpBlock">{{.Group.GetDeniedIPAsString}}</textarea>
+                                    <small id="deniedIPHelpBlock" class="form-text text-muted">
+                                        Comma separated IP/Mask in CIDR format, example: "192.168.1.0/24,10.8.0.100/32"
+                                    </small>
+                                </div>
+                            </div>
+
+                            <div class="form-group row">
+                                <label for="idAllowedIP" class="col-sm-2 col-form-label">Allowed IP/Mask</label>
+                                <div class="col-sm-10">
+                                    <textarea class="form-control" id="idAllowedIP" name="allowed_ip" rows="3" placeholder=""
+                                        aria-describedby="allowedIPHelpBlock">{{.Group.GetAllowedIPAsString}}</textarea>
+                                    <small id="allowedIPHelpBlock" class="form-text text-muted">
+                                        Comma separated IP/Mask in CIDR format, example: "192.168.1.0/24,10.8.0.100/32"
+                                    </small>
+                                </div>
+                            </div>
+
+                        </div>
+                    </div>
+                </div>
+                <div class="card">
+                    <div class="card-header" id="headingQuota">
+                        <h2 class="mb-0">
+                            <button class="btn btn-link btn-block text-left collapsed" type="button" data-toggle="collapse"
+                                data-target="#collapseQuota" aria-expanded="false" aria-controls="collapseQuota">
+                                <h6 class="m-0 font-weight-bold text-primary">Disk quota and bandwidth limits</h6>
+                            </button>
+                        </h2>
+                    </div>
+                    <div id="collapseQuota" class="collapse" aria-labelledby="headingTwo" data-parent="#accordionUser">
+                        <div class="card-body">
+
+                            <div class="form-group row">
+                                <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=""
+                                        value="{{.Group.UserSettings.QuotaSize}}" min="0" aria-describedby="qsHelpBlock">
+                                    <small id="qsHelpBlock" class="form-text text-muted">
+                                        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="{{.Group.UserSettings.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">
+                                <label for="idMaxUploadSize" class="col-sm-2 col-form-label">Max file upload size (bytes)</label>
+                                <div class="col-sm-10">
+                                    <input type="number" class="form-control" id="idMaxUploadSize" name="max_upload_file_size"
+                                        placeholder="" value="{{.Group.UserSettings.Filters.MaxUploadFileSize}}" min="0"
+                                        aria-describedby="fqsHelpBlock">
+                                    <small id="fqsHelpBlock" class="form-text text-muted">
+                                        Maximum upload size for a single file. 0 means no limit
+                                    </small>
+                                </div>
+                            </div>
+
+                            <div class="form-group row">
+                                <label for="idUploadBandwidth" class="col-sm-2 col-form-label">Bandwidth UL (KB/s)</label>
+                                <div class="col-sm-3">
+                                    <input type="number" class="form-control" id="idUploadBandwidth" name="upload_bandwidth"
+                                        placeholder="" value="{{.Group.UserSettings.UploadBandwidth}}" min="0" aria-describedby="ulHelpBlock">
+                                    <small id="ulHelpBlock" class="form-text text-muted">
+                                        0 means no limit
+                                    </small>
+                                </div>
+                                <div class="col-sm-2"></div>
+                                <label for="idDownloadBandwidth" class="col-sm-2 col-form-label">Bandwidth DL (KB/s)</label>
+                                <div class="col-sm-3">
+                                    <input type="number" class="form-control" id="idDownloadBandwidth" name="download_bandwidth"
+                                        placeholder="" value="{{.Group.UserSettings.DownloadBandwidth}}" min="0" aria-describedby="dlHelpBlock">
+                                    <small id="dlHelpBlock" class="form-text text-muted">
+                                        0 means no limit
+                                    </small>
+                                </div>
+                            </div>
+
+                            <div class="card bg-light mb-3">
+                                <div class="card-header">
+                                    <b>Per-source bandwidth speed limits</b>
+                                </div>
+                                <div class="card-body">
+                                    <div class="form-group row">
+                                        <div class="col-md-12 form_field_bwlimits_outer">
+                                            {{range $idx, $bwLimit := .Group.UserSettings.Filters.BandwidthLimits -}}
+                                            <div class="row form_field_bwlimits_outer_row">
+                                                <div class="form-group col-md-8">
+                                                    <textarea class="form-control" id="idBandwidthLimitSources{{$idx}}" name="bandwidth_limit_sources{{$idx}}" rows="4" placeholder=""
+                                                            aria-describedby="bwLimitSourcesHelpBlock{{$idx}}">{{$bwLimit.GetSourcesAsString}}</textarea>
+                                                    <small id="bwLimitSourcesHelpBlock{{$idx}}" class="form-text text-muted">
+                                                        Comma separated IP/Mask in CIDR format, example: "192.168.1.0/24,10.8.0.100/32"
+                                                    </small>
+                                                </div>
+                                                <div class="col-md-3">
+                                                    <div class="form-group">
+                                                        <input type="number" class="form-control" id="idUploadBandwidthSource{{$idx}}" name="upload_bandwidth_source{{$idx}}"
+                                                            placeholder="" value="{{$bwLimit.UploadBandwidth}}" min="0" aria-describedby="ulHelpBlock{{$idx}}">
+                                                        <small id="ulHelpBlock{{$idx}}" class="form-text text-muted">
+                                                            UL (KB/s). 0 means no limit
+                                                        </small>
+                                                    </div>
+                                                    <div class="form-group">
+                                                        <input type="number" class="form-control" id="idDownloadBandwidthSource{{$idx}}" name="download_bandwidth_source{{$idx}}"
+                                                            placeholder="" value="{{$bwLimit.DownloadBandwidth}}" min="0" aria-describedby="dlHelpBlock{{$idx}}">
+                                                        <small id="dlHelpBlock{{$idx}}" class="form-text text-muted">
+                                                            DL (KB/s). 0 means no limit
+                                                        </small>
+                                                    </div>
+                                                </div>
+
+                                                <div class="form-group col-md-1">
+                                                    <button class="btn btn-circle btn-danger remove_bwlimit_btn_frm_field">
+                                                        <i class="fas fa-trash"></i>
+                                                    </button>
+                                                </div>
+                                            </div>
+                                            {{else}}
+                                            <div class="row form_field_bwlimits_outer_row">
+                                                <div class="form-group col-md-8">
+                                                    <textarea class="form-control" id="idBandwidthLimitSources0" name="bandwidth_limit_sources0" rows="4" placeholder=""
+                                                            aria-describedby="bwLimitSourcesHelpBlock0"></textarea>
+                                                    <small id="bwLimitSourcesHelpBlock0" class="form-text text-muted">
+                                                        Comma separated IP/Mask in CIDR format, example: "192.168.1.0/24,10.8.0.100/32"
+                                                    </small>
+                                                </div>
+                                                <div class="col-md-3">
+                                                    <div class="form-group">
+                                                        <input type="number" class="form-control" id="idUploadBandwidthSource0" name="upload_bandwidth_source0"
+                                                            placeholder="" value="" min="0" aria-describedby="ulHelpBlock0">
+                                                        <small id="ulHelpBlock0" class="form-text text-muted">
+                                                            UL (KB/s). 0 means no limit
+                                                        </small>
+                                                    </div>
+                                                    <div class="form-group">
+                                                        <input type="number" class="form-control" id="idDownloadBandwidthSource0" name="download_bandwidth_source0"
+                                                            placeholder="" value="" min="0" aria-describedby="dlHelpBlock0">
+                                                        <small id="dlHelpBlock0" class="form-text text-muted">
+                                                            DL (KB/s). 0 means no limit
+                                                        </small>
+                                                    </div>
+                                                </div>
+                                                <div class="form-group col-md-1">
+                                                    <button class="btn btn-circle btn-danger remove_bwlimit_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_bwlimit_field_btn">
+                                            <i class="fas fa-plus"></i> Add new speed limit
+                                        </button>
+                                    </div>
+                                </div>
+                            </div>
+
+                            <div class="form-group row">
+                                <label for="idTransferUL" class="col-sm-2 col-form-label">Upload data transfer (MB)</label>
+                                <div class="col-sm-3">
+                                    <input type="number" class="form-control" id="idTransferUL" name="upload_data_transfer" placeholder=""
+                                        value="{{.Group.UserSettings.UploadDataTransfer}}" min="0" aria-describedby="ulTransferHelpBlock">
+                                    <small id="ulTransferHelpBlock" class="form-text text-muted">
+                                        Maximum data transfer allowed for uploads. 0 means no limit
+                                    </small>
+                                </div>
+                                <div class="col-sm-2"></div>
+                                <label for="idTransferDL" class="col-sm-2 col-form-label">Download data transfer (MB)</label>
+                                <div class="col-sm-3">
+                                    <input type="number" class="form-control" id="idTransferDL" name="download_data_transfer" placeholder=""
+                                        value="{{.Group.UserSettings.DownloadDataTransfer}}" min="0" aria-describedby="dlTransferHelpBlock">
+                                    <small id="dlTransferHelpBlock" class="form-text text-muted">
+                                        Maximum data transfer allowed for downloads. 0 means no limit
+                                    </small>
+                                </div>
+                            </div>
+
+                            <div class="form-group row">
+                                <label for="idTransferTotal" class="col-sm-2 col-form-label">Total data transfer (MB)</label>
+                                <div class="col-sm-10">
+                                    <input type="number" class="form-control" id="idTransferTotal" name="total_data_transfer"
+                                        placeholder="" value="{{.Group.UserSettings.TotalDataTransfer}}" min="0"
+                                        aria-describedby="totalTransferHelpBlock">
+                                    <small id="totalTransferHelpBlock" class="form-text text-muted">
+                                        Maximum data transfer allowed for uploads + downloads. Replace the individual limits. 0 means no limit
+                                    </small>
+                                </div>
+                            </div>
+
+                            <div class="card bg-light mb-3">
+                                <div class="card-header">
+                                    <b>Per-source data transfer limits</b>
+                                </div>
+                                <div class="card-body">
+                                    <div class="form-group row">
+                                        <div class="col-md-12 form_field_dtlimits_outer">
+                                            {{range $idx, $dtLimit := .Group.UserSettings.Filters.DataTransferLimits -}}
+                                            <div class="row form_field_dtlimits_outer_row">
+                                                <div class="form-group col-md-5">
+                                                    <textarea class="form-control" id="idDataTransferLimitSources{{$idx}}" name="data_transfer_limit_sources{{$idx}}" rows="4" placeholder=""
+                                                            aria-describedby="dtLimitSourcesHelpBlock{{$idx}}">{{$dtLimit.GetSourcesAsString}}</textarea>
+                                                    <small id="dtLimitSourcesHelpBlock{{$idx}}" class="form-text text-muted">
+                                                        Comma separated IP/Mask in CIDR format, example: "192.168.1.0/24,10.8.0.100/32"
+                                                    </small>
+                                                </div>
+                                                <div class="col-md-3">
+                                                    <div class="form-group">
+                                                        <input type="number" class="form-control" id="idUploadTransferSource{{$idx}}" name="upload_data_transfer_source{{$idx}}"
+                                                            placeholder="" value="{{$dtLimit.UploadDataTransfer}}" min="0" aria-describedby="ulDtHelpBlock{{$idx}}">
+                                                        <small id="ulDtHelpBlock{{$idx}}" class="form-text text-muted">
+                                                            UL (MB). 0 means no limit
+                                                        </small>
+                                                    </div>
+                                                    <div class="form-group">
+                                                        <input type="number" class="form-control" id="idDownloadTransferSource{{$idx}}" name="download_data_transfer_source{{$idx}}"
+                                                            placeholder="" value="{{$dtLimit.DownloadDataTransfer}}" min="0" aria-describedby="dlDtHelpBlock{{$idx}}">
+                                                        <small id="dlDtHelpBlock{{$idx}}" class="form-text text-muted">
+                                                            DL (MB). 0 means no limit
+                                                        </small>
+                                                    </div>
+                                                </div>
+                                                <div class="col-md-3">
+                                                    <div class="form-group">
+                                                        <input type="number" class="form-control" id="idTotalTransferSource{{$idx}}" name="total_data_transfer_source{{$idx}}"
+                                                            placeholder="" value="{{$dtLimit.TotalDataTransfer}}" min="0" aria-describedby="totalDtHelpBlock{{$idx}}">
+                                                        <small id="totalDtHelpBlock{{$idx}}" class="form-text text-muted">
+                                                            Total (MB). 0 means no limit
+                                                        </small>
+                                                    </div>
+                                                </div>
+                                                <div class="form-group col-md-1">
+                                                    <button class="btn btn-circle btn-danger remove_dtlimit_btn_frm_field">
+                                                        <i class="fas fa-trash"></i>
+                                                    </button>
+                                                </div>
+                                            </div>
+                                            {{else}}
+                                            <div class="row form_field_dtlimits_outer_row">
+                                                <div class="form-group col-md-5">
+                                                    <textarea class="form-control" id="idDataTransferLimitSources0" name="data_transfer_limit_sources0" rows="4" placeholder=""
+                                                            aria-describedby="dtLimitSourcesHelpBlock0"></textarea>
+                                                    <small id="dtLimitSourcesHelpBlock0" class="form-text text-muted">
+                                                        Comma separated IP/Mask in CIDR format, example: "192.168.1.0/24,10.8.0.100/32"
+                                                    </small>
+                                                </div>
+                                                <div class="col-md-3">
+                                                    <div class="form-group">
+                                                        <input type="number" class="form-control" id="idUploadTransferSource0" name="upload_data_transfer_source0"
+                                                            placeholder="" value="" min="0" aria-describedby="ulDtHelpBlock0">
+                                                        <small id="ulDtHelpBlock0" class="form-text text-muted">
+                                                            UL (MB). 0 means no limit
+                                                        </small>
+                                                    </div>
+                                                    <div class="form-group">
+                                                        <input type="number" class="form-control" id="idDownloadTransferSource0" name="download_data_transfer_source0"
+                                                            placeholder="" value="" min="0" aria-describedby="dlDtHelpBlock0">
+                                                        <small id="dlDtHelpBlock0" class="form-text text-muted">
+                                                            DL (MB). 0 means no limit
+                                                        </small>
+                                                    </div>
+                                                </div>
+                                                <div class="col-md-3">
+                                                    <div class="form-group">
+                                                        <input type="number" class="form-control" id="idTotalTransferSource0" name="total_data_transfer_source0"
+                                                            placeholder="" value="" min="0" aria-describedby="totalDtHelpBlock0">
+                                                        <small id="totalDtHelpBlock0" class="form-text text-muted">
+                                                            Total (MB). 0 means no limit
+                                                        </small>
+                                                    </div>
+                                                </div>
+                                                <div class="form-group col-md-1">
+                                                    <button class="btn btn-circle btn-danger remove_dtlimit_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_dtlimit_field_btn">
+                                            <i class="fas fa-plus"></i> Add new data transfer limit
+                                        </button>
+                                    </div>
+                                </div>
+                            </div>
+
+                        </div>
+                    </div>
+                </div>
+
+                <div class="card">
+                    <div class="card-header" id="headingAdvanced">
+                        <h2 class="mb-0">
+                            <button class="btn btn-link btn-block text-left collapsed" type="button" data-toggle="collapse"
+                                data-target="#collapseAdvanced" aria-expanded="false" aria-controls="collapseAdvanced">
+                                <h6 class="m-0 font-weight-bold text-primary">More</h6>
+                            </button>
+                        </h2>
+                    </div>
+                    <div id="collapseAdvanced" class="collapse" aria-labelledby="headingTwo" data-parent="#accordionUser">
+                        <div class="card-body">
+
+                            <div class="form-group row">
+                                <label for="idStartDirectory" class="col-sm-2 col-form-label">Start directory</label>
+                                <div class="col-sm-10">
+                                    <input type="text" class="form-control" id="idStartDirectory" name="start_directory" placeholder=""
+                                        value="{{.Group.UserSettings.Filters.StartDirectory}}" aria-describedby="startDirHelpBlock">
+                                    <small id="startDirHelpBlock" class="form-text text-muted">
+                                        Alternate start directory to use instead of "/". Supported for SFTP/FTP/HTTP
+                                    </small>
+                                </div>
+                            </div>
+
+                            <div class="form-group row">
+                                <label for="idTLSUsername" class="col-sm-2 col-form-label">TLS username</label>
+                                <div class="col-sm-10">
+                                    <select class="form-control selectpicker" id="idTLSUsername" name="tls_username" aria-describedby="tlsUsernameHelpBlock">
+                                        <option value="None" {{if eq .Group.UserSettings.Filters.TLSUsername "None" }}selected{{end}}>None</option>
+                                        <option value="CommonName" {{if eq .Group.UserSettings.Filters.TLSUsername "CommonName" }}selected{{end}}>Common Name</option>
+                                    </select>
+                                    <small id="tlsUsernameHelpBlock" class="form-text text-muted">
+                                        Defines the TLS certificate field to use as username. Ignored if mutual TLS is disabled
+                                    </small>
+                                </div>
+                            </div>
+
+                            <div class="form-group row">
+                                <label for="idHooks" class="col-sm-2 col-form-label">Hooks</label>
+                                <div class="col-sm-10">
+                                    <select class="form-control selectpicker" id="idHooks" name="hooks" multiple>
+                                        <option value="external_auth_disabled" {{if .Group.UserSettings.Filters.Hooks.ExternalAuthDisabled}}selected{{end}}>
+                                            External auth disabled
+                                        </option>
+                                        <option value="pre_login_disabled" {{if .Group.UserSettings.Filters.Hooks.PreLoginDisabled}}selected{{end}}>
+                                            Pre-login disabled
+                                        </option>
+                                        <option value="check_password_disabled" {{if .Group.UserSettings.Filters.Hooks.CheckPasswordDisabled}}selected{{end}}>
+                                            Check password disabled
+                                        </option>
+                                    </select>
+                                </div>
+                            </div>
+
+                            <div class="form-group">
+                                <div class="form-check">
+                                    <input type="checkbox" class="form-check-input" id="idDisableFsChecks" name="disable_fs_checks"
+                                    {{if .Group.UserSettings.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">
+                                <div class="form-check">
+                                    <input type="checkbox" class="form-check-input" id="idAllowAPIKeyAuth" name="allow_api_key_auth"
+                                    {{if .Group.UserSettings.Filters.AllowAPIKeyAuth}}checked{{end}} aria-describedby="allowAPIKeyAuthHelpBlock">
+                                    <label for="idAllowAPIKeyAuth" class="form-check-label">Allow API key authentication</label>
+                                    <small id="allowAPIKeyAuthHelpBlock" class="form-text text-muted">
+                                        Allow to impersonate the associated users, in REST API, with an API key
+                                    </small>
+                                </div>
+                            </div>
+
+                            <div class="form-group row {{if not .Group.HasExternalAuth}}d-none{{end}}">
+                                <label for="idExtAuthCacheTime" class="col-sm-2 col-form-label">External auth cache time</label>
+                                <div class="col-sm-10">
+                                    <input type="number" min="0" class="form-control" id="idExtAuthCacheTime" name="external_auth_cache_time" placeholder=""
+                                        value="{{.Group.UserSettings.Filters.ExternalAuthCacheTime}}" aria-describedby="extAuthCacheHelpBlock">
+                                    <small id="extAuthCacheHelpBlock" class="form-text text-muted">
+                                        Cache time, in seconds, for users authenticated using an external auth hook. 0 means no cache
+                                    </small>
+                                </div>
+                            </div>
+
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
+            <div class="col-sm-12 text-right px-0">
+                <button type="submit" class="btn btn-primary mt-3 ml-3 px-5" name="form_action" value="submit">Submit</button>
+            </div>
+        </form>
+    </div>
+</div>
+{{end}}
+
+{{define "extra_js"}}
+<script src="{{.StaticURL}}/vendor/bootstrap-select/js/bootstrap-select.min.js"></script>
+<script type="text/javascript">
+    $(document).ready(function () {
+        {{if .Error}}
+        $('#accordionUser .collapse').removeAttr("data-parent").collapse('show');
+        {{end}}
+        onFilesystemChanged('{{.Group.UserSettings.FsConfig.Provider.Name}}');
+    });
+</script>
+
+{{template "fsjs"}}
+{{template "shared_user_group" .}}
+{{end}}

+ 193 - 0
templates/webadmin/groups.html

@@ -0,0 +1,193 @@
+{{template "base" .}}
+
+{{define "title"}}{{.Title}}{{end}}
+
+{{define "extra_css"}}
+<link href="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.css" rel="stylesheet">
+<link href="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.css" rel="stylesheet">
+<link href="{{.StaticURL}}/vendor/datatables/fixedHeader.bootstrap4.min.css" rel="stylesheet">
+<link href="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.css" rel="stylesheet">
+<link href="{{.StaticURL}}/vendor/datatables/select.bootstrap4.min.css" rel="stylesheet">
+{{end}}
+
+{{define "page_body"}}
+<div id="errorMsg" class="card mb-4 border-left-warning" style="display: none;">
+    <div id="errorTxt" class="card-body text-form-error"></div>
+</div>
+
+<div id="successMsg" class="card mb-4 border-left-success" style="display: none;">
+    <div id="successTxt" class="card-body"></div>
+</div>
+
+<div class="card shadow mb-4">
+    <div class="card-header py-3">
+        <h6 class="m-0 font-weight-bold text-primary">View and manage groups</h6>
+    </div>
+    <div class="card-body">
+        <div class="table-responsive">
+            <table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0">
+                <thead>
+                    <tr>
+                        <th>Name</th>
+                        <th>Description</th>
+                        <th>Used by</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    {{range .Groups}}
+                    <tr>
+                        <td>{{.Name}}</td>
+                        <td>{{.Description}}</td>
+                        <td>{{.GetUsersAsString}}</td>
+                    </tr>
+                    {{end}}
+                </tbody>
+            </table>
+        </div>
+    </div>
+</div>
+{{end}}
+
+{{define "dialog"}}
+<div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel"
+    aria-hidden="true">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="deleteModalLabel">
+                    Confirmation required
+                </h5>
+                <button class="close" type="button" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <div class="modal-body">Do you want to delete the selected group? A referenced group cannot be removed</div>
+            <div class="modal-footer">
+                <button class="btn btn-secondary" type="button" data-dismiss="modal">
+                    Cancel
+                </button>
+                <a class="btn btn-warning" href="#" onclick="deleteAction()">
+                    Delete
+                </a>
+            </div>
+        </div>
+    </div>
+</div>
+{{end}}
+
+{{define "extra_js"}}
+<script src="{{.StaticURL}}/vendor/datatables/jquery.dataTables.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/dataTables.buttons.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/dataTables.fixedHeader.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/dataTables.responsive.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/dataTables.select.min.js"></script>
+<script type="text/javascript">
+
+function deleteAction() {
+        var table = $('#dataTable').DataTable();
+        table.button('delete:name').enable(false);
+        var groupName = table.row({ selected: true }).data()[0];
+        var path = '{{.GroupURL}}' + "/" + fixedEncodeURIComponent(groupName);
+        $('#deleteModal').modal('hide');
+        $.ajax({
+            url: path,
+            type: 'DELETE',
+            dataType: 'json',
+            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
+            timeout: 15000,
+            success: function (result) {
+                window.location.href = '{{.GroupsURL}}';
+            },
+            error: function ($xhr, textStatus, errorThrown) {
+                var txt = "Unable to delete the selected group";
+                if ($xhr) {
+                    var json = $xhr.responseJSON;
+                    if (json) {
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
+                    }
+                }
+                $('#errorTxt').text(txt);
+                $('#errorMsg').show();
+                setTimeout(function () {
+                    $('#errorMsg').hide();
+                }, 5000);
+            }
+        });
+    }
+
+    $(document).ready(function () {
+        $.fn.dataTable.ext.buttons.add = {
+            text: '<i class="fas fa-plus"></i>',
+            name: 'add',
+            titleAttr: "Add",
+            action: function (e, dt, node, config) {
+                window.location.href = '{{.GroupURL}}';
+            }
+        };
+
+        $.fn.dataTable.ext.buttons.edit = {
+            text: '<i class="fas fa-pen"></i>',
+            name: 'edit',
+            titleAttr: "Edit",
+            action: function (e, dt, node, config) {
+                var groupName = table.row({ selected: true }).data()[0];
+                var path = '{{.GroupURL}}' + "/" + fixedEncodeURIComponent(groupName);
+                window.location.href = path;
+            },
+            enabled: false
+        };
+
+        $.fn.dataTable.ext.buttons.delete = {
+            text: '<i class="fas fa-trash"></i>',
+            name: 'delete',
+            titleAttr: "Delete",
+            action: function (e, dt, node, config) {
+                $('#deleteModal').modal('show');
+            },
+            enabled: false
+        };
+
+        var table = $('#dataTable').DataTable({
+            "select": {
+                "style": "single",
+                "blurable": true
+            },
+            "stateSave": true,
+            "stateDuration": 0,
+            "buttons": [],
+            "scrollX": false,
+            "scrollY": false,
+            "responsive": true,
+            "language": {
+                "emptyTable": "No group defined"
+            },
+            "order": [[0, 'asc']]
+        });
+
+        new $.fn.dataTable.FixedHeader( table );
+
+        {{if .LoggedAdmin.HasPermission "manage_groups"}}
+        table.button().add(0,'delete');
+        table.button().add(0,'edit');
+        table.button().add(0,'add');
+
+        table.buttons().container().appendTo('.col-md-6:eq(0)', table.table().container());
+
+        table.on('select deselect', function () {
+            var selectedRows = table.rows({ selected: true }).count();
+            table.button('delete:name').enable(selectedRows == 1);
+            table.button('edit:name').enable(selectedRows == 1);
+        });
+        {{end}}
+
+    });
+
+</script>
+{{end}}

+ 217 - 0
templates/webadmin/sharedcomponents.html

@@ -0,0 +1,217 @@
+{{define "shared_user_group"}}
+<script type="text/javascript">
+    $("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}}
+            $("#idSubDirPermissions"+index).selectpicker();
+        });
+
+        $("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}}
+        $("#idVfolderName"+index).selectpicker({'liveSearch': true});
+    });
+
+    $("body").on("click", ".remove_vfolder_btn_frm_field", function () {
+        $(this).closest(".form_field_vfolder_outer_row").remove();
+    });
+
+    $("body").on("click", ".add_new_bwlimit_field_btn", function () {
+        var index = $(".form_field_bwlimits_outer").find(".form_field_bwlimits_outer_row").length;
+        while (document.getElementById("idBandwidthLimitSources"+index) != null){
+            index++;
+        }
+        $(".form_field_bwlimits_outer").append(`
+                <div class="row form_field_bwlimits_outer_row">
+                     <div class="form-group col-md-8">
+                        <textarea class="form-control" id="idBandwidthLimitSources0" name="bandwidth_limit_sources${index}" rows="4" placeholder=""
+                            aria-describedby="bwLimitSourcesHelpBlock${index}"></textarea>
+                        <small id="bwLimitSourcesHelpBlock${index}" class="form-text text-muted">
+                            Comma separated IP/Mask in CIDR format, example: "192.168.1.0/24,10.8.0.100/32"
+                        </small>
+                    </div>
+                    <div class="col-md-3">
+                        <div class="form-group">
+                            <input type="number" class="form-control" id="idUploadBandwidthSource${index}" name="upload_bandwidth_source${index}"
+                                placeholder="" value="" min="0" aria-describedby="ulHelpBlock${index}">
+                            <small id="ulHelpBlock${index}" class="form-text text-muted">
+                                UL (KB/s). 0 means no limit
+                            </small>
+                        </div>
+                        <div class="form-group">
+                            <input type="number" class="form-control" id="idDownloadBandwidthSource${index}" name="download_bandwidth_source${index}"
+                                placeholder="" value="" min="0" aria-describedby="dlHelpBlock${index}">
+                            <small id="dlHelpBlock${index}" class="form-text text-muted">
+                                DL (KB/s). 0 means no limit
+                            </small>
+                        </div>
+                    </div>
+                    <div class="form-group col-md-1">
+                        <button class="btn btn-circle btn-danger remove_bwlimit_btn_frm_field">
+                            <i class="fas fa-trash"></i>
+                        </button>
+                    </div>
+                </div>
+            `);
+    });
+
+     $("body").on("click", ".remove_bwlimit_btn_frm_field", function () {
+        $(this).closest(".form_field_bwlimits_outer_row").remove();
+    });
+
+    $("body").on("click", ".add_new_dtlimit_field_btn", function () {
+        var index = $(".form_field_dtlimits_outer").find(".form_field_dtlimits_outer_row").length;
+        while (document.getElementById("idDataTransferLimitSources"+index) != null){
+            index++;
+        }
+        $(".form_field_dtlimits_outer").append(`
+                <div class="row form_field_dtlimits_outer_row">
+                    <div class="form-group col-md-5">
+                        <textarea class="form-control" id="idDataTransferLimitSources${index}" name="data_transfer_limit_sources${index}" rows="4" placeholder=""
+                            aria-describedby="dtLimitSourcesHelpBlock${index}"></textarea>
+                        <small id="dtLimitSourcesHelpBlock${index}" class="form-text text-muted">
+                            Comma separated IP/Mask in CIDR format, example: "192.168.1.0/24,10.8.0.100/32"
+                        </small>
+                    </div>
+                    <div class="col-md-3">
+                        <div class="form-group">
+                            <input type="number" class="form-control" id="idUploadTransferSource${index}" name="upload_data_transfer_source${index}"
+                                placeholder="" value="" min="0" aria-describedby="ulDtHelpBlock${index}">
+                            <small id="ulDtHelpBlock${index}" class="form-text text-muted">
+                                UL (MB). 0 means no limit
+                            </small>
+                        </div>
+                        <div class="form-group">
+                            <input type="number" class="form-control" id="idDownloadTransferSource${index}" name="download_data_transfer_source${index}"
+                                placeholder="" value="" min="0" aria-describedby="dlDtHelpBlock${index}">
+                            <small id="dlDtHelpBlock${index}" class="form-text text-muted">
+                                DL (MB). 0 means no limit
+                            </small>
+                        </div>
+                    </div>
+                    <div class="col-md-3">
+                        <div class="form-group">
+                            <input type="number" class="form-control" id="idTotalTransferSource${index}" name="total_data_transfer_source${index}"
+                                placeholder="" value="" min="0" aria-describedby="totalDtHelpBlock${index}">
+                            <small id="totalDtHelpBlock${index}" class="form-text text-muted">
+                                Total (MB). 0 means no limit
+                            </small>
+                        </div>
+                    </div>
+                    <div class="form-group col-md-1">
+                        <button class="btn btn-circle btn-danger remove_dtlimit_btn_frm_field">
+                            <i class="fas fa-trash"></i>
+                        </button>
+                    </div>
+                </div>
+            `);
+    });
+
+    $("body").on("click", ".remove_dtlimit_btn_frm_field", function () {
+        $(this).closest(".form_field_dtlimits_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-3">
+                        <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-4">
+                        <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-2">
+                        <select class="form-control" id="idPatternPolicy${index}" name="pattern_policy${index}">
+                            <option value="0">Visible</option>
+                            <option value="1">Hidden</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>
+            `);
+        $("#idPatternType"+index).selectpicker();
+        $("#idPatternPolicy"+index).selectpicker();
+    });
+
+    $("body").on("click", ".remove_pattern_btn_frm_field", function () {
+        $(this).closest(".form_field_patterns_outer_row").remove();
+    });
+</script>
+{{end}}

+ 112 - 290
templates/webadmin/user.html

@@ -4,6 +4,7 @@
 
 
 {{define "extra_css"}}
 {{define "extra_css"}}
 <link href="{{.StaticURL}}/vendor/tempusdominus/css/tempusdominus-bootstrap-4.min.css" rel="stylesheet">
 <link href="{{.StaticURL}}/vendor/tempusdominus/css/tempusdominus-bootstrap-4.min.css" rel="stylesheet">
+<link href="{{.StaticURL}}/vendor/bootstrap-select/css/bootstrap-select.min.css" rel="stylesheet">
 {{end}}
 {{end}}
 
 
 {{define "page_body"}}
 {{define "page_body"}}
@@ -140,6 +141,36 @@
             </div>
             </div>
             {{end}}
             {{end}}
 
 
+            <div class="card bg-light mb-3">
+                <div class="card-header">
+                    <b>Groups</b>
+                </div>
+                <div class="card-body">
+                    <h6 class="card-title mb-4">Group membership impart the group settings if no override exist</h6>
+                    <div class="form-group row">
+                        <label for="idPrimaryGroup" class="col-sm-2 col-form-label">Primary group</label>
+                        <div class="col-sm-10">
+                            <select class="form-control selectpicker" data-live-search="true" id="idPrimaryGroup" name="primary_group">
+                                <option value=""></option>
+                                {{- range .Groups}}
+                                <option value="{{.Name}}" {{if $.User.HasPrimaryGroup .Name}}selected{{end}}>{{.Name}}</option>
+                                {{- end}}
+                            </select>
+                        </div>
+                    </div>
+                    <div class="form-group row">
+                        <label for="idSecondaryGroup" class="col-sm-2 col-form-label">Secondary groups</label>
+                        <div class="col-sm-10">
+                            <select class="form-control selectpicker" data-live-search="true" id="idSecondaryGroup" name="secondary_groups" multiple>
+                                {{- range .Groups}}
+                                <option value="{{.Name}}" {{if $.User.HasSecondaryGroup .Name}}selected{{end}}>{{.Name}}</option>
+                                {{- end}}
+                            </select>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
             {{template "fshtml" .FsWrapper}}
             {{template "fshtml" .FsWrapper}}
             {{if .VirtualFolders}}
             {{if .VirtualFolders}}
             <div class="card bg-light mb-3">
             <div class="card bg-light mb-3">
@@ -156,7 +187,7 @@
                                     <input type="text" class="form-control" id="idVolderPath{{$idx}}" name="vfolder_path" placeholder="mount path, i.e. /vfolder" value="{{$val.VirtualPath}}" maxlength="255">
                                     <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>
                                 <div class="form-group col-md-3">
                                 <div class="form-group col-md-3">
-                                    <select class="form-control" id="idVfolderName{{$idx}}" name="vfolder_name">
+                                    <select class="form-control selectpicker" data-live-search="true" id="idVfolderName{{$idx}}" name="vfolder_name">
                                         <option value=""></option>
                                         <option value=""></option>
                                         {{range $.VirtualFolders}}
                                         {{range $.VirtualFolders}}
                                         <option value="{{.Name}}" {{if eq $val.Name .Name}}selected{{end}}>{{.Name}}</option>
                                         <option value="{{.Name}}" {{if eq $val.Name .Name}}selected{{end}}>{{.Name}}</option>
@@ -189,7 +220,7 @@
                                     <input type="text" class="form-control" id="idVolderPath0" name="vfolder_path" placeholder="mount path, i.e. /vfolder" value="" maxlength="255">
                                     <input type="text" class="form-control" id="idVolderPath0" name="vfolder_path" placeholder="mount path, i.e. /vfolder" value="" maxlength="255">
                                 </div>
                                 </div>
                                 <div class="form-group col-md-3">
                                 <div class="form-group col-md-3">
-                                    <select class="form-control" id="idVfolderName0" name="vfolder_name">
+                                    <select class="form-control selectpicker" data-live-search="true" id="idVfolderName0" name="vfolder_name">
                                         <option value=""></option>
                                         <option value=""></option>
                                         {{range .VirtualFolders}}
                                         {{range .VirtualFolders}}
                                         <option value="{{.Name}}">{{.Name}}</option>
                                         <option value="{{.Name}}">{{.Name}}</option>
@@ -245,7 +276,7 @@
                             <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>
                                 <div class="col-sm-10">
                                 <div class="col-sm-10">
-                                    <select class="form-control" id="idStatus" name="status">
+                                    <select class="form-control selectpicker" id="idStatus" name="status">
                                         <option value="1" {{if eq .User.Status 1 }}selected{{end}}>Active</option>
                                         <option value="1" {{if eq .User.Status 1 }}selected{{end}}>Active</option>
                                         <option value="0" {{if eq .User.Status 0 }}selected{{end}}>Inactive</option>
                                         <option value="0" {{if eq .User.Status 0 }}selected{{end}}>Inactive</option>
                                     </select>
                                     </select>
@@ -322,7 +353,7 @@
                             <div class="form-group row">
                             <div class="form-group row">
                                 <label for="idPermissions" class="col-sm-2 col-form-label">Permissions</label>
                                 <label for="idPermissions" class="col-sm-2 col-form-label">Permissions</label>
                                 <div class="col-sm-10">
                                 <div class="col-sm-10">
-                                    <select class="form-control" id="idPermissions" name="permissions" required multiple>
+                                    <select class="form-control selectpicker" id="idPermissions" name="permissions" required multiple>
                                         {{range $validPerm := .ValidPerms}}
                                         {{range $validPerm := .ValidPerms}}
                                         <option value="{{$validPerm}}" {{range $perm :=$.RootDirPerms }}{{if eq $perm $validPerm}}selected{{end}}{{end}}>{{$validPerm}}</option>
                                         <option value="{{$validPerm}}" {{range $perm :=$.RootDirPerms }}{{if eq $perm $validPerm}}selected{{end}}{{end}}>{{$validPerm}}</option>
                                         {{end}}
                                         {{end}}
@@ -343,7 +374,7 @@
                                                     <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">
                                                     <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>
                                                 <div class="form-group col-md-3">
                                                 <div class="form-group col-md-3">
-                                                    <select class="form-control" id="idSubDirPermissions{{$idx}}" name="sub_perm_permissions{{$idx}}" multiple>
+                                                    <select class="form-control selectpicker" id="idSubDirPermissions{{$idx}}" name="sub_perm_permissions{{$idx}}" multiple>
                                                         {{range $validPerm := $.ValidPerms}}
                                                         {{range $validPerm := $.ValidPerms}}
                                                         <option value="{{$validPerm}}" {{range $perm := $dirPerms.Permissions }}{{if eq $perm $validPerm}}selected{{end}}{{end}}>{{$validPerm}}</option>
                                                         <option value="{{$validPerm}}" {{range $perm := $dirPerms.Permissions }}{{if eq $perm $validPerm}}selected{{end}}{{end}}>{{$validPerm}}</option>
                                                         {{end}}
                                                         {{end}}
@@ -361,7 +392,7 @@
                                                     <input type="text" class="form-control" id="idSubDirPermsPath0" name="sub_perm_path0" placeholder="directory path, i.e. /dir" value="" maxlength="255">
                                                     <input type="text" class="form-control" id="idSubDirPermsPath0" name="sub_perm_path0" placeholder="directory path, i.e. /dir" value="" maxlength="255">
                                                 </div>
                                                 </div>
                                                 <div class="form-group col-md-3">
                                                 <div class="form-group col-md-3">
-                                                    <select class="form-control" id="idSubDirPermissions0" name="sub_perm_permissions0" multiple>
+                                                    <select class="form-control selectpicker" id="idSubDirPermissions0" name="sub_perm_permissions0" multiple>
                                                         {{range $validPerm := .ValidPerms}}
                                                         {{range $validPerm := .ValidPerms}}
                                                         <option value="{{$validPerm}}">{{$validPerm}}</option>
                                                         <option value="{{$validPerm}}">{{$validPerm}}</option>
                                                         {{end}}
                                                         {{end}}
@@ -394,7 +425,7 @@
                                     <p class="card-text">Denied entries are visible in directory listing by default, you can hide them by setting the "Hidden" policy, but please be aware that this may cause performance issues for large directories</p>
                                     <p class="card-text">Denied entries are visible in directory listing by default, you can hide them by setting the "Hidden" policy, but please be aware that this may cause performance issues for large directories</p>
                                     <div class="form-group row">
                                     <div class="form-group row">
                                         <div class="col-md-12 form_field_patterns_outer">
                                         <div class="col-md-12 form_field_patterns_outer">
-                                            {{range $idx, $pattern := .User.GetFlatFilePatterns -}}
+                                            {{range $idx, $pattern := .User.Filters.GetFlatFilePatterns -}}
                                             <div class="row form_field_patterns_outer_row">
                                             <div class="row form_field_patterns_outer_row">
                                                 <div class="form-group col-md-3">
                                                 <div class="form-group col-md-3">
                                                     <input type="text" class="form-control" id="idPatternPath{{$idx}}" name="pattern_path{{$idx}}" placeholder="directory path, i.e. /dir" value="{{$pattern.Path}}" maxlength="255">
                                                     <input type="text" class="form-control" id="idPatternPath{{$idx}}" name="pattern_path{{$idx}}" placeholder="directory path, i.e. /dir" value="{{$pattern.Path}}" maxlength="255">
@@ -403,13 +434,13 @@
                                                     <input type="text" class="form-control" id="idPatterns{{$idx}}" name="patterns{{$idx}}" placeholder="*.zip,?.txt" value="{{$pattern.GetCommaSeparatedPatterns}}" maxlength="255">
                                                     <input type="text" class="form-control" id="idPatterns{{$idx}}" name="patterns{{$idx}}" placeholder="*.zip,?.txt" value="{{$pattern.GetCommaSeparatedPatterns}}" maxlength="255">
                                                 </div>
                                                 </div>
                                                 <div class="form-group col-md-2">
                                                 <div class="form-group col-md-2">
-                                                    <select class="form-control" id="idPatternType{{$idx}}" name="pattern_type{{$idx}}">
+                                                    <select class="form-control selectpicker" id="idPatternType{{$idx}}" name="pattern_type{{$idx}}">
                                                         <option value="denied" {{if $pattern.IsDenied}}selected{{end}}>Denied</option>
                                                         <option value="denied" {{if $pattern.IsDenied}}selected{{end}}>Denied</option>
                                                         <option value="allowed" {{if $pattern.IsAllowed}}selected{{end}}>Allowed</option>
                                                         <option value="allowed" {{if $pattern.IsAllowed}}selected{{end}}>Allowed</option>
                                                     </select>
                                                     </select>
                                                 </div>
                                                 </div>
                                                 <div class="form-group col-md-2">
                                                 <div class="form-group col-md-2">
-                                                    <select class="form-control" id="idPatternPolicy{{$idx}}" name="pattern_policy{{$idx}}">
+                                                    <select class="form-control selectpicker" id="idPatternPolicy{{$idx}}" name="pattern_policy{{$idx}}">
                                                         <option value="0" {{if eq $pattern.DenyPolicy 0}}selected{{end}}>Visible</option>
                                                         <option value="0" {{if eq $pattern.DenyPolicy 0}}selected{{end}}>Visible</option>
                                                         <option value="1" {{if eq $pattern.DenyPolicy 1}}selected{{end}}>Hidden</option>
                                                         <option value="1" {{if eq $pattern.DenyPolicy 1}}selected{{end}}>Hidden</option>
                                                     </select>
                                                     </select>
@@ -429,13 +460,13 @@
                                                     <input type="text" class="form-control" id="idPatterns0" name="patterns0" placeholder="*.zip,?.txt" value="" maxlength="255">
                                                     <input type="text" class="form-control" id="idPatterns0" name="patterns0" placeholder="*.zip,?.txt" value="" maxlength="255">
                                                 </div>
                                                 </div>
                                                 <div class="form-group col-md-2">
                                                 <div class="form-group col-md-2">
-                                                    <select class="form-control" id="idPatternType0" name="pattern_type0">
+                                                    <select class="form-control selectpicker" id="idPatternType0" name="pattern_type0">
                                                         <option value="denied">Denied</option>
                                                         <option value="denied">Denied</option>
                                                         <option value="allowed">Allowed</option>
                                                         <option value="allowed">Allowed</option>
                                                     </select>
                                                     </select>
                                                 </div>
                                                 </div>
                                                 <div class="form-group col-md-2">
                                                 <div class="form-group col-md-2">
-                                                    <select class="form-control" id="idPatternPolicy0" name="pattern_policy0">
+                                                    <select class="form-control selectpicker" id="idPatternPolicy0" name="pattern_policy0">
                                                         <option value="0">Visible</option>
                                                         <option value="0">Visible</option>
                                                         <option value="1">Hidden</option>
                                                         <option value="1">Hidden</option>
                                                     </select>
                                                     </select>
@@ -472,7 +503,7 @@
                             <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">
-                                    <select class="form-control" id="idProtocols" name="denied_protocols" multiple>
+                                    <select class="form-control selectpicker" id="idProtocols" name="denied_protocols" multiple>
                                         {{range $protocol := .ValidProtocols}}
                                         {{range $protocol := .ValidProtocols}}
                                         <option value="{{$protocol}}" {{range $p :=$.User.Filters.DeniedProtocols }}{{if eq $p $protocol}}selected{{end}}{{end}}>{{$protocol}}
                                         <option value="{{$protocol}}" {{range $p :=$.User.Filters.DeniedProtocols }}{{if eq $p $protocol}}selected{{end}}{{end}}>{{$protocol}}
                                         </option>
                                         </option>
@@ -484,7 +515,7 @@
                             <div class="form-group row">
                             <div class="form-group row">
                                 <label for="idLoginMethods" class="col-sm-2 col-form-label">Denied login methods</label>
                                 <label for="idLoginMethods" class="col-sm-2 col-form-label">Denied login methods</label>
                                 <div class="col-sm-10">
                                 <div class="col-sm-10">
-                                    <select class="form-control" id="idLoginMethods" name="denied_login_methods" multiple aria-describedby="deniedLoginMethodsHelpBlock">
+                                    <select class="form-control selectpicker" id="idLoginMethods" name="denied_login_methods" multiple aria-describedby="deniedLoginMethodsHelpBlock">
                                         {{range $method := .ValidLoginMethods}}
                                         {{range $method := .ValidLoginMethods}}
                                         <option value="{{$method}}" {{range $m :=$.User.Filters.DeniedLoginMethods }}{{if eq $m $method}}selected{{end}}{{end}}>{{$method}}
                                         <option value="{{$method}}" {{range $m :=$.User.Filters.DeniedLoginMethods }}{{if eq $m $method}}selected{{end}}{{end}}>{{$method}}
                                         </option>
                                         </option>
@@ -499,7 +530,7 @@
                             <div class="form-group row">
                             <div class="form-group row">
                                 <label for="idTwoFactorProtocols" class="col-sm-2 col-form-label">Require two-factor auth for</label>
                                 <label for="idTwoFactorProtocols" class="col-sm-2 col-form-label">Require two-factor auth for</label>
                                 <div class="col-sm-10">
                                 <div class="col-sm-10">
-                                    <select class="form-control" id="idTwoFactorProtocols" name="required_two_factor_protocols" multiple>
+                                    <select class="form-control selectpicker" id="idTwoFactorProtocols" name="required_two_factor_protocols" multiple>
                                         {{range $protocol := .TwoFactorProtocols}}
                                         {{range $protocol := .TwoFactorProtocols}}
                                         <option value="{{$protocol}}" {{range $p :=$.User.Filters.TwoFactorAuthProtocols }}{{if eq $p $protocol}}selected{{end}}{{end}}>{{$protocol}}
                                         <option value="{{$protocol}}" {{range $p :=$.User.Filters.TwoFactorAuthProtocols }}{{if eq $p $protocol}}selected{{end}}{{end}}>{{$protocol}}
                                         </option>
                                         </option>
@@ -511,7 +542,7 @@
                             <div class="form-group row">
                             <div class="form-group row">
                                 <label for="idWebClient" class="col-sm-2 col-form-label">Web client/REST API</label>
                                 <label for="idWebClient" class="col-sm-2 col-form-label">Web client/REST API</label>
                                 <div class="col-sm-10">
                                 <div class="col-sm-10">
-                                    <select class="form-control" id="idWebClient" name="web_client_options" multiple>
+                                    <select class="form-control selectpicker" id="idWebClient" name="web_client_options" multiple>
                                         {{range $option := .WebClientOptions}}
                                         {{range $option := .WebClientOptions}}
                                         <option value="{{$option}}" {{range $p :=$.User.Filters.WebClient }}{{if eq $p $option}}selected{{end}}{{end}}>{{$option}}
                                         <option value="{{$option}}" {{range $p :=$.User.Filters.WebClient }}{{if eq $p $option}}selected{{end}}{{end}}>{{$option}}
                                         </option>
                                         </option>
@@ -852,7 +883,7 @@
                             <div class="form-group row">
                             <div class="form-group row">
                                 <label for="idTLSUsername" class="col-sm-2 col-form-label">TLS username</label>
                                 <label for="idTLSUsername" class="col-sm-2 col-form-label">TLS username</label>
                                 <div class="col-sm-10">
                                 <div class="col-sm-10">
-                                    <select class="form-control" id="idTLSUsername" name="tls_username" aria-describedby="tlsUsernameHelpBlock">
+                                    <select class="form-control selectpicker" id="idTLSUsername" name="tls_username" aria-describedby="tlsUsernameHelpBlock">
                                         <option value="None" {{if eq .User.Filters.TLSUsername "None" }}selected{{end}}>None</option>
                                         <option value="None" {{if eq .User.Filters.TLSUsername "None" }}selected{{end}}>None</option>
                                         <option value="CommonName" {{if eq .User.Filters.TLSUsername "CommonName" }}selected{{end}}>Common Name</option>
                                         <option value="CommonName" {{if eq .User.Filters.TLSUsername "CommonName" }}selected{{end}}>Common Name</option>
                                     </select>
                                     </select>
@@ -862,6 +893,23 @@
                                 </div>
                                 </div>
                             </div>
                             </div>
 
 
+                            <div class="form-group row">
+                                <label for="idHooks" class="col-sm-2 col-form-label">Hooks</label>
+                                <div class="col-sm-10">
+                                    <select class="form-control selectpicker" id="idHooks" name="hooks" multiple>
+                                        <option value="external_auth_disabled" {{if .User.Filters.Hooks.ExternalAuthDisabled}}selected{{end}}>
+                                            External auth disabled
+                                        </option>
+                                        <option value="pre_login_disabled" {{if .User.Filters.Hooks.PreLoginDisabled}}selected{{end}}>
+                                            Pre-login disabled
+                                        </option>
+                                        <option value="check_password_disabled" {{if .User.Filters.Hooks.CheckPasswordDisabled}}selected{{end}}>
+                                            Check password disabled
+                                        </option>
+                                    </select>
+                                </div>
+                            </div>
+
                             <div class="form-group row {{if not .CanImpersonate}}d-none{{end}}">
                             <div class="form-group row {{if not .CanImpersonate}}d-none{{end}}">
                                 <label for="idUID" class="col-sm-2 col-form-label">UID</label>
                                 <label for="idUID" class="col-sm-2 col-form-label">UID</label>
                                 <div class="col-sm-3">
                                 <div class="col-sm-3">
@@ -887,23 +935,6 @@
                                 </div>
                                 </div>
                             </div>
                             </div>
 
 
-                            <div class="form-group row">
-                                <label for="idHooks" class="col-sm-2 col-form-label">Hooks</label>
-                                <div class="col-sm-10">
-                                    <select class="form-control" id="idHooks" name="hooks" multiple>
-                                        <option value="external_auth_disabled" {{if .User.Filters.Hooks.ExternalAuthDisabled}}selected{{end}}>
-                                            External auth disabled
-                                        </option>
-                                        <option value="pre_login_disabled" {{if .User.Filters.Hooks.PreLoginDisabled}}selected{{end}}>
-                                            Pre-login disabled
-                                        </option>
-                                        <option value="check_password_disabled" {{if .User.Filters.Hooks.CheckPasswordDisabled}}selected{{end}}>
-                                            Check password disabled
-                                        </option>
-                                    </select>
-                                </div>
-                            </div>
-
                             <div class="form-group row {{if not .User.HasExternalAuth}}d-none{{end}}">
                             <div class="form-group row {{if not .User.HasExternalAuth}}d-none{{end}}">
                                 <label for="idExtAuthCacheTime" class="col-sm-2 col-form-label">External auth cache time</label>
                                 <label for="idExtAuthCacheTime" class="col-sm-2 col-form-label">External auth cache time</label>
                                 <div class="col-sm-10">
                                 <div class="col-sm-10">
@@ -950,8 +981,10 @@
 {{define "extra_js"}}
 {{define "extra_js"}}
 <script src="{{.StaticURL}}/vendor/moment/js/moment.min.js"></script>
 <script src="{{.StaticURL}}/vendor/moment/js/moment.min.js"></script>
 <script src="{{.StaticURL}}/vendor/tempusdominus/js/tempusdominus-bootstrap-4.min.js"></script>
 <script src="{{.StaticURL}}/vendor/tempusdominus/js/tempusdominus-bootstrap-4.min.js"></script>
+<script src="{{.StaticURL}}/vendor/bootstrap-select/js/bootstrap-select.min.js"></script>
 <script type="text/javascript">
 <script type="text/javascript">
     $(document).ready(function () {
     $(document).ready(function () {
+        $.fn.selectpicker.Constructor.BootstrapVersion = '4';
         {{if .Error}}
         {{if .Error}}
         $('#accordionUser .collapse').removeAttr("data-parent").collapse('show');
         $('#accordionUser .collapse').removeAttr("data-parent").collapse('show');
         {{end}}
         {{end}}
@@ -987,273 +1020,62 @@
         });
         });
 
 
         onFilesystemChanged('{{.User.FsConfig.Provider.Name}}');
         onFilesystemChanged('{{.User.FsConfig.Provider.Name}}');
+    });
 
 
-        $("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>
+    $("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>
-                `);
-
-            {{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 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>
-                `);
-
-            {{range .VirtualFolders}}
-            $("#idVfolderName"+index).append($('<option>').val('{{.Name}}').text('{{.Name}}'));
-            {{end}}
-        });
+                </div>
+            `);
+    });
 
 
-        $("body").on("click", ".remove_vfolder_btn_frm_field", function () {
-            $(this).closest(".form_field_vfolder_outer_row").remove();
-        });
+    $("body").on("click", ".remove_pk_btn_frm_field", function () {
+        $(this).closest(".form_field_pk_outer_row").remove();
+    });
 
 
-        $("body").on("click", ".add_new_bwlimit_field_btn", function () {
-            var index = $(".form_field_bwlimits_outer").find(".form_field_bwlimits_outer_row").length;
-            while (document.getElementById("idBandwidthLimitSources"+index) != null){
-                index++;
-            }
-            $(".form_field_bwlimits_outer").append(`
-                    <div class="row form_field_bwlimits_outer_row">
-                        <div class="form-group col-md-8">
-                            <textarea class="form-control" id="idBandwidthLimitSources0" name="bandwidth_limit_sources${index}" rows="4" placeholder=""
-                                aria-describedby="bwLimitSourcesHelpBlock${index}"></textarea>
-                            <small id="bwLimitSourcesHelpBlock${index}" class="form-text text-muted">
-                                Comma separated IP/Mask in CIDR format, example: "192.168.1.0/24,10.8.0.100/32"
-                            </small>
-                        </div>
-                        <div class="col-md-3">
-                            <div class="form-group">
-                                <input type="number" class="form-control" id="idUploadBandwidthSource${index}" name="upload_bandwidth_source${index}"
-                                    placeholder="" value="" min="0" aria-describedby="ulHelpBlock${index}">
-                                <small id="ulHelpBlock${index}" class="form-text text-muted">
-                                    UL (KB/s). 0 means no limit
-                                </small>
-                            </div>
-                            <div class="form-group">
-                                <input type="number" class="form-control" id="idDownloadBandwidthSource${index}" name="download_bandwidth_source${index}"
-                                    placeholder="" value="" min="0" aria-describedby="dlHelpBlock${index}">
-                                <small id="dlHelpBlock${index}" class="form-text text-muted">
-                                    DL (KB/s). 0 means no limit
-                                </small>
-                            </div>
-                        </div>
-                        <div class="form-group col-md-1">
-                            <button class="btn btn-circle btn-danger remove_bwlimit_btn_frm_field">
-                                <i class="fas fa-trash"></i>
-                            </button>
-                        </div>
+    $("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>
-                `);
-        });
-
-        $("body").on("click", ".remove_bwlimit_btn_frm_field", function () {
-            $(this).closest(".form_field_bwlimits_outer_row").remove();
-        });
-
-        $("body").on("click", ".add_new_dtlimit_field_btn", function () {
-            var index = $(".form_field_dtlimits_outer").find(".form_field_dtlimits_outer_row").length;
-            while (document.getElementById("idDataTransferLimitSources"+index) != null){
-                index++;
-            }
-            $(".form_field_dtlimits_outer").append(`
-                    <div class="row form_field_dtlimits_outer_row">
-                        <div class="form-group col-md-5">
-                            <textarea class="form-control" id="idDataTransferLimitSources${index}" name="data_transfer_limit_sources${index}" rows="4" placeholder=""
-                                aria-describedby="dtLimitSourcesHelpBlock${index}"></textarea>
-                            <small id="dtLimitSourcesHelpBlock${index}" class="form-text text-muted">
-                                Comma separated IP/Mask in CIDR format, example: "192.168.1.0/24,10.8.0.100/32"
-                            </small>
-                        </div>
-                        <div class="col-md-3">
-                            <div class="form-group">
-                                <input type="number" class="form-control" id="idUploadTransferSource${index}" name="upload_data_transfer_source${index}"
-                                    placeholder="" value="" min="0" aria-describedby="ulDtHelpBlock${index}">
-                                <small id="ulDtHelpBlock${index}" class="form-text text-muted">
-                                    UL (MB). 0 means no limit
-                                </small>
-                            </div>
-                            <div class="form-group">
-                                <input type="number" class="form-control" id="idDownloadTransferSource${index}" name="download_data_transfer_source${index}"
-                                    placeholder="" value="" min="0" aria-describedby="dlDtHelpBlock${index}">
-                                <small id="dlDtHelpBlock${index}" class="form-text text-muted">
-                                    DL (MB). 0 means no limit
-                                </small>
-                            </div>
-                        </div>
-                        <div class="col-md-3">
-                            <div class="form-group">
-                                <input type="number" class="form-control" id="idTotalTransferSource${index}" name="total_data_transfer_source${index}"
-                                    placeholder="" value="" min="0" aria-describedby="totalDtHelpBlock${index}">
-                                <small id="totalDtHelpBlock${index}" class="form-text text-muted">
-                                    Total (MB). 0 means no limit
-                                </small>
-                            </div>
-                        </div>
-                        <div class="form-group col-md-1">
-                            <button class="btn btn-circle btn-danger remove_dtlimit_btn_frm_field">
-                                <i class="fas fa-trash"></i>
-                            </button>
-                        </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>
-                `);
-        });
-
-        $("body").on("click", ".remove_dtlimit_btn_frm_field", function () {
-            $(this).closest(".form_field_dtlimits_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-3">
-                            <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-4">
-                            <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-2">
-                            <select class="form-control" id="idPatternPolicy${index}" name="pattern_policy${index}">
-                                <option value="0">Visible</option>
-                                <option value="1">Hidden</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 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>
-                `);
-        });
-
-        $("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 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();
-        });
-
+                </div>
+            `);
     });
     });
 
 
-    {{template "fsjs"}}
+    $("body").on("click", ".remove_tpl_user_btn_frm_field", function () {
+        $(this).closest(".form_field_tpl_user_outer_row").remove();
+    });
 </script>
 </script>
+{{template "fsjs"}}
+{{template "shared_user_group" .}}
 {{end}}
 {{end}}

+ 6 - 5
util/util.go

@@ -71,15 +71,16 @@ func RemoveDuplicates(obj []string) []string {
 	if len(obj) == 0 {
 	if len(obj) == 0 {
 		return obj
 		return obj
 	}
 	}
-	result := make([]string, 0, len(obj))
 	seen := make(map[string]bool)
 	seen := make(map[string]bool)
+	validIdx := 0
 	for _, item := range obj {
 	for _, item := range obj {
-		if _, ok := seen[item]; !ok {
-			result = append(result, item)
+		if !seen[item] {
+			seen[item] = true
+			obj[validIdx] = item
+			validIdx++
 		}
 		}
-		seen[item] = true
 	}
 	}
-	return result
+	return obj[:validIdx]
 }
 }
 
 
 // GetTimeAsMsSinceEpoch returns unix timestamp as milliseconds from a time struct
 // GetTimeAsMsSinceEpoch returns unix timestamp as milliseconds from a time struct

+ 5 - 0
vfs/folder.go

@@ -26,6 +26,8 @@ type BaseVirtualFolder struct {
 	LastQuotaUpdate int64 `json:"last_quota_update"`
 	LastQuotaUpdate int64 `json:"last_quota_update"`
 	// list of usernames associated with this virtual folder
 	// list of usernames associated with this virtual folder
 	Users []string `json:"users,omitempty"`
 	Users []string `json:"users,omitempty"`
+	// list of group names associated with this virtual folder
+	Groups []string `json:"groups,omitempty"`
 	// Filesystem configuration details
 	// Filesystem configuration details
 	FsConfig Filesystem `json:"filesystem"`
 	FsConfig Filesystem `json:"filesystem"`
 }
 }
@@ -44,6 +46,8 @@ func (v *BaseVirtualFolder) GetGCSCredentialsFilePath() string {
 func (v *BaseVirtualFolder) GetACopy() BaseVirtualFolder {
 func (v *BaseVirtualFolder) GetACopy() BaseVirtualFolder {
 	users := make([]string, len(v.Users))
 	users := make([]string, len(v.Users))
 	copy(users, v.Users)
 	copy(users, v.Users)
+	groups := make([]string, len(v.Groups))
+	copy(groups, v.Groups)
 	return BaseVirtualFolder{
 	return BaseVirtualFolder{
 		ID:              v.ID,
 		ID:              v.ID,
 		Name:            v.Name,
 		Name:            v.Name,
@@ -53,6 +57,7 @@ func (v *BaseVirtualFolder) GetACopy() BaseVirtualFolder {
 		UsedQuotaFiles:  v.UsedQuotaFiles,
 		UsedQuotaFiles:  v.UsedQuotaFiles,
 		LastQuotaUpdate: v.LastQuotaUpdate,
 		LastQuotaUpdate: v.LastQuotaUpdate,
 		Users:           users,
 		Users:           users,
+		Groups:          v.Groups,
 		FsConfig:        v.FsConfig.GetACopy(),
 		FsConfig:        v.FsConfig.GetACopy(),
 	}
 	}
 }
 }

+ 2 - 2
webdavd/internal_test.go

@@ -1119,7 +1119,7 @@ func TestCachedUserWithFolders(t *testing.T) {
 	folder, err := dataprovider.GetFolderByName(folderName)
 	folder, err := dataprovider.GetFolderByName(folderName)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	// updating a used folder should invalidate the cache only if the fs changed
 	// updating a used folder should invalidate the cache only if the fs changed
-	err = dataprovider.UpdateFolder(&folder, folder.Users, "", "")
+	err = dataprovider.UpdateFolder(&folder, folder.Users, folder.Groups, "", "")
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 
 
 	_, isCached, _, loginMethod, err = server.authenticate(req, ipAddr)
 	_, isCached, _, loginMethod, err = server.authenticate(req, ipAddr)
@@ -1132,7 +1132,7 @@ func TestCachedUserWithFolders(t *testing.T) {
 	}
 	}
 	// changing the folder path should invalidate the cache
 	// changing the folder path should invalidate the cache
 	folder.MappedPath = filepath.Join(os.TempDir(), "anotherpath")
 	folder.MappedPath = filepath.Join(os.TempDir(), "anotherpath")
-	err = dataprovider.UpdateFolder(&folder, folder.Users, "", "")
+	err = dataprovider.UpdateFolder(&folder, folder.Users, folder.Groups, "", "")
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	_, isCached, _, loginMethod, err = server.authenticate(req, ipAddr)
 	_, isCached, _, loginMethod, err = server.authenticate(req, ipAddr)
 	assert.NoError(t, err)
 	assert.NoError(t, err)

Vissa filer visades inte eftersom för många filer har ändrats