add groups support

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

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2022-04-25 15:49:11 +02:00
parent 857b6cc10a
commit 504cd3efda
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
53 changed files with 6986 additions and 1076 deletions

View file

@ -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).

View file

@ -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)
}

View file

@ -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)

View file

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

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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

File diff suppressed because it is too large Load diff

228
dataprovider/group.go Normal file
View file

@ -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, ",")
}

View file

@ -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

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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

View file

@ -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
}*/
}

View file

@ -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 {

View file

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

41
docs/groups.md Normal file
View file

@ -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.

View file

@ -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{

2
go.mod
View file

@ -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

4
go.sum
View file

@ -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=

View file

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

134
httpd/api_group.go Normal file
View file

@ -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)
}

View file

@ -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) {

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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 {

View file

@ -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)

File diff suppressed because it is too large Load diff

View file

@ -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)

View file

@ -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)

View file

@ -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).

View file

@ -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)
}

View file

@ -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")
}

View file

@ -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")

View file

@ -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:

View file

@ -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)

View file

@ -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)
}

View file

@ -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{

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,217 @@
{{define "shared_user_group"}}
<script type="text/javascript">
$("body").on("click", ".add_new_dirperms_field_btn", function () {
var index = $(".form_field_dirperms_outer").find(".form_field_dirperms_outer_row").length;
while (document.getElementById("idSubDirPermsPath"+index) != null){
index++;
}
$(".form_field_dirperms_outer").append(`
<div class="row form_field_dirperms_outer_row">
<div class="form-group col-md-8">
<input type="text" class="form-control" id="idSubDirPermsPath${index}" name="sub_perm_path${index}" placeholder="directory path, i.e. /dir" value="" maxlength="255">
</div>
<div class="form-group col-md-3">
<select class="form-control" id="idSubDirPermissions${index}" name="sub_perm_permissions${index}" multiple>
</select>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_dirperms_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
{{- range .ValidPerms}}
$("#idSubDirPermissions"+index).append($('<option>').val('{{.}}').text('{{.}}'));
{{- end}}
$("#idSubDirPermissions"+index).selectpicker();
});
$("body").on("click", ".remove_dirperms_btn_frm_field", function () {
$(this).closest(".form_field_dirperms_outer_row").remove();
});
$("body").on("click", ".add_new_vfolder_field_btn", function () {
var index = $(".form_field_vfolders_outer").find(".form_field_vfolder_outer_row").length;
while (document.getElementById("idVolderPath" + index) != null) {
index++;
}
$(".form_field_vfolders_outer").append(`
<div class="row form_field_vfolder_outer_row">
<div class="form-group col-md-3">
<input type="text" class="form-control" id="idVolderPath${index}" name="vfolder_path" placeholder="mount path, i.e. /vfolder" value="" maxlength="255">
</div>
<div class="form-group col-md-3">
<select class="form-control" id="idVfolderName${index}" name="vfolder_name">
<option value=""></option>
</select>
</div>
<div class="form-group col-md-3">
<input type="number" class="form-control" id="idVfolderQuotaSize${index}" name="vfolder_quota_size"
value="" min="-1" aria-describedby="vqsHelpBlock${index}">
<small id="vqsHelpBlock${index}" class="form-text text-muted">
Quota size (bytes)
</small>
</div>
<div class="form-group col-md-2">
<input type="number" class="form-control" id="idVfolderQuotaFiles${index}" name="vfolder_quota_files"
value="" min="-1" aria-describedby="vqfHelpBlock${index}">
<small id="vqfHelpBlock${index}" class="form-text text-muted">
Quota files
</small>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_vfolder_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
{{- range .VirtualFolders}}
$("#idVfolderName"+index).append($('<option>').val('{{.Name}}').text('{{.Name}}'));
{{- end}}
$("#idVfolderName"+index).selectpicker({'liveSearch': true});
});
$("body").on("click", ".remove_vfolder_btn_frm_field", function () {
$(this).closest(".form_field_vfolder_outer_row").remove();
});
$("body").on("click", ".add_new_bwlimit_field_btn", function () {
var index = $(".form_field_bwlimits_outer").find(".form_field_bwlimits_outer_row").length;
while (document.getElementById("idBandwidthLimitSources"+index) != null){
index++;
}
$(".form_field_bwlimits_outer").append(`
<div class="row form_field_bwlimits_outer_row">
<div class="form-group col-md-8">
<textarea class="form-control" id="idBandwidthLimitSources0" name="bandwidth_limit_sources${index}" rows="4" placeholder=""
aria-describedby="bwLimitSourcesHelpBlock${index}"></textarea>
<small id="bwLimitSourcesHelpBlock${index}" class="form-text text-muted">
Comma separated IP/Mask in CIDR format, example: "192.168.1.0/24,10.8.0.100/32"
</small>
</div>
<div class="col-md-3">
<div class="form-group">
<input type="number" class="form-control" id="idUploadBandwidthSource${index}" name="upload_bandwidth_source${index}"
placeholder="" value="" min="0" aria-describedby="ulHelpBlock${index}">
<small id="ulHelpBlock${index}" class="form-text text-muted">
UL (KB/s). 0 means no limit
</small>
</div>
<div class="form-group">
<input type="number" class="form-control" id="idDownloadBandwidthSource${index}" name="download_bandwidth_source${index}"
placeholder="" value="" min="0" aria-describedby="dlHelpBlock${index}">
<small id="dlHelpBlock${index}" class="form-text text-muted">
DL (KB/s). 0 means no limit
</small>
</div>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_bwlimit_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
});
$("body").on("click", ".remove_bwlimit_btn_frm_field", function () {
$(this).closest(".form_field_bwlimits_outer_row").remove();
});
$("body").on("click", ".add_new_dtlimit_field_btn", function () {
var index = $(".form_field_dtlimits_outer").find(".form_field_dtlimits_outer_row").length;
while (document.getElementById("idDataTransferLimitSources"+index) != null){
index++;
}
$(".form_field_dtlimits_outer").append(`
<div class="row form_field_dtlimits_outer_row">
<div class="form-group col-md-5">
<textarea class="form-control" id="idDataTransferLimitSources${index}" name="data_transfer_limit_sources${index}" rows="4" placeholder=""
aria-describedby="dtLimitSourcesHelpBlock${index}"></textarea>
<small id="dtLimitSourcesHelpBlock${index}" class="form-text text-muted">
Comma separated IP/Mask in CIDR format, example: "192.168.1.0/24,10.8.0.100/32"
</small>
</div>
<div class="col-md-3">
<div class="form-group">
<input type="number" class="form-control" id="idUploadTransferSource${index}" name="upload_data_transfer_source${index}"
placeholder="" value="" min="0" aria-describedby="ulDtHelpBlock${index}">
<small id="ulDtHelpBlock${index}" class="form-text text-muted">
UL (MB). 0 means no limit
</small>
</div>
<div class="form-group">
<input type="number" class="form-control" id="idDownloadTransferSource${index}" name="download_data_transfer_source${index}"
placeholder="" value="" min="0" aria-describedby="dlDtHelpBlock${index}">
<small id="dlDtHelpBlock${index}" class="form-text text-muted">
DL (MB). 0 means no limit
</small>
</div>
</div>
<div class="col-md-3">
<div class="form-group">
<input type="number" class="form-control" id="idTotalTransferSource${index}" name="total_data_transfer_source${index}"
placeholder="" value="" min="0" aria-describedby="totalDtHelpBlock${index}">
<small id="totalDtHelpBlock${index}" class="form-text text-muted">
Total (MB). 0 means no limit
</small>
</div>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_dtlimit_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
});
$("body").on("click", ".remove_dtlimit_btn_frm_field", function () {
$(this).closest(".form_field_dtlimits_outer_row").remove();
});
$("body").on("click", ".add_new_pattern_field_btn", function () {
var index = $(".form_field_patterns_outer").find(".form_field_patterns_outer_row").length;
while (document.getElementById("idPatternPath"+index) != null){
index++;
}
$(".form_field_patterns_outer").append(`
<div class="row form_field_patterns_outer_row">
<div class="form-group col-md-3">
<input type="text" class="form-control" id="idPatternPath${index}" name="pattern_path${index}" placeholder="directory path, i.e. /dir" value="" maxlength="255">
</div>
<div class="form-group col-md-4">
<input type="text" class="form-control" id="idPatterns${index}" name="patterns${index}" placeholder="*.zip,?.txt" value="" maxlength="255">
</div>
<div class="form-group col-md-2">
<select class="form-control" id="idPatternType${index}" name="pattern_type${index}">
<option value="denied">Denied</option>
<option value="allowed">Allowed</option>
</select>
</div>
<div class="form-group col-md-2">
<select class="form-control" id="idPatternPolicy${index}" name="pattern_policy${index}">
<option value="0">Visible</option>
<option value="1">Hidden</option>
</select>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_pattern_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
$("#idPatternType"+index).selectpicker();
$("#idPatternPolicy"+index).selectpicker();
});
$("body").on("click", ".remove_pattern_btn_frm_field", function () {
$(this).closest(".form_field_patterns_outer_row").remove();
});
</script>
{{end}}

View file

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

View file

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

View file

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

View file

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