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.
 - 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.
+- Simplified user administrations using [groups](./docs/groups.md).
 - 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).
 - [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() {
 	addConfigFlags(revertProviderCmd)
 	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)
 }

+ 5 - 0
cmd/startsubsys.go

@@ -140,6 +140,11 @@ Command-line flags should be specified in the Subsystem declaration.
 					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)
 			if err != nil && err != io.EOF {
 				logger.Warn(logSender, connectionID, "serving subsystem finished with error: %v", err)

+ 1 - 0
dataprovider/actions.go

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

+ 5 - 4
dataprovider/admin.go

@@ -32,6 +32,7 @@ const (
 	PermAdminCloseConnections = "close_conns"
 	PermAdminViewServerStatus = "view_status"
 	PermAdminManageAdmins     = "manage_admins"
+	PermAdminManageGroups     = "manage_groups"
 	PermAdminManageAPIKeys    = "manage_apikeys"
 	PermAdminQuotaScans       = "quota_scans"
 	PermAdminManageSystem     = "manage_system"
@@ -45,10 +46,10 @@ const (
 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}])))\\.?$")
 	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

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
 // 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()
 	defer cache.Unlock()
 
@@ -74,11 +78,17 @@ func (cache *usersCache) swap(user *User) {
 			delete(cache.users, user.Username)
 			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
 			providerLog(logger.LevelDebug, "current password and fs unchanged for for user %#v, swap cached one",
 				user.Username)
-			cachedUser.User = *user
+			cachedUser.User = user
 			cache.users[user.Username] = cachedUser
 		} else {
 			// 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
 	// map for users, username is the key
 	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
 	vfolders map[string]vfs.BaseVirtualFolder
 	// slice with ordered folder names
@@ -64,6 +68,8 @@ func initializeMemoryProvider(basePath string) {
 			isClosed:        false,
 			usernames:       []string{},
 			users:           make(map[string]User),
+			groupnames:      []string{},
+			groups:          make(map[string]Group),
 			vfolders:        make(map[string]vfs.BaseVirtualFolder),
 			vfoldersNames:   []string{},
 			admins:          make(map[string]Admin),
@@ -299,7 +305,12 @@ func (p *MemoryProvider) addUser(user *User) error {
 	user.LastLogin = 0
 	user.CreatedAt = 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.usernames = append(p.dbHandle.usernames, user.Username)
 	sort.Strings(p.dbHandle.usernames)
@@ -325,9 +336,19 @@ func (p *MemoryProvider) updateUser(user *User) error {
 		return err
 	}
 	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.UsedQuotaSize = u.UsedQuotaSize
 	user.UsedQuotaFiles = u.UsedQuotaFiles
@@ -342,7 +363,7 @@ func (p *MemoryProvider) updateUser(user *User) error {
 	return nil
 }
 
-func (p *MemoryProvider) deleteUser(user *User) error {
+func (p *MemoryProvider) deleteUser(user User) error {
 	p.dbHandle.Lock()
 	defer p.dbHandle.Unlock()
 	if p.dbHandle.isClosed {
@@ -353,7 +374,12 @@ func (p *MemoryProvider) deleteUser(user *User) error {
 		return err
 	}
 	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)
 	// this could be more efficient
@@ -433,9 +459,21 @@ func (p *MemoryProvider) getUsersForQuotaCheck(toFetch map[string]bool) ([]User,
 		if val, ok := toFetch[username]; ok {
 			u := p.dbHandle.users[username]
 			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 {
 				p.addVirtualFoldersToUser(&user)
 			}
+			user.SetEmptySecretsIfNil()
 			user.PrepareForRendering()
 			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))
 }
 
+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 {
 	p.dbHandle.Lock()
 	defer p.dbHandle.Unlock()
@@ -558,7 +603,7 @@ func (p *MemoryProvider) updateAdmin(admin *Admin) error {
 	return nil
 }
 
-func (p *MemoryProvider) deleteAdmin(admin *Admin) error {
+func (p *MemoryProvider) deleteAdmin(admin Admin) error {
 	p.dbHandle.Lock()
 	defer p.dbHandle.Unlock()
 	if p.dbHandle.isClosed {
@@ -680,6 +725,192 @@ func (p *MemoryProvider) updateFolderQuota(name string, filesAdd int, sizeAdd in
 	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) {
 	p.dbHandle.Lock()
 	defer p.dbHandle.Unlock()
@@ -694,11 +925,70 @@ func (p *MemoryProvider) getUsedFolderQuota(name string) (int, int64, error) {
 	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
 	for idx := range user.VirtualFolders {
 		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 {
 			folder.BaseVirtualFolder = f
 			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)
 	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
 	}
 }
@@ -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)
 	if err == nil {
 		// exists
 		folder.MappedPath = baseFolder.MappedPath
 		folder.Description = baseFolder.Description
 		folder.FsConfig = baseFolder.FsConfig.GetACopy()
-		if !util.IsStringInSlice(username, folder.Users) {
+		if username != "" && !util.IsStringInSlice(username, folder.Users) {
 			folder.Users = append(folder.Users, username)
 		}
+		if groupname != "" && !util.IsStringInSlice(groupname, folder.Groups) {
+			folder.Groups = append(folder.Groups, groupname)
+		}
 		p.updateFoldersMappingInternal(folder)
 		return folder, nil
 	}
@@ -766,7 +1070,12 @@ func (p *MemoryProvider) addOrUpdateFolderInternal(baseFolder *vfs.BaseVirtualFo
 		folder.UsedQuotaSize = usedQuotaSize
 		folder.UsedQuotaFiles = usedQuotaFiles
 		folder.LastQuotaUpdate = lastQuotaUpdate
-		folder.Users = []string{username}
+		if username != "" {
+			folder.Users = []string{username}
+		}
+		if groupname != "" {
+			folder.Groups = []string{groupname}
+		}
 		p.updateFoldersMappingInternal(folder)
 		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))
 }
 
-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)
 	var err error
 	p.dbHandle.Lock()
@@ -902,7 +1211,7 @@ func (p *MemoryProvider) updateFolder(folder *vfs.BaseVirtualFolder) error {
 	return nil
 }
 
-func (p *MemoryProvider) deleteFolder(folder *vfs.BaseVirtualFolder) error {
+func (p *MemoryProvider) deleteFolder(folder vfs.BaseVirtualFolder) error {
 	p.dbHandle.Lock()
 	defer p.dbHandle.Unlock()
 	if p.dbHandle.isClosed {
@@ -927,6 +1236,20 @@ func (p *MemoryProvider) deleteFolder(folder *vfs.BaseVirtualFolder) error {
 			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)
 	p.dbHandle.vfoldersNames = []string{}
 	for name := range p.dbHandle.vfolders {
@@ -1022,7 +1345,7 @@ func (p *MemoryProvider) updateAPIKey(apiKey *APIKey) error {
 	return nil
 }
 
-func (p *MemoryProvider) deleteAPIKey(apiKey *APIKey) error {
+func (p *MemoryProvider) deleteAPIKey(apiKey APIKey) error {
 	p.dbHandle.Lock()
 	defer p.dbHandle.Unlock()
 	if p.dbHandle.isClosed {
@@ -1249,7 +1572,7 @@ func (p *MemoryProvider) updateShare(share *Share) error {
 	return nil
 }
 
-func (p *MemoryProvider) deleteShare(share *Share) error {
+func (p *MemoryProvider) deleteShare(share Share) error {
 	p.dbHandle.Lock()
 	defer p.dbHandle.Unlock()
 	if p.dbHandle.isClosed {
@@ -1430,6 +1753,16 @@ func (p *MemoryProvider) getNextAdminID() int64 {
 	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() {
 	p.dbHandle.Lock()
 	defer p.dbHandle.Unlock()
@@ -1482,6 +1815,10 @@ func (p *MemoryProvider) reloadConfig() error {
 		return err
 	}
 
+	if err := p.restoreGroups(&dump); err != nil {
+		return err
+	}
+
 	if err := p.restoreUsers(&dump); err != nil {
 		return err
 	}
@@ -1573,6 +1910,30 @@ func (p *MemoryProvider) restoreAdmins(dump *BackupData) error {
 	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 {
 	for _, folder := range dump.Folders {
 		folder := folder // pin
@@ -1580,7 +1941,7 @@ func (p *MemoryProvider) restoreFolders(dump *BackupData) error {
 		f, err := p.getFolderByName(folder.Name)
 		if err == nil {
 			folder.ID = f.ID
-			err = UpdateFolder(&folder, f.Users, ActionExecutorSystem, "")
+			err = UpdateFolder(&folder, f.Users, f.Groups, ActionExecutorSystem, "")
 			if err != nil {
 				providerLog(logger.LevelError, "error updating folder %#v: %v", folder.Name, err)
 				return err

+ 138 - 18
dataprovider/mysql.go

@@ -24,10 +24,14 @@ import (
 const (
 	mysqlResetSQL = "DROP TABLE IF EXISTS `{{api_keys}}` 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 `{{folders}}` CASCADE;" +
 		"DROP TABLE IF EXISTS `{{shares}}` 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_hosts}}` 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 `download_data_transfer`;" +
 		"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
@@ -243,7 +294,7 @@ func (p *MySQLProvider) updateUser(user *User) error {
 	return sqlCommonUpdateUser(user, p.dbHandle)
 }
 
-func (p *MySQLProvider) deleteUser(user *User) error {
+func (p *MySQLProvider) deleteUser(user User) error {
 	return sqlCommonDeleteUser(user, p.dbHandle)
 }
 
@@ -271,8 +322,8 @@ func (p *MySQLProvider) dumpFolders() ([]vfs.BaseVirtualFolder, error) {
 	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) {
@@ -289,7 +340,7 @@ func (p *MySQLProvider) updateFolder(folder *vfs.BaseVirtualFolder) error {
 	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)
 }
 
@@ -301,6 +352,38 @@ func (p *MySQLProvider) getUsedFolderQuota(name string) (int, int64, error) {
 	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) {
 	return sqlCommonGetAdminByUsername(username, p.dbHandle)
 }
@@ -313,7 +396,7 @@ func (p *MySQLProvider) updateAdmin(admin *Admin) error {
 	return sqlCommonUpdateAdmin(admin, p.dbHandle)
 }
 
-func (p *MySQLProvider) deleteAdmin(admin *Admin) error {
+func (p *MySQLProvider) deleteAdmin(admin Admin) error {
 	return sqlCommonDeleteAdmin(admin, p.dbHandle)
 }
 
@@ -341,7 +424,7 @@ func (p *MySQLProvider) updateAPIKey(apiKey *APIKey) error {
 	return sqlCommonUpdateAPIKey(apiKey, p.dbHandle)
 }
 
-func (p *MySQLProvider) deleteAPIKey(apiKey *APIKey) error {
+func (p *MySQLProvider) deleteAPIKey(apiKey APIKey) error {
 	return sqlCommonDeleteAPIKey(apiKey, p.dbHandle)
 }
 
@@ -369,7 +452,7 @@ func (p *MySQLProvider) updateShare(share *Share) error {
 	return sqlCommonUpdateShare(share, p.dbHandle)
 }
 
-func (p *MySQLProvider) deleteShare(share *Share) error {
+func (p *MySQLProvider) deleteShare(share Share) error {
 	return sqlCommonDeleteShare(share, p.dbHandle)
 }
 
@@ -487,6 +570,8 @@ func (p *MySQLProvider) migrateDatabase() error {
 		return err
 	case version == 15:
 		return updateMySQLDatabaseFromV15(p.dbHandle)
+	case version == 16:
+		return updateMySQLDatabaseFromV16(p.dbHandle)
 	default:
 		if version > sqlDatabaseVersion {
 			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 {
 	case 16:
 		return downgradeMySQLDatabaseFromV16(p.dbHandle)
+	case 17:
+		return downgradeMySQLDatabaseFromV17(p.dbHandle)
 	default:
 		return fmt.Errorf("database version not handled: %v", dbVersion.Version)
 	}
 }
 
 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)
 }
 
 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 {
@@ -547,6 +639,20 @@ func updateMySQLDatabaseFrom15To16(dbHandle *sql.DB) error {
 	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 {
 	logger.InfoToConsole("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)
 	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 (
 	pgsqlResetSQL = `DROP TABLE IF EXISTS "{{api_keys}}" 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 "{{folders}}" CASCADE;
 DROP TABLE IF EXISTS "{{shares}}" 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_hosts}}" 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 "download_data_transfer" 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)
 }
 
-func (p *PGSQLProvider) deleteUser(user *User) error {
+func (p *PGSQLProvider) deleteUser(user User) error {
 	return sqlCommonDeleteUser(user, p.dbHandle)
 }
 
@@ -247,8 +291,8 @@ func (p *PGSQLProvider) dumpFolders() ([]vfs.BaseVirtualFolder, error) {
 	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) {
@@ -265,7 +309,7 @@ func (p *PGSQLProvider) updateFolder(folder *vfs.BaseVirtualFolder) error {
 	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)
 }
 
@@ -277,6 +321,38 @@ func (p *PGSQLProvider) getUsedFolderQuota(name string) (int, int64, error) {
 	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) {
 	return sqlCommonGetAdminByUsername(username, p.dbHandle)
 }
@@ -289,7 +365,7 @@ func (p *PGSQLProvider) updateAdmin(admin *Admin) error {
 	return sqlCommonUpdateAdmin(admin, p.dbHandle)
 }
 
-func (p *PGSQLProvider) deleteAdmin(admin *Admin) error {
+func (p *PGSQLProvider) deleteAdmin(admin Admin) error {
 	return sqlCommonDeleteAdmin(admin, p.dbHandle)
 }
 
@@ -317,7 +393,7 @@ func (p *PGSQLProvider) updateAPIKey(apiKey *APIKey) error {
 	return sqlCommonUpdateAPIKey(apiKey, p.dbHandle)
 }
 
-func (p *PGSQLProvider) deleteAPIKey(apiKey *APIKey) error {
+func (p *PGSQLProvider) deleteAPIKey(apiKey APIKey) error {
 	return sqlCommonDeleteAPIKey(apiKey, p.dbHandle)
 }
 
@@ -345,7 +421,7 @@ func (p *PGSQLProvider) updateShare(share *Share) error {
 	return sqlCommonUpdateShare(share, p.dbHandle)
 }
 
-func (p *PGSQLProvider) deleteShare(share *Share) error {
+func (p *PGSQLProvider) deleteShare(share Share) error {
 	return sqlCommonDeleteShare(share, p.dbHandle)
 }
 
@@ -469,6 +545,8 @@ func (p *PGSQLProvider) migrateDatabase() error {
 		return err
 	case version == 15:
 		return updatePGSQLDatabaseFromV15(p.dbHandle)
+	case version == 16:
+		return updatePGSQLDatabaseFromV16(p.dbHandle)
 	default:
 		if version > sqlDatabaseVersion {
 			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 {
 	case 16:
 		return downgradePGSQLDatabaseFromV16(p.dbHandle)
+	case 17:
+		return downgradePGSQLDatabaseFromV17(p.dbHandle)
 	default:
 		return fmt.Errorf("database version not handled: %v", dbVersion.Version)
 	}
 }
 
 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)
 }
 
 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 {
@@ -529,6 +614,25 @@ func updatePGSQLDatabaseFrom15To16(dbHandle *sql.DB) error {
 	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 {
 	logger.InfoToConsole("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)
 	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 (
 	sqliteResetSQL = `DROP TABLE IF EXISTS "{{api_keys}}";
 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 "{{folders}}";
 DROP TABLE IF EXISTS "{{shares}}";
 DROP TABLE IF EXISTS "{{users}}";
+DROP TABLE IF EXISTS "{{groups}}";
 DROP TABLE IF EXISTS "{{defender_events}}";
 DROP TABLE IF EXISTS "{{defender_hosts}}";
 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 "download_data_transfer";
 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)
 }
 
-func (p *SQLiteProvider) deleteUser(user *User) error {
+func (p *SQLiteProvider) deleteUser(user User) error {
 	return sqlCommonDeleteUser(user, p.dbHandle)
 }
 
@@ -222,8 +269,8 @@ func (p *SQLiteProvider) dumpFolders() ([]vfs.BaseVirtualFolder, error) {
 	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) {
@@ -240,7 +287,7 @@ func (p *SQLiteProvider) updateFolder(folder *vfs.BaseVirtualFolder) error {
 	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)
 }
 
@@ -252,6 +299,38 @@ func (p *SQLiteProvider) getUsedFolderQuota(name string) (int, int64, error) {
 	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) {
 	return sqlCommonGetAdminByUsername(username, p.dbHandle)
 }
@@ -264,7 +343,7 @@ func (p *SQLiteProvider) updateAdmin(admin *Admin) error {
 	return sqlCommonUpdateAdmin(admin, p.dbHandle)
 }
 
-func (p *SQLiteProvider) deleteAdmin(admin *Admin) error {
+func (p *SQLiteProvider) deleteAdmin(admin Admin) error {
 	return sqlCommonDeleteAdmin(admin, p.dbHandle)
 }
 
@@ -292,7 +371,7 @@ func (p *SQLiteProvider) updateAPIKey(apiKey *APIKey) error {
 	return sqlCommonUpdateAPIKey(apiKey, p.dbHandle)
 }
 
-func (p *SQLiteProvider) deleteAPIKey(apiKey *APIKey) error {
+func (p *SQLiteProvider) deleteAPIKey(apiKey APIKey) error {
 	return sqlCommonDeleteAPIKey(apiKey, p.dbHandle)
 }
 
@@ -320,7 +399,7 @@ func (p *SQLiteProvider) updateShare(share *Share) error {
 	return sqlCommonUpdateShare(share, p.dbHandle)
 }
 
-func (p *SQLiteProvider) deleteShare(share *Share) error {
+func (p *SQLiteProvider) deleteShare(share Share) error {
 	return sqlCommonDeleteShare(share, p.dbHandle)
 }
 
@@ -438,6 +517,8 @@ func (p *SQLiteProvider) migrateDatabase() error {
 		return err
 	case version == 15:
 		return updateSQLiteDatabaseFromV15(p.dbHandle)
+	case version == 16:
+		return updateSQLiteDatabaseFromV16(p.dbHandle)
 	default:
 		if version > sqlDatabaseVersion {
 			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 {
 	case 16:
 		return downgradeSQLiteDatabaseFromV16(p.dbHandle)
+	case 17:
+		return downgradeSQLiteDatabaseFromV17(p.dbHandle)
 	default:
 		return fmt.Errorf("database version not handled: %v", dbVersion.Version)
 	}
 }
 
 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)
 }
 
 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 {
 	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 {
 	logger.InfoToConsole("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)
 }
 
+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 {
 	logger.InfoToConsole("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)
 }
 
-/*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)
 	defer cancel()
 
@@ -514,4 +642,4 @@ func downgradeSQLiteDatabaseFrom16To15(dbHandle *sql.DB) error {
 
 	_, err := dbHandle.ExecContext(ctx, sql)
 	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"
 	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"
+	selectGroupFields = "id,name,description,created_at,updated_at,user_settings"
 )
 
 func getSQLPlaceholders() []string {
@@ -105,6 +106,78 @@ func getDefenderEventsCleanupQuery() string {
 	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 {
 	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])
 }
 
-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])
 }
 
-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)
-		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])
 }
 
-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])
 }
 
@@ -426,6 +528,23 @@ func getQuotaFolderQuery() string {
 		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 {
 	var sb strings.Builder
 	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,
 		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 {
@@ -458,7 +577,59 @@ func getRelatedUsersForFoldersQuery(folders []vfs.BaseVirtualFolder) string {
 		sb.WriteString(")")
 	}
 	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 {

+ 273 - 76
dataprovider/user.go

@@ -123,8 +123,12 @@ type User struct {
 	VirtualFolders []vfs.VirtualFolder `json:"virtual_folders,omitempty"`
 	// Filesystem configuration details
 	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.
 	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
@@ -453,6 +457,9 @@ func (u *User) HasBufferedSFTP(name string) bool {
 
 func (u *User) getForbiddenSFTPSelfUsers(username string) ([]string, error) {
 	sftpUser, err := UserExists(username)
+	if err == nil {
+		err = sftpUser.LoadAndApplyGroupSettings()
+	}
 	if err == nil {
 		// we don't allow local nested SFTP folders
 		var forbiddens []string
@@ -912,30 +919,6 @@ func (u *User) GetAllowedLoginMethods() []string {
 	return allowedMethods
 }
 
-// GetFlatFilePatterns returns file patterns as flat list
-// duplicating a path if it has both allowed and denied patterns
-func (u *User) GetFlatFilePatterns() []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 {
 	var filter sdk.PatternsFilter
 	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
 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
@@ -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 {
 	u.SetEmptySecretsIfNil()
 	pubKeys := make([]string, len(u.PublicKeys))
@@ -1457,42 +1690,27 @@ func (u *User) getACopy() User {
 		vfolder := u.VirtualFolders[idx].GetACopy()
 		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)
 	for k, v := range u.Permissions {
 		perms := make([]string, len(v))
 		copy(perms, v)
 		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.ConfigName = u.Filters.TOTPConfig.ConfigName
 	filters.TOTPConfig.Secret = u.Filters.TOTPConfig.Secret.Clone()
 	filters.TOTPConfig.Protocols = make([]string, len(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))
 	for _, code := range u.Filters.RecoveryCodes {
 		if code.Secret == nil {
@@ -1503,29 +1721,6 @@ func (u *User) getACopy() User {
 			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{
 		BaseUser: sdk.BaseUser{
@@ -1559,9 +1754,11 @@ func (u *User) getACopy() User {
 			CreatedAt:                u.CreatedAt,
 			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
 	err = dataprovider.Initialize(providerConf, configDir, true)
 	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)
 	if assert.NoError(t, err) {
 		err = checkBasicFTP(client)
@@ -789,11 +794,32 @@ func TestLoginExternalAuth(t *testing.T) {
 		err := client.Quit()
 		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"
 	client, err = getFTPClient(u, true, nil)
 	if !assert.Error(t, err) {
 		err := client.Quit()
 		assert.NoError(t, err)
+	} else {
+		assert.Contains(t, err.Error(), "invalid credentials")
 	}
 
 	user, _, err := httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
@@ -803,6 +829,8 @@ func TestLoginExternalAuth(t *testing.T) {
 	assert.NoError(t, err)
 	err = os.RemoveAll(user.GetHomeDir())
 	assert.NoError(t, err)
+	_, err = httpdtest.RemoveGroup(group, http.StatusOK)
+	assert.NoError(t, err)
 
 	err = dataprovider.Close()
 	assert.NoError(t, err)
@@ -2920,7 +2948,7 @@ func TestClientCertificateAndPwdAuth(t *testing.T) {
 	assert.NoError(t, err)
 }
 
-func TestExternatAuthWithClientCert(t *testing.T) {
+func TestExternalAuthWithClientCert(t *testing.T) {
 	if runtime.GOOS == osWindows {
 		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 {
 	user := dataprovider.User{
 		BaseUser: sdk.BaseUser{

+ 1 - 1
go.mod

@@ -50,7 +50,7 @@ require (
 	github.com/rs/cors v1.8.2
 	github.com/rs/xid v1.4.0
 	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/spf13/afero v1.8.2
 	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/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/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/go.mod h1:D01hZJ4pVHPpCTZ3m3T2+wDF2YAGfd+H4ifUguaQzHM=
 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
 	}
 
-	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)
+		return
 	}
+	render.JSON(w, r, folders)
 }
 
 func addFolder(w http.ResponseWriter, r *http.Request) {
@@ -57,6 +57,7 @@ func updateFolder(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	users := folder.Users
+	groups := folder.Groups
 	folderID := folder.ID
 	name = folder.Name
 	currentS3AccessSecret := folder.FsConfig.S3Config.AccessSecret
@@ -82,7 +83,7 @@ func updateFolder(w http.ResponseWriter, r *http.Request) {
 	folder.FsConfig.SetEmptySecretsIfNil()
 	updateEncryptedSecrets(&folder.FsConfig, currentS3AccessSecret, currentAzAccountKey, currentAzSASUrl, currentGCSCredentials,
 		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 {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		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)
 		return nil, fmt.Errorf("invalid token claims %w", err)
 	}
-	user, err := dataprovider.UserExists(claims.Username)
+	user, err := dataprovider.GetUserWithGroupSettings(claims.Username)
 	if err != nil {
 		sendAPIResponse(w, r, nil, "Unable to retrieve your user", getRespStatus(err))
 		return nil, err
@@ -461,22 +461,22 @@ func updateUserProfile(w http.ResponseWriter, r *http.Request) {
 		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
 		return
 	}
-	user, err := dataprovider.UserExists(claims.Username)
+	user, userMerged, err := dataprovider.GetUserVariants(claims.Username)
 	if err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		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)
 		return
 	}
-	if user.CanManagePublicKeys() {
+	if userMerged.CanManagePublicKeys() {
 		user.PublicKeys = req.PublicKeys
 	}
-	if user.CanChangeAPIKeyAuth() {
+	if userMerged.CanChangeAPIKeyAuth() {
 		user.Filters.AllowAPIKeyAuth = req.AllowAPIKeyAuth
 	}
-	if user.CanChangeInfo() {
+	if userMerged.CanChangeInfo() {
 		user.Email = req.Email
 		user.Description = req.Description
 	}
@@ -518,14 +518,14 @@ func doChangeUserPassword(r *http.Request, currentPassword, newPassword, confirm
 	if err != nil || claims.Username == "" {
 		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))
 	if err != nil {
 		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) {

+ 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 {
 	dump, err := dataprovider.ParseDumpData(content)
 	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 {
 		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 {
 		return err
 	}
@@ -229,12 +233,12 @@ func RestoreFolders(folders []vfs.BaseVirtualFolder, inputFile string, mode, sca
 			}
 			folder.ID = f.ID
 			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 {
 			folder.Users = nil
 			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 {
 			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
 			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 {
 			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 {
 			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
 			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 {
 			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 {
 			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.Username = a.Username
 			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 {
 			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 {
 			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
 }
 
+// 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
 func RestoreUsers(users []dataprovider.User, inputFile string, mode, scanQuota int, executor, ipAddress string) error {
 	for _, user := range users {
@@ -349,15 +372,13 @@ func RestoreUsers(users []dataprovider.User, inputFile string, mode, scanQuota i
 			user.ID = u.ID
 			user.Username = u.Username
 			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 {
 				disconnectUser(user.Username)
 			}
 		} else {
 			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 {
 			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) {
 	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 {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		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)
 		return
 	}
-	user, err := dataprovider.UserExists(getURLParam(r, "username"))
+	user, err := dataprovider.GetUserWithGroupSettings(getURLParam(r, "username"))
 	if err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
@@ -171,7 +171,7 @@ func doUpdateUserQuotaUsage(w http.ResponseWriter, r *http.Request, username str
 		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
 		return
 	}
-	user, err := dataprovider.UserExists(username)
+	user, err := dataprovider.GetUserWithGroupSettings(username)
 	if err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		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)
 		return
 	}
-	user, err := dataprovider.UserExists(username)
+	user, err := dataprovider.GetUserWithGroupSettings(username)
 	if err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		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) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	username := getURLParam(r, "username")
-	user, err := dataprovider.UserExists(username)
+	user, err := dataprovider.GetUserWithGroupSettings(username)
 	if err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		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
 		}
 	}
-	user, err := dataprovider.UserExists(share.Username)
+	user, err := dataprovider.GetUserWithGroupSettings(share.Username)
 	if err != nil {
 		renderError(err, "", getRespStatus(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 {
 	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]
 	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)
 	}
 	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
 }

+ 26 - 26
httpd/api_utils.go

@@ -529,19 +529,19 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
 	var user dataprovider.User
 
 	if username == "" {
-		return util.NewValidationError("Username is mandatory")
+		return util.NewValidationError("username is mandatory")
 	}
 	if isAdmin {
 		admin, err = dataprovider.AdminExists(username)
 		email = admin.Email
 		subject = fmt.Sprintf("Email Verification Code for admin %#v", username)
 	} else {
-		user, err = dataprovider.UserExists(username)
+		user, err = dataprovider.GetUserWithGroupSettings(username)
 		email = user.Email
 		subject = fmt.Sprintf("Email Verification Code for user %#v", username)
 		if err == nil {
 			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
 
 	if newPassword == "" {
-		return &admin, &user, util.NewValidationError("Please set a password")
+		return &admin, &user, util.NewValidationError("please set a password")
 	}
 	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)
 	if !ok {
-		return &admin, &user, util.NewValidationError("Confirmation code not found")
+		return &admin, &user, util.NewValidationError("confirmation code not found")
 	}
 	resetCode := c.(*resetCode)
 	if resetCode.IsAdmin != isAdmin {
-		return &admin, &user, util.NewValidationError("Invalid confirmation code")
+		return &admin, &user, util.NewValidationError("invalid confirmation code")
 	}
 	if isAdmin {
 		admin, err = dataprovider.AdminExists(resetCode.Username)
 		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
-		err = dataprovider.UpdateAdmin(&admin, admin.Username, util.GetIPFromRemoteAddress(r.RemoteAddr))
+		err = dataprovider.UpdateAdmin(&admin, dataprovider.ActionExecutorSelf, util.GetIPFromRemoteAddress(r.RemoteAddr))
 		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 {

+ 7 - 0
httpd/httpd.go

@@ -42,6 +42,7 @@ const (
 	userPath                              = "/api/v2/users"
 	versionPath                           = "/api/v2/version"
 	folderPath                            = "/api/v2/folders"
+	groupPath                             = "/api/v2/groups"
 	serverStatusPath                      = "/api/v2/status"
 	dumpDataPath                          = "/api/v2/dumpdata"
 	loadDataPath                          = "/api/v2/loaddata"
@@ -101,6 +102,8 @@ const (
 	webConnectionsPathDefault             = "/web/admin/connections"
 	webFoldersPathDefault                 = "/web/admin/folders"
 	webFolderPathDefault                  = "/web/admin/folder"
+	webGroupsPathDefault                  = "/web/admin/groups"
+	webGroupPathDefault                   = "/web/admin/group"
 	webStatusPathDefault                  = "/web/admin/status"
 	webAdminsPathDefault                  = "/web/admin/managers"
 	webAdminPathDefault                   = "/web/admin/manager"
@@ -180,6 +183,8 @@ var (
 	webConnectionsPath             string
 	webFoldersPath                 string
 	webFolderPath                  string
+	webGroupsPath                  string
+	webGroupPath                   string
 	webStatusPath                  string
 	webAdminsPath                  string
 	webAdminPath                   string
@@ -764,6 +769,8 @@ func updateWebAdminURLs(baseURL string) {
 	webConnectionsPath = path.Join(baseURL, webConnectionsPathDefault)
 	webFoldersPath = path.Join(baseURL, webFoldersPathDefault)
 	webFolderPath = path.Join(baseURL, webFolderPathDefault)
+	webGroupsPath = path.Join(baseURL, webGroupsPathDefault)
+	webGroupPath = path.Join(baseURL, webGroupPathDefault)
 	webStatusPath = path.Join(baseURL, webStatusPathDefault)
 	webAdminsPath = path.Join(baseURL, webAdminsPathDefault)
 	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.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()
 	server.handleWebAddAdminPost(rr, req)
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	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()
 	server.handleWebClientTwoFactorRecoveryPost(rr, req)
 	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 {
 		return err
 	}
-	user, err := dataprovider.UserExists(username)
+	user, err := dataprovider.GetUserWithGroupSettings(username)
 	if err != nil {
 		updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}},
 			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)
 	if err != nil {
-		if e, ok := err.(*util.ValidationError); ok {
-			s.renderClientResetPwdPage(w, e.GetErrorString(), ipAddr)
-			return
-		}
 		s.renderClientResetPwdPage(w, err.Error(), ipAddr)
 		return
 	}
@@ -305,12 +301,12 @@ func (s *httpdServer) handleWebClientTwoFactorRecoveryPost(w http.ResponseWriter
 		s.renderClientTwoFactorRecoveryPage(w, err.Error(), ipAddr)
 		return
 	}
-	user, err := dataprovider.UserExists(username)
+	user, userMerged, err := dataprovider.GetUserVariants(username)
 	if err != nil {
 		s.renderClientTwoFactorRecoveryPage(w, "Invalid credentials", ipAddr)
 		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)
 		return
 	}
@@ -332,7 +328,7 @@ func (s *httpdServer) handleWebClientTwoFactorRecoveryPost(w http.ResponseWriter
 				return
 			}
 			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)
 			return
 		}
@@ -362,7 +358,7 @@ func (s *httpdServer) handleWebClientTwoFactorPost(w http.ResponseWriter, r *htt
 		s.renderClientTwoFactorPage(w, err.Error(), ipAddr)
 		return
 	}
-	user, err := dataprovider.UserExists(username)
+	user, err := dataprovider.GetUserWithGroupSettings(username)
 	if err != nil {
 		s.renderClientTwoFactorPage(w, "Invalid credentials", ipAddr)
 		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) {
-	user, err := dataprovider.UserExists(tokenClaims.Username)
+	user, err := dataprovider.GetUserWithGroupSettings(tokenClaims.Username)
 	if err != nil {
 		return
 	}
@@ -1211,6 +1207,11 @@ func (s *httpdServer) initializeRouter() {
 		router.With(s.checkPerm(dataprovider.PermAdminAddUsers)).Post(folderPath, addFolder)
 		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.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(loadDataPath, loadData)
 		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.PermAdminChangeUsers)).Post(webUserPath+"/{username}",
 				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).
 				Get(webConnectionsPath, s.handleWebGetConnections)
 			router.With(s.checkPerm(dataprovider.PermAdminViewUsers), s.refreshCookie).

+ 401 - 70
httpd/webadmin.go

@@ -43,40 +43,51 @@ const (
 	folderPageModeTemplate
 )
 
+type groupPageMode int
+
+const (
+	groupPageModeAdd groupPageMode = iota + 1
+	groupPageModeUpdate
+)
+
 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 (
@@ -93,6 +104,8 @@ type basePage struct {
 	AdminURL           string
 	QuotaScanURL       string
 	ConnectionsURL     string
+	GroupsURL          string
+	GroupURL           string
 	FoldersURL         string
 	FolderURL          string
 	FolderTemplateURL  string
@@ -109,6 +122,7 @@ type basePage struct {
 	AdminsTitle        string
 	ConnectionsTitle   string
 	FoldersTitle       string
+	GroupsTitle        string
 	StatusTitle        string
 	MaintenanceTitle   string
 	DefenderTitle      string
@@ -135,6 +149,11 @@ type foldersPage struct {
 	Folders []vfs.BaseVirtualFolder
 }
 
+type groupsPage struct {
+	basePage
+	Groups []dataprovider.Group
+}
+
 type connectionsPage struct {
 	basePage
 	Connections []common.ConnectionStatus
@@ -148,6 +167,7 @@ type statusPage struct {
 type fsWrapper struct {
 	vfs.Filesystem
 	IsUserPage      bool
+	IsGroupPage     bool
 	HasUsersBaseDir bool
 	DirPath         string
 }
@@ -166,6 +186,7 @@ type userPage struct {
 	RedactedSecret     string
 	Mode               userPageMode
 	VirtualFolders     []vfs.BaseVirtualFolder
+	Groups             []dataprovider.Group
 	CanImpersonate     bool
 	FsWrapper          fsWrapper
 }
@@ -228,6 +249,20 @@ type folderPage struct {
 	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 {
 	basePage
 	Error   string
@@ -247,6 +282,7 @@ func loadAdminTemplates(templatesPath string) {
 	}
 	userPaths := []string{
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
+		filepath.Join(templatesPath, templateAdminDir, templateSharedComponents),
 		filepath.Join(templatesPath, templateAdminDir, templateFsConfig),
 		filepath.Join(templatesPath, templateAdminDir, templateUser),
 	}
@@ -283,6 +319,16 @@ func loadAdminTemplates(templatesPath string) {
 		filepath.Join(templatesPath, templateAdminDir, templateFsConfig),
 		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{
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateStatus),
@@ -336,6 +382,8 @@ func loadAdminTemplates(templatesPath string) {
 	adminTmpl := util.LoadTemplate(nil, adminPaths...)
 	connectionsTmpl := util.LoadTemplate(nil, connectionsPaths...)
 	messageTmpl := util.LoadTemplate(nil, messagePaths...)
+	groupsTmpl := util.LoadTemplate(nil, groupsPaths...)
+	groupTmpl := util.LoadTemplate(fsBaseTpl, groupPaths...)
 	foldersTmpl := util.LoadTemplate(nil, foldersPaths...)
 	folderTmpl := util.LoadTemplate(fsBaseTpl, folderPaths...)
 	statusTmpl := util.LoadTemplate(nil, statusPaths...)
@@ -357,6 +405,8 @@ func loadAdminTemplates(templatesPath string) {
 	adminTemplates[templateAdmin] = adminTmpl
 	adminTemplates[templateConnections] = connectionsTmpl
 	adminTemplates[templateMessage] = messageTmpl
+	adminTemplates[templateGroups] = groupsTmpl
+	adminTemplates[templateGroup] = groupTmpl
 	adminTemplates[templateFolders] = foldersTmpl
 	adminTemplates[templateFolder] = folderTmpl
 	adminTemplates[templateStatus] = statusTmpl
@@ -386,6 +436,8 @@ func (s *httpdServer) getBasePageData(title, currentURL string, r *http.Request)
 		UserTemplateURL:    webTemplateUser,
 		AdminsURL:          webAdminsPath,
 		AdminURL:           webAdminPath,
+		GroupsURL:          webGroupsPath,
+		GroupURL:           webGroupPath,
 		FoldersURL:         webFoldersPath,
 		FolderURL:          webFolderPath,
 		FolderTemplateURL:  webTemplateFolder,
@@ -404,6 +456,7 @@ func (s *httpdServer) getBasePageData(title, currentURL string, r *http.Request)
 		AdminsTitle:        pageAdminsTitle,
 		ConnectionsTitle:   pageConnectionsTitle,
 		FoldersTitle:       pageFoldersTitle,
+		GroupsTitle:        pageGroupsTitle,
 		StatusTitle:        pageStatusTitle,
 		MaintenanceTitle:   pageMaintenanceTitle,
 		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,
 	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 {
 		return
 	}
@@ -628,10 +685,12 @@ func (s *httpdServer) renderUserPage(w http.ResponseWriter, r *http.Request, use
 		WebClientOptions:   sdk.WebClientOptions,
 		RootDirPerms:       user.GetPermissionsForPath("/"),
 		VirtualFolders:     folders,
+		Groups:             groups,
 		CanImpersonate:     os.Getuid() == 0,
 		FsWrapper: fsWrapper{
 			Filesystem:      user.FsConfig,
 			IsUserPage:      true,
+			IsGroupPage:     false,
 			HasUsersBaseDir: dataprovider.HasUsersBaseDir(),
 			DirPath:         user.HomeDir,
 		},
@@ -639,7 +698,52 @@ func (s *httpdServer) renderUserPage(w http.ResponseWriter, r *http.Request, use
 	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
 	switch mode {
 	case folderPageModeAdd:
@@ -663,6 +767,7 @@ func (s *httpdServer) renderFolderPage(w http.ResponseWriter, r *http.Request, f
 		FsWrapper: fsWrapper{
 			Filesystem:      folder.FsConfig,
 			IsUserPage:      false,
+			IsGroupPage:     false,
 			HasUsersBaseDir: false,
 			DirPath:         folder.MappedPath,
 		},
@@ -763,9 +868,8 @@ func getVirtualFoldersFromPostFields(r *http.Request) []vfs.VirtualFolder {
 	return virtualFolders
 }
 
-func getUserPermissionsFromPostFields(r *http.Request) map[string][]string {
+func getSubDirPermissionsFromPostFields(r *http.Request) map[string][]string {
 	permissions := make(map[string][]string)
-	permissions["/"] = r.Form["permissions"]
 
 	for k := range r.Form {
 		if strings.HasPrefix(k, "sub_perm_path") {
@@ -780,6 +884,13 @@ func getUserPermissionsFromPostFields(r *http.Request) map[string][]string {
 	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) {
 	var result []sdk.DataTransferLimit
 
@@ -928,6 +1039,27 @@ func getFilePatternsFromPostField(r *http.Request) []sdk.PatternsFilter {
 	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) {
 	var filters sdk.BaseUserFilters
 	bwLimits, err := getBandwidthLimitsFromPostFields(r)
@@ -938,6 +1070,10 @@ func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error)
 	if err != nil {
 		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.DataTransferLimits = dtLimits
 	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.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")
-	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 {
@@ -990,27 +1130,30 @@ func getS3Config(r *http.Request) (vfs.S3FsConfig, error) {
 	config.KeyPrefix = r.Form.Get("s3_key_prefix")
 	config.UploadPartSize, err = strconv.ParseInt(r.Form.Get("s3_upload_part_size"), 10, 64)
 	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"))
 	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)
 	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"))
 	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.DownloadPartMaxTime, err = strconv.Atoi(r.Form.Get("s3_download_part_max_time"))
 	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"))
-	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) {
@@ -1059,7 +1202,10 @@ func getSFTPConfig(r *http.Request) (vfs.SFTPFsConfig, error) {
 	config.Prefix = r.Form.Get("sftp_prefix")
 	config.DisableCouncurrentReads = len(r.Form.Get("sftp_disable_concurrent_reads")) > 0
 	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) {
@@ -1075,18 +1221,21 @@ func getAzureConfig(r *http.Request) (vfs.AzBlobFsConfig, error) {
 	config.UseEmulator = len(r.Form.Get("az_use_emulator")) > 0
 	config.UploadPartSize, err = strconv.ParseInt(r.Form.Get("az_upload_part_size"), 10, 64)
 	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"))
 	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)
 	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"))
-	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) {
@@ -1287,7 +1436,7 @@ func getQuotaLimits(r *http.Request) (int64, int, error) {
 }
 
 func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
-	var user dataprovider.User
+	user := dataprovider.User{}
 	err := r.ParseMultipartForm(maxRequestSize)
 	if err != nil {
 		return user, err
@@ -1370,13 +1519,71 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
 		},
 		VirtualFolders: getVirtualFoldersFromPostFields(r),
 		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 {
-		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) {
@@ -1927,11 +2134,11 @@ func (s *httpdServer) handleWebAddUserPost(w http.ResponseWriter, r *http.Reques
 		PublicKeys: user.PublicKeys,
 	})
 	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())
+		return
 	}
+	http.Redirect(w, r, webUsersPath, http.StatusSeeOther)
 }
 
 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)
-	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) {
@@ -2108,18 +2315,18 @@ func (s *httpdServer) handleWebUpdateFolderPost(w http.ResponseWriter, r *http.R
 
 	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 {
-		s.renderFolderPage(w, r, folder, folderPageModeUpdate, err.Error())
+		s.renderFolderPage(w, r, updatedFolder, folderPageModeUpdate, err.Error())
 		return
 	}
 	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)
 	for {
-		f, err := dataprovider.GetFolders(limit, len(folders), dataprovider.OrderASC)
+		f, err := dataprovider.GetFolders(limit, len(folders), dataprovider.OrderASC, minimal)
 		if err != nil {
 			s.renderInternalServerErrorPage(w, r, err)
 			return folders, err
@@ -2142,7 +2349,7 @@ func (s *httpdServer) handleWebGetFolders(w http.ResponseWriter, r *http.Request
 			limit = defaultQueryLimit
 		}
 	}
-	folders, err := s.getWebVirtualFolders(w, r, limit)
+	folders, err := s.getWebVirtualFolders(w, r, limit, false)
 	if err != nil {
 		return
 	}
@@ -2153,3 +2360,127 @@ func (s *httpdServer) handleWebGetFolders(w http.ResponseWriter, r *http.Request
 	}
 	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),
 		Error:          error,
 	}
-	user, err := dataprovider.UserExists(data.LoggedUser.Username)
+	user, userMerged, err := dataprovider.GetUserVariants(data.LoggedUser.Username)
 	if err != nil {
 		s.renderClientInternalServerErrorPage(w, r, err)
 		return
@@ -565,7 +565,7 @@ func (s *httpdServer) renderClientProfilePage(w http.ResponseWriter, r *http.Req
 	data.AllowAPIKeyAuth = user.Filters.AllowAPIKeyAuth
 	data.Email = user.Email
 	data.Description = user.Description
-	data.CanSubmit = user.CanChangeAPIKeyAuth() || user.CanManagePublicKeys() || user.CanChangeInfo()
+	data.CanSubmit = userMerged.CanChangeAPIKeyAuth() || userMerged.CanManagePublicKeys() || userMerged.CanChangeInfo()
 	renderClientTemplate(w, templateClientProfile, data)
 }
 
@@ -586,7 +586,7 @@ func (s *httpdServer) handleWebClientDownloadZip(w http.ResponseWriter, r *http.
 		return
 	}
 
-	user, err := dataprovider.UserExists(claims.Username)
+	user, err := dataprovider.GetUserWithGroupSettings(claims.Username)
 	if err != nil {
 		s.renderClientMessagePage(w, r, "Unable to retrieve your user", "", getRespStatus(err), nil, "")
 		return
@@ -735,7 +735,7 @@ func (s *httpdServer) handleClientGetDirContents(w http.ResponseWriter, r *http.
 		return
 	}
 
-	user, err := dataprovider.UserExists(claims.Username)
+	user, err := dataprovider.GetUserWithGroupSettings(claims.Username)
 	if err != nil {
 		sendAPIResponse(w, r, nil, "Unable to retrieve your user", getRespStatus(err))
 		return
@@ -812,7 +812,7 @@ func (s *httpdServer) handleClientGetFiles(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	user, err := dataprovider.UserExists(claims.Username)
+	user, err := dataprovider.GetUserWithGroupSettings(claims.Username)
 	if err != nil {
 		s.renderClientMessagePage(w, r, "Unable to retrieve your user", "", getRespStatus(err), nil, "")
 		return
@@ -872,7 +872,7 @@ func (s *httpdServer) handleClientEditFile(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	user, err := dataprovider.UserExists(claims.Username)
+	user, err := dataprovider.GetUserWithGroupSettings(claims.Username)
 	if err != nil {
 		s.renderClientMessagePage(w, r, "Unable to retrieve your user", "", getRespStatus(err), nil, "")
 		return
@@ -1120,22 +1120,22 @@ func (s *httpdServer) handleWebClientProfilePost(w http.ResponseWriter, r *http.
 		s.renderClientForbiddenPage(w, r, "Invalid token claims")
 		return
 	}
-	user, err := dataprovider.UserExists(claims.Username)
+	user, userMerged, err := dataprovider.GetUserVariants(claims.Username)
 	if err != nil {
 		s.renderClientProfilePage(w, r, err.Error())
 		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")
 		return
 	}
-	if user.CanManagePublicKeys() {
+	if userMerged.CanManagePublicKeys() {
 		user.PublicKeys = r.Form["public_keys"]
 	}
-	if user.CanChangeAPIKeyAuth() {
+	if userMerged.CanChangeAPIKeyAuth() {
 		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.Description = r.Form.Get("description")
 	}

+ 251 - 56
httpdtest/httpdtest.go

@@ -15,6 +15,7 @@ import (
 	"strings"
 
 	"github.com/go-chi/render"
+	"github.com/sftpgo/sdk"
 
 	"github.com/drakkan/sftpgo/v2/common"
 	"github.com/drakkan/sftpgo/v2/dataprovider"
@@ -33,6 +34,7 @@ const (
 	quotaScanPath         = "/api/v2/quotas/users/scans"
 	quotaScanVFolderPath  = "/api/v2/quotas/folders/scans"
 	userPath              = "/api/v2/users"
+	groupPath             = "/api/v2/groups"
 	versionPath           = "/api/v2/version"
 	folderPath            = "/api/v2/folders"
 	serverStatusPath      = "/api/v2/status"
@@ -244,6 +246,115 @@ func GetUsers(limit, offset int64, expectedStatusCode int) ([]dataprovider.User,
 	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.
 func AddAdmin(admin dataprovider.Admin, expectedStatusCode int) (dataprovider.Admin, []byte, error) {
 	var newAdmin dataprovider.Admin
@@ -1043,6 +1154,35 @@ func getResponseBody(resp *http.Response) ([]byte, error) {
 	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 {
 	if expected.ID <= 0 {
 		if actual.ID <= 0 {
@@ -1185,27 +1325,30 @@ func checkUser(expected *dataprovider.User, actual *dataprovider.User) error {
 	if expected.Email != actual.Email {
 		return errors.New("email mismatch")
 	}
-	if err := compareUserPermissions(expected, actual); err != nil {
+	if err := compareUserPermissions(expected.Permissions, actual.Permissions); err != nil {
 		return err
 	}
-	if err := compareUserFilters(expected, actual); err != nil {
+	if err := compareUserFilters(expected.Filters.BaseUserFilters, actual.Filters.BaseUserFilters); err != nil {
 		return err
 	}
 	if err := compareFsConfig(&expected.FsConfig, &actual.FsConfig); err != nil {
 		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 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")
 	}
-	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 {
 				if !util.IsStringInSlice(v, perms) {
 					return errors.New("permissions contents mismatch")
@@ -1218,13 +1361,34 @@ func compareUserPermissions(expected *dataprovider.User, actual *dataprovider.Us
 	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")
 	}
-	for _, v := range actual.VirtualFolders {
+	for _, v := range actual {
 		found := false
-		for _, v1 := range expected.VirtualFolders {
+		for _, v1 := range expected {
 			if path.Clean(v.VirtualPath) == path.Clean(v1.VirtualPath) {
 				if err := checkFolder(&v1.BaseVirtualFolder, &v.BaseVirtualFolder); err != nil {
 					return err
@@ -1455,80 +1619,80 @@ func checkEncryptedSecret(expected, actual *kms.Secret) error {
 	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")
 		}
 	}
-	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")
 		}
 	}
-	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")
 		}
 	}
-	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")
 		}
 	}
-	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 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")
 	}
-	if expected.Filters.Hooks.PreLoginDisabled != actual.Filters.Hooks.PreLoginDisabled {
+	if expected.Hooks.PreLoginDisabled != actual.Hooks.PreLoginDisabled {
 		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")
 	}
-	if expected.Filters.DisableFsChecks != actual.Filters.DisableFsChecks {
+	if expected.DisableFsChecks != actual.DisableFsChecks {
 		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 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")
 	}
-	if len(expected.Filters.DeniedIP) != len(actual.Filters.DeniedIP) {
+	if len(expected.DeniedIP) != len(actual.DeniedIP) {
 		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")
 	}
-	if len(expected.Filters.DeniedProtocols) != len(actual.Filters.DeniedProtocols) {
+	if len(expected.DeniedProtocols) != len(actual.DeniedProtocols) {
 		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")
 	}
-	if expected.Filters.TLSUsername != actual.Filters.TLSUsername {
+	if expected.TLSUsername != actual.TLSUsername {
 		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")
 	}
-	if expected.Filters.AllowAPIKeyAuth != actual.Filters.AllowAPIKeyAuth {
+	if expected.AllowAPIKeyAuth != actual.AllowAPIKeyAuth {
 		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")
 	}
 	if err := compareUserFilterSubStructs(expected, actual); err != nil {
@@ -1555,21 +1719,21 @@ func checkFilterMatch(expected []string, actual []string) bool {
 	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")
 	}
-	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")
 		}
-		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")
 		}
-		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")
 		}
-		for _, source := range actual.Filters.DataTransferLimits[idx].Sources {
+		for _, source := range actual.DataTransferLimits[idx].Sources {
 			if !util.IsStringInSlice(source, l.Sources) {
 				return errors.New("data transfer limit source mismatch")
 			}
@@ -1579,22 +1743,22 @@ func compareUserDataTransferLimitFilters(expected *dataprovider.User, actual *da
 	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")
 	}
 
-	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")
 		}
-		if actual.Filters.BandwidthLimits[idx].DownloadBandwidth != l.DownloadBandwidth {
+		if actual.BandwidthLimits[idx].DownloadBandwidth != l.DownloadBandwidth {
 			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")
 		}
-		for _, source := range actual.Filters.BandwidthLimits[idx].Sources {
+		for _, source := range actual.BandwidthLimits[idx].Sources {
 			if !util.IsStringInSlice(source, l.Sources) {
 				return errors.New("bandwidth filters source mismatch")
 			}
@@ -1604,13 +1768,13 @@ func compareUserBandwidthLimitFilters(expected *dataprovider.User, actual *datap
 	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")
 	}
-	for _, f := range expected.Filters.FilePatterns {
+	for _, f := range expected.FilePatterns {
 		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 !checkFilterMatch(f.AllowedPatterns, f1.AllowedPatterns) ||
 					!checkFilterMatch(f.DeniedPatterns, f1.DeniedPatterns) {
@@ -1626,6 +1790,37 @@ func compareUserFilePatternsFilters(expected *dataprovider.User, actual *datapro
 	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 {
 	if dataprovider.ConvertName(expected.Username) != actual.Username {
 		return errors.New("username mismatch")

+ 288 - 9
openapi/openapi.yaml

@@ -9,6 +9,7 @@ tags:
   - name: defender
   - name: quota
   - name: folders
+  - name: groups
   - name: users
   - name: data retention
   - 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.
     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.
+    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.
     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
@@ -1696,7 +1698,7 @@ paths:
         - in: query
           name: order
           required: false
-          description: Ordering folders by path. Default ASC
+          description: Ordering folders by name. Default ASC
           schema:
             type: string
             enum:
@@ -1844,6 +1846,181 @@ paths:
           $ref: '#/components/responses/InternalServerError'
         default:
           $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:
     get:
       tags:
@@ -4606,7 +4783,7 @@ components:
         total_data_transfer:
           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'
-    UserFilters:
+    BaseUserFilters:
       type: object
       properties:
         allowed_ip:
@@ -4665,12 +4842,6 @@ components:
           description: 'API key authentication allows to impersonate this user with an API key'
         user_type:
           $ref: '#/components/schemas/UserType'
-        totp_config:
-          $ref: '#/components/schemas/UserTOTPConfig'
-        recovery_codes:
-          type: array
-          items:
-            $ref: '#/components/schemas/RecoveryCode'
         bandwidth_limits:
           type: array
           items:
@@ -4691,6 +4862,17 @@ components:
             $ref: '#/components/schemas/MFAProtocols'
           description: 'Defines protocols that require two factor authentication'
       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:
       type: object
       properties:
@@ -4984,7 +5166,7 @@ components:
           type: array
           items:
             $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:
           type: integer
           format: int32
@@ -5070,6 +5252,10 @@ components:
         additional_info:
           type: string
           description: Free form text field for external systems
+        groups:
+          type: array
+          items:
+            $ref: '#/components/schemas/GroupMapping'
         oidc_custom_fields:
           type: object
           additionalProperties: true
@@ -5652,6 +5838,95 @@ components:
           example:
             - 192.0.2.0/24
             - '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:
       type: object
       properties:
@@ -5663,6 +5938,10 @@ components:
           type: array
           items:
             $ref: '#/components/schemas/BaseVirtualFolder'
+        groups:
+          type: array
+          items:
+            $ref: '#/components/schemas/Group'
         admins:
           type: array
           items:

+ 4 - 0
service/service.go

@@ -308,6 +308,10 @@ func (s *Service) restoreDump(dump *dataprovider.BackupData) error {
 	if err != nil {
 		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, "")
 	if err != nil {
 		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 {
 			var nextMethods []string
-			user, err := dataprovider.UserExists(conn.User())
+			user, err := dataprovider.GetUserWithGroupSettings(conn.User())
 			if err == nil {
 				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)
 
 	err = createTestFile(revokeUserCerts, 10*1024*1024)
+	assert.NoError(t, err)
 	sftpdConf.RevokedUserCertsFile = revokeUserCerts
 	err = sftpdConf.Initialize(configDir)
 	assert.Error(t, err)
@@ -606,6 +607,39 @@ func TestBasicSFTPFsHandling(t *testing.T) {
 	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) {
 	usePubKey := false
 	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 {
 	user := dataprovider.User{
 		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>
             {{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"}}
             <li class="nav-item {{if eq .CurrentURL .ConnectionsURL}}active{{end}}">
                 <a class="nav-link" href="{{.ConnectionsURL}}">

+ 7 - 2
templates/webadmin/folder.html

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

+ 6 - 4
templates/webadmin/fsconfig.html

@@ -7,7 +7,7 @@
         <div class="form-group row">
             <label for="idFilesystem" class="col-sm-2 col-form-label">Storage</label>
             <div class="col-sm-10">
-                <select class="form-control" id="idFilesystem" name="fs_provider"
+                <select class="form-control selectpicker" id="idFilesystem" name="fs_provider"
                     onchange="onFilesystemChanged(this.value)">
                     {{ range ListFSProviders }}
                     <option value="{{.Name}}" {{if eq . $.Provider }}selected{{end}}>{{.ShortInfo}}</option>
@@ -15,14 +15,14 @@
                 </select>
             </div>
         </div>
-        {{if .IsUserPage}}
+        {{if or .IsUserPage .IsGroupPage}}
         <div class="form-group row">
             <label for="idHomeDir" class="col-sm-2 col-form-label">Home Dir</label>
             <div class="col-sm-10">
                 <input type="text" class="form-control" id="idHomeDir" name="home_dir" placeholder="Absolute path to a local directory"
                     value="{{.DirPath}}" aria-describedby="homeDirHelpBlock">
                 <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>
             </div>
         </div>
@@ -299,7 +299,7 @@
         <div class="form-group row fsconfig fsconfig-azblobfs">
             <label for="idAzAccessTier" class="col-sm-2 col-form-label">Access Tier</label>
             <div class="col-sm-10">
-                <select class="form-control" id="idAzAccessTier" name="az_access_tier">
+                <select class="form-control selectpicker" id="idAzAccessTier" name="az_access_tier">
                     <option value="" {{if eq .AzBlobConfig.AccessTier "" }}selected{{end}}>Default</option>
                     <option value="Hot" {{if eq .AzBlobConfig.AccessTier "Hot" }}selected{{end}}>Hot</option>
                     <option value="Cool" {{if eq .AzBlobConfig.AccessTier "Cool" }}selected{{end}}>Cool</option>
@@ -459,6 +459,7 @@
 {{end}}
 
 {{define "fsjs"}}
+<script type="text/javascript">
     function onFilesystemChanged(val){
         // each fsconfig form-group has the 'fsconfig' css class
         // as well as a 'fsconfig-{name}' class where name is the FilesystemProvider.Name
@@ -466,4 +467,5 @@
         $('.form-group.fsconfig').hide();
         $('.form-group.fsconfig-'+val).show();
     }
+</script>
 {{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"}}
 <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}}
 
 {{define "page_body"}}
@@ -140,6 +141,36 @@
             </div>
             {{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}}
             {{if .VirtualFolders}}
             <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">
                                 </div>
                                 <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>
                                         {{range $.VirtualFolders}}
                                         <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">
                                 </div>
                                 <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>
                                         {{range .VirtualFolders}}
                                         <option value="{{.Name}}">{{.Name}}</option>
@@ -245,7 +276,7 @@
                             <div class="form-group row">
                                 <label for="idStatus" class="col-sm-2 col-form-label">Status</label>
                                 <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="0" {{if eq .User.Status 0 }}selected{{end}}>Inactive</option>
                                     </select>
@@ -322,7 +353,7 @@
                             <div class="form-group row">
                                 <label for="idPermissions" class="col-sm-2 col-form-label">Permissions</label>
                                 <div class="col-sm-10">
-                                    <select class="form-control" id="idPermissions" name="permissions" required multiple>
+                                    <select class="form-control selectpicker" id="idPermissions" name="permissions" required multiple>
                                         {{range $validPerm := .ValidPerms}}
                                         <option value="{{$validPerm}}" {{range $perm :=$.RootDirPerms }}{{if eq $perm $validPerm}}selected{{end}}{{end}}>{{$validPerm}}</option>
                                         {{end}}
@@ -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">
                                                 </div>
                                                 <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}}
                                                         <option value="{{$validPerm}}" {{range $perm := $dirPerms.Permissions }}{{if eq $perm $validPerm}}selected{{end}}{{end}}>{{$validPerm}}</option>
                                                         {{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">
                                                 </div>
                                                 <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}}
                                                         <option value="{{$validPerm}}">{{$validPerm}}</option>
                                                         {{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>
                                     <div class="form-group row">
                                         <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="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">
@@ -403,13 +434,13 @@
                                                     <input type="text" class="form-control" id="idPatterns{{$idx}}" name="patterns{{$idx}}" placeholder="*.zip,?.txt" value="{{$pattern.GetCommaSeparatedPatterns}}" maxlength="255">
                                                 </div>
                                                 <div class="form-group col-md-2">
-                                                    <select class="form-control" id="idPatternType{{$idx}}" name="pattern_type{{$idx}}">
+                                                    <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" 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="1" {{if eq $pattern.DenyPolicy 1}}selected{{end}}>Hidden</option>
                                                     </select>
@@ -429,13 +460,13 @@
                                                     <input type="text" class="form-control" id="idPatterns0" name="patterns0" placeholder="*.zip,?.txt" value="" maxlength="255">
                                                 </div>
                                                 <div class="form-group col-md-2">
-                                                    <select class="form-control" id="idPatternType0" name="pattern_type0">
+                                                    <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" id="idPatternPolicy0" name="pattern_policy0">
+                                                    <select class="form-control selectpicker" id="idPatternPolicy0" name="pattern_policy0">
                                                         <option value="0">Visible</option>
                                                         <option value="1">Hidden</option>
                                                     </select>
@@ -472,7 +503,7 @@
                             <div class="form-group row">
                                 <label for="idProtocols" class="col-sm-2 col-form-label">Denied protocols</label>
                                 <div class="col-sm-10">
-                                    <select class="form-control" id="idProtocols" name="denied_protocols" multiple>
+                                    <select class="form-control selectpicker" id="idProtocols" name="denied_protocols" multiple>
                                         {{range $protocol := .ValidProtocols}}
                                         <option value="{{$protocol}}" {{range $p :=$.User.Filters.DeniedProtocols }}{{if eq $p $protocol}}selected{{end}}{{end}}>{{$protocol}}
                                         </option>
@@ -484,7 +515,7 @@
                             <div class="form-group row">
                                 <label for="idLoginMethods" class="col-sm-2 col-form-label">Denied login methods</label>
                                 <div class="col-sm-10">
-                                    <select class="form-control" id="idLoginMethods" name="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}}
                                         <option value="{{$method}}" {{range $m :=$.User.Filters.DeniedLoginMethods }}{{if eq $m $method}}selected{{end}}{{end}}>{{$method}}
                                         </option>
@@ -499,7 +530,7 @@
                             <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" id="idTwoFactorProtocols" name="required_two_factor_protocols" multiple>
+                                    <select class="form-control selectpicker" id="idTwoFactorProtocols" name="required_two_factor_protocols" multiple>
                                         {{range $protocol := .TwoFactorProtocols}}
                                         <option value="{{$protocol}}" {{range $p :=$.User.Filters.TwoFactorAuthProtocols }}{{if eq $p $protocol}}selected{{end}}{{end}}>{{$protocol}}
                                         </option>
@@ -511,7 +542,7 @@
                             <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" id="idWebClient" name="web_client_options" multiple>
+                                    <select class="form-control selectpicker" id="idWebClient" name="web_client_options" multiple>
                                         {{range $option := .WebClientOptions}}
                                         <option value="{{$option}}" {{range $p :=$.User.Filters.WebClient }}{{if eq $p $option}}selected{{end}}{{end}}>{{$option}}
                                         </option>
@@ -852,7 +883,7 @@
                             <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" 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="CommonName" {{if eq .User.Filters.TLSUsername "CommonName" }}selected{{end}}>Common Name</option>
                                     </select>
@@ -862,6 +893,23 @@
                                 </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}}">
                                 <label for="idUID" class="col-sm-2 col-form-label">UID</label>
                                 <div class="col-sm-3">
@@ -887,23 +935,6 @@
                                 </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}}">
                                 <label for="idExtAuthCacheTime" class="col-sm-2 col-form-label">External auth cache time</label>
                                 <div class="col-sm-10">
@@ -950,8 +981,10 @@
 {{define "extra_js"}}
 <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/bootstrap-select/js/bootstrap-select.min.js"></script>
 <script type="text/javascript">
     $(document).ready(function () {
+        $.fn.selectpicker.Constructor.BootstrapVersion = '4';
         {{if .Error}}
         $('#accordionUser .collapse').removeAttr("data-parent").collapse('show');
         {{end}}
@@ -987,273 +1020,62 @@
         });
 
         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>
-                `);
-
-            {{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>
-                `);
-
-            {{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>
-                `);
-        });
-
-        $("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>
-                `);
-        });
-
-        $("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>
-                `);
-        });
-
-        $("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>
-                `);
-        });
-
-        $("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>
+{{template "fsjs"}}
+{{template "shared_user_group" .}}
 {{end}}

+ 6 - 5
util/util.go

@@ -71,15 +71,16 @@ func RemoveDuplicates(obj []string) []string {
 	if len(obj) == 0 {
 		return obj
 	}
-	result := make([]string, 0, len(obj))
 	seen := make(map[string]bool)
+	validIdx := 0
 	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

+ 5 - 0
vfs/folder.go

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

+ 2 - 2
webdavd/internal_test.go

@@ -1119,7 +1119,7 @@ func TestCachedUserWithFolders(t *testing.T) {
 	folder, err := dataprovider.GetFolderByName(folderName)
 	assert.NoError(t, err)
 	// 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)
 
 	_, isCached, _, loginMethod, err = server.authenticate(req, ipAddr)
@@ -1132,7 +1132,7 @@ func TestCachedUserWithFolders(t *testing.T) {
 	}
 	// changing the folder path should invalidate the cache
 	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)
 	_, isCached, _, loginMethod, err = server.authenticate(req, ipAddr)
 	assert.NoError(t, err)

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