From 504cd3efda0910693dc8d988af564d42ea611758 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Mon, 25 Apr 2022 15:49:11 +0200 Subject: [PATCH] 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 --- README.md | 1 + cmd/revertprovider.go | 1 - cmd/startsubsys.go | 5 + dataprovider/actions.go | 1 + dataprovider/admin.go | 9 +- dataprovider/bolt.go | 780 +++++++++++--- dataprovider/cacheduser.go | 16 +- dataprovider/dataprovider.go | 575 +++++++--- dataprovider/group.go | 228 ++++ dataprovider/memory.go | 409 +++++++- dataprovider/mysql.go | 156 ++- dataprovider/pgsql.go | 159 ++- dataprovider/sqlcommon.go | 800 +++++++++++++- dataprovider/sqlite.go | 168 ++- dataprovider/sqlqueries.go | 187 +++- dataprovider/user.go | 349 +++++-- docs/groups.md | 41 + ftpd/ftpd_test.go | 39 +- go.mod | 2 +- go.sum | 4 +- httpd/api_folder.go | 11 +- httpd/api_group.go | 134 +++ httpd/api_http_user.go | 18 +- httpd/api_maintenance.go | 61 +- httpd/api_metadata.go | 2 +- httpd/api_quota.go | 6 +- httpd/api_retention.go | 2 +- httpd/api_shares.go | 6 +- httpd/api_utils.go | 52 +- httpd/httpd.go | 7 + httpd/httpd_test.go | 984 +++++++++++++++++- httpd/internal_test.go | 25 + httpd/middleware.go | 2 +- httpd/server.go | 30 +- httpd/webadmin.go | 471 +++++++-- httpd/webclient.go | 22 +- httpdtest/httpdtest.go | 307 +++++- openapi/openapi.yaml | 297 +++++- service/service.go | 4 + sftpd/server.go | 2 +- sftpd/sftpd_test.go | 43 + .../css/bootstrap-select.min.css | 6 + .../js/bootstrap-select.min.js | 9 + templates/webadmin/base.html | 8 + templates/webadmin/folder.html | 9 +- templates/webadmin/fsconfig.html | 10 +- templates/webadmin/group.html | 754 ++++++++++++++ templates/webadmin/groups.html | 193 ++++ templates/webadmin/sharedcomponents.html | 217 ++++ templates/webadmin/user.html | 420 +++----- util/util.go | 11 +- vfs/folder.go | 5 + webdavd/internal_test.go | 4 +- 53 files changed, 6986 insertions(+), 1076 deletions(-) create mode 100644 dataprovider/group.go create mode 100644 docs/groups.md create mode 100644 httpd/api_group.go create mode 100644 static/vendor/bootstrap-select/css/bootstrap-select.min.css create mode 100644 static/vendor/bootstrap-select/js/bootstrap-select.min.js create mode 100644 templates/webadmin/group.html create mode 100644 templates/webadmin/groups.html create mode 100644 templates/webadmin/sharedcomponents.html diff --git a/README.md b/README.md index 22e35458..7433be43 100644 --- a/README.md +++ b/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). diff --git a/cmd/revertprovider.go b/cmd/revertprovider.go index 5d0a9ca9..e43c0782 100644 --- a/cmd/revertprovider.go +++ b/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) } diff --git a/cmd/startsubsys.go b/cmd/startsubsys.go index 7971f35a..21037899 100644 --- a/cmd/startsubsys.go +++ b/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) diff --git a/dataprovider/actions.go b/dataprovider/actions.go index 68a8c2fe..a14ea5dd 100644 --- a/dataprovider/actions.go +++ b/dataprovider/actions.go @@ -29,6 +29,7 @@ const ( const ( actionObjectUser = "user" + actionObjectGroup = "group" actionObjectAdmin = "admin" actionObjectAPIKey = "api_key" actionObjectShare = "share" diff --git a/dataprovider/admin.go b/dataprovider/admin.go index a6caff18..4bbc030b 100644 --- a/dataprovider/admin.go +++ b/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 diff --git a/dataprovider/bolt.go b/dataprovider/bolt.go index 15987dcb..cb5b12b6 100644 --- a/dataprovider/bolt.go +++ b/dataprovider/bolt.go @@ -20,18 +20,19 @@ import ( ) const ( - boltDatabaseVersion = 15 + boltDatabaseVersion = 17 ) var ( usersBucket = []byte("users") + groupsBucket = []byte("groups") foldersBucket = []byte("folders") adminsBucket = []byte("admins") apiKeysBucket = []byte("api_keys") sharesBucket = []byte("shares") dbVersionBucket = []byte("db_version") dbVersionKey = []byte("version") - boltBuckets = [][]byte{usersBucket, foldersBucket, adminsBucket, apiKeysBucket, + boltBuckets = [][]byte{usersBucket, groupsBucket, foldersBucket, adminsBucket, apiKeysBucket, sharesBucket, dbVersionBucket} ) @@ -133,7 +134,7 @@ func (p *BoltProvider) validateUserAndPubKey(username string, pubKey []byte, isS func (p *BoltProvider) updateAPIKeyLastUse(keyID string) error { return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getAPIKeysBucket(tx) + bucket, err := p.getAPIKeysBucket(tx) if err != nil { return err } @@ -163,7 +164,7 @@ func (p *BoltProvider) updateAPIKeyLastUse(keyID string) error { func (p *BoltProvider) setUpdatedAt(username string) { p.dbHandle.Update(func(tx *bolt.Tx) error { //nolint:errcheck - bucket, err := getUsersBucket(tx) + bucket, err := p.getUsersBucket(tx) if err != nil { return err } @@ -193,7 +194,7 @@ func (p *BoltProvider) setUpdatedAt(username string) { func (p *BoltProvider) updateLastLogin(username string) error { return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getUsersBucket(tx) + bucket, err := p.getUsersBucket(tx) if err != nil { return err } @@ -212,10 +213,10 @@ func (p *BoltProvider) updateLastLogin(username string) error { return err } err = bucket.Put([]byte(username), buf) - if err == nil { - providerLog(logger.LevelDebug, "last login updated for user %#v", username) - } else { + if err != nil { providerLog(logger.LevelWarn, "error updating last login for user %#v: %v", username, err) + } else { + providerLog(logger.LevelDebug, "last login updated for user %#v", username) } return err }) @@ -223,7 +224,7 @@ func (p *BoltProvider) updateLastLogin(username string) error { func (p *BoltProvider) updateAdminLastLogin(username string) error { return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getAdminsBucket(tx) + bucket, err := p.getAdminsBucket(tx) if err != nil { return err } @@ -253,7 +254,7 @@ func (p *BoltProvider) updateAdminLastLogin(username string) error { func (p *BoltProvider) updateTransferQuota(username string, uploadSize, downloadSize int64, reset bool) error { return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getUsersBucket(tx) + bucket, err := p.getUsersBucket(tx) if err != nil { return err } @@ -288,7 +289,7 @@ func (p *BoltProvider) updateTransferQuota(username string, uploadSize, download func (p *BoltProvider) updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error { return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getUsersBucket(tx) + bucket, err := p.getUsersBucket(tx) if err != nil { return err } @@ -333,7 +334,7 @@ func (p *BoltProvider) adminExists(username string) (Admin, error) { var admin Admin err := p.dbHandle.View(func(tx *bolt.Tx) error { - bucket, err := getAdminsBucket(tx) + bucket, err := p.getAdminsBucket(tx) if err != nil { return err } @@ -353,7 +354,7 @@ func (p *BoltProvider) addAdmin(admin *Admin) error { return err } return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getAdminsBucket(tx) + bucket, err := p.getAdminsBucket(tx) if err != nil { return err } @@ -382,7 +383,7 @@ func (p *BoltProvider) updateAdmin(admin *Admin) error { return err } return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getAdminsBucket(tx) + bucket, err := p.getAdminsBucket(tx) if err != nil { return err } @@ -409,9 +410,9 @@ func (p *BoltProvider) updateAdmin(admin *Admin) error { }) } -func (p *BoltProvider) deleteAdmin(admin *Admin) error { +func (p *BoltProvider) deleteAdmin(admin Admin) error { return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getAdminsBucket(tx) + bucket, err := p.getAdminsBucket(tx) if err != nil { return err } @@ -420,7 +421,7 @@ func (p *BoltProvider) deleteAdmin(admin *Admin) error { return util.NewRecordNotFoundError(fmt.Sprintf("admin %v does not exist", admin.Username)) } - if err := deleteRelatedAPIKey(tx, admin.Username, APIKeyScopeAdmin); err != nil { + if err := p.deleteRelatedAPIKey(tx, admin.Username, APIKeyScopeAdmin); err != nil { return err } @@ -432,7 +433,7 @@ func (p *BoltProvider) getAdmins(limit int, offset int, order string) ([]Admin, admins := make([]Admin, 0, limit) err := p.dbHandle.View(func(tx *bolt.Tx) error { - bucket, err := getAdminsBucket(tx) + bucket, err := p.getAdminsBucket(tx) if err != nil { return err } @@ -482,7 +483,7 @@ func (p *BoltProvider) getAdmins(limit int, offset int, order string) ([]Admin, func (p *BoltProvider) dumpAdmins() ([]Admin, error) { admins := make([]Admin, 0, 30) err := p.dbHandle.View(func(tx *bolt.Tx) error { - bucket, err := getAdminsBucket(tx) + bucket, err := p.getAdminsBucket(tx) if err != nil { return err } @@ -505,7 +506,7 @@ func (p *BoltProvider) dumpAdmins() ([]Admin, error) { func (p *BoltProvider) userExists(username string) (User, error) { var user User err := p.dbHandle.View(func(tx *bolt.Tx) error { - bucket, err := getUsersBucket(tx) + bucket, err := p.getUsersBucket(tx) if err != nil { return err } @@ -513,11 +514,11 @@ func (p *BoltProvider) userExists(username string) (User, error) { if u == nil { return util.NewRecordNotFoundError(fmt.Sprintf("username %#v does not exist", username)) } - folderBucket, err := getFoldersBucket(tx) + folderBucket, err := p.getFoldersBucket(tx) if err != nil { return err } - user, err = joinUserAndFolders(u, folderBucket) + user, err = p.joinUserAndFolders(u, folderBucket) return err }) return user, err @@ -529,11 +530,15 @@ func (p *BoltProvider) addUser(user *User) error { return err } return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getUsersBucket(tx) + bucket, err := p.getUsersBucket(tx) if err != nil { return err } - folderBucket, err := getFoldersBucket(tx) + folderBucket, err := p.getFoldersBucket(tx) + if err != nil { + return err + } + groupBucket, err := p.getGroupsBucket(tx) if err != nil { return err } @@ -554,7 +559,13 @@ func (p *BoltProvider) addUser(user *User) error { user.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) for idx := range user.VirtualFolders { - err = addUserToFolderMapping(&user.VirtualFolders[idx].BaseVirtualFolder, user, folderBucket) + err = p.addRelationToFolderMapping(&user.VirtualFolders[idx].BaseVirtualFolder, user, nil, folderBucket) + if err != nil { + return err + } + } + for idx := range user.Groups { + err = p.addUserToGroupMapping(user.Username, user.Groups[idx].Name, groupBucket) if err != nil { return err } @@ -573,11 +584,7 @@ func (p *BoltProvider) updateUser(user *User) error { return err } return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getUsersBucket(tx) - if err != nil { - return err - } - folderBucket, err := getFoldersBucket(tx) + bucket, err := p.getUsersBucket(tx) if err != nil { return err } @@ -590,17 +597,8 @@ func (p *BoltProvider) updateUser(user *User) error { if err != nil { return err } - for idx := range oldUser.VirtualFolders { - err = removeUserFromFolderMapping(&oldUser.VirtualFolders[idx], &oldUser, folderBucket) - if err != nil { - return err - } - } - for idx := range user.VirtualFolders { - err = addUserToFolderMapping(&user.VirtualFolders[idx].BaseVirtualFolder, user, folderBucket) - if err != nil { - return err - } + if err = p.updateUserRelations(tx, user, oldUser); err != nil { + return err } user.ID = oldUser.ID user.LastQuotaUpdate = oldUser.LastQuotaUpdate @@ -619,9 +617,9 @@ func (p *BoltProvider) updateUser(user *User) error { }) } -func (p *BoltProvider) deleteUser(user *User) error { +func (p *BoltProvider) deleteUser(user User) error { return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getUsersBucket(tx) + bucket, err := p.getUsersBucket(tx) if err != nil { return err } @@ -631,22 +629,33 @@ func (p *BoltProvider) deleteUser(user *User) error { } if len(user.VirtualFolders) > 0 { - folderBucket, err := getFoldersBucket(tx) + folderBucket, err := p.getFoldersBucket(tx) if err != nil { return err } for idx := range user.VirtualFolders { - err = removeUserFromFolderMapping(&user.VirtualFolders[idx], user, folderBucket) + err = p.removeRelationFromFolderMapping(user.VirtualFolders[idx], user.Username, "", folderBucket) if err != nil { return err } } } - - if err := deleteRelatedAPIKey(tx, user.Username, APIKeyScopeUser); err != nil { + if len(user.Groups) > 0 { + groupBucket, err := p.getGroupsBucket(tx) + if err != nil { + return err + } + for idx := range user.Groups { + err = p.removeUserFromGroupMapping(user.Username, user.Groups[idx].Name, groupBucket) + if err != nil { + return err + } + } + } + if err := p.deleteRelatedAPIKey(tx, user.Username, APIKeyScopeUser); err != nil { return err } - if err := deleteRelatedShares(tx, user.Username); err != nil { + if err := p.deleteRelatedShares(tx, user.Username); err != nil { return err } return bucket.Delete([]byte(user.Username)) @@ -655,7 +664,7 @@ func (p *BoltProvider) deleteUser(user *User) error { func (p *BoltProvider) updateUserPassword(username, password string) error { return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getUsersBucket(tx) + bucket, err := p.getUsersBucket(tx) if err != nil { return err } @@ -680,17 +689,17 @@ func (p *BoltProvider) updateUserPassword(username, password string) error { func (p *BoltProvider) dumpUsers() ([]User, error) { users := make([]User, 0, 100) err := p.dbHandle.View(func(tx *bolt.Tx) error { - bucket, err := getUsersBucket(tx) + bucket, err := p.getUsersBucket(tx) if err != nil { return err } - folderBucket, err := getFoldersBucket(tx) + folderBucket, err := p.getFoldersBucket(tx) if err != nil { return err } cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - user, err := joinUserAndFolders(v, folderBucket) + user, err := p.joinUserAndFolders(v, folderBucket) if err != nil { return err } @@ -714,11 +723,15 @@ func (p *BoltProvider) getUsersForQuotaCheck(toFetch map[string]bool) ([]User, e users := make([]User, 0, 30) err := p.dbHandle.View(func(tx *bolt.Tx) error { - bucket, err := getUsersBucket(tx) + bucket, err := p.getUsersBucket(tx) if err != nil { return err } - foldersBucket, err := getFoldersBucket(tx) + foldersBucket, err := p.getFoldersBucket(tx) + if err != nil { + return err + } + groupsBucket, err := p.getGroupsBucket(tx) if err != nil { return err } @@ -733,11 +746,22 @@ func (p *BoltProvider) getUsersForQuotaCheck(toFetch map[string]bool) ([]User, e if !ok { continue } + if len(user.Groups) > 0 { + groupMapping := make(map[string]Group) + for idx := range user.Groups { + group, err := p.groupExistsInternal(user.Groups[idx].Name, groupsBucket) + if err != nil { + continue + } + groupMapping[group.Name] = group + } + user.applyGroupSettings(groupMapping) + } if needFolders && len(user.VirtualFolders) > 0 { var folders []vfs.VirtualFolder for idx := range user.VirtualFolders { folder := &user.VirtualFolders[idx] - baseFolder, err := folderExistsInternal(folder.Name, foldersBucket) + baseFolder, err := p.folderExistsInternal(folder.Name, foldersBucket) if err != nil { continue } @@ -764,11 +788,11 @@ func (p *BoltProvider) getUsers(limit int, offset int, order string) ([]User, er return users, err } err = p.dbHandle.View(func(tx *bolt.Tx) error { - bucket, err := getUsersBucket(tx) + bucket, err := p.getUsersBucket(tx) if err != nil { return err } - folderBucket, err := getFoldersBucket(tx) + folderBucket, err := p.getFoldersBucket(tx) if err != nil { return err } @@ -780,11 +804,12 @@ func (p *BoltProvider) getUsers(limit int, offset int, order string) ([]User, er if itNum <= offset { continue } - user, err := joinUserAndFolders(v, folderBucket) - if err == nil { - user.PrepareForRendering() - users = append(users, user) + user, err := p.joinUserAndFolders(v, folderBucket) + if err != nil { + return err } + user.PrepareForRendering() + users = append(users, user) if len(users) >= limit { break } @@ -795,11 +820,12 @@ func (p *BoltProvider) getUsers(limit int, offset int, order string) ([]User, er if itNum <= offset { continue } - user, err := joinUserAndFolders(v, folderBucket) - if err == nil { - user.PrepareForRendering() - users = append(users, user) + user, err := p.joinUserAndFolders(v, folderBucket) + if err != nil { + return err } + user.PrepareForRendering() + users = append(users, user) if len(users) >= limit { break } @@ -813,7 +839,7 @@ func (p *BoltProvider) getUsers(limit int, offset int, order string) ([]User, er func (p *BoltProvider) dumpFolders() ([]vfs.BaseVirtualFolder, error) { folders := make([]vfs.BaseVirtualFolder, 0, 50) err := p.dbHandle.View(func(tx *bolt.Tx) error { - bucket, err := getFoldersBucket(tx) + bucket, err := p.getFoldersBucket(tx) if err != nil { return err } @@ -831,14 +857,14 @@ func (p *BoltProvider) dumpFolders() ([]vfs.BaseVirtualFolder, error) { return folders, err } -func (p *BoltProvider) getFolders(limit, offset int, order string) ([]vfs.BaseVirtualFolder, error) { +func (p *BoltProvider) getFolders(limit, offset int, order string, minimal bool) ([]vfs.BaseVirtualFolder, error) { folders := make([]vfs.BaseVirtualFolder, 0, limit) var err error if limit <= 0 { return folders, err } err = p.dbHandle.View(func(tx *bolt.Tx) error { - bucket, err := getFoldersBucket(tx) + bucket, err := p.getFoldersBucket(tx) if err != nil { return err } @@ -887,11 +913,11 @@ func (p *BoltProvider) getFolders(limit, offset int, order string) ([]vfs.BaseVi func (p *BoltProvider) getFolderByName(name string) (vfs.BaseVirtualFolder, error) { var folder vfs.BaseVirtualFolder err := p.dbHandle.View(func(tx *bolt.Tx) error { - bucket, err := getFoldersBucket(tx) + bucket, err := p.getFoldersBucket(tx) if err != nil { return err } - folder, err = folderExistsInternal(name, bucket) + folder, err = p.folderExistsInternal(name, bucket) return err }) return folder, err @@ -903,7 +929,7 @@ func (p *BoltProvider) addFolder(folder *vfs.BaseVirtualFolder) error { return err } return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getFoldersBucket(tx) + bucket, err := p.getFoldersBucket(tx) if err != nil { return err } @@ -911,7 +937,7 @@ func (p *BoltProvider) addFolder(folder *vfs.BaseVirtualFolder) error { return fmt.Errorf("folder %v already exists", folder.Name) } folder.Users = nil - return addFolderInternal(*folder, bucket) + return p.addFolderInternal(*folder, bucket) }) } @@ -921,7 +947,7 @@ func (p *BoltProvider) updateFolder(folder *vfs.BaseVirtualFolder) error { return err } return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getFoldersBucket(tx) + bucket, err := p.getFoldersBucket(tx) if err != nil { return err } @@ -949,16 +975,79 @@ func (p *BoltProvider) updateFolder(folder *vfs.BaseVirtualFolder) error { }) } -func (p *BoltProvider) deleteFolder(folder *vfs.BaseVirtualFolder) error { +func (p *BoltProvider) deleteFolderMappings(tx *bolt.Tx, folder vfs.BaseVirtualFolder, usersBucket, + groupsBucket *bolt.Bucket, +) error { + for _, username := range folder.Users { + var u []byte + if u = usersBucket.Get([]byte(username)); u == nil { + continue + } + var user User + err := json.Unmarshal(u, &user) + if err != nil { + return err + } + var folders []vfs.VirtualFolder + for _, userFolder := range user.VirtualFolders { + if folder.Name != userFolder.Name { + folders = append(folders, userFolder) + } + } + user.VirtualFolders = folders + buf, err := json.Marshal(user) + if err != nil { + return err + } + err = usersBucket.Put([]byte(user.Username), buf) + if err != nil { + return err + } + } + for _, groupname := range folder.Groups { + var u []byte + if u = groupsBucket.Get([]byte(groupname)); u == nil { + continue + } + var group Group + err := json.Unmarshal(u, &group) + if err != nil { + return err + } + var folders []vfs.VirtualFolder + for _, groupFolder := range group.VirtualFolders { + if folder.Name != groupFolder.Name { + folders = append(folders, groupFolder) + } + } + group.VirtualFolders = folders + buf, err := json.Marshal(group) + if err != nil { + return err + } + err = groupsBucket.Put([]byte(group.Name), buf) + if err != nil { + return err + } + } + return nil +} + +func (p *BoltProvider) deleteFolder(folder vfs.BaseVirtualFolder) error { return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getFoldersBucket(tx) + bucket, err := p.getFoldersBucket(tx) if err != nil { return err } - usersBucket, err := getUsersBucket(tx) + usersBucket, err := p.getUsersBucket(tx) if err != nil { return err } + groupsBucket, err := p.getGroupsBucket(tx) + if err != nil { + return err + } + var f []byte if f = bucket.Get([]byte(folder.Name)); f == nil { return util.NewRecordNotFoundError(fmt.Sprintf("folder %v does not exist", folder.Name)) @@ -968,31 +1057,8 @@ func (p *BoltProvider) deleteFolder(folder *vfs.BaseVirtualFolder) error { if err != nil { return err } - for _, username := range folder.Users { - var u []byte - if u = usersBucket.Get([]byte(username)); u == nil { - continue - } - var user User - err = json.Unmarshal(u, &user) - if err != nil { - return err - } - var folders []vfs.VirtualFolder - for _, userFolder := range user.VirtualFolders { - if folder.Name != userFolder.Name { - folders = append(folders, userFolder) - } - } - user.VirtualFolders = folders - buf, err := json.Marshal(user) - if err != nil { - return err - } - err = usersBucket.Put([]byte(user.Username), buf) - if err != nil { - return err - } + if err = p.deleteFolderMappings(tx, folder, usersBucket, groupsBucket); err != nil { + return err } return bucket.Delete([]byte(folder.Name)) @@ -1001,7 +1067,7 @@ func (p *BoltProvider) deleteFolder(folder *vfs.BaseVirtualFolder) error { func (p *BoltProvider) updateFolderQuota(name string, filesAdd int, sizeAdd int64, reset bool) error { return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getFoldersBucket(tx) + bucket, err := p.getFoldersBucket(tx) if err != nil { return err } @@ -1039,10 +1105,278 @@ func (p *BoltProvider) getUsedFolderQuota(name string) (int, int64, error) { return folder.UsedQuotaFiles, folder.UsedQuotaSize, err } +func (p *BoltProvider) getGroups(limit, offset int, order string, minimal bool) ([]Group, error) { + groups := make([]Group, 0, limit) + var err error + if limit <= 0 { + return groups, err + } + err = p.dbHandle.View(func(tx *bolt.Tx) error { + bucket, err := p.getGroupsBucket(tx) + if err != nil { + return err + } + folderBucket, err := p.getFoldersBucket(tx) + if err != nil { + return err + } + cursor := bucket.Cursor() + itNum := 0 + if order == OrderASC { + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + itNum++ + if itNum <= offset { + continue + } + var group Group + group, err = p.joinGroupAndFolders(v, folderBucket) + if err != nil { + return err + } + group.PrepareForRendering() + groups = append(groups, group) + if len(groups) >= limit { + break + } + } + } else { + for k, v := cursor.Last(); k != nil; k, v = cursor.Prev() { + itNum++ + if itNum <= offset { + continue + } + var group Group + group, err = p.joinGroupAndFolders(v, folderBucket) + if err != nil { + return err + } + group.PrepareForRendering() + groups = append(groups, group) + if len(groups) >= limit { + break + } + } + } + return err + }) + return groups, err +} + +func (p *BoltProvider) getGroupsWithNames(names []string) ([]Group, error) { + var groups []Group + err := p.dbHandle.View(func(tx *bolt.Tx) error { + bucket, err := p.getGroupsBucket(tx) + if err != nil { + return err + } + folderBucket, err := p.getFoldersBucket(tx) + if err != nil { + return err + } + for _, name := range names { + g := bucket.Get([]byte(name)) + if g == nil { + continue + } + group, err := p.joinGroupAndFolders(g, folderBucket) + if err != nil { + return err + } + groups = append(groups, group) + } + return nil + }) + return groups, err +} + +func (p *BoltProvider) getUsersInGroups(names []string) ([]string, error) { + var usernames []string + err := p.dbHandle.View(func(tx *bolt.Tx) error { + bucket, err := p.getGroupsBucket(tx) + if err != nil { + return err + } + for _, name := range names { + g := bucket.Get([]byte(name)) + if g == nil { + continue + } + var group Group + err := json.Unmarshal(g, &group) + if err != nil { + return err + } + usernames = append(usernames, group.Users...) + } + return nil + }) + return usernames, err +} + +func (p *BoltProvider) groupExists(name string) (Group, error) { + var group Group + err := p.dbHandle.View(func(tx *bolt.Tx) error { + bucket, err := p.getGroupsBucket(tx) + if err != nil { + return err + } + g := bucket.Get([]byte(name)) + if g == nil { + return util.NewRecordNotFoundError(fmt.Sprintf("group %#v does not exist", name)) + } + folderBucket, err := p.getFoldersBucket(tx) + if err != nil { + return err + } + group, err = p.joinGroupAndFolders(g, folderBucket) + return err + }) + return group, err +} + +func (p *BoltProvider) addGroup(group *Group) error { + if err := group.validate(); err != nil { + return err + } + return p.dbHandle.Update(func(tx *bolt.Tx) error { + bucket, err := p.getGroupsBucket(tx) + if err != nil { + return err + } + folderBucket, err := p.getFoldersBucket(tx) + if err != nil { + return err + } + if u := bucket.Get([]byte(group.Name)); u != nil { + return fmt.Errorf("group %v already exists", group.Name) + } + id, err := bucket.NextSequence() + if err != nil { + return err + } + group.ID = int64(id) + group.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) + group.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) + for idx := range group.VirtualFolders { + err = p.addRelationToFolderMapping(&group.VirtualFolders[idx].BaseVirtualFolder, nil, group, folderBucket) + if err != nil { + return err + } + } + buf, err := json.Marshal(group) + if err != nil { + return err + } + return bucket.Put([]byte(group.Name), buf) + }) +} + +func (p *BoltProvider) updateGroup(group *Group) error { + if err := group.validate(); err != nil { + return err + } + return p.dbHandle.Update(func(tx *bolt.Tx) error { + bucket, err := p.getGroupsBucket(tx) + if err != nil { + return err + } + folderBucket, err := p.getFoldersBucket(tx) + if err != nil { + return err + } + var g []byte + if g = bucket.Get([]byte(group.Name)); g == nil { + return util.NewRecordNotFoundError(fmt.Sprintf("group %#v does not exist", group.Name)) + } + var oldGroup Group + err = json.Unmarshal(g, &oldGroup) + if err != nil { + return err + } + for idx := range oldGroup.VirtualFolders { + err = p.removeRelationFromFolderMapping(oldGroup.VirtualFolders[idx], "", oldGroup.Name, folderBucket) + if err != nil { + return err + } + } + for idx := range group.VirtualFolders { + err = p.addRelationToFolderMapping(&group.VirtualFolders[idx].BaseVirtualFolder, nil, group, folderBucket) + if err != nil { + return err + } + } + group.ID = oldGroup.ID + group.CreatedAt = oldGroup.CreatedAt + group.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) + buf, err := json.Marshal(group) + if err != nil { + return err + } + return bucket.Put([]byte(group.Name), buf) + }) +} + +func (p *BoltProvider) deleteGroup(group Group) error { + return p.dbHandle.Update(func(tx *bolt.Tx) error { + bucket, err := p.getGroupsBucket(tx) + if err != nil { + return err + } + var g []byte + if g = bucket.Get([]byte(group.Name)); g == nil { + return util.NewRecordNotFoundError(fmt.Sprintf("group %#v does not exist", group.Name)) + } + var oldGroup Group + err = json.Unmarshal(g, &oldGroup) + if err != nil { + return err + } + if len(oldGroup.Users) > 0 { + return util.NewValidationError(fmt.Sprintf("the group %#v is referenced, it cannot be removed", group.Name)) + } + folderBucket, err := p.getFoldersBucket(tx) + if err != nil { + return err + } + for idx := range group.VirtualFolders { + err = p.removeRelationFromFolderMapping(group.VirtualFolders[idx], "", group.Name, folderBucket) + if err != nil { + return err + } + } + + return bucket.Delete([]byte(group.Name)) + }) +} + +func (p *BoltProvider) dumpGroups() ([]Group, error) { + groups := make([]Group, 0, 50) + err := p.dbHandle.View(func(tx *bolt.Tx) error { + bucket, err := p.getGroupsBucket(tx) + if err != nil { + return err + } + folderBucket, err := p.getFoldersBucket(tx) + if err != nil { + return err + } + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + group, err := p.joinGroupAndFolders(v, folderBucket) + if err != nil { + return err + } + groups = append(groups, group) + } + return err + }) + return groups, err +} + func (p *BoltProvider) apiKeyExists(keyID string) (APIKey, error) { var apiKey APIKey err := p.dbHandle.View(func(tx *bolt.Tx) error { - bucket, err := getAPIKeysBucket(tx) + bucket, err := p.getAPIKeysBucket(tx) if err != nil { return err } @@ -1062,7 +1396,7 @@ func (p *BoltProvider) addAPIKey(apiKey *APIKey) error { return err } return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getAPIKeysBucket(tx) + bucket, err := p.getAPIKeysBucket(tx) if err != nil { return err } @@ -1101,7 +1435,7 @@ func (p *BoltProvider) updateAPIKey(apiKey *APIKey) error { return err } return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getAPIKeysBucket(tx) + bucket, err := p.getAPIKeysBucket(tx) if err != nil { return err } @@ -1140,9 +1474,9 @@ func (p *BoltProvider) updateAPIKey(apiKey *APIKey) error { }) } -func (p *BoltProvider) deleteAPIKey(apiKey *APIKey) error { +func (p *BoltProvider) deleteAPIKey(apiKey APIKey) error { return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getAPIKeysBucket(tx) + bucket, err := p.getAPIKeysBucket(tx) if err != nil { return err } @@ -1159,7 +1493,7 @@ func (p *BoltProvider) getAPIKeys(limit int, offset int, order string) ([]APIKey apiKeys := make([]APIKey, 0, limit) err := p.dbHandle.View(func(tx *bolt.Tx) error { - bucket, err := getAPIKeysBucket(tx) + bucket, err := p.getAPIKeysBucket(tx) if err != nil { return err } @@ -1209,7 +1543,7 @@ func (p *BoltProvider) getAPIKeys(limit int, offset int, order string) ([]APIKey func (p *BoltProvider) dumpAPIKeys() ([]APIKey, error) { apiKeys := make([]APIKey, 0, 30) err := p.dbHandle.View(func(tx *bolt.Tx) error { - bucket, err := getAPIKeysBucket(tx) + bucket, err := p.getAPIKeysBucket(tx) if err != nil { return err } @@ -1232,7 +1566,7 @@ func (p *BoltProvider) dumpAPIKeys() ([]APIKey, error) { func (p *BoltProvider) shareExists(shareID, username string) (Share, error) { var share Share err := p.dbHandle.View(func(tx *bolt.Tx) error { - bucket, err := getSharesBucket(tx) + bucket, err := p.getSharesBucket(tx) if err != nil { return err } @@ -1258,7 +1592,7 @@ func (p *BoltProvider) addShare(share *Share) error { return err } return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getSharesBucket(tx) + bucket, err := p.getSharesBucket(tx) if err != nil { return err } @@ -1299,7 +1633,7 @@ func (p *BoltProvider) updateShare(share *Share) error { } return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getSharesBucket(tx) + bucket, err := p.getSharesBucket(tx) if err != nil { return err } @@ -1341,9 +1675,9 @@ func (p *BoltProvider) updateShare(share *Share) error { }) } -func (p *BoltProvider) deleteShare(share *Share) error { +func (p *BoltProvider) deleteShare(share Share) error { return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getSharesBucket(tx) + bucket, err := p.getSharesBucket(tx) if err != nil { return err } @@ -1369,7 +1703,7 @@ func (p *BoltProvider) getShares(limit int, offset int, order, username string) shares := make([]Share, 0, limit) err := p.dbHandle.View(func(tx *bolt.Tx) error { - bucket, err := getSharesBucket(tx) + bucket, err := p.getSharesBucket(tx) if err != nil { return err } @@ -1424,7 +1758,7 @@ func (p *BoltProvider) getShares(limit int, offset int, order, username string) func (p *BoltProvider) dumpShares() ([]Share, error) { shares := make([]Share, 0, 30) err := p.dbHandle.View(func(tx *bolt.Tx) error { - bucket, err := getSharesBucket(tx) + bucket, err := p.getSharesBucket(tx) if err != nil { return err } @@ -1446,7 +1780,7 @@ func (p *BoltProvider) dumpShares() ([]Share, error) { func (p *BoltProvider) updateShareLastUse(shareID string, numTokens int) error { return p.dbHandle.Update(func(tx *bolt.Tx) error { - bucket, err := getSharesBucket(tx) + bucket, err := p.getSharesBucket(tx) if err != nil { return err } @@ -1554,8 +1888,8 @@ func (p *BoltProvider) migrateDatabase() error { providerLog(logger.LevelError, "%v", err) logger.ErrorToConsole("%v", err) return err - case version == 15: - return updateBoltDatabaseVersion(p.dbHandle, 16) + case version == 15, version == 16: + return updateBoltDatabaseVersion(p.dbHandle, 17) default: if version > boltDatabaseVersion { providerLog(logger.LevelError, "database version %v is newer than the supported one: %v", version, @@ -1577,7 +1911,7 @@ func (p *BoltProvider) revertDatabase(targetVersion int) error { return errors.New("current version match target version, nothing to do") } switch dbVersion.Version { - case 16: + case 16, 17: return updateBoltDatabaseVersion(p.dbHandle, 15) default: return fmt.Errorf("database version not handled: %v", dbVersion.Version) @@ -1596,7 +1930,30 @@ func (p *BoltProvider) resetDatabase() error { }) } -func joinUserAndFolders(u []byte, foldersBucket *bolt.Bucket) (User, error) { +func (p *BoltProvider) joinGroupAndFolders(g []byte, foldersBucket *bolt.Bucket) (Group, error) { + var group Group + err := json.Unmarshal(g, &group) + if err != nil { + return group, err + } + if len(group.VirtualFolders) > 0 { + var folders []vfs.VirtualFolder + for idx := range group.VirtualFolders { + folder := &group.VirtualFolders[idx] + baseFolder, err := p.folderExistsInternal(folder.Name, foldersBucket) + if err != nil { + continue + } + folder.BaseVirtualFolder = baseFolder + folders = append(folders, *folder) + } + group.VirtualFolders = folders + } + group.SetEmptySecretsIfNil() + return group, err +} + +func (p *BoltProvider) joinUserAndFolders(u []byte, foldersBucket *bolt.Bucket) (User, error) { var user User err := json.Unmarshal(u, &user) if err != nil { @@ -1606,7 +1963,7 @@ func joinUserAndFolders(u []byte, foldersBucket *bolt.Bucket) (User, error) { var folders []vfs.VirtualFolder for idx := range user.VirtualFolders { folder := &user.VirtualFolders[idx] - baseFolder, err := folderExistsInternal(folder.Name, foldersBucket) + baseFolder, err := p.folderExistsInternal(folder.Name, foldersBucket) if err != nil { continue } @@ -1619,18 +1976,29 @@ func joinUserAndFolders(u []byte, foldersBucket *bolt.Bucket) (User, error) { return user, err } -func folderExistsInternal(name string, bucket *bolt.Bucket) (vfs.BaseVirtualFolder, error) { +func (p *BoltProvider) groupExistsInternal(name string, bucket *bolt.Bucket) (Group, error) { + var group Group + g := bucket.Get([]byte(name)) + if g == nil { + err := util.NewRecordNotFoundError(fmt.Sprintf("group %#v does not exist", name)) + return group, err + } + err := json.Unmarshal(g, &group) + return group, err +} + +func (p *BoltProvider) folderExistsInternal(name string, bucket *bolt.Bucket) (vfs.BaseVirtualFolder, error) { var folder vfs.BaseVirtualFolder f := bucket.Get([]byte(name)) if f == nil { - err := util.NewRecordNotFoundError(fmt.Sprintf("folder %v does not exist", name)) + err := util.NewRecordNotFoundError(fmt.Sprintf("folder %#v does not exist", name)) return folder, err } err := json.Unmarshal(f, &folder) return folder, err } -func addFolderInternal(folder vfs.BaseVirtualFolder, bucket *bolt.Bucket) error { +func (p *BoltProvider) addFolderInternal(folder vfs.BaseVirtualFolder, bucket *bolt.Bucket) error { id, err := bucket.NextSequence() if err != nil { return err @@ -1643,15 +2011,68 @@ func addFolderInternal(folder vfs.BaseVirtualFolder, bucket *bolt.Bucket) error return bucket.Put([]byte(folder.Name), buf) } -func addUserToFolderMapping(baseFolder *vfs.BaseVirtualFolder, user *User, bucket *bolt.Bucket) error { +func (p *BoltProvider) addUserToGroupMapping(username, groupname string, bucket *bolt.Bucket) error { + g := bucket.Get([]byte(groupname)) + if g == nil { + return util.NewRecordNotFoundError(fmt.Sprintf("group %#v does not exist", groupname)) + } + var group Group + err := json.Unmarshal(g, &group) + if err != nil { + return err + } + if !util.IsStringInSlice(username, group.Users) { + group.Users = append(group.Users, username) + buf, err := json.Marshal(group) + if err != nil { + return err + } + return bucket.Put([]byte(group.Name), buf) + } + return nil +} + +func (p *BoltProvider) removeUserFromGroupMapping(username, groupname string, bucket *bolt.Bucket) error { + g := bucket.Get([]byte(groupname)) + if g == nil { + return util.NewRecordNotFoundError(fmt.Sprintf("group %#v does not exist", groupname)) + } + var group Group + err := json.Unmarshal(g, &group) + if err != nil { + return err + } + if util.IsStringInSlice(username, group.Users) { + var users []string + for _, u := range group.Users { + if u != username { + users = append(users, u) + } + } + group.Users = users + buf, err := json.Marshal(group) + if err != nil { + return err + } + return bucket.Put([]byte(group.Name), buf) + } + return nil +} + +func (p *BoltProvider) addRelationToFolderMapping(baseFolder *vfs.BaseVirtualFolder, user *User, group *Group, bucket *bolt.Bucket) error { f := bucket.Get([]byte(baseFolder.Name)) if f == nil { // folder does not exists, try to create baseFolder.LastQuotaUpdate = 0 baseFolder.UsedQuotaFiles = 0 baseFolder.UsedQuotaSize = 0 - baseFolder.Users = []string{user.Username} - return addFolderInternal(*baseFolder, bucket) + if user != nil { + baseFolder.Users = []string{user.Username} + } + if group != nil { + baseFolder.Groups = []string{group.Name} + } + return p.addFolderInternal(*baseFolder, bucket) } var oldFolder vfs.BaseVirtualFolder err := json.Unmarshal(f, &oldFolder) @@ -1663,9 +2084,13 @@ func addUserToFolderMapping(baseFolder *vfs.BaseVirtualFolder, user *User, bucke baseFolder.UsedQuotaFiles = oldFolder.UsedQuotaFiles baseFolder.UsedQuotaSize = oldFolder.UsedQuotaSize baseFolder.Users = oldFolder.Users - if !util.IsStringInSlice(user.Username, baseFolder.Users) { + baseFolder.Groups = oldFolder.Groups + if user != nil && !util.IsStringInSlice(user.Username, baseFolder.Users) { baseFolder.Users = append(baseFolder.Users, user.Username) } + if group != nil && !util.IsStringInSlice(group.Name, baseFolder.Groups) { + baseFolder.Groups = append(baseFolder.Groups, group.Name) + } buf, err := json.Marshal(baseFolder) if err != nil { return err @@ -1673,10 +2098,12 @@ func addUserToFolderMapping(baseFolder *vfs.BaseVirtualFolder, user *User, bucke return bucket.Put([]byte(baseFolder.Name), buf) } -func removeUserFromFolderMapping(folder *vfs.VirtualFolder, user *User, bucket *bolt.Bucket) error { +func (p *BoltProvider) removeRelationFromFolderMapping(folder vfs.VirtualFolder, username, groupname string, + bucket *bolt.Bucket, +) error { var f []byte if f = bucket.Get([]byte(folder.Name)); f == nil { - // the folder does not exists so there is no associated user + // the folder does not exist so there is no associated user/group return nil } var baseFolder vfs.BaseVirtualFolder @@ -1684,25 +2111,75 @@ func removeUserFromFolderMapping(folder *vfs.VirtualFolder, user *User, bucket * if err != nil { return err } - if util.IsStringInSlice(user.Username, baseFolder.Users) { + found := false + if username != "" && util.IsStringInSlice(username, baseFolder.Users) { + found = true var newUserMapping []string for _, u := range baseFolder.Users { - if u != user.Username { + if u != username { newUserMapping = append(newUserMapping, u) } } baseFolder.Users = newUserMapping - buf, err := json.Marshal(baseFolder) + } + if groupname != "" && util.IsStringInSlice(groupname, baseFolder.Groups) { + found = true + var newGroupMapping []string + for _, g := range baseFolder.Groups { + if g != groupname { + newGroupMapping = append(newGroupMapping, g) + } + } + baseFolder.Groups = newGroupMapping + } + if !found { + return nil + } + buf, err := json.Marshal(baseFolder) + if err != nil { + return err + } + return bucket.Put([]byte(folder.Name), buf) +} + +func (p *BoltProvider) updateUserRelations(tx *bolt.Tx, user *User, oldUser User) error { + folderBucket, err := p.getFoldersBucket(tx) + if err != nil { + return err + } + groupBucket, err := p.getGroupsBucket(tx) + if err != nil { + return err + } + for idx := range oldUser.VirtualFolders { + err = p.removeRelationFromFolderMapping(oldUser.VirtualFolders[idx], oldUser.Username, "", folderBucket) if err != nil { return err } - return bucket.Put([]byte(folder.Name), buf) } - return err + for idx := range oldUser.Groups { + err = p.removeUserFromGroupMapping(user.Username, oldUser.Groups[idx].Name, groupBucket) + if err != nil { + return err + } + } + for idx := range user.VirtualFolders { + err = p.addRelationToFolderMapping(&user.VirtualFolders[idx].BaseVirtualFolder, user, nil, folderBucket) + if err != nil { + return err + } + } + for idx := range user.Groups { + err = p.addUserToGroupMapping(user.Username, user.Groups[idx].Name, groupBucket) + if err != nil { + return err + } + } + return nil } func (p *BoltProvider) adminExistsInternal(tx *bolt.Tx, username string) error { - bucket, err := getAdminsBucket(tx) + bucket, err := p.getAdminsBucket(tx) if err != nil { return err } @@ -1714,7 +2191,7 @@ func (p *BoltProvider) adminExistsInternal(tx *bolt.Tx, username string) error { } func (p *BoltProvider) userExistsInternal(tx *bolt.Tx, username string) error { - bucket, err := getUsersBucket(tx) + bucket, err := p.getUsersBucket(tx) if err != nil { return err } @@ -1725,8 +2202,8 @@ func (p *BoltProvider) userExistsInternal(tx *bolt.Tx, username string) error { return nil } -func deleteRelatedShares(tx *bolt.Tx, username string) error { - bucket, err := getSharesBucket(tx) +func (p *BoltProvider) deleteRelatedShares(tx *bolt.Tx, username string) error { + bucket, err := p.getSharesBucket(tx) if err != nil { return err } @@ -1752,8 +2229,8 @@ func deleteRelatedShares(tx *bolt.Tx, username string) error { return nil } -func deleteRelatedAPIKey(tx *bolt.Tx, username string, scope APIKeyScope) error { - bucket, err := getAPIKeysBucket(tx) +func (p *BoltProvider) deleteRelatedAPIKey(tx *bolt.Tx, username string, scope APIKeyScope) error { + bucket, err := p.getAPIKeysBucket(tx) if err != nil { return err } @@ -1785,7 +2262,7 @@ func deleteRelatedAPIKey(tx *bolt.Tx, username string, scope APIKeyScope) error return nil } -func getSharesBucket(tx *bolt.Tx) (*bolt.Bucket, error) { +func (p *BoltProvider) getSharesBucket(tx *bolt.Tx) (*bolt.Bucket, error) { var err error bucket := tx.Bucket(sharesBucket) @@ -1795,7 +2272,7 @@ func getSharesBucket(tx *bolt.Tx) (*bolt.Bucket, error) { return bucket, err } -func getAPIKeysBucket(tx *bolt.Tx) (*bolt.Bucket, error) { +func (p *BoltProvider) getAPIKeysBucket(tx *bolt.Tx) (*bolt.Bucket, error) { var err error bucket := tx.Bucket(apiKeysBucket) @@ -1805,7 +2282,7 @@ func getAPIKeysBucket(tx *bolt.Tx) (*bolt.Bucket, error) { return bucket, err } -func getAdminsBucket(tx *bolt.Tx) (*bolt.Bucket, error) { +func (p *BoltProvider) getAdminsBucket(tx *bolt.Tx) (*bolt.Bucket, error) { var err error bucket := tx.Bucket(adminsBucket) @@ -1815,7 +2292,7 @@ func getAdminsBucket(tx *bolt.Tx) (*bolt.Bucket, error) { return bucket, err } -func getUsersBucket(tx *bolt.Tx) (*bolt.Bucket, error) { +func (p *BoltProvider) getUsersBucket(tx *bolt.Tx) (*bolt.Bucket, error) { var err error bucket := tx.Bucket(usersBucket) if bucket == nil { @@ -1824,7 +2301,16 @@ func getUsersBucket(tx *bolt.Tx) (*bolt.Bucket, error) { return bucket, err } -func getFoldersBucket(tx *bolt.Tx) (*bolt.Bucket, error) { +func (p *BoltProvider) getGroupsBucket(tx *bolt.Tx) (*bolt.Bucket, error) { + var err error + bucket := tx.Bucket(groupsBucket) + if bucket == nil { + err = fmt.Errorf("unable to find groups buckets, bolt database structure not correcly defined") + } + return bucket, err +} + +func (p *BoltProvider) getFoldersBucket(tx *bolt.Tx) (*bolt.Bucket, error) { var err error bucket := tx.Bucket(foldersBucket) if bucket == nil { diff --git a/dataprovider/cacheduser.go b/dataprovider/cacheduser.go index c5bf4877..eb084e86 100644 --- a/dataprovider/cacheduser.go +++ b/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 diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index 8cdb18d7..2c628fbb 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -71,7 +71,7 @@ const ( CockroachDataProviderName = "cockroachdb" // DumpVersion defines the version for the dump. // For restore/load we support the current version and the previous one - DumpVersion = 11 + DumpVersion = 12 argonPwdPrefix = "$argon2id$" bcryptPwdPrefix = "$2a$" @@ -148,26 +148,30 @@ var ( hashPwdPrefixes = []string{argonPwdPrefix, bcryptPwdPrefix, pbkdf2SHA1Prefix, pbkdf2SHA256Prefix, pbkdf2SHA512Prefix, pbkdf2SHA256B64SaltPrefix, md5cryptPwdPrefix, md5cryptApr1PwdPrefix, md5LDAPPwdPrefix, sha512cryptPwdPrefix} - pbkdfPwdPrefixes = []string{pbkdf2SHA1Prefix, pbkdf2SHA256Prefix, pbkdf2SHA512Prefix, pbkdf2SHA256B64SaltPrefix} - pbkdfPwdB64SaltPrefixes = []string{pbkdf2SHA256B64SaltPrefix} - unixPwdPrefixes = []string{md5cryptPwdPrefix, md5cryptApr1PwdPrefix, sha512cryptPwdPrefix} - sharedProviders = []string{PGSQLDataProviderName, MySQLDataProviderName, CockroachDataProviderName} - logSender = "dataprovider" - credentialsDirPath string - sqlTableUsers = "users" - sqlTableFolders = "folders" - sqlTableFoldersMapping = "folders_mapping" - sqlTableAdmins = "admins" - sqlTableAPIKeys = "api_keys" - sqlTableShares = "shares" - sqlTableDefenderHosts = "defender_hosts" - sqlTableDefenderEvents = "defender_events" - sqlTableActiveTransfers = "active_transfers" - sqlTableSchemaVersion = "schema_version" - argon2Params *argon2id.Params - lastLoginMinDelay = 10 * time.Minute - usernameRegex = regexp.MustCompile("^[a-zA-Z0-9-_.~]+$") - tempPath string + pbkdfPwdPrefixes = []string{pbkdf2SHA1Prefix, pbkdf2SHA256Prefix, pbkdf2SHA512Prefix, pbkdf2SHA256B64SaltPrefix} + pbkdfPwdB64SaltPrefixes = []string{pbkdf2SHA256B64SaltPrefix} + unixPwdPrefixes = []string{md5cryptPwdPrefix, md5cryptApr1PwdPrefix, sha512cryptPwdPrefix} + sharedProviders = []string{PGSQLDataProviderName, MySQLDataProviderName, CockroachDataProviderName} + logSender = "dataprovider" + credentialsDirPath string + sqlTableUsers = "users" + sqlTableFolders = "folders" + sqlTableFoldersMapping = "folders_mapping" + sqlTableUsersFoldersMapping = "users_folders_mapping" + sqlTableAdmins = "admins" + sqlTableAPIKeys = "api_keys" + sqlTableShares = "shares" + sqlTableDefenderHosts = "defender_hosts" + sqlTableDefenderEvents = "defender_events" + sqlTableActiveTransfers = "active_transfers" + sqlTableGroups = "groups" + sqlTableUsersGroupsMapping = "users_groups_mapping" + sqlTableGroupsFoldersMapping = "groups_folders_mapping" + sqlTableSchemaVersion = "schema_version" + argon2Params *argon2id.Params + lastLoginMinDelay = 10 * time.Minute + usernameRegex = regexp.MustCompile("^[a-zA-Z0-9-_.~]+$") + tempPath string ) type schemaVersion struct { @@ -573,6 +577,7 @@ func (d *DefenderEntry) MarshalJSON() ([]byte, error) { // BackupData defines the structure for the backup/restore files type BackupData struct { Users []User `json:"users"` + Groups []Group `json:"groups"` Folders []vfs.BaseVirtualFolder `json:"folders"` Admins []Admin `json:"admins"` APIKeys []APIKey `json:"api_keys"` @@ -626,7 +631,7 @@ type Provider interface { userExists(username string) (User, error) addUser(user *User) error updateUser(user *User) error - deleteUser(user *User) error + deleteUser(user User) error updateUserPassword(username, password string) error getUsers(limit int, offset int, order string) ([]User, error) dumpUsers() ([]User, error) @@ -635,32 +640,40 @@ type Provider interface { updateLastLogin(username string) error updateAdminLastLogin(username string) error setUpdatedAt(username string) - getFolders(limit, offset int, order string) ([]vfs.BaseVirtualFolder, error) + getFolders(limit, offset int, order string, minimal bool) ([]vfs.BaseVirtualFolder, error) getFolderByName(name string) (vfs.BaseVirtualFolder, error) addFolder(folder *vfs.BaseVirtualFolder) error updateFolder(folder *vfs.BaseVirtualFolder) error - deleteFolder(folder *vfs.BaseVirtualFolder) error + deleteFolder(folder vfs.BaseVirtualFolder) error updateFolderQuota(name string, filesAdd int, sizeAdd int64, reset bool) error getUsedFolderQuota(name string) (int, int64, error) dumpFolders() ([]vfs.BaseVirtualFolder, error) + getGroups(limit, offset int, order string, minimal bool) ([]Group, error) + getGroupsWithNames(names []string) ([]Group, error) + getUsersInGroups(names []string) ([]string, error) + groupExists(name string) (Group, error) + addGroup(group *Group) error + updateGroup(group *Group) error + deleteGroup(group Group) error + dumpGroups() ([]Group, error) adminExists(username string) (Admin, error) addAdmin(admin *Admin) error updateAdmin(admin *Admin) error - deleteAdmin(admin *Admin) error + deleteAdmin(admin Admin) error getAdmins(limit int, offset int, order string) ([]Admin, error) dumpAdmins() ([]Admin, error) validateAdminAndPass(username, password, ip string) (Admin, error) apiKeyExists(keyID string) (APIKey, error) addAPIKey(apiKey *APIKey) error updateAPIKey(apiKey *APIKey) error - deleteAPIKey(apiKey *APIKey) error + deleteAPIKey(apiKey APIKey) error getAPIKeys(limit int, offset int, order string) ([]APIKey, error) dumpAPIKeys() ([]APIKey, error) updateAPIKeyLastUse(keyID string) error shareExists(shareID, username string) (Share, error) addShare(share *Share) error updateShare(share *Share) error - deleteShare(share *Share) error + deleteShare(share Share) error getShares(limit int, offset int, order, username string) ([]Share, error) dumpShares() ([]Share, error) updateShareLastUse(shareID string, numTokens int) error @@ -991,15 +1004,35 @@ func CheckCompositeCredentials(username, password, ip, loginMethod, protocol str func CheckUserBeforeTLSAuth(username, ip, protocol string, tlsCert *x509.Certificate) (User, error) { username = config.convertName(username) if plugin.Handler.HasAuthScope(plugin.AuthScopeTLSCertificate) { - return doPluginAuth(username, "", nil, ip, protocol, tlsCert, plugin.AuthScopeTLSCertificate) + user, err := doPluginAuth(username, "", nil, ip, protocol, tlsCert, plugin.AuthScopeTLSCertificate) + if err != nil { + return user, err + } + err = user.LoadAndApplyGroupSettings() + return user, err } if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&8 != 0) { - return doExternalAuth(username, "", nil, "", ip, protocol, tlsCert) + user, err := doExternalAuth(username, "", nil, "", ip, protocol, tlsCert) + if err != nil { + return user, err + } + err = user.LoadAndApplyGroupSettings() + return user, err } if config.PreLoginHook != "" { - return executePreLoginHook(username, LoginMethodTLSCertificate, ip, protocol, nil) + user, err := executePreLoginHook(username, LoginMethodTLSCertificate, ip, protocol, nil) + if err != nil { + return user, err + } + err = user.LoadAndApplyGroupSettings() + return user, err } - return UserExists(username) + user, err := UserExists(username) + if err != nil { + return user, err + } + err = user.LoadAndApplyGroupSettings() + return user, err } // CheckUserAndTLSCert returns the SFTPGo user with the given username and check if the @@ -1110,10 +1143,18 @@ func CheckKeyboardInteractiveAuth(username, authHook string, client ssh.Keyboard // If a pre-login hook is defined it will be executed so the SFTPGo user // can be created if it does not exist func GetUserAfterIDPAuth(username, ip, protocol string, oidcTokenFields *map[string]interface{}) (User, error) { + var user User + var err error if config.PreLoginHook != "" { - return executePreLoginHook(username, LoginMethodIDP, ip, protocol, oidcTokenFields) + user, err = executePreLoginHook(username, LoginMethodIDP, ip, protocol, oidcTokenFields) + } else { + user, err = UserExists(username) } - return UserExists(username) + if err != nil { + return user, err + } + err = user.LoadAndApplyGroupSettings() + return user, err } // GetDefenderHosts returns hosts that are banned or for which some violations have been detected @@ -1310,7 +1351,7 @@ func DeleteShare(shareID string, executor, ipAddress string) error { if err != nil { return err } - err = provider.deleteShare(&share) + err = provider.deleteShare(share) if err == nil { executeAction(operationDelete, executor, ipAddress, actionObjectShare, shareID, &share) } @@ -1325,6 +1366,67 @@ func ShareExists(shareID, username string) (Share, error) { return provider.shareExists(shareID, username) } +// AddGroup adds a new group +func AddGroup(group *Group, executor, ipAddress string) error { + group.Name = config.convertName(group.Name) + err := provider.addGroup(group) + if err == nil { + executeAction(operationAdd, executor, ipAddress, actionObjectGroup, group.Name, group) + } + return err +} + +// UpdateGroup updates an existing Group +func UpdateGroup(group *Group, users []string, executor, ipAddress string) error { + err := provider.updateGroup(group) + if err == nil { + for _, user := range users { + provider.setUpdatedAt(user) + u, err := provider.userExists(user) + if err == nil { + webDAVUsersCache.swap(&u) + executeAction(operationUpdate, executor, ipAddress, actionObjectUser, u.Username, &u) + } else { + RemoveCachedWebDAVUser(user) + } + } + executeAction(operationUpdate, executor, ipAddress, actionObjectGroup, group.Name, group) + } + return err +} + +// DeleteGroup deletes an existing Group +func DeleteGroup(name string, executor, ipAddress string) error { + name = config.convertName(name) + group, err := provider.groupExists(name) + if err != nil { + return err + } + if len(group.Users) > 0 { + errorString := fmt.Sprintf("the group %#v is referenced, it cannot be removed", group.Name) + return util.NewValidationError(errorString) + } + err = provider.deleteGroup(group) + if err == nil { + for _, user := range group.Users { + provider.setUpdatedAt(user) + u, err := provider.userExists(user) + if err == nil { + executeAction(operationUpdate, executor, ipAddress, actionObjectUser, u.Username, &u) + } + RemoveCachedWebDAVUser(user) + } + executeAction(operationDelete, executor, ipAddress, actionObjectGroup, group.Name, &group) + } + return err +} + +// GroupExists returns the Group with the given name if it exists +func GroupExists(name string) (Group, error) { + name = config.convertName(name) + return provider.groupExists(name) +} + // AddAPIKey adds a new API key func AddAPIKey(apiKey *APIKey, executor, ipAddress string) error { err := provider.addAPIKey(apiKey) @@ -1349,7 +1451,7 @@ func DeleteAPIKey(keyID string, executor, ipAddress string) error { if err != nil { return err } - err = provider.deleteAPIKey(&apiKey) + err = provider.deleteAPIKey(apiKey) if err == nil { executeAction(operationDelete, executor, ipAddress, actionObjectAPIKey, apiKey.KeyID, &apiKey) } @@ -1401,7 +1503,7 @@ func DeleteAdmin(username, executor, ipAddress string) error { if err != nil { return err } - err = provider.deleteAdmin(&admin) + err = provider.deleteAdmin(admin) if err == nil { executeAction(operationDelete, executor, ipAddress, actionObjectAdmin, admin.Username, &admin) } @@ -1420,6 +1522,31 @@ func UserExists(username string) (User, error) { return provider.userExists(username) } +// GetUserWithGroupSettings tries to return the user with the specified username +// loading also the group settings +func GetUserWithGroupSettings(username string) (User, error) { + username = config.convertName(username) + user, err := provider.userExists(username) + if err != nil { + return user, err + } + err = user.LoadAndApplyGroupSettings() + return user, err +} + +// GetUserVariants tries to return the user with the specified username with and without +// group settings applied +func GetUserVariants(username string) (User, User, error) { + username = config.convertName(username) + user, err := provider.userExists(username) + if err != nil { + return user, User{}, err + } + userWithGroupSettings := user.getACopy() + err = userWithGroupSettings.LoadAndApplyGroupSettings() + return user, userWithGroupSettings, err +} + // AddUser adds a new SFTPGo user. func AddUser(user *User, executor, ipAddress string) error { user.Filters.RecoveryCodes = nil @@ -1434,8 +1561,26 @@ func AddUser(user *User, executor, ipAddress string) error { return err } +// UpdateUserPassword updates the user password +func UpdateUserPassword(username, plainPwd, executor, ipAddress string) error { + hashedPwd, err := hashPlainPassword(plainPwd) + if err != nil { + return util.NewGenericError(fmt.Sprintf("unable to set the new password: %v", err)) + } + err = provider.updateUserPassword(username, hashedPwd) + if err != nil { + return util.NewGenericError(fmt.Sprintf("unable to set the new password: %v", err)) + } + cachedPasswords.Remove(username) + executeAction(operationUpdate, executor, ipAddress, actionObjectUser, username, &User{}) + return nil +} + // UpdateUser updates an existing SFTPGo user. func UpdateUser(user *User, executor, ipAddress string) error { + if user.groupSettingsApplied { + return errors.New("cannot save a user with group settings applied") + } err := provider.updateUser(user) if err == nil { webDAVUsersCache.swap(user) @@ -1452,7 +1597,7 @@ func DeleteUser(username, executor, ipAddress string) error { if err != nil { return err } - err = provider.deleteUser(&user) + err = provider.deleteUser(user) if err == nil { RemoveCachedWebDAVUser(user.Username) delayedQuotaUpdater.resetUserQuota(username) @@ -1524,7 +1669,12 @@ func GetAdmins(limit, offset int, order string) ([]Admin, error) { return provider.getAdmins(limit, offset, order) } -// GetUsers returns an array of users respecting limit and offset and filtered by username exact match if not empty +// GetGroups returns an array of groups respecting limit and offset +func GetGroups(limit, offset int, order string, minimal bool) ([]Group, error) { + return provider.getGroups(limit, offset, order, minimal) +} + +// GetUsers returns an array of users respecting limit and offset func GetUsers(limit, offset int, order string) ([]User, error) { return provider.getUsers(limit, offset, order) } @@ -1541,9 +1691,16 @@ func AddFolder(folder *vfs.BaseVirtualFolder) error { } // UpdateFolder updates the specified virtual folder -func UpdateFolder(folder *vfs.BaseVirtualFolder, users []string, executor, ipAddress string) error { +func UpdateFolder(folder *vfs.BaseVirtualFolder, users []string, groups []string, executor, ipAddress string) error { err := provider.updateFolder(folder) if err == nil { + usersInGroups, errGrp := provider.getUsersInGroups(groups) + if errGrp == nil { + users = append(users, usersInGroups...) + users = util.RemoveDuplicates(users) + } else { + providerLog(logger.LevelWarn, "unable to get users in groups %+v: %v", groups, errGrp) + } for _, user := range users { provider.setUpdatedAt(user) u, err := provider.userExists(user) @@ -1565,9 +1722,17 @@ func DeleteFolder(folderName, executor, ipAddress string) error { if err != nil { return err } - err = provider.deleteFolder(&folder) + err = provider.deleteFolder(folder) if err == nil { - for _, user := range folder.Users { + users := folder.Users + usersInGroups, errGrp := provider.getUsersInGroups(folder.Groups) + if errGrp == nil { + users = append(users, usersInGroups...) + users = util.RemoveDuplicates(users) + } else { + providerLog(logger.LevelWarn, "unable to get users in groups %+v: %v", folder.Groups, errGrp) + } + for _, user := range users { provider.setUpdatedAt(user) u, err := provider.userExists(user) if err == nil { @@ -1587,13 +1752,17 @@ func GetFolderByName(name string) (vfs.BaseVirtualFolder, error) { } // GetFolders returns an array of folders respecting limit and offset -func GetFolders(limit, offset int, order string) ([]vfs.BaseVirtualFolder, error) { - return provider.getFolders(limit, offset, order) +func GetFolders(limit, offset int, order string, minimal bool) ([]vfs.BaseVirtualFolder, error) { + return provider.getFolders(limit, offset, order, minimal) } // DumpData returns all users, folders, admins, api keys, shares func DumpData() (BackupData, error) { var data BackupData + groups, err := provider.dumpGroups() + if err != nil { + return data, err + } users, err := provider.dumpUsers() if err != nil { return data, err @@ -1615,6 +1784,7 @@ func DumpData() (BackupData, error) { return data, err } data.Users = users + data.Groups = groups data.Folders = folders data.Admins = admins data.APIKeys = apiKeys @@ -1683,6 +1853,58 @@ func createProvider(basePath string) error { } } +func copyBaseUserFilters(in sdk.BaseUserFilters) sdk.BaseUserFilters { + filters := sdk.BaseUserFilters{} + filters.MaxUploadFileSize = in.MaxUploadFileSize + filters.TLSUsername = in.TLSUsername + filters.UserType = in.UserType + filters.AllowedIP = make([]string, len(in.AllowedIP)) + copy(filters.AllowedIP, in.AllowedIP) + filters.DeniedIP = make([]string, len(in.DeniedIP)) + copy(filters.DeniedIP, in.DeniedIP) + filters.DeniedLoginMethods = make([]string, len(in.DeniedLoginMethods)) + copy(filters.DeniedLoginMethods, in.DeniedLoginMethods) + filters.FilePatterns = make([]sdk.PatternsFilter, len(in.FilePatterns)) + copy(filters.FilePatterns, in.FilePatterns) + filters.DeniedProtocols = make([]string, len(in.DeniedProtocols)) + copy(filters.DeniedProtocols, in.DeniedProtocols) + filters.TwoFactorAuthProtocols = make([]string, len(in.TwoFactorAuthProtocols)) + copy(filters.TwoFactorAuthProtocols, in.TwoFactorAuthProtocols) + filters.Hooks.ExternalAuthDisabled = in.Hooks.ExternalAuthDisabled + filters.Hooks.PreLoginDisabled = in.Hooks.PreLoginDisabled + filters.Hooks.CheckPasswordDisabled = in.Hooks.CheckPasswordDisabled + filters.DisableFsChecks = in.DisableFsChecks + filters.StartDirectory = in.StartDirectory + filters.AllowAPIKeyAuth = in.AllowAPIKeyAuth + filters.ExternalAuthCacheTime = in.ExternalAuthCacheTime + filters.WebClient = make([]string, len(in.WebClient)) + copy(filters.WebClient, in.WebClient) + filters.BandwidthLimits = make([]sdk.BandwidthLimit, 0, len(in.BandwidthLimits)) + for _, limit := range in.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(in.DataTransferLimits)) + for _, limit := range in.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 filters +} + func buildUserHomeDir(user *User) { if user.HomeDir == "" { if config.UsersBaseDir != "" { @@ -1697,6 +1919,8 @@ func buildUserHomeDir(user *User) { user.HomeDir = filepath.Join(os.TempDir(), user.Username) } } + } else { + user.HomeDir = filepath.Clean(user.HomeDir) } } @@ -1753,29 +1977,56 @@ func getVirtualFolderIfInvalid(folder *vfs.BaseVirtualFolder) *vfs.BaseVirtualFo return folder } -func validateUserVirtualFolders(user *User) error { - if len(user.VirtualFolders) == 0 { - user.VirtualFolders = []vfs.VirtualFolder{} +func validateUserGroups(user *User) error { + if len(user.Groups) == 0 { return nil } + hasPrimary := false + groupNames := make(map[string]bool) + + for _, g := range user.Groups { + if g.Type < sdk.GroupTypePrimary && g.Type > sdk.GroupTypeSecondary { + return util.NewValidationError(fmt.Sprintf("invalid group type: %v", g.Type)) + } + if g.Type == sdk.GroupTypePrimary { + if hasPrimary { + return util.NewValidationError("only one primary group is allowed") + } + hasPrimary = true + } + if groupNames[g.Name] { + return util.NewValidationError(fmt.Sprintf("the group %#v is duplicated", g.Name)) + } + groupNames[g.Name] = true + } + return nil +} + +func validateAssociatedVirtualFolders(vfolders []vfs.VirtualFolder) ([]vfs.VirtualFolder, error) { + if len(vfolders) == 0 { + return []vfs.VirtualFolder{}, nil + } var virtualFolders []vfs.VirtualFolder folderNames := make(map[string]bool) - for _, v := range user.VirtualFolders { + for _, v := range vfolders { + if v.VirtualPath == "" { + return nil, util.NewValidationError("mount/virtual path is mandatory") + } cleanedVPath := util.CleanPath(v.VirtualPath) if err := validateFolderQuotaLimits(v); err != nil { - return err + return nil, err } folder := getVirtualFolderIfInvalid(&v.BaseVirtualFolder) if err := ValidateFolder(folder); err != nil { - return err + return nil, err } if folderNames[folder.Name] { - return util.NewValidationError(fmt.Sprintf("the folder %#v is duplicated", folder.Name)) + return nil, util.NewValidationError(fmt.Sprintf("the folder %#v is duplicated", folder.Name)) } for _, vFolder := range virtualFolders { if isVirtualDirOverlapped(vFolder.VirtualPath, cleanedVPath, false) { - return util.NewValidationError(fmt.Sprintf("invalid virtual folder %#v, it overlaps with virtual folder %#v", + return nil, util.NewValidationError(fmt.Sprintf("invalid virtual folder %#v, it overlaps with virtual folder %#v", v.VirtualPath, vFolder.VirtualPath)) } } @@ -1787,8 +2038,7 @@ func validateUserVirtualFolders(user *User) error { }) folderNames[folder.Name] = true } - user.VirtualFolders = virtualFolders - return nil + return virtualFolders, nil } func validateUserTOTPConfig(c *UserTOTPConfig, username string) error { @@ -1840,24 +2090,18 @@ func validateUserRecoveryCodes(user *User) error { return nil } -func validatePermissions(user *User) error { - if len(user.Permissions) == 0 { - return util.NewValidationError("please grant some permissions to this user") - } +func validateUserPermissions(permsToCheck map[string][]string) (map[string][]string, error) { permissions := make(map[string][]string) - if _, ok := user.Permissions["/"]; !ok { - return util.NewValidationError("permissions for the root dir \"/\" must be set") - } - for dir, perms := range user.Permissions { + for dir, perms := range permsToCheck { if len(perms) == 0 && dir == "/" { - return util.NewValidationError(fmt.Sprintf("no permissions granted for the directory: %#v", dir)) + return permissions, util.NewValidationError(fmt.Sprintf("no permissions granted for the directory: %#v", dir)) } if len(perms) > len(ValidPerms) { - return util.NewValidationError("invalid permissions") + return permissions, util.NewValidationError("invalid permissions") } for _, p := range perms { if !util.IsStringInSlice(p, ValidPerms) { - return util.NewValidationError(fmt.Sprintf("invalid permission: %#v", p)) + return permissions, util.NewValidationError(fmt.Sprintf("invalid permission: %#v", p)) } } cleanedDir := filepath.ToSlash(path.Clean(dir)) @@ -1865,10 +2109,10 @@ func validatePermissions(user *User) error { cleanedDir = strings.TrimSuffix(cleanedDir, "/") } if !path.IsAbs(cleanedDir) { - return util.NewValidationError(fmt.Sprintf("cannot set permissions for non absolute path: %#v", dir)) + return permissions, util.NewValidationError(fmt.Sprintf("cannot set permissions for non absolute path: %#v", dir)) } if dir != cleanedDir && cleanedDir == "/" { - return util.NewValidationError(fmt.Sprintf("cannot set permissions for invalid subdirectory: %#v is an alias for \"/\"", dir)) + return permissions, util.NewValidationError(fmt.Sprintf("cannot set permissions for invalid subdirectory: %#v is an alias for \"/\"", dir)) } if util.IsStringInSlice(PermAny, perms) { permissions[cleanedDir] = []string{PermAny} @@ -1876,6 +2120,21 @@ func validatePermissions(user *User) error { permissions[cleanedDir] = util.RemoveDuplicates(perms) } } + + return permissions, nil +} + +func validatePermissions(user *User) error { + if len(user.Permissions) == 0 { + return util.NewValidationError("please grant some permissions to this user") + } + if _, ok := user.Permissions["/"]; !ok { + return util.NewValidationError("permissions for the root dir \"/\" must be set") + } + permissions, err := validateUserPermissions(user.Permissions) + if err != nil { + return err + } user.Permissions = permissions return nil } @@ -1899,14 +2158,14 @@ func validatePublicKeys(user *User) error { return nil } -func validateFiltersPatternExtensions(user *User) error { - if len(user.Filters.FilePatterns) == 0 { - user.Filters.FilePatterns = []sdk.PatternsFilter{} +func validateFiltersPatternExtensions(baseFilters *sdk.BaseUserFilters) error { + if len(baseFilters.FilePatterns) == 0 { + baseFilters.FilePatterns = []sdk.PatternsFilter{} return nil } filteredPaths := []string{} var filters []sdk.PatternsFilter - for _, f := range user.Filters.FilePatterns { + for _, f := range baseFilters.FilePatterns { cleanedPath := filepath.ToSlash(path.Clean(f.Path)) if !path.IsAbs(cleanedPath) { return util.NewValidationError(fmt.Sprintf("invalid path %#v for file patterns filter", f.Path)) @@ -1942,35 +2201,35 @@ func validateFiltersPatternExtensions(user *User) error { filters = append(filters, f) filteredPaths = append(filteredPaths, cleanedPath) } - user.Filters.FilePatterns = filters + baseFilters.FilePatterns = filters return nil } -func checkEmptyFiltersStruct(user *User) { - if len(user.Filters.AllowedIP) == 0 { - user.Filters.AllowedIP = []string{} +func checkEmptyFiltersStruct(filters *sdk.BaseUserFilters) { + if len(filters.AllowedIP) == 0 { + filters.AllowedIP = []string{} } - if len(user.Filters.DeniedIP) == 0 { - user.Filters.DeniedIP = []string{} + if len(filters.DeniedIP) == 0 { + filters.DeniedIP = []string{} } - if len(user.Filters.DeniedLoginMethods) == 0 { - user.Filters.DeniedLoginMethods = []string{} + if len(filters.DeniedLoginMethods) == 0 { + filters.DeniedLoginMethods = []string{} } - if len(user.Filters.DeniedProtocols) == 0 { - user.Filters.DeniedProtocols = []string{} + if len(filters.DeniedProtocols) == 0 { + filters.DeniedProtocols = []string{} } } -func validateIPFilters(user *User) error { - user.Filters.DeniedIP = util.RemoveDuplicates(user.Filters.DeniedIP) - for _, IPMask := range user.Filters.DeniedIP { +func validateIPFilters(filters *sdk.BaseUserFilters) error { + filters.DeniedIP = util.RemoveDuplicates(filters.DeniedIP) + for _, IPMask := range filters.DeniedIP { _, _, err := net.ParseCIDR(IPMask) if err != nil { return util.NewValidationError(fmt.Sprintf("could not parse denied IP/Mask %#v: %v", IPMask, err)) } } - user.Filters.AllowedIP = util.RemoveDuplicates(user.Filters.AllowedIP) - for _, IPMask := range user.Filters.AllowedIP { + filters.AllowedIP = util.RemoveDuplicates(filters.AllowedIP) + for _, IPMask := range filters.AllowedIP { _, _, err := net.ParseCIDR(IPMask) if err != nil { return util.NewValidationError(fmt.Sprintf("could not parse allowed IP/Mask %#v: %v", IPMask, err)) @@ -1992,24 +2251,24 @@ func validateBandwidthLimit(bl sdk.BandwidthLimit) error { return nil } -func validateBandwidthLimitsFilter(user *User) error { - for idx, bandwidthLimit := range user.Filters.BandwidthLimits { +func validateBandwidthLimitsFilter(filters *sdk.BaseUserFilters) error { + for idx, bandwidthLimit := range filters.BandwidthLimits { if err := validateBandwidthLimit(bandwidthLimit); err != nil { return err } if bandwidthLimit.DownloadBandwidth < 0 { - user.Filters.BandwidthLimits[idx].DownloadBandwidth = 0 + filters.BandwidthLimits[idx].DownloadBandwidth = 0 } if bandwidthLimit.UploadBandwidth < 0 { - user.Filters.BandwidthLimits[idx].UploadBandwidth = 0 + filters.BandwidthLimits[idx].UploadBandwidth = 0 } } return nil } -func validateTransferLimitsFilter(user *User) error { - for idx, limit := range user.Filters.DataTransferLimits { - user.Filters.DataTransferLimits[idx].Sources = util.RemoveDuplicates(limit.Sources) +func validateTransferLimitsFilter(filters *sdk.BaseUserFilters) error { + for idx, limit := range filters.DataTransferLimits { + filters.DataTransferLimits[idx].Sources = util.RemoveDuplicates(limit.Sources) if len(limit.Sources) == 0 { return util.NewValidationError("no data transfer limit source specified") } @@ -2020,36 +2279,33 @@ func validateTransferLimitsFilter(user *User) error { } } if limit.TotalDataTransfer > 0 { - user.Filters.DataTransferLimits[idx].UploadDataTransfer = 0 - user.Filters.DataTransferLimits[idx].DownloadDataTransfer = 0 + filters.DataTransferLimits[idx].UploadDataTransfer = 0 + filters.DataTransferLimits[idx].DownloadDataTransfer = 0 } } return nil } -func updateFiltersValues(user *User) { - if !user.HasExternalAuth() { - user.Filters.ExternalAuthCacheTime = 0 - } - if user.Filters.StartDirectory != "" { - user.Filters.StartDirectory = util.CleanPath(user.Filters.StartDirectory) - if user.Filters.StartDirectory == "/" { - user.Filters.StartDirectory = "" +func updateFiltersValues(filters *sdk.BaseUserFilters) { + if filters.StartDirectory != "" { + filters.StartDirectory = util.CleanPath(filters.StartDirectory) + if filters.StartDirectory == "/" { + filters.StartDirectory = "" } } } -func validateFilterProtocols(user *User) error { - if len(user.Filters.DeniedProtocols) >= len(ValidProtocols) { +func validateFilterProtocols(filters *sdk.BaseUserFilters) error { + if len(filters.DeniedProtocols) >= len(ValidProtocols) { return util.NewValidationError("invalid denied_protocols") } - for _, p := range user.Filters.DeniedProtocols { + for _, p := range filters.DeniedProtocols { if !util.IsStringInSlice(p, ValidProtocols) { return util.NewValidationError(fmt.Sprintf("invalid denied protocol %#v", p)) } } - for _, p := range user.Filters.TwoFactorAuthProtocols { + for _, p := range filters.TwoFactorAuthProtocols { if !util.IsStringInSlice(p, MFAProtocols) { return util.NewValidationError(fmt.Sprintf("invalid two factor protocol %#v", p)) } @@ -2057,41 +2313,41 @@ func validateFilterProtocols(user *User) error { return nil } -func validateFilters(user *User) error { - checkEmptyFiltersStruct(user) - if err := validateIPFilters(user); err != nil { +func validateBaseFilters(filters *sdk.BaseUserFilters) error { + checkEmptyFiltersStruct(filters) + if err := validateIPFilters(filters); err != nil { return err } - if err := validateBandwidthLimitsFilter(user); err != nil { + if err := validateBandwidthLimitsFilter(filters); err != nil { return err } - if err := validateTransferLimitsFilter(user); err != nil { + if err := validateTransferLimitsFilter(filters); err != nil { return err } - if len(user.Filters.DeniedLoginMethods) >= len(ValidLoginMethods) { + if len(filters.DeniedLoginMethods) >= len(ValidLoginMethods) { return util.NewValidationError("invalid denied_login_methods") } - for _, loginMethod := range user.Filters.DeniedLoginMethods { + for _, loginMethod := range filters.DeniedLoginMethods { if !util.IsStringInSlice(loginMethod, ValidLoginMethods) { return util.NewValidationError(fmt.Sprintf("invalid login method: %#v", loginMethod)) } } - if err := validateFilterProtocols(user); err != nil { + if err := validateFilterProtocols(filters); err != nil { return err } - if user.Filters.TLSUsername != "" { - if !util.IsStringInSlice(string(user.Filters.TLSUsername), validTLSUsernames) { - return util.NewValidationError(fmt.Sprintf("invalid TLS username: %#v", user.Filters.TLSUsername)) + if filters.TLSUsername != "" { + if !util.IsStringInSlice(string(filters.TLSUsername), validTLSUsernames) { + return util.NewValidationError(fmt.Sprintf("invalid TLS username: %#v", filters.TLSUsername)) } } - for _, opts := range user.Filters.WebClient { + for _, opts := range filters.WebClient { if !util.IsStringInSlice(opts, sdk.WebClientOptions) { return util.NewValidationError(fmt.Sprintf("invalid web client options %#v", opts)) } } - updateFiltersValues(user) + updateFiltersValues(filters) - return validateFiltersPatternExtensions(user) + return validateFiltersPatternExtensions(filters) } func saveGCSCredentials(fsConfig *vfs.Filesystem, helper vfs.ValidatorHelper) error { @@ -2146,6 +2402,9 @@ func validateBaseParams(user *User) error { return util.NewValidationError(fmt.Sprintf("username %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~", user.Username)) } + if user.hasRedactedSecret() { + return util.NewValidationError("cannot save a user with a redacted secret") + } if user.HomeDir == "" { return util.NewValidationError("home_dir is mandatory") } @@ -2167,7 +2426,7 @@ func validateBaseParams(user *User) error { user.UploadDataTransfer = 0 user.DownloadDataTransfer = 0 } - return nil + return user.FsConfig.Validate(user) } func hashPlainPassword(plainPwd string) (string, error) { @@ -2238,11 +2497,11 @@ func ValidateUser(user *User) error { if err := validateBaseParams(user); err != nil { return err } - if err := validatePermissions(user); err != nil { + if err := validateUserGroups(user); err != nil { return err } - if user.hasRedactedSecret() { - return util.NewValidationError("cannot save a user with a redacted secret") + if err := validatePermissions(user); err != nil { + return err } if err := validateUserTOTPConfig(&user.Filters.TOTPConfig, user.Username); err != nil { return err @@ -2250,12 +2509,11 @@ func ValidateUser(user *User) error { if err := validateUserRecoveryCodes(user); err != nil { return err } - if err := user.FsConfig.Validate(user); err != nil { - return err - } - if err := validateUserVirtualFolders(user); err != nil { + vfolders, err := validateAssociatedVirtualFolders(user.VirtualFolders) + if err != nil { return err } + user.VirtualFolders = vfolders if user.Status < 0 || user.Status > 1 { return util.NewValidationError(fmt.Sprintf("invalid user status: %v", user.Status)) } @@ -2265,9 +2523,12 @@ func ValidateUser(user *User) error { if err := validatePublicKeys(user); err != nil { return err } - if err := validateFilters(user); err != nil { + if err := validateBaseFilters(&user.Filters.BaseUserFilters); err != nil { return err } + if !user.HasExternalAuth() { + user.Filters.ExternalAuthCacheTime = 0 + } if user.Filters.TOTPConfig.Enabled && util.IsStringInSlice(sdk.WebClientMFADisabled, user.Filters.WebClient) { return util.NewValidationError("two-factor authentication cannot be disabled for a user with an active configuration") } @@ -2335,7 +2596,11 @@ func convertUserPassword(username, plainPwd string) { } func checkUserAndTLSCertificate(user *User, protocol string, tlsCert *x509.Certificate) (User, error) { - err := user.CheckLoginConditions() + err := user.LoadAndApplyGroupSettings() + if err != nil { + return *user, err + } + err = user.CheckLoginConditions() if err != nil { return *user, err } @@ -2354,7 +2619,11 @@ func checkUserAndTLSCertificate(user *User, protocol string, tlsCert *x509.Certi } func checkUserAndPass(user *User, password, ip, protocol string) (User, error) { - err := user.CheckLoginConditions() + err := user.LoadAndApplyGroupSettings() + if err != nil { + return *user, err + } + err = user.CheckLoginConditions() if err != nil { return *user, err } @@ -2432,7 +2701,11 @@ func checkUserPasscode(user *User, password, protocol string) (string, error) { } func checkUserAndPubKey(user *User, pubKey []byte, isSSHCert bool) (User, string, error) { - err := user.CheckLoginConditions() + err := user.LoadAndApplyGroupSettings() + if err != nil { + return *user, "", err + } + err = user.CheckLoginConditions() if err != nil { return *user, "", err } @@ -2625,6 +2898,10 @@ func doBuiltinKeyboardInteractiveAuth(user *User, client ssh.KeyboardInteractive if len(answers) != 1 { return 0, fmt.Errorf("unexpected number of answers: %v", len(answers)) } + err = user.LoadAndApplyGroupSettings() + if err != nil { + return 0, err + } _, err = checkUserAndPass(user, answers[0], ip, protocol) if err != nil { return 0, err @@ -2884,6 +3161,10 @@ func doKeyboardInteractiveAuth(user *User, authHook string, client ssh.KeyboardI if authResult != 1 { return *user, fmt.Errorf("keyboard interactive auth failed, result: %v", authResult) } + err = user.LoadAndApplyGroupSettings() + if err != nil { + return *user, err + } err = user.CheckLoginConditions() if err != nil { return *user, err @@ -3006,11 +3287,11 @@ func getPreLoginHookResponse(loginMethod, ip, protocol string, userAsJSON []byte } func executePreLoginHook(username, loginMethod, ip, protocol string, oidcTokenFields *map[string]interface{}) (User, error) { - u, userAsJSON, err := getUserAndJSONForHook(username, oidcTokenFields) + u, mergedUser, userAsJSON, err := getUserAndJSONForHook(username, oidcTokenFields) if err != nil { return u, err } - if u.Filters.Hooks.PreLoginDisabled { + if mergedUser.Filters.Hooks.PreLoginDisabled { return u, nil } startTime := time.Now() @@ -3211,16 +3492,16 @@ func doExternalAuth(username, password string, pubKey []byte, keyboardInteractiv ) (User, error) { var user User - u, userAsJSON, err := getUserAndJSONForHook(username, nil) + u, mergedUser, userAsJSON, err := getUserAndJSONForHook(username, nil) if err != nil { return user, err } - if u.Filters.Hooks.ExternalAuthDisabled { + if mergedUser.Filters.Hooks.ExternalAuthDisabled { return u, nil } - if u.isExternalAuthCached() { + if mergedUser.isExternalAuthCached() { return u, nil } @@ -3291,16 +3572,16 @@ func doPluginAuth(username, password string, pubKey []byte, ip, protocol string, ) (User, error) { var user User - u, userAsJSON, err := getUserAndJSONForHook(username, nil) + u, mergedUser, userAsJSON, err := getUserAndJSONForHook(username, nil) if err != nil { return user, err } - if u.Filters.Hooks.ExternalAuthDisabled { + if mergedUser.Filters.Hooks.ExternalAuthDisabled { return u, nil } - if u.isExternalAuthCached() { + if mergedUser.isExternalAuthCached() { return u, nil } @@ -3356,12 +3637,12 @@ func doPluginAuth(username, password string, pubKey []byte, ip, protocol string, return provider.userExists(user.Username) } -func getUserAndJSONForHook(username string, oidcTokenFields *map[string]interface{}) (User, []byte, error) { +func getUserAndJSONForHook(username string, oidcTokenFields *map[string]interface{}) (User, User, []byte, error) { var userAsJSON []byte u, err := provider.userExists(username) if err != nil { if _, ok := err.(*util.RecordNotFoundError); !ok { - return u, userAsJSON, err + return u, u, userAsJSON, err } u = User{ BaseUser: sdk.BaseUser{ @@ -3370,12 +3651,18 @@ func getUserAndJSONForHook(username string, oidcTokenFields *map[string]interfac }, } } + mergedUser := u.getACopy() + err = mergedUser.LoadAndApplyGroupSettings() + if err != nil { + return u, mergedUser, userAsJSON, err + } + u.OIDCCustomFields = oidcTokenFields userAsJSON, err = json.Marshal(u) if err != nil { - return u, userAsJSON, err + return u, mergedUser, userAsJSON, err } - return u, userAsJSON, err + return u, mergedUser, userAsJSON, err } func isLastActivityRecent(lastActivity int64, minDelay time.Duration) bool { diff --git a/dataprovider/group.go b/dataprovider/group.go new file mode 100644 index 00000000..6e7b7c44 --- /dev/null +++ b/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, ",") +} diff --git a/dataprovider/memory.go b/dataprovider/memory.go index 96438f63..ca413601 100644 --- a/dataprovider/memory.go +++ b/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 diff --git a/dataprovider/mysql.go b/dataprovider/mysql.go index 79d9b0cc..8759c0f2 100644 --- a/dataprovider/mysql.go +++ b/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) +} diff --git a/dataprovider/pgsql.go b/dataprovider/pgsql.go index 3adb03c5..2c028b73 100644 --- a/dataprovider/pgsql.go +++ b/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) +} diff --git a/dataprovider/sqlcommon.go b/dataprovider/sqlcommon.go index b97e03dc..317ffacd 100644 --- a/dataprovider/sqlcommon.go +++ b/dataprovider/sqlcommon.go @@ -11,6 +11,7 @@ import ( "time" "github.com/cockroachdb/cockroach-go/v2/crdb" + "github.com/sftpgo/sdk" "github.com/drakkan/sftpgo/v2/logger" "github.com/drakkan/sftpgo/v2/util" @@ -18,13 +19,15 @@ import ( ) const ( - sqlDatabaseVersion = 16 + sqlDatabaseVersion = 17 defaultSQLQueryTimeout = 10 * time.Second longSQLQueryTimeout = 60 * time.Second ) var ( - errSQLFoldersAssosaction = errors.New("unable to associate virtual folders to user") + errSQLFoldersAssociation = errors.New("unable to associate virtual folders to user") + errSQLGroupsAssociation = errors.New("unable to associate groups to user") + errSQLUsersAssociation = errors.New("unable to associate users to group") errSchemaVersionEmpty = errors.New("we can't determine schema version because the schema_migration table is empty. The SFTPGo database might be corrupted. Consider using the \"resetprovider\" sub-command") ) @@ -36,6 +39,25 @@ type sqlScanner interface { Scan(dest ...interface{}) error } +func sqlReplaceAll(sql string) string { + sql = strings.ReplaceAll(sql, "{{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, "{{groups}}", sqlTableGroups) + 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, "{{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 = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix) + return sql +} + func sqlCommonGetShareByID(shareID, username string, dbHandle sqlQuerier) (Share, error) { var share Share ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) @@ -169,7 +191,7 @@ func sqlCommonUpdateShare(share *Share, dbHandle *sql.DB) error { return err } -func sqlCommonDeleteShare(share *Share, dbHandle *sql.DB) error { +func sqlCommonDeleteShare(share Share, dbHandle *sql.DB) error { ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) defer cancel() @@ -319,7 +341,7 @@ func sqlCommonUpdateAPIKey(apiKey *APIKey, dbHandle *sql.DB) error { return err } -func sqlCommonDeleteAPIKey(apiKey *APIKey, dbHandle *sql.DB) error { +func sqlCommonDeleteAPIKey(apiKey APIKey, dbHandle *sql.DB) error { ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) defer cancel() q := getDeleteAPIKeyQuery() @@ -499,7 +521,7 @@ func sqlCommonUpdateAdmin(admin *Admin, dbHandle *sql.DB) error { return err } -func sqlCommonDeleteAdmin(admin *Admin, dbHandle *sql.DB) error { +func sqlCommonDeleteAdmin(admin Admin, dbHandle *sql.DB) error { ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) defer cancel() q := getDeleteAdminQuery() @@ -574,10 +596,264 @@ func sqlCommonDumpAdmins(dbHandle sqlQuerier) ([]Admin, error) { return admins, rows.Err() } +func sqlCommonGetGroupByName(name string, dbHandle sqlQuerier) (Group, error) { + var group Group + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + q := getGroupByNameQuery() + stmt, err := dbHandle.PrepareContext(ctx, q) + if err != nil { + providerLog(logger.LevelError, "error preparing database query %#v: %v", q, err) + return group, err + } + defer stmt.Close() + + row := stmt.QueryRowContext(ctx, name) + group, err = getGroupFromDbRow(row) + if err != nil { + return group, err + } + group, err = getGroupWithVirtualFolders(ctx, group, dbHandle) + if err != nil { + return group, err + } + return getGroupWithUsers(ctx, group, dbHandle) +} + +func sqlCommonDumpGroups(dbHandle sqlQuerier) ([]Group, error) { + groups := make([]Group, 0, 50) + ctx, cancel := context.WithTimeout(context.Background(), longSQLQueryTimeout) + defer cancel() + + q := getDumpGroupsQuery() + stmt, err := dbHandle.PrepareContext(ctx, q) + if err != nil { + providerLog(logger.LevelError, "error preparing database query %#v: %v", q, err) + return nil, err + } + defer stmt.Close() + + rows, err := stmt.QueryContext(ctx) + if err != nil { + return groups, err + } + defer rows.Close() + + for rows.Next() { + group, err := getGroupFromDbRow(rows) + if err != nil { + return groups, err + } + group.PrepareForRendering() + groups = append(groups, group) + } + err = rows.Err() + return groups, err +} + +func sqlCommonGetUsersInGroups(names []string, dbHandle sqlQuerier) ([]string, error) { + if len(names) == 0 { + return nil, nil + } + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + q := getUsersInGroupsQuery(len(names)) + stmt, err := dbHandle.PrepareContext(ctx, q) + if err != nil { + providerLog(logger.LevelError, "error preparing database query %#v: %v", q, err) + return nil, err + } + defer stmt.Close() + + args := make([]interface{}, 0, len(names)) + for _, name := range names { + args = append(args, name) + } + + usernames := make([]string, 0, len(names)) + rows, err := stmt.QueryContext(ctx, args...) + if err == nil { + defer rows.Close() + for rows.Next() { + var username string + err = rows.Scan(&username) + if err != nil { + return usernames, err + } + usernames = append(usernames, username) + } + } + return usernames, rows.Err() +} + +func sqlCommonGetGroupsWithNames(names []string, dbHandle sqlQuerier) ([]Group, error) { + if len(names) == 0 { + return nil, nil + } + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + q := getGroupsWithNamesQuery(len(names)) + stmt, err := dbHandle.PrepareContext(ctx, q) + if err != nil { + providerLog(logger.LevelError, "error preparing database query %#v: %v", q, err) + return nil, err + } + defer stmt.Close() + + args := make([]interface{}, 0, len(names)) + for _, name := range names { + args = append(args, name) + } + + groups := make([]Group, 0, len(names)) + rows, err := stmt.QueryContext(ctx, args...) + if err == nil { + defer rows.Close() + for rows.Next() { + group, err := getGroupFromDbRow(rows) + if err != nil { + return groups, err + } + groups = append(groups, group) + } + } + err = rows.Err() + if err != nil { + return groups, err + } + return getGroupsWithVirtualFolders(ctx, groups, dbHandle) +} + +func sqlCommonGetGroups(limit int, offset int, order string, minimal bool, dbHandle sqlQuerier) ([]Group, error) { + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + q := getGroupsQuery(order, minimal) + stmt, err := dbHandle.PrepareContext(ctx, q) + if err != nil { + providerLog(logger.LevelError, "error preparing database query %#v: %v", q, err) + return nil, err + } + defer stmt.Close() + + groups := make([]Group, 0, limit) + rows, err := stmt.QueryContext(ctx, limit, offset) + if err == nil { + defer rows.Close() + for rows.Next() { + var group Group + if minimal { + err = rows.Scan(&group.ID, &group.Name) + } else { + group, err = getGroupFromDbRow(rows) + } + if err != nil { + return groups, err + } + groups = append(groups, group) + } + } + err = rows.Err() + if err != nil { + return groups, err + } + if minimal { + return groups, nil + } + groups, err = getGroupsWithVirtualFolders(ctx, groups, dbHandle) + if err != nil { + return groups, err + } + groups, err = getGroupsWithUsers(ctx, groups, dbHandle) + if err != nil { + return groups, err + } + for idx := range groups { + groups[idx].PrepareForRendering() + } + return groups, nil +} + +func sqlCommonAddGroup(group *Group, dbHandle *sql.DB) error { + if err := group.validate(); err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + return sqlCommonExecuteTx(ctx, dbHandle, func(tx *sql.Tx) error { + q := getAddGroupQuery() + stmt, err := tx.PrepareContext(ctx, q) + if err != nil { + providerLog(logger.LevelError, "error preparing database query %#v: %v", q, err) + return err + } + defer stmt.Close() + + settings, err := json.Marshal(group.UserSettings) + if err != nil { + return err + } + _, err = stmt.ExecContext(ctx, group.Name, group.Description, util.GetTimeAsMsSinceEpoch(time.Now()), + util.GetTimeAsMsSinceEpoch(time.Now()), string(settings)) + if err != nil { + return err + } + return generateGroupVirtualFoldersMapping(ctx, group, tx) + }) +} + +func sqlCommonUpdateGroup(group *Group, dbHandle *sql.DB) error { + if err := group.validate(); err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + return sqlCommonExecuteTx(ctx, dbHandle, func(tx *sql.Tx) error { + q := getUpdateGroupQuery() + stmt, err := tx.PrepareContext(ctx, q) + if err != nil { + providerLog(logger.LevelError, "error preparing database query %#v: %v", q, err) + return err + } + defer stmt.Close() + + settings, err := json.Marshal(group.UserSettings) + if err != nil { + return err + } + _, err = stmt.ExecContext(ctx, group.Description, settings, util.GetTimeAsMsSinceEpoch(time.Now()), group.Name) + if err != nil { + return err + } + return generateGroupVirtualFoldersMapping(ctx, group, tx) + }) +} + +func sqlCommonDeleteGroup(group Group, dbHandle *sql.DB) error { + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + q := getDeleteGroupQuery() + stmt, err := dbHandle.PrepareContext(ctx, q) + if err != nil { + providerLog(logger.LevelError, "error preparing database query %#v: %v", q, err) + return err + } + defer stmt.Close() + _, err = stmt.ExecContext(ctx, group.Name) + return err +} + func sqlCommonGetUserByUsername(username string, dbHandle sqlQuerier) (User, error) { var user User ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) defer cancel() + q := getUserByUsernameQuery() stmt, err := dbHandle.PrepareContext(ctx, q) if err != nil { @@ -591,7 +867,11 @@ func sqlCommonGetUserByUsername(username string, dbHandle sqlQuerier) (User, err if err != nil { return user, err } - return getUserWithVirtualFolders(ctx, user, dbHandle) + user, err = getUserWithVirtualFolders(ctx, user, dbHandle) + if err != nil { + return user, err + } + return getUserWithGroups(ctx, user, dbHandle) } func sqlCommonValidateUserAndPass(username, password, ip, protocol string, dbHandle *sql.DB) (User, error) { @@ -834,7 +1114,10 @@ func sqlCommonAddUser(user *User, dbHandle *sql.DB) error { if err != nil { return err } - return generateVirtualFoldersMapping(ctx, user, tx) + if err := generateUserVirtualFoldersMapping(ctx, user, tx); err != nil { + return err + } + return generateUserGroupMapping(ctx, user, tx) }) } @@ -893,11 +1176,14 @@ func sqlCommonUpdateUser(user *User, dbHandle *sql.DB) error { if err != nil { return err } - return generateVirtualFoldersMapping(ctx, user, tx) + if err := generateUserVirtualFoldersMapping(ctx, user, tx); err != nil { + return err + } + return generateUserGroupMapping(ctx, user, tx) }) } -func sqlCommonDeleteUser(user *User, dbHandle *sql.DB) error { +func sqlCommonDeleteUser(user User, dbHandle *sql.DB) error { ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) defer cancel() q := getDeleteUserQuery() @@ -943,7 +1229,11 @@ func sqlCommonDumpUsers(dbHandle sqlQuerier) ([]User, error) { if err != nil { return users, err } - return getUsersWithVirtualFolders(ctx, users, dbHandle) + users, err = getUsersWithVirtualFolders(ctx, users, dbHandle) + if err != nil { + return users, err + } + return getUsersWithGroups(ctx, users, dbHandle) } func sqlCommonGetRecentlyUpdatedUsers(after int64, dbHandle sqlQuerier) ([]User, error) { @@ -973,7 +1263,34 @@ func sqlCommonGetRecentlyUpdatedUsers(after int64, dbHandle sqlQuerier) ([]User, if err != nil { return users, err } - return getUsersWithVirtualFolders(ctx, users, dbHandle) + users, err = getUsersWithVirtualFolders(ctx, users, dbHandle) + if err != nil { + return users, err + } + users, err = getUsersWithGroups(ctx, users, dbHandle) + if err != nil { + return users, err + } + var groupNames []string + for _, u := range users { + for _, g := range u.Groups { + groupNames = append(groupNames, g.Name) + } + } + groupNames = util.RemoveDuplicates(groupNames) + groups, err := sqlCommonGetGroupsWithNames(groupNames, dbHandle) + if err != nil { + return users, err + } + groupsMapping := make(map[string]Group) + for _, g := range groups { + groupsMapping[g.Name] = g + } + for idx := range users { + ref := &users[idx] + ref.applyGroupSettings(groupsMapping) + } + return users, nil } func sqlCommonGetUsersForQuotaCheck(toFetch map[string]bool, dbHandle sqlQuerier) ([]User, error) { @@ -1021,6 +1338,29 @@ func sqlCommonGetUsersForQuotaCheck(toFetch map[string]bool, dbHandle sqlQuerier return users, err } users = append(users, usersWithFolders...) + users, err = getUsersWithGroups(ctx, users, dbHandle) + if err != nil { + return users, err + } + var groupNames []string + for _, u := range users { + for _, g := range u.Groups { + groupNames = append(groupNames, g.Name) + } + } + groupNames = util.RemoveDuplicates(groupNames) + groups, err := sqlCommonGetGroupsWithNames(groupNames, dbHandle) + if err != nil { + return users, err + } + groupsMapping := make(map[string]Group) + for _, g := range groups { + groupsMapping[g.Name] = g + } + for idx := range users { + ref := &users[idx] + ref.applyGroupSettings(groupsMapping) + } return users, nil } @@ -1188,7 +1528,6 @@ func sqlCommonGetUsers(limit int, offset int, order string, dbHandle sqlQuerier) if err != nil { return users, err } - u.PrepareForRendering() users = append(users, u) } } @@ -1196,7 +1535,18 @@ func sqlCommonGetUsers(limit int, offset int, order string, dbHandle sqlQuerier) if err != nil { return users, err } - return getUsersWithVirtualFolders(ctx, users, dbHandle) + users, err = getUsersWithVirtualFolders(ctx, users, dbHandle) + if err != nil { + return users, err + } + users, err = getUsersWithGroups(ctx, users, dbHandle) + if err != nil { + return users, err + } + for idx := range users { + users[idx].PrepareForRendering() + } + return users, nil } func sqlCommonGetDefenderHosts(from int64, limit int, dbHandle sqlQuerier) ([]DefenderEntry, error) { @@ -1572,6 +1922,31 @@ func getAdminFromDbRow(row sqlScanner) (Admin, error) { return admin, nil } +func getGroupFromDbRow(row sqlScanner) (Group, error) { + var group Group + var userSettings, description sql.NullString + + err := row.Scan(&group.ID, &group.Name, &description, &group.CreatedAt, &group.UpdatedAt, &userSettings) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return group, util.NewRecordNotFoundError(err.Error()) + } + return group, err + } + if description.Valid { + group.Description = description.String + } + if userSettings.Valid { + var settings GroupUserSettings + err = json.Unmarshal([]byte(userSettings.String), &settings) + if err == nil { + group.UserSettings = settings + } + } + + return group, nil +} + func getUserFromDbRow(row sqlScanner) (User, error) { var user User var permissions sql.NullString @@ -1701,6 +2076,13 @@ func sqlCommonGetFolderByName(ctx context.Context, name string, dbHandle sqlQuer if len(folders) != 1 { return folder, fmt.Errorf("unable to associate users with folder %#v", name) } + folders, err = getVirtualFoldersWithGroups([]vfs.BaseVirtualFolder{folders[0]}, dbHandle) + if err != nil { + return folder, err + } + if len(folders) != 1 { + return folder, fmt.Errorf("unable to associate groups with folder %#v", name) + } return folders[0], nil } @@ -1775,7 +2157,7 @@ func sqlCommonUpdateFolder(folder *vfs.BaseVirtualFolder, dbHandle sqlQuerier) e return err } -func sqlCommonDeleteFolder(folder *vfs.BaseVirtualFolder, dbHandle sqlQuerier) error { +func sqlCommonDeleteFolder(folder vfs.BaseVirtualFolder, dbHandle sqlQuerier) error { ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) defer cancel() q := getDeleteFolderQuery() @@ -1829,17 +2211,14 @@ func sqlCommonDumpFolders(dbHandle sqlQuerier) ([]vfs.BaseVirtualFolder, error) folders = append(folders, folder) } err = rows.Err() - if err != nil { - return folders, err - } - return getVirtualFoldersWithUsers(folders, dbHandle) + return folders, err } -func sqlCommonGetFolders(limit, offset int, order string, dbHandle sqlQuerier) ([]vfs.BaseVirtualFolder, error) { +func sqlCommonGetFolders(limit, offset int, order string, minimal bool, dbHandle sqlQuerier) ([]vfs.BaseVirtualFolder, error) { folders := make([]vfs.BaseVirtualFolder, 0, limit) ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) defer cancel() - q := getFoldersQuery(order) + q := getFoldersQuery(order, minimal) stmt, err := dbHandle.PrepareContext(ctx, q) if err != nil { providerLog(logger.LevelError, "error preparing database query %#v: %v", q, err) @@ -1854,23 +2233,30 @@ func sqlCommonGetFolders(limit, offset int, order string, dbHandle sqlQuerier) ( defer rows.Close() for rows.Next() { var folder vfs.BaseVirtualFolder - var mappedPath, description, fsConfig sql.NullString - err = rows.Scan(&folder.ID, &mappedPath, &folder.UsedQuotaSize, &folder.UsedQuotaFiles, - &folder.LastQuotaUpdate, &folder.Name, &description, &fsConfig) - if err != nil { - return folders, err - } - if mappedPath.Valid { - folder.MappedPath = mappedPath.String - } - if description.Valid { - folder.Description = description.String - } - if fsConfig.Valid { - var fs vfs.Filesystem - err = json.Unmarshal([]byte(fsConfig.String), &fs) - if err == nil { - folder.FsConfig = fs + if minimal { + err = rows.Scan(&folder.ID, &folder.Name) + if err != nil { + return folders, err + } + } else { + var mappedPath, description, fsConfig sql.NullString + err = rows.Scan(&folder.ID, &mappedPath, &folder.UsedQuotaSize, &folder.UsedQuotaFiles, + &folder.LastQuotaUpdate, &folder.Name, &description, &fsConfig) + if err != nil { + return folders, err + } + if mappedPath.Valid { + folder.MappedPath = mappedPath.String + } + if description.Valid { + folder.Description = description.String + } + if fsConfig.Valid { + var fs vfs.Filesystem + err = json.Unmarshal([]byte(fsConfig.String), &fs) + if err == nil { + folder.FsConfig = fs + } } } folder.PrepareForRendering() @@ -1881,11 +2267,18 @@ func sqlCommonGetFolders(limit, offset int, order string, dbHandle sqlQuerier) ( if err != nil { return folders, err } - return getVirtualFoldersWithUsers(folders, dbHandle) + if minimal { + return folders, nil + } + folders, err = getVirtualFoldersWithUsers(folders, dbHandle) + if err != nil { + return folders, err + } + return getVirtualFoldersWithGroups(folders, dbHandle) } -func sqlCommonClearFolderMapping(ctx context.Context, user *User, dbHandle sqlQuerier) error { - q := getClearFolderMappingQuery() +func sqlCommonClearUserFolderMapping(ctx context.Context, user *User, dbHandle sqlQuerier) error { + q := getClearUserFolderMappingQuery() stmt, err := dbHandle.PrepareContext(ctx, q) if err != nil { providerLog(logger.LevelError, "error preparing database query %#v: %v", q, err) @@ -1896,8 +2289,32 @@ func sqlCommonClearFolderMapping(ctx context.Context, user *User, dbHandle sqlQu return err } -func sqlCommonAddFolderMapping(ctx context.Context, user *User, folder *vfs.VirtualFolder, dbHandle sqlQuerier) error { - q := getAddFolderMappingQuery() +func sqlCommonClearGroupFolderMapping(ctx context.Context, group *Group, dbHandle sqlQuerier) error { + q := getClearGroupFolderMappingQuery() + stmt, err := dbHandle.PrepareContext(ctx, q) + if err != nil { + providerLog(logger.LevelError, "error preparing database query %#v: %v", q, err) + return err + } + defer stmt.Close() + _, err = stmt.ExecContext(ctx, group.Name) + return err +} + +func sqlCommonClearUserGroupMapping(ctx context.Context, user *User, dbHandle sqlQuerier) error { + q := getClearUserGroupMappingQuery() + stmt, err := dbHandle.PrepareContext(ctx, q) + if err != nil { + providerLog(logger.LevelError, "error preparing database query %#v: %v", q, err) + return err + } + defer stmt.Close() + _, err = stmt.ExecContext(ctx, user.Username) + return err +} + +func sqlCommonAddUserFolderMapping(ctx context.Context, user *User, folder *vfs.VirtualFolder, dbHandle sqlQuerier) error { + q := getAddUserFolderMappingQuery() stmt, err := dbHandle.PrepareContext(ctx, q) if err != nil { providerLog(logger.LevelError, "error preparing database query %#v: %v", q, err) @@ -1908,8 +2325,52 @@ func sqlCommonAddFolderMapping(ctx context.Context, user *User, folder *vfs.Virt return err } -func generateVirtualFoldersMapping(ctx context.Context, user *User, dbHandle sqlQuerier) error { - err := sqlCommonClearFolderMapping(ctx, user, dbHandle) +func sqlCommonAddGroupFolderMapping(ctx context.Context, group *Group, folder *vfs.VirtualFolder, dbHandle sqlQuerier) error { + q := getAddGroupFolderMappingQuery() + stmt, err := dbHandle.PrepareContext(ctx, q) + if err != nil { + providerLog(logger.LevelError, "error preparing database query %#v: %v", q, err) + return err + } + defer stmt.Close() + _, err = stmt.ExecContext(ctx, folder.VirtualPath, folder.QuotaSize, folder.QuotaFiles, folder.Name, group.Name) + return err +} + +func sqlCommonAddUserGroupMapping(ctx context.Context, username, groupName string, groupType int, dbHandle sqlQuerier) error { + q := getAddUserGroupMappingQuery() + stmt, err := dbHandle.PrepareContext(ctx, q) + if err != nil { + providerLog(logger.LevelError, "error preparing database query %#v: %v", q, err) + return err + } + defer stmt.Close() + _, err = stmt.ExecContext(ctx, username, groupName, groupType) + return err +} + +func generateGroupVirtualFoldersMapping(ctx context.Context, group *Group, dbHandle sqlQuerier) error { + err := sqlCommonClearGroupFolderMapping(ctx, group, dbHandle) + if err != nil { + return err + } + for idx := range group.VirtualFolders { + vfolder := &group.VirtualFolders[idx] + f, err := sqlCommonAddOrUpdateFolder(ctx, &vfolder.BaseVirtualFolder, 0, 0, 0, dbHandle) + if err != nil { + return err + } + vfolder.BaseVirtualFolder = f + err = sqlCommonAddGroupFolderMapping(ctx, group, vfolder, dbHandle) + if err != nil { + return err + } + } + return err +} + +func generateUserVirtualFoldersMapping(ctx context.Context, user *User, dbHandle sqlQuerier) error { + err := sqlCommonClearUserFolderMapping(ctx, user, dbHandle) if err != nil { return err } @@ -1920,7 +2381,7 @@ func generateVirtualFoldersMapping(ctx context.Context, user *User, dbHandle sql return err } vfolder.BaseVirtualFolder = f - err = sqlCommonAddFolderMapping(ctx, user, vfolder, dbHandle) + err = sqlCommonAddUserFolderMapping(ctx, user, vfolder, dbHandle) if err != nil { return err } @@ -1928,15 +2389,18 @@ func generateVirtualFoldersMapping(ctx context.Context, user *User, dbHandle sql return err } -func getUserWithVirtualFolders(ctx context.Context, user User, dbHandle sqlQuerier) (User, error) { - users, err := getUsersWithVirtualFolders(ctx, []User{user}, dbHandle) +func generateUserGroupMapping(ctx context.Context, user *User, dbHandle sqlQuerier) error { + err := sqlCommonClearUserGroupMapping(ctx, user, dbHandle) if err != nil { - return user, err + return err } - if len(users) == 0 { - return user, errSQLFoldersAssosaction + for _, group := range user.Groups { + err = sqlCommonAddUserGroupMapping(ctx, user.Username, group.Name, group.Type, dbHandle) + if err != nil { + return err + } } - return users[0], err + return err } func getDefenderHostsWithScores(ctx context.Context, hosts []DefenderEntry, from int64, idForScores []int64, @@ -1994,6 +2458,17 @@ func getDefenderHostsWithScores(ctx context.Context, hosts []DefenderEntry, from return result, nil } +func getUserWithVirtualFolders(ctx context.Context, user User, dbHandle sqlQuerier) (User, error) { + users, err := getUsersWithVirtualFolders(ctx, []User{user}, dbHandle) + if err != nil { + return user, err + } + if len(users) == 0 { + return user, errSQLFoldersAssociation + } + return users[0], err +} + func getUsersWithVirtualFolders(ctx context.Context, users []User, dbHandle sqlQuerier) ([]User, error) { if len(users) == 0 { return users, nil @@ -2052,13 +2527,232 @@ func getUsersWithVirtualFolders(ctx context.Context, users []User, dbHandle sqlQ return users, err } +func getUserWithGroups(ctx context.Context, user User, dbHandle sqlQuerier) (User, error) { + users, err := getUsersWithGroups(ctx, []User{user}, dbHandle) + if err != nil { + return user, err + } + if len(users) == 0 { + return user, errSQLGroupsAssociation + } + return users[0], err +} + +func getUsersWithGroups(ctx context.Context, users []User, dbHandle sqlQuerier) ([]User, error) { + if len(users) == 0 { + return users, nil + } + var err error + usersGroups := make(map[int64][]sdk.GroupMapping) + q := getRelatedGroupsForUsersQuery(users) + stmt, err := dbHandle.PrepareContext(ctx, q) + if err != nil { + providerLog(logger.LevelError, "error preparing database query %#v: %v", q, err) + return nil, err + } + defer stmt.Close() + rows, err := stmt.QueryContext(ctx) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var group sdk.GroupMapping + var userID int64 + err = rows.Scan(&group.Name, &group.Type, &userID) + if err != nil { + return users, err + } + usersGroups[userID] = append(usersGroups[userID], group) + } + err = rows.Err() + if err != nil { + return users, err + } + if len(usersGroups) == 0 { + return users, err + } + for idx := range users { + ref := &users[idx] + ref.Groups = usersGroups[ref.ID] + } + return users, err +} + +func getGroupWithUsers(ctx context.Context, group Group, dbHandle sqlQuerier) (Group, error) { + groups, err := getGroupsWithUsers(ctx, []Group{group}, dbHandle) + if err != nil { + return group, err + } + if len(groups) == 0 { + return group, errSQLUsersAssociation + } + return groups[0], err +} + +func getGroupWithVirtualFolders(ctx context.Context, group Group, dbHandle sqlQuerier) (Group, error) { + groups, err := getGroupsWithVirtualFolders(ctx, []Group{group}, dbHandle) + if err != nil { + return group, err + } + if len(groups) == 0 { + return group, errSQLFoldersAssociation + } + return groups[0], err +} + +func getGroupsWithVirtualFolders(ctx context.Context, groups []Group, dbHandle sqlQuerier) ([]Group, error) { + if len(groups) == 0 { + return groups, nil + } + + var err error + q := getRelatedFoldersForGroupsQuery(groups) + stmt, err := dbHandle.PrepareContext(ctx, q) + if err != nil { + providerLog(logger.LevelError, "error preparing database query %#v: %v", q, err) + return nil, err + } + defer stmt.Close() + rows, err := stmt.QueryContext(ctx) + if err != nil { + return nil, err + } + defer rows.Close() + groupsVirtualFolders := make(map[int64][]vfs.VirtualFolder) + + for rows.Next() { + var groupID int64 + var folder vfs.VirtualFolder + var mappedPath, fsConfig, description sql.NullString + err = rows.Scan(&folder.ID, &folder.Name, &mappedPath, &folder.UsedQuotaSize, &folder.UsedQuotaFiles, + &folder.LastQuotaUpdate, &folder.VirtualPath, &folder.QuotaSize, &folder.QuotaFiles, &groupID, &fsConfig, + &description) + if err != nil { + return groups, err + } + if mappedPath.Valid { + folder.MappedPath = mappedPath.String + } + if description.Valid { + folder.Description = description.String + } + if fsConfig.Valid { + var fs vfs.Filesystem + err = json.Unmarshal([]byte(fsConfig.String), &fs) + if err == nil { + folder.FsConfig = fs + } + } + groupsVirtualFolders[groupID] = append(groupsVirtualFolders[groupID], folder) + } + err = rows.Err() + if err != nil { + return groups, err + } + if len(groupsVirtualFolders) == 0 { + return groups, err + } + for idx := range groups { + ref := &groups[idx] + ref.VirtualFolders = groupsVirtualFolders[ref.ID] + } + return groups, err +} + +func getGroupsWithUsers(ctx context.Context, groups []Group, dbHandle sqlQuerier) ([]Group, error) { + if len(groups) == 0 { + return groups, nil + } + + var err error + q := getRelatedUsersForGroupsQuery(groups) + stmt, err := dbHandle.PrepareContext(ctx, q) + if err != nil { + providerLog(logger.LevelError, "error preparing database query %#v: %v", q, err) + return nil, err + } + defer stmt.Close() + rows, err := stmt.QueryContext(ctx) + if err != nil { + return nil, err + } + defer rows.Close() + groupsUsers := make(map[int64][]string) + + for rows.Next() { + var username string + var groupID int64 + err = rows.Scan(&groupID, &username) + if err != nil { + return groups, err + } + groupsUsers[groupID] = append(groupsUsers[groupID], username) + } + err = rows.Err() + if err != nil { + return groups, err + } + if len(groupsUsers) == 0 { + return groups, err + } + for idx := range groups { + ref := &groups[idx] + ref.Users = groupsUsers[ref.ID] + } + return groups, err +} + +func getVirtualFoldersWithGroups(folders []vfs.BaseVirtualFolder, dbHandle sqlQuerier) ([]vfs.BaseVirtualFolder, error) { + if len(folders) == 0 { + return folders, nil + } + var err error + vFoldersGroups := make(map[int64][]string) + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + q := getRelatedGroupsForFoldersQuery(folders) + stmt, err := dbHandle.PrepareContext(ctx, q) + if err != nil { + providerLog(logger.LevelError, "error preparing database query %#v: %v", q, err) + return nil, err + } + defer stmt.Close() + rows, err := stmt.QueryContext(ctx) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var name string + var folderID int64 + err = rows.Scan(&folderID, &name) + if err != nil { + return folders, err + } + vFoldersGroups[folderID] = append(vFoldersGroups[folderID], name) + } + err = rows.Err() + if err != nil { + return folders, err + } + if len(vFoldersGroups) == 0 { + return folders, err + } + for idx := range folders { + ref := &folders[idx] + ref.Groups = vFoldersGroups[ref.ID] + } + return folders, err +} + func getVirtualFoldersWithUsers(folders []vfs.BaseVirtualFolder, dbHandle sqlQuerier) ([]vfs.BaseVirtualFolder, error) { if len(folders) == 0 { return folders, nil } var err error - vFoldersUsers := make(map[int64][]string) ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) defer cancel() q := getRelatedUsersForFoldersQuery(folders) @@ -2073,6 +2767,8 @@ func getVirtualFoldersWithUsers(folders []vfs.BaseVirtualFolder, dbHandle sqlQue return nil, err } defer rows.Close() + + vFoldersUsers := make(map[int64][]string) for rows.Next() { var username string var folderID int64 diff --git a/dataprovider/sqlite.go b/dataprovider/sqlite.go index ae213bf3..b7f44fd3 100644 --- a/dataprovider/sqlite.go +++ b/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 -}*/ +} diff --git a/dataprovider/sqlqueries.go b/dataprovider/sqlqueries.go index e2615ec1..aff9a067 100644 --- a/dataprovider/sqlqueries.go +++ b/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 { diff --git a/dataprovider/user.go b/dataprovider/user.go index 6a0dacea..11ed53ef 100644 --- a/dataprovider/user.go +++ b/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, } } diff --git a/docs/groups.md b/docs/groups.md new file mode 100644 index 00000000..cd76c71d --- /dev/null +++ b/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. diff --git a/ftpd/ftpd_test.go b/ftpd/ftpd_test.go index dab9b489..89cc4fe6 100644 --- a/ftpd/ftpd_test.go +++ b/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{ diff --git a/go.mod b/go.mod index 1138ff88..d6e6c494 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index df2f7277..da144c57 100644 --- a/go.sum +++ b/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= diff --git a/httpd/api_folder.go b/httpd/api_folder.go index 6938fa01..273b876b 100644 --- a/httpd/api_folder.go +++ b/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 diff --git a/httpd/api_group.go b/httpd/api_group.go new file mode 100644 index 00000000..ead160e6 --- /dev/null +++ b/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) +} diff --git a/httpd/api_http_user.go b/httpd/api_http_user.go index 2d282e84..09dacff7 100644 --- a/httpd/api_http_user.go +++ b/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) { diff --git a/httpd/api_maintenance.go b/httpd/api_maintenance.go index 48b6ec6d..800176bd 100644 --- a/httpd/api_maintenance.go +++ b/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) diff --git a/httpd/api_metadata.go b/httpd/api_metadata.go index 303af531..92fa6173 100644 --- a/httpd/api_metadata.go +++ b/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 diff --git a/httpd/api_quota.go b/httpd/api_quota.go index bfc85826..d1edece3 100644 --- a/httpd/api_quota.go +++ b/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 diff --git a/httpd/api_retention.go b/httpd/api_retention.go index 3a0745e8..729b2361 100644 --- a/httpd/api_retention.go +++ b/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 diff --git a/httpd/api_shares.go b/httpd/api_shares.go index 4d034c88..44d9e39a 100644 --- a/httpd/api_shares.go +++ b/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 } diff --git a/httpd/api_utils.go b/httpd/api_utils.go index 15763a11..ef97a952 100644 --- a/httpd/api_utils.go +++ b/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 { diff --git a/httpd/httpd.go b/httpd/httpd.go index e4825266..181d8e56 100644 --- a/httpd/httpd.go +++ b/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) diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index b4730af6..a629a9d9 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -74,6 +74,7 @@ const ( adminPath = "/api/v2/admins" adminPwdPath = "/api/v2/admin/changepwd" folderPath = "/api/v2/folders" + groupPath = "/api/v2/groups" activeConnectionsPath = "/api/v2/connections" serverStatusPath = "/api/v2/status" quotasBasePath = "/api/v2/quotas" @@ -123,6 +124,8 @@ const ( webLogoutPath = "/web/admin/logout" webUsersPath = "/web/admin/users" webUserPath = "/web/admin/user" + webGroupsPath = "/web/admin/groups" + webGroupPath = "/web/admin/group" webFoldersPath = "/web/admin/folders" webFolderPath = "/web/admin/folder" webConnectionsPath = "/web/admin/connections" @@ -534,6 +537,484 @@ func TestBasicUserHandling(t *testing.T) { assert.NoError(t, err) } +func TestBasicGroupHandling(t *testing.T) { + g := getTestGroup() + group, _, err := httpdtest.AddGroup(g, http.StatusCreated) + assert.NoError(t, err) + assert.Greater(t, group.CreatedAt, int64(0)) + assert.Greater(t, group.UpdatedAt, int64(0)) + groupGet, _, err := httpdtest.GetGroupByName(group.Name, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, group, groupGet) + groups, _, err := httpdtest.GetGroups(0, 0, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, groups, 1) { + assert.Equal(t, group, groups[0]) + } + groups, _, err = httpdtest.GetGroups(0, 1, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, groups, 0) + _, _, err = httpdtest.GetGroupByName(group.Name+"_", http.StatusNotFound) + assert.NoError(t, err) + // adding the same group again should fail + _, _, err = httpdtest.AddGroup(g, http.StatusInternalServerError) + assert.NoError(t, err) + + group.UserSettings.HomeDir = filepath.Join(os.TempDir(), "%username%") + group.UserSettings.FsConfig.Provider = sdk.SFTPFilesystemProvider + group.UserSettings.FsConfig.SFTPConfig.Endpoint = sftpServerAddr + group.UserSettings.FsConfig.SFTPConfig.Username = defaultUsername + group.UserSettings.FsConfig.SFTPConfig.Password = kms.NewPlainSecret(defaultPassword) + group, _, err = httpdtest.UpdateGroup(group, http.StatusOK) + assert.NoError(t, err) + // update again and check that the password was preserved + dbGroup, err := dataprovider.GroupExists(group.Name) + assert.NoError(t, err) + group.UserSettings.FsConfig.SFTPConfig.Password = kms.NewSecret( + dbGroup.UserSettings.FsConfig.SFTPConfig.Password.GetStatus(), + dbGroup.UserSettings.FsConfig.SFTPConfig.Password.GetPayload(), "", "") + group, _, err = httpdtest.UpdateGroup(group, http.StatusOK) + assert.NoError(t, err) + dbGroup, err = dataprovider.GroupExists(group.Name) + assert.NoError(t, err) + err = dbGroup.UserSettings.FsConfig.SFTPConfig.Password.Decrypt() + assert.NoError(t, err) + assert.Equal(t, defaultPassword, dbGroup.UserSettings.FsConfig.SFTPConfig.Password.GetPayload()) + + group.UserSettings.HomeDir = "relative path" + _, _, err = httpdtest.UpdateGroup(group, http.StatusBadRequest) + assert.NoError(t, err) + + _, err = httpdtest.RemoveGroup(group, http.StatusOK) + assert.NoError(t, err) + _, _, err = httpdtest.UpdateGroup(group, http.StatusNotFound) + assert.NoError(t, err) + _, err = httpdtest.RemoveGroup(group, http.StatusNotFound) + assert.NoError(t, err) +} + +func TestGroupRelations(t *testing.T) { + mappedPath1 := filepath.Join(os.TempDir(), util.GenerateUniqueID()) + folderName1 := filepath.Base(mappedPath1) + mappedPath2 := filepath.Join(os.TempDir(), util.GenerateUniqueID()) + folderName2 := filepath.Base(mappedPath2) + _, _, err := httpdtest.AddFolder(vfs.BaseVirtualFolder{ + Name: folderName2, + MappedPath: mappedPath2, + }, http.StatusCreated) + assert.NoError(t, err) + g1 := getTestGroup() + g1.Name += "_1" + g1.VirtualFolders = append(g1.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + Name: folderName1, + MappedPath: mappedPath1, + }, + VirtualPath: "/vdir1", + }) + g2 := getTestGroup() + g2.Name += "_2" + g2.VirtualFolders = append(g2.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + Name: folderName1, + MappedPath: mappedPath1, + }, + VirtualPath: "/vdir2", + }) + g3 := getTestGroup() + g3.Name += "_3" + g3.VirtualFolders = append(g3.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + Name: folderName1, + MappedPath: mappedPath1, + }, + VirtualPath: "/vdir3", + }) + group1, resp, err := httpdtest.AddGroup(g1, http.StatusCreated) + assert.NoError(t, err, string(resp)) + assert.Len(t, group1.VirtualFolders, 1) + group2, resp, err := httpdtest.AddGroup(g2, http.StatusCreated) + assert.NoError(t, err, string(resp)) + assert.Len(t, group2.VirtualFolders, 1) + group3, resp, err := httpdtest.AddGroup(g3, http.StatusCreated) + assert.NoError(t, err, string(resp)) + assert.Len(t, group3.VirtualFolders, 1) + + folder1, _, err := httpdtest.GetFolderByName(folderName1, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, folder1.Groups, 3) + folder2, _, err := httpdtest.GetFolderByName(folderName2, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, folder2.Groups, 0) + + group1.VirtualFolders = append(group1.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: folder2, + VirtualPath: "/vfolder2", + }) + group1, _, err = httpdtest.UpdateGroup(group1, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, group1.VirtualFolders, 2) + + folder2, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, folder2.Groups, 1) + + group1.VirtualFolders = []vfs.VirtualFolder{ + { + BaseVirtualFolder: vfs.BaseVirtualFolder{ + Name: folder1.Name, + MappedPath: folder1.MappedPath, + }, + VirtualPath: "/vpathmod", + }, + } + group1, _, err = httpdtest.UpdateGroup(group1, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, group1.VirtualFolders, 1) + + folder2, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, folder2.Groups, 0) + + group1.VirtualFolders = append(group1.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: folder2, + VirtualPath: "/vfolder2mod", + }) + group1, _, err = httpdtest.UpdateGroup(group1, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, group1.VirtualFolders, 2) + + folder2, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, folder2.Groups, 1) + + u := getTestUser() + u.Groups = []sdk.GroupMapping{ + { + Name: group1.Name, + Type: sdk.GroupTypePrimary, + }, + { + Name: group2.Name, + Type: sdk.GroupTypeSecondary, + }, + { + Name: group3.Name, + Type: sdk.GroupTypeSecondary, + }, + } + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + if assert.Len(t, user.Groups, 3) { + for _, g := range user.Groups { + if g.Name == group1.Name { + assert.Equal(t, sdk.GroupTypePrimary, g.Type) + } else { + assert.Equal(t, sdk.GroupTypeSecondary, g.Type) + } + } + } + group1, _, err = httpdtest.GetGroupByName(group1.Name, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, group1.Users, 1) + group2, _, err = httpdtest.GetGroupByName(group2.Name, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, group2.Users, 1) + group3, _, err = httpdtest.GetGroupByName(group3.Name, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, group3.Users, 1) + + user.Groups = []sdk.GroupMapping{ + { + Name: group3.Name, + Type: sdk.GroupTypeSecondary, + }, + } + user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + assert.Len(t, user.Groups, 1) + + group1, _, err = httpdtest.GetGroupByName(group1.Name, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, group1.Users, 0) + group2, _, err = httpdtest.GetGroupByName(group2.Name, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, group2.Users, 0) + group3, _, err = httpdtest.GetGroupByName(group3.Name, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, group3.Users, 1) + + user.Groups = []sdk.GroupMapping{ + { + Name: group1.Name, + Type: sdk.GroupTypePrimary, + }, + { + Name: group2.Name, + Type: sdk.GroupTypeSecondary, + }, + { + Name: group3.Name, + Type: sdk.GroupTypeSecondary, + }, + } + user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + assert.Len(t, user.Groups, 3) + + group1, _, err = httpdtest.GetGroupByName(group1.Name, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, group1.Users, 1) + group2, _, err = httpdtest.GetGroupByName(group2.Name, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, group2.Users, 1) + group3, _, err = httpdtest.GetGroupByName(group3.Name, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, group3.Users, 1) + + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveFolder(folder1, http.StatusOK) + assert.NoError(t, err) + group1, _, err = httpdtest.GetGroupByName(group1.Name, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, group1.Users, 0) + assert.Len(t, group1.VirtualFolders, 1) + group2, _, err = httpdtest.GetGroupByName(group2.Name, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, group2.Users, 0) + assert.Len(t, group2.VirtualFolders, 0) + group3, _, err = httpdtest.GetGroupByName(group3.Name, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, group3.Users, 0) + assert.Len(t, group3.VirtualFolders, 0) + _, err = httpdtest.RemoveGroup(group1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveGroup(group2, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveGroup(group3, http.StatusOK) + assert.NoError(t, err) + folder2, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, folder2.Groups, 0) + _, err = httpdtest.RemoveFolder(folder2, http.StatusOK) + assert.NoError(t, err) +} + +func TestGroupValidation(t *testing.T) { + group := getTestGroup() + group.VirtualFolders = []vfs.VirtualFolder{ + { + BaseVirtualFolder: vfs.BaseVirtualFolder{ + Name: util.GenerateUniqueID(), + MappedPath: filepath.Join(os.TempDir(), util.GenerateUniqueID()), + }, + }, + } + _, resp, err := httpdtest.AddGroup(group, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "virtual path is mandatory") + group.VirtualFolders = nil + group.UserSettings.FsConfig.Provider = sdk.SFTPFilesystemProvider + _, resp, err = httpdtest.AddGroup(group, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "endpoint cannot be empty") + group.UserSettings.FsConfig.Provider = sdk.LocalFilesystemProvider + group.UserSettings.Permissions = map[string][]string{ + "a": nil, + } + _, resp, err = httpdtest.AddGroup(group, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "cannot set permissions for non absolute path") + group.UserSettings.Permissions = map[string][]string{ + "/": nil, + } + _, resp, err = httpdtest.AddGroup(group, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "no permissions granted") + group.UserSettings.Permissions = map[string][]string{ + "/..": nil, + } + _, resp, err = httpdtest.AddGroup(group, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "cannot set permissions for invalid subdirectory") + group.UserSettings.Permissions = map[string][]string{ + "/": {dataprovider.PermAny}, + } + group.UserSettings.HomeDir = "relative" + _, resp, err = httpdtest.AddGroup(group, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "home_dir must be an absolute path") + group.UserSettings.HomeDir = "" + group.UserSettings.Filters.WebClient = []string{"invalid permission"} + _, resp, err = httpdtest.AddGroup(group, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "invalid web client options") +} + +func TestGroupSettingsOverride(t *testing.T) { + mappedPath1 := filepath.Join(os.TempDir(), util.GenerateUniqueID()) + folderName1 := filepath.Base(mappedPath1) + mappedPath2 := filepath.Join(os.TempDir(), util.GenerateUniqueID()) + folderName2 := filepath.Base(mappedPath2) + g1 := getTestGroup() + g1.Name += "_1" + g1.VirtualFolders = append(g1.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + Name: folderName1, + MappedPath: mappedPath1, + }, + VirtualPath: "/vdir1", + }) + g2 := getTestGroup() + g2.Name += "_2" + g2.VirtualFolders = append(g2.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + Name: folderName1, + MappedPath: mappedPath1, + }, + VirtualPath: "/vdir2", + }) + g2.VirtualFolders = append(g2.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + Name: folderName2, + MappedPath: mappedPath2, + }, + VirtualPath: "/vdir3", + }) + group1, resp, err := httpdtest.AddGroup(g1, http.StatusCreated) + assert.NoError(t, err, string(resp)) + group2, resp, err := httpdtest.AddGroup(g2, http.StatusCreated) + assert.NoError(t, err, string(resp)) + u := getTestUser() + u.Groups = []sdk.GroupMapping{ + { + Name: group1.Name, + Type: sdk.GroupTypePrimary, + }, + { + Name: group2.Name, + Type: sdk.GroupTypeSecondary, + }, + } + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + assert.Len(t, user.VirtualFolders, 0) + + user, err = dataprovider.CheckUserAndPass(defaultUsername, defaultPassword, "", common.ProtocolHTTP) + assert.NoError(t, err) + assert.Len(t, user.VirtualFolders, 3) + + user, err = dataprovider.GetUserAfterIDPAuth(defaultUsername, "", common.ProtocolOIDC, nil) + assert.NoError(t, err) + assert.Len(t, user.VirtualFolders, 3) + + user1, user2, err := dataprovider.GetUserVariants(defaultUsername) + assert.NoError(t, err) + assert.Len(t, user1.VirtualFolders, 0) + assert.Len(t, user2.VirtualFolders, 3) + + group2.UserSettings.FsConfig = vfs.Filesystem{ + Provider: sdk.SFTPFilesystemProvider, + SFTPConfig: vfs.SFTPFsConfig{ + BaseSFTPFsConfig: sdk.BaseSFTPFsConfig{ + Endpoint: sftpServerAddr, + Username: defaultUsername, + }, + Password: kms.NewPlainSecret(defaultPassword), + }, + } + group2.UserSettings.Permissions = map[string][]string{ + "/": {dataprovider.PermListItems, dataprovider.PermDownload}, + "/%username%": {dataprovider.PermListItems}, + } + group2.UserSettings.DownloadBandwidth = 128 + group2.UserSettings.UploadBandwidth = 256 + group2.UserSettings.Filters.WebClient = []string{sdk.WebClientInfoChangeDisabled, sdk.WebClientMFADisabled} + _, _, err = httpdtest.UpdateGroup(group2, http.StatusOK) + assert.NoError(t, err) + user, err = dataprovider.CheckUserAndPass(defaultUsername, defaultPassword, "", common.ProtocolHTTP) + assert.NoError(t, err) + assert.Len(t, user.VirtualFolders, 3) + assert.Equal(t, sdk.LocalFilesystemProvider, user.FsConfig.Provider) + assert.Equal(t, int64(0), user.DownloadBandwidth) + assert.Equal(t, int64(0), user.UploadBandwidth) + assert.Equal(t, []string{dataprovider.PermAny}, user.GetPermissionsForPath("/")) + assert.Equal(t, []string{dataprovider.PermListItems}, user.GetPermissionsForPath("/"+defaultUsername)) + assert.Len(t, user.Filters.WebClient, 2) + + group1.UserSettings.FsConfig = vfs.Filesystem{ + Provider: sdk.SFTPFilesystemProvider, + SFTPConfig: vfs.SFTPFsConfig{ + BaseSFTPFsConfig: sdk.BaseSFTPFsConfig{ + Endpoint: sftpServerAddr, + Username: altAdminUsername, + Prefix: "/dirs/%username%", + }, + Password: kms.NewPlainSecret(defaultPassword), + }, + } + group1.UserSettings.MaxSessions = 2 + group1.UserSettings.QuotaFiles = 1000 + group1.UserSettings.UploadBandwidth = 512 + group1.UserSettings.DownloadBandwidth = 1024 + group1.UserSettings.TotalDataTransfer = 2048 + group1.UserSettings.Filters.MaxUploadFileSize = 1024 * 1024 + group1.UserSettings.Filters.StartDirectory = "/startdir/%username%" + group1.UserSettings.Filters.WebClient = []string{sdk.WebClientInfoChangeDisabled} + group1.UserSettings.Permissions = map[string][]string{ + "/": {dataprovider.PermListItems, dataprovider.PermUpload}, + "/sub/%username%": {dataprovider.PermRename}, + "/%username%": {dataprovider.PermDelete}, + } + group1.UserSettings.Filters.FilePatterns = []sdk.PatternsFilter{ + { + Path: "/sub2/%username%test", + AllowedPatterns: []string{}, + DeniedPatterns: []string{"*.jpg", "*.zip"}, + }, + } + _, _, err = httpdtest.UpdateGroup(group1, http.StatusOK) + assert.NoError(t, err) + user, err = dataprovider.CheckUserAndPass(defaultUsername, defaultPassword, "", common.ProtocolHTTP) + assert.NoError(t, err) + assert.Len(t, user.VirtualFolders, 3) + assert.Equal(t, sdk.SFTPFilesystemProvider, user.FsConfig.Provider) + assert.Equal(t, altAdminUsername, user.FsConfig.SFTPConfig.Username) + assert.Equal(t, "/dirs/"+defaultUsername, user.FsConfig.SFTPConfig.Prefix) + assert.Equal(t, []string{dataprovider.PermListItems, dataprovider.PermUpload}, user.GetPermissionsForPath("/")) + assert.Equal(t, []string{dataprovider.PermDelete}, user.GetPermissionsForPath("/"+defaultUsername)) + assert.Equal(t, []string{dataprovider.PermRename}, user.GetPermissionsForPath("/sub/"+defaultUsername)) + assert.Equal(t, group1.UserSettings.MaxSessions, user.MaxSessions) + assert.Equal(t, group1.UserSettings.QuotaFiles, user.QuotaFiles) + assert.Equal(t, group1.UserSettings.UploadBandwidth, user.UploadBandwidth) + assert.Equal(t, group1.UserSettings.TotalDataTransfer, user.TotalDataTransfer) + assert.Equal(t, group1.UserSettings.Filters.MaxUploadFileSize, user.Filters.MaxUploadFileSize) + assert.Equal(t, "/startdir/"+defaultUsername, user.Filters.StartDirectory) + if assert.Len(t, user.Filters.FilePatterns, 1) { + assert.Equal(t, "/sub2/"+defaultUsername+"test", user.Filters.FilePatterns[0].Path) + } + if assert.Len(t, user.Filters.WebClient, 2) { + assert.Contains(t, user.Filters.WebClient, sdk.WebClientInfoChangeDisabled) + assert.Contains(t, user.Filters.WebClient, sdk.WebClientMFADisabled) + } + + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveGroup(group1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveGroup(group2, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveFolder(vfs.BaseVirtualFolder{Name: folderName1}, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveFolder(vfs.BaseVirtualFolder{Name: folderName2}, http.StatusOK) + assert.NoError(t, err) +} + func TestUserTransferLimits(t *testing.T) { u := getTestUser() u.TotalDataTransfer = 100 @@ -4009,8 +4490,7 @@ func TestNamingRules(t *testing.T) { req.RemoteAddr = defaultRemoteAddr req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = executeRequest(req) - assert.Equal(t, http.StatusOK, rr.Code) - assert.Contains(t, rr.Body.String(), "Unable to set the new password") + assert.Equal(t, http.StatusFound, rr.Code) adminAPIToken, err = getJWTAPITokenFromTestServer(admin.Username, defaultTokenAuthPass) assert.NoError(t, err) @@ -4106,7 +4586,7 @@ func TestNamingRules(t *testing.T) { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = executeRequest(req) assert.Equal(t, http.StatusOK, rr.Code) - assert.Contains(t, rr.Body.String(), "Unable to set the new password") + assert.Contains(t, rr.Body.String(), "unable to set the new password") _, err = httpdtest.RemoveAdmin(admin, http.StatusOK) assert.NoError(t, err) @@ -4350,6 +4830,8 @@ func TestProviderErrors(t *testing.T) { assert.NoError(t, err) _, _, err = httpdtest.GetUsers(1, 0, http.StatusInternalServerError) assert.NoError(t, err) + _, _, err = httpdtest.GetGroups(1, 0, http.StatusInternalServerError) + assert.NoError(t, err) _, _, err = httpdtest.GetAdmins(1, 0, http.StatusInternalServerError) assert.NoError(t, err) _, _, err = httpdtest.GetAPIKeys(1, 0, http.StatusInternalServerError) @@ -4426,6 +4908,14 @@ func TestProviderErrors(t *testing.T) { assert.NoError(t, err) backupData.Users = nil backupData.Folders = nil + backupData.Groups = append(backupData.Groups, getTestGroup()) + backupContent, err = json.Marshal(backupData) + assert.NoError(t, err) + err = os.WriteFile(backupFilePath, backupContent, os.ModePerm) + assert.NoError(t, err) + _, _, err = httpdtest.Loaddata(backupFilePath, "", "", http.StatusInternalServerError) + assert.NoError(t, err) + backupData.Groups = nil backupData.Admins = append(backupData.Admins, getTestAdmin()) backupContent, err = json.Marshal(backupData) assert.NoError(t, err) @@ -4474,6 +4964,26 @@ func TestProviderErrors(t *testing.T) { setJWTCookieForReq(req, testServerToken) rr = executeRequest(req) checkResponseCode(t, http.StatusInternalServerError, rr) + req, err = http.NewRequest(http.MethodGet, webGroupPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, testServerToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + req, err = http.NewRequest(http.MethodGet, webGroupsPath+"?qlimit=a", nil) + assert.NoError(t, err) + setJWTCookieForReq(req, testServerToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + req, err = http.NewRequest(http.MethodGet, path.Join(webGroupPath, "groupname"), nil) + assert.NoError(t, err) + setJWTCookieForReq(req, testServerToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + req, err = http.NewRequest(http.MethodPost, path.Join(webGroupPath, "grpname"), nil) + assert.NoError(t, err) + setJWTCookieForReq(req, testServerToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) req, err = http.NewRequest(http.MethodGet, webTemplateFolder+"?from=afolder", nil) assert.NoError(t, err) setJWTCookieForReq(req, testServerToken) @@ -4613,6 +5123,8 @@ func TestDumpdata(t *testing.T) { assert.True(t, ok) _, ok = response["users"] assert.True(t, ok) + _, ok = response["groups"] + assert.True(t, ok) _, ok = response["folders"] assert.True(t, ok) _, ok = response["api_keys"] @@ -4880,14 +5392,24 @@ func TestRestoreShares(t *testing.T) { func TestLoaddataFromPostBody(t *testing.T) { mappedPath := filepath.Join(os.TempDir(), "restored_folder") folderName := filepath.Base(mappedPath) + group := getTestGroup() + group.ID = 1 + group.Name = "test_group_restored" user := getTestUser() user.ID = 1 user.Username = "test_user_restored" + user.Groups = []sdk.GroupMapping{ + { + Name: group.Name, + Type: sdk.GroupTypePrimary, + }, + } admin := getTestAdmin() admin.ID = 1 admin.Username = "test_admin_restored" backupData := dataprovider.BackupData{} backupData.Users = append(backupData.Users, user) + backupData.Groups = append(backupData.Groups, group) backupData.Admins = append(backupData.Admins, admin) backupData.Folders = []vfs.BaseVirtualFolder{ { @@ -4940,11 +5462,20 @@ func TestLoaddataFromPostBody(t *testing.T) { assert.NoError(t, err) user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) assert.NoError(t, err) + if assert.Len(t, user.Groups, 1) { + assert.Equal(t, sdk.GroupTypePrimary, user.Groups[0].Type) + assert.Equal(t, group.Name, user.Groups[0].Name) + } _, err = dataprovider.ShareExists(keyID, user.Username) assert.NoError(t, err) _, err = httpdtest.RemoveUser(user, http.StatusOK) assert.NoError(t, err) + group, _, err = httpdtest.GetGroupByName(group.Name, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveGroup(group, http.StatusOK) + assert.NoError(t, err) + admin, _, err = httpdtest.GetAdminByUsername(admin.Username, http.StatusOK) assert.NoError(t, err) _, err = httpdtest.RemoveAdmin(admin, http.StatusOK) @@ -4972,6 +5503,9 @@ func TestLoaddata(t *testing.T) { user := getTestUser() user.ID = 1 user.Username = "test_user_restore" + group := getTestGroup() + group.ID = 1 + group.Name = "test_group_restore" admin := getTestAdmin() admin.ID = 1 admin.Username = "test_admin_restore" @@ -4990,6 +5524,7 @@ func TestLoaddata(t *testing.T) { } backupData := dataprovider.BackupData{} backupData.Users = append(backupData.Users, user) + backupData.Groups = append(backupData.Groups, group) backupData.Admins = append(backupData.Admins, admin) backupData.Folders = []vfs.BaseVirtualFolder{ { @@ -5029,7 +5564,7 @@ func TestLoaddata(t *testing.T) { err = os.Chmod(backupFilePath, 0644) assert.NoError(t, err) } - // add user, folder, admin, API key, share from backup + // add user, group, folder, admin, API key, share from backup _, _, err = httpdtest.Loaddata(backupFilePath, "1", "", http.StatusOK) assert.NoError(t, err) // update from backup @@ -5042,6 +5577,11 @@ func TestLoaddata(t *testing.T) { _, err = httpdtest.RemoveUser(user, http.StatusOK) assert.NoError(t, err) + group, _, err = httpdtest.GetGroupByName(group.Name, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveGroup(group, http.StatusOK) + assert.NoError(t, err) + admin, _, err = httpdtest.GetAdminByUsername(admin.Username, http.StatusOK) assert.NoError(t, err) _, err = httpdtest.RemoveAdmin(admin, http.StatusOK) @@ -5084,6 +5624,15 @@ func TestLoaddataMode(t *testing.T) { user := getTestUser() user.ID = 1 user.Username = "test_user_restore" + group := getTestGroup() + group.ID = 1 + group.Name = "test_group_restore" + user.Groups = []sdk.GroupMapping{ + { + Name: group.Name, + Type: sdk.GroupTypePrimary, + }, + } admin := getTestAdmin() admin.ID = 1 admin.Username = "test_admin_restore" @@ -5103,6 +5652,7 @@ func TestLoaddataMode(t *testing.T) { } backupData := dataprovider.BackupData{} backupData.Users = append(backupData.Users, user) + backupData.Groups = append(backupData.Groups, group) backupData.Admins = append(backupData.Admins, admin) backupData.Folders = []vfs.BaseVirtualFolder{ { @@ -5139,6 +5689,13 @@ func TestLoaddataMode(t *testing.T) { user.UploadBandwidth = oldUploadBandwidth + 128 user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") assert.NoError(t, err) + group, _, err = httpdtest.GetGroupByName(group.Name, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, group.Users, 1) + oldGroupDesc := group.Description + group.Description = "new group description" + group, _, err = httpdtest.UpdateGroup(group, http.StatusOK) + assert.NoError(t, err) admin, _, err = httpdtest.GetAdminByUsername(admin.Username, http.StatusOK) assert.NoError(t, err) oldInfo := admin.AdditionalInfo @@ -5166,6 +5723,9 @@ func TestLoaddataMode(t *testing.T) { } _, _, err = httpdtest.Loaddata(backupFilePath, "0", "1", http.StatusOK) assert.NoError(t, err) + group, _, err = httpdtest.GetGroupByName(group.Name, http.StatusOK) + assert.NoError(t, err) + assert.NotEqual(t, oldGroupDesc, group.Description) folder, _, err = httpdtest.GetFolderByName(folderName, http.StatusOK) assert.NoError(t, err) assert.Equal(t, mappedPath+"1", folder.MappedPath) @@ -5205,8 +5765,13 @@ func TestLoaddataMode(t *testing.T) { user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) assert.NoError(t, err) assert.Equal(t, oldUploadBandwidth, user.UploadBandwidth) + // the group is referenced + _, err = httpdtest.RemoveGroup(group, http.StatusBadRequest) + assert.NoError(t, err) _, err = httpdtest.RemoveUser(user, http.StatusOK) assert.NoError(t, err) + _, err = httpdtest.RemoveGroup(group, http.StatusOK) + assert.NoError(t, err) _, err = httpdtest.RemoveAdmin(admin, http.StatusOK) assert.NoError(t, err) _, err = httpdtest.RemoveFolder(folder, http.StatusOK) @@ -5378,6 +5943,36 @@ func TestAddFolderInvalidJsonMock(t *testing.T) { checkResponseCode(t, http.StatusBadRequest, rr) } +func TestGroupErrorsMock(t *testing.T) { + token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) + assert.NoError(t, err) + reqBody := bytes.NewBuffer([]byte("not a json string")) + + req, err := http.NewRequest(http.MethodPost, groupPath, reqBody) + assert.NoError(t, err) + setBearerForReq(req, token) + rr := executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + req, err = http.NewRequest(http.MethodGet, groupPath+"?limit=a", nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + group, _, err := httpdtest.AddGroup(getTestGroup(), http.StatusCreated) + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodPut, path.Join(groupPath, group.Name), reqBody) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + _, err = httpdtest.RemoveGroup(group, http.StatusOK) + assert.NoError(t, err) +} + func TestUpdateFolderInvalidJsonMock(t *testing.T) { folder := vfs.BaseVirtualFolder{ Name: "name", @@ -7133,6 +7728,66 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) { checkResponseCode(t, http.StatusNotFound, rr) } +func TestPermGroupOverride(t *testing.T) { + g := getTestGroup() + g.UserSettings.Filters.WebClient = []string{sdk.WebClientPasswordChangeDisabled} + group, _, err := httpdtest.AddGroup(g, http.StatusCreated) + assert.NoError(t, err) + u := getTestUser() + u.Groups = []sdk.GroupMapping{ + { + Name: group.Name, + Type: sdk.GroupTypePrimary, + }, + } + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + + pwd := make(map[string]string) + pwd["current_password"] = defaultPassword + pwd["new_password"] = altAdminPassword + asJSON, err := json.Marshal(pwd) + assert.NoError(t, err) + req, err := http.NewRequest(http.MethodPut, userPwdPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr := executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + + group.UserSettings.Filters.TwoFactorAuthProtocols = []string{common.ProtocolHTTP} + group, _, err = httpdtest.UpdateGroup(group, http.StatusOK) + assert.NoError(t, err) + + token, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodGet, userDirsPath, nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "Two-factor authentication requirements not met, please configure two-factor authentication for the following protocols") + + req, err = http.NewRequest(http.MethodGet, webClientFilesPath, nil) + assert.NoError(t, err) + req.RequestURI = webClientFilesPath + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "Two-factor authentication requirements not met, please configure two-factor authentication for the following protocols") + + _, 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 TestWebAPIChangeUserPwdMock(t *testing.T) { user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) assert.NoError(t, err) @@ -10244,13 +10899,13 @@ func TestBrowseShares(t *testing.T) { assert.NoError(t, err) rr = executeRequest(req) checkResponseCode(t, http.StatusBadRequest, rr) - assert.Contains(t, rr.Body.String(), "The shared object is not a directory and so it is not browsable") + assert.Contains(t, rr.Body.String(), "the shared object is not a directory and so it is not browsable") req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path=%2F"), nil) assert.NoError(t, err) rr = executeRequest(req) checkResponseCode(t, http.StatusBadRequest, rr) - assert.Contains(t, rr.Body.String(), "The shared object is not a directory and so it is not browsable") + assert.Contains(t, rr.Body.String(), "the shared object is not a directory and so it is not browsable") // now test a missing shareID objectID = "123456" @@ -10315,7 +10970,7 @@ func TestBrowseShares(t *testing.T) { assert.NoError(t, err) rr = executeRequest(req) checkResponseCode(t, http.StatusBadRequest, rr) - assert.Contains(t, rr.Body.String(), "A share with multiple paths is not browsable") + assert.Contains(t, rr.Body.String(), "a share with multiple paths is not browsable") // share the root path share = dataprovider.Share{ Name: "test share root", @@ -14578,6 +15233,14 @@ func TestWebUserAddMock(t *testing.T) { assert.NoError(t, err) csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) + group1 := getTestGroup() + group1.Name += "_1" + group1, _, err = httpdtest.AddGroup(group1, http.StatusCreated) + assert.NoError(t, err) + group2 := getTestGroup() + group2.Name += "_2" + group2, _, err = httpdtest.AddGroup(group2, http.StatusCreated) + assert.NoError(t, err) user := getTestUser() user.UploadBandwidth = 32 user.DownloadBandwidth = 64 @@ -14606,6 +15269,8 @@ func TestWebUserAddMock(t *testing.T) { form.Set("email", user.Email) form.Set("home_dir", user.HomeDir) form.Set("password", user.Password) + form.Set("primary_group", group1.Name) + form.Set("secondary_groups", group2.Name) form.Set("status", strconv.Itoa(user.Status)) form.Set("expiration_date", "") form.Set("permissions", "*") @@ -14998,7 +15663,7 @@ func TestWebUserAddMock(t *testing.T) { } } } - + assert.Len(t, newUser.Groups, 2) assert.Equal(t, sdk.TLSUsernameNone, newUser.Filters.TLSUsername) req, _ = http.NewRequest(http.MethodDelete, path.Join(userPath, newUser.Username), nil) setBearerForReq(req, apiToken) @@ -15008,6 +15673,10 @@ func TestWebUserAddMock(t *testing.T) { setBearerForReq(req, apiToken) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) + _, err = httpdtest.RemoveGroup(group1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveGroup(group2, http.StatusOK) + assert.NoError(t, err) } func TestWebUserUpdateMock(t *testing.T) { @@ -16649,6 +17318,172 @@ func TestWebUserSFTPFsMock(t *testing.T) { checkResponseCode(t, http.StatusOK, rr) } +func TestAddWebGroup(t *testing.T) { + webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) + assert.NoError(t, err) + apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) + assert.NoError(t, err) + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) + assert.NoError(t, err) + group := getTestGroup() + group.UserSettings = dataprovider.GroupUserSettings{ + BaseGroupUserSettings: sdk.BaseGroupUserSettings{ + HomeDir: filepath.Join(os.TempDir(), util.GenerateUniqueID()), + Permissions: make(map[string][]string), + MaxSessions: 2, + QuotaSize: 123, + QuotaFiles: 10, + UploadBandwidth: 128, + DownloadBandwidth: 256, + }, + } + form := make(url.Values) + form.Set("name", group.Name) + form.Set("description", group.Description) + form.Set("home_dir", group.UserSettings.HomeDir) + b, contentType, err := getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, webGroupPath, &b) + assert.NoError(t, err) + req.Header.Set("Content-Type", contentType) + setJWTCookieForReq(req, webToken) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "invalid max sessions") + form.Set("max_sessions", strconv.FormatInt(int64(group.UserSettings.MaxSessions), 10)) + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webGroupPath, &b) + assert.NoError(t, err) + req.Header.Set("Content-Type", contentType) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "invalid quota size") + form.Set("quota_files", strconv.FormatInt(int64(group.UserSettings.QuotaFiles), 10)) + form.Set("quota_size", strconv.FormatInt(group.UserSettings.QuotaSize, 10)) + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webGroupPath, &b) + assert.NoError(t, err) + req.Header.Set("Content-Type", contentType) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "invalid upload bandwidth") + form.Set("upload_bandwidth", strconv.FormatInt(group.UserSettings.UploadBandwidth, 10)) + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webGroupPath, &b) + assert.NoError(t, err) + req.Header.Set("Content-Type", contentType) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "invalid download bandwidth") + form.Set("download_bandwidth", strconv.FormatInt(group.UserSettings.DownloadBandwidth, 10)) + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webGroupPath, &b) + assert.NoError(t, err) + req.Header.Set("Content-Type", contentType) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "invalid upload data transfer") + form.Set("upload_data_transfer", "0") + form.Set("download_data_transfer", "0") + form.Set("total_data_transfer", "0") + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webGroupPath, &b) + assert.NoError(t, err) + req.Header.Set("Content-Type", contentType) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "invalid max upload file size") + form.Set("max_upload_file_size", "0") + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webGroupPath, &b) + assert.NoError(t, err) + req.Header.Set("Content-Type", contentType) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "invalid external auth cache time") + form.Set("external_auth_cache_time", "0") + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webGroupPath, &b) + assert.NoError(t, err) + req.Header.Set("Content-Type", contentType) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "unable to verify form token") + form.Set(csrfFormToken, csrfToken) + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webGroupPath+"?b=%2", &b) + assert.NoError(t, err) + req.Header.Set("Content-Type", contentType) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) // error parsing the multipart form + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webGroupPath, &b) + assert.NoError(t, err) + req.Header.Set("Content-Type", contentType) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusSeeOther, rr) + // a new add will fail + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webGroupPath, &b) + assert.NoError(t, err) + req.Header.Set("Content-Type", contentType) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // list groups + req, err = http.NewRequest(http.MethodGet, webGroupsPath, nil) + assert.NoError(t, err) + req.Header.Set("Content-Type", contentType) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // render the new group page + req, err = http.NewRequest(http.MethodGet, path.Join(webGroupPath, group.Name), nil) + assert.NoError(t, err) + req.Header.Set("Content-Type", contentType) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // check the added group + groupGet, _, err := httpdtest.GetGroupByName(group.Name, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, group.UserSettings, groupGet.UserSettings) + assert.Equal(t, group.Name, groupGet.Name) + assert.Equal(t, group.Description, groupGet.Description) + // cleanup + req, err = http.NewRequest(http.MethodDelete, path.Join(groupPath, group.Name), nil) + assert.NoError(t, err) + setBearerForReq(req, apiToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, err = http.NewRequest(http.MethodGet, path.Join(webGroupPath, group.Name), nil) + assert.NoError(t, err) + req.Header.Set("Content-Type", contentType) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) +} + func TestAddWebFoldersMock(t *testing.T) { webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) @@ -16867,6 +17702,106 @@ func TestS3WebFolderMock(t *testing.T) { checkResponseCode(t, http.StatusOK, rr) } +func TestUpdateWebGroupMock(t *testing.T) { + webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) + assert.NoError(t, err) + apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) + assert.NoError(t, err) + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) + assert.NoError(t, err) + group, _, err := httpdtest.AddGroup(getTestGroup(), http.StatusCreated) + assert.NoError(t, err) + + group.UserSettings = dataprovider.GroupUserSettings{ + BaseGroupUserSettings: sdk.BaseGroupUserSettings{ + HomeDir: filepath.Join(os.TempDir(), util.GenerateUniqueID()), + Permissions: make(map[string][]string), + }, + FsConfig: vfs.Filesystem{ + Provider: sdk.SFTPFilesystemProvider, + SFTPConfig: vfs.SFTPFsConfig{ + BaseSFTPFsConfig: sdk.BaseSFTPFsConfig{ + Endpoint: sftpServerAddr, + Username: defaultUsername, + BufferSize: 1, + }, + }, + }, + } + form := make(url.Values) + form.Set("name", group.Name) + form.Set("description", group.Description) + form.Set("home_dir", group.UserSettings.HomeDir) + form.Set("max_sessions", strconv.FormatInt(int64(group.UserSettings.MaxSessions), 10)) + form.Set("quota_files", strconv.FormatInt(int64(group.UserSettings.QuotaFiles), 10)) + form.Set("quota_size", strconv.FormatInt(group.UserSettings.QuotaSize, 10)) + form.Set("upload_bandwidth", strconv.FormatInt(group.UserSettings.UploadBandwidth, 10)) + form.Set("download_bandwidth", strconv.FormatInt(group.UserSettings.DownloadBandwidth, 10)) + form.Set("upload_data_transfer", "0") + form.Set("download_data_transfer", "0") + form.Set("total_data_transfer", "0") + form.Set("max_upload_file_size", "0") + form.Set("external_auth_cache_time", "0") + form.Set("fs_provider", strconv.FormatInt(int64(group.UserSettings.FsConfig.Provider), 10)) + form.Set("sftp_endpoint", group.UserSettings.FsConfig.SFTPConfig.Endpoint) + form.Set("sftp_username", group.UserSettings.FsConfig.SFTPConfig.Username) + b, contentType, err := getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, path.Join(webGroupPath, group.Name), &b) + assert.NoError(t, err) + req.Header.Set("Content-Type", contentType) + setJWTCookieForReq(req, webToken) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "invalid SFTP buffer size") + form.Set("sftp_buffer_size", strconv.FormatInt(group.UserSettings.FsConfig.SFTPConfig.BufferSize, 10)) + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, path.Join(webGroupPath, group.Name), &b) + assert.NoError(t, err) + req.Header.Set("Content-Type", contentType) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "unable to verify form token") + + form.Set(csrfFormToken, csrfToken) + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, path.Join(webGroupPath, group.Name), &b) + assert.NoError(t, err) + req.Header.Set("Content-Type", contentType) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "credentials cannot be empty") + + form.Set("sftp_password", defaultPassword) + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, path.Join(webGroupPath, group.Name), &b) + assert.NoError(t, err) + req.Header.Set("Content-Type", contentType) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusSeeOther, rr) + + req, err = http.NewRequest(http.MethodDelete, path.Join(groupPath, group.Name), nil) + assert.NoError(t, err) + setBearerForReq(req, apiToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, path.Join(webGroupPath, group.Name), &b) + assert.NoError(t, err) + req.Header.Set("Content-Type", contentType) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) +} + func TestUpdateWebFolderMock(t *testing.T) { webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) @@ -17112,7 +18047,7 @@ func TestAdminForgotPassword(t *testing.T) { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = executeRequest(req) assert.Equal(t, http.StatusOK, rr.Code) - assert.Contains(t, rr.Body.String(), "Username is mandatory") + assert.Contains(t, rr.Body.String(), "username is mandatory") lastResetCode = "" form.Set("username", altAdminUsername) @@ -17139,7 +18074,7 @@ func TestAdminForgotPassword(t *testing.T) { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = executeRequest(req) assert.Equal(t, http.StatusOK, rr.Code) - assert.Contains(t, rr.Body.String(), "Please set a password") + assert.Contains(t, rr.Body.String(), "please set a password") // no code form.Set("password", defaultPassword) req, err = http.NewRequest(http.MethodPost, webAdminResetPwdPath, bytes.NewBuffer([]byte(form.Encode()))) @@ -17148,7 +18083,7 @@ func TestAdminForgotPassword(t *testing.T) { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = executeRequest(req) assert.Equal(t, http.StatusOK, rr.Code) - assert.Contains(t, rr.Body.String(), "Please set a confirmation code") + assert.Contains(t, rr.Body.String(), "please set a confirmation code") // ok form.Set("code", lastResetCode) req, err = http.NewRequest(http.MethodPost, webAdminResetPwdPath, bytes.NewBuffer([]byte(form.Encode()))) @@ -17264,7 +18199,7 @@ func TestUserForgotPassword(t *testing.T) { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = executeRequest(req) assert.Equal(t, http.StatusOK, rr.Code) - assert.Contains(t, rr.Body.String(), "Username is mandatory") + assert.Contains(t, rr.Body.String(), "username is mandatory") // user cannot reset the password form.Set("username", user.Username) req, err = http.NewRequest(http.MethodPost, webClientForgotPwdPath, bytes.NewBuffer([]byte(form.Encode()))) @@ -17273,7 +18208,7 @@ func TestUserForgotPassword(t *testing.T) { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = executeRequest(req) assert.Equal(t, http.StatusOK, rr.Code) - assert.Contains(t, rr.Body.String(), "You are not allowed to reset your password") + assert.Contains(t, rr.Body.String(), "you are not allowed to reset your password") user.Filters.WebClient = []string{sdk.WebClientAPIKeyAuthChangeDisabled} user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") assert.NoError(t, err) @@ -17302,7 +18237,7 @@ func TestUserForgotPassword(t *testing.T) { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = executeRequest(req) assert.Equal(t, http.StatusOK, rr.Code) - assert.Contains(t, rr.Body.String(), "Please set a password") + assert.Contains(t, rr.Body.String(), "please set a password") // no code form.Set("password", altAdminPassword) req, err = http.NewRequest(http.MethodPost, webClientResetPwdPath, bytes.NewBuffer([]byte(form.Encode()))) @@ -17311,7 +18246,7 @@ func TestUserForgotPassword(t *testing.T) { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = executeRequest(req) assert.Equal(t, http.StatusOK, rr.Code) - assert.Contains(t, rr.Body.String(), "Please set a confirmation code") + assert.Contains(t, rr.Body.String(), "please set a confirmation code") // ok form.Set("code", lastResetCode) req, err = http.NewRequest(http.MethodPost, webClientResetPwdPath, bytes.NewBuffer([]byte(form.Encode()))) @@ -17415,7 +18350,7 @@ func TestAPIForgotPassword(t *testing.T) { assert.NoError(t, err) rr = executeRequest(req) checkResponseCode(t, http.StatusBadRequest, rr) - assert.Contains(t, rr.Body.String(), "Invalid confirmation code") + assert.Contains(t, rr.Body.String(), "invalid confirmation code") req, err = http.NewRequest(http.MethodPost, path.Join(adminPath, altAdminUsername, "/reset-password"), bytes.NewBuffer(asJSON)) assert.NoError(t, err) @@ -17427,7 +18362,7 @@ func TestAPIForgotPassword(t *testing.T) { assert.NoError(t, err) rr = executeRequest(req) checkResponseCode(t, http.StatusBadRequest, rr) - assert.Contains(t, rr.Body.String(), "Confirmation code not found") + assert.Contains(t, rr.Body.String(), "confirmation code not found") admin, err = dataprovider.AdminExists(altAdminUsername) assert.NoError(t, err) @@ -17473,7 +18408,7 @@ func TestAPIForgotPassword(t *testing.T) { assert.NoError(t, err) rr = executeRequest(req) checkResponseCode(t, http.StatusBadRequest, rr) - assert.Contains(t, rr.Body.String(), "You are not allowed to reset your password") + assert.Contains(t, rr.Body.String(), "you are not allowed to reset your password") user.Filters.WebClient = []string{sdk.WebClientSharesDisabled} _, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") @@ -17487,7 +18422,7 @@ func TestAPIForgotPassword(t *testing.T) { assert.NoError(t, err) rr = executeRequest(req) checkResponseCode(t, http.StatusBadRequest, rr) - assert.Contains(t, rr.Body.String(), "Confirmation code not found") + assert.Contains(t, rr.Body.String(), "confirmation code not found") user, err = dataprovider.UserExists(defaultUsername) assert.NoError(t, err) @@ -17543,7 +18478,7 @@ func TestAPIForgotPassword(t *testing.T) { assert.NoError(t, err) rr = executeRequest(req) checkResponseCode(t, http.StatusBadRequest, rr) - assert.Contains(t, rr.Body.String(), "Unable to associate the confirmation code with an existing admin") + assert.Contains(t, rr.Body.String(), "unable to associate the confirmation code with an existing admin") _, err = httpdtest.RemoveUser(user, http.StatusOK) assert.NoError(t, err) @@ -17809,6 +18744,15 @@ func getTestAdmin() dataprovider.Admin { } } +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{ diff --git a/httpd/internal_test.go b/httpd/internal_test.go index 7631ac17..e3c30ce3 100644 --- a/httpd/internal_test.go +++ b/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) diff --git a/httpd/middleware.go b/httpd/middleware.go index baefa48c..bef53977 100644 --- a/httpd/middleware.go +++ b/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) diff --git a/httpd/server.go b/httpd/server.go index 61f95d0b..187edec1 100644 --- a/httpd/server.go +++ b/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). diff --git a/httpd/webadmin.go b/httpd/webadmin.go index e3d535bc..dfb742ba 100644 --- a/httpd/webadmin.go +++ b/httpd/webadmin.go @@ -43,40 +43,51 @@ const ( folderPageModeTemplate ) +type groupPageMode int + 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 + groupPageModeAdd groupPageMode = iota + 1 + groupPageModeUpdate +) + +const ( + 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 user, fmt.Errorf("invalid max upload file size: %w", err) + return group, err } - user.Filters.MaxUploadFileSize = maxFileSize - return user, 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 group, 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) +} diff --git a/httpd/webclient.go b/httpd/webclient.go index 38598418..2068314b 100644 --- a/httpd/webclient.go +++ b/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") } diff --git a/httpdtest/httpdtest.go b/httpdtest/httpdtest.go index eb109565..3d808481 100644 --- a/httpdtest/httpdtest.go +++ b/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") diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index f21e62b3..f738e49e 100644 --- a/openapi/openapi.yaml +++ b/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: diff --git a/service/service.go b/service/service.go index 7229e2b8..b51ea221 100644 --- a/service/service.go +++ b/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) diff --git a/sftpd/server.go b/sftpd/server.go index 6931d545..b1f8fa7b 100644 --- a/sftpd/server.go +++ b/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) } diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index 991524c6..0bcb2769 100644 --- a/sftpd/sftpd_test.go +++ b/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{ diff --git a/static/vendor/bootstrap-select/css/bootstrap-select.min.css b/static/vendor/bootstrap-select/css/bootstrap-select.min.css new file mode 100644 index 00000000..59708ed5 --- /dev/null +++ b/static/vendor/bootstrap-select/css/bootstrap-select.min.css @@ -0,0 +1,6 @@ +/*! + * Bootstrap-select v1.13.14 (https://developer.snapappointments.com/bootstrap-select) + * + * Copyright 2012-2020 SnapAppointments, LLC + * Licensed under MIT (https://github.com/snapappointments/bootstrap-select/blob/master/LICENSE) + */@-webkit-keyframes bs-notify-fadeOut{0%{opacity:.9}100%{opacity:0}}@-o-keyframes bs-notify-fadeOut{0%{opacity:.9}100%{opacity:0}}@keyframes bs-notify-fadeOut{0%{opacity:.9}100%{opacity:0}}.bootstrap-select>select.bs-select-hidden,select.bs-select-hidden,select.selectpicker{display:none!important}.bootstrap-select{width:220px\0;vertical-align:middle}.bootstrap-select>.dropdown-toggle{position:relative;width:100%;text-align:right;white-space:nowrap;display:-webkit-inline-box;display:-webkit-inline-flex;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between}.bootstrap-select>.dropdown-toggle:after{margin-top:-1px}.bootstrap-select>.dropdown-toggle.bs-placeholder,.bootstrap-select>.dropdown-toggle.bs-placeholder:active,.bootstrap-select>.dropdown-toggle.bs-placeholder:focus,.bootstrap-select>.dropdown-toggle.bs-placeholder:hover{color:#999}.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-danger,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-danger:active,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-danger:focus,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-danger:hover,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-dark,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-dark:active,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-dark:focus,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-dark:hover,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-info,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-info:active,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-info:focus,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-info:hover,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-primary,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-primary:active,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-primary:focus,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-primary:hover,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-secondary,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-secondary:active,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-secondary:focus,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-secondary:hover,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-success,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-success:active,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-success:focus,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-success:hover{color:rgba(255,255,255,.5)}.bootstrap-select>select{position:absolute!important;bottom:0;left:50%;display:block!important;width:.5px!important;height:100%!important;padding:0!important;opacity:0!important;border:none;z-index:0!important}.bootstrap-select>select.mobile-device{top:0;left:0;display:block!important;width:100%!important;z-index:2!important}.bootstrap-select.is-invalid .dropdown-toggle,.error .bootstrap-select .dropdown-toggle,.has-error .bootstrap-select .dropdown-toggle,.was-validated .bootstrap-select select:invalid+.dropdown-toggle{border-color:#b94a48}.bootstrap-select.is-valid .dropdown-toggle,.was-validated .bootstrap-select select:valid+.dropdown-toggle{border-color:#28a745}.bootstrap-select.fit-width{width:auto!important}.bootstrap-select:not([class*=col-]):not([class*=form-control]):not(.input-group-btn){width:220px}.bootstrap-select .dropdown-toggle:focus,.bootstrap-select>select.mobile-device:focus+.dropdown-toggle{outline:thin dotted #333!important;outline:5px auto -webkit-focus-ring-color!important;outline-offset:-2px}.bootstrap-select.form-control{margin-bottom:0;padding:0;border:none;height:auto}:not(.input-group)>.bootstrap-select.form-control:not([class*=col-]){width:100%}.bootstrap-select.form-control.input-group-btn{float:none;z-index:auto}.form-inline .bootstrap-select,.form-inline .bootstrap-select.form-control:not([class*=col-]){width:auto}.bootstrap-select:not(.input-group-btn),.bootstrap-select[class*=col-]{float:none;display:inline-block;margin-left:0}.bootstrap-select.dropdown-menu-right,.bootstrap-select[class*=col-].dropdown-menu-right,.row .bootstrap-select[class*=col-].dropdown-menu-right{float:right}.form-group .bootstrap-select,.form-horizontal .bootstrap-select,.form-inline .bootstrap-select{margin-bottom:0}.form-group-lg .bootstrap-select.form-control,.form-group-sm .bootstrap-select.form-control{padding:0}.form-group-lg .bootstrap-select.form-control .dropdown-toggle,.form-group-sm .bootstrap-select.form-control .dropdown-toggle{height:100%;font-size:inherit;line-height:inherit;border-radius:inherit}.bootstrap-select.form-control-lg .dropdown-toggle,.bootstrap-select.form-control-sm .dropdown-toggle{font-size:inherit;line-height:inherit;border-radius:inherit}.bootstrap-select.form-control-sm .dropdown-toggle{padding:.25rem .5rem}.bootstrap-select.form-control-lg .dropdown-toggle{padding:.5rem 1rem}.form-inline .bootstrap-select .form-control{width:100%}.bootstrap-select.disabled,.bootstrap-select>.disabled{cursor:not-allowed}.bootstrap-select.disabled:focus,.bootstrap-select>.disabled:focus{outline:0!important}.bootstrap-select.bs-container{position:absolute;top:0;left:0;height:0!important;padding:0!important}.bootstrap-select.bs-container .dropdown-menu{z-index:1060}.bootstrap-select .dropdown-toggle .filter-option{position:static;top:0;left:0;float:left;height:100%;width:100%;text-align:left;overflow:hidden;-webkit-box-flex:0;-webkit-flex:0 1 auto;-ms-flex:0 1 auto;flex:0 1 auto}.bs3.bootstrap-select .dropdown-toggle .filter-option{padding-right:inherit}.input-group .bs3-has-addon.bootstrap-select .dropdown-toggle .filter-option{position:absolute;padding-top:inherit;padding-bottom:inherit;padding-left:inherit;float:none}.input-group .bs3-has-addon.bootstrap-select .dropdown-toggle .filter-option .filter-option-inner{padding-right:inherit}.bootstrap-select .dropdown-toggle .filter-option-inner-inner{overflow:hidden}.bootstrap-select .dropdown-toggle .filter-expand{width:0!important;float:left;opacity:0!important;overflow:hidden}.bootstrap-select .dropdown-toggle .caret{position:absolute;top:50%;right:12px;margin-top:-2px;vertical-align:middle}.input-group .bootstrap-select.form-control .dropdown-toggle{border-radius:inherit}.bootstrap-select[class*=col-] .dropdown-toggle{width:100%}.bootstrap-select .dropdown-menu{min-width:100%;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.bootstrap-select .dropdown-menu>.inner:focus{outline:0!important}.bootstrap-select .dropdown-menu.inner{position:static;float:none;border:0;padding:0;margin:0;border-radius:0;-webkit-box-shadow:none;box-shadow:none}.bootstrap-select .dropdown-menu li{position:relative}.bootstrap-select .dropdown-menu li.active small{color:rgba(255,255,255,.5)!important}.bootstrap-select .dropdown-menu li.disabled a{cursor:not-allowed}.bootstrap-select .dropdown-menu li a{cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.bootstrap-select .dropdown-menu li a.opt{position:relative;padding-left:2.25em}.bootstrap-select .dropdown-menu li a span.check-mark{display:none}.bootstrap-select .dropdown-menu li a span.text{display:inline-block}.bootstrap-select .dropdown-menu li small{padding-left:.5em}.bootstrap-select .dropdown-menu .notify{position:absolute;bottom:5px;width:96%;margin:0 2%;min-height:26px;padding:3px 5px;background:#f5f5f5;border:1px solid #e3e3e3;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.05);box-shadow:inset 0 1px 1px rgba(0,0,0,.05);pointer-events:none;opacity:.9;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.bootstrap-select .dropdown-menu .notify.fadeOut{-webkit-animation:.3s linear 750ms forwards bs-notify-fadeOut;-o-animation:.3s linear 750ms forwards bs-notify-fadeOut;animation:.3s linear 750ms forwards bs-notify-fadeOut}.bootstrap-select .no-results{padding:3px;background:#f5f5f5;margin:0 5px;white-space:nowrap}.bootstrap-select.fit-width .dropdown-toggle .filter-option{position:static;display:inline;padding:0}.bootstrap-select.fit-width .dropdown-toggle .filter-option-inner,.bootstrap-select.fit-width .dropdown-toggle .filter-option-inner-inner{display:inline}.bootstrap-select.fit-width .dropdown-toggle .bs-caret:before{content:'\00a0'}.bootstrap-select.fit-width .dropdown-toggle .caret{position:static;top:auto;margin-top:-1px}.bootstrap-select.show-tick .dropdown-menu .selected span.check-mark{position:absolute;display:inline-block;right:15px;top:5px}.bootstrap-select.show-tick .dropdown-menu li a span.text{margin-right:34px}.bootstrap-select .bs-ok-default:after{content:'';display:block;width:.5em;height:1em;border-style:solid;border-width:0 .26em .26em 0;-webkit-transform:rotate(45deg);-ms-transform:rotate(45deg);-o-transform:rotate(45deg);transform:rotate(45deg)}.bootstrap-select.show-menu-arrow.open>.dropdown-toggle,.bootstrap-select.show-menu-arrow.show>.dropdown-toggle{z-index:1061}.bootstrap-select.show-menu-arrow .dropdown-toggle .filter-option:before{content:'';border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:7px solid rgba(204,204,204,.2);position:absolute;bottom:-4px;left:9px;display:none}.bootstrap-select.show-menu-arrow .dropdown-toggle .filter-option:after{content:'';border-left:6px solid transparent;border-right:6px solid transparent;border-bottom:6px solid #fff;position:absolute;bottom:-4px;left:10px;display:none}.bootstrap-select.show-menu-arrow.dropup .dropdown-toggle .filter-option:before{bottom:auto;top:-4px;border-top:7px solid rgba(204,204,204,.2);border-bottom:0}.bootstrap-select.show-menu-arrow.dropup .dropdown-toggle .filter-option:after{bottom:auto;top:-4px;border-top:6px solid #fff;border-bottom:0}.bootstrap-select.show-menu-arrow.pull-right .dropdown-toggle .filter-option:before{right:12px;left:auto}.bootstrap-select.show-menu-arrow.pull-right .dropdown-toggle .filter-option:after{right:13px;left:auto}.bootstrap-select.show-menu-arrow.open>.dropdown-toggle .filter-option:after,.bootstrap-select.show-menu-arrow.open>.dropdown-toggle .filter-option:before,.bootstrap-select.show-menu-arrow.show>.dropdown-toggle .filter-option:after,.bootstrap-select.show-menu-arrow.show>.dropdown-toggle .filter-option:before{display:block}.bs-actionsbox,.bs-donebutton,.bs-searchbox{padding:4px 8px}.bs-actionsbox{width:100%;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.bs-actionsbox .btn-group button{width:50%}.bs-donebutton{float:left;width:100%;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.bs-donebutton .btn-group button{width:100%}.bs-searchbox+.bs-actionsbox{padding:0 8px 4px}.bs-searchbox .form-control{margin-bottom:0;width:100%;float:none} \ No newline at end of file diff --git a/static/vendor/bootstrap-select/js/bootstrap-select.min.js b/static/vendor/bootstrap-select/js/bootstrap-select.min.js new file mode 100644 index 00000000..92e3a32e --- /dev/null +++ b/static/vendor/bootstrap-select/js/bootstrap-select.min.js @@ -0,0 +1,9 @@ +/*! + * Bootstrap-select v1.13.14 (https://developer.snapappointments.com/bootstrap-select) + * + * Copyright 2012-2020 SnapAppointments, LLC + * Licensed under MIT (https://github.com/snapappointments/bootstrap-select/blob/master/LICENSE) + */ + +!function(e,t){void 0===e&&void 0!==window&&(e=window),"function"==typeof define&&define.amd?define(["jquery"],function(e){return t(e)}):"object"==typeof module&&module.exports?module.exports=t(require("jquery")):t(e.jQuery)}(this,function(e){!function(z){"use strict";var d=["sanitize","whiteList","sanitizeFn"],r=["background","cite","href","itemtype","longdesc","poster","src","xlink:href"],e={"*":["class","dir","id","lang","role","tabindex","style",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],div:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},l=/^(?:(?:https?|mailto|ftp|tel|file):|[^&:/?#]*(?:[/?#]|$))/gi,a=/^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i;function v(e,t){var i=e.nodeName.toLowerCase();if(-1!==z.inArray(i,t))return-1===z.inArray(i,r)||Boolean(e.nodeValue.match(l)||e.nodeValue.match(a));for(var s=z(t).filter(function(e,t){return t instanceof RegExp}),n=0,o=s.length;n]+>/g,"")),s&&(a=w(a)),a=a.toUpperCase(),o="contains"===i?0<=a.indexOf(t):a.startsWith(t)))break}return o}function L(e){return parseInt(e,10)||0}z.fn.triggerNative=function(e){var t,i=this[0];i.dispatchEvent?(u?t=new Event(e,{bubbles:!0}):(t=document.createEvent("Event")).initEvent(e,!0,!1),i.dispatchEvent(t)):i.fireEvent?((t=document.createEventObject()).eventType=e,i.fireEvent("on"+e,t)):this.trigger(e)};var f={"\xc0":"A","\xc1":"A","\xc2":"A","\xc3":"A","\xc4":"A","\xc5":"A","\xe0":"a","\xe1":"a","\xe2":"a","\xe3":"a","\xe4":"a","\xe5":"a","\xc7":"C","\xe7":"c","\xd0":"D","\xf0":"d","\xc8":"E","\xc9":"E","\xca":"E","\xcb":"E","\xe8":"e","\xe9":"e","\xea":"e","\xeb":"e","\xcc":"I","\xcd":"I","\xce":"I","\xcf":"I","\xec":"i","\xed":"i","\xee":"i","\xef":"i","\xd1":"N","\xf1":"n","\xd2":"O","\xd3":"O","\xd4":"O","\xd5":"O","\xd6":"O","\xd8":"O","\xf2":"o","\xf3":"o","\xf4":"o","\xf5":"o","\xf6":"o","\xf8":"o","\xd9":"U","\xda":"U","\xdb":"U","\xdc":"U","\xf9":"u","\xfa":"u","\xfb":"u","\xfc":"u","\xdd":"Y","\xfd":"y","\xff":"y","\xc6":"Ae","\xe6":"ae","\xde":"Th","\xfe":"th","\xdf":"ss","\u0100":"A","\u0102":"A","\u0104":"A","\u0101":"a","\u0103":"a","\u0105":"a","\u0106":"C","\u0108":"C","\u010a":"C","\u010c":"C","\u0107":"c","\u0109":"c","\u010b":"c","\u010d":"c","\u010e":"D","\u0110":"D","\u010f":"d","\u0111":"d","\u0112":"E","\u0114":"E","\u0116":"E","\u0118":"E","\u011a":"E","\u0113":"e","\u0115":"e","\u0117":"e","\u0119":"e","\u011b":"e","\u011c":"G","\u011e":"G","\u0120":"G","\u0122":"G","\u011d":"g","\u011f":"g","\u0121":"g","\u0123":"g","\u0124":"H","\u0126":"H","\u0125":"h","\u0127":"h","\u0128":"I","\u012a":"I","\u012c":"I","\u012e":"I","\u0130":"I","\u0129":"i","\u012b":"i","\u012d":"i","\u012f":"i","\u0131":"i","\u0134":"J","\u0135":"j","\u0136":"K","\u0137":"k","\u0138":"k","\u0139":"L","\u013b":"L","\u013d":"L","\u013f":"L","\u0141":"L","\u013a":"l","\u013c":"l","\u013e":"l","\u0140":"l","\u0142":"l","\u0143":"N","\u0145":"N","\u0147":"N","\u014a":"N","\u0144":"n","\u0146":"n","\u0148":"n","\u014b":"n","\u014c":"O","\u014e":"O","\u0150":"O","\u014d":"o","\u014f":"o","\u0151":"o","\u0154":"R","\u0156":"R","\u0158":"R","\u0155":"r","\u0157":"r","\u0159":"r","\u015a":"S","\u015c":"S","\u015e":"S","\u0160":"S","\u015b":"s","\u015d":"s","\u015f":"s","\u0161":"s","\u0162":"T","\u0164":"T","\u0166":"T","\u0163":"t","\u0165":"t","\u0167":"t","\u0168":"U","\u016a":"U","\u016c":"U","\u016e":"U","\u0170":"U","\u0172":"U","\u0169":"u","\u016b":"u","\u016d":"u","\u016f":"u","\u0171":"u","\u0173":"u","\u0174":"W","\u0175":"w","\u0176":"Y","\u0177":"y","\u0178":"Y","\u0179":"Z","\u017b":"Z","\u017d":"Z","\u017a":"z","\u017c":"z","\u017e":"z","\u0132":"IJ","\u0133":"ij","\u0152":"Oe","\u0153":"oe","\u0149":"'n","\u017f":"s"},m=/[\xc0-\xd6\xd8-\xf6\xf8-\xff\u0100-\u017f]/g,g=RegExp("[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff\\u1ab0-\\u1aff\\u1dc0-\\u1dff]","g");function b(e){return f[e]}function w(e){return(e=e.toString())&&e.replace(m,b).replace(g,"")}var I,x,y,$,S=(I={"&":"&","<":"<",">":">",'"':""","'":"'","`":"`"},x="(?:"+Object.keys(I).join("|")+")",y=RegExp(x),$=RegExp(x,"g"),function(e){return e=null==e?"":""+e,y.test(e)?e.replace($,E):e});function E(e){return I[e]}var C={32:" ",48:"0",49:"1",50:"2",51:"3",52:"4",53:"5",54:"6",55:"7",56:"8",57:"9",59:";",65:"A",66:"B",67:"C",68:"D",69:"E",70:"F",71:"G",72:"H",73:"I",74:"J",75:"K",76:"L",77:"M",78:"N",79:"O",80:"P",81:"Q",82:"R",83:"S",84:"T",85:"U",86:"V",87:"W",88:"X",89:"Y",90:"Z",96:"0",97:"1",98:"2",99:"3",100:"4",101:"5",102:"6",103:"7",104:"8",105:"9"},N=27,D=13,H=32,W=9,B=38,M=40,R={success:!1,major:"3"};try{R.full=(z.fn.dropdown.Constructor.VERSION||"").split(" ")[0].split("."),R.major=R.full[0],R.success=!0}catch(e){}var U=0,j=".bs.select",V={DISABLED:"disabled",DIVIDER:"divider",SHOW:"open",DROPUP:"dropup",MENU:"dropdown-menu",MENURIGHT:"dropdown-menu-right",MENULEFT:"dropdown-menu-left",BUTTONCLASS:"btn-default",POPOVERHEADER:"popover-title",ICONBASE:"glyphicon",TICKICON:"glyphicon-ok"},F={MENU:"."+V.MENU},_={span:document.createElement("span"),i:document.createElement("i"),subtext:document.createElement("small"),a:document.createElement("a"),li:document.createElement("li"),whitespace:document.createTextNode("\xa0"),fragment:document.createDocumentFragment()};_.a.setAttribute("role","option"),"4"===R.major&&(_.a.className="dropdown-item"),_.subtext.className="text-muted",_.text=_.span.cloneNode(!1),_.text.className="text",_.checkMark=_.span.cloneNode(!1);var G=new RegExp(B+"|"+M),q=new RegExp("^"+W+"$|"+N),K={li:function(e,t,i){var s=_.li.cloneNode(!1);return e&&(1===e.nodeType||11===e.nodeType?s.appendChild(e):s.innerHTML=e),void 0!==t&&""!==t&&(s.className=t),null!=i&&s.classList.add("optgroup-"+i),s},a:function(e,t,i){var s=_.a.cloneNode(!0);return e&&(11===e.nodeType?s.appendChild(e):s.insertAdjacentHTML("beforeend",e)),void 0!==t&&""!==t&&s.classList.add.apply(s.classList,t.split(" ")),i&&s.setAttribute("style",i),s},text:function(e,t){var i,s,n=_.text.cloneNode(!1);if(e.content)n.innerHTML=e.content;else{if(n.textContent=e.text,e.icon){var o=_.whitespace.cloneNode(!1);(s=(!0===t?_.i:_.span).cloneNode(!1)).className=this.options.iconBase+" "+e.icon,_.fragment.appendChild(s),_.fragment.appendChild(o)}e.subtext&&((i=_.subtext.cloneNode(!1)).textContent=e.subtext,n.appendChild(i))}if(!0===t)for(;0'},maxOptions:!1,mobile:!1,selectOnTab:!1,dropdownAlignRight:!1,windowPadding:0,virtualScroll:600,display:!1,sanitize:!0,sanitizeFn:null,whiteList:e},Y.prototype={constructor:Y,init:function(){var i=this,e=this.$element.attr("id");U++,this.selectId="bs-select-"+U,this.$element[0].classList.add("bs-select-hidden"),this.multiple=this.$element.prop("multiple"),this.autofocus=this.$element.prop("autofocus"),this.$element[0].classList.contains("show-tick")&&(this.options.showTick=!0),this.$newElement=this.createDropdown(),this.buildData(),this.$element.after(this.$newElement).prependTo(this.$newElement),this.$button=this.$newElement.children("button"),this.$menu=this.$newElement.children(F.MENU),this.$menuInner=this.$menu.children(".inner"),this.$searchbox=this.$menu.find("input"),this.$element[0].classList.remove("bs-select-hidden"),!0===this.options.dropdownAlignRight&&this.$menu[0].classList.add(V.MENURIGHT),void 0!==e&&this.$button.attr("data-id",e),this.checkDisabled(),this.clickListener(),this.options.liveSearch?(this.liveSearchListener(),this.focusedParent=this.$searchbox[0]):this.focusedParent=this.$menuInner[0],this.setStyle(),this.render(),this.setWidth(),this.options.container?this.selectPosition():this.$element.on("hide"+j,function(){if(i.isVirtual()){var e=i.$menuInner[0],t=e.firstChild.cloneNode(!1);e.replaceChild(t,e.firstChild),e.scrollTop=0}}),this.$menu.data("this",this),this.$newElement.data("this",this),this.options.mobile&&this.mobile(),this.$newElement.on({"hide.bs.dropdown":function(e){i.$element.trigger("hide"+j,e)},"hidden.bs.dropdown":function(e){i.$element.trigger("hidden"+j,e)},"show.bs.dropdown":function(e){i.$element.trigger("show"+j,e)},"shown.bs.dropdown":function(e){i.$element.trigger("shown"+j,e)}}),i.$element[0].hasAttribute("required")&&this.$element.on("invalid"+j,function(){i.$button[0].classList.add("bs-invalid"),i.$element.on("shown"+j+".invalid",function(){i.$element.val(i.$element.val()).off("shown"+j+".invalid")}).on("rendered"+j,function(){this.validity.valid&&i.$button[0].classList.remove("bs-invalid"),i.$element.off("rendered"+j)}),i.$button.on("blur"+j,function(){i.$element.trigger("focus").trigger("blur"),i.$button.off("blur"+j)})}),setTimeout(function(){i.buildList(),i.$element.trigger("loaded"+j)})},createDropdown:function(){var e=this.multiple||this.options.showTick?" show-tick":"",t=this.multiple?' aria-multiselectable="true"':"",i="",s=this.autofocus?" autofocus":"";R.major<4&&this.$element.parent().hasClass("input-group")&&(i=" input-group-btn");var n,o="",r="",l="",a="";return this.options.header&&(o='
'+this.options.header+"
"),this.options.liveSearch&&(r=''),this.multiple&&this.options.actionsBox&&(l='
"),this.multiple&&this.options.doneButton&&(a='
"),n='",z(n)},setPositionData:function(){this.selectpicker.view.canHighlight=[];for(var e=this.selectpicker.view.size=0;e=this.options.virtualScroll||!0===this.options.virtualScroll},createView:function(A,e,t){var L,N,D=this,i=0,H=[];if(this.selectpicker.isSearching=A,this.selectpicker.current=A?this.selectpicker.search:this.selectpicker.main,this.setPositionData(),e)if(t)i=this.$menuInner[0].scrollTop;else if(!D.multiple){var s=D.$element[0],n=(s.options[s.selectedIndex]||{}).liIndex;if("number"==typeof n&&!1!==D.options.size){var o=D.selectpicker.main.data[n],r=o&&o.position;r&&(i=r-(D.sizeInfo.menuInnerHeight+D.sizeInfo.liHeight)/2)}}function l(e,t){var i,s,n,o,r,l,a,c,d=D.selectpicker.current.elements.length,h=[],p=!0,u=D.isVirtual();D.selectpicker.view.scrollTop=e,i=Math.ceil(D.sizeInfo.menuInnerHeight/D.sizeInfo.liHeight*1.5),s=Math.round(d/i)||1;for(var f=0;fd-1?0:D.selectpicker.current.data[d-1].position-D.selectpicker.current.data[D.selectpicker.view.position1-1].position,b.firstChild.style.marginTop=v+"px",b.firstChild.style.marginBottom=g+"px"):(b.firstChild.style.marginTop=0,b.firstChild.style.marginBottom=0),b.firstChild.appendChild(w),!0===u&&D.sizeInfo.hasScrollBar){var C=b.firstChild.offsetWidth;if(t&&CD.sizeInfo.selectWidth)b.firstChild.style.minWidth=D.sizeInfo.menuInnerInnerWidth+"px";else if(C>D.sizeInfo.menuInnerInnerWidth){D.$menu[0].style.minWidth=0;var O=b.firstChild.offsetWidth;O>D.sizeInfo.menuInnerInnerWidth&&(D.sizeInfo.menuInnerInnerWidth=O,b.firstChild.style.minWidth=D.sizeInfo.menuInnerInnerWidth+"px"),D.$menu[0].style.minWidth=""}}}if(D.prevActiveIndex=D.activeIndex,D.options.liveSearch){if(A&&t){var z,T=0;D.selectpicker.view.canHighlight[T]||(T=1+D.selectpicker.view.canHighlight.slice(1).indexOf(!0)),z=D.selectpicker.view.visibleElements[T],D.defocusItem(D.selectpicker.view.currentActive),D.activeIndex=(D.selectpicker.current.data[T]||{}).index,D.focusItem(z)}}else D.$menuInner.trigger("focus")}l(i,!0),this.$menuInner.off("scroll.createView").on("scroll.createView",function(e,t){D.noScroll||l(this.scrollTop,t),D.noScroll=!1}),z(window).off("resize"+j+"."+this.selectId+".createView").on("resize"+j+"."+this.selectId+".createView",function(){D.$newElement.hasClass(V.SHOW)&&l(D.$menuInner[0].scrollTop)})},focusItem:function(e,t,i){if(e){t=t||this.selectpicker.main.data[this.activeIndex];var s=e.firstChild;s&&(s.setAttribute("aria-setsize",this.selectpicker.view.size),s.setAttribute("aria-posinset",t.posinset),!0!==i&&(this.focusedParent.setAttribute("aria-activedescendant",s.id),e.classList.add("active"),s.classList.add("active")))}},defocusItem:function(e){e&&(e.classList.remove("active"),e.firstChild&&e.firstChild.classList.remove("active"))},setPlaceholder:function(){var e=!1;if(this.options.title&&!this.multiple){this.selectpicker.view.titleOption||(this.selectpicker.view.titleOption=document.createElement("option")),e=!0;var t=this.$element[0],i=!1,s=!this.selectpicker.view.titleOption.parentNode;if(s)this.selectpicker.view.titleOption.className="bs-title-option",this.selectpicker.view.titleOption.value="",i=void 0===z(t.options[t.selectedIndex]).attr("selected")&&void 0===this.$element.data("selected");!s&&0===this.selectpicker.view.titleOption.index||t.insertBefore(this.selectpicker.view.titleOption,t.firstChild),i&&(t.selectedIndex=0)}return e},buildData:function(){var p=':not([hidden]):not([data-hidden="true"])',u=[],f=0,e=this.setPlaceholder()?1:0;this.options.hideDisabled&&(p+=":not(:disabled)");var t=this.$element[0].querySelectorAll("select > *"+p);function m(e){var t=u[u.length-1];t&&"divider"===t.type&&(t.optID||e.optID)||((e=e||{}).type="divider",u.push(e))}function v(e,t){if((t=t||{}).divider="true"===e.getAttribute("data-divider"),t.divider)m({optID:t.optID});else{var i=u.length,s=e.style.cssText,n=s?S(s):"",o=(e.className||"")+(t.optgroupClass||"");t.optID&&(o="opt "+o),t.optionClass=o.trim(),t.inlineStyle=n,t.text=e.textContent,t.content=e.getAttribute("data-content"),t.tokens=e.getAttribute("data-tokens"),t.subtext=e.getAttribute("data-subtext"),t.icon=e.getAttribute("data-icon"),e.liIndex=i,t.display=t.content||t.text,t.type="option",t.index=i,t.option=e,t.selected=!!e.selected,t.disabled=t.disabled||!!e.disabled,u.push(t)}}function i(e,t){var i=t[e],s=t[e-1],n=t[e+1],o=i.querySelectorAll("option"+p);if(o.length){var r,l,a={display:S(i.label),subtext:i.getAttribute("data-subtext"),icon:i.getAttribute("data-icon"),type:"optgroup-label",optgroupClass:" "+(i.className||"")};f++,s&&m({optID:f}),a.optID=f,u.push(a);for(var c=0,d=o.length;c li")},render:function(){var e,t=this,i=this.$element[0],s=this.setPlaceholder()&&0===i.selectedIndex,n=O(i,this.options.hideDisabled),o=n.length,r=this.$button[0],l=r.querySelector(".filter-option-inner-inner"),a=document.createTextNode(this.options.multipleSeparator),c=_.fragment.cloneNode(!1),d=!1;if(r.classList.toggle("bs-placeholder",t.multiple?!o:!T(i,n)),this.tabIndex(),"static"===this.options.selectedTextFormat)c=K.text.call(this,{text:this.options.title},!0);else if(!1===(this.multiple&&-1!==this.options.selectedTextFormat.indexOf("count")&&1")).length&&o>e[1]||1===e.length&&2<=o))){if(!s){for(var h=0;h option"+m+", optgroup"+m+" option"+m).length,g="function"==typeof this.options.countSelectedText?this.options.countSelectedText(o,v):this.options.countSelectedText;c=K.text.call(this,{text:g.replace("{0}",o.toString()).replace("{1}",v.toString())},!0)}if(null==this.options.title&&(this.options.title=this.$element.attr("title")),c.childNodes.length||(c=K.text.call(this,{text:void 0!==this.options.title?this.options.title:this.options.noneSelectedText},!0)),r.title=c.textContent.replace(/<[^>]*>?/g,"").trim(),this.options.sanitize&&d&&P([c],t.options.whiteList,t.options.sanitizeFn),l.innerHTML="",l.appendChild(c),R.major<4&&this.$newElement[0].classList.contains("bs3-has-addon")){var b=r.querySelector(".filter-expand"),w=l.cloneNode(!0);w.className="filter-expand",b?r.replaceChild(w,b):r.appendChild(w)}this.$element.trigger("rendered"+j)},setStyle:function(e,t){var i,s=this.$button[0],n=this.$newElement[0],o=this.options.style.trim();this.$element.attr("class")&&this.$newElement.addClass(this.$element.attr("class").replace(/selectpicker|mobile-device|bs-select-hidden|validate\[.*\]/gi,"")),R.major<4&&(n.classList.add("bs3"),n.parentNode.classList.contains("input-group")&&(n.previousElementSibling||n.nextElementSibling)&&(n.previousElementSibling||n.nextElementSibling).classList.contains("input-group-addon")&&n.classList.add("bs3-has-addon")),i=e?e.trim():o,"add"==t?i&&s.classList.add.apply(s.classList,i.split(" ")):"remove"==t?i&&s.classList.remove.apply(s.classList,i.split(" ")):(o&&s.classList.remove.apply(s.classList,o.split(" ")),i&&s.classList.add.apply(s.classList,i.split(" ")))},liHeight:function(e){if(e||!1!==this.options.size&&!Object.keys(this.sizeInfo).length){var t=document.createElement("div"),i=document.createElement("div"),s=document.createElement("div"),n=document.createElement("ul"),o=document.createElement("li"),r=document.createElement("li"),l=document.createElement("li"),a=document.createElement("a"),c=document.createElement("span"),d=this.options.header&&0this.sizeInfo.menuExtras.vert&&l+this.sizeInfo.menuExtras.vert+50>this.sizeInfo.selectOffsetBot,!0===this.selectpicker.isSearching&&(a=this.selectpicker.dropup),this.$newElement.toggleClass(V.DROPUP,a),this.selectpicker.dropup=a),"auto"===this.options.size)n=3this.options.size){for(var b=0;bthis.sizeInfo.menuInnerHeight&&(this.sizeInfo.hasScrollBar=!0,this.sizeInfo.totalMenuWidth=this.sizeInfo.menuWidth+this.sizeInfo.scrollBarWidth),"auto"===this.options.dropdownAlignRight&&this.$menu.toggleClass(V.MENURIGHT,this.sizeInfo.selectOffsetLeft>this.sizeInfo.selectOffsetRight&&this.sizeInfo.selectOffsetRightthis.options.size&&i.off("resize"+j+"."+this.selectId+".setMenuSize scroll"+j+"."+this.selectId+".setMenuSize")}this.createView(!1,!0,e)},setWidth:function(){var i=this;"auto"===this.options.width?requestAnimationFrame(function(){i.$menu.css("min-width","0"),i.$element.on("loaded"+j,function(){i.liHeight(),i.setMenuSize();var e=i.$newElement.clone().appendTo("body"),t=e.css("width","auto").children("button").outerWidth();e.remove(),i.sizeInfo.selectWidth=Math.max(i.sizeInfo.totalMenuWidth,t),i.$newElement.css("width",i.sizeInfo.selectWidth+"px")})}):"fit"===this.options.width?(this.$menu.css("min-width",""),this.$newElement.css("width","").addClass("fit-width")):this.options.width?(this.$menu.css("min-width",""),this.$newElement.css("width",this.options.width)):(this.$menu.css("min-width",""),this.$newElement.css("width","")),this.$newElement.hasClass("fit-width")&&"fit"!==this.options.width&&this.$newElement[0].classList.remove("fit-width")},selectPosition:function(){this.$bsContainer=z('
');function e(e){var t={},i=r.options.display||!!z.fn.dropdown.Constructor.Default&&z.fn.dropdown.Constructor.Default.display;r.$bsContainer.addClass(e.attr("class").replace(/form-control|fit-width/gi,"")).toggleClass(V.DROPUP,e.hasClass(V.DROPUP)),s=e.offset(),l.is("body")?n={top:0,left:0}:((n=l.offset()).top+=parseInt(l.css("borderTopWidth"))-l.scrollTop(),n.left+=parseInt(l.css("borderLeftWidth"))-l.scrollLeft()),o=e.hasClass(V.DROPUP)?0:e[0].offsetHeight,(R.major<4||"static"===i)&&(t.top=s.top-n.top+o,t.left=s.left-n.left),t.width=e[0].offsetWidth,r.$bsContainer.css(t)}var s,n,o,r=this,l=z(this.options.container);this.$button.on("click.bs.dropdown.data-api",function(){r.isDisabled()||(e(r.$newElement),r.$bsContainer.appendTo(r.options.container).toggleClass(V.SHOW,!r.$button.hasClass(V.SHOW)).append(r.$menu))}),z(window).off("resize"+j+"."+this.selectId+" scroll"+j+"."+this.selectId).on("resize"+j+"."+this.selectId+" scroll"+j+"."+this.selectId,function(){r.$newElement.hasClass(V.SHOW)&&e(r.$newElement)}),this.$element.on("hide"+j,function(){r.$menu.data("height",r.$menu.height()),r.$bsContainer.detach()})},setOptionStatus:function(e){var t=this;if(t.noScroll=!1,t.selectpicker.view.visibleElements&&t.selectpicker.view.visibleElements.length)for(var i=0;i
');y[2]&&($=$.replace("{var}",y[2][1"+$+"")),d=!1,C.$element.trigger("maxReached"+j)),g&&w&&(E.append(z("
"+S+"
")),d=!1,C.$element.trigger("maxReachedGrp"+j)),setTimeout(function(){C.setSelected(r,!1)},10),E[0].classList.add("fadeOut"),setTimeout(function(){E.remove()},1050)}}}else c&&(c.selected=!1),h.selected=!0,C.setSelected(r,!0);!C.multiple||C.multiple&&1===C.options.maxOptions?C.$button.trigger("focus"):C.options.liveSearch&&C.$searchbox.trigger("focus"),d&&(!C.multiple&&a===s.selectedIndex||(A=[h.index,p.prop("selected"),l],C.$element.triggerNative("change")))}}),this.$menu.on("click","li."+V.DISABLED+" a, ."+V.POPOVERHEADER+", ."+V.POPOVERHEADER+" :not(.close)",function(e){e.currentTarget==this&&(e.preventDefault(),e.stopPropagation(),C.options.liveSearch&&!z(e.target).hasClass("close")?C.$searchbox.trigger("focus"):C.$button.trigger("focus"))}),this.$menuInner.on("click",".divider, .dropdown-header",function(e){e.preventDefault(),e.stopPropagation(),C.options.liveSearch?C.$searchbox.trigger("focus"):C.$button.trigger("focus")}),this.$menu.on("click","."+V.POPOVERHEADER+" .close",function(){C.$button.trigger("click")}),this.$searchbox.on("click",function(e){e.stopPropagation()}),this.$menu.on("click",".actions-btn",function(e){C.options.liveSearch?C.$searchbox.trigger("focus"):C.$button.trigger("focus"),e.preventDefault(),e.stopPropagation(),z(this).hasClass("bs-select-all")?C.selectAll():C.deselectAll()}),this.$element.on("change"+j,function(){C.render(),C.$element.trigger("changed"+j,A),A=null}).on("focus"+j,function(){C.options.mobile||C.$button.trigger("focus")})},liveSearchListener:function(){var u=this,f=document.createElement("li");this.$button.on("click.bs.dropdown.data-api",function(){u.$searchbox.val()&&u.$searchbox.val("")}),this.$searchbox.on("click.bs.dropdown.data-api focus.bs.dropdown.data-api touchend.bs.dropdown.data-api",function(e){e.stopPropagation()}),this.$searchbox.on("input propertychange",function(){var e=u.$searchbox.val();if(u.selectpicker.search.elements=[],u.selectpicker.search.data=[],e){var t=[],i=e.toUpperCase(),s={},n=[],o=u._searchStyle(),r=u.options.liveSearchNormalize;r&&(i=w(i));for(var l=0;l=a.selectpicker.view.canHighlight.length&&(t=0),a.selectpicker.view.canHighlight[t+f]||(t=t+1+a.selectpicker.view.canHighlight.slice(t+f+1).indexOf(!0))),e.preventDefault();var m=f+t;e.which===B?0===f&&t===c.length-1?(a.$menuInner[0].scrollTop=a.$menuInner[0].scrollHeight,m=a.selectpicker.current.elements.length-1):d=(o=(n=a.selectpicker.current.data[m]).position-n.height)u+a.sizeInfo.menuInnerHeight),s=a.selectpicker.main.elements[v],a.activeIndex=b[x],a.focusItem(s),s&&s.firstChild.focus(),d&&(a.$menuInner[0].scrollTop=o),r.trigger("focus")}}i&&(e.which===H&&!a.selectpicker.keydown.keyHistory||e.which===D||e.which===W&&a.options.selectOnTab)&&(e.which!==H&&e.preventDefault(),a.options.liveSearch&&e.which===H||(a.$menuInner.find(".active a").trigger("click",!0),r.trigger("focus"),a.options.liveSearch||(e.preventDefault(),z(document).data("spaceSelect",!0))))}},mobile:function(){this.$element[0].classList.add("mobile-device")},refresh:function(){var e=z.extend({},this.options,this.$element.data());this.options=e,this.checkDisabled(),this.setStyle(),this.render(),this.buildData(),this.buildList(),this.setWidth(),this.setSize(!0),this.$element.trigger("refreshed"+j)},hide:function(){this.$newElement.hide()},show:function(){this.$newElement.show()},remove:function(){this.$newElement.remove(),this.$element.remove()},destroy:function(){this.$newElement.before(this.$element).remove(),this.$bsContainer?this.$bsContainer.remove():this.$menu.remove(),this.$element.off(j).removeData("selectpicker").removeClass("bs-select-hidden selectpicker"),z(window).off(j+"."+this.selectId)}};var J=z.fn.selectpicker;z.fn.selectpicker=Z,z.fn.selectpicker.Constructor=Y,z.fn.selectpicker.noConflict=function(){return z.fn.selectpicker=J,this};var Q=z.fn.dropdown.Constructor._dataApiKeydownHandler||z.fn.dropdown.Constructor.prototype.keydown;z(document).off("keydown.bs.dropdown.data-api").on("keydown.bs.dropdown.data-api",':not(.bootstrap-select) > [data-toggle="dropdown"]',Q).on("keydown.bs.dropdown.data-api",":not(.bootstrap-select) > .dropdown-menu",Q).on("keydown"+j,'.bootstrap-select [data-toggle="dropdown"], .bootstrap-select [role="listbox"], .bootstrap-select .bs-searchbox input',Y.prototype.keydown).on("focusin.modal",'.bootstrap-select [data-toggle="dropdown"], .bootstrap-select [role="listbox"], .bootstrap-select .bs-searchbox input',function(e){e.stopPropagation()}),z(window).on("load"+j+".data-api",function(){z(".selectpicker").each(function(){var e=z(this);Z.call(e,e.data())})})}(e)}); +//# sourceMappingURL=bootstrap-select.min.js.map \ No newline at end of file diff --git a/templates/webadmin/base.html b/templates/webadmin/base.html index 61a8db0a..77fa149c 100644 --- a/templates/webadmin/base.html +++ b/templates/webadmin/base.html @@ -93,6 +93,14 @@ {{end}} + {{ if .LoggedAdmin.HasPermission "manage_groups"}} + + {{end}} + {{ if .LoggedAdmin.HasPermission "view_conns"}}