diff --git a/docs/eventmanager.md b/docs/eventmanager.md index d783921f..4aa9fbf4 100644 --- a/docs/eventmanager.md +++ b/docs/eventmanager.md @@ -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 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. diff --git a/go.mod b/go.mod index 03d53763..b99ecd25 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index f62c65d6..ca811659 100644 --- a/go.sum +++ b/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.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= diff --git a/internal/common/eventmanager.go b/internal/common/eventmanager.go index 1e103c84..912f4fb7 100644 --- a/internal/common/eventmanager.go +++ b/internal/common/eventmanager.go @@ -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 diff --git a/internal/common/eventmanager_test.go b/internal/common/eventmanager_test.go index aa3522fd..7c4cf436 100644 --- a/internal/common/eventmanager_test.go +++ b/internal/common/eventmanager_test.go @@ -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 "" diff --git a/internal/dataprovider/eventrule.go b/internal/dataprovider/eventrule.go index 118eae6e..33cb781b 100644 --- a/internal/dataprovider/eventrule.go +++ b/internal/dataprovider/eventrule.go @@ -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 diff --git a/internal/dataprovider/sqlcommon.go b/internal/dataprovider/sqlcommon.go index 4fd9ea39..07163911 100644 --- a/internal/dataprovider/sqlcommon.go +++ b/internal/dataprovider/sqlcommon.go @@ -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 diff --git a/internal/httpd/api_eventrule.go b/internal/httpd/api_eventrule.go index c8d18f3f..b362acc8 100644 --- a/internal/httpd/api_eventrule.go +++ b/internal/httpd/api_eventrule.go @@ -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) } diff --git a/internal/httpd/api_maintenance.go b/internal/httpd/api_maintenance.go index 04cb844c..0eaf23d5 100644 --- a/internal/httpd/api_maintenance.go +++ b/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 { 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 diff --git a/internal/httpd/api_utils.go b/internal/httpd/api_utils.go index 5b2a549c..535e117b 100644 --- a/internal/httpd/api_utils.go +++ b/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 { - 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) { diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index 64ec9dc7..9526dc7a 100644 --- a/internal/httpd/httpd_test.go +++ b/internal/httpd/httpd_test.go @@ -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{ { diff --git a/internal/httpd/server.go b/internal/httpd/server.go index b697f3be..49153ef8 100644 --- a/internal/httpd/server.go +++ b/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)).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). diff --git a/internal/httpdtest/httpdtest.go b/internal/httpdtest/httpdtest.go index 03cc1695..f9b7ab70 100644 --- a/internal/httpdtest/httpdtest.go +++ b/internal/httpdtest/httpdtest.go @@ -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 diff --git a/internal/sftpd/sftpd_test.go b/internal/sftpd/sftpd_test.go index a5d4e5d5..122c66ff 100644 --- a/internal/sftpd/sftpd_test.go +++ b/internal/sftpd/sftpd_test.go @@ -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) diff --git a/internal/util/errors.go b/internal/util/errors.go index dcd48df4..57135fdd 100644 --- a/internal/util/errors.go +++ b/internal/util/errors.go @@ -24,6 +24,14 @@ const ( "sftpgo serve -c \"\"" ) +// 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{ diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index ccf7468d..bb728be2 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -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: diff --git a/templates/webadmin/eventrule.html b/templates/webadmin/eventrule.html index c8529083..932f7e0e 100644 --- a/templates/webadmin/eventrule.html +++ b/templates/webadmin/eventrule.html @@ -193,7 +193,7 @@ along with this program. If not, see . -
+
Name filters
@@ -247,7 +247,7 @@ along with this program. If not, see .
-
+
Group name filters
@@ -301,7 +301,7 @@ along with this program. If not, see .
-
+
Role name filters
@@ -710,21 +710,19 @@ along with this program. If not, see . $('.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}`); diff --git a/templates/webadmin/eventrules.html b/templates/webadmin/eventrules.html index 2e1d1acc..1a1f4f8d 100644 --- a/templates/webadmin/eventrules.html +++ b/templates/webadmin/eventrules.html @@ -29,6 +29,9 @@ along with this program. If not, see . +
View and manage event rules
@@ -38,6 +41,7 @@ along with this program. If not, see . + @@ -48,6 +52,7 @@ along with this program. If not, see . {{range .Rules}} + @@ -87,6 +92,31 @@ along with this program. If not, see . + + {{end}} {{define "extra_js"}} @@ -102,11 +132,51 @@ along with this program. If not, see .
Name Status Description
{{.Trigger}} {{.Name}} {{if eq .Status 1 }}Active{{else}}Inactive{{end}} {{.Description}}