EventManager: add "on-demand" trigger
Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
parent
53f17b5715
commit
7b5bebc588
20 changed files with 410 additions and 49 deletions
|
@ -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.
|
||||
|
||||
|
|
4
go.mod
4
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
|
||||
|
|
8
go.sum
8
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=
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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{
|
||||
{
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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}`);
|
||||
|
|
|
@ -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">×</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);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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 () {
|
||||
|
|
Loading…
Reference in a new issue