diff --git a/README.md b/README.md index 0b734053..93019176 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Several storage backends are supported: local filesystem, encrypted local filesy - Chroot isolation for local accounts. Cloud-based accounts can be restricted to a certain base path. - Per-user and per-directory virtual permissions, for each exposed path you can allow or deny: directory listing, upload, overwrite, download, delete, rename, create directories, create symlinks, change owner/group/file mode and modification time. - [REST API](./docs/rest-api.md) for users and folders management, data retention, backup, restore and real time reports of the active connections with possibility of forcibly closing a connection. +- The [Event Manager](./docs/eventmanager.md) allows to define custom workflows based on server events or schedules. - [Web based administration interface](./docs/web-admin.md) to easily manage users, folders and connections. - [Web client interface](./docs/web-client.md) so that end users can change their credentials, manage and share their files in the browser. - Public key and password authentication. Multiple public keys per-user are supported. diff --git a/cmd/smtptest.go b/cmd/smtptest.go index cf7cd4a8..a3a714e9 100644 --- a/cmd/smtptest.go +++ b/cmd/smtptest.go @@ -34,7 +34,7 @@ If the SMTP configuration is correct you should receive this email.`, logger.ErrorToConsole("unable to initialize SMTP configuration: %v", err) os.Exit(1) } - err = smtp.SendEmail(smtpTestRecipient, "SFTPGo - Testing Email Settings", "It appears your SFTPGo email is setup correctly!", + err = smtp.SendEmail([]string{smtpTestRecipient}, "SFTPGo - Testing Email Settings", "It appears your SFTPGo email is setup correctly!", smtp.EmailContentTypeTextPlain) if err != nil { logger.WarnToConsole("Error sending email: %v", err) diff --git a/common/actions.go b/common/actions.go index ed624341..c06dbf0a 100644 --- a/common/actions.go +++ b/common/actions.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "os/exec" + "path" "path/filepath" "strings" "time" @@ -87,7 +88,8 @@ func ExecuteActionNotification(conn *BaseConnection, operation, filePath, virtua ) error { hasNotifiersPlugin := plugin.Handler.HasNotifiers() hasHook := util.Contains(Config.Actions.ExecuteOn, operation) - if !hasHook && !hasNotifiersPlugin { + hasRules := dataprovider.EventManager.HasFsRules() + if !hasHook && !hasNotifiersPlugin && !hasRules { return nil } notification := newActionNotification(&conn.User, operation, filePath, virtualPath, target, virtualTarget, sshCmd, @@ -95,15 +97,34 @@ func ExecuteActionNotification(conn *BaseConnection, operation, filePath, virtua if hasNotifiersPlugin { plugin.Handler.NotifyFsEvent(notification) } - + var errRes error + if hasRules { + errRes = dataprovider.EventManager.HandleFsEvent(dataprovider.EventParams{ + Name: notification.Username, + Event: notification.Action, + Status: notification.Status, + VirtualPath: notification.VirtualPath, + FsPath: notification.Path, + VirtualTargetPath: notification.VirtualTargetPath, + FsTargetPath: notification.TargetPath, + ObjectName: path.Base(notification.VirtualPath), + FileSize: notification.FileSize, + Protocol: notification.Protocol, + IP: notification.IP, + Timestamp: notification.Timestamp, + Object: nil, + }) + } if hasHook { if util.Contains(Config.Actions.ExecuteSync, operation) { - return actionHandler.Handle(notification) + if errHook := actionHandler.Handle(notification); errHook != nil { + errRes = errHook + } + } else { + go actionHandler.Handle(notification) //nolint:errcheck } - - go actionHandler.Handle(notification) //nolint:errcheck } - return nil + return errRes } // ActionHandler handles a notification for a Protocol Action. @@ -119,7 +140,6 @@ func newActionNotification( err error, ) *notifier.FsEvent { var bucket, endpoint string - status := 1 fsConfig := user.GetFsConfigForPath(virtualPath) @@ -140,12 +160,6 @@ func newActionNotification( endpoint = fsConfig.HTTPConfig.Endpoint } - if err == ErrQuotaExceeded { - status = 3 - } else if err != nil { - status = 2 - } - return ¬ifier.FsEvent{ Action: operation, Username: user.Username, @@ -158,7 +172,7 @@ func newActionNotification( FsProvider: int(fsConfig.Provider), Bucket: bucket, Endpoint: endpoint, - Status: status, + Status: getNotificationStatus(err), Protocol: protocol, IP: ip, SessionID: sessionID, @@ -211,7 +225,7 @@ func (h *defaultActionHandler) handleHTTP(event *notifier.FsEvent) error { } } - logger.Debug(event.Protocol, "", "notified operation %#v to URL: %v status code: %v, elapsed: %v err: %v", + logger.Debug(event.Protocol, "", "notified operation %q to URL: %s status code: %d, elapsed: %s err: %v", event.Action, u.Redacted(), respCode, time.Since(startTime), err) return err @@ -243,22 +257,32 @@ func (h *defaultActionHandler) handleCommand(event *notifier.FsEvent) error { func notificationAsEnvVars(event *notifier.FsEvent) []string { return []string{ - fmt.Sprintf("SFTPGO_ACTION=%v", event.Action), - fmt.Sprintf("SFTPGO_ACTION_USERNAME=%v", event.Username), - fmt.Sprintf("SFTPGO_ACTION_PATH=%v", event.Path), - fmt.Sprintf("SFTPGO_ACTION_TARGET=%v", event.TargetPath), - fmt.Sprintf("SFTPGO_ACTION_VIRTUAL_PATH=%v", event.VirtualPath), - fmt.Sprintf("SFTPGO_ACTION_VIRTUAL_TARGET=%v", event.VirtualTargetPath), - fmt.Sprintf("SFTPGO_ACTION_SSH_CMD=%v", event.SSHCmd), - fmt.Sprintf("SFTPGO_ACTION_FILE_SIZE=%v", event.FileSize), - fmt.Sprintf("SFTPGO_ACTION_FS_PROVIDER=%v", event.FsProvider), - fmt.Sprintf("SFTPGO_ACTION_BUCKET=%v", event.Bucket), - fmt.Sprintf("SFTPGO_ACTION_ENDPOINT=%v", event.Endpoint), - fmt.Sprintf("SFTPGO_ACTION_STATUS=%v", event.Status), - fmt.Sprintf("SFTPGO_ACTION_PROTOCOL=%v", event.Protocol), - fmt.Sprintf("SFTPGO_ACTION_IP=%v", event.IP), - fmt.Sprintf("SFTPGO_ACTION_SESSION_ID=%v", event.SessionID), - fmt.Sprintf("SFTPGO_ACTION_OPEN_FLAGS=%v", event.OpenFlags), - fmt.Sprintf("SFTPGO_ACTION_TIMESTAMP=%v", event.Timestamp), + fmt.Sprintf("SFTPGO_ACTION=%s", event.Action), + fmt.Sprintf("SFTPGO_ACTION_USERNAME=%s", event.Username), + fmt.Sprintf("SFTPGO_ACTION_PATH=%s", event.Path), + fmt.Sprintf("SFTPGO_ACTION_TARGET=%s", event.TargetPath), + fmt.Sprintf("SFTPGO_ACTION_VIRTUAL_PATH=%s", event.VirtualPath), + fmt.Sprintf("SFTPGO_ACTION_VIRTUAL_TARGET=%s", event.VirtualTargetPath), + fmt.Sprintf("SFTPGO_ACTION_SSH_CMD=%s", event.SSHCmd), + fmt.Sprintf("SFTPGO_ACTION_FILE_SIZE=%d", event.FileSize), + fmt.Sprintf("SFTPGO_ACTION_FS_PROVIDER=%d", event.FsProvider), + fmt.Sprintf("SFTPGO_ACTION_BUCKET=%s", event.Bucket), + fmt.Sprintf("SFTPGO_ACTION_ENDPOINT=%s", event.Endpoint), + fmt.Sprintf("SFTPGO_ACTION_STATUS=%d", event.Status), + fmt.Sprintf("SFTPGO_ACTION_PROTOCOL=%s", event.Protocol), + fmt.Sprintf("SFTPGO_ACTION_IP=%s", event.IP), + fmt.Sprintf("SFTPGO_ACTION_SESSION_ID=%s", event.SessionID), + fmt.Sprintf("SFTPGO_ACTION_OPEN_FLAGS=%d", event.OpenFlags), + fmt.Sprintf("SFTPGO_ACTION_TIMESTAMP=%d", event.Timestamp), } } + +func getNotificationStatus(err error) int { + status := 1 + if err == ErrQuotaExceeded { + status = 3 + } else if err != nil { + status = 2 + } + return status +} diff --git a/common/common.go b/common/common.go index fb10bf60..c3df955d 100644 --- a/common/common.go +++ b/common/common.go @@ -124,9 +124,7 @@ var ( // Config is the configuration for the supported protocols Config Configuration // Connections is the list of active connections - Connections ActiveConnections - // QuotaScans is the list of active quota scans - QuotaScans ActiveScans + Connections ActiveConnections transfersChecker TransfersChecker periodicTimeoutTicker *time.Ticker periodicTimeoutTickerDone chan bool @@ -396,11 +394,11 @@ func (t *ConnectionTransfer) getConnectionTransferAsString() string { case operationDownload: result += "DL " } - result += fmt.Sprintf("%#v ", t.VirtualPath) + result += fmt.Sprintf("%q ", t.VirtualPath) if t.Size > 0 { elapsed := time.Since(util.GetTimeFromMsecSinceEpoch(t.StartTime)) speed := float64(t.Size) / float64(util.GetTimeAsMsSinceEpoch(time.Now())-t.StartTime) - result += fmt.Sprintf("Size: %#v Elapsed: %#v Speed: \"%.1f KB/s\"", util.ByteCountIEC(t.Size), + result += fmt.Sprintf("Size: %s Elapsed: %s Speed: \"%.1f KB/s\"", util.ByteCountIEC(t.Size), util.GetDurationAsString(elapsed), speed) } return result @@ -1150,117 +1148,3 @@ func (c *ConnectionStatus) GetTransfersAsString() string { } return result } - -// ActiveQuotaScan defines an active quota scan for a user home dir -type ActiveQuotaScan struct { - // Username to which the quota scan refers - Username string `json:"username"` - // quota scan start time as unix timestamp in milliseconds - StartTime int64 `json:"start_time"` -} - -// ActiveVirtualFolderQuotaScan defines an active quota scan for a virtual folder -type ActiveVirtualFolderQuotaScan struct { - // folder name to which the quota scan refers - Name string `json:"name"` - // quota scan start time as unix timestamp in milliseconds - StartTime int64 `json:"start_time"` -} - -// ActiveScans holds the active quota scans -type ActiveScans struct { - sync.RWMutex - UserScans []ActiveQuotaScan - FolderScans []ActiveVirtualFolderQuotaScan -} - -// GetUsersQuotaScans returns the active quota scans for users home directories -func (s *ActiveScans) GetUsersQuotaScans() []ActiveQuotaScan { - s.RLock() - defer s.RUnlock() - - scans := make([]ActiveQuotaScan, len(s.UserScans)) - copy(scans, s.UserScans) - return scans -} - -// AddUserQuotaScan adds a user to the ones with active quota scans. -// Returns false if the user has a quota scan already running -func (s *ActiveScans) AddUserQuotaScan(username string) bool { - s.Lock() - defer s.Unlock() - - for _, scan := range s.UserScans { - if scan.Username == username { - return false - } - } - s.UserScans = append(s.UserScans, ActiveQuotaScan{ - Username: username, - StartTime: util.GetTimeAsMsSinceEpoch(time.Now()), - }) - return true -} - -// RemoveUserQuotaScan removes a user from the ones with active quota scans. -// Returns false if the user has no active quota scans -func (s *ActiveScans) RemoveUserQuotaScan(username string) bool { - s.Lock() - defer s.Unlock() - - for idx, scan := range s.UserScans { - if scan.Username == username { - lastIdx := len(s.UserScans) - 1 - s.UserScans[idx] = s.UserScans[lastIdx] - s.UserScans = s.UserScans[:lastIdx] - return true - } - } - - return false -} - -// GetVFoldersQuotaScans returns the active quota scans for virtual folders -func (s *ActiveScans) GetVFoldersQuotaScans() []ActiveVirtualFolderQuotaScan { - s.RLock() - defer s.RUnlock() - scans := make([]ActiveVirtualFolderQuotaScan, len(s.FolderScans)) - copy(scans, s.FolderScans) - return scans -} - -// AddVFolderQuotaScan adds a virtual folder to the ones with active quota scans. -// Returns false if the folder has a quota scan already running -func (s *ActiveScans) AddVFolderQuotaScan(folderName string) bool { - s.Lock() - defer s.Unlock() - - for _, scan := range s.FolderScans { - if scan.Name == folderName { - return false - } - } - s.FolderScans = append(s.FolderScans, ActiveVirtualFolderQuotaScan{ - Name: folderName, - StartTime: util.GetTimeAsMsSinceEpoch(time.Now()), - }) - return true -} - -// RemoveVFolderQuotaScan removes a folder from the ones with active quota scans. -// Returns false if the folder has no active quota scans -func (s *ActiveScans) RemoveVFolderQuotaScan(folderName string) bool { - s.Lock() - defer s.Unlock() - - for idx, scan := range s.FolderScans { - if scan.Name == folderName { - lastIdx := len(s.FolderScans) - 1 - s.FolderScans[idx] = s.FolderScans[lastIdx] - s.FolderScans = s.FolderScans[:lastIdx] - return true - } - } - - return false -} diff --git a/common/common_test.go b/common/common_test.go index efc3f8f9..8f66d19e 100644 --- a/common/common_test.go +++ b/common/common_test.go @@ -7,6 +7,7 @@ import ( "net" "os" "os/exec" + "path" "path/filepath" "runtime" "strings" @@ -701,35 +702,6 @@ func TestConnectionStatus(t *testing.T) { assert.Len(t, stats, 0) } -func TestQuotaScans(t *testing.T) { - username := "username" - assert.True(t, QuotaScans.AddUserQuotaScan(username)) - assert.False(t, QuotaScans.AddUserQuotaScan(username)) - usersScans := QuotaScans.GetUsersQuotaScans() - if assert.Len(t, usersScans, 1) { - assert.Equal(t, usersScans[0].Username, username) - assert.Equal(t, QuotaScans.UserScans[0].StartTime, usersScans[0].StartTime) - QuotaScans.UserScans[0].StartTime = 0 - assert.NotEqual(t, QuotaScans.UserScans[0].StartTime, usersScans[0].StartTime) - } - - assert.True(t, QuotaScans.RemoveUserQuotaScan(username)) - assert.False(t, QuotaScans.RemoveUserQuotaScan(username)) - assert.Len(t, QuotaScans.GetUsersQuotaScans(), 0) - assert.Len(t, usersScans, 1) - - folderName := "folder" - assert.True(t, QuotaScans.AddVFolderQuotaScan(folderName)) - assert.False(t, QuotaScans.AddVFolderQuotaScan(folderName)) - if assert.Len(t, QuotaScans.GetVFoldersQuotaScans(), 1) { - assert.Equal(t, QuotaScans.GetVFoldersQuotaScans()[0].Name, folderName) - } - - assert.True(t, QuotaScans.RemoveVFolderQuotaScan(folderName)) - assert.False(t, QuotaScans.RemoveVFolderQuotaScan(folderName)) - assert.Len(t, QuotaScans.GetVFoldersQuotaScans(), 0) -} - func TestProxyProtocolVersion(t *testing.T) { c := Configuration{ ProxyProtocol: 0, @@ -1044,6 +1016,110 @@ func TestUserRecentActivity(t *testing.T) { assert.True(t, res) } +func TestEventRuleMatch(t *testing.T) { + conditions := dataprovider.EventConditions{ + ProviderEvents: []string{"add", "update"}, + Options: dataprovider.ConditionOptions{ + Names: []dataprovider.ConditionPattern{ + { + Pattern: "user1", + InverseMatch: true, + }, + }, + }, + } + res := conditions.ProviderEventMatch(dataprovider.EventParams{ + Name: "user1", + Event: "add", + }) + assert.False(t, res) + res = conditions.ProviderEventMatch(dataprovider.EventParams{ + Name: "user2", + Event: "update", + }) + assert.True(t, res) + res = conditions.ProviderEventMatch(dataprovider.EventParams{ + Name: "user2", + Event: "delete", + }) + assert.False(t, res) + conditions.Options.ProviderObjects = []string{"api_key"} + res = conditions.ProviderEventMatch(dataprovider.EventParams{ + Name: "user2", + Event: "update", + ObjectType: "share", + }) + assert.False(t, res) + res = conditions.ProviderEventMatch(dataprovider.EventParams{ + Name: "user2", + Event: "update", + ObjectType: "api_key", + }) + assert.True(t, res) + // now test fs events + conditions = dataprovider.EventConditions{ + FsEvents: []string{operationUpload, operationDownload}, + Options: dataprovider.ConditionOptions{ + Names: []dataprovider.ConditionPattern{ + { + Pattern: "user*", + }, + { + Pattern: "tester*", + }, + }, + FsPaths: []dataprovider.ConditionPattern{ + { + Pattern: "*.txt", + }, + }, + Protocols: []string{ProtocolSFTP}, + MinFileSize: 10, + MaxFileSize: 30, + }, + } + params := dataprovider.EventParams{ + Name: "tester4", + Event: operationDelete, + VirtualPath: "/path.txt", + Protocol: ProtocolSFTP, + ObjectName: "path.txt", + FileSize: 20, + } + res = conditions.FsEventMatch(params) + assert.False(t, res) + params.Event = operationDownload + res = conditions.FsEventMatch(params) + assert.True(t, res) + params.Name = "name" + res = conditions.FsEventMatch(params) + assert.False(t, res) + params.Name = "user5" + res = conditions.FsEventMatch(params) + assert.True(t, res) + params.VirtualPath = "/sub/f.jpg" + params.ObjectName = path.Base(params.VirtualPath) + res = conditions.FsEventMatch(params) + assert.False(t, res) + params.VirtualPath = "/sub/f.txt" + params.ObjectName = path.Base(params.VirtualPath) + res = conditions.FsEventMatch(params) + assert.True(t, res) + params.Protocol = ProtocolHTTP + res = conditions.FsEventMatch(params) + assert.False(t, res) + params.Protocol = ProtocolSFTP + params.FileSize = 5 + res = conditions.FsEventMatch(params) + assert.False(t, res) + params.FileSize = 50 + res = conditions.FsEventMatch(params) + assert.False(t, res) + params.FileSize = 25 + res = conditions.FsEventMatch(params) + assert.True(t, res) +} + func BenchmarkBcryptHashing(b *testing.B) { bcryptPassword := "bcryptpassword" for i := 0; i < b.N; i++ { diff --git a/common/dataretention.go b/common/dataretention.go index 8fc36001..9457b1b2 100644 --- a/common/dataretention.go +++ b/common/dataretention.go @@ -35,11 +35,11 @@ const ( ) var ( - // RetentionChecks is the list of active quota scans + // RetentionChecks is the list of active retention checks RetentionChecks ActiveRetentionChecks ) -// ActiveRetentionChecks holds the active quota scans +// ActiveRetentionChecks holds the active retention checks type ActiveRetentionChecks struct { sync.RWMutex Checks []RetentionCheck @@ -390,7 +390,7 @@ func (c *RetentionCheck) sendEmailNotification(elapsed time.Duration, errCheck e } startTime := time.Now() subject := fmt.Sprintf("Retention check completed for user %#v", c.conn.User.Username) - if err := smtp.SendEmail(c.Email, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil { + if err := smtp.SendEmail([]string{c.Email}, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil { c.conn.Log(logger.LevelError, "unable to notify retention check result via email: %v, elapsed: %v", err, time.Since(startTime)) return err diff --git a/common/protocol_test.go b/common/protocol_test.go index 8d2a5a39..0b368679 100644 --- a/common/protocol_test.go +++ b/common/protocol_test.go @@ -14,6 +14,7 @@ import ( "path/filepath" "runtime" "strings" + "sync" "testing" "time" @@ -39,6 +40,7 @@ import ( "github.com/drakkan/sftpgo/v2/kms" "github.com/drakkan/sftpgo/v2/logger" "github.com/drakkan/sftpgo/v2/mfa" + "github.com/drakkan/sftpgo/v2/smtp" "github.com/drakkan/sftpgo/v2/util" "github.com/drakkan/sftpgo/v2/vfs" ) @@ -58,10 +60,11 @@ const ( ) var ( - allPerms = []string{dataprovider.PermAny} - homeBasePath string - logFilePath string - testFileContent = []byte("test data") + allPerms = []string{dataprovider.PermAny} + homeBasePath string + logFilePath string + testFileContent = []byte("test data") + lastReceivedEmail receivedEmail ) func TestMain(m *testing.M) { @@ -172,6 +175,7 @@ func TestMain(m *testing.M) { go func() { if err := smtpd.ListenAndServe(smtpServerAddr, func(remoteAddr net.Addr, from string, to []string, data []byte) error { + lastReceivedEmail.set(from, to, data) return nil }, "SFTPGo test", "localhost"); err != nil { logger.ErrorToConsole("could not start SMTP server: %v", err) @@ -2799,6 +2803,279 @@ func TestPasswordCaching(t *testing.T) { assert.False(t, match) } +func TestEventRule(t *testing.T) { + if runtime.GOOS == osWindows { + t.Skip("this test is not available on Windows") + } + smtpCfg := smtp.Config{ + Host: "127.0.0.1", + Port: 2525, + From: "notification@example.com", + TemplatesPath: "templates", + } + err := smtpCfg.Initialize("..") + require.NoError(t, err) + + a1 := dataprovider.BaseEventAction{ + Name: "action1", + Type: dataprovider.ActionTypeHTTP, + Options: dataprovider.BaseEventActionOptions{ + HTTPConfig: dataprovider.EventActionHTTPConfig{ + Endpoint: "http://localhost", + Timeout: 20, + Method: http.MethodGet, + }, + }, + } + a2 := dataprovider.BaseEventAction{ + Name: "action2", + Type: dataprovider.ActionTypeEmail, + Options: dataprovider.BaseEventActionOptions{ + EmailConfig: dataprovider.EventActionEmailConfig{ + Recipients: []string{"test1@example.com", "test2@example.com"}, + Subject: `New "{{Event}}" from "{{Name}}"`, + Body: "Fs path {{FsPath}}, size: {{FileSize}}, protocol: {{Protocol}}, IP: {{IP}}", + }, + }, + } + a3 := dataprovider.BaseEventAction{ + Name: "action3", + Type: dataprovider.ActionTypeEmail, + Options: dataprovider.BaseEventActionOptions{ + EmailConfig: dataprovider.EventActionEmailConfig{ + Recipients: []string{"failure@example.com"}, + Subject: `Failed "{{Event}}" from "{{Name}}"`, + Body: "Fs path {{FsPath}}, protocol: {{Protocol}}, IP: {{IP}}", + }, + }, + } + action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated) + assert.NoError(t, err) + action2, _, err := httpdtest.AddEventAction(a2, http.StatusCreated) + assert.NoError(t, err) + action3, _, err := httpdtest.AddEventAction(a3, http.StatusCreated) + assert.NoError(t, err) + r1 := dataprovider.EventRule{ + Name: "test rule1", + Trigger: dataprovider.EventTriggerFsEvent, + Conditions: dataprovider.EventConditions{ + FsEvents: []string{"upload"}, + Options: dataprovider.ConditionOptions{ + FsPaths: []dataprovider.ConditionPattern{ + { + Pattern: "/subdir/*.dat", + }, + { + Pattern: "*.txt", + }, + }, + }, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, + }, + Order: 1, + Options: dataprovider.EventActionOptions{ + ExecuteSync: true, + StopOnFailure: true, + }, + }, + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action2.Name, + }, + Order: 2, + }, + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action3.Name, + }, + Order: 3, + Options: dataprovider.EventActionOptions{ + IsFailureAction: true, + }, + }, + }, + } + rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated) + assert.NoError(t, err) + + r2 := dataprovider.EventRule{ + Name: "test rule2", + Trigger: dataprovider.EventTriggerFsEvent, + Conditions: dataprovider.EventConditions{ + FsEvents: []string{"download"}, + Options: dataprovider.ConditionOptions{ + FsPaths: []dataprovider.ConditionPattern{ + { + Pattern: "*.dat", + }, + }, + }, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action2.Name, + }, + Order: 1, + }, + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action3.Name, + }, + Order: 2, + Options: dataprovider.EventActionOptions{ + IsFailureAction: true, + }, + }, + }, + } + rule2, _, err := httpdtest.AddEventRule(r2, http.StatusCreated) + assert.NoError(t, err) + + r3 := dataprovider.EventRule{ + Name: "test rule3", + Trigger: dataprovider.EventTriggerProviderEvent, + Conditions: dataprovider.EventConditions{ + ProviderEvents: []string{"delete"}, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action2.Name, + }, + Order: 1, + }, + }, + } + rule3, _, err := httpdtest.AddEventRule(r3, http.StatusCreated) + assert.NoError(t, err) + + uploadScriptPath := filepath.Join(os.TempDir(), "upload.sh") + u := getTestUser() + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + movedFileName := "moved.dat" + movedPath := filepath.Join(user.HomeDir, movedFileName) + err = os.WriteFile(uploadScriptPath, getUploadScriptContent(movedPath, 0), 0755) + assert.NoError(t, err) + + action1.Type = dataprovider.ActionTypeCommand + action1.Options = dataprovider.BaseEventActionOptions{ + CmdConfig: dataprovider.EventActionCommandConfig{ + Cmd: uploadScriptPath, + Timeout: 10, + EnvVars: []dataprovider.KeyValue{ + { + Key: "SFTPGO_ACTION_PATH", + Value: "{{FsPath}}", + }, + }, + }, + } + action1, _, err = httpdtest.UpdateEventAction(action1, http.StatusOK) + assert.NoError(t, err) + + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + size := int64(32768) + // rule conditions does not match + err = writeSFTPFileNoCheck(testFileName, size, client) + assert.NoError(t, err) + info, err := client.Stat(testFileName) + if assert.NoError(t, err) { + assert.Equal(t, size, info.Size()) + } + dirName := "subdir" + err = client.Mkdir(dirName) + assert.NoError(t, err) + // rule conditions match + lastReceivedEmail.reset() + err = writeSFTPFileNoCheck(path.Join(dirName, testFileName), size, client) + assert.NoError(t, err) + _, err = client.Stat(path.Join(dirName, testFileName)) + assert.Error(t, err) + info, err = client.Stat(movedFileName) + if assert.NoError(t, err) { + assert.Equal(t, size, info.Size()) + } + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 3000*time.Millisecond, 100*time.Millisecond) + email := lastReceivedEmail.get() + assert.Len(t, email.To, 2) + assert.True(t, util.Contains(email.To, "test1@example.com")) + assert.True(t, util.Contains(email.To, "test2@example.com")) + assert.Contains(t, string(email.Data), fmt.Sprintf(`Subject: New "upload" from "%s"`, user.Username)) + // remove the upload script to test the failure action + err = os.Remove(uploadScriptPath) + assert.NoError(t, err) + lastReceivedEmail.reset() + err = writeSFTPFileNoCheck(path.Join(dirName, testFileName), size, client) + assert.Error(t, err) + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 3000*time.Millisecond, 100*time.Millisecond) + email = lastReceivedEmail.get() + assert.Len(t, email.To, 1) + assert.True(t, util.Contains(email.To, "failure@example.com")) + assert.Contains(t, string(email.Data), fmt.Sprintf(`Subject: Failed "upload" from "%s"`, user.Username)) + // now test the download rule + lastReceivedEmail.reset() + f, err := client.Open(movedFileName) + assert.NoError(t, err) + contents, err := io.ReadAll(f) + assert.NoError(t, err) + err = f.Close() + assert.NoError(t, err) + assert.Len(t, contents, int(size)) + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 3000*time.Millisecond, 100*time.Millisecond) + email = lastReceivedEmail.get() + assert.Len(t, email.To, 2) + assert.True(t, util.Contains(email.To, "test1@example.com")) + assert.True(t, util.Contains(email.To, "test2@example.com")) + assert.Contains(t, string(email.Data), fmt.Sprintf(`Subject: New "download" from "%s"`, user.Username)) + } + + _, err = httpdtest.RemoveEventRule(rule1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventRule(rule2, http.StatusOK) + assert.NoError(t, err) + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 3000*time.Millisecond, 100*time.Millisecond) + email := lastReceivedEmail.get() + assert.Len(t, email.To, 2) + assert.True(t, util.Contains(email.To, "test1@example.com")) + assert.True(t, util.Contains(email.To, "test2@example.com")) + assert.Contains(t, string(email.Data), `Subject: New "delete" from "admin"`) + _, err = httpdtest.RemoveEventRule(rule3, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action2, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action3, http.StatusOK) + assert.NoError(t, err) + lastReceivedEmail.reset() + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + + smtpCfg = smtp.Config{} + err = smtpCfg.Initialize("..") + require.NoError(t, err) +} + func TestSyncUploadAction(t *testing.T) { if runtime.GOOS == osWindows { t.Skip("this test is not available on Windows") @@ -3865,3 +4142,39 @@ func printLatestLogs(maxNumberOfLines int) { logger.DebugToConsole(line) } } + +type receivedEmail struct { + sync.RWMutex + From string + To []string + Data []byte +} + +func (e *receivedEmail) set(from string, to []string, data []byte) { + e.Lock() + defer e.Unlock() + + e.From = from + e.To = to + e.Data = data +} + +func (e *receivedEmail) reset() { + e.Lock() + defer e.Unlock() + + e.From = "" + e.To = nil + e.Data = nil +} + +func (e *receivedEmail) get() receivedEmail { + e.RLock() + defer e.RUnlock() + + return receivedEmail{ + From: e.From, + To: e.To, + Data: e.Data, + } +} diff --git a/config/config.go b/config/config.go index 5432ca74..4dea5b51 100644 --- a/config/config.go +++ b/config/config.go @@ -342,11 +342,6 @@ func Init() { NamingRules: 1, IsShared: 0, BackupsPath: "backups", - AutoBackup: dataprovider.AutoBackup{ - Enabled: true, - Hour: "0", - DayOfWeek: "*", - }, }, HTTPDConfig: httpd.Conf{ Bindings: []httpd.Binding{defaultHTTPDBinding}, @@ -1919,9 +1914,6 @@ func setViperDefaults() { viper.SetDefault("data_provider.naming_rules", globalConf.ProviderConf.NamingRules) viper.SetDefault("data_provider.is_shared", globalConf.ProviderConf.IsShared) viper.SetDefault("data_provider.backups_path", globalConf.ProviderConf.BackupsPath) - viper.SetDefault("data_provider.auto_backup.enabled", globalConf.ProviderConf.AutoBackup.Enabled) - viper.SetDefault("data_provider.auto_backup.hour", globalConf.ProviderConf.AutoBackup.Hour) - viper.SetDefault("data_provider.auto_backup.day_of_week", globalConf.ProviderConf.AutoBackup.DayOfWeek) viper.SetDefault("httpd.templates_path", globalConf.HTTPDConfig.TemplatesPath) viper.SetDefault("httpd.static_files_path", globalConf.HTTPDConfig.StaticFilesPath) viper.SetDefault("httpd.openapi_path", globalConf.HTTPDConfig.OpenAPIPath) diff --git a/dataprovider/actions.go b/dataprovider/actions.go index 3ca36099..319cf0ad 100644 --- a/dataprovider/actions.go +++ b/dataprovider/actions.go @@ -28,11 +28,13 @@ const ( ) const ( - actionObjectUser = "user" - actionObjectGroup = "group" - actionObjectAdmin = "admin" - actionObjectAPIKey = "api_key" - actionObjectShare = "share" + actionObjectUser = "user" + actionObjectGroup = "group" + actionObjectAdmin = "admin" + actionObjectAPIKey = "api_key" + actionObjectShare = "share" + actionObjectEventAction = "event_action" + actionObjectEventRule = "event_rule" ) func executeAction(operation, executor, ip, objectType, objectName string, object plugin.Renderer) { @@ -46,6 +48,18 @@ func executeAction(operation, executor, ip, objectType, objectName string, objec Timestamp: time.Now().UnixNano(), }, object) } + if EventManager.hasProviderEvents() { + EventManager.handleProviderEvent(EventParams{ + Name: executor, + ObjectName: objectName, + Event: operation, + Status: 1, + ObjectType: objectType, + IP: ip, + Timestamp: time.Now().UnixNano(), + Object: object, + }) + } if config.Actions.Hook == "" { return } @@ -74,7 +88,7 @@ func executeAction(operation, executor, ip, objectType, objectName string, objec q.Add("ip", ip) q.Add("object_type", objectType) q.Add("object_name", objectName) - q.Add("timestamp", fmt.Sprintf("%v", time.Now().UnixNano())) + q.Add("timestamp", fmt.Sprintf("%d", time.Now().UnixNano())) url.RawQuery = q.Encode() startTime := time.Now() resp, err := httpclient.RetryablePost(url.String(), "application/json", bytes.NewBuffer(dataAsJSON)) @@ -104,13 +118,13 @@ func executeNotificationCommand(operation, executor, ip, objectType, objectName cmd := exec.CommandContext(ctx, config.Actions.Hook) cmd.Env = append(env, - fmt.Sprintf("SFTPGO_PROVIDER_ACTION=%v", operation), - fmt.Sprintf("SFTPGO_PROVIDER_OBJECT_TYPE=%v", objectType), - fmt.Sprintf("SFTPGO_PROVIDER_OBJECT_NAME=%v", objectName), - fmt.Sprintf("SFTPGO_PROVIDER_USERNAME=%v", executor), - fmt.Sprintf("SFTPGO_PROVIDER_IP=%v", ip), - fmt.Sprintf("SFTPGO_PROVIDER_TIMESTAMP=%v", util.GetTimeAsMsSinceEpoch(time.Now())), - fmt.Sprintf("SFTPGO_PROVIDER_OBJECT=%v", string(objectAsJSON))) + fmt.Sprintf("SFTPGO_PROVIDER_ACTION=%vs", operation), + fmt.Sprintf("SFTPGO_PROVIDER_OBJECT_TYPE=%s", objectType), + fmt.Sprintf("SFTPGO_PROVIDER_OBJECT_NAME=%s", objectName), + fmt.Sprintf("SFTPGO_PROVIDER_USERNAME=%s", executor), + fmt.Sprintf("SFTPGO_PROVIDER_IP=%s", ip), + fmt.Sprintf("SFTPGO_PROVIDER_TIMESTAMP=%d", util.GetTimeAsMsSinceEpoch(time.Now())), + fmt.Sprintf("SFTPGO_PROVIDER_OBJECT=%s", string(objectAsJSON))) startTime := time.Now() err := cmd.Run() diff --git a/dataprovider/admin.go b/dataprovider/admin.go index aab891f3..c239486a 100644 --- a/dataprovider/admin.go +++ b/dataprovider/admin.go @@ -40,6 +40,7 @@ const ( PermAdminRetentionChecks = "retention_checks" PermAdminMetadataChecks = "metadata_checks" PermAdminViewEvents = "view_events" + PermAdminManageEventRules = "manage_event_rules" ) var ( diff --git a/dataprovider/bolt.go b/dataprovider/bolt.go index 998893b6..d4835ec1 100644 --- a/dataprovider/bolt.go +++ b/dataprovider/bolt.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "path/filepath" + "sort" "time" bolt "go.etcd.io/bbolt" @@ -20,7 +21,7 @@ import ( ) const ( - boltDatabaseVersion = 19 + boltDatabaseVersion = 20 ) var ( @@ -30,10 +31,12 @@ var ( adminsBucket = []byte("admins") apiKeysBucket = []byte("api_keys") sharesBucket = []byte("shares") + actionsBucket = []byte("events_actions") + rulesBucket = []byte("events_rules") dbVersionBucket = []byte("db_version") dbVersionKey = []byte("version") boltBuckets = [][]byte{usersBucket, groupsBucket, foldersBucket, adminsBucket, apiKeysBucket, - sharesBucket, dbVersionBucket} + sharesBucket, actionsBucket, rulesBucket, dbVersionBucket} ) // BoltProvider defines the auth provider for bolt key/value store @@ -629,30 +632,35 @@ func (p *BoltProvider) deleteUser(user User) error { if err != nil { return err } - exists := bucket.Get([]byte(user.Username)) - if exists == nil { - return util.NewRecordNotFoundError(fmt.Sprintf("user %#v does not exist", user.Username)) + var u []byte + if u = bucket.Get([]byte(user.Username)); u == nil { + return util.NewRecordNotFoundError(fmt.Sprintf("username %q does not exist", user.Username)) + } + var oldUser User + err = json.Unmarshal(u, &oldUser) + if err != nil { + return err } - if len(user.VirtualFolders) > 0 { + if len(oldUser.VirtualFolders) > 0 { foldersBucket, err := p.getFoldersBucket(tx) if err != nil { return err } - for idx := range user.VirtualFolders { - err = p.removeRelationFromFolderMapping(user.VirtualFolders[idx], user.Username, "", foldersBucket) + for idx := range oldUser.VirtualFolders { + err = p.removeRelationFromFolderMapping(oldUser.VirtualFolders[idx], oldUser.Username, "", foldersBucket) if err != nil { return err } } } - if len(user.Groups) > 0 { + if len(oldUser.Groups) > 0 { groupBucket, err := p.getGroupsBucket(tx) if err != nil { return err } - for idx := range user.Groups { - err = p.removeUserFromGroupMapping(user.Username, user.Groups[idx].Name, groupBucket) + for idx := range oldUser.Groups { + err = p.removeUserFromGroupMapping(oldUser.Username, oldUser.Groups[idx].Name, groupBucket) if err != nil { return err } @@ -1362,6 +1370,7 @@ func (p *BoltProvider) updateGroup(group *Group) error { } group.ID = oldGroup.ID group.CreatedAt = oldGroup.CreatedAt + group.Users = oldGroup.Users group.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) buf, err := json.Marshal(group) if err != nil { @@ -1932,6 +1941,490 @@ func (p *BoltProvider) cleanupSharedSessions(sessionType SessionType, before int return ErrNotImplemented } +func (p *BoltProvider) getEventActions(limit, offset int, order string, minimal bool) ([]BaseEventAction, error) { + if limit <= 0 { + return nil, nil + } + actions := make([]BaseEventAction, 0, limit) + err := p.dbHandle.View(func(tx *bolt.Tx) error { + bucket, err := p.getActionsBucket(tx) + if err != nil { + return err + } + itNum := 0 + cursor := bucket.Cursor() + if order == OrderASC { + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + itNum++ + if itNum <= offset { + continue + } + var action BaseEventAction + err = json.Unmarshal(v, &action) + if err != nil { + return err + } + action.PrepareForRendering() + actions = append(actions, action) + if len(actions) >= limit { + break + } + } + } else { + for k, v := cursor.Last(); k != nil; k, v = cursor.Prev() { + itNum++ + if itNum <= offset { + continue + } + var action BaseEventAction + err = json.Unmarshal(v, &action) + if err != nil { + return err + } + action.PrepareForRendering() + actions = append(actions, action) + if len(actions) >= limit { + break + } + } + } + return nil + }) + return actions, err +} + +func (p *BoltProvider) dumpEventActions() ([]BaseEventAction, error) { + actions := make([]BaseEventAction, 0, 50) + err := p.dbHandle.View(func(tx *bolt.Tx) error { + bucket, err := p.getActionsBucket(tx) + if err != nil { + return err + } + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var action BaseEventAction + err = json.Unmarshal(v, &action) + if err != nil { + return err + } + actions = append(actions, action) + } + return nil + }) + return actions, err +} + +func (p *BoltProvider) eventActionExists(name string) (BaseEventAction, error) { + var action BaseEventAction + err := p.dbHandle.View(func(tx *bolt.Tx) error { + bucket, err := p.getActionsBucket(tx) + if err != nil { + return err + } + k := bucket.Get([]byte(name)) + if k == nil { + return util.NewRecordNotFoundError(fmt.Sprintf("action %q does not exist", name)) + } + return json.Unmarshal(k, &action) + }) + return action, err +} + +func (p *BoltProvider) addEventAction(action *BaseEventAction) error { + err := action.validate() + if err != nil { + return err + } + return p.dbHandle.Update(func(tx *bolt.Tx) error { + bucket, err := p.getActionsBucket(tx) + if err != nil { + return err + } + if a := bucket.Get([]byte(action.Name)); a != nil { + return fmt.Errorf("event action %s already exists", action.Name) + } + id, err := bucket.NextSequence() + if err != nil { + return err + } + action.ID = int64(id) + action.Rules = nil + buf, err := json.Marshal(action) + if err != nil { + return err + } + return bucket.Put([]byte(action.Name), buf) + }) +} + +func (p *BoltProvider) updateEventAction(action *BaseEventAction) error { + err := action.validate() + if err != nil { + return err + } + return p.dbHandle.Update(func(tx *bolt.Tx) error { + bucket, err := p.getActionsBucket(tx) + if err != nil { + return err + } + var a []byte + + if a = bucket.Get([]byte(action.Name)); a == nil { + return util.NewRecordNotFoundError(fmt.Sprintf("event action %s does not exist", action.Name)) + } + var oldAction BaseEventAction + err = json.Unmarshal(a, &oldAction) + if err != nil { + return err + } + action.ID = oldAction.ID + action.Name = oldAction.Name + action.Rules = nil + if len(oldAction.Rules) > 0 { + rulesBucket, err := p.getRulesBucket(tx) + if err != nil { + return err + } + var relatedRules []string + for _, ruleName := range oldAction.Rules { + r := rulesBucket.Get([]byte(ruleName)) + if r != nil { + relatedRules = append(relatedRules, ruleName) + var rule EventRule + err := json.Unmarshal(r, &rule) + if err != nil { + return err + } + rule.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) + buf, err := json.Marshal(rule) + if err != nil { + return err + } + if err = rulesBucket.Put([]byte(rule.Name), buf); err != nil { + return err + } + setLastRuleUpdate() + } + } + action.Rules = relatedRules + } + buf, err := json.Marshal(action) + if err != nil { + return err + } + return bucket.Put([]byte(action.Name), buf) + }) +} + +func (p *BoltProvider) deleteEventAction(action BaseEventAction) error { + return p.dbHandle.Update(func(tx *bolt.Tx) error { + bucket, err := p.getActionsBucket(tx) + if err != nil { + return err + } + var a []byte + + if a = bucket.Get([]byte(action.Name)); a == nil { + return util.NewRecordNotFoundError(fmt.Sprintf("action %s does not exist", action.Name)) + } + var oldAction BaseEventAction + err = json.Unmarshal(a, &oldAction) + if err != nil { + return err + } + if len(oldAction.Rules) > 0 { + return util.NewValidationError(fmt.Sprintf("action %s is referenced, it cannot be removed", oldAction.Name)) + } + return bucket.Delete([]byte(action.Name)) + }) +} + +func (p *BoltProvider) getEventRules(limit, offset int, order string) ([]EventRule, error) { + if limit <= 0 { + return nil, nil + } + rules := make([]EventRule, 0, limit) + err := p.dbHandle.View(func(tx *bolt.Tx) error { + bucket, err := p.getRulesBucket(tx) + if err != nil { + return err + } + actionsBucket, err := p.getActionsBucket(tx) + if err != nil { + return err + } + itNum := 0 + cursor := bucket.Cursor() + if order == OrderASC { + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + itNum++ + if itNum <= offset { + continue + } + var rule EventRule + rule, err = p.joinRuleAndActions(v, actionsBucket) + if err != nil { + return err + } + rule.PrepareForRendering() + rules = append(rules, rule) + if len(rules) >= limit { + break + } + } + } else { + for k, v := cursor.Last(); k != nil; k, v = cursor.Prev() { + itNum++ + if itNum <= offset { + continue + } + var rule EventRule + rule, err = p.joinRuleAndActions(v, actionsBucket) + if err != nil { + return err + } + rule.PrepareForRendering() + rules = append(rules, rule) + if len(rules) >= limit { + break + } + } + } + return err + }) + return rules, err +} + +func (p *BoltProvider) dumpEventRules() ([]EventRule, error) { + rules := make([]EventRule, 0, 50) + err := p.dbHandle.View(func(tx *bolt.Tx) error { + bucket, err := p.getRulesBucket(tx) + if err != nil { + return err + } + actionsBucket, err := p.getActionsBucket(tx) + if err != nil { + return err + } + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + rule, err := p.joinRuleAndActions(v, actionsBucket) + if err != nil { + return err + } + rules = append(rules, rule) + } + return nil + }) + return rules, err +} + +func (p *BoltProvider) getRecentlyUpdatedRules(after int64) ([]EventRule, error) { + if getLastRuleUpdate() < after { + return nil, nil + } + rules := make([]EventRule, 0, 10) + err := p.dbHandle.View(func(tx *bolt.Tx) error { + bucket, err := p.getRulesBucket(tx) + if err != nil { + return err + } + actionsBucket, err := p.getActionsBucket(tx) + if err != nil { + return err + } + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var rule EventRule + err := json.Unmarshal(v, &rule) + if err != nil { + return err + } + if rule.UpdatedAt < after { + continue + } + var actions []EventAction + for idx := range rule.Actions { + action := &rule.Actions[idx] + var baseAction BaseEventAction + k := actionsBucket.Get([]byte(action.Name)) + if k == nil { + continue + } + err = json.Unmarshal(k, &baseAction) + if err != nil { + continue + } + baseAction.Options.SetEmptySecretsIfNil() + action.BaseEventAction = baseAction + actions = append(actions, *action) + } + rule.Actions = actions + rules = append(rules, rule) + } + return nil + }) + return rules, err +} + +func (p *BoltProvider) eventRuleExists(name string) (EventRule, error) { + var rule EventRule + err := p.dbHandle.View(func(tx *bolt.Tx) error { + bucket, err := p.getRulesBucket(tx) + if err != nil { + return err + } + r := bucket.Get([]byte(name)) + if r == nil { + return util.NewRecordNotFoundError(fmt.Sprintf("event rule %q does not exist", name)) + } + actionsBucket, err := p.getActionsBucket(tx) + if err != nil { + return err + } + rule, err = p.joinRuleAndActions(r, actionsBucket) + return err + }) + return rule, err +} + +func (p *BoltProvider) addEventRule(rule *EventRule) error { + if err := rule.validate(); err != nil { + return err + } + return p.dbHandle.Update(func(tx *bolt.Tx) error { + bucket, err := p.getRulesBucket(tx) + if err != nil { + return err + } + actionsBucket, err := p.getActionsBucket(tx) + if err != nil { + return err + } + if r := bucket.Get([]byte(rule.Name)); r != nil { + return fmt.Errorf("event rule %q already exists", rule.Name) + } + id, err := bucket.NextSequence() + if err != nil { + return err + } + rule.ID = int64(id) + rule.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) + rule.UpdatedAt = rule.CreatedAt + for idx := range rule.Actions { + if err = p.addRuleToActionMapping(rule.Name, rule.Actions[idx].Name, actionsBucket); err != nil { + return err + } + } + sort.Slice(rule.Actions, func(i, j int) bool { + return rule.Actions[i].Order < rule.Actions[j].Order + }) + buf, err := json.Marshal(rule) + if err != nil { + return err + } + err = bucket.Put([]byte(rule.Name), buf) + if err == nil { + setLastRuleUpdate() + } + return err + }) +} + +func (p *BoltProvider) updateEventRule(rule *EventRule) error { + if err := rule.validate(); err != nil { + return err + } + return p.dbHandle.Update(func(tx *bolt.Tx) error { + bucket, err := p.getRulesBucket(tx) + if err != nil { + return err + } + actionsBucket, err := p.getActionsBucket(tx) + if err != nil { + return err + } + var r []byte + if r = bucket.Get([]byte(rule.Name)); r == nil { + return util.NewRecordNotFoundError(fmt.Sprintf("event rule %q does not exist", rule.Name)) + } + var oldRule EventRule + if err = json.Unmarshal(r, &oldRule); err != nil { + return err + } + for idx := range oldRule.Actions { + if err = p.removeRuleFromActionMapping(rule.Name, oldRule.Actions[idx].Name, actionsBucket); err != nil { + return err + } + } + for idx := range rule.Actions { + if err = p.addRuleToActionMapping(rule.Name, rule.Actions[idx].Name, actionsBucket); err != nil { + return err + } + } + rule.ID = oldRule.ID + rule.CreatedAt = oldRule.CreatedAt + rule.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) + buf, err := json.Marshal(rule) + if err != nil { + return err + } + sort.Slice(rule.Actions, func(i, j int) bool { + return rule.Actions[i].Order < rule.Actions[j].Order + }) + err = bucket.Put([]byte(rule.Name), buf) + if err == nil { + setLastRuleUpdate() + } + return err + }) +} + +func (p *BoltProvider) deleteEventRule(rule EventRule, softDelete bool) error { + return p.dbHandle.Update(func(tx *bolt.Tx) error { + bucket, err := p.getRulesBucket(tx) + if err != nil { + return err + } + var r []byte + if r = bucket.Get([]byte(rule.Name)); r == nil { + return util.NewRecordNotFoundError(fmt.Sprintf("event rule %q does not exist", rule.Name)) + } + var oldRule EventRule + if err = json.Unmarshal(r, &oldRule); err != nil { + return err + } + if len(oldRule.Actions) > 0 { + actionsBucket, err := p.getActionsBucket(tx) + if err != nil { + return err + } + for idx := range oldRule.Actions { + if err = p.removeRuleFromActionMapping(rule.Name, oldRule.Actions[idx].Name, actionsBucket); err != nil { + return err + } + } + } + return bucket.Delete([]byte(rule.Name)) + }) +} + +func (p *BoltProvider) getTaskByName(name string) (Task, error) { + return Task{}, ErrNotImplemented +} + +func (p *BoltProvider) addTask(name string) error { + return ErrNotImplemented +} + +func (p *BoltProvider) updateTask(name string, version int64) error { + return ErrNotImplemented +} + +func (p *BoltProvider) updateTaskTimestamp(name string) error { + return ErrNotImplemented +} + func (p *BoltProvider) close() error { return p.dbHandle.Close() } @@ -1959,6 +2452,10 @@ func (p *BoltProvider) migrateDatabase() error { providerLog(logger.LevelError, "%v", err) logger.ErrorToConsole("%v", err) return err + case version == 19: + logger.InfoToConsole(fmt.Sprintf("updating database version: %d -> 20", version)) + providerLog(logger.LevelInfo, "updating database version: %d -> 20", version) + return updateBoltDatabaseVersion(p.dbHandle, 20) default: if version > boltDatabaseVersion { providerLog(logger.LevelError, "database version %v is newer than the supported one: %v", version, @@ -1980,6 +2477,22 @@ func (p *BoltProvider) revertDatabase(targetVersion int) error { return errors.New("current version match target version, nothing to do") } switch dbVersion.Version { + case 20: + logger.InfoToConsole("downgrading database version: 20 -> 19") + providerLog(logger.LevelInfo, "downgrading database version: 20 -> 19") + err := p.dbHandle.Update(func(tx *bolt.Tx) error { + for _, bucketName := range [][]byte{actionsBucket, rulesBucket} { + err := tx.DeleteBucket(bucketName) + if err != nil && !errors.Is(err, bolt.ErrBucketNotFound) { + return err + } + } + return nil + }) + if err != nil { + return err + } + return updateBoltDatabaseVersion(p.dbHandle, 19) default: return fmt.Errorf("database version not handled: %v", dbVersion.Version) } @@ -1997,6 +2510,32 @@ func (p *BoltProvider) resetDatabase() error { }) } +func (p *BoltProvider) joinRuleAndActions(r []byte, actionsBucket *bolt.Bucket) (EventRule, error) { + var rule EventRule + err := json.Unmarshal(r, &rule) + if err != nil { + return rule, err + } + var actions []EventAction + for idx := range rule.Actions { + action := &rule.Actions[idx] + var baseAction BaseEventAction + k := actionsBucket.Get([]byte(action.Name)) + if k == nil { + continue + } + err = json.Unmarshal(k, &baseAction) + if err != nil { + continue + } + baseAction.Options.SetEmptySecretsIfNil() + action.BaseEventAction = baseAction + actions = append(actions, *action) + } + rule.Actions = actions + return rule, nil +} + func (p *BoltProvider) joinGroupAndFolders(g []byte, foldersBucket *bolt.Bucket) (Group, error) { var group Group err := json.Unmarshal(g, &group) @@ -2078,10 +2617,59 @@ func (p *BoltProvider) addFolderInternal(folder vfs.BaseVirtualFolder, bucket *b return bucket.Put([]byte(folder.Name), buf) } +func (p *BoltProvider) addRuleToActionMapping(ruleName, actionName string, bucket *bolt.Bucket) error { + a := bucket.Get([]byte(actionName)) + if a == nil { + return util.NewGenericError(fmt.Sprintf("action %q does not exist", actionName)) + } + var action BaseEventAction + err := json.Unmarshal(a, &action) + if err != nil { + return err + } + if !util.Contains(action.Rules, ruleName) { + action.Rules = append(action.Rules, ruleName) + buf, err := json.Marshal(action) + if err != nil { + return err + } + return bucket.Put([]byte(action.Name), buf) + } + return nil +} + +func (p *BoltProvider) removeRuleFromActionMapping(ruleName, actionName string, bucket *bolt.Bucket) error { + a := bucket.Get([]byte(actionName)) + if a == nil { + providerLog(logger.LevelWarn, "action %q does not exist, cannot remove from mapping", actionName) + return nil + } + var action BaseEventAction + err := json.Unmarshal(a, &action) + if err != nil { + return err + } + if util.Contains(action.Rules, ruleName) { + var rules []string + for _, r := range action.Rules { + if r != ruleName { + rules = append(rules, r) + } + } + action.Rules = util.RemoveDuplicates(rules, false) + buf, err := json.Marshal(action) + if err != nil { + return err + } + return bucket.Put([]byte(action.Name), buf) + } + return nil +} + func (p *BoltProvider) addUserToGroupMapping(username, groupname string, bucket *bolt.Bucket) error { g := bucket.Get([]byte(groupname)) if g == nil { - return util.NewRecordNotFoundError(fmt.Sprintf("group %#v does not exist", groupname)) + return util.NewRecordNotFoundError(fmt.Sprintf("group %q does not exist", groupname)) } var group Group err := json.Unmarshal(g, &group) @@ -2102,7 +2690,7 @@ func (p *BoltProvider) addUserToGroupMapping(username, groupname string, bucket func (p *BoltProvider) removeUserFromGroupMapping(username, groupname string, bucket *bolt.Bucket) error { g := bucket.Get([]byte(groupname)) if g == nil { - return util.NewRecordNotFoundError(fmt.Sprintf("group %#v does not exist", groupname)) + return util.NewRecordNotFoundError(fmt.Sprintf("group %q does not exist", groupname)) } var group Group err := json.Unmarshal(g, &group) @@ -2116,7 +2704,7 @@ func (p *BoltProvider) removeUserFromGroupMapping(username, groupname string, bu users = append(users, u) } } - group.Users = users + group.Users = util.RemoveDuplicates(users, false) buf, err := json.Marshal(group) if err != nil { return err @@ -2372,7 +2960,7 @@ func (p *BoltProvider) getGroupsBucket(tx *bolt.Tx) (*bolt.Bucket, error) { var err error bucket := tx.Bucket(groupsBucket) if bucket == nil { - err = fmt.Errorf("unable to find groups buckets, bolt database structure not correcly defined") + err = fmt.Errorf("unable to find groups bucket, bolt database structure not correcly defined") } return bucket, err } @@ -2381,7 +2969,25 @@ func (p *BoltProvider) getFoldersBucket(tx *bolt.Tx) (*bolt.Bucket, error) { var err error bucket := tx.Bucket(foldersBucket) if bucket == nil { - err = fmt.Errorf("unable to find folders buckets, bolt database structure not correcly defined") + err = fmt.Errorf("unable to find folders bucket, bolt database structure not correcly defined") + } + return bucket, err +} + +func (p *BoltProvider) getActionsBucket(tx *bolt.Tx) (*bolt.Bucket, error) { + var err error + bucket := tx.Bucket(actionsBucket) + if bucket == nil { + err = fmt.Errorf("unable to find event actions bucket, bolt database structure not correcly defined") + } + return bucket, err +} + +func (p *BoltProvider) getRulesBucket(tx *bolt.Tx) (*bolt.Bucket, error) { + var err error + bucket := tx.Bucket(rulesBucket) + if bucket == nil { + err = fmt.Errorf("unable to find event rules bucket, bolt database structure not correcly defined") } return bucket, err } @@ -2405,7 +3011,7 @@ func getBoltDatabaseVersion(dbHandle *bolt.DB) (schemaVersion, error) { return dbVersion, err } -/*func updateBoltDatabaseVersion(dbHandle *bolt.DB, version int) error { +func updateBoltDatabaseVersion(dbHandle *bolt.DB, version int) error { err := dbHandle.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket(dbVersionBucket) if bucket == nil { @@ -2421,4 +3027,4 @@ func getBoltDatabaseVersion(dbHandle *bolt.DB) (schemaVersion, error) { return bucket.Put(dbVersionKey, buf) }) return err -}*/ +} diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index c4d0d307..be38b114 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -72,7 +72,7 @@ const ( CockroachDataProviderName = "cockroachdb" // DumpVersion defines the version for the dump. // For restore/load we support the current version and the previous one - DumpVersion = 12 + DumpVersion = 13 argonPwdPrefix = "$argon2id$" bcryptPwdPrefix = "$2a$" @@ -168,6 +168,10 @@ var ( sqlTableUsersGroupsMapping string sqlTableGroupsFoldersMapping string sqlTableSharedSessions string + sqlTableEventsActions string + sqlTableEventsRules string + sqlTableRulesActionsMapping string + sqlTableTasks string sqlTableSchemaVersion string argon2Params *argon2id.Params lastLoginMinDelay = 10 * time.Minute @@ -189,6 +193,10 @@ func initSQLTables() { sqlTableUsersGroupsMapping = "users_groups_mapping" sqlTableGroupsFoldersMapping = "groups_folders_mapping" sqlTableSharedSessions = "shared_sessions" + sqlTableEventsActions = "events_actions" + sqlTableEventsRules = "events_rules" + sqlTableRulesActionsMapping = "rules_actions_mapping" + sqlTableTasks = "tasks" sqlTableSchemaVersion = "schema_version" } @@ -250,24 +258,6 @@ type ProviderStatus struct { Error string `json:"error"` } -// AutoBackup defines the settings for automatic provider backups. -// Example: hour "0" and day_of_week "*" means a backup every day at midnight. -// The backup file name is in the format backup__.json -// files with the same name will be overwritten -type AutoBackup struct { - Enabled bool `json:"enabled" mapstructure:"enabled"` - // hour as standard cron expression. Allowed values: 0-23. - // Allowed special characters: asterisk (*), slash (/), comma (,), hyphen (-). - // More info about special characters here: - // https://pkg.go.dev/github.com/robfig/cron#hdr-Special_Characters - Hour string `json:"hour" mapstructure:"hour"` - // Day of the week as cron expression. Allowed values: 0-6 (Sunday to Saturday). - // Allowed special characters: asterisk (*), slash (/), comma (,), hyphen (-), question mark (?). - // More info about special characters here: - // https://pkg.go.dev/github.com/robfig/cron#hdr-Special_Characters - DayOfWeek string `json:"day_of_week" mapstructure:"day_of_week"` -} - // Config provider configuration type Config struct { // Driver name, must be one of the SupportedProviders @@ -417,8 +407,6 @@ type Config struct { IsShared int `json:"is_shared" mapstructure:"is_shared"` // Path to the backup directory. This can be an absolute path or a path relative to the config dir BackupsPath string `json:"backups_path" mapstructure:"backups_path"` - // Settings for automatic backups - AutoBackup AutoBackup `json:"auto_backup" mapstructure:"auto_backup"` } // GetShared returns the provider share mode @@ -430,7 +418,7 @@ func (c *Config) GetShared() int { } func (c *Config) convertName(name string) string { - if c.NamingRules == 0 { + if c.NamingRules <= 1 { return name } if c.NamingRules&2 != 0 { @@ -464,31 +452,32 @@ func (c *Config) requireCustomTLSForMySQL() bool { return false } -func (c *Config) doBackup() { - now := time.Now() - outputFile := filepath.Join(c.BackupsPath, fmt.Sprintf("backup_%v_%v.json", now.Weekday(), now.Hour())) - providerLog(logger.LevelDebug, "starting auto backup to file %#v", outputFile) +func (c *Config) doBackup() error { + now := time.Now().UTC() + outputFile := filepath.Join(c.BackupsPath, fmt.Sprintf("backup_%s_%d.json", now.Weekday(), now.Hour())) + eventManagerLog(logger.LevelDebug, "starting backup to file %q", outputFile) err := os.MkdirAll(filepath.Dir(outputFile), 0700) if err != nil { - providerLog(logger.LevelError, "unable to create backup dir %#v: %v", outputFile, err) - return + eventManagerLog(logger.LevelError, "unable to create backup dir %q: %v", outputFile, err) + return fmt.Errorf("unable to create backup dir: %w", err) } backup, err := DumpData() if err != nil { - providerLog(logger.LevelError, "unable to execute backup: %v", err) - return + eventManagerLog(logger.LevelError, "unable to execute backup: %v", err) + return fmt.Errorf("unable to dump backup data: %w", err) } dump, err := json.Marshal(backup) if err != nil { - providerLog(logger.LevelError, "unable to marshal backup as JSON: %v", err) - return + eventManagerLog(logger.LevelError, "unable to marshal backup as JSON: %v", err) + return fmt.Errorf("unable to marshal backup data as JSON: %w", err) } err = os.WriteFile(outputFile, dump, 0600) if err != nil { - providerLog(logger.LevelError, "unable to save backup: %v", err) - return + eventManagerLog(logger.LevelError, "unable to save backup: %v", err) + return fmt.Errorf("unable to save backup: %w", err) } - providerLog(logger.LevelDebug, "auto backup saved to %#v", outputFile) + eventManagerLog(logger.LevelDebug, "auto backup saved to %q", outputFile) + return nil } // ConvertName converts the given name based on the configured rules @@ -586,13 +575,15 @@ func (d *DefenderEntry) MarshalJSON() ([]byte, error) { // BackupData defines the structure for the backup/restore files type BackupData struct { - Users []User `json:"users"` - Groups []Group `json:"groups"` - Folders []vfs.BaseVirtualFolder `json:"folders"` - Admins []Admin `json:"admins"` - APIKeys []APIKey `json:"api_keys"` - Shares []Share `json:"shares"` - Version int `json:"version"` + Users []User `json:"users"` + Groups []Group `json:"groups"` + Folders []vfs.BaseVirtualFolder `json:"folders"` + Admins []Admin `json:"admins"` + APIKeys []APIKey `json:"api_keys"` + Shares []Share `json:"shares"` + EventActions []BaseEventAction `json:"event_actions"` + EventRules []EventRule `json:"event_rules"` + Version int `json:"version"` } // HasFolder returns true if the folder with the given name is included @@ -704,6 +695,23 @@ type Provider interface { deleteSharedSession(key string) error getSharedSession(key string) (Session, error) cleanupSharedSessions(sessionType SessionType, before int64) error + getEventActions(limit, offset int, order string, minimal bool) ([]BaseEventAction, error) + dumpEventActions() ([]BaseEventAction, error) + eventActionExists(name string) (BaseEventAction, error) + addEventAction(action *BaseEventAction) error + updateEventAction(action *BaseEventAction) error + deleteEventAction(action BaseEventAction) error + getEventRules(limit, offset int, order string) ([]EventRule, error) + dumpEventRules() ([]EventRule, error) + getRecentlyUpdatedRules(after int64) ([]EventRule, error) + eventRuleExists(name string) (EventRule, error) + addEventRule(rule *EventRule) error + updateEventRule(rule *EventRule) error + deleteEventRule(rule EventRule, softDelete bool) error + getTaskByName(name string) (Task, error) + addTask(name string) error + updateTask(name string, version int64) error + updateTaskTimestamp(name string) error checkAvailability() error close() error reloadConfig() error @@ -851,13 +859,19 @@ func validateSQLTablesPrefix() error { sqlTableUsersGroupsMapping = config.SQLTablesPrefix + sqlTableUsersGroupsMapping sqlTableGroupsFoldersMapping = config.SQLTablesPrefix + sqlTableGroupsFoldersMapping sqlTableSharedSessions = config.SQLTablesPrefix + sqlTableSharedSessions + sqlTableEventsActions = config.SQLTablesPrefix + sqlTableEventsActions + sqlTableEventsRules = config.SQLTablesPrefix + sqlTableEventsRules + sqlTableRulesActionsMapping = config.SQLTablesPrefix + sqlTableRulesActionsMapping + sqlTableTasks = config.SQLTablesPrefix + sqlTableTasks sqlTableSchemaVersion = config.SQLTablesPrefix + sqlTableSchemaVersion providerLog(logger.LevelDebug, "sql table for users %q, folders %q users folders mapping %q admins %q "+ "api keys %q shares %q defender hosts %q defender events %q transfers %q groups %q "+ - "users groups mapping %q groups folders mapping %q shared sessions %q schema version %q", + "users groups mapping %q groups folders mapping %q shared sessions %q schema version %q"+ + "events actions %q events rules %q rules actions mapping %q tasks %q", sqlTableUsers, sqlTableFolders, sqlTableUsersFoldersMapping, sqlTableAdmins, sqlTableAPIKeys, sqlTableShares, sqlTableDefenderHosts, sqlTableDefenderEvents, sqlTableActiveTransfers, sqlTableGroups, - sqlTableUsersGroupsMapping, sqlTableGroupsFoldersMapping, sqlTableSharedSessions, sqlTableSchemaVersion) + sqlTableUsersGroupsMapping, sqlTableGroupsFoldersMapping, sqlTableSharedSessions, sqlTableSchemaVersion, + sqlTableEventsActions, sqlTableEventsRules, sqlTableRulesActionsMapping, sqlTableTasks) } return nil } @@ -1468,6 +1482,102 @@ func APIKeyExists(keyID string) (APIKey, error) { return provider.apiKeyExists(keyID) } +// GetEventActions returns an array of event actions respecting limit and offset +func GetEventActions(limit, offset int, order string, minimal bool) ([]BaseEventAction, error) { + return provider.getEventActions(limit, offset, order, minimal) +} + +// EventActionExists returns the event action with the given name if it exists +func EventActionExists(name string) (BaseEventAction, error) { + name = config.convertName(name) + return provider.eventActionExists(name) +} + +// AddEventAction adds a new event action +func AddEventAction(action *BaseEventAction, executor, ipAddress string) error { + action.Name = config.convertName(action.Name) + err := provider.addEventAction(action) + if err == nil { + executeAction(operationAdd, executor, ipAddress, actionObjectEventAction, action.Name, action) + } + return err +} + +// UpdateEventAction updates an existing event action +func UpdateEventAction(action *BaseEventAction, executor, ipAddress string) error { + err := provider.updateEventAction(action) + if err == nil { + EventManager.loadRules() + executeAction(operationUpdate, executor, ipAddress, actionObjectEventAction, action.Name, action) + } + return err +} + +// DeleteEventAction deletes an existing event action +func DeleteEventAction(name string, executor, ipAddress string) error { + name = config.convertName(name) + action, err := provider.eventActionExists(name) + if err != nil { + return err + } + if len(action.Rules) > 0 { + errorString := fmt.Sprintf("the event action %#q is referenced, it cannot be removed", action.Name) + return util.NewValidationError(errorString) + } + err = provider.deleteEventAction(action) + if err == nil { + executeAction(operationDelete, executor, ipAddress, actionObjectEventAction, action.Name, &action) + } + return err +} + +// GetEventRules returns an array of event rules respecting limit and offset +func GetEventRules(limit, offset int, order string) ([]EventRule, error) { + return provider.getEventRules(limit, offset, order) +} + +// EventRuleExists returns the event rule with the given name if it exists +func EventRuleExists(name string) (EventRule, error) { + name = config.convertName(name) + return provider.eventRuleExists(name) +} + +// AddEventRule adds a new event rule +func AddEventRule(rule *EventRule, executor, ipAddress string) error { + rule.Name = config.convertName(rule.Name) + err := provider.addEventRule(rule) + if err == nil { + EventManager.loadRules() + executeAction(operationAdd, executor, ipAddress, actionObjectEventRule, rule.Name, rule) + } + return err +} + +// UpdateEventRule updates an existing event rule +func UpdateEventRule(rule *EventRule, executor, ipAddress string) error { + err := provider.updateEventRule(rule) + if err == nil { + EventManager.loadRules() + executeAction(operationUpdate, executor, ipAddress, actionObjectEventRule, rule.Name, rule) + } + return err +} + +// DeleteEventRule deletes an existing event rule +func DeleteEventRule(name string, executor, ipAddress string) error { + name = config.convertName(name) + rule, err := provider.eventRuleExists(name) + if err != nil { + return err + } + err = provider.deleteEventRule(rule, config.GetShared() == 1) + if err == nil { + EventManager.RemoveRule(rule.Name) + executeAction(operationDelete, executor, ipAddress, actionObjectEventRule, rule.Name, &rule) + } + return err +} + // HasAdmin returns true if the first admin has been created // and so SFTPGo is ready to be used func HasAdmin() bool { @@ -1794,7 +1904,7 @@ func GetFolders(limit, offset int, order string, minimal bool) ([]vfs.BaseVirtua return provider.getFolders(limit, offset, order, minimal) } -// DumpData returns all users, folders, admins, api keys, shares +// DumpData returns all users, groups, folders, admins, api keys, shares, actions, rules func DumpData() (BackupData, error) { var data BackupData groups, err := provider.dumpGroups() @@ -1821,12 +1931,22 @@ func DumpData() (BackupData, error) { if err != nil { return data, err } + actions, err := provider.dumpEventActions() + if err != nil { + return data, err + } + rules, err := provider.dumpEventRules() + if err != nil { + return data, err + } data.Users = users data.Groups = groups data.Folders = folders data.Admins = admins data.APIKeys = apiKeys data.Shares = shares + data.EventActions = actions + data.EventRules = rules data.Version = DumpVersion return data, err } diff --git a/dataprovider/eventrule.go b/dataprovider/eventrule.go new file mode 100644 index 00000000..8fccfc1e --- /dev/null +++ b/dataprovider/eventrule.go @@ -0,0 +1,1096 @@ +package dataprovider + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + "time" + + "github.com/robfig/cron/v3" + + "github.com/drakkan/sftpgo/v2/kms" + "github.com/drakkan/sftpgo/v2/logger" + "github.com/drakkan/sftpgo/v2/smtp" + "github.com/drakkan/sftpgo/v2/util" + "github.com/drakkan/sftpgo/v2/vfs" +) + +// Supported event actions +const ( + ActionTypeHTTP = iota + 1 + ActionTypeCommand + ActionTypeEmail + ActionTypeBackup + ActionTypeUserQuotaReset + ActionTypeFolderQuotaReset + ActionTypeTransferQuotaReset +) + +var ( + supportedEventActions = []int{ActionTypeHTTP, ActionTypeCommand, ActionTypeEmail, ActionTypeBackup, + ActionTypeUserQuotaReset, ActionTypeFolderQuotaReset, ActionTypeTransferQuotaReset} +) + +func isActionTypeValid(action int) bool { + return util.Contains(supportedEventActions, action) +} + +func getActionTypeAsString(action int) string { + switch action { + case ActionTypeHTTP: + return "HTTP" + case ActionTypeEmail: + return "Email" + case ActionTypeBackup: + return "Backup" + case ActionTypeUserQuotaReset: + return "User quota reset" + case ActionTypeFolderQuotaReset: + return "Folder quota reset" + case ActionTypeTransferQuotaReset: + return "Transfer quota reset" + default: + return "Command" + } +} + +// Supported event triggers +const ( + // Filesystem events such as upload, download, mkdir ... + EventTriggerFsEvent = iota + 1 + // Provider events such as add, update, delete + EventTriggerProviderEvent + EventTriggerSchedule +) + +var ( + supportedEventTriggers = []int{EventTriggerFsEvent, EventTriggerProviderEvent, EventTriggerSchedule} +) + +func isEventTriggerValid(trigger int) bool { + return util.Contains(supportedEventTriggers, trigger) +} + +func getTriggerTypeAsString(trigger int) string { + switch trigger { + case EventTriggerFsEvent: + return "Filesystem event" + case EventTriggerProviderEvent: + return "Provider event" + default: + return "Schedule" + } +} + +// TODO: replace the copied strings with shared constants +var ( + // SupportedFsEvents defines the supported filesystem events + SupportedFsEvents = []string{"upload", "download", "delete", "rename", "mkdir", "rmdir", "ssh_cmd"} + // SupportedProviderEvents defines the supported provider events + SupportedProviderEvents = []string{operationAdd, operationUpdate, operationDelete} + // SupportedRuleConditionProtocols defines the supported protcols for rule conditions + SupportedRuleConditionProtocols = []string{"SFTP", "SCP", "SSH", "FTP", "DAV", "HTTP", "HTTPShare", + "OIDC"} + // SupporteRuleConditionProviderObjects defines the supported provider objects for rule conditions + SupporteRuleConditionProviderObjects = []string{actionObjectUser, actionObjectGroup, actionObjectAdmin, + actionObjectAPIKey, actionObjectShare, actionObjectEventRule, actionObjectEventAction} + // SupportedHTTPActionMethods defines the supported methods for HTTP actions + SupportedHTTPActionMethods = []string{http.MethodPost, http.MethodGet, http.MethodPut} +) + +// enum mappings +var ( + EventActionTypes []EnumMapping + EventTriggerTypes []EnumMapping +) + +func init() { + for _, t := range supportedEventActions { + EventActionTypes = append(EventActionTypes, EnumMapping{ + Value: t, + Name: getActionTypeAsString(t), + }) + } + for _, t := range supportedEventTriggers { + EventTriggerTypes = append(EventTriggerTypes, EnumMapping{ + Value: t, + Name: getTriggerTypeAsString(t), + }) + } +} + +// EnumMapping defines a mapping between enum values and names +type EnumMapping struct { + Name string + Value int +} + +// KeyValue defines a key/value pair +type KeyValue struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// EventActionHTTPConfig defines the configuration for an HTTP event target +type EventActionHTTPConfig struct { + Endpoint string `json:"endpoint"` + Username string `json:"username,omitempty"` + Password *kms.Secret `json:"password,omitempty"` + Headers []KeyValue `json:"headers,omitempty"` + Timeout int `json:"timeout"` + SkipTLSVerify bool `json:"skip_tls_verify,omitempty"` + Method string `json:"method"` + QueryParameters []KeyValue `json:"query_parameters,omitempty"` + Body string `json:"post_body,omitempty"` +} + +func (c *EventActionHTTPConfig) validate(additionalData string) error { + if c.Endpoint == "" { + return util.NewValidationError("HTTP endpoint is required") + } + if !util.IsStringPrefixInSlice(c.Endpoint, []string{"http://", "https://"}) { + return util.NewValidationError("invalid HTTP endpoint schema: http and https are supported") + } + if c.Timeout < 1 || c.Timeout > 120 { + return util.NewValidationError(fmt.Sprintf("invalid HTTP timeout %d", c.Timeout)) + } + for _, kv := range c.Headers { + if kv.Key == "" || kv.Value == "" { + return util.NewValidationError("invalid HTTP headers") + } + } + if c.Password.IsRedacted() { + return util.NewValidationError("cannot save HTTP configuration with a redacted secret") + } + if c.Password.IsPlain() { + c.Password.SetAdditionalData(additionalData) + err := c.Password.Encrypt() + if err != nil { + return util.NewValidationError(fmt.Sprintf("could not encrypt HTTP password: %v", err)) + } + } + if !util.Contains(SupportedHTTPActionMethods, c.Method) { + return util.NewValidationError(fmt.Sprintf("unsupported HTTP method: %s", c.Method)) + } + for _, kv := range c.QueryParameters { + if kv.Key == "" || kv.Value == "" { + return util.NewValidationError("invalid HTTP query parameters") + } + } + return nil +} + +func (c *EventActionHTTPConfig) getEndpoint(replacer *strings.Replacer) (string, error) { + if len(c.QueryParameters) > 0 { + u, err := url.Parse(c.Endpoint) + if err != nil { + return "", fmt.Errorf("invalid endpoint: %w", err) + } + q := u.Query() + + for _, keyVal := range c.QueryParameters { + q.Add(keyVal.Key, replaceWithReplacer(keyVal.Value, replacer)) + } + + u.RawQuery = q.Encode() + return u.String(), nil + } + return c.Endpoint, nil +} + +func (c *EventActionHTTPConfig) getHTTPClient() *http.Client { + client := &http.Client{ + Timeout: time.Duration(c.Timeout) * time.Second, + } + if c.SkipTLSVerify { + transport := http.DefaultTransport.(*http.Transport).Clone() + if transport.TLSClientConfig != nil { + transport.TLSClientConfig.InsecureSkipVerify = true + } else { + transport.TLSClientConfig = &tls.Config{ + NextProtos: []string{"http/1.1", "h2"}, + InsecureSkipVerify: true, + } + } + client.Transport = transport + } + return client +} + +func (c *EventActionHTTPConfig) execute(params EventParams) error { + if !c.Password.IsEmpty() { + if err := c.Password.TryDecrypt(); err != nil { + return fmt.Errorf("unable to decrypt password: %w", err) + } + } + addObjectData := false + if params.Object != nil { + if !addObjectData { + if strings.Contains(c.Body, "{{ObjectData}}") { + addObjectData = true + } + } + } + + replacements := params.getStringReplacements(addObjectData) + replacer := strings.NewReplacer(replacements...) + endpoint, err := c.getEndpoint(replacer) + if err != nil { + return err + } + + var body io.Reader + if c.Body != "" && c.Method != http.MethodGet { + body = bytes.NewBufferString(replaceWithReplacer(c.Body, replacer)) + } + req, err := http.NewRequest(c.Method, endpoint, body) + if err != nil { + return err + } + if c.Username != "" { + req.SetBasicAuth(replaceWithReplacer(c.Username, replacer), c.Password.GetAdditionalData()) + } + for _, keyVal := range c.Headers { + req.Header.Set(keyVal.Key, replaceWithReplacer(keyVal.Value, replacer)) + } + client := c.getHTTPClient() + defer client.CloseIdleConnections() + + startTime := time.Now() + resp, err := client.Do(req) + if err != nil { + eventManagerLog(logger.LevelDebug, "unable to send http notification, endpoint: %s, elapsed: %s, err: %v", + endpoint, time.Since(startTime), err) + return err + } + defer resp.Body.Close() + + eventManagerLog(logger.LevelDebug, "http notification sent, endopoint: %s, elapsed: %s, status code: %d", + endpoint, time.Since(startTime), resp.StatusCode) + if resp.StatusCode < http.StatusOK || resp.StatusCode > http.StatusNoContent { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return nil +} + +// EventActionCommandConfig defines the configuration for a command event target +type EventActionCommandConfig struct { + Cmd string `json:"cmd"` + Timeout int `json:"timeout"` + EnvVars []KeyValue `json:"env_vars"` +} + +func (c *EventActionCommandConfig) validate() error { + if c.Cmd == "" { + return util.NewValidationError("command is required") + } + if !filepath.IsAbs(c.Cmd) { + return util.NewValidationError("invalid command, it must be an absolute path") + } + if c.Timeout < 1 || c.Timeout > 120 { + return util.NewValidationError(fmt.Sprintf("invalid command action timeout %d", c.Timeout)) + } + for _, kv := range c.EnvVars { + if kv.Key == "" || kv.Value == "" { + return util.NewValidationError("invalid command env vars") + } + } + return nil +} + +func (c *EventActionCommandConfig) getEnvVars(params EventParams) []string { + envVars := make([]string, 0, len(c.EnvVars)) + addObjectData := false + if params.Object != nil { + for _, k := range c.EnvVars { + if strings.Contains(k.Value, "{{ObjectData}}") { + addObjectData = true + break + } + } + } + replacements := params.getStringReplacements(addObjectData) + replacer := strings.NewReplacer(replacements...) + for _, keyVal := range c.EnvVars { + envVars = append(envVars, fmt.Sprintf("%s=%s", keyVal.Key, replaceWithReplacer(keyVal.Value, replacer))) + } + + return envVars +} + +func (c *EventActionCommandConfig) execute(params EventParams) error { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(c.Timeout)*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, c.Cmd) + cmd.Env = append(cmd.Env, os.Environ()...) + cmd.Env = append(cmd.Env, c.getEnvVars(params)...) + + startTime := time.Now() + err := cmd.Run() + + eventManagerLog(logger.LevelDebug, "executed command %q, elapsed: %s, error: %v", + c.Cmd, time.Since(startTime), err) + + return err +} + +// EventActionEmailConfig defines the configuration options for SMTP event actions +type EventActionEmailConfig struct { + Recipients []string `json:"recipients"` + Subject string `json:"subject"` + Body string `json:"body"` +} + +// GetRecipientsAsString returns the list of recipients as comma separated string +func (o EventActionEmailConfig) GetRecipientsAsString() string { + return strings.Join(o.Recipients, ",") +} + +func (o *EventActionEmailConfig) validate() error { + if len(o.Recipients) == 0 { + return util.NewValidationError("at least one email recipient is required") + } + o.Recipients = util.RemoveDuplicates(o.Recipients, false) + for _, r := range o.Recipients { + if r == "" { + return util.NewValidationError("invalid email recipients") + } + } + if o.Subject == "" { + return util.NewValidationError("email subject is required") + } + if o.Body == "" { + return util.NewValidationError("email body is required") + } + return nil +} + +func (o *EventActionEmailConfig) execute(params EventParams) error { + addObjectData := false + if params.Object != nil { + if strings.Contains(o.Body, "{{ObjectData}}") { + addObjectData = true + } + } + replacements := params.getStringReplacements(addObjectData) + replacer := strings.NewReplacer(replacements...) + body := replaceWithReplacer(o.Body, replacer) + subject := replaceWithReplacer(o.Subject, replacer) + startTime := time.Now() + err := smtp.SendEmail(o.Recipients, subject, body, smtp.EmailContentTypeTextPlain) + eventManagerLog(logger.LevelDebug, "executed email notification action, elapsed: %s, error: %v", + time.Since(startTime), err) + return err +} + +// BaseEventActionOptions defines the supported configuration options for a base event actions +type BaseEventActionOptions struct { + HTTPConfig EventActionHTTPConfig `json:"http_config"` + CmdConfig EventActionCommandConfig `json:"cmd_config"` + EmailConfig EventActionEmailConfig `json:"email_config"` +} + +func (o *BaseEventActionOptions) getACopy() BaseEventActionOptions { + o.SetEmptySecretsIfNil() + emailRecipients := make([]string, len(o.EmailConfig.Recipients)) + copy(emailRecipients, o.EmailConfig.Recipients) + + return BaseEventActionOptions{ + HTTPConfig: EventActionHTTPConfig{ + Endpoint: o.HTTPConfig.Endpoint, + Username: o.HTTPConfig.Username, + Password: o.HTTPConfig.Password.Clone(), + Headers: cloneKeyValues(o.HTTPConfig.Headers), + Timeout: o.HTTPConfig.Timeout, + SkipTLSVerify: o.HTTPConfig.SkipTLSVerify, + Method: o.HTTPConfig.Method, + QueryParameters: cloneKeyValues(o.HTTPConfig.QueryParameters), + Body: o.HTTPConfig.Body, + }, + CmdConfig: EventActionCommandConfig{ + Cmd: o.CmdConfig.Cmd, + Timeout: o.CmdConfig.Timeout, + EnvVars: cloneKeyValues(o.CmdConfig.EnvVars), + }, + EmailConfig: EventActionEmailConfig{ + Recipients: emailRecipients, + Subject: o.EmailConfig.Subject, + Body: o.EmailConfig.Body, + }, + } +} + +// SetEmptySecretsIfNil sets the secrets to empty if nil +func (o *BaseEventActionOptions) SetEmptySecretsIfNil() { + if o.HTTPConfig.Password == nil { + o.HTTPConfig.Password = kms.NewEmptySecret() + } +} + +func (o *BaseEventActionOptions) setNilSecretsIfEmpty() { + if o.HTTPConfig.Password != nil && o.HTTPConfig.Password.IsEmpty() { + o.HTTPConfig.Password = nil + } +} + +func (o *BaseEventActionOptions) hideConfidentialData() { + if o.HTTPConfig.Password != nil { + o.HTTPConfig.Password.Hide() + } +} + +func (o *BaseEventActionOptions) validate(action int, name string) error { + o.SetEmptySecretsIfNil() + switch action { + case ActionTypeHTTP: + o.CmdConfig = EventActionCommandConfig{} + o.EmailConfig = EventActionEmailConfig{} + return o.HTTPConfig.validate(name) + case ActionTypeCommand: + o.HTTPConfig = EventActionHTTPConfig{} + o.EmailConfig = EventActionEmailConfig{} + return o.CmdConfig.validate() + case ActionTypeEmail: + o.HTTPConfig = EventActionHTTPConfig{} + o.CmdConfig = EventActionCommandConfig{} + return o.EmailConfig.validate() + default: + o.HTTPConfig = EventActionHTTPConfig{} + o.CmdConfig = EventActionCommandConfig{} + o.EmailConfig = EventActionEmailConfig{} + } + return nil +} + +// BaseEventAction defines the common fields for an event action +type BaseEventAction struct { + // Data provider unique identifier + ID int64 `json:"id"` + // Action name + Name string `json:"name"` + // optional description + Description string `json:"description,omitempty"` + // ActionType, see the above enum + Type int `json:"type"` + // Configuration options specific for the action type + Options BaseEventActionOptions `json:"options"` + // list of rule names associated with this event action + Rules []string `json:"rules,omitempty"` +} + +func (a *BaseEventAction) getACopy() BaseEventAction { + rules := make([]string, len(a.Rules)) + copy(rules, a.Rules) + return BaseEventAction{ + ID: a.ID, + Name: a.Name, + Description: a.Description, + Type: a.Type, + Options: a.Options.getACopy(), + Rules: rules, + } +} + +// GetTypeAsString returns the action type as string +func (a *BaseEventAction) GetTypeAsString() string { + return getActionTypeAsString(a.Type) +} + +// GetRulesAsString returns the list of rules as comma separated string +func (a *BaseEventAction) GetRulesAsString() string { + return strings.Join(a.Rules, ",") +} + +// PrepareForRendering prepares a BaseEventAction for rendering. +// It hides confidential data and set to nil the empty secrets +// so they are not serialized +func (a *BaseEventAction) PrepareForRendering() { + a.Options.setNilSecretsIfEmpty() + a.Options.hideConfidentialData() +} + +// RenderAsJSON implements the renderer interface used within plugins +func (a *BaseEventAction) RenderAsJSON(reload bool) ([]byte, error) { + if reload { + action, err := provider.eventActionExists(a.Name) + if err != nil { + providerLog(logger.LevelError, "unable to reload event action before rendering as json: %v", err) + return nil, err + } + action.PrepareForRendering() + return json.Marshal(action) + } + a.PrepareForRendering() + return json.Marshal(a) +} + +func (a *BaseEventAction) validate() error { + if a.Name == "" { + return util.NewValidationError("name is mandatory") + } + if !isActionTypeValid(a.Type) { + return util.NewValidationError(fmt.Sprintf("invalid action type: %d", a.Type)) + } + return a.Options.validate(a.Type, a.Name) +} + +func (a *BaseEventAction) doUsersQuotaReset(conditions ConditionOptions) error { + users, err := provider.dumpUsers() + if err != nil { + return fmt.Errorf("unable to get users: %w", err) + } + var failedResets []string + for _, user := range users { + if !checkConditionPatterns(user.Username, conditions.Names) { + eventManagerLog(logger.LevelDebug, "skipping scheduled quota reset for user %s, name conditions don't match", + user.Username) + continue + } + if !QuotaScans.AddUserQuotaScan(user.Username) { + eventManagerLog(logger.LevelError, "another quota scan is already in progress for user %s", user.Username) + failedResets = append(failedResets, user.Username) + continue + } + numFiles, size, err := user.ScanQuota() + QuotaScans.RemoveUserQuotaScan(user.Username) + if err != nil { + eventManagerLog(logger.LevelError, "error scanning quota for user %s: %v", user.Username, err) + failedResets = append(failedResets, user.Username) + continue + } + err = UpdateUserQuota(&user, numFiles, size, true) + if err != nil { + eventManagerLog(logger.LevelError, "error updating quota for user %s: %v", user.Username, err) + failedResets = append(failedResets, user.Username) + continue + } + } + if len(failedResets) > 0 { + return fmt.Errorf("quota reset failed for users: %+v", failedResets) + } + return nil +} + +func (a *BaseEventAction) doFoldersQuotaReset(conditions ConditionOptions) error { + folders, err := provider.dumpFolders() + if err != nil { + return fmt.Errorf("unable to get folders: %w", err) + } + var failedResets []string + for _, folder := range folders { + if !checkConditionPatterns(folder.Name, conditions.Names) { + eventManagerLog(logger.LevelDebug, "skipping scheduled quota reset for folder %s, name conditions don't match", + folder.Name) + continue + } + if !QuotaScans.AddVFolderQuotaScan(folder.Name) { + eventManagerLog(logger.LevelError, "another quota scan is already in progress for folder %s", folder.Name) + failedResets = append(failedResets, folder.Name) + continue + } + f := vfs.VirtualFolder{ + BaseVirtualFolder: folder, + VirtualPath: "/", + } + numFiles, size, err := f.ScanQuota() + QuotaScans.RemoveVFolderQuotaScan(folder.Name) + if err != nil { + eventManagerLog(logger.LevelError, "error scanning quota for folder %s: %v", folder.Name, err) + failedResets = append(failedResets, folder.Name) + continue + } + err = UpdateVirtualFolderQuota(&folder, numFiles, size, true) + if err != nil { + eventManagerLog(logger.LevelError, "error updating quota for folder %s: %v", folder.Name, err) + failedResets = append(failedResets, folder.Name) + continue + } + } + if len(failedResets) > 0 { + return fmt.Errorf("quota reset failed for folders: %+v", failedResets) + } + return nil +} + +func (a *BaseEventAction) doTransferQuotaReset(conditions ConditionOptions) error { + users, err := provider.dumpUsers() + if err != nil { + return fmt.Errorf("unable to get users: %w", err) + } + var failedResets []string + for _, user := range users { + if !checkConditionPatterns(user.Username, conditions.Names) { + eventManagerLog(logger.LevelDebug, "skipping scheduled transfer quota reset for user %s, name conditions don't match", + user.Username) + continue + } + err = UpdateUserTransferQuota(&user, 0, 0, true) + if err != nil { + eventManagerLog(logger.LevelError, "error updating transfer quota for user %s: %v", user.Username, err) + failedResets = append(failedResets, user.Username) + continue + } + } + if len(failedResets) > 0 { + return fmt.Errorf("transfer quota reset failed for users: %+v", failedResets) + } + return nil +} + +func (a *BaseEventAction) execute(params EventParams, conditions ConditionOptions) error { + switch a.Type { + case ActionTypeHTTP: + return a.Options.HTTPConfig.execute(params) + case ActionTypeCommand: + return a.Options.CmdConfig.execute(params) + case ActionTypeEmail: + return a.Options.EmailConfig.execute(params) + case ActionTypeBackup: + return config.doBackup() + case ActionTypeUserQuotaReset: + return a.doUsersQuotaReset(conditions) + case ActionTypeFolderQuotaReset: + return a.doFoldersQuotaReset(conditions) + case ActionTypeTransferQuotaReset: + return a.doTransferQuotaReset(conditions) + default: + return fmt.Errorf("unsupported action type: %d", a.Type) + } +} + +// EventActionOptions defines the supported configuration options for an event action +type EventActionOptions struct { + IsFailureAction bool `json:"is_failure_action"` + StopOnFailure bool `json:"stop_on_failure"` + ExecuteSync bool `json:"execute_sync"` +} + +// EventAction defines an event action +type EventAction struct { + BaseEventAction + // Order defines the execution order + Order int `json:"order,omitempty"` + Options EventActionOptions `json:"relation_options"` +} + +func (a *EventAction) getACopy() EventAction { + return EventAction{ + BaseEventAction: a.BaseEventAction.getACopy(), + Order: a.Order, + Options: EventActionOptions{ + IsFailureAction: a.Options.IsFailureAction, + StopOnFailure: a.Options.StopOnFailure, + ExecuteSync: a.Options.ExecuteSync, + }, + } +} + +func (a *EventAction) validateAssociation(trigger int, fsEvents []string) error { + if a.Options.IsFailureAction { + if a.Options.ExecuteSync { + return util.NewValidationError("sync execution is not supported for failure actions") + } + } + if trigger != EventTriggerFsEvent || !util.Contains(fsEvents, "upload") { + if a.Options.ExecuteSync { + return util.NewValidationError("sync execution is only supported for upload event") + } + } + return nil +} + +// ConditionPattern defines a pattern for condition filters +type ConditionPattern struct { + Pattern string `json:"pattern,omitempty"` + InverseMatch bool `json:"inverse_match,omitempty"` +} + +func (p *ConditionPattern) match(name string) bool { + matched, err := path.Match(p.Pattern, name) + if err != nil { + eventManagerLog(logger.LevelError, "pattern matching error %q, err: %v", p.Pattern, err) + return false + } + if p.InverseMatch { + return !matched + } + return matched +} + +func (p *ConditionPattern) validate() error { + if p.Pattern == "" { + return util.NewValidationError("empty condition pattern not allowed") + } + _, err := path.Match(p.Pattern, "abc") + if err != nil { + return util.NewValidationError(fmt.Sprintf("invalid condition pattern %q", p.Pattern)) + } + return nil +} + +// ConditionOptions defines options for event conditions +type ConditionOptions struct { + // Usernames or folder names + Names []ConditionPattern `json:"names,omitempty"` + // Virtual paths + FsPaths []ConditionPattern `json:"fs_paths,omitempty"` + Protocols []string `json:"protocols,omitempty"` + ProviderObjects []string `json:"provider_objects,omitempty"` + MinFileSize int64 `json:"min_size,omitempty"` + MaxFileSize int64 `json:"max_size,omitempty"` + // allow to execute scheduled tasks concurrently from multiple instances + ConcurrentExecution bool `json:"concurrent_execution,omitempty"` +} + +func (f *ConditionOptions) getACopy() ConditionOptions { + protocols := make([]string, len(f.Protocols)) + copy(protocols, f.Protocols) + providerObjects := make([]string, len(f.ProviderObjects)) + copy(providerObjects, f.ProviderObjects) + + return ConditionOptions{ + Names: cloneConditionPatterns(f.Names), + FsPaths: cloneConditionPatterns(f.FsPaths), + Protocols: protocols, + ProviderObjects: providerObjects, + MinFileSize: f.MinFileSize, + MaxFileSize: f.MaxFileSize, + ConcurrentExecution: f.ConcurrentExecution, + } +} + +func (f *ConditionOptions) validate() error { + for _, name := range f.Names { + if err := name.validate(); err != nil { + return err + } + } + for _, fsPath := range f.FsPaths { + if err := fsPath.validate(); err != nil { + return err + } + } + for _, p := range f.Protocols { + if !util.Contains(SupportedRuleConditionProtocols, p) { + return util.NewValidationError(fmt.Sprintf("unsupported rule condition protocol: %q", p)) + } + } + for _, p := range f.ProviderObjects { + if !util.Contains(SupporteRuleConditionProviderObjects, p) { + return util.NewValidationError(fmt.Sprintf("unsupported provider object: %q", p)) + } + } + if f.MinFileSize > 0 && f.MaxFileSize > 0 { + if f.MaxFileSize <= f.MinFileSize { + return util.NewValidationError(fmt.Sprintf("invalid max file size %d, it is lesser or equal than min file size %d", + f.MaxFileSize, f.MinFileSize)) + } + } + if config.GetShared() == 0 { + f.ConcurrentExecution = false + } + return nil +} + +// Schedule defines an event schedule +type Schedule struct { + Hours string `json:"hour"` + DayOfWeek string `json:"day_of_week"` + DayOfMonth string `json:"day_of_month"` + Month string `json:"month"` +} + +func (s *Schedule) getCronSpec() string { + return fmt.Sprintf("0 %s %s %s %s", s.Hours, s.DayOfMonth, s.Month, s.DayOfWeek) +} + +func (s *Schedule) validate() error { + _, err := cron.ParseStandard(s.getCronSpec()) + if err != nil { + return util.NewValidationError(fmt.Sprintf("invalid schedule, hour: %q, day of month: %q, month: %q, day of week: %q", + s.Hours, s.DayOfMonth, s.Month, s.DayOfWeek)) + } + return nil +} + +// EventConditions defines the conditions for an event rule +type EventConditions struct { + // Only one between FsEvents, ProviderEvents and Schedule is allowed + FsEvents []string `json:"fs_events,omitempty"` + ProviderEvents []string `json:"provider_events,omitempty"` + Schedules []Schedule `json:"schedules,omitempty"` + Options ConditionOptions `json:"options"` +} + +func (c *EventConditions) getACopy() EventConditions { + fsEvents := make([]string, len(c.FsEvents)) + copy(fsEvents, c.FsEvents) + providerEvents := make([]string, len(c.ProviderEvents)) + copy(providerEvents, c.ProviderEvents) + schedules := make([]Schedule, 0, len(c.Schedules)) + for _, schedule := range c.Schedules { + schedules = append(schedules, Schedule{ + Hours: schedule.Hours, + DayOfWeek: schedule.DayOfWeek, + DayOfMonth: schedule.DayOfMonth, + Month: schedule.Month, + }) + } + + return EventConditions{ + FsEvents: fsEvents, + ProviderEvents: providerEvents, + Schedules: schedules, + Options: c.Options.getACopy(), + } +} + +// ProviderEventMatch returns true if the specified provider event match +func (c *EventConditions) ProviderEventMatch(params EventParams) bool { + if !util.Contains(c.ProviderEvents, params.Event) { + return false + } + if !checkConditionPatterns(params.Name, c.Options.Names) { + return false + } + if len(c.Options.ProviderObjects) > 0 && !util.Contains(c.Options.ProviderObjects, params.ObjectType) { + return false + } + return true +} + +// FsEventMatch returns true if the specified filesystem event match +func (c *EventConditions) FsEventMatch(params EventParams) bool { + if !util.Contains(c.FsEvents, params.Event) { + return false + } + if !checkConditionPatterns(params.Name, c.Options.Names) { + return false + } + if !checkConditionPatterns(params.VirtualPath, c.Options.FsPaths) { + if !checkConditionPatterns(params.ObjectName, c.Options.FsPaths) { + return false + } + } + if len(c.Options.Protocols) > 0 && !util.Contains(c.Options.Protocols, params.Protocol) { + return false + } + if params.Event == "upload" || params.Event == "download" { + if c.Options.MinFileSize > 0 { + if params.FileSize < c.Options.MinFileSize { + return false + } + } + if c.Options.MaxFileSize > 0 { + if params.FileSize > c.Options.MaxFileSize { + return false + } + } + } + return true +} + +func (c *EventConditions) validate(trigger int) error { + switch trigger { + case EventTriggerFsEvent: + c.ProviderEvents = nil + c.Schedules = nil + c.Options.ProviderObjects = nil + if len(c.FsEvents) == 0 { + return util.NewValidationError("at least one filesystem event is required") + } + for _, ev := range c.FsEvents { + if !util.Contains(SupportedFsEvents, ev) { + return util.NewValidationError(fmt.Sprintf("unsupported fs event: %q", ev)) + } + } + case EventTriggerProviderEvent: + c.FsEvents = nil + c.Schedules = nil + c.Options.FsPaths = nil + c.Options.Protocols = nil + c.Options.MinFileSize = 0 + c.Options.MaxFileSize = 0 + if len(c.ProviderEvents) == 0 { + return util.NewValidationError("at least one provider event is required") + } + for _, ev := range c.ProviderEvents { + if !util.Contains(SupportedProviderEvents, ev) { + return util.NewValidationError(fmt.Sprintf("unsupported provider event: %q", ev)) + } + } + case EventTriggerSchedule: + 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 + if len(c.Schedules) == 0 { + return util.NewValidationError("at least one schedule is required") + } + for _, schedule := range c.Schedules { + if err := schedule.validate(); err != nil { + return err + } + } + default: + c.FsEvents = nil + c.ProviderEvents = nil + c.Options.FsPaths = nil + c.Options.Protocols = nil + c.Options.MinFileSize = 0 + c.Options.MaxFileSize = 0 + c.Schedules = nil + } + + return c.Options.validate() +} + +// EventRule defines the trigger, conditions and actions for an event +type EventRule struct { + // Data provider unique identifier + ID int64 `json:"id"` + // Rule name + Name string `json:"name"` + // optional description + Description string `json:"description,omitempty"` + // Creation time as unix timestamp in milliseconds + CreatedAt int64 `json:"created_at"` + // last update time as unix timestamp in milliseconds + UpdatedAt int64 `json:"updated_at"` + // Event trigger + Trigger int `json:"trigger"` + // Event conditions + Conditions EventConditions `json:"conditions"` + // actions to execute + Actions []EventAction `json:"actions"` + // in multi node setups we mark the rule as deleted to be able to update the cache + DeletedAt int64 `json:"-"` +} + +func (r *EventRule) getACopy() EventRule { + actions := make([]EventAction, 0, len(r.Actions)) + for _, action := range r.Actions { + actions = append(actions, action.getACopy()) + } + + return EventRule{ + ID: r.ID, + Name: r.Name, + Description: r.Description, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + Trigger: r.Trigger, + Conditions: r.Conditions.getACopy(), + Actions: actions, + DeletedAt: r.DeletedAt, + } +} + +func (r *EventRule) guardFromConcurrentExecution() bool { + if config.GetShared() == 0 { + return false + } + return !r.Conditions.Options.ConcurrentExecution +} + +// GetTriggerAsString returns the rule trigger as string +func (r *EventRule) GetTriggerAsString() string { + return getTriggerTypeAsString(r.Trigger) +} + +// GetActionsAsString returns the list of action names as comma separated string +func (r *EventRule) GetActionsAsString() string { + actions := make([]string, 0, len(r.Actions)) + for _, action := range r.Actions { + actions = append(actions, action.Name) + } + return strings.Join(actions, ",") +} + +func (r *EventRule) validate() error { + if r.Name == "" { + return util.NewValidationError("name is mandatory") + } + if !isEventTriggerValid(r.Trigger) { + return util.NewValidationError(fmt.Sprintf("invalid event rule trigger: %d", r.Trigger)) + } + if err := r.Conditions.validate(r.Trigger); err != nil { + return err + } + if len(r.Actions) == 0 { + return util.NewValidationError("at least one action is required") + } + actionNames := make(map[string]bool) + actionOrders := make(map[int]bool) + failureActions := 0 + for idx := range r.Actions { + if r.Actions[idx].Name == "" { + return util.NewValidationError(fmt.Sprintf("invalid action at position %d, name not specified", idx)) + } + if actionNames[r.Actions[idx].Name] { + return util.NewValidationError(fmt.Sprintf("duplicated action %q", r.Actions[idx].Name)) + } + if actionOrders[r.Actions[idx].Order] { + return util.NewValidationError(fmt.Sprintf("duplicated order %d for action %q", + r.Actions[idx].Order, r.Actions[idx].Name)) + } + if err := r.Actions[idx].validateAssociation(r.Trigger, r.Conditions.FsEvents); err != nil { + return err + } + if r.Actions[idx].Options.IsFailureAction { + failureActions++ + } + actionNames[r.Actions[idx].Name] = true + actionOrders[r.Actions[idx].Order] = true + } + if len(r.Actions) == failureActions { + return util.NewValidationError("at least a non-failure action is required") + } + return nil +} + +// PrepareForRendering prepares an EventRule for rendering. +// It hides confidential data and set to nil the empty secrets +// so they are not serialized +func (r *EventRule) PrepareForRendering() { + for idx := range r.Actions { + r.Actions[idx].PrepareForRendering() + } +} + +// RenderAsJSON implements the renderer interface used within plugins +func (r *EventRule) RenderAsJSON(reload bool) ([]byte, error) { + if reload { + rule, err := provider.eventRuleExists(r.Name) + if err != nil { + providerLog(logger.LevelError, "unable to reload event rule before rendering as json: %v", err) + return nil, err + } + rule.PrepareForRendering() + return json.Marshal(rule) + } + r.PrepareForRendering() + return json.Marshal(r) +} + +// Task stores the state for a scheduled task +type Task struct { + Name string `json:"name"` + UpdateAt int64 `json:"updated_at"` + Version int64 `json:"version"` +} diff --git a/dataprovider/eventruleutil.go b/dataprovider/eventruleutil.go new file mode 100644 index 00000000..362d6a03 --- /dev/null +++ b/dataprovider/eventruleutil.go @@ -0,0 +1,460 @@ +package dataprovider + +import ( + "fmt" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/robfig/cron/v3" + + "github.com/drakkan/sftpgo/v2/logger" + "github.com/drakkan/sftpgo/v2/plugin" + "github.com/drakkan/sftpgo/v2/util" +) + +var ( + // EventManager handle the supported event rules actions + EventManager EventRulesContainer +) + +func init() { + EventManager = EventRulesContainer{ + schedulesMapping: make(map[string][]cron.EntryID), + } +} + +// EventRulesContainer stores event rules by trigger +type EventRulesContainer struct { + sync.RWMutex + FsEvents []EventRule + ProviderEvents []EventRule + Schedules []EventRule + schedulesMapping map[string][]cron.EntryID + lastLoad int64 +} + +func (r *EventRulesContainer) getLastLoadTime() int64 { + return atomic.LoadInt64(&r.lastLoad) +} + +func (r *EventRulesContainer) setLastLoadTime(modTime int64) { + atomic.StoreInt64(&r.lastLoad, modTime) +} + +// RemoveRule deletes the rule with the specified name +func (r *EventRulesContainer) RemoveRule(name string) { + r.Lock() + defer r.Unlock() + + r.removeRuleInternal(name) + eventManagerLog(logger.LevelDebug, "event rules updated after delete, fs events: %d, provider events: %d, schedules: %d", + len(r.FsEvents), len(r.ProviderEvents), len(r.Schedules)) +} + +func (r *EventRulesContainer) removeRuleInternal(name string) { + for idx := range r.FsEvents { + if r.FsEvents[idx].Name == name { + lastIdx := len(r.FsEvents) - 1 + r.FsEvents[idx] = r.FsEvents[lastIdx] + r.FsEvents = r.FsEvents[:lastIdx] + eventManagerLog(logger.LevelDebug, "removed rule %q from fs events", name) + return + } + } + for idx := range r.ProviderEvents { + if r.ProviderEvents[idx].Name == name { + lastIdx := len(r.ProviderEvents) - 1 + r.ProviderEvents[idx] = r.ProviderEvents[lastIdx] + r.ProviderEvents = r.ProviderEvents[:lastIdx] + eventManagerLog(logger.LevelDebug, "removed rule %q from provider events", name) + return + } + } + for idx := range r.Schedules { + if r.Schedules[idx].Name == name { + if schedules, ok := r.schedulesMapping[name]; ok { + for _, entryID := range schedules { + eventManagerLog(logger.LevelDebug, "removing scheduled entry id %d for rule %q", entryID, name) + scheduler.Remove(entryID) + } + delete(r.schedulesMapping, name) + } + + lastIdx := len(r.Schedules) - 1 + r.Schedules[idx] = r.Schedules[lastIdx] + r.Schedules = r.Schedules[:lastIdx] + eventManagerLog(logger.LevelDebug, "removed rule %q from scheduled events", name) + return + } + } +} + +func (r *EventRulesContainer) addUpdateRuleInternal(rule EventRule) { + r.removeRuleInternal(rule.Name) + if rule.DeletedAt > 0 { + deletedAt := util.GetTimeFromMsecSinceEpoch(rule.DeletedAt) + if deletedAt.Add(30 * time.Minute).Before(time.Now()) { + eventManagerLog(logger.LevelDebug, "removing rule %q deleted at %s", rule.Name, deletedAt) + go provider.deleteEventRule(rule, false) //nolint:errcheck + } + return + } + switch rule.Trigger { + case EventTriggerFsEvent: + r.FsEvents = append(r.FsEvents, rule) + eventManagerLog(logger.LevelDebug, "added rule %q to fs events", rule.Name) + case EventTriggerProviderEvent: + r.ProviderEvents = append(r.ProviderEvents, rule) + eventManagerLog(logger.LevelDebug, "added rule %q to provider events", rule.Name) + case EventTriggerSchedule: + r.Schedules = append(r.Schedules, rule) + eventManagerLog(logger.LevelDebug, "added rule %q to scheduled events", rule.Name) + for _, schedule := range rule.Conditions.Schedules { + cronSpec := schedule.getCronSpec() + job := &cronJob{ + ruleName: ConvertName(rule.Name), + } + entryID, err := scheduler.AddJob(cronSpec, job) + if err != nil { + eventManagerLog(logger.LevelError, "unable to add scheduled rule %q: %v", rule.Name, err) + } else { + r.schedulesMapping[rule.Name] = append(r.schedulesMapping[rule.Name], entryID) + eventManagerLog(logger.LevelDebug, "scheduled rule %q added, id: %d, active scheduling rules: %d", + rule.Name, entryID, len(r.schedulesMapping)) + } + } + default: + eventManagerLog(logger.LevelError, "unsupported trigger: %d", rule.Trigger) + } +} + +func (r *EventRulesContainer) loadRules() { + eventManagerLog(logger.LevelDebug, "loading updated rules") + modTime := util.GetTimeAsMsSinceEpoch(time.Now()) + rules, err := provider.getRecentlyUpdatedRules(r.getLastLoadTime()) + if err != nil { + eventManagerLog(logger.LevelError, "unable to load event rules: %v", err) + return + } + eventManagerLog(logger.LevelDebug, "recently updated event rules loaded: %d", len(rules)) + + if len(rules) > 0 { + r.Lock() + defer r.Unlock() + + for _, rule := range rules { + r.addUpdateRuleInternal(rule) + } + } + eventManagerLog(logger.LevelDebug, "event rules updated, fs events: %d, provider events: %d, schedules: %d", + len(r.FsEvents), len(r.ProviderEvents), len(r.Schedules)) + + r.setLastLoadTime(modTime) +} + +// HasFsRules returns true if there are any rules for filesystem event triggers +func (r *EventRulesContainer) HasFsRules() bool { + r.RLock() + defer r.RUnlock() + + return len(r.FsEvents) > 0 +} + +func (r *EventRulesContainer) hasProviderEvents() bool { + r.RLock() + defer r.RUnlock() + + return len(r.ProviderEvents) > 0 +} + +// HandleFsEvent executes the rules actions defined for the specified event +func (r *EventRulesContainer) HandleFsEvent(params EventParams) error { + r.RLock() + + var rulesWithSyncActions, rulesAsync []EventRule + for _, rule := range r.FsEvents { + if rule.Conditions.FsEventMatch(params) { + hasSyncActions := false + for _, action := range rule.Actions { + if action.Options.ExecuteSync { + hasSyncActions = true + break + } + } + if hasSyncActions { + rulesWithSyncActions = append(rulesWithSyncActions, rule) + } else { + rulesAsync = append(rulesAsync, rule) + } + } + } + + r.RUnlock() + + if len(rulesAsync) > 0 { + go executeAsyncActions(rulesAsync, params) + } + + if len(rulesWithSyncActions) > 0 { + return executeSyncActions(rulesWithSyncActions, params) + } + return nil +} + +func (r *EventRulesContainer) handleProviderEvent(params EventParams) { + r.RLock() + defer r.RUnlock() + + var rules []EventRule + for _, rule := range r.ProviderEvents { + if rule.Conditions.ProviderEventMatch(params) { + rules = append(rules, rule) + } + } + + go executeAsyncActions(rules, params) +} + +// EventParams defines the supported event parameters +type EventParams struct { + Name string + Event string + Status int + VirtualPath string + FsPath string + VirtualTargetPath string + FsTargetPath string + ObjectName string + ObjectType string + FileSize int64 + Protocol string + IP string + Timestamp int64 + Object plugin.Renderer +} + +func (p *EventParams) getStringReplacements(addObjectData bool) []string { + replacements := []string{ + "{{Name}}", p.Name, + "{{Event}}", p.Event, + "{{Status}}", fmt.Sprintf("%d", p.Status), + "{{VirtualPath}}", p.VirtualPath, + "{{FsPath}}", p.FsPath, + "{{VirtualTargetPath}}", p.VirtualTargetPath, + "{{FsTargetPath}}", p.FsTargetPath, + "{{ObjectName}}", p.ObjectName, + "{{ObjectType}}", p.ObjectType, + "{{FileSize}}", fmt.Sprintf("%d", p.FileSize), + "{{Protocol}}", p.Protocol, + "{{IP}}", p.IP, + "{{Timestamp}}", fmt.Sprintf("%d", p.Timestamp), + } + if addObjectData { + data, err := p.Object.RenderAsJSON(p.Event != operationDelete) + if err == nil { + replacements = append(replacements, "{{ObjectData}}", string(data)) + } + } + return replacements +} + +func replaceWithReplacer(input string, replacer *strings.Replacer) string { + if !strings.Contains(input, "{{") { + return input + } + return replacer.Replace(input) +} + +// checkConditionPatterns returns false if patterns are defined and no match is found +func checkConditionPatterns(name string, patterns []ConditionPattern) bool { + if len(patterns) == 0 { + return true + } + for _, p := range patterns { + if p.match(name) { + return true + } + } + + return false +} + +func executeSyncActions(rules []EventRule, params EventParams) error { + var errRes error + + for _, rule := range rules { + var failedActions []string + for _, action := range rule.Actions { + if !action.Options.IsFailureAction && action.Options.ExecuteSync { + startTime := time.Now() + if err := action.execute(params, rule.Conditions.Options); err != nil { + eventManagerLog(logger.LevelError, "unable to execute sync action %q for rule %q, elapsed %s, err: %v", + action.Name, rule.Name, time.Since(startTime), err) + failedActions = append(failedActions, action.Name) + // we return the last error, it is ok for now + errRes = err + if action.Options.StopOnFailure { + break + } + } else { + eventManagerLog(logger.LevelDebug, "executed sync action %q for rule %q, elapsed: %s", + action.Name, rule.Name, time.Since(startTime)) + } + } + } + // execute async actions if any, including failure actions + go executeRuleAsyncActions(rule, params, failedActions) + } + + return errRes +} + +func executeAsyncActions(rules []EventRule, params EventParams) { + for _, rule := range rules { + executeRuleAsyncActions(rule, params, nil) + } +} + +func executeRuleAsyncActions(rule EventRule, params EventParams, failedActions []string) { + for _, action := range rule.Actions { + if !action.Options.IsFailureAction && !action.Options.ExecuteSync { + startTime := time.Now() + if err := action.execute(params, rule.Conditions.Options); err != nil { + eventManagerLog(logger.LevelError, "unable to execute action %q for rule %q, elapsed %s, err: %v", + action.Name, rule.Name, time.Since(startTime), err) + failedActions = append(failedActions, action.Name) + if action.Options.StopOnFailure { + break + } + } else { + eventManagerLog(logger.LevelDebug, "executed action %q for rule %q, elapsed %s", + action.Name, rule.Name, time.Since(startTime)) + } + } + if len(failedActions) > 0 { + // execute failure actions + for _, action := range rule.Actions { + if action.Options.IsFailureAction { + startTime := time.Now() + if err := action.execute(params, rule.Conditions.Options); err != nil { + eventManagerLog(logger.LevelError, "unable to execute failure action %q for rule %q, elapsed %s, err: %v", + action.Name, rule.Name, time.Since(startTime), err) + if action.Options.StopOnFailure { + break + } + } else { + eventManagerLog(logger.LevelDebug, "executed failure action %q for rule %q, elapsed: %s", + action.Name, rule.Name, time.Since(startTime)) + } + } + } + } + } +} + +type cronJob struct { + ruleName string +} + +func (j *cronJob) getTask(rule EventRule) (Task, error) { + if rule.guardFromConcurrentExecution() { + task, err := provider.getTaskByName(rule.Name) + if _, ok := err.(*util.RecordNotFoundError); ok { + eventManagerLog(logger.LevelDebug, "adding task for rule %q", rule.Name) + task = Task{ + Name: rule.Name, + UpdateAt: 0, + Version: 0, + } + err = provider.addTask(rule.Name) + if err != nil { + eventManagerLog(logger.LevelWarn, "unable to add task for rule %q: %v", rule.Name, err) + return task, err + } + } else { + eventManagerLog(logger.LevelWarn, "unable to get task for rule %q: %v", rule.Name, err) + } + return task, err + } + + return Task{}, nil +} + +func (j *cronJob) Run() { + eventManagerLog(logger.LevelDebug, "executing scheduled rule %q", j.ruleName) + rule, err := provider.eventRuleExists(j.ruleName) + if err != nil { + eventManagerLog(logger.LevelError, "unable to load rule with name %q", j.ruleName) + return + } + task, err := j.getTask(rule) + if err != nil { + return + } + if task.Name != "" { + updateInterval := 5 * time.Minute + updatedAt := util.GetTimeFromMsecSinceEpoch(task.UpdateAt) + if updatedAt.Add(updateInterval*2 + 1).After(time.Now()) { + eventManagerLog(logger.LevelDebug, "task for rule %q too recent: %s, skip execution", rule.Name, updatedAt) + return + } + err = provider.updateTask(rule.Name, task.Version) + if err != nil { + eventManagerLog(logger.LevelInfo, "unable to update task timestamp for rule %q, skip execution, err: %v", + rule.Name, err) + return + } + ticker := time.NewTicker(updateInterval) + done := make(chan bool) + + go func(taskName string) { + eventManagerLog(logger.LevelDebug, "update task %q timestamp worker started", taskName) + for { + select { + case <-done: + eventManagerLog(logger.LevelDebug, "update task %q timestamp worker finished", taskName) + return + case <-ticker.C: + err := provider.updateTaskTimestamp(taskName) + eventManagerLog(logger.LevelInfo, "updated timestamp for task %q, err: %v", taskName, err) + } + } + }(task.Name) + + executeRuleAsyncActions(rule, EventParams{}, nil) + + done <- true + ticker.Stop() + } else { + executeRuleAsyncActions(rule, EventParams{}, nil) + } + eventManagerLog(logger.LevelDebug, "execution for scheduled rule %q finished", j.ruleName) +} + +func cloneKeyValues(keyVals []KeyValue) []KeyValue { + res := make([]KeyValue, 0, len(keyVals)) + for _, kv := range keyVals { + res = append(res, KeyValue{ + Key: kv.Key, + Value: kv.Value, + }) + } + return res +} + +func cloneConditionPatterns(patterns []ConditionPattern) []ConditionPattern { + res := make([]ConditionPattern, 0, len(patterns)) + for _, p := range patterns { + res = append(res, ConditionPattern{ + Pattern: p.Pattern, + InverseMatch: p.InverseMatch, + }) + } + return res +} + +func eventManagerLog(level logger.LogLevel, format string, v ...any) { + logger.Log(level, "eventmanager", "", format, v...) +} diff --git a/dataprovider/memory.go b/dataprovider/memory.go index 3410e296..bb649a3b 100644 --- a/dataprovider/memory.go +++ b/dataprovider/memory.go @@ -48,6 +48,14 @@ type memoryProviderHandle struct { shares map[string]Share // slice with ordered shares shareID sharesIDs []string + // map for event actions, name is the key + actions map[string]BaseEventAction + // slice with ordered actions + actionsNames []string + // map for event actions, name is the key + rules map[string]EventRule + // slice with ordered rules + rulesNames []string } // MemoryProvider defines the auth provider for a memory store @@ -78,6 +86,10 @@ func initializeMemoryProvider(basePath string) { apiKeysIDs: []string{}, shares: make(map[string]Share), sharesIDs: []string{}, + actions: make(map[string]BaseEventAction), + actionsNames: []string{}, + rules: make(map[string]EventRule), + rulesNames: []string{}, configFile: configFile, }, } @@ -576,14 +588,28 @@ func (p *MemoryProvider) userExistsInternal(username string) (User, error) { if val, ok := p.dbHandle.users[username]; ok { return val.getACopy(), nil } - return User{}, util.NewRecordNotFoundError(fmt.Sprintf("username %#v does not exist", username)) + return User{}, util.NewRecordNotFoundError(fmt.Sprintf("username %q does not exist", username)) } func (p *MemoryProvider) groupExistsInternal(name string) (Group, error) { if val, ok := p.dbHandle.groups[name]; ok { return val.getACopy(), nil } - return Group{}, util.NewRecordNotFoundError(fmt.Sprintf("group %#v does not exist", name)) + return Group{}, util.NewRecordNotFoundError(fmt.Sprintf("group %q does not exist", name)) +} + +func (p *MemoryProvider) actionExistsInternal(name string) (BaseEventAction, error) { + if val, ok := p.dbHandle.actions[name]; ok { + return val.getACopy(), nil + } + return BaseEventAction{}, util.NewRecordNotFoundError(fmt.Sprintf("event action %q does not exist", name)) +} + +func (p *MemoryProvider) ruleExistsInternal(name string) (EventRule, error) { + if val, ok := p.dbHandle.rules[name]; ok { + return val.getACopy(), nil + } + return EventRule{}, util.NewRecordNotFoundError(fmt.Sprintf("event rule %q does not exist", name)) } func (p *MemoryProvider) addAdmin(admin *Admin) error { @@ -983,6 +1009,52 @@ func (p *MemoryProvider) addVirtualFoldersToGroup(group *Group) { } } +func (p *MemoryProvider) addActionsToRule(rule *EventRule) { + var actions []EventAction + for idx := range rule.Actions { + action := &rule.Actions[idx] + baseAction, err := p.actionExistsInternal(action.Name) + if err != nil { + continue + } + baseAction.Options.SetEmptySecretsIfNil() + action.BaseEventAction = baseAction + actions = append(actions, *action) + } + rule.Actions = actions +} + +func (p *MemoryProvider) addRuleToActionMapping(ruleName, actionName string) error { + a, err := p.actionExistsInternal(actionName) + if err != nil { + return util.NewGenericError(fmt.Sprintf("action %q does not exist", actionName)) + } + if !util.Contains(a.Rules, ruleName) { + a.Rules = append(a.Rules, ruleName) + p.dbHandle.actions[actionName] = a + } + return nil +} + +func (p *MemoryProvider) removeRuleFromActionMapping(ruleName, actionName string) error { + a, err := p.actionExistsInternal(actionName) + if err != nil { + providerLog(logger.LevelWarn, "action %q does not exist, cannot remove from mapping", actionName) + return nil + } + if util.Contains(a.Rules, ruleName) { + var rules []string + for _, r := range a.Rules { + if r != ruleName { + rules = append(rules, r) + } + } + a.Rules = rules + p.dbHandle.actions[actionName] = a + } + return nil +} + func (p *MemoryProvider) addUserFromGroupMapping(username, groupname string) error { g, err := p.groupExistsInternal(groupname) if err != nil { @@ -1768,6 +1840,359 @@ func (p *MemoryProvider) cleanupSharedSessions(sessionType SessionType, before i return ErrNotImplemented } +func (p *MemoryProvider) getEventActions(limit, offset int, order string, minimal bool) ([]BaseEventAction, error) { + p.dbHandle.Lock() + defer p.dbHandle.Unlock() + if p.dbHandle.isClosed { + return nil, errMemoryProviderClosed + } + if limit <= 0 { + return nil, nil + } + actions := make([]BaseEventAction, 0, limit) + itNum := 0 + if order == OrderASC { + for _, name := range p.dbHandle.actionsNames { + itNum++ + if itNum <= offset { + continue + } + a := p.dbHandle.actions[name] + action := a.getACopy() + action.PrepareForRendering() + actions = append(actions, action) + if len(actions) >= limit { + break + } + } + } else { + for i := len(p.dbHandle.actionsNames) - 1; i >= 0; i-- { + itNum++ + if itNum <= offset { + continue + } + name := p.dbHandle.actionsNames[i] + a := p.dbHandle.actions[name] + action := a.getACopy() + action.PrepareForRendering() + actions = append(actions, action) + if len(actions) >= limit { + break + } + } + } + return actions, nil +} + +func (p *MemoryProvider) dumpEventActions() ([]BaseEventAction, error) { + p.dbHandle.Lock() + defer p.dbHandle.Unlock() + if p.dbHandle.isClosed { + return nil, errMemoryProviderClosed + } + actions := make([]BaseEventAction, 0, len(p.dbHandle.actions)) + for _, name := range p.dbHandle.actionsNames { + a := p.dbHandle.actions[name] + action := a.getACopy() + actions = append(actions, action) + } + return actions, nil +} + +func (p *MemoryProvider) eventActionExists(name string) (BaseEventAction, error) { + p.dbHandle.Lock() + defer p.dbHandle.Unlock() + if p.dbHandle.isClosed { + return BaseEventAction{}, errMemoryProviderClosed + } + return p.actionExistsInternal(name) +} + +func (p *MemoryProvider) addEventAction(action *BaseEventAction) error { + err := action.validate() + if err != nil { + return err + } + p.dbHandle.Lock() + defer p.dbHandle.Unlock() + if p.dbHandle.isClosed { + return errMemoryProviderClosed + } + _, err = p.actionExistsInternal(action.Name) + if err == nil { + return fmt.Errorf("event action %q already exists", action.Name) + } + action.ID = p.getNextActionID() + action.Rules = nil + p.dbHandle.actions[action.Name] = action.getACopy() + p.dbHandle.actionsNames = append(p.dbHandle.actionsNames, action.Name) + sort.Strings(p.dbHandle.actionsNames) + return nil +} + +func (p *MemoryProvider) updateEventAction(action *BaseEventAction) error { + err := action.validate() + if err != nil { + return err + } + p.dbHandle.Lock() + defer p.dbHandle.Unlock() + if p.dbHandle.isClosed { + return errMemoryProviderClosed + } + oldAction, err := p.actionExistsInternal(action.Name) + if err != nil { + return fmt.Errorf("event action %s does not exist", action.Name) + } + action.ID = oldAction.ID + action.Name = oldAction.Name + action.Rules = nil + if len(oldAction.Rules) > 0 { + var relatedRules []string + for _, ruleName := range oldAction.Rules { + rule, err := p.ruleExistsInternal(ruleName) + if err == nil { + relatedRules = append(relatedRules, ruleName) + rule.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) + p.dbHandle.rules[ruleName] = rule + setLastRuleUpdate() + } + } + action.Rules = relatedRules + } + p.dbHandle.actions[action.Name] = action.getACopy() + return nil +} + +func (p *MemoryProvider) deleteEventAction(action BaseEventAction) error { + p.dbHandle.Lock() + defer p.dbHandle.Unlock() + if p.dbHandle.isClosed { + return errMemoryProviderClosed + } + oldAction, err := p.actionExistsInternal(action.Name) + if err != nil { + return fmt.Errorf("event action %s does not exist", action.Name) + } + if len(oldAction.Rules) > 0 { + return util.NewValidationError(fmt.Sprintf("action %s is referenced, it cannot be removed", oldAction.Name)) + } + delete(p.dbHandle.actions, action.Name) + // this could be more efficient + p.dbHandle.actionsNames = make([]string, 0, len(p.dbHandle.actions)) + for name := range p.dbHandle.actions { + p.dbHandle.actionsNames = append(p.dbHandle.actionsNames, name) + } + sort.Strings(p.dbHandle.actionsNames) + return nil +} + +func (p *MemoryProvider) getEventRules(limit, offset int, order string) ([]EventRule, error) { + p.dbHandle.Lock() + defer p.dbHandle.Unlock() + if p.dbHandle.isClosed { + return nil, errMemoryProviderClosed + } + if limit <= 0 { + return nil, nil + } + itNum := 0 + rules := make([]EventRule, 0, limit) + if order == OrderASC { + for _, name := range p.dbHandle.rulesNames { + itNum++ + if itNum <= offset { + continue + } + r := p.dbHandle.rules[name] + rule := r.getACopy() + p.addActionsToRule(&rule) + rule.PrepareForRendering() + rules = append(rules, rule) + if len(rules) >= limit { + break + } + } + } else { + for i := len(p.dbHandle.rulesNames) - 1; i >= 0; i-- { + itNum++ + if itNum <= offset { + continue + } + name := p.dbHandle.rulesNames[i] + r := p.dbHandle.rules[name] + rule := r.getACopy() + p.addActionsToRule(&rule) + rule.PrepareForRendering() + rules = append(rules, rule) + if len(rules) >= limit { + break + } + } + } + return rules, nil +} + +func (p *MemoryProvider) dumpEventRules() ([]EventRule, error) { + p.dbHandle.Lock() + defer p.dbHandle.Unlock() + if p.dbHandle.isClosed { + return nil, errMemoryProviderClosed + } + rules := make([]EventRule, 0, len(p.dbHandle.rules)) + for _, name := range p.dbHandle.rulesNames { + r := p.dbHandle.rules[name] + rule := r.getACopy() + p.addActionsToRule(&rule) + rules = append(rules, rule) + } + return rules, nil +} + +func (p *MemoryProvider) getRecentlyUpdatedRules(after int64) ([]EventRule, error) { + if getLastRuleUpdate() < after { + return nil, nil + } + p.dbHandle.Lock() + defer p.dbHandle.Unlock() + if p.dbHandle.isClosed { + return nil, errMemoryProviderClosed + } + rules := make([]EventRule, 0, 10) + for _, name := range p.dbHandle.rulesNames { + r := p.dbHandle.rules[name] + if r.UpdatedAt < after { + continue + } + rule := r.getACopy() + p.addActionsToRule(&rule) + rules = append(rules, rule) + } + return rules, nil +} + +func (p *MemoryProvider) eventRuleExists(name string) (EventRule, error) { + p.dbHandle.Lock() + defer p.dbHandle.Unlock() + if p.dbHandle.isClosed { + return EventRule{}, errMemoryProviderClosed + } + rule, err := p.ruleExistsInternal(name) + if err != nil { + return rule, err + } + p.addActionsToRule(&rule) + return rule, nil +} + +func (p *MemoryProvider) addEventRule(rule *EventRule) error { + if err := rule.validate(); err != nil { + return err + } + p.dbHandle.Lock() + defer p.dbHandle.Unlock() + if p.dbHandle.isClosed { + return errMemoryProviderClosed + } + _, err := p.ruleExistsInternal(rule.Name) + if err == nil { + return fmt.Errorf("event rule %q already exists", rule.Name) + } + rule.ID = p.getNextRuleID() + rule.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) + rule.UpdatedAt = rule.CreatedAt + for idx := range rule.Actions { + if err := p.addRuleToActionMapping(rule.Name, rule.Actions[idx].Name); err != nil { + return err + } + } + sort.Slice(rule.Actions, func(i, j int) bool { + return rule.Actions[i].Order < rule.Actions[j].Order + }) + p.dbHandle.rules[rule.Name] = rule.getACopy() + p.dbHandle.rulesNames = append(p.dbHandle.rulesNames, rule.Name) + sort.Strings(p.dbHandle.rulesNames) + setLastRuleUpdate() + return nil +} + +func (p *MemoryProvider) updateEventRule(rule *EventRule) error { + if err := rule.validate(); err != nil { + return err + } + p.dbHandle.Lock() + defer p.dbHandle.Unlock() + if p.dbHandle.isClosed { + return errMemoryProviderClosed + } + oldRule, err := p.ruleExistsInternal(rule.Name) + if err != nil { + return err + } + for idx := range oldRule.Actions { + if err = p.removeRuleFromActionMapping(rule.Name, oldRule.Actions[idx].Name); err != nil { + return err + } + } + for idx := range rule.Actions { + if err = p.addRuleToActionMapping(rule.Name, rule.Actions[idx].Name); err != nil { + return err + } + } + rule.ID = oldRule.ID + rule.CreatedAt = oldRule.CreatedAt + rule.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) + sort.Slice(rule.Actions, func(i, j int) bool { + return rule.Actions[i].Order < rule.Actions[j].Order + }) + p.dbHandle.rules[rule.Name] = rule.getACopy() + setLastRuleUpdate() + return nil +} + +func (p *MemoryProvider) deleteEventRule(rule EventRule, softDelete bool) error { + p.dbHandle.Lock() + defer p.dbHandle.Unlock() + if p.dbHandle.isClosed { + return errMemoryProviderClosed + } + oldRule, err := p.ruleExistsInternal(rule.Name) + if err != nil { + return err + } + if len(oldRule.Actions) > 0 { + for idx := range oldRule.Actions { + if err = p.removeRuleFromActionMapping(rule.Name, oldRule.Actions[idx].Name); err != nil { + return err + } + } + } + delete(p.dbHandle.rules, rule.Name) + p.dbHandle.rulesNames = make([]string, 0, len(p.dbHandle.rules)) + for name := range p.dbHandle.rules { + p.dbHandle.rulesNames = append(p.dbHandle.rulesNames, name) + } + sort.Strings(p.dbHandle.rulesNames) + setLastRuleUpdate() + return nil +} + +func (p *MemoryProvider) getTaskByName(name string) (Task, error) { + return Task{}, ErrNotImplemented +} + +func (p *MemoryProvider) addTask(name string) error { + return ErrNotImplemented +} + +func (p *MemoryProvider) updateTask(name string, version int64) error { + return ErrNotImplemented +} + +func (p *MemoryProvider) updateTaskTimestamp(name string) error { + return ErrNotImplemented +} + func (p *MemoryProvider) getNextID() int64 { nextID := int64(1) for _, v := range p.dbHandle.users { @@ -1808,6 +2233,26 @@ func (p *MemoryProvider) getNextGroupID() int64 { return nextID } +func (p *MemoryProvider) getNextActionID() int64 { + nextID := int64(1) + for _, a := range p.dbHandle.actions { + if a.ID >= nextID { + nextID = a.ID + 1 + } + } + return nextID +} + +func (p *MemoryProvider) getNextRuleID() int64 { + nextID := int64(1) + for _, r := range p.dbHandle.rules { + if r.ID >= nextID { + nextID = r.ID + 1 + } + } + return nextID +} + func (p *MemoryProvider) clear() { p.dbHandle.Lock() defer p.dbHandle.Unlock() @@ -1856,27 +2301,35 @@ func (p *MemoryProvider) reloadConfig() error { } p.clear() - if err := p.restoreFolders(&dump); err != nil { + if err := p.restoreFolders(dump); err != nil { return err } - if err := p.restoreGroups(&dump); err != nil { + if err := p.restoreGroups(dump); err != nil { return err } - if err := p.restoreUsers(&dump); err != nil { + if err := p.restoreUsers(dump); err != nil { return err } - if err := p.restoreAdmins(&dump); err != nil { + if err := p.restoreAdmins(dump); err != nil { return err } - if err := p.restoreAPIKeys(&dump); err != nil { + if err := p.restoreAPIKeys(dump); err != nil { return err } - if err := p.restoreShares(&dump); err != nil { + if err := p.restoreShares(dump); err != nil { + return err + } + + if err := p.restoreEventActions(dump); err != nil { + return err + } + + if err := p.restoreEventRules(dump); err != nil { return err } @@ -1884,7 +2337,51 @@ func (p *MemoryProvider) reloadConfig() error { return nil } -func (p *MemoryProvider) restoreShares(dump *BackupData) error { +func (p *MemoryProvider) restoreEventActions(dump BackupData) error { + for _, action := range dump.EventActions { + a, err := p.eventActionExists(action.Name) + action := action // pin + if err == nil { + action.ID = a.ID + err = UpdateEventAction(&action, ActionExecutorSystem, "") + if err != nil { + providerLog(logger.LevelError, "error updating event action %q: %v", action.Name, err) + return err + } + } else { + err = AddEventAction(&action, ActionExecutorSystem, "") + if err != nil { + providerLog(logger.LevelError, "error adding event action %q: %v", action.Name, err) + return err + } + } + } + return nil +} + +func (p *MemoryProvider) restoreEventRules(dump BackupData) error { + for _, rule := range dump.EventRules { + r, err := p.eventRuleExists(rule.Name) + rule := rule // pin + if err == nil { + rule.ID = r.ID + err = UpdateEventRule(&rule, ActionExecutorSystem, "") + if err != nil { + providerLog(logger.LevelError, "error updating event rule %q: %v", rule.Name, err) + return err + } + } else { + err = AddEventRule(&rule, ActionExecutorSystem, "") + if err != nil { + providerLog(logger.LevelError, "error adding event rule %q: %v", rule.Name, err) + return err + } + } + } + return nil +} + +func (p *MemoryProvider) restoreShares(dump BackupData) error { for _, share := range dump.Shares { s, err := p.shareExists(share.ShareID, "") share := share // pin @@ -1907,7 +2404,7 @@ func (p *MemoryProvider) restoreShares(dump *BackupData) error { return nil } -func (p *MemoryProvider) restoreAPIKeys(dump *BackupData) error { +func (p *MemoryProvider) restoreAPIKeys(dump BackupData) error { for _, apiKey := range dump.APIKeys { if apiKey.Key == "" { return fmt.Errorf("cannot restore an empty API key: %+v", apiKey) @@ -1932,7 +2429,7 @@ func (p *MemoryProvider) restoreAPIKeys(dump *BackupData) error { return nil } -func (p *MemoryProvider) restoreAdmins(dump *BackupData) error { +func (p *MemoryProvider) restoreAdmins(dump BackupData) error { for _, admin := range dump.Admins { admin := admin // pin admin.Username = config.convertName(admin.Username) @@ -1955,7 +2452,7 @@ func (p *MemoryProvider) restoreAdmins(dump *BackupData) error { return nil } -func (p *MemoryProvider) restoreGroups(dump *BackupData) error { +func (p *MemoryProvider) restoreGroups(dump BackupData) error { for _, group := range dump.Groups { group := group // pin group.Name = config.convertName(group.Name) @@ -1979,7 +2476,7 @@ func (p *MemoryProvider) restoreGroups(dump *BackupData) error { return nil } -func (p *MemoryProvider) restoreFolders(dump *BackupData) error { +func (p *MemoryProvider) restoreFolders(dump BackupData) error { for _, folder := range dump.Folders { folder := folder // pin folder.Name = config.convertName(folder.Name) @@ -2003,7 +2500,7 @@ func (p *MemoryProvider) restoreFolders(dump *BackupData) error { return nil } -func (p *MemoryProvider) restoreUsers(dump *BackupData) error { +func (p *MemoryProvider) restoreUsers(dump BackupData) error { for _, user := range dump.Users { user := user // pin user.Username = config.convertName(user.Username) diff --git a/dataprovider/mysql.go b/dataprovider/mysql.go index f0d52168..5eaf16fd 100644 --- a/dataprovider/mysql.go +++ b/dataprovider/mysql.go @@ -36,6 +36,10 @@ const ( "DROP TABLE IF EXISTS `{{defender_hosts}}` CASCADE;" + "DROP TABLE IF EXISTS `{{active_transfers}}` CASCADE;" + "DROP TABLE IF EXISTS `{{shared_sessions}}` CASCADE;" + + "DROP TABLE IF EXISTS `{{rules_actions_mapping}}` CASCADE;" + + "DROP TABLE IF EXISTS `{{events_actions}}` CASCADE;" + + "DROP TABLE IF EXISTS `{{events_rules}}` CASCADE;" + + "DROP TABLE IF EXISTS `{{tasks}}` CASCADE;" + "DROP TABLE IF EXISTS `{{schema_version}}` CASCADE;" mysqlInitialSQL = "CREATE TABLE `{{schema_version}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, `version` integer NOT NULL);" + "CREATE TABLE `{{admins}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, `username` varchar(255) NOT NULL UNIQUE, " + @@ -119,6 +123,33 @@ const ( "CREATE INDEX `{{prefix}}shared_sessions_type_idx` ON `{{shared_sessions}}` (`type`);" + "CREATE INDEX `{{prefix}}shared_sessions_timestamp_idx` ON `{{shared_sessions}}` (`timestamp`);" + "INSERT INTO {{schema_version}} (version) VALUES (19);" + mysqlV20SQL = "CREATE TABLE `{{events_rules}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, " + + "`name` varchar(255) NOT NULL UNIQUE, `description` varchar(512) NULL, `created_at` bigint NOT NULL, " + + "`updated_at` bigint NOT NULL, `trigger` integer NOT NULL, `conditions` longtext NOT NULL, `deleted_at` bigint NOT NULL);" + + "CREATE TABLE `{{events_actions}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, " + + "`name` varchar(255) NOT NULL UNIQUE, `description` varchar(512) NULL, `type` integer NOT NULL, " + + "`options` longtext NOT NULL);" + + "CREATE TABLE `{{rules_actions_mapping}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, " + + "`rule_id` integer NOT NULL, `action_id` integer NOT NULL, `order` integer NOT NULL, `options` longtext NOT NULL);" + + "CREATE TABLE `{{tasks}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` varchar(255) NOT NULL UNIQUE, " + + "`updated_at` bigint NOT NULL, `version` bigint NOT NULL);" + + "ALTER TABLE `{{rules_actions_mapping}}` ADD CONSTRAINT `{{prefix}}unique_rule_action_mapping` UNIQUE (`rule_id`, `action_id`);" + + "ALTER TABLE `{{rules_actions_mapping}}` ADD CONSTRAINT `{{prefix}}rules_actions_mapping_rule_id_fk_events_rules_id` " + + "FOREIGN KEY (`rule_id`) REFERENCES `{{events_rules}}` (`id`) ON DELETE CASCADE;" + + "ALTER TABLE `{{rules_actions_mapping}}` ADD CONSTRAINT `{{prefix}}rules_actions_mapping_action_id_fk_events_targets_id` " + + "FOREIGN KEY (`action_id`) REFERENCES `{{events_actions}}` (`id`) ON DELETE NO ACTION;" + + "ALTER TABLE `{{users}}` ADD COLUMN `deleted_at` bigint DEFAULT 0 NOT NULL;" + + "ALTER TABLE `{{users}}` ALTER COLUMN `deleted_at` DROP DEFAULT;" + + "CREATE INDEX `{{prefix}}events_rules_updated_at_idx` ON `{{events_rules}}` (`updated_at`);" + + "CREATE INDEX `{{prefix}}events_rules_deleted_at_idx` ON `{{events_rules}}` (`deleted_at`);" + + "CREATE INDEX `{{prefix}}events_rules_trigger_idx` ON `{{events_rules}}` (`trigger`);" + + "CREATE INDEX `{{prefix}}rules_actions_mapping_order_idx` ON `{{rules_actions_mapping}}` (`order`);" + + "CREATE INDEX `{{prefix}}users_deleted_at_idx` ON `{{users}}` (`deleted_at`);" + mysqlV20DownSQL = "DROP TABLE `{{rules_actions_mapping}}` CASCADE;" + + "DROP TABLE `{{events_rules}}` CASCADE;" + + "DROP TABLE `{{events_actions}}` CASCADE;" + + "DROP TABLE `{{tasks}}` CASCADE;" + + "ALTER TABLE `{{users}}` DROP COLUMN `deleted_at`;" ) // MySQLProvider defines the auth provider for MySQL/MariaDB database @@ -503,6 +534,74 @@ func (p *MySQLProvider) cleanupSharedSessions(sessionType SessionType, before in return sqlCommonCleanupSessions(sessionType, before, p.dbHandle) } +func (p *MySQLProvider) getEventActions(limit, offset int, order string, minimal bool) ([]BaseEventAction, error) { + return sqlCommonGetEventActions(limit, offset, order, minimal, p.dbHandle) +} + +func (p *MySQLProvider) dumpEventActions() ([]BaseEventAction, error) { + return sqlCommonDumpEventActions(p.dbHandle) +} + +func (p *MySQLProvider) eventActionExists(name string) (BaseEventAction, error) { + return sqlCommonGetEventActionByName(name, p.dbHandle) +} + +func (p *MySQLProvider) addEventAction(action *BaseEventAction) error { + return sqlCommonAddEventAction(action, p.dbHandle) +} + +func (p *MySQLProvider) updateEventAction(action *BaseEventAction) error { + return sqlCommonUpdateEventAction(action, p.dbHandle) +} + +func (p *MySQLProvider) deleteEventAction(action BaseEventAction) error { + return sqlCommonDeleteEventAction(action, p.dbHandle) +} + +func (p *MySQLProvider) getEventRules(limit, offset int, order string) ([]EventRule, error) { + return sqlCommonGetEventRules(limit, offset, order, p.dbHandle) +} + +func (p *MySQLProvider) dumpEventRules() ([]EventRule, error) { + return sqlCommonDumpEventRules(p.dbHandle) +} + +func (p *MySQLProvider) getRecentlyUpdatedRules(after int64) ([]EventRule, error) { + return sqlCommonGetRecentlyUpdatedRules(after, p.dbHandle) +} + +func (p *MySQLProvider) eventRuleExists(name string) (EventRule, error) { + return sqlCommonGetEventRuleByName(name, p.dbHandle) +} + +func (p *MySQLProvider) addEventRule(rule *EventRule) error { + return sqlCommonAddEventRule(rule, p.dbHandle) +} + +func (p *MySQLProvider) updateEventRule(rule *EventRule) error { + return sqlCommonUpdateEventRule(rule, p.dbHandle) +} + +func (p *MySQLProvider) deleteEventRule(rule EventRule, softDelete bool) error { + return sqlCommonDeleteEventRule(rule, softDelete, p.dbHandle) +} + +func (p *MySQLProvider) getTaskByName(name string) (Task, error) { + return sqlCommonGetTaskByName(name, p.dbHandle) +} + +func (p *MySQLProvider) addTask(name string) error { + return sqlCommonAddTask(name, p.dbHandle) +} + +func (p *MySQLProvider) updateTask(name string, version int64) error { + return sqlCommonUpdateTask(name, version, p.dbHandle) +} + +func (p *MySQLProvider) updateTaskTimestamp(name string) error { + return sqlCommonUpdateTaskTimestamp(name, p.dbHandle) +} + func (p *MySQLProvider) close() error { return p.dbHandle.Close() } @@ -542,6 +641,8 @@ func (p *MySQLProvider) migrateDatabase() error { //nolint:dupl providerLog(logger.LevelError, "%v", err) logger.ErrorToConsole("%v", err) return err + case version == 19: + return updateMySQLDatabaseFromV19(p.dbHandle) default: if version > sqlDatabaseVersion { providerLog(logger.LevelError, "database version %v is newer than the supported one: %v", version, @@ -564,6 +665,8 @@ func (p *MySQLProvider) revertDatabase(targetVersion int) error { } switch dbVersion.Version { + case 20: + return downgradeMySQLDatabaseFromV20(p.dbHandle) default: return fmt.Errorf("database version not handled: %v", dbVersion.Version) } @@ -573,3 +676,34 @@ func (p *MySQLProvider) resetDatabase() error { sql := sqlReplaceAll(mysqlResetSQL) return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, strings.Split(sql, ";"), 0, false) } + +func updateMySQLDatabaseFromV19(dbHandle *sql.DB) error { + return updateMySQLDatabaseFrom19To20(dbHandle) +} + +func downgradeMySQLDatabaseFromV20(dbHandle *sql.DB) error { + return downgradeMySQLDatabaseFrom20To19(dbHandle) +} + +func updateMySQLDatabaseFrom19To20(dbHandle *sql.DB) error { + logger.InfoToConsole("updating database version: 19 -> 20") + providerLog(logger.LevelInfo, "updating database version: 19 -> 20") + sql := strings.ReplaceAll(mysqlV20SQL, "{{events_actions}}", sqlTableEventsActions) + sql = strings.ReplaceAll(sql, "{{events_rules}}", sqlTableEventsRules) + sql = strings.ReplaceAll(sql, "{{rules_actions_mapping}}", sqlTableRulesActionsMapping) + sql = strings.ReplaceAll(sql, "{{users}}", sqlTableUsers) + sql = strings.ReplaceAll(sql, "{{tasks}}", sqlTableTasks) + sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix) + return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 20, true) +} + +func downgradeMySQLDatabaseFrom20To19(dbHandle *sql.DB) error { + logger.InfoToConsole("downgrading database version: 20 -> 19") + providerLog(logger.LevelInfo, "downgrading database version: 20 -> 19") + sql := strings.ReplaceAll(mysqlV20DownSQL, "{{events_actions}}", sqlTableEventsActions) + sql = strings.ReplaceAll(sql, "{{events_rules}}", sqlTableEventsRules) + sql = strings.ReplaceAll(sql, "{{rules_actions_mapping}}", sqlTableRulesActionsMapping) + sql = strings.ReplaceAll(sql, "{{users}}", sqlTableUsers) + sql = strings.ReplaceAll(sql, "{{tasks}}", sqlTableTasks) + return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 19, false) +} diff --git a/dataprovider/pgsql.go b/dataprovider/pgsql.go index 923b661a..a1d0f835 100644 --- a/dataprovider/pgsql.go +++ b/dataprovider/pgsql.go @@ -35,6 +35,10 @@ DROP TABLE IF EXISTS "{{defender_events}}" CASCADE; DROP TABLE IF EXISTS "{{defender_hosts}}" CASCADE; DROP TABLE IF EXISTS "{{active_transfers}}" CASCADE; DROP TABLE IF EXISTS "{{shared_sessions}}" CASCADE; +DROP TABLE IF EXISTS "{{rules_actions_mapping}}" CASCADE; +DROP TABLE IF EXISTS "{{events_actions}}" CASCADE; +DROP TABLE IF EXISTS "{{events_rules}}" CASCADE; +DROP TABLE IF EXISTS "{{tasks}}" CASCADE; DROP TABLE IF EXISTS "{{schema_version}}" CASCADE; ` pgsqlInitial = `CREATE TABLE "{{schema_version}}" ("id" serial NOT NULL PRIMARY KEY, "version" integer NOT NULL); @@ -127,6 +131,36 @@ CREATE INDEX "{{prefix}}active_transfers_updated_at_idx" ON "{{active_transfers} CREATE INDEX "{{prefix}}shared_sessions_type_idx" ON "{{shared_sessions}}" ("type"); CREATE INDEX "{{prefix}}shared_sessions_timestamp_idx" ON "{{shared_sessions}}" ("timestamp"); INSERT INTO {{schema_version}} (version) VALUES (19); +` + pgsqlV20SQL = `CREATE TABLE "{{events_rules}}" ("id" serial NOT NULL PRIMARY KEY, "name" varchar(255) NOT NULL UNIQUE, +"description" varchar(512) NULL, "created_at" bigint NOT NULL, "updated_at" bigint NOT NULL, "trigger" integer NOT NULL, +"conditions" text NOT NULL, "deleted_at" bigint NOT NULL); +CREATE TABLE "{{events_actions}}" ("id" serial NOT NULL PRIMARY KEY, "name" varchar(255) NOT NULL UNIQUE, +"description" varchar(512) NULL, "type" integer NOT NULL, "options" text NOT NULL); +CREATE TABLE "{{rules_actions_mapping}}" ("id" serial NOT NULL PRIMARY KEY, "rule_id" integer NOT NULL, +"action_id" integer NOT NULL, "order" integer NOT NULL, "options" text NOT NULL); +CREATE TABLE "{{tasks}}" ("id" serial NOT NULL PRIMARY KEY, "name" varchar(255) NOT NULL UNIQUE, "updated_at" bigint NOT NULL, +"version" bigint NOT NULL); +ALTER TABLE "{{rules_actions_mapping}}" ADD CONSTRAINT "{{prefix}}unique_rule_action_mapping" UNIQUE ("rule_id", "action_id"); +ALTER TABLE "{{rules_actions_mapping}}" ADD CONSTRAINT "{{prefix}}rules_actions_mapping_rule_id_fk_events_rules_id" +FOREIGN KEY ("rule_id") REFERENCES "{{events_rules}}" ("id") MATCH SIMPLE ON UPDATE NO ACTION ON DELETE CASCADE; +ALTER TABLE "{{rules_actions_mapping}}" ADD CONSTRAINT "{{prefix}}rules_actions_mapping_action_id_fk_events_targets_id" +FOREIGN KEY ("action_id") REFERENCES "{{events_actions}}" ("id") MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION; +ALTER TABLE "{{users}}" ADD COLUMN "deleted_at" bigint DEFAULT 0 NOT NULL; +ALTER TABLE "{{users}}" ALTER COLUMN "deleted_at" DROP DEFAULT; +CREATE INDEX "{{prefix}}events_rules_updated_at_idx" ON "{{events_rules}}" ("updated_at"); +CREATE INDEX "{{prefix}}events_rules_deleted_at_idx" ON "{{events_rules}}" ("deleted_at"); +CREATE INDEX "{{prefix}}events_rules_trigger_idx" ON "{{events_rules}}" ("trigger"); +CREATE INDEX "{{prefix}}rules_actions_mapping_rule_id_idx" ON "{{rules_actions_mapping}}" ("rule_id"); +CREATE INDEX "{{prefix}}rules_actions_mapping_action_id_idx" ON "{{rules_actions_mapping}}" ("action_id"); +CREATE INDEX "{{prefix}}rules_actions_mapping_order_idx" ON "{{rules_actions_mapping}}" ("order"); +CREATE INDEX "{{prefix}}users_deleted_at_idx" ON "{{users}}" ("deleted_at"); +` + pgsqlV20DownSQL = `DROP TABLE "{{rules_actions_mapping}}" CASCADE; +DROP TABLE "{{events_rules}}" CASCADE; +DROP TABLE "{{events_actions}}" CASCADE; +DROP TABLE "{{tasks}}" CASCADE; +ALTER TABLE "{{users}}" DROP COLUMN "deleted_at" CASCADE; ` ) @@ -475,6 +509,74 @@ func (p *PGSQLProvider) cleanupSharedSessions(sessionType SessionType, before in return sqlCommonCleanupSessions(sessionType, before, p.dbHandle) } +func (p *PGSQLProvider) getEventActions(limit, offset int, order string, minimal bool) ([]BaseEventAction, error) { + return sqlCommonGetEventActions(limit, offset, order, minimal, p.dbHandle) +} + +func (p *PGSQLProvider) dumpEventActions() ([]BaseEventAction, error) { + return sqlCommonDumpEventActions(p.dbHandle) +} + +func (p *PGSQLProvider) eventActionExists(name string) (BaseEventAction, error) { + return sqlCommonGetEventActionByName(name, p.dbHandle) +} + +func (p *PGSQLProvider) addEventAction(action *BaseEventAction) error { + return sqlCommonAddEventAction(action, p.dbHandle) +} + +func (p *PGSQLProvider) updateEventAction(action *BaseEventAction) error { + return sqlCommonUpdateEventAction(action, p.dbHandle) +} + +func (p *PGSQLProvider) deleteEventAction(action BaseEventAction) error { + return sqlCommonDeleteEventAction(action, p.dbHandle) +} + +func (p *PGSQLProvider) getEventRules(limit, offset int, order string) ([]EventRule, error) { + return sqlCommonGetEventRules(limit, offset, order, p.dbHandle) +} + +func (p *PGSQLProvider) dumpEventRules() ([]EventRule, error) { + return sqlCommonDumpEventRules(p.dbHandle) +} + +func (p *PGSQLProvider) getRecentlyUpdatedRules(after int64) ([]EventRule, error) { + return sqlCommonGetRecentlyUpdatedRules(after, p.dbHandle) +} + +func (p *PGSQLProvider) eventRuleExists(name string) (EventRule, error) { + return sqlCommonGetEventRuleByName(name, p.dbHandle) +} + +func (p *PGSQLProvider) addEventRule(rule *EventRule) error { + return sqlCommonAddEventRule(rule, p.dbHandle) +} + +func (p *PGSQLProvider) updateEventRule(rule *EventRule) error { + return sqlCommonUpdateEventRule(rule, p.dbHandle) +} + +func (p *PGSQLProvider) deleteEventRule(rule EventRule, softDelete bool) error { + return sqlCommonDeleteEventRule(rule, softDelete, p.dbHandle) +} + +func (p *PGSQLProvider) getTaskByName(name string) (Task, error) { + return sqlCommonGetTaskByName(name, p.dbHandle) +} + +func (p *PGSQLProvider) addTask(name string) error { + return sqlCommonAddTask(name, p.dbHandle) +} + +func (p *PGSQLProvider) updateTask(name string, version int64) error { + return sqlCommonUpdateTask(name, version, p.dbHandle) +} + +func (p *PGSQLProvider) updateTaskTimestamp(name string) error { + return sqlCommonUpdateTaskTimestamp(name, p.dbHandle) +} + func (p *PGSQLProvider) close() error { return p.dbHandle.Close() } @@ -495,12 +597,6 @@ func (p *PGSQLProvider) initializeDatabase() error { logger.InfoToConsole("creating initial database schema, version 19") providerLog(logger.LevelInfo, "creating initial database schema, version 19") initialSQL := sqlReplaceAll(pgsqlInitial) - if config.Driver == CockroachDataProviderName { - // Cockroach does not support deferrable constraint validation, we don't need them, - // we keep these definitions for the PostgreSQL driver to avoid changes for users - // upgrading from old SFTPGo versions - initialSQL = strings.ReplaceAll(initialSQL, "DEFERRABLE INITIALLY DEFERRED", "") - } return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, []string{initialSQL}, 19, true) } @@ -520,6 +616,8 @@ func (p *PGSQLProvider) migrateDatabase() error { //nolint:dupl providerLog(logger.LevelError, "%v", err) logger.ErrorToConsole("%v", err) return err + case version == 19: + return updatePgSQLDatabaseFromV19(p.dbHandle) default: if version > sqlDatabaseVersion { providerLog(logger.LevelError, "database version %v is newer than the supported one: %v", version, @@ -542,6 +640,8 @@ func (p *PGSQLProvider) revertDatabase(targetVersion int) error { } switch dbVersion.Version { + case 20: + return downgradePgSQLDatabaseFromV20(p.dbHandle) default: return fmt.Errorf("database version not handled: %v", dbVersion.Version) } @@ -551,3 +651,34 @@ func (p *PGSQLProvider) resetDatabase() error { sql := sqlReplaceAll(pgsqlResetSQL) return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, []string{sql}, 0, false) } + +func updatePgSQLDatabaseFromV19(dbHandle *sql.DB) error { + return updatePgSQLDatabaseFrom19To20(dbHandle) +} + +func downgradePgSQLDatabaseFromV20(dbHandle *sql.DB) error { + return downgradePgSQLDatabaseFrom20To19(dbHandle) +} + +func updatePgSQLDatabaseFrom19To20(dbHandle *sql.DB) error { + logger.InfoToConsole("updating database version: 19 -> 20") + providerLog(logger.LevelInfo, "updating database version: 19 -> 20") + sql := strings.ReplaceAll(pgsqlV20SQL, "{{events_actions}}", sqlTableEventsActions) + sql = strings.ReplaceAll(sql, "{{events_rules}}", sqlTableEventsRules) + sql = strings.ReplaceAll(sql, "{{rules_actions_mapping}}", sqlTableRulesActionsMapping) + sql = strings.ReplaceAll(sql, "{{tasks}}", sqlTableTasks) + sql = strings.ReplaceAll(sql, "{{users}}", sqlTableUsers) + sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix) + return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 20, true) +} + +func downgradePgSQLDatabaseFrom20To19(dbHandle *sql.DB) error { + logger.InfoToConsole("downgrading database version: 20 -> 19") + providerLog(logger.LevelInfo, "downgrading database version: 20 -> 19") + sql := strings.ReplaceAll(pgsqlV20DownSQL, "{{events_actions}}", sqlTableEventsActions) + sql = strings.ReplaceAll(sql, "{{events_rules}}", sqlTableEventsRules) + sql = strings.ReplaceAll(sql, "{{rules_actions_mapping}}", sqlTableRulesActionsMapping) + sql = strings.ReplaceAll(sql, "{{users}}", sqlTableUsers) + sql = strings.ReplaceAll(sql, "{{tasks}}", sqlTableTasks) + return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 19, false) +} diff --git a/dataprovider/quota.go b/dataprovider/quota.go new file mode 100644 index 00000000..569ff644 --- /dev/null +++ b/dataprovider/quota.go @@ -0,0 +1,127 @@ +package dataprovider + +import ( + "sync" + "time" + + "github.com/drakkan/sftpgo/v2/util" +) + +var ( + // QuotaScans is the list of active quota scans + QuotaScans ActiveScans +) + +// ActiveQuotaScan defines an active quota scan for a user home dir +type ActiveQuotaScan struct { + // Username to which the quota scan refers + Username string `json:"username"` + // quota scan start time as unix timestamp in milliseconds + StartTime int64 `json:"start_time"` +} + +// ActiveVirtualFolderQuotaScan defines an active quota scan for a virtual folder +type ActiveVirtualFolderQuotaScan struct { + // folder name to which the quota scan refers + Name string `json:"name"` + // quota scan start time as unix timestamp in milliseconds + StartTime int64 `json:"start_time"` +} + +// ActiveScans holds the active quota scans +type ActiveScans struct { + sync.RWMutex + UserScans []ActiveQuotaScan + FolderScans []ActiveVirtualFolderQuotaScan +} + +// GetUsersQuotaScans returns the active quota scans for users home directories +func (s *ActiveScans) GetUsersQuotaScans() []ActiveQuotaScan { + s.RLock() + defer s.RUnlock() + + scans := make([]ActiveQuotaScan, len(s.UserScans)) + copy(scans, s.UserScans) + return scans +} + +// AddUserQuotaScan adds a user to the ones with active quota scans. +// Returns false if the user has a quota scan already running +func (s *ActiveScans) AddUserQuotaScan(username string) bool { + s.Lock() + defer s.Unlock() + + for _, scan := range s.UserScans { + if scan.Username == username { + return false + } + } + s.UserScans = append(s.UserScans, ActiveQuotaScan{ + Username: username, + StartTime: util.GetTimeAsMsSinceEpoch(time.Now()), + }) + return true +} + +// RemoveUserQuotaScan removes a user from the ones with active quota scans. +// Returns false if the user has no active quota scans +func (s *ActiveScans) RemoveUserQuotaScan(username string) bool { + s.Lock() + defer s.Unlock() + + for idx, scan := range s.UserScans { + if scan.Username == username { + lastIdx := len(s.UserScans) - 1 + s.UserScans[idx] = s.UserScans[lastIdx] + s.UserScans = s.UserScans[:lastIdx] + return true + } + } + + return false +} + +// GetVFoldersQuotaScans returns the active quota scans for virtual folders +func (s *ActiveScans) GetVFoldersQuotaScans() []ActiveVirtualFolderQuotaScan { + s.RLock() + defer s.RUnlock() + scans := make([]ActiveVirtualFolderQuotaScan, len(s.FolderScans)) + copy(scans, s.FolderScans) + return scans +} + +// AddVFolderQuotaScan adds a virtual folder to the ones with active quota scans. +// Returns false if the folder has a quota scan already running +func (s *ActiveScans) AddVFolderQuotaScan(folderName string) bool { + s.Lock() + defer s.Unlock() + + for _, scan := range s.FolderScans { + if scan.Name == folderName { + return false + } + } + s.FolderScans = append(s.FolderScans, ActiveVirtualFolderQuotaScan{ + Name: folderName, + StartTime: util.GetTimeAsMsSinceEpoch(time.Now()), + }) + return true +} + +// RemoveVFolderQuotaScan removes a folder from the ones with active quota scans. +// Returns false if the folder has no active quota scans +func (s *ActiveScans) RemoveVFolderQuotaScan(folderName string) bool { + s.Lock() + defer s.Unlock() + + for idx, scan := range s.FolderScans { + if scan.Name == folderName { + lastIdx := len(s.FolderScans) - 1 + s.FolderScans[idx] = s.FolderScans[lastIdx] + s.FolderScans = s.FolderScans[:lastIdx] + return true + } + } + + return false +} diff --git a/dataprovider/scheduler.go b/dataprovider/scheduler.go index d87b2791..4578b365 100644 --- a/dataprovider/scheduler.go +++ b/dataprovider/scheduler.go @@ -13,11 +13,12 @@ import ( ) var ( - scheduler *cron.Cron - lastCachesUpdate int64 - // used for bolt and memory providers, so we avoid iterating all users + scheduler *cron.Cron + lastUserCacheUpdate int64 + // used for bolt and memory providers, so we avoid iterating all users/rules // to find recently modified ones lastUserUpdate int64 + lastRuleUpdate int64 ) func stopScheduler() { @@ -30,30 +31,22 @@ func stopScheduler() { func startScheduler() error { stopScheduler() - scheduler = cron.New() - _, err := scheduler.AddFunc("@every 30s", checkDataprovider) + scheduler = cron.New(cron.WithLocation(time.UTC)) + _, err := scheduler.AddFunc("@every 60s", checkDataprovider) if err != nil { return fmt.Errorf("unable to schedule dataprovider availability check: %w", err) } - - if config.AutoBackup.Enabled { - spec := fmt.Sprintf("0 %v * * %v", config.AutoBackup.Hour, config.AutoBackup.DayOfWeek) - _, err = scheduler.AddFunc(spec, config.doBackup) - if err != nil { - return fmt.Errorf("unable to schedule auto backup: %w", err) - } - } - err = addScheduledCacheUpdates() if err != nil { return err } + EventManager.loadRules() scheduler.Start() return nil } func addScheduledCacheUpdates() error { - lastCachesUpdate = util.GetTimeAsMsSinceEpoch(time.Now()) + lastUserCacheUpdate = util.GetTimeAsMsSinceEpoch(time.Now()) _, err := scheduler.AddFunc("@every 10m", checkCacheUpdates) if err != nil { return fmt.Errorf("unable to schedule cache updates: %w", err) @@ -70,9 +63,9 @@ func checkDataprovider() { } func checkCacheUpdates() { - providerLog(logger.LevelDebug, "start caches check, update time %v", util.GetTimeFromMsecSinceEpoch(lastCachesUpdate)) + providerLog(logger.LevelDebug, "start caches check, update time %v", util.GetTimeFromMsecSinceEpoch(lastUserCacheUpdate)) checkTime := util.GetTimeAsMsSinceEpoch(time.Now()) - users, err := provider.getRecentlyUpdatedUsers(lastCachesUpdate) + users, err := provider.getRecentlyUpdatedUsers(lastUserCacheUpdate) if err != nil { providerLog(logger.LevelError, "unable to get recently updated users: %v", err) return @@ -83,8 +76,9 @@ func checkCacheUpdates() { cachedPasswords.Remove(user.Username) } - lastCachesUpdate = checkTime - providerLog(logger.LevelDebug, "end caches check, new update time %v", util.GetTimeFromMsecSinceEpoch(lastCachesUpdate)) + lastUserCacheUpdate = checkTime + EventManager.loadRules() + providerLog(logger.LevelDebug, "end caches check, new update time %v", util.GetTimeFromMsecSinceEpoch(lastUserCacheUpdate)) } func setLastUserUpdate() { @@ -94,3 +88,11 @@ func setLastUserUpdate() { func getLastUserUpdate() int64 { return atomic.LoadInt64(&lastUserUpdate) } + +func setLastRuleUpdate() { + atomic.StoreInt64(&lastRuleUpdate, util.GetTimeAsMsSinceEpoch(time.Now())) +} + +func getLastRuleUpdate() int64 { + return atomic.LoadInt64(&lastRuleUpdate) +} diff --git a/dataprovider/sqlcommon.go b/dataprovider/sqlcommon.go index f90e08af..39ba3638 100644 --- a/dataprovider/sqlcommon.go +++ b/dataprovider/sqlcommon.go @@ -20,7 +20,7 @@ import ( ) const ( - sqlDatabaseVersion = 19 + sqlDatabaseVersion = 20 defaultSQLQueryTimeout = 10 * time.Second longSQLQueryTimeout = 60 * time.Second ) @@ -57,6 +57,10 @@ func sqlReplaceAll(sql string) string { sql = strings.ReplaceAll(sql, "{{defender_hosts}}", sqlTableDefenderHosts) sql = strings.ReplaceAll(sql, "{{active_transfers}}", sqlTableActiveTransfers) sql = strings.ReplaceAll(sql, "{{shared_sessions}}", sqlTableSharedSessions) + sql = strings.ReplaceAll(sql, "{{events_actions}}", sqlTableEventsActions) + sql = strings.ReplaceAll(sql, "{{events_rules}}", sqlTableEventsRules) + sql = strings.ReplaceAll(sql, "{{rules_actions_mapping}}", sqlTableRulesActionsMapping) + sql = strings.ReplaceAll(sql, "{{tasks}}", sqlTableTasks) sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix) return sql } @@ -655,17 +659,16 @@ func sqlCommonAddGroup(group *Group, dbHandle *sql.DB) error { if err := group.validate(); err != nil { return err } + settings, err := json.Marshal(group.UserSettings) + if err != nil { + return err + } ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) defer cancel() return sqlCommonExecuteTx(ctx, dbHandle, func(tx *sql.Tx) error { q := getAddGroupQuery() - - settings, err := json.Marshal(group.UserSettings) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, q, group.Name, group.Description, util.GetTimeAsMsSinceEpoch(time.Now()), + _, err := tx.ExecContext(ctx, q, group.Name, group.Description, util.GetTimeAsMsSinceEpoch(time.Now()), util.GetTimeAsMsSinceEpoch(time.Now()), string(settings)) if err != nil { return err @@ -678,17 +681,17 @@ func sqlCommonUpdateGroup(group *Group, dbHandle *sql.DB) error { if err := group.validate(); err != nil { return err } + + settings, err := json.Marshal(group.UserSettings) + if err != nil { + return err + } ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) defer cancel() return sqlCommonExecuteTx(ctx, dbHandle, func(tx *sql.Tx) error { q := getUpdateGroupQuery() - - settings, err := json.Marshal(group.UserSettings) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, q, group.Description, settings, util.GetTimeAsMsSinceEpoch(time.Now()), group.Name) + _, err := tx.ExecContext(ctx, q, group.Description, settings, util.GetTimeAsMsSinceEpoch(time.Now()), group.Name) if err != nil { return err } @@ -898,28 +901,29 @@ func sqlCommonAddUser(user *User, dbHandle *sql.DB) error { if err != nil { return err } + + permissions, err := user.GetPermissionsAsJSON() + if err != nil { + return err + } + publicKeys, err := user.GetPublicKeysAsJSON() + if err != nil { + return err + } + filters, err := user.GetFiltersAsJSON() + if err != nil { + return err + } + fsConfig, err := user.GetFsConfigAsJSON() + if err != nil { + return err + } ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) defer cancel() return sqlCommonExecuteTx(ctx, dbHandle, func(tx *sql.Tx) error { q := getAddUserQuery() - permissions, err := user.GetPermissionsAsJSON() - if err != nil { - return err - } - publicKeys, err := user.GetPublicKeysAsJSON() - if err != nil { - return err - } - filters, err := user.GetFiltersAsJSON() - if err != nil { - return err - } - fsConfig, err := user.GetFsConfigAsJSON() - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, q, user.Username, user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, + _, err := tx.ExecContext(ctx, q, user.Username, user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize, user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate, string(filters), string(fsConfig), user.AdditionalInfo, user.Description, user.Email, util.GetTimeAsMsSinceEpoch(time.Now()), util.GetTimeAsMsSinceEpoch(time.Now()), @@ -948,28 +952,29 @@ func sqlCommonUpdateUser(user *User, dbHandle *sql.DB) error { if err != nil { return err } + + permissions, err := user.GetPermissionsAsJSON() + if err != nil { + return err + } + publicKeys, err := user.GetPublicKeysAsJSON() + if err != nil { + return err + } + filters, err := user.GetFiltersAsJSON() + if err != nil { + return err + } + fsConfig, err := user.GetFsConfigAsJSON() + if err != nil { + return err + } ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) defer cancel() return sqlCommonExecuteTx(ctx, dbHandle, func(tx *sql.Tx) error { q := getUpdateUserQuery() - permissions, err := user.GetPermissionsAsJSON() - if err != nil { - return err - } - publicKeys, err := user.GetPublicKeysAsJSON() - if err != nil { - return err - } - filters, err := user.GetFiltersAsJSON() - if err != nil { - return err - } - fsConfig, err := user.GetFsConfigAsJSON() - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, q, user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, + _, err := tx.ExecContext(ctx, q, user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize, user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate, string(filters), string(fsConfig), user.AdditionalInfo, user.Description, user.Email, util.GetTimeAsMsSinceEpoch(time.Now()), user.UploadDataTransfer, user.DownloadDataTransfer, user.TotalDataTransfer, @@ -1611,6 +1616,55 @@ func getAdminFromDbRow(row sqlScanner) (Admin, error) { return admin, nil } +func getEventActionFromDbRow(row sqlScanner) (BaseEventAction, error) { + var action BaseEventAction + var description sql.NullString + var options []byte + + err := row.Scan(&action.ID, &action.Name, &description, &action.Type, &options) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return action, util.NewRecordNotFoundError(err.Error()) + } + return action, err + } + if description.Valid { + action.Description = description.String + } + if len(options) > 0 { + err = json.Unmarshal(options, &action.Options) + if err != nil { + return action, err + } + } + return action, nil +} + +func getEventRuleFromDbRow(row sqlScanner) (EventRule, error) { + var rule EventRule + var description sql.NullString + var conditions []byte + + err := row.Scan(&rule.ID, &rule.Name, &description, &rule.CreatedAt, &rule.UpdatedAt, &rule.Trigger, + &conditions, &rule.DeletedAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return rule, util.NewRecordNotFoundError(err.Error()) + } + return rule, err + } + if len(conditions) > 0 { + err = json.Unmarshal(conditions, &rule.Conditions) + if err != nil { + return rule, err + } + } + if description.Valid { + rule.Description = description.String + } + return rule, nil +} + func getGroupFromDbRow(row sqlScanner) (Group, error) { var group Group var userSettings, description sql.NullString @@ -2062,7 +2116,6 @@ func getUsersWithVirtualFolders(ctx context.Context, users []User, dbHandle sqlQ return users, nil } - var err error usersVirtualFolders := make(map[int64][]vfs.VirtualFolder) q := getRelatedFoldersForUsersQuery(users) rows, err := dbHandle.QueryContext(ctx, q) @@ -2124,7 +2177,6 @@ func getUsersWithGroups(ctx context.Context, users []User, dbHandle sqlQuerier) if len(users) == 0 { return users, nil } - var err error usersGroups := make(map[int64][]sdk.GroupMapping) q := getRelatedGroupsForUsersQuery(users) rows, err := dbHandle.QueryContext(ctx, q) @@ -2182,8 +2234,6 @@ func getGroupsWithVirtualFolders(ctx context.Context, groups []Group, dbHandle s if len(groups) == 0 { return groups, nil } - - var err error q := getRelatedFoldersForGroupsQuery(groups) rows, err := dbHandle.QueryContext(ctx, q) if err != nil { @@ -2235,8 +2285,6 @@ func getGroupsWithUsers(ctx context.Context, groups []Group, dbHandle sqlQuerier if len(groups) == 0 { return groups, nil } - - var err error q := getRelatedUsersForGroupsQuery(groups) rows, err := dbHandle.QueryContext(ctx, q) if err != nil { @@ -2272,7 +2320,6 @@ func getVirtualFoldersWithGroups(folders []vfs.BaseVirtualFolder, dbHandle sqlQu if len(folders) == 0 { return folders, nil } - var err error vFoldersGroups := make(map[int64][]string) ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) defer cancel() @@ -2310,7 +2357,6 @@ func getVirtualFoldersWithUsers(folders []vfs.BaseVirtualFolder, dbHandle sqlQue if len(folders) == 0 { return folders, nil } - var err error ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) defer cancel() @@ -2509,6 +2555,492 @@ func sqlCommonCleanupSessions(sessionType SessionType, before int64, dbHandle *s return err } +func getActionsWithRuleNames(ctx context.Context, actions []BaseEventAction, dbHandle sqlQuerier, +) ([]BaseEventAction, error) { + if len(actions) == 0 { + return actions, nil + } + q := getRelatedRulesForActionsQuery(actions) + rows, err := dbHandle.QueryContext(ctx, q) + if err != nil { + return nil, err + } + defer rows.Close() + + actionsRules := make(map[int64][]string) + for rows.Next() { + var name string + var actionID int64 + if err = rows.Scan(&actionID, &name); err != nil { + return nil, err + } + actionsRules[actionID] = append(actionsRules[actionID], name) + } + err = rows.Err() + if err != nil { + return nil, err + } + if len(actionsRules) == 0 { + return actions, nil + } + for idx := range actions { + ref := &actions[idx] + ref.Rules = actionsRules[ref.ID] + } + return actions, nil +} + +func getRulesWithActions(ctx context.Context, rules []EventRule, dbHandle sqlQuerier) ([]EventRule, error) { + if len(rules) == 0 { + return rules, nil + } + rulesActions := make(map[int64][]EventAction) + q := getRelatedActionsForRulesQuery(rules) + rows, err := dbHandle.QueryContext(ctx, q) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var action EventAction + var ruleID int64 + var description sql.NullString + var baseOptions, options []byte + err = rows.Scan(&action.ID, &action.Name, &description, &action.Type, &baseOptions, &options, + &action.Order, &ruleID) + if err != nil { + return rules, err + } + if len(baseOptions) > 0 { + err = json.Unmarshal(baseOptions, &action.BaseEventAction.Options) + if err != nil { + return rules, err + } + } + if len(options) > 0 { + err = json.Unmarshal(options, &action.Options) + if err != nil { + return rules, err + } + } + action.BaseEventAction.Options.SetEmptySecretsIfNil() + rulesActions[ruleID] = append(rulesActions[ruleID], action) + } + err = rows.Err() + if err != nil { + return rules, err + } + if len(rulesActions) == 0 { + return rules, nil + } + for idx := range rules { + ref := &rules[idx] + ref.Actions = rulesActions[ref.ID] + } + return rules, nil +} + +func generateEventRuleActionsMapping(ctx context.Context, rule *EventRule, dbHandle sqlQuerier) error { + q := getClearRuleActionMappingQuery() + _, err := dbHandle.ExecContext(ctx, q, rule.Name) + if err != nil { + return err + } + for _, action := range rule.Actions { + options, err := json.Marshal(action.Options) + if err != nil { + return err + } + q = getAddRuleActionMappingQuery() + _, err = dbHandle.ExecContext(ctx, q, rule.Name, action.Name, action.Order, string(options)) + if err != nil { + return err + } + } + return nil +} + +func sqlCommonGetEventActionByName(name string, dbHandle sqlQuerier) (BaseEventAction, error) { + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + q := getEventActionByNameQuery() + row := dbHandle.QueryRowContext(ctx, q, name) + + action, err := getEventActionFromDbRow(row) + if err != nil { + return action, err + } + actions, err := getActionsWithRuleNames(ctx, []BaseEventAction{action}, dbHandle) + if err != nil { + return action, err + } + if len(actions) != 1 { + return action, fmt.Errorf("unable to associate rules with action %q", name) + } + return actions[0], nil +} + +func sqlCommonDumpEventActions(dbHandle sqlQuerier) ([]BaseEventAction, error) { + actions := make([]BaseEventAction, 0, 10) + ctx, cancel := context.WithTimeout(context.Background(), longSQLQueryTimeout) + defer cancel() + + q := getDumpEventActionsQuery() + rows, err := dbHandle.QueryContext(ctx, q) + if err != nil { + return actions, err + } + defer rows.Close() + + for rows.Next() { + action, err := getEventActionFromDbRow(rows) + if err != nil { + return actions, err + } + actions = append(actions, action) + } + return actions, rows.Err() +} + +func sqlCommonGetEventActions(limit int, offset int, order string, minimal bool, + dbHandle sqlQuerier, +) ([]BaseEventAction, error) { + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + q := getEventsActionsQuery(order, minimal) + + actions := make([]BaseEventAction, 0, limit) + rows, err := dbHandle.QueryContext(ctx, q, limit, offset) + if err != nil { + return actions, err + } + defer rows.Close() + + for rows.Next() { + var action BaseEventAction + if minimal { + err = rows.Scan(&action.ID, &action.Name) + } else { + action, err = getEventActionFromDbRow(rows) + } + if err != nil { + return actions, err + } + actions = append(actions, action) + } + err = rows.Err() + if err != nil { + return nil, err + } + if minimal { + return actions, nil + } + actions, err = getActionsWithRuleNames(ctx, actions, dbHandle) + if err != nil { + return nil, err + } + for idx := range actions { + actions[idx].PrepareForRendering() + } + return actions, nil +} + +func sqlCommonAddEventAction(action *BaseEventAction, dbHandle *sql.DB) error { + if err := action.validate(); err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + q := getAddEventActionQuery() + options, err := json.Marshal(action.Options) + if err != nil { + return err + } + _, err = dbHandle.ExecContext(ctx, q, action.Name, action.Description, action.Type, string(options)) + return err +} + +func sqlCommonUpdateEventAction(action *BaseEventAction, dbHandle *sql.DB) error { + if err := action.validate(); err != nil { + return err + } + options, err := json.Marshal(action.Options) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + return sqlCommonExecuteTx(ctx, dbHandle, func(tx *sql.Tx) error { + q := getUpdateEventActionQuery() + _, err = tx.ExecContext(ctx, q, action.Description, action.Type, string(options), action.Name) + if err != nil { + return err + } + q = getUpdateRulesTimestampQuery() + _, err = tx.ExecContext(ctx, q, util.GetTimeAsMsSinceEpoch(time.Now()), action.ID) + return err + }) +} + +func sqlCommonDeleteEventAction(action BaseEventAction, dbHandle *sql.DB) error { + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + q := getDeleteEventActionQuery() + res, err := dbHandle.ExecContext(ctx, q, action.Name) + if err != nil { + return err + } + return sqlCommonRequireRowAffected(res) +} + +func sqlCommonGetEventRuleByName(name string, dbHandle sqlQuerier) (EventRule, error) { + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + q := getEventRulesByNameQuery() + row := dbHandle.QueryRowContext(ctx, q, name) + rule, err := getEventRuleFromDbRow(row) + if err != nil { + return rule, err + } + rules, err := getRulesWithActions(ctx, []EventRule{rule}, dbHandle) + if err != nil { + return rule, err + } + if len(rules) != 1 { + return rule, fmt.Errorf("unable to associate rule %q with actions", name) + } + return rules[0], nil +} + +func sqlCommonDumpEventRules(dbHandle sqlQuerier) ([]EventRule, error) { + rules := make([]EventRule, 0, 10) + ctx, cancel := context.WithTimeout(context.Background(), longSQLQueryTimeout) + defer cancel() + + q := getDumpEventRulesQuery() + rows, err := dbHandle.QueryContext(ctx, q) + if err != nil { + return rules, err + } + defer rows.Close() + + for rows.Next() { + rule, err := getEventRuleFromDbRow(rows) + if err != nil { + return rules, err + } + rules = append(rules, rule) + } + err = rows.Err() + if err != nil { + return rules, err + } + return getRulesWithActions(ctx, rules, dbHandle) +} + +func sqlCommonGetRecentlyUpdatedRules(after int64, dbHandle sqlQuerier) ([]EventRule, error) { + rules := make([]EventRule, 0, 10) + ctx, cancel := context.WithTimeout(context.Background(), longSQLQueryTimeout) + defer cancel() + + q := getRecentlyUpdatedRulesQuery() + rows, err := dbHandle.QueryContext(ctx, q, after) + if err != nil { + return rules, err + } + defer rows.Close() + + for rows.Next() { + rule, err := getEventRuleFromDbRow(rows) + if err != nil { + return rules, err + } + rules = append(rules, rule) + } + err = rows.Err() + if err != nil { + return rules, err + } + return getRulesWithActions(ctx, rules, dbHandle) +} + +func sqlCommonGetEventRules(limit int, offset int, order string, dbHandle sqlQuerier) ([]EventRule, error) { + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + q := getEventRulesQuery(order) + + rules := make([]EventRule, 0, limit) + rows, err := dbHandle.QueryContext(ctx, q, limit, offset) + if err != nil { + return rules, err + } + defer rows.Close() + + for rows.Next() { + rule, err := getEventRuleFromDbRow(rows) + if err != nil { + return rules, err + } + rules = append(rules, rule) + } + err = rows.Err() + if err != nil { + return rules, err + } + rules, err = getRulesWithActions(ctx, rules, dbHandle) + if err != nil { + return rules, err + } + for idx := range rules { + rules[idx].PrepareForRendering() + } + return rules, nil +} + +func sqlCommonAddEventRule(rule *EventRule, dbHandle *sql.DB) error { + if err := rule.validate(); err != nil { + return err + } + conditions, err := json.Marshal(rule.Conditions) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + return sqlCommonExecuteTx(ctx, dbHandle, func(tx *sql.Tx) error { + q := getAddEventRuleQuery() + _, err := tx.ExecContext(ctx, q, rule.Name, rule.Description, util.GetTimeAsMsSinceEpoch(time.Now()), + util.GetTimeAsMsSinceEpoch(time.Now()), rule.Trigger, string(conditions)) + if err != nil { + return err + } + return generateEventRuleActionsMapping(ctx, rule, tx) + }) +} + +func sqlCommonUpdateEventRule(rule *EventRule, dbHandle *sql.DB) error { + if err := rule.validate(); err != nil { + return err + } + conditions, err := json.Marshal(rule.Conditions) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + return sqlCommonExecuteTx(ctx, dbHandle, func(tx *sql.Tx) error { + q := getUpdateEventRuleQuery() + _, err := tx.ExecContext(ctx, q, rule.Description, util.GetTimeAsMsSinceEpoch(time.Now()), + rule.Trigger, string(conditions), rule.Name) + if err != nil { + return err + } + return generateEventRuleActionsMapping(ctx, rule, tx) + }) +} + +func sqlCommonDeleteEventRule(rule EventRule, softDelete bool, dbHandle *sql.DB) error { + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + return sqlCommonExecuteTx(ctx, dbHandle, func(tx *sql.Tx) error { + if softDelete { + q := getClearRuleActionMappingQuery() + _, err := tx.ExecContext(ctx, q, rule.Name) + if err != nil { + return err + } + } + q := getDeleteEventRuleQuery(softDelete) + if softDelete { + ts := util.GetTimeAsMsSinceEpoch(time.Now()) + res, err := tx.ExecContext(ctx, q, ts, ts, rule.Name) + if err != nil { + return err + } + return sqlCommonRequireRowAffected(res) + } + res, err := tx.ExecContext(ctx, q, rule.Name) + if err != nil { + return err + } + if err = sqlCommonRequireRowAffected(res); err != nil { + return err + } + return sqlCommonDeleteTask(rule.Name, tx) + }) +} + +func sqlCommonGetTaskByName(name string, dbHandle sqlQuerier) (Task, error) { + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + task := Task{ + Name: name, + } + q := getTaskByNameQuery() + row := dbHandle.QueryRowContext(ctx, q, name) + err := row.Scan(&task.UpdateAt, &task.Version) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return task, util.NewRecordNotFoundError(err.Error()) + } + } + return task, err +} + +func sqlCommonAddTask(name string, dbHandle *sql.DB) error { + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + q := getAddTaskQuery() + _, err := dbHandle.ExecContext(ctx, q, name, util.GetTimeAsMsSinceEpoch(time.Now())) + return err +} + +func sqlCommonUpdateTask(name string, version int64, dbHandle *sql.DB) error { + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + q := getUpdateTaskQuery() + res, err := dbHandle.ExecContext(ctx, q, util.GetTimeAsMsSinceEpoch(time.Now()), name, version) + if err != nil { + return err + } + return sqlCommonRequireRowAffected(res) +} + +func sqlCommonUpdateTaskTimestamp(name string, dbHandle *sql.DB) error { + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + q := getUpdateTaskTimestampQuery() + res, err := dbHandle.ExecContext(ctx, q, util.GetTimeAsMsSinceEpoch(time.Now()), name) + if err != nil { + return err + } + return sqlCommonRequireRowAffected(res) +} + +func sqlCommonDeleteTask(name string, dbHandle sqlQuerier) error { + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + + q := getDeleteTaskQuery() + _, err := dbHandle.ExecContext(ctx, q, name) + return err +} + func sqlCommonGetDatabaseVersion(dbHandle sqlQuerier, showInitWarn bool) (schemaVersion, error) { var result schemaVersion ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) @@ -2594,7 +3126,7 @@ func sqlAcquireLock(dbHandle *sql.DB) error { return errors.New("unable to get lock: null value returned") } if lockResult.Int64 != 1 { - return fmt.Errorf("unable to get lock, result: %v", lockResult.Int64) + return fmt.Errorf("unable to get lock, result: %d", lockResult.Int64) } providerLog(logger.LevelInfo, "acquired database lock") } diff --git a/dataprovider/sqlite.go b/dataprovider/sqlite.go index 05802147..19e3a1e1 100644 --- a/dataprovider/sqlite.go +++ b/dataprovider/sqlite.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "path/filepath" + "strings" "time" // we import go-sqlite3 here to be able to disable SQLite support using a build tag @@ -36,6 +37,10 @@ DROP TABLE IF EXISTS "{{defender_events}}"; DROP TABLE IF EXISTS "{{defender_hosts}}"; DROP TABLE IF EXISTS "{{active_transfers}}"; DROP TABLE IF EXISTS "{{shared_sessions}}"; +DROP TABLE IF EXISTS "{{rules_actions_mapping}}"; +DROP TABLE IF EXISTS "{{events_rules}}"; +DROP TABLE IF EXISTS "{{events_actions}}"; +DROP TABLE IF EXISTS "{{tasks}}"; DROP TABLE IF EXISTS "{{schema_version}}"; ` sqliteInitialSQL = `CREATE TABLE "{{schema_version}}" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "version" integer NOT NULL); @@ -115,6 +120,33 @@ CREATE INDEX "{{prefix}}active_transfers_updated_at_idx" ON "{{active_transfers} CREATE INDEX "{{prefix}}shared_sessions_type_idx" ON "{{shared_sessions}}" ("type"); CREATE INDEX "{{prefix}}shared_sessions_timestamp_idx" ON "{{shared_sessions}}" ("timestamp"); INSERT INTO {{schema_version}} (version) VALUES (19); +` + sqliteV20SQL = `CREATE TABLE "{{events_rules}}" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, +"name" varchar(255) NOT NULL UNIQUE, "description" varchar(512) NULL, "created_at" bigint NOT NULL, +"updated_at" bigint NOT NULL, "trigger" integer NOT NULL, "conditions" text NOT NULL, "deleted_at" bigint NOT NULL); +CREATE TABLE "{{events_actions}}" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "name" varchar(255) NOT NULL UNIQUE, +"description" varchar(512) NULL, "type" integer NOT NULL, "options" text NOT NULL); +CREATE TABLE "{{rules_actions_mapping}}" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, +"rule_id" integer NOT NULL REFERENCES "{{events_rules}}" ("id") ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, +"action_id" integer NOT NULL REFERENCES "{{events_actions}}" ("id") ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED, +"order" integer NOT NULL, "options" text NOT NULL, +CONSTRAINT "{{prefix}}unique_rule_action_mapping" UNIQUE ("rule_id", "action_id")); +CREATE TABLE "{{tasks}}" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "name" varchar(255) NOT NULL UNIQUE, +"updated_at" bigint NOT NULL, "version" bigint NOT NULL); +ALTER TABLE "{{users}}" ADD COLUMN "deleted_at" bigint DEFAULT 0 NOT NULL; +CREATE INDEX "{{prefix}}events_rules_updated_at_idx" ON "{{events_rules}}" ("updated_at"); +CREATE INDEX "{{prefix}}events_rules_deleted_at_idx" ON "{{events_rules}}" ("deleted_at"); +CREATE INDEX "{{prefix}}events_rules_trigger_idx" ON "{{events_rules}}" ("trigger"); +CREATE INDEX "{{prefix}}rules_actions_mapping_rule_id_idx" ON "{{rules_actions_mapping}}" ("rule_id"); +CREATE INDEX "{{prefix}}rules_actions_mapping_action_id_idx" ON "{{rules_actions_mapping}}" ("action_id"); +CREATE INDEX "{{prefix}}rules_actions_mapping_order_idx" ON "{{rules_actions_mapping}}" ("order"); +CREATE INDEX "{{prefix}}users_deleted_at_idx" ON "{{users}}" ("deleted_at"); +` + sqliteV20DownSQL = `DROP TABLE "{{rules_actions_mapping}}"; +DROP TABLE "{{events_rules}}"; +DROP TABLE "{{events_actions}}"; +DROP TABLE "{{tasks}}"; +ALTER TABLE "{{users}}" DROP COLUMN "deleted_at"; ` ) @@ -449,6 +481,74 @@ func (p *SQLiteProvider) cleanupSharedSessions(sessionType SessionType, before i return sqlCommonCleanupSessions(sessionType, before, p.dbHandle) } +func (p *SQLiteProvider) getEventActions(limit, offset int, order string, minimal bool) ([]BaseEventAction, error) { + return sqlCommonGetEventActions(limit, offset, order, minimal, p.dbHandle) +} + +func (p *SQLiteProvider) dumpEventActions() ([]BaseEventAction, error) { + return sqlCommonDumpEventActions(p.dbHandle) +} + +func (p *SQLiteProvider) eventActionExists(name string) (BaseEventAction, error) { + return sqlCommonGetEventActionByName(name, p.dbHandle) +} + +func (p *SQLiteProvider) addEventAction(action *BaseEventAction) error { + return sqlCommonAddEventAction(action, p.dbHandle) +} + +func (p *SQLiteProvider) updateEventAction(action *BaseEventAction) error { + return sqlCommonUpdateEventAction(action, p.dbHandle) +} + +func (p *SQLiteProvider) deleteEventAction(action BaseEventAction) error { + return sqlCommonDeleteEventAction(action, p.dbHandle) +} + +func (p *SQLiteProvider) getEventRules(limit, offset int, order string) ([]EventRule, error) { + return sqlCommonGetEventRules(limit, offset, order, p.dbHandle) +} + +func (p *SQLiteProvider) dumpEventRules() ([]EventRule, error) { + return sqlCommonDumpEventRules(p.dbHandle) +} + +func (p *SQLiteProvider) getRecentlyUpdatedRules(after int64) ([]EventRule, error) { + return sqlCommonGetRecentlyUpdatedRules(after, p.dbHandle) +} + +func (p *SQLiteProvider) eventRuleExists(name string) (EventRule, error) { + return sqlCommonGetEventRuleByName(name, p.dbHandle) +} + +func (p *SQLiteProvider) addEventRule(rule *EventRule) error { + return sqlCommonAddEventRule(rule, p.dbHandle) +} + +func (p *SQLiteProvider) updateEventRule(rule *EventRule) error { + return sqlCommonUpdateEventRule(rule, p.dbHandle) +} + +func (p *SQLiteProvider) deleteEventRule(rule EventRule, softDelete bool) error { + return sqlCommonDeleteEventRule(rule, softDelete, p.dbHandle) +} + +func (p *SQLiteProvider) getTaskByName(name string) (Task, error) { + return sqlCommonGetTaskByName(name, p.dbHandle) +} + +func (p *SQLiteProvider) addTask(name string) error { + return sqlCommonAddTask(name, p.dbHandle) +} + +func (p *SQLiteProvider) updateTask(name string, version int64) error { + return sqlCommonUpdateTask(name, version, p.dbHandle) +} + +func (p *SQLiteProvider) updateTaskTimestamp(name string) error { + return sqlCommonUpdateTaskTimestamp(name, p.dbHandle) +} + func (p *SQLiteProvider) close() error { return p.dbHandle.Close() } @@ -488,6 +588,8 @@ func (p *SQLiteProvider) migrateDatabase() error { //nolint:dupl providerLog(logger.LevelError, "%v", err) logger.ErrorToConsole("%v", err) return err + case version == 19: + return updateSQLiteDatabaseFromV19(p.dbHandle) default: if version > sqlDatabaseVersion { providerLog(logger.LevelError, "database version %v is newer than the supported one: %v", version, @@ -510,6 +612,8 @@ func (p *SQLiteProvider) revertDatabase(targetVersion int) error { } switch dbVersion.Version { + case 20: + return downgradeSQLiteDatabaseFromV20(p.dbHandle) default: return fmt.Errorf("database version not handled: %v", dbVersion.Version) } @@ -520,6 +624,37 @@ func (p *SQLiteProvider) resetDatabase() error { return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, []string{sql}, 0, false) } +func updateSQLiteDatabaseFromV19(dbHandle *sql.DB) error { + return updateSQLiteDatabaseFrom19To20(dbHandle) +} + +func downgradeSQLiteDatabaseFromV20(dbHandle *sql.DB) error { + return downgradeSQLiteDatabaseFrom20To19(dbHandle) +} + +func updateSQLiteDatabaseFrom19To20(dbHandle *sql.DB) error { + logger.InfoToConsole("updating database version: 19 -> 20") + providerLog(logger.LevelInfo, "updating database version: 19 -> 20") + sql := strings.ReplaceAll(sqliteV20SQL, "{{events_actions}}", sqlTableEventsActions) + sql = strings.ReplaceAll(sql, "{{events_rules}}", sqlTableEventsRules) + sql = strings.ReplaceAll(sql, "{{rules_actions_mapping}}", sqlTableRulesActionsMapping) + sql = strings.ReplaceAll(sql, "{{tasks}}", sqlTableTasks) + sql = strings.ReplaceAll(sql, "{{users}}", sqlTableUsers) + sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix) + return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 20, true) +} + +func downgradeSQLiteDatabaseFrom20To19(dbHandle *sql.DB) error { + logger.InfoToConsole("downgrading database version: 20 -> 19") + providerLog(logger.LevelInfo, "downgrading database version: 20 -> 19") + sql := strings.ReplaceAll(sqliteV20DownSQL, "{{events_actions}}", sqlTableEventsActions) + sql = strings.ReplaceAll(sql, "{{events_rules}}", sqlTableEventsRules) + sql = strings.ReplaceAll(sql, "{{rules_actions_mapping}}", sqlTableRulesActionsMapping) + sql = strings.ReplaceAll(sql, "{{users}}", sqlTableUsers) + sql = strings.ReplaceAll(sql, "{{tasks}}", sqlTableTasks) + return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 19, false) +} + /*func setPragmaFK(dbHandle *sql.DB, value string) error { ctx, cancel := context.WithTimeout(context.Background(), longSQLQueryTimeout) defer cancel() diff --git a/dataprovider/sqlqueries.go b/dataprovider/sqlqueries.go index a41fe213..5974e659 100644 --- a/dataprovider/sqlqueries.go +++ b/dataprovider/sqlqueries.go @@ -18,7 +18,9 @@ const ( selectAPIKeyFields = "key_id,name,api_key,scope,created_at,updated_at,last_use_at,expires_at,description,user_id,admin_id" selectShareFields = "s.share_id,s.name,s.description,s.scope,s.paths,u.username,s.created_at,s.updated_at,s.last_use_at," + "s.expires_at,s.password,s.max_tokens,s.used_tokens,s.allow_from" - selectGroupFields = "id,name,description,created_at,updated_at,user_settings" + selectGroupFields = "id,name,description,created_at,updated_at,user_settings" + selectEventActionFields = "id,name,description,type,options" + selectMinimalFields = "id,name" ) func getSQLPlaceholders() []string { @@ -33,12 +35,20 @@ func getSQLPlaceholders() []string { return placeholders } -func getSQLTableGroups() string { +func getSQLQuotedName(name string) string { if config.Driver == MySQLDataProviderName { - return fmt.Sprintf("`%s`", sqlTableGroups) + return fmt.Sprintf("`%s`", name) } - return sqlTableGroups + return fmt.Sprintf(`"%s"`, name) +} + +func getSelectEventRuleFields() string { + if config.Driver == MySQLDataProviderName { + return "id,name,description,created_at,updated_at,`trigger`,conditions,deleted_at" + } + + return `id,name,description,created_at,updated_at,"trigger",conditions,deleted_at` } func getAddSessionQuery() string { @@ -147,18 +157,19 @@ func getDefenderEventsCleanupQuery() string { } func getGroupByNameQuery() string { - return fmt.Sprintf(`SELECT %s FROM %s WHERE name = %s`, selectGroupFields, getSQLTableGroups(), sqlPlaceholders[0]) + return fmt.Sprintf(`SELECT %s FROM %s WHERE name = %s`, selectGroupFields, getSQLQuotedName(sqlTableGroups), + sqlPlaceholders[0]) } func getGroupsQuery(order string, minimal bool) string { var fieldSelection string if minimal { - fieldSelection = "id,name" + fieldSelection = selectMinimalFields } else { fieldSelection = selectGroupFields } - return fmt.Sprintf(`SELECT %s FROM %s ORDER BY name %s LIMIT %s OFFSET %s`, fieldSelection, getSQLTableGroups(), - order, sqlPlaceholders[0], sqlPlaceholders[1]) + return fmt.Sprintf(`SELECT %s FROM %s ORDER BY name %s LIMIT %s OFFSET %s`, fieldSelection, + getSQLQuotedName(sqlTableGroups), order, sqlPlaceholders[0], sqlPlaceholders[1]) } func getGroupsWithNamesQuery(numArgs int) string { @@ -176,7 +187,7 @@ func getGroupsWithNamesQuery(numArgs int) string { } else { sb.WriteString("('')") } - return fmt.Sprintf(`SELECT %s FROM %s WHERE name in %s`, selectGroupFields, getSQLTableGroups(), sb.String()) + return fmt.Sprintf(`SELECT %s FROM %s WHERE name in %s`, selectGroupFields, getSQLQuotedName(sqlTableGroups), sb.String()) } func getUsersInGroupsQuery(numArgs int) string { @@ -195,27 +206,27 @@ func getUsersInGroupsQuery(numArgs int) string { sb.WriteString("('')") } return fmt.Sprintf(`SELECT username FROM %s WHERE id IN (SELECT user_id from %s WHERE group_id IN (SELECT id FROM %s WHERE name IN (%s)))`, - sqlTableUsers, sqlTableUsersGroupsMapping, getSQLTableGroups(), sb.String()) + sqlTableUsers, sqlTableUsersGroupsMapping, getSQLQuotedName(sqlTableGroups), sb.String()) } func getDumpGroupsQuery() string { - return fmt.Sprintf(`SELECT %s FROM %s`, selectGroupFields, getSQLTableGroups()) + return fmt.Sprintf(`SELECT %s FROM %s`, selectGroupFields, getSQLQuotedName(sqlTableGroups)) } func getAddGroupQuery() string { return fmt.Sprintf(`INSERT INTO %s (name,description,created_at,updated_at,user_settings) - VALUES (%s,%s,%s,%s,%s)`, getSQLTableGroups(), sqlPlaceholders[0], sqlPlaceholders[1], + VALUES (%s,%s,%s,%s,%s)`, getSQLQuotedName(sqlTableGroups), sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4]) } func getUpdateGroupQuery() string { return fmt.Sprintf(`UPDATE %s SET description=%s,user_settings=%s,updated_at=%s - WHERE name = %s`, getSQLTableGroups(), sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], + WHERE name = %s`, getSQLQuotedName(sqlTableGroups), sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3]) } func getDeleteGroupQuery() string { - return fmt.Sprintf(`DELETE FROM %s WHERE name = %s`, getSQLTableGroups(), sqlPlaceholders[0]) + return fmt.Sprintf(`DELETE FROM %s WHERE name = %s`, getSQLQuotedName(sqlTableGroups), sqlPlaceholders[0]) } func getAdminByUsernameQuery() string { @@ -457,8 +468,8 @@ func getAddUserQuery() string { return fmt.Sprintf(`INSERT INTO %s (username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions, used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,status,last_login,expiration_date,filters, filesystem,additional_info,description,email,created_at,updated_at,upload_data_transfer,download_data_transfer,total_data_transfer, - used_upload_data_transfer,used_download_data_transfer) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,0,0,%s,%s,%s,0,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,0)`, + used_upload_data_transfer,used_download_data_transfer,deleted_at) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,0,0,%s,%s,%s,0,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,0,0)`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], sqlPlaceholders[14], @@ -527,19 +538,20 @@ func getClearUserGroupMappingQuery() string { func getAddUserGroupMappingQuery() string { return fmt.Sprintf(`INSERT INTO %s (user_id,group_id,group_type) VALUES ((SELECT id FROM %s WHERE username = %s), (SELECT id FROM %s WHERE name = %s),%s)`, - sqlTableUsersGroupsMapping, sqlTableUsers, sqlPlaceholders[0], getSQLTableGroups(), sqlPlaceholders[1], sqlPlaceholders[2]) + sqlTableUsersGroupsMapping, sqlTableUsers, sqlPlaceholders[0], getSQLQuotedName(sqlTableGroups), + sqlPlaceholders[1], sqlPlaceholders[2]) } func getClearGroupFolderMappingQuery() string { return fmt.Sprintf(`DELETE FROM %s WHERE group_id = (SELECT id FROM %s WHERE name = %s)`, sqlTableGroupsFoldersMapping, - getSQLTableGroups(), sqlPlaceholders[0]) + getSQLQuotedName(sqlTableGroups), sqlPlaceholders[0]) } func getAddGroupFolderMappingQuery() string { return fmt.Sprintf(`INSERT INTO %s (virtual_path,quota_size,quota_files,folder_id,group_id) VALUES (%s,%s,%s,(SELECT id FROM %s WHERE name = %s),(SELECT id FROM %s WHERE name = %s))`, sqlTableGroupsFoldersMapping, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlTableFolders, - sqlPlaceholders[3], getSQLTableGroups(), sqlPlaceholders[4]) + sqlPlaceholders[3], getSQLQuotedName(sqlTableGroups), sqlPlaceholders[4]) } func getClearUserFolderMappingQuery() string { @@ -557,7 +569,7 @@ func getAddUserFolderMappingQuery() string { func getFoldersQuery(order string, minimal bool) string { var fieldSelection string if minimal { - fieldSelection = "id,name" + fieldSelection = selectMinimalFields } else { fieldSelection = selectFolderFields } @@ -593,7 +605,7 @@ func getRelatedGroupsForUsersQuery(users []User) string { sb.WriteString(")") } return fmt.Sprintf(`SELECT g.name,ug.group_type,ug.user_id FROM %s g INNER JOIN %s ug ON g.id = ug.group_id WHERE - ug.user_id IN %s ORDER BY ug.user_id`, getSQLTableGroups(), sqlTableUsersGroupsMapping, sb.String()) + ug.user_id IN %s ORDER BY ug.user_id`, getSQLQuotedName(sqlTableGroups), sqlTableUsersGroupsMapping, sb.String()) } func getRelatedFoldersForUsersQuery(users []User) string { @@ -645,7 +657,8 @@ func getRelatedGroupsForFoldersQuery(folders []vfs.BaseVirtualFolder) string { sb.WriteString(")") } return fmt.Sprintf(`SELECT fm.folder_id,g.name FROM %s fm INNER JOIN %s g ON fm.group_id = g.id - WHERE fm.folder_id IN %s ORDER BY fm.folder_id`, sqlTableGroupsFoldersMapping, getSQLTableGroups(), sb.String()) + WHERE fm.folder_id IN %s ORDER BY fm.folder_id`, sqlTableGroupsFoldersMapping, getSQLQuotedName(sqlTableGroups), + sb.String()) } func getRelatedUsersForGroupsQuery(groups []Group) string { @@ -711,6 +724,156 @@ func getCleanupActiveTransfersQuery() string { return fmt.Sprintf(`DELETE FROM %s WHERE updated_at < %s`, sqlTableActiveTransfers, sqlPlaceholders[0]) } +func getRelatedRulesForActionsQuery(actions []BaseEventAction) string { + var sb strings.Builder + for _, a := range actions { + if sb.Len() == 0 { + sb.WriteString("(") + } else { + sb.WriteString(",") + } + sb.WriteString(strconv.FormatInt(a.ID, 10)) + } + if sb.Len() > 0 { + sb.WriteString(")") + } + return fmt.Sprintf(`SELECT am.action_id,r.name FROM %s am INNER JOIN %s r ON am.rule_id = r.id + WHERE am.action_id IN %s ORDER BY r.name ASC`, sqlTableRulesActionsMapping, sqlTableEventsRules, sb.String()) +} + +func getEventsActionsQuery(order string, minimal bool) string { + var fieldSelection string + if minimal { + fieldSelection = selectMinimalFields + } else { + fieldSelection = selectEventActionFields + } + return fmt.Sprintf(`SELECT %s FROM %s ORDER BY name %s LIMIT %s OFFSET %s`, fieldSelection, + sqlTableEventsActions, order, sqlPlaceholders[0], sqlPlaceholders[1]) +} + +func getDumpEventActionsQuery() string { + return fmt.Sprintf(`SELECT %s FROM %s`, selectEventActionFields, sqlTableEventsActions) +} + +func getEventActionByNameQuery() string { + return fmt.Sprintf(`SELECT %s FROM %s WHERE name = %s`, selectEventActionFields, sqlTableEventsActions, + sqlPlaceholders[0]) +} + +func getAddEventActionQuery() string { + return fmt.Sprintf(`INSERT INTO %s (name,description,type,options) VALUES (%s,%s,%s,%s)`, + sqlTableEventsActions, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3]) +} + +func getUpdateEventActionQuery() string { + return fmt.Sprintf(`UPDATE %s SET description=%s,type=%s,options=%s WHERE name = %s`, sqlTableEventsActions, + sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3]) +} + +func getDeleteEventActionQuery() string { + return fmt.Sprintf(`DELETE FROM %s WHERE name = %s`, sqlTableEventsActions, sqlPlaceholders[0]) +} + +func getEventRulesQuery(order string) string { + return fmt.Sprintf(`SELECT %s FROM %s WHERE deleted_at = 0 ORDER BY name %s LIMIT %s OFFSET %s`, + getSelectEventRuleFields(), sqlTableEventsRules, order, sqlPlaceholders[0], sqlPlaceholders[1]) +} + +func getDumpEventRulesQuery() string { + return fmt.Sprintf(`SELECT %s FROM %s WHERE deleted_at = 0`, getSelectEventRuleFields(), sqlTableEventsRules) +} + +func getRecentlyUpdatedRulesQuery() string { + return fmt.Sprintf(`SELECT %s FROM %s WHERE updated_at >= %s OR deleted_at > 0`, getSelectEventRuleFields(), + sqlTableEventsRules, sqlPlaceholders[0]) +} + +func getEventRulesByNameQuery() string { + return fmt.Sprintf(`SELECT %s FROM %s WHERE name = %s AND deleted_at = 0`, getSelectEventRuleFields(), sqlTableEventsRules, + sqlPlaceholders[0]) +} + +func getAddEventRuleQuery() string { + return fmt.Sprintf(`INSERT INTO %s (name,description,created_at,updated_at,%s,conditions,deleted_at) + VALUES (%s,%s,%s,%s,%s,%s,0)`, + sqlTableEventsRules, getSQLQuotedName("trigger"), sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], + sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5]) +} + +func getUpdateEventRuleQuery() string { + return fmt.Sprintf(`UPDATE %s SET description=%s,updated_at=%s,%s=%s,conditions=%s WHERE name = %s`, + sqlTableEventsRules, sqlPlaceholders[0], sqlPlaceholders[1], getSQLQuotedName("trigger"), sqlPlaceholders[2], + sqlPlaceholders[3], sqlPlaceholders[4]) +} + +func getDeleteEventRuleQuery(softDelete bool) string { + if softDelete { + return fmt.Sprintf(`UPDATE %s SET updated_at=%s,deleted_at=%s WHERE name = %s`, + sqlTableEventsRules, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2]) + } + return fmt.Sprintf(`DELETE FROM %s WHERE name = %s`, sqlTableEventsRules, sqlPlaceholders[0]) +} + +func getClearRuleActionMappingQuery() string { + return fmt.Sprintf(`DELETE FROM %s WHERE rule_id = (SELECT id FROM %s WHERE name = %s)`, sqlTableRulesActionsMapping, + sqlTableEventsRules, sqlPlaceholders[0]) +} + +func getUpdateRulesTimestampQuery() string { + return fmt.Sprintf(`UPDATE %s SET updated_at=%s WHERE id IN (SELECT rule_id FROM %s WHERE action_id = %s)`, + sqlTableEventsRules, sqlPlaceholders[0], sqlTableRulesActionsMapping, sqlPlaceholders[1]) +} + +func getRelatedActionsForRulesQuery(rules []EventRule) string { + var sb strings.Builder + for _, r := range rules { + if sb.Len() == 0 { + sb.WriteString("(") + } else { + sb.WriteString(",") + } + sb.WriteString(strconv.FormatInt(r.ID, 10)) + } + if sb.Len() > 0 { + sb.WriteString(")") + } + return fmt.Sprintf(`SELECT a.id,a.name,a.description,a.type,a.options,am.options,am.%s, + am.rule_id FROM %s a INNER JOIN %s am ON a.id = am.action_id WHERE am.rule_id IN %s ORDER BY am.%s ASC`, + getSQLQuotedName("order"), sqlTableEventsActions, sqlTableRulesActionsMapping, sb.String(), + getSQLQuotedName("order")) +} + +func getAddRuleActionMappingQuery() string { + return fmt.Sprintf(`INSERT INTO %s (rule_id,action_id,%s,options) VALUES ((SELECT id FROM %s WHERE name = %s), + (SELECT id FROM %s WHERE name = %s),%s,%s)`, + sqlTableRulesActionsMapping, getSQLQuotedName("order"), sqlTableEventsRules, sqlPlaceholders[0], + sqlTableEventsActions, sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3]) +} + +func getTaskByNameQuery() string { + return fmt.Sprintf(`SELECT updated_at,version FROM %s WHERE name = %s`, sqlTableTasks, sqlPlaceholders[0]) +} + +func getAddTaskQuery() string { + return fmt.Sprintf(`INSERT INTO %s (name,updated_at,version) VALUES (%s,%s,0)`, + sqlTableTasks, sqlPlaceholders[0], sqlPlaceholders[1]) +} + +func getUpdateTaskQuery() string { + return fmt.Sprintf(`UPDATE %s SET updated_at=%s,version = version + 1 WHERE name = %s AND version = %s`, + sqlTableTasks, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2]) +} + +func getUpdateTaskTimestampQuery() string { + return fmt.Sprintf(`UPDATE %s SET updated_at=%s WHERE name = %s`, + sqlTableTasks, sqlPlaceholders[0], sqlPlaceholders[1]) +} + +func getDeleteTaskQuery() string { + return fmt.Sprintf(`DELETE FROM %s WHERE name = %s`, sqlTableTasks, sqlPlaceholders[0]) +} + func getDatabaseVersionQuery() string { return fmt.Sprintf("SELECT version from %s LIMIT 1", sqlTableSchemaVersion) } diff --git a/dataprovider/user.go b/dataprovider/user.go index 3a9030ed..823ef4fe 100644 --- a/dataprovider/user.go +++ b/dataprovider/user.go @@ -1524,10 +1524,11 @@ func (u *User) applyGroupSettings(groupsMapping map[string]Group) { if u.groupSettingsApplied { return } + replacer := u.getGroupPlacehodersReplacer() for _, g := range u.Groups { if g.Type == sdk.GroupTypePrimary { if group, ok := groupsMapping[g.Name]; ok { - u.mergeWithPrimaryGroup(group) + u.mergeWithPrimaryGroup(group, replacer) } else { providerLog(logger.LevelError, "mapping not found for user %s, group %s", u.Username, g.Name) } @@ -1537,7 +1538,7 @@ func (u *User) applyGroupSettings(groupsMapping map[string]Group) { for _, g := range u.Groups { if g.Type == sdk.GroupTypeSecondary { if group, ok := groupsMapping[g.Name]; ok { - u.mergeAdditiveProperties(group, sdk.GroupTypeSecondary) + u.mergeAdditiveProperties(group, sdk.GroupTypeSecondary, replacer) } else { providerLog(logger.LevelError, "mapping not found for user %s, group %s", u.Username, g.Name) } @@ -1566,10 +1567,11 @@ func (u *User) LoadAndApplyGroupSettings() error { if err != nil { return fmt.Errorf("unable to get groups: %w", err) } + replacer := u.getGroupPlacehodersReplacer() // make sure to always merge with the primary group first for idx, g := range groups { if g.Name == primaryGroupName { - u.mergeWithPrimaryGroup(g) + u.mergeWithPrimaryGroup(g, replacer) lastIdx := len(groups) - 1 groups[idx] = groups[lastIdx] groups = groups[:lastIdx] @@ -1577,42 +1579,46 @@ func (u *User) LoadAndApplyGroupSettings() error { } } for _, g := range groups { - u.mergeAdditiveProperties(g, sdk.GroupTypeSecondary) + u.mergeAdditiveProperties(g, sdk.GroupTypeSecondary, replacer) } u.removeDuplicatesAfterGroupMerge() return nil } -func (u *User) replacePlaceholder(value string) string { +func (u *User) getGroupPlacehodersReplacer() *strings.Replacer { + return strings.NewReplacer("%username%", u.Username) +} + +func (u *User) replacePlaceholder(value string, replacer *strings.Replacer) string { if value == "" { return value } - return strings.ReplaceAll(value, "%username%", u.Username) + return replacer.Replace(value) } -func (u *User) replaceFsConfigPlaceholders(fsConfig vfs.Filesystem) vfs.Filesystem { +func (u *User) replaceFsConfigPlaceholders(fsConfig vfs.Filesystem, replacer *strings.Replacer) vfs.Filesystem { switch fsConfig.Provider { case sdk.S3FilesystemProvider: - fsConfig.S3Config.KeyPrefix = u.replacePlaceholder(fsConfig.S3Config.KeyPrefix) + fsConfig.S3Config.KeyPrefix = u.replacePlaceholder(fsConfig.S3Config.KeyPrefix, replacer) case sdk.GCSFilesystemProvider: - fsConfig.GCSConfig.KeyPrefix = u.replacePlaceholder(fsConfig.GCSConfig.KeyPrefix) + fsConfig.GCSConfig.KeyPrefix = u.replacePlaceholder(fsConfig.GCSConfig.KeyPrefix, replacer) case sdk.AzureBlobFilesystemProvider: - fsConfig.AzBlobConfig.KeyPrefix = u.replacePlaceholder(fsConfig.AzBlobConfig.KeyPrefix) + fsConfig.AzBlobConfig.KeyPrefix = u.replacePlaceholder(fsConfig.AzBlobConfig.KeyPrefix, replacer) case sdk.SFTPFilesystemProvider: - fsConfig.SFTPConfig.Username = u.replacePlaceholder(fsConfig.SFTPConfig.Username) - fsConfig.SFTPConfig.Prefix = u.replacePlaceholder(fsConfig.SFTPConfig.Prefix) + fsConfig.SFTPConfig.Username = u.replacePlaceholder(fsConfig.SFTPConfig.Username, replacer) + fsConfig.SFTPConfig.Prefix = u.replacePlaceholder(fsConfig.SFTPConfig.Prefix, replacer) case sdk.HTTPFilesystemProvider: - fsConfig.HTTPConfig.Username = u.replacePlaceholder(fsConfig.HTTPConfig.Username) + fsConfig.HTTPConfig.Username = u.replacePlaceholder(fsConfig.HTTPConfig.Username, replacer) } return fsConfig } -func (u *User) mergeWithPrimaryGroup(group Group) { +func (u *User) mergeWithPrimaryGroup(group Group, replacer *strings.Replacer) { if group.UserSettings.HomeDir != "" { - u.HomeDir = u.replacePlaceholder(group.UserSettings.HomeDir) + u.HomeDir = u.replacePlaceholder(group.UserSettings.HomeDir, replacer) } if group.UserSettings.FsConfig.Provider != 0 { - u.FsConfig = u.replaceFsConfigPlaceholders(group.UserSettings.FsConfig) + u.FsConfig = u.replaceFsConfigPlaceholders(group.UserSettings.FsConfig, replacer) } if u.MaxSessions == 0 { u.MaxSessions = group.UserSettings.MaxSessions @@ -1634,11 +1640,11 @@ func (u *User) mergeWithPrimaryGroup(group Group) { u.DownloadDataTransfer = group.UserSettings.DownloadDataTransfer u.TotalDataTransfer = group.UserSettings.TotalDataTransfer } - u.mergePrimaryGroupFilters(group.UserSettings.Filters) - u.mergeAdditiveProperties(group, sdk.GroupTypePrimary) + u.mergePrimaryGroupFilters(group.UserSettings.Filters, replacer) + u.mergeAdditiveProperties(group, sdk.GroupTypePrimary, replacer) } -func (u *User) mergePrimaryGroupFilters(filters sdk.BaseUserFilters) { +func (u *User) mergePrimaryGroupFilters(filters sdk.BaseUserFilters, replacer *strings.Replacer) { if u.Filters.MaxUploadFileSize == 0 { u.Filters.MaxUploadFileSize = filters.MaxUploadFileSize } @@ -1664,14 +1670,14 @@ func (u *User) mergePrimaryGroupFilters(filters sdk.BaseUserFilters) { u.Filters.ExternalAuthCacheTime = filters.ExternalAuthCacheTime } if u.Filters.StartDirectory == "" { - u.Filters.StartDirectory = u.replacePlaceholder(filters.StartDirectory) + u.Filters.StartDirectory = u.replacePlaceholder(filters.StartDirectory, replacer) } } -func (u *User) mergeAdditiveProperties(group Group, groupType int) { - u.mergeVirtualFolders(group, groupType) - u.mergePermissions(group, groupType) - u.mergeFilePatterns(group, groupType) +func (u *User) mergeAdditiveProperties(group Group, groupType int, replacer *strings.Replacer) { + u.mergeVirtualFolders(group, groupType, replacer) + u.mergePermissions(group, groupType, replacer) + u.mergeFilePatterns(group, groupType, replacer) u.Filters.BandwidthLimits = append(u.Filters.BandwidthLimits, group.UserSettings.Filters.BandwidthLimits...) u.Filters.DataTransferLimits = append(u.Filters.DataTransferLimits, group.UserSettings.Filters.DataTransferLimits...) u.Filters.AllowedIP = append(u.Filters.AllowedIP, group.UserSettings.Filters.AllowedIP...) @@ -1682,7 +1688,7 @@ func (u *User) mergeAdditiveProperties(group Group, groupType int) { u.Filters.TwoFactorAuthProtocols = append(u.Filters.TwoFactorAuthProtocols, group.UserSettings.Filters.TwoFactorAuthProtocols...) } -func (u *User) mergeVirtualFolders(group Group, groupType int) { +func (u *User) mergeVirtualFolders(group Group, groupType int, replacer *strings.Replacer) { if len(group.VirtualFolders) > 0 { folderPaths := make(map[string]bool) for _, folder := range u.VirtualFolders { @@ -1692,17 +1698,17 @@ func (u *User) mergeVirtualFolders(group Group, groupType int) { if folder.VirtualPath == "/" && groupType != sdk.GroupTypePrimary { continue } - folder.VirtualPath = u.replacePlaceholder(folder.VirtualPath) + folder.VirtualPath = u.replacePlaceholder(folder.VirtualPath, replacer) if _, ok := folderPaths[folder.VirtualPath]; !ok { - folder.MappedPath = u.replacePlaceholder(folder.MappedPath) - folder.FsConfig = u.replaceFsConfigPlaceholders(folder.FsConfig) + folder.MappedPath = u.replacePlaceholder(folder.MappedPath, replacer) + folder.FsConfig = u.replaceFsConfigPlaceholders(folder.FsConfig, replacer) u.VirtualFolders = append(u.VirtualFolders, folder) } } } } -func (u *User) mergePermissions(group Group, groupType int) { +func (u *User) mergePermissions(group Group, groupType int, replacer *strings.Replacer) { for k, v := range group.UserSettings.Permissions { if k == "/" { if groupType == sdk.GroupTypePrimary { @@ -1711,14 +1717,14 @@ func (u *User) mergePermissions(group Group, groupType int) { continue } } - k = u.replacePlaceholder(k) + k = u.replacePlaceholder(k, replacer) if _, ok := u.Permissions[k]; !ok { u.Permissions[k] = v } } } -func (u *User) mergeFilePatterns(group Group, groupType int) { +func (u *User) mergeFilePatterns(group Group, groupType int, replacer *strings.Replacer) { if len(group.UserSettings.Filters.FilePatterns) > 0 { patternPaths := make(map[string]bool) for _, pattern := range u.Filters.FilePatterns { @@ -1728,7 +1734,7 @@ func (u *User) mergeFilePatterns(group Group, groupType int) { if pattern.Path == "/" && groupType != sdk.GroupTypePrimary { continue } - pattern.Path = u.replacePlaceholder(pattern.Path) + pattern.Path = u.replacePlaceholder(pattern.Path, replacer) if _, ok := patternPaths[pattern.Path]; !ok { u.Filters.FilePatterns = append(u.Filters.FilePatterns, pattern) } diff --git a/docs/custom-actions.md b/docs/custom-actions.md index 56251f80..107f21b9 100644 --- a/docs/custom-actions.md +++ b/docs/custom-actions.md @@ -62,7 +62,7 @@ If the `hook` defines an HTTP URL then this URL will be invoked as HTTP POST. Th - `virtual_target_path`, string, virtual target path, seen by SFTPGo users - `ssh_cmd`, string, included for `ssh_cmd` action - `file_size`, int64, included for `pre-upload`, `upload`, `download`, `delete` actions if the file size is greater than `0` -- `fs_provider`, integer, `0` for local filesystem, `1` for S3 backend, `2` for Google Cloud Storage (GCS) backend, `3` for Azure Blob Storage backend, `4` for local encrypted backend, `5` for SFTP backend +- `fs_provider`, integer, `0` for local filesystem, `1` for S3 backend, `2` for Google Cloud Storage (GCS) backend, `3` for Azure Blob Storage backend, `4` for local encrypted backend, `5` for SFTP backend, `6` for HTTPFs backend - `bucket`, string, included for S3, GCS and Azure backends - `endpoint`, string, included for S3, SFTP and Azure backend if configured - `status`, integer. Status for `upload`, `download` and `ssh_cmd` actions. 1 means no error, 2 means a generic error occurred, 3 means quota exceeded error @@ -85,8 +85,12 @@ The `actions` struct inside the `data_provider` configuration section allows you The supported object types are: - `user` +- `group` - `admin` - `api_key` +- `share` +- `event_action` +- `event_rule` Actions will not be fired for internal updates, such as the last login or the user quota fields, or after external authentication. diff --git a/docs/eventmanager.md b/docs/eventmanager.md new file mode 100644 index 00000000..3ab1c683 --- /dev/null +++ b/docs/eventmanager.md @@ -0,0 +1,47 @@ +# Event Manager + +The Event Manager allows an administrator to configure HTTP notifications, commands execution, email notifications and carry out certain server operations based on server events or schedules. + +The following actions are supported: + +- `HTTP notification`. You can notify an HTTP/S endpoing via GET, POST, PUT methods. You can define custom headers, query parameters and a body for POST and PUT request. Placeholders are supported for username, body, header and query parameter values. +- `Command execution`. You can launch custom commands passing parameters via environment variables. Placeholders are supported for environment variable values. +- `Email notification`. Placeholders are supported in subject and body. The email will be sent as plain text. For this action to work you have to configure an SMTP server in the SFTPGo configuration file. +- `Backup`. A backup will be saved in the configured backup directory. The backup will contain the week day and the hour in the file name. +- `User quota reset`. The quota used by users will be updated based on current usage. +- `Folder quota reset`. The quota used by virtual folders will be updated based on current usage. +- `Transfer quota reset`. The transfer quota values will be reset to `0`. + +The following placeholders are supported: + +- `{{Name}}`. Username, folder name or admin username for provider actions. +- `{{Event}}`. Event name, for example `upload`, `download` for filesystem events or `add`, `update` for provider events. +- `{{Status}}`. Status for `upload`, `download` and `ssh_cmd` events. 1 means no error, 2 means a generic error occurred, 3 means quota exceeded error. +- `{{VirtualPath}}`. Path seen by SFTPGo users, for example `/adir/afile.txt`. +- `{{FsPath}}`. Full filesystem path, for example `/user/homedir/adir/afile.txt` or `C:/data/user/homedir/adir/afile.txt` on Windows. +- `{{ObjectName}}`. File/directory name, for example `afile.txt` or provider object name. +- `{{ObjectType}}`. Object type for provider events: `user`, `group`, `admin`, etc. +- `{{VirtualTargetPath}}`. Virtual target path for renames. +- `{{FsTargetPath}}`. Full filesystem target path for renames. +- `{{FileSize}}`. File size. +- `{{Protocol}}`. Used protocol, for example `SFTP`, `FTP`. +- `{{IP}}`. Client IP address. +- `{{Timestamp}}`. Event timestamp as nanoseconds since epoch. +- `{{ObjectData}}`. Provider object data serialized as JSON with sensitive fields removed. + +Event rules are based on the premise that an event occours. To each rule you can associate one or more actions. +The following trigger events are supported: + +- `Filesystem events`, for example `upload`, `download` etc. +- `Provider events`, for example add/update/delete user. +- `Schedules`. + +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. + +Actions are executed in a sequential order. For each action associated to a rule you can define the following settings: + +- `Stop on failure`, the next action will not be executed if the current one fails. +- `Failure action`, this action will be executed only if at least another one fails. +- `Execute sync`, for upload events, you can execute the action synchronously. Executing an action synchronously means that SFTPGo will not return a result code to the client (which is waiting for it) until your action have completed its execution. If your acion takes a long time to complete this could cause a timeout on the client side, which wouldn't receive the server response in a timely manner and eventually drop the connection. + +If you are running multiple SFTPGo instances connected to the same data provider, you can choose whether to allow simultaneous execution for scheduled actions. diff --git a/docs/full-configuration.md b/docs/full-configuration.md index cf3aba68..fc2d5e22 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -247,12 +247,8 @@ The configuration file contains the following sections: - `update_mode`, integer. Defines how the database will be initialized/updated. 0 means automatically. 1 means manually using the initprovider sub-command. - `create_default_admin`, boolean. Before you can use SFTPGo you need to create an admin account. If you open the admin web UI, a setup screen will guide you in creating the first admin account. You can automatically create the first admin account by enabling this setting and setting the environment variables `SFTPGO_DEFAULT_ADMIN_USERNAME` and `SFTPGO_DEFAULT_ADMIN_PASSWORD`. You can also create the first admin by loading initial data. This setting has no effect if an admin account is already found within the data provider. Default `false`. - `naming_rules`, integer. Naming rules for usernames, folder and group names. `0` means no rules. `1` means you can use any UTF-8 character. The names are used in URIs for REST API and Web admin. If not set only unreserved URI characters are allowed: ALPHA / DIGIT / "-" / "." / "_" / "~". `2` means names are converted to lowercase before saving/matching and so case insensitive matching is possible. `3` means trimming trailing and leading white spaces before saving/matching. Rules can be combined, for example `3` means both converting to lowercase and allowing any UTF-8 character. Enabling these options for existing installations could be backward incompatible, some users could be unable to login, for example existing users with mixed cases in their usernames. You have to ensure that all existing users respect the defined rules. Default: `1`. - - `is_shared`, integer. If the data provider is shared across multiple SFTPGo instances, set this parameter to `1`. `MySQL`, `PostgreSQL` and `CockroachDB` can be shared, this setting is ignored for other data providers. For shared data providers, active transfers are persisted in the database and thus quota checks between ongoing transfers will work cross multiple instances. Password reset requests and OIDC tokens/states are also persisted in the database if the provider is shared. The database table `shared_sessions` is used only to store temporary sessions. In performance critical installations, you might consider using a database-specific optimization, for example you might use an `UNLOGGED` table for PostgreSQL. This optimization in only required in very limited use cases. Default: `0`. + - `is_shared`, integer. If the data provider is shared across multiple SFTPGo instances, set this parameter to `1`. `MySQL`, `PostgreSQL` and `CockroachDB` can be shared, this setting is ignored for other data providers. For shared data providers, active transfers are persisted in the database and thus quota checks between ongoing transfers will work cross multiple instances. Password reset requests and OIDC tokens/states are also persisted in the database if the provider is shared. For shared data providers, scheduled event actions are only executed on a single SFTPGo instance by default, you can override this behavior on a per-action basis. The database table `shared_sessions` is used only to store temporary sessions. In performance critical installations, you might consider using a database-specific optimization, for example you might use an `UNLOGGED` table for PostgreSQL. This optimization in only required in very limited use cases. Default: `0`. - `backups_path`, string. Path to the backup directory. This can be an absolute path or a path relative to the config dir. We don't allow backups in arbitrary paths for security reasons. - - `auto_backup`, struct. Defines the configuration for automatic data provider backups. Example: hour `0` and day_of_week `*` means a backup every day at midnight. The backup file name is in the format `backup__.json`, files with the same name will be overwritten. Note, this process will only backup provider data (users, folders, shares, admins, api keys) and will not backup the configuration file and users files. - - `enabled`, boolean. Set to `true` to enable automatic backups. Default: `true`. - - `hour`, string. Hour as standard cron expression. Allowed values: 0-23. Allowed special characters: asterisk (`*`), slash (`/`), comma (`,`), hyphen (`-`). More info about special characters [here](https://pkg.go.dev/github.com/robfig/cron#hdr-Special_Characters). Default: `0`. - - `day_of_week`, string. Day of week as standard cron expression. Allowed values: 0-6 (Sunday to Saturday). Allowed special characters: asterisk (`*`), slash (`/`), comma (`,`), hyphen (`-`), question mark (`?`). More info about special characters [here](https://pkg.go.dev/github.com/robfig/cron#hdr-Special_Characters). Default: `*`. - **"httpd"**, the configuration for the HTTP server used to serve REST API and to expose the built-in web interface - `bindings`, list of structs. Each struct has the following fields: - `port`, integer. The port used for serving HTTP requests. Default: 8080. diff --git a/ftpd/ftpd_test.go b/ftpd/ftpd_test.go index aae54117..7bfe44d9 100644 --- a/ftpd/ftpd_test.go +++ b/ftpd/ftpd_test.go @@ -77,154 +77,154 @@ CzgWkxiz7XE4lgUwX44FCXZM3+JeUbI= -----END EC PRIVATE KEY-----` caCRT = `-----BEGIN CERTIFICATE----- MIIE5jCCAs6gAwIBAgIBATANBgkqhkiG9w0BAQsFADATMREwDwYDVQQDEwhDZXJ0 -QXV0aDAeFw0yMTAxMDIyMTIwNTVaFw0yMjA3MDIyMTMwNTJaMBMxETAPBgNVBAMT -CENlcnRBdXRoMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA4Tiho5xW -AC15JRkMwfp3/TJwI2As7MY5dele5cmdr5bHAE+sRKqC+Ti88OJWCV5saoyax/1S -CjxJlQMZMl169P1QYJskKjdG2sdv6RLWLMgwSNRRjxp/Bw9dHdiEb9MjLgu28Jro -9peQkHcRHeMf5hM9WvlIJGrdzbC4hUehmqggcqgARainBkYjf0SwuWxHeu4nMqkp -Ak5tcSTLCjHfEFHZ9Te0TIPG5YkWocQKyeLgu4lvuU+DD2W2lym+YVUtRMGs1Env -k7p+N0DcGU26qfzZ2sF5ZXkqm7dBsGQB9pIxwc2Q8T1dCIyP9OQCKVILdc5aVFf1 -cryQFHYzYNNZXFlIBims5VV5Mgfp8ESHQSue+v6n6ykecLEyKt1F1Y/MWY/nWUSI -8zdq83jdBAZVjo9MSthxVn57/06s/hQca65IpcTZV2gX0a+eRlAVqaRbAhL3LaZe -bYsW3WHKoUOftwemuep3nL51TzlXZVL7Oz/ClGaEOsnGG9KFO6jh+W768qC0zLQI -CdE7v2Zex98sZteHCg9fGJHIaYoF0aJG5P3WI5oZf2fy7UIYN9ADLFZiorCXAZEh -CSU6mDoRViZ4RGR9GZxbDZ9KYn7O8M/KCR72bkQg73TlMsk1zSXEw0MKLUjtsw6c -rZ0Jt8t3sRatHO3JrYHALMt9vZfyNCZp0IsCAwEAAaNFMEMwDgYDVR0PAQH/BAQD -AgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFO1yCNAGr/zQTJIi8lw3 -w5OiuBvMMA0GCSqGSIb3DQEBCwUAA4ICAQA6gCNuM7r8mnx674dm31GxBjQy5ZwB -7CxDzYEvL/oiZ3Tv3HlPfN2LAAsJUfGnghh9DOytenL2CTZWjl/emP5eijzmlP+9 -zva5I6CIMCf/eDDVsRdO244t0o4uG7+At0IgSDM3bpVaVb4RHZNjEziYChsEYY8d -HK6iwuRSvFniV6yhR/Vj1Ymi9yZ5xclqseLXiQnUB0PkfIk23+7s42cXB16653fH -O/FsPyKBLiKJArizLYQc12aP3QOrYoYD9+fAzIIzew7A5C0aanZCGzkuFpO6TRlD -Tb7ry9Gf0DfPpCgxraH8tOcmnqp/ka3hjqo/SRnnTk0IFrmmLdarJvjD46rKwBo4 -MjyAIR1mQ5j8GTlSFBmSgETOQ/EYvO3FPLmra1Fh7L+DvaVzTpqI9fG3TuyyY+Ri -Fby4ycTOGSZOe5Fh8lqkX5Y47mCUJ3zHzOA1vUJy2eTlMRGpu47Eb1++Vm6EzPUP -2EF5aD+zwcssh+atZvQbwxpgVqVcyLt91RSkKkmZQslh0rnlTb68yxvUnD3zw7So -o6TAf9UvwVMEvdLT9NnFd6hwi2jcNte/h538GJwXeBb8EkfpqLKpTKyicnOdkamZ -7E9zY8SHNRYMwB9coQ/W8NvufbCgkvOoLyMXk5edbXofXl3PhNGOlraWbghBnzf5 -r3rwjFsQOoZotA== +QXV0aDAeFw0yMjA3MDQxNTQzMTFaFw0yNDAxMDQxNTUzMDhaMBMxETAPBgNVBAMT +CENlcnRBdXRoMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA4eyDJkmW +D4OVYo7ddgiZkd6QQdPyLcsa31Wc9jdR2/peEabyNT8jSWteS6ouY84GRlnhfFeZ +mpXgbaUJu/Z8Y/8riPxwL8XF4vCScQDMywpQnVUd6E9x2/+/uaD4p/BBswgKqKPe +uDcHZn7MkD4QlquUhMElDrBUi1Dv/AVHnQ6iP4vd5Jlv0F+40jdq/8Wa7yhW7Pu5 +iNvPwCk8HjENBKVur/re+Acif8A2TlbCsuOnVduSQNmnWH+iZmB9upyBZtUszGS0 +JhUwtSnwUX/JapF70Pwte/PV3RK8cJ5FjuAPNeTyJvSuMTELFSAyCeiNynFGgyhW +cqbEiPu6BURLculyVkmh4dOrhTrYZv/n3UJAhyxkdYrbh3INHmTa4izvclcuwoEo +lFlJp3l77D0lIi+pbtcBV6ys7reyuxUAkBNwnpt2pWfCQoi4QYKcNbHm47c2phOb +QSojQ8SsNU5bnlY2MDzkKo5DPav/i4d0HpndphUpx4f8hA0KylLevDRkMz9TAH7H +uDssn0CxFOGHiveEAGGbn+doHjNWM339x/cdLbK0vuieDKby8YYcBY1JML57Dl9f +rs52ySnDZbMqOb9zF66mQpC2FZoAj713xSkDSnSCUekrqgck1EA1ifxAviHt+p26 +JwaEDL7Lk01EEdYN4csSd1fezbCqTrG8ffUCAwEAAaNFMEMwDgYDVR0PAQH/BAQD +AgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFPirPBPO01zUuf7xC+ds +bOOY5QvAMA0GCSqGSIb3DQEBCwUAA4ICAQBUYa+ydfTPKjTN4lXyEZgchZQ+juny +aMy1xosLz6Evj0us2Bwczmy6X2Zvaw/KteFlgKaU1Ex2UkU7FfAlaH0HtwTLFMVM +p9nB7ZzStvg0n8zFM29SEkOFwZ9FRonxx4sY3FdvI4QvAWyDyqgOl8+Eedg0kC4+ +M7hxarTFmZZ7POZl8Hio592yx3asMmSCcmb7oUCKVI98qsf9fuL+LIZSpn4fE7av +AiNBcOqCZ10CRnl4VSgAW2LH4oqROYdUv+me1u1YRwh7fCF/R7VjOLuaDzv0mp/g +hzG9U+Yso3WV4b28MsctwUmGTK8Zc5QaANKgmI3ulkta37wN5KjrUuescHC7MqZg +vN9n60801be1EoUL83KUx57Bix95YZR02Zge0gYdYTb+E2bwaZ4GMlf7cs6qmC6A +ZPLR7Tffw2J4dPTcfEx3rPZ91s3MkAdPzYYGdGlbKp8RCFnezZ7rw2z57rnT0zDr +LuL3Q6ADBfothoos/EBIC5ekXb9czp8gig+nJXLC6jlqcQpCLrV88oS3+8zACmx1 +d6tje9uuAqPgiQGddKZj4b4BlHmAMXq0PufQsZVoyzboTewZiLVCtTR9/iF7Cepg +6EVv57p61pFhPu8lNRAi0aH/po9yt+7435FGpn2kan6k9aDIVdaqeuxxITwsqJ4R +WwSa13hh6yjoDQ== -----END CERTIFICATE-----` caCRL = `-----BEGIN X509 CRL----- MIICpzCBkAIBATANBgkqhkiG9w0BAQsFADATMREwDwYDVQQDEwhDZXJ0QXV0aBcN -MjEwMTAyMjEzNDA1WhcNMjMwMTAyMjEzNDA1WjAkMCICEQC+l04DbHWMyC3fG09k -VXf+Fw0yMTAxMDIyMTM0MDVaoCMwITAfBgNVHSMEGDAWgBTtcgjQBq/80EySIvJc -N8OTorgbzDANBgkqhkiG9w0BAQsFAAOCAgEAEJ7z+uNc8sqtxlOhSdTGDzX/xput -E857kFQkSlMnU2whQ8c+XpYrBLA5vIZJNSSwohTpM4+zVBX/bJpmu3wqqaArRO9/ -YcW5mQk9Anvb4WjQW1cHmtNapMTzoC9AiYt/OWPfy+P6JCgCr4Hy6LgQyIRL6bM9 -VYTalolOm1qa4Y5cIeT7iHq/91mfaqo8/6MYRjLl8DOTROpmw8OS9bCXkzGKdCat -AbAzwkQUSauyoCQ10rpX+Y64w9ng3g4Dr20aCqPf5osaqplEJ2HTK8ljDTidlslv -9anQj8ax3Su89vI8+hK+YbfVQwrThabgdSjQsn+veyx8GlP8WwHLAQ379KjZjWg+ -OlOSwBeU1vTdP0QcB8X5C2gVujAyuQekbaV86xzIBOj7vZdfHZ6ee30TZ2FKiMyg -7/N2OqW0w77ChsjB4MSHJCfuTgIeg62GzuZXLM+Q2Z9LBdtm4Byg+sm/P52adOEg -gVb2Zf4KSvsAmA0PIBlu449/QXUFcMxzLFy7mwTeZj2B4Ln0Hm0szV9f9R8MwMtB -SyLYxVH+mgqaR6Jkk22Q/yYyLPaELfafX5gp/AIXG8n0zxfVaTvK3auSgb1Q6ZLS -5QH9dSIsmZHlPq7GoSXmKpMdjUL8eaky/IMteioyXgsBiATzl5L2dsw6MTX3MDF0 -QbDK+MzhmbKfDxs= +MjIwNzA0MTU1MzU4WhcNMjQwNzAzMTU1MzU4WjAkMCICEQDZo5Q3lhxFuDUsxGNm +794YFw0yMjA3MDQxNTUzNThaoCMwITAfBgNVHSMEGDAWgBT4qzwTztNc1Ln+8Qvn +bGzjmOULwDANBgkqhkiG9w0BAQsFAAOCAgEA1lK6g8qmhyY6myx8342dDuaauY03 +0iojkxpasuYcytK6XRm96YqjZK9EETxsHHViVU0vCXES60D6wJ9gw4fTWn3WxEdx +nIwbGyjUGHh2y+R3uQsfvwxsdYvDsTLAnOLwOo68dAHWmMDZRmgTuGNoYFxVQRGR +Cn90ZR7LPLpCScclWM8FE/W1B90x3ZE8EhJiCI/WyyTh3EgshmB7A5GoDrFZfmvR +dzoTKO+F9p2XjtmgfiBE3czWQysfATmbutZUbG/ZRb89u+ZEUyPoC94mg8fhNWoX +1d5G9QAkZFHp957/5QHLq9OHNfnWXoohhebjF4VWqZH7w+RtLc8t0PIog2lX4t1o +5N/xFk9akvuoyNGg/fYuJBmN162Q0MdeYfYKDGWdXxf6fpHxVr5v2JrIx6gOwubb +cIKP22ZBv/PYOeFsAZ755lTl4OTFUjU5ZJEPD6pUc1daaIqfxsxu8gDZP92FZjsB +zaalMbh30n2OhagSMBzSLg5rE6WmBzlQX0ZN8YrW4l2Vq6twnnFHY+UyblRZS+d4 +oHBaoOaxPEkLxNZ8ulzJS4B6c4D1CXOaBEf++snVzRRUOEdX3x7TvkkrLvIsm06R +ux0L1zJb9LbZ/1rhuv70z/kIlD55sqYuRqu3RpgTgZuTERU//rYIqWd03Y5Qon8i +VoC6Yp9DPldQJrk= -----END X509 CRL-----` client1Crt = `-----BEGIN CERTIFICATE----- -MIIEITCCAgmgAwIBAgIRAIppZHoj1hM80D7WzTEKLuAwDQYJKoZIhvcNAQELBQAw -EzERMA8GA1UEAxMIQ2VydEF1dGgwHhcNMjEwMTAyMjEyMzEwWhcNMjIwNzAyMjEz -MDUxWjASMRAwDgYDVQQDEwdjbGllbnQxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A -MIIBCgKCAQEAoKbYY9MdF2kF/nhBESIiZTdVYtA8XL9xrIZyDj9EnCiTxHiVbJtH -XVwszqSl5TRrotPmnmAQcX3r8OCk+z+RQZ0QQj257P3kG6q4rNnOcWCS5xEd20jP -yhQ3m+hMGfZsotNTQze1ochuQgLUN6IPyPxZkH22ia3jX4iu1eo/QxeLYHj1UHw4 -3Cii9yE+j5kPUC21xmnrGKdUrB55NYLXHx6yTIqYR5znSOVB8oJi18/hwdZmH859 -DHhm0Hx1HrS+jbjI3+CMorZJ3WUyNf+CkiVLD3xYutPbxzEpwiqkG/XYzLH0habT -cDcILo18n+o3jvem2KWBrDhyairjIDscwQIDAQABo3EwbzAOBgNVHQ8BAf8EBAMC -A7gwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMB0GA1UdDgQWBBSJ5GIv -zIrE4ZSQt2+CGblKTDswizAfBgNVHSMEGDAWgBTtcgjQBq/80EySIvJcN8OTorgb -zDANBgkqhkiG9w0BAQsFAAOCAgEALh4f5GhvNYNou0Ab04iQBbLEdOu2RlbK1B5n -K9P/umYenBHMY/z6HT3+6tpcHsDuqE8UVdq3f3Gh4S2Gu9m8PRitT+cJ3gdo9Plm -3rD4ufn/s6rGg3ppydXcedm17492tbccUDWOBZw3IO/ASVq13WPgT0/Kev7cPq0k -sSdSNhVeXqx8Myc2/d+8GYyzbul2Kpfa7h9i24sK49E9ftnSmsIvngONo08eT1T0 -3wAOyK2981LIsHaAWcneShKFLDB6LeXIT9oitOYhiykhFlBZ4M1GNlSNfhQ8IIQP -xbqMNXCLkW4/BtLhGEEcg0QVso6Kudl9rzgTfQknrdF7pHp6rS46wYUjoSyIY6dl -oLmnoAVJX36J3QPWelePI9e07X2wrTfiZWewwgw3KNRWjd6/zfPLe7GoqXnK1S2z -PT8qMfCaTwKTtUkzXuTFvQ8bAo2My/mS8FOcpkt2oQWeOsADHAUX7fz5BCoa2DL3 -k/7Mh4gVT+JYZEoTwCFuYHgMWFWe98naqHi9lB4yR981p1QgXgxO7qBeipagKY1F -LlH1iwXUqZ3MZnkNA+4e1Fglsw3sa/rC+L98HnznJ/YbTfQbCP6aQ1qcOymrjMud -7MrFwqZjtd/SK4Qx1VpK6jGEAtPgWBTUS3p9ayg6lqjMBjsmySWfvRsDQbq6P5Ct -O/e3EH8= +MIIEITCCAgmgAwIBAgIRAJla/m/UkZMifNwG+DxFr2MwDQYJKoZIhvcNAQELBQAw +EzERMA8GA1UEAxMIQ2VydEF1dGgwHhcNMjIwNzA0MTU0MzM3WhcNMjQwMTA0MTU1 +MzA3WjASMRAwDgYDVQQDEwdjbGllbnQxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEA8xM5v+2QfdzfwnNT5cl+6oEy2fZoI2YG6L6c25rG0pr+yl1IHKdM +Zcvn93uat7hlbzxeOLfJRM7+QK1lLaxuppq9p+gT+1x9eG3E4X7e0pdbjrpJGbvN +ji0hwDBLDWD8mHNq/SCk9FKtGnfZqrNB5BLw2uIKjJzVGXVlsjN6geBDm2hVjTSm +zMr39CfLUdtvMaZhpIPJzbH+sNfp1zKavFIpmwCd77p/z0QAiQ9NaIvzv4PZDDEE +MUHzmVAU6bUjD8GToXaMbRiz694SU8aAwvvcdjGexdbHnfSAfLOl2wTPPxvePncR +aa656ZeZWxY9pRCItP+v43nm7d4sAyRD4QIDAQABo3EwbzAOBgNVHQ8BAf8EBAMC +A7gwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMB0GA1UdDgQWBBQbwDqF +aja3ifZHm6mtSeTK9IHc+zAfBgNVHSMEGDAWgBT4qzwTztNc1Ln+8QvnbGzjmOUL +wDANBgkqhkiG9w0BAQsFAAOCAgEAprE/zV6u8UIH8g4Jb73wtUD/eIL3iBJ7mNYa +lqwCyJrWH7/F9fcovJnF9WO1QPTeHxhoD9rlQK70GitUAeboYw611yNWDS4tDlaL +sjpJKykUxBgBR7QSLZCrPtQ3fP2WvlZzLGqB28rASTLphShqTuGp4gJaxGHfbCU7 +mlV9QYi+InQxOICJJPebXUOwx5wYkFQWJ9qE1AK3QrWPi8QYFznJvHgkNAaMBEmI +jAlggOzpveVvy8f4z3QG9o29LIwp7JvtJQs7QXL80FZK98/8US/3gONwTrBz2Imx +28ywvwCq7fpMyPgxX4sXtxphCNim+vuHcqDn2CvLS9p/6L6zzqbFNxpmMkJDLrOc +YqtHE4TLWIaXpb5JNrYJgNCZyJuYDICVTbivtMacHpSwYtXQ4iuzY2nIr0+4y9i9 +MNpqv3W47xnvgUQa5vbTbIqo2NSY24A84mF5EyjhaNgNtDlN56+qTQ6HLZNVr6pv +eUCCWnY4GkaZUEU1M8/uNtKaZKv1WA7gJxZDQHj8+R110mPtzm1C5jqg7jSjGy9C +8PhAwBqIXkVLNayFEtyZZobTxMH5qY1yFkI3sic7S9ZyXt3quY1Q1UT3liRteIm/ +sZHC5zEoidsHObkTeU44hqZVPkbvrfmgW01xTJjddnMPBH+yqjCCc94yCbW79j/2 +7LEmxYg= -----END CERTIFICATE-----` client1Key = `-----BEGIN RSA PRIVATE KEY----- -MIIEpAIBAAKCAQEAoKbYY9MdF2kF/nhBESIiZTdVYtA8XL9xrIZyDj9EnCiTxHiV -bJtHXVwszqSl5TRrotPmnmAQcX3r8OCk+z+RQZ0QQj257P3kG6q4rNnOcWCS5xEd -20jPyhQ3m+hMGfZsotNTQze1ochuQgLUN6IPyPxZkH22ia3jX4iu1eo/QxeLYHj1 -UHw43Cii9yE+j5kPUC21xmnrGKdUrB55NYLXHx6yTIqYR5znSOVB8oJi18/hwdZm -H859DHhm0Hx1HrS+jbjI3+CMorZJ3WUyNf+CkiVLD3xYutPbxzEpwiqkG/XYzLH0 -habTcDcILo18n+o3jvem2KWBrDhyairjIDscwQIDAQABAoIBAEBSjVFqtbsp0byR -aXvyrtLX1Ng7h++at2jca85Ihq//jyqbHTje8zPuNAKI6eNbmb0YGr5OuEa4pD9N -ssDmMsKSoG/lRwwcm7h4InkSvBWpFShvMgUaohfHAHzsBYxfnh+TfULsi0y7c2n6 -t/2OZcOTRkkUDIITnXYiw93ibHHv2Mv2bBDu35kGrcK+c2dN5IL5ZjTjMRpbJTe2 -44RBJbdTxHBVSgoGBnugF+s2aEma6Ehsj70oyfoVpM6Aed5kGge0A5zA1JO7WCn9 -Ay/DzlULRXHjJIoRWd2NKvx5n3FNppUc9vJh2plRHalRooZ2+MjSf8HmXlvG2Hpb -ScvmWgECgYEA1G+A/2KnxWsr/7uWIJ7ClcGCiNLdk17Pv3DZ3G4qUsU2ITftfIbb -tU0Q/b19na1IY8Pjy9ptP7t74/hF5kky97cf1FA8F+nMj/k4+wO8QDI8OJfzVzh9 -PwielA5vbE+xmvis5Hdp8/od1Yrc/rPSy2TKtPFhvsqXjqoUmOAjDP8CgYEAwZjH -9dt1sc2lx/rMxihlWEzQ3JPswKW9/LJAmbRBoSWF9FGNjbX7uhWtXRKJkzb8ZAwa -88azluNo2oftbDD/+jw8b2cDgaJHlLAkSD4O1D1RthW7/LKD15qZ/oFsRb13NV85 -ZNKtwslXGbfVNyGKUVFm7fVA8vBAOUey+LKDFj8CgYEAg8WWstOzVdYguMTXXuyb -ruEV42FJaDyLiSirOvxq7GTAKuLSQUg1yMRBIeQEo2X1XU0JZE3dLodRVhuO4EXP -g7Dn4X7Th9HSvgvNuIacowWGLWSz4Qp9RjhGhXhezUSx2nseY6le46PmFavJYYSR -4PBofMyt4PcyA6Cknh+KHmkCgYEAnTriG7ETE0a7v4DXUpB4TpCEiMCy5Xs2o8Z5 -ZNva+W+qLVUWq+MDAIyechqeFSvxK6gRM69LJ96lx+XhU58wJiFJzAhT9rK/g+jS -bsHH9WOfu0xHkuHA5hgvvV2Le9B2wqgFyva4HJy82qxMxCu/VG/SMqyfBS9OWbb7 -ibQhdq0CgYAl53LUWZsFSZIth1vux2LVOsI8C3X1oiXDGpnrdlQ+K7z57hq5EsRq -GC+INxwXbvKNqp5h0z2MvmKYPDlGVTgw8f8JjM7TkN17ERLcydhdRrMONUryZpo8 -1xTob+8blyJgfxZUIAKbMbMbIiU0WAF0rfD/eJJwS4htOW/Hfv4TGA== +MIIEpAIBAAKCAQEA8xM5v+2QfdzfwnNT5cl+6oEy2fZoI2YG6L6c25rG0pr+yl1I +HKdMZcvn93uat7hlbzxeOLfJRM7+QK1lLaxuppq9p+gT+1x9eG3E4X7e0pdbjrpJ +GbvNji0hwDBLDWD8mHNq/SCk9FKtGnfZqrNB5BLw2uIKjJzVGXVlsjN6geBDm2hV +jTSmzMr39CfLUdtvMaZhpIPJzbH+sNfp1zKavFIpmwCd77p/z0QAiQ9NaIvzv4PZ +DDEEMUHzmVAU6bUjD8GToXaMbRiz694SU8aAwvvcdjGexdbHnfSAfLOl2wTPPxve +PncRaa656ZeZWxY9pRCItP+v43nm7d4sAyRD4QIDAQABAoIBADE17zcgDWSt1s8z +MgUPahZn2beu3x5rhXKRRIhhKWdx4atufy7t39WsFmZQK96OAlsmyZyJ+MFpdqf5 +csZwZmZsZYEcxw7Yhr5e2sEcQlg4NF0M8ce38cGa+X5DSK6IuBrVIw/kEAE2y7zU +Dsk0SV63RvPJV4FoLuxcjB4rtd2c+JBduNUXQYVppz/KhsXN+9CbPbZ7wo1cB5fo +Iu/VswvvW6EAxVx39zZcwSGdkss9XUktU8akx7T/pepIH6fwkm7uXSNez6GH9d1I +8qOiORk/gAtqPL1TJgConyYheWMM9RbXP/IwL0BV8U4ZVG53S8jx2XpP4OJQ+k35 +WYvz8JECgYEA+9OywKOG2lMiiUB1qZfmXB80PngNsz+L6xUWkrw58gSqYZIg0xyH +Sfr7HBo0yn/PB0oMMWPpNfYvG8/kSMIWiVlsYz9fdsUuqIvN+Kh9VF6o2wn+gnJk +sBE3KVMofcgwgLE6eMVv2MSQlBoXhGPNlCBHS1gorQdYE82dxDPBBzsCgYEA9xpm +c3C9LxiVbw9ZZ5D2C+vzwIG2+ZeDwKSizM1436MAnzNQgQTMzQ20uFGNBD562VjI +rHFlZYr3KCtSIw5gvCSuox0YB64Yq/WAtGZtH9JyKRz4h4juq6iM4FT7nUwM4DF9 +3CUiDS8DGoqvCNpY50GvzSR5QVT1DKTZsMunh5MCgYEAyIWMq7pK0iQqtvG9/3o1 +8xrhxfBgsF+kcV+MZvE8jstKRIFQY+oujCkutPTlHm3hE2PSC64L8G0Em/fRRmJO +AbZUCT9YK8HdYlZYf2zix0DM4gW2RHcEV/KNYvmVn3q9rGvzLGHCqu/yVAvmuAOk +mhON0Z/0W7siVjp/KtEvHisCgYA/cfTaMRkyDXLY6C0BbXPvTa7xP5z2atO2U89F +HICrkxOmzKsf5VacU6eSJ8Y4T76FLcmglSD+uHaLRsw5Ggj2Zci9MswntKi7Bjb8 +msvr/sG3EqwxSJRXWNiLBObx1UP9EFgLfTFIB0kZuIAGmuF2xyPXXUUQ5Dpi+7S1 +MyUZpwKBgQDg+AIPvk41vQ4Cz2CKrQX5/uJSW4bOhgP1yk7ruIH4Djkag3ZzTnHM +zA9/pLzRfz1ENc5I/WaYSh92eKw3j6tUtMJlE2AbfCpgOQtRUNs3IBmzCWrY8J01 +W/8bwB+KhfFxNYwvszYsvvOq51NgahYQkgThVm38UixB3PFpEf+NiQ== -----END RSA PRIVATE KEY-----` // client 2 crt is revoked client2Crt = `-----BEGIN CERTIFICATE----- -MIIEITCCAgmgAwIBAgIRAL6XTgNsdYzILd8bT2RVd/4wDQYJKoZIhvcNAQELBQAw -EzERMA8GA1UEAxMIQ2VydEF1dGgwHhcNMjEwMTAyMjEyMzIwWhcNMjIwNzAyMjEz -MDUxWjASMRAwDgYDVQQDEwdjbGllbnQyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A -MIIBCgKCAQEA6xjW5KQR3/OFQtV5M75WINqQ4AzXSu6DhSz/yumaaQZP/UxY+6hi -jcrFzGo9MMie/Sza8DhkXOFAl2BelUubrOeB2cl+/Gr8OCyRi2Gv6j3zCsuN/4jQ -tNaoez/IbkDvI3l/ZpzBtnuNY2RiemGgHuORXHRVf3qVlsw+npBIRW5rM2HkO/xG -oZjeBErWVu390Lyn+Gvk2TqQDnkutWnxUC60/zPlHhXZ4BwaFAekbSnjsSDB1YFM -s8HwW4oBryoxdj3/+/qLrBHt75IdLw3T7/V1UDJQM3EvSQOr12w4egpldhtsC871 -nnBQZeY6qA5feffIwwg/6lJm70o6S6OX6wIDAQABo3EwbzAOBgNVHQ8BAf8EBAMC -A7gwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMB0GA1UdDgQWBBTB84v5 -t9HqhLhMODbn6oYkEQt3KzAfBgNVHSMEGDAWgBTtcgjQBq/80EySIvJcN8OTorgb -zDANBgkqhkiG9w0BAQsFAAOCAgEALGtBCve5k8tToL3oLuXp/oSik6ovIB/zq4I/ -4zNMYPU31+ZWz6aahysgx1JL1yqTa3Qm8o2tu52MbnV10dM7CIw7c/cYa+c+OPcG -5LF97kp13X+r2axy+CmwM86b4ILaDGs2Qyai6VB6k7oFUve+av5o7aUrNFpqGCJz -HWdtHZSVA3JMATzy0TfWanwkzreqfdw7qH0yZ9bDURlBKAVWrqnCstva9jRuv+AI -eqxr/4Ro986TFjJdoAP3Vr16CPg7/B6GA/KmsBWJrpeJdPWq4i2gpLKvYZoy89qD -mUZf34RbzcCtV4NvV1DadGnt4us0nvLrvS5rL2+2uWD09kZYq9RbLkvgzF/cY0fz -i7I1bi5XQ+alWe0uAk5ZZL/D+GTRYUX1AWwCqwJxmHrMxcskMyO9pXvLyuSWRDLo -YNBrbX9nLcfJzVCp+X+9sntTHjs4l6Cw+fLepJIgtgqdCHtbhTiv68vSM6cgb4br -6n2xrXRKuioiWFOrTSRr+oalZh8dGJ/xvwY8IbWknZAvml9mf1VvfE7Ma5P777QM -fsbYVTq0Y3R/5hIWsC3HA5z6MIM8L1oRe/YyhP3CTmrCHkVKyDOosGXpGz+JVcyo -cfYkY5A3yFKB2HaCwZSfwFmRhxkrYWGEbHv3Cd9YkZs1J3hNhGFZyVMC9Uh0S85a -6zdDidU= +MIIEITCCAgmgAwIBAgIRANmjlDeWHEW4NSzEY2bv3hgwDQYJKoZIhvcNAQELBQAw +EzERMA8GA1UEAxMIQ2VydEF1dGgwHhcNMjIwNzA0MTU0MzUxWhcNMjQwMTA0MTU1 +MzA3WjASMRAwDgYDVQQDEwdjbGllbnQyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAzNl7q7yS8MSaQs6zRbuqrsUuwEJ5ZH85vf7zHZKgOW3zNniXLOmH +JdtQ3jKZQ1BCIsJFvez2GxGIMWbXaSPw4bL0J3vl5oItChsjGg34IvqcDxWuIk2a +muRdMh7r1ryVs2ir2cQ5YHzI59BEpUWKQg3bD4yragdkb6BRc7lVgzCbrM1Eq758 +HHbaLwlsfpqOvheaum4IG113CeD/HHrw42W6g/qQWL+FHlYqV3plHZ8Bj+bhcZI5 +jdU4paGEzeY0a0NlnyH4gXGPjLKvPKFZHy4D6RiRlLHvHeiRyDtTu4wFkAiXxzGs +E4UBbykmYUB85zgwpjaktOaoe36IM1T8CQIDAQABo3EwbzAOBgNVHQ8BAf8EBAMC +A7gwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMB0GA1UdDgQWBBRdYIEk +gxh+vTaMpAbqaPGRKGGBpTAfBgNVHSMEGDAWgBT4qzwTztNc1Ln+8QvnbGzjmOUL +wDANBgkqhkiG9w0BAQsFAAOCAgEABSR/PbPfiNZ6FOrt91/I0g6LviwICDcuXhfr +re4UsWp1kxXeS3CB2G71qXv3hswN8phG2hdsij0/FBEGUTLS3FTCmLmqmcVqPj3/ +677PMFDoACBKgT5iIwpnNvdD+4ROM8JFjUwy7aTWx85a5yoPFGnB+ORMfLCYjr2S +D02KFvKuSXWCjXphqJ41cFGne4oeh/JMkN0RNArm7wTT8yWCGgO1k4OON8dphuTV +48Wm6I9UBSWuLk1vcIlgb/8YWVwy9rBNmjOBDGuroL6PSmfZD+e9Etii0X2znZ+t +qDpXJB7V5U0DbsBCtGM/dHaFz/LCoBYX9z6th1iPUHksUTM3RzN9L24r9/28dY/a +shBpn5rK3ui/2mPBpO26wX14Kl/DUkdKUV9dJllSlmwo8Z0RluY9S4xnCrna/ODH +FbhWmlTSs+odCZl6Lc0nuw+WQ2HnlTVJYBSFAGfsGQQ3pzk4DC5VynnxY0UniUgD +WYPR8JEYa+BpH3rIQ9jmnOKWLtyc7lFPB9ab63pQBBiwRvWo+tZ2vybqjeHPuu5N +BuKvvtu3RKKdSCnIo5Rs5zw4JYCjvlx/NVk9jtpa1lIHYHilvBmCcRX5DkE/yH/x +IjEKhCOQpGR6D5Kkca9xNL7zNcat3bzLn+d7Wo4m09uWi9ifPdchxed0w5d9ihx1 +enqNrFI= -----END CERTIFICATE-----` client2Key = `-----BEGIN RSA PRIVATE KEY----- -MIIEpAIBAAKCAQEA6xjW5KQR3/OFQtV5M75WINqQ4AzXSu6DhSz/yumaaQZP/UxY -+6hijcrFzGo9MMie/Sza8DhkXOFAl2BelUubrOeB2cl+/Gr8OCyRi2Gv6j3zCsuN -/4jQtNaoez/IbkDvI3l/ZpzBtnuNY2RiemGgHuORXHRVf3qVlsw+npBIRW5rM2Hk -O/xGoZjeBErWVu390Lyn+Gvk2TqQDnkutWnxUC60/zPlHhXZ4BwaFAekbSnjsSDB -1YFMs8HwW4oBryoxdj3/+/qLrBHt75IdLw3T7/V1UDJQM3EvSQOr12w4egpldhts -C871nnBQZeY6qA5feffIwwg/6lJm70o6S6OX6wIDAQABAoIBAFatstVb1KdQXsq0 -cFpui8zTKOUiduJOrDkWzTygAmlEhYtrccdfXu7OWz0x0lvBLDVGK3a0I/TGrAzj -4BuFY+FM/egxTVt9in6fmA3et4BS1OAfCryzUdfK6RV//8L+t+zJZ/qKQzWnugpy -QYjDo8ifuMFwtvEoXizaIyBNLAhEp9hnrv+Tyi2O2gahPvCHsD48zkyZRCHYRstD -NH5cIrwz9/RJgPO1KI+QsJE7Nh7stR0sbr+5TPU4fnsL2mNhMUF2TJrwIPrc1yp+ -YIUjdnh3SO88j4TQT3CIrWi8i4pOy6N0dcVn3gpCRGaqAKyS2ZYUj+yVtLO4KwxZ -SZ1lNvECgYEA78BrF7f4ETfWSLcBQ3qxfLs7ibB6IYo2x25685FhZjD+zLXM1AKb -FJHEXUm3mUYrFJK6AFEyOQnyGKBOLs3S6oTAswMPbTkkZeD1Y9O6uv0AHASLZnK6 -pC6ub0eSRF5LUyTQ55Jj8D7QsjXJueO8v+G5ihWhNSN9tB2UA+8NBmkCgYEA+weq -cvoeMIEMBQHnNNLy35bwfqrceGyPIRBcUIvzQfY1vk7KW6DYOUzC7u+WUzy/hA52 -DjXVVhua2eMQ9qqtOav7djcMc2W9RbLowxvno7K5qiCss013MeWk64TCWy+WMp5A -AVAtOliC3hMkIKqvR2poqn+IBTh1449agUJQqTMCgYEAu06IHGq1GraV6g9XpGF5 -wqoAlMzUTdnOfDabRilBf/YtSr+J++ThRcuwLvXFw7CnPZZ4TIEjDJ7xjj3HdxeE -fYYjineMmNd40UNUU556F1ZLvJfsVKizmkuCKhwvcMx+asGrmA+tlmds4p3VMS50 -KzDtpKzLWlmU/p/RINWlRmkCgYBy0pHTn7aZZx2xWKqCDg+L2EXPGqZX6wgZDpu7 -OBifzlfM4ctL2CmvI/5yPmLbVgkgBWFYpKUdiujsyyEiQvWTUKhn7UwjqKDHtcsk -G6p7xS+JswJrzX4885bZJ9Oi1AR2yM3sC9l0O7I4lDbNPmWIXBLeEhGMmcPKv/Kc -91Ff4wKBgQCF3ur+Vt0PSU0ucrPVHjCe7tqazm0LJaWbPXL1Aw0pzdM2EcNcW/MA -w0kqpr7MgJ94qhXCBcVcfPuFN9fBOadM3UBj1B45Cz3pptoK+ScI8XKno6jvVK/p -xr5cb9VBRBtB9aOKVfuRhpatAfS2Pzm2Htae9lFn7slGPUmu2hkjDw== +MIIEowIBAAKCAQEAzNl7q7yS8MSaQs6zRbuqrsUuwEJ5ZH85vf7zHZKgOW3zNniX +LOmHJdtQ3jKZQ1BCIsJFvez2GxGIMWbXaSPw4bL0J3vl5oItChsjGg34IvqcDxWu +Ik2amuRdMh7r1ryVs2ir2cQ5YHzI59BEpUWKQg3bD4yragdkb6BRc7lVgzCbrM1E +q758HHbaLwlsfpqOvheaum4IG113CeD/HHrw42W6g/qQWL+FHlYqV3plHZ8Bj+bh +cZI5jdU4paGEzeY0a0NlnyH4gXGPjLKvPKFZHy4D6RiRlLHvHeiRyDtTu4wFkAiX +xzGsE4UBbykmYUB85zgwpjaktOaoe36IM1T8CQIDAQABAoIBAETHMJK0udFE8VZE ++EQNgn0zj0LWDtQDM2vrUc04Ebu2gtZjHr7hmZLIVBqGepbzN4FcIPZnvSnRdRzB +HsoaWyIsZ3VqUAJY6q5d9iclUY7M/eDCsripvaML0Y6meyCaKNkX57sx+uG+g+Xx +M1saQhVzeX17CYKMANjJxw9HxsJI0aBPyiBbILHMwfRfsJU8Ou72HH1sIQuPdH2H +/c9ru8YZAno6oVq1zuC/pCis+h50U9HzTnt3/4NNS6cWG/y2YLztCvm9uGo4MTd/ +mA9s4cxVhvQW6gCDHgGn6zj661OL/d2rpak1eWizhZvZ8jsIN/sM87b0AJeVT4zH +6xA3egECgYEA1nI5EsCetQbFBp7tDovSp3fbitwoQtdtHtLn2u4DfvmbLrgSoq0Z +L+9N13xML/l8lzWai2gI69uA3c2+y1O64LkaiSeDqbeBp9b6fKMlmwIVbklEke1w +XVTIWOYTTF5/8+tUOlsgme5BhLAWnQ7+SoitzHtl5e1vEYaAGamE2DECgYEA9Is2 +BbTk2YCqkcsB7D9q95JbY0SZpecvTv0rLR+acz3T8JrAASdmvqdBOlPWc+0ZaEdS +PcJaOEw3yxYJ33cR/nLBaR2/Uu5qQebyPALs3B2pjjTFdGvcpeFxO55fowwsfR/e +0H+HeiFj5Y4S+kFWT+3FRmJ6GUB828LJYaVhQ1kCgYEA1bdsTdYN1Vfzz89fbZnH +zQLUl6UlssfDhm6mhzeh4E+eaocke1+LtIwHxfOocj9v/bp8VObPzU8rNOIxfa3q +lr+jRIFO5DtwSfckGEb32W3QMeNvJQe/biRqrr5NCVU8q7kibi4XZZFfVn+vacNh +hqKEoz9vpCBnCs5CqFCbhmECgYAG8qWYR+lwnI08Ey58zdh2LDxYd6x94DGh5uOB +JrK2r30ECwGFht8Ob6YUyCkBpizgn5YglxMFInU7Webx6GokdpI0MFotOwTd1nfv +aI3eOyGEHs+1XRMpy1vyO6+v7DqfW3ZzKgxpVeWGsiCr54tSPgkq1MVvTju96qza +D17SEQKBgCKC0GjDjnt/JvujdzHuBt1sWdOtb+B6kQvA09qVmuDF/Dq36jiaHDjg +XMf5HU3ThYqYn3bYypZZ8nQ7BXVh4LqGNqG29wR4v6l+dLO6odXnLzfApGD9e+d4 +2tmlLP54LaN35hQxRjhT8lCN0BkrNF44+bh8frwm/kuxSd8wT2S+ -----END RSA PRIVATE KEY-----` testFileName = "test_file_ftp.dat" testDLFileName = "test_download_ftp.dat" diff --git a/go.mod b/go.mod index c7bf06ea..0ed395e9 100644 --- a/go.mod +++ b/go.mod @@ -4,25 +4,25 @@ go 1.18 require ( cloud.google.com/go/storage v1.23.0 - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.0 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.1 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.4.1 github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 - github.com/aws/aws-sdk-go-v2 v1.16.6 - github.com/aws/aws-sdk-go-v2/config v1.15.12 - github.com/aws/aws-sdk-go-v2/credentials v1.12.7 - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.7 - github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.17 - github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.13.7 - github.com/aws/aws-sdk-go-v2/service/s3 v1.26.12 - github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.12 - github.com/aws/aws-sdk-go-v2/service/sts v1.16.8 + github.com/aws/aws-sdk-go-v2 v1.16.7 + github.com/aws/aws-sdk-go-v2/config v1.15.13 + github.com/aws/aws-sdk-go-v2/credentials v1.12.8 + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.8 + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.19 + github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.13.8 + github.com/aws/aws-sdk-go-v2/service/s3 v1.27.1 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.13 + github.com/aws/aws-sdk-go-v2/service/sts v1.16.9 github.com/cockroachdb/cockroach-go/v2 v2.2.14 github.com/coreos/go-oidc/v3 v3.2.0 github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001 github.com/fclairamb/ftpserverlib v0.18.1-0.20220515214847-f96d31ec626e github.com/fclairamb/go-log v0.3.0 - github.com/go-acme/lego/v4 v4.7.0 + github.com/go-acme/lego/v4 v4.8.0 github.com/go-chi/chi/v5 v5.0.8-0.20220512131524-9e71a0d4b3d6 github.com/go-chi/jwtauth/v5 v5.0.2 github.com/go-chi/render v1.0.1 @@ -52,13 +52,13 @@ require ( github.com/rs/xid v1.4.0 github.com/rs/zerolog v1.27.0 github.com/sftpgo/sdk v0.1.2-0.20220611083241-b653555f7f4d - github.com/shirou/gopsutil/v3 v3.22.5 + github.com/shirou/gopsutil/v3 v3.22.6 github.com/spf13/afero v1.8.2 github.com/spf13/cobra v1.5.0 github.com/spf13/viper v1.12.0 github.com/stretchr/testify v1.8.0 github.com/studio-b12/gowebdav v0.0.0-20220128162035-c7b1ff8a5e62 - github.com/unrolled/secure v1.10.0 + github.com/unrolled/secure v1.11.0 github.com/wagslane/go-password-validator v0.3.0 github.com/xhit/go-simple-mail/v2 v2.11.0 github.com/yl2chen/cidranger v1.0.3-0.20210928021809-d1cb2c52f37a @@ -68,7 +68,7 @@ require ( golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e golang.org/x/oauth2 v0.0.0-20220630143837-2104d58473e0 - golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b + golang.org/x/sys v0.0.0-20220708085239-5a0f0661e09d golang.org/x/time v0.0.0-20220609170525-579cf78fd858 google.golang.org/api v0.86.0 gopkg.in/natefinch/lumberjack.v2 v2.0.0 @@ -80,15 +80,15 @@ require ( cloud.google.com/go/iam v0.3.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.3 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.13 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.7 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.3.14 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.3.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.5 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.8 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.7 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.7 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.11.10 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.8 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.11.11 // indirect github.com/aws/smithy-go v1.12.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/boombuler/barcode v1.0.1 // indirect @@ -155,7 +155,7 @@ require ( golang.org/x/tools v0.1.11 // indirect golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20220630160836-4327a74d660d // indirect + google.golang.org/genproto v0.0.0-20220708155623-50e5f4832e73 // indirect google.golang.org/grpc v1.47.0 // indirect google.golang.org/protobuf v1.28.0 // indirect gopkg.in/ini.v1 v1.66.6 // indirect diff --git a/go.sum b/go.sum index dc8ad0ee..e3674e45 100644 --- a/go.sum +++ b/go.sum @@ -90,8 +90,8 @@ github.com/Azure/azure-sdk-for-go v51.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9mo github.com/Azure/azure-sdk-for-go v59.3.0+incompatible h1:dPIm0BO4jsMXFcCI/sLTPkBtE7mk8WMuRHA0JeWhlcQ= github.com/Azure/azure-sdk-for-go v59.3.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.0 h1:Ut0ZGdOwJDw0npYEg+TLlPls3Pq6JiZaP2/aGKir7Zw= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.0/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.1 h1:tz19qLF65vuu2ibfTqGVJxG/zZAI27NEIIbvAOQwYbw= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.1/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U= github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.0.0 h1:Yoicul8bnVdQrhDMTHxdEckRGX01XvwXDHUT9zYZ3k0= github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8= @@ -140,64 +140,64 @@ github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZo github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.43.31/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= github.com/aws/aws-sdk-go-v2 v1.16.2/go.mod h1:ytwTPBG6fXTZLxxeeCCWj2/EMYp/xDUgX+OET6TLNNU= -github.com/aws/aws-sdk-go-v2 v1.16.6 h1:kzafGZYwkwVgLZ2zEX7P+vTwLli6uIMXF8aGjunN6UI= -github.com/aws/aws-sdk-go-v2 v1.16.6/go.mod h1:6CpKuLXg2w7If3ABZCl/qZ6rEgwtjZTn4eAf4RcEyuw= +github.com/aws/aws-sdk-go-v2 v1.16.7 h1:zfBwXus3u14OszRxGcqCDS4MfMCv10e8SMJ2r8Xm0Ns= +github.com/aws/aws-sdk-go-v2 v1.16.7/go.mod h1:6CpKuLXg2w7If3ABZCl/qZ6rEgwtjZTn4eAf4RcEyuw= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.1/go.mod h1:n8Bs1ElDD2wJ9kCRTczA83gYbBmjSwZp3umc6zF4EeM= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.3 h1:S/ZBwevQkr7gv5YxONYpGQxlMFFYSRfz3RMcjsC9Qhk= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.3/go.mod h1:gNsR5CaXKmQSSzrmGxmwmct/r+ZBfbxorAuXYsj/M5Y= github.com/aws/aws-sdk-go-v2/config v1.15.3/go.mod h1:9YL3v07Xc/ohTsxFXzan9ZpFpdTOFl4X65BAKYaz8jg= -github.com/aws/aws-sdk-go-v2/config v1.15.12 h1:D4mdf0cOSmZRgJe0DDOd1Qm6tkwHJ7r5i1lz0asa+AA= -github.com/aws/aws-sdk-go-v2/config v1.15.12/go.mod h1:oxRNnH11J580bxDEXyfTqfB3Auo2fxzhV052LD4HnyA= +github.com/aws/aws-sdk-go-v2/config v1.15.13 h1:CJH9zn/Enst7lDiGpoguVt0lZr5HcpNVlRJWbJ6qreo= +github.com/aws/aws-sdk-go-v2/config v1.15.13/go.mod h1:AcMu50uhV6wMBUlURnEXhr9b3fX6FLSTlEV89krTEGk= github.com/aws/aws-sdk-go-v2/credentials v1.11.2/go.mod h1:j8YsY9TXTm31k4eFhspiQicfXPLZ0gYXA50i4gxPE8g= -github.com/aws/aws-sdk-go-v2/credentials v1.12.7 h1:e2DcCR0gP+T2zVj5eQPMQoRdxo+vd2p9BkpJ72BdyzA= -github.com/aws/aws-sdk-go-v2/credentials v1.12.7/go.mod h1:8b1nSHdDaKLho9VEK+K8WivifA/2K5pPm4sfI21NlQ8= +github.com/aws/aws-sdk-go-v2/credentials v1.12.8 h1:niTa7zc7uyOP2ufri0jPESBt1h9yP3Zc0q+xzih3h8o= +github.com/aws/aws-sdk-go-v2/credentials v1.12.8/go.mod h1:P2Hd4Sy7mXRxPNcQMPBmqszSJoDXexX8XEDaT6lucO0= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.3/go.mod h1:uk1vhHHERfSVCUnqSqz8O48LBYDSC+k6brng09jcMOk= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.7 h1:8yi2ORCwXpXEPnj0vP3DjYhejwDQD/5klgBoxXcKOxY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.7/go.mod h1:81k6q0UUZj6AdQZ1E/VQ27cLrTUpJGraZR6/hVHRxjE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.8 h1:VfBdn2AxwMbFyJN/lF/xuT3SakomJ86PZu3rCxb5K0s= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.8/go.mod h1:oL1Q3KuCq1D4NykQnIvtRiBGLUXhcpY5pl6QZB2XEPU= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.3/go.mod h1:0dHuD2HZZSiwfJSy1FO5bX1hQ1TxVV1QXXjpn3XUE44= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.17 h1:9Y+OvoIvC8KocGNqbbBNDvMu0zsIgzKg3r+ZllSuH5Y= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.17/go.mod h1:z/7g6Z78jPG0l3HeShseUWzA+aBJDK4Mu5DkKkYdIW0= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.19 h1:WfCYqsAADDRNCQQ5LGcrlqbR7SK3PYrP/UCh7qNGBQM= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.19/go.mod h1:koLPv2oF6ksE3zBKLDP0GFmKfaCmYwVHqGIbaPrHIRg= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.9/go.mod h1:AnVH5pvai0pAF4lXRq0bmhbes1u9R8wTE+g+183bZNM= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.13 h1:WuQ1yGs3TMJgxpGVLspcsU/5q1omSA0SG6Cu0yZ4jkM= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.13/go.mod h1:wLLesU+LdMZDM3U0PP9vZXJW39zmD/7L4nY2pSrYZ/g= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.14 h1:2C0pYHcUBmdzPj+EKNC4qj97oK6yjrUhc1KoSodglvk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.14/go.mod h1:kdjrMwHwrC3+FsKhNcCMJ7tUVj/8uSD5CZXeQ4wV6fM= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.3/go.mod h1:ssOhaLpRlh88H3UmEcsBoVKq309quMvm3Ds8e9d4eJM= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.7 h1:mCeDDYeDXp3loo/xKi7nkx34eeh7q3n1mUBtzptsj8c= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.7/go.mod h1:93Uot80ddyVzSl//xEJreNKMhxntr71WtR3v/A1cRYk= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.8 h1:2J+jdlBJWEmTyAwC82Ym68xCykIvnSnIN18b8xHGlcc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.8/go.mod h1:ZIV8GYoC6WLBW5KGs+o4rsc65/ozd+eQ0L31XF5VDwk= github.com/aws/aws-sdk-go-v2/internal/ini v1.3.10/go.mod h1:8DcYQcz0+ZJaSxANlHIsbbi6S+zMwjwdDqwW3r9AzaE= -github.com/aws/aws-sdk-go-v2/internal/ini v1.3.14 h1:bJv4Y9QOiW0GZPStgLgpGrpdfRDSR3XM4V4M3YCQRZo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.3.14/go.mod h1:R1HF8ZDdcRFfAGF+13En4LSHi2IrrNuPQCaxgWCeGyY= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.4 h1:wusoY1MJ9JNrPoX3n4kxY4MTIUivCiXvTYQbYh59yxs= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.4/go.mod h1:cHTMyJVEXRUZ25f8V+pq6CAwoYARarJRFGf3XH4eIxE= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.15 h1:QquxR7NH3ULBsKC+NoTpilzbKKS+5AELfNREInbhvas= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.15/go.mod h1:Tkrthp/0sNBShQQsamR7j/zY4p19tVTAs+nnqhH6R3c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.5 h1:tEEHn+PGAxRVqMPEhtU8oCSW/1Ge3zP5nUgPrGQNUPs= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.5/go.mod h1:aIwFF3dUk95ocCcA3zfk3nhz0oLkpzHFWuMp8l/4nNs= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.1/go.mod h1:GeUru+8VzrTXV/83XyMJ80KpH8xO89VPoUileyNQ+tc= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.3 h1:4n4KCtv5SUoT5Er5XV41huuzrCqepxlW3SDI9qHQebc= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.3/go.mod h1:gkb2qADY+OHaGLKNTYxMaQNacfeyQpZ4csDTQMeFmcw= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.3/go.mod h1:Seb8KNmD6kVTjwRjVEgOT5hPin6sq+v4C2ycJQDwuH8= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.8 h1:BzBekDihMMeBexBhdK7xS3AIh2Jg/mECyLWO5RRwwHY= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.8/go.mod h1:a1BSeQI9IVr1j5Dwn73cdAKi4MdizTaV9YovUaHefGI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.9 h1:gVv2vXOMqJeR4ZHHV32K7LElIJIIzyw/RU1b0lSfWTQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.9/go.mod h1:EF5RLnD9l0xvEWwMRcktIS/dI6lF8lU5eV3B13k6sWo= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.3/go.mod h1:wlY6SVjuwvh3TVRpTqdy4I1JpBFLX4UGeKZdWntaocw= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.7 h1:M7/BzQNsu0XXiJRe3gUn8UA8tExF6kLMAfvo5PT/KJY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.7/go.mod h1:HvVdEh/x4jsPBsjNvDy+MH3CDCPy4gTZEzFe2r4uJY8= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.8 h1:oKnAXxSF2FUvfgw8uzU/v9OTYorJJZ8eBmWhr9TWVVQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.8/go.mod h1:rDVhIMAX9N2r8nWxDUlbubvvaFMnfsm+3jAV7q+rpM4= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.3/go.mod h1:Bm/v2IaN6rZ+Op7zX+bOUMdL4fsrYZiD0dsjLhNKwZc= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.7 h1:imb0NhTQZaTDSAQvgFyiZbKTwl0F+AkZL1ZNoEHtuQc= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.7/go.mod h1:V952z/yIT247sKya+CB+Ls3sxpB9jeBj5TkLraCGKGU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.8 h1:TlN1UC39A0LUNoD51ubO5h32haznA+oVe15jO9O4Lj0= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.8/go.mod h1:JlVwmWtT/1c5W+6oUsjXjAJ0iJZ+hlghdrDy/8JxGCU= github.com/aws/aws-sdk-go-v2/service/kms v1.16.3/go.mod h1:QuiHPBqlOFCi4LqdSskYYAWpQlx3PKmohy+rE2F+o5g= -github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.13.7 h1:/5I4p9aytzW1pQfQaCYAqTUzVE48jgJHVFfQVTU4LB8= -github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.13.7/go.mod h1:DbtPrtO8rAOefDzgqGCqUV+Ub2UtL3/prfQt9bFFYoU= +github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.13.8 h1:J1muHmn3xAYD1KQpngUX0lCVMu1hJeLbZIihiAwH7ME= +github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.13.8/go.mod h1:U1XEH0dP8jhhfKNaRktE4WnAjvLtq10fI+CtPLMv/5s= github.com/aws/aws-sdk-go-v2/service/s3 v1.26.3/go.mod h1:g1qvDuRsJY+XghsV6zg00Z4KJ7DtFFCx8fJD2a491Ak= -github.com/aws/aws-sdk-go-v2/service/s3 v1.26.12 h1:/JTTdNObz+GygQqnbdBzummuxFIcuB6hbra1mqS+Wic= -github.com/aws/aws-sdk-go-v2/service/s3 v1.26.12/go.mod h1:eas8WnpTDJtCvEjRXAINFuox9TmEGeevxiUKEKv2tQ8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.27.1 h1:OKQIQ0QhEBmGr2LfT952meIZz3ujrPYnxH+dO/5ldnI= +github.com/aws/aws-sdk-go-v2/service/s3 v1.27.1/go.mod h1:NffjpNsMUFXp6Ok/PahrktAncoekWrywvmIK83Q2raE= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.4/go.mod h1:PJc8s+lxyU8rrre0/4a0pn2wgwiDvOEzoOjcJUBr67o= -github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.12 h1:Pq2GrbfG74dX/JhY/O+bWBz7DUzdgTvqugofqTWLACQ= -github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.12/go.mod h1:MfgrkSNjFbMLz19srWgyGJtvDEfXg/ZUJ6AIrxdj65M= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.13 h1:9hFlfWKP1+u3js8IhRGf3M+S4MSoDK2v3bqIndGEpxU= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.13/go.mod h1:ByZbrzJwj5ScH6gvAlGslJK/LgJtPd0tteTBoG+yjVc= github.com/aws/aws-sdk-go-v2/service/sns v1.17.4/go.mod h1:kElt+uCcXxcqFyc+bQqZPFD9DME/eC6oHBXvFzQ9Bcw= github.com/aws/aws-sdk-go-v2/service/sqs v1.18.3/go.mod h1:skmQo0UPvsjsuYYSYMVmrPc1HWCbHUJyrCEp+ZaLzqM= github.com/aws/aws-sdk-go-v2/service/ssm v1.24.1/go.mod h1:NR/xoKjdbRJ+qx0pMR4mI+N/H1I1ynHwXnO6FowXJc0= github.com/aws/aws-sdk-go-v2/service/sso v1.11.3/go.mod h1:7UQ/e69kU7LDPtY40OyoHYgRmgfGM4mgsLYtcObdveU= -github.com/aws/aws-sdk-go-v2/service/sso v1.11.10 h1:icon5WWg9Yg5nkB0pJF6bfKw6M0xozukeGKSNKtnqzw= -github.com/aws/aws-sdk-go-v2/service/sso v1.11.10/go.mod h1:UHxA35uPrCykRySBV5iSPZhZRlYnWSS2c/aaZVsoU94= +github.com/aws/aws-sdk-go-v2/service/sso v1.11.11 h1:XOJWXNFXJyapJqQuCIPfftsOf0XZZioM0kK6OPRt9MY= +github.com/aws/aws-sdk-go-v2/service/sso v1.11.11/go.mod h1:MO4qguFjs3wPGcCSpQ7kOFTwRvb+eu+fn+1vKleGHUk= github.com/aws/aws-sdk-go-v2/service/sts v1.16.3/go.mod h1:bfBj0iVmsUyUg4weDB4NxktD9rDGeKSVWnjTnwbx9b8= -github.com/aws/aws-sdk-go-v2/service/sts v1.16.8 h1:GLGfpqX+1bmjNvUJkwB1ZaDpNFXQwJ3z9RkQDA58OBY= -github.com/aws/aws-sdk-go-v2/service/sts v1.16.8/go.mod h1:50YdFq1WIuxA0AGrygvYGucnNYrG24WYzu5fNp7lMgY= +github.com/aws/aws-sdk-go-v2/service/sts v1.16.9 h1:yOfILxyjmtr2ubRkRJldlHDFBhf5vw4CzhbwWIBmimQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.16.9/go.mod h1:O1IvkYxr+39hRf960Us6j0x1P8pDqhTX+oXM5kQNl/Y= github.com/aws/smithy-go v1.11.2/go.mod h1:3xHYmszWVx2c0kIwQeEVf9uSm4fYZt67FBJnwub1bgM= github.com/aws/smithy-go v1.12.0 h1:gXpeZel/jPoWQ7OEmLIgCUnhkFftqNfwWUwAHSlp1v0= github.com/aws/smithy-go v1.12.0/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= @@ -292,8 +292,8 @@ github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeME github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/gin-gonic/gin v1.7.3/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY= -github.com/go-acme/lego/v4 v4.7.0 h1:f5/9EigoIC3Lu14XUsbDzlJ1ZcTYBN3zu/0TJExQaOc= -github.com/go-acme/lego/v4 v4.7.0/go.mod h1:hoPWeY+jooDbgbe5GUqHTGRGdENDhrkiypiDCAqgmmg= +github.com/go-acme/lego/v4 v4.8.0 h1:XienkuT6ZKHe0DE/LXeGP4ZY+ft+7ZMlqtiJ7XJs2pI= +github.com/go-acme/lego/v4 v4.8.0/go.mod h1:MXCdgHuQh25bfi/tPpyOV/9k2p1JVu6oxXcylAwkouI= github.com/go-chi/chi/v5 v5.0.4/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.0.8-0.20220512131524-9e71a0d4b3d6 h1:+fT7oFUOersdx+u7uIxOjabDVGxg+qqNV6kRdAXIvaQ= github.com/go-chi/chi/v5 v5.0.8-0.20220512131524-9e71a0d4b3d6/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= @@ -710,8 +710,8 @@ github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdh github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo= github.com/sftpgo/sdk v0.1.2-0.20220611083241-b653555f7f4d h1:gpshxOhLsGFbCy4ke96X8FVMy4xvXZQChSF7dikqxp4= github.com/sftpgo/sdk v0.1.2-0.20220611083241-b653555f7f4d/go.mod h1:JdxJrGnk6RKhRMTqwH5fFfaMiZuGi5qR1HxQaSDsswo= -github.com/shirou/gopsutil/v3 v3.22.5 h1:atX36I/IXgFiB81687vSiBI5zrMsxcIBkP9cQMJQoJA= -github.com/shirou/gopsutil/v3 v3.22.5/go.mod h1:so9G9VzeHt/hsd0YwqprnjHnfARAUktauykSbr+y2gA= +github.com/shirou/gopsutil/v3 v3.22.6 h1:FnHOFOh+cYAM0C30P+zysPISzlknLC5Z1G4EAElznfQ= +github.com/shirou/gopsutil/v3 v3.22.6/go.mod h1:EdIubSnZhbAvBS1yJ7Xi+AShB/hxwLHOMz4MCYz7yMs= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= @@ -744,6 +744,7 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/studio-b12/gowebdav v0.0.0-20220128162035-c7b1ff8a5e62 h1:b2nJXyPCa9HY7giGM+kYcnQ71m14JnGdQabMPmyt++8= @@ -759,8 +760,8 @@ github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjM github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= -github.com/unrolled/secure v1.10.0 h1:TBNP42z2AB+2pW9PR6vdbqhlQuv1iTeSVzK1qHjOBzA= -github.com/unrolled/secure v1.10.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= +github.com/unrolled/secure v1.11.0 h1:fjkKhD/MsQnlmz/au+MmFptCFNhvf5iv04ALkdCXRCI= +github.com/unrolled/secure v1.11.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I= github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ= github.com/xhit/go-simple-mail/v2 v2.11.0 h1:o/056V50zfkO3Mm5tVdo9rG3ryg4ZmJ2XW5GMinHfVs= @@ -970,8 +971,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b h1:2n253B2r0pYSmEV+UNCQoPfU/FiaizQEK5Gu4Bq4JE8= -golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220708085239-5a0f0661e09d h1:/m5NbqQelATgoSPVC2Z23sR4kVNokFwDDyWh/3rGY+I= +golang.org/x/sys v0.0.0-20220708085239-5a0f0661e09d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1221,8 +1222,8 @@ google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljW google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220630160836-4327a74d660d h1:9IbUS1WUO1OPTBCp32JFwX9MqyT5pc3UWXH8plPX8is= -google.golang.org/genproto v0.0.0-20220630160836-4327a74d660d/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220708155623-50e5f4832e73 h1:sdZWfcGN37Dv0QWIhuasQGMzAQJOL2oqnvot4/kPgfQ= +google.golang.org/genproto v0.0.0-20220708155623-50e5f4832e73/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= diff --git a/httpd/api_eventrule.go b/httpd/api_eventrule.go new file mode 100644 index 00000000..94c0f9cf --- /dev/null +++ b/httpd/api_eventrule.go @@ -0,0 +1,235 @@ +package httpd + +import ( + "context" + "net/http" + + "github.com/go-chi/render" + + "github.com/drakkan/sftpgo/v2/dataprovider" + "github.com/drakkan/sftpgo/v2/util" +) + +func getEventActions(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + limit, offset, order, err := getSearchFilters(w, r) + if err != nil { + return + } + + actions, err := dataprovider.GetEventActions(limit, offset, order, false) + if err != nil { + sendAPIResponse(w, r, err, "", http.StatusInternalServerError) + return + } + render.JSON(w, r, actions) +} + +func renderEventAction(w http.ResponseWriter, r *http.Request, name string, status int) { + action, err := dataprovider.EventActionExists(name) + if err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return + } + action.PrepareForRendering() + if status != http.StatusOK { + ctx := context.WithValue(r.Context(), render.StatusCtxKey, status) + render.JSON(w, r.WithContext(ctx), action) + } else { + render.JSON(w, r, action) + } +} + +func getEventActionByName(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + name := getURLParam(r, "name") + renderEventAction(w, r, name, http.StatusOK) +} + +func addEventAction(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + + claims, err := getTokenClaims(r) + if err != nil || claims.Username == "" { + sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest) + return + } + var action dataprovider.BaseEventAction + err = render.DecodeJSON(r.Body, &action) + if err != nil { + sendAPIResponse(w, r, err, "", http.StatusBadRequest) + return + } + err = dataprovider.AddEventAction(&action, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr)) + if err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return + } + renderEventAction(w, r, action.Name, http.StatusCreated) +} + +func updateEventAction(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + claims, err := getTokenClaims(r) + if err != nil || claims.Username == "" { + sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest) + return + } + + name := getURLParam(r, "name") + action, err := dataprovider.EventActionExists(name) + if err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return + } + actionID := action.ID + name = action.Name + currentHTTPPassword := action.Options.HTTPConfig.Password + action.Options = dataprovider.BaseEventActionOptions{} + + err = render.DecodeJSON(r.Body, &action) + if err != nil { + sendAPIResponse(w, r, err, "", http.StatusBadRequest) + return + } + action.ID = actionID + action.Name = name + action.Options.SetEmptySecretsIfNil() + switch action.Type { + case dataprovider.ActionTypeHTTP: + if action.Options.HTTPConfig.Password.IsNotPlainAndNotEmpty() { + action.Options.HTTPConfig.Password = currentHTTPPassword + } + } + + err = dataprovider.UpdateEventAction(&action, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr)) + if err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return + } + sendAPIResponse(w, r, nil, "Event target updated", http.StatusOK) +} + +func deleteEventAction(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + claims, err := getTokenClaims(r) + if err != nil || claims.Username == "" { + sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest) + return + } + name := getURLParam(r, "name") + err = dataprovider.DeleteEventAction(name, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr)) + if err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return + } + sendAPIResponse(w, r, err, "Event action deleted", http.StatusOK) +} + +func getEventRules(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + limit, offset, order, err := getSearchFilters(w, r) + if err != nil { + return + } + + rules, err := dataprovider.GetEventRules(limit, offset, order) + if err != nil { + sendAPIResponse(w, r, err, "", http.StatusInternalServerError) + return + } + render.JSON(w, r, rules) +} + +func renderEventRule(w http.ResponseWriter, r *http.Request, name string, status int) { + rule, err := dataprovider.EventRuleExists(name) + if err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return + } + rule.PrepareForRendering() + if status != http.StatusOK { + ctx := context.WithValue(r.Context(), render.StatusCtxKey, status) + render.JSON(w, r.WithContext(ctx), rule) + } else { + render.JSON(w, r, rule) + } +} + +func getEventRuleByName(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + name := getURLParam(r, "name") + renderEventRule(w, r, name, http.StatusOK) +} + +func addEventRule(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + + claims, err := getTokenClaims(r) + if err != nil || claims.Username == "" { + sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest) + return + } + var rule dataprovider.EventRule + err = render.DecodeJSON(r.Body, &rule) + if err != nil { + sendAPIResponse(w, r, err, "", http.StatusBadRequest) + return + } + err = dataprovider.AddEventRule(&rule, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr)) + if err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return + } + renderEventRule(w, r, rule.Name, http.StatusCreated) +} + +func updateEventRule(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + claims, err := getTokenClaims(r) + if err != nil || claims.Username == "" { + sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest) + return + } + + name := getURLParam(r, "name") + rule, err := dataprovider.EventRuleExists(name) + if err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return + } + ruleID := rule.ID + name = rule.Name + rule.Actions = nil + + err = render.DecodeJSON(r.Body, &rule) + if err != nil { + sendAPIResponse(w, r, err, "", http.StatusBadRequest) + return + } + rule.ID = ruleID + rule.Name = name + + err = dataprovider.UpdateEventRule(&rule, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr)) + if err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return + } + sendAPIResponse(w, r, nil, "Event rules updated", http.StatusOK) +} + +func deleteEventRule(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + claims, err := getTokenClaims(r) + if err != nil || claims.Username == "" { + sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest) + return + } + name := getURLParam(r, "name") + err = dataprovider.DeleteEventRule(name, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr)) + if err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return + } + sendAPIResponse(w, r, err, "Event rule deleted", http.StatusOK) +} diff --git a/httpd/api_maintenance.go b/httpd/api_maintenance.go index 800176bd..f78de5a1 100644 --- a/httpd/api_maintenance.go +++ b/httpd/api_maintenance.go @@ -13,7 +13,6 @@ import ( "github.com/go-chi/render" - "github.com/drakkan/sftpgo/v2/common" "github.com/drakkan/sftpgo/v2/dataprovider" "github.com/drakkan/sftpgo/v2/logger" "github.com/drakkan/sftpgo/v2/util" @@ -190,7 +189,15 @@ func restoreBackup(content []byte, inputFile string, scanQuota, mode int, execut return err } - logger.Debug(logSender, "", "backup restored, users: %v, folders: %v, admins: %vs", + if err = RestoreEventActions(dump.EventActions, inputFile, mode, executor, ipAddress); err != nil { + return err + } + + if err = RestoreEventRules(dump.EventRules, inputFile, mode, executor, ipAddress); err != nil { + return err + } + + logger.Debug(logSender, "", "backup restored, users: %d, folders: %d, admins: %d", len(dump.Users), len(dump.Folders), len(dump.Admins)) return nil @@ -244,7 +251,7 @@ func RestoreFolders(folders []vfs.BaseVirtualFolder, inputFile string, mode, sca return fmt.Errorf("unable to restore folder %#v: %w", folder.Name, err) } if scanQuota >= 1 { - if common.QuotaScans.AddVFolderQuotaScan(folder.Name) { + if dataprovider.QuotaScans.AddVFolderQuotaScan(folder.Name) { logger.Debug(logSender, "", "starting quota scan for restored folder: %#v", folder.Name) go doFolderQuotaScan(folder) //nolint:errcheck } @@ -280,6 +287,54 @@ func RestoreShares(shares []dataprovider.Share, inputFile string, mode int, exec return nil } +// RestoreEventActions restores the specified event actions +func RestoreEventActions(actions []dataprovider.BaseEventAction, inputFile string, mode int, executor, ipAddress string) error { + for _, action := range actions { + action := action // pin + a, err := dataprovider.EventActionExists(action.Name) + if err == nil { + if mode == 1 { + logger.Debug(logSender, "", "loaddata mode 1, existing event action %q not updated", a.Name) + continue + } + action.ID = a.ID + err = dataprovider.UpdateEventAction(&action, executor, ipAddress) + logger.Debug(logSender, "", "restoring event action %q, dump file: %q, error: %v", action.Name, inputFile, err) + } else { + err = dataprovider.AddEventAction(&action, executor, ipAddress) + logger.Debug(logSender, "", "adding new event action %q, dump file: %q, error: %v", action.Name, inputFile, err) + } + if err != nil { + return fmt.Errorf("unable to restore event action %q: %w", action.Name, err) + } + } + return nil +} + +// RestoreEventRules restores the specified event rules +func RestoreEventRules(rules []dataprovider.EventRule, inputFile string, mode int, executor, ipAddress string) error { + for _, rule := range rules { + rule := rule // pin + r, err := dataprovider.EventRuleExists(rule.Name) + if err == nil { + if mode == 1 { + logger.Debug(logSender, "", "loaddata mode 1, existing event rule %q not updated", r.Name) + continue + } + rule.ID = r.ID + err = dataprovider.UpdateEventRule(&rule, executor, ipAddress) + logger.Debug(logSender, "", "restoring event rule %q, dump file: %q, error: %v", rule.Name, inputFile, err) + } else { + err = dataprovider.AddEventRule(&rule, executor, ipAddress) + logger.Debug(logSender, "", "adding new event rule %q, dump file: %q, error: %v", rule.Name, inputFile, err) + } + if err != nil { + return fmt.Errorf("unable to restore event rule %q: %w", rule.Name, err) + } + } + return nil +} + // RestoreAPIKeys restores the specified API keys func RestoreAPIKeys(apiKeys []dataprovider.APIKey, inputFile string, mode int, executor, ipAddress string) error { for _, apiKey := range apiKeys { @@ -384,7 +439,7 @@ func RestoreUsers(users []dataprovider.User, inputFile string, mode, scanQuota i return fmt.Errorf("unable to restore user %#v: %w", user.Username, err) } if scanQuota == 1 || (scanQuota == 2 && user.HasQuotaRestrictions()) { - if common.QuotaScans.AddUserQuotaScan(user.Username) { + if dataprovider.QuotaScans.AddUserQuotaScan(user.Username) { logger.Debug(logSender, "", "starting quota scan for restored user: %#v", user.Username) go doUserQuotaScan(user) //nolint:errcheck } diff --git a/httpd/api_quota.go b/httpd/api_quota.go index cc919162..6c34989e 100644 --- a/httpd/api_quota.go +++ b/httpd/api_quota.go @@ -7,7 +7,6 @@ import ( "github.com/go-chi/render" - "github.com/drakkan/sftpgo/v2/common" "github.com/drakkan/sftpgo/v2/dataprovider" "github.com/drakkan/sftpgo/v2/logger" "github.com/drakkan/sftpgo/v2/vfs" @@ -30,12 +29,12 @@ type transferQuotaUsage struct { func getUsersQuotaScans(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) - render.JSON(w, r, common.QuotaScans.GetUsersQuotaScans()) + render.JSON(w, r, dataprovider.QuotaScans.GetUsersQuotaScans()) } func getFoldersQuotaScans(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) - render.JSON(w, r, common.QuotaScans.GetVFoldersQuotaScans()) + render.JSON(w, r, dataprovider.QuotaScans.GetVFoldersQuotaScans()) } func updateUserQuotaUsage(w http.ResponseWriter, r *http.Request) { @@ -128,11 +127,11 @@ func doUpdateUserQuotaUsage(w http.ResponseWriter, r *http.Request, username str "", http.StatusBadRequest) return } - if !common.QuotaScans.AddUserQuotaScan(user.Username) { + if !dataprovider.QuotaScans.AddUserQuotaScan(user.Username) { sendAPIResponse(w, r, err, "A quota scan is in progress for this user", http.StatusConflict) return } - defer common.QuotaScans.RemoveUserQuotaScan(user.Username) + defer dataprovider.QuotaScans.RemoveUserQuotaScan(user.Username) err = dataprovider.UpdateUserQuota(&user, usage.UsedQuotaFiles, usage.UsedQuotaSize, mode == quotaUpdateModeReset) if err != nil { sendAPIResponse(w, r, err, "", getRespStatus(err)) @@ -157,11 +156,11 @@ func doUpdateFolderQuotaUsage(w http.ResponseWriter, r *http.Request, name strin sendAPIResponse(w, r, err, "", getRespStatus(err)) return } - if !common.QuotaScans.AddVFolderQuotaScan(folder.Name) { + if !dataprovider.QuotaScans.AddVFolderQuotaScan(folder.Name) { sendAPIResponse(w, r, err, "A quota scan is in progress for this folder", http.StatusConflict) return } - defer common.QuotaScans.RemoveVFolderQuotaScan(folder.Name) + defer dataprovider.QuotaScans.RemoveVFolderQuotaScan(folder.Name) err = dataprovider.UpdateVirtualFolderQuota(&folder, usage.UsedQuotaFiles, usage.UsedQuotaSize, mode == quotaUpdateModeReset) if err != nil { sendAPIResponse(w, r, err, "", getRespStatus(err)) @@ -180,8 +179,8 @@ func doStartUserQuotaScan(w http.ResponseWriter, r *http.Request, username strin sendAPIResponse(w, r, err, "", getRespStatus(err)) return } - if !common.QuotaScans.AddUserQuotaScan(user.Username) { - sendAPIResponse(w, r, err, fmt.Sprintf("Another scan is already in progress for user %#v", username), + if !dataprovider.QuotaScans.AddUserQuotaScan(user.Username) { + sendAPIResponse(w, r, nil, fmt.Sprintf("Another scan is already in progress for user %#v", username), http.StatusConflict) return } @@ -199,7 +198,7 @@ func doStartFolderQuotaScan(w http.ResponseWriter, r *http.Request, name string) sendAPIResponse(w, r, err, "", getRespStatus(err)) return } - if !common.QuotaScans.AddVFolderQuotaScan(folder.Name) { + if !dataprovider.QuotaScans.AddVFolderQuotaScan(folder.Name) { sendAPIResponse(w, r, err, fmt.Sprintf("Another scan is already in progress for folder %#v", name), http.StatusConflict) return @@ -209,7 +208,7 @@ func doStartFolderQuotaScan(w http.ResponseWriter, r *http.Request, name string) } func doUserQuotaScan(user dataprovider.User) error { - defer common.QuotaScans.RemoveUserQuotaScan(user.Username) + defer dataprovider.QuotaScans.RemoveUserQuotaScan(user.Username) numFiles, size, err := user.ScanQuota() if err != nil { logger.Warn(logSender, "", "error scanning user quota %#v: %v", user.Username, err) @@ -221,7 +220,7 @@ func doUserQuotaScan(user dataprovider.User) error { } func doFolderQuotaScan(folder vfs.BaseVirtualFolder) error { - defer common.QuotaScans.RemoveVFolderQuotaScan(folder.Name) + defer dataprovider.QuotaScans.RemoveVFolderQuotaScan(folder.Name) f := vfs.VirtualFolder{ BaseVirtualFolder: folder, VirtualPath: "/", diff --git a/httpd/api_utils.go b/httpd/api_utils.go index dd0c9a87..2537f3df 100644 --- a/httpd/api_utils.go +++ b/httpd/api_utils.go @@ -574,7 +574,7 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error return util.NewGenericError("Unable to render password reset template") } startTime := time.Now() - if err := smtp.SendEmail(email, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil { + if err := smtp.SendEmail([]string{email}, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil { logger.Warn(logSender, middleware.GetReqID(r.Context()), "unable to send password reset code via email: %v, elapsed: %v", err, time.Since(startTime)) return util.NewGenericError(fmt.Sprintf("Unable to send confirmation code via email: %v", err)) diff --git a/httpd/httpd.go b/httpd/httpd.go index 1f25782c..e0d164c9 100644 --- a/httpd/httpd.go +++ b/httpd/httpd.go @@ -74,6 +74,8 @@ const ( fsEventsPath = "/api/v2/events/fs" providerEventsPath = "/api/v2/events/provider" sharesPath = "/api/v2/shares" + eventActionsPath = "/api/v2/eventactions" + eventRulesPath = "/api/v2/eventrules" healthzPath = "/healthz" robotsTxtPath = "/robots.txt" webRootPathDefault = "/" @@ -107,6 +109,10 @@ const ( webAdminResetPwdPathDefault = "/web/admin/reset-password" webAdminProfilePathDefault = "/web/admin/profile" webAdminMFAPathDefault = "/web/admin/mfa" + webAdminEventRulesPathDefault = "/web/admin/eventrules" + webAdminEventRulePathDefault = "/web/admin/eventrule" + webAdminEventActionsPathDefault = "/web/admin/eventactions" + webAdminEventActionPathDefault = "/web/admin/eventaction" webAdminTOTPGeneratePathDefault = "/web/admin/totp/generate" webAdminTOTPValidatePathDefault = "/web/admin/totp/validate" webAdminTOTPSavePathDefault = "/web/admin/totp/save" @@ -185,6 +191,10 @@ var ( webQuotaScanPath string webAdminProfilePath string webAdminMFAPath string + webAdminEventRulesPath string + webAdminEventRulePath string + webAdminEventActionsPath string + webAdminEventActionPath string webAdminTOTPGeneratePath string webAdminTOTPValidatePath string webAdminTOTPSavePath string @@ -755,6 +765,7 @@ func (c *Conf) Initialize(configDir string, isShared int) error { return } server := newHttpdServer(b, staticFilesPath, c.SigningPassphrase, c.Cors, openAPIPath) + server.setShared(isShared) exitChannel <- server.listenAndServe() }(binding) @@ -890,6 +901,10 @@ func updateWebAdminURLs(baseURL string) { webAdminResetPwdPath = path.Join(baseURL, webAdminResetPwdPathDefault) webAdminProfilePath = path.Join(baseURL, webAdminProfilePathDefault) webAdminMFAPath = path.Join(baseURL, webAdminMFAPathDefault) + webAdminEventRulesPath = path.Join(baseURL, webAdminEventRulesPathDefault) + webAdminEventRulePath = path.Join(baseURL, webAdminEventRulePathDefault) + webAdminEventActionsPath = path.Join(baseURL, webAdminEventActionsPathDefault) + webAdminEventActionPath = path.Join(baseURL, webAdminEventActionPathDefault) webAdminTOTPGeneratePath = path.Join(baseURL, webAdminTOTPGeneratePathDefault) webAdminTOTPValidatePath = path.Join(baseURL, webAdminTOTPValidatePathDefault) webAdminTOTPSavePath = path.Join(baseURL, webAdminTOTPSavePathDefault) diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index d92ae106..d28168c7 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -109,6 +109,8 @@ const ( fsEventsPath = "/api/v2/events/fs" providerEventsPath = "/api/v2/events/provider" sharesPath = "/api/v2/shares" + eventActionsPath = "/api/v2/eventactions" + eventRulesPath = "/api/v2/eventrules" healthzPath = "/healthz" robotsTxtPath = "/robots.txt" webBasePath = "/web" @@ -158,6 +160,10 @@ const ( webClientForgotPwdPath = "/web/client/forgot-password" webClientResetPwdPath = "/web/client/reset-password" webClientViewPDFPath = "/web/client/viewpdf" + webAdminEventRulesPath = "/web/admin/eventrules" + webAdminEventRulePath = "/web/admin/eventrule" + webAdminEventActionsPath = "/web/admin/eventactions" + webAdminEventActionPath = "/web/admin/eventaction" httpBaseURL = "http://127.0.0.1:8081" defaultRemoteAddr = "127.0.0.1:1234" sftpServerAddr = "127.0.0.1:8022" @@ -1020,6 +1026,598 @@ func TestGroupSettingsOverride(t *testing.T) { assert.NoError(t, err) } +func TestBasicActionRulesHandling(t *testing.T) { + actionName := "test action" + a := dataprovider.BaseEventAction{ + Name: actionName, + Description: "test description", + Type: dataprovider.ActionTypeBackup, + Options: dataprovider.BaseEventActionOptions{}, + } + action, _, err := httpdtest.AddEventAction(a, http.StatusCreated) + assert.NoError(t, err) + // adding the same action should fail + _, _, err = httpdtest.AddEventAction(a, http.StatusInternalServerError) + assert.NoError(t, err) + actionGet, _, err := httpdtest.GetEventActionByName(actionName, http.StatusOK) + assert.NoError(t, err) + actions, _, err := httpdtest.GetEventActions(0, 0, http.StatusOK) + assert.NoError(t, err) + assert.Greater(t, len(actions), 0) + found := false + for _, ac := range actions { + if ac.Name == actionName { + assert.Equal(t, actionGet, ac) + found = true + } + } + assert.True(t, found) + a.Description = "new description" + a.Type = dataprovider.ActionTypeCommand + a.Options = dataprovider.BaseEventActionOptions{ + CmdConfig: dataprovider.EventActionCommandConfig{ + Cmd: filepath.Join(os.TempDir(), "test_cmd"), + Timeout: 20, + EnvVars: []dataprovider.KeyValue{ + { + Key: "NAME", + Value: "VALUE", + }, + }, + }, + } + _, _, err = httpdtest.UpdateEventAction(a, http.StatusOK) + assert.NoError(t, err) + // invalid type + a.Type = 1000 + _, _, err = httpdtest.UpdateEventAction(a, http.StatusBadRequest) + assert.NoError(t, err) + + a.Type = dataprovider.ActionTypeEmail + a.Options = dataprovider.BaseEventActionOptions{ + EmailConfig: dataprovider.EventActionEmailConfig{ + Recipients: []string{"email@example.com"}, + Subject: "Event: {{Event}}", + Body: "test mail body", + }, + } + + _, _, err = httpdtest.UpdateEventAction(a, http.StatusOK) + assert.NoError(t, err) + + a.Type = dataprovider.ActionTypeHTTP + a.Options = dataprovider.BaseEventActionOptions{ + HTTPConfig: dataprovider.EventActionHTTPConfig{ + Endpoint: "https://localhost:1234", + Username: defaultUsername, + Password: kms.NewPlainSecret(defaultPassword), + Headers: []dataprovider.KeyValue{ + { + Key: "Content-Type", + Value: "application/json", + }, + }, + Timeout: 10, + SkipTLSVerify: true, + Method: http.MethodPost, + QueryParameters: []dataprovider.KeyValue{ + { + Key: "a", + Value: "b", + }, + }, + Body: `{"event":"{{Event}}","name":"{{Name}}"}`, + }, + } + action, _, err = httpdtest.UpdateEventAction(a, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, sdkkms.SecretStatusSecretBox, action.Options.HTTPConfig.Password.GetStatus()) + assert.NotEmpty(t, action.Options.HTTPConfig.Password.GetPayload()) + assert.Empty(t, action.Options.HTTPConfig.Password.GetKey()) + assert.Empty(t, action.Options.HTTPConfig.Password.GetAdditionalData()) + // update again and check that the password was preserved + dbAction, err := dataprovider.EventActionExists(actionName) + assert.NoError(t, err) + action.Options.HTTPConfig.Password = kms.NewSecret( + dbAction.Options.HTTPConfig.Password.GetStatus(), + dbAction.Options.HTTPConfig.Password.GetPayload(), "", "") + action, _, err = httpdtest.UpdateEventAction(action, http.StatusOK) + assert.NoError(t, err) + dbAction, err = dataprovider.EventActionExists(actionName) + assert.NoError(t, err) + err = dbAction.Options.HTTPConfig.Password.Decrypt() + assert.NoError(t, err) + assert.Equal(t, defaultPassword, dbAction.Options.HTTPConfig.Password.GetPayload()) + + r := dataprovider.EventRule{ + Name: "test rule name", + Description: "", + Trigger: dataprovider.EventTriggerFsEvent, + Conditions: dataprovider.EventConditions{ + FsEvents: []string{"upload"}, + Options: dataprovider.ConditionOptions{ + MinFileSize: 1024 * 1024, + }, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: actionName, + }, + Order: 1, + Options: dataprovider.EventActionOptions{ + IsFailureAction: false, + StopOnFailure: true, + ExecuteSync: true, + }, + }, + }, + } + rule, _, err := httpdtest.AddEventRule(r, http.StatusCreated) + assert.NoError(t, err) + // adding the same rule should fail + _, _, err = httpdtest.AddEventRule(r, http.StatusInternalServerError) + assert.NoError(t, err) + + rule.Description = "new rule desc" + rule.Trigger = 1000 + _, _, err = httpdtest.UpdateEventRule(rule, http.StatusBadRequest) + assert.NoError(t, err) + rule.Trigger = dataprovider.EventTriggerFsEvent + rule, _, err = httpdtest.UpdateEventRule(rule, http.StatusOK) + assert.NoError(t, err) + + ruleGet, _, err := httpdtest.GetEventRuleByName(rule.Name, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, ruleGet.Actions, 1) { + if assert.NotNil(t, ruleGet.Actions[0].BaseEventAction.Options.HTTPConfig.Password) { + assert.Equal(t, sdkkms.SecretStatusSecretBox, ruleGet.Actions[0].BaseEventAction.Options.HTTPConfig.Password.GetStatus()) + assert.NotEmpty(t, ruleGet.Actions[0].BaseEventAction.Options.HTTPConfig.Password.GetPayload()) + assert.Empty(t, ruleGet.Actions[0].BaseEventAction.Options.HTTPConfig.Password.GetKey()) + assert.Empty(t, ruleGet.Actions[0].BaseEventAction.Options.HTTPConfig.Password.GetAdditionalData()) + } + } + rules, _, err := httpdtest.GetEventRules(0, 0, http.StatusOK) + assert.NoError(t, err) + assert.Greater(t, len(rules), 0) + found = false + for _, ru := range rules { + if ru.Name == rule.Name { + assert.Equal(t, ruleGet, ru) + found = true + } + } + assert.True(t, found) + + _, err = httpdtest.RemoveEventRule(rule, http.StatusOK) + assert.NoError(t, err) + _, _, err = httpdtest.UpdateEventRule(rule, http.StatusNotFound) + assert.NoError(t, err) + _, _, err = httpdtest.GetEventRuleByName(rule.Name, http.StatusNotFound) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventRule(rule, http.StatusNotFound) + assert.NoError(t, err) + + _, err = httpdtest.RemoveEventAction(action, http.StatusOK) + assert.NoError(t, err) + _, _, err = httpdtest.UpdateEventAction(action, http.StatusNotFound) + assert.NoError(t, err) + _, _, err = httpdtest.GetEventActionByName(actionName, http.StatusNotFound) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action, http.StatusNotFound) + assert.NoError(t, err) +} + +func TestActionRuleRelations(t *testing.T) { + a1 := dataprovider.BaseEventAction{ + Name: "action1", + Description: "test description", + Type: dataprovider.ActionTypeBackup, + Options: dataprovider.BaseEventActionOptions{}, + } + a2 := dataprovider.BaseEventAction{ + Name: "action2", + Type: dataprovider.ActionTypeTransferQuotaReset, + Options: dataprovider.BaseEventActionOptions{}, + } + a3 := dataprovider.BaseEventAction{ + Name: "action3", + Type: dataprovider.ActionTypeEmail, + Options: dataprovider.BaseEventActionOptions{ + EmailConfig: dataprovider.EventActionEmailConfig{ + Recipients: []string{"test@example.net"}, + Subject: "test subject", + Body: "test body", + }, + }, + } + action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated) + assert.NoError(t, err) + action2, _, err := httpdtest.AddEventAction(a2, http.StatusCreated) + assert.NoError(t, err) + action3, _, err := httpdtest.AddEventAction(a3, http.StatusCreated) + assert.NoError(t, err) + + r1 := dataprovider.EventRule{ + Name: "rule1", + Description: "", + Trigger: dataprovider.EventTriggerProviderEvent, + Conditions: dataprovider.EventConditions{ + ProviderEvents: []string{"add"}, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action3.Name, + }, + Order: 2, + Options: dataprovider.EventActionOptions{ + IsFailureAction: true, + }, + }, + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, + }, + Order: 1, + }, + }, + } + rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated) + assert.NoError(t, err) + if assert.Len(t, rule1.Actions, 2) { + assert.Equal(t, action1.Name, rule1.Actions[0].Name) + assert.Equal(t, 1, rule1.Actions[0].Order) + assert.Equal(t, action3.Name, rule1.Actions[1].Name) + assert.Equal(t, 2, rule1.Actions[1].Order) + assert.True(t, rule1.Actions[1].Options.IsFailureAction) + } + + r2 := dataprovider.EventRule{ + Name: "rule2", + Description: "", + Trigger: dataprovider.EventTriggerSchedule, + Conditions: dataprovider.EventConditions{ + Schedules: []dataprovider.Schedule{ + { + Hours: "1", + DayOfWeek: "*", + DayOfMonth: "*", + Month: "*", + }, + }, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action3.Name, + }, + Order: 2, + Options: dataprovider.EventActionOptions{ + IsFailureAction: true, + }, + }, + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action2.Name, + }, + Order: 1, + }, + }, + } + rule2, _, err := httpdtest.AddEventRule(r2, http.StatusCreated) + assert.NoError(t, err) + if assert.Len(t, rule1.Actions, 2) { + assert.Equal(t, action2.Name, rule2.Actions[0].Name) + assert.Equal(t, 1, rule2.Actions[0].Order) + assert.Equal(t, action3.Name, rule2.Actions[1].Name) + assert.Equal(t, 2, rule2.Actions[1].Order) + assert.True(t, rule2.Actions[1].Options.IsFailureAction) + } + // check the references + action1, _, err = httpdtest.GetEventActionByName(action1.Name, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, action1.Rules, 1) + assert.True(t, util.Contains(action1.Rules, rule1.Name)) + action2, _, err = httpdtest.GetEventActionByName(action2.Name, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, action2.Rules, 1) + assert.True(t, util.Contains(action2.Rules, rule2.Name)) + action3, _, err = httpdtest.GetEventActionByName(action3.Name, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, action3.Rules, 2) + assert.True(t, util.Contains(action3.Rules, rule1.Name)) + assert.True(t, util.Contains(action3.Rules, rule2.Name)) + // referenced actions cannot be removed + _, err = httpdtest.RemoveEventAction(action1, http.StatusBadRequest) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action2, http.StatusBadRequest) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action3, http.StatusBadRequest) + assert.NoError(t, err) + // remove action3 from rule2 + r2.Actions = []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action2.Name, + }, + Order: 10, + }, + } + rule2, _, err = httpdtest.UpdateEventRule(r2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, rule2.Actions, 1) { + assert.Equal(t, action2.Name, rule2.Actions[0].Name) + assert.Equal(t, 10, rule2.Actions[0].Order) + } + // check the updated relation + action3, _, err = httpdtest.GetEventActionByName(action3.Name, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, action3.Rules, 1) + assert.True(t, util.Contains(action3.Rules, rule1.Name)) + + _, err = httpdtest.RemoveEventRule(rule1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventRule(rule2, http.StatusOK) + assert.NoError(t, err) + // no relations anymore + action1, _, err = httpdtest.GetEventActionByName(action1.Name, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, action1.Rules, 0) + action2, _, err = httpdtest.GetEventActionByName(action2.Name, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, action2.Rules, 0) + action3, _, err = httpdtest.GetEventActionByName(action3.Name, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, action3.Rules, 0) + + _, err = httpdtest.RemoveEventAction(action1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action2, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action3, http.StatusOK) + assert.NoError(t, err) +} + +func TestEventActionValidation(t *testing.T) { + action := dataprovider.BaseEventAction{ + Name: "", + } + _, resp, err := httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "name is mandatory") + action = dataprovider.BaseEventAction{ + Name: "n", + Type: -1, + } + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "invalid action type") + action.Type = dataprovider.ActionTypeHTTP + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "HTTP endpoint is required") + action.Options.HTTPConfig.Endpoint = "abc" + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "invalid HTTP endpoint schema") + action.Options.HTTPConfig.Endpoint = "http://localhost" + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "invalid HTTP timeout") + action.Options.HTTPConfig.Timeout = 20 + action.Options.HTTPConfig.Headers = []dataprovider.KeyValue{ + { + Key: "", + Value: "", + }, + } + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "invalid HTTP headers") + action.Options.HTTPConfig.Headers = []dataprovider.KeyValue{ + { + Key: "Content-Type", + Value: "application/json", + }, + } + action.Options.HTTPConfig.Password = kms.NewSecret(sdkkms.SecretStatusRedacted, "paylod", "", "") + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "cannot save HTTP configuration with a redacted secret") + action.Options.HTTPConfig.Password = nil + action.Options.HTTPConfig.Method = http.MethodDelete + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "unsupported HTTP method") + action.Options.HTTPConfig.Method = http.MethodGet + action.Options.HTTPConfig.QueryParameters = []dataprovider.KeyValue{ + { + Key: "a", + Value: "", + }, + } + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "invalid HTTP query parameters") + + action.Type = dataprovider.ActionTypeCommand + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "command is required") + action.Options.CmdConfig.Cmd = "relative" + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "invalid command, it must be an absolute path") + action.Options.CmdConfig.Cmd = filepath.Join(os.TempDir(), "cmd") + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "invalid command action timeout") + action.Options.CmdConfig.Timeout = 30 + action.Options.CmdConfig.EnvVars = []dataprovider.KeyValue{ + { + Key: "k", + }, + } + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "invalid command env vars") + + action.Type = dataprovider.ActionTypeEmail + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "at least one email recipient is required") + action.Options.EmailConfig.Recipients = []string{""} + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "invalid email recipients") + action.Options.EmailConfig.Recipients = []string{"a@a.com"} + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "email subject is required") + action.Options.EmailConfig.Subject = "subject" + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "email body is required") +} + +func TestEventRuleValidation(t *testing.T) { + rule := dataprovider.EventRule{ + Name: "", + } + _, resp, err := httpdtest.AddEventRule(rule, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "name is mandatory") + rule.Name = "r" + rule.Trigger = 1000 + _, resp, err = httpdtest.AddEventRule(rule, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "invalid event rule trigger") + rule.Trigger = dataprovider.EventTriggerFsEvent + _, resp, err = httpdtest.AddEventRule(rule, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "at least one filesystem event is required") + rule.Conditions.FsEvents = []string{""} + _, resp, err = httpdtest.AddEventRule(rule, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "unsupported fs event") + rule.Conditions.FsEvents = []string{"upload"} + _, resp, err = httpdtest.AddEventRule(rule, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "at least one action is required") + rule.Actions = []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: "action1", + }, + Order: 1, + }, + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: "", + }, + }, + } + _, resp, err = httpdtest.AddEventRule(rule, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "name not specified") + rule.Actions = []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: "action", + }, + Order: 1, + }, + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: "action", + }, + }, + } + _, resp, err = httpdtest.AddEventRule(rule, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "duplicated action") + rule.Actions = []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: "action11", + }, + Order: 1, + }, + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: "action12", + }, + Order: 1, + }, + } + _, resp, err = httpdtest.AddEventRule(rule, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "duplicated order") + rule.Actions = []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: "action111", + }, + Order: 1, + Options: dataprovider.EventActionOptions{ + IsFailureAction: true, + }, + }, + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: "action112", + }, + Order: 2, + Options: dataprovider.EventActionOptions{ + IsFailureAction: true, + }, + }, + } + _, resp, err = httpdtest.AddEventRule(rule, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "at least a non-failure action is required") + rule.Actions = []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: "action1234", + }, + Order: 1, + Options: dataprovider.EventActionOptions{ + IsFailureAction: false, + }, + }, + } + rule.Trigger = dataprovider.EventTriggerProviderEvent + _, resp, err = httpdtest.AddEventRule(rule, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "at least one provider event is required") + rule.Conditions.ProviderEvents = []string{""} + _, resp, err = httpdtest.AddEventRule(rule, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "unsupported provider event") + rule.Trigger = dataprovider.EventTriggerSchedule + _, resp, err = httpdtest.AddEventRule(rule, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "at least one schedule is required") + rule.Conditions.Schedules = []dataprovider.Schedule{ + {}, + } + _, resp, err = httpdtest.AddEventRule(rule, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "invalid schedule") + rule.Conditions.Schedules = []dataprovider.Schedule{ + { + Hours: "3", + DayOfWeek: "*", + DayOfMonth: "*", + Month: "*", + }, + } + _, _, err = httpdtest.AddEventRule(rule, http.StatusInternalServerError) + assert.NoError(t, err) +} + func TestUserTransferLimits(t *testing.T) { u := getTestUser() u.TotalDataTransfer = 100 @@ -4823,6 +5421,10 @@ func TestProviderErrors(t *testing.T) { assert.NoError(t, err) _, _, err = httpdtest.GetAPIKeys(1, 0, http.StatusInternalServerError) assert.NoError(t, err) + _, _, err = httpdtest.GetEventActions(1, 0, http.StatusInternalServerError) + assert.NoError(t, err) + _, _, err = httpdtest.GetEventRules(1, 0, http.StatusInternalServerError) + assert.NoError(t, err) req, err := http.NewRequest(http.MethodGet, userSharesPath, nil) assert.NoError(t, err) setBearerForReq(req, userAPIToken) @@ -4939,6 +5541,53 @@ func TestProviderErrors(t *testing.T) { assert.NoError(t, err) _, _, err = httpdtest.Loaddata(backupFilePath, "", "", http.StatusInternalServerError) assert.NoError(t, err) + backupData = dataprovider.BackupData{ + EventActions: []dataprovider.BaseEventAction{ + { + Name: "quota reset", + Type: dataprovider.ActionTypeFolderQuotaReset, + }, + }, + } + backupContent, err = json.Marshal(backupData) + 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) + backupData = dataprovider.BackupData{ + EventRules: []dataprovider.EventRule{ + { + Name: "quota reset", + Trigger: dataprovider.EventTriggerSchedule, + Conditions: dataprovider.EventConditions{ + Schedules: []dataprovider.Schedule{ + { + Hours: "2", + DayOfWeek: "1", + DayOfMonth: "2", + Month: "3", + }, + }, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: "unknown action", + }, + Order: 1, + }, + }, + }, + }, + } + backupContent, err = json.Marshal(backupData) + 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) + err = os.Remove(backupFilePath) assert.NoError(t, err) req, err = http.NewRequest(http.MethodGet, webUserPath, nil) @@ -4976,6 +5625,41 @@ func TestProviderErrors(t *testing.T) { setJWTCookieForReq(req, testServerToken) rr = executeRequest(req) checkResponseCode(t, http.StatusInternalServerError, rr) + req, err = http.NewRequest(http.MethodGet, path.Join(webAdminEventActionPath, "actionname"), nil) + assert.NoError(t, err) + setJWTCookieForReq(req, testServerToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, "actionname"), bytes.NewBuffer(nil)) + assert.NoError(t, err) + setJWTCookieForReq(req, testServerToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + req, err = http.NewRequest(http.MethodGet, webAdminEventActionsPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, testServerToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + req, err = http.NewRequest(http.MethodGet, path.Join(webAdminEventRulePath, "rulename"), nil) + assert.NoError(t, err) + setJWTCookieForReq(req, testServerToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventRulePath, "rulename"), bytes.NewBuffer(nil)) + assert.NoError(t, err) + setJWTCookieForReq(req, testServerToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + req, err = http.NewRequest(http.MethodGet, webAdminEventRulesPath+"?qlimit=10", nil) + assert.NoError(t, err) + setJWTCookieForReq(req, testServerToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + req, err = http.NewRequest(http.MethodGet, webAdminEventRulePath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, testServerToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) err = config.LoadConfig(configDir, "") assert.NoError(t, err) providerConf := config.GetProviderConf() @@ -5462,6 +6146,39 @@ func TestLoaddata(t *testing.T) { Paths: []string{"/"}, Username: user.Username, } + action := dataprovider.BaseEventAction{ + ID: 81, + Name: "test restore action", + Type: dataprovider.ActionTypeHTTP, + Options: dataprovider.BaseEventActionOptions{ + HTTPConfig: dataprovider.EventActionHTTPConfig{ + Endpoint: "https://localhost:4567/action", + Username: defaultUsername, + Password: kms.NewPlainSecret(defaultPassword), + Timeout: 10, + SkipTLSVerify: true, + Method: http.MethodPost, + Body: `{"event":"{{Event}}","name":"{{Name}}"}`, + }, + }, + } + rule := dataprovider.EventRule{ + ID: 100, + Name: "test rule restore", + Description: "", + Trigger: dataprovider.EventTriggerFsEvent, + Conditions: dataprovider.EventConditions{ + FsEvents: []string{"download"}, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action.Name, + }, + Order: 1, + }, + }, + } backupData := dataprovider.BackupData{} backupData.Users = append(backupData.Users, user) backupData.Groups = append(backupData.Groups, group) @@ -5483,6 +6200,8 @@ func TestLoaddata(t *testing.T) { } backupData.APIKeys = append(backupData.APIKeys, apiKey) backupData.Shares = append(backupData.Shares, share) + backupData.EventActions = append(backupData.EventActions, action) + backupData.EventRules = append(backupData.EventRules, rule) backupContent, err := json.Marshal(backupData) assert.NoError(t, err) backupFilePath := filepath.Join(backupsPath, "backup.json") @@ -5504,7 +6223,7 @@ func TestLoaddata(t *testing.T) { err = os.Chmod(backupFilePath, 0644) assert.NoError(t, err) } - // add user, group, folder, admin, API key, share from backup + // add objects from backup _, resp, err := httpdtest.Loaddata(backupFilePath, "1", "", http.StatusOK) assert.NoError(t, err, string(resp)) // update from backup @@ -5529,6 +6248,20 @@ func TestLoaddata(t *testing.T) { apiKey, _, err = httpdtest.GetAPIKeyByID(apiKey.KeyID, http.StatusOK) assert.NoError(t, err) + action, _, err = httpdtest.GetEventActionByName(action.Name, http.StatusOK) + assert.NoError(t, err) + + rule, _, err = httpdtest.GetEventRuleByName(rule.Name, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, rule.Actions, 1) { + if assert.NotNil(t, rule.Actions[0].BaseEventAction.Options.HTTPConfig.Password) { + assert.Equal(t, sdkkms.SecretStatusSecretBox, rule.Actions[0].BaseEventAction.Options.HTTPConfig.Password.GetStatus()) + assert.NotEmpty(t, rule.Actions[0].BaseEventAction.Options.HTTPConfig.Password.GetPayload()) + assert.Empty(t, rule.Actions[0].BaseEventAction.Options.HTTPConfig.Password.GetKey()) + assert.Empty(t, rule.Actions[0].BaseEventAction.Options.HTTPConfig.Password.GetAdditionalData()) + } + } + response, _, err := httpdtest.Dumpdata("", "1", "0", http.StatusOK) assert.NoError(t, err) var dumpedData dataprovider.BackupData @@ -5550,7 +6283,21 @@ func TestLoaddata(t *testing.T) { if assert.Len(t, dumpedData.Groups, 1) { assert.Equal(t, len(group.VirtualFolders), len(dumpedData.Groups[0].VirtualFolders)) } - + found = false + for _, a := range dumpedData.EventActions { + if a.Name == action.Name { + found = true + } + } + assert.True(t, found) + found = false + for _, r := range dumpedData.EventRules { + if r.Name == rule.Name { + found = true + assert.Len(t, r.Actions, 1) + } + } + assert.True(t, found) folder, _, err := httpdtest.GetFolderByName(folderName, http.StatusOK) assert.NoError(t, err) assert.Equal(t, mappedPath, folder.MappedPath) @@ -5567,6 +6314,10 @@ func TestLoaddata(t *testing.T) { assert.NoError(t, err) _, err = httpdtest.RemoveAPIKey(apiKey, http.StatusOK) assert.NoError(t, err) + _, err = httpdtest.RemoveEventRule(rule, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action, http.StatusOK) + assert.NoError(t, err) err = os.Remove(backupFilePath) assert.NoError(t, err) @@ -5616,10 +6367,46 @@ func TestLoaddataMode(t *testing.T) { Paths: []string{"/"}, Username: user.Username, } + action := dataprovider.BaseEventAction{ + ID: 81, + Name: "test restore action data mode", + Description: "action desc", + Type: dataprovider.ActionTypeHTTP, + Options: dataprovider.BaseEventActionOptions{ + HTTPConfig: dataprovider.EventActionHTTPConfig{ + Endpoint: "https://localhost:4567/mode", + Username: defaultUsername, + Password: kms.NewPlainSecret(defaultPassword), + Timeout: 10, + SkipTLSVerify: true, + Method: http.MethodPost, + Body: `{"event":"{{Event}}","name":"{{Name}}"}`, + }, + }, + } + rule := dataprovider.EventRule{ + ID: 100, + Name: "test rule restore data mode", + Description: "rule desc", + Trigger: dataprovider.EventTriggerFsEvent, + Conditions: dataprovider.EventConditions{ + FsEvents: []string{"mkdir"}, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action.Name, + }, + Order: 1, + }, + }, + } backupData := dataprovider.BackupData{} backupData.Users = append(backupData.Users, user) backupData.Groups = append(backupData.Groups, group) backupData.Admins = append(backupData.Admins, admin) + backupData.EventActions = append(backupData.EventActions, action) + backupData.EventRules = append(backupData.EventRules, rule) backupData.Folders = []vfs.BaseVirtualFolder{ { Name: folderName, @@ -5681,6 +6468,20 @@ func TestLoaddataMode(t *testing.T) { err = dataprovider.UpdateShare(&share, "", "") assert.NoError(t, err) + action, _, err = httpdtest.GetEventActionByName(action.Name, http.StatusOK) + assert.NoError(t, err) + oldActionDesc := action.Description + action.Description = "new action description" + action, _, err = httpdtest.UpdateEventAction(action, http.StatusOK) + assert.NoError(t, err) + + rule, _, err = httpdtest.GetEventRuleByName(rule.Name, http.StatusOK) + assert.NoError(t, err) + oldRuleDesc := rule.Description + rule.Description = "new rule description" + rule, _, err = httpdtest.UpdateEventRule(rule, http.StatusOK) + assert.NoError(t, err) + backupData.Folders = []vfs.BaseVirtualFolder{ { MappedPath: mappedPath, @@ -5699,6 +6500,12 @@ func TestLoaddataMode(t *testing.T) { assert.Equal(t, 456, folder.UsedQuotaFiles) assert.Equal(t, int64(789), folder.LastQuotaUpdate) assert.Len(t, folder.Users, 0) + action, _, err = httpdtest.GetEventActionByName(action.Name, http.StatusOK) + assert.NoError(t, err) + assert.NotEqual(t, oldActionDesc, action.Description) + rule, _, err = httpdtest.GetEventRuleByName(rule.Name, http.StatusOK) + assert.NoError(t, err) + assert.NotEqual(t, oldRuleDesc, rule.Description) c := common.NewBaseConnection("connID", common.ProtocolFTP, "", "", user) fakeConn := &fakeConnection{ @@ -5744,6 +6551,10 @@ func TestLoaddataMode(t *testing.T) { assert.NoError(t, err) _, err = httpdtest.RemoveAPIKey(apiKey, http.StatusOK) assert.NoError(t, err) + _, err = httpdtest.RemoveEventRule(rule, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action, http.StatusOK) + assert.NoError(t, err) err = os.Remove(backupFilePath) assert.NoError(t, err) } @@ -5909,6 +6720,99 @@ func TestAddFolderInvalidJsonMock(t *testing.T) { checkResponseCode(t, http.StatusBadRequest, rr) } +func TestAddEventRuleInvalidJsonMock(t *testing.T) { + token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) + assert.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, eventActionsPath, bytes.NewBuffer([]byte("invalid json"))) + require.NoError(t, err) + setBearerForReq(req, token) + rr := executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + req, err = http.NewRequest(http.MethodPost, eventRulesPath, bytes.NewBuffer([]byte("invalid json"))) + require.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) +} + +func TestEventRuleErrorsMock(t *testing.T) { + token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) + assert.NoError(t, err) + reqBody := bytes.NewBuffer([]byte("invalid json body")) + + req, err := http.NewRequest(http.MethodGet, eventActionsPath+"?limit=a", nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr := executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + req, err = http.NewRequest(http.MethodGet, eventRulesPath+"?limit=a", nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + a := dataprovider.BaseEventAction{ + Name: "action name", + Description: "test description", + Type: dataprovider.ActionTypeBackup, + Options: dataprovider.BaseEventActionOptions{}, + } + action, _, err := httpdtest.AddEventAction(a, http.StatusCreated) + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodPut, path.Join(eventActionsPath, action.Name), reqBody) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + r := dataprovider.EventRule{ + Name: "test event rule", + Trigger: dataprovider.EventTriggerSchedule, + Conditions: dataprovider.EventConditions{ + Schedules: []dataprovider.Schedule{ + { + Hours: "2", + DayOfWeek: "*", + DayOfMonth: "*", + Month: "*", + }, + }, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action.Name, + }, + Order: 1, + }, + }, + } + rule, _, err := httpdtest.AddEventRule(r, http.StatusCreated) + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodPut, path.Join(eventRulesPath, rule.Name), reqBody) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + rule.Actions[0].Name = "misssing action name" + asJSON, err := json.Marshal(rule) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPut, path.Join(eventRulesPath, rule.Name), bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + + _, err = httpdtest.RemoveEventRule(rule, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action, http.StatusOK) + assert.NoError(t, err) +} + func TestGroupErrorsMock(t *testing.T) { token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) @@ -8336,12 +9240,12 @@ func TestUpdateUserQuotaUsageMock(t *testing.T) { setBearerForReq(req, token) rr = executeRequest(req) checkResponseCode(t, http.StatusBadRequest, rr) - assert.True(t, common.QuotaScans.AddUserQuotaScan(user.Username)) + assert.True(t, dataprovider.QuotaScans.AddUserQuotaScan(user.Username)) req, _ = http.NewRequest(http.MethodPut, path.Join(quotasBasePath, "users", u.Username, "usage"), bytes.NewBuffer(userAsJSON)) setBearerForReq(req, token) rr = executeRequest(req) checkResponseCode(t, http.StatusConflict, rr) - assert.True(t, common.QuotaScans.RemoveUserQuotaScan(user.Username)) + assert.True(t, dataprovider.QuotaScans.RemoveUserQuotaScan(user.Username)) req, _ = http.NewRequest(http.MethodDelete, path.Join(userPath, user.Username), nil) setBearerForReq(req, token) rr = executeRequest(req) @@ -8613,12 +9517,12 @@ func TestStartQuotaScanMock(t *testing.T) { assert.NoError(t, err) } // simulate a duplicate quota scan - common.QuotaScans.AddUserQuotaScan(user.Username) + dataprovider.QuotaScans.AddUserQuotaScan(user.Username) req, _ = http.NewRequest(http.MethodPost, path.Join(quotasBasePath, "users", user.Username, "scan"), nil) setBearerForReq(req, token) rr = executeRequest(req) checkResponseCode(t, http.StatusConflict, rr) - assert.True(t, common.QuotaScans.RemoveUserQuotaScan(user.Username)) + assert.True(t, dataprovider.QuotaScans.RemoveUserQuotaScan(user.Username)) req, _ = http.NewRequest(http.MethodPost, path.Join(quotasBasePath, "users", user.Username, "scan"), nil) setBearerForReq(req, token) @@ -8732,13 +9636,13 @@ func TestUpdateFolderQuotaUsageMock(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusBadRequest, rr) - assert.True(t, common.QuotaScans.AddVFolderQuotaScan(folderName)) + assert.True(t, dataprovider.QuotaScans.AddVFolderQuotaScan(folderName)) req, _ = http.NewRequest(http.MethodPut, path.Join(quotasBasePath, "folders", folder.Name, "usage"), bytes.NewBuffer(folderAsJSON)) setBearerForReq(req, token) rr = executeRequest(req) checkResponseCode(t, http.StatusConflict, rr) - assert.True(t, common.QuotaScans.RemoveVFolderQuotaScan(folderName)) + assert.True(t, dataprovider.QuotaScans.RemoveVFolderQuotaScan(folderName)) req, _ = http.NewRequest(http.MethodDelete, path.Join(folderPath, folderName), nil) setBearerForReq(req, token) @@ -8767,12 +9671,12 @@ func TestStartFolderQuotaScanMock(t *testing.T) { assert.NoError(t, err) } // simulate a duplicate quota scan - common.QuotaScans.AddVFolderQuotaScan(folderName) + dataprovider.QuotaScans.AddVFolderQuotaScan(folderName) req, _ = http.NewRequest(http.MethodPost, path.Join(quotasBasePath, "folders", folder.Name, "scan"), nil) setBearerForReq(req, token) rr = executeRequest(req) checkResponseCode(t, http.StatusConflict, rr) - assert.True(t, common.QuotaScans.RemoveVFolderQuotaScan(folderName)) + assert.True(t, dataprovider.QuotaScans.RemoveVFolderQuotaScan(folderName)) // and now a real quota scan _, err = os.Stat(mappedPath) if err != nil && errors.Is(err, fs.ErrNotExist) { @@ -17411,6 +18315,516 @@ func TestWebUserSFTPFsMock(t *testing.T) { checkResponseCode(t, http.StatusOK, rr) } +func TestWebEventAction(t *testing.T) { + webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) + assert.NoError(t, err) + apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) + assert.NoError(t, err) + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) + assert.NoError(t, err) + action := dataprovider.BaseEventAction{ + ID: 81, + Name: "web_action_http", + Description: "http web action", + Type: dataprovider.ActionTypeHTTP, + Options: dataprovider.BaseEventActionOptions{ + HTTPConfig: dataprovider.EventActionHTTPConfig{ + Endpoint: "https://localhost:4567/action", + Username: defaultUsername, + Headers: []dataprovider.KeyValue{ + { + Key: "Content-Type", + Value: "application/json", + }, + }, + Password: kms.NewPlainSecret(defaultPassword), + Timeout: 10, + SkipTLSVerify: true, + Method: http.MethodPost, + QueryParameters: []dataprovider.KeyValue{ + { + Key: "param1", + Value: "value1", + }, + }, + Body: `{"event":"{{Event}}","name":"{{Name}}"}`, + }, + }, + } + form := make(url.Values) + form.Set("name", action.Name) + form.Set("description", action.Description) + form.Set("type", "a") + req, err := http.NewRequest(http.MethodPost, webAdminEventActionPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "invalid action type") + form.Set("type", fmt.Sprintf("%d", action.Type)) + form.Set("http_timeout", "b") + req, err = http.NewRequest(http.MethodPost, webAdminEventActionPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "invalid http timeout") + form.Set("cmd_timeout", "20") + form.Set("http_timeout", fmt.Sprintf("%d", action.Options.HTTPConfig.Timeout)) + form.Set("http_header_key0", action.Options.HTTPConfig.Headers[0].Key) + form.Set("http_header_val0", action.Options.HTTPConfig.Headers[0].Value) + form.Set("http_header_key1", action.Options.HTTPConfig.Headers[0].Key) // ignored + form.Set("http_query_key0", action.Options.HTTPConfig.QueryParameters[0].Key) + form.Set("http_query_val0", action.Options.HTTPConfig.QueryParameters[0].Value) + form.Set("http_body", action.Options.HTTPConfig.Body) + form.Set("http_skip_tls_verify", "1") + form.Set("http_username", action.Options.HTTPConfig.Username) + form.Set("http_password", action.Options.HTTPConfig.Password.GetPayload()) + form.Set("http_method", action.Options.HTTPConfig.Method) + req, err = http.NewRequest(http.MethodPost, webAdminEventActionPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "unable to verify form token") + form.Set(csrfFormToken, csrfToken) + req, err = http.NewRequest(http.MethodPost, webAdminEventActionPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "HTTP endpoint is required") + form.Set("http_endpoint", action.Options.HTTPConfig.Endpoint) + req, err = http.NewRequest(http.MethodPost, webAdminEventActionPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusSeeOther, rr) + // a new add will fail + req, err = http.NewRequest(http.MethodPost, webAdminEventActionPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // list actions + req, err = http.NewRequest(http.MethodGet, webAdminEventActionsPath+"?qlimit=a", nil) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // render add page + req, err = http.NewRequest(http.MethodGet, webAdminEventActionPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // render action page + req, err = http.NewRequest(http.MethodGet, path.Join(webAdminEventActionPath, action.Name), nil) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // missing action + req, err = http.NewRequest(http.MethodGet, path.Join(webAdminEventActionPath, action.Name+"1"), nil) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + // check the action + actionGet, _, err := httpdtest.GetEventActionByName(action.Name, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, action.Type, actionGet.Type) + assert.Equal(t, action.Description, actionGet.Description) + assert.Equal(t, action.Options.HTTPConfig.Body, actionGet.Options.HTTPConfig.Body) + assert.Equal(t, action.Options.HTTPConfig.Endpoint, actionGet.Options.HTTPConfig.Endpoint) + assert.Equal(t, action.Options.HTTPConfig.Headers, actionGet.Options.HTTPConfig.Headers) + assert.Equal(t, action.Options.HTTPConfig.Method, actionGet.Options.HTTPConfig.Method) + assert.Equal(t, action.Options.HTTPConfig.SkipTLSVerify, actionGet.Options.HTTPConfig.SkipTLSVerify) + assert.Equal(t, action.Options.HTTPConfig.Timeout, actionGet.Options.HTTPConfig.Timeout) + assert.Equal(t, action.Options.HTTPConfig.Username, actionGet.Options.HTTPConfig.Username) + assert.Equal(t, sdkkms.SecretStatusSecretBox, actionGet.Options.HTTPConfig.Password.GetStatus()) + assert.NotEmpty(t, actionGet.Options.HTTPConfig.Password.GetPayload()) + assert.Empty(t, actionGet.Options.HTTPConfig.Password.GetKey()) + assert.Empty(t, actionGet.Options.HTTPConfig.Password.GetAdditionalData()) + // update and check that the password is preserved + form.Set("http_password", redactedSecret) + req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, action.Name), + bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusSeeOther, rr) + dbAction, err := dataprovider.EventActionExists(action.Name) + assert.NoError(t, err) + err = dbAction.Options.HTTPConfig.Password.Decrypt() + assert.NoError(t, err) + assert.Equal(t, defaultPassword, dbAction.Options.HTTPConfig.Password.GetPayload()) + // change action type + action.Type = dataprovider.ActionTypeCommand + action.Options.CmdConfig = dataprovider.EventActionCommandConfig{ + Cmd: filepath.Join(os.TempDir(), "cmd"), + Timeout: 20, + EnvVars: []dataprovider.KeyValue{ + { + Key: "key", + Value: "val", + }, + }, + } + form.Set("type", fmt.Sprintf("%d", action.Type)) + req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, action.Name), + bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "command is required") + form.Set("cmd_path", action.Options.CmdConfig.Cmd) + form.Set("cmd_timeout", "a") + req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, action.Name), + bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "invalid command timeout") + form.Set("cmd_timeout", fmt.Sprintf("%d", action.Options.CmdConfig.Timeout)) + form.Set("cmd_env_key0", action.Options.CmdConfig.EnvVars[0].Key) + form.Set("cmd_env_val0", action.Options.CmdConfig.EnvVars[0].Value) + req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, action.Name), + bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusSeeOther, rr) + // update a missing action + req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, action.Name+"1"), + bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + // update with no csrf token + form.Del(csrfFormToken) + req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, action.Name), + bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "unable to verify form token") + form.Set(csrfFormToken, csrfToken) + // check the update + actionGet, _, err = httpdtest.GetEventActionByName(action.Name, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, action.Type, actionGet.Type) + assert.Equal(t, action.Options.CmdConfig.Cmd, actionGet.Options.CmdConfig.Cmd) + assert.Equal(t, action.Options.CmdConfig.Timeout, actionGet.Options.CmdConfig.Timeout) + assert.Equal(t, action.Options.CmdConfig.EnvVars, actionGet.Options.CmdConfig.EnvVars) + assert.Equal(t, dataprovider.EventActionHTTPConfig{}, actionGet.Options.HTTPConfig) + // change action type again + action.Type = dataprovider.ActionTypeEmail + action.Options.EmailConfig = dataprovider.EventActionEmailConfig{ + Recipients: []string{"address1@example.com", "address2@example.com"}, + Subject: "subject", + Body: "body", + } + form.Set("type", fmt.Sprintf("%d", action.Type)) + form.Set("email_recipients", "address1@example.com, address2@example.com") + form.Set("email_subject", action.Options.EmailConfig.Subject) + form.Set("email_body", action.Options.EmailConfig.Body) + req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, action.Name), + bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusSeeOther, rr) + // check the update + actionGet, _, err = httpdtest.GetEventActionByName(action.Name, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, action.Type, actionGet.Type) + assert.Equal(t, action.Options.EmailConfig.Recipients, actionGet.Options.EmailConfig.Recipients) + assert.Equal(t, action.Options.EmailConfig.Subject, actionGet.Options.EmailConfig.Subject) + assert.Equal(t, action.Options.EmailConfig.Body, actionGet.Options.EmailConfig.Body) + assert.Equal(t, dataprovider.EventActionHTTPConfig{}, actionGet.Options.HTTPConfig) + assert.Empty(t, actionGet.Options.CmdConfig.Cmd) + assert.Equal(t, 0, actionGet.Options.CmdConfig.Timeout) + assert.Len(t, actionGet.Options.CmdConfig.EnvVars, 0) + + req, err = http.NewRequest(http.MethodDelete, path.Join(webAdminEventActionPath, action.Name), nil) + assert.NoError(t, err) + setBearerForReq(req, apiToken) + setCSRFHeaderForReq(req, csrfToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusFound, rr) + assert.Equal(t, webLoginPath, rr.Header().Get("Location")) + + req, err = http.NewRequest(http.MethodDelete, path.Join(webAdminEventActionPath, action.Name), nil) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + setCSRFHeaderForReq(req, csrfToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) +} + +func TestWebEventRule(t *testing.T) { + webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) + assert.NoError(t, err) + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) + assert.NoError(t, err) + a := dataprovider.BaseEventAction{ + Name: "web_action", + Type: dataprovider.ActionTypeBackup, + } + action, _, err := httpdtest.AddEventAction(a, http.StatusCreated) + assert.NoError(t, err) + rule := dataprovider.EventRule{ + Name: "test_web_rule", + Description: "rule added using web API", + Trigger: dataprovider.EventTriggerSchedule, + Conditions: dataprovider.EventConditions{ + Schedules: []dataprovider.Schedule{ + { + Hours: "0", + DayOfWeek: "*", + DayOfMonth: "*", + Month: "*", + }, + }, + Options: dataprovider.ConditionOptions{ + Names: []dataprovider.ConditionPattern{ + { + Pattern: "u*", + InverseMatch: true, + }, + }, + }, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action.Name, + }, + Order: 1, + }, + }, + } + form := make(url.Values) + form.Set("name", rule.Name) + form.Set("description", rule.Description) + form.Set("trigger", "a") + req, err := http.NewRequest(http.MethodPost, webAdminEventRulePath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "invalid trigger") + form.Set("trigger", fmt.Sprintf("%d", rule.Trigger)) + form.Set("schedule_hour0", rule.Conditions.Schedules[0].Hours) + form.Set("schedule_day_of_week0", rule.Conditions.Schedules[0].DayOfWeek) + form.Set("schedule_day_of_month0", rule.Conditions.Schedules[0].DayOfMonth) + form.Set("schedule_month0", rule.Conditions.Schedules[0].Month) + form.Set("name_pattern0", rule.Conditions.Options.Names[0].Pattern) + form.Set("type_name_pattern0", "inverse") + req, err = http.NewRequest(http.MethodPost, webAdminEventRulePath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "invalid min file size") + form.Set("fs_min_size", "0") + req, err = http.NewRequest(http.MethodPost, webAdminEventRulePath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "invalid max file size") + form.Set("fs_max_size", "0") + form.Set("action_name0", action.Name) + form.Set("action_order0", "a") + req, err = http.NewRequest(http.MethodPost, webAdminEventRulePath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "invalid order") + form.Set("action_order0", "0") + req, err = http.NewRequest(http.MethodPost, webAdminEventRulePath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "unable to verify form token") + form.Set(csrfFormToken, csrfToken) + req, err = http.NewRequest(http.MethodPost, webAdminEventRulePath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusSeeOther, rr) + // a new add will fail + req, err = http.NewRequest(http.MethodPost, webAdminEventRulePath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // list rules + req, err = http.NewRequest(http.MethodGet, webAdminEventRulesPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // render add page + req, err = http.NewRequest(http.MethodGet, webAdminEventRulePath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // render rule page + req, err = http.NewRequest(http.MethodGet, path.Join(webAdminEventRulePath, rule.Name), nil) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // missing rule + req, err = http.NewRequest(http.MethodGet, path.Join(webAdminEventRulePath, rule.Name+"1"), nil) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + // check the rule + ruleGet, _, err := httpdtest.GetEventRuleByName(rule.Name, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, rule.Trigger, ruleGet.Trigger) + assert.Equal(t, rule.Description, ruleGet.Description) + assert.Equal(t, rule.Conditions, ruleGet.Conditions) + if assert.Len(t, ruleGet.Actions, 1) { + assert.Equal(t, rule.Actions[0].Name, ruleGet.Actions[0].Name) + assert.Equal(t, rule.Actions[0].Order, ruleGet.Actions[0].Order) + } + // change rule trigger + rule.Trigger = dataprovider.EventTriggerFsEvent + rule.Conditions = dataprovider.EventConditions{ + FsEvents: []string{"upload", "download"}, + Options: dataprovider.ConditionOptions{ + Names: []dataprovider.ConditionPattern{ + { + Pattern: "u*", + InverseMatch: true, + }, + }, + FsPaths: []dataprovider.ConditionPattern{ + { + Pattern: "/subdir/*.txt", + }, + }, + Protocols: []string{common.ProtocolSFTP, common.ProtocolHTTP}, + MinFileSize: 1024 * 1024, + MaxFileSize: 5 * 1024 * 1024, + }, + } + form.Set("trigger", fmt.Sprintf("%d", rule.Trigger)) + for _, event := range rule.Conditions.FsEvents { + form.Add("fs_events", event) + } + form.Set("fs_path_pattern0", rule.Conditions.Options.FsPaths[0].Pattern) + for _, protocol := range rule.Conditions.Options.Protocols { + form.Add("fs_protocols", protocol) + } + form.Set("fs_min_size", fmt.Sprintf("%d", rule.Conditions.Options.MinFileSize)) + form.Set("fs_max_size", fmt.Sprintf("%d", rule.Conditions.Options.MaxFileSize)) + req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventRulePath, rule.Name), + bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusSeeOther, rr) + // check the rule + ruleGet, _, err = httpdtest.GetEventRuleByName(rule.Name, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, rule.Trigger, ruleGet.Trigger) + assert.Equal(t, rule.Description, ruleGet.Description) + assert.Equal(t, rule.Conditions, ruleGet.Conditions) + if assert.Len(t, ruleGet.Actions, 1) { + assert.Equal(t, rule.Actions[0].Name, ruleGet.Actions[0].Name) + assert.Equal(t, rule.Actions[0].Order, ruleGet.Actions[0].Order) + } + // update a missing rule + req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventRulePath, rule.Name+"1"), + bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + // update with no csrf token + form.Del(csrfFormToken) + req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventRulePath, rule.Name), + bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "unable to verify form token") + form.Set(csrfFormToken, csrfToken) + // update with no action defined + form.Del("action_name0") + form.Del("action_order0") + req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventRulePath, rule.Name), + bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "at least one action is required") + // invalid trigger + form.Set("trigger", "a") + req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventRulePath, rule.Name), + bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "invalid trigger") + + req, err = http.NewRequest(http.MethodDelete, path.Join(webAdminEventRulePath, rule.Name), nil) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + setCSRFHeaderForReq(req, csrfToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, err = http.NewRequest(http.MethodDelete, path.Join(webAdminEventActionPath, action.Name), nil) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + setCSRFHeaderForReq(req, csrfToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) +} + func TestAddWebGroup(t *testing.T) { webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) @@ -17571,7 +18985,6 @@ func TestAddWebGroup(t *testing.T) { req, err = http.NewRequest(http.MethodGet, path.Join(webGroupPath, group.Name), nil) assert.NoError(t, err) - req.Header.Set("Content-Type", contentType) setJWTCookieForReq(req, webToken) rr = executeRequest(req) checkResponseCode(t, http.StatusNotFound, rr) @@ -18854,7 +20267,7 @@ func startOIDCMockServer() { func waitForUsersQuotaScan(t *testing.T, token string) { for { - var scans []common.ActiveQuotaScan + var scans []dataprovider.ActiveQuotaScan req, _ := http.NewRequest(http.MethodGet, quotaScanPath, nil) setBearerForReq(req, token) rr := executeRequest(req) @@ -18872,7 +20285,7 @@ func waitForUsersQuotaScan(t *testing.T, token string) { } func waitForFoldersQuotaScanPath(t *testing.T, token string) { - var scans []common.ActiveVirtualFolderQuotaScan + var scans []dataprovider.ActiveVirtualFolderQuotaScan for { req, _ := http.NewRequest(http.MethodGet, quotaScanVFolderPath, nil) setBearerForReq(req, token) diff --git a/httpd/internal_test.go b/httpd/internal_test.go index 09d45c3b..4a1d0e39 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -611,6 +611,36 @@ func TestInvalidToken(t *testing.T) { assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Contains(t, rr.Body.String(), "Invalid token claims") + rr = httptest.NewRecorder() + addEventAction(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid token claims") + + rr = httptest.NewRecorder() + updateEventAction(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid token claims") + + rr = httptest.NewRecorder() + deleteEventAction(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid token claims") + + rr = httptest.NewRecorder() + addEventRule(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid token claims") + + rr = httptest.NewRecorder() + updateEventRule(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid token claims") + + rr = httptest.NewRecorder() + deleteEventRule(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid token claims") + rr = httptest.NewRecorder() server.handleWebAddAdminPost(rr, req) assert.Equal(t, http.StatusBadRequest, rr.Code) @@ -626,6 +656,26 @@ func TestInvalidToken(t *testing.T) { assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Contains(t, rr.Body.String(), "invalid token claims") + rr = httptest.NewRecorder() + server.handleWebAddEventActionPost(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "invalid token claims") + + rr = httptest.NewRecorder() + server.handleWebUpdateEventActionPost(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "invalid token claims") + + rr = httptest.NewRecorder() + server.handleWebAddEventRulePost(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "invalid token claims") + + rr = httptest.NewRecorder() + server.handleWebUpdateEventRulePost(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "invalid token claims") + rr = httptest.NewRecorder() server.handleWebClientTwoFactorRecoveryPost(rr, req) assert.Equal(t, http.StatusNotFound, rr.Code) @@ -836,6 +886,7 @@ func TestCreateTokenError(t *testing.T) { rr = httptest.NewRecorder() server.handleWebAdminLoginPost(rr, req) assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + req, _ = http.NewRequest(http.MethodGet, webAdminLoginPath+"?a=a%C3%A1%G2", nil) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = httptest.NewRecorder() @@ -848,6 +899,16 @@ func TestCreateTokenError(t *testing.T) { _, err := getAdminFromPostFields(req) assert.Error(t, err) + req, _ = http.NewRequest(http.MethodPost, webAdminEventActionPath+"?a=a%C3%AO%GG", nil) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + _, err = getEventActionFromPostFields(req) + assert.Error(t, err) + + req, _ = http.NewRequest(http.MethodPost, webAdminEventRulePath+"?a=a%C3%AO%GG", nil) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + _, err = getEventRuleFromPostFields(req) + assert.Error(t, err) + req, _ = http.NewRequest(http.MethodPost, webClientLoginPath+"?a=a%C3%AO%GG", bytes.NewBuffer([]byte(form.Encode()))) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = httptest.NewRecorder() @@ -1330,7 +1391,7 @@ func TestQuotaScanInvalidFs(t *testing.T) { Provider: sdk.S3FilesystemProvider, }, } - common.QuotaScans.AddUserQuotaScan(user.Username) + dataprovider.QuotaScans.AddUserQuotaScan(user.Username) err := doUserQuotaScan(user) assert.Error(t, err) } diff --git a/httpd/server.go b/httpd/server.go index 328c42fb..228d3ad9 100644 --- a/httpd/server.go +++ b/httpd/server.go @@ -44,6 +44,7 @@ type httpdServer struct { enableWebAdmin bool enableWebClient bool renderOpenAPI bool + isShared int router *chi.Mux tokenAuth *jwtauth.JWTAuth signingPassphrase string @@ -68,6 +69,10 @@ func newHttpdServer(b Binding, staticFilesPath, signingPassphrase string, cors C } } +func (s *httpdServer) setShared(value int) { + s.isShared = value +} + func (s *httpdServer) listenAndServe() error { s.initializeRouter() httpServer := &http.Server{ @@ -1259,6 +1264,16 @@ func (s *httpdServer) initializeRouter() { Put(apiKeysPath+"/{id}", updateAPIKey) router.With(forbidAPIKeyAuthentication, s.checkPerm(dataprovider.PermAdminManageAPIKeys)). Delete(apiKeysPath+"/{id}", deleteAPIKey) + router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Get(eventActionsPath, getEventActions) + router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Get(eventActionsPath+"/{name}", getEventActionByName) + router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Post(eventActionsPath, addEventAction) + router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Put(eventActionsPath+"/{name}", updateEventAction) + router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Delete(eventActionsPath+"/{name}", deleteEventAction) + router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Get(eventRulesPath, getEventRules) + router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Get(eventRulesPath+"/{name}", getEventRuleByName) + 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) }) s.router.Get(userTokenPath, s.getUserToken) @@ -1504,7 +1519,8 @@ func (s *httpdServer) setupWebAdminRoutes() { router.With(verifyCSRFHeader, s.requireBuiltinLogin).Post(webAdminTOTPGeneratePath, generateTOTPSecret) router.With(verifyCSRFHeader, s.requireBuiltinLogin).Post(webAdminTOTPValidatePath, validateTOTPPasscode) router.With(verifyCSRFHeader, s.requireBuiltinLogin).Post(webAdminTOTPSavePath, saveTOTPConfig) - router.With(verifyCSRFHeader, s.requireBuiltinLogin, s.refreshCookie).Get(webAdminRecoveryCodesPath, getRecoveryCodes) + router.With(verifyCSRFHeader, s.requireBuiltinLogin, s.refreshCookie).Get(webAdminRecoveryCodesPath, + getRecoveryCodes) router.With(verifyCSRFHeader, s.requireBuiltinLogin).Post(webAdminRecoveryCodesPath, generateRecoveryCodes) router.With(s.checkPerm(dataprovider.PermAdminViewUsers), s.refreshCookie). @@ -1574,6 +1590,30 @@ func (s *httpdServer) setupWebAdminRoutes() { router.With(s.checkPerm(dataprovider.PermAdminViewDefender)).Get(webDefenderHostsPath, getDefenderHosts) router.With(s.checkPerm(dataprovider.PermAdminManageDefender)).Delete(webDefenderHostsPath+"/{id}", deleteDefenderHostByID) + router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), s.refreshCookie). + Get(webAdminEventActionsPath, s.handleWebGetEventActions) + router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), s.refreshCookie). + Get(webAdminEventActionPath, s.handleWebAddEventActionGet) + router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Post(webAdminEventActionPath, + s.handleWebAddEventActionPost) + router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), s.refreshCookie). + Get(webAdminEventActionPath+"/{name}", s.handleWebUpdateEventActionGet) + router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Post(webAdminEventActionPath+"/{name}", + s.handleWebUpdateEventActionPost) + router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), verifyCSRFHeader). + Delete(webAdminEventActionPath+"/{name}", deleteEventAction) + router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), s.refreshCookie). + Get(webAdminEventRulesPath, s.handleWebGetEventRules) + router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), s.refreshCookie). + Get(webAdminEventRulePath, s.handleWebAddEventRuleGet) + router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Post(webAdminEventRulePath, + s.handleWebAddEventRulePost) + router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), s.refreshCookie). + Get(webAdminEventRulePath+"/{name}", s.handleWebUpdateEventRuleGet) + router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Post(webAdminEventRulePath+"/{name}", + s.handleWebUpdateEventRulePost) + router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), verifyCSRFHeader). + Delete(webAdminEventRulePath+"/{name}", deleteEventRule) }) } } diff --git a/httpd/webadmin.go b/httpd/webadmin.go index f3ed8258..1a3fe389 100644 --- a/httpd/webadmin.go +++ b/httpd/webadmin.go @@ -43,11 +43,11 @@ const ( folderPageModeTemplate ) -type groupPageMode int +type genericPageMode int const ( - groupPageModeAdd groupPageMode = iota + 1 - groupPageModeUpdate + genericPageModeAdd genericPageMode = iota + 1 + genericPageModeUpdate ) const ( @@ -65,6 +65,10 @@ const ( templateGroup = "group.html" templateFolders = "folders.html" templateFolder = "folder.html" + templateEventRules = "eventrules.html" + templateEventRule = "eventrule.html" + templateEventActions = "eventactions.html" + templateEventAction = "eventaction.html" templateMessage = "message.html" templateStatus = "status.html" templateLogin = "login.html" @@ -80,6 +84,8 @@ const ( pageStatusTitle = "Status" pageFoldersTitle = "Folders" pageGroupsTitle = "Groups" + pageEventRulesTitle = "Event rules" + pageEventActionsTitle = "Event actions" pageProfileTitle = "My profile" pageChangePwdTitle = "Change password" pageMaintenanceTitle = "Maintenance" @@ -114,6 +120,10 @@ type basePage struct { ProfileURL string ChangePwdURL string MFAURL string + EventRulesURL string + EventRuleURL string + EventActionsURL string + EventActionURL string FolderQuotaScanURL string StatusURL string MaintenanceURL string @@ -123,11 +133,14 @@ type basePage struct { ConnectionsTitle string FoldersTitle string GroupsTitle string + EventRulesTitle string + EventActionsTitle string StatusTitle string MaintenanceTitle string DefenderTitle string Version string CSRFToken string + IsEventManagerPage bool HasDefender bool HasExternalLogin bool LoggedAdmin *dataprovider.Admin @@ -154,6 +167,16 @@ type groupsPage struct { Groups []dataprovider.Group } +type eventRulesPage struct { + basePage + Rules []dataprovider.EventRule +} + +type eventActionsPage struct { + basePage + Actions []dataprovider.BaseEventAction +} + type connectionsPage struct { basePage Connections []common.ConnectionStatus @@ -183,7 +206,6 @@ type userPage struct { TwoFactorProtocols []string WebClientOptions []string RootDirPerms []string - RedactedSecret string Mode userPageMode VirtualFolders []vfs.BaseVirtualFolder Groups []dataprovider.Group @@ -253,7 +275,7 @@ type groupPage struct { basePage Group *dataprovider.Group Error string - Mode groupPageMode + Mode genericPageMode ValidPerms []string ValidLoginMethods []string ValidProtocols []string @@ -263,6 +285,30 @@ type groupPage struct { FsWrapper fsWrapper } +type eventActionPage struct { + basePage + Action dataprovider.BaseEventAction + ActionTypes []dataprovider.EnumMapping + HTTPMethods []string + RedactedSecret string + Error string + Mode genericPageMode +} + +type eventRulePage struct { + basePage + Rule dataprovider.EventRule + TriggerTypes []dataprovider.EnumMapping + Actions []dataprovider.BaseEventAction + FsEvents []string + Protocols []string + ProviderEvents []string + ProviderObjects []string + Error string + Mode genericPageMode + IsShared bool +} + type messagePage struct { basePage Error string @@ -341,6 +387,26 @@ func loadAdminTemplates(templatesPath string) { filepath.Join(templatesPath, templateAdminDir, templateSharedComponents), filepath.Join(templatesPath, templateAdminDir, templateGroup), } + eventRulesPaths := []string{ + filepath.Join(templatesPath, templateCommonDir, templateCommonCSS), + filepath.Join(templatesPath, templateAdminDir, templateBase), + filepath.Join(templatesPath, templateAdminDir, templateEventRules), + } + eventRulePaths := []string{ + filepath.Join(templatesPath, templateCommonDir, templateCommonCSS), + filepath.Join(templatesPath, templateAdminDir, templateBase), + filepath.Join(templatesPath, templateAdminDir, templateEventRule), + } + eventActionsPaths := []string{ + filepath.Join(templatesPath, templateCommonDir, templateCommonCSS), + filepath.Join(templatesPath, templateAdminDir, templateBase), + filepath.Join(templatesPath, templateAdminDir, templateEventActions), + } + eventActionPaths := []string{ + filepath.Join(templatesPath, templateCommonDir, templateCommonCSS), + filepath.Join(templatesPath, templateAdminDir, templateBase), + filepath.Join(templatesPath, templateAdminDir, templateEventAction), + } statusPaths := []string{ filepath.Join(templatesPath, templateCommonDir, templateCommonCSS), filepath.Join(templatesPath, templateAdminDir, templateBase), @@ -408,6 +474,10 @@ func loadAdminTemplates(templatesPath string) { groupTmpl := util.LoadTemplate(fsBaseTpl, groupPaths...) foldersTmpl := util.LoadTemplate(nil, foldersPaths...) folderTmpl := util.LoadTemplate(fsBaseTpl, folderPaths...) + eventRulesTmpl := util.LoadTemplate(nil, eventRulesPaths...) + eventRuleTmpl := util.LoadTemplate(nil, eventRulePaths...) + eventActionsTmpl := util.LoadTemplate(nil, eventActionsPaths...) + eventActionTmpl := util.LoadTemplate(nil, eventActionPaths...) statusTmpl := util.LoadTemplate(nil, statusPaths...) loginTmpl := util.LoadTemplate(nil, loginPaths...) profileTmpl := util.LoadTemplate(nil, profilePaths...) @@ -431,6 +501,10 @@ func loadAdminTemplates(templatesPath string) { adminTemplates[templateGroup] = groupTmpl adminTemplates[templateFolders] = foldersTmpl adminTemplates[templateFolder] = folderTmpl + adminTemplates[templateEventRules] = eventRulesTmpl + adminTemplates[templateEventRule] = eventRuleTmpl + adminTemplates[templateEventActions] = eventActionsTmpl + adminTemplates[templateEventAction] = eventActionTmpl adminTemplates[templateStatus] = statusTmpl adminTemplates[templateLogin] = loginTmpl adminTemplates[templateProfile] = profileTmpl @@ -445,6 +519,22 @@ func loadAdminTemplates(templatesPath string) { adminTemplates[templateResetPassword] = resetPwdTmpl } +func isEventManagerResource(currentURL string) bool { + if currentURL == webAdminEventRulesPath { + return true + } + if currentURL == webAdminEventActionsPath { + return true + } + if currentURL == webAdminEventRulePath || strings.HasPrefix(currentURL, webAdminEventRulePath+"/") { + return true + } + if currentURL == webAdminEventActionPath || strings.HasPrefix(currentURL, webAdminEventActionPath+"/") { + return true + } + return false +} + func (s *httpdServer) getBasePageData(title, currentURL string, r *http.Request) basePage { var csrfToken string if currentURL != "" { @@ -468,6 +558,10 @@ func (s *httpdServer) getBasePageData(title, currentURL string, r *http.Request) ProfileURL: webAdminProfilePath, ChangePwdURL: webChangeAdminPwdPath, MFAURL: webAdminMFAPath, + EventRulesURL: webAdminEventRulesPath, + EventRuleURL: webAdminEventRulePath, + EventActionsURL: webAdminEventActionsPath, + EventActionURL: webAdminEventActionPath, QuotaScanURL: webQuotaScanPath, ConnectionsURL: webConnectionsPath, StatusURL: webStatusPath, @@ -479,11 +573,14 @@ func (s *httpdServer) getBasePageData(title, currentURL string, r *http.Request) ConnectionsTitle: pageConnectionsTitle, FoldersTitle: pageFoldersTitle, GroupsTitle: pageGroupsTitle, + EventRulesTitle: pageEventRulesTitle, + EventActionsTitle: pageEventActionsTitle, StatusTitle: pageStatusTitle, MaintenanceTitle: pageMaintenanceTitle, DefenderTitle: pageDefenderTitle, Version: version.GetAsString(), LoggedAdmin: getAdminFromToken(r), + IsEventManagerPage: isEventManagerResource(currentURL), HasDefender: common.Config.DefenderConfig.Enabled, HasExternalLogin: isLoggedInWithOIDC(r), CSRFToken: csrfToken, @@ -721,7 +818,7 @@ func (s *httpdServer) renderUserPage(w http.ResponseWriter, r *http.Request, use } func (s *httpdServer) renderGroupPage(w http.ResponseWriter, r *http.Request, group dataprovider.Group, - mode groupPageMode, error string, + mode genericPageMode, error string, ) { folders, err := s.getWebVirtualFolders(w, r, defaultQueryLimit, true) if err != nil { @@ -731,10 +828,10 @@ func (s *httpdServer) renderGroupPage(w http.ResponseWriter, r *http.Request, gr group.UserSettings.FsConfig.RedactedSecret = redactedSecret var title, currentURL string switch mode { - case groupPageModeAdd: + case genericPageModeAdd: title = "Add a new group" currentURL = webGroupPath - case groupPageModeUpdate: + case genericPageModeUpdate: title = "Update group" currentURL = fmt.Sprintf("%v/%v", webGroupPath, url.PathEscape(group.Name)) } @@ -763,6 +860,71 @@ func (s *httpdServer) renderGroupPage(w http.ResponseWriter, r *http.Request, gr renderAdminTemplate(w, templateGroup, data) } +func (s *httpdServer) renderEventActionPage(w http.ResponseWriter, r *http.Request, action dataprovider.BaseEventAction, + mode genericPageMode, error string, +) { + action.Options.SetEmptySecretsIfNil() + var title, currentURL string + switch mode { + case genericPageModeAdd: + title = "Add a new event action" + currentURL = webAdminEventActionPath + case genericPageModeUpdate: + title = "Update event action" + currentURL = fmt.Sprintf("%v/%v", webAdminEventActionPath, url.PathEscape(action.Name)) + } + if action.Options.HTTPConfig.Timeout == 0 { + action.Options.HTTPConfig.Timeout = 20 + } + if action.Options.CmdConfig.Timeout == 0 { + action.Options.CmdConfig.Timeout = 20 + } + + data := eventActionPage{ + basePage: s.getBasePageData(title, currentURL, r), + Action: action, + ActionTypes: dataprovider.EventActionTypes, + HTTPMethods: dataprovider.SupportedHTTPActionMethods, + RedactedSecret: redactedSecret, + Error: error, + Mode: mode, + } + renderAdminTemplate(w, templateEventAction, data) +} + +func (s *httpdServer) renderEventRulePage(w http.ResponseWriter, r *http.Request, rule dataprovider.EventRule, + mode genericPageMode, error string, +) { + actions, err := s.getWebEventActions(w, r, defaultQueryLimit, true) + if err != nil { + return + } + var title, currentURL string + switch mode { + case genericPageModeAdd: + title = "Add new event rules" + currentURL = webAdminEventRulePath + case genericPageModeUpdate: + title = "Update event rules" + currentURL = fmt.Sprintf("%v/%v", webAdminEventRulePath, url.PathEscape(rule.Name)) + } + + data := eventRulePage{ + basePage: s.getBasePageData(title, currentURL, r), + Rule: rule, + TriggerTypes: dataprovider.EventTriggerTypes, + Actions: actions, + FsEvents: dataprovider.SupportedFsEvents, + Protocols: dataprovider.SupportedRuleConditionProtocols, + ProviderEvents: dataprovider.SupportedProviderEvents, + ProviderObjects: dataprovider.SupporteRuleConditionProviderObjects, + Error: error, + Mode: mode, + IsShared: s.isShared > 0, + } + renderAdminTemplate(w, templateEventRule, data) +} + func (s *httpdServer) renderFolderPage(w http.ResponseWriter, r *http.Request, folder vfs.BaseVirtualFolder, mode folderPageMode, error string, ) { @@ -1630,6 +1792,204 @@ func getGroupFromPostFields(r *http.Request) (dataprovider.Group, error) { return group, nil } +func getKeyValsFromPostFields(r *http.Request, key, val string) []dataprovider.KeyValue { + var res []dataprovider.KeyValue + for k := range r.Form { + if strings.HasPrefix(k, key) { + formKey := r.Form.Get(k) + idx := strings.TrimPrefix(k, key) + formVal := r.Form.Get(fmt.Sprintf("%s%s", val, idx)) + if formKey != "" && formVal != "" { + res = append(res, dataprovider.KeyValue{ + Key: formKey, + Value: formVal, + }) + } + } + } + return res +} + +func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEventActionOptions, error) { + httpTimeout, err := strconv.Atoi(r.Form.Get("http_timeout")) + if err != nil { + return dataprovider.BaseEventActionOptions{}, fmt.Errorf("invalid http timeout: %w", err) + } + cmdTimeout, err := strconv.Atoi(r.Form.Get("cmd_timeout")) + if err != nil { + return dataprovider.BaseEventActionOptions{}, fmt.Errorf("invalid command timeout: %w", err) + } + options := dataprovider.BaseEventActionOptions{ + HTTPConfig: dataprovider.EventActionHTTPConfig{ + Endpoint: r.Form.Get("http_endpoint"), + Username: r.Form.Get("http_username"), + Password: getSecretFromFormField(r, "http_password"), + Headers: getKeyValsFromPostFields(r, "http_header_key", "http_header_val"), + Timeout: httpTimeout, + SkipTLSVerify: r.Form.Get("http_skip_tls_verify") != "", + Method: r.Form.Get("http_method"), + QueryParameters: getKeyValsFromPostFields(r, "http_query_key", "http_query_val"), + Body: r.Form.Get("http_body"), + }, + CmdConfig: dataprovider.EventActionCommandConfig{ + Cmd: r.Form.Get("cmd_path"), + Timeout: cmdTimeout, + EnvVars: getKeyValsFromPostFields(r, "cmd_env_key", "cmd_env_val"), + }, + EmailConfig: dataprovider.EventActionEmailConfig{ + Recipients: strings.Split(strings.ReplaceAll(r.Form.Get("email_recipients"), " ", ""), ","), + Subject: r.Form.Get("email_subject"), + Body: r.Form.Get("email_body"), + }, + } + return options, nil +} + +func getEventActionFromPostFields(r *http.Request) (dataprovider.BaseEventAction, error) { + err := r.ParseForm() + if err != nil { + return dataprovider.BaseEventAction{}, err + } + actionType, err := strconv.Atoi(r.Form.Get("type")) + if err != nil { + return dataprovider.BaseEventAction{}, fmt.Errorf("invalid action type: %w", err) + } + options, err := getEventActionOptionsFromPostFields(r) + if err != nil { + return dataprovider.BaseEventAction{}, err + } + action := dataprovider.BaseEventAction{ + Name: r.Form.Get("name"), + Description: r.Form.Get("description"), + Type: actionType, + Options: options, + } + return action, nil +} + +func getEventRuleConditionsFromPostFields(r *http.Request) (dataprovider.EventConditions, error) { + var schedules []dataprovider.Schedule + var names, fsPaths []dataprovider.ConditionPattern + for k := range r.Form { + if strings.HasPrefix(k, "schedule_hour") { + hour := r.Form.Get(k) + if hour != "" { + idx := strings.TrimPrefix(k, "schedule_hour") + dayOfWeek := r.Form.Get(fmt.Sprintf("schedule_day_of_week%s", idx)) + dayOfMonth := r.Form.Get(fmt.Sprintf("schedule_day_of_month%s", idx)) + month := r.Form.Get(fmt.Sprintf("schedule_month%s", idx)) + schedules = append(schedules, dataprovider.Schedule{ + Hours: hour, + DayOfWeek: dayOfWeek, + DayOfMonth: dayOfMonth, + Month: month, + }) + } + } + if strings.HasPrefix(k, "name_pattern") { + pattern := r.Form.Get(k) + if pattern != "" { + idx := strings.TrimPrefix(k, "name_pattern") + patternType := r.Form.Get(fmt.Sprintf("type_name_pattern%s", idx)) + names = append(names, dataprovider.ConditionPattern{ + Pattern: pattern, + InverseMatch: patternType == "inverse", + }) + } + } + if strings.HasPrefix(k, "fs_path_pattern") { + pattern := r.Form.Get(k) + if pattern != "" { + idx := strings.TrimPrefix(k, "fs_path_pattern") + patternType := r.Form.Get(fmt.Sprintf("type_fs_path_pattern%s", idx)) + fsPaths = append(fsPaths, dataprovider.ConditionPattern{ + Pattern: pattern, + InverseMatch: patternType == "inverse", + }) + } + } + } + minFileSize, err := strconv.ParseInt(r.Form.Get("fs_min_size"), 10, 64) + if err != nil { + return dataprovider.EventConditions{}, fmt.Errorf("invalid min file size: %w", err) + } + maxFileSize, err := strconv.ParseInt(r.Form.Get("fs_max_size"), 10, 64) + if err != nil { + return dataprovider.EventConditions{}, fmt.Errorf("invalid max file size: %w", err) + } + conditions := dataprovider.EventConditions{ + FsEvents: r.Form["fs_events"], + ProviderEvents: r.Form["provider_events"], + Schedules: schedules, + Options: dataprovider.ConditionOptions{ + Names: names, + FsPaths: fsPaths, + Protocols: r.Form["fs_protocols"], + ProviderObjects: r.Form["provider_objects"], + MinFileSize: minFileSize, + MaxFileSize: maxFileSize, + ConcurrentExecution: r.Form.Get("concurrent_execution") != "", + }, + } + return conditions, nil +} + +func getEventRuleActionsFromPostFields(r *http.Request) ([]dataprovider.EventAction, error) { + var actions []dataprovider.EventAction + for k := range r.Form { + if strings.HasPrefix(k, "action_name") { + name := r.Form.Get(k) + if name != "" { + idx := strings.TrimPrefix(k, "action_name") + order, err := strconv.Atoi(r.Form.Get(fmt.Sprintf("action_order%s", idx))) + if err != nil { + return actions, fmt.Errorf("invalid order: %w", err) + } + options := r.Form[fmt.Sprintf("action_options%s", idx)] + actions = append(actions, dataprovider.EventAction{ + BaseEventAction: dataprovider.BaseEventAction{ + Name: name, + }, + Order: order + 1, + Options: dataprovider.EventActionOptions{ + IsFailureAction: util.Contains(options, "1"), + StopOnFailure: util.Contains(options, "2"), + ExecuteSync: util.Contains(options, "3"), + }, + }) + } + } + } + return actions, nil +} + +func getEventRuleFromPostFields(r *http.Request) (dataprovider.EventRule, error) { + err := r.ParseForm() + if err != nil { + return dataprovider.EventRule{}, err + } + trigger, err := strconv.Atoi(r.Form.Get("trigger")) + if err != nil { + return dataprovider.EventRule{}, fmt.Errorf("invalid trigger: %w", err) + } + conditions, err := getEventRuleConditionsFromPostFields(r) + if err != nil { + return dataprovider.EventRule{}, err + } + actions, err := getEventRuleActionsFromPostFields(r) + if err != nil { + return dataprovider.EventRule{}, err + } + rule := dataprovider.EventRule{ + Name: r.Form.Get("name"), + Description: r.Form.Get("description"), + Trigger: trigger, + Conditions: conditions, + Actions: actions, + } + return rule, nil +} + func (s *httpdServer) handleWebAdminForgotPwd(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) if !smtp.IsEnabled() { @@ -2450,7 +2810,7 @@ func (s *httpdServer) handleWebGetGroups(w http.ResponseWriter, r *http.Request) func (s *httpdServer) handleWebAddGroupGet(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) - s.renderGroupPage(w, r, dataprovider.Group{}, groupPageModeAdd, "") + s.renderGroupPage(w, r, dataprovider.Group{}, genericPageModeAdd, "") } func (s *httpdServer) handleWebAddGroupPost(w http.ResponseWriter, r *http.Request) { @@ -2462,7 +2822,7 @@ func (s *httpdServer) handleWebAddGroupPost(w http.ResponseWriter, r *http.Reque } group, err := getGroupFromPostFields(r) if err != nil { - s.renderGroupPage(w, r, group, groupPageModeAdd, err.Error()) + s.renderGroupPage(w, r, group, genericPageModeAdd, err.Error()) return } ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) @@ -2472,7 +2832,7 @@ func (s *httpdServer) handleWebAddGroupPost(w http.ResponseWriter, r *http.Reque } err = dataprovider.AddGroup(&group, claims.Username, ipAddr) if err != nil { - s.renderGroupPage(w, r, group, groupPageModeAdd, err.Error()) + s.renderGroupPage(w, r, group, genericPageModeAdd, err.Error()) return } http.Redirect(w, r, webGroupsPath, http.StatusSeeOther) @@ -2483,7 +2843,7 @@ func (s *httpdServer) handleWebUpdateGroupGet(w http.ResponseWriter, r *http.Req name := getURLParam(r, "name") group, err := dataprovider.GroupExists(name) if err == nil { - s.renderGroupPage(w, r, group, groupPageModeUpdate, "") + s.renderGroupPage(w, r, group, genericPageModeUpdate, "") } else if _, ok := err.(*util.RecordNotFoundError); ok { s.renderNotFoundPage(w, r, err) } else { @@ -2509,7 +2869,7 @@ func (s *httpdServer) handleWebUpdateGroupPost(w http.ResponseWriter, r *http.Re } updatedGroup, err := getGroupFromPostFields(r) if err != nil { - s.renderGroupPage(w, r, group, groupPageModeUpdate, err.Error()) + s.renderGroupPage(w, r, group, genericPageModeUpdate, err.Error()) return } ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) @@ -2530,8 +2890,245 @@ func (s *httpdServer) handleWebUpdateGroupPost(w http.ResponseWriter, r *http.Re err = dataprovider.UpdateGroup(&updatedGroup, group.Users, claims.Username, ipAddr) if err != nil { - s.renderGroupPage(w, r, updatedGroup, groupPageModeUpdate, err.Error()) + s.renderGroupPage(w, r, updatedGroup, genericPageModeUpdate, err.Error()) return } http.Redirect(w, r, webGroupsPath, http.StatusSeeOther) } + +func (s *httpdServer) getWebEventActions(w http.ResponseWriter, r *http.Request, limit int, minimal bool, +) ([]dataprovider.BaseEventAction, error) { + actions := make([]dataprovider.BaseEventAction, 0, limit) + for { + res, err := dataprovider.GetEventActions(limit, len(actions), dataprovider.OrderASC, minimal) + if err != nil { + s.renderInternalServerErrorPage(w, r, err) + return actions, err + } + actions = append(actions, res...) + if len(res) < limit { + break + } + } + return actions, nil +} + +func (s *httpdServer) handleWebGetEventActions(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + limit := defaultQueryLimit + if _, ok := r.URL.Query()["qlimit"]; ok { + var err error + limit, err = strconv.Atoi(r.URL.Query().Get("qlimit")) + if err != nil { + limit = defaultQueryLimit + } + } + actions, err := s.getWebEventActions(w, r, limit, false) + if err != nil { + return + } + + data := eventActionsPage{ + basePage: s.getBasePageData(pageEventActionsTitle, webAdminEventActionsPath, r), + Actions: actions, + } + renderAdminTemplate(w, templateEventActions, data) +} + +func (s *httpdServer) handleWebAddEventActionGet(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + action := dataprovider.BaseEventAction{ + Type: dataprovider.ActionTypeHTTP, + } + s.renderEventActionPage(w, r, action, genericPageModeAdd, "") +} + +func (s *httpdServer) handleWebAddEventActionPost(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + claims, err := getTokenClaims(r) + if err != nil || claims.Username == "" { + s.renderBadRequestPage(w, r, errors.New("invalid token claims")) + return + } + action, err := getEventActionFromPostFields(r) + if err != nil { + s.renderEventActionPage(w, r, action, genericPageModeAdd, err.Error()) + return + } + ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) + if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil { + s.renderForbiddenPage(w, r, err.Error()) + return + } + if err = dataprovider.AddEventAction(&action, claims.Username, ipAddr); err != nil { + s.renderEventActionPage(w, r, action, genericPageModeAdd, err.Error()) + return + } + http.Redirect(w, r, webAdminEventActionsPath, http.StatusSeeOther) +} + +func (s *httpdServer) handleWebUpdateEventActionGet(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + name := getURLParam(r, "name") + action, err := dataprovider.EventActionExists(name) + if err == nil { + s.renderEventActionPage(w, r, action, genericPageModeUpdate, "") + } else if _, ok := err.(*util.RecordNotFoundError); ok { + s.renderNotFoundPage(w, r, err) + } else { + s.renderInternalServerErrorPage(w, r, err) + } +} + +func (s *httpdServer) handleWebUpdateEventActionPost(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + claims, err := getTokenClaims(r) + if err != nil || claims.Username == "" { + s.renderBadRequestPage(w, r, errors.New("invalid token claims")) + return + } + name := getURLParam(r, "name") + action, err := dataprovider.EventActionExists(name) + if _, ok := err.(*util.RecordNotFoundError); ok { + s.renderNotFoundPage(w, r, err) + return + } else if err != nil { + s.renderInternalServerErrorPage(w, r, err) + return + } + updatedAction, err := getEventActionFromPostFields(r) + if err != nil { + s.renderEventActionPage(w, r, updatedAction, genericPageModeUpdate, err.Error()) + return + } + ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) + if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil { + s.renderForbiddenPage(w, r, err.Error()) + return + } + updatedAction.ID = action.ID + updatedAction.Name = action.Name + updatedAction.Options.SetEmptySecretsIfNil() + switch updatedAction.Type { + case dataprovider.ActionTypeHTTP: + if updatedAction.Options.HTTPConfig.Password.IsNotPlainAndNotEmpty() { + updatedAction.Options.HTTPConfig.Password = action.Options.HTTPConfig.Password + } + } + err = dataprovider.UpdateEventAction(&updatedAction, claims.Username, ipAddr) + if err != nil { + s.renderEventActionPage(w, r, updatedAction, genericPageModeUpdate, err.Error()) + return + } + http.Redirect(w, r, webAdminEventActionsPath, http.StatusSeeOther) +} + +func (s *httpdServer) handleWebGetEventRules(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + limit := defaultQueryLimit + if _, ok := r.URL.Query()["qlimit"]; ok { + if lim, err := strconv.Atoi(r.URL.Query().Get("qlimit")); err == nil { + limit = lim + } + } + rules := make([]dataprovider.EventRule, 0, limit) + for { + res, err := dataprovider.GetEventRules(limit, len(rules), dataprovider.OrderASC) + if err != nil { + s.renderInternalServerErrorPage(w, r, err) + return + } + rules = append(rules, res...) + if len(res) < limit { + break + } + } + + data := eventRulesPage{ + basePage: s.getBasePageData(pageEventRulesTitle, webAdminEventRulesPath, r), + Rules: rules, + } + renderAdminTemplate(w, templateEventRules, data) +} + +func (s *httpdServer) handleWebAddEventRuleGet(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + rule := dataprovider.EventRule{ + Trigger: dataprovider.EventTriggerFsEvent, + } + s.renderEventRulePage(w, r, rule, genericPageModeAdd, "") +} + +func (s *httpdServer) handleWebAddEventRulePost(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + claims, err := getTokenClaims(r) + if err != nil || claims.Username == "" { + s.renderBadRequestPage(w, r, errors.New("invalid token claims")) + return + } + rule, err := getEventRuleFromPostFields(r) + if err != nil { + s.renderEventRulePage(w, r, rule, genericPageModeAdd, err.Error()) + return + } + ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) + err = verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr) + if err != nil { + s.renderForbiddenPage(w, r, err.Error()) + return + } + if err = dataprovider.AddEventRule(&rule, claims.Username, ipAddr); err != nil { + s.renderEventRulePage(w, r, rule, genericPageModeAdd, err.Error()) + return + } + http.Redirect(w, r, webAdminEventRulesPath, http.StatusSeeOther) +} + +func (s *httpdServer) handleWebUpdateEventRuleGet(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + name := getURLParam(r, "name") + rule, err := dataprovider.EventRuleExists(name) + if err == nil { + s.renderEventRulePage(w, r, rule, genericPageModeUpdate, "") + } else if _, ok := err.(*util.RecordNotFoundError); ok { + s.renderNotFoundPage(w, r, err) + } else { + s.renderInternalServerErrorPage(w, r, err) + } +} + +func (s *httpdServer) handleWebUpdateEventRulePost(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + claims, err := getTokenClaims(r) + if err != nil || claims.Username == "" { + s.renderBadRequestPage(w, r, errors.New("invalid token claims")) + return + } + name := getURLParam(r, "name") + rule, err := dataprovider.EventRuleExists(name) + if _, ok := err.(*util.RecordNotFoundError); ok { + s.renderNotFoundPage(w, r, err) + return + } else if err != nil { + s.renderInternalServerErrorPage(w, r, err) + return + } + updatedRule, err := getEventRuleFromPostFields(r) + if err != nil { + s.renderEventRulePage(w, r, updatedRule, genericPageModeUpdate, err.Error()) + return + } + ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) + if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil { + s.renderForbiddenPage(w, r, err.Error()) + return + } + updatedRule.ID = rule.ID + updatedRule.Name = rule.Name + err = dataprovider.UpdateEventRule(&updatedRule, claims.Username, ipAddr) + if err != nil { + s.renderEventRulePage(w, r, updatedRule, genericPageModeUpdate, err.Error()) + return + } + http.Redirect(w, r, webAdminEventRulesPath, http.StatusSeeOther) +} diff --git a/httpdtest/httpdtest.go b/httpdtest/httpdtest.go index 8b56ac2b..2725059b 100644 --- a/httpdtest/httpdtest.go +++ b/httpdtest/httpdtest.go @@ -46,6 +46,8 @@ const ( apiKeysPath = "/api/v2/apikeys" retentionBasePath = "/api/v2/retention/users" retentionChecksPath = "/api/v2/retention/users/checks" + eventActionsPath = "/api/v2/eventactions" + eventRulesPath = "/api/v2/eventrules" ) const ( @@ -598,9 +600,227 @@ func GetAPIKeyByID(keyID string, expectedStatusCode int) (dataprovider.APIKey, [ return apiKey, body, err } +// AddEventAction adds a new event action +func AddEventAction(action dataprovider.BaseEventAction, expectedStatusCode int) (dataprovider.BaseEventAction, []byte, error) { + var newAction dataprovider.BaseEventAction + var body []byte + asJSON, _ := json.Marshal(action) + resp, err := sendHTTPRequest(http.MethodPost, buildURLRelativeToBase(eventActionsPath), bytes.NewBuffer(asJSON), + "application/json", getDefaultToken()) + if err != nil { + return newAction, body, err + } + defer resp.Body.Close() + err = checkResponse(resp.StatusCode, expectedStatusCode) + if expectedStatusCode != http.StatusCreated { + body, _ = getResponseBody(resp) + return newAction, body, err + } + if err == nil { + err = render.DecodeJSON(resp.Body, &newAction) + } else { + body, _ = getResponseBody(resp) + } + if err == nil { + err = checkEventAction(action, newAction) + } + return newAction, body, err +} + +// UpdateEventAction updates an existing event action +func UpdateEventAction(action dataprovider.BaseEventAction, expectedStatusCode int) (dataprovider.BaseEventAction, []byte, error) { + var newAction dataprovider.BaseEventAction + var body []byte + + asJSON, _ := json.Marshal(action) + resp, err := sendHTTPRequest(http.MethodPut, buildURLRelativeToBase(eventActionsPath, url.PathEscape(action.Name)), + bytes.NewBuffer(asJSON), "application/json", getDefaultToken()) + if err != nil { + return newAction, body, err + } + defer resp.Body.Close() + body, _ = getResponseBody(resp) + err = checkResponse(resp.StatusCode, expectedStatusCode) + if expectedStatusCode != http.StatusOK { + return newAction, body, err + } + if err == nil { + newAction, body, err = GetEventActionByName(action.Name, expectedStatusCode) + } + if err == nil { + err = checkEventAction(action, newAction) + } + return newAction, body, err +} + +// RemoveEventAction removes an existing action and checks the received HTTP Status code against expectedStatusCode. +func RemoveEventAction(action dataprovider.BaseEventAction, expectedStatusCode int) ([]byte, error) { + var body []byte + resp, err := sendHTTPRequest(http.MethodDelete, buildURLRelativeToBase(eventActionsPath, url.PathEscape(action.Name)), + nil, "", getDefaultToken()) + if err != nil { + return body, err + } + defer resp.Body.Close() + body, _ = getResponseBody(resp) + return body, checkResponse(resp.StatusCode, expectedStatusCode) +} + +// GetEventActionByName gets an event action by name and checks the received HTTP Status code against expectedStatusCode. +func GetEventActionByName(name string, expectedStatusCode int) (dataprovider.BaseEventAction, []byte, error) { + var action dataprovider.BaseEventAction + var body []byte + resp, err := sendHTTPRequest(http.MethodGet, buildURLRelativeToBase(eventActionsPath, url.PathEscape(name)), + nil, "", getDefaultToken()) + if err != nil { + return action, body, err + } + defer resp.Body.Close() + err = checkResponse(resp.StatusCode, expectedStatusCode) + if err == nil && expectedStatusCode == http.StatusOK { + err = render.DecodeJSON(resp.Body, &action) + } else { + body, _ = getResponseBody(resp) + } + return action, body, err +} + +// GetEventActions returns a list of event actions and checks the received HTTP Status code against expectedStatusCode. +// The number of results can be limited specifying a limit. +// Some results can be skipped specifying an offset. +func GetEventActions(limit, offset int64, expectedStatusCode int) ([]dataprovider.BaseEventAction, []byte, error) { + var actions []dataprovider.BaseEventAction + var body []byte + url, err := addLimitAndOffsetQueryParams(buildURLRelativeToBase(eventActionsPath), limit, offset) + if err != nil { + return actions, body, err + } + resp, err := sendHTTPRequest(http.MethodGet, url.String(), nil, "", getDefaultToken()) + if err != nil { + return actions, body, err + } + defer resp.Body.Close() + err = checkResponse(resp.StatusCode, expectedStatusCode) + if err == nil && expectedStatusCode == http.StatusOK { + err = render.DecodeJSON(resp.Body, &actions) + } else { + body, _ = getResponseBody(resp) + } + return actions, body, err +} + +// AddEventRule adds a new event rule +func AddEventRule(rule dataprovider.EventRule, expectedStatusCode int) (dataprovider.EventRule, []byte, error) { + var newRule dataprovider.EventRule + var body []byte + asJSON, _ := json.Marshal(rule) + resp, err := sendHTTPRequest(http.MethodPost, buildURLRelativeToBase(eventRulesPath), bytes.NewBuffer(asJSON), + "application/json", getDefaultToken()) + if err != nil { + return newRule, body, err + } + defer resp.Body.Close() + err = checkResponse(resp.StatusCode, expectedStatusCode) + if expectedStatusCode != http.StatusCreated { + body, _ = getResponseBody(resp) + return newRule, body, err + } + if err == nil { + err = render.DecodeJSON(resp.Body, &newRule) + } else { + body, _ = getResponseBody(resp) + } + if err == nil { + err = checkEventRule(rule, newRule) + } + return newRule, body, err +} + +// UpdateEventRule updates an existing event rule +func UpdateEventRule(rule dataprovider.EventRule, expectedStatusCode int) (dataprovider.EventRule, []byte, error) { + var newRule dataprovider.EventRule + var body []byte + + asJSON, _ := json.Marshal(rule) + resp, err := sendHTTPRequest(http.MethodPut, buildURLRelativeToBase(eventRulesPath, url.PathEscape(rule.Name)), + bytes.NewBuffer(asJSON), "application/json", getDefaultToken()) + if err != nil { + return newRule, body, err + } + defer resp.Body.Close() + body, _ = getResponseBody(resp) + err = checkResponse(resp.StatusCode, expectedStatusCode) + if expectedStatusCode != http.StatusOK { + return newRule, body, err + } + if err == nil { + newRule, body, err = GetEventRuleByName(rule.Name, expectedStatusCode) + } + if err == nil { + err = checkEventRule(rule, newRule) + } + return newRule, body, err +} + +// RemoveEventRule removes an existing rule and checks the received HTTP Status code against expectedStatusCode. +func RemoveEventRule(rule dataprovider.EventRule, expectedStatusCode int) ([]byte, error) { + var body []byte + resp, err := sendHTTPRequest(http.MethodDelete, buildURLRelativeToBase(eventRulesPath, url.PathEscape(rule.Name)), + nil, "", getDefaultToken()) + if err != nil { + return body, err + } + defer resp.Body.Close() + body, _ = getResponseBody(resp) + return body, checkResponse(resp.StatusCode, expectedStatusCode) +} + +// GetEventRuleByName gets an event rule by name and checks the received HTTP Status code against expectedStatusCode. +func GetEventRuleByName(name string, expectedStatusCode int) (dataprovider.EventRule, []byte, error) { + var rule dataprovider.EventRule + var body []byte + resp, err := sendHTTPRequest(http.MethodGet, buildURLRelativeToBase(eventRulesPath, url.PathEscape(name)), + nil, "", getDefaultToken()) + if err != nil { + return rule, body, err + } + defer resp.Body.Close() + err = checkResponse(resp.StatusCode, expectedStatusCode) + if err == nil && expectedStatusCode == http.StatusOK { + err = render.DecodeJSON(resp.Body, &rule) + } else { + body, _ = getResponseBody(resp) + } + return rule, body, err +} + +// GetEventRules returns a list of event rules and checks the received HTTP Status code against expectedStatusCode. +// The number of results can be limited specifying a limit. +// Some results can be skipped specifying an offset. +func GetEventRules(limit, offset int64, expectedStatusCode int) ([]dataprovider.EventRule, []byte, error) { + var rules []dataprovider.EventRule + var body []byte + url, err := addLimitAndOffsetQueryParams(buildURLRelativeToBase(eventRulesPath), limit, offset) + if err != nil { + return rules, body, err + } + resp, err := sendHTTPRequest(http.MethodGet, url.String(), nil, "", getDefaultToken()) + if err != nil { + return rules, body, err + } + defer resp.Body.Close() + err = checkResponse(resp.StatusCode, expectedStatusCode) + if err == nil && expectedStatusCode == http.StatusOK { + err = render.DecodeJSON(resp.Body, &rules) + } else { + body, _ = getResponseBody(resp) + } + return rules, body, err +} + // 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 +func GetQuotaScans(expectedStatusCode int) ([]dataprovider.ActiveQuotaScan, []byte, error) { + var quotaScans []dataprovider.ActiveQuotaScan var body []byte resp, err := sendHTTPRequest(http.MethodGet, buildURLRelativeToBase(quotaScanPath), nil, "", getDefaultToken()) if err != nil { @@ -843,8 +1063,8 @@ func GetFolders(limit int64, offset int64, expectedStatusCode int) ([]vfs.BaseVi } // GetFoldersQuotaScans gets active quota scans for folders and checks the received HTTP Status code against expectedStatusCode. -func GetFoldersQuotaScans(expectedStatusCode int) ([]common.ActiveVirtualFolderQuotaScan, []byte, error) { - var quotaScans []common.ActiveVirtualFolderQuotaScan +func GetFoldersQuotaScans(expectedStatusCode int) ([]dataprovider.ActiveVirtualFolderQuotaScan, []byte, error) { + var quotaScans []dataprovider.ActiveVirtualFolderQuotaScan var body []byte resp, err := sendHTTPRequest(http.MethodGet, buildURLRelativeToBase(quotaScanVFolderPath), nil, "", getDefaultToken()) if err != nil { @@ -1087,7 +1307,149 @@ func getResponseBody(resp *http.Response) ([]byte, error) { return io.ReadAll(resp.Body) } -func checkGroup(expected dataprovider.Group, actual dataprovider.Group) error { +func checkEventAction(expected, actual dataprovider.BaseEventAction) error { + if expected.ID <= 0 { + if actual.ID <= 0 { + return errors.New("actual action ID must be > 0") + } + } else { + if actual.ID != expected.ID { + return errors.New("action ID mismatch") + } + } + if dataprovider.ConvertName(expected.Name) != actual.Name { + return errors.New("name mismatch") + } + if expected.Description != actual.Description { + return errors.New("description mismatch") + } + if expected.Type != actual.Type { + return errors.New("type mismatch") + } + if err := compareEventActionCmdConfigFields(expected.Options.CmdConfig, actual.Options.CmdConfig); err != nil { + return err + } + if err := compareEventActionEmailConfigFields(expected.Options.EmailConfig, actual.Options.EmailConfig); err != nil { + return err + } + return compareEventActionHTTPConfigFields(expected.Options.HTTPConfig, actual.Options.HTTPConfig) +} + +func checkEventSchedules(expected, actual []dataprovider.Schedule) error { + if len(expected) != len(actual) { + return errors.New("schedules mismatch") + } + for _, ex := range expected { + found := false + for _, ac := range actual { + if ac.DayOfMonth == ex.DayOfMonth && ac.DayOfWeek == ex.DayOfWeek && ac.Hours == ex.Hours && ac.Month == ex.Month { + found = true + break + } + } + if !found { + return errors.New("schedules content mismatch") + } + } + return nil +} + +func compareConditionPatternOptions(expected, actual []dataprovider.ConditionPattern) error { + if len(expected) != len(actual) { + return errors.New("condition pattern mismatch") + } + for _, ex := range expected { + found := false + for _, ac := range actual { + if ac.Pattern == ex.Pattern && ac.InverseMatch == ex.InverseMatch { + found = true + break + } + } + if !found { + return errors.New("condition pattern content mismatch") + } + } + return nil +} + +func checkEventConditionOptions(expected, actual dataprovider.ConditionOptions) error { + if err := compareConditionPatternOptions(expected.Names, actual.Names); err != nil { + return errors.New("condition names mismatch") + } + if err := compareConditionPatternOptions(expected.FsPaths, actual.FsPaths); err != nil { + return errors.New("condition fs_paths mismatch") + } + if len(expected.Protocols) != len(actual.Protocols) { + return errors.New("condition protocols mismatch") + } + for _, v := range expected.Protocols { + if !util.Contains(actual.Protocols, v) { + return errors.New("condition protocols content mismatch") + } + } + if len(expected.ProviderObjects) != len(actual.ProviderObjects) { + return errors.New("condition provider objects mismatch") + } + for _, v := range expected.ProviderObjects { + if !util.Contains(actual.ProviderObjects, v) { + return errors.New("condition provider objects content mismatch") + } + } + if expected.MinFileSize != actual.MinFileSize { + return errors.New("condition min file size mismatch") + } + if expected.MaxFileSize != actual.MaxFileSize { + return errors.New("condition max file size mismatch") + } + return nil +} + +func checkEventConditions(expected, actual dataprovider.EventConditions) error { + if len(expected.FsEvents) != len(actual.FsEvents) { + return errors.New("fs events mismatch") + } + for _, v := range expected.FsEvents { + if !util.Contains(actual.FsEvents, v) { + return errors.New("fs events content mismatch") + } + } + if len(expected.ProviderEvents) != len(actual.ProviderEvents) { + return errors.New("provider events mismatch") + } + for _, v := range expected.ProviderEvents { + if !util.Contains(actual.ProviderEvents, v) { + return errors.New("provider events content mismatch") + } + } + if err := checkEventConditionOptions(expected.Options, actual.Options); err != nil { + return err + } + + return checkEventSchedules(expected.Schedules, actual.Schedules) +} + +func checkEventRuleActions(expected, actual []dataprovider.EventAction) error { + if len(expected) != len(actual) { + return errors.New("actions mismatch") + } + for _, ex := range expected { + found := false + for _, ac := range actual { + if ex.Name == ac.Name && ex.Order == ac.Order && ex.Options.ExecuteSync == ac.Options.ExecuteSync && + ex.Options.IsFailureAction == ac.Options.IsFailureAction && ex.Options.StopOnFailure == ac.Options.StopOnFailure { + found = true + break + } + } + if !found { + return errors.New("actions contents mismatch") + } + } + return nil +} + +func checkEventRule(expected, actual dataprovider.EventRule) error { if expected.ID <= 0 { if actual.ID <= 0 { return errors.New("actual group ID must be > 0") @@ -1103,6 +1465,43 @@ func checkGroup(expected dataprovider.Group, actual dataprovider.Group) error { if expected.Description != actual.Description { return errors.New("description mismatch") } + if actual.CreatedAt == 0 { + return errors.New("created_at unset") + } + if actual.UpdatedAt == 0 { + return errors.New("updated_at unset") + } + if expected.Trigger != actual.Trigger { + return errors.New("trigger mismatch") + } + if err := checkEventConditions(expected.Conditions, actual.Conditions); err != nil { + return err + } + return checkEventRuleActions(expected.Actions, actual.Actions) +} + +func checkGroup(expected, actual dataprovider.Group) error { + if expected.ID <= 0 { + if actual.ID <= 0 { + return errors.New("actual group ID must be > 0") + } + } else { + if actual.ID != expected.ID { + return errors.New("group ID mismatch") + } + } + if dataprovider.ConvertName(expected.Name) != actual.Name { + return errors.New("name mismatch") + } + if expected.Description != actual.Description { + return errors.New("description mismatch") + } + if actual.CreatedAt == 0 { + return errors.New("created_at unset") + } + if actual.UpdatedAt == 0 { + return errors.New("updated_at unset") + } if err := compareEqualGroupSettingsFields(expected.UserSettings.BaseGroupUserSettings, actual.UserSettings.BaseGroupUserSettings); err != nil { return err @@ -1202,12 +1601,12 @@ func checkAdmin(expected, actual *dataprovider.Admin) error { return errors.New("permissions content mismatch") } } - if len(expected.Filters.AllowList) != len(actual.Filters.AllowList) { - return errors.New("allow list mismatch") - } if expected.Filters.AllowAPIKeyAuth != actual.Filters.AllowAPIKeyAuth { return errors.New("allow_api_key_auth mismatch") } + if len(expected.Filters.AllowList) != len(actual.Filters.AllowList) { + return errors.New("allow list mismatch") + } for _, v := range expected.Filters.AllowList { if !util.Contains(actual.Filters.AllowList, v) { return errors.New("allow list content mismatch") @@ -1748,6 +2147,87 @@ func compareUserFilePatternsFilters(expected sdk.BaseUserFilters, actual sdk.Bas return nil } +func compareKeyValues(expected, actual []dataprovider.KeyValue) error { + if len(expected) != len(actual) { + return errors.New("kay values mismatch") + } + for _, ex := range expected { + found := false + for _, ac := range actual { + if ac.Key == ex.Key && ac.Value == ex.Value { + found = true + break + } + } + if !found { + return errors.New("kay values mismatch") + } + } + return nil +} + +func compareEventActionHTTPConfigFields(expected, actual dataprovider.EventActionHTTPConfig) error { + if expected.Endpoint != actual.Endpoint { + return errors.New("http endpoint mismatch") + } + if expected.Username != actual.Username { + return errors.New("http username mismatch") + } + if err := checkEncryptedSecret(expected.Password, actual.Password); err != nil { + return err + } + if err := compareKeyValues(expected.Headers, actual.Headers); err != nil { + return errors.New("http headers mismatch") + } + if expected.Timeout != actual.Timeout { + return errors.New("http timeout mismatch") + } + if expected.SkipTLSVerify != actual.SkipTLSVerify { + return errors.New("http skip TLS verify mismatch") + } + if expected.Method != actual.Method { + return errors.New("http method mismatch") + } + if err := compareKeyValues(expected.QueryParameters, actual.QueryParameters); err != nil { + return errors.New("http query parameters mismatch") + } + if expected.Body != actual.Body { + return errors.New("http body mismatch") + } + return nil +} + +func compareEventActionEmailConfigFields(expected, actual dataprovider.EventActionEmailConfig) error { + if len(expected.Recipients) != len(actual.Recipients) { + return errors.New("email recipients mismatch") + } + for _, v := range expected.Recipients { + if !util.Contains(actual.Recipients, v) { + return errors.New("email recipients content mismatch") + } + } + if expected.Subject != actual.Subject { + return errors.New("email subject mismatch") + } + if expected.Body != actual.Body { + return errors.New("email body mismatch") + } + return nil +} + +func compareEventActionCmdConfigFields(expected, actual dataprovider.EventActionCommandConfig) error { + if expected.Cmd != actual.Cmd { + return errors.New("command mismatch") + } + if expected.Timeout != actual.Timeout { + return errors.New("cmd timeout mismatch") + } + if err := compareKeyValues(expected.EnvVars, actual.EnvVars); err != nil { + return errors.New("cmd env vars mismatch") + } + return nil +} + func compareEqualGroupSettingsFields(expected sdk.BaseGroupUserSettings, actual sdk.BaseGroupUserSettings) error { if expected.HomeDir != actual.HomeDir { return errors.New("home dir mismatch") diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 5ac504f3..3006f9e6 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -16,6 +16,7 @@ tags: - name: metadata - name: user APIs - name: public shares + - name: event manager info: title: SFTPGo description: | @@ -1450,7 +1451,7 @@ paths: schema: $ref: '#/components/schemas/ApiResponse' example: - message: User updated + message: Folder updated '400': $ref: '#/components/responses/BadRequest' '401': @@ -1625,7 +1626,7 @@ paths: schema: $ref: '#/components/schemas/ApiResponse' example: - message: User updated + message: Group updated '400': $ref: '#/components/responses/BadRequest' '401': @@ -1641,7 +1642,7 @@ paths: delete: tags: - groups - summary: Delete + summary: Delete group description: Deletes an existing group operationId: delete_group responses: @@ -1652,7 +1653,357 @@ paths: schema: $ref: '#/components/schemas/ApiResponse' example: - message: User deleted + message: Group deleted + '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' + /eventactions: + get: + tags: + - event manager + summary: Get event actions + description: Returns an array with one or more event actions + operationId: get_event_actons + parameters: + - in: query + name: offset + schema: + type: integer + minimum: 0 + default: 0 + required: false + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 500 + default: 100 + required: false + description: 'The maximum number of items to return. Max value is 500, default is 100' + - in: query + name: order + required: false + description: Ordering actions by name. Default ASC + schema: + type: string + enum: + - ASC + - DESC + example: ASC + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/BaseEventAction' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + default: + $ref: '#/components/responses/DefaultResponse' + post: + tags: + - event manager + summary: Add event action + operationId: add_event_action + description: Adds a new event actions + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BaseEventAction' + responses: + '201': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/BaseEventAction' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + default: + $ref: '#/components/responses/DefaultResponse' + '/eventactions/{name}': + parameters: + - name: name + in: path + description: action name + required: true + schema: + type: string + get: + tags: + - event manager + summary: Find event actions by name + description: Returns the event action with the given name if it exists. + operationId: get_event_action_by_name + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/BaseEventAction' + '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' + put: + tags: + - event manager + summary: Update event action + description: Updates an existing event action + operationId: update_event_action + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BaseEventAction' + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + message: Event action updated + '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' + delete: + tags: + - event manager + summary: Delete event action + description: Deletes an existing event action + operationId: delete_event_action + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + message: Event action deleted + '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' + /eventrules: + get: + tags: + - event manager + summary: Get event rules + description: Returns an array with one or more event rules + operationId: get_event_rules + parameters: + - in: query + name: offset + schema: + type: integer + minimum: 0 + default: 0 + required: false + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 500 + default: 100 + required: false + description: 'The maximum number of items to return. Max value is 500, default is 100' + - in: query + name: order + required: false + description: Ordering rules by name. Default ASC + schema: + type: string + enum: + - ASC + - DESC + example: ASC + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/EventRule' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + default: + $ref: '#/components/responses/DefaultResponse' + post: + tags: + - event manager + summary: Add event rule + operationId: add_event_rule + description: Adds a new event rule + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EventRuleMinimal' + responses: + '201': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/EventRule' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + default: + $ref: '#/components/responses/DefaultResponse' + '/eventrules/{name}': + parameters: + - name: name + in: path + description: rule name + required: true + schema: + type: string + get: + tags: + - event manager + summary: Find event rules by name + description: Returns the event rule with the given name if it exists. + operationId: get_event_rile_by_name + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/EventRule' + '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' + put: + tags: + - event manager + summary: Update event rule + description: Updates an existing event rule + operationId: update_event_rule + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EventRuleMinimal' + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + message: Event rules updated + '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' + delete: + tags: + - event manager + summary: Delete event rule + description: Deletes an existing event rule + operationId: delete_event_rule + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + message: Event rules deleted '400': $ref: '#/components/responses/BadRequest' '401': @@ -3942,6 +4293,7 @@ components: - retention_checks - metadata_checks - view_events + - manage_event_rules description: | Admin permissions: * `*` - all permissions are granted @@ -3961,6 +4313,7 @@ components: * `retention_checks` - view and start retention checks is allowed * `metadata_checks` - view and start metadata checks is allowed * `view_events` - view and search filesystem and provider events is allowed + * `manage_event_rules` - manage event actions and rules is allowed FsProviders: type: integer enum: @@ -3980,6 +4333,36 @@ components: * `4` - Local filesystem encrypted * `5` - SFTP * `6` - HTTP filesystem + EventActionTypes: + type: integer + enum: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + description: | + Supported event action types: + * `1` - HTTP + * `2` - Command + * `3` - Email + * `4` - Backup + * `5` - User quota reset + * `6` - Folder quota reset + * `7` - Transfer quota reset + EventTriggerTypes: + type: integer + enum: + - 1 + - 2 + - 3 + description: | + Supported event trigger types: + * `1` - Filesystem event + * `2` - Provider event + * `3` - Schedule LoginMethods: type: string enum: @@ -5289,20 +5672,6 @@ components: type: boolean mfa: $ref: '#/components/schemas/MFAStatus' - BanStatus: - type: object - properties: - date_time: - type: string - format: date-time - nullable: true - description: if null the host is not banned - ScoreStatus: - type: object - properties: - score: - type: integer - description: if 0 the host is not listed Share: type: object properties: @@ -5574,6 +5943,269 @@ components: type: string instance_id: type: string + KeyValue: + type: object + properties: + key: + type: string + value: + type: string + EventActionHTTPConfig: + type: object + properties: + endpoint: + type: string + description: HTTP endpoint + example: https://example.com + username: + type: string + password: + $ref: '#/components/schemas/Secret' + headers: + type: array + items: + $ref: '#/components/schemas/KeyValue' + description: headers to add + timeout: + type: integer + minimum: 1 + maximum: 120 + skip_tls_verify: + type: boolean + description: 'if enabled the HTTP client accepts any TLS certificate presented by the server and any host name in that certificate. In this mode, TLS is susceptible to man-in-the-middle attacks. This should be used only for testing.' + method: + type: string + enum: + - GET + - POST + - PUT + query_parameters: + type: array + items: + $ref: '#/components/schemas/KeyValue' + body: + type: string + description: HTTP POST/PUT body + EventActionCommandConfig: + type: object + properties: + cmd: + type: string + description: absolute path to the command to execute + timeout: + type: integer + minimum: 1 + maximum: 120 + env_vars: + type: array + items: + $ref: '#/components/schemas/KeyValue' + EventActionEmailConfig: + type: object + properties: + recipients: + type: array + items: + type: string + subject: + type: string + body: + type: string + BaseEventActionOptions: + type: object + properties: + http_config: + $ref: '#/components/schemas/EventActionHTTPConfig' + cmd_config: + $ref: '#/components/schemas/EventActionCommandConfig' + email_config: + $ref: '#/components/schemas/EventActionEmailConfig' + BaseEventAction: + type: object + properties: + id: + type: integer + format: int32 + minimum: 1 + name: + type: string + description: unique name + description: + type: string + description: optional description + type: + $ref: '#/components/schemas/EventActionTypes' + options: + $ref: '#/components/schemas/BaseEventActionOptions' + rules: + type: array + items: + type: string + description: list of event rules names associated with this action + EventActionOptions: + type: object + properties: + is_failure_action: + type: boolean + stop_on_failure: + type: boolean + execute_sync: + type: boolean + EventAction: + allOf: + - $ref: '#/components/schemas/BaseEventAction' + - type: object + properties: + order: + type: integer + description: execution order + relation_options: + $ref: '#/components/schemas/EventActionOptions' + EventActionMinimal: + type: object + properties: + name: + type: string + order: + type: integer + description: execution order + relation_options: + $ref: '#/components/schemas/EventActionOptions' + ConditionPattern: + type: object + properties: + pattern: + type: string + inverse_match: + type: boolean + ConditionOptions: + type: object + properties: + names: + type: array + items: + $ref: '#/components/schemas/ConditionPattern' + fs_paths: + type: array + items: + $ref: '#/components/schemas/ConditionPattern' + protocols: + type: array + items: + type: string + enum: + - SFTP + - SCP + - SSH + - FTP + - DAV + - HTTP + - HTTPShare + - OIDC + provider_objects: + type: array + items: + type: string + enum: + - user + - group + - admin + - api_key + - share + - event_action + - event_rule + min_size: + type: integer + format: int64 + max_size: + type: integer + format: int64 + concurrent_execution: + type: boolean + description: allow concurrent execution from multiple nodes + Schedule: + type: object + properties: + hour: + type: string + day_of_week: + type: string + day_of_month: + type: string + month: + type: string + EventConditions: + type: object + properties: + fs_events: + type: array + items: + type: string + enum: + - upload + - download + - delete + - rename + - mkdir + - rmdir + - ssh_cmd + provider_events: + type: array + items: + type: string + enum: + - add + - update + - delete + schedules: + type: array + items: + $ref: '#/components/schemas/Schedule' + options: + $ref: '#/components/schemas/ConditionOptions' + BaseEventRule: + type: object + properties: + id: + type: integer + format: int32 + minimum: 1 + name: + type: string + description: unique name + description: + type: string + description: optional description + created_at: + type: integer + format: int64 + description: creation time as unix timestamp in milliseconds + updated_at: + type: integer + format: int64 + description: last update time as unix timestamp in millisecond + trigger: + $ref: '#/components/schemas/EventTriggerTypes' + conditions: + $ref: '#/components/schemas/EventConditions' + EventRule: + allOf: + - $ref: '#/components/schemas/BaseEventRule' + - type: object + properties: + actions: + type: array + items: + $ref: '#/components/schemas/EventAction' + EventRuleMinimal: + allOf: + - $ref: '#/components/schemas/BaseEventRule' + - type: object + properties: + actions: + type: array + items: + $ref: '#/components/schemas/EventActionMinimal' ApiResponse: type: object properties: diff --git a/service/service.go b/service/service.go index e558f963..665d7669 100644 --- a/service/service.go +++ b/service/service.go @@ -351,5 +351,13 @@ func (s *Service) restoreDump(dump *dataprovider.BackupData) error { if err != nil { return fmt.Errorf("unable to restore API keys from file %#v: %v", s.LoadDataFrom, err) } + err = httpd.RestoreEventActions(dump.EventActions, s.LoadDataFrom, s.LoadDataMode, dataprovider.ActionExecutorSystem, "") + if err != nil { + return fmt.Errorf("unable to restore event actions from file %#v: %v", s.LoadDataFrom, err) + } + err = httpd.RestoreEventRules(dump.EventRules, s.LoadDataFrom, s.LoadDataMode, dataprovider.ActionExecutorSystem, "") + if err != nil { + return fmt.Errorf("unable to restore event rules from file %#v: %v", s.LoadDataFrom, err) + } return nil } diff --git a/sftpd/internal_test.go b/sftpd/internal_test.go index 764d5e4a..f5680bf7 100644 --- a/sftpd/internal_test.go +++ b/sftpd/internal_test.go @@ -135,7 +135,7 @@ func newMockOsFs(err, statErr error, atomicUpload bool, connectionID, rootDir st } func TestRemoveNonexistentQuotaScan(t *testing.T) { - assert.False(t, common.QuotaScans.RemoveUserQuotaScan("username")) + assert.False(t, dataprovider.QuotaScans.RemoveUserQuotaScan("username")) } func TestGetOSOpenFlags(t *testing.T) { diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index f79e90f7..701b78d1 100644 --- a/sftpd/sftpd_test.go +++ b/sftpd/sftpd_test.go @@ -4403,14 +4403,14 @@ func TestQuotaScan(t *testing.T) { } func TestMultipleQuotaScans(t *testing.T) { - res := common.QuotaScans.AddUserQuotaScan(defaultUsername) + res := dataprovider.QuotaScans.AddUserQuotaScan(defaultUsername) assert.True(t, res) - res = common.QuotaScans.AddUserQuotaScan(defaultUsername) + res = dataprovider.QuotaScans.AddUserQuotaScan(defaultUsername) assert.False(t, res, "add quota must fail if another scan is already active") - assert.True(t, common.QuotaScans.RemoveUserQuotaScan(defaultUsername)) - activeScans := common.QuotaScans.GetUsersQuotaScans() + assert.True(t, dataprovider.QuotaScans.RemoveUserQuotaScan(defaultUsername)) + activeScans := dataprovider.QuotaScans.GetUsersQuotaScans() assert.Equal(t, 0, len(activeScans)) - assert.False(t, common.QuotaScans.RemoveUserQuotaScan(defaultUsername)) + assert.False(t, dataprovider.QuotaScans.RemoveUserQuotaScan(defaultUsername)) } func TestQuotaLimits(t *testing.T) { @@ -6762,15 +6762,15 @@ func TestVirtualFolderQuotaScan(t *testing.T) { func TestVFolderMultipleQuotaScan(t *testing.T) { folderName := "folder_name" - res := common.QuotaScans.AddVFolderQuotaScan(folderName) + res := dataprovider.QuotaScans.AddVFolderQuotaScan(folderName) assert.True(t, res) - res = common.QuotaScans.AddVFolderQuotaScan(folderName) + res = dataprovider.QuotaScans.AddVFolderQuotaScan(folderName) assert.False(t, res) - res = common.QuotaScans.RemoveVFolderQuotaScan(folderName) + res = dataprovider.QuotaScans.RemoveVFolderQuotaScan(folderName) assert.True(t, res) - activeScans := common.QuotaScans.GetVFoldersQuotaScans() + activeScans := dataprovider.QuotaScans.GetVFoldersQuotaScans() assert.Len(t, activeScans, 0) - res = common.QuotaScans.RemoveVFolderQuotaScan(folderName) + res = dataprovider.QuotaScans.RemoveVFolderQuotaScan(folderName) assert.False(t, res) } diff --git a/sftpgo.json b/sftpgo.json index e821ebb9..f7a03e18 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -231,12 +231,7 @@ "create_default_admin": false, "naming_rules": 1, "is_shared": 0, - "backups_path": "backups", - "auto_backup": { - "enabled": true, - "hour": "0", - "day_of_week": "*" - } + "backups_path": "backups" }, "httpd": { "bindings": [ diff --git a/smtp/smtp.go b/smtp/smtp.go index c7b6dc86..0b90128c 100644 --- a/smtp/smtp.go +++ b/smtp/smtp.go @@ -22,7 +22,7 @@ const ( // EmailContentType defines the support content types for email body type EmailContentType int -// Supporte email body content type +// Supported email body content type const ( EmailContentTypeTextPlain EmailContentType = iota EmailContentTypeTextHTML @@ -169,7 +169,7 @@ func RenderPasswordResetTemplate(buf *bytes.Buffer, data any) error { } // SendEmail tries to send an email using the specified parameters. -func SendEmail(to, subject, body string, contentType EmailContentType) error { +func SendEmail(to []string, subject, body string, contentType EmailContentType) error { if smtpServer == nil { return errors.New("smtp: not configured") } @@ -184,7 +184,7 @@ func SendEmail(to, subject, body string, contentType EmailContentType) error { } else { email.SetFrom(smtpServer.Username) } - email.AddTo(to).SetSubject(subject) + email.AddTo(to...).SetSubject(subject) switch contentType { case EmailContentTypeTextPlain: email.SetBody(mail.TextPlain, body) diff --git a/static/vendor/moment/js/moment.min.js b/static/vendor/moment/js/moment.min.js index 70329af1..3427886d 100644 --- a/static/vendor/moment/js/moment.min.js +++ b/static/vendor/moment/js/moment.min.js @@ -1,2 +1,2 @@ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.moment=t()}(this,function(){"use strict";var H;function f(){return H.apply(null,arguments)}function a(e){return e instanceof Array||"[object Array]"===Object.prototype.toString.call(e)}function F(e){return null!=e&&"[object Object]"===Object.prototype.toString.call(e)}function c(e,t){return Object.prototype.hasOwnProperty.call(e,t)}function L(e){if(Object.getOwnPropertyNames)return 0===Object.getOwnPropertyNames(e).length;for(var t in e)if(c(e,t))return;return 1}function o(e){return void 0===e}function u(e){return"number"==typeof e||"[object Number]"===Object.prototype.toString.call(e)}function V(e){return e instanceof Date||"[object Date]"===Object.prototype.toString.call(e)}function G(e,t){for(var n=[],s=e.length,i=0;i>>0,s=0;sAe(e)?(r=e+1,t-Ae(e)):(r=e,t);return{year:r,dayOfYear:n}}function qe(e,t,n){var s,i,r=ze(e.year(),t,n),r=Math.floor((e.dayOfYear()-r-1)/7)+1;return r<1?s=r+P(i=e.year()-1,t,n):r>P(e.year(),t,n)?(s=r-P(e.year(),t,n),i=e.year()+1):(i=e.year(),s=r),{week:s,year:i}}function P(e,t,n){var s=ze(e,t,n),t=ze(e+1,t,n);return(Ae(e)-s+t)/7}s("w",["ww",2],"wo","week"),s("W",["WW",2],"Wo","isoWeek"),t("week","w"),t("isoWeek","W"),n("week",5),n("isoWeek",5),v("w",p),v("ww",p,w),v("W",p),v("WW",p,w),Te(["w","ww","W","WW"],function(e,t,n,s){t[s.substr(0,1)]=g(e)});function Be(e,t){return e.slice(t,7).concat(e.slice(0,t))}s("d",0,"do","day"),s("dd",0,0,function(e){return this.localeData().weekdaysMin(this,e)}),s("ddd",0,0,function(e){return this.localeData().weekdaysShort(this,e)}),s("dddd",0,0,function(e){return this.localeData().weekdays(this,e)}),s("e",0,0,"weekday"),s("E",0,0,"isoWeekday"),t("day","d"),t("weekday","e"),t("isoWeekday","E"),n("day",11),n("weekday",11),n("isoWeekday",11),v("d",p),v("e",p),v("E",p),v("dd",function(e,t){return t.weekdaysMinRegex(e)}),v("ddd",function(e,t){return t.weekdaysShortRegex(e)}),v("dddd",function(e,t){return t.weekdaysRegex(e)}),Te(["dd","ddd","dddd"],function(e,t,n,s){s=n._locale.weekdaysParse(e,s,n._strict);null!=s?t.d=s:m(n).invalidWeekday=e}),Te(["d","e","E"],function(e,t,n,s){t[s]=g(e)});var Je="Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),Qe="Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"),Xe="Su_Mo_Tu_We_Th_Fr_Sa".split("_"),Ke=k,et=k,tt=k;function nt(){function e(e,t){return t.length-e.length}for(var t,n,s,i=[],r=[],a=[],o=[],u=0;u<7;u++)s=l([2e3,1]).day(u),t=M(this.weekdaysMin(s,"")),n=M(this.weekdaysShort(s,"")),s=M(this.weekdays(s,"")),i.push(t),r.push(n),a.push(s),o.push(t),o.push(n),o.push(s);i.sort(e),r.sort(e),a.sort(e),o.sort(e),this._weekdaysRegex=new RegExp("^("+o.join("|")+")","i"),this._weekdaysShortRegex=this._weekdaysRegex,this._weekdaysMinRegex=this._weekdaysRegex,this._weekdaysStrictRegex=new RegExp("^("+a.join("|")+")","i"),this._weekdaysShortStrictRegex=new RegExp("^("+r.join("|")+")","i"),this._weekdaysMinStrictRegex=new RegExp("^("+i.join("|")+")","i")}function st(){return this.hours()%12||12}function it(e,t){s(e,0,0,function(){return this.localeData().meridiem(this.hours(),this.minutes(),t)})}function rt(e,t){return t._meridiemParse}s("H",["HH",2],0,"hour"),s("h",["hh",2],0,st),s("k",["kk",2],0,function(){return this.hours()||24}),s("hmm",0,0,function(){return""+st.apply(this)+r(this.minutes(),2)}),s("hmmss",0,0,function(){return""+st.apply(this)+r(this.minutes(),2)+r(this.seconds(),2)}),s("Hmm",0,0,function(){return""+this.hours()+r(this.minutes(),2)}),s("Hmmss",0,0,function(){return""+this.hours()+r(this.minutes(),2)+r(this.seconds(),2)}),it("a",!0),it("A",!1),t("hour","h"),n("hour",13),v("a",rt),v("A",rt),v("H",p),v("h",p),v("k",p),v("HH",p,w),v("hh",p,w),v("kk",p,w),v("hmm",ge),v("hmmss",we),v("Hmm",ge),v("Hmmss",we),D(["H","HH"],x),D(["k","kk"],function(e,t,n){e=g(e);t[x]=24===e?0:e}),D(["a","A"],function(e,t,n){n._isPm=n._locale.isPM(e),n._meridiem=e}),D(["h","hh"],function(e,t,n){t[x]=g(e),m(n).bigHour=!0}),D("hmm",function(e,t,n){var s=e.length-2;t[x]=g(e.substr(0,s)),t[T]=g(e.substr(s)),m(n).bigHour=!0}),D("hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[x]=g(e.substr(0,s)),t[T]=g(e.substr(s,2)),t[N]=g(e.substr(i)),m(n).bigHour=!0}),D("Hmm",function(e,t,n){var s=e.length-2;t[x]=g(e.substr(0,s)),t[T]=g(e.substr(s))}),D("Hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[x]=g(e.substr(0,s)),t[T]=g(e.substr(s,2)),t[N]=g(e.substr(i))});k=de("Hours",!0);var at,ot={calendar:{sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},longDateFormat:{LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},invalidDate:"Invalid date",ordinal:"%d",dayOfMonthOrdinalParse:/\d{1,2}/,relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",ss:"%d seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",w:"a week",ww:"%d weeks",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},months:Ce,monthsShort:Ue,week:{dow:0,doy:6},weekdays:Je,weekdaysMin:Xe,weekdaysShort:Qe,meridiemParse:/[ap]\.?m?\.?/i},R={},ut={};function lt(e){return e&&e.toLowerCase().replace("_","-")}function ht(e){for(var t,n,s,i,r=0;r=t&&function(e,t){for(var n=Math.min(e.length,t.length),s=0;s=t-1)break;t--}r++}return at}function dt(t){var e;if(void 0===R[t]&&"undefined"!=typeof module&&module&&module.exports&&null!=t.match("^[^/\\\\]*$"))try{e=at._abbr,require("./locale/"+t),ct(e)}catch(e){R[t]=null}return R[t]}function ct(e,t){return e&&((t=o(t)?mt(e):ft(e,t))?at=t:"undefined"!=typeof console&&console.warn&&console.warn("Locale "+e+" not found. Did you forget to load it?")),at._abbr}function ft(e,t){if(null===t)return delete R[e],null;var n,s=ot;if(t.abbr=e,null!=R[e])Q("defineLocaleOverride","use moment.updateLocale(localeName, config) to change an existing locale. moment.defineLocale(localeName, config) should only be used for creating a new locale See http://momentjs.com/guides/#/warnings/define-locale/ for more info."),s=R[e]._config;else if(null!=t.parentLocale)if(null!=R[t.parentLocale])s=R[t.parentLocale]._config;else{if(null==(n=dt(t.parentLocale)))return ut[t.parentLocale]||(ut[t.parentLocale]=[]),ut[t.parentLocale].push({name:e,config:t}),null;s=n._config}return R[e]=new K(X(s,t)),ut[e]&&ut[e].forEach(function(e){ft(e.name,e.config)}),ct(e),R[e]}function mt(e){var t;if(!(e=e&&e._locale&&e._locale._abbr?e._locale._abbr:e))return at;if(!a(e)){if(t=dt(e))return t;e=[e]}return ht(e)}function _t(e){var t=e._a;return t&&-2===m(e).overflow&&(t=t[O]<0||11We(t[Y],t[O])?b:t[x]<0||24P(r,u,l)?m(s)._overflowWeeks=!0:null!=h?m(s)._overflowWeekday=!0:(d=$e(r,a,o,u,l),s._a[Y]=d.year,s._dayOfYear=d.dayOfYear)),null!=e._dayOfYear&&(i=bt(e._a[Y],n[Y]),(e._dayOfYear>Ae(i)||0===e._dayOfYear)&&(m(e)._overflowDayOfYear=!0),h=Ze(i,0,e._dayOfYear),e._a[O]=h.getUTCMonth(),e._a[b]=h.getUTCDate()),t=0;t<3&&null==e._a[t];++t)e._a[t]=c[t]=n[t];for(;t<7;t++)e._a[t]=c[t]=null==e._a[t]?2===t?1:0:e._a[t];24===e._a[x]&&0===e._a[T]&&0===e._a[N]&&0===e._a[Ne]&&(e._nextDay=!0,e._a[x]=0),e._d=(e._useUTC?Ze:je).apply(null,c),r=e._useUTC?e._d.getUTCDay():e._d.getDay(),null!=e._tzm&&e._d.setUTCMinutes(e._d.getUTCMinutes()-e._tzm),e._nextDay&&(e._a[x]=24),e._w&&void 0!==e._w.d&&e._w.d!==r&&(m(e).weekdayMismatch=!0)}}function Tt(e){if(e._f===f.ISO_8601)St(e);else if(e._f===f.RFC_2822)Ot(e);else{e._a=[],m(e).empty=!0;for(var t,n,s,i,r,a=""+e._i,o=a.length,u=0,l=ae(e._f,e._locale).match(te)||[],h=l.length,d=0;de.valueOf():e.valueOf()"}),i.toJSON=function(){return this.isValid()?this.toISOString():null},i.toString=function(){return this.clone().locale("en").format("ddd MMM DD YYYY HH:mm:ss [GMT]ZZ")},i.unix=function(){return Math.floor(this.valueOf()/1e3)},i.valueOf=function(){return this._d.valueOf()-6e4*(this._offset||0)},i.creationData=function(){return{input:this._i,format:this._f,locale:this._locale,isUTC:this._isUTC,strict:this._strict}},i.eraName=function(){for(var e,t=this.localeData().eras(),n=0,s=t.length;nthis.clone().month(0).utcOffset()||this.utcOffset()>this.clone().month(5).utcOffset()},i.isLocal=function(){return!!this.isValid()&&!this._isUTC},i.isUtcOffset=function(){return!!this.isValid()&&this._isUTC},i.isUtc=At,i.isUTC=At,i.zoneAbbr=function(){return this._isUTC?"UTC":""},i.zoneName=function(){return this._isUTC?"Coordinated Universal Time":""},i.dates=e("dates accessor is deprecated. Use date instead.",ke),i.months=e("months accessor is deprecated. Use month instead",Ge),i.years=e("years accessor is deprecated. Use year instead",Ie),i.zone=e("moment().zone is deprecated, use moment().utcOffset instead. http://momentjs.com/guides/#/warnings/zone/",function(e,t){return null!=e?(this.utcOffset(e="string"!=typeof e?-e:e,t),this):-this.utcOffset()}),i.isDSTShifted=e("isDSTShifted is deprecated. See http://momentjs.com/guides/#/warnings/dst-shifted/ for more information",function(){if(!o(this._isDSTShifted))return this._isDSTShifted;var e,t={};return $(t,this),(t=Nt(t))._a?(e=(t._isUTC?l:W)(t._a),this._isDSTShifted=this.isValid()&&0>>0,s=0;sAe(e)?(r=e+1,t-Ae(e)):(r=e,t);return{year:r,dayOfYear:n}}function qe(e,t,n){var s,i,r=ze(e.year(),t,n),r=Math.floor((e.dayOfYear()-r-1)/7)+1;return r<1?s=r+P(i=e.year()-1,t,n):r>P(e.year(),t,n)?(s=r-P(e.year(),t,n),i=e.year()+1):(i=e.year(),s=r),{week:s,year:i}}function P(e,t,n){var s=ze(e,t,n),t=ze(e+1,t,n);return(Ae(e)-s+t)/7}s("w",["ww",2],"wo","week"),s("W",["WW",2],"Wo","isoWeek"),t("week","w"),t("isoWeek","W"),n("week",5),n("isoWeek",5),v("w",p),v("ww",p,w),v("W",p),v("WW",p,w),Te(["w","ww","W","WW"],function(e,t,n,s){t[s.substr(0,1)]=g(e)});function Be(e,t){return e.slice(t,7).concat(e.slice(0,t))}s("d",0,"do","day"),s("dd",0,0,function(e){return this.localeData().weekdaysMin(this,e)}),s("ddd",0,0,function(e){return this.localeData().weekdaysShort(this,e)}),s("dddd",0,0,function(e){return this.localeData().weekdays(this,e)}),s("e",0,0,"weekday"),s("E",0,0,"isoWeekday"),t("day","d"),t("weekday","e"),t("isoWeekday","E"),n("day",11),n("weekday",11),n("isoWeekday",11),v("d",p),v("e",p),v("E",p),v("dd",function(e,t){return t.weekdaysMinRegex(e)}),v("ddd",function(e,t){return t.weekdaysShortRegex(e)}),v("dddd",function(e,t){return t.weekdaysRegex(e)}),Te(["dd","ddd","dddd"],function(e,t,n,s){s=n._locale.weekdaysParse(e,s,n._strict);null!=s?t.d=s:m(n).invalidWeekday=e}),Te(["d","e","E"],function(e,t,n,s){t[s]=g(e)});var Je="Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),Qe="Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"),Xe="Su_Mo_Tu_We_Th_Fr_Sa".split("_"),Ke=k,et=k,tt=k;function nt(){function e(e,t){return t.length-e.length}for(var t,n,s,i=[],r=[],a=[],o=[],u=0;u<7;u++)s=l([2e3,1]).day(u),t=M(this.weekdaysMin(s,"")),n=M(this.weekdaysShort(s,"")),s=M(this.weekdays(s,"")),i.push(t),r.push(n),a.push(s),o.push(t),o.push(n),o.push(s);i.sort(e),r.sort(e),a.sort(e),o.sort(e),this._weekdaysRegex=new RegExp("^("+o.join("|")+")","i"),this._weekdaysShortRegex=this._weekdaysRegex,this._weekdaysMinRegex=this._weekdaysRegex,this._weekdaysStrictRegex=new RegExp("^("+a.join("|")+")","i"),this._weekdaysShortStrictRegex=new RegExp("^("+r.join("|")+")","i"),this._weekdaysMinStrictRegex=new RegExp("^("+i.join("|")+")","i")}function st(){return this.hours()%12||12}function it(e,t){s(e,0,0,function(){return this.localeData().meridiem(this.hours(),this.minutes(),t)})}function rt(e,t){return t._meridiemParse}s("H",["HH",2],0,"hour"),s("h",["hh",2],0,st),s("k",["kk",2],0,function(){return this.hours()||24}),s("hmm",0,0,function(){return""+st.apply(this)+r(this.minutes(),2)}),s("hmmss",0,0,function(){return""+st.apply(this)+r(this.minutes(),2)+r(this.seconds(),2)}),s("Hmm",0,0,function(){return""+this.hours()+r(this.minutes(),2)}),s("Hmmss",0,0,function(){return""+this.hours()+r(this.minutes(),2)+r(this.seconds(),2)}),it("a",!0),it("A",!1),t("hour","h"),n("hour",13),v("a",rt),v("A",rt),v("H",p),v("h",p),v("k",p),v("HH",p,w),v("hh",p,w),v("kk",p,w),v("hmm",ge),v("hmmss",we),v("Hmm",ge),v("Hmmss",we),D(["H","HH"],x),D(["k","kk"],function(e,t,n){e=g(e);t[x]=24===e?0:e}),D(["a","A"],function(e,t,n){n._isPm=n._locale.isPM(e),n._meridiem=e}),D(["h","hh"],function(e,t,n){t[x]=g(e),m(n).bigHour=!0}),D("hmm",function(e,t,n){var s=e.length-2;t[x]=g(e.substr(0,s)),t[T]=g(e.substr(s)),m(n).bigHour=!0}),D("hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[x]=g(e.substr(0,s)),t[T]=g(e.substr(s,2)),t[N]=g(e.substr(i)),m(n).bigHour=!0}),D("Hmm",function(e,t,n){var s=e.length-2;t[x]=g(e.substr(0,s)),t[T]=g(e.substr(s))}),D("Hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[x]=g(e.substr(0,s)),t[T]=g(e.substr(s,2)),t[N]=g(e.substr(i))});k=de("Hours",!0);var at,ot={calendar:{sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},longDateFormat:{LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},invalidDate:"Invalid date",ordinal:"%d",dayOfMonthOrdinalParse:/\d{1,2}/,relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",ss:"%d seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",w:"a week",ww:"%d weeks",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},months:Ce,monthsShort:Ue,week:{dow:0,doy:6},weekdays:Je,weekdaysMin:Xe,weekdaysShort:Qe,meridiemParse:/[ap]\.?m?\.?/i},R={},ut={};function lt(e){return e&&e.toLowerCase().replace("_","-")}function ht(e){for(var t,n,s,i,r=0;r=t&&function(e,t){for(var n=Math.min(e.length,t.length),s=0;s=t-1)break;t--}r++}return at}function dt(t){var e;if(void 0===R[t]&&"undefined"!=typeof module&&module&&module.exports&&null!=t.match("^[^/\\\\]*$"))try{e=at._abbr,require("./locale/"+t),ct(e)}catch(e){R[t]=null}return R[t]}function ct(e,t){return e&&((t=o(t)?mt(e):ft(e,t))?at=t:"undefined"!=typeof console&&console.warn&&console.warn("Locale "+e+" not found. Did you forget to load it?")),at._abbr}function ft(e,t){if(null===t)return delete R[e],null;var n,s=ot;if(t.abbr=e,null!=R[e])Q("defineLocaleOverride","use moment.updateLocale(localeName, config) to change an existing locale. moment.defineLocale(localeName, config) should only be used for creating a new locale See http://momentjs.com/guides/#/warnings/define-locale/ for more info."),s=R[e]._config;else if(null!=t.parentLocale)if(null!=R[t.parentLocale])s=R[t.parentLocale]._config;else{if(null==(n=dt(t.parentLocale)))return ut[t.parentLocale]||(ut[t.parentLocale]=[]),ut[t.parentLocale].push({name:e,config:t}),null;s=n._config}return R[e]=new K(X(s,t)),ut[e]&&ut[e].forEach(function(e){ft(e.name,e.config)}),ct(e),R[e]}function mt(e){var t;if(!(e=e&&e._locale&&e._locale._abbr?e._locale._abbr:e))return at;if(!a(e)){if(t=dt(e))return t;e=[e]}return ht(e)}function _t(e){var t=e._a;return t&&-2===m(e).overflow&&(t=t[O]<0||11We(t[Y],t[O])?b:t[x]<0||24P(r,u,l)?m(s)._overflowWeeks=!0:null!=h?m(s)._overflowWeekday=!0:(d=$e(r,a,o,u,l),s._a[Y]=d.year,s._dayOfYear=d.dayOfYear)),null!=e._dayOfYear&&(i=bt(e._a[Y],n[Y]),(e._dayOfYear>Ae(i)||0===e._dayOfYear)&&(m(e)._overflowDayOfYear=!0),h=Ze(i,0,e._dayOfYear),e._a[O]=h.getUTCMonth(),e._a[b]=h.getUTCDate()),t=0;t<3&&null==e._a[t];++t)e._a[t]=c[t]=n[t];for(;t<7;t++)e._a[t]=c[t]=null==e._a[t]?2===t?1:0:e._a[t];24===e._a[x]&&0===e._a[T]&&0===e._a[N]&&0===e._a[Ne]&&(e._nextDay=!0,e._a[x]=0),e._d=(e._useUTC?Ze:je).apply(null,c),r=e._useUTC?e._d.getUTCDay():e._d.getDay(),null!=e._tzm&&e._d.setUTCMinutes(e._d.getUTCMinutes()-e._tzm),e._nextDay&&(e._a[x]=24),e._w&&void 0!==e._w.d&&e._w.d!==r&&(m(e).weekdayMismatch=!0)}}function Tt(e){if(e._f===f.ISO_8601)St(e);else if(e._f===f.RFC_2822)Ot(e);else{e._a=[],m(e).empty=!0;for(var t,n,s,i,r,a=""+e._i,o=a.length,u=0,l=ae(e._f,e._locale).match(te)||[],h=l.length,d=0;de.valueOf():e.valueOf()"}),i.toJSON=function(){return this.isValid()?this.toISOString():null},i.toString=function(){return this.clone().locale("en").format("ddd MMM DD YYYY HH:mm:ss [GMT]ZZ")},i.unix=function(){return Math.floor(this.valueOf()/1e3)},i.valueOf=function(){return this._d.valueOf()-6e4*(this._offset||0)},i.creationData=function(){return{input:this._i,format:this._f,locale:this._locale,isUTC:this._isUTC,strict:this._strict}},i.eraName=function(){for(var e,t=this.localeData().eras(),n=0,s=t.length;nthis.clone().month(0).utcOffset()||this.utcOffset()>this.clone().month(5).utcOffset()},i.isLocal=function(){return!!this.isValid()&&!this._isUTC},i.isUtcOffset=function(){return!!this.isValid()&&this._isUTC},i.isUtc=At,i.isUTC=At,i.zoneAbbr=function(){return this._isUTC?"UTC":""},i.zoneName=function(){return this._isUTC?"Coordinated Universal Time":""},i.dates=e("dates accessor is deprecated. Use date instead.",ke),i.months=e("months accessor is deprecated. Use month instead",Ge),i.years=e("years accessor is deprecated. Use year instead",Ie),i.zone=e("moment().zone is deprecated, use moment().utcOffset instead. http://momentjs.com/guides/#/warnings/zone/",function(e,t){return null!=e?(this.utcOffset(e="string"!=typeof e?-e:e,t),this):-this.utcOffset()}),i.isDSTShifted=e("isDSTShifted is deprecated. See http://momentjs.com/guides/#/warnings/dst-shifted/ for more information",function(){if(!o(this._isDSTShifted))return this._isDSTShifted;var e,t={};return $(t,this),(t=Nt(t))._a?(e=(t._isUTC?l:W)(t._a),this._isDSTShifted=this.isValid()&&0 {{end}} + {{ if .LoggedAdmin.HasPermission "manage_event_rules"}} + + {{end}} + {{ if .LoggedAdmin.HasPermission "view_conns"}}