WIP new WebAdmin: event actions

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2024-01-31 20:49:25 +01:00
parent b18b37042d
commit c85601146d
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
17 changed files with 1585 additions and 1188 deletions

View file

@ -26,9 +26,9 @@ The following actions are supported:
The following placeholders 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. - `{{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". - `{{StatusString}}`. Status as string. Possible values "OK", "KO".
- `{{ErrorString}}`. Error details. Replaced with an empty string if no errors occur. - `{{ErrorString}}`. Error details. Replaced with an empty string if no errors occur.
- `{{VirtualPath}}`. Path seen by SFTPGo users, for example `/adir/afile.txt`. - `{{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. - `{{ObjectName}}`. File/directory name, for example `afile.txt` or provider object name.
- `{{ObjectType}}`. Object type for provider events: `user`, `group`, `admin`, etc. - `{{ObjectType}}`. Object type for provider events: `user`, `group`, `admin`, etc.
- `{{Ext}}`. File extension, for example `.txt` if the filename is `afile.txt`. - `{{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. - `{{VirtualTargetDirPath}}`. Parent directory for VirtualTargetPath.
- `{{TargetName}}`. Target object name for renames. - `{{TargetName}}`. Target object name for rename and copy operations.
- `{{FsTargetPath}}`. Full filesystem target path for renames. - `{{FsTargetPath}}`. Full filesystem target path for rename and copy operations.
- `{{FileSize}}`. File size. - `{{FileSize}}`. File size.
- `{{Elapsed}}`. Elapsed time as milliseconds for filesystem events. - `{{Elapsed}}`. Elapsed time as milliseconds for filesystem events.
- `{{Protocol}}`. Used protocol, for example `SFTP`, `FTP`. - `{{Protocol}}`. Used protocol, for example `SFTP`, `FTP`.

8
go.mod
View file

@ -41,7 +41,7 @@ require (
github.com/klauspost/compress v1.17.5 github.com/klauspost/compress v1.17.5
github.com/lestrrat-go/jwx/v2 v2.0.19 github.com/lestrrat-go/jwx/v2 v2.0.19
github.com/lithammer/shortuuid/v3 v3.0.7 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/mhale/smtpd v0.8.2
github.com/minio/sio v0.3.1 github.com/minio/sio v0.3.1
github.com/otiai10/copy v1.14.0 github.com/otiai10/copy v1.14.0
@ -74,15 +74,15 @@ require (
golang.org/x/sys v0.16.0 golang.org/x/sys v0.16.0
golang.org/x/term v0.16.0 golang.org/x/term v0.16.0
golang.org/x/time v0.5.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 gopkg.in/natefinch/lumberjack.v2 v2.2.1
) )
require ( require (
cloud.google.com/go v0.112.0 // indirect 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/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/Azure/azure-sdk-for-go/sdk/internal v1.5.1 // indirect
github.com/ajg/form 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 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect

16
go.sum
View file

@ -1,12 +1,12 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 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 h1:tpFCD7hpHFlQ8yPwT3x+QeXqc2T6+n6T+hmABHfDUSM=
cloud.google.com/go v0.112.0/go.mod h1:3jEEVwZ/MHU4djK5t5RHuKOA/GbLddgTdVubX1qnPD4= 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.4 h1:EBT9Nw4q3zyE7G45Wvv3MzolIrCJEuHys5muLY0wvAw=
cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI= 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 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= 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.6 h1:bEa06k05IO4f4uJonbB5iAgKTPpABy1ayxaIZV/GHVc=
cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8= 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 h1:pj1sRfut2eRbD9pFRjNnPNg/CzJPuQAzUujMIM1vVeM=
cloud.google.com/go/kms v1.15.5/go.mod h1:cU2H5jnp6G2TDpUGZyqTCoy1n16fbubHZjmVXSMtwDI= cloud.google.com/go/kms v1.15.5/go.mod h1:cU2H5jnp6G2TDpUGZyqTCoy1n16fbubHZjmVXSMtwDI=
cloud.google.com/go/storage v1.37.0 h1:WI8CsaFO8Q9KjPVtsZ5Cmi0dXV25zMoX0FklT7c3Jm4= 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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.21 h1:IXocQLOykluc3xPE0Lvy8FtggMz1G+U3mEjg+0zGizc=
github.com/mattn/go-sqlite3 v1.14.20/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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 h1:rHKOMHeFoDvcq8Na9ErCbNcjlWTSyGtznOmJpWsOzuc=
github.com/mhale/smtpd v0.8.2/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4= 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= 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-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 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 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.161.0 h1:oYzk/bs26WN10AV7iU7MVJVXBH8oCPS2hHyBiEeFoSU=
google.golang.org/api v0.159.0/go.mod h1:0mu0TpK33qnydLvWqbImq2b1eQ5FHRSDCBzAxX9ZHyw= 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.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.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=

View file

@ -64,31 +64,31 @@ func isActionTypeValid(action int) bool {
func getActionTypeAsString(action int) string { func getActionTypeAsString(action int) string {
switch action { switch action {
case ActionTypeHTTP: case ActionTypeHTTP:
return "HTTP" return util.I18nActionTypeHTTP
case ActionTypeEmail: case ActionTypeEmail:
return "Email" return util.I18nActionTypeEmail
case ActionTypeBackup: case ActionTypeBackup:
return "Backup" return util.I18nActionTypeBackup
case ActionTypeUserQuotaReset: case ActionTypeUserQuotaReset:
return "User quota reset" return util.I18nActionTypeUserQuotaReset
case ActionTypeFolderQuotaReset: case ActionTypeFolderQuotaReset:
return "Folder quota reset" return util.I18nActionTypeFolderQuotaReset
case ActionTypeTransferQuotaReset: case ActionTypeTransferQuotaReset:
return "Transfer quota reset" return util.I18nActionTypeTransferQuotaReset
case ActionTypeDataRetentionCheck: case ActionTypeDataRetentionCheck:
return "Data retention check" return util.I18nActionTypeDataRetentionCheck
case ActionTypeMetadataCheck: case ActionTypeMetadataCheck:
return "Metadata check" return util.I18nActionTypeMetadataCheck
case ActionTypeFilesystem: case ActionTypeFilesystem:
return "Filesystem" return util.I18nActionTypeFilesystem
case ActionTypePasswordExpirationCheck: case ActionTypePasswordExpirationCheck:
return "Password expiration check" return util.I18nActionTypePwdExpirationCheck
case ActionTypeUserExpirationCheck: case ActionTypeUserExpirationCheck:
return "User expiration check" return util.I18nActionTypeUserExpirationCheck
case ActionTypeIDPAccountCheck: case ActionTypeIDPAccountCheck:
return "Identity Provider account check" return util.I18nActionTypeIDPCheck
default: default:
return "Command" return util.I18nActionTypeCommand
} }
} }
@ -171,17 +171,17 @@ func isFilesystemActionValid(value int) bool {
func getFsActionTypeAsString(value int) string { func getFsActionTypeAsString(value int) string {
switch value { switch value {
case FilesystemActionRename: case FilesystemActionRename:
return "Rename" return util.I18nActionFsTypeRename
case FilesystemActionDelete: case FilesystemActionDelete:
return "Delete" return util.I18nActionFsTypeDelete
case FilesystemActionExist: case FilesystemActionExist:
return "Paths exist" return util.I18nActionFsTypePathExists
case FilesystemActionCompress: case FilesystemActionCompress:
return "Compress" return util.I18nActionFsTypeCompress
case FilesystemActionCopy: case FilesystemActionCopy:
return "Copy" return util.I18nActionFsTypeCopy
default: default:
return "Create directories" return util.I18nActionFsTypeCreateDirs
} }
} }
@ -259,7 +259,7 @@ type HTTPPart struct {
func (p *HTTPPart) validate() error { func (p *HTTPPart) validate() error {
if p.Name == "" { 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 { for _, kv := range p.Headers {
if kv.isNotValid() { if kv.isNotValid() {
@ -268,7 +268,10 @@ func (p *HTTPPart) validate() error {
} }
if p.Filepath == "" { if p.Filepath == "" {
if p.Body == "" { 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 { } else {
p.Body = "" p.Body = ""
@ -318,18 +321,24 @@ func (c *EventActionHTTPConfig) validateMultiparts() error {
} }
if filePath := c.Parts[idx].Filepath; filePath != "" { if filePath := c.Parts[idx].Filepath; filePath != "" {
if filePaths[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 filePaths[filePath] = true
} }
} }
if len(c.Parts) > 0 { if len(c.Parts) > 0 {
if c.Body != "" { 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 { for _, k := range c.Headers {
if strings.ToLower(k.Key) == "content-type" { 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 { func (c *EventActionHTTPConfig) validate(additionalData string) error {
if c.Endpoint == "" { 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://"}) { 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() { if c.isTimeoutNotValid() {
return util.NewValidationError(fmt.Sprintf("invalid HTTP timeout %d", c.Timeout)) return util.NewValidationError(fmt.Sprintf("invalid HTTP timeout %d", c.Timeout))
@ -443,10 +455,13 @@ type EventActionCommandConfig struct {
func (c *EventActionCommandConfig) validate() error { func (c *EventActionCommandConfig) validate() error {
if c.Cmd == "" { if c.Cmd == "" {
return util.NewValidationError("command is required") return util.NewI18nError(util.NewValidationError("command is required"), util.I18nErrorCommandRequired)
} }
if !filepath.IsAbs(c.Cmd) { 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 { if c.Timeout < 1 || c.Timeout > 120 {
return util.NewValidationError(fmt.Sprintf("invalid command action timeout %d", c.Timeout)) 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 { func (c *EventActionEmailConfig) validate() error {
if len(c.Recipients) == 0 { 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) c.Recipients = util.RemoveDuplicates(c.Recipients, false)
for _, r := range c.Recipients { for _, r := range c.Recipients {
@ -521,10 +539,16 @@ func (c *EventActionEmailConfig) validate() error {
} }
} }
if c.Subject == "" { if c.Subject == "" {
return util.NewValidationError("email subject is required") return util.NewI18nError(
util.NewValidationError("email subject is required"),
util.I18nErrorEmailSubjectRequired,
)
} }
if c.Body == "" { 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 { if c.ContentType < 0 || c.ContentType > 1 {
return util.NewValidationError("invalid email content type") return util.NewValidationError("invalid email content type")
@ -589,12 +613,18 @@ func (c *EventActionDataRetentionConfig) validate() error {
nothingToDo = false nothingToDo = false
} }
if _, ok := folderPaths[f.Path]; ok { 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 folderPaths[f.Path] = true
} }
if nothingToDo { if nothingToDo {
return util.NewValidationError("nothing to delete!") return util.NewI18nError(
util.NewValidationError("nothing to delete!"),
util.I18nErrorRetentionDirRequired,
)
} }
return nil return nil
} }
@ -609,14 +639,14 @@ type EventActionFsCompress struct {
func (c *EventActionFsCompress) validate() error { func (c *EventActionFsCompress) validate() error {
if c.Name == "" { 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)) c.Name = util.CleanPath(strings.TrimSpace(c.Name))
if 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 { 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 { for idx, val := range c.Paths {
val = strings.TrimSpace(val) val = strings.TrimSpace(val)
@ -673,7 +703,7 @@ func (c EventActionFilesystemConfig) GetCompressPathsAsString() string {
func (c *EventActionFilesystemConfig) validateRenames() error { func (c *EventActionFilesystemConfig) validateRenames() error {
if len(c.Renames) == 0 { 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 { for idx, kv := range c.Renames {
key := strings.TrimSpace(kv.Key) key := strings.TrimSpace(kv.Key)
@ -684,10 +714,16 @@ func (c *EventActionFilesystemConfig) validateRenames() error {
key = util.CleanPath(key) key = util.CleanPath(key)
value = util.CleanPath(value) value = util.CleanPath(value)
if key == 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 == "/" { 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{ c.Renames[idx] = KeyValue{
Key: key, Key: key,
@ -699,7 +735,7 @@ func (c *EventActionFilesystemConfig) validateRenames() error {
func (c *EventActionFilesystemConfig) validateCopy() error { func (c *EventActionFilesystemConfig) validateCopy() error {
if len(c.Copy) == 0 { 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 { for idx, kv := range c.Copy {
key := strings.TrimSpace(kv.Key) key := strings.TrimSpace(kv.Key)
@ -710,10 +746,16 @@ func (c *EventActionFilesystemConfig) validateCopy() error {
key = util.CleanPath(key) key = util.CleanPath(key)
value = util.CleanPath(value) value = util.CleanPath(value)
if key == 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 == "/" { 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, "/") { if strings.HasSuffix(c.Copy[idx].Key, "/") {
key += "/" key += "/"
@ -731,7 +773,7 @@ func (c *EventActionFilesystemConfig) validateCopy() error {
func (c *EventActionFilesystemConfig) validateDeletes() error { func (c *EventActionFilesystemConfig) validateDeletes() error {
if len(c.Deletes) == 0 { 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 { for idx, val := range c.Deletes {
val = strings.TrimSpace(val) val = strings.TrimSpace(val)
@ -746,7 +788,7 @@ func (c *EventActionFilesystemConfig) validateDeletes() error {
func (c *EventActionFilesystemConfig) validateMkdirs() error { func (c *EventActionFilesystemConfig) validateMkdirs() error {
if len(c.MkDirs) == 0 { 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 { for idx, val := range c.MkDirs {
val = strings.TrimSpace(val) val = strings.TrimSpace(val)
@ -761,7 +803,7 @@ func (c *EventActionFilesystemConfig) validateMkdirs() error {
func (c *EventActionFilesystemConfig) validateExist() error { func (c *EventActionFilesystemConfig) validateExist() error {
if len(c.Exist) == 0 { 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 { for idx, val := range c.Exist {
val = strings.TrimSpace(val) val = strings.TrimSpace(val)
@ -885,7 +927,10 @@ type EventActionIDPAccountCheck struct {
func (c *EventActionIDPAccountCheck) validate() error { func (c *EventActionIDPAccountCheck) validate() error {
if c.TemplateAdmin == "" && c.TemplateUser == "" { 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 { if c.Mode < 0 || c.Mode > 1 {
return util.NewValidationError(fmt.Sprintf("invalid account check mode: %d", c.Mode)) 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 { func (a *BaseEventAction) validate() error {
if a.Name == "" { if a.Name == "" {
return util.NewValidationError("name is mandatory") return util.NewI18nError(util.NewValidationError("name is mandatory"), util.I18nErrorNameRequired)
} }
if !isActionTypeValid(a.Type) { if !isActionTypeValid(a.Type) {
return util.NewValidationError(fmt.Sprintf("invalid action type: %d", a.Type)) return util.NewValidationError(fmt.Sprintf("invalid action type: %d", a.Type))

View file

@ -1761,6 +1761,8 @@ func (s *httpdServer) setupWebAdminRoutes() {
router.With(s.checkPerm(dataprovider.PermAdminViewDefender)).Get(webDefenderHostsPath, getDefenderHosts) router.With(s.checkPerm(dataprovider.PermAdminViewDefender)).Get(webDefenderHostsPath, getDefenderHosts)
router.With(s.checkPerm(dataprovider.PermAdminManageDefender)).Delete(webDefenderHostsPath+"/{id}", router.With(s.checkPerm(dataprovider.PermAdminManageDefender)).Delete(webDefenderHostsPath+"/{id}",
deleteDefenderHostByID) deleteDefenderHostByID)
router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), compressor.Handler, s.refreshCookie).
Get(webAdminEventActionsPath+jsonAPISuffix, getAllActions)
router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), s.refreshCookie). router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), s.refreshCookie).
Get(webAdminEventActionsPath, s.handleWebGetEventActions) Get(webAdminEventActionsPath, s.handleWebGetEventActions)
router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), s.refreshCookie). router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), s.refreshCookie).

View file

@ -42,7 +42,6 @@ const (
templateResetPassword = "reset-password.html" templateResetPassword = "reset-password.html"
templateChangePwd = "changepassword.html" templateChangePwd = "changepassword.html"
templateMessage = "message.html" templateMessage = "message.html"
templateCommonCSS = "sftpgo.css"
templateCommonBase = "base.html" templateCommonBase = "base.html"
templateCommonBaseLogin = "baselogin.html" templateCommonBaseLogin = "baselogin.html"
templateCommonLogin = "login.html" templateCommonLogin = "login.html"

View file

@ -99,7 +99,6 @@ const (
templateMFA = "mfa.html" templateMFA = "mfa.html"
templateSetup = "adminsetup.html" templateSetup = "adminsetup.html"
pageEventRulesTitle = "Event rules" pageEventRulesTitle = "Event rules"
pageEventActionsTitle = "Event actions"
defaultQueryLimit = 1000 defaultQueryLimit = 1000
inversePatternType = "inverse" inversePatternType = "inverse"
) )
@ -159,11 +158,6 @@ type eventRulesPage struct {
Rules []dataprovider.EventRule Rules []dataprovider.EventRule
} }
type eventActionsPage struct {
basePage
Actions []dataprovider.BaseEventAction
}
type statusPage struct { type statusPage struct {
basePage basePage
Status *ServicesStatus Status *ServicesStatus
@ -305,7 +299,7 @@ type eventActionPage struct {
FsActions []dataprovider.EnumMapping FsActions []dataprovider.EnumMapping
HTTPMethods []string HTTPMethods []string
RedactedSecret string RedactedSecret string
Error string Error *util.I18nError
Mode genericPageMode Mode genericPageMode
} }
@ -418,22 +412,22 @@ func loadAdminTemplates(templatesPath string) {
filepath.Join(templatesPath, templateAdminDir, templateGroup), filepath.Join(templatesPath, templateAdminDir, templateGroup),
} }
eventRulesPaths := []string{ eventRulesPaths := []string{
filepath.Join(templatesPath, templateCommonDir, templateCommonCSS), filepath.Join(templatesPath, templateCommonDir, templateCommonBase),
filepath.Join(templatesPath, templateAdminDir, templateBase), filepath.Join(templatesPath, templateAdminDir, templateBase),
filepath.Join(templatesPath, templateAdminDir, templateEventRules), filepath.Join(templatesPath, templateAdminDir, templateEventRules),
} }
eventRulePaths := []string{ eventRulePaths := []string{
filepath.Join(templatesPath, templateCommonDir, templateCommonCSS), filepath.Join(templatesPath, templateCommonDir, templateCommonBase),
filepath.Join(templatesPath, templateAdminDir, templateBase), filepath.Join(templatesPath, templateAdminDir, templateBase),
filepath.Join(templatesPath, templateAdminDir, templateEventRule), filepath.Join(templatesPath, templateAdminDir, templateEventRule),
} }
eventActionsPaths := []string{ eventActionsPaths := []string{
filepath.Join(templatesPath, templateCommonDir, templateCommonCSS), filepath.Join(templatesPath, templateCommonDir, templateCommonBase),
filepath.Join(templatesPath, templateAdminDir, templateBase), filepath.Join(templatesPath, templateAdminDir, templateBase),
filepath.Join(templatesPath, templateAdminDir, templateEventActions), filepath.Join(templatesPath, templateAdminDir, templateEventActions),
} }
eventActionPaths := []string{ eventActionPaths := []string{
filepath.Join(templatesPath, templateCommonDir, templateCommonCSS), filepath.Join(templatesPath, templateCommonDir, templateCommonBase),
filepath.Join(templatesPath, templateAdminDir, templateBase), filepath.Join(templatesPath, templateAdminDir, templateBase),
filepath.Join(templatesPath, templateAdminDir, templateEventAction), 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, func (s *httpdServer) renderEventActionPage(w http.ResponseWriter, r *http.Request, action dataprovider.BaseEventAction,
mode genericPageMode, error string, mode genericPageMode, err error,
) { ) {
action.Options.SetEmptySecretsIfNil() action.Options.SetEmptySecretsIfNil()
var title, currentURL string var title, currentURL string
switch mode { switch mode {
case genericPageModeAdd: case genericPageModeAdd:
title = "Add a new event action" title = util.I18nAddActionTitle
currentURL = webAdminEventActionPath currentURL = webAdminEventActionPath
case genericPageModeUpdate: case genericPageModeUpdate:
title = "Update event action" title = util.I18nUpdateActionTitle
currentURL = fmt.Sprintf("%s/%s", webAdminEventActionPath, url.PathEscape(action.Name)) currentURL = fmt.Sprintf("%s/%s", webAdminEventActionPath, url.PathEscape(action.Name))
} }
if action.Options.HTTPConfig.Timeout == 0 { if action.Options.HTTPConfig.Timeout == 0 {
@ -1104,7 +1098,7 @@ func (s *httpdServer) renderEventActionPage(w http.ResponseWriter, r *http.Reque
FsActions: dataprovider.FsActionTypes, FsActions: dataprovider.FsActionTypes,
HTTPMethods: dataprovider.SupportedHTTPActionMethods, HTTPMethods: dataprovider.SupportedHTTPActionMethods,
RedactedSecret: redactedSecret, RedactedSecret: redactedSecret,
Error: error, Error: getI18nError(err),
Mode: mode, Mode: mode,
} }
renderAdminTemplate(w, templateEventAction, data) 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 { func getKeyValsFromPostFields(r *http.Request, key, val string) []dataprovider.KeyValue {
var res []dataprovider.KeyValue var res []dataprovider.KeyValue
for k := range r.Form {
if strings.HasPrefix(k, key) { keys := r.Form[key]
formKey := r.Form.Get(k) values := r.Form[val]
idx := strings.TrimPrefix(k, key)
formVal := strings.TrimSpace(r.Form.Get(fmt.Sprintf("%s%s", val, idx))) for idx, k := range keys {
if formKey != "" && formVal != "" { v := values[idx]
res = append(res, dataprovider.KeyValue{ if k != "" && v != "" {
Key: formKey, res = append(res, dataprovider.KeyValue{
Value: formVal, Key: k,
}) Value: v,
} })
} }
} }
return res return res
} }
func getFoldersRetentionFromPostFields(r *http.Request) ([]dataprovider.FolderRetention, error) { func getFoldersRetentionFromPostFields(r *http.Request) ([]dataprovider.FolderRetention, error) {
var res []dataprovider.FolderRetention var res []dataprovider.FolderRetention
for k := range r.Form { paths := r.Form["folder_retention_path"]
if strings.HasPrefix(k, "folder_retention_path") { values := r.Form["folder_retention_val"]
folderPath := strings.TrimSpace(r.Form.Get(k))
if folderPath != "" { for idx, p := range paths {
idx := strings.TrimPrefix(k, "folder_retention_path") if p != "" {
retention, err := strconv.Atoi(r.Form.Get(fmt.Sprintf("folder_retention_val%s", idx))) retention, err := strconv.Atoi(values[idx])
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid retention for path %q: %w", folderPath, err) return nil, fmt.Errorf("invalid retention for path %q: %w", p, 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"),
})
} }
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 return res, nil
} }
func getHTTPPartsFromPostFields(r *http.Request) []dataprovider.HTTPPart { func getHTTPPartsFromPostFields(r *http.Request) []dataprovider.HTTPPart {
var result []dataprovider.HTTPPart var result []dataprovider.HTTPPart
for k := range r.Form {
if strings.HasPrefix(k, "http_part_name") { names := r.Form["http_part_name"]
partName := strings.TrimSpace(r.Form.Get(k)) files := r.Form["http_part_file"]
if partName != "" { headers := r.Form["http_part_headers"]
idx := strings.TrimPrefix(k, "http_part_name") bodies := r.Form["http_part_body"]
order, err := strconv.Atoi(idx) orders := r.Form["http_part_order"]
if err != nil {
continue for idx, partName := range names {
} if partName != "" {
filePath := strings.TrimSpace(r.Form.Get(fmt.Sprintf("http_part_file%s", idx))) order, err := strconv.Atoi(orders[idx])
body := r.Form.Get(fmt.Sprintf("http_part_body%s", idx)) if err != nil {
concatHeaders := getSliceFromDelimitedValues(r.Form.Get(fmt.Sprintf("http_part_headers%s", idx)), "\n") continue
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,
})
} }
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 { sort.Slice(result, func(i, j int) bool {
return result[i].Order < result[j].Order return result[i].Order < result[j].Order
}) })
return result 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) { func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEventActionOptions, error) {
updateRepeaterFormActionFields(r)
httpTimeout, err := strconv.Atoi(r.Form.Get("http_timeout")) httpTimeout, err := strconv.Atoi(r.Form.Get("http_timeout"))
if err != nil { if err != nil {
return dataprovider.BaseEventActionOptions{}, fmt.Errorf("invalid http timeout: %w", err) 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")), Endpoint: strings.TrimSpace(r.Form.Get("http_endpoint")),
Username: strings.TrimSpace(r.Form.Get("http_username")), Username: strings.TrimSpace(r.Form.Get("http_username")),
Password: getSecretFromFormField(r, "http_password"), 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, Timeout: httpTimeout,
SkipTLSVerify: r.Form.Get("http_skip_tls_verify") != "", SkipTLSVerify: r.Form.Get("http_skip_tls_verify") != "",
Method: r.Form.Get("http_method"), 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"), Body: r.Form.Get("http_body"),
Parts: getHTTPPartsFromPostFields(r), Parts: getHTTPPartsFromPostFields(r),
}, },
@ -2293,7 +2347,7 @@ func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEven
Cmd: strings.TrimSpace(r.Form.Get("cmd_path")), Cmd: strings.TrimSpace(r.Form.Get("cmd_path")),
Args: cmdArgs, Args: cmdArgs,
Timeout: cmdTimeout, Timeout: cmdTimeout,
EnvVars: getKeyValsFromPostFields(r, "cmd_env_key", "cmd_env_val"), EnvVars: getKeyValsFromPostFields(r, "cmd_env_key", "cmd_env_value"),
}, },
EmailConfig: dataprovider.EventActionEmailConfig{ EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: getSliceFromDelimitedValues(r.Form.Get("email_recipients"), ","), Recipients: getSliceFromDelimitedValues(r.Form.Get("email_recipients"), ","),
@ -3623,25 +3677,27 @@ func (s *httpdServer) getWebEventActions(w http.ResponseWriter, r *http.Request,
return actions, nil 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) r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
limit := defaultQueryLimit actions := make([]dataprovider.BaseEventAction, 0, 10)
if _, ok := r.URL.Query()["qlimit"]; ok { for {
var err error res, err := dataprovider.GetEventActions(defaultQueryLimit, len(actions), dataprovider.OrderASC, false)
limit, err = strconv.Atoi(r.URL.Query().Get("qlimit"))
if err != nil { 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) render.JSON(w, r, actions)
if err != nil { }
return
}
data := eventActionsPage{ func (s *httpdServer) handleWebGetEventActions(w http.ResponseWriter, r *http.Request) {
basePage: s.getBasePageData(pageEventActionsTitle, webAdminEventActionsPath, r), r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
Actions: actions,
} data := s.getBasePageData(util.I18nActionsTitle, webAdminEventActionsPath, r)
renderAdminTemplate(w, templateEventActions, data) renderAdminTemplate(w, templateEventActions, data)
} }
@ -3650,7 +3706,7 @@ func (s *httpdServer) handleWebAddEventActionGet(w http.ResponseWriter, r *http.
action := dataprovider.BaseEventAction{ action := dataprovider.BaseEventAction{
Type: dataprovider.ActionTypeHTTP, 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) { 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) action, err := getEventActionFromPostFields(r)
if err != nil { if err != nil {
s.renderEventActionPage(w, r, action, genericPageModeAdd, err.Error()) s.renderEventActionPage(w, r, action, genericPageModeAdd, err)
return return
} }
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
@ -3671,7 +3727,7 @@ func (s *httpdServer) handleWebAddEventActionPost(w http.ResponseWriter, r *http
return return
} }
if err = dataprovider.AddEventAction(&action, claims.Username, ipAddr, claims.Role); err != nil { 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 return
} }
http.Redirect(w, r, webAdminEventActionsPath, http.StatusSeeOther) http.Redirect(w, r, webAdminEventActionsPath, http.StatusSeeOther)
@ -3682,7 +3738,7 @@ func (s *httpdServer) handleWebUpdateEventActionGet(w http.ResponseWriter, r *ht
name := getURLParam(r, "name") name := getURLParam(r, "name")
action, err := dataprovider.EventActionExists(name) action, err := dataprovider.EventActionExists(name)
if err == nil { if err == nil {
s.renderEventActionPage(w, r, action, genericPageModeUpdate, "") s.renderEventActionPage(w, r, action, genericPageModeUpdate, nil)
} else if errors.Is(err, util.ErrNotFound) { } else if errors.Is(err, util.ErrNotFound) {
s.renderNotFoundPage(w, r, err) s.renderNotFoundPage(w, r, err)
} else { } else {
@ -3708,7 +3764,7 @@ func (s *httpdServer) handleWebUpdateEventActionPost(w http.ResponseWriter, r *h
} }
updatedAction, err := getEventActionFromPostFields(r) updatedAction, err := getEventActionFromPostFields(r)
if err != nil { if err != nil {
s.renderEventActionPage(w, r, updatedAction, genericPageModeUpdate, err.Error()) s.renderEventActionPage(w, r, updatedAction, genericPageModeUpdate, err)
return return
} }
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) 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) err = dataprovider.UpdateEventAction(&updatedAction, claims.Username, ipAddr, claims.Role)
if err != nil { if err != nil {
s.renderEventActionPage(w, r, updatedAction, genericPageModeUpdate, err.Error()) s.renderEventActionPage(w, r, updatedAction, genericPageModeUpdate, err)
return return
} }
http.Redirect(w, r, webAdminEventActionsPath, http.StatusSeeOther) http.Redirect(w, r, webAdminEventActionsPath, http.StatusSeeOther)

View file

@ -68,6 +68,9 @@ const (
I18nUpdateIPListTitle = "title.update_ip_list" I18nUpdateIPListTitle = "title.update_ip_list"
I18nDefenderTitle = "title.defender" I18nDefenderTitle = "title.defender"
I18nEventsTitle = "title.logs" I18nEventsTitle = "title.logs"
I18nActionsTitle = "title.event_actions"
I18nAddActionTitle = "title.add_action"
I18nUpdateActionTitle = "title.update_action"
I18nStatusTitle = "status.desc" I18nStatusTitle = "status.desc"
I18nErrorSetupInstallCode = "setup.install_code_mismatch" I18nErrorSetupInstallCode = "setup.install_code_mismatch"
I18nInvalidAuth = "general.invalid_auth_request" I18nInvalidAuth = "general.invalid_auth_request"
@ -238,6 +241,43 @@ const (
I18nErrorSMTPClientIDRequired = "smtp.client_id_required" I18nErrorSMTPClientIDRequired = "smtp.client_id_required"
I18nErrorSMTPClientSecretRequired = "smtp.client_secret_required" I18nErrorSMTPClientSecretRequired = "smtp.client_secret_required"
I18nErrorSMTPRefreshTokenRequired = "smtp.refresh_token_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 // NewI18nError returns a I18nError wrappring the provided error

View file

@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
NFPM_VERSION=2.35.2 NFPM_VERSION=2.35.3
NFPM_ARCH=${NFPM_ARCH:-amd64} NFPM_ARCH=${NFPM_ARCH:-amd64}
if [ -z ${SFTPGO_VERSION} ] if [ -z ${SFTPGO_VERSION} ]
then then

View file

@ -48,6 +48,7 @@
"add_user": "Add user", "add_user": "Add user",
"update_user": "Update user", "update_user": "Update user",
"template_user": "User template", "template_user": "User template",
"template_admin": "Admin template",
"add_group": "Add group", "add_group": "Add group",
"update_group": "Update group", "update_group": "Update group",
"add_folder": "Add virtual folder", "add_folder": "Add virtual folder",
@ -60,7 +61,9 @@
"add_admin": "Add admin", "add_admin": "Add admin",
"update_admin": "Update admin", "update_admin": "Update admin",
"add_ip_list": "Add IP list entry", "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": { "setup": {
"desc": "To start using SFTPGo you need to create an administrator user", "desc": "To start using SFTPGo you need to create an administrator user",
@ -243,7 +246,13 @@
"domain": "Domain", "domain": "Domain",
"test": "Test", "test": "Test",
"get": "Get", "get": "Get",
"export": "Export" "export": "Export",
"value": "Value",
"method": "Method",
"timeout": "Timeout",
"env_vars": "Environment variables",
"hours": "Hours",
"paths": "Paths"
}, },
"fs": { "fs": {
"view_file": "View file \"{{- path}}\"", "view_file": "View file \"{{- path}}\"",
@ -401,7 +410,6 @@
"scope_write": "Write", "scope_write": "Write",
"scope_read_write": "Read/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", "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", "path_help": "file or directory path, i.e. /dir or /dir/file.txt",
"password_help": "If set the share will be password-protected", "password_help": "If set the share will be password-protected",
"max_tokens": "Max tokens", "max_tokens": "Max tokens",
@ -877,5 +885,123 @@
"role": "role", "role": "role",
"ip_list_entry": "IP list entry", "ip_list_entry": "IP list entry",
"configs": "Configurations" "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"
}
} }
} }

View file

@ -48,6 +48,7 @@
"add_user": "Aggiungi utente", "add_user": "Aggiungi utente",
"update_user": "Aggiorna utente", "update_user": "Aggiorna utente",
"template_user": "Modello utente", "template_user": "Modello utente",
"template_admin": "Modello amministratore",
"add_group": "Aggiungi gruppo", "add_group": "Aggiungi gruppo",
"update_group": "Aggiorna gruppo", "update_group": "Aggiorna gruppo",
"add_folder": "Aggiungi cartella virtuale", "add_folder": "Aggiungi cartella virtuale",
@ -60,7 +61,9 @@
"add_admin": "Aggiungi amministratore", "add_admin": "Aggiungi amministratore",
"update_admin": "Aggiorna amministratore", "update_admin": "Aggiorna amministratore",
"add_ip_list": "Aggiungi elemento a lista IP", "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": { "setup": {
"desc": "Per iniziare a utilizzare SFTPGo devi creare un utente amministratore", "desc": "Per iniziare a utilizzare SFTPGo devi creare un utente amministratore",
@ -243,7 +246,13 @@
"domain": "Dominio", "domain": "Dominio",
"test": "Test", "test": "Test",
"get": "Ottieni", "get": "Ottieni",
"export": "Esporta" "export": "Esporta",
"value": "Valore",
"method": "Metodo",
"timeout": "Timeout",
"env_vars": "Variabili d'ambiente",
"hours": "Ore",
"paths": "Percorsi"
}, },
"fs": { "fs": {
"view_file": "Visualizza file \"{{- path}}\"", "view_file": "Visualizza file \"{{- path}}\"",
@ -401,7 +410,6 @@
"scope_write": "Scrittura", "scope_write": "Scrittura",
"scope_read_write": "Lettura/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", "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", "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", "password_help": "Se impostata, la condivisione sarà protetta da password",
"max_tokens": "Token massimi", "max_tokens": "Token massimi",
@ -877,5 +885,123 @@
"role": "ruolo", "role": "ruolo",
"ip_list_entry": "Elemento lista IP", "ip_list_entry": "Elemento lista IP",
"configs": "Configurazioni" "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"
}
} }
} }

View file

@ -336,6 +336,10 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
font-weight: 500 !important; font-weight: 500 !important;
font-size: 1.1rem !important; font-size: 1.1rem !important;
} }
.shortcut {
font-family: monospace; color: #666;
}
</style> </style>
{{- end}} {{- end}}

File diff suppressed because it is too large Load diff

View file

@ -1,228 +1,332 @@
<!-- <!--
Copyright (C) 2019 Nicola Murino Copyright (C) 2024 Nicola Murino
This program is free software: you can redistribute it and/or modify This WebUI uses the KeenThemes Mega Bundle, a proprietary theme:
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, version 3.
This program is distributed in the hope that it will be useful, https://keenthemes.com/products/templates-mega-bundle
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License KeenThemes HTML/CSS/JS components are allowed for use only within the
along with this program. If not, see <https://www.gnu.org/licenses/>. SFTPGo product and restricted to be used in a resealable HTML template
that can compete with KeenThemes products anyhow.
This WebUI is allowed for use only within the SFTPGo product and
therefore cannot be used in derivative works/products without an
explicit grant from the SFTPGo Team (support@sftpgo.com).
--> -->
{{template "base" .}} {{template "base" .}}
{{define "title"}}{{.Title}}{{end}} {{- define "extra_css"}}
<link href="{{.StaticURL}}/assets/plugins/custom/datatables/datatables.bundle.css" rel="stylesheet" type="text/css"/>
{{- end}}
{{define "extra_css"}} {{- define "page_body"}}
<link href="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.css" rel="stylesheet"> {{- template "errmsg" ""}}
<link href="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.css" rel="stylesheet"> <div class="card shadow-sm">
<link href="{{.StaticURL}}/vendor/datatables/fixedHeader.bootstrap4.min.css" rel="stylesheet"> <div class="card-header bg-light">
<link href="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.css" rel="stylesheet"> <h3 data-i18n="actions.view_manage" class="card-title section-title">View and manage event actions</h3>
<link href="{{.StaticURL}}/vendor/datatables/select.bootstrap4.min.css" rel="stylesheet">
{{end}}
{{define "page_body"}}
<div id="errorMsg" class="alert alert-warning fade show" style="display: none;" role="alert">
<span id="errorTxt"></span>
<button type="button" class="close" aria-label="Close" onclick="dismissErrorMsg();">
<span aria-hidden="true">&times;</span>
</button>
</div>
<script type="text/javascript">
function dismissErrorMsg(){
$('#errorMsg').hide();
}
</script>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">View and manage event actions</h6>
</div> </div>
<div class="card-body"> <div id="card_body" class="card-body">
<div class="table-responsive"> <div id="loader" class="align-items-center text-center my-10">
<table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0"> <span class="spinner-border w-15px h-15px text-muted align-middle me-2"></span>
<span data-i18n="general.loading" class="text-gray-700">Loading...</span>
</div>
<div id="card_content" class="d-none">
<div class="d-flex flex-stack flex-wrap mb-5">
<div class="d-flex align-items-center position-relative my-2">
<i class="ki-solid ki-magnifier fs-1 position-absolute ms-6"></i>
<input name="search" data-i18n="[placeholder]general.search" type="text" data-table-filter="search"
class="form-control rounded-1 w-250px ps-15 me-5" placeholder="Search" />
</div>
<div class="d-flex justify-content-end my-2" data-table-toolbar="base">
{{- if .LoggedUser.HasPermission "manage_event_rules"}}
<a href="{{.EventActionURL}}" class="btn btn-primary ms-5">
<i class="ki-duotone ki-plus fs-2"></i>
<span data-i18n="general.add">Add</span>
</a>
{{- end}}
</div>
</div>
<table id="dataTable" class="table align-middle table-row-dashed fs-6 gy-5">
<thead> <thead>
<tr> <tr class="text-start text-muted fw-bold fs-6 gs-0">
<th>Name</th> <th data-i18n="general.name">Name</th>
<th>Description</th> <th data-i18n="general.type">Type</th>
<th>Type</th> <th data-i18n="title.event_rules">Rules</th>
<th>Rules</th> <th class="min-w-100px"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="table_body" class="text-gray-800 fw-semibold"></tbody>
{{range .Actions}}
<tr>
<td>{{.Name}}</td>
<td>{{.Description}}</td>
<td>{{.GetTypeAsString}}</td>
<td>{{.GetRulesAsString}}</td>
</tr>
{{end}}
</tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
{{end}} {{- end}}
{{- define "extra_js"}}
<script {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}} src="{{.StaticURL}}/assets/plugins/custom/datatables/datatables.bundle.js"></script>
<script type="text/javascript" {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}}>
function deleteAction(name) {
ModalAlert.fire({
text: $.t('general.delete_confirm_generic'),
icon: "warning",
confirmButtonText: $.t('general.delete_confirm_btn'),
cancelButtonText: $.t('general.cancel'),
customClass: {
confirmButton: "btn btn-danger",
cancelButton: 'btn btn-secondary'
}
}).then((result) => {
if (result.isConfirmed){
$('#loading_message').text("");
KTApp.showPageLoading();
let path = '{{.EventActionURL}}' + "/" + encodeURIComponent(name);
{{define "dialog"}} axios.delete(path, {
<div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel" timeout: 15000,
aria-hidden="true"> headers: {
<div class="modal-dialog" role="document"> 'X-CSRF-TOKEN': '{{.CSRFToken}}'
<div class="modal-content"> },
<div class="modal-header"> validateStatus: function (status) {
<h5 class="modal-title" id="deleteModalLabel"> return status == 200;
Confirmation required }
</h5> }).then(function(response){
<button class="close" type="button" data-dismiss="modal" aria-label="Close"> location.reload();
<span aria-hidden="true">&times;</span> }).catch(function(error){
</button> KTApp.hidePageLoading();
</div> let errorMessage;
<div class="modal-body">Do you want to delete the selected event action? A referenced action cannot be removed</div> if (error && error.response) {
<div class="modal-footer"> switch (error.response.status) {
<button class="btn btn-secondary" type="button" data-dismiss="modal"> case 403:
Cancel errorMessage = "general.delete_error_403";
</button> break;
<a class="btn btn-warning" href="#" onclick="deleteAction()"> case 404:
Delete errorMessage = "general.delete_error_404";
</a> break;
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script src="{{.StaticURL}}/vendor/datatables/jquery.dataTables.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.buttons.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/buttons.colVis.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.fixedHeader.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.responsive.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.select.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/ellipsis.js"></script>
<script type="text/javascript">
function deleteAction() {
let table = $('#dataTable').DataTable();
table.button('delete:name').enable(false);
let name = table.row({ selected: true }).data()[0];
let path = '{{.EventActionURL}}' + "/" + fixedEncodeURIComponent(name);
$('#deleteModal').modal('hide');
$('#errorMsg').hide();
$.ajax({
url: path,
type: 'DELETE',
dataType: 'json',
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
timeout: 15000,
success: function (result) {
window.location.href = '{{.EventActionsURL}}';
},
error: function ($xhr, textStatus, errorThrown) {
var txt = "Unable to delete the selected action";
if ($xhr) {
var json = $xhr.responseJSON;
if (json) {
if (json.message){
txt += ": " + json.message;
} else {
txt += ": " + json.error;
} }
} }
} if (!errorMessage){
$('#errorTxt').text(txt); errorMessage = "general.delete_error_generic";
$('#errorMsg').show(); }
ModalAlert.fire({
text: $.t(errorMessage),
icon: "warning",
confirmButtonText: $.t('general.ok'),
customClass: {
confirmButton: "btn btn-primary"
}
});
});
} }
}); });
} }
$(document).ready(function () { var datatable = function(){
$.fn.dataTable.ext.buttons.add = { var dt;
text: '<i class="fas fa-plus"></i>',
name: 'add',
titleAttr: "Add",
action: function (e, dt, node, config) {
window.location.href = '{{.EventActionURL}}';
}
};
$.fn.dataTable.ext.buttons.edit = { var initDatatable = function () {
text: '<i class="fas fa-pen"></i>', $('#errorMsg').addClass("d-none");
name: 'edit', dt = $('#dataTable').DataTable({
titleAttr: "Edit", ajax: {
action: function (e, dt, node, config) { url: "{{.EventActionsURL}}/json",
var name = table.row({ selected: true }).data()[0]; dataSrc: "",
var path = '{{.EventActionURL}}' + "/" + fixedEncodeURIComponent(name); error: function ($xhr, textStatus, errorThrown) {
window.location.href = path; $(".dataTables_processing").hide();
}, let txt = "";
enabled: false if ($xhr) {
}; let json = $xhr.responseJSON;
if (json) {
if (json.message){
txt = json.message;
}
}
}
if (!txt){
txt = "general.error500";
}
setI18NData($('#errorTxt'), txt);
$('#errorMsg').removeClass("d-none");
}
},
columns: [
{
data: "name",
render: function(data, type, row) {
if (type === 'display') {
return escapeHTML(data);
}
return data;
}
},
{
data: "type",
render: function(data, type, row) {
if (type === 'display') {
switch (data){
case 1:
return $.t('actions.types.http');
case 2:
return $.t('actions.types.command');
case 3:
return $.t('actions.types.email');
case 4:
return $.t('actions.types.backup');
case 5:
return $.t('actions.types.user_quota_reset');
case 6:
return $.t('actions.types.folder_quota_reset');
case 7:
return $.t('actions.types.transfer_quota_reset');
case 8:
return $.t('actions.types.data_retention_check');
case 9:
return $.t('actions.types.filesystem');
case 10:
return $.t('actions.types.metadata_check');
case 11:
return $.t('actions.types.password_expiration_check');
case 12:
return $.t('actions.types.user_expiration_check');
case 13:
return $.t('actions.types.idp_check');
default:
return "";
}
}
return data;
}
},
{
data: "rules",
defaultContent: [],
searchable: false,
orderable: false,
render: function(data, type, row) {
if (type === 'display') {
if (data){
return escapeHTML(data.join(', '));
}
return ""
}
return "";
}
},
{
data: "id",
searchable: false,
orderable: false,
className: 'text-end',
render: function (data, type, row) {
if (type === 'display') {
let numActions = 0;
let actions = `<button class="btn btn-light btn-active-light-primary btn-flex btn-center btn-sm rotate" data-kt-menu-trigger="click" data-kt-menu-placement="bottom-end">
<span data-i18n="general.actions" class="fs-6">Actions</span>
<i class="ki-duotone ki-down fs-5 ms-1 rotate-180"></i>
</button>
<div class="menu menu-sub menu-sub-dropdown menu-column menu-rounded menu-gray-700 menu-state-bg-light-primary fw-semibold fs-6 w-200px py-4" data-kt-menu="true">`;
$.fn.dataTable.ext.buttons.delete = { //{{- if .LoggedUser.HasPermission "manage_event_rules"}}
text: '<i class="fas fa-trash"></i>', numActions++;
name: 'delete', actions+=`<div class="menu-item px-3">
titleAttr: "Delete", <a data-i18n="general.edit" href="#" class="menu-link px-3" data-table-action="edit_row">Edit</a>
action: function (e, dt, node, config) { </div>`
$('#deleteModal').modal('show'); numActions++;
}, actions+=`<div class="menu-item px-3">
enabled: false <a data-i18n="general.delete" href="#" class="menu-link text-danger px-3" data-table-action="delete_row">Delete</a>
}; </div>`
//{{- end}}
var table = $('#dataTable').DataTable({ if (numActions > 0){
"select": { actions+=`</div>`;
"style": "single", return actions;
"blurable": true }
}, }
"stateSave": true, return "";
"stateDuration": 0, }
"buttons": [ },
{ ],
"text": "Column visibility", deferRender: true,
"extend": "colvis", stateSave: true,
"columns": ":not(.noVis)" stateDuration: 0,
stateLoadParams: function (settings, data) {
if (data.search.search){
const filterSearch = document.querySelector('[data-table-filter="search"]');
filterSearch.value = data.search.search;
}
},
language: {
info: $.t('datatable.info'),
infoEmpty: $.t('datatable.info_empty'),
infoFiltered: $.t('datatable.info_filtered'),
loadingRecords: "",
processing: $.t('datatable.processing'),
zeroRecords: "",
emptyTable: $.t('datatable.no_records')
},
order: [[0, 'asc']],
initComplete: function(settings, json) {
$('#loader').addClass("d-none");
$('#card_content').removeClass("d-none");
let api = $.fn.dataTable.Api(settings);
api.columns.adjust().draw("page");
drawAction();
} }
], });
"columnDefs": [
{
"targets": [0],
"className": "noVis"
},
{
"targets": [3],
"render": $.fn.dataTable.render.ellipsis(100, true)
},
],
"scrollX": false,
"scrollY": false,
"responsive": true,
"language": {
"emptyTable": "No event actions defined"
},
"order": [[0, 'asc']]
});
new $.fn.dataTable.FixedHeader( table ); dt.on('draw', drawAction);
dt.on('column-reorder', function(e, settings, details){
drawAction();
});
}
table.button().add(0,'delete'); function drawAction() {
table.button().add(0,'edit'); KTMenu.createInstances();
table.button().add(0,'add'); handleRowActions();
$('#table_body').localize();
}
table.buttons().container().appendTo('.col-md-6:eq(0)', table.table().container()); var handleDatatableActions = function () {
const filterSearch = $(document.querySelector('[data-table-filter="search"]'));
filterSearch.off("keyup");
filterSearch.on('keyup', function (e) {
dt.rows().deselect();
dt.search(e.target.value, true, false).draw();
});
}
table.on('select deselect', function () { function handleRowActions() {
var selectedRows = table.rows({ selected: true }).count(); const editButtons = document.querySelectorAll('[data-table-action="edit_row"]');
table.button('delete:name').enable(selectedRows == 1); editButtons.forEach(d => {
table.button('edit:name').enable(selectedRows == 1); let el = $(d);
}); el.off("click");
el.on("click", function(e){
e.preventDefault();
let rowData = dt.row(e.target.closest('tr')).data();
window.location.replace('{{.EventActionURL}}' + "/" + encodeURIComponent(rowData['name']));
});
});
const deleteButtons = document.querySelectorAll('[data-table-action="delete_row"]');
deleteButtons.forEach(d => {
let el = $(d);
el.off("click");
el.on("click", function(e){
e.preventDefault();
const parent = e.target.closest('tr');
deleteAction(dt.row(parent).data()['name']);
});
});
}
return {
init: function () {
initDatatable();
handleDatatableActions();
}
}
}();
$(document).on("i18nshow", function(){
datatable.init();
}); });
</script> </script>
{{end}} {{- end}}

View file

@ -357,7 +357,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
render: function(data, type, row) { render: function(data, type, row) {
if (type === 'display') { if (type === 'display') {
if (data){ if (data){
return escapeHTML(data.join()); return escapeHTML(data.join(', '));
} }
return "" return ""
} }

View file

@ -17,7 +17,6 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
{{- define "extra_css"}} {{- define "extra_css"}}
<style {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}}> <style {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}}>
.shortcut {font-family: monospace; color: #666;}
.cm-editor { .cm-editor {
height: 100%; height: 100%;
width: 100%; width: 100%;
@ -72,18 +71,18 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
<i class="ki-solid ki-cross fs-2x text-gray-700"></i> <i class="ki-solid ki-cross fs-2x text-gray-700"></i>
</div> </div>
</div> </div>
<div class="modal-body"> <div class="modal-body fs-5 fw-semibold">
<p> <p>
<span class="shortcut">Ctrl-F / Cmd-F</span> => <span data-i18n="editor.search" class="fw-semibold">Open search panel</span> <span class="shortcut">Ctrl-F / Cmd-F</span> => <span data-i18n="editor.search">Open search panel</span>
</p> </p>
<p> <p>
<span class="shortcut">Alt-G</span> => <span data-i18n="editor.goto" class="fw-semibold">Jump to line</span> <span class="shortcut">Alt-G</span> => <span data-i18n="editor.goto">Jump to line</span>
</p> </p>
<p> <p>
<span class="shortcut">Tab</span> => <span data-i18n="editor.indent_more" class="fw-semibold">Indent more</span> <span class="shortcut">Tab</span> => <span data-i18n="editor.indent_more">Indent more</span>
</p> </p>
<p> <p>
<span class="shortcut">Shift-Tab</span> => <span data-i18n="editor.indent_less" class="fw-semibold">Indent less</span> <span class="shortcut">Shift-Tab</span> => <span data-i18n="editor.indent_less">Indent less</span>
</p> </p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View file

@ -47,7 +47,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
<div class="card mt-10"> <div class="card mt-10">
<div class="card-header bg-light"> <div class="card-header bg-light">
<h3 data-i18n="share.paths" class="card-title section-title-inner">Paths</h3> <h3 data-i18n="general.paths" class="card-title section-title-inner">Paths</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<div id="paths"> <div id="paths">