Browse Source

EventManager: breaking change for placeholder names

Placeholder names must now be in the format:

{{.VirtualPath}}

instead of:

{{.VirtualPath}}

Signed-off-by: Nicola Murino <[email protected]>
Nicola Murino 6 months ago
parent
commit
0da8adb7ac

+ 39 - 39
internal/common/eventmanager.go

@@ -56,8 +56,8 @@ import (
 const (
 	ipBlockedEventName       = "IP Blocked"
 	maxAttachmentsSize       = int64(10 * 1024 * 1024)
-	objDataPlaceholder       = "{{ObjectData}}"
-	objDataPlaceholderString = "{{ObjectDataString}}"
+	objDataPlaceholder       = "{{.ObjectData}}"
+	objDataPlaceholderString = "{{.ObjectDataString}}"
 	dateTimeMillisFormat     = "2006-01-02T15:04:05.000"
 )
 
@@ -796,45 +796,45 @@ func (p *EventParams) getStringReplacements(addObjectData, jsonEscaped bool) []s
 	minute := dateTimeString[14:16]
 
 	replacements := []string{
-		"{{Name}}", p.getStringReplacement(p.Name, jsonEscaped),
-		"{{Event}}", p.Event,
-		"{{Status}}", fmt.Sprintf("%d", p.Status),
-		"{{VirtualPath}}", p.getStringReplacement(p.VirtualPath, jsonEscaped),
-		"{{EscapedVirtualPath}}", p.getStringReplacement(url.QueryEscape(p.VirtualPath), jsonEscaped),
-		"{{FsPath}}", p.getStringReplacement(p.FsPath, jsonEscaped),
-		"{{VirtualTargetPath}}", p.getStringReplacement(p.VirtualTargetPath, jsonEscaped),
-		"{{FsTargetPath}}", p.getStringReplacement(p.FsTargetPath, jsonEscaped),
-		"{{ObjectName}}", p.getStringReplacement(p.ObjectName, jsonEscaped),
-		"{{ObjectBaseName}}", p.getStringReplacement(strings.TrimSuffix(p.ObjectName, p.Extension), jsonEscaped),
-		"{{ObjectType}}", p.ObjectType,
-		"{{FileSize}}", strconv.FormatInt(p.FileSize, 10),
-		"{{Elapsed}}", strconv.FormatInt(p.Elapsed, 10),
-		"{{Protocol}}", p.Protocol,
-		"{{IP}}", p.IP,
-		"{{Role}}", p.getStringReplacement(p.Role, jsonEscaped),
-		"{{Email}}", p.getStringReplacement(p.Email, jsonEscaped),
-		"{{Timestamp}}", strconv.FormatInt(p.Timestamp.UnixNano(), 10),
-		"{{DateTime}}", dateTimeString,
-		"{{Year}}", year,
-		"{{Month}}", month,
-		"{{Day}}", day,
-		"{{Hour}}", hour,
-		"{{Minute}}", minute,
-		"{{StatusString}}", p.getStatusString(),
-		"{{UID}}", p.getStringReplacement(p.UID, jsonEscaped),
-		"{{Ext}}", p.getStringReplacement(p.Extension, jsonEscaped),
+		"{{.Name}}", p.getStringReplacement(p.Name, jsonEscaped),
+		"{{.Event}}", p.Event,
+		"{{.Status}}", fmt.Sprintf("%d", p.Status),
+		"{{.VirtualPath}}", p.getStringReplacement(p.VirtualPath, jsonEscaped),
+		"{{.EscapedVirtualPath}}", p.getStringReplacement(url.QueryEscape(p.VirtualPath), jsonEscaped),
+		"{{.FsPath}}", p.getStringReplacement(p.FsPath, jsonEscaped),
+		"{{.VirtualTargetPath}}", p.getStringReplacement(p.VirtualTargetPath, jsonEscaped),
+		"{{.FsTargetPath}}", p.getStringReplacement(p.FsTargetPath, jsonEscaped),
+		"{{.ObjectName}}", p.getStringReplacement(p.ObjectName, jsonEscaped),
+		"{{.ObjectBaseName}}", p.getStringReplacement(strings.TrimSuffix(p.ObjectName, p.Extension), jsonEscaped),
+		"{{.ObjectType}}", p.ObjectType,
+		"{{.FileSize}}", strconv.FormatInt(p.FileSize, 10),
+		"{{.Elapsed}}", strconv.FormatInt(p.Elapsed, 10),
+		"{{.Protocol}}", p.Protocol,
+		"{{.IP}}", p.IP,
+		"{{.Role}}", p.getStringReplacement(p.Role, jsonEscaped),
+		"{{.Email}}", p.getStringReplacement(p.Email, jsonEscaped),
+		"{{.Timestamp}}", strconv.FormatInt(p.Timestamp.UnixNano(), 10),
+		"{{.DateTime}}", dateTimeString,
+		"{{.Year}}", year,
+		"{{.Month}}", month,
+		"{{.Day}}", day,
+		"{{.Hour}}", hour,
+		"{{.Minute}}", minute,
+		"{{.StatusString}}", p.getStatusString(),
+		"{{.UID}}", p.getStringReplacement(p.UID, jsonEscaped),
+		"{{.Ext}}", p.getStringReplacement(p.Extension, jsonEscaped),
 	}
 	if p.VirtualPath != "" {
-		replacements = append(replacements, "{{VirtualDirPath}}", p.getStringReplacement(path.Dir(p.VirtualPath), jsonEscaped))
+		replacements = append(replacements, "{{.VirtualDirPath}}", p.getStringReplacement(path.Dir(p.VirtualPath), jsonEscaped))
 	}
 	if p.VirtualTargetPath != "" {
-		replacements = append(replacements, "{{VirtualTargetDirPath}}", p.getStringReplacement(path.Dir(p.VirtualTargetPath), jsonEscaped))
-		replacements = append(replacements, "{{TargetName}}", p.getStringReplacement(path.Base(p.VirtualTargetPath), jsonEscaped))
+		replacements = append(replacements, "{{.VirtualTargetDirPath}}", p.getStringReplacement(path.Dir(p.VirtualTargetPath), jsonEscaped))
+		replacements = append(replacements, "{{.TargetName}}", p.getStringReplacement(path.Base(p.VirtualTargetPath), jsonEscaped))
 	}
 	if len(p.errors) > 0 {
-		replacements = append(replacements, "{{ErrorString}}", p.getStringReplacement(strings.Join(p.errors, ", "), jsonEscaped))
+		replacements = append(replacements, "{{.ErrorString}}", p.getStringReplacement(strings.Join(p.errors, ", "), jsonEscaped))
 	} else {
-		replacements = append(replacements, "{{ErrorString}}", "")
+		replacements = append(replacements, "{{.ErrorString}}", "")
 	}
 	replacements = append(replacements, objDataPlaceholder, "{}")
 	replacements = append(replacements, objDataPlaceholderString, "")
@@ -848,11 +848,11 @@ func (p *EventParams) getStringReplacements(addObjectData, jsonEscaped bool) []s
 	}
 	if p.IDPCustomFields != nil {
 		for k, v := range *p.IDPCustomFields {
-			replacements = append(replacements, fmt.Sprintf("{{IDPField%s}}", k), p.getStringReplacement(v, jsonEscaped))
+			replacements = append(replacements, fmt.Sprintf("{{.IDPField%s}}", k), p.getStringReplacement(v, jsonEscaped))
 		}
 	}
-	replacements = append(replacements, "{{Metadata}}", "{}")
-	replacements = append(replacements, "{{MetadataString}}", "")
+	replacements = append(replacements, "{{.Metadata}}", "{}")
+	replacements = append(replacements, "{{.MetadataString}}", "")
 	if len(p.Metadata) > 0 {
 		data, err := json.Marshal(p.Metadata)
 		if err == nil {
@@ -1193,7 +1193,7 @@ func getMailAttachments(conn *BaseConnection, attachments []string, replacer *st
 }
 
 func replaceWithReplacer(input string, replacer *strings.Replacer) string {
-	if !strings.Contains(input, "{{") {
+	if !strings.Contains(input, "{{.") {
 		return input
 	}
 	return replacer.Replace(input)
@@ -1280,7 +1280,7 @@ func getHTTPRuleActionEndpoint(c *dataprovider.EventActionHTTPConfig, replacer *
 	if err != nil {
 		return "", fmt.Errorf("invalid endpoint: %w", err)
 	}
-	if strings.Contains(u.Path, "{{") {
+	if strings.Contains(u.Path, "{{.") {
 		pathComponents := strings.Split(u.Path, "/")
 		for idx := range pathComponents {
 			part := replaceWithReplacer(pathComponents[idx], replacer)

+ 17 - 17
internal/common/eventmanager_test.go

@@ -810,17 +810,17 @@ func TestDateTimePlaceholder(t *testing.T) {
 	}
 	replacements := params.getStringReplacements(false, false)
 	r := strings.NewReplacer(replacements...)
-	res := r.Replace("{{DateTime}}")
+	res := r.Replace("{{.DateTime}}")
 	assert.Equal(t, dateTime.UTC().Format(dateTimeMillisFormat), res)
-	res = r.Replace("{{Year}}-{{Month}}-{{Day}}T{{Hour}}:{{Minute}}")
+	res = r.Replace("{{.Year}}-{{.Month}}-{{.Day}}T{{.Hour}}:{{.Minute}}")
 	assert.Equal(t, dateTime.UTC().Format(dateTimeMillisFormat)[:16], res)
 
 	Config.TZ = "local"
 	replacements = params.getStringReplacements(false, false)
 	r = strings.NewReplacer(replacements...)
-	res = r.Replace("{{DateTime}}")
+	res = r.Replace("{{.DateTime}}")
 	assert.Equal(t, dateTime.Local().Format(dateTimeMillisFormat), res)
-	res = r.Replace("{{Year}}-{{Month}}-{{Day}}T{{Hour}}:{{Minute}}")
+	res = r.Replace("{{.Year}}-{{.Month}}-{{.Day}}T{{.Hour}}:{{.Minute}}")
 	assert.Equal(t, dateTime.Local().Format(dateTimeMillisFormat)[:16], res)
 
 	Config.TZ = oldTZ
@@ -845,7 +845,7 @@ func TestEventRuleActions(t *testing.T) {
 			HTTPConfig: dataprovider.EventActionHTTPConfig{
 				Endpoint:      "http://foo\x7f.com/", // invalid URL
 				SkipTLSVerify: true,
-				Body:          `"data": "{{ObjectDataString}}"`,
+				Body:          `"data": "{{.ObjectDataString}}"`,
 				Method:        http.MethodPost,
 				QueryParameters: []dataprovider.KeyValue{
 					{
@@ -913,7 +913,7 @@ func TestEventRuleActions(t *testing.T) {
 	assert.Contains(t, getErrorString(err), "error getting user")
 
 	action.Options.HTTPConfig.Parts = nil
-	action.Options.HTTPConfig.Body = "{{ObjectData}}"
+	action.Options.HTTPConfig.Body = "{{.ObjectData}}"
 	// test disk and transfer quota reset
 	username1 := "user1"
 	username2 := "user2"
@@ -1249,7 +1249,7 @@ func TestEventRuleActions(t *testing.T) {
 			Type: dataprovider.FilesystemActionCompress,
 			Compress: dataprovider.EventActionFsCompress{
 				Name:  "test.zip",
-				Paths: []string{"/{{VirtualPath}}"},
+				Paths: []string{"/{{.VirtualPath}}"},
 			},
 		},
 	}
@@ -1970,7 +1970,7 @@ func TestScheduledActions(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			FsConfig: dataprovider.EventActionFilesystemConfig{
 				Type:   dataprovider.FilesystemActionMkdirs,
-				MkDirs: []string{"{{Year}}_{{Month}}_{{Day}}"},
+				MkDirs: []string{"{{.Year}}_{{.Month}}_{{.Day}}"},
 			},
 		},
 	}
@@ -2159,11 +2159,11 @@ func TestWriteHTTPPartsError(t *testing.T) {
 }
 
 func TestReplacePathsPlaceholders(t *testing.T) {
-	replacer := strings.NewReplacer("{{VirtualPath}}", "/path1")
-	paths := []string{"{{VirtualPath}}", "/path1"}
+	replacer := strings.NewReplacer("{{.VirtualPath}}", "/path1")
+	paths := []string{"{{.VirtualPath}}", "/path1"}
 	paths = replacePathsPlaceholders(paths, replacer)
 	assert.Equal(t, []string{"/path1"}, paths)
-	paths = []string{"{{VirtualPath}}", "/path2"}
+	paths = []string{"{{.VirtualPath}}", "/path2"}
 	paths = replacePathsPlaceholders(paths, replacer)
 	assert.Equal(t, []string{"/path1", "/path2"}, paths)
 }
@@ -2256,7 +2256,7 @@ func TestOnDemandRule(t *testing.T) {
 				Recipients:  []string{"[email protected]"},
 				Subject:     "subject",
 				Body:        "body",
-				Attachments: []string{"/{{VirtualPath}}"},
+				Attachments: []string{"/{{.VirtualPath}}"},
 			},
 		},
 	}
@@ -2297,21 +2297,21 @@ func getErrorString(err error) string {
 
 func TestHTTPEndpointWithPlaceholders(t *testing.T) {
 	c := dataprovider.EventActionHTTPConfig{
-		Endpoint: "http://127.0.0.1:8080/base/url/{{Name}}/{{VirtualPath}}/upload",
+		Endpoint: "http://127.0.0.1:8080/base/url/{{.Name}}/{{.VirtualPath}}/upload",
 		QueryParameters: []dataprovider.KeyValue{
 			{
 				Key:   "u",
-				Value: "{{Name}}",
+				Value: "{{.Name}}",
 			},
 			{
 				Key:   "p",
-				Value: "{{VirtualPath}}",
+				Value: "{{.VirtualPath}}",
 			},
 		},
 	}
 	name := "uname"
 	vPath := "/a dir/@ file.txt"
-	replacer := strings.NewReplacer("{{Name}}", name, "{{VirtualPath}}", vPath)
+	replacer := strings.NewReplacer("{{.Name}}", name, "{{.VirtualPath}}", vPath)
 	u, err := getHTTPRuleActionEndpoint(&c, replacer)
 	assert.NoError(t, err)
 	expected := "http://127.0.0.1:8080/base/url/" + url.PathEscape(name) + "/" + url.PathEscape(vPath) +
@@ -2333,7 +2333,7 @@ func TestMetadataReplacement(t *testing.T) {
 	}
 	replacements := params.getStringReplacements(false, false)
 	replacer := strings.NewReplacer(replacements...)
-	reader, _, err := getHTTPRuleActionBody(&dataprovider.EventActionHTTPConfig{Body: "{{Metadata}} {{MetadataString}}"}, replacer, nil, dataprovider.User{}, params, false)
+	reader, _, err := getHTTPRuleActionBody(&dataprovider.EventActionHTTPConfig{Body: "{{.Metadata}} {{.MetadataString}}"}, replacer, nil, dataprovider.User{}, params, false)
 	require.NoError(t, err)
 	data, err := io.ReadAll(reader)
 	require.NoError(t, err)

+ 69 - 69
internal/common/protocol_test.go

@@ -3784,8 +3784,8 @@ func TestEventRule(t *testing.T) {
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients: []string{"[email protected]", "[email protected]"},
 				Bcc:        []string{"[email protected]"},
-				Subject:    `New "{{Event}}" from "{{Name}}" status {{StatusString}}`,
-				Body:       "Fs path {{FsPath}}, size: {{FileSize}}, protocol: {{Protocol}}, IP: {{IP}} Data: {{ObjectData}} {{ErrorString}}",
+				Subject:    `New "{{.Event}}" from "{{.Name}}" status {{.StatusString}}`,
+				Body:       "Fs path {{.FsPath}}, size: {{.FileSize}}, protocol: {{.Protocol}}, IP: {{.IP}} Data: {{.ObjectData}} {{.ErrorString}}",
 			},
 		},
 	}
@@ -3795,8 +3795,8 @@ func TestEventRule(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients: []string{"[email protected]"},
-				Subject:    `Failed "{{Event}}" from "{{Name}}"`,
-				Body:       "Fs path {{FsPath}}, protocol: {{Protocol}}, IP: {{IP}} {{ErrorString}}",
+				Subject:    `Failed "{{.Event}}" from "{{.Name}}"`,
+				Body:       "Fs path {{.FsPath}}, protocol: {{.Protocol}}, IP: {{.IP}} {{.ErrorString}}",
 			},
 		},
 	}
@@ -3941,7 +3941,7 @@ func TestEventRule(t *testing.T) {
 			EnvVars: []dataprovider.KeyValue{
 				{
 					Key:   "SFTPGO_ACTION_PATH",
-					Value: "{{FsPath}}",
+					Value: "{{.FsPath}}",
 				},
 				{
 					Key:   "CUSTOM_ENV_VAR",
@@ -4050,7 +4050,7 @@ func TestEventRule(t *testing.T) {
 		assert.Contains(t, email.Data, fmt.Sprintf(`Subject: New "download" from "%s"`, user.Username))
 	}
 	// test upload action command with arguments
-	action1.Options.CmdConfig.Args = []string{"{{Event}}", "{{VirtualPath}}", "custom_arg"}
+	action1.Options.CmdConfig.Args = []string{"{{.Event}}", "{{.VirtualPath}}", "custom_arg"}
 	action1, _, err = httpdtest.UpdateEventAction(action1, http.StatusOK)
 	assert.NoError(t, err)
 	uploadLogFilePath := filepath.Join(os.TempDir(), "upload.log")
@@ -4128,8 +4128,8 @@ func TestEventRuleStatues(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients: []string{"[email protected]"},
-				Subject:    `New "{{Event}}" error`,
-				Body:       "{{ErrorString}}",
+				Subject:    `New "{{.Event}}" error`,
+				Body:       "{{.ErrorString}}",
 			},
 		},
 	}
@@ -4241,7 +4241,7 @@ func TestEventRuleDisabledCommand(t *testing.T) {
 				EnvVars: []dataprovider.KeyValue{
 					{
 						Key:   "SFTPGO_OBJECT_DATA",
-						Value: "{{ObjectData}}",
+						Value: "{{.ObjectData}}",
 					},
 				},
 			},
@@ -4253,8 +4253,8 @@ func TestEventRuleDisabledCommand(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients: []string{"[email protected]"},
-				Subject:    `New "{{Event}}" from "{{Name}}"`,
-				Body:       "Object name: {{ObjectName}} object type: {{ObjectType}} Data: {{ObjectData}}",
+				Subject:    `New "{{.Event}}" from "{{.Name}}"`,
+				Body:       "Object name: {{.ObjectName}} object type: {{.ObjectType}} Data: {{.ObjectData}}",
 			},
 		},
 	}
@@ -4265,8 +4265,8 @@ func TestEventRuleDisabledCommand(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients: []string{"[email protected]"},
-				Subject:    `Failed "{{Event}}" from "{{Name}}"`,
-				Body:       "Object name: {{ObjectName}} object type: {{ObjectType}}, IP: {{IP}}",
+				Subject:    `Failed "{{.Event}}" from "{{.Name}}"`,
+				Body:       "Object name: {{.ObjectName}} object type: {{.ObjectType}}, IP: {{.IP}}",
 			},
 		},
 	}
@@ -4390,7 +4390,7 @@ func TestEventRuleProviderEvents(t *testing.T) {
 				EnvVars: []dataprovider.KeyValue{
 					{
 						Key:   "SFTPGO_OBJECT_DATA",
-						Value: "{{ObjectData}}",
+						Value: "{{.ObjectData}}",
 					},
 				},
 			},
@@ -4402,8 +4402,8 @@ func TestEventRuleProviderEvents(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients: []string{"[email protected]"},
-				Subject:    `New "{{Event}}" from "{{Name}}"`,
-				Body:       "Object name: {{ObjectName}} object type: {{ObjectType}} Data: {{ObjectData}}",
+				Subject:    `New "{{.Event}}" from "{{.Name}}"`,
+				Body:       "Object name: {{.ObjectName}} object type: {{.ObjectType}} Data: {{.ObjectData}}",
 			},
 		},
 	}
@@ -4414,8 +4414,8 @@ func TestEventRuleProviderEvents(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients: []string{"[email protected]"},
-				Subject:    `Failed "{{Event}}" from "{{Name}}"`,
-				Body:       "Object name: {{ObjectName}} object type: {{ObjectType}}, IP: {{IP}}",
+				Subject:    `Failed "{{.Event}}" from "{{.Name}}"`,
+				Body:       "Object name: {{.ObjectName}} object type: {{.ObjectType}}, IP: {{.IP}}",
 			},
 		},
 	}
@@ -4561,8 +4561,8 @@ func TestEventRuleFsActions(t *testing.T) {
 				Renames: []dataprovider.RenameConfig{
 					{
 						KeyValue: dataprovider.KeyValue{
-							Key:   "/{{VirtualDirPath}}/{{ObjectName}}",
-							Value: "/{{ObjectName}}_renamed",
+							Key:   "/{{.VirtualDirPath}}/{{.ObjectName}}",
+							Value: "/{{.ObjectName}}_renamed",
 						},
 					},
 				},
@@ -4575,7 +4575,7 @@ func TestEventRuleFsActions(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			FsConfig: dataprovider.EventActionFilesystemConfig{
 				Type:    dataprovider.FilesystemActionDelete,
-				Deletes: []string{"/{{ObjectName}}_renamed"},
+				Deletes: []string{"/{{.ObjectName}}_renamed"},
 			},
 		},
 	}
@@ -4593,7 +4593,7 @@ func TestEventRuleFsActions(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			FsConfig: dataprovider.EventActionFilesystemConfig{
 				Type:  dataprovider.FilesystemActionExist,
-				Exist: []string{"/{{VirtualPath}}"},
+				Exist: []string{"/{{.VirtualPath}}"},
 			},
 		},
 	}
@@ -4831,8 +4831,8 @@ func TestEventActionObjectBaseName(t *testing.T) {
 				Renames: []dataprovider.RenameConfig{
 					{
 						KeyValue: dataprovider.KeyValue{
-							Key:   "/{{VirtualDirPath}}/{{ObjectName}}",
-							Value: "/{{ObjectBaseName}}",
+							Key:   "/{{.VirtualDirPath}}/{{.ObjectName}}",
+							Value: "/{{.ObjectBaseName}}",
 						},
 					},
 				},
@@ -4911,8 +4911,8 @@ func TestUploadEventRule(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients: []string{"[email protected]"},
-				Subject:    `New "{{Event}}" from "{{Name}}" status {{StatusString}}`,
-				Body:       "Fs path {{FsPath}}, size: {{FileSize}}, protocol: {{Protocol}}, IP: {{IP}} Data: {{ObjectData}} {{ErrorString}}",
+				Subject:    `New "{{.Event}}" from "{{.Name}}" status {{.StatusString}}`,
+				Body:       "Fs path {{.FsPath}}, size: {{.FileSize}}, protocol: {{.Protocol}}, IP: {{.IP}} Data: {{.ObjectData}} {{.ErrorString}}",
 			},
 		},
 	}
@@ -5047,8 +5047,8 @@ func TestEventRulePreDelete(t *testing.T) {
 				Renames: []dataprovider.RenameConfig{
 					{
 						KeyValue: dataprovider.KeyValue{
-							Key:   "/{{VirtualPath}}",
-							Value: fmt.Sprintf("/%s/{{VirtualPath}}", movePath),
+							Key:   "/{{.VirtualPath}}",
+							Value: fmt.Sprintf("/%s/{{.VirtualPath}}", movePath),
 						},
 						UpdateModTime: true,
 					},
@@ -5410,7 +5410,7 @@ func TestFsActionCopy(t *testing.T) {
 				Type: dataprovider.FilesystemActionCopy,
 				Copy: []dataprovider.KeyValue{
 					{
-						Key:   "/{{VirtualPath}}/",
+						Key:   "/{{.VirtualPath}}/",
 						Value: "/dircopy/",
 					},
 				},
@@ -5492,8 +5492,8 @@ func TestEventFsActionsGroupFilters(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients: []string{"[email protected]"},
-				Subject:    `New "{{Event}}" from "{{Name}}" status {{StatusString}}`,
-				Body:       "Fs path {{FsPath}}, size: {{FileSize}}, protocol: {{Protocol}}, IP: {{IP}} {{ErrorString}}",
+				Subject:    `New "{{.Event}}" from "{{.Name}}" status {{.StatusString}}`,
+				Body:       "Fs path {{.FsPath}}, size: {{.FileSize}}, protocol: {{.Protocol}}, IP: {{.IP}} {{.ErrorString}}",
 			},
 		},
 	}
@@ -5623,8 +5623,8 @@ func TestEventProviderActionGroupFilters(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients: []string{"[email protected]"},
-				Subject:    `New "{{Event}}" from "{{Name}}"`,
-				Body:       "IP: {{IP}}",
+				Subject:    `New "{{.Event}}" from "{{.Name}}"`,
+				Body:       "IP: {{.IP}}",
 			},
 		},
 	}
@@ -5759,9 +5759,9 @@ func TestBackupAsAttachment(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients:  []string{"[email protected]"},
-				Subject:     `"{{Event}} {{StatusString}}"`,
-				Body:        "Domain: {{Name}}",
-				Attachments: []string{"/{{VirtualPath}}"},
+				Subject:     `"{{.Event}} {{.StatusString}}"`,
+				Body:        "Domain: {{.Name}}",
+				Attachments: []string{"/{{.VirtualPath}}"},
 			},
 		},
 	}
@@ -5840,11 +5840,11 @@ func TestEventActionHTTPMultipart(t *testing.T) {
 								Value: "application/json",
 							},
 						},
-						Body: `{"FilePath": "{{VirtualPath}}"}`,
+						Body: `{"FilePath": "{{.VirtualPath}}"}`,
 					},
 					{
 						Name:     "file",
-						Filepath: "/{{VirtualPath}}",
+						Filepath: "/{{.VirtualPath}}",
 					},
 				},
 			},
@@ -5920,8 +5920,8 @@ func TestEventActionCompress(t *testing.T) {
 			FsConfig: dataprovider.EventActionFilesystemConfig{
 				Type: dataprovider.FilesystemActionCompress,
 				Compress: dataprovider.EventActionFsCompress{
-					Name:  "/{{VirtualPath}}.zip",
-					Paths: []string{"/{{VirtualPath}}"},
+					Name:  "/{{.VirtualPath}}.zip",
+					Paths: []string{"/{{.VirtualPath}}"},
 				},
 			},
 		},
@@ -6091,7 +6091,7 @@ func TestEventActionCompressQuotaErrors(t *testing.T) {
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients: []string{"[email protected]"},
 				Subject:    `"Compress failed"`,
-				Body:       "Error: {{ErrorString}}",
+				Body:       "Error: {{.ErrorString}}",
 			},
 		},
 	}
@@ -6235,8 +6235,8 @@ func TestEventActionCompressQuotaFolder(t *testing.T) {
 			FsConfig: dataprovider.EventActionFilesystemConfig{
 				Type: dataprovider.FilesystemActionCompress,
 				Compress: dataprovider.EventActionFsCompress{
-					Name:  "/{{VirtualPath}}.zip",
-					Paths: []string{"/{{VirtualPath}}", testDir},
+					Name:  "/{{.VirtualPath}}.zip",
+					Paths: []string{"/{{.VirtualPath}}", testDir},
 				},
 			},
 		},
@@ -6361,8 +6361,8 @@ func TestEventActionCompressErrors(t *testing.T) {
 			FsConfig: dataprovider.EventActionFilesystemConfig{
 				Type: dataprovider.FilesystemActionCompress,
 				Compress: dataprovider.EventActionFsCompress{
-					Name:  "/{{VirtualPath}}.zip",
-					Paths: []string{"/{{VirtualPath}}.zip"}, // cannot compress itself
+					Name:  "/{{.VirtualPath}}.zip",
+					Paths: []string{"/{{.VirtualPath}}.zip"}, // cannot compress itself
 				},
 			},
 		},
@@ -6424,7 +6424,7 @@ func TestEventActionCompressErrors(t *testing.T) {
 	// try to overwrite a directory
 	testDir := "/adir"
 	action1.Options.FsConfig.Compress.Name = testDir
-	action1.Options.FsConfig.Compress.Paths = []string{"/{{VirtualPath}}"}
+	action1.Options.FsConfig.Compress.Paths = []string{"/{{.VirtualPath}}"}
 	_, _, err = httpdtest.UpdateEventAction(action1, http.StatusOK)
 	assert.NoError(t, err)
 	conn, client, err = getSftpClient(user)
@@ -6469,8 +6469,8 @@ func TestEventActionEmailAttachments(t *testing.T) {
 			FsConfig: dataprovider.EventActionFilesystemConfig{
 				Type: dataprovider.FilesystemActionCompress,
 				Compress: dataprovider.EventActionFsCompress{
-					Name:  "/archive/{{VirtualPath}}.zip",
-					Paths: []string{"/{{VirtualPath}}"},
+					Name:  "/archive/{{.VirtualPath}}.zip",
+					Paths: []string{"/{{.VirtualPath}}"},
 				},
 			},
 		},
@@ -6483,9 +6483,9 @@ func TestEventActionEmailAttachments(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients:  []string{"[email protected]"},
-				Subject:     `"{{Event}}" from "{{Name}}"`,
-				Body:        "Fs path {{FsPath}}, size: {{FileSize}}, protocol: {{Protocol}}, IP: {{IP}} {{EscapedVirtualPath}}",
-				Attachments: []string{"/archive/{{VirtualPath}}.zip"},
+				Subject:     `"{{.Event}}" from "{{.Name}}"`,
+				Body:        "Fs path {{.FsPath}}, size: {{.FileSize}}, protocol: {{.Protocol}}, IP: {{.IP}} {{.EscapedVirtualPath}}",
+				Attachments: []string{"/archive/{{.VirtualPath}}.zip"},
 			},
 		},
 	}
@@ -6604,8 +6604,8 @@ func TestEventActionsRetentionReports(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients:  []string{"[email protected]"},
-				Subject:     `"{{Event}}" from "{{Name}}"`,
-				Body:        "Fs path {{FsPath}}, size: {{FileSize}}, protocol: {{Protocol}}, IP: {{IP}}",
+				Subject:     `"{{.Event}}" from "{{.Name}}"`,
+				Body:        "Fs path {{.FsPath}}, size: {{.FileSize}}, protocol: {{.Protocol}}, IP: {{.IP}}",
 				Attachments: []string{dataprovider.RetentionReportPlaceHolder},
 			},
 		},
@@ -6832,8 +6832,8 @@ func TestEventRuleFirstUploadDownloadActions(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients: []string{"[email protected]"},
-				Subject:    `"{{Event}}" from "{{Name}}"`,
-				Body:       "Fs path {{FsPath}}, size: {{FileSize}}, protocol: {{Protocol}}, IP: {{IP}}",
+				Subject:    `"{{.Event}}" from "{{.Name}}"`,
+				Body:       "Fs path {{.FsPath}}, size: {{.FileSize}}, protocol: {{.Protocol}}, IP: {{.IP}}",
 			},
 		},
 	}
@@ -6964,9 +6964,9 @@ func TestEventRuleRenameEvent(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients:  []string{"[email protected]"},
-				Subject:     `"{{Event}}" from "{{Name}}"`,
+				Subject:     `"{{.Event}}" from "{{.Name}}"`,
 				ContentType: 1,
-				Body:        `<p>Fs path {{FsPath}}, Target path "{{VirtualTargetDirPath}}/{{TargetName}}", size: {{FileSize}}</p>`,
+				Body:        `<p>Fs path {{.FsPath}}, Target path "{{.VirtualTargetDirPath}}/{{.TargetName}}", size: {{.FileSize}}</p>`,
 			},
 		},
 	}
@@ -7045,9 +7045,9 @@ func TestEventRuleIDPLogin(t *testing.T) {
 	username := `test_"idp_"login`
 	custom1 := `cust"oa"1`
 	u := map[string]any{
-		"username": "{{Name}}",
+		"username": "{{.Name}}",
 		"status":   1,
-		"home_dir": filepath.Join(os.TempDir(), "{{IDPFieldcustom1}}"),
+		"home_dir": filepath.Join(os.TempDir(), "{{.IDPFieldcustom1}}"),
 		"permissions": map[string][]string{
 			"/": {dataprovider.PermAny},
 		},
@@ -7055,7 +7055,7 @@ func TestEventRuleIDPLogin(t *testing.T) {
 	userTmpl, err := json.Marshal(u)
 	require.NoError(t, err)
 	a := map[string]any{
-		"username":    "{{Name}}",
+		"username":    "{{.Name}}",
 		"status":      1,
 		"permissions": []string{dataprovider.PermAdminAny},
 	}
@@ -7081,8 +7081,8 @@ func TestEventRuleIDPLogin(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients: []string{"[email protected]"},
-				Subject:    `"{{Event}} {{StatusString}}"`,
-				Body:       "{{Name}} Custom field: {{IDPFieldcustom1}}",
+				Subject:    `"{{.Event}} {{.StatusString}}"`,
+				Body:       "{{.Name}} Custom field: {{.IDPFieldcustom1}}",
 			},
 		},
 	}
@@ -7327,8 +7327,8 @@ func TestEventRuleEmailField(t *testing.T) {
 		Type: dataprovider.ActionTypeEmail,
 		Options: dataprovider.BaseEventActionOptions{
 			EmailConfig: dataprovider.EventActionEmailConfig{
-				Recipients: []string{"{{Email}}"},
-				Subject:    `"{{Event}}" from "{{Name}}"`,
+				Recipients: []string{"{{.Email}}"},
+				Subject:    `"{{.Event}}" from "{{.Name}}"`,
 				Body:       "Sample email body",
 			},
 		},
@@ -7342,7 +7342,7 @@ func TestEventRuleEmailField(t *testing.T) {
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients: []string{"[email protected]"},
 				Subject:    `"Failure`,
-				Body:       "{{ErrorString}}",
+				Body:       "{{.ErrorString}}",
 			},
 		},
 	}
@@ -7473,9 +7473,9 @@ func TestEventRuleCertificate(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients:  []string{"[email protected]"},
-				Subject:     `"{{Event}} {{StatusString}}"`,
+				Subject:     `"{{.Event}} {{.StatusString}}"`,
 				ContentType: 0,
-				Body:        "Domain: {{Name}} Timestamp: {{Timestamp}} {{ErrorString}} Date time: {{DateTime}}",
+				Body:        "Domain: {{.Name}} Timestamp: {{.Timestamp}} {{.ErrorString}} Date time: {{.DateTime}}",
 			},
 		},
 	}
@@ -7613,8 +7613,8 @@ func TestEventRuleIPBlocked(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients: []string{"[email protected]", "[email protected]"},
-				Subject:    `New "{{Event}}"`,
-				Body:       "IP: {{IP}} Timestamp: {{Timestamp}}",
+				Subject:    `New "{{.Event}}"`,
+				Body:       "IP: {{.IP}} Timestamp: {{.Timestamp}}",
 			},
 		},
 	}

+ 14 - 6
internal/dataprovider/bolt.go

@@ -40,7 +40,7 @@ import (
 )
 
 const (
-	boltDatabaseVersion = 31
+	boltDatabaseVersion = 32
 )
 
 var (
@@ -3186,10 +3186,13 @@ func (p *BoltProvider) migrateDatabase() error {
 		providerLog(logger.LevelError, "%v", err)
 		logger.ErrorToConsole("%v", err)
 		return err
-	case version == 29, version == 30:
-		logger.InfoToConsole("updating database schema version: %d -> 31", version)
-		providerLog(logger.LevelInfo, "updating database schema version: %d -> 31", version)
-		return updateBoltDatabaseVersion(p.dbHandle, 31)
+	case version == 29, version == 30, version == 31:
+		logger.InfoToConsole("updating database schema version: %d -> 32", version)
+		providerLog(logger.LevelInfo, "updating database schema version: %d -> 32", version)
+		if err := updateEventActions(); err != nil {
+			return err
+		}
+		return updateBoltDatabaseVersion(p.dbHandle, 32)
 	default:
 		if version > boltDatabaseVersion {
 			providerLog(logger.LevelError, "database schema version %d is newer than the supported one: %d", version,
@@ -3211,9 +3214,14 @@ func (p *BoltProvider) revertDatabase(targetVersion int) error { //nolint:gocycl
 		return errors.New("current version match target version, nothing to do")
 	}
 	switch dbVersion.Version {
-	case 30, 31:
+	case 30, 31, 32:
 		logger.InfoToConsole("downgrading database schema version: %d -> 29", dbVersion.Version)
 		providerLog(logger.LevelInfo, "downgrading database schema version: %d -> 29", dbVersion.Version)
+		if dbVersion.Version == 32 {
+			if err := restoreEventActions(); err != nil {
+				return err
+			}
+		}
 		return updateBoltDatabaseVersion(p.dbHandle, 29)
 	default:
 		return fmt.Errorf("database schema version not handled: %v", dbVersion.Version)

+ 183 - 1
internal/dataprovider/dataprovider.go

@@ -27,6 +27,7 @@ import (
 	"crypto/sha512"
 	"crypto/subtle"
 	"crypto/x509"
+	"database/sql"
 	"encoding/base64"
 	"encoding/hex"
 	"encoding/json"
@@ -90,7 +91,7 @@ const (
 	CockroachDataProviderName = "cockroachdb"
 	// DumpVersion defines the version for the dump.
 	// For restore/load we support the current version and the previous one
-	DumpVersion = 16
+	DumpVersion = 17
 
 	argonPwdPrefix            = "$argon2id$"
 	bcryptPwdPrefix           = "$2a$"
@@ -2557,6 +2558,17 @@ func DumpData(scopes []string) (BackupData, error) {
 func ParseDumpData(data []byte) (BackupData, error) {
 	var dump BackupData
 	err := json.Unmarshal(data, &dump)
+	if err != nil {
+		return dump, err
+	}
+	if dump.Version < 17 {
+		providerLog(logger.LevelInfo, "updating placeholders for actions restored from dump version %d", dump.Version)
+		eventActions, err := updateEventActionPlaceholders(dump.EventActions)
+		if err != nil {
+			return dump, fmt.Errorf("unable to update event action placeholders for dump version %d: %w", dump.Version, err)
+		}
+		dump.EventActions = eventActions
+	}
 	return dump, err
 }
 
@@ -4673,6 +4685,176 @@ func isExternalAuthConfigured(loginMethod string) bool {
 	}
 }
 
+func replaceTemplateVars(input string) string {
+	var result strings.Builder
+	i := 0
+	for i < len(input) {
+		if i+2 <= len(input) && input[i:i+2] == "{{" {
+			if i+2 < len(input) {
+				nextChar := input[i+2]
+				if nextChar == ' ' || nextChar == '.' || nextChar == '-' {
+					// Don't replace if followed by space, dot or minus.
+					result.WriteString("{{")
+					i += 2
+					continue
+				}
+			}
+
+			// Find the closing "}}"
+			closing := strings.Index(input[i:], "}}")
+			if closing != -1 {
+				// Replace with {{. only if it's a proper template variable.
+				result.WriteString("{{.")
+				result.WriteString(input[i+2 : i+closing])
+				result.WriteString("}}")
+				i += closing + 2
+				continue
+			}
+		}
+		result.WriteByte(input[i])
+		i++
+	}
+	return result.String()
+}
+
+func restoreTemplateVars(input string) string {
+	var result strings.Builder
+	i := 0
+
+	for i < len(input) {
+		if i+3 <= len(input) && input[i:i+3] == "{{." {
+			if i+3 < len(input) {
+				nextChar := input[i+3]
+				if nextChar == ' ' || nextChar == '.' || nextChar == '-' {
+					// Don't change if it's a space, dot, or minus
+					result.WriteString("{{.")
+					i += 3
+					continue
+				}
+			}
+			// Find the closing "}}"
+			closing := strings.Index(input[i:], "}}")
+			if closing != -1 {
+				// Strip the dot and write the rest
+				result.WriteString("{{")
+				result.WriteString(input[i+3 : i+closing])
+				result.WriteString("}}")
+				i += closing + 2
+				continue
+			}
+		}
+
+		result.WriteByte(input[i])
+		i++
+	}
+
+	return result.String()
+}
+
+func updateEventActionPlaceholders(actions []BaseEventAction) ([]BaseEventAction, error) {
+	var result []BaseEventAction
+
+	for _, action := range actions {
+		options, err := json.Marshal(action.Options)
+		if err != nil {
+			return nil, err
+		}
+		convertedOptions := replaceTemplateVars(string(options))
+		var opts BaseEventActionOptions
+		err = json.Unmarshal([]byte(convertedOptions), &opts)
+		if err != nil {
+			return nil, err
+		}
+		action.Options = opts
+		result = append(result, action)
+	}
+
+	return result, nil
+}
+
+func restoreEventActionsPlaceholders(actions []BaseEventAction) ([]BaseEventAction, error) {
+	var result []BaseEventAction
+
+	for _, action := range actions {
+		options, err := json.Marshal(action.Options)
+		if err != nil {
+			return nil, err
+		}
+		convertedOptions := restoreTemplateVars(string(options))
+		var opts BaseEventActionOptions
+		err = json.Unmarshal([]byte(convertedOptions), &opts)
+		if err != nil {
+			return nil, err
+		}
+		action.Options = opts
+		result = append(result, action)
+	}
+
+	return result, nil
+}
+
+func updateEventActions() error {
+	actions, err := provider.dumpEventActions()
+	if err != nil {
+		return err
+	}
+	convertedActions, err := updateEventActionPlaceholders(actions)
+	if err != nil {
+		return err
+	}
+	for _, action := range convertedActions {
+		providerLog(logger.LevelInfo, "updating placeholders for event action %q", action.Name)
+		if err := provider.updateEventAction(&action); err != nil {
+			return fmt.Errorf("unable to save updated event action %q: %w", action.Name, err)
+		}
+	}
+	return nil
+}
+
+func restoreEventActions() error {
+	actions, err := provider.dumpEventActions()
+	if err != nil {
+		return err
+	}
+	convertedActions, err := restoreEventActionsPlaceholders(actions)
+	if err != nil {
+		return err
+	}
+	for _, action := range convertedActions {
+		providerLog(logger.LevelInfo, "restoring placeholders for event action %q", action.Name)
+		if err := provider.updateEventAction(&action); err != nil {
+			return fmt.Errorf("unable to save updated event action %q: %w", action.Name, err)
+		}
+	}
+	return nil
+}
+
+func updateSQLDatabaseFrom31To32(dbHandle *sql.DB) error {
+	logger.InfoToConsole("updating database data version: 31 -> 32")
+	providerLog(logger.LevelInfo, "updating database data version: 31 -> 32")
+
+	if err := updateEventActions(); err != nil {
+		return err
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), longSQLQueryTimeout)
+	defer cancel()
+
+	return sqlCommonUpdateDatabaseVersion(ctx, dbHandle, 32)
+}
+
+func downgradeSQLDatabaseFrom32To31(dbHandle *sql.DB) error {
+	logger.InfoToConsole("downgrading database data version: 32 -> 31")
+	providerLog(logger.LevelInfo, "downgrading database data version: 32 -> 31")
+
+	if err := restoreEventActions(); err != nil {
+		return err
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), longSQLQueryTimeout)
+	defer cancel()
+
+	return sqlCommonUpdateDatabaseVersion(ctx, dbHandle, 31)
+}
+
 func getConfigPath(name, configDir string) string {
 	if !util.IsFileInputValid(name) {
 		return ""

+ 19 - 1
internal/dataprovider/mysql.go

@@ -818,6 +818,8 @@ func (p *MySQLProvider) migrateDatabase() error {
 		return updateMySQLDatabaseFromV29(p.dbHandle)
 	case version == 30:
 		return updateMySQLDatabaseFromV30(p.dbHandle)
+	case version == 31:
+		return updateMySQLDatabaseFromV31(p.dbHandle)
 	default:
 		if version > sqlDatabaseVersion {
 			providerLog(logger.LevelError, "database schema version %d is newer than the supported one: %d", version,
@@ -844,6 +846,8 @@ func (p *MySQLProvider) revertDatabase(targetVersion int) error {
 		return downgradeMySQLDatabaseFromV30(p.dbHandle)
 	case 31:
 		return downgradeMySQLDatabaseFromV31(p.dbHandle)
+	case 32:
+		return downgradeMySQLDatabaseFromV32(p.dbHandle)
 	default:
 		return fmt.Errorf("database schema version not handled: %d", dbVersion.Version)
 	}
@@ -890,7 +894,14 @@ func updateMySQLDatabaseFromV29(dbHandle *sql.DB) error {
 }
 
 func updateMySQLDatabaseFromV30(dbHandle *sql.DB) error {
-	return updateMySQLDatabaseFrom30To31(dbHandle)
+	if err := updateMySQLDatabaseFrom30To31(dbHandle); err != nil {
+		return err
+	}
+	return updateMySQLDatabaseFromV31(dbHandle)
+}
+
+func updateMySQLDatabaseFromV31(dbHandle *sql.DB) error {
+	return updateSQLDatabaseFrom31To32(dbHandle)
 }
 
 func downgradeMySQLDatabaseFromV30(dbHandle *sql.DB) error {
@@ -904,6 +915,13 @@ func downgradeMySQLDatabaseFromV31(dbHandle *sql.DB) error {
 	return downgradeMySQLDatabaseFromV30(dbHandle)
 }
 
+func downgradeMySQLDatabaseFromV32(dbHandle *sql.DB) error {
+	if err := downgradeSQLDatabaseFrom32To31(dbHandle); err != nil {
+		return err
+	}
+	return downgradeMySQLDatabaseFromV31(dbHandle)
+}
+
 func updateMySQLDatabaseFrom29To30(dbHandle *sql.DB) error {
 	logger.InfoToConsole("updating database schema version: 29 -> 30")
 	providerLog(logger.LevelInfo, "updating database schema version: 29 -> 30")

+ 19 - 1
internal/dataprovider/pgsql.go

@@ -843,6 +843,8 @@ func (p *PGSQLProvider) migrateDatabase() error { //nolint:dupl
 		return updatePGSQLDatabaseFromV29(p.dbHandle)
 	case version == 30:
 		return updatePGSQLDatabaseFromV30(p.dbHandle)
+	case version == 31:
+		return updatePGSQLDatabaseFromV31(p.dbHandle)
 	default:
 		if version > sqlDatabaseVersion {
 			providerLog(logger.LevelError, "database schema version %d is newer than the supported one: %d", version,
@@ -869,6 +871,8 @@ func (p *PGSQLProvider) revertDatabase(targetVersion int) error {
 		return downgradePGSQLDatabaseFromV30(p.dbHandle)
 	case 31:
 		return downgradePGSQLDatabaseFromV31(p.dbHandle)
+	case 32:
+		return downgradePGSQLDatabaseFromV32(p.dbHandle)
 	default:
 		return fmt.Errorf("database schema version not handled: %d", dbVersion.Version)
 	}
@@ -915,7 +919,14 @@ func updatePGSQLDatabaseFromV29(dbHandle *sql.DB) error {
 }
 
 func updatePGSQLDatabaseFromV30(dbHandle *sql.DB) error {
-	return updatePGSQLDatabaseFrom30To31(dbHandle)
+	if err := updatePGSQLDatabaseFrom30To31(dbHandle); err != nil {
+		return err
+	}
+	return updatePGSQLDatabaseFromV31(dbHandle)
+}
+
+func updatePGSQLDatabaseFromV31(dbHandle *sql.DB) error {
+	return updateSQLDatabaseFrom31To32(dbHandle)
 }
 
 func downgradePGSQLDatabaseFromV30(dbHandle *sql.DB) error {
@@ -929,6 +940,13 @@ func downgradePGSQLDatabaseFromV31(dbHandle *sql.DB) error {
 	return downgradePGSQLDatabaseFromV30(dbHandle)
 }
 
+func downgradePGSQLDatabaseFromV32(dbHandle *sql.DB) error {
+	if err := downgradeSQLDatabaseFrom32To31(dbHandle); err != nil {
+		return err
+	}
+	return downgradePGSQLDatabaseFromV31(dbHandle)
+}
+
 func updatePGSQLDatabaseFrom29To30(dbHandle *sql.DB) error {
 	logger.InfoToConsole("updating database schema version: 29 -> 30")
 	providerLog(logger.LevelInfo, "updating database schema version: 29 -> 30")

+ 1 - 1
internal/dataprovider/sqlcommon.go

@@ -36,7 +36,7 @@ import (
 )
 
 const (
-	sqlDatabaseVersion     = 31
+	sqlDatabaseVersion     = 32
 	defaultSQLQueryTimeout = 10 * time.Second
 	longSQLQueryTimeout    = 60 * time.Second
 )

+ 19 - 1
internal/dataprovider/sqlite.go

@@ -741,6 +741,8 @@ func (p *SQLiteProvider) migrateDatabase() error { //nolint:dupl
 		return updateSQLiteDatabaseFromV29(p.dbHandle)
 	case version == 30:
 		return updateSQLiteDatabaseFromV30(p.dbHandle)
+	case version == 31:
+		return updateSQLiteDatabaseFromV31(p.dbHandle)
 	default:
 		if version > sqlDatabaseVersion {
 			providerLog(logger.LevelError, "database schema version %d is newer than the supported one: %d", version,
@@ -767,6 +769,8 @@ func (p *SQLiteProvider) revertDatabase(targetVersion int) error {
 		return downgradeSQLiteDatabaseFromV30(p.dbHandle)
 	case 31:
 		return downgradeSQLiteDatabaseFromV31(p.dbHandle)
+	case 32:
+		return downgradeSQLiteDatabaseFromV32(p.dbHandle)
 	default:
 		return fmt.Errorf("database schema version not handled: %d", dbVersion.Version)
 	}
@@ -820,7 +824,14 @@ func updateSQLiteDatabaseFromV29(dbHandle *sql.DB) error {
 }
 
 func updateSQLiteDatabaseFromV30(dbHandle *sql.DB) error {
-	return updateSQLiteDatabaseFrom30To31(dbHandle)
+	if err := updateSQLiteDatabaseFrom30To31(dbHandle); err != nil {
+		return err
+	}
+	return updateSQLiteDatabaseFromV31(dbHandle)
+}
+
+func updateSQLiteDatabaseFromV31(dbHandle *sql.DB) error {
+	return updateSQLDatabaseFrom31To32(dbHandle)
 }
 
 func downgradeSQLiteDatabaseFromV30(dbHandle *sql.DB) error {
@@ -834,6 +845,13 @@ func downgradeSQLiteDatabaseFromV31(dbHandle *sql.DB) error {
 	return downgradeSQLiteDatabaseFromV30(dbHandle)
 }
 
+func downgradeSQLiteDatabaseFromV32(dbHandle *sql.DB) error {
+	if err := downgradeSQLDatabaseFrom32To31(dbHandle); err != nil {
+		return err
+	}
+	return downgradeSQLiteDatabaseFromV31(dbHandle)
+}
+
 func updateSQLiteDatabaseFrom29To30(dbHandle *sql.DB) error {
 	logger.InfoToConsole("updating database schema version: 29 -> 30")
 	providerLog(logger.LevelInfo, "updating database schema version: 29 -> 30")

+ 155 - 10
internal/httpd/httpd_test.go

@@ -628,6 +628,72 @@ func TestInitialization(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestMigrateEventActionPlaceholders(t *testing.T) {
+	if config.GetProviderConf().Driver == dataprovider.MemoryDataProviderName {
+		t.Skip("this test is not supported with the memory provider")
+	}
+	// Add some event actions using the old placeholders syntax
+	a1 := dataprovider.BaseEventAction{
+		Name: xid.New().String(),
+		Type: dataprovider.ActionTypeEmail,
+		Options: dataprovider.BaseEventActionOptions{
+			EmailConfig: dataprovider.EventActionEmailConfig{
+				Recipients: []string{"[email protected]"},
+				Subject:    `Failed "{{Event}}" from "{{Name}}"`,
+				Body:       "Object name: {{ObjectName}} object type: {{ObjectType}}, IP: {{IP}}",
+			},
+		},
+	}
+	a2 := dataprovider.BaseEventAction{
+		Name: xid.New().String(),
+		Type: dataprovider.ActionTypeFilesystem,
+		Options: dataprovider.BaseEventActionOptions{
+			FsConfig: dataprovider.EventActionFilesystemConfig{
+				Type: dataprovider.FilesystemActionRename,
+				Renames: []dataprovider.RenameConfig{
+					{
+						KeyValue: dataprovider.KeyValue{
+							Key:   "/{{VirtualDirPath}}/{{ObjectName}}",
+							Value: "/{{ObjectName}}_renamed",
+						},
+					},
+				},
+			},
+		},
+	}
+	action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated)
+	assert.NoError(t, err)
+	action2, _, err := httpdtest.AddEventAction(a2, http.StatusCreated)
+	assert.NoError(t, err)
+	// Revert the database to the previous version.
+	err = dataprovider.Close()
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	providerConf := config.GetProviderConf()
+	err = dataprovider.RevertDatabase(providerConf, configDir, 29)
+	assert.NoError(t, err)
+	// Close and initialize.
+	err = dataprovider.Close()
+	assert.NoError(t, err)
+	err = dataprovider.Initialize(providerConf, configDir, true)
+	assert.NoError(t, err)
+	// Check that actions are migrated.
+	action1Get, _, err := httpdtest.GetEventActionByName(action1.Name, http.StatusOK)
+	assert.NoError(t, err)
+	action2Get, _, err := httpdtest.GetEventActionByName(action2.Name, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Equal(t, `Failed "{{.Event}}" from "{{.Name}}"`, action1Get.Options.EmailConfig.Subject)
+	assert.Equal(t, `Object name: {{.ObjectName}} object type: {{.ObjectType}}, IP: {{.IP}}`, action1Get.Options.EmailConfig.Body)
+	assert.Equal(t, `/{{.VirtualDirPath}}/{{.ObjectName}}`, action2Get.Options.FsConfig.Renames[0].Key)
+	assert.Equal(t, `/{{.ObjectName}}_renamed`, action2Get.Options.FsConfig.Renames[0].Value)
+	// Clenup.
+	_, err = httpdtest.RemoveEventAction(action1, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveEventAction(action2, http.StatusOK)
+	assert.NoError(t, err)
+}
+
 func TestBasicUserHandling(t *testing.T) {
 	u := getTestUser()
 	u.Email = "[email protected]"
@@ -1863,9 +1929,9 @@ func TestBasicActionRulesHandling(t *testing.T) {
 		EmailConfig: dataprovider.EventActionEmailConfig{
 			Recipients:  []string{"[email protected]"},
 			Bcc:         []string{"[email protected]"},
-			Subject:     "Event: {{Event}}",
+			Subject:     "Event: {{.Event}}",
 			Body:        "test mail body",
-			Attachments: []string{"/{{VirtualPath}}"},
+			Attachments: []string{"/{{.VirtualPath}}"},
 		},
 	}
 
@@ -1903,7 +1969,7 @@ func TestBasicActionRulesHandling(t *testing.T) {
 					Value: "b",
 				},
 			},
-			Body: `{"event":"{{Event}}","name":"{{Name}}"}`,
+			Body: `{"event":"{{.Event}}","name":"{{.Name}}"}`,
 		},
 	}
 	action, _, err = httpdtest.UpdateEventAction(a, http.StatusOK)
@@ -8362,7 +8428,7 @@ func TestLoaddata(t *testing.T) {
 				Timeout:       10,
 				SkipTLSVerify: true,
 				Method:        http.MethodPost,
-				Body:          `{"event":"{{Event}}","name":"{{Name}}"}`,
+				Body:          `{"event":"{{.Event}}","name":"{{.Name}}"}`,
 			},
 		},
 	}
@@ -8608,6 +8674,85 @@ func TestLoaddata(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestLoaddataConvertActions(t *testing.T) {
+	a1 := dataprovider.BaseEventAction{
+		Name: xid.New().String(),
+		Type: dataprovider.ActionTypeEmail,
+		Options: dataprovider.BaseEventActionOptions{
+			EmailConfig: dataprovider.EventActionEmailConfig{
+				Recipients: []string{"[email protected]"},
+				Subject:    `Failed "{{Event}}" from "{{Name}}"`,
+				Body:       "Object name: {{ObjectName}} object type: {{ObjectType}}, IP: {{IP}}",
+			},
+		},
+	}
+	a2 := dataprovider.BaseEventAction{
+		Name: xid.New().String(),
+		Type: dataprovider.ActionTypeFilesystem,
+		Options: dataprovider.BaseEventActionOptions{
+			FsConfig: dataprovider.EventActionFilesystemConfig{
+				Type: dataprovider.FilesystemActionRename,
+				Renames: []dataprovider.RenameConfig{
+					{
+						KeyValue: dataprovider.KeyValue{
+							Key:   "/{{VirtualDirPath}}/{{ObjectName}}",
+							Value: "/{{ObjectName}}_renamed",
+						},
+					},
+				},
+			},
+		},
+	}
+	backupData := dataprovider.BackupData{
+		EventActions: []dataprovider.BaseEventAction{a1, a2},
+		Version:      16,
+	}
+	backupContent, err := json.Marshal(backupData)
+	assert.NoError(t, err)
+	backupFilePath := filepath.Join(backupsPath, "backup.json")
+	err = os.WriteFile(backupFilePath, backupContent, os.ModePerm)
+	assert.NoError(t, err)
+	_, resp, err := httpdtest.Loaddata(backupFilePath, "1", "2", http.StatusOK)
+	assert.NoError(t, err, string(resp))
+	// Check that actions are migrated.
+	action1, _, err := httpdtest.GetEventActionByName(a1.Name, http.StatusOK)
+	assert.NoError(t, err)
+	action2, _, err := httpdtest.GetEventActionByName(a2.Name, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Equal(t, `Failed "{{.Event}}" from "{{.Name}}"`, action1.Options.EmailConfig.Subject)
+	assert.Equal(t, `Object name: {{.ObjectName}} object type: {{.ObjectType}}, IP: {{.IP}}`, action1.Options.EmailConfig.Body)
+	assert.Equal(t, `/{{.VirtualDirPath}}/{{.ObjectName}}`, action2.Options.FsConfig.Renames[0].Key)
+	assert.Equal(t, `/{{.ObjectName}}_renamed`, action2.Options.FsConfig.Renames[0].Value)
+	// If we restore a backup from the current version actions are not migrated.
+	backupData = dataprovider.BackupData{
+		EventActions: []dataprovider.BaseEventAction{a1, a2},
+		Version:      dataprovider.DumpVersion,
+	}
+	backupContent, err = json.Marshal(backupData)
+	assert.NoError(t, err)
+	backupFilePath = filepath.Join(backupsPath, "backup.json")
+	err = os.WriteFile(backupFilePath, backupContent, os.ModePerm)
+	assert.NoError(t, err)
+	_, resp, err = httpdtest.Loaddata(backupFilePath, "1", "2", http.StatusOK)
+	assert.NoError(t, err, string(resp))
+	action1, _, err = httpdtest.GetEventActionByName(a1.Name, http.StatusOK)
+	assert.NoError(t, err)
+	action2, _, err = httpdtest.GetEventActionByName(a2.Name, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Equal(t, `Failed "{{Event}}" from "{{Name}}"`, action1.Options.EmailConfig.Subject)
+	assert.Equal(t, `Object name: {{ObjectName}} object type: {{ObjectType}}, IP: {{IP}}`, action1.Options.EmailConfig.Body)
+	assert.Equal(t, `/{{VirtualDirPath}}/{{ObjectName}}`, action2.Options.FsConfig.Renames[0].Key)
+	assert.Equal(t, `/{{ObjectName}}_renamed`, action2.Options.FsConfig.Renames[0].Value)
+	// Cleanup.
+	_, err = httpdtest.RemoveEventAction(action1, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveEventAction(action2, http.StatusOK)
+	assert.NoError(t, err)
+	actions, _, err := httpdtest.GetEventActions(0, 0, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Len(t, actions, 0)
+}
+
 func TestLoaddataMode(t *testing.T) {
 	err := dataprovider.UpdateConfigs(nil, "", "", "")
 	assert.NoError(t, err)
@@ -8665,7 +8810,7 @@ func TestLoaddataMode(t *testing.T) {
 				Timeout:       10,
 				SkipTLSVerify: true,
 				Method:        http.MethodPost,
-				Body:          `{"event":"{{Event}}","name":"{{Name}}"}`,
+				Body:          `{"event":"{{.Event}}","name":"{{.Name}}"}`,
 			},
 		},
 	}
@@ -23831,7 +23976,7 @@ func TestWebEventAction(t *testing.T) {
 						Value: "value1",
 					},
 				},
-				Body: `{"event":"{{Event}}","name":"{{Name}}"}`,
+				Body: `{"event":"{{.Event}}","name":"{{.Name}}"}`,
 			},
 		},
 	}
@@ -23950,12 +24095,12 @@ func TestWebEventAction(t *testing.T) {
 	form.Del("http_headers[0][http_header_key]")
 	form.Del("http_headers[0][http_header_val]")
 	form.Set("multipart_body[0][http_part_name]", "part1")
-	form.Set("multipart_body[0][http_part_file]", "{{VirtualPath}}")
+	form.Set("multipart_body[0][http_part_file]", "{{.VirtualPath}}")
 	form.Set("multipart_body[0][http_part_body]", "")
 	form.Set("multipart_body[0][http_part_headers]", "X-MyHeader: a:b,c")
 	form.Set("multipart_body[12][http_part_name]", "part2")
 	form.Set("multipart_body[12][http_part_headers]", "Content-Type:application/json \r\n")
-	form.Set("multipart_body[12][http_part_body]", "{{ObjectData}}")
+	form.Set("multipart_body[12][http_part_body]", "{{.ObjectData}}")
 	req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, action.Name),
 		bytes.NewBuffer([]byte(form.Encode())))
 	assert.NoError(t, err)
@@ -23972,12 +24117,12 @@ func TestWebEventAction(t *testing.T) {
 	assert.Equal(t, 0, dbAction.Options.HTTPConfig.Timeout)
 	if assert.Len(t, dbAction.Options.HTTPConfig.Parts, 2) {
 		assert.Equal(t, "part1", dbAction.Options.HTTPConfig.Parts[0].Name)
-		assert.Equal(t, "/{{VirtualPath}}", dbAction.Options.HTTPConfig.Parts[0].Filepath)
+		assert.Equal(t, "/{{.VirtualPath}}", dbAction.Options.HTTPConfig.Parts[0].Filepath)
 		assert.Empty(t, dbAction.Options.HTTPConfig.Parts[0].Body)
 		assert.Equal(t, "X-MyHeader", dbAction.Options.HTTPConfig.Parts[0].Headers[0].Key)
 		assert.Equal(t, "a:b,c", dbAction.Options.HTTPConfig.Parts[0].Headers[0].Value)
 		assert.Equal(t, "part2", dbAction.Options.HTTPConfig.Parts[1].Name)
-		assert.Equal(t, "{{ObjectData}}", dbAction.Options.HTTPConfig.Parts[1].Body)
+		assert.Equal(t, "{{.ObjectData}}", dbAction.Options.HTTPConfig.Parts[1].Body)
 		assert.Empty(t, dbAction.Options.HTTPConfig.Parts[1].Filepath)
 		assert.Equal(t, "Content-Type", dbAction.Options.HTTPConfig.Parts[1].Headers[0].Key)
 		assert.Equal(t, "application/json", dbAction.Options.HTTPConfig.Parts[1].Headers[0].Value)

+ 4 - 4
internal/httpd/oidc_test.go

@@ -1182,18 +1182,18 @@ func TestOIDCEvMgrIntegration(t *testing.T) {
 	// add a special chars to check json replacer
 	username := `test_"oidc_eventmanager`
 	u := map[string]any{
-		"username": "{{Name}}",
+		"username": "{{.Name}}",
 		"status":   1,
-		"home_dir": filepath.Join(os.TempDir(), "{{IDPFieldcustom1.sub}}"),
+		"home_dir": filepath.Join(os.TempDir(), "{{.IDPFieldcustom1.sub}}"),
 		"permissions": map[string][]string{
 			"/": {dataprovider.PermAny},
 		},
-		"description": "{{IDPFieldcustom2}}",
+		"description": "{{.IDPFieldcustom2}}",
 	}
 	userTmpl, err := json.Marshal(u)
 	require.NoError(t, err)
 	a := map[string]any{
-		"username":    "{{Name}}",
+		"username":    "{{.Name}}",
 		"status":      1,
 		"permissions": []string{dataprovider.PermAdminAny},
 	}

+ 37 - 37
templates/webadmin/eventaction.html

@@ -889,115 +889,115 @@ explicit grant from the SFTPGo Team ([email protected]).
             </div>
             <div class="modal-body fs-5 fw-semibold">
                 <p>
-                    <span class="shortcut">{{`{{Name}}`}}</span> => <span data-i18n="actions.placeholders_modal.name">Username, folder name, admin username for provider events, domain name for certificate events.</span>
+                    <span class="shortcut">{{`{{.Name}}`}}</span> => <span data-i18n="actions.placeholders_modal.name">Username, folder name, admin username for provider events, domain name for certificate events.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{Event}}`}}</span> => <span data-i18n="actions.placeholders_modal.event">Event name, for example "upload", "download" for filesystem events or "add", "update" for provider events.</span>
+                    <span class="shortcut">{{`{{.Event}}`}}</span> => <span data-i18n="actions.placeholders_modal.event">Event name, for example "upload", "download" for filesystem events or "add", "update" for provider events.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{Status}}`}}</span> => <span data-i18n="actions.placeholders_modal.status">Status for "upload", "download" and "ssh_cmd" events. 1 means no error, 2 means a generic error occurred, 3 means quota exceeded error.</span>
+                    <span class="shortcut">{{`{{.Status}}`}}</span> => <span data-i18n="actions.placeholders_modal.status">Status for "upload", "download" and "ssh_cmd" events. 1 means no error, 2 means a generic error occurred, 3 means quota exceeded error.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{StatusString}}`}}</span> => <span data-i18n="actions.placeholders_modal.status_string">Status as string. Possible values "OK", "KO".</span>
+                    <span class="shortcut">{{`{{.StatusString}}`}}</span> => <span data-i18n="actions.placeholders_modal.status_string">Status as string. Possible values "OK", "KO".</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{ErrorString}}`}}</span> => <span data-i18n="actions.placeholders_modal.error_string">Error details. Replaced with an empty string if no errors occur.</span>
+                    <span class="shortcut">{{`{{.ErrorString}}`}}</span> => <span data-i18n="actions.placeholders_modal.error_string">Error details. Replaced with an empty string if no errors occur.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{VirtualPath}}`}}</span> => <span data-i18n="actions.placeholders_modal.virtual_path">Path seen by SFTPGo users, for example "/adir/afile.txt".</span>
+                    <span class="shortcut">{{`{{.VirtualPath}}`}}</span> => <span data-i18n="actions.placeholders_modal.virtual_path">Path seen by SFTPGo users, for example "/adir/afile.txt".</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{EscapedVirtualPath}}`}}</span> => <span data-i18n="actions.placeholders_modal.escaped_virtual_path">HTTP query string encoded path, for example "%2Fadir%2Fafile.txt".</span>
+                    <span class="shortcut">{{`{{.EscapedVirtualPath}}`}}</span> => <span data-i18n="actions.placeholders_modal.escaped_virtual_path">HTTP query string encoded path, for example "%2Fadir%2Fafile.txt".</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{VirtualDirPath}}`}}</span> => <span data-i18n="actions.placeholders_modal.virtual_dir_path">Parent directory for VirtualPath, for example if VirtualPath is "/adir/afile.txt", VirtualDirPath is "/adir".</span>
+                    <span class="shortcut">{{`{{.VirtualDirPath}}`}}</span> => <span data-i18n="actions.placeholders_modal.virtual_dir_path">Parent directory for VirtualPath, for example if VirtualPath is "/adir/afile.txt", VirtualDirPath is "/adir".</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{FsPath}}`}}</span> => <span data-i18n="actions.placeholders_modal.fs_path">Full filesystem path, for example "/user/homedir/adir/afile.txt" or "C:/data/user/homedir/adir/afile.txt" on Windows.</span>
+                    <span class="shortcut">{{`{{.FsPath}}`}}</span> => <span data-i18n="actions.placeholders_modal.fs_path">Full filesystem path, for example "/user/homedir/adir/afile.txt" or "C:/data/user/homedir/adir/afile.txt" on Windows.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{Ext}}`}}</span> => <span data-i18n="actions.placeholders_modal.ext">File extension, for example ".txt" if the filename is "afile.txt".</span>
+                    <span class="shortcut">{{`{{.Ext}}`}}</span> => <span data-i18n="actions.placeholders_modal.ext">File extension, for example ".txt" if the filename is "afile.txt".</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{ObjectName}}`}}</span> => <span data-i18n="actions.placeholders_modal.object_name">File/directory name, for example "afile.txt" or provider object name.</span>
+                    <span class="shortcut">{{`{{.ObjectName}}`}}</span> => <span data-i18n="actions.placeholders_modal.object_name">File/directory name, for example "afile.txt" or provider object name.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{ObjectBaseName}}`}}</span> => <span data-i18n="actions.placeholders_modal.object_basename">Filename without extension, for example "afile" if the filename is "afile.txt".</span>
+                    <span class="shortcut">{{`{{.ObjectBaseName}}`}}</span> => <span data-i18n="actions.placeholders_modal.object_basename">Filename without extension, for example "afile" if the filename is "afile.txt".</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{ObjectType}}`}}</span> => <span data-i18n="actions.placeholders_modal.object_type">Object type for provider events: "user", "group", "admin", etc.</span>
+                    <span class="shortcut">{{`{{.ObjectType}}`}}</span> => <span data-i18n="actions.placeholders_modal.object_type">Object type for provider events: "user", "group", "admin", etc.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{VirtualTargetPath}}`}}</span> => <span data-i18n="actions.placeholders_modal.virtual_target_path">Virtual target path for renames.</span>
+                    <span class="shortcut">{{`{{.VirtualTargetPath}}`}}</span> => <span data-i18n="actions.placeholders_modal.virtual_target_path">Virtual target path for renames.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{VirtualTargetDirPath}}`}}</span> => <span data-i18n="actions.placeholders_modal.virtual_target_dir_path">Parent directory for VirtualTargetPath.</span>
+                    <span class="shortcut">{{`{{.VirtualTargetDirPath}}`}}</span> => <span data-i18n="actions.placeholders_modal.virtual_target_dir_path">Parent directory for VirtualTargetPath.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{TargetName}}`}}</span> => <span data-i18n="actions.placeholders_modal.target_name">Target object name for renames.</span>
+                    <span class="shortcut">{{`{{.TargetName}}`}}</span> => <span data-i18n="actions.placeholders_modal.target_name">Target object name for renames.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{FsTargetPath}}`}}</span> => <span data-i18n="actions.placeholders_modal.fs_target_path">Full filesystem target path for renames.</span>
+                    <span class="shortcut">{{`{{.FsTargetPath}}`}}</span> => <span data-i18n="actions.placeholders_modal.fs_target_path">Full filesystem target path for renames.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{FileSize}}`}}</span> => <span data-i18n="actions.placeholders_modal.file_size">File size.</span>
+                    <span class="shortcut">{{`{{.FileSize}}`}}</span> => <span data-i18n="actions.placeholders_modal.file_size">File size.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{Elapsed}}`}}</span> => <span data-i18n="actions.placeholders_modal.elapsed">Elapsed time as milliseconds for filesystem events.</span>
+                    <span class="shortcut">{{`{{.Elapsed}}`}}</span> => <span data-i18n="actions.placeholders_modal.elapsed">Elapsed time as milliseconds for filesystem events.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{Protocol}}`}}</span> => <span data-i18n="actions.placeholders_modal.protocol">Protocol, for example "SFTP", "FTP".</span>
+                    <span class="shortcut">{{`{{.Protocol}}`}}</span> => <span data-i18n="actions.placeholders_modal.protocol">Protocol, for example "SFTP", "FTP".</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{IP}}`}}</span> => <span data-i18n="actions.placeholders_modal.ip">Client IP address.</span>
+                    <span class="shortcut">{{`{{.IP}}`}}</span> => <span data-i18n="actions.placeholders_modal.ip">Client IP address.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{Role}}`}}</span> => <span data-i18n="actions.placeholders_modal.role">User or admin role.</span>
+                    <span class="shortcut">{{`{{.Role}}`}}</span> => <span data-i18n="actions.placeholders_modal.role">User or admin role.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{Timestamp}}`}}</span> => <span data-i18n="actions.placeholders_modal.timestamp">Event timestamp as nanoseconds since epoch.</span>
+                    <span class="shortcut">{{`{{.Timestamp}}`}}</span> => <span data-i18n="actions.placeholders_modal.timestamp">Event timestamp as nanoseconds since epoch.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{DateTime}}`}}</span> => <span data-i18n="actions.placeholders_modal.datetime">Timestamp formatted as YYYY-MM-DDTHH:MM:SS.ZZZ.</span>
+                    <span class="shortcut">{{`{{.DateTime}}`}}</span> => <span data-i18n="actions.placeholders_modal.datetime">Timestamp formatted as YYYY-MM-DDTHH:MM:SS.ZZZ.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{Year}}`}}</span> => <span data-i18n="actions.placeholders_modal.year">Event year formatted as four digits.</span>
+                    <span class="shortcut">{{`{{.Year}}`}}</span> => <span data-i18n="actions.placeholders_modal.year">Event year formatted as four digits.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{Month}}`}}</span> => <span data-i18n="actions.placeholders_modal.month">Event month formatted as two digits.</span>
+                    <span class="shortcut">{{`{{.Month}}`}}</span> => <span data-i18n="actions.placeholders_modal.month">Event month formatted as two digits.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{Day}}`}}</span> => <span data-i18n="actions.placeholders_modal.day">Event day formatted as two digits.</span>
+                    <span class="shortcut">{{`{{.Day}}`}}</span> => <span data-i18n="actions.placeholders_modal.day">Event day formatted as two digits.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{Hour}}`}}</span> => <span data-i18n="actions.placeholders_modal.hour">Event hour formatted as two digits.</span>
+                    <span class="shortcut">{{`{{.Hour}}`}}</span> => <span data-i18n="actions.placeholders_modal.hour">Event hour formatted as two digits.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{Minute}}`}}</span> => <span data-i18n="actions.placeholders_modal.minute">Event minute formatted as two digits.</span>
+                    <span class="shortcut">{{`{{.Minute}}`}}</span> => <span data-i18n="actions.placeholders_modal.minute">Event minute formatted as two digits.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{Email}}`}}</span> => <span data-i18n="actions.placeholders_modal.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.</span>
+                    <span class="shortcut">{{`{{.Email}}`}}</span> => <span data-i18n="actions.placeholders_modal.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.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{ObjectData}}`}}</span> => <span data-i18n="actions.placeholders_modal.object_data">Provider object data serialized as JSON with sensitive fields removed.</span>
+                    <span class="shortcut">{{`{{.ObjectData}}`}}</span> => <span data-i18n="actions.placeholders_modal.object_data">Provider object data serialized as JSON with sensitive fields removed.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{ObjectDataString}}`}}</span> => <span data-i18n="actions.placeholders_modal.object_data_string">Provider object data as JSON escaped string with sensitive fields removed.</span>
+                    <span class="shortcut">{{`{{.ObjectDataString}}`}}</span> => <span data-i18n="actions.placeholders_modal.object_data_string">Provider object data as JSON escaped string with sensitive fields removed.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{RetentionReports}}`}}</span> => <span data-i18n="actions.placeholders_modal.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.</span>
+                    <span class="shortcut">{{`{{.RetentionReports}}`}}</span> => <span data-i18n="actions.placeholders_modal.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.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{IDPField<fieldname>}}`}}</span> => <span data-i18n="actions.placeholders_modal.idp_field">Identity Provider custom fields containing a string.</span>
+                    <span class="shortcut">{{`{{.IDPField<fieldname>}}`}}</span> => <span data-i18n="actions.placeholders_modal.idp_field">Identity Provider custom fields containing a string.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{Metadata}}`}}</span> => <span data-i18n="actions.placeholders_modal.metadata">Cloud storage metadata for the downloaded file serialized as JSON.</span>
+                    <span class="shortcut">{{`{{.Metadata}}`}}</span> => <span data-i18n="actions.placeholders_modal.metadata">Cloud storage metadata for the downloaded file serialized as JSON.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{MetadataString}}`}}</span> => <span data-i18n="actions.placeholders_modal.metadata_string">Cloud storage metadata for the downloaded file as JSON escaped string.</span>
+                    <span class="shortcut">{{`{{.MetadataString}}`}}</span> => <span data-i18n="actions.placeholders_modal.metadata_string">Cloud storage metadata for the downloaded file as JSON escaped string.</span>
                 </p>
                 <p>
-                    <span class="shortcut">{{`{{UID}}`}}</span> => <span data-i18n="actions.placeholders_modal.uid">Unique ID.</span>
+                    <span class="shortcut">{{`{{.UID}}`}}</span> => <span data-i18n="actions.placeholders_modal.uid">Unique ID.</span>
                 </p>
             </div>
             <div class="modal-footer">