Explorar o código

eventmanager: add support for file/directory compression

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino %!s(int64=2) %!d(string=hai) anos
pai
achega
3e44a1dd2d

+ 1 - 0
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:
 

+ 1 - 1
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

+ 2 - 2
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=

+ 9 - 3
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) {

+ 1 - 1
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)

+ 275 - 1
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...)
 }

+ 86 - 27
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()
+}

+ 410 - 2
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)

+ 63 - 4
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,
+		},
 	}
 }
 

+ 12 - 0
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) {

+ 4 - 0
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

+ 16 - 1
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 {

+ 13 - 0
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:

+ 26 - 0
templates/webadmin/eventaction.html

@@ -606,6 +606,28 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                 </div>
             </div>
 
+            <div class="form-group row action-type action-fs-type action-fs-compress">
+                <label for="idFsCompressName" class="col-sm-2 col-form-label">Archive path</label>
+                <div class="col-sm-10">
+                    <input type="text" class="form-control" id="idFsCompressName" name="fs_compress_name" placeholder=""
+                            value="{{.Action.Options.FsConfig.Compress.Name}}" maxlength="255"  aria-describedby="fsCompressNameHelpBlock">
+                    <small id="fsCompressPathsHelpBlock" class="form-text text-muted">
+                        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
+                    </small>
+                </div>
+            </div>
+
+            <div class="form-group row action-type action-fs-type action-fs-compress">
+                <label for="idFsCompressPaths" class="col-sm-2 col-form-label">Paths</label>
+                <div class="col-sm-10">
+                    <textarea class="form-control" id="idFsCompressPaths" name="fs_compress_paths" rows="2"
+                        aria-describedby="fsCompressPathsHelpBlock">{{.Action.Options.FsConfig.GetCompressPathsAsString}}</textarea>
+                    <small id="fsCompressPathsHelpBlock" class="form-text text-muted">
+                        Comma separated paths to compress (zip) as seen by SFTPGo users. Placeholders are supported. The required permissions are granted automatically
+                    </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>
@@ -924,6 +946,10 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
             case 4:
                 $('.action-fs-exist').show();
                 break;
+            case '5':
+            case 5:
+                $('.action-fs-compress').show();
+                break;
         }
     }