From c85601146d767efddcdb34e2738318d781d22a9f Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Wed, 31 Jan 2024 20:49:25 +0100 Subject: [PATCH] WIP new WebAdmin: event actions Signed-off-by: Nicola Murino --- docs/eventmanager.md | 10 +- go.mod | 8 +- go.sum | 16 +- internal/dataprovider/eventrule.go | 139 ++- internal/httpd/server.go | 2 + internal/httpd/web.go | 1 - internal/httpd/webadmin.go | 244 ++-- internal/util/i18n.go | 40 + pkgs/build.sh | 2 +- static/locales/en/translation.json | 132 ++- static/locales/it/translation.json | 132 ++- templates/common/base.html | 4 + templates/webadmin/eventaction.html | 1534 ++++++++++++-------------- templates/webadmin/eventactions.html | 494 +++++---- templates/webadmin/users.html | 2 +- templates/webclient/editfile.html | 11 +- templates/webclient/share.html | 2 +- 17 files changed, 1585 insertions(+), 1188 deletions(-) diff --git a/docs/eventmanager.md b/docs/eventmanager.md index e671fb7e..f1d2a69b 100644 --- a/docs/eventmanager.md +++ b/docs/eventmanager.md @@ -26,9 +26,9 @@ The following actions are supported: The following placeholders are supported: -- `{{Name}}`. Username, folder name or admin username for provider events. +- `{{Name}}`. Username, virtual folder name, admin username for provider events, domain name for TLS certificate events. - `{{Event}}`. Event name, for example `upload`, `download` for filesystem events or `add`, `update` for provider events. -- `{{Status}}`. Status for `upload`, `download` and `ssh_cmd` events. 1 means no error, 2 means a generic error occurred, 3 means quota exceeded error. +- `{{Status}}`. Status for filesystem events. 1 means no error, 2 means a generic error occurred, 3 means quota exceeded error. - `{{StatusString}}`. Status as string. Possible values "OK", "KO". - `{{ErrorString}}`. Error details. Replaced with an empty string if no errors occur. - `{{VirtualPath}}`. Path seen by SFTPGo users, for example `/adir/afile.txt`. @@ -37,10 +37,10 @@ The following placeholders are supported: - `{{ObjectName}}`. File/directory name, for example `afile.txt` or provider object name. - `{{ObjectType}}`. Object type for provider events: `user`, `group`, `admin`, etc. - `{{Ext}}`. File extension, for example `.txt` if the filename is `afile.txt`. -- `{{VirtualTargetPath}}`. Virtual target path for renames. +- `{{VirtualTargetPath}}`. Virtual target path for rename and copy operations. - `{{VirtualTargetDirPath}}`. Parent directory for VirtualTargetPath. -- `{{TargetName}}`. Target object name for renames. -- `{{FsTargetPath}}`. Full filesystem target path for renames. +- `{{TargetName}}`. Target object name for rename and copy operations. +- `{{FsTargetPath}}`. Full filesystem target path for rename and copy operations. - `{{FileSize}}`. File size. - `{{Elapsed}}`. Elapsed time as milliseconds for filesystem events. - `{{Protocol}}`. Used protocol, for example `SFTP`, `FTP`. diff --git a/go.mod b/go.mod index e9ef564c..3f65c20d 100644 --- a/go.mod +++ b/go.mod @@ -41,7 +41,7 @@ require ( github.com/klauspost/compress v1.17.5 github.com/lestrrat-go/jwx/v2 v2.0.19 github.com/lithammer/shortuuid/v3 v3.0.7 - github.com/mattn/go-sqlite3 v1.14.20 + github.com/mattn/go-sqlite3 v1.14.21 github.com/mhale/smtpd v0.8.2 github.com/minio/sio v0.3.1 github.com/otiai10/copy v1.14.0 @@ -74,15 +74,15 @@ require ( golang.org/x/sys v0.16.0 golang.org/x/term v0.16.0 golang.org/x/time v0.5.0 - google.golang.org/api v0.159.0 + google.golang.org/api v0.161.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) require ( cloud.google.com/go v0.112.0 // indirect - cloud.google.com/go/compute v1.23.3 // indirect + cloud.google.com/go/compute v1.23.4 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect - cloud.google.com/go/iam v1.1.5 // indirect + cloud.google.com/go/iam v1.1.6 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1 // indirect github.com/ajg/form v1.5.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect diff --git a/go.sum b/go.sum index d4c27db1..ec8c3e9b 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,12 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.112.0 h1:tpFCD7hpHFlQ8yPwT3x+QeXqc2T6+n6T+hmABHfDUSM= cloud.google.com/go v0.112.0/go.mod h1:3jEEVwZ/MHU4djK5t5RHuKOA/GbLddgTdVubX1qnPD4= -cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk= -cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI= +cloud.google.com/go/compute v1.23.4 h1:EBT9Nw4q3zyE7G45Wvv3MzolIrCJEuHys5muLY0wvAw= +cloud.google.com/go/compute v1.23.4/go.mod h1:/EJMj55asU6kAFnuZET8zqgwgJ9FvXWXOkkfQZa4ioI= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= -cloud.google.com/go/iam v1.1.5 h1:1jTsCu4bcsNsE4iiqNT5SHwrDRCfRmIaaaVFhRveTJI= -cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8= +cloud.google.com/go/iam v1.1.6 h1:bEa06k05IO4f4uJonbB5iAgKTPpABy1ayxaIZV/GHVc= +cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI= cloud.google.com/go/kms v1.15.5 h1:pj1sRfut2eRbD9pFRjNnPNg/CzJPuQAzUujMIM1vVeM= cloud.google.com/go/kms v1.15.5/go.mod h1:cU2H5jnp6G2TDpUGZyqTCoy1n16fbubHZjmVXSMtwDI= cloud.google.com/go/storage v1.37.0 h1:WI8CsaFO8Q9KjPVtsZ5Cmi0dXV25zMoX0FklT7c3Jm4= @@ -286,8 +286,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.20 h1:BAZ50Ns0OFBNxdAqFhbZqdPcht1Xlb16pDCqkq1spr0= -github.com/mattn/go-sqlite3 v1.14.20/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.21 h1:IXocQLOykluc3xPE0Lvy8FtggMz1G+U3mEjg+0zGizc= +github.com/mattn/go-sqlite3 v1.14.21/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mhale/smtpd v0.8.2 h1:rHKOMHeFoDvcq8Na9ErCbNcjlWTSyGtznOmJpWsOzuc= github.com/mhale/smtpd v0.8.2/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4= github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= @@ -523,8 +523,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= -google.golang.org/api v0.159.0 h1:fVTj+7HHiUYz4JEZCHHoRIeQX7h5FMzrA2RF/DzDdbs= -google.golang.org/api v0.159.0/go.mod h1:0mu0TpK33qnydLvWqbImq2b1eQ5FHRSDCBzAxX9ZHyw= +google.golang.org/api v0.161.0 h1:oYzk/bs26WN10AV7iU7MVJVXBH8oCPS2hHyBiEeFoSU= +google.golang.org/api v0.161.0/go.mod h1:0mu0TpK33qnydLvWqbImq2b1eQ5FHRSDCBzAxX9ZHyw= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= diff --git a/internal/dataprovider/eventrule.go b/internal/dataprovider/eventrule.go index f95106d9..f013969a 100644 --- a/internal/dataprovider/eventrule.go +++ b/internal/dataprovider/eventrule.go @@ -64,31 +64,31 @@ func isActionTypeValid(action int) bool { func getActionTypeAsString(action int) string { switch action { case ActionTypeHTTP: - return "HTTP" + return util.I18nActionTypeHTTP case ActionTypeEmail: - return "Email" + return util.I18nActionTypeEmail case ActionTypeBackup: - return "Backup" + return util.I18nActionTypeBackup case ActionTypeUserQuotaReset: - return "User quota reset" + return util.I18nActionTypeUserQuotaReset case ActionTypeFolderQuotaReset: - return "Folder quota reset" + return util.I18nActionTypeFolderQuotaReset case ActionTypeTransferQuotaReset: - return "Transfer quota reset" + return util.I18nActionTypeTransferQuotaReset case ActionTypeDataRetentionCheck: - return "Data retention check" + return util.I18nActionTypeDataRetentionCheck case ActionTypeMetadataCheck: - return "Metadata check" + return util.I18nActionTypeMetadataCheck case ActionTypeFilesystem: - return "Filesystem" + return util.I18nActionTypeFilesystem case ActionTypePasswordExpirationCheck: - return "Password expiration check" + return util.I18nActionTypePwdExpirationCheck case ActionTypeUserExpirationCheck: - return "User expiration check" + return util.I18nActionTypeUserExpirationCheck case ActionTypeIDPAccountCheck: - return "Identity Provider account check" + return util.I18nActionTypeIDPCheck default: - return "Command" + return util.I18nActionTypeCommand } } @@ -171,17 +171,17 @@ func isFilesystemActionValid(value int) bool { func getFsActionTypeAsString(value int) string { switch value { case FilesystemActionRename: - return "Rename" + return util.I18nActionFsTypeRename case FilesystemActionDelete: - return "Delete" + return util.I18nActionFsTypeDelete case FilesystemActionExist: - return "Paths exist" + return util.I18nActionFsTypePathExists case FilesystemActionCompress: - return "Compress" + return util.I18nActionFsTypeCompress case FilesystemActionCopy: - return "Copy" + return util.I18nActionFsTypeCopy default: - return "Create directories" + return util.I18nActionFsTypeCreateDirs } } @@ -259,7 +259,7 @@ type HTTPPart struct { func (p *HTTPPart) validate() error { if p.Name == "" { - return util.NewValidationError("HTTP part name is required") + return util.NewI18nError(util.NewValidationError("HTTP part name is required"), util.I18nErrorHTTPPartNameRequired) } for _, kv := range p.Headers { if kv.isNotValid() { @@ -268,7 +268,10 @@ func (p *HTTPPart) validate() error { } if p.Filepath == "" { if p.Body == "" { - return util.NewValidationError("HTTP part body is required if no file path is provided") + return util.NewI18nError( + util.NewValidationError("HTTP part body is required if no file path is provided"), + util.I18nErrorHTTPPartBodyRequired, + ) } } else { p.Body = "" @@ -318,18 +321,24 @@ func (c *EventActionHTTPConfig) validateMultiparts() error { } if filePath := c.Parts[idx].Filepath; filePath != "" { if filePaths[filePath] { - return fmt.Errorf("filepath %q is duplicated", filePath) + return util.NewI18nError(fmt.Errorf("filepath %q is duplicated", filePath), util.I18nErrorPathDuplicated) } filePaths[filePath] = true } } if len(c.Parts) > 0 { if c.Body != "" { - return util.NewValidationError("multipart requests require no body. The request body is build from the specified parts") + return util.NewI18nError( + util.NewValidationError("multipart requests require no body. The request body is build from the specified parts"), + util.I18nErrorMultipartBody, + ) } for _, k := range c.Headers { if strings.ToLower(k.Key) == "content-type" { - return util.NewValidationError("content type is automatically set for multipart requests") + return util.NewI18nError( + util.NewValidationError("content type is automatically set for multipart requests"), + util.I18nErrorMultipartCType, + ) } } } @@ -338,10 +347,13 @@ func (c *EventActionHTTPConfig) validateMultiparts() error { func (c *EventActionHTTPConfig) validate(additionalData string) error { if c.Endpoint == "" { - return util.NewValidationError("HTTP endpoint is required") + return util.NewI18nError(util.NewValidationError("HTTP endpoint is required"), util.I18nErrorURLRequired) } if !util.IsStringPrefixInSlice(c.Endpoint, []string{"http://", "https://"}) { - return util.NewValidationError("invalid HTTP endpoint schema: http and https are supported") + return util.NewI18nError( + util.NewValidationError("invalid HTTP endpoint schema: http and https are supported"), + util.I18nErrorURLInvalid, + ) } if c.isTimeoutNotValid() { return util.NewValidationError(fmt.Sprintf("invalid HTTP timeout %d", c.Timeout)) @@ -443,10 +455,13 @@ type EventActionCommandConfig struct { func (c *EventActionCommandConfig) validate() error { if c.Cmd == "" { - return util.NewValidationError("command is required") + return util.NewI18nError(util.NewValidationError("command is required"), util.I18nErrorCommandRequired) } if !filepath.IsAbs(c.Cmd) { - return util.NewValidationError("invalid command, it must be an absolute path") + return util.NewI18nError( + util.NewValidationError("invalid command, it must be an absolute path"), + util.I18nErrorCommandInvalid, + ) } if c.Timeout < 1 || c.Timeout > 120 { return util.NewValidationError(fmt.Sprintf("invalid command action timeout %d", c.Timeout)) @@ -506,7 +521,10 @@ func (c *EventActionEmailConfig) hasFilesAttachments() bool { func (c *EventActionEmailConfig) validate() error { if len(c.Recipients) == 0 { - return util.NewValidationError("at least one email recipient is required") + return util.NewI18nError( + util.NewValidationError("at least one email recipient is required"), + util.I18nErrorEmailRecipientRequired, + ) } c.Recipients = util.RemoveDuplicates(c.Recipients, false) for _, r := range c.Recipients { @@ -521,10 +539,16 @@ func (c *EventActionEmailConfig) validate() error { } } if c.Subject == "" { - return util.NewValidationError("email subject is required") + return util.NewI18nError( + util.NewValidationError("email subject is required"), + util.I18nErrorEmailSubjectRequired, + ) } if c.Body == "" { - return util.NewValidationError("email body is required") + return util.NewI18nError( + util.NewValidationError("email body is required"), + util.I18nErrorEmailBodyRequired, + ) } if c.ContentType < 0 || c.ContentType > 1 { return util.NewValidationError("invalid email content type") @@ -589,12 +613,18 @@ func (c *EventActionDataRetentionConfig) validate() error { nothingToDo = false } if _, ok := folderPaths[f.Path]; ok { - return util.NewValidationError(fmt.Sprintf("duplicated folder path %q", f.Path)) + return util.NewI18nError( + util.NewValidationError(fmt.Sprintf("duplicated folder path %q", f.Path)), + util.I18nErrorPathDuplicated, + ) } folderPaths[f.Path] = true } if nothingToDo { - return util.NewValidationError("nothing to delete!") + return util.NewI18nError( + util.NewValidationError("nothing to delete!"), + util.I18nErrorRetentionDirRequired, + ) } return nil } @@ -609,14 +639,14 @@ type EventActionFsCompress struct { func (c *EventActionFsCompress) validate() error { if c.Name == "" { - return util.NewValidationError("archive name is mandatory") + return util.NewI18nError(util.NewValidationError("archive name is mandatory"), util.I18nErrorArchiveNameRequired) } c.Name = util.CleanPath(strings.TrimSpace(c.Name)) if c.Name == "/" { - return util.NewValidationError("invalid archive name") + return util.NewI18nError(util.NewValidationError("invalid archive name"), util.I18nErrorRootNotAllowed) } if len(c.Paths) == 0 { - return util.NewValidationError("no path to compress specified") + return util.NewI18nError(util.NewValidationError("no path to compress specified"), util.I18nErrorPathRequired) } for idx, val := range c.Paths { val = strings.TrimSpace(val) @@ -673,7 +703,7 @@ func (c EventActionFilesystemConfig) GetCompressPathsAsString() string { func (c *EventActionFilesystemConfig) validateRenames() error { if len(c.Renames) == 0 { - return util.NewValidationError("no path to rename specified") + return util.NewI18nError(util.NewValidationError("no path to rename specified"), util.I18nErrorPathRequired) } for idx, kv := range c.Renames { key := strings.TrimSpace(kv.Key) @@ -684,10 +714,16 @@ func (c *EventActionFilesystemConfig) validateRenames() error { key = util.CleanPath(key) value = util.CleanPath(value) if key == value { - return util.NewValidationError("rename source and target cannot be equal") + return util.NewI18nError( + util.NewValidationError("rename source and target cannot be equal"), + util.I18nErrorSourceDestMatch, + ) } if key == "/" || value == "/" { - return util.NewValidationError("renaming the root directory is not allowed") + return util.NewI18nError( + util.NewValidationError("renaming the root directory is not allowed"), + util.I18nErrorRootNotAllowed, + ) } c.Renames[idx] = KeyValue{ Key: key, @@ -699,7 +735,7 @@ func (c *EventActionFilesystemConfig) validateRenames() error { func (c *EventActionFilesystemConfig) validateCopy() error { if len(c.Copy) == 0 { - return util.NewValidationError("no path to copy specified") + return util.NewI18nError(util.NewValidationError("no path to copy specified"), util.I18nErrorPathRequired) } for idx, kv := range c.Copy { key := strings.TrimSpace(kv.Key) @@ -710,10 +746,16 @@ func (c *EventActionFilesystemConfig) validateCopy() error { key = util.CleanPath(key) value = util.CleanPath(value) if key == value { - return util.NewValidationError("copy source and target cannot be equal") + return util.NewI18nError( + util.NewValidationError("copy source and target cannot be equal"), + util.I18nErrorSourceDestMatch, + ) } if key == "/" || value == "/" { - return util.NewValidationError("copying the root directory is not allowed") + return util.NewI18nError( + util.NewValidationError("copying the root directory is not allowed"), + util.I18nErrorRootNotAllowed, + ) } if strings.HasSuffix(c.Copy[idx].Key, "/") { key += "/" @@ -731,7 +773,7 @@ func (c *EventActionFilesystemConfig) validateCopy() error { func (c *EventActionFilesystemConfig) validateDeletes() error { if len(c.Deletes) == 0 { - return util.NewValidationError("no path to delete specified") + return util.NewI18nError(util.NewValidationError("no path to delete specified"), util.I18nErrorPathRequired) } for idx, val := range c.Deletes { val = strings.TrimSpace(val) @@ -746,7 +788,7 @@ func (c *EventActionFilesystemConfig) validateDeletes() error { func (c *EventActionFilesystemConfig) validateMkdirs() error { if len(c.MkDirs) == 0 { - return util.NewValidationError("no directory to create specified") + return util.NewI18nError(util.NewValidationError("no directory to create specified"), util.I18nErrorPathRequired) } for idx, val := range c.MkDirs { val = strings.TrimSpace(val) @@ -761,7 +803,7 @@ func (c *EventActionFilesystemConfig) validateMkdirs() error { func (c *EventActionFilesystemConfig) validateExist() error { if len(c.Exist) == 0 { - return util.NewValidationError("no path to check for existence specified") + return util.NewI18nError(util.NewValidationError("no path to check for existence specified"), util.I18nErrorPathRequired) } for idx, val := range c.Exist { val = strings.TrimSpace(val) @@ -885,7 +927,10 @@ type EventActionIDPAccountCheck struct { func (c *EventActionIDPAccountCheck) validate() error { if c.TemplateAdmin == "" && c.TemplateUser == "" { - return util.NewValidationError("at least a template must be set") + return util.NewI18nError( + util.NewValidationError("at least a template must be set"), + util.I18nErrorIDPTemplateRequired, + ) } if c.Mode < 0 || c.Mode > 1 { return util.NewValidationError(fmt.Sprintf("invalid account check mode: %d", c.Mode)) @@ -1129,7 +1174,7 @@ func (a *BaseEventAction) RenderAsJSON(reload bool) ([]byte, error) { func (a *BaseEventAction) validate() error { if a.Name == "" { - return util.NewValidationError("name is mandatory") + return util.NewI18nError(util.NewValidationError("name is mandatory"), util.I18nErrorNameRequired) } if !isActionTypeValid(a.Type) { return util.NewValidationError(fmt.Sprintf("invalid action type: %d", a.Type)) diff --git a/internal/httpd/server.go b/internal/httpd/server.go index d46d1277..470ab1bb 100644 --- a/internal/httpd/server.go +++ b/internal/httpd/server.go @@ -1761,6 +1761,8 @@ func (s *httpdServer) setupWebAdminRoutes() { router.With(s.checkPerm(dataprovider.PermAdminViewDefender)).Get(webDefenderHostsPath, getDefenderHosts) router.With(s.checkPerm(dataprovider.PermAdminManageDefender)).Delete(webDefenderHostsPath+"/{id}", deleteDefenderHostByID) + router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), compressor.Handler, s.refreshCookie). + Get(webAdminEventActionsPath+jsonAPISuffix, getAllActions) router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), s.refreshCookie). Get(webAdminEventActionsPath, s.handleWebGetEventActions) router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), s.refreshCookie). diff --git a/internal/httpd/web.go b/internal/httpd/web.go index 34e69d29..01326fb3 100644 --- a/internal/httpd/web.go +++ b/internal/httpd/web.go @@ -42,7 +42,6 @@ const ( templateResetPassword = "reset-password.html" templateChangePwd = "changepassword.html" templateMessage = "message.html" - templateCommonCSS = "sftpgo.css" templateCommonBase = "base.html" templateCommonBaseLogin = "baselogin.html" templateCommonLogin = "login.html" diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go index d250072b..3fb39183 100644 --- a/internal/httpd/webadmin.go +++ b/internal/httpd/webadmin.go @@ -99,7 +99,6 @@ const ( templateMFA = "mfa.html" templateSetup = "adminsetup.html" pageEventRulesTitle = "Event rules" - pageEventActionsTitle = "Event actions" defaultQueryLimit = 1000 inversePatternType = "inverse" ) @@ -159,11 +158,6 @@ type eventRulesPage struct { Rules []dataprovider.EventRule } -type eventActionsPage struct { - basePage - Actions []dataprovider.BaseEventAction -} - type statusPage struct { basePage Status *ServicesStatus @@ -305,7 +299,7 @@ type eventActionPage struct { FsActions []dataprovider.EnumMapping HTTPMethods []string RedactedSecret string - Error string + Error *util.I18nError Mode genericPageMode } @@ -418,22 +412,22 @@ func loadAdminTemplates(templatesPath string) { filepath.Join(templatesPath, templateAdminDir, templateGroup), } eventRulesPaths := []string{ - filepath.Join(templatesPath, templateCommonDir, templateCommonCSS), + filepath.Join(templatesPath, templateCommonDir, templateCommonBase), filepath.Join(templatesPath, templateAdminDir, templateBase), filepath.Join(templatesPath, templateAdminDir, templateEventRules), } eventRulePaths := []string{ - filepath.Join(templatesPath, templateCommonDir, templateCommonCSS), + filepath.Join(templatesPath, templateCommonDir, templateCommonBase), filepath.Join(templatesPath, templateAdminDir, templateBase), filepath.Join(templatesPath, templateAdminDir, templateEventRule), } eventActionsPaths := []string{ - filepath.Join(templatesPath, templateCommonDir, templateCommonCSS), + filepath.Join(templatesPath, templateCommonDir, templateCommonBase), filepath.Join(templatesPath, templateAdminDir, templateBase), filepath.Join(templatesPath, templateAdminDir, templateEventActions), } eventActionPaths := []string{ - filepath.Join(templatesPath, templateCommonDir, templateCommonCSS), + filepath.Join(templatesPath, templateCommonDir, templateCommonBase), filepath.Join(templatesPath, templateAdminDir, templateBase), filepath.Join(templatesPath, templateAdminDir, templateEventAction), } @@ -1075,16 +1069,16 @@ func (s *httpdServer) renderGroupPage(w http.ResponseWriter, r *http.Request, gr } func (s *httpdServer) renderEventActionPage(w http.ResponseWriter, r *http.Request, action dataprovider.BaseEventAction, - mode genericPageMode, error string, + mode genericPageMode, err error, ) { action.Options.SetEmptySecretsIfNil() var title, currentURL string switch mode { case genericPageModeAdd: - title = "Add a new event action" + title = util.I18nAddActionTitle currentURL = webAdminEventActionPath case genericPageModeUpdate: - title = "Update event action" + title = util.I18nUpdateActionTitle currentURL = fmt.Sprintf("%s/%s", webAdminEventActionPath, url.PathEscape(action.Name)) } if action.Options.HTTPConfig.Timeout == 0 { @@ -1104,7 +1098,7 @@ func (s *httpdServer) renderEventActionPage(w http.ResponseWriter, r *http.Reque FsActions: dataprovider.FsActionTypes, HTTPMethods: dataprovider.SupportedHTTPActionMethods, RedactedSecret: redactedSecret, - Error: error, + Error: getI18nError(err), Mode: mode, } renderAdminTemplate(w, templateEventAction, data) @@ -2159,87 +2153,147 @@ func getGroupFromPostFields(r *http.Request) (dataprovider.Group, error) { func getKeyValsFromPostFields(r *http.Request, key, val string) []dataprovider.KeyValue { var res []dataprovider.KeyValue - for k := range r.Form { - if strings.HasPrefix(k, key) { - formKey := r.Form.Get(k) - idx := strings.TrimPrefix(k, key) - formVal := strings.TrimSpace(r.Form.Get(fmt.Sprintf("%s%s", val, idx))) - if formKey != "" && formVal != "" { - res = append(res, dataprovider.KeyValue{ - Key: formKey, - Value: formVal, - }) - } + + keys := r.Form[key] + values := r.Form[val] + + for idx, k := range keys { + v := values[idx] + if k != "" && v != "" { + res = append(res, dataprovider.KeyValue{ + Key: k, + Value: v, + }) } } + return res } func getFoldersRetentionFromPostFields(r *http.Request) ([]dataprovider.FolderRetention, error) { var res []dataprovider.FolderRetention - for k := range r.Form { - if strings.HasPrefix(k, "folder_retention_path") { - folderPath := strings.TrimSpace(r.Form.Get(k)) - if folderPath != "" { - idx := strings.TrimPrefix(k, "folder_retention_path") - retention, err := strconv.Atoi(r.Form.Get(fmt.Sprintf("folder_retention_val%s", idx))) - if err != nil { - return nil, fmt.Errorf("invalid retention for path %q: %w", folderPath, err) - } - options := r.Form[fmt.Sprintf("folder_retention_options%s", idx)] - res = append(res, dataprovider.FolderRetention{ - Path: folderPath, - Retention: retention, - DeleteEmptyDirs: util.Contains(options, "1"), - IgnoreUserPermissions: util.Contains(options, "2"), - }) + paths := r.Form["folder_retention_path"] + values := r.Form["folder_retention_val"] + + for idx, p := range paths { + if p != "" { + retention, err := strconv.Atoi(values[idx]) + if err != nil { + return nil, fmt.Errorf("invalid retention for path %q: %w", p, err) } + opts := r.Form["folder_retention_options"+strconv.Itoa(idx)] + res = append(res, dataprovider.FolderRetention{ + Path: p, + Retention: retention, + DeleteEmptyDirs: util.Contains(opts, "1"), + IgnoreUserPermissions: util.Contains(opts, "2"), + }) } } + return res, nil } func getHTTPPartsFromPostFields(r *http.Request) []dataprovider.HTTPPart { var result []dataprovider.HTTPPart - for k := range r.Form { - if strings.HasPrefix(k, "http_part_name") { - partName := strings.TrimSpace(r.Form.Get(k)) - if partName != "" { - idx := strings.TrimPrefix(k, "http_part_name") - order, err := strconv.Atoi(idx) - if err != nil { - continue - } - filePath := strings.TrimSpace(r.Form.Get(fmt.Sprintf("http_part_file%s", idx))) - body := r.Form.Get(fmt.Sprintf("http_part_body%s", idx)) - concatHeaders := getSliceFromDelimitedValues(r.Form.Get(fmt.Sprintf("http_part_headers%s", idx)), "\n") - var headers []dataprovider.KeyValue - for _, h := range concatHeaders { - values := strings.SplitN(h, ":", 2) - if len(values) > 1 { - headers = append(headers, dataprovider.KeyValue{ - Key: strings.TrimSpace(values[0]), - Value: strings.TrimSpace(values[1]), - }) - } - } - result = append(result, dataprovider.HTTPPart{ - Name: partName, - Filepath: filePath, - Headers: headers, - Body: body, - Order: order, - }) + + names := r.Form["http_part_name"] + files := r.Form["http_part_file"] + headers := r.Form["http_part_headers"] + bodies := r.Form["http_part_body"] + orders := r.Form["http_part_order"] + + for idx, partName := range names { + if partName != "" { + order, err := strconv.Atoi(orders[idx]) + if err != nil { + continue } + filePath := files[idx] + body := bodies[idx] + concatHeaders := getSliceFromDelimitedValues(headers[idx], "\n") + var headers []dataprovider.KeyValue + for _, h := range concatHeaders { + values := strings.SplitN(h, ":", 2) + if len(values) > 1 { + headers = append(headers, dataprovider.KeyValue{ + Key: strings.TrimSpace(values[0]), + Value: strings.TrimSpace(values[1]), + }) + } + } + result = append(result, dataprovider.HTTPPart{ + Name: partName, + Filepath: filePath, + Headers: headers, + Body: body, + Order: order, + }) } } + sort.Slice(result, func(i, j int) bool { return result[i].Order < result[j].Order }) return result } +func updateRepeaterFormActionFields(r *http.Request) { + for k := range r.Form { + if hasPrefixAndSuffix(k, "http_headers[", "][http_header_key]") { + base, _ := strings.CutSuffix(k, "[http_header_key]") + r.Form.Add("http_header_key", strings.TrimSpace(r.Form.Get(k))) + r.Form.Add("http_header_value", strings.TrimSpace(r.Form.Get(base+"[http_header_value]"))) + continue + } + if hasPrefixAndSuffix(k, "query_parameters[", "][http_query_key]") { + base, _ := strings.CutSuffix(k, "[http_query_key]") + r.Form.Add("http_query_key", strings.TrimSpace(r.Form.Get(k))) + r.Form.Add("http_query_value", strings.TrimSpace(r.Form.Get(base+"[http_query_value]"))) + continue + } + if hasPrefixAndSuffix(k, "multipart_body[", "][http_part_name]") { + base, _ := strings.CutSuffix(k, "[http_part_name]") + order, _ := strings.CutPrefix(k, "multipart_body[") + order, _ = strings.CutSuffix(order, "][http_part_name]") + r.Form.Add("http_part_name", strings.TrimSpace(r.Form.Get(k))) + r.Form.Add("http_part_file", strings.TrimSpace(r.Form.Get(base+"[http_part_file]"))) + r.Form.Add("http_part_headers", strings.TrimSpace(r.Form.Get(base+"[http_part_headers]"))) + r.Form.Add("http_part_body", strings.TrimSpace(r.Form.Get(base+"[http_part_body]"))) + r.Form.Add("http_part_order", order) + continue + } + if hasPrefixAndSuffix(k, "env_vars[", "][cmd_env_key]") { + base, _ := strings.CutSuffix(k, "[cmd_env_key]") + r.Form.Add("cmd_env_key", strings.TrimSpace(r.Form.Get(k))) + r.Form.Add("cmd_env_value", strings.TrimSpace(r.Form.Get(base+"[cmd_env_value]"))) + continue + } + if hasPrefixAndSuffix(k, "data_retention[", "][folder_retention_path]") { + base, _ := strings.CutSuffix(k, "[folder_retention_path]") + r.Form.Add("folder_retention_path", strings.TrimSpace(r.Form.Get(k))) + r.Form.Add("folder_retention_val", strings.TrimSpace(r.Form.Get(base+"[folder_retention_val]"))) + r.Form["folder_retention_options"+strconv.Itoa(len(r.Form["folder_retention_path"])-1)] = + r.Form[base+"[folder_retention_options][]"] + continue + } + if hasPrefixAndSuffix(k, "fs_rename[", "][fs_rename_source]") { + base, _ := strings.CutSuffix(k, "[fs_rename_source]") + r.Form.Add("fs_rename_source", strings.TrimSpace(r.Form.Get(k))) + r.Form.Add("fs_rename_target", strings.TrimSpace(r.Form.Get(base+"[fs_rename_target]"))) + continue + } + if hasPrefixAndSuffix(k, "fs_copy[", "][fs_copy_source]") { + base, _ := strings.CutSuffix(k, "[fs_copy_source]") + r.Form.Add("fs_copy_source", strings.TrimSpace(r.Form.Get(k))) + r.Form.Add("fs_copy_target", strings.TrimSpace(r.Form.Get(base+"[fs_copy_target]"))) + continue + } + } +} + func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEventActionOptions, error) { + updateRepeaterFormActionFields(r) httpTimeout, err := strconv.Atoi(r.Form.Get("http_timeout")) if err != nil { return dataprovider.BaseEventActionOptions{}, fmt.Errorf("invalid http timeout: %w", err) @@ -2281,11 +2335,11 @@ func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEven Endpoint: strings.TrimSpace(r.Form.Get("http_endpoint")), Username: strings.TrimSpace(r.Form.Get("http_username")), Password: getSecretFromFormField(r, "http_password"), - Headers: getKeyValsFromPostFields(r, "http_header_key", "http_header_val"), + Headers: getKeyValsFromPostFields(r, "http_header_key", "http_header_value"), Timeout: httpTimeout, SkipTLSVerify: r.Form.Get("http_skip_tls_verify") != "", Method: r.Form.Get("http_method"), - QueryParameters: getKeyValsFromPostFields(r, "http_query_key", "http_query_val"), + QueryParameters: getKeyValsFromPostFields(r, "http_query_key", "http_query_value"), Body: r.Form.Get("http_body"), Parts: getHTTPPartsFromPostFields(r), }, @@ -2293,7 +2347,7 @@ func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEven Cmd: strings.TrimSpace(r.Form.Get("cmd_path")), Args: cmdArgs, Timeout: cmdTimeout, - EnvVars: getKeyValsFromPostFields(r, "cmd_env_key", "cmd_env_val"), + EnvVars: getKeyValsFromPostFields(r, "cmd_env_key", "cmd_env_value"), }, EmailConfig: dataprovider.EventActionEmailConfig{ Recipients: getSliceFromDelimitedValues(r.Form.Get("email_recipients"), ","), @@ -3623,25 +3677,27 @@ func (s *httpdServer) getWebEventActions(w http.ResponseWriter, r *http.Request, return actions, nil } -func (s *httpdServer) handleWebGetEventActions(w http.ResponseWriter, r *http.Request) { +func getAllActions(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")) + actions := make([]dataprovider.BaseEventAction, 0, 10) + for { + res, err := dataprovider.GetEventActions(defaultQueryLimit, len(actions), dataprovider.OrderASC, false) if err != nil { - limit = defaultQueryLimit + sendAPIResponse(w, r, err, getI18NErrorString(err, util.I18nError500Message), http.StatusInternalServerError) + return + } + actions = append(actions, res...) + if len(res) < defaultQueryLimit { + break } } - actions, err := s.getWebEventActions(w, r, limit, false) - if err != nil { - return - } + render.JSON(w, r, actions) +} - data := eventActionsPage{ - basePage: s.getBasePageData(pageEventActionsTitle, webAdminEventActionsPath, r), - Actions: actions, - } +func (s *httpdServer) handleWebGetEventActions(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + + data := s.getBasePageData(util.I18nActionsTitle, webAdminEventActionsPath, r) renderAdminTemplate(w, templateEventActions, data) } @@ -3650,7 +3706,7 @@ func (s *httpdServer) handleWebAddEventActionGet(w http.ResponseWriter, r *http. action := dataprovider.BaseEventAction{ Type: dataprovider.ActionTypeHTTP, } - s.renderEventActionPage(w, r, action, genericPageModeAdd, "") + s.renderEventActionPage(w, r, action, genericPageModeAdd, nil) } func (s *httpdServer) handleWebAddEventActionPost(w http.ResponseWriter, r *http.Request) { @@ -3662,7 +3718,7 @@ func (s *httpdServer) handleWebAddEventActionPost(w http.ResponseWriter, r *http } action, err := getEventActionFromPostFields(r) if err != nil { - s.renderEventActionPage(w, r, action, genericPageModeAdd, err.Error()) + s.renderEventActionPage(w, r, action, genericPageModeAdd, err) return } ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) @@ -3671,7 +3727,7 @@ func (s *httpdServer) handleWebAddEventActionPost(w http.ResponseWriter, r *http return } if err = dataprovider.AddEventAction(&action, claims.Username, ipAddr, claims.Role); err != nil { - s.renderEventActionPage(w, r, action, genericPageModeAdd, err.Error()) + s.renderEventActionPage(w, r, action, genericPageModeAdd, err) return } http.Redirect(w, r, webAdminEventActionsPath, http.StatusSeeOther) @@ -3682,7 +3738,7 @@ func (s *httpdServer) handleWebUpdateEventActionGet(w http.ResponseWriter, r *ht name := getURLParam(r, "name") action, err := dataprovider.EventActionExists(name) if err == nil { - s.renderEventActionPage(w, r, action, genericPageModeUpdate, "") + s.renderEventActionPage(w, r, action, genericPageModeUpdate, nil) } else if errors.Is(err, util.ErrNotFound) { s.renderNotFoundPage(w, r, err) } else { @@ -3708,7 +3764,7 @@ func (s *httpdServer) handleWebUpdateEventActionPost(w http.ResponseWriter, r *h } updatedAction, err := getEventActionFromPostFields(r) if err != nil { - s.renderEventActionPage(w, r, updatedAction, genericPageModeUpdate, err.Error()) + s.renderEventActionPage(w, r, updatedAction, genericPageModeUpdate, err) return } ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) @@ -3727,7 +3783,7 @@ func (s *httpdServer) handleWebUpdateEventActionPost(w http.ResponseWriter, r *h } err = dataprovider.UpdateEventAction(&updatedAction, claims.Username, ipAddr, claims.Role) if err != nil { - s.renderEventActionPage(w, r, updatedAction, genericPageModeUpdate, err.Error()) + s.renderEventActionPage(w, r, updatedAction, genericPageModeUpdate, err) return } http.Redirect(w, r, webAdminEventActionsPath, http.StatusSeeOther) diff --git a/internal/util/i18n.go b/internal/util/i18n.go index aa793169..fb33dc4d 100644 --- a/internal/util/i18n.go +++ b/internal/util/i18n.go @@ -68,6 +68,9 @@ const ( I18nUpdateIPListTitle = "title.update_ip_list" I18nDefenderTitle = "title.defender" I18nEventsTitle = "title.logs" + I18nActionsTitle = "title.event_actions" + I18nAddActionTitle = "title.add_action" + I18nUpdateActionTitle = "title.update_action" I18nStatusTitle = "status.desc" I18nErrorSetupInstallCode = "setup.install_code_mismatch" I18nInvalidAuth = "general.invalid_auth_request" @@ -238,6 +241,43 @@ const ( I18nErrorSMTPClientIDRequired = "smtp.client_id_required" I18nErrorSMTPClientSecretRequired = "smtp.client_secret_required" I18nErrorSMTPRefreshTokenRequired = "smtp.refresh_token_required" + I18nErrorURLRequired = "actions.http_url_required" + I18nErrorURLInvalid = "actions.http_url_invalid" + I18nErrorHTTPPartNameRequired = "actions.http_part_name_required" + I18nErrorHTTPPartBodyRequired = "actions.http_part_body_required" + I18nErrorMultipartBody = "actions.http_multipart_body_error" + I18nErrorMultipartCType = "actions.http_multipart_ctype_error" + I18nErrorPathDuplicated = "actions.path_duplicated" + I18nErrorCommandRequired = "actions.command_required" + I18nErrorCommandInvalid = "actions.command_invalid" + I18nErrorEmailRecipientRequired = "actions.email_recipient_required" + I18nErrorEmailSubjectRequired = "actions.email_subject_required" + I18nErrorEmailBodyRequired = "actions.email_body_required" + I18nErrorRetentionDirRequired = "actions.retention_directory_required" + I18nErrorPathRequired = "actions.path_required" + I18nErrorSourceDestMatch = "actions.source_dest_different" + I18nErrorRootNotAllowed = "actions.root_not_allowed" + I18nErrorArchiveNameRequired = "actions.archive_name_required" + I18nErrorIDPTemplateRequired = "actions.idp_template_required" + I18nActionTypeHTTP = "actions.types.http" + I18nActionTypeEmail = "actions.types.email" + I18nActionTypeBackup = "actions.types.backup" + I18nActionTypeUserQuotaReset = "actions.types.user_quota_reset" + I18nActionTypeFolderQuotaReset = "actions.types.folder_quota_reset" + I18nActionTypeTransferQuotaReset = "actions.types.transfer_quota_reset" + I18nActionTypeDataRetentionCheck = "actions.types.data_retention_check" + I18nActionTypeMetadataCheck = "actions.types.metadata_check" + I18nActionTypeFilesystem = "actions.types.filesystem" + I18nActionTypePwdExpirationCheck = "actions.types.password_expiration_check" + I18nActionTypeUserExpirationCheck = "actions.types.user_expiration_check" + I18nActionTypeIDPCheck = "actions.types.idp_check" + I18nActionTypeCommand = "actions.types.command" + I18nActionFsTypeRename = "actions.fs_types.rename" + I18nActionFsTypeDelete = "actions.fs_types.delete" + I18nActionFsTypePathExists = "actions.fs_types.path_exists" + I18nActionFsTypeCompress = "actions.fs_types.compress" + I18nActionFsTypeCopy = "actions.fs_types.copy" + I18nActionFsTypeCreateDirs = "actions.fs_types.create_dirs" ) // NewI18nError returns a I18nError wrappring the provided error diff --git a/pkgs/build.sh b/pkgs/build.sh index d53c5291..a2695fa6 100755 --- a/pkgs/build.sh +++ b/pkgs/build.sh @@ -1,6 +1,6 @@ #!/bin/bash -NFPM_VERSION=2.35.2 +NFPM_VERSION=2.35.3 NFPM_ARCH=${NFPM_ARCH:-amd64} if [ -z ${SFTPGO_VERSION} ] then diff --git a/static/locales/en/translation.json b/static/locales/en/translation.json index 1361df08..df84ab10 100644 --- a/static/locales/en/translation.json +++ b/static/locales/en/translation.json @@ -48,6 +48,7 @@ "add_user": "Add user", "update_user": "Update user", "template_user": "User template", + "template_admin": "Admin template", "add_group": "Add group", "update_group": "Update group", "add_folder": "Add virtual folder", @@ -60,7 +61,9 @@ "add_admin": "Add admin", "update_admin": "Update admin", "add_ip_list": "Add IP list entry", - "update_ip_list": "Update IP list entry" + "update_ip_list": "Update IP list entry", + "add_action": "Add action", + "update_action": "Update action" }, "setup": { "desc": "To start using SFTPGo you need to create an administrator user", @@ -243,7 +246,13 @@ "domain": "Domain", "test": "Test", "get": "Get", - "export": "Export" + "export": "Export", + "value": "Value", + "method": "Method", + "timeout": "Timeout", + "env_vars": "Environment variables", + "hours": "Hours", + "paths": "Paths" }, "fs": { "view_file": "View file \"{{- path}}\"", @@ -401,7 +410,6 @@ "scope_write": "Write", "scope_read_write": "Read/Write", "scope_help": "For scope \"Write\" and \"Read/Write\" you have to define a single path and it must be a directory", - "paths": "Paths", "path_help": "file or directory path, i.e. /dir or /dir/file.txt", "password_help": "If set the share will be password-protected", "max_tokens": "Max tokens", @@ -877,5 +885,123 @@ "role": "role", "ip_list_entry": "IP list entry", "configs": "Configurations" + }, + "actions": { + "view_manage": "View and manage rule actions for events", + "http_url": "Server URL", + "http_url_help": "i.e https://host:port/path. Placeholders are supported within the URL path", + "http_url_required": "URL is required", + "http_url_invalid": "The URL is invalid, http and https schemes are supported", + "http_part_name_required": "HTTP part name is required", + "http_part_body_required": "HTTP part body is required if no file path is provided", + "http_multipart_body_error": "Multipart requests require no body. The request body is build from the specified parts", + "http_multipart_ctype_error": "Content-Type is automatically set for multipart requests", + "path_duplicated": "Path duplicated", + "command_required": "Command is required", + "command_invalid": "Invalid command, it must be an absolute path", + "email_recipient_required": "At least one email recipient is required", + "email_subject_required": "Email subject is required", + "email_body_required": "Email body is required", + "retention_directory_required": "At least one directory to check is required", + "path_required": "At least a path is required", + "source_dest_different": "Source and target path must be different", + "root_not_allowed": "The root path (/) is not allowed", + "archive_name_required": "Compressed archive name is required", + "idp_template_required": "A user or admin template is required", + "threshold": "Threshold", + "threshold_help": "An email notification will be generated for users whose password expires in a number of days less than or equal to this threshold", + "idp_mode_add_update": "Create or update", + "idp_mode_add": "Create if it doesn't exist", + "template_user_help": "Template for SFTPGo users in JSON format. Placeholders are supported", + "template_admin_help": "Template for SFTPGo admins in JSON format. Placeholders are supported", + "placeholders_help": "Placeholders are supported", + "http_headers": "HTTP headers", + "query_parameters": "Query string parameters", + "http_timeout_help": "Ignored for multipart requests with files as attachments", + "body": "Body", + "http_body_help": "Placeholders are supported. Ignored for HTTP get requested. Leave empty for multipart requests", + "multipart_body": "Multipart body", + "multipart_body_help": "HTTP Multipart requests allow to combine one or more sets of data into a single body. For each part, you can set a file path or a body as text. Placeholders are supported in file path, body, header values", + "http_part_name": "Part name", + "http_part_file": "File path", + "http_part_headers": "Additional part headers one per line as \"key: value\"", + "command_help": "Absolute path of the command to execute", + "command_args": "Arguments", + "command_args_help": "Comma separated command arguments. Placeholders are supported", + "command_env_vars_help": "Placeholders are supported in values. Setting the name to \"$\" without quotes means retrieving the value from the environment", + "email_recipients": "To", + "email_recipients_help": "Comma separated recipients. Placeholders are supported", + "email_bcc": "Bcc", + "email_bcc_help": "Comma separated Bcc addresses. Placeholders are supported", + "email_subject": "Subject", + "content_type": "Content Type", + "attachments": "Attachments", + "attachments_help": "Comma separated paths to attach. Placeholders are supported. The total size is limited to 10 MB", + "data_retention": "Data retention", + "data_retention_help": "Set the data retention, as hours, per path. Retention applies recursively. Setting 0 as retention means excluding the specified path. \"Ignore user permissions\" defines whether to delete files even if the user does not have the \"delete\" permission, by default files will be skipped if the user does not have the \"delete\" permission", + "delete_empty_dirs": "Delete empty dirs", + "ignore_user_perms": "Ignore user permissions", + "fs_action": "Filesystem action", + "paths_src_dst_help": "Paths as seen by SFTPGo users. Placeholders are supported. The required permissions are granted automatically", + "source_path": "Source", + "target_path": "Target", + "paths_help": "Comma separated paths as seen by SFTPGo users. Placeholders are supported. The required permissions are granted automatically", + "archive_path": "Archive path", + "archive_path_help": "Full path, as seen by SFTPGo users, to the zip archive to create. Placeholders are supported. If the specified file already exists, it is overwritten", + "placeholders_modal_title": "Supported placeholders", + "types": { + "http": "HTTP", + "email": "Email", + "backup": "Backup", + "user_quota_reset": "User quota reset", + "folder_quota_reset": "Folder quota reset", + "transfer_quota_reset": "Transfer quota reset", + "data_retention_check": "Data retention check", + "metadata_check": "Metadata check", + "filesystem": "Filesystem", + "password_expiration_check": "Password expiration check", + "user_expiration_check": "User expiration check", + "idp_check": "Identity Provider account check", + "command": "Command" + }, + "fs_types": { + "rename": "Rename", + "delete": "Delete", + "path_exists": "Paths exis", + "compress": "Compress", + "copy": "Copy", + "create_dirs": "Create directories" + }, + "placeholders_modal": { + "name": "Username, virtual folder name, admin username for provider events, domain name for TLS certificate events", + "event": "Event name, for example \"upload\", \"download\" for filesystem events or \"add\", \"update\" for provider events", + "status": "Status for filesystem events. 1 means no error, 2 means a generic error occurred, 3 means quota exceeded error", + "status_string": "Status as string. Possible values \"OK\", \"KO\"", + "error_string": "Error details. Replaced with an empty string if no errors occur", + "virtual_path": "Path seen by SFTPGo users, for example \"/adir/afile.txt\"", + "virtual_dir_path": "Parent directory for \"VirtualPath\", for example if \"VirtualPath\" is \"/adir/afile.txt\", \"VirtualDirPath\" is \"/adir\"", + "fs_path": "Full filesystem path, for example \"/user/homedir/adir/afile.txt\" or \"C:/data/user/homedir/adir/afile.txt\" on Windows", + "ext": "File extension, for example \".txt\" if the filename is \"afile.txt\"", + "object_name": "File/directory name, for example \"afile.txt\" or provider object name", + "object_type": "Object type for provider events: \"user\", \"group\", \"admin\", etc", + "virtual_target_path": "Virtual target path for rename and copy operations", + "virtual_target_dir_path": "Parent directory for \"VirtualTargetPath\"", + "target_name": "Target object name for rename and copy operations", + "fs_target_path": "Full filesystem target path for rename and copy operations", + "file_size": "File size (bytes)", + "elapsed": "Elapsed time as milliseconds for filesystem events", + "protocol": "Protocol, for example \"SFTP\", \"FTP\"", + "ip": "Client IP address", + "role": "User or admin role", + "timestamp": "Event timestamp as nanoseconds since epoch", + "email": "For filesystem events, this is the email associated with the user performing the action. For the provider events, this is the email associated with the affected user or admin. Blank in all other cases", + "object_data": "Provider object data serialized as JSON with sensitive fields removed", + "object_data_string": "Provider object data as JSON escaped string with sensitive fields removed", + "retention_reports": "Data retention reports as zip compressed CSV files. Supported as email attachment, file path for multipart HTTP request and as single parameter for HTTP requests body", + "idp_field": "Identity Provider custom fields containing a string", + "metadata": "Cloud storage metadata for the downloaded file serialized as JSON", + "metadata_string": "Cloud storage metadata for the downloaded file as JSON escaped string", + "uid": "Unique ID" + } } } \ No newline at end of file diff --git a/static/locales/it/translation.json b/static/locales/it/translation.json index 1738fc36..ddd002f3 100644 --- a/static/locales/it/translation.json +++ b/static/locales/it/translation.json @@ -48,6 +48,7 @@ "add_user": "Aggiungi utente", "update_user": "Aggiorna utente", "template_user": "Modello utente", + "template_admin": "Modello amministratore", "add_group": "Aggiungi gruppo", "update_group": "Aggiorna gruppo", "add_folder": "Aggiungi cartella virtuale", @@ -60,7 +61,9 @@ "add_admin": "Aggiungi amministratore", "update_admin": "Aggiorna amministratore", "add_ip_list": "Aggiungi elemento a lista IP", - "update_ip_list": "Aggiorna elemento lista IP" + "update_ip_list": "Aggiorna elemento lista IP", + "add_action": "Aggiungi azione", + "update_action": "Aggiorna azione" }, "setup": { "desc": "Per iniziare a utilizzare SFTPGo devi creare un utente amministratore", @@ -243,7 +246,13 @@ "domain": "Dominio", "test": "Test", "get": "Ottieni", - "export": "Esporta" + "export": "Esporta", + "value": "Valore", + "method": "Metodo", + "timeout": "Timeout", + "env_vars": "Variabili d'ambiente", + "hours": "Ore", + "paths": "Percorsi" }, "fs": { "view_file": "Visualizza file \"{{- path}}\"", @@ -401,7 +410,6 @@ "scope_write": "Scrittura", "scope_read_write": "Lettura/Scrittura", "scope_help": "Per gli ambiti \"Scrittura\" e \"Lettura/Scrittura\" devi definire un singolo percorso e deve essere una cartella", - "paths": "Percorsi", "path_help": "percorso di un file o di una directory, ad esempio /dir o /dir/file.txt", "password_help": "Se impostata, la condivisione sarà protetta da password", "max_tokens": "Token massimi", @@ -877,5 +885,123 @@ "role": "ruolo", "ip_list_entry": "Elemento lista IP", "configs": "Configurazioni" + }, + "actions": { + "view_manage": "Visualizza e gestisci le azioni delle regole per gli eventi", + "http_url": "URL Server", + "http_url_help": "Ad es. \"https://host:port/path\". I segnaposto sono supportati nel path dell'URL", + "http_url_required": "L'URL è obbligatorio", + "http_url_invalid": "L'URL non è valido, gli schemi http e https sono supportati", + "http_part_name_required": "Il nome della parte HTTP è obbligatorio", + "http_part_body_required": "Il body della parte HTTP è obbligatorio se non viene fornito alcun percorso file", + "http_multipart_body_error": "Le richieste multipart non richiedono body. Il body della richiesta è costruito dalle parti specificate", + "http_multipart_ctype_error": "Il Content-Type è impostato automaticamente per le richieste multipart", + "path_duplicated": "Percorso duplicato", + "command_required": "Il comando è obbligatorio", + "command_invalid": "Il comando non è valido, deve essere un percorso assoluto", + "email_recipient_required": "È obbligatorio almeno un destinatario e-mail", + "email_subject_required": "L'oggetto dell'e-mail è obbligatorio", + "email_body_required": "Il corpo dell'e-mail è obbligatorio", + "retention_directory_required": "È obbligatoria almeno una cartella da controllare", + "path_required": "Almeno un percorso è obbligatorio", + "source_dest_different": "Il percorso di origine e destinazione devono essere differenti", + "root_not_allowed": "La directory radice (/) non è permessa", + "archive_name_required": "Il nome dell'archivio compresso è obbligatorio", + "idp_template_required": "Un modello di utenti o amministratori è obbligatorio", + "threshold": "Soglia", + "threshold_help": "Verrà generata una notifica email per gli utenti la cui password scade tra un numero di giorni inferiore o uguale a questa soglia", + "idp_mode_add_update": "Crea o aggiorna", + "idp_mode_add": "Crea se non esiste", + "template_user_help": "Modello per gli utenti SFTPGo in formato JSON. I segnaposto sono supportati", + "template_admin_help": "Modello per gli amministratori SFTPGo in formato JSON. I segnaposto sono supportati", + "placeholders_help": "I segnaposto sono supportati", + "http_headers": "Header HTTP", + "query_parameters": "Parametri query-string", + "http_timeout_help": "Ignorato per richieste multipart con file allegati", + "body": "Body", + "http_body_help": "I segnaposto sono supportati. Ignorato per la richiesta HTTP. Lasciare vuoto per richieste multipart", + "multipart_body": "Body multipart", + "multipart_body_help": "Le richieste HTTP multipart consentono di combinare uno o più set di dati in un unico body. Per ciascuna parte è possibile impostare un percorso file o un body come testo. I segnaposto sono supportati nel percorso del file, nel body e nei valori degli header", + "http_part_name": "Nome parte", + "http_part_file": "Percorso file", + "http_part_headers": "Header aggiuntivi, una per riga come \"chiave: valore\"", + "command_help": "Percorso assoluto del comando da eseguire", + "command_args": "Argomenti", + "command_args_help": "Argomenti del comando separati da virgole. I segnaposto sono supportati", + "command_env_vars_help": "I segnaposto sono supportati nei valori. Impostare il nome su \"$\" senza virgolette significa recuperare il valore dall'ambiente", + "email_recipients": "A", + "email_recipients_help": "Destinatari separati da virgole. I segnaposto sono supportati", + "email_bcc": "Ccn", + "email_bcc_help": "Indirizzi Ccn separati da virgole. I segnaposto sono supportati", + "email_subject": "Oggetto", + "content_type": "Content Type", + "attachments": "Allegati", + "attachments_help": "Percorsi da allegare separati da virgole. I segnaposto sono supportati. La dimensione totale è limitata a 10 MB", + "data_retention": "Conservazione dati", + "data_retention_help": "Imposta la conservazione dei dati, in ore, per percorso. La conservazione si applica in modo ricorsivo. Impostare 0 come conservazione significa escludere il percorso specificato. \"Ignora permessi utente\" definisce se eliminare i file anche se l'utente non dispone dell'autorizzazione \"delete\", per impostazione predefinita i file verranno ignorati se l'utente non dispone dell'autorizzazione \"delete\"", + "delete_empty_dirs": "Cancella cartelle vuote", + "ignore_user_perms": "Ignora permessi utente", + "fs_action": "Azione del filesystem", + "paths_src_dst_help": "Percorsi visti dagli utenti SFTPGo. I segnaposto sono supportati. Le autorizzazioni richieste vengono concesse automaticamente", + "source_path": "Origine", + "target_path": "Destinazione", + "paths_help": "Percorsi visti dagli utenti SFTPGo separati da virgole. I segnaposto sono supportati. Le autorizzazioni richieste vengono concesse automaticamente", + "archive_path": "Percorso dell'archivio", + "archive_path_help": "Percorso completo, come visto dagli utenti SFTPGo, dell'archivio zip da creare. I segnaposto sono supportati. Se il file specificato esiste già, verrà sovrascritto", + "placeholders_modal_title": "Segnaposto supportati", + "types": { + "http": "HTTP", + "email": "Email", + "backup": "Backup", + "user_quota_reset": "Ricalcolo quota utente", + "folder_quota_reset": "Ricalcolo quota cartella virtuale", + "transfer_quota_reset": "Reimpostazione quota trasferimenti", + "data_retention_check": "Controllo conservazione dati", + "metadata_check": "Controllo metadati", + "filesystem": "Filesystem", + "password_expiration_check": "Controllo password scadute", + "user_expiration_check": "Controllo utenti scaduti", + "idp_check": "Controllo account Identity Provider", + "command": "Comando" + }, + "fs_types": { + "rename": "Rinomina", + "delete": "Eliminazione", + "path_exists": "Esistenza percorsi", + "compress": "Compressione", + "copy": "Copia", + "create_dirs": "Creazione directory" + }, + "placeholders_modal": { + "name": "Nome utente, nome cartella, nome utente amministratore per eventi provider, nome dominio per eventi relativi ai certificati TLS", + "event": "Nome dell'evento, ad esempio \"upload\", \"download\" per eventi del file system o \"add\", \"update\" per eventi del provider", + "status": "Stato per eventi del file system. 1 significa nessun errore, 2 significa che si è verificato un errore generico, 3 significa errore di superamento quota", + "status_string": "Stato come stringa. Valori possibili \"OK\", \"KO\"", + "error_string": "Dettagli circa l'errore. Sostituito con una stringa vuota se non si verificano errori", + "virtual_path": "Percorso visualizzato dagli utenti SFTPGo, ad esempio \"/adir/afile.txt\"", + "virtual_dir_path": "Directory superiore per \"VirtualPath\", ad esempio se \"VirtualPath\" è \"/adir/afile.txt\", \"VirtualDirPath\" è \"/adir\"", + "fs_path": "Percorso completo del filesystem, ad esempio \"/user/homedir/adir/afile.txt\" o \"C:/data/user/homedir/adir/afile.txt\" su Windows", + "ext": "Estensione del file, ad esempio \".txt\" se il nome del file è \"afile.txt\"", + "object_name": "Nome del file/directory, ad esempio \"afile.txt\" o nome dell'oggetto del provider", + "object_type": "Tipo di oggetto per gli eventi provider: \"user\", \"group\", \"admin\", ecc", + "virtual_target_path": "Percorso di destinazione virtuale per le operazioni di ridenominazione e copia", + "virtual_target_dir_path": "Cartella superiore per \"VirtualTargetPath\"", + "target_name": "Nome dell'oggetto di destinazione per le operazioni di ridenominazione e copia", + "fs_target_path": "Percorso di destinazione completo su file system per le operazioni di ridenominazione e copia", + "file_size": "Dimensione file (bytes)", + "elapsed": "Tempo trascorso in millisecondi per gli eventi del file system", + "protocol": "Protocollo, ad esempio \"SFTP\", \"FTP\"", + "ip": "Indirizzo IP del client", + "role": "Ruolo dell'utente o dell'amministratore", + "timestamp": "Timestamp dell'evento in nanosecondi dall'epoch time", + "email": "Per gli eventi del file system, questa è l'e-mail associata all'utente che esegue l'azione. Per gli eventi del provider, si tratta dell'e-mail associata all'utente o all'amministratore interessato. Vuoto in tutti gli altri casi", + "object_data": "Dati dell'oggetto provider serializzati come JSON con campi sensibili rimossi", + "object_data_string": "Dati dell'oggetto provider serializzati come stringa JSON escaped con campi sensibili rimossi", + "retention_reports": "Report sulla conservazione dei dati come file CSV compressi zip. Supportato come allegato e-mail, percorso file per richieste HTTP multipart e come parametro singolo per il body delle richieste HTTP", + "idp_field": "Campi personalizzati dell'Identity Provdider contenenti una stringa", + "metadata": "Metadati del Cloud Storage Provider serializzati come JSON per i file scaricati", + "metadata_string": "Metadati del Cloud Storage Provider serializzati come stringa JSON escaped per i file scaricati", + "uid": "ID univoco" + } } } \ No newline at end of file diff --git a/templates/common/base.html b/templates/common/base.html index 78b4778f..4d108c4b 100644 --- a/templates/common/base.html +++ b/templates/common/base.html @@ -336,6 +336,10 @@ explicit grant from the SFTPGo Team (support@sftpgo.com). font-weight: 500 !important; font-size: 1.1rem !important; } + + .shortcut { + font-family: monospace; color: #666; + } {{- end}} diff --git a/templates/webadmin/eventaction.html b/templates/webadmin/eventaction.html index 998d4226..21df870d 100644 --- a/templates/webadmin/eventaction.html +++ b/templates/webadmin/eventaction.html @@ -1,257 +1,259 @@ {{template "base" .}} -{{define "title"}}{{.Title}}{{end}} - -{{define "extra_css"}} - -{{end}} - -{{define "additionalnavitems"}} - - -
-{{end}} - -{{define "page_body"}} -
-
-
{{.Title}}
+{{- define "page_body"}} +
+
+

- {{if .Error}} - - {{end}} + {{- template "errmsg" .Error}}
+
- -
- + +
+
-
- -
- - - Optional description - +
+ +
+
-
- -
- {{- range .ActionTypes}} - + {{- end}}
-
- -
- - - An email notification will be generated for users whose password expires in a number of days less than or equal to this threshold. - +
+ +
+ +
-
- -
- + +
-
- -
- - - Template for SFTPGo users in JSON format. Placeholders are supported - +
+ +
+ +
-
- -
- - - Template for SFTPGo admins in JSON format. Placeholders are supported - +
+ +
+ +
-
- -
- - - Endpoint URL, i.e https://host:port/path. Placeholders are supported within the URL path - +
+ +
+ +
-
- -
- - - Placeholders are supported - -
-
- -
- +
+ +
+ +
-
-
- HTTP headers +
+ +
+ +
+
+ +
+
+

HTTP headers

-
Placeholders are supported in header values.
-
-
- {{range $idx, $val := .Action.Options.HTTPConfig.Headers}} -
-
- +
+
+
+ {{- range $idx, $val := .Action.Options.HTTPConfig.Headers}} +
+
+
+
+ +
+
+ +
+ +
+
-
- -
-
-
- + {{- else}} +
+
+
+ +
+
+ +
+ +
+ {{- end}}
- {{else}} -
-
- -
-
- -
-
-
- -
-
- {{end}}
-
-
- +
-
-
- Query parameters +
+
+

Query parameters

-
Placeholders are supported in query values.
-
-
- {{range $idx, $val := .Action.Options.HTTPConfig.QueryParameters}} -
-
- +
+
+
+ {{- range $idx, $val := .Action.Options.HTTPConfig.QueryParameters}} +
+
+
+
+ +
+
+ +
+ +
+
-
- -
-
-
- + {{- else}} +
+
+
+ +
+
+ +
+ +
+ {{- end}}
- {{else}} -
-
- -
-
- -
-
-
- -
-
- {{end}}
-
-
- +
-
- -
- {{- range .HTTPMethods}} {{- end}} @@ -259,827 +261,696 @@ along with this program. If not, see .
-
- -
- - - Ignored for multipart requests with files as attachments. - +
+ +
+ +
-
-
- - +
+
+
+ + +
-
- -
- - - Placeholders are supported. Ignored for HTTP get requested. Leave empty for multipart requests. - +
+ +
+ +
-
-
- Multipart body +
+
+

Multipart body

-
Multipart requests allow to combine one or more sets of data into a single body. For each part, you can set a file path or a body as text. Placeholders are supported in file path, body, header values.
-
-
- {{range $idx, $val := .Action.Options.HTTPConfig.Parts}} -
-
-
-
- +
+ {{template "infomsg" "actions.multipart_body_help"}} +
+
+ {{- range $idx, $val := .Action.Options.HTTPConfig.Parts}} +
+
+
+
+ +
+
+ +
+
+ +
+
-
- -
-
- - - One header per line as "key: value", example: "Content-Type: application/json", without quotes. Content type for files is automatically detected - -
-
-
- +
+
+ +
-
-
- -
-
-
-
- {{else}} -
-
-
-
- + {{- else}} +
+
+
+
-
- +
+
-
- - - One header per line as "key: value", example: "Content-Type: application/json", without quotes. Content type for files is automatically detected - +
+
-
- -
-
- +
+
+
-
+ {{- end}}
- {{end}} +
+ +
-
- -
-
- -
- - - Absolute path of the command to execute - +
+ +
+ +
-
- -
- - - Comma separated command arguments. Placeholders are supported. - +
+ +
+ +
-
- -
- +
+ +
+
-
-
- Environment variables +
+
+

Environment variables

-
Placeholders are supported in values. Setting the value to "$" without quotes means retrieving the key from the environment.
-
-
- {{range $idx, $val := .Action.Options.CmdConfig.EnvVars}} -
-
- +
+ {{template "infomsg" "actions.command_env_vars_help"}} +
+
+ {{- range $idx, $val := .Action.Options.CmdConfig.EnvVars}} +
+
+
+
+ +
+
+ +
+ +
+
-
- -
-
-
- + {{- else}} +
+
+
+ +
+
+ +
+ +
+ {{- end}}
- {{else}} -
-
- -
-
- -
-
-
- -
-
- {{end}} +
+ +
- -
- -
-
- -
- - - Comma separated recipients. Placeholders are supported - +
+ +
+ +
-
- -
- - - Comma separated Bcc addresses. Placeholders are supported - +
+ +
+ +
-
- -
- - - Placeholders are supported - +
+ +
+ +
-
- -
-
-
- -
- - - Placeholders are supported - +
+ +
+ +
-
- -
- - - Comma separated paths to attach. Placeholders are supported. The total size is limited to 10 MB. - +
+ +
+ +
-
-
- Data retention +
+
+

Data retention

-
Set the data retention, as hours, per path. Retention applies recursively. Setting 0 as retention means excluding the specified path. "Ignore user permissions" defines whether to delete files even if the user does not have the "delete" permission, by default files will be skipped if the user does not have the "delete" permission.
-
-
- {{range $idx, $val := .Action.Options.RetentionConfig.Folders}} -
-
- +
+ {{template "infomsg" "actions.data_retention_help"}} +
+
+ {{- range $idx, $val := .Action.Options.RetentionConfig.Folders}} +
+
+
+
+ +
+
+ +
+
+ +
+ +
+
-
- -
-
- -
-
-
- + {{- else}} +
+
+
+ +
+
+ +
+
+ +
+ +
+ {{- end}}
- {{else}} -
-
- -
-
- -
-
- -
-
-
- -
-
- {{end}}
-
-
- +
-
- -
- {{- range .FsActions}} - + {{- end}}
-
-
- Rename +
+
+

Rename

-
Paths to rename as seen by SFTPGo users. Placeholders are supported. The required permissions are granted automatically
-
-
- {{range $idx, $val := .Action.Options.FsConfig.Renames}} -
-
- +
+ {{template "infomsg" "actions.paths_src_dst_help"}} +
+
+ {{- range $idx, $val := .Action.Options.FsConfig.Renames}} +
+
+
+
+ +
+
+ +
+ +
+
-
- -
-
-
- + {{- else}} +
+
+
+ +
+
+ +
+ +
+ {{- end}}
- {{else}} -
-
- -
-
- -
-
-
- -
-
- {{end}} +
+ +
- -
- -
-
- -
- - - Comma separated paths to delete as seen by SFTPGo users. Placeholders are supported. The required permissions are granted automatically - +
+ +
+ +
-
- -
- - - Comma separated directories paths to create as seen by SFTPGo users. Placeholders are supported. The required permissions are granted automatically - +
+ +
+ +
-
- -
- - - Comma separated paths to check for existence as seen by SFTPGo users. Placeholders are supported. The required permissions are granted automatically - +
+ +
+ +
-
-
- Copy +
+
+

Copy

-
Paths to copy as seen by SFTPGo users. Placeholders are supported. The required permissions are granted automatically
-
-
- {{range $idx, $val := .Action.Options.FsConfig.Copy}} -
-
- +
+ {{template "infomsg" "actions.paths_src_dst_help"}} +
+
+ {{- range $idx, $val := .Action.Options.FsConfig.Copy}} +
+
+
+
+ +
+
+ +
+ +
+
-
- -
-
-
- + {{- else}} +
+
+
+ +
+
+ +
+ +
+ {{- end}}
- {{else}} -
-
- -
-
- -
-
-
- -
-
- {{end}} +
+ +
- -
- -
-
- -
- - - Full path, as seen by SFTPGo users, to the zip archive to create. Placeholders are supported. If the specified file already exists, it is overwritten - +
+ +
+ +
-
- -
- - - Comma separated paths to compress (zip) as seen by SFTPGo users. Placeholders are supported. The required permissions are granted automatically - +
+ +
+ +
- -
- +
+ +
-{{end}} +{{- end}} -{{define "dialog"}} -