add event manager

auto backup removed from setting. You can now schedule backups with
the event manager

Fixes #762

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2022-07-11 08:17:36 +02:00
parent e46051299f
commit 1b8f94c08f
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
54 changed files with 10238 additions and 869 deletions

View file

@ -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.

View file

@ -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)

View file

@ -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
}
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 &notifier.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
}

View file

@ -125,8 +125,6 @@ var (
Config Configuration
// Connections is the list of active connections
Connections ActiveConnections
// QuotaScans is the list of active quota scans
QuotaScans ActiveScans
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
}

View file

@ -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++ {

View file

@ -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

View file

@ -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"
)
@ -62,6 +64,7 @@ var (
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,
}
}

View file

@ -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)

View file

@ -33,6 +33,8 @@ const (
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()

View file

@ -40,6 +40,7 @@ const (
PermAdminRetentionChecks = "retention_checks"
PermAdminMetadataChecks = "metadata_checks"
PermAdminViewEvents = "view_events"
PermAdminManageEventRules = "manage_event_rules"
)
var (

View file

@ -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
}*/
}

View file

@ -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_<day_of_week>_<hour>.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
@ -592,6 +581,8 @@ type BackupData struct {
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"`
}
@ -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
}

1096
dataprovider/eventrule.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -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...)
}

View file

@ -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)

View file

@ -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)
}

View file

@ -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)
}

127
dataprovider/quota.go Normal file
View file

@ -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
}

View file

@ -14,10 +14,11 @@ import (
var (
scheduler *cron.Cron
lastCachesUpdate int64
// used for bolt and memory providers, so we avoid iterating all users
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)
}

View file

@ -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
}
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)
ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
defer cancel()
return sqlCommonExecuteTx(ctx, dbHandle, func(tx *sql.Tx) error {
q := getUpdateGroupQuery()
_, err := tx.ExecContext(ctx, q, group.Description, settings, util.GetTimeAsMsSinceEpoch(time.Now()), group.Name)
if err != nil {
return err
}
@ -898,11 +901,7 @@ func sqlCommonAddUser(user *User, dbHandle *sql.DB) error {
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
@ -919,7 +918,12 @@ func sqlCommonAddUser(user *User, dbHandle *sql.DB) error {
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, q, user.Username, user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID,
ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
defer cancel()
return sqlCommonExecuteTx(ctx, dbHandle, func(tx *sql.Tx) error {
q := getAddUserQuery()
_, 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,11 +952,7 @@ func sqlCommonUpdateUser(user *User, dbHandle *sql.DB) error {
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
@ -969,7 +969,12 @@ func sqlCommonUpdateUser(user *User, dbHandle *sql.DB) error {
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, q, user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions,
ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
defer cancel()
return sqlCommonExecuteTx(ctx, dbHandle, func(tx *sql.Tx) error {
q := getUpdateUserQuery()
_, 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")
}

View file

@ -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()

View file

@ -19,6 +19,8 @@ const (
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"
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)
}

View file

@ -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)
}

View file

@ -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.

47
docs/eventmanager.md Normal file
View file

@ -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 rules actions are taken. For example you can react to uploads only if they are performed by a particular user or using a specified protocol.
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.

View file

@ -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_<day_of_week>_<hour>.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.

View file

@ -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"

46
go.mod
View file

@ -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

93
go.sum
View file

@ -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=

235
httpd/api_eventrule.go Normal file
View file

@ -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)
}

View file

@ -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
}

View file

@ -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: "/",

View file

@ -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))

View file

@ -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)

File diff suppressed because it is too large Load diff

View file

@ -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)
}

View file

@ -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)
})
}
}

View file

@ -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)
}

View file

@ -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")

View file

@ -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:

View file

@ -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
}

View file

@ -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) {

View file

@ -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)
}

View file

@ -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": [

View file

@ -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)

File diff suppressed because one or more lines are too long

View file

@ -74,6 +74,22 @@
</li>
{{end}}
{{ if .LoggedAdmin.HasPermission "manage_event_rules"}}
<li class="nav-item {{if .IsEventManagerPage}}active{{end}}">
<a class="nav-link {{if not .IsEventManagerPage}}collapsed{{end}}" href="#" data-toggle="collapse" data-target="#collapseEventManager"
aria-expanded="true" aria-controls="collapseEventManager">
<i class="fas fa-calendar-alt"></i>
<span>Event Manager</span>
</a>
<div id="collapseEventManager" class="collapse {{if .IsEventManagerPage}}show{{end}}" aria-labelledby="headingEventManager" data-parent="#accordionSidebar">
<div class="bg-white py-2 collapse-inner rounded">
<a class="collapse-item {{if eq .CurrentURL .EventRulesURL}}active{{end}}" href="{{.EventRulesURL}}">{{.EventRulesTitle}}</a>
<a class="collapse-item {{if eq .CurrentURL .EventActionsURL}}active{{end}}" href="{{.EventActionsURL}}">{{.EventActionsTitle}}</a>
</div>
</div>
</li>
{{end}}
{{ if .LoggedAdmin.HasPermission "view_conns"}}
<li class="nav-item {{if eq .CurrentURL .ConnectionsURL}}active{{end}}">
<a class="nav-link" href="{{.ConnectionsURL}}">
@ -142,6 +158,7 @@
<!-- Topbar Navbar -->
<ul class="navbar-nav ml-auto">
{{block "additionalnavitems" .}}{{end}}
<!-- Nav Item - User Information -->
<li class="nav-item dropdown no-arrow">

View file

@ -0,0 +1,511 @@
{{template "base" .}}
{{define "title"}}{{.Title}}{{end}}
{{define "extra_css"}}
<link href="{{.StaticURL}}/vendor/bootstrap-select/css/bootstrap-select.min.css" rel="stylesheet">
{{end}}
{{define "additionalnavitems"}}
<li class="nav-item dropdown no-arrow mx-1">
<a class="nav-link dropdown-toggle" href="#" id="editorDropdown" role="button"
data-toggle="modal" data-target="#infoModal">
<i class="fas fa-info fa-fw"></i>
</a>
</li>
<div class="topbar-divider d-none d-sm-block"></div>
{{end}}
{{define "page_body"}}
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">{{.Title}}</h6>
</div>
<div class="card-body">
{{if .Error}}
<div class="card mb-4 border-left-warning">
<div class="card-body text-form-error">{{.Error}}</div>
</div>
{{end}}
<form id="eventaction_form" action="{{.CurrentURL}}" method="POST" autocomplete="off">
<div class="form-group row">
<label for="idName" class="col-sm-2 col-form-label">Name</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idName" name="name" placeholder=""
value="{{.Action.Name}}" maxlength="255" autocomplete="nope" required {{if eq .Mode 2}}readonly{{end}}>
</div>
</div>
<div class="form-group row">
<label for="idDescription" class="col-sm-2 col-form-label">Description</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idDescription" name="description" placeholder=""
value="{{.Action.Description}}" maxlength="255" aria-describedby="descriptionHelpBlock">
<small id="descriptionHelpBlock" class="form-text text-muted">
Optional description
</small>
</div>
</div>
<div class="form-group row">
<label for="idType" class="col-sm-2 col-form-label">Type</label>
<div class="col-sm-10">
<select class="form-control selectpicker" id="idType" name="type" onchange="onTypeChanged(this.value)">
{{- range .ActionTypes}}
<option value="{{.Value}}" {{if eq $.Action.Type .Value }}selected{{end}}>{{.Name}}</option>
{{- end}}
</select>
</div>
</div>
<div class="form-group row action-type action-http">
<label for="idHTTPEndpoint" class="col-sm-2 col-form-label">Endpoint</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idHTTPEndpoint" name="http_endpoint" placeholder=""
aria-describedby="HTTPEndpointHelpBlock" value="{{.Action.Options.HTTPConfig.Endpoint}}">
<small id="HTTPEndpointHelpBlock" class="form-text text-muted">
Endpoint URL, i.e https://host:port/path
</small>
</div>
</div>
<div class="form-group row action-type action-http">
<label for="idHTTPUsername" class="col-sm-2 col-form-label">Username</label>
<div class="col-sm-3">
<input type="text" class="form-control" id="idHTTPUsername" name="http_username" placeholder=""
aria-describedby="usernameHelpBlock" value="{{.Action.Options.HTTPConfig.Username}}" maxlength="255">
<small id="httpBodyHelpBlock" class="form-text text-muted">
Placeholders are supported
</small>
</div>
<div class="col-sm-2"></div>
<label for="idHTTPPassword" class="col-sm-2 col-form-label">Password</label>
<div class="col-sm-3">
<input type="password" class="form-control" id="idHTTPPassword" name="http_password" placeholder=""
value="{{if .Action.Options.HTTPConfig.Password.IsEncrypted}}{{.RedactedSecret}}{{else}}{{.Action.Options.HTTPConfig.Password.GetPayload}}{{end}}">
</div>
</div>
<div class="card bg-light mb-3 action-type action-http">
<div class="card-header">
<b>HTTP headers</b>
</div>
<div class="card-body">
<h6 class="card-title mb-4">Placeholders are supported in header values.</h6>
<div class="form-group row">
<div class="col-md-12 form_field_http_headers_outer">
{{range $idx, $val := .Action.Options.HTTPConfig.Headers}}
<div class="row form_field_http_headers_outer_row">
<div class="form-group col-md-5">
<input type="text" class="form-control" id="idHTTPHeaderKey{{$idx}}" name="http_header_key{{$idx}}" placeholder="Enter key" value="{{$val.Key}}">
</div>
<div class="form-group col-md-5">
<input type="text" class="form-control" id="idHTTPHeaderVal{{$idx}}" name="http_header_val{{$idx}}" placeholder="Enter value" value="{{$val.Value}}">
</div>
<div class="form-group col-md-1"></div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_http_header_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{else}}
<div class="row form_field_http_headers_outer_row">
<div class="form-group col-md-5">
<input type="text" class="form-control" id="idHTTPHeaderKey0" name="http_header_key0" placeholder="Enter key" value="">
</div>
<div class="form-group col-md-5">
<input type="text" class="form-control" id="idHTTPHeaderVal0" name="http_header_val0" placeholder="Enter value" value="">
</div>
<div class="form-group col-md-1"></div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_http_header_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{end}}
</div>
</div>
<div class="row mx-1">
<button type="button" class="btn btn-secondary add_new_header_field_btn">
<i class="fas fa-plus"></i> Add new header
</button>
</div>
</div>
</div>
<div class="card bg-light mb-3 action-type action-http">
<div class="card-header">
<b>Query parameters</b>
</div>
<div class="card-body">
<h6 class="card-title mb-4">Placeholders are supported in query values.</h6>
<div class="form-group row">
<div class="col-md-12 form_field_http_query_outer">
{{range $idx, $val := .Action.Options.HTTPConfig.QueryParameters}}
<div class="row form_field_http_query_outer_row">
<div class="form-group col-md-5">
<input type="text" class="form-control" id="idHTTPQueryKey{{$idx}}" name="http_query_key{{$idx}}" placeholder="Enter key" value="{{$val.Key}}">
</div>
<div class="form-group col-md-5">
<input type="text" class="form-control" id="idHTTPQueryVal{{$idx}}" name="http_query_val{{$idx}}" placeholder="Enter value" value="{{$val.Value}}">
</div>
<div class="form-group col-md-1"></div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_http_query_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{else}}
<div class="row form_field_http_query_outer_row">
<div class="form-group col-md-5">
<input type="text" class="form-control" id="idHTTPQueryKey0" name="http_query_key0" placeholder="Enter key" value="">
</div>
<div class="form-group col-md-5">
<input type="text" class="form-control" id="idHTTPQueryVal0" name="http_query_val0" placeholder="Enter value" value="">
</div>
<div class="form-group col-md-1"></div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_http_query_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{end}}
</div>
</div>
<div class="row mx-1">
<button type="button" class="btn btn-secondary add_new_query_field_btn">
<i class="fas fa-plus"></i> Add new parameter
</button>
</div>
</div>
</div>
<div class="form-group row action-type action-http">
<label for="idHTTPMethod" class="col-sm-2 col-form-label">Method</label>
<div class="col-sm-10">
<select class="form-control selectpicker" id="idHTTPMethod" name="http_method">
{{- range .HTTPMethods}}
<option value="{{.}}" {{if eq $.Action.Options.HTTPConfig.Method . }}selected{{end}}>{{.}}</option>
{{- end}}
</select>
</div>
</div>
<div class="form-group row action-type action-http">
<label for="idHTTPBody" class="col-sm-2 col-form-label">Body</label>
<div class="col-sm-10">
<textarea class="form-control" id="idHTTPBody" name="http_body" rows="4" placeholder=""
aria-describedby="httpBodyHelpBlock">{{.Action.Options.HTTPConfig.Body}}</textarea>
<small id="httpBodyHelpBlock" class="form-text text-muted">
Placeholders are supported
</small>
</div>
</div>
<div class="form-group row action-type action-http">
<label for="idHTTPTimeout" class="col-sm-2 col-form-label">Timeout</label>
<div class="col-sm-10">
<input type="number" min="1" max="120" class="form-control" id="idHTTPTimeout" name="http_timeout" placeholder=""
value="{{.Action.Options.HTTPConfig.Timeout}}">
</div>
</div>
<div class="form-group action-type action-http">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="idHTTPSkipTLSVerify" name="http_skip_tls_verify"
{{if .Action.Options.HTTPConfig.SkipTLSVerify}}checked{{end}}>
<label for="idHTTPSkipTLSVerify" class="form-check-label">Skip TLS verify</label>
</div>
</div>
<div class="form-group row action-type action-cmd">
<label for="idCmdPath" class="col-sm-2 col-form-label">Command</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idCmdPath" name="cmd_path" placeholder=""
aria-describedby="CmdPathHelpBlock" value="{{.Action.Options.CmdConfig.Cmd}}">
<small id="CmdPathHelpBlock" class="form-text text-muted">
Absolute path of the command to execute
</small>
</div>
</div>
<div class="form-group row action-type action-cmd">
<label for="idCmdTimeout" class="col-sm-2 col-form-label">Timeout</label>
<div class="col-sm-10">
<input type="number" min="1" max="120" class="form-control" id="idCmdTimeout" name="cmd_timeout" placeholder=""
value="{{.Action.Options.CmdConfig.Timeout}}">
</div>
</div>
<div class="card bg-light mb-3 action-type action-cmd">
<div class="card-header">
<b>Environment variables</b>
</div>
<div class="card-body">
<h6 class="card-title mb-4">Placeholders are supported in values.</h6>
<div class="form-group row">
<div class="col-md-12 form_field_cmd_env_outer">
{{range $idx, $val := .Action.Options.CmdConfig.EnvVars}}
<div class="row form_field_cmd_env_outer_row">
<div class="form-group col-md-5">
<input type="text" class="form-control" id="idCMDEnvKey{{$idx}}" name="cmd_env_key{{$idx}}" placeholder="Enter key" value="{{$val.Key}}">
</div>
<div class="form-group col-md-5">
<input type="text" class="form-control" id="idCMDEnvVal{{$idx}}" name="cmd_env_val{{$idx}}" placeholder="Enter value" value="{{$val.Value}}">
</div>
<div class="form-group col-md-1"></div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_cmd_env_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{else}}
<div class="row form_field_cmd_env_outer_row">
<div class="form-group col-md-5">
<input type="text" class="form-control" id="idCMDEnvKey0" name="cmd_env_key0" placeholder="Enter key" value="">
</div>
<div class="form-group col-md-5">
<input type="text" class="form-control" id="idCMDEnvVal0" name="cmd_env_val0" placeholder="Enter value" value="">
</div>
<div class="form-group col-md-1"></div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_cmd_env_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{end}}
</div>
</div>
<div class="row mx-1">
<button type="button" class="btn btn-secondary add_new_cmd_env_field_btn">
<i class="fas fa-plus"></i> Add environment variable
</button>
</div>
</div>
</div>
<div class="form-group row action-type action-smtp">
<label for="idEmailRecipients" class="col-sm-2 col-form-label">Email recipients</label>
<div class="col-sm-10">
<textarea class="form-control" id="idEmailRecipients" name="email_recipients" rows="2" placeholder=""
aria-describedby="smtpRecipientsHelpBlock">{{.Action.Options.EmailConfig.GetRecipientsAsString}}</textarea>
<small id="smtpRecipientsHelpBlock" class="form-text text-muted">
Comma separated email recipients
</small>
</div>
</div>
<div class="form-group row action-type action-smtp">
<label for="idEmailSubject" class="col-sm-2 col-form-label">Email subject</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idEmailSubject" name="email_subject" placeholder=""
value="{{.Action.Options.EmailConfig.Subject}}" maxlength="255" aria-describedby="emailSubjectHelpBlock">
<small id="emailSubjectHelpBlock" class="form-text text-muted">
Placeholders are supported
</small>
</div>
</div>
<div class="form-group row action-type action-smtp">
<label for="idEmailBody" class="col-sm-2 col-form-label">Email body</label>
<div class="col-sm-10">
<textarea class="form-control" id="idEmailBody" name="email_body" rows="4" placeholder=""
aria-describedby="smtpBodyHelpBlock">{{.Action.Options.EmailConfig.Body}}</textarea>
<small id="smtpBodyHelpBlock" class="form-text text-muted">
Placeholders are supported
</small>
</div>
</div>
<input type="hidden" name="_form_token" value="{{.CSRFToken}}">
<div class="col-sm-12 text-right px-0">
<button type="submit" class="btn btn-primary mt-3 ml-3 px-5" name="form_action" value="submit">Submit</button>
</div>
</form>
</div>
</div>
{{end}}
{{define "dialog"}}
<div class="modal fade" id="infoModal" tabindex="-1" role="dialog" aria-labelledby="infoModalLabel"
aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="infoModalLabel">
Supported placeholders
</h5>
<button class="close" type="button" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>
<span class="shortcut"><b>{{`{{Name}}`}}</b></span> => Username, folder name, or admin username for provider actions.
</p>
<p>
<span class="shortcut"><b>{{`{{Event}}`}}</b></span> => Event name, for example "upload", "download" for filesystem events or "add", "update" for provider events.
</p>
<p>
<span class="shortcut"><b>{{`{{Status}}`}}</b></span> => Status for "upload", "download" and "ssh_cmd" events. 1 means no error, 2 means a generic error occurred, 3 means quota exceeded error.
</p>
<p>
<span class="shortcut"><b>{{`{{VirtualPath}}`}}</b></span> => Path seen by SFTPGo users, for example "/adir/afile.txt".
</p>
<p>
<span class="shortcut"><b>{{`{{FsPath}}`}}</b></span> => Full filesystem path, for example "/user/homedir/adir/afile.txt" or "C:/data/user/homedir/adir/afile.txt" on Windows.
</p>
<p>
<span class="shortcut"><b>{{`{{ObjectName}}`}}</b></span> => File/directory name, for example "afile.txt" or provider object name.
</p>
<p>
<span class="shortcut"><b>{{`{{ObjectType}}`}}</b></span> => Object type for provider events: "user", "group", "admin", etc.
</p>
<p>
<span class="shortcut"><b>{{`{{VirtualTargetPath}}`}}</b></span> => Virtual target path for renames.
</p>
<p>
<span class="shortcut"><b>{{`{{FsTargetPath}}`}}</b></span> => Full filesystem target path for renames.
</p>
<p>
<span class="shortcut"><b>{{`{{FileSize}}`}}</b></span> => File size.
</p>
<p>
<span class="shortcut"><b>{{`{{Protocol}}`}}</b></span> => Protocol, for example "SFTP", "FTP".
</p>
<p>
<span class="shortcut"><b>{{`{{IP}}`}}</b></span> => Client IP address.
</p>
<p>
<span class="shortcut"><b>{{`{{Timestamp}}`}}</b></span> => Event timestamp as nanoseconds since epoch.
</p>
<p>
<span class="shortcut"><b>{{`{{ObjectData}}`}}</b></span> => Provider object data serialized as JSON with sensitive fields removed.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-primary" type="button" data-dismiss="modal">OK</button>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script src="{{.StaticURL}}/vendor/bootstrap-select/js/bootstrap-select.min.js"></script>
<script type="text/javascript">
$("body").on("click", ".add_new_header_field_btn", function () {
var index = $(".form_field_http_headers_outer").find(".form_field_http_headers_outer_row").length;
while (document.getElementById("idHTTPHeaderKey"+index) != null){
index++;
}
$(".form_field_http_headers_outer").append(`
<div class="row form_field_http_headers_outer_row">
<div class="form-group col-md-5">
<input type="text" class="form-control" id="idHTTPHeaderKey${index}" name="http_header_key${index}" placeholder="Enter key" value="">
</div>
<div class="form-group col-md-5">
<input type="text" class="form-control" id="idHTTPHeaderVal${index}" name="http_header_val${index}" placeholder="Enter value" value="">
</div>
<div class="form-group col-md-1"></div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_http_header_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
});
$("body").on("click", ".remove_http_header_btn_frm_field", function () {
$(this).closest(".form_field_http_headers_outer_row").remove();
});
$("body").on("click", ".add_new_query_field_btn", function () {
var index = $(".form_field_http_query_outer").find(".form_field_http_query_outer_row").length;
while (document.getElementById("idHTTPQueryKey"+index) != null){
index++;
}
$(".form_field_http_query_outer").append(`
<div class="row form_field_http_query_outer_row">
<div class="form-group col-md-5">
<input type="text" class="form-control" id="idHTTPQueryKey${index}" name="http_query_key${index}" placeholder="Enter key" value="">
</div>
<div class="form-group col-md-5">
<input type="text" class="form-control" id="idHTTPQueryVal${index}" name="http_query_val${index}" placeholder="Enter value" value="">
</div>
<div class="form-group col-md-1"></div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_http_query_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
});
$("body").on("click", ".remove_http_query_btn_frm_field", function () {
$(this).closest(".form_field_http_query_outer_row").remove();
});
$("body").on("click", ".add_new_cmd_env_field_btn", function () {
var index = $(".form_field_cmd_env_outer").find(".form_field_cmd_env_outer_row").length;
while (document.getElementById("idCMDEnvKey"+index) != null){
index++;
}
$(".form_field_cmd_env_outer").append(`
<div class="row form_field_cmd_env_outer_row">
<div class="form-group col-md-5">
<input type="text" class="form-control" id="idCMDEnvKey${index}" name="cmd_env_key${index}" placeholder="Enter key" value="">
</div>
<div class="form-group col-md-5">
<input type="text" class="form-control" id="idCMDEnvVal${index}" name="cmd_env_val${index}" placeholder="Enter value" value="">
</div>
<div class="form-group col-md-1"></div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_cmd_env_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
});
$("body").on("click", ".remove_cmd_env_btn_frm_field", function () {
$(this).closest(".form_field_cmd_env_outer_row").remove();
});
function onTypeChanged(val){
$('.action-type').hide();
switch (val) {
case '1':
case 1:
$('.action-http').show();
break;
case '2':
case 2:
$('.action-cmd').show();
break;
case '3':
case 3:
$('.action-smtp').show();
break;
}
}
$(document).ready(function () {
onTypeChanged('{{.Action.Type}}');
});
</script>
{{end}}

View file

@ -0,0 +1,206 @@
{{template "base" .}}
{{define "title"}}{{.Title}}{{end}}
{{define "extra_css"}}
<link href="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.css" rel="stylesheet">
<link href="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.css" rel="stylesheet">
<link href="{{.StaticURL}}/vendor/datatables/fixedHeader.bootstrap4.min.css" rel="stylesheet">
<link href="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.css" rel="stylesheet">
<link href="{{.StaticURL}}/vendor/datatables/select.bootstrap4.min.css" rel="stylesheet">
{{end}}
{{define "page_body"}}
<div id="errorMsg" class="card mb-4 border-left-warning" style="display: none;">
<div id="errorTxt" class="card-body text-form-error"></div>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">View and manage event actions</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Type</th>
<th>Rules</th>
</tr>
</thead>
<tbody>
{{range .Actions}}
<tr>
<td>{{.Name}}</td>
<td>{{.Description}}</td>
<td>{{.GetTypeAsString}}</td>
<td>{{.GetRulesAsString}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
{{end}}
{{define "dialog"}}
<div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel"
aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">
Confirmation required
</h5>
<button class="close" type="button" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">Do you want to delete the selected event action? A referenced action cannot be removed</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-dismiss="modal">
Cancel
</button>
<a class="btn btn-warning" href="#" onclick="deleteAction()">
Delete
</a>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script src="{{.StaticURL}}/vendor/datatables/jquery.dataTables.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.buttons.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/buttons.colVis.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.fixedHeader.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.responsive.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.select.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/ellipsis.js"></script>
<script type="text/javascript">
function deleteAction() {
var table = $('#dataTable').DataTable();
table.button('delete:name').enable(false);
var name = table.row({ selected: true }).data()[0];
var path = '{{.EventActionURL}}' + "/" + fixedEncodeURIComponent(name);
$('#deleteModal').modal('hide');
$.ajax({
url: path,
type: 'DELETE',
dataType: 'json',
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
timeout: 15000,
success: function (result) {
window.location.href = '{{.EventActionsURL}}';
},
error: function ($xhr, textStatus, errorThrown) {
var txt = "Unable to delete the selected action";
if ($xhr) {
var json = $xhr.responseJSON;
if (json) {
if (json.message){
txt += ": " + json.message;
} else {
txt += ": " + json.error;
}
}
}
$('#errorTxt').text(txt);
$('#errorMsg').show();
setTimeout(function () {
$('#errorMsg').hide();
}, 5000);
}
});
}
$(document).ready(function () {
$.fn.dataTable.ext.buttons.add = {
text: '<i class="fas fa-plus"></i>',
name: 'add',
titleAttr: "Add",
action: function (e, dt, node, config) {
window.location.href = '{{.EventActionURL}}';
}
};
$.fn.dataTable.ext.buttons.edit = {
text: '<i class="fas fa-pen"></i>',
name: 'edit',
titleAttr: "Edit",
action: function (e, dt, node, config) {
var name = table.row({ selected: true }).data()[0];
var path = '{{.EventActionURL}}' + "/" + fixedEncodeURIComponent(name);
window.location.href = path;
},
enabled: false
};
$.fn.dataTable.ext.buttons.delete = {
text: '<i class="fas fa-trash"></i>',
name: 'delete',
titleAttr: "Delete",
action: function (e, dt, node, config) {
$('#deleteModal').modal('show');
},
enabled: false
};
var table = $('#dataTable').DataTable({
"select": {
"style": "single",
"blurable": true
},
"stateSave": true,
"stateDuration": 0,
"buttons": [
{
"text": "Column visibility",
"extend": "colvis",
"columns": ":not(.noVis)"
}
],
"columnDefs": [
{
"targets": [0],
"className": "noVis"
},
{
"targets": [3],
"render": $.fn.dataTable.render.ellipsis(100, true)
},
],
"scrollX": false,
"scrollY": false,
"responsive": true,
"language": {
"emptyTable": "No event actions defined"
},
"order": [[0, 'asc']]
});
new $.fn.dataTable.FixedHeader( table );
table.button().add(0,'delete');
table.button().add(0,'edit');
table.button().add(0,'add');
table.buttons().container().appendTo('.col-md-6:eq(0)', table.table().container());
table.on('select deselect', function () {
var selectedRows = table.rows({ selected: true }).count();
table.button('delete:name').enable(selectedRows == 1);
table.button('edit:name').enable(selectedRows == 1);
});
});
</script>
{{end}}

View file

@ -0,0 +1,541 @@
{{template "base" .}}
{{define "title"}}{{.Title}}{{end}}
{{define "extra_css"}}
<link href="{{.StaticURL}}/vendor/bootstrap-select/css/bootstrap-select.min.css" rel="stylesheet">
{{end}}
{{define "page_body"}}
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">{{.Title}}</h6>
</div>
<div class="card-body">
{{if .Error}}
<div class="card mb-4 border-left-warning">
<div class="card-body text-form-error">{{.Error}}</div>
</div>
{{end}}
<form id="eventrule_form" action="{{.CurrentURL}}" method="POST" autocomplete="off">
<div class="form-group row">
<label for="idName" class="col-sm-2 col-form-label">Name</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idName" name="name" placeholder=""
value="{{.Rule.Name}}" maxlength="255" autocomplete="nope" required {{if eq .Mode 2}}readonly{{end}}>
</div>
</div>
<div class="form-group row">
<label for="idDescription" class="col-sm-2 col-form-label">Description</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idDescription" name="description" placeholder=""
value="{{.Rule.Description}}" maxlength="255" aria-describedby="descriptionHelpBlock">
<small id="descriptionHelpBlock" class="form-text text-muted">
Optional description
</small>
</div>
</div>
<div class="form-group row">
<label for="idTrigger" class="col-sm-2 col-form-label">Trigger</label>
<div class="col-sm-10">
<select class="form-control selectpicker" id="idTrigger" name="trigger" onchange="onTriggerChanged(this.value)">
{{- range .TriggerTypes}}
<option value="{{.Value}}" {{if eq $.Rule.Trigger .Value }}selected{{end}}>{{.Name}}</option>
{{- end}}
</select>
</div>
</div>
<div class="form-group row trigger trigger-fs">
<label for="idFsEvents" class="col-sm-2 col-form-label">Fs events</label>
<div class="col-sm-10">
<select class="form-control selectpicker" id="idFsEvents" name="fs_events" multiple>
{{- range $event := .FsEvents}}
<option value="{{$event}}" {{- range $.Rule.Conditions.FsEvents }}{{- if eq . $event}}selected{{- end}}{{- end}}>{{$event}}</option>
{{- end}}
</select>
</div>
</div>
<div class="form-group row trigger trigger-provider">
<label for="idProviderEvents" class="col-sm-2 col-form-label">Provider events</label>
<div class="col-sm-10">
<select class="form-control selectpicker" id="idProviderEvents" name="provider_events" multiple>
{{- range $event := .ProviderEvents}}
<option value="{{$event}}" {{- range $.Rule.Conditions.ProviderEvents }}{{- if eq . $event}}selected{{- end}}{{- end}}>{{$event}}</option>
{{- end}}
</select>
</div>
</div>
<div class="card bg-light mb-3 trigger trigger-schedule">
<div class="card-header">
<b>Schedules</b>
</div>
<div class="card-body">
<h6 class="card-title mb-4">Hours: 0-23. Day of week: 0-6 (Sun-Sat). Day of month: 1-31. Month: 1-12. Asterisk (*) indicates a match for all the values of the field. e.g. every day of week, every day of month and so on. More <a href="https://pkg.go.dev/github.com/robfig/cron/v3#hdr-CRON_Expression_Format" target="_blank">info</a>.</h6>
<div class="form-group row">
<div class="col-md-12 form_field_schedules_outer">
{{range $idx, $val := .Rule.Conditions.Schedules}}
<div class="row form_field_schedules_outer_row">
<div class="form-group col-md-3">
<input type="text" class="form-control" id="idScheduleHour{{$idx}}" name="schedule_hour{{$idx}}" placeholder="Hours" value="{{$val.Hours}}">
</div>
<div class="form-group col-md-3">
<input type="text" class="form-control" id="idScheduleDayOfWeek{{$idx}}" name="schedule_day_of_week{{$idx}}" placeholder="Day of week" value="{{$val.DayOfWeek}}">
</div>
<div class="form-group col-md-3">
<input type="text" class="form-control" id="idScheduleDayOfMonth{{$idx}}" name="schedule_day_of_month{{$idx}}" placeholder="Day of month" value="{{$val.DayOfMonth}}">
</div>
<div class="form-group col-md-2">
<input type="text" class="form-control" id="idScheduleMonth{{$idx}}" name="schedule_month{{$idx}}" placeholder="Month" value="{{$val.Month}}">
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_schedule_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{else}}
<div class="row form_field_schedules_outer_row">
<div class="form-group col-md-3">
<input type="text" class="form-control" id="idScheduleHour0" name="schedule_hour0" placeholder="Hours" value="">
</div>
<div class="form-group col-md-3">
<input type="text" class="form-control" id="idScheduleDayOfWeek0" name="schedule_day_of_week0" placeholder="Day of week" value="">
</div>
<div class="form-group col-md-3">
<input type="text" class="form-control" id="idScheduleDayOfMonth0" name="schedule_day_of_month0" placeholder="Day of month" value="">
</div>
<div class="form-group col-md-2">
<input type="text" class="form-control" id="idScheduleMonth0" name="schedule_month0" placeholder="Month" value="">
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_schedule_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{end}}
</div>
</div>
<div class="row mx-1">
<button type="button" class="btn btn-secondary add_new_schedule_field_btn">
<i class="fas fa-plus"></i> Add new schedule
</button>
</div>
</div>
</div>
{{if .IsShared}}
<div class="form-group trigger trigger-schedule">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="idConcurrentExecution" name="concurrent_execution"
{{if .Rule.Conditions.Options.ConcurrentExecution}}checked{{end}}>
<label for="idConcurrentExecution" class="form-check-label">Allow concurrent execution from multiple instances</label>
</div>
</div>
{{end}}
<div class="form-group row trigger trigger-fs">
<label for="idFsProtocols" class="col-sm-2 col-form-label">Protocol filters</label>
<div class="col-sm-10">
<select class="form-control selectpicker" id="idFsProtocols" name="fs_protocols" aria-describedby="fsProtocolsHelpBlock" multiple>
{{- range $p := .Protocols}}
<option value="{{$p}}" {{- range $.Rule.Conditions.Options.Protocols }}{{- if eq . $p}}selected{{- end}}{{- end}}>{{$p}}</option>
{{- end}}
</select>
<small id="fsProtocolsHelpBlock" class="form-text text-muted">
No selection means any protocol will trigger events
</small>
</div>
</div>
<div class="form-group row trigger trigger-provider">
<label for="idProviderObjects" class="col-sm-2 col-form-label">Object filters</label>
<div class="col-sm-10">
<select class="form-control selectpicker" id="idProviderObjects" name="provider_objects" aria-describedby="providerObjectsHelpBlock" multiple>
{{- range $p := .ProviderObjects}}
<option value="{{$p}}" {{- range $.Rule.Conditions.Options.ProviderObjects }}{{- if eq . $p}}selected{{- end}}{{- end}}>{{$p}}</option>
{{- end}}
</select>
<small id="providerObjectsHelpBlock" class="form-text text-muted">
No selection means any provider object will trigger events
</small>
</div>
</div>
<div class="card bg-light mb-3">
<div class="card-header">
<b>Name filters</b>
</div>
<div class="card-body">
<h6 class="card-title mb-4">Shell-like pattern filters for usernames, folder names. For example "user*"" will match names starting with "user"</h6>
<div class="form-group row">
<div class="col-md-12 form_field_names_outer">
{{range $idx, $val := .Rule.Conditions.Options.Names}}
<div class="row form_field_names_outer_row">
<div class="form-group col-md-8">
<input type="text" class="form-control" id="idNamePattern{{$idx}}" name="name_pattern{{$idx}}" placeholder="" value="{{$val.Pattern}}" maxlength="255">
</div>
<div class="form-group col-md-3">
<select class="form-control selectpicker" id="idNamePatternType{{$idx}}" name="type_name_pattern{{$idx}}">
<option value=""></option>
<option value="inverse" {{if $val.InverseMatch}}selected{{end}}>Inverse match</option>
</select>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_name_pattern_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{else}}
<div class="row form_field_names_outer_row">
<div class="form-group col-md-8">
<input type="text" class="form-control" id="idNamePattern0" name="name_pattern0" placeholder="" value="" maxlength="255">
</div>
<div class="form-group col-md-3">
<select class="form-control selectpicker" id="idNamePatternType0" name="type_name_pattern0">
<option value=""></option>
<option value="inverse">Inverse match</option>
</select>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_name_pattern_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{end}}
</div>
</div>
<div class="row mx-1">
<button type="button" class="btn btn-secondary add_new_name_pattern_field_btn">
<i class="fas fa-plus"></i> Add new filter
</button>
</div>
</div>
</div>
<div class="card bg-light mb-3 trigger trigger-fs">
<div class="card-header">
<b>Path filters</b>
</div>
<div class="card-body">
<h6 class="card-title mb-4">Shell-like pattern filters for filesystem events. For example "/adir/*.txt"" will match paths in the "/adir" directory ending with ".txt"</h6>
<div class="form-group row">
<div class="col-md-12 form_field_fs_paths_outer">
{{range $idx, $val := .Rule.Conditions.Options.FsPaths}}
<div class="row form_field_fs_paths_outer_row">
<div class="form-group col-md-8">
<input type="text" class="form-control" id="idFsPathPattern{{$idx}}" name="fs_path_pattern{{$idx}}" placeholder="" value="{{$val.Pattern}}" maxlength="255">
</div>
<div class="form-group col-md-3">
<select class="form-control selectpicker" id="idFsPathPatternType{{$idx}}" name="type_fs_path_pattern{{$idx}}">
<option value=""></option>
<option value="inverse" {{if $val.InverseMatch}}selected{{end}}>Inverse match</option>
</select>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_fs_path_pattern_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{else}}
<div class="row form_field_fs_paths_outer_row">
<div class="form-group col-md-8">
<input type="text" class="form-control" id="idFsPathPattern0" name="fs_path_pattern0" placeholder="" value="" maxlength="255">
</div>
<div class="form-group col-md-3">
<select class="form-control selectpicker" id="idFsPathPatternType0" name="type_fs_path_pattern0">
<option value=""></option>
<option value="inverse">Inverse match</option>
</select>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_fs_path_pattern_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{end}}
</div>
</div>
<div class="row mx-1">
<button type="button" class="btn btn-secondary add_new_fs_path_pattern_field_btn">
<i class="fas fa-plus"></i> Add new filter
</button>
</div>
</div>
</div>
<div class="card bg-light mb-3 trigger trigger-fs">
<div class="card-header">
<b>File size limits. 0 means no limit</b>
</div>
<div class="card-body">
<div class="form-group row">
<label for="idFsMinSize" class="col-sm-2 col-form-label">Min size</label>
<div class="col-sm-3">
<input type="number" class="form-control" id="idFsMinSize" name="fs_min_size" placeholder=""
value="{{.Rule.Conditions.Options.MinFileSize}}" min="0">
</div>
<div class="col-sm-2"></div>
<label for="idFsMaxSize" class="col-sm-2 col-form-label">Max size</label>
<div class="col-sm-3">
<input type="number" class="form-control" id="idFsMaxSize" name="fs_max_size" placeholder=""
value="{{.Rule.Conditions.Options.MaxFileSize}}" min="0">
</div>
</div>
</div>
</div>
<div class="card bg-light mb-3">
<div class="card-header">
<b>Actions</b>
</div>
<div class="card-body">
<h6 class="card-title mb-4">One or more actions to execute. The "Execute sync" options is only supported for upload events</h6>
<div class="form-group row">
<div class="col-md-12 form_field_action_outer">
{{range $idx, $val := .Rule.Actions}}
<div class="row form_field_action_outer_row">
<div class="form-group col-md-5">
<select class="form-control selectpicker" data-live-search="true" id="idActionName{{$idx}}" name="action_name{{$idx}}">
<option value=""></option>
{{range $.Actions}}
<option value="{{.Name}}" {{if eq $val.Name .Name}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
<div class="form-group col-md-5">
<select class="form-control selectpicker" id="idActionOptions{{$idx}}" name="action_options{{$idx}}" multiple>
<option value="2" {{if $val.Options.StopOnFailure}}selected{{end}}>Stop on failure</option>
<option value="3" {{if $val.Options.ExecuteSync}}selected{{end}}>Execute sync</option>
<option value="1" {{if $val.Options.IsFailureAction}}selected{{end}}>Is failure action</option>
</select>
</div>
<div class="col-sm-1">
<input type="hidden" name="action_order{{$idx}}" value="{{$idx}}">
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_action_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{else}}
<div class="row form_field_action_outer_row">
<div class="form-group col-md-5">
<select class="form-control selectpicker" data-live-search="true" id="idActionName0" name="action_name0">
<option value=""></option>
{{range $.Actions}}
<option value="{{.Name}}">{{.Name}}</option>
{{end}}
</select>
</div>
<div class="form-group col-md-5">
<select class="form-control selectpicker" id="idActionOptions0" name="action_options0" multiple>
<option value="1">Is failure action</option>
<option value="2">Stop on failure</option>
<option value="3">Execute sync</option>
</select>
</div>
<div class="col-sm-1">
<input type="hidden" name="action_order0" value="0">
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_action_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{end}}
</div>
</div>
<div class="row mx-1">
<button type="button" class="btn btn-secondary add_new_action_field_btn">
<i class="fas fa-plus"></i> Add new action
</button>
</div>
</div>
</div>
<input type="hidden" name="_form_token" value="{{.CSRFToken}}">
<div class="col-sm-12 text-right px-0">
<button type="submit" class="btn btn-primary mt-3 ml-3 px-5" name="form_action" value="submit">Submit</button>
</div>
</form>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script src="{{.StaticURL}}/vendor/bootstrap-select/js/bootstrap-select.min.js"></script>
<script type="text/javascript">
$("body").on("click", ".add_new_schedule_field_btn", function () {
var index = $(".form_field_schedules_outer").find(".form_field_schedules_outer_row").length;
while (document.getElementById("idScheduleHour"+index) != null){
index++;
}
$(".form_field_schedules_outer").append(`
<div class="row form_field_schedules_outer_row">
<div class="form-group col-md-3">
<input type="text" class="form-control" id="idScheduleHour${index}" name="schedule_hour${index}" placeholder="Hours" value="">
</div>
<div class="form-group col-md-3">
<input type="text" class="form-control" id="idScheduleDayOfWeek${index}" name="schedule_day_of_week${index}" placeholder="Day of week" value="">
</div>
<div class="form-group col-md-3">
<input type="text" class="form-control" id="idScheduleDayOfMonth${index}" name="schedule_day_of_month${index}" placeholder="Day of month" value="">
</div>
<div class="form-group col-md-2">
<input type="text" class="form-control" id="idScheduleMonth${index}" name="schedule_month${index}" placeholder="Month" value="">
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_schedule_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
});
$("body").on("click", ".remove_schedule_btn_frm_field", function () {
$(this).closest(".form_field_schedules_outer_row").remove();
});
$("body").on("click", ".add_new_name_pattern_field_btn", function () {
var index = $(".form_field_names_outer").find(".form_field_names_outer_row").length;
while (document.getElementById("idNamePattern"+index) != null){
index++;
}
$(".form_field_names_outer").append(`
<div class="row form_field_names_outer_row">
<div class="form-group col-md-8">
<input type="text" class="form-control" id="idNamePattern${index}" name="name_pattern${index}" placeholder="" value="" maxlength="255">
</div>
<div class="form-group col-md-3">
<select class="form-control" id="idNamePatternType${index}" name="type_name_pattern${index}">
<option value=""></option>
<option value="inverse">Inverse match</option>
</select>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_name_pattern_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
$("#idNamePatternType"+index).selectpicker();
});
$("body").on("click", ".remove_name_pattern_btn_frm_field", function () {
$(this).closest(".form_field_names_outer_row").remove();
});
$("body").on("click", ".add_new_fs_path_pattern_field_btn", function () {
var index = $(".form_field_fs_paths_outer").find("form_field_fs_paths_outer_row").length;
while (document.getElementById("idFsPathPattern"+index) != null){
index++;
}
$(".form_field_fs_paths_outer").append(`
<div class="row form_field_fs_paths_outer_row">
<div class="form-group col-md-8">
<input type="text" class="form-control" id="idFsPathPattern${index}" name="fs_path_pattern${index}" placeholder="" value="" maxlength="255">
</div>
<div class="form-group col-md-3">
<select class="form-control" id="idFsPathPatternType${index}" name="type_fs_path_pattern${index}">
<option value=""></option>
<option value="inverse">Inverse match</option>
</select>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_fs_path_pattern_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
$("#idFsPathPatternType"+index).selectpicker();
});
$("body").on("click", ".remove_fs_path_pattern_btn_frm_field", function () {
$(this).closest(".form_field_fs_paths_outer_row").remove();
});
$("body").on("click", ".add_new_action_field_btn", function () {
var index = $(".form_field_action_outer").find("form_field_action_outer_row").length;
while (document.getElementById("idActionName"+index) != null){
index++;
}
$(".form_field_action_outer").append(`
<div class="row form_field_action_outer_row">
<div class="form-group col-md-5">
<select class="form-control" id="idActionName${index}" name="action_name${index}">
<option value=""></option>
</select>
</div>
<div class="form-group col-md-5">
<select class="form-control" id="idActionOptions${index}" name="action_options${index}" multiple>
<option value="1">Is failure action</option>
<option value="2">Stop on failure</option>
<option value="3">Execute sync</option>
</select>
</div>
<div class="col-sm-1">
<input type="hidden" name="action_order${index}" value="${index}">
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_action_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
{{- range .Actions}}
$("#idActionName"+index).append($('<option>').val('{{.Name}}').text('{{.Name}}'));
{{- end}}
console.log("index "+index);
$("#idActionName"+index).selectpicker({'liveSearch': true});
$("#idActionOptions"+index).selectpicker();
});
$("body").on("click", ".remove_action_btn_frm_field", function () {
$(this).closest(".form_field_action_outer_row").remove();
});
function onTriggerChanged(val){
$('.trigger').hide();
switch (val) {
case '1':
case 1:
$('.trigger-fs').show();
break;
case '2':
case 2:
$('.trigger-provider').show();
break;
case '3':
case 3:
$('.trigger-schedule').show();
break;
default:
console.log(`unsupported event trigger type: ${val}`);
}
}
$(document).ready(function () {
onTriggerChanged('{{.Rule.Trigger}}');
});
</script>
{{end}}

View file

@ -0,0 +1,206 @@
{{template "base" .}}
{{define "title"}}{{.Title}}{{end}}
{{define "extra_css"}}
<link href="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.css" rel="stylesheet">
<link href="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.css" rel="stylesheet">
<link href="{{.StaticURL}}/vendor/datatables/fixedHeader.bootstrap4.min.css" rel="stylesheet">
<link href="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.css" rel="stylesheet">
<link href="{{.StaticURL}}/vendor/datatables/select.bootstrap4.min.css" rel="stylesheet">
{{end}}
{{define "page_body"}}
<div id="errorMsg" class="card mb-4 border-left-warning" style="display: none;">
<div id="errorTxt" class="card-body text-form-error"></div>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">View and manage event rules</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Trigger</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Rules}}
<tr>
<td>{{.Name}}</td>
<td>{{.Description}}</td>
<td>{{.GetTriggerAsString}}</td>
<td>{{.GetActionsAsString}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
{{end}}
{{define "dialog"}}
<div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel"
aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">
Confirmation required
</h5>
<button class="close" type="button" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">Do you want to delete the selected rule?</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-dismiss="modal">
Cancel
</button>
<a class="btn btn-warning" href="#" onclick="deleteAction()">
Delete
</a>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script src="{{.StaticURL}}/vendor/datatables/jquery.dataTables.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.buttons.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/buttons.colVis.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.fixedHeader.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.responsive.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.select.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/ellipsis.js"></script>
<script type="text/javascript">
function deleteAction() {
var table = $('#dataTable').DataTable();
table.button('delete:name').enable(false);
var name = table.row({ selected: true }).data()[0];
var path = '{{.EventRuleURL}}' + "/" + fixedEncodeURIComponent(name);
$('#deleteModal').modal('hide');
$.ajax({
url: path,
type: 'DELETE',
dataType: 'json',
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
timeout: 15000,
success: function (result) {
window.location.href = '{{.EventRulesURL}}';
},
error: function ($xhr, textStatus, errorThrown) {
var txt = "Unable to delete the selected rule";
if ($xhr) {
var json = $xhr.responseJSON;
if (json) {
if (json.message){
txt += ": " + json.message;
} else {
txt += ": " + json.error;
}
}
}
$('#errorTxt').text(txt);
$('#errorMsg').show();
setTimeout(function () {
$('#errorMsg').hide();
}, 5000);
}
});
}
$(document).ready(function () {
$.fn.dataTable.ext.buttons.add = {
text: '<i class="fas fa-plus"></i>',
name: 'add',
titleAttr: "Add",
action: function (e, dt, node, config) {
window.location.href = '{{.EventRuleURL}}';
}
};
$.fn.dataTable.ext.buttons.edit = {
text: '<i class="fas fa-pen"></i>',
name: 'edit',
titleAttr: "Edit",
action: function (e, dt, node, config) {
var name = table.row({ selected: true }).data()[0];
var path = '{{.EventRuleURL}}' + "/" + fixedEncodeURIComponent(name);
window.location.href = path;
},
enabled: false
};
$.fn.dataTable.ext.buttons.delete = {
text: '<i class="fas fa-trash"></i>',
name: 'delete',
titleAttr: "Delete",
action: function (e, dt, node, config) {
$('#deleteModal').modal('show');
},
enabled: false
};
var table = $('#dataTable').DataTable({
"select": {
"style": "single",
"blurable": true
},
"stateSave": true,
"stateDuration": 0,
"buttons": [
{
"text": "Column visibility",
"extend": "colvis",
"columns": ":not(.noVis)"
}
],
"columnDefs": [
{
"targets": [0],
"className": "noVis"
},
{
"targets": [3],
"render": $.fn.dataTable.render.ellipsis(100, true)
},
],
"scrollX": false,
"scrollY": false,
"responsive": true,
"language": {
"emptyTable": "No event rules defined"
},
"order": [[0, 'asc']]
});
new $.fn.dataTable.FixedHeader( table );
table.button().add(0,'delete');
table.button().add(0,'edit');
table.button().add(0,'add');
table.buttons().container().appendTo('.col-md-6:eq(0)', table.table().container());
table.on('select deselect', function () {
var selectedRows = table.rows({ selected: true }).count();
table.button('delete:name').enable(selectedRows == 1);
table.button('edit:name').enable(selectedRows == 1);
});
});
</script>
{{end}}

View file

@ -15,10 +15,6 @@
<div id="errorTxt" class="card-body text-form-error"></div>
</div>
<div id="successMsg" class="card mb-4 border-left-success" style="display: none;">
<div id="successTxt" class="card-body"></div>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">View and manage groups</h6>

View file

@ -78,154 +78,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_dav.dat"
testDLFileName = "test_download_dav.dat"