From 5c8214e1210701422ed65a5946338b12d43ed843 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Thu, 11 Jan 2024 19:26:13 +0100 Subject: [PATCH] WIP new WebAdmin: groups page Signed-off-by: Nicola Murino --- go.mod | 2 +- go.sum | 4 +- internal/dataprovider/dataprovider.go | 15 +- internal/dataprovider/group.go | 17 +- internal/httpd/server.go | 2 + internal/httpd/webadmin.go | 41 +-- internal/util/i18n.go | 19 + internal/vfs/httpfs.go | 31 +- internal/vfs/sftpfs.go | 30 +- internal/vfs/vfs.go | 127 +++++-- static/locales/en/translation.json | 32 +- static/locales/it/translation.json | 38 +- templates/webadmin/fsconfig.html | 16 +- templates/webadmin/groups.html | 502 ++++++++++++++++---------- templates/webadmin/users.html | 11 +- templates/webclient/files.html | 5 +- templates/webclient/shares.html | 11 +- 17 files changed, 590 insertions(+), 313 deletions(-) diff --git a/go.mod b/go.mod index f472903c..447b7a39 100644 --- a/go.mod +++ b/go.mod @@ -165,7 +165,7 @@ require ( go.opentelemetry.io/otel/metric v1.21.0 // indirect go.opentelemetry.io/otel/trace v1.21.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc // indirect + golang.org/x/exp v0.0.0-20240110193028-0dcbfd608b1e // indirect golang.org/x/mod v0.14.0 // indirect golang.org/x/sync v0.6.0 // indirect golang.org/x/text v0.14.0 // indirect diff --git a/go.sum b/go.sum index adb72f72..a78035e3 100644 --- a/go.sum +++ b/go.sum @@ -430,8 +430,8 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8 gocloud.dev v0.36.0 h1:q5zoXux4xkOZP473e1EZbG8Gq9f0vlg1VNH5Du/ybus= gocloud.dev v0.36.0/go.mod h1:bLxah6JQVKBaIxzsr5BQLYB4IYdWHkMZdzCXlo6F0gg= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc h1:ao2WRsKSzW6KuUY9IWPwWahcHCgR0s52IfwutMfEbdM= -golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= +golang.org/x/exp v0.0.0-20240110193028-0dcbfd608b1e h1:723BNChdd0c2Wk6WOE320qGBiPtYx0F0Bbm1kriShfE= +golang.org/x/exp v0.0.0-20240110193028-0dcbfd608b1e/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= diff --git a/internal/dataprovider/dataprovider.go b/internal/dataprovider/dataprovider.go index 3bb7c780..14a3d421 100644 --- a/internal/dataprovider/dataprovider.go +++ b/internal/dataprovider/dataprovider.go @@ -3131,7 +3131,7 @@ func validateBaseParams(user *User) error { } err := user.FsConfig.Validate(user.GetEncryptionAdditionalData()) if err != nil { - return util.NewI18nError(err, util.I18nErrorFsValidation) + return err } return nil } @@ -3173,17 +3173,22 @@ func createUserPasswordHash(user *User) error { func ValidateFolder(folder *vfs.BaseVirtualFolder) error { folder.FsConfig.SetEmptySecretsIfNil() if folder.Name == "" { - return util.NewValidationError("folder name is mandatory") + return util.NewI18nError(util.NewValidationError("folder name is mandatory"), util.I18nErrorNameRequired) } if config.NamingRules&1 == 0 && !usernameRegex.MatchString(folder.Name) { - return util.NewValidationError(fmt.Sprintf("folder name %q is not valid, the following characters are allowed: a-zA-Z0-9-_.~", - folder.Name)) + return util.NewI18nError( + util.NewValidationError(fmt.Sprintf("folder name %q is not valid, the following characters are allowed: a-zA-Z0-9-_.~", folder.Name)), + util.I18nErrorInvalidName, + ) } if folder.FsConfig.Provider == sdk.LocalFilesystemProvider || folder.FsConfig.Provider == sdk.CryptedFilesystemProvider || folder.MappedPath != "" { cleanedMPath := filepath.Clean(folder.MappedPath) if !filepath.IsAbs(cleanedMPath) { - return util.NewValidationError(fmt.Sprintf("invalid folder mapped path %q", folder.MappedPath)) + return util.NewI18nError( + util.NewValidationError(fmt.Sprintf("invalid folder mapped path %q", folder.MappedPath)), + util.I18nErrorInvalidHomeDir, + ) } folder.MappedPath = cleanedMPath } diff --git a/internal/dataprovider/group.go b/internal/dataprovider/group.go index d2c17360..35bdbe20 100644 --- a/internal/dataprovider/group.go +++ b/internal/dataprovider/group.go @@ -135,13 +135,16 @@ func (g *Group) hasRedactedSecret() bool { func (g *Group) validate() error { g.SetEmptySecretsIfNil() if g.Name == "" { - return util.NewValidationError("name is mandatory") + return util.NewI18nError(util.NewValidationError("name is mandatory"), util.I18nErrorNameRequired) } if config.NamingRules&1 == 0 && !usernameRegex.MatchString(g.Name) { - return util.NewValidationError(fmt.Sprintf("name %q is not valid, the following characters are allowed: a-zA-Z0-9-_.~", g.Name)) + return util.NewI18nError( + util.NewValidationError(fmt.Sprintf("name %q is not valid, the following characters are allowed: a-zA-Z0-9-_.~", g.Name)), + util.I18nErrorInvalidName, + ) } if g.hasRedactedSecret() { - return util.NewValidationError("cannot save a user with a redacted secret") + return util.NewValidationError("cannot save a group with a redacted secret") } vfolders, err := validateAssociatedVirtualFolders(g.VirtualFolders) if err != nil { @@ -155,8 +158,10 @@ 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)) + return util.NewI18nError( + util.NewValidationError(fmt.Sprintf("home_dir must be an absolute path, actual value: %v", g.UserSettings.HomeDir)), + util.I18nErrorInvalidHomeDir, + ) } } if err := g.UserSettings.FsConfig.Validate(g.GetEncryptionAdditionalData()); err != nil { @@ -170,7 +175,7 @@ func (g *Group) validateUserSettings() error { if len(g.UserSettings.Permissions) > 0 { permissions, err := validateUserPermissions(g.UserSettings.Permissions) if err != nil { - return err + return util.NewI18nError(err, util.I18nErrorGenericPermission) } g.UserSettings.Permissions = permissions } diff --git a/internal/httpd/server.go b/internal/httpd/server.go index 20dadf62..de78dd7c 100644 --- a/internal/httpd/server.go +++ b/internal/httpd/server.go @@ -1693,6 +1693,8 @@ func (s *httpdServer) setupWebAdminRoutes() { s.handleWebUpdateUserPost) router.With(s.checkPerm(dataprovider.PermAdminManageGroups), s.refreshCookie). Get(webGroupsPath, s.handleWebGetGroups) + router.With(s.checkPerm(dataprovider.PermAdminManageGroups), s.refreshCookie). + Get(webGroupsPath+"/json", getAllGroups) router.With(s.checkPerm(dataprovider.PermAdminManageGroups), s.refreshCookie). Get(webGroupPath, s.handleWebAddGroupGet) router.With(s.checkPerm(dataprovider.PermAdminManageGroups)).Post(webGroupPath, s.handleWebAddGroupPost) diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go index 513b7e13..9ef67d13 100644 --- a/internal/httpd/webadmin.go +++ b/internal/httpd/webadmin.go @@ -180,11 +180,6 @@ type foldersPage struct { Folders []vfs.BaseVirtualFolder } -type groupsPage struct { - basePage - Groups []dataprovider.Group -} - type rolesPage struct { basePage Roles []dataprovider.Role @@ -447,7 +442,7 @@ func loadAdminTemplates(templatesPath string) { filepath.Join(templatesPath, templateAdminDir, templateFolder), } groupsPaths := []string{ - filepath.Join(templatesPath, templateCommonDir, templateCommonCSS), + filepath.Join(templatesPath, templateCommonDir, templateCommonBase), filepath.Join(templatesPath, templateAdminDir, templateBase), filepath.Join(templatesPath, templateAdminDir, templateGroups), } @@ -2982,7 +2977,7 @@ func getAllUsers(w http.ResponseWriter, r *http.Request) { sendAPIResponse(w, r, nil, util.I18nErrorDirList403, http.StatusForbidden) return } - users := make([]dataprovider.User, 0, defaultQueryLimit) + users := make([]dataprovider.User, 0, 100) for { u, err := dataprovider.GetUsers(defaultQueryLimit, len(users), dataprovider.OrderASC, claims.Role) if err != nil { @@ -3500,7 +3495,7 @@ func (s *httpdServer) handleWebGetFolders(w http.ResponseWriter, r *http.Request } func (s *httpdServer) getWebGroups(w http.ResponseWriter, r *http.Request, limit int, minimal bool) ([]dataprovider.Group, error) { - groups := make([]dataprovider.Group, 0, limit) + groups := make([]dataprovider.Group, 0, 50) for { f, err := dataprovider.GetGroups(limit, len(groups), dataprovider.OrderASC, minimal) if err != nil { @@ -3515,25 +3510,27 @@ func (s *httpdServer) getWebGroups(w http.ResponseWriter, r *http.Request, limit return groups, nil } -func (s *httpdServer) handleWebGetGroups(w http.ResponseWriter, r *http.Request) { +func getAllGroups(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")) + groups := make([]dataprovider.Group, 0, 50) + for { + f, err := dataprovider.GetGroups(defaultQueryLimit, len(groups), dataprovider.OrderASC, false) if err != nil { - limit = defaultQueryLimit + sendAPIResponse(w, r, err, getI18NErrorString(err, util.I18nError500Message), http.StatusInternalServerError) + return + } + groups = append(groups, f...) + if len(f) < defaultQueryLimit { + break } } - groups, err := s.getWebGroups(w, r, limit, false) - if err != nil { - return - } + render.JSON(w, r, groups) +} - data := groupsPage{ - basePage: s.getBasePageData(pageGroupsTitle, webGroupsPath, r), - Groups: groups, - } +func (s *httpdServer) handleWebGetGroups(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + + data := s.getBasePageData(pageGroupsTitle, webGroupsPath, r) renderAdminTemplate(w, templateGroups, data) } diff --git a/internal/util/i18n.go b/internal/util/i18n.go index 067665a9..1c2e1a73 100644 --- a/internal/util/i18n.go +++ b/internal/util/i18n.go @@ -89,6 +89,7 @@ const ( I18nErrorReservedUsername = "user.username_reserved" I18nErrorInvalidEmail = "general.email_invalid" I18nErrorInvalidUser = "user.username_invalid" + I18nErrorInvalidName = "user.name_invalid" I18nErrorHomeRequired = "user.home_required" I18nErrorHomeInvalid = "user.home_invalid" I18nErrorPubKeyInvalid = "user.pub_key_invalid" @@ -161,6 +162,24 @@ const ( I18nStorageHTTP = "storage.http" I18nErrorInvalidQuotaSize = "user.invalid_quota_size" I18nErrorInvalidMaxFilesize = "filters.max_upload_size_invalid" + I18nErrorInvalidHomeDir = "storage.home_dir_invalid" + I18nErrorBucketRequired = "storage.bucket_required" + I18nErrorRegionRequired = "storage.region_required" + I18nErrorKeyPrefixInvalid = "storage.key_prefix_invalid" + I18nErrorULPartSizeInvalid = "storage.ul_part_size_invalid" + I18nErrorDLPartSizeInvalid = "storage.dl_part_size_invalid" + I18nErrorULConcurrencyInvalid = "storage.ul_concurrency_invalid" + I18nErrorDLConcurrencyInvalid = "storage.dl_concurrency_invalid" + I18nErrorAccessKeyRequired = "storage.access_key_required" + I18nErrorAccessSecretRequired = "storage.access_secret_required" + I18nErrorFsCredentialsRequired = "storage.credentials_required" + I18nErrorContainerRequired = "storage.container_required" + I18nErrorAccountNameRequired = "storage.account_name_required" + I18nErrorSASURLInvalid = "storage.sas_url_invalid" + I18nErrorPassphraseRequired = "storage.passphrase_required" + I18nErrorEndpointInvalid = "storage.endpoint_invalid" + I18nErrorEndpointRequired = "storage.endpoint_required" + I18nErrorFsUsernameRequired = "storage.username_required" ) // NewI18nError returns a I18nError wrappring the provided error diff --git a/internal/vfs/httpfs.go b/internal/vfs/httpfs.go index 711a8498..dfe4ab22 100644 --- a/internal/vfs/httpfs.go +++ b/internal/vfs/httpfs.go @@ -122,20 +122,26 @@ func (c *HTTPFsConfig) isSameResource(other HTTPFsConfig) bool { func (c *HTTPFsConfig) validate() error { c.setEmptyCredentialsIfNil() if c.Endpoint == "" { - return errors.New("httpfs: endpoint cannot be empty") + return util.NewI18nError(errors.New("httpfs: endpoint cannot be empty"), util.I18nErrorEndpointRequired) } c.Endpoint = strings.TrimRight(c.Endpoint, "/") endpointURL, err := url.Parse(c.Endpoint) if err != nil { - return fmt.Errorf("httpfs: invalid endpoint: %w", err) + return util.NewI18nError(fmt.Errorf("httpfs: invalid endpoint: %w", err), util.I18nErrorEndpointInvalid) } if !util.IsStringPrefixInSlice(c.Endpoint, supportedEndpointSchema) { - return errors.New("httpfs: invalid endpoint schema: http and https are supported") + return util.NewI18nError( + errors.New("httpfs: invalid endpoint schema: http and https are supported"), + util.I18nErrorEndpointInvalid, + ) } if endpointURL.Host == "unix" { socketPath := endpointURL.Query().Get("socket_path") if !filepath.IsAbs(socketPath) { - return fmt.Errorf("httpfs: invalid unix domain socket path: %q", socketPath) + return util.NewI18nError( + fmt.Errorf("httpfs: invalid unix domain socket path: %q", socketPath), + util.I18nErrorEndpointInvalid, + ) } } if !isEqualityCheckModeValid(c.EqualityCheckMode) { @@ -159,18 +165,29 @@ func (c *HTTPFsConfig) validate() error { // ValidateAndEncryptCredentials validates the config and encrypts credentials if they are in plain text func (c *HTTPFsConfig) ValidateAndEncryptCredentials(additionalData string) error { if err := c.validate(); err != nil { - return util.NewValidationError(fmt.Sprintf("could not validate HTTP fs config: %v", err)) + var errI18n *util.I18nError + errValidation := util.NewValidationError(fmt.Sprintf("could not validate HTTP fs config: %v", err)) + if errors.As(err, &errI18n) { + return util.NewI18nError(errValidation, errI18n.Message) + } + return util.NewI18nError(errValidation, util.I18nErrorFsValidation) } if c.Password.IsPlain() { c.Password.SetAdditionalData(additionalData) if err := c.Password.Encrypt(); err != nil { - return util.NewValidationError(fmt.Sprintf("could not encrypt HTTP fs password: %v", err)) + return util.NewI18nError( + util.NewValidationError(fmt.Sprintf("could not encrypt HTTP fs password: %v", err)), + util.I18nErrorFsValidation, + ) } } if c.APIKey.IsPlain() { c.APIKey.SetAdditionalData(additionalData) if err := c.APIKey.Encrypt(); err != nil { - return util.NewValidationError(fmt.Sprintf("could not encrypt HTTP fs API key: %v", err)) + return util.NewI18nError( + util.NewValidationError(fmt.Sprintf("could not encrypt HTTP fs API key: %v", err)), + util.I18nErrorFsValidation, + ) } } return nil diff --git a/internal/vfs/sftpfs.go b/internal/vfs/sftpfs.go index cab83dcf..8c2b4ae3 100644 --- a/internal/vfs/sftpfs.go +++ b/internal/vfs/sftpfs.go @@ -153,17 +153,17 @@ func (c *SFTPFsConfig) isSameResource(other SFTPFsConfig) bool { func (c *SFTPFsConfig) validate() error { c.setEmptyCredentialsIfNil() if c.Endpoint == "" { - return errors.New("endpoint cannot be empty") + return util.NewI18nError(errors.New("endpoint cannot be empty"), util.I18nErrorEndpointRequired) } if !strings.Contains(c.Endpoint, ":") { c.Endpoint += ":22" } _, _, err := net.SplitHostPort(c.Endpoint) if err != nil { - return fmt.Errorf("invalid endpoint: %v", err) + return util.NewI18nError(fmt.Errorf("invalid endpoint: %v", err), util.I18nErrorEndpointInvalid) } if c.Username == "" { - return errors.New("username cannot be empty") + return util.NewI18nError(errors.New("username cannot be empty"), util.I18nErrorFsUsernameRequired) } if c.BufferSize < 0 || c.BufferSize > 16 { return errors.New("invalid buffer_size, valid range is 0-16") @@ -184,7 +184,7 @@ func (c *SFTPFsConfig) validate() error { func (c *SFTPFsConfig) validateCredentials() error { if c.Password.IsEmpty() && c.PrivateKey.IsEmpty() { - return errors.New("credentials cannot be empty") + return util.NewI18nError(errors.New("credentials cannot be empty"), util.I18nErrorFsCredentialsRequired) } if c.Password.IsEncrypted() && !c.Password.IsValid() { return errors.New("invalid encrypted password") @@ -210,24 +210,38 @@ func (c *SFTPFsConfig) validateCredentials() error { // ValidateAndEncryptCredentials validates the config and encrypts credentials if they are in plain text func (c *SFTPFsConfig) ValidateAndEncryptCredentials(additionalData string) error { if err := c.validate(); err != nil { - return util.NewValidationError(fmt.Sprintf("could not validate SFTP fs config: %v", err)) + var errI18n *util.I18nError + errValidation := util.NewValidationError(fmt.Sprintf("could not validate SFTP fs config: %v", err)) + if errors.As(err, &errI18n) { + return util.NewI18nError(errValidation, errI18n.Message) + } + return util.NewI18nError(errValidation, util.I18nErrorFsValidation) } if c.Password.IsPlain() { c.Password.SetAdditionalData(additionalData) if err := c.Password.Encrypt(); err != nil { - return util.NewValidationError(fmt.Sprintf("could not encrypt SFTP fs password: %v", err)) + return util.NewI18nError( + util.NewValidationError(fmt.Sprintf("could not encrypt SFTP fs password: %v", err)), + util.I18nErrorFsValidation, + ) } } if c.PrivateKey.IsPlain() { c.PrivateKey.SetAdditionalData(additionalData) if err := c.PrivateKey.Encrypt(); err != nil { - return util.NewValidationError(fmt.Sprintf("could not encrypt SFTP fs private key: %v", err)) + return util.NewI18nError( + util.NewValidationError(fmt.Sprintf("could not encrypt SFTP fs private key: %v", err)), + util.I18nErrorFsValidation, + ) } } if c.KeyPassphrase.IsPlain() { c.KeyPassphrase.SetAdditionalData(additionalData) if err := c.KeyPassphrase.Encrypt(); err != nil { - return util.NewValidationError(fmt.Sprintf("could not encrypt SFTP fs private key passphrase: %v", err)) + return util.NewI18nError( + util.NewValidationError(fmt.Sprintf("could not encrypt SFTP fs private key passphrase: %v", err)), + util.I18nErrorFsValidation, + ) } } return nil diff --git a/internal/vfs/vfs.go b/internal/vfs/vfs.go index 75dba336..683bcfef 100644 --- a/internal/vfs/vfs.go +++ b/internal/vfs/vfs.go @@ -305,10 +305,16 @@ func (c *S3FsConfig) isSecretEqual(other S3FsConfig) bool { func (c *S3FsConfig) checkCredentials() error { if c.AccessKey == "" && !c.AccessSecret.IsEmpty() { - return errors.New("access_key cannot be empty with access_secret not empty") + return util.NewI18nError( + errors.New("access_key cannot be empty with access_secret not empty"), + util.I18nErrorAccessKeyRequired, + ) } if c.AccessSecret.IsEmpty() && c.AccessKey != "" { - return errors.New("access_secret cannot be empty with access_key not empty") + return util.NewI18nError( + errors.New("access_secret cannot be empty with access_key not empty"), + util.I18nErrorAccessSecretRequired, + ) } if c.AccessSecret.IsEncrypted() && !c.AccessSecret.IsValid() { return errors.New("invalid encrypted access_secret") @@ -322,13 +328,21 @@ func (c *S3FsConfig) checkCredentials() error { // ValidateAndEncryptCredentials validates the configuration and encrypts access secret if it is in plain text func (c *S3FsConfig) ValidateAndEncryptCredentials(additionalData string) error { if err := c.validate(); err != nil { - return util.NewValidationError(fmt.Sprintf("could not validate s3config: %v", err)) + var errI18n *util.I18nError + errValidation := util.NewValidationError(fmt.Sprintf("could not validate s3config: %v", err)) + if errors.As(err, &errI18n) { + return util.NewI18nError(errValidation, errI18n.Message) + } + return util.NewI18nError(errValidation, util.I18nErrorFsValidation) } if c.AccessSecret.IsPlain() { c.AccessSecret.SetAdditionalData(additionalData) err := c.AccessSecret.Encrypt() if err != nil { - return util.NewValidationError(fmt.Sprintf("could not encrypt s3 access secret: %v", err)) + return util.NewI18nError( + util.NewValidationError(fmt.Sprintf("could not encrypt s3 access secret: %v", err)), + util.I18nErrorFsValidation, + ) } } return nil @@ -336,16 +350,28 @@ func (c *S3FsConfig) ValidateAndEncryptCredentials(additionalData string) error func (c *S3FsConfig) checkPartSizeAndConcurrency() error { if c.UploadPartSize != 0 && (c.UploadPartSize < 5 || c.UploadPartSize > 5000) { - return errors.New("upload_part_size cannot be != 0, lower than 5 (MB) or greater than 5000 (MB)") + return util.NewI18nError( + errors.New("upload_part_size cannot be != 0, lower than 5 (MB) or greater than 5000 (MB)"), + util.I18nErrorULPartSizeInvalid, + ) } if c.UploadConcurrency < 0 || c.UploadConcurrency > 64 { - return fmt.Errorf("invalid upload concurrency: %v", c.UploadConcurrency) + return util.NewI18nError( + fmt.Errorf("invalid upload concurrency: %v", c.UploadConcurrency), + util.I18nErrorULConcurrencyInvalid, + ) } if c.DownloadPartSize != 0 && (c.DownloadPartSize < 5 || c.DownloadPartSize > 5000) { - return errors.New("download_part_size cannot be != 0, lower than 5 (MB) or greater than 5000 (MB)") + return util.NewI18nError( + errors.New("download_part_size cannot be != 0, lower than 5 (MB) or greater than 5000 (MB)"), + util.I18nErrorDLPartSizeInvalid, + ) } if c.DownloadConcurrency < 0 || c.DownloadConcurrency > 64 { - return fmt.Errorf("invalid download concurrency: %v", c.DownloadConcurrency) + return util.NewI18nError( + fmt.Errorf("invalid download concurrency: %v", c.DownloadConcurrency), + util.I18nErrorDLConcurrencyInvalid, + ) } return nil } @@ -366,19 +392,19 @@ func (c *S3FsConfig) validate() error { c.AccessSecret = kms.NewEmptySecret() } if c.Bucket == "" { - return errors.New("bucket cannot be empty") + return util.NewI18nError(errors.New("bucket cannot be empty"), util.I18nErrorBucketRequired) } // the region may be embedded within the endpoint for some S3 compatible // object storage, for example B2 if c.Endpoint == "" && c.Region == "" { - return errors.New("region cannot be empty") + return util.NewI18nError(errors.New("region cannot be empty"), util.I18nErrorRegionRequired) } if err := c.checkCredentials(); err != nil { return err } if c.KeyPrefix != "" { if strings.HasPrefix(c.KeyPrefix, "/") { - return errors.New("key_prefix cannot start with /") + return util.NewI18nError(errors.New("key_prefix cannot start with /"), util.I18nErrorKeyPrefixInvalid) } c.KeyPrefix = path.Clean(c.KeyPrefix) if !strings.HasSuffix(c.KeyPrefix, "/") { @@ -406,13 +432,21 @@ func (c *GCSFsConfig) HideConfidentialData() { // ValidateAndEncryptCredentials validates the configuration and encrypts credentials if they are in plain text func (c *GCSFsConfig) ValidateAndEncryptCredentials(additionalData string) error { if err := c.validate(); err != nil { - return util.NewValidationError(fmt.Sprintf("could not validate GCS config: %v", err)) + var errI18n *util.I18nError + errValidation := util.NewValidationError(fmt.Sprintf("could not validate GCS config: %v", err)) + if errors.As(err, &errI18n) { + return util.NewI18nError(errValidation, errI18n.Message) + } + return util.NewI18nError(errValidation, util.I18nErrorFsValidation) } if c.Credentials.IsPlain() { c.Credentials.SetAdditionalData(additionalData) err := c.Credentials.Encrypt() if err != nil { - return util.NewValidationError(fmt.Sprintf("could not encrypt GCS credentials: %v", err)) + return util.NewI18nError( + util.NewValidationError(fmt.Sprintf("could not encrypt GCS credentials: %v", err)), + util.I18nErrorFsValidation, + ) } } return nil @@ -459,11 +493,11 @@ func (c *GCSFsConfig) validate() error { c.Credentials = kms.NewEmptySecret() } if c.Bucket == "" { - return errors.New("bucket cannot be empty") + return util.NewI18nError(errors.New("bucket cannot be empty"), util.I18nErrorBucketRequired) } if c.KeyPrefix != "" { if strings.HasPrefix(c.KeyPrefix, "/") { - return errors.New("key_prefix cannot start with /") + return util.NewI18nError(errors.New("key_prefix cannot start with /"), util.I18nErrorKeyPrefixInvalid) } c.KeyPrefix = path.Clean(c.KeyPrefix) if !strings.HasSuffix(c.KeyPrefix, "/") { @@ -474,7 +508,7 @@ func (c *GCSFsConfig) validate() error { return errors.New("invalid encrypted credentials") } if c.AutomaticCredentials == 0 && !c.Credentials.IsValidInput() { - return errors.New("invalid credentials") + return util.NewI18nError(errors.New("invalid credentials"), util.I18nErrorFsCredentialsRequired) } c.StorageClass = strings.TrimSpace(c.StorageClass) c.ACL = strings.TrimSpace(c.ACL) @@ -563,18 +597,29 @@ func (c *AzBlobFsConfig) isSecretEqual(other AzBlobFsConfig) bool { // ValidateAndEncryptCredentials validates the configuration and encrypts access secret if it is in plain text func (c *AzBlobFsConfig) ValidateAndEncryptCredentials(additionalData string) error { if err := c.validate(); err != nil { - return util.NewValidationError(fmt.Sprintf("could not validate Azure Blob config: %v", err)) + var errI18n *util.I18nError + errValidation := util.NewValidationError(fmt.Sprintf("could not validate Azure Blob config: %v", err)) + if errors.As(err, &errI18n) { + return util.NewI18nError(errValidation, errI18n.Message) + } + return util.NewI18nError(errValidation, util.I18nErrorFsValidation) } if c.AccountKey.IsPlain() { c.AccountKey.SetAdditionalData(additionalData) if err := c.AccountKey.Encrypt(); err != nil { - return util.NewValidationError(fmt.Sprintf("could not encrypt Azure blob account key: %v", err)) + return util.NewI18nError( + util.NewValidationError(fmt.Sprintf("could not encrypt Azure blob account key: %v", err)), + util.I18nErrorFsValidation, + ) } } if c.SASURL.IsPlain() { c.SASURL.SetAdditionalData(additionalData) if err := c.SASURL.Encrypt(); err != nil { - return util.NewValidationError(fmt.Sprintf("could not encrypt Azure blob SAS URL: %v", err)) + return util.NewI18nError( + util.NewValidationError(fmt.Sprintf("could not encrypt Azure blob SAS URL: %v", err)), + util.I18nErrorFsValidation, + ) } } return nil @@ -583,7 +628,7 @@ func (c *AzBlobFsConfig) ValidateAndEncryptCredentials(additionalData string) er func (c *AzBlobFsConfig) checkCredentials() error { if c.SASURL.IsPlain() { _, err := url.Parse(c.SASURL.GetPayload()) - return err + return util.NewI18nError(err, util.I18nErrorSASURLInvalid) } if c.SASURL.IsEncrypted() && !c.SASURL.IsValid() { return errors.New("invalid encrypted sas_url") @@ -592,7 +637,7 @@ func (c *AzBlobFsConfig) checkCredentials() error { return nil } if c.AccountName == "" || !c.AccountKey.IsValidInput() { - return errors.New("credentials cannot be empty or invalid") + return util.NewI18nError(errors.New("credentials cannot be empty or invalid"), util.I18nErrorAccountNameRequired) } if c.AccountKey.IsEncrypted() && !c.AccountKey.IsValid() { return errors.New("invalid encrypted account_key") @@ -602,16 +647,28 @@ func (c *AzBlobFsConfig) checkCredentials() error { func (c *AzBlobFsConfig) checkPartSizeAndConcurrency() error { if c.UploadPartSize < 0 || c.UploadPartSize > 100 { - return fmt.Errorf("invalid upload part size: %v", c.UploadPartSize) + return util.NewI18nError( + fmt.Errorf("invalid upload part size: %v", c.UploadPartSize), + util.I18nErrorULPartSizeInvalid, + ) } if c.UploadConcurrency < 0 || c.UploadConcurrency > 64 { - return fmt.Errorf("invalid upload concurrency: %v", c.UploadConcurrency) + return util.NewI18nError( + fmt.Errorf("invalid upload concurrency: %v", c.UploadConcurrency), + util.I18nErrorULConcurrencyInvalid, + ) } if c.DownloadPartSize < 0 || c.DownloadPartSize > 100 { - return fmt.Errorf("invalid download part size: %v", c.DownloadPartSize) + return util.NewI18nError( + fmt.Errorf("invalid download part size: %v", c.DownloadPartSize), + util.I18nErrorDLPartSizeInvalid, + ) } if c.DownloadConcurrency < 0 || c.DownloadConcurrency > 64 { - return fmt.Errorf("invalid upload concurrency: %v", c.DownloadConcurrency) + return util.NewI18nError( + fmt.Errorf("invalid upload concurrency: %v", c.DownloadConcurrency), + util.I18nErrorDLConcurrencyInvalid, + ) } return nil } @@ -646,14 +703,14 @@ func (c *AzBlobFsConfig) validate() error { } // container could be embedded within SAS URL we check this at runtime if c.SASURL.IsEmpty() && c.Container == "" { - return errors.New("container cannot be empty") + return util.NewI18nError(errors.New("container cannot be empty"), util.I18nErrorContainerRequired) } if err := c.checkCredentials(); err != nil { return err } if c.KeyPrefix != "" { if strings.HasPrefix(c.KeyPrefix, "/") { - return errors.New("key_prefix cannot start with /") + return util.NewI18nError(errors.New("key_prefix cannot start with /"), util.I18nErrorKeyPrefixInvalid) } c.KeyPrefix = path.Clean(c.KeyPrefix) if !strings.HasSuffix(c.KeyPrefix, "/") { @@ -695,12 +752,20 @@ func (c *CryptFsConfig) isEqual(other CryptFsConfig) bool { // ValidateAndEncryptCredentials validates the configuration and encrypts the passphrase if it is in plain text func (c *CryptFsConfig) ValidateAndEncryptCredentials(additionalData string) error { if err := c.validate(); err != nil { - return util.NewValidationError(fmt.Sprintf("could not validate Crypt fs config: %v", err)) + var errI18n *util.I18nError + errValidation := util.NewValidationError(fmt.Sprintf("could not validate crypt fs config: %v", err)) + if errors.As(err, &errI18n) { + return util.NewI18nError(errValidation, errI18n.Message) + } + return util.NewI18nError(errValidation, util.I18nErrorFsValidation) } if c.Passphrase.IsPlain() { c.Passphrase.SetAdditionalData(additionalData) if err := c.Passphrase.Encrypt(); err != nil { - return util.NewValidationError(fmt.Sprintf("could not encrypt Crypt fs passphrase: %v", err)) + return util.NewI18nError( + util.NewValidationError(fmt.Sprintf("could not encrypt Crypt fs passphrase: %v", err)), + util.I18nErrorFsValidation, + ) } } return nil @@ -713,10 +778,10 @@ func (c *CryptFsConfig) isSameResource(other CryptFsConfig) bool { // validate returns an error if the configuration is not valid func (c *CryptFsConfig) validate() error { if c.Passphrase == nil || c.Passphrase.IsEmpty() { - return errors.New("invalid passphrase") + return util.NewI18nError(errors.New("invalid passphrase"), util.I18nErrorPassphraseRequired) } if !c.Passphrase.IsValidInput() { - return errors.New("passphrase cannot be empty or invalid") + return util.NewI18nError(errors.New("passphrase cannot be empty or invalid"), util.I18nErrorPassphraseRequired) } if c.Passphrase.IsEncrypted() && !c.Passphrase.IsValid() { return errors.New("invalid encrypted passphrase") diff --git a/static/locales/en/translation.json b/static/locales/en/translation.json index 1f4d4301..15ed24c3 100644 --- a/static/locales/en/translation.json +++ b/static/locales/en/translation.json @@ -203,7 +203,8 @@ "denied": "Denied", "zero_no_limit_help": "0 means no limit", "global_settings": "Global settings", - "mandatory_encryption": "Mandatory encryption" + "mandatory_encryption": "Mandatory encryption", + "name_invalid": "The specified username is not valid, the following characters are allowed: a-zA-Z0-9-_.~" }, "fs": { "view_file": "View file \"{{- path}}\"", @@ -311,7 +312,8 @@ "info": "Showing _START_ to _END_ of _TOTAL_ records", "info_empty": "Showing no record", "info_filtered": "(filtered from _MAX_ total records)", - "processing": "Processing..." + "processing": "Processing...", + "no_records": "No records found" }, "editor": { "keybinding": "Editor keybindings", @@ -462,6 +464,11 @@ "submit_export": "Generate and export users", "invalid_quota_size": "Invalid quota size" }, + "group": { + "view_manage": "View and manage groups", + "members": "Members", + "members_summary": "Users: {{users}}. Admins: {{admins}}" + }, "virtual_folders": { "mount_path": "mount path, i.e. /vfolder", "quota_size": "Quota size", @@ -483,6 +490,7 @@ "home_dir_help1": "Leave blank for an appropriate default", "home_dir_help2": "Leave blank and storage to \"Local disk\" to not override the root directory", "home_dir_help3": "Required for local disk storage providers. For other storage providers this folder will be used for temporary files, you can leave it blank for an appropriate default", + "home_dir_invalid": "Invalid root directory, make sure it is an absolute path", "sftp_home_dir": "SFTP root directory", "sftp_home_help": "Restrict access to this SFTP path. Example: \"/somedir/subdir\"", "os_read_buffer": "Download buffer (MB)", @@ -535,7 +543,25 @@ "sftp_concurrent_reads": "Disable concurrent reads", "relaxed_equality_check": "Relaxed equality check", "relaxed_equality_check_help": "Enable to consider only the endpoint to determine if different configurations point to the same server. By default, both the endpoint and username must match", - "api_key": "API key" + "api_key": "API key", + "fs_error": "Filesystem configuration error", + "bucket_required": "$t(storage.fs_error): bucket is required", + "region_required": "$t(storage.fs_error): region is required", + "key_prefix_invalid": "$t(storage.fs_error): invalid key prefix, cannot start with \"/\"", + "ul_part_size_invalid": "$t(storage.fs_error): invalid upload part size", + "ul_concurrency_invalid": "$t(storage.fs_error): invalid upload concurrency", + "dl_part_size_invalid": "$t(storage.fs_error): invalid download part size", + "dl_concurrency_invalid": "$t(storage.fs_error): invalid download concurrency", + "access_key_required": "$t(storage.fs_error): access Key is required", + "access_secret_required": "$t(storage.fs_error): access Secret is required", + "credentials_required": "$t(storage.fs_error): credentials are required", + "container_required": "$t(storage.fs_error): container is required", + "account_name_required": "$t(storage.fs_error): account name is required", + "sas_url_invalid": "$t(storage.fs_error): invalid SAS URL", + "passphrase_required": "$t(storage.fs_error): passphrase is required", + "endpoint_invalid": "$t(storage.fs_error): endpoint is invalid", + "endpoint_required": "$t(storage.fs_error): endpoint is required", + "username_required": "$t(storage.fs_error): username is required" }, "oidc": { "token_expired": "Your OpenID token has expired, please log in again", diff --git a/static/locales/it/translation.json b/static/locales/it/translation.json index 9347908f..f014d4a7 100644 --- a/static/locales/it/translation.json +++ b/static/locales/it/translation.json @@ -203,7 +203,8 @@ "denied": "Negato", "zero_no_limit_help": "0 significa nessun limite", "global_settings": "Impostazioni globali", - "mandatory_encryption": "Crittografia obbligatoria" + "mandatory_encryption": "Crittografia obbligatoria", + "name_invalid": "Il nome specificato non è valido, sono consentiti i seguenti caratteri: a-zA-Z0-9-_.~" }, "fs": { "view_file": "Visualizza file \"{{- path}}\"", @@ -309,9 +310,10 @@ }, "datatable": { "info": "Risultati da _START_ a _END_ di _TOTAL_ elementi", - "info_empty": "Nessun risultato", + "info_empty": "Nulla da mostrare", "info_filtered": "(filtrati da _MAX_ elementi totali)", - "processing": "Elaborazione..." + "processing": "Elaborazione...", + "no_records": "Nessun risultato" }, "editor": { "keybinding": "Combinazioni di tasti dell'editor", @@ -462,6 +464,11 @@ "submit_export": "Genera ed esporta utenti", "invalid_quota_size": "Quota (dimensione) non valida" }, + "group": { + "view_manage": "Visualizza e gestisci gruppi", + "members": "Membri", + "members_summary": "Utenti: {{users}}. Amministratori: {{admins}}" + }, "virtual_folders": { "mount_path": "percorso, es. /vfolder", "quota_size": "Quota (dimensione)", @@ -483,6 +490,7 @@ "home_dir_help1": "Lasciare vuoto per un valore predefinito appropriato", "home_dir_help2": "Lascia vuoto e archiviazione su \"Disco locale\" per non sovrascrivere la directory principale", "home_dir_help3": "Obbligatorio per i provider di archiviazione su disco locale. Per gli altri provider di archiviazione questa cartella sarà usata per i file temporanei, puoi lasciare vuoto per un valore predefinito appropriato", + "home_dir_invalid": "Cartella principale non valida, assicurati che sia un path assoluto", "sftp_home_dir": "Cartella principale SFTP", "sftp_home_help": "Limitare l'accesso a questo percorso SFTP. Esempio: \"/somedir/subdir\"", "os_read_buffer": "Buffer download (MB)", @@ -495,12 +503,12 @@ "endpoint": "Endpoint", "endpoint_help": "Per AWS S3, lasciare vuoto per utilizzare l'endpoint predefinito per la regione specificata", "sftp_endpoint_help": "Endpoint come host:porta. La porta è sempre richiesta", - "ul_part_size": "Dimensioni parte per upload (MB)", + "ul_part_size": "Dimensione parte per upload (MB)", "part_size_help": "0 significa il default (5 MB). Il minimo è 5", "gcs_part_size_help": "0 significa il default (16 MB)", "ul_concurrency": "Concorrenza upload", "ul_concurrency_help": "Numero di parti caricate in parallelo. 0 significa il default (5)", - "dl_part_size": "Dimensioni parte per download (MB)", + "dl_part_size": "Dimensione parte per download (MB)", "dl_concurrency": "Concorrenza download", "dl_concurrency_help": "Numero di parti scaricate in parallelo. 0 significa il default (5)", "ul_part_timeout": "Timeout per upload parte", @@ -535,7 +543,25 @@ "sftp_concurrent_reads": "Disabilitare letture concorrenti", "relaxed_equality_check": "Controllo di uguaglianza non rigoroso", "relaxed_equality_check_help": "Abilitare per considerare solo l'endpoint per determinare se diverse configurazioni puntano allo stesso server. Per impostazione predefinita, sia l'endpoint che il nome utente devono corrispondere", - "api_key": "Chiave API" + "api_key": "Chiave API", + "fs_error": "Errore configurazione filesystem", + "bucket_required": "$t(storage.fs_error): il bucket è obbligatorio", + "region_required": "$t(storage.fs_error): la regione è obbligatoria", + "key_prefix_invalid": "$t(storage.fs_error): prefisso chiave non valido, non può iniziare con \"/\"", + "ul_part_size_invalid": "$t(storage.fs_error): dimensione parte per upload non valida", + "ul_concurrency_invalid": "$t(storage.fs_error): concorrenza upload non valida", + "dl_part_size_invalid": "$t(storage.fs_error): dimensione parte per download non valida", + "dl_concurrency_invalid": "$t(storage.fs_error): concorrenza download non valida", + "access_key_required": "$t(storage.fs_error): la chiave di accesso è obbligatoria", + "access_secret_required": "$t(storage.fs_error): la chiave di accesso segreta è obbligatoria", + "credentials_required": "$t(storage.fs_error): le credenziali per il filesystem sono obbligatorie", + "container_required": "$t(storage.fs_error): il contenitore è obbligatorio", + "account_name_required": "$t(storage.fs_error): il nome account è obbligatorio", + "sas_url_invalid": "$t(storage.fs_error): SAS URL non valido", + "passphrase_required": "$t(storage.fs_error): la passphrase è obbligatoria", + "endpoint_invalid": "$t(storage.fs_error): endpoint non valido", + "endpoint_required": "$t(storage.fs_error): endpoint è obbligatorio", + "username_required": "$t(storage.fs_error): nome utente è obbligatorio" }, "oidc": { "token_expired": "Il tuo token OpenID è scaduto, effettua nuovamente l'accesso", diff --git a/templates/webadmin/fsconfig.html b/templates/webadmin/fsconfig.html index 4dac2cf1..9fc862fd 100644 --- a/templates/webadmin/fsconfig.html +++ b/templates/webadmin/fsconfig.html @@ -130,13 +130,13 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
- +
- +
@@ -144,13 +144,13 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
- +
- +
@@ -306,13 +306,13 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
- +
- +
@@ -320,13 +320,13 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
- +
- +
diff --git a/templates/webadmin/groups.html b/templates/webadmin/groups.html index 425726a8..543a2cca 100644 --- a/templates/webadmin/groups.html +++ b/templates/webadmin/groups.html @@ -1,229 +1,339 @@ {{template "base" .}} -{{define "title"}}{{.Title}}{{end}} +{{- define "extra_css"}} + +{{- end}} -{{define "extra_css"}} - - - - - -{{end}} - -{{define "page_body"}} - - - -
-
-
View and manage groups
+{{- define "page_body"}} +{{- template "errmsg" ""}} +
+
+

View and manage groups

-
-
- +
+
+ + Loading... +
+
+
+
+ + +
+ +
+ + + {{- if .LoggedUser.HasPermission "manage_groups"}} + + + Add + + {{- end}} +
+
+ +
- - - - + + + + + - - {{range .Groups}} - - - - - - {{end}} - +
NameDescriptionMembers
NameMembersDescription
{{.Name}}{{.Description}}{{.GetMembersAsString}}
-{{end}} +{{- end}} +{{- define "extra_js"}} + + - - - - - - - - - - -{{end}} \ No newline at end of file +{{- end}} \ No newline at end of file diff --git a/templates/webadmin/users.html b/templates/webadmin/users.html index c26311c0..44655f9f 100644 --- a/templates/webadmin/users.html +++ b/templates/webadmin/users.html @@ -33,10 +33,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
- - - - +
@@ -48,8 +45,8 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).