Browse Source

EventManager: add "on-demand" trigger

Signed-off-by: Nicola Murino <[email protected]>
Nicola Murino 2 năm trước cách đây
mục cha
commit
7b5bebc588

+ 1 - 0
docs/eventmanager.md

@@ -54,6 +54,7 @@ The following trigger events are supported:
 - `Schedules`. The scheduler uses UTC time.
 - `Schedules`. The scheduler uses UTC time.
 - `IP Blocked`, this event can be generated if you enable the [defender](./defender.md).
 - `IP Blocked`, this event can be generated if you enable the [defender](./defender.md).
 - `Certificate`, this event is generated when a certificate is renewed using the built-in ACME protocol. Both successful and failed renewals are notified.
 - `Certificate`, this event is generated when a certificate is renewed using the built-in ACME protocol. Both successful and failed renewals are notified.
+- `On demand`, this trigger is generated manually using the WebAdmin or the REST API.
 
 
 You can further restrict a rule by specifying additional conditions that must be met before the rule’s actions are taken. For example you can react to uploads only if they are performed by a particular user or using a specified protocol.
 You can further restrict a rule by specifying additional conditions that must be met before the rule’s actions are taken. For example you can react to uploads only if they are performed by a particular user or using a specified protocol.
 
 

+ 2 - 2
go.mod

@@ -3,7 +3,7 @@ module github.com/drakkan/sftpgo/v2
 go 1.19
 go 1.19
 
 
 require (
 require (
-	cloud.google.com/go/storage v1.28.1
+	cloud.google.com/go/storage v1.29.0
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.3.0
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.3.0
 	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.6.1
 	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.6.1
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
@@ -157,7 +157,7 @@ require (
 	golang.org/x/tools v0.5.0 // indirect
 	golang.org/x/tools v0.5.0 // indirect
 	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
 	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20230117162540-28d6b9783ac4 // indirect
+	google.golang.org/genproto v0.0.0-20230119192704-9d59e20e5cd1 // indirect
 	google.golang.org/grpc v1.52.0 // indirect
 	google.golang.org/grpc v1.52.0 // indirect
 	google.golang.org/protobuf v1.28.1 // indirect
 	google.golang.org/protobuf v1.28.1 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect

+ 4 - 4
go.sum

@@ -347,8 +347,8 @@ cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq
 cloud.google.com/go/storage v1.23.0/go.mod h1:vOEEDNFnciUMhBeT6hsJIn3ieU5cFRmzeLgDvXzfIXc=
 cloud.google.com/go/storage v1.23.0/go.mod h1:vOEEDNFnciUMhBeT6hsJIn3ieU5cFRmzeLgDvXzfIXc=
 cloud.google.com/go/storage v1.27.0/go.mod h1:x9DOL8TK/ygDUMieqwfhdpQryTeEkhGKMi80i/iqR2s=
 cloud.google.com/go/storage v1.27.0/go.mod h1:x9DOL8TK/ygDUMieqwfhdpQryTeEkhGKMi80i/iqR2s=
 cloud.google.com/go/storage v1.28.0/go.mod h1:qlgZML35PXA3zoEnIkiPLY4/TOkUleufRlu6qmcf7sI=
 cloud.google.com/go/storage v1.28.0/go.mod h1:qlgZML35PXA3zoEnIkiPLY4/TOkUleufRlu6qmcf7sI=
-cloud.google.com/go/storage v1.28.1 h1:F5QDG5ChchaAVQhINh24U99OWHURqrW8OmQcGKXcbgI=
-cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y=
+cloud.google.com/go/storage v1.29.0 h1:6weCgzRvMg7lzuUurI4697AqIRPU1SvzHhynwpW31jI=
+cloud.google.com/go/storage v1.29.0/go.mod h1:4puEjyTKnku6gfKoTfNOU/W+a9JyuVNxjpS5GBrB8h4=
 cloud.google.com/go/storagetransfer v1.5.0/go.mod h1:dxNzUopWy7RQevYFHewchb29POFv3/AaBgnhqzqiK0w=
 cloud.google.com/go/storagetransfer v1.5.0/go.mod h1:dxNzUopWy7RQevYFHewchb29POFv3/AaBgnhqzqiK0w=
 cloud.google.com/go/storagetransfer v1.6.0/go.mod h1:y77xm4CQV/ZhFZH75PLEXY0ROiS7Gh6pSKrM8dJyg6I=
 cloud.google.com/go/storagetransfer v1.6.0/go.mod h1:y77xm4CQV/ZhFZH75PLEXY0ROiS7Gh6pSKrM8dJyg6I=
 cloud.google.com/go/talent v1.1.0/go.mod h1:Vl4pt9jiHKvOgF9KoZo6Kob9oV4lwd/ZD5Cto54zDRw=
 cloud.google.com/go/talent v1.1.0/go.mod h1:Vl4pt9jiHKvOgF9KoZo6Kob9oV4lwd/ZD5Cto54zDRw=
@@ -2710,8 +2710,8 @@ google.golang.org/genproto v0.0.0-20221109142239-94d6d90a7d66/go.mod h1:rZS5c/ZV
 google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg=
 google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg=
 google.golang.org/genproto v0.0.0-20221201164419-0e50fba7f41c/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg=
 google.golang.org/genproto v0.0.0-20221201164419-0e50fba7f41c/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg=
 google.golang.org/genproto v0.0.0-20221201204527-e3fa12d562f3/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg=
 google.golang.org/genproto v0.0.0-20221201204527-e3fa12d562f3/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg=
-google.golang.org/genproto v0.0.0-20230117162540-28d6b9783ac4 h1:yF0uHwqqYt2tIL2F4hxRWA1ZFX43SEunWAK8MnQiclk=
-google.golang.org/genproto v0.0.0-20230117162540-28d6b9783ac4/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
+google.golang.org/genproto v0.0.0-20230119192704-9d59e20e5cd1 h1:wSjSSQW7LuPdv3m1IrSN33nVxH/kID6OIKy+FMwGB2k=
+google.golang.org/genproto v0.0.0-20230119192704-9d59e20e5cd1/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
 google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
 google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=

+ 29 - 4
internal/common/eventmanager.go

@@ -197,7 +197,7 @@ func (r *eventRulesContainer) addUpdateRuleInternal(rule dataprovider.EventRule)
 		}
 		}
 		return
 		return
 	}
 	}
-	if rule.Status != 1 {
+	if rule.Status != 1 || rule.Trigger == dataprovider.EventTriggerOnDemand {
 		return
 		return
 	}
 	}
 	switch rule.Trigger {
 	switch rule.Trigger {
@@ -2283,7 +2283,7 @@ type eventCronJob struct {
 	ruleName string
 	ruleName string
 }
 }
 
 
-func (j *eventCronJob) getTask(rule dataprovider.EventRule) (dataprovider.Task, error) {
+func (j *eventCronJob) getTask(rule *dataprovider.EventRule) (dataprovider.Task, error) {
 	if rule.GuardFromConcurrentExecution() {
 	if rule.GuardFromConcurrentExecution() {
 		task, err := dataprovider.GetTaskByName(rule.Name)
 		task, err := dataprovider.GetTaskByName(rule.Name)
 		if err != nil {
 		if err != nil {
@@ -2316,11 +2316,11 @@ func (j *eventCronJob) Run() {
 		eventManagerLog(logger.LevelError, "unable to load rule with name %q", j.ruleName)
 		eventManagerLog(logger.LevelError, "unable to load rule with name %q", j.ruleName)
 		return
 		return
 	}
 	}
-	if err = rule.CheckActionsConsistency(""); err != nil {
+	if err := rule.CheckActionsConsistency(""); err != nil {
 		eventManagerLog(logger.LevelWarn, "scheduled rule %q skipped: %v", rule.Name, err)
 		eventManagerLog(logger.LevelWarn, "scheduled rule %q skipped: %v", rule.Name, err)
 		return
 		return
 	}
 	}
-	task, err := j.getTask(rule)
+	task, err := j.getTask(&rule)
 	if err != nil {
 	if err != nil {
 		return
 		return
 	}
 	}
@@ -2366,6 +2366,31 @@ func (j *eventCronJob) Run() {
 	eventManagerLog(logger.LevelDebug, "execution for scheduled rule %q finished", j.ruleName)
 	eventManagerLog(logger.LevelDebug, "execution for scheduled rule %q finished", j.ruleName)
 }
 }
 
 
+// RunOnDemandRule executes actions for a rule with on-demand trigger
+func RunOnDemandRule(name string) error {
+	eventManagerLog(logger.LevelDebug, "executing on demand rule %q", name)
+	rule, err := dataprovider.EventRuleExists(name)
+	if err != nil {
+		eventManagerLog(logger.LevelDebug, "unable to load rule with name %q", name)
+		return util.NewRecordNotFoundError(fmt.Sprintf("rule %q does not exist", name))
+	}
+	if rule.Trigger != dataprovider.EventTriggerOnDemand {
+		eventManagerLog(logger.LevelDebug, "cannot run rule %q as on demand, trigger: %d", name, rule.Trigger)
+		return util.NewValidationError(fmt.Sprintf("rule %q is not defined as on-demand", name))
+	}
+	if rule.Status != 1 {
+		eventManagerLog(logger.LevelDebug, "on-demand rule %q is inactive", name)
+		return util.NewValidationError(fmt.Sprintf("rule %q is inactive", name))
+	}
+	if err := rule.CheckActionsConsistency(""); err != nil {
+		eventManagerLog(logger.LevelError, "on-demand rule %q has incompatible actions: %v", name, err)
+		return util.NewValidationError(fmt.Sprintf("rule %q has incosistent actions", name))
+	}
+	eventManagerLog(logger.LevelDebug, "on-demand rule %q started", name)
+	go executeAsyncRulesActions([]dataprovider.EventRule{rule}, EventParams{Status: 1, updateStatusFromError: true})
+	return nil
+}
+
 type zipWriterWrapper struct {
 type zipWriterWrapper struct {
 	Name    string
 	Name    string
 	Entries map[string]bool
 	Entries map[string]bool

+ 81 - 0
internal/common/eventmanager_test.go

@@ -1803,6 +1803,87 @@ func TestEstimateZipSizeErrors(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
 
 
+func TestOnDemandRule(t *testing.T) {
+	a := &dataprovider.BaseEventAction{
+		Name:    "a",
+		Type:    dataprovider.ActionTypeBackup,
+		Options: dataprovider.BaseEventActionOptions{},
+	}
+	err := dataprovider.AddEventAction(a, "", "", "")
+	assert.NoError(t, err)
+	r := &dataprovider.EventRule{
+		Name:    "test on demand rule",
+		Status:  1,
+		Trigger: dataprovider.EventTriggerOnDemand,
+		Actions: []dataprovider.EventAction{
+			{
+				BaseEventAction: dataprovider.BaseEventAction{
+					Name: a.Name,
+				},
+			},
+		},
+	}
+	err = dataprovider.AddEventRule(r, "", "", "")
+	assert.NoError(t, err)
+
+	err = RunOnDemandRule(r.Name)
+	assert.NoError(t, err)
+
+	r.Status = 0
+	err = dataprovider.UpdateEventRule(r, "", "", "")
+	assert.NoError(t, err)
+	err = RunOnDemandRule(r.Name)
+	assert.ErrorIs(t, err, util.ErrValidation)
+	assert.Contains(t, err.Error(), "is inactive")
+
+	r.Status = 1
+	r.Trigger = dataprovider.EventTriggerCertificate
+	err = dataprovider.UpdateEventRule(r, "", "", "")
+	assert.NoError(t, err)
+	err = RunOnDemandRule(r.Name)
+	assert.ErrorIs(t, err, util.ErrValidation)
+	assert.Contains(t, err.Error(), "is not defined as on-demand")
+
+	a1 := &dataprovider.BaseEventAction{
+		Name: "a1",
+		Type: dataprovider.ActionTypeEmail,
+		Options: dataprovider.BaseEventActionOptions{
+			EmailConfig: dataprovider.EventActionEmailConfig{
+				Recipients:  []string{"[email protected]"},
+				Subject:     "subject",
+				Body:        "body",
+				Attachments: []string{"/{{VirtualPath}}"},
+			},
+		},
+	}
+	err = dataprovider.AddEventAction(a1, "", "", "")
+	assert.NoError(t, err)
+
+	r.Trigger = dataprovider.EventTriggerOnDemand
+	r.Actions = []dataprovider.EventAction{
+		{
+			BaseEventAction: dataprovider.BaseEventAction{
+				Name: a1.Name,
+			},
+		},
+	}
+	err = dataprovider.UpdateEventRule(r, "", "", "")
+	assert.NoError(t, err)
+	err = RunOnDemandRule(r.Name)
+	assert.ErrorIs(t, err, util.ErrValidation)
+	assert.Contains(t, err.Error(), "incosistent actions")
+
+	err = dataprovider.DeleteEventRule(r.Name, "", "", "")
+	assert.NoError(t, err)
+	err = dataprovider.DeleteEventAction(a.Name, "", "", "")
+	assert.NoError(t, err)
+	err = dataprovider.DeleteEventAction(a1.Name, "", "", "")
+	assert.NoError(t, err)
+
+	err = RunOnDemandRule(r.Name)
+	assert.ErrorIs(t, err, util.ErrNotFound)
+}
+
 func getErrorString(err error) string {
 func getErrorString(err error) string {
 	if err == nil {
 	if err == nil {
 		return ""
 		return ""

+ 14 - 1
internal/dataprovider/eventrule.go

@@ -94,11 +94,12 @@ const (
 	EventTriggerSchedule
 	EventTriggerSchedule
 	EventTriggerIPBlocked
 	EventTriggerIPBlocked
 	EventTriggerCertificate
 	EventTriggerCertificate
+	EventTriggerOnDemand
 )
 )
 
 
 var (
 var (
 	supportedEventTriggers = []int{EventTriggerFsEvent, EventTriggerProviderEvent, EventTriggerSchedule,
 	supportedEventTriggers = []int{EventTriggerFsEvent, EventTriggerProviderEvent, EventTriggerSchedule,
-		EventTriggerIPBlocked, EventTriggerCertificate}
+		EventTriggerIPBlocked, EventTriggerCertificate, EventTriggerOnDemand}
 )
 )
 
 
 func isEventTriggerValid(trigger int) bool {
 func isEventTriggerValid(trigger int) bool {
@@ -115,6 +116,8 @@ func getTriggerTypeAsString(trigger int) string {
 		return "IP blocked"
 		return "IP blocked"
 	case EventTriggerCertificate:
 	case EventTriggerCertificate:
 		return "Certificate renewal"
 		return "Certificate renewal"
+	case EventTriggerOnDemand:
+		return "On demand"
 	default:
 	default:
 		return "Schedule"
 		return "Schedule"
 	}
 	}
@@ -1292,6 +1295,16 @@ func (c *EventConditions) validate(trigger int) error {
 		c.Options.MinFileSize = 0
 		c.Options.MinFileSize = 0
 		c.Options.MaxFileSize = 0
 		c.Options.MaxFileSize = 0
 		c.Schedules = nil
 		c.Schedules = nil
+	case EventTriggerOnDemand:
+		c.FsEvents = nil
+		c.ProviderEvents = nil
+		c.Options.FsPaths = nil
+		c.Options.Protocols = nil
+		c.Options.MinFileSize = 0
+		c.Options.MaxFileSize = 0
+		c.Options.ProviderObjects = nil
+		c.Schedules = nil
+		c.Options.ConcurrentExecution = false
 	default:
 	default:
 		c.FsEvents = nil
 		c.FsEvents = nil
 		c.ProviderEvents = nil
 		c.ProviderEvents = nil

+ 3 - 3
internal/dataprovider/sqlcommon.go

@@ -108,7 +108,7 @@ func sqlCommonAddShare(share *Share, dbHandle *sql.DB) error {
 
 
 	user, err := provider.userExists(share.Username, "")
 	user, err := provider.userExists(share.Username, "")
 	if err != nil {
 	if err != nil {
-		return util.NewValidationError(fmt.Sprintf("unable to validate user %#v", share.Username))
+		return util.NewGenericError(fmt.Sprintf("unable to validate user %#v", share.Username))
 	}
 	}
 
 
 	paths, err := json.Marshal(share.Paths)
 	paths, err := json.Marshal(share.Paths)
@@ -168,7 +168,7 @@ func sqlCommonUpdateShare(share *Share, dbHandle *sql.DB) error {
 
 
 	user, err := provider.userExists(share.Username, "")
 	user, err := provider.userExists(share.Username, "")
 	if err != nil {
 	if err != nil {
-		return util.NewValidationError(fmt.Sprintf("unable to validate user %#v", share.Username))
+		return util.NewGenericError(fmt.Sprintf("unable to validate user %#v", share.Username))
 	}
 	}
 
 
 	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
 	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
@@ -2915,7 +2915,7 @@ func sqlCommonGetAPIKeyRelatedIDs(apiKey *APIKey) (sql.NullInt64, sql.NullInt64,
 	if apiKey.User != "" {
 	if apiKey.User != "" {
 		u, err := provider.userExists(apiKey.User, "")
 		u, err := provider.userExists(apiKey.User, "")
 		if err != nil {
 		if err != nil {
-			return userID, adminID, util.NewValidationError(fmt.Sprintf("unable to validate user %v", apiKey.User))
+			return userID, adminID, util.NewGenericError(fmt.Sprintf("unable to validate user %v", apiKey.User))
 		}
 		}
 		userID.Valid = true
 		userID.Valid = true
 		userID.Int64 = u.ID
 		userID.Int64 = u.ID

+ 13 - 1
internal/httpd/api_eventrule.go

@@ -21,6 +21,7 @@ import (
 
 
 	"github.com/go-chi/render"
 	"github.com/go-chi/render"
 
 
+	"github.com/drakkan/sftpgo/v2/internal/common"
 	"github.com/drakkan/sftpgo/v2/internal/dataprovider"
 	"github.com/drakkan/sftpgo/v2/internal/dataprovider"
 	"github.com/drakkan/sftpgo/v2/internal/util"
 	"github.com/drakkan/sftpgo/v2/internal/util"
 )
 )
@@ -244,5 +245,16 @@ func deleteEventRule(w http.ResponseWriter, r *http.Request) {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
 		return
 	}
 	}
-	sendAPIResponse(w, r, err, "Event rule deleted", http.StatusOK)
+	sendAPIResponse(w, r, nil, "Event rule deleted", http.StatusOK)
+}
+
+func runOnDemandRule(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+
+	name := getURLParam(r, "name")
+	if err := common.RunOnDemandRule(name); err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	sendAPIResponse(w, r, nil, "Event rule started", http.StatusAccepted)
 }
 }

+ 3 - 1
internal/httpd/api_maintenance.go

@@ -132,6 +132,7 @@ func loadDataFromRequest(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 	if err := restoreBackup(content, "", scanQuota, mode, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr), claims.Role); err != nil {
 	if err := restoreBackup(content, "", scanQuota, mode, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr), claims.Role); err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
 	}
 	}
 	sendAPIResponse(w, r, err, "Data restored", http.StatusOK)
 	sendAPIResponse(w, r, err, "Data restored", http.StatusOK)
 }
 }
@@ -170,6 +171,7 @@ func loadData(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 	if err := restoreBackup(content, inputFile, scanQuota, mode, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr), claims.Role); err != nil {
 	if err := restoreBackup(content, inputFile, scanQuota, mode, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr), claims.Role); err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
 	}
 	}
 	sendAPIResponse(w, r, err, "Data restored", http.StatusOK)
 	sendAPIResponse(w, r, err, "Data restored", http.StatusOK)
 }
 }
@@ -300,7 +302,7 @@ func RestoreShares(shares []dataprovider.Share, inputFile string, mode int, exec
 			logger.Debug(logSender, "", "adding new share %#v, dump file: %#v, error: %v", share.ShareID, inputFile, err)
 			logger.Debug(logSender, "", "adding new share %#v, dump file: %#v, error: %v", share.ShareID, inputFile, err)
 		}
 		}
 		if err != nil {
 		if err != nil {
-			return fmt.Errorf("unable to restore share %#v: %w", share.ShareID, err)
+			return fmt.Errorf("unable to restore share %q: %w", share.ShareID, err)
 		}
 		}
 	}
 	}
 	return nil
 	return nil

+ 3 - 3
internal/httpd/api_utils.go

@@ -86,13 +86,13 @@ func sendAPIResponse(w http.ResponseWriter, r *http.Request, err error, message
 }
 }
 
 
 func getRespStatus(err error) int {
 func getRespStatus(err error) int {
-	if _, ok := err.(*util.ValidationError); ok {
+	if errors.Is(err, util.ErrValidation) {
 		return http.StatusBadRequest
 		return http.StatusBadRequest
 	}
 	}
-	if _, ok := err.(*util.MethodDisabledError); ok {
+	if errors.Is(err, util.ErrMethodDisabled) {
 		return http.StatusForbidden
 		return http.StatusForbidden
 	}
 	}
-	if _, ok := err.(*util.RecordNotFoundError); ok {
+	if errors.Is(err, util.ErrNotFound) {
 		return http.StatusNotFound
 		return http.StatusNotFound
 	}
 	}
 	if errors.Is(err, fs.ErrNotExist) {
 	if errors.Is(err, fs.ErrNotExist) {

+ 43 - 2
internal/httpd/httpd_test.go

@@ -1666,6 +1666,47 @@ func TestActionRuleRelations(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
 
 
+func TestOnDemandEventRules(t *testing.T) {
+	ruleName := "test on demand rule"
+	a := dataprovider.BaseEventAction{
+		Name:    "a",
+		Type:    dataprovider.ActionTypeBackup,
+		Options: dataprovider.BaseEventActionOptions{},
+	}
+	action, _, err := httpdtest.AddEventAction(a, http.StatusCreated)
+	assert.NoError(t, err)
+	r := dataprovider.EventRule{
+		Name:    ruleName,
+		Status:  1,
+		Trigger: dataprovider.EventTriggerOnDemand,
+		Actions: []dataprovider.EventAction{
+			{
+				BaseEventAction: dataprovider.BaseEventAction{
+					Name: a.Name,
+				},
+			},
+		},
+	}
+	rule, _, err := httpdtest.AddEventRule(r, http.StatusCreated)
+	assert.NoError(t, err)
+	_, err = httpdtest.RunOnDemandRule(ruleName, http.StatusAccepted)
+	assert.NoError(t, err)
+	rule.Status = 0
+	_, _, err = httpdtest.UpdateEventRule(rule, http.StatusOK)
+	assert.NoError(t, err)
+	resp, err := httpdtest.RunOnDemandRule(ruleName, http.StatusBadRequest)
+	assert.NoError(t, err)
+	assert.Contains(t, string(resp), "is inactive")
+
+	_, err = httpdtest.RemoveEventRule(rule, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveEventAction(action, http.StatusOK)
+	assert.NoError(t, err)
+
+	_, err = httpdtest.RunOnDemandRule(ruleName, http.StatusNotFound)
+	assert.NoError(t, err)
+}
+
 func TestEventActionValidation(t *testing.T) {
 func TestEventActionValidation(t *testing.T) {
 	action := dataprovider.BaseEventAction{
 	action := dataprovider.BaseEventAction{
 		Name: "",
 		Name: "",
@@ -6591,8 +6632,8 @@ func TestProviderErrors(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	err = os.WriteFile(backupFilePath, backupContent, os.ModePerm)
 	err = os.WriteFile(backupFilePath, backupContent, os.ModePerm)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
-	_, _, err = httpdtest.Loaddata(backupFilePath, "", "", http.StatusInternalServerError)
-	assert.NoError(t, err)
+	_, resp, err := httpdtest.Loaddata(backupFilePath, "", "", http.StatusInternalServerError)
+	assert.NoError(t, err, string(resp))
 	backupData = dataprovider.BackupData{
 	backupData = dataprovider.BackupData{
 		EventActions: []dataprovider.BaseEventAction{
 		EventActions: []dataprovider.BaseEventAction{
 			{
 			{

+ 3 - 0
internal/httpd/server.go

@@ -1302,6 +1302,7 @@ func (s *httpdServer) initializeRouter() {
 			router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Post(eventRulesPath, addEventRule)
 			router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Post(eventRulesPath, addEventRule)
 			router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Put(eventRulesPath+"/{name}", updateEventRule)
 			router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Put(eventRulesPath+"/{name}", updateEventRule)
 			router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Delete(eventRulesPath+"/{name}", deleteEventRule)
 			router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Delete(eventRulesPath+"/{name}", deleteEventRule)
+			router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Post(eventRulesPath+"/run/{name}", runOnDemandRule)
 			router.With(s.checkPerm(dataprovider.PermAdminManageRoles)).Get(rolesPath, getRoles)
 			router.With(s.checkPerm(dataprovider.PermAdminManageRoles)).Get(rolesPath, getRoles)
 			router.With(s.checkPerm(dataprovider.PermAdminManageRoles)).Get(rolesPath+"/{name}", getRoleByName)
 			router.With(s.checkPerm(dataprovider.PermAdminManageRoles)).Get(rolesPath+"/{name}", getRoleByName)
 			router.With(s.checkPerm(dataprovider.PermAdminManageRoles)).Post(rolesPath, addRole)
 			router.With(s.checkPerm(dataprovider.PermAdminManageRoles)).Post(rolesPath, addRole)
@@ -1657,6 +1658,8 @@ func (s *httpdServer) setupWebAdminRoutes() {
 				s.handleWebUpdateEventRulePost)
 				s.handleWebUpdateEventRulePost)
 			router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), verifyCSRFHeader).
 			router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), verifyCSRFHeader).
 				Delete(webAdminEventRulePath+"/{name}", deleteEventRule)
 				Delete(webAdminEventRulePath+"/{name}", deleteEventRule)
+			router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), verifyCSRFHeader).
+				Post(webAdminEventRulePath+"/run/{name}", runOnDemandRule)
 			router.With(s.checkPerm(dataprovider.PermAdminManageRoles), s.refreshCookie).
 			router.With(s.checkPerm(dataprovider.PermAdminManageRoles), s.refreshCookie).
 				Get(webAdminRolesPath, s.handleWebGetRoles)
 				Get(webAdminRolesPath, s.handleWebGetRoles)
 			router.With(s.checkPerm(dataprovider.PermAdminManageRoles), s.refreshCookie).
 			router.With(s.checkPerm(dataprovider.PermAdminManageRoles), s.refreshCookie).

+ 19 - 0
internal/httpdtest/httpdtest.go

@@ -942,6 +942,25 @@ func GetEventRules(limit, offset int64, expectedStatusCode int) ([]dataprovider.
 	return rules, body, err
 	return rules, body, err
 }
 }
 
 
+// RunOnDemandRule executes the specified on demand rule
+func RunOnDemandRule(name string, expectedStatusCode int) ([]byte, error) {
+	resp, err := sendHTTPRequest(http.MethodPost, buildURLRelativeToBase(eventRulesPath, "run", url.PathEscape(name)),
+		nil, "application/json", getDefaultToken())
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	b, err := getResponseBody(resp)
+	if err != nil {
+		return b, err
+	}
+	if err := checkResponse(resp.StatusCode, expectedStatusCode); err != nil {
+		return b, err
+	}
+	return b, nil
+}
+
 // GetQuotaScans gets active quota scans for users and checks the received HTTP Status code against expectedStatusCode.
 // GetQuotaScans gets active quota scans for users and checks the received HTTP Status code against expectedStatusCode.
 func GetQuotaScans(expectedStatusCode int) ([]common.ActiveQuotaScan, []byte, error) {
 func GetQuotaScans(expectedStatusCode int) ([]common.ActiveQuotaScan, []byte, error) {
 	var quotaScans []common.ActiveQuotaScan
 	var quotaScans []common.ActiveQuotaScan

+ 12 - 5
internal/sftpd/sftpd_test.go

@@ -9118,16 +9118,23 @@ func TestSSHCopyQuotaLimits(t *testing.T) {
 		// now decrease the limits
 		// now decrease the limits
 		user.QuotaFiles = 1
 		user.QuotaFiles = 1
 		user.QuotaSize = testFileSize * 10
 		user.QuotaSize = testFileSize * 10
-		user.VirtualFolders[1].QuotaSize = testFileSize
-		user.VirtualFolders[1].QuotaFiles = 10
+		for idx, f := range user.VirtualFolders {
+			if f.Name == folderName2 {
+				user.VirtualFolders[idx].QuotaSize = testFileSize
+				user.VirtualFolders[idx].QuotaFiles = 10
+			}
+		}
 		user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
 		user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		assert.Equal(t, 1, user.QuotaFiles)
 		assert.Equal(t, 1, user.QuotaFiles)
 		assert.Equal(t, testFileSize*10, user.QuotaSize)
 		assert.Equal(t, testFileSize*10, user.QuotaSize)
 		if assert.Len(t, user.VirtualFolders, 2) {
 		if assert.Len(t, user.VirtualFolders, 2) {
-			f := user.VirtualFolders[1]
-			assert.Equal(t, testFileSize, f.QuotaSize)
-			assert.Equal(t, 10, f.QuotaFiles)
+			for _, f := range user.VirtualFolders {
+				if f.Name == folderName2 {
+					assert.Equal(t, testFileSize, f.QuotaSize)
+					assert.Equal(t, 10, f.QuotaFiles)
+				}
+			}
 		}
 		}
 		_, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %v %v", path.Join(vdirPath1, testDir),
 		_, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %v %v", path.Join(vdirPath1, testDir),
 			path.Join(vdirPath2, testDir+".copy")), user, usePubKey)
 			path.Join(vdirPath2, testDir+".copy")), user, usePubKey)

+ 32 - 0
internal/util/errors.go

@@ -24,6 +24,14 @@ const (
 		"sftpgo serve -c \"<path to dir containing the default config file and templates directory>\""
 		"sftpgo serve -c \"<path to dir containing the default config file and templates directory>\""
 )
 )
 
 
+// errors definitions
+var (
+	ErrValidation     = NewValidationError("")
+	ErrNotFound       = NewRecordNotFoundError("")
+	ErrMethodDisabled = NewMethodDisabledError("")
+	ErrGeneric        = NewGenericError("")
+)
+
 // ValidationError raised if input data is not valid
 // ValidationError raised if input data is not valid
 type ValidationError struct {
 type ValidationError struct {
 	err string
 	err string
@@ -39,6 +47,12 @@ func (e *ValidationError) GetErrorString() string {
 	return e.err
 	return e.err
 }
 }
 
 
+// Is reports if target matches
+func (e *ValidationError) Is(target error) bool {
+	_, ok := target.(*ValidationError)
+	return ok
+}
+
 // NewValidationError returns a validation errors
 // NewValidationError returns a validation errors
 func NewValidationError(error string) *ValidationError {
 func NewValidationError(error string) *ValidationError {
 	return &ValidationError{
 	return &ValidationError{
@@ -55,6 +69,12 @@ func (e *RecordNotFoundError) Error() string {
 	return fmt.Sprintf("not found: %s", e.err)
 	return fmt.Sprintf("not found: %s", e.err)
 }
 }
 
 
+// Is reports if target matches
+func (e *RecordNotFoundError) Is(target error) bool {
+	_, ok := target.(*RecordNotFoundError)
+	return ok
+}
+
 // NewRecordNotFoundError returns a not found error
 // NewRecordNotFoundError returns a not found error
 func NewRecordNotFoundError(error string) *RecordNotFoundError {
 func NewRecordNotFoundError(error string) *RecordNotFoundError {
 	return &RecordNotFoundError{
 	return &RecordNotFoundError{
@@ -74,6 +94,12 @@ func (e *MethodDisabledError) Error() string {
 	return fmt.Sprintf("Method disabled error: %s", e.err)
 	return fmt.Sprintf("Method disabled error: %s", e.err)
 }
 }
 
 
+// Is reports if target matches
+func (e *MethodDisabledError) Is(target error) bool {
+	_, ok := target.(*MethodDisabledError)
+	return ok
+}
+
 // NewMethodDisabledError returns a method disabled error
 // NewMethodDisabledError returns a method disabled error
 func NewMethodDisabledError(error string) *MethodDisabledError {
 func NewMethodDisabledError(error string) *MethodDisabledError {
 	return &MethodDisabledError{
 	return &MethodDisabledError{
@@ -90,6 +116,12 @@ func (e *GenericError) Error() string {
 	return e.err
 	return e.err
 }
 }
 
 
+// Is reports if target matches
+func (e *GenericError) Is(target error) bool {
+	_, ok := target.(*GenericError)
+	return ok
+}
+
 // NewGenericError returns a generic error
 // NewGenericError returns a generic error
 func NewGenericError(error string) *GenericError {
 func NewGenericError(error string) *GenericError {
 	return &GenericError{
 	return &GenericError{

+ 37 - 0
openapi/openapi.yaml

@@ -2213,6 +2213,41 @@ paths:
           $ref: '#/components/responses/InternalServerError'
           $ref: '#/components/responses/InternalServerError'
         default:
         default:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
+  '/eventrules/run/{name}':
+    parameters:
+      - name: name
+        in: path
+        description: on-demand rule name
+        required: true
+        schema:
+          type: string
+    post:
+      tags:
+        - event manager
+      summary: Run an on-demand event rule
+      description: The rule's actions will run in background. SFTPGo will not monitor any concurrency and such. If you want to be notified at the end of the execution please add an appropriate action
+      operationId: run_event_rule
+      responses:
+        '202':
+          description: successful operation
+          content:
+            application/json; charset=utf-8:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+              example:
+                message: Event rule started
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '404':
+          $ref: '#/components/responses/NotFound'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
   /events/fs:
   /events/fs:
     get:
     get:
       tags:
       tags:
@@ -4667,6 +4702,7 @@ components:
         - 3
         - 3
         - 4
         - 4
         - 5
         - 5
+        - 6
       description: |
       description: |
         Supported event trigger types:
         Supported event trigger types:
           * `1` - Filesystem event
           * `1` - Filesystem event
@@ -4674,6 +4710,7 @@ components:
           * `3` - Schedule
           * `3` - Schedule
           * `4` - IP blocked
           * `4` - IP blocked
           * `5` - Certificate renewal
           * `5` - Certificate renewal
+          * `6` - On demand, like schedule but executed on demand
     LoginMethods:
     LoginMethods:
       type: string
       type: string
       enum:
       enum:

+ 6 - 8
templates/webadmin/eventrule.html

@@ -193,7 +193,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
                 </div>
                 </div>
             </div>
             </div>
 
 
-            <div class="card bg-light mb-3 trigger trigger-fs trigger-provider trigger-schedule">
+            <div class="card bg-light mb-3 trigger trigger-fs trigger-provider trigger-schedule trigger-on-demand">
                 <div class="card-header">
                 <div class="card-header">
                     <b>Name filters</b>
                     <b>Name filters</b>
                 </div>
                 </div>
@@ -247,7 +247,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
                 </div>
                 </div>
             </div>
             </div>
 
 
-            <div class="card bg-light mb-3 trigger trigger-fs trigger-schedule">
+            <div class="card bg-light mb-3 trigger trigger-fs trigger-schedule trigger-on-demand">
                 <div class="card-header">
                 <div class="card-header">
                     <b>Group name filters</b>
                     <b>Group name filters</b>
                 </div>
                 </div>
@@ -301,7 +301,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
                 </div>
                 </div>
             </div>
             </div>
 
 
-            <div class="card bg-light mb-3 trigger trigger-fs trigger-schedule trigger-provider">
+            <div class="card bg-light mb-3 trigger trigger-fs trigger-schedule trigger-provider trigger-on-demand">
                 <div class="card-header">
                 <div class="card-header">
                     <b>Role name filters</b>
                     <b>Role name filters</b>
                 </div>
                 </div>
@@ -710,21 +710,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
         $('.trigger').hide();
         $('.trigger').hide();
         switch (val) {
         switch (val) {
             case '1':
             case '1':
-            case 1:
                 $('.trigger-fs').show();
                 $('.trigger-fs').show();
                 break;
                 break;
             case '2':
             case '2':
-            case 2:
                 $('.trigger-provider').show();
                 $('.trigger-provider').show();
                 break;
                 break;
             case '3':
             case '3':
-            case 3:
                 $('.trigger-schedule').show();
                 $('.trigger-schedule').show();
                 break;
                 break;
             case '4':
             case '4':
-            case 4:
             case '5':
             case '5':
-            case 5:
+                break;
+            case '6':
+                $('.trigger-on-demand').show();
                 break;
                 break;
             default:
             default:
                 console.log(`unsupported event trigger type: ${val}`);
                 console.log(`unsupported event trigger type: ${val}`);

+ 102 - 10
templates/webadmin/eventrules.html

@@ -29,6 +29,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 <div id="errorMsg" class="card mb-4 border-left-warning" style="display: none;">
 <div id="errorMsg" class="card mb-4 border-left-warning" style="display: none;">
     <div id="errorTxt" class="card-body text-form-error"></div>
     <div id="errorTxt" class="card-body text-form-error"></div>
 </div>
 </div>
+<div id="successMsg" class="card mb-4 border-left-success" style="display: none;">
+    <div id="successTxt" class="card-body"></div>
+</div>
 <div class="card shadow mb-4">
 <div class="card shadow mb-4">
     <div class="card-header py-3">
     <div class="card-header py-3">
         <h6 class="m-0 font-weight-bold text-primary">View and manage event rules</h6>
         <h6 class="m-0 font-weight-bold text-primary">View and manage event rules</h6>
@@ -38,6 +41,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
             <table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0">
             <table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0">
                 <thead>
                 <thead>
                     <tr>
                     <tr>
+                        <th></th>
                         <th>Name</th>
                         <th>Name</th>
                         <th>Status</th>
                         <th>Status</th>
                         <th>Description</th>
                         <th>Description</th>
@@ -48,6 +52,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
                 <tbody>
                 <tbody>
                     {{range .Rules}}
                     {{range .Rules}}
                     <tr>
                     <tr>
+                        <td>{{.Trigger}}</td>
                         <td>{{.Name}}</td>
                         <td>{{.Name}}</td>
                         <td>{{if eq .Status 1 }}Active{{else}}Inactive{{end}}</td>
                         <td>{{if eq .Status 1 }}Active{{else}}Inactive{{end}}</td>
                         <td>{{.Description}}</td>
                         <td>{{.Description}}</td>
@@ -87,6 +92,31 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
         </div>
         </div>
     </div>
     </div>
 </div>
 </div>
+
+<div class="modal fade" id="runModal" tabindex="-1" role="dialog" aria-labelledby="runModalLabel"
+    aria-hidden="true">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="runModalLabel">
+                    Confirmation required
+                </h5>
+                <button class="close" type="button" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <div class="modal-body">Do you want to execute the selected rule?</div>
+            <div class="modal-footer">
+                <button class="btn btn-secondary" type="button" data-dismiss="modal">
+                    Cancel
+                </button>
+                <a class="btn btn-warning" href="#" onclick="runAction()">
+                    Run
+                </a>
+            </div>
+        </div>
+    </div>
+</div>
 {{end}}
 {{end}}
 
 
 {{define "extra_js"}}
 {{define "extra_js"}}
@@ -102,11 +132,51 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 <script src="{{.StaticURL}}/vendor/datatables/ellipsis.js"></script>
 <script src="{{.StaticURL}}/vendor/datatables/ellipsis.js"></script>
 <script type="text/javascript">
 <script type="text/javascript">
 
 
+    function runAction(){
+        let table = $('#dataTable').DataTable();
+        table.button('run:name').enable(false);
+        let name = table.row({ selected: true }).data()[1];
+        let path = '{{.EventRuleURL}}' + "/run/" + fixedEncodeURIComponent(name);
+        $('#runModal').modal('hide');
+        $.ajax({
+            url: path,
+            type: 'POST',
+            dataType: 'json',
+            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
+            timeout: 15000,
+            success: function (result) {
+                $('#successTxt').text("Rule actions started");
+                $('#successMsg').show();
+                setTimeout(function () {
+                    $('#successMsg').hide();
+                }, 8000);
+            },
+            error: function ($xhr, textStatus, errorThrown) {
+                var txt = "Unable to run the selected rule";
+                if ($xhr) {
+                    var json = $xhr.responseJSON;
+                    if (json) {
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
+                    }
+                }
+                $('#errorTxt').text(txt);
+                $('#errorMsg').show();
+                setTimeout(function () {
+                    $('#errorMsg').hide();
+                }, 8000);
+            }
+        });
+    }
+
     function deleteAction() {
     function deleteAction() {
-        var table = $('#dataTable').DataTable();
+        let table = $('#dataTable').DataTable();
         table.button('delete:name').enable(false);
         table.button('delete:name').enable(false);
-        var name = table.row({ selected: true }).data()[0];
-        var path = '{{.EventRuleURL}}' + "/" + fixedEncodeURIComponent(name);
+        let name = table.row({ selected: true }).data()[1];
+        let path = '{{.EventRuleURL}}' + "/" + fixedEncodeURIComponent(name);
         $('#deleteModal').modal('hide');
         $('#deleteModal').modal('hide');
         $.ajax({
         $.ajax({
             url: path,
             url: path,
@@ -133,7 +203,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
                 $('#errorMsg').show();
                 $('#errorMsg').show();
                 setTimeout(function () {
                 setTimeout(function () {
                     $('#errorMsg').hide();
                     $('#errorMsg').hide();
-                }, 5000);
+                }, 8000);
             }
             }
         });
         });
     }
     }
@@ -153,8 +223,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
             name: 'edit',
             name: 'edit',
             titleAttr: "Edit",
             titleAttr: "Edit",
             action: function (e, dt, node, config) {
             action: function (e, dt, node, config) {
-                var name = table.row({ selected: true }).data()[0];
-                var path = '{{.EventRuleURL}}' + "/" + fixedEncodeURIComponent(name);
+                let name = table.row({ selected: true }).data()[1];
+                let path = '{{.EventRuleURL}}' + "/" + fixedEncodeURIComponent(name);
                 window.location.href = path;
                 window.location.href = path;
             },
             },
             enabled: false
             enabled: false
@@ -170,6 +240,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
             enabled: false
             enabled: false
         };
         };
 
 
+        $.fn.dataTable.ext.buttons.run = {
+            text: '<i class="fas fa-play"></i>',
+            name: 'run',
+            titleAttr: "Run",
+            action: function (e, dt, node, config) {
+                $('#runModal').modal('show');
+            },
+            enabled: false
+        };
+
         var table = $('#dataTable').DataTable({
         var table = $('#dataTable').DataTable({
             "select": {
             "select": {
                 "style": "single",
                 "style": "single",
@@ -186,15 +266,21 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
             ],
             ],
             "columnDefs": [
             "columnDefs": [
                 {
                 {
-                    "targets": [0,1],
+                    "targets": [0],
+                    "visible": false,
+                    "searchable": false,
                     "className": "noVis"
                     "className": "noVis"
                 },
                 },
                 {
                 {
-                    "targets": [2],
+                    "targets": [1,2],
+                    "className": "noVis"
+                },
+                {
+                    "targets": [3],
                     "visible": false
                     "visible": false
                 },
                 },
                 {
                 {
-                    "targets": [4],
+                    "targets": [5],
                     "render": $.fn.dataTable.render.ellipsis(100, true)
                     "render": $.fn.dataTable.render.ellipsis(100, true)
                 },
                 },
             ],
             ],
@@ -204,11 +290,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
             "language": {
             "language": {
                 "emptyTable": "No event rules defined"
                 "emptyTable": "No event rules defined"
             },
             },
-            "order": [[0, 'asc']]
+            "order": [[1, 'asc']]
         });
         });
 
 
         new $.fn.dataTable.FixedHeader( table );
         new $.fn.dataTable.FixedHeader( table );
 
 
+        table.button().add(0,'run');
         table.button().add(0,'delete');
         table.button().add(0,'delete');
         table.button().add(0,'edit');
         table.button().add(0,'edit');
         table.button().add(0,'add');
         table.button().add(0,'add');
@@ -219,6 +306,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
             var selectedRows = table.rows({ selected: true }).count();
             var selectedRows = table.rows({ selected: true }).count();
             table.button('delete:name').enable(selectedRows == 1);
             table.button('delete:name').enable(selectedRows == 1);
             table.button('edit:name').enable(selectedRows == 1);
             table.button('edit:name').enable(selectedRows == 1);
+            if (selectedRows == 1){
+                table.button('run:name').enable(table.row({ selected: true }).data()[0] == 6);
+            } else {
+                table.button('run:name').enable(false);
+            }
         });
         });
 
 
     });
     });

+ 0 - 2
templates/webadmin/events.html

@@ -572,11 +572,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
     function onEventChanged(val){
     function onEventChanged(val){
         switch (val){
         switch (val){
             case '1':
             case '1':
-            case 1:
                 selectFsEvents();
                 selectFsEvents();
                 break;
                 break;
             case '2':
             case '2':
-            case 2:
                 selectProviderEvents();
                 selectProviderEvents();
                 break;
                 break;
             default:
             default:

+ 3 - 3
templates/webadmin/folders.html

@@ -204,15 +204,15 @@ function deleteAction() {
             titleAttr: 'Quota Scan',
             titleAttr: 'Quota Scan',
             action: function (e, dt, node, config) {
             action: function (e, dt, node, config) {
                 dt.button('quota_scan:name').enable(false);
                 dt.button('quota_scan:name').enable(false);
-                var folderName = dt.row({ selected: true }).data()[1];
-                var path = '{{.FolderQuotaScanURL}}'+ "/" + fixedEncodeURIComponent(folderName);
+                let folderName = dt.row({ selected: true }).data()[1];
+                let path = '{{.FolderQuotaScanURL}}'+ "/" + fixedEncodeURIComponent(folderName);
                 $.ajax({
                 $.ajax({
                     url: path,
                     url: path,
                     type: 'POST',
                     type: 'POST',
                     headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
                     headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
                     timeout: 15000,
                     timeout: 15000,
                     success: function (result) {
                     success: function (result) {
-                        dt.button('quota_scan:name').enable(true);
+                        //dt.button('quota_scan:name').enable(true);
                         $('#successTxt').text("Quota scan started for the selected folder. Please reload the folders page to check when the scan ends");
                         $('#successTxt').text("Quota scan started for the selected folder. Please reload the folders page to check when the scan ends");
                         $('#successMsg').show();
                         $('#successMsg').show();
                         setTimeout(function () {
                         setTimeout(function () {