Browse Source

eventmanager: add support for pre-* actions

Signed-off-by: Nicola Murino <[email protected]>
Nicola Murino 2 years ago
parent
commit
2611dd2c98

+ 2 - 2
docs/custom-actions.md

@@ -28,9 +28,9 @@ For cloud backends directories are virtual, they are created implicitly when you
 
 The notification will indicate if an error is detected and so, for example, a partial file is uploaded.
 
-The `pre-delete` action, if defined, will be called just before files deletion. If the external command completes with a zero exit status or the HTTP notification response code is `200` then SFTPGo will assume that the file was already deleted/moved and so it will not try to remove the file and it will not execute the hook defined for the `delete` action.
+The `pre-delete` action, if defined, will be called just before files deletion. If the external command completes with a zero exit status or the HTTP notification response code is `200`, SFTPGo will assume that the file was already deleted/moved and so it will not try to remove the file and it will not execute the hook defined for the `delete` action.
 
-The `pre-download` and `pre-upload` actions, will be called before downloads and uploads. If the external command completes with a zero exit status or the HTTP notification response code is `200` then SFTPGo allows the operation, otherwise the client will get a permission denied error.
+The `pre-download` and `pre-upload` actions, will be called before downloads and uploads. If the external command completes with a zero exit status or the HTTP notification response code is `200`, SFTPGo will allow the operation, otherwise the client will get a permission denied error.
 
 If the `hook` defines a path to an external program, then this program can read the following environment variables:
 

+ 1 - 1
docs/eventmanager.md

@@ -63,7 +63,7 @@ Actions are executed in a sequential order except for sync actions that are exec
 
 - `Stop on failure`, the next action will not be executed if the current one fails.
 - `Failure action`, this action will be executed only if at least another one fails. :warning: Please note that a failure action isn't executed if the event fails, for example if a download fails the main action is executed. The failure action is executed only if one of the non-failure actions associated to a rule fails.
-- `Execute sync`, for upload events, you can execute the action synchronously. Executing an action synchronously means that SFTPGo will not return a result code to the client (which is waiting for it) until your action have completed its execution. If your acion takes a long time to complete this could cause a timeout on the client side, which wouldn't receive the server response in a timely manner and eventually drop the connection.
+- `Execute sync`, for upload events, you can execute the action(s) synchronously. Executing an action synchronously means that SFTPGo will not return a result code to the client (which is waiting for it) until your action have completed its execution. If your acion takes a long time to complete this could cause a timeout on the client side, which wouldn't receive the server response in a timely manner and eventually drop the connection. For pre-* events at least a sync action is required. If pre-delete sync action(s) completes successfully, SFTPGo will assume that the file was already deleted/moved and so it will not try to remove the file and it will not execute any defined `delete` actions. If pre-upload/download action(s) completes successfully, SFTPGo will allow the operation, otherwise the client will get a permission denied error.
 
 If you are running multiple SFTPGo instances connected to the same data provider, you can choose whether to allow simultaneous execution for scheduled actions.
 

+ 1 - 1
go.mod

@@ -52,7 +52,7 @@ require (
 	github.com/rs/xid v1.4.0
 	github.com/rs/zerolog v1.28.0
 	github.com/sftpgo/sdk v0.1.3-0.20221217110036-383c1bb50fa0
-	github.com/shirou/gopsutil/v3 v3.22.11
+	github.com/shirou/gopsutil/v3 v3.22.12
 	github.com/spf13/afero v1.9.3
 	github.com/spf13/cobra v1.6.1
 	github.com/spf13/viper v1.14.0

+ 2 - 2
go.sum

@@ -1453,8 +1453,8 @@ github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
 github.com/sftpgo/sdk v0.1.3-0.20221217110036-383c1bb50fa0 h1:e1OQroqX8SWV06Z270CxG2/v//Wx1026iXKTDRn5J1E=
 github.com/sftpgo/sdk v0.1.3-0.20221217110036-383c1bb50fa0/go.mod h1:3GpW3Qy8IHH6kex0ny+Y6ayeYb9OJxz8Pxh3IZgAs2E=
-github.com/shirou/gopsutil/v3 v3.22.11 h1:kxsPKS+Eeo+VnEQ2XCaGJepeP6KY53QoRTETx3+1ndM=
-github.com/shirou/gopsutil/v3 v3.22.11/go.mod h1:xl0EeL4vXJ+hQMAGN8B9VFpxukEMA0XdevQOe5MZ1oY=
+github.com/shirou/gopsutil/v3 v3.22.12 h1:oG0ns6poeUSxf78JtOsfygNWuEHYYz8hnnNg7P04TJs=
+github.com/shirou/gopsutil/v3 v3.22.12/go.mod h1:Xd7P1kwZcp5VW52+9XsirIKd/BROzbb2wdX3Kqlz9uI=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=

+ 37 - 14
internal/common/actions.go

@@ -95,7 +95,8 @@ func ExecutePreAction(conn *BaseConnection, operation, filePath, virtualPath str
 	var event *notifier.FsEvent
 	hasNotifiersPlugin := plugin.Handler.HasNotifiers()
 	hasHook := util.Contains(Config.Actions.ExecuteOn, operation)
-	if !hasHook && !hasNotifiersPlugin {
+	hasRules := eventManager.hasFsRules()
+	if !hasHook && !hasNotifiersPlugin && !hasRules {
 		return handleUnconfiguredPreAction(operation)
 	}
 	event = newActionNotification(&conn.User, operation, filePath, virtualPath, "", "", "",
@@ -103,6 +104,29 @@ func ExecutePreAction(conn *BaseConnection, operation, filePath, virtualPath str
 	if hasNotifiersPlugin {
 		plugin.Handler.NotifyFsEvent(event)
 	}
+	if hasRules {
+		params := EventParams{
+			Name:              event.Username,
+			Groups:            conn.User.Groups,
+			Event:             event.Action,
+			Status:            event.Status,
+			VirtualPath:       event.VirtualPath,
+			FsPath:            event.Path,
+			VirtualTargetPath: event.VirtualTargetPath,
+			FsTargetPath:      event.TargetPath,
+			ObjectName:        path.Base(event.VirtualPath),
+			FileSize:          event.FileSize,
+			Protocol:          event.Protocol,
+			IP:                event.IP,
+			Role:              event.Role,
+			Timestamp:         event.Timestamp,
+			Object:            nil,
+		}
+		executedSync, err := eventManager.handleFsEvent(params)
+		if executedSync {
+			return err
+		}
+	}
 	if !hasHook {
 		return handleUnconfiguredPreAction(operation)
 	}
@@ -124,7 +148,6 @@ func ExecuteActionNotification(conn *BaseConnection, operation, filePath, virtua
 	if hasNotifiersPlugin {
 		plugin.Handler.NotifyFsEvent(notification)
 	}
-	var errRes error
 	if hasRules {
 		params := EventParams{
 			Name:              notification.Username,
@@ -146,23 +169,23 @@ func ExecuteActionNotification(conn *BaseConnection, operation, filePath, virtua
 		if err != nil {
 			params.AddError(fmt.Errorf("%q failed: %w", params.Event, err))
 		}
-		errRes = eventManager.handleFsEvent(params)
+		executedSync, err := eventManager.handleFsEvent(params)
+		if executedSync {
+			return err
+		}
 	}
 	if hasHook {
 		if util.Contains(Config.Actions.ExecuteSync, operation) {
-			if errHook := actionHandler.Handle(notification); errHook != nil {
-				errRes = errHook
-			}
-		} else {
-			go func() {
-				startNewHook()
-				defer hookEnded()
-
-				actionHandler.Handle(notification) //nolint:errcheck
-			}()
+			return actionHandler.Handle(notification)
 		}
+		go func() {
+			startNewHook()
+			defer hookEnded()
+
+			actionHandler.Handle(notification) //nolint:errcheck
+		}()
 	}
-	return errRes
+	return nil
 }
 
 // ActionHandler handles a notification for a Protocol Action.

+ 10 - 8
internal/common/eventmanager.go

@@ -315,10 +315,11 @@ func (r *eventRulesContainer) hasFsRules() bool {
 	return len(r.FsEvents) > 0
 }
 
-// handleFsEvent executes the rules actions defined for the specified event
-func (r *eventRulesContainer) handleFsEvent(params EventParams) error {
+// handleFsEvent executes the rules actions defined for the specified event.
+// The boolean parameter indicates whether a sync action was executed
+func (r *eventRulesContainer) handleFsEvent(params EventParams) (bool, error) {
 	if params.Protocol == protocolEventAction {
-		return nil
+		return false, nil
 	}
 	r.RLock()
 
@@ -353,9 +354,9 @@ func (r *eventRulesContainer) handleFsEvent(params EventParams) error {
 	}
 
 	if len(rulesWithSyncActions) > 0 {
-		return executeSyncRulesActions(rulesWithSyncActions, params)
+		return true, executeSyncRulesActions(rulesWithSyncActions, params)
 	}
-	return nil
+	return false, nil
 }
 
 // username is populated for user objects
@@ -1312,10 +1313,11 @@ func getUserForEventAction(user dataprovider.User) (dataprovider.User, error) {
 }
 
 func replacePathsPlaceholders(paths []string, replacer *strings.Replacer) []string {
-	for idx := range paths {
-		paths[idx] = util.CleanPath(replaceWithReplacer(paths[idx], replacer))
+	results := make([]string, 0, len(paths))
+	for _, p := range paths {
+		results = append(results, util.CleanPath(replaceWithReplacer(p, replacer)))
 	}
-	return util.RemoveDuplicates(paths, false)
+	return util.RemoveDuplicates(results, false)
 }
 
 func executeDeleteFileFsAction(conn *BaseConnection, item string, info os.FileInfo) error {

+ 207 - 0
internal/common/protocol_test.go

@@ -4195,6 +4195,213 @@ func TestEventRuleFsActions(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestEventRulePreDelete(t *testing.T) {
+	movePath := "recycle bin"
+	a1 := dataprovider.BaseEventAction{
+		Name: "a1",
+		Type: dataprovider.ActionTypeFilesystem,
+		Options: dataprovider.BaseEventActionOptions{
+			FsConfig: dataprovider.EventActionFilesystemConfig{
+				Type:   dataprovider.FilesystemActionMkdirs,
+				MkDirs: []string{fmt.Sprintf("/%s/{{VirtualDirPath}}", movePath)},
+			},
+		},
+	}
+	action1, resp, err := httpdtest.AddEventAction(a1, http.StatusCreated)
+	assert.NoError(t, err, string(resp))
+	a2 := dataprovider.BaseEventAction{
+		Name: "a2",
+		Type: dataprovider.ActionTypeFilesystem,
+		Options: dataprovider.BaseEventActionOptions{
+			FsConfig: dataprovider.EventActionFilesystemConfig{
+				Type: dataprovider.FilesystemActionRename,
+				Renames: []dataprovider.KeyValue{
+					{
+						Key:   "/{{VirtualPath}}",
+						Value: fmt.Sprintf("/%s/{{VirtualPath}}", movePath),
+					},
+				},
+			},
+		},
+	}
+	action2, resp, err := httpdtest.AddEventAction(a2, http.StatusCreated)
+	assert.NoError(t, err, string(resp))
+	r1 := dataprovider.EventRule{
+		Name:    "rule1",
+		Trigger: dataprovider.EventTriggerFsEvent,
+		Conditions: dataprovider.EventConditions{
+			FsEvents: []string{"pre-delete"},
+		},
+		Actions: []dataprovider.EventAction{
+			{
+				BaseEventAction: dataprovider.BaseEventAction{
+					Name: action1.Name,
+				},
+				Order: 1,
+				Options: dataprovider.EventActionOptions{
+					ExecuteSync: true,
+				},
+			},
+			{
+				BaseEventAction: dataprovider.BaseEventAction{
+					Name: action2.Name,
+				},
+				Order: 2,
+				Options: dataprovider.EventActionOptions{
+					ExecuteSync: true,
+				},
+			},
+		},
+	}
+	rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated)
+	assert.NoError(t, err)
+	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+	assert.NoError(t, err)
+	conn, client, err := getSftpClient(user)
+	if assert.NoError(t, err) {
+		defer conn.Close()
+		defer client.Close()
+
+		testDir := "sub dir"
+		err = client.MkdirAll(testDir)
+		assert.NoError(t, err)
+		err = writeSFTPFile(testFileName, 100, client)
+		assert.NoError(t, err)
+		err = writeSFTPFile(path.Join(testDir, testFileName), 100, client)
+		assert.NoError(t, err)
+		err = client.Remove(testFileName)
+		assert.NoError(t, err)
+		err = client.Remove(path.Join(testDir, testFileName))
+		assert.NoError(t, err)
+		// check
+		_, err = client.Stat(testFileName)
+		assert.ErrorIs(t, err, os.ErrNotExist)
+		_, err = client.Stat(path.Join(testDir, testFileName))
+		assert.ErrorIs(t, err, os.ErrNotExist)
+		_, err = client.Stat(path.Join("/", movePath, testFileName))
+		assert.NoError(t, err)
+		_, err = client.Stat(path.Join("/", movePath, testDir, testFileName))
+		assert.NoError(t, err)
+	}
+
+	_, err = httpdtest.RemoveEventRule(rule1, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveEventAction(action1, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveEventAction(action2, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
+func TestEventRulePreDownloadUpload(t *testing.T) {
+	testDir := "/d"
+	a1 := dataprovider.BaseEventAction{
+		Name: "a1",
+		Type: dataprovider.ActionTypeFilesystem,
+		Options: dataprovider.BaseEventActionOptions{
+			FsConfig: dataprovider.EventActionFilesystemConfig{
+				Type:   dataprovider.FilesystemActionMkdirs,
+				MkDirs: []string{testDir},
+			},
+		},
+	}
+	action1, resp, err := httpdtest.AddEventAction(a1, http.StatusCreated)
+	assert.NoError(t, err, string(resp))
+	a2 := dataprovider.BaseEventAction{
+		Name: "a2",
+		Type: dataprovider.ActionTypeFilesystem,
+		Options: dataprovider.BaseEventActionOptions{
+			FsConfig: dataprovider.EventActionFilesystemConfig{
+				Type: dataprovider.FilesystemActionRename,
+				Renames: []dataprovider.KeyValue{
+					{
+						Key:   "/missing source",
+						Value: "/missing target",
+					},
+				},
+			},
+		},
+	}
+	action2, resp, err := httpdtest.AddEventAction(a2, http.StatusCreated)
+	assert.NoError(t, err, string(resp))
+	r1 := dataprovider.EventRule{
+		Name:    "rule1",
+		Trigger: dataprovider.EventTriggerFsEvent,
+		Conditions: dataprovider.EventConditions{
+			FsEvents: []string{"pre-download", "pre-upload"},
+		},
+		Actions: []dataprovider.EventAction{
+			{
+				BaseEventAction: dataprovider.BaseEventAction{
+					Name: action1.Name,
+				},
+				Order: 1,
+				Options: dataprovider.EventActionOptions{
+					ExecuteSync: true,
+				},
+			},
+		},
+	}
+	rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated)
+	assert.NoError(t, err)
+	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+	assert.NoError(t, err)
+	conn, client, err := getSftpClient(user)
+	if assert.NoError(t, err) {
+		defer conn.Close()
+		defer client.Close()
+		// the rule will always succeed, so uploads/downloads will work
+		err = writeSFTPFile(testFileName, 100, client)
+		assert.NoError(t, err)
+		_, err = client.Stat(testDir)
+		assert.NoError(t, err)
+		err = client.RemoveDirectory(testDir)
+		assert.NoError(t, err)
+		f, err := client.Open(testFileName)
+		assert.NoError(t, err)
+		contents := make([]byte, 100)
+		n, err := io.ReadFull(f, contents)
+		assert.NoError(t, err)
+		assert.Equal(t, int(100), n)
+		err = f.Close()
+		assert.NoError(t, err)
+		// now update the rule so that it will always fail
+		rule1.Actions = []dataprovider.EventAction{
+			{
+				BaseEventAction: dataprovider.BaseEventAction{
+					Name: action2.Name,
+				},
+				Order: 1,
+				Options: dataprovider.EventActionOptions{
+					ExecuteSync: true,
+				},
+			},
+		}
+		_, _, err = httpdtest.UpdateEventRule(rule1, http.StatusOK)
+		assert.NoError(t, err)
+		_, err = client.Open(testFileName)
+		assert.ErrorIs(t, err, os.ErrPermission)
+		err = client.Remove(testFileName)
+		assert.NoError(t, err)
+		err = writeSFTPFile(testFileName, 100, client)
+		assert.ErrorIs(t, err, os.ErrPermission)
+	}
+
+	_, err = httpdtest.RemoveEventRule(rule1, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveEventAction(action1, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveEventAction(action2, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestFsActionCopy(t *testing.T) {
 	a1 := dataprovider.BaseEventAction{
 		Name: "a1",

+ 31 - 5
internal/dataprovider/eventrule.go

@@ -164,8 +164,8 @@ func getFsActionTypeAsString(value int) string {
 // TODO: replace the copied strings with shared constants
 var (
 	// SupportedFsEvents defines the supported filesystem events
-	SupportedFsEvents = []string{"upload", "first-upload", "download", "first-download", "delete", "rename",
-		"mkdir", "rmdir", "copy", "ssh_cmd"}
+	SupportedFsEvents = []string{"upload", "pre-upload", "first-upload", "download", "pre-download",
+		"first-download", "delete", "pre-delete", "rename", "mkdir", "rmdir", "copy", "ssh_cmd"}
 	// SupportedProviderEvents defines the supported provider events
 	SupportedProviderEvents = []string{operationAdd, operationUpdate, operationDelete}
 	// SupportedRuleConditionProtocols defines the supported protcols for rule conditions
@@ -176,6 +176,8 @@ var (
 		actionObjectAdmin, actionObjectAPIKey, actionObjectShare, actionObjectEventRule, actionObjectEventAction}
 	// SupportedHTTPActionMethods defines the supported methods for HTTP actions
 	SupportedHTTPActionMethods = []string{http.MethodPost, http.MethodGet, http.MethodPut}
+	allowedSyncFsEvents        = []string{"upload", "pre-upload", "pre-download", "pre-delete"}
+	mandatorySyncFsEvents      = []string{"pre-upload", "pre-download", "pre-delete"}
 )
 
 // enum mappings
@@ -1076,9 +1078,14 @@ func (a *EventAction) validateAssociation(trigger int, fsEvents []string) error
 			return util.NewValidationError("sync execution is not supported for failure actions")
 		}
 	}
-	if trigger != EventTriggerFsEvent || !util.Contains(fsEvents, "upload") {
-		if a.Options.ExecuteSync {
-			return util.NewValidationError("sync execution is only supported for upload event")
+	if a.Options.ExecuteSync {
+		if trigger != EventTriggerFsEvent {
+			return util.NewValidationError("sync execution is only supported for some filesystem events")
+		}
+		for _, ev := range fsEvents {
+			if !util.Contains(allowedSyncFsEvents, ev) {
+				return util.NewValidationError("sync execution is only supported for upload and pre-* events")
+			}
 		}
 	}
 	return nil
@@ -1380,6 +1387,7 @@ func (r *EventRule) validate() error {
 	actionNames := make(map[string]bool)
 	actionOrders := make(map[int]bool)
 	failureActions := 0
+	hasSyncAction := false
 	for idx := range r.Actions {
 		if r.Actions[idx].Name == "" {
 			return util.NewValidationError(fmt.Sprintf("invalid action at position %d, name not specified", idx))
@@ -1397,12 +1405,30 @@ func (r *EventRule) validate() error {
 		if r.Actions[idx].Options.IsFailureAction {
 			failureActions++
 		}
+		if r.Actions[idx].Options.ExecuteSync {
+			hasSyncAction = true
+		}
 		actionNames[r.Actions[idx].Name] = true
 		actionOrders[r.Actions[idx].Order] = true
 	}
 	if len(r.Actions) == failureActions {
 		return util.NewValidationError("at least a non-failure action is required")
 	}
+	if !hasSyncAction {
+		return r.validateMandatorySyncActions()
+	}
+	return nil
+}
+
+func (r *EventRule) validateMandatorySyncActions() error {
+	if r.Trigger != EventTriggerFsEvent {
+		return nil
+	}
+	for _, ev := range r.Conditions.FsEvents {
+		if util.Contains(mandatorySyncFsEvents, ev) {
+			return util.NewValidationError(fmt.Sprintf("event %s requires at least a sync action", ev))
+		}
+	}
 	return nil
 }
 

+ 45 - 1
internal/httpd/httpd_test.go

@@ -2024,6 +2024,51 @@ func TestEventRuleValidation(t *testing.T) {
 	_, resp, err = httpdtest.AddEventRule(rule, http.StatusBadRequest)
 	assert.NoError(t, err)
 	assert.Contains(t, string(resp), "at least a non-failure action is required")
+	rule.Conditions.FsEvents = []string{"upload", "download"}
+	rule.Actions = []dataprovider.EventAction{
+		{
+			BaseEventAction: dataprovider.BaseEventAction{
+				Name: "action111",
+			},
+			Order: 1,
+			Options: dataprovider.EventActionOptions{
+				ExecuteSync: true,
+			},
+		},
+	}
+	_, resp, err = httpdtest.AddEventRule(rule, http.StatusBadRequest)
+	assert.NoError(t, err)
+	assert.Contains(t, string(resp), "sync execution is only supported for upload and pre-* events")
+	rule.Conditions.FsEvents = []string{"pre-upload", "download"}
+	rule.Actions = []dataprovider.EventAction{
+		{
+			BaseEventAction: dataprovider.BaseEventAction{
+				Name: "action",
+			},
+			Order: 1,
+			Options: dataprovider.EventActionOptions{
+				ExecuteSync: false,
+			},
+		},
+	}
+	_, resp, err = httpdtest.AddEventRule(rule, http.StatusBadRequest)
+	assert.NoError(t, err)
+	assert.Contains(t, string(resp), "event pre-upload requires at least a sync action")
+	rule.Actions = []dataprovider.EventAction{
+		{
+			BaseEventAction: dataprovider.BaseEventAction{
+				Name: "action",
+			},
+			Order: 1,
+			Options: dataprovider.EventActionOptions{
+				ExecuteSync: true,
+			},
+		},
+	}
+	_, resp, err = httpdtest.AddEventRule(rule, http.StatusBadRequest)
+	assert.NoError(t, err)
+	assert.Contains(t, string(resp), "sync execution is only supported for upload and pre-* events")
+	rule.Trigger = dataprovider.EventTriggerProviderEvent
 	rule.Actions = []dataprovider.EventAction{
 		{
 			BaseEventAction: dataprovider.BaseEventAction{
@@ -2035,7 +2080,6 @@ func TestEventRuleValidation(t *testing.T) {
 			},
 		},
 	}
-	rule.Trigger = dataprovider.EventTriggerProviderEvent
 	_, resp, err = httpdtest.AddEventRule(rule, http.StatusBadRequest)
 	assert.NoError(t, err)
 	assert.Contains(t, string(resp), "at least one provider event is required")

+ 1 - 1
templates/webadmin/admin.html

@@ -259,7 +259,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
 <script src="{{.StaticURL}}/vendor/bootstrap-select/js/bootstrap-select.min.js"></script>
 <script type="text/javascript">
     $("body").on("click", ".add_new_group_field_btn", function () {
-        var index = $(".form_field_groups_outer").find("form_field_groups_outer_row").length;
+        let index = $(".form_field_groups_outer").find(".form_field_groups_outer_row").length;
         while (document.getElementById("idGroup"+index) != null){
             index++;
         }

+ 7 - 7
templates/webadmin/eventaction.html

@@ -788,7 +788,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
 <script src="{{.StaticURL}}/vendor/bootstrap-select/js/bootstrap-select.min.js"></script>
 <script type="text/javascript">
     $("body").on("click", ".add_new_header_field_btn", function () {
-        var index = $(".form_field_http_headers_outer").find(".form_field_http_headers_outer_row").length;
+        let index = $(".form_field_http_headers_outer").find(".form_field_http_headers_outer_row").length;
         while (document.getElementById("idHTTPHeaderKey"+index) != null){
             index++;
         }
@@ -815,7 +815,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
     });
 
     $("body").on("click", ".add_new_query_field_btn", function () {
-        var index = $(".form_field_http_query_outer").find(".form_field_http_query_outer_row").length;
+        let index = $(".form_field_http_query_outer").find(".form_field_http_query_outer_row").length;
         while (document.getElementById("idHTTPQueryKey"+index) != null){
             index++;
         }
@@ -842,7 +842,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
     });
 
     $("body").on("click", ".add_new_cmd_env_field_btn", function () {
-        var index = $(".form_field_cmd_env_outer").find(".form_field_cmd_env_outer_row").length;
+        let index = $(".form_field_cmd_env_outer").find(".form_field_cmd_env_outer_row").length;
         while (document.getElementById("idCMDEnvKey"+index) != null){
             index++;
         }
@@ -869,7 +869,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
     });
 
     $("body").on("click", ".add_new_data_retention_field_btn", function () {
-        var index = $(".form_field_data_retention_outer").find(".form_field_data_retention_outer_row").length;
+        let index = $(".form_field_data_retention_outer").find(".form_field_data_retention_outer_row").length;
         while (document.getElementById("idFolderRetentionPath"+index) != null){
             index++;
         }
@@ -903,7 +903,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
     });
 
     $("body").on("click", ".add_new_fs_rename_field_btn", function () {
-        var index = $(".form_field_fs_rename_outer").find(".form_field_fs_rename_outer_row").length;
+        let index = $(".form_field_fs_rename_outer").find(".form_field_fs_rename_outer_row").length;
         while (document.getElementById("idFsRenameSource"+index) != null){
             index++;
         }
@@ -930,7 +930,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
     });
 
     $("body").on("click", ".add_new_fs_copy_field_btn", function () {
-        var index = $(".form_field_fs_copy_outer").find(".form_field_fs_copy_outer_row").length;
+        let index = $(".form_field_fs_copy_outer").find(".form_field_fs_copy_outer_row").length;
         while (document.getElementById("idFsCopySource"+index) != null){
             index++;
         }
@@ -957,7 +957,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
     });
 
     $("body").on("click", ".add_new_http_part_field_btn", function () {
-        var index = $(".form_field_http_part_outer").find(".form_field_http_part_outer_row").length;
+        let index = $(".form_field_http_part_outer").find(".form_field_http_part_outer_row").length;
         while (document.getElementById("idHTTPPartName"+index) != null){
             index++;
         }

+ 7 - 7
templates/webadmin/eventrule.html

@@ -425,7 +425,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                     <b>Actions</b>
                 </div>
                 <div class="card-body">
-                    <h6 class="card-title mb-4">One or more actions to execute. The "Execute sync" options is only supported for upload events</h6>
+                    <h6 class="card-title mb-4">One or more actions to execute. The "Execute sync" options is supported for upload events and required for pre-* events</h6>
                     <div class="form-group row">
                         <div class="col-md-12 form_field_action_outer">
                             {{range $idx, $val := .Rule.Actions}}
@@ -505,7 +505,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
 <script src="{{.StaticURL}}/vendor/bootstrap-select/js/bootstrap-select.min.js"></script>
 <script type="text/javascript">
     $("body").on("click", ".add_new_schedule_field_btn", function () {
-        var index = $(".form_field_schedules_outer").find(".form_field_schedules_outer_row").length;
+        let index = $(".form_field_schedules_outer").find(".form_field_schedules_outer_row").length;
         while (document.getElementById("idScheduleHour"+index) != null){
             index++;
         }
@@ -537,7 +537,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
     });
 
     $("body").on("click", ".add_new_name_pattern_field_btn", function () {
-        var index = $(".form_field_names_outer").find(".form_field_names_outer_row").length;
+        let index = $(".form_field_names_outer").find(".form_field_names_outer_row").length;
         while (document.getElementById("idNamePattern"+index) != null){
             index++;
         }
@@ -567,7 +567,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
     });
 
     $("body").on("click", ".add_new_group_name_pattern_field_btn", function () {
-        var index = $(".form_field_group_names_outer").find(".form_field_group_names_outer_row").length;
+        let index = $(".form_field_group_names_outer").find(".form_field_group_names_outer_row").length;
         while (document.getElementById("idGroupNamePattern"+index) != null){
             index++;
         }
@@ -597,7 +597,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
     });
 
     $("body").on("click", ".add_new_role_name_pattern_field_btn", function () {
-        var index = $(".form_field_role_names_outer").find(".form_field_role_names_outer_row").length;
+        let index = $(".form_field_role_names_outer").find(".form_field_role_names_outer_row").length;
         while (document.getElementById("idRoleNamePattern"+index) != null){
             index++;
         }
@@ -627,7 +627,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
     });
 
     $("body").on("click", ".add_new_fs_path_pattern_field_btn", function () {
-        var index = $(".form_field_fs_paths_outer").find("form_field_fs_paths_outer_row").length;
+        let index = $(".form_field_fs_paths_outer").find(".form_field_fs_paths_outer_row").length;
         while (document.getElementById("idFsPathPattern"+index) != null){
             index++;
         }
@@ -657,7 +657,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
     });
 
     $("body").on("click", ".add_new_action_field_btn", function () {
-        var index = $(".form_field_action_outer").find("form_field_action_outer_row").length;
+        let index = $(".form_field_action_outer").find(".form_field_action_outer_row").length;
         while (document.getElementById("idActionName"+index) != null){
             index++;
         }

+ 1 - 1
templates/webadmin/folder.html

@@ -117,7 +117,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
         onFilesystemChanged('{{.Folder.FsConfig.Provider.Name}}');
 
         $("body").on("click", ".add_new_tpl_folder_field_btn", function () {
-            var index = $(".form_field_tpl_folders_outer").find(".form_field_tpl_folder_outer_row").length;
+            let index = $(".form_field_tpl_folders_outer").find(".form_field_tpl_folder_outer_row").length;
             while (document.getElementById("idTplFolder"+index) != null){
                 index++;
             }

+ 5 - 5
templates/webadmin/sharedcomponents.html

@@ -16,7 +16,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
 {{define "shared_user_group"}}
 <script type="text/javascript">
     $("body").on("click", ".add_new_dirperms_field_btn", function () {
-        var index = $(".form_field_dirperms_outer").find(".form_field_dirperms_outer_row").length;
+        let index = $(".form_field_dirperms_outer").find(".form_field_dirperms_outer_row").length;
         while (document.getElementById("idSubDirPermsPath"+index) != null){
             index++;
         }
@@ -48,7 +48,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
     });
 
     $("body").on("click", ".add_new_vfolder_field_btn", function () {
-        var index = $(".form_field_vfolders_outer").find(".form_field_vfolder_outer_row").length;
+        let index = $(".form_field_vfolders_outer").find(".form_field_vfolder_outer_row").length;
         while (document.getElementById("idVolderPath" + index) != null) {
             index++;
         }
@@ -95,7 +95,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
     });
 
     $("body").on("click", ".add_new_bwlimit_field_btn", function () {
-        var index = $(".form_field_bwlimits_outer").find(".form_field_bwlimits_outer_row").length;
+        let index = $(".form_field_bwlimits_outer").find(".form_field_bwlimits_outer_row").length;
         while (document.getElementById("idBandwidthLimitSources"+index) != null){
             index++;
         }
@@ -138,7 +138,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
     });
 
     $("body").on("click", ".add_new_dtlimit_field_btn", function () {
-        var index = $(".form_field_dtlimits_outer").find(".form_field_dtlimits_outer_row").length;
+        let index = $(".form_field_dtlimits_outer").find(".form_field_dtlimits_outer_row").length;
         while (document.getElementById("idDataTransferLimitSources"+index) != null){
             index++;
         }
@@ -190,7 +190,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
     });
 
     $("body").on("click", ".add_new_pattern_field_btn", function () {
-        var index = $(".form_field_patterns_outer").find(".form_field_patterns_outer_row").length;
+        let index = $(".form_field_patterns_outer").find(".form_field_patterns_outer_row").length;
         while (document.getElementById("idPatternPath"+index) != null){
             index++;
         }

+ 2 - 2
templates/webadmin/user.html

@@ -1129,7 +1129,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
     });
 
     $("body").on("click", ".add_new_pk_field_btn", function () {
-        var index = $(".form_field_pk_outer").find(".form_field_pk_outer_row").length;
+        let index = $(".form_field_pk_outer").find(".form_field_pk_outer_row").length;
         while (document.getElementById("idPublicKey"+index) != null){
             index++;
         }
@@ -1153,7 +1153,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
     });
 
     $("body").on("click", ".add_new_tpl_user_field_btn", function () {
-        var index = $(".form_field_tpl_users_outer").find(".form_field_tpl_user_outer_row").length;
+        let index = $(".form_field_tpl_users_outer").find(".form_field_tpl_user_outer_row").length;
         while (document.getElementById("idTplUsername"+index) != null){
             index++;
         }

+ 1 - 1
templates/webclient/profile.html

@@ -115,7 +115,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
 <script type="text/javascript">
     $(document).ready(function () {
         $("body").on("click", ".add_new_pk_field_btn", function () {
-            var index = $(".form_field_pk_outer").find(".form_field_pk_outer_row").length;
+            let index = $(".form_field_pk_outer").find(".form_field_pk_outer_row").length;
             while (document.getElementById("idPublicKey"+index) != null){
                 index++;
             }

+ 1 - 1
templates/webclient/share.html

@@ -199,7 +199,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
         });
 
         $("body").on("click", ".add_new_path_field_btn", function () {
-            var index = $(".form_field_path_outer").find(".form_field_path_outer_row").length;
+            let index = $(".form_field_path_outer").find(".form_field_path_outer_row").length;
             while (document.getElementById("idPath"+index) != null){
                 index++;
             }