diff --git a/docs/eventmanager.md b/docs/eventmanager.md index afbd361c..91bf9569 100644 --- a/docs/eventmanager.md +++ b/docs/eventmanager.md @@ -18,6 +18,7 @@ The following actions are supported: - `Delete`. You can delete one or more files and directories. - `Create directories`. You can create one or more directories including sub-directories. - `Path exists`. Check if the specified path exists. + - `Compress paths`. You can compress (currently as zip) ore or more files and directories. The following placeholders are supported: diff --git a/go.mod b/go.mod index 8f55dc17..fb8a078a 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,7 @@ require ( github.com/hashicorp/go-hclog v1.3.1 github.com/hashicorp/go-plugin v1.4.5 github.com/hashicorp/go-retryablehttp v0.7.1 - github.com/jackc/pgx/v5 v5.0.1 + github.com/jackc/pgx/v5 v5.0.2 github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126 github.com/klauspost/compress v1.15.11 github.com/lestrrat-go/jwx v1.2.25 diff --git a/go.sum b/go.sum index ce4694c7..fbb64325 100644 --- a/go.sum +++ b/go.sum @@ -1014,8 +1014,8 @@ github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQ github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= github.com/jackc/pgx/v4 v4.16.0/go.mod h1:N0A9sFdWzkw/Jy1lwoiB64F2+ugFZi987zRxcPez/wI= github.com/jackc/pgx/v4 v4.16.1/go.mod h1:SIhx0D5hoADaiXZVyv+3gSm3LCIIINTVO0PficsvWGQ= -github.com/jackc/pgx/v5 v5.0.1 h1:JZu9othr7l8so2JMDAGeDUMXqERAuZpovyfl4H50tdg= -github.com/jackc/pgx/v5 v5.0.1/go.mod h1:JBbvW3Hdw77jKl9uJrEDATUZIFM2VFPzRq4RWIhkF4o= +github.com/jackc/pgx/v5 v5.0.2 h1:V+EonE9i33VwJR9YIHRdglAmrODLLkwIdHjko6b1rRk= +github.com/jackc/pgx/v5 v5.0.2/go.mod h1:JBbvW3Hdw77jKl9uJrEDATUZIFM2VFPzRq4RWIhkF4o= github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= diff --git a/internal/common/connection.go b/internal/common/connection.go index 5e3495cd..43b0b7f0 100644 --- a/internal/common/connection.go +++ b/internal/common/connection.go @@ -608,8 +608,9 @@ func (c *BaseConnection) getPathForSetStatPerms(fs vfs.Fs, fsPath, virtualPath s return pathForPerms } -// DoStat execute a Stat if mode = 0, Lstat if mode = 1 -func (c *BaseConnection) DoStat(virtualPath string, mode int, checkFilePatterns bool) (os.FileInfo, error) { +func (c *BaseConnection) doStatInternal(virtualPath string, mode int, checkFilePatterns, + convertResult bool, +) (os.FileInfo, error) { // for some vfs we don't create intermediary folders so we cannot simply check // if virtualPath is a virtual folder vfolders := c.User.GetVirtualFoldersInPath(path.Dir(virtualPath)) @@ -639,12 +640,17 @@ func (c *BaseConnection) DoStat(virtualPath string, mode int, checkFilePatterns c.Log(logger.LevelWarn, "stat error for path %#v: %+v", virtualPath, err) return info, c.GetFsError(fs, err) } - if vfs.IsCryptOsFs(fs) { + if convertResult && vfs.IsCryptOsFs(fs) { info = fs.(*vfs.CryptFs).ConvertFileInfo(info) } return info, nil } +// DoStat execute a Stat if mode = 0, Lstat if mode = 1 +func (c *BaseConnection) DoStat(virtualPath string, mode int, checkFilePatterns bool) (os.FileInfo, error) { + return c.doStatInternal(virtualPath, mode, checkFilePatterns, true) +} + func (c *BaseConnection) createDirIfMissing(name string) error { _, err := c.DoStat(name, 0, false) if c.IsNotExistError(err) { diff --git a/internal/common/connection_test.go b/internal/common/connection_test.go index 22256245..061d5bed 100644 --- a/internal/common/connection_test.go +++ b/internal/common/connection_test.go @@ -319,7 +319,7 @@ func TestErrorsMapping(t *testing.T) { fs := vfs.NewOsFs("", os.TempDir(), "") conn := NewBaseConnection("", ProtocolSFTP, "", "", dataprovider.User{BaseUser: sdk.BaseUser{HomeDir: os.TempDir()}}) osErrorsProtocols := []string{ProtocolWebDAV, ProtocolFTP, ProtocolHTTP, ProtocolHTTPShare, - ProtocolDataRetention, ProtocolOIDC} + ProtocolDataRetention, ProtocolOIDC, protocolEventAction} for _, protocol := range supportedProtocols { conn.SetProtocol(protocol) err := conn.GetFsError(fs, os.ErrNotExist) diff --git a/internal/common/eventmanager.go b/internal/common/eventmanager.go index 029936c2..17c2377e 100644 --- a/internal/common/eventmanager.go +++ b/internal/common/eventmanager.go @@ -621,6 +621,161 @@ func getCSVRetentionReport(results []folderRetentionCheckResult) ([]byte, error) return b.Bytes(), err } +func closeWriterAndUpdateQuota(w io.WriteCloser, conn *BaseConnection, virtualPath string, numFiles int, + truncatedSize int64, errTransfer error, +) error { + errWrite := w.Close() + info, err := conn.doStatInternal(virtualPath, 0, false, false) + if err == nil { + updateUserQuotaAfterFileWrite(conn, virtualPath, numFiles, info.Size()-truncatedSize) + _, fsPath, errFs := conn.GetFsAndResolvedPath(virtualPath) + if errFs == nil { + if errTransfer == nil { + errTransfer = errWrite + } + ExecuteActionNotification(conn, operationUpload, fsPath, virtualPath, "", "", "", info.Size(), errTransfer) //nolint:errcheck + } + } else { + eventManagerLog(logger.LevelWarn, "unable to update quota after writing %q: %v", virtualPath, err) + } + return errWrite +} + +func updateUserQuotaAfterFileWrite(conn *BaseConnection, virtualPath string, numFiles int, fileSize int64) { + vfolder, err := conn.User.GetVirtualFolderForPath(path.Dir(virtualPath)) + if err != nil { + dataprovider.UpdateUserQuota(&conn.User, numFiles, fileSize, false) //nolint:errcheck + return + } + dataprovider.UpdateVirtualFolderQuota(&vfolder.BaseVirtualFolder, numFiles, fileSize, false) //nolint:errcheck + if vfolder.IsIncludedInUserQuota() { + dataprovider.UpdateUserQuota(&conn.User, numFiles, fileSize, false) //nolint:errcheck + } +} + +func getFileWriter(conn *BaseConnection, virtualPath string) (io.WriteCloser, int, int64, func(), error) { + fs, fsPath, err := conn.GetFsAndResolvedPath(virtualPath) + if err != nil { + return nil, 0, 0, nil, err + } + var truncatedSize, fileSize int64 + numFiles := 1 + isFileOverwrite := false + + info, err := fs.Lstat(fsPath) + if err == nil { + fileSize = info.Size() + if info.IsDir() { + return nil, numFiles, truncatedSize, nil, fmt.Errorf("cannot write to a directory: %q", virtualPath) + } + if info.Mode().IsRegular() { + isFileOverwrite = true + truncatedSize = fileSize + } + numFiles = 0 + } + if err != nil && !fs.IsNotExist(err) { + return nil, numFiles, truncatedSize, nil, conn.GetFsError(fs, err) + } + f, w, cancelFn, err := fs.Create(fsPath, 0) + if err != nil { + return nil, numFiles, truncatedSize, nil, conn.GetFsError(fs, err) + } + vfs.SetPathPermissions(fs, fsPath, conn.User.GetUID(), conn.User.GetGID()) + + if isFileOverwrite { + if vfs.HasTruncateSupport(fs) || vfs.IsCryptOsFs(fs) { + updateUserQuotaAfterFileWrite(conn, virtualPath, numFiles, -fileSize) + truncatedSize = 0 + } + } + if cancelFn == nil { + cancelFn = func() {} + } + if f != nil { + return f, numFiles, truncatedSize, cancelFn, nil + } + return w, numFiles, truncatedSize, cancelFn, nil +} + +func addZipEntry(wr *zipWriterWrapper, conn *BaseConnection, entryPath, baseDir string) error { + if entryPath == wr.Name { + // skip the archive itself + return nil + } + info, err := conn.DoStat(entryPath, 1, false) + if err != nil { + eventManagerLog(logger.LevelError, "unable to add zip entry %#v, stat error: %v", entryPath, err) + return err + } + entryName, err := getZipEntryName(entryPath, baseDir) + if err != nil { + eventManagerLog(logger.LevelError, "unable to get zip entry name: %v", err) + return err + } + if _, ok := wr.Entries[entryName]; ok { + eventManagerLog(logger.LevelInfo, "skipping duplicate zip entry %q, is dir %t", entryPath, info.IsDir()) + return nil + } + wr.Entries[entryName] = true + if info.IsDir() { + _, err = wr.Writer.CreateHeader(&zip.FileHeader{ + Name: entryName + "/", + Method: zip.Deflate, + Modified: info.ModTime(), + }) + if err != nil { + eventManagerLog(logger.LevelError, "unable to create zip entry %q: %v", entryPath, err) + return fmt.Errorf("unable to create zip entry %q: %w", entryPath, err) + } + contents, err := conn.ListDir(entryPath) + if err != nil { + eventManagerLog(logger.LevelError, "unable to add zip entry %q, read dir error: %v", entryPath, err) + return fmt.Errorf("unable to add zip entry %q: %w", entryPath, err) + } + for _, info := range contents { + fullPath := util.CleanPath(path.Join(entryPath, info.Name())) + if err := addZipEntry(wr, conn, fullPath, baseDir); err != nil { + eventManagerLog(logger.LevelError, "unable to add zip entry: %v", err) + return err + } + } + return nil + } + if !info.Mode().IsRegular() { + // we only allow regular files + eventManagerLog(logger.LevelInfo, "skipping zip entry for non regular file %q", entryPath) + return nil + } + reader, cancelFn, err := getFileReader(conn, entryPath) + if err != nil { + eventManagerLog(logger.LevelError, "unable to add zip entry %q, cannot open file: %v", entryPath, err) + return fmt.Errorf("unable to open %q: %w", entryPath, err) + } + defer cancelFn() + defer reader.Close() + + f, err := wr.Writer.CreateHeader(&zip.FileHeader{ + Name: entryName, + Method: zip.Deflate, + Modified: info.ModTime(), + }) + if err != nil { + eventManagerLog(logger.LevelError, "unable to create zip entry %q: %v", entryPath, err) + return fmt.Errorf("unable to create zip entry %q: %w", entryPath, err) + } + _, err = io.Copy(f, reader) + return err +} + +func getZipEntryName(entryPath, baseDir string) (string, error) { + if !strings.HasPrefix(entryPath, baseDir) { + return "", fmt.Errorf("entry path %q is outside base dir %q", entryPath, baseDir) + } + entryPath = strings.TrimPrefix(entryPath, baseDir) + return strings.TrimPrefix(entryPath, "/"), nil +} + func getFileReader(conn *BaseConnection, virtualPath string) (io.ReadCloser, func(), error) { fs, fsPath, err := conn.GetFsAndResolvedPath(virtualPath) if err != nil { @@ -628,7 +783,7 @@ func getFileReader(conn *BaseConnection, virtualPath string) (io.ReadCloser, fun } f, r, cancelFn, err := fs.Open(fsPath, 0) if err != nil { - return nil, nil, err + return nil, nil, conn.GetFsError(fs, err) } if cancelFn == nil { cancelFn = func() {} @@ -1035,8 +1190,13 @@ func getUserForEventAction(user dataprovider.User) (dataprovider.User, error) { eventManagerLog(logger.LevelError, "unable to get group for user %q: %+v", user.Username, err) return dataprovider.User{}, fmt.Errorf("unable to get groups for user %q", user.Username) } + user.UploadDataTransfer = 0 + user.UploadBandwidth = 0 + user.DownloadBandwidth = 0 user.Filters.DisableFsChecks = false user.Filters.FilePatterns = nil + user.Filters.BandwidthLimits = nil + user.Filters.DataTransferLimits = nil for k := range user.Permissions { user.Permissions[k] = []string{dataprovider.PermAny} } @@ -1279,6 +1439,72 @@ func executeRenameFsRuleAction(renames []dataprovider.KeyValue, replacer *string return nil } +func getArchiveBaseDir(paths []string) string { + var parentDirs []string + for _, p := range paths { + parentDirs = append(parentDirs, path.Dir(p)) + } + parentDirs = util.RemoveDuplicates(parentDirs, false) + baseDir := "/" + if len(parentDirs) == 1 { + baseDir = parentDirs[0] + } + return baseDir +} + +func executeCompressFsActionForUser(c dataprovider.EventActionFsCompress, replacer *strings.Replacer, + user dataprovider.User, +) error { + user, err := getUserForEventAction(user) + if err != nil { + return err + } + connectionID := fmt.Sprintf("%s_%s", protocolEventAction, xid.New().String()) + err = user.CheckFsRoot(connectionID) + defer user.CloseFs() //nolint:errcheck + if err != nil { + return fmt.Errorf("compress error, unable to check root fs for user %q: %w", user.Username, err) + } + conn := NewBaseConnection(connectionID, protocolEventAction, "", "", user) + name := util.CleanPath(replaceWithReplacer(c.Name, replacer)) + paths := make([]string, 0, len(c.Paths)) + for idx := range c.Paths { + p := util.CleanPath(replaceWithReplacer(c.Paths[idx], replacer)) + if p == name { + return fmt.Errorf("cannot compress the archive to create: %q", name) + } + paths = append(paths, p) + } + writer, numFiles, truncatedSize, cancelFn, err := getFileWriter(conn, name) + if err != nil { + eventManagerLog(logger.LevelError, "unable to create archive %q: %v", name, err) + return fmt.Errorf("unable to create archive: %w", err) + } + defer cancelFn() + + paths = util.RemoveDuplicates(paths, false) + baseDir := getArchiveBaseDir(paths) + eventManagerLog(logger.LevelDebug, "creating archive %q for paths %+v", name, paths) + + zipWriter := &zipWriterWrapper{ + Name: name, + Writer: zip.NewWriter(writer), + Entries: make(map[string]bool), + } + for _, item := range paths { + if err := addZipEntry(zipWriter, conn, item, baseDir); err != nil { + closeWriterAndUpdateQuota(writer, conn, name, numFiles, truncatedSize, err) //nolint:errcheck + return err + } + } + if err := zipWriter.Writer.Close(); err != nil { + eventManagerLog(logger.LevelError, "unable to close zip file %q: %v", name, err) + closeWriterAndUpdateQuota(writer, conn, name, numFiles, truncatedSize, err) //nolint:errcheck + return fmt.Errorf("unable to close zip file %q: %w", name, err) + } + return closeWriterAndUpdateQuota(writer, conn, name, numFiles, truncatedSize, err) +} + func executeExistFsRuleAction(exist []string, replacer *strings.Replacer, conditions dataprovider.ConditionOptions, params *EventParams, ) error { @@ -1319,6 +1545,46 @@ func executeExistFsRuleAction(exist []string, replacer *strings.Replacer, condit return nil } +func executeCompressFsRuleAction(c dataprovider.EventActionFsCompress, replacer *strings.Replacer, + conditions dataprovider.ConditionOptions, params *EventParams, +) error { + users, err := params.getUsers() + if err != nil { + return fmt.Errorf("unable to get users: %w", err) + } + var failures []string + executed := 0 + for _, user := range users { + // if sender is set, the conditions have already been evaluated + if params.sender == "" { + if !checkEventConditionPatterns(user.Username, conditions.Names) { + eventManagerLog(logger.LevelDebug, "skipping fs compress for user %s, name conditions don't match", + user.Username) + continue + } + if !checkEventGroupConditionPatters(user.Groups, conditions.GroupNames) { + eventManagerLog(logger.LevelDebug, "skipping fs compress for user %s, group name conditions don't match", + user.Username) + continue + } + } + executed++ + if err = executeCompressFsActionForUser(c, replacer, user); err != nil { + failures = append(failures, user.Username) + params.AddError(err) + continue + } + } + if len(failures) > 0 { + return fmt.Errorf("fs compress failed for users: %+v", failures) + } + if executed == 0 { + eventManagerLog(logger.LevelError, "no file/folder compressed") + return errors.New("no file/folder compressed") + } + return nil +} + func executeFsRuleAction(c dataprovider.EventActionFilesystemConfig, conditions dataprovider.ConditionOptions, params *EventParams, ) error { @@ -1334,6 +1600,8 @@ func executeFsRuleAction(c dataprovider.EventActionFilesystemConfig, conditions return executeMkdirFsRuleAction(c.MkDirs, replacer, conditions, params) case dataprovider.FilesystemActionExist: return executeExistFsRuleAction(c.Exist, replacer, conditions, params) + case dataprovider.FilesystemActionCompress: + return executeCompressFsRuleAction(c.Compress, replacer, conditions, params) default: return fmt.Errorf("unsupported filesystem action %d", c.Type) } @@ -1818,6 +2086,12 @@ func (j *eventCronJob) Run() { eventManagerLog(logger.LevelDebug, "execution for scheduled rule %q finished", j.ruleName) } +type zipWriterWrapper struct { + Name string + Entries map[string]bool + Writer *zip.Writer +} + func eventManagerLog(level logger.LogLevel, format string, v ...any) { logger.Log(level, "eventmanager", "", format, v...) } diff --git a/internal/common/eventmanager_test.go b/internal/common/eventmanager_test.go index a7224530..0f32434f 100644 --- a/internal/common/eventmanager_test.go +++ b/internal/common/eventmanager_test.go @@ -15,6 +15,7 @@ package common import ( + "bytes" "crypto/rand" "fmt" "io" @@ -28,6 +29,7 @@ import ( "testing" "time" + "github.com/klauspost/compress/zip" "github.com/sftpgo/sdk" sdkkms "github.com/sftpgo/sdk/kms" "github.com/stretchr/testify/assert" @@ -333,6 +335,8 @@ func TestEventManagerErrors(t *testing.T) { assert.Error(t, err) err = executeExistFsRuleAction(nil, nil, dataprovider.ConditionOptions{}, &EventParams{}) assert.Error(t, err) + err = executeCompressFsRuleAction(dataprovider.EventActionFsCompress{}, nil, dataprovider.ConditionOptions{}, &EventParams{}) + assert.Error(t, err) groupName := "agroup" err = executeQuotaResetForUser(dataprovider.User{ @@ -398,6 +402,15 @@ func TestEventManagerErrors(t *testing.T) { }, }) assert.Error(t, err) + err = executeCompressFsActionForUser(dataprovider.EventActionFsCompress{}, nil, dataprovider.User{ + Groups: []sdk.GroupMapping{ + { + Name: groupName, + Type: sdk.GroupTypePrimary, + }, + }, + }) + assert.Error(t, err) _, err = getMailAttachments(dataprovider.User{ Groups: []sdk.GroupMapping{ { @@ -576,9 +589,8 @@ func TestEventRuleActions(t *testing.T) { }, } err = executeRuleAction(action, params, dataprovider.ConditionOptions{}) - if assert.Error(t, err) { - assert.Contains(t, err.Error(), "error getting user") - } + assert.Contains(t, getErrorString(err), "error getting user") + action.Options.HTTPConfig.Parts = nil action.Options.HTTPConfig.Body = "{{ObjectData}}" // test disk and transfer quota reset @@ -656,9 +668,8 @@ func TestEventRuleActions(t *testing.T) { }, }, }) - if assert.Error(t, err) { - assert.Contains(t, err.Error(), "no user quota reset executed") - } + assert.Error(t, err) + assert.Contains(t, getErrorString(err), "no user quota reset executed") action = dataprovider.BaseEventAction{ Type: dataprovider.ActionTypeMetadataCheck, @@ -671,9 +682,8 @@ func TestEventRuleActions(t *testing.T) { }, }, }) - if assert.Error(t, err) { - assert.Contains(t, err.Error(), "no metadata check executed") - } + assert.Error(t, err) + assert.Contains(t, getErrorString(err), "no metadata check executed") err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{ Names: []dataprovider.ConditionPattern{ @@ -784,9 +794,9 @@ func TestEventRuleActions(t *testing.T) { }, }, }) - if assert.Error(t, err) { - assert.Contains(t, err.Error(), "no retention check executed") - } + assert.Error(t, err) + assert.Contains(t, getErrorString(err), "no retention check executed") + // test file exists action action = dataprovider.BaseEventAction{ Type: dataprovider.ActionTypeFilesystem, @@ -804,9 +814,9 @@ func TestEventRuleActions(t *testing.T) { }, }, }) - if assert.Error(t, err) { - assert.Contains(t, err.Error(), "no existence check executed") - } + assert.Error(t, err) + assert.Contains(t, getErrorString(err), "no existence check executed") + err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{ Names: []dataprovider.ConditionPattern{ { @@ -852,9 +862,9 @@ func TestEventRuleActions(t *testing.T) { }, }, }) - if assert.Error(t, err) { - assert.Contains(t, err.Error(), "no transfer quota reset executed") - } + assert.Error(t, err) + assert.Contains(t, getErrorString(err), "no transfer quota reset executed") + action.Type = dataprovider.ActionTypeFilesystem action.Options = dataprovider.BaseEventActionOptions{ FsConfig: dataprovider.EventActionFilesystemConfig{ @@ -874,9 +884,9 @@ func TestEventRuleActions(t *testing.T) { }, }, }) - if assert.Error(t, err) { - assert.Contains(t, err.Error(), "no rename executed") - } + assert.Error(t, err) + assert.Contains(t, getErrorString(err), "no rename executed") + action.Options = dataprovider.BaseEventActionOptions{ FsConfig: dataprovider.EventActionFilesystemConfig{ Type: dataprovider.FilesystemActionDelete, @@ -890,9 +900,9 @@ func TestEventRuleActions(t *testing.T) { }, }, }) - if assert.Error(t, err) { - assert.Contains(t, err.Error(), "no delete executed") - } + assert.Error(t, err) + assert.Contains(t, getErrorString(err), "no delete executed") + action.Options = dataprovider.BaseEventActionOptions{ FsConfig: dataprovider.EventActionFilesystemConfig{ Type: dataprovider.FilesystemActionMkdirs, @@ -906,9 +916,37 @@ func TestEventRuleActions(t *testing.T) { }, }, }) - if assert.Error(t, err) { - assert.Contains(t, err.Error(), "no mkdir executed") + assert.Error(t, err) + assert.Contains(t, getErrorString(err), "no mkdir executed") + + action.Options = dataprovider.BaseEventActionOptions{ + FsConfig: dataprovider.EventActionFilesystemConfig{ + Type: dataprovider.FilesystemActionCompress, + Compress: dataprovider.EventActionFsCompress{ + Name: "test.zip", + Paths: []string{"/{{VirtualPath}}"}, + }, + }, } + err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{ + Names: []dataprovider.ConditionPattern{ + { + Pattern: "no match", + }, + }, + }) + assert.Error(t, err) + assert.Contains(t, getErrorString(err), "no file/folder compressed") + + err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{ + GroupNames: []dataprovider.ConditionPattern{ + { + Pattern: "no match", + }, + }, + }) + assert.Error(t, err) + assert.Contains(t, getErrorString(err), "no file/folder compressed") err = dataprovider.DeleteUser(username1, "", "") assert.NoError(t, err) @@ -1164,6 +1202,10 @@ func TestFilesystemActionErrors(t *testing.T) { assert.Error(t, err) err = executeExistFsActionForUser(nil, testReplacer, user) assert.Error(t, err) + err = executeCompressFsActionForUser(dataprovider.EventActionFsCompress{}, testReplacer, user) + assert.Error(t, err) + _, _, _, _, err = getFileWriter(conn, "/path.txt") //nolint:dogsled + assert.Error(t, err) err = executeEmailRuleAction(dataprovider.EventActionEmailConfig{ Recipients: []string{"test@example.net"}, Subject: "subject", @@ -1230,7 +1272,7 @@ func TestFilesystemActionErrors(t *testing.T) { err := os.MkdirAll(dirPath, os.ModePerm) assert.NoError(t, err) filePath := filepath.Join(dirPath, "f.dat") - err = os.WriteFile(filePath, nil, 0666) + err = os.WriteFile(filePath, []byte("test file content"), 0666) assert.NoError(t, err) err = os.Chmod(dirPath, 0001) assert.NoError(t, err) @@ -1290,6 +1332,16 @@ func TestFilesystemActionErrors(t *testing.T) { err = os.Chmod(dirPath, os.ModePerm) assert.NoError(t, err) + + conn = NewBaseConnection("", protocolEventAction, "", "", user) + wr := &zipWriterWrapper{ + Name: "test.zip", + Writer: zip.NewWriter(bytes.NewBuffer(nil)), + Entries: map[string]bool{}, + } + err = addZipEntry(wr, conn, "/adir/sub/f.dat", "/adir/sub/sub") + assert.Error(t, err) + assert.Contains(t, getErrorString(err), "is outside base dir") } err = dataprovider.DeleteUser(username, "", "") @@ -1545,3 +1597,10 @@ func TestWriteHTTPPartsError(t *testing.T) { }, nil, nil, nil, &EventParams{}) assert.ErrorIs(t, err, io.ErrUnexpectedEOF) } + +func getErrorString(err error) string { + if err == nil { + return "" + } + return err.Error() +} diff --git a/internal/common/protocol_test.go b/internal/common/protocol_test.go index cac1bad8..355b912b 100644 --- a/internal/common/protocol_test.go +++ b/internal/common/protocol_test.go @@ -38,6 +38,7 @@ import ( _ "github.com/jackc/pgx/v5/stdlib" _ "github.com/mattn/go-sqlite3" "github.com/mhale/smtpd" + "github.com/minio/sio" "github.com/pkg/sftp" "github.com/pquerna/otp" "github.com/pquerna/otp/totp" @@ -4108,6 +4109,385 @@ func TestEventActionHTTPMultipart(t *testing.T) { assert.NoError(t, err) } +func TestEventActionCompress(t *testing.T) { + a1 := dataprovider.BaseEventAction{ + Name: "action1", + Type: dataprovider.ActionTypeFilesystem, + Options: dataprovider.BaseEventActionOptions{ + FsConfig: dataprovider.EventActionFilesystemConfig{ + Type: dataprovider.FilesystemActionCompress, + Compress: dataprovider.EventActionFsCompress{ + Name: "/{{VirtualPath}}.zip", + Paths: []string{"/{{VirtualPath}}"}, + }, + }, + }, + } + action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated) + assert.NoError(t, err) + r1 := dataprovider.EventRule{ + Name: "test compress", + Trigger: dataprovider.EventTriggerFsEvent, + Conditions: dataprovider.EventConditions{ + FsEvents: []string{"upload"}, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, + }, + Order: 1, + Options: dataprovider.EventActionOptions{ + ExecuteSync: true, + }, + }, + }, + } + rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated) + assert.NoError(t, err) + + u := getTestUser() + u.QuotaFiles = 1000 + localUser, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + u = getTestSFTPUser() + u.FsConfig.SFTPConfig.BufferSize = 1 + u.QuotaFiles = 1000 + sftpUser, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + u = getCryptFsUser() + u.QuotaFiles = 1000 + cryptFsUser, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + for _, user := range []dataprovider.User{localUser, sftpUser, cryptFsUser} { + // cleanup home dir + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + rule1.Conditions.Options.Names = []dataprovider.ConditionPattern{ + { + Pattern: user.Username, + }, + } + _, _, err = httpdtest.UpdateEventRule(rule1, http.StatusOK) + assert.NoError(t, err) + + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + expectedQuotaSize := int64(len(testFileContent)) + expectedQuotaFiles := 1 + if user.Username == cryptFsUser.Username { + encryptedFileSize, err := getEncryptedFileSize(expectedQuotaSize) + assert.NoError(t, err) + expectedQuotaSize = encryptedFileSize + } + + f, err := client.Create(testFileName) + assert.NoError(t, err) + _, err = f.Write(testFileContent) + assert.NoError(t, err) + err = f.Close() + assert.NoError(t, err) + info, err := client.Stat(testFileName + ".zip") + if assert.NoError(t, err) { + assert.Greater(t, info.Size(), int64(0)) + // check quota + archiveSize := info.Size() + if user.Username == cryptFsUser.Username { + encryptedFileSize, err := getEncryptedFileSize(archiveSize) + assert.NoError(t, err) + archiveSize = encryptedFileSize + } + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, expectedQuotaFiles+1, user.UsedQuotaFiles, + "quota file does no match for user %q", user.Username) + assert.Equal(t, expectedQuotaSize+archiveSize, user.UsedQuotaSize, + "quota size does no match for user %q", user.Username) + } + // now overwrite the same file + f, err = client.Create(testFileName) + assert.NoError(t, err) + _, err = f.Write(testFileContent) + assert.NoError(t, err) + err = f.Close() + assert.NoError(t, err) + info, err = client.Stat(testFileName + ".zip") + if assert.NoError(t, err) { + assert.Greater(t, info.Size(), int64(0)) + archiveSize := info.Size() + if user.Username == cryptFsUser.Username { + encryptedFileSize, err := getEncryptedFileSize(archiveSize) + assert.NoError(t, err) + archiveSize = encryptedFileSize + } + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, expectedQuotaFiles+1, user.UsedQuotaFiles, + "quota file after overwrite does no match for user %q", user.Username) + assert.Equal(t, expectedQuotaSize+archiveSize, user.UsedQuotaSize, + "quota size after overwrite does no match for user %q", user.Username) + } + } + if user.Username == localUser.Username { + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + } + } + + _, err = httpdtest.RemoveEventRule(rule1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(sftpUser, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(localUser, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(localUser.GetHomeDir()) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(cryptFsUser, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(cryptFsUser.GetHomeDir()) + assert.NoError(t, err) +} + +func TestEventActionCompressQuotaFolder(t *testing.T) { + testDir := "/folder" + a1 := dataprovider.BaseEventAction{ + Name: "action1", + Type: dataprovider.ActionTypeFilesystem, + Options: dataprovider.BaseEventActionOptions{ + FsConfig: dataprovider.EventActionFilesystemConfig{ + Type: dataprovider.FilesystemActionCompress, + Compress: dataprovider.EventActionFsCompress{ + Name: "/{{VirtualPath}}.zip", + Paths: []string{"/{{VirtualPath}}", testDir}, + }, + }, + }, + } + action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated) + assert.NoError(t, err) + r1 := dataprovider.EventRule{ + Name: "test compress", + Trigger: dataprovider.EventTriggerFsEvent, + Conditions: dataprovider.EventConditions{ + FsEvents: []string{"upload"}, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, + }, + Order: 1, + Options: dataprovider.EventActionOptions{ + ExecuteSync: true, + }, + }, + }, + } + rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated) + assert.NoError(t, err) + u := getTestUser() + u.QuotaFiles = 1000 + mappedPath := filepath.Join(os.TempDir(), "virtualpath") + folderName := filepath.Base(mappedPath) + vdirPath := "/virtualpath" + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + Name: folderName, + MappedPath: mappedPath, + }, + VirtualPath: vdirPath, + QuotaSize: -1, + QuotaFiles: -1, + }) + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + err = client.Mkdir(testDir) + assert.NoError(t, err) + expectedQuotaSize := int64(len(testFileContent)) + expectedQuotaFiles := 1 + err = client.Symlink(path.Join(testDir, testFileName), path.Join(testDir, testFileName+"_link")) + assert.NoError(t, err) + f, err := client.Create(path.Join(testDir, testFileName)) + assert.NoError(t, err) + _, err = f.Write(testFileContent) + assert.NoError(t, err) + err = f.Close() + assert.NoError(t, err) + info, err := client.Stat(path.Join(testDir, testFileName) + ".zip") + if assert.NoError(t, err) { + assert.Greater(t, info.Size(), int64(0)) + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + expectedQuotaFiles++ + expectedQuotaSize += info.Size() + assert.Equal(t, expectedQuotaFiles, user.UsedQuotaFiles) + assert.Equal(t, expectedQuotaSize, user.UsedQuotaSize) + } + vfolder, _, err := httpdtest.GetFolderByName(folderName, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 0, vfolder.UsedQuotaFiles) + assert.Equal(t, int64(0), vfolder.UsedQuotaSize) + // upload in the virtual path + f, err = client.Create(path.Join(vdirPath, testFileName)) + assert.NoError(t, err) + _, err = f.Write(testFileContent) + assert.NoError(t, err) + err = f.Close() + assert.NoError(t, err) + info, err = client.Stat(path.Join(vdirPath, testFileName) + ".zip") + if assert.NoError(t, err) { + assert.Greater(t, info.Size(), int64(0)) + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + expectedQuotaFiles += 2 + expectedQuotaSize += info.Size() + int64(len(testFileContent)) + assert.Equal(t, expectedQuotaFiles, user.UsedQuotaFiles) + assert.Equal(t, expectedQuotaSize, user.UsedQuotaSize) + vfolder, _, err := httpdtest.GetFolderByName(folderName, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 2, vfolder.UsedQuotaFiles) + assert.Equal(t, info.Size()+int64(len(testFileContent)), vfolder.UsedQuotaSize) + } + } + + _, err = httpdtest.RemoveEventRule(rule1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + _, err = httpdtest.RemoveFolder(vfs.BaseVirtualFolder{Name: folderName}, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath) + assert.NoError(t, err) +} + +func TestEventActionCompressErrors(t *testing.T) { + a1 := dataprovider.BaseEventAction{ + Name: "action1", + Type: dataprovider.ActionTypeFilesystem, + Options: dataprovider.BaseEventActionOptions{ + FsConfig: dataprovider.EventActionFilesystemConfig{ + Type: dataprovider.FilesystemActionCompress, + Compress: dataprovider.EventActionFsCompress{ + Name: "/{{VirtualPath}}.zip", + Paths: []string{"/{{VirtualPath}}.zip"}, // cannot compress itself + }, + }, + }, + } + action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated) + assert.NoError(t, err) + r1 := dataprovider.EventRule{ + Name: "test compress", + Trigger: dataprovider.EventTriggerFsEvent, + Conditions: dataprovider.EventConditions{ + FsEvents: []string{"upload"}, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, + }, + Order: 1, + Options: dataprovider.EventActionOptions{ + ExecuteSync: true, + }, + }, + }, + } + rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated) + assert.NoError(t, err) + + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + f, err := client.Create(testFileName) + assert.NoError(t, err) + _, err = f.Write(testFileContent) + assert.NoError(t, err) + err = f.Close() + assert.Error(t, err) + } + // try to compress a missing file + action1.Options.FsConfig.Compress.Paths = []string{"/missing file"} + _, _, 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() + + f, err := client.Create(testFileName) + assert.NoError(t, err) + _, err = f.Write(testFileContent) + assert.NoError(t, err) + err = f.Close() + assert.Error(t, err) + } + // try to overwrite a directory + testDir := "/adir" + action1.Options.FsConfig.Compress.Name = testDir + action1.Options.FsConfig.Compress.Paths = []string{"/{{VirtualPath}}"} + _, _, 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() + + err = client.Mkdir(testDir) + assert.NoError(t, err) + f, err := client.Create(testFileName) + assert.NoError(t, err) + _, err = f.Write(testFileContent) + assert.NoError(t, err) + err = f.Close() + assert.Error(t, err) + } + // try to write to a missing directory + action1.Options.FsConfig.Compress.Name = "/subdir/missing/path/file.zip" + _, _, 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() + + f, err := client.Create(testFileName) + assert.NoError(t, err) + _, err = f.Write(testFileContent) + assert.NoError(t, err) + err = f.Close() + assert.Error(t, err) + } + + _, err = httpdtest.RemoveEventRule(rule1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + func TestEventActionEmailAttachments(t *testing.T) { smtpCfg := smtp.Config{ Host: "127.0.0.1", @@ -4120,17 +4500,32 @@ func TestEventActionEmailAttachments(t *testing.T) { a1 := dataprovider.BaseEventAction{ Name: "action1", + Type: dataprovider.ActionTypeFilesystem, + Options: dataprovider.BaseEventActionOptions{ + FsConfig: dataprovider.EventActionFilesystemConfig{ + Type: dataprovider.FilesystemActionCompress, + Compress: dataprovider.EventActionFsCompress{ + Name: "/{{VirtualPath}}.zip", + Paths: []string{"/{{VirtualPath}}"}, + }, + }, + }, + } + action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated) + assert.NoError(t, err) + a2 := dataprovider.BaseEventAction{ + Name: "action2", Type: dataprovider.ActionTypeEmail, Options: dataprovider.BaseEventActionOptions{ EmailConfig: dataprovider.EventActionEmailConfig{ Recipients: []string{"test@example.com"}, Subject: `"{{Event}}" from "{{Name}}"`, Body: "Fs path {{FsPath}}, size: {{FileSize}}, protocol: {{Protocol}}, IP: {{IP}}", - Attachments: []string{"/{{VirtualPath}}"}, + Attachments: []string{"/{{VirtualPath}}.zip"}, }, }, } - action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated) + action2, _, err := httpdtest.AddEventAction(a2, http.StatusCreated) assert.NoError(t, err) r1 := dataprovider.EventRule{ Name: "test email with attachment", @@ -4145,6 +4540,12 @@ func TestEventActionEmailAttachments(t *testing.T) { }, Order: 1, }, + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action2.Name, + }, + Order: 2, + }, }, } rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated) @@ -4185,6 +4586,8 @@ func TestEventActionEmailAttachments(t *testing.T) { 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.RemoveUser(sftpUser, http.StatusOK) assert.NoError(t, err) _, err = httpdtest.RemoveUser(localUser, http.StatusOK) @@ -5957,6 +6360,11 @@ func isDbDefenderSupported() bool { } } +func getEncryptedFileSize(size int64) (int64, error) { + encSize, err := sio.EncryptedSize(uint64(size)) + return int64(encSize) + 33, err +} + func printLatestLogs(maxNumberOfLines int) { var lines []string f, err := os.Open(logFilePath) diff --git a/internal/dataprovider/eventrule.go b/internal/dataprovider/eventrule.go index 24b29b02..ae5b251e 100644 --- a/internal/dataprovider/eventrule.go +++ b/internal/dataprovider/eventrule.go @@ -48,9 +48,9 @@ const ( ) var ( - supportedEventActions = []int{ActionTypeHTTP, ActionTypeCommand, ActionTypeEmail, ActionTypeBackup, - ActionTypeUserQuotaReset, ActionTypeFolderQuotaReset, ActionTypeTransferQuotaReset, - ActionTypeDataRetentionCheck, ActionTypeMetadataCheck, ActionTypeFilesystem} + supportedEventActions = []int{ActionTypeHTTP, ActionTypeCommand, ActionTypeEmail, ActionTypeFilesystem, + ActionTypeBackup, ActionTypeUserQuotaReset, ActionTypeFolderQuotaReset, ActionTypeTransferQuotaReset, + ActionTypeDataRetentionCheck, ActionTypeMetadataCheck} ) func isActionTypeValid(action int) bool { @@ -123,6 +123,7 @@ const ( FilesystemActionDelete FilesystemActionMkdirs FilesystemActionExist + FilesystemActionCompress ) const ( @@ -132,7 +133,7 @@ const ( var ( supportedFsActions = []int{FilesystemActionRename, FilesystemActionDelete, FilesystemActionMkdirs, - FilesystemActionExist} + FilesystemActionCompress, FilesystemActionExist} ) func isFilesystemActionValid(value int) bool { @@ -147,6 +148,8 @@ func getFsActionTypeAsString(value int) string { return "Delete" case FilesystemActionExist: return "Paths exist" + case FilesystemActionCompress: + return "Compress" default: return "Create directories" } @@ -539,6 +542,36 @@ func (c *EventActionDataRetentionConfig) validate() error { return nil } +// EventActionFsCompress defines the configuration for the compress filesystem action +type EventActionFsCompress struct { + // Archive path + Name string `json:"name,omitempty"` + // Paths to compress + Paths []string `json:"paths,omitempty"` +} + +func (c *EventActionFsCompress) validate() error { + if c.Name == "" { + return util.NewValidationError("archive name is mandatory") + } + c.Name = util.CleanPath(strings.TrimSpace(c.Name)) + if c.Name == "/" { + return util.NewValidationError("invalid archive name") + } + if len(c.Paths) == 0 { + return util.NewValidationError("no path to compress specified") + } + for idx, val := range c.Paths { + val = strings.TrimSpace(val) + if val == "" { + return util.NewValidationError("invalid path to compress") + } + c.Paths[idx] = util.CleanPath(val) + } + c.Paths = util.RemoveDuplicates(c.Paths, false) + return nil +} + // EventActionFilesystemConfig defines the configuration for filesystem actions type EventActionFilesystemConfig struct { // Filesystem actions, see the above enum @@ -551,6 +584,8 @@ type EventActionFilesystemConfig struct { Deletes []string `json:"deletes,omitempty"` // file/dirs to check for existence Exist []string `json:"exist,omitempty"` + // paths to compress and archive name + Compress EventActionFsCompress `json:"compress"` } // GetDeletesAsString returns the list of items to delete as comma separated string. @@ -571,6 +606,12 @@ func (c EventActionFilesystemConfig) GetExistAsString() string { return strings.Join(c.Exist, ",") } +// GetCompressPathsAsString returns the list of items to compress as comma separated string. +// Using a pointer receiver will not work in web templates +func (c EventActionFilesystemConfig) GetCompressPathsAsString() string { + return strings.Join(c.Compress.Paths, ",") +} + func (c *EventActionFilesystemConfig) validateRenames() error { if len(c.Renames) == 0 { return util.NewValidationError("no path to rename specified") @@ -651,6 +692,7 @@ func (c *EventActionFilesystemConfig) validate() error { c.MkDirs = nil c.Deletes = nil c.Exist = nil + c.Compress = EventActionFsCompress{} if err := c.validateRenames(); err != nil { return err } @@ -658,6 +700,7 @@ func (c *EventActionFilesystemConfig) validate() error { c.Renames = nil c.MkDirs = nil c.Exist = nil + c.Compress = EventActionFsCompress{} if err := c.validateDeletes(); err != nil { return err } @@ -665,6 +708,7 @@ func (c *EventActionFilesystemConfig) validate() error { c.Renames = nil c.Deletes = nil c.Exist = nil + c.Compress = EventActionFsCompress{} if err := c.validateMkdirs(); err != nil { return err } @@ -672,9 +716,18 @@ func (c *EventActionFilesystemConfig) validate() error { c.Renames = nil c.Deletes = nil c.MkDirs = nil + c.Compress = EventActionFsCompress{} if err := c.validateExist(); err != nil { return err } + case FilesystemActionCompress: + c.Renames = nil + c.MkDirs = nil + c.Deletes = nil + c.Exist = nil + if err := c.Compress.validate(); err != nil { + return err + } } return nil } @@ -686,6 +739,8 @@ func (c *EventActionFilesystemConfig) getACopy() EventActionFilesystemConfig { copy(deletes, c.Deletes) exist := make([]string, len(c.Exist)) copy(exist, c.Exist) + compressPaths := make([]string, len(c.Compress.Paths)) + copy(compressPaths, c.Compress.Paths) return EventActionFilesystemConfig{ Type: c.Type, @@ -693,6 +748,10 @@ func (c *EventActionFilesystemConfig) getACopy() EventActionFilesystemConfig { MkDirs: mkdirs, Deletes: deletes, Exist: exist, + Compress: EventActionFsCompress{ + Paths: compressPaths, + Name: c.Compress.Name, + }, } } diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index b78be1c6..2f36bdb7 100644 --- a/internal/httpd/httpd_test.go +++ b/internal/httpd/httpd_test.go @@ -1694,6 +1694,18 @@ func TestEventActionValidation(t *testing.T) { _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) assert.NoError(t, err) assert.Contains(t, string(resp), "invalid path to check for existence") + action.Options.FsConfig.Type = dataprovider.FilesystemActionCompress + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "archive name is mandatory") + action.Options.FsConfig.Compress.Name = "archive.zip" + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "no path to compress specified") + action.Options.FsConfig.Compress.Paths = []string{"item1", ""} + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "invalid path to compress") } func TestEventRuleValidation(t *testing.T) { diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go index a6c3507c..d6467fb2 100644 --- a/internal/httpd/webadmin.go +++ b/internal/httpd/webadmin.go @@ -2054,6 +2054,10 @@ func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEven Deletes: strings.Split(strings.ReplaceAll(r.Form.Get("fs_delete_paths"), " ", ""), ","), MkDirs: strings.Split(strings.ReplaceAll(r.Form.Get("fs_mkdir_paths"), " ", ""), ","), Exist: strings.Split(strings.ReplaceAll(r.Form.Get("fs_exist_paths"), " ", ""), ","), + Compress: dataprovider.EventActionFsCompress{ + Name: r.Form.Get("fs_compress_name"), + Paths: strings.Split(strings.ReplaceAll(r.Form.Get("fs_compress_paths"), " ", ""), ","), + }, }, } return options, nil diff --git a/internal/httpdtest/httpdtest.go b/internal/httpdtest/httpdtest.go index b248f204..b6564db6 100644 --- a/internal/httpdtest/httpdtest.go +++ b/internal/httpdtest/httpdtest.go @@ -2322,6 +2322,21 @@ func compareEventActionEmailConfigFields(expected, actual dataprovider.EventActi return nil } +func compareEventActionFsCompressFields(expected, actual dataprovider.EventActionFsCompress) error { + if expected.Name != actual.Name { + return errors.New("fs compress name mismatch") + } + if len(expected.Paths) != len(actual.Paths) { + return errors.New("fs compress paths mismatch") + } + for _, v := range expected.Paths { + if !util.Contains(actual.Paths, v) { + return errors.New("fs compress paths content mismatch") + } + } + return nil +} + func compareEventActionFsConfigFields(expected, actual dataprovider.EventActionFilesystemConfig) error { if expected.Type != actual.Type { return errors.New("fs type mismatch") @@ -2353,7 +2368,7 @@ func compareEventActionFsConfigFields(expected, actual dataprovider.EventActionF return errors.New("fs exist content mismatch") } } - return nil + return compareEventActionFsCompressFields(expected.Compress, actual.Compress) } func compareEventActionCmdConfigFields(expected, actual dataprovider.EventActionCommandConfig) error { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 446d5078..d330026a 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -6156,6 +6156,17 @@ components: type: array items: $ref: '#/components/schemas/FolderRetention' + EventActionFsCompress: + type: object + properties: + name: + type: string + description: 'Full path to the (zip) archive to create. The parent dir must exist' + paths: + type: array + items: + type: string + description: 'paths to add the archive' EventActionFilesystemConfig: type: object properties: @@ -6177,6 +6188,8 @@ components: type: array items: type: string + compress: + $ref: '#/components/schemas/EventActionFsCompress' BaseEventActionOptions: type: object properties: diff --git a/templates/webadmin/eventaction.html b/templates/webadmin/eventaction.html index baa506d1..3a31dad1 100644 --- a/templates/webadmin/eventaction.html +++ b/templates/webadmin/eventaction.html @@ -606,6 +606,28 @@ along with this program. If not, see . +
+ +
+ + + Full path, as seen by SFTPGo users, to the zip archive to create. Placeholders are supported. If the specified file already exists, it is overwritten + +
+
+ +
+ +
+ + + Comma separated paths to compress (zip) as seen by SFTPGo users. Placeholders are supported. The required permissions are granted automatically + +
+
+
@@ -924,6 +946,10 @@ along with this program. If not, see . case 4: $('.action-fs-exist').show(); break; + case '5': + case 5: + $('.action-fs-compress').show(); + break; } }