EventManager: add "on-demand" trigger

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2023-01-21 15:41:24 +01:00
parent 53f17b5715
commit 7b5bebc588
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
20 changed files with 410 additions and 49 deletions

View file

@ -54,6 +54,7 @@ The following trigger events are supported:
- `Schedules`. The scheduler uses UTC time.
- `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.
- `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 rules actions are taken. For example you can react to uploads only if they are performed by a particular user or using a specified protocol.

4
go.mod
View file

@ -3,7 +3,7 @@ module github.com/drakkan/sftpgo/v2
go 1.19
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/storage/azblob v0.6.1
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/xerrors v0.0.0-20220907171357-04be3eba64a2 // 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/protobuf v1.28.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect

8
go.sum
View file

@ -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.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.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.6.0/go.mod h1:y77xm4CQV/ZhFZH75PLEXY0ROiS7Gh6pSKrM8dJyg6I=
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-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-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 v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=

View file

@ -197,7 +197,7 @@ func (r *eventRulesContainer) addUpdateRuleInternal(rule dataprovider.EventRule)
}
return
}
if rule.Status != 1 {
if rule.Status != 1 || rule.Trigger == dataprovider.EventTriggerOnDemand {
return
}
switch rule.Trigger {
@ -2283,7 +2283,7 @@ type eventCronJob struct {
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() {
task, err := dataprovider.GetTaskByName(rule.Name)
if err != nil {
@ -2316,11 +2316,11 @@ func (j *eventCronJob) Run() {
eventManagerLog(logger.LevelError, "unable to load rule with name %q", j.ruleName)
return
}
if err = rule.CheckActionsConsistency(""); err != nil {
if err := rule.CheckActionsConsistency(""); err != nil {
eventManagerLog(logger.LevelWarn, "scheduled rule %q skipped: %v", rule.Name, err)
return
}
task, err := j.getTask(rule)
task, err := j.getTask(&rule)
if err != nil {
return
}
@ -2366,6 +2366,31 @@ func (j *eventCronJob) Run() {
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 {
Name string
Entries map[string]bool

View file

@ -1803,6 +1803,87 @@ func TestEstimateZipSizeErrors(t *testing.T) {
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{"example@example.org"},
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 {
if err == nil {
return ""

View file

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

View file

@ -108,7 +108,7 @@ func sqlCommonAddShare(share *Share, dbHandle *sql.DB) error {
user, err := provider.userExists(share.Username, "")
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)
@ -168,7 +168,7 @@ func sqlCommonUpdateShare(share *Share, dbHandle *sql.DB) error {
user, err := provider.userExists(share.Username, "")
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)
@ -2915,7 +2915,7 @@ func sqlCommonGetAPIKeyRelatedIDs(apiKey *APIKey) (sql.NullInt64, sql.NullInt64,
if apiKey.User != "" {
u, err := provider.userExists(apiKey.User, "")
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.Int64 = u.ID

View file

@ -21,6 +21,7 @@ import (
"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/util"
)
@ -244,5 +245,16 @@ func deleteEventRule(w http.ResponseWriter, r *http.Request) {
sendAPIResponse(w, r, err, "", getRespStatus(err))
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)
}

View file

@ -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 {
sendAPIResponse(w, r, err, "", getRespStatus(err))
return
}
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 {
sendAPIResponse(w, r, err, "", getRespStatus(err))
return
}
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)
}
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

View file

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

View file

@ -1666,6 +1666,47 @@ func TestActionRuleRelations(t *testing.T) {
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) {
action := dataprovider.BaseEventAction{
Name: "",
@ -6591,8 +6632,8 @@ func TestProviderErrors(t *testing.T) {
assert.NoError(t, err)
err = os.WriteFile(backupFilePath, backupContent, os.ModePerm)
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{
EventActions: []dataprovider.BaseEventAction{
{

View file

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

View file

@ -942,6 +942,25 @@ func GetEventRules(limit, offset int64, expectedStatusCode int) ([]dataprovider.
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.
func GetQuotaScans(expectedStatusCode int) ([]common.ActiveQuotaScan, []byte, error) {
var quotaScans []common.ActiveQuotaScan

View file

@ -9118,16 +9118,23 @@ func TestSSHCopyQuotaLimits(t *testing.T) {
// now decrease the limits
user.QuotaFiles = 1
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, "")
assert.NoError(t, err)
assert.Equal(t, 1, user.QuotaFiles)
assert.Equal(t, testFileSize*10, user.QuotaSize)
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),
path.Join(vdirPath2, testDir+".copy")), user, usePubKey)

View file

@ -24,6 +24,14 @@ const (
"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
type ValidationError struct {
err string
@ -39,6 +47,12 @@ func (e *ValidationError) GetErrorString() string {
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
func NewValidationError(error string) *ValidationError {
return &ValidationError{
@ -55,6 +69,12 @@ func (e *RecordNotFoundError) Error() string {
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
func NewRecordNotFoundError(error string) *RecordNotFoundError {
return &RecordNotFoundError{
@ -74,6 +94,12 @@ func (e *MethodDisabledError) Error() string {
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
func NewMethodDisabledError(error string) *MethodDisabledError {
return &MethodDisabledError{
@ -90,6 +116,12 @@ func (e *GenericError) Error() string {
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
func NewGenericError(error string) *GenericError {
return &GenericError{

View file

@ -2213,6 +2213,41 @@ paths:
$ref: '#/components/responses/InternalServerError'
default:
$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:
get:
tags:
@ -4667,6 +4702,7 @@ components:
- 3
- 4
- 5
- 6
description: |
Supported event trigger types:
* `1` - Filesystem event
@ -4674,6 +4710,7 @@ components:
* `3` - Schedule
* `4` - IP blocked
* `5` - Certificate renewal
* `6` - On demand, like schedule but executed on demand
LoginMethods:
type: string
enum:

View file

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

View file

@ -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="errorTxt" class="card-body text-form-error"></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-header py-3">
<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">
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Status</th>
<th>Description</th>
@ -48,6 +52,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<tbody>
{{range .Rules}}
<tr>
<td>{{.Trigger}}</td>
<td>{{.Name}}</td>
<td>{{if eq .Status 1 }}Active{{else}}Inactive{{end}}</td>
<td>{{.Description}}</td>
@ -87,6 +92,31 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</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}}
{{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 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() {
var table = $('#dataTable').DataTable();
let table = $('#dataTable').DataTable();
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');
$.ajax({
url: path,
@ -133,7 +203,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
$('#errorMsg').show();
setTimeout(function () {
$('#errorMsg').hide();
}, 5000);
}, 8000);
}
});
}
@ -153,8 +223,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
name: 'edit',
titleAttr: "Edit",
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;
},
enabled: false
@ -170,6 +240,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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({
"select": {
"style": "single",
@ -186,15 +266,21 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
],
"columnDefs": [
{
"targets": [0,1],
"targets": [0],
"visible": false,
"searchable": false,
"className": "noVis"
},
{
"targets": [2],
"targets": [1,2],
"className": "noVis"
},
{
"targets": [3],
"visible": false
},
{
"targets": [4],
"targets": [5],
"render": $.fn.dataTable.render.ellipsis(100, true)
},
],
@ -204,11 +290,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
"language": {
"emptyTable": "No event rules defined"
},
"order": [[0, 'asc']]
"order": [[1, 'asc']]
});
new $.fn.dataTable.FixedHeader( table );
table.button().add(0,'run');
table.button().add(0,'delete');
table.button().add(0,'edit');
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();
table.button('delete: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);
}
});
});

View file

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

View file

@ -204,15 +204,15 @@ function deleteAction() {
titleAttr: 'Quota Scan',
action: function (e, dt, node, config) {
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({
url: path,
type: 'POST',
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
timeout: 15000,
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");
$('#successMsg').show();
setTimeout(function () {