eventmanager: add support for data retention checks

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2022-08-04 21:50:38 +02:00
parent 71fff28d29
commit b1efe8d0b5
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
26 changed files with 663 additions and 133 deletions

View file

@ -11,11 +11,11 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
go: [1.18]
go: [1.19]
os: [ubuntu-latest, macos-latest]
upload-coverage: [true]
include:
- go: 1.18
- go: 1.19
os: windows-latest
upload-coverage: false
@ -232,7 +232,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.18
go-version: 1.19
- name: Build
run: |
@ -306,7 +306,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.18
go-version: 1.19
- name: Build
run: |
@ -383,7 +383,7 @@ jobs:
matrix:
include:
- arch: amd64
go: 1.18
go: 1.19
go-arch: amd64
- arch: aarch64
distro: ubuntu18.04
@ -504,7 +504,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.18
go-version: 1.19
- uses: actions/checkout@v3
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v3

View file

@ -5,7 +5,7 @@ on:
tags: 'v*'
env:
GO_VERSION: 1.18.3
GO_VERSION: 1.19
jobs:
prepare-sources-with-deps:

View file

@ -1,4 +1,4 @@
FROM golang:1.18-bullseye as builder
FROM golang:1.19-bullseye as builder
ENV GOFLAGS="-mod=readonly"

View file

@ -1,4 +1,4 @@
FROM golang:1.18-alpine3.16 AS builder
FROM golang:1.19-alpine3.16 AS builder
ENV GOFLAGS="-mod=readonly"

View file

@ -1,4 +1,4 @@
FROM golang:1.18-bullseye as builder
FROM golang:1.19-bullseye as builder
ENV CGO_ENABLED=0 GOFLAGS="-mod=readonly"

View file

@ -11,6 +11,7 @@ The following actions are supported:
- `User quota reset`. The quota used by users will be updated based on current usage.
- `Folder quota reset`. The quota used by virtual folders will be updated based on current usage.
- `Transfer quota reset`. The transfer quota values will be reset to `0`.
- `Data retention check`. You can define per-folder retention policies.
The following placeholders are supported:

10
go.mod
View file

@ -1,6 +1,6 @@
module github.com/drakkan/sftpgo/v2
go 1.18
go 1.19
require (
cloud.google.com/go/storage v1.24.0
@ -64,13 +64,13 @@ require (
github.com/yl2chen/cidranger v1.0.3-0.20210928021809-d1cb2c52f37a
go.etcd.io/bbolt v1.3.6
go.uber.org/automaxprocs v1.5.1
gocloud.dev v0.25.0
gocloud.dev v0.26.0
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa
golang.org/x/net v0.0.0-20220728211354-c7608f3a8462
golang.org/x/oauth2 v0.0.0-20220722155238-128564f6959c
golang.org/x/sys v0.0.0-20220731174439-a90be440212d
golang.org/x/sys v0.0.0-20220803195053-6e608f9ce704
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9
google.golang.org/api v0.90.0
google.golang.org/api v0.91.0
gopkg.in/natefinch/lumberjack.v2 v2.0.0
)
@ -155,7 +155,7 @@ require (
golang.org/x/tools v0.1.12 // indirect
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20220802133213-ce4fa296bf78 // indirect
google.golang.org/genproto v0.0.0-20220804142021-4e6b2dfa6612 // indirect
google.golang.org/grpc v1.48.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/ini.v1 v1.66.6 // indirect

18
go.sum
View file

@ -602,7 +602,6 @@ github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-ieproxy v0.0.1/go.mod h1:pYabZ6IHcRpFh7vIaLfK7rdcWgFEb3SFJ6/gNWuh88E=
github.com/mattn/go-ieproxy v0.0.3/go.mod h1:6ZpRmhBaYuBX1U2za+9rC9iCGLsSp2tftelZne7CPko=
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
@ -808,8 +807,8 @@ go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
gocloud.dev v0.25.0 h1:Y7vDq8xj7SyM848KXf32Krda2e6jQ4CLh/mTeCSqXtk=
gocloud.dev v0.25.0/go.mod h1:7HegHVCYZrMiU3IE1qtnzf/vRrDwLYnRNR3EhWX8x9Y=
gocloud.dev v0.26.0 h1:4rM/SVL0lLs+rhC0Gmc+gt/82DBpb7nbpIZKXXnfMXg=
gocloud.dev v0.26.0/go.mod h1:mkUgejbnbLotorqDyvedJO20XcZNTynmSeVSQS9btVg=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@ -955,7 +954,6 @@ golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -973,8 +971,8 @@ golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220731174439-a90be440212d h1:Sv5ogFZatcgIMMtBSTTAgMYsicp25MXBubjXNDKwm80=
golang.org/x/sys v0.0.0-20220731174439-a90be440212d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220803195053-6e608f9ce704 h1:Y7NOhdqIOU8kYI7BxsgL38d0ot0raxvcW+EMQU2QrT4=
golang.org/x/sys v0.0.0-20220803195053-6e608f9ce704/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -1117,8 +1115,8 @@ google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3
google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o=
google.golang.org/api v0.85.0/go.mod h1:AqZf8Ep9uZ2pyTvgL+x0D3Zt0eoT9b5E8fmzfu6FO2g=
google.golang.org/api v0.86.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
google.golang.org/api v0.90.0 h1:WMnUWAvihIClUYFNeFA69VTuR3duKS3IalMGDQcLvq8=
google.golang.org/api v0.90.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
google.golang.org/api v0.91.0 h1:731+JzuwaJoZXRQGmPoBiV+SrsAfUaIkdMCWTcQNPyA=
google.golang.org/api v0.91.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@ -1225,8 +1223,8 @@ google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljW
google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220802133213-ce4fa296bf78 h1:QntLWYqZeuBtJkth3m/6DLznnI0AHJr+AgJXvVh/izw=
google.golang.org/genproto v0.0.0-20220802133213-ce4fa296bf78/go.mod h1:iHe1svFLAZg9VWz891+QbRMwUv9O/1Ww+/mngYeThbc=
google.golang.org/genproto v0.0.0-20220804142021-4e6b2dfa6612 h1:NX3L5YesD5qgxxrPHdKqHH38Ao0AG6poRXG+JljPsGU=
google.golang.org/genproto v0.0.0-20220804142021-4e6b2dfa6612/go.mod h1:iHe1svFLAZg9VWz891+QbRMwUv9O/1Ww+/mngYeThbc=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=

View file

@ -622,7 +622,7 @@ func (c *BaseConnection) DoStat(virtualPath string, mode int, checkFilePatterns
info, err = fs.Stat(c.getRealFsPath(fsPath))
}
if err != nil {
c.Log(logger.LevelError, "stat error for path %#v: %+v", virtualPath, err)
c.Log(logger.LevelWarn, "stat error for path %#v: %+v", virtualPath, err)
return info, c.GetFsError(fs, err)
}
if vfs.IsCryptOsFs(fs) {

View file

@ -66,7 +66,7 @@ func (c *ActiveRetentionChecks) Get() []RetentionCheck {
checks := make([]RetentionCheck, 0, len(c.Checks))
for _, check := range c.Checks {
foldersCopy := make([]FolderRetention, len(check.Folders))
foldersCopy := make([]dataprovider.FolderRetention, len(check.Folders))
copy(foldersCopy, check.Folders)
notificationsCopy := make([]string, len(check.Notifications))
copy(notificationsCopy, check.Notifications)
@ -124,37 +124,6 @@ func (c *ActiveRetentionChecks) remove(username string) bool {
return false
}
// FolderRetention defines the retention policy for the specified directory path
type FolderRetention struct {
// Path is the exposed virtual directory path, if no other specific retention is defined,
// the retention applies for sub directories too. For example if retention is defined
// for the paths "/" and "/sub" then the retention for "/" is applied for any file outside
// the "/sub" directory
Path string `json:"path"`
// Retention time in hours. 0 means exclude this path
Retention int `json:"retention"`
// DeleteEmptyDirs defines if empty directories will be deleted.
// The user need the delete permission
DeleteEmptyDirs bool `json:"delete_empty_dirs,omitempty"`
// IgnoreUserPermissions defines if delete files even if the user does not have the delete permission.
// The default is "false" which means that files will be skipped if the user does not have the permission
// to delete them. This applies to sub directories too.
IgnoreUserPermissions bool `json:"ignore_user_permissions,omitempty"`
}
func (f *FolderRetention) isValid() error {
f.Path = path.Clean(f.Path)
if !path.IsAbs(f.Path) {
return util.NewValidationError(fmt.Sprintf("folder retention: invalid path %#v, please specify an absolute POSIX path",
f.Path))
}
if f.Retention < 0 {
return util.NewValidationError(fmt.Sprintf("invalid folder retention %v, it must be greater or equal to zero",
f.Retention))
}
return nil
}
type folderRetentionCheckResult struct {
Path string `json:"path"`
Retention int `json:"retention"`
@ -172,7 +141,7 @@ type RetentionCheck struct {
// retention check start time as unix timestamp in milliseconds
StartTime int64 `json:"start_time"`
// affected folders
Folders []FolderRetention `json:"folders"`
Folders []dataprovider.FolderRetention `json:"folders"`
// how cleanup results will be notified
Notifications []RetentionCheckNotification `json:"notifications,omitempty"`
// email to use if the notification method is set to email
@ -188,7 +157,7 @@ func (c *RetentionCheck) Validate() error {
nothingToDo := true
for idx := range c.Folders {
f := &c.Folders[idx]
if err := f.isValid(); err != nil {
if err := f.Validate(); err != nil {
return err
}
if f.Retention > 0 {
@ -230,7 +199,7 @@ func (c *RetentionCheck) updateUserPermissions() {
}
}
func (c *RetentionCheck) getFolderRetention(folderPath string) (FolderRetention, error) {
func (c *RetentionCheck) getFolderRetention(folderPath string) (dataprovider.FolderRetention, error) {
dirsForPath := util.GetDirsForVirtualPath(folderPath)
for _, dirPath := range dirsForPath {
for _, folder := range c.Folders {
@ -240,7 +209,7 @@ func (c *RetentionCheck) getFolderRetention(folderPath string) (FolderRetention,
}
}
return FolderRetention{}, fmt.Errorf("unable to find folder retention for %#v", folderPath)
return dataprovider.FolderRetention{}, fmt.Errorf("unable to find folder retention for %#v", folderPath)
}
func (c *RetentionCheck) removeFile(virtualPath string, info os.FileInfo) error {
@ -346,7 +315,7 @@ func (c *RetentionCheck) checkEmptyDirRemoval(folderPath string) {
}
// Start starts the retention check
func (c *RetentionCheck) Start() {
func (c *RetentionCheck) Start() error {
c.conn.Log(logger.LevelInfo, "retention check started")
defer RetentionChecks.remove(c.conn.User.Username)
defer c.conn.CloseFS() //nolint:errcheck
@ -357,13 +326,14 @@ func (c *RetentionCheck) Start() {
if err := c.cleanupFolder(folder.Path); err != nil {
c.conn.Log(logger.LevelError, "retention check failed, unable to cleanup folder %#v", folder.Path)
c.sendNotifications(time.Since(startTime), err)
return
return err
}
}
}
c.conn.Log(logger.LevelInfo, "retention check completed")
c.sendNotifications(time.Since(startTime), nil)
return nil
}
func (c *RetentionCheck) sendNotifications(elapsed time.Duration, err error) {

View file

@ -32,25 +32,17 @@ import (
func TestRetentionValidation(t *testing.T) {
check := RetentionCheck{}
check.Folders = append(check.Folders, FolderRetention{
Path: "relative",
Retention: 10,
})
err := check.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "please specify an absolute POSIX path")
check.Folders = []FolderRetention{
check.Folders = []dataprovider.FolderRetention{
{
Path: "/",
Retention: -1,
},
}
err = check.Validate()
err := check.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid folder retention")
check.Folders = []FolderRetention{
check.Folders = []dataprovider.FolderRetention{
{
Path: "/ab/..",
Retention: 0,
@ -61,7 +53,7 @@ func TestRetentionValidation(t *testing.T) {
assert.Contains(t, err.Error(), "nothing to delete")
assert.Equal(t, "/", check.Folders[0].Path)
check.Folders = append(check.Folders, FolderRetention{
check.Folders = append(check.Folders, dataprovider.FolderRetention{
Path: "/../..",
Retention: 24,
})
@ -69,7 +61,7 @@ func TestRetentionValidation(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), `duplicated folder path "/"`)
check.Folders = []FolderRetention{
check.Folders = []dataprovider.FolderRetention{
{
Path: "/dir1",
Retention: 48,
@ -240,7 +232,7 @@ func TestRetentionPermissionsAndGetFolder(t *testing.T) {
user.Permissions["/dir2/sub2"] = []string{dataprovider.PermDelete}
check := RetentionCheck{
Folders: []FolderRetention{
Folders: []dataprovider.FolderRetention{
{
Path: "/dir2",
Retention: 24 * 7,
@ -300,7 +292,7 @@ func TestRetentionCheckAddRemove(t *testing.T) {
user.Permissions = make(map[string][]string)
user.Permissions["/"] = []string{dataprovider.PermAny}
check := RetentionCheck{
Folders: []FolderRetention{
Folders: []dataprovider.FolderRetention{
{
Path: "/",
Retention: 48,
@ -334,7 +326,7 @@ func TestCleanupErrors(t *testing.T) {
user.Permissions = make(map[string][]string)
user.Permissions["/"] = []string{dataprovider.PermAny}
check := &RetentionCheck{
Folders: []FolderRetention{
Folders: []dataprovider.FolderRetention{
{
Path: "/path",
Retention: 48,

View file

@ -495,6 +495,31 @@ func executeEmailRuleAction(c dataprovider.EventActionEmailConfig, params EventP
return err
}
func executeQuotaResetForUser(user dataprovider.User) error {
if err := user.LoadAndApplyGroupSettings(); err != nil {
eventManagerLog(logger.LevelDebug, "skipping scheduled quota reset for user %s, cannot apply group settings: %v",
user.Username, err)
return err
}
if !QuotaScans.AddUserQuotaScan(user.Username) {
eventManagerLog(logger.LevelError, "another quota scan is already in progress for user %s", user.Username)
return fmt.Errorf("another quota scan is in progress for user %s", user.Username)
}
defer QuotaScans.RemoveUserQuotaScan(user.Username)
numFiles, size, err := user.ScanQuota()
if err != nil {
eventManagerLog(logger.LevelError, "error scanning quota for user %s: %v", user.Username, err)
return err
}
err = dataprovider.UpdateUserQuota(&user, numFiles, size, true)
if err != nil {
eventManagerLog(logger.LevelError, "error updating quota for user %s: %v", user.Username, err)
return err
}
return nil
}
func executeUsersQuotaResetRuleAction(conditions dataprovider.ConditionOptions) error {
users, err := dataprovider.DumpUsers()
if err != nil {
@ -507,21 +532,7 @@ func executeUsersQuotaResetRuleAction(conditions dataprovider.ConditionOptions)
user.Username)
continue
}
if !QuotaScans.AddUserQuotaScan(user.Username) {
eventManagerLog(logger.LevelError, "another quota scan is already in progress for user %s", user.Username)
failedResets = append(failedResets, user.Username)
continue
}
numFiles, size, err := user.ScanQuota()
QuotaScans.RemoveUserQuotaScan(user.Username)
if err != nil {
eventManagerLog(logger.LevelError, "error scanning quota for user %s: %v", user.Username, err)
failedResets = append(failedResets, user.Username)
continue
}
err = dataprovider.UpdateUserQuota(&user, numFiles, size, true)
if err != nil {
eventManagerLog(logger.LevelError, "error updating quota for user %s: %v", user.Username, err)
if err = executeQuotaResetForUser(user); err != nil {
failedResets = append(failedResets, user.Username)
continue
}
@ -564,7 +575,6 @@ func executeFoldersQuotaResetRuleAction(conditions dataprovider.ConditionOptions
if err != nil {
eventManagerLog(logger.LevelError, "error updating quota for folder %s: %v", folder.Name, err)
failedResets = append(failedResets, folder.Name)
continue
}
}
if len(failedResets) > 0 {
@ -589,7 +599,6 @@ func executeTransferQuotaResetRuleAction(conditions dataprovider.ConditionOption
if err != nil {
eventManagerLog(logger.LevelError, "error updating transfer quota for user %s: %v", user.Username, err)
failedResets = append(failedResets, user.Username)
continue
}
}
if len(failedResets) > 0 {
@ -598,6 +607,52 @@ func executeTransferQuotaResetRuleAction(conditions dataprovider.ConditionOption
return nil
}
func executeDataRetentionCheckForUser(user dataprovider.User, folders []dataprovider.FolderRetention) error {
if err := user.LoadAndApplyGroupSettings(); err != nil {
eventManagerLog(logger.LevelDebug, "skipping scheduled retention check for user %s, cannot apply group settings: %v",
user.Username, err)
return err
}
check := RetentionCheck{
Folders: folders,
}
c := RetentionChecks.Add(check, &user)
if c == nil {
eventManagerLog(logger.LevelError, "another retention check is already in progress for user %s", user.Username)
return fmt.Errorf("another retention check is in progress for user %s", user.Username)
}
if err := c.Start(); err != nil {
eventManagerLog(logger.LevelError, "error checking retention for user %s: %v", user.Username, err)
return err
}
return nil
}
func executeDataRetentionCheckRuleAction(config dataprovider.EventActionDataRetentionConfig,
conditions dataprovider.ConditionOptions,
) error {
users, err := dataprovider.DumpUsers()
if err != nil {
return fmt.Errorf("unable to get users: %w", err)
}
var failedChecks []string
for _, user := range users {
if !checkEventConditionPatterns(user.Username, conditions.Names) {
eventManagerLog(logger.LevelDebug, "skipping scheduled retention check for user %s, name conditions don't match",
user.Username)
continue
}
if err = executeDataRetentionCheckForUser(user, config.Folders); err != nil {
failedChecks = append(failedChecks, user.Username)
continue
}
}
if len(failedChecks) > 0 {
return fmt.Errorf("retention check failed for users: %+v", failedChecks)
}
return nil
}
func executeRuleAction(action dataprovider.BaseEventAction, params EventParams, conditions dataprovider.ConditionOptions) error {
switch action.Type {
case dataprovider.ActionTypeHTTP:
@ -614,6 +669,8 @@ func executeRuleAction(action dataprovider.BaseEventAction, params EventParams,
return executeFoldersQuotaResetRuleAction(conditions)
case dataprovider.ActionTypeTransferQuotaReset:
return executeTransferQuotaResetRuleAction(conditions)
case dataprovider.ActionTypeDataRetentionCheck:
return executeDataRetentionCheckRuleAction(action.Options.RetentionConfig, conditions)
default:
return fmt.Errorf("unsupported action type: %d", action.Type)
}

View file

@ -265,6 +265,48 @@ func TestEventManagerErrors(t *testing.T) {
assert.Error(t, err)
err = executeTransferQuotaResetRuleAction(dataprovider.ConditionOptions{})
assert.Error(t, err)
err = executeQuotaResetForUser(dataprovider.User{
Groups: []sdk.GroupMapping{
{
Name: "agroup",
Type: sdk.GroupTypePrimary,
},
},
})
assert.Error(t, err)
err = executeDataRetentionCheckForUser(dataprovider.User{
Groups: []sdk.GroupMapping{
{
Name: "agroup",
Type: sdk.GroupTypePrimary,
},
},
}, nil)
assert.Error(t, err)
dataRetentionAction := dataprovider.BaseEventAction{
Type: dataprovider.ActionTypeDataRetentionCheck,
Options: dataprovider.BaseEventActionOptions{
RetentionConfig: dataprovider.EventActionDataRetentionConfig{
Folders: []dataprovider.FolderRetention{
{
Path: "/",
Retention: 24,
},
},
},
},
}
err = executeRuleAction(dataRetentionAction, EventParams{}, dataprovider.ConditionOptions{
Names: []dataprovider.ConditionPattern{
{
Pattern: "username1",
},
},
})
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "unable to get users")
}
eventManager.loadRules()
@ -447,6 +489,88 @@ func TestEventRuleActions(t *testing.T) {
assert.Error(t, err)
assert.True(t, QuotaScans.RemoveUserQuotaScan(username1))
dataRetentionAction := dataprovider.BaseEventAction{
Type: dataprovider.ActionTypeDataRetentionCheck,
Options: dataprovider.BaseEventActionOptions{
RetentionConfig: dataprovider.EventActionDataRetentionConfig{
Folders: []dataprovider.FolderRetention{
{
Path: "",
Retention: 24,
},
},
},
},
}
err = executeRuleAction(dataRetentionAction, EventParams{}, dataprovider.ConditionOptions{
Names: []dataprovider.ConditionPattern{
{
Pattern: username1,
},
},
})
assert.Error(t, err) // invalid config, no folder path specified
retentionDir := "testretention"
dataRetentionAction = dataprovider.BaseEventAction{
Type: dataprovider.ActionTypeDataRetentionCheck,
Options: dataprovider.BaseEventActionOptions{
RetentionConfig: dataprovider.EventActionDataRetentionConfig{
Folders: []dataprovider.FolderRetention{
{
Path: path.Join("/", retentionDir),
Retention: 24,
DeleteEmptyDirs: true,
},
},
},
},
}
// create some test files
file1 := filepath.Join(user1.GetHomeDir(), "file1.txt")
file2 := filepath.Join(user1.GetHomeDir(), retentionDir, "file2.txt")
file3 := filepath.Join(user1.GetHomeDir(), retentionDir, "file3.txt")
file4 := filepath.Join(user1.GetHomeDir(), retentionDir, "sub", "file4.txt")
err = os.MkdirAll(filepath.Dir(file4), os.ModePerm)
assert.NoError(t, err)
for _, f := range []string{file1, file2, file3, file4} {
err = os.WriteFile(f, []byte(""), 0666)
assert.NoError(t, err)
}
timeBeforeRetention := time.Now().Add(-48 * time.Hour)
err = os.Chtimes(file1, timeBeforeRetention, timeBeforeRetention)
assert.NoError(t, err)
err = os.Chtimes(file2, timeBeforeRetention, timeBeforeRetention)
assert.NoError(t, err)
err = os.Chtimes(file4, timeBeforeRetention, timeBeforeRetention)
assert.NoError(t, err)
err = executeRuleAction(dataRetentionAction, EventParams{}, dataprovider.ConditionOptions{
Names: []dataprovider.ConditionPattern{
{
Pattern: username1,
},
},
})
assert.NoError(t, err)
assert.FileExists(t, file1)
assert.NoFileExists(t, file2)
assert.FileExists(t, file3)
assert.NoDirExists(t, filepath.Dir(file4))
// simulate another check in progress
c := RetentionChecks.Add(RetentionCheck{}, &user1)
assert.NotNil(t, c)
err = executeRuleAction(dataRetentionAction, EventParams{}, dataprovider.ConditionOptions{
Names: []dataprovider.ConditionPattern{
{
Pattern: username1,
},
},
})
assert.Error(t, err)
RetentionChecks.remove(user1.Username)
err = os.RemoveAll(user1.GetHomeDir())
assert.NoError(t, err)

View file

@ -3454,7 +3454,7 @@ func TestRetentionAPI(t *testing.T) {
err = writeSFTPFile(uploadPath, 32, client)
assert.NoError(t, err)
folderRetention := []common.FolderRetention{
folderRetention := []dataprovider.FolderRetention{
{
Path: "/",
Retention: 24,
@ -3535,7 +3535,7 @@ func TestRetentionAPI(t *testing.T) {
err = client.Chtimes(innerUploadFilePath, time.Now().Add(-48*time.Hour), time.Now().Add(-48*time.Hour))
assert.NoError(t, err)
folderRetention := []common.FolderRetention{
folderRetention := []dataprovider.FolderRetention{
{
Path: "/missing",
Retention: 24,
@ -3576,7 +3576,7 @@ func TestRetentionAPI(t *testing.T) {
_, err = client.Stat(innerUploadFilePath)
assert.NoError(t, err)
folderRetention = []common.FolderRetention{
folderRetention = []dataprovider.FolderRetention{
{
Path: "/" + testDir,
@ -3611,7 +3611,7 @@ func TestRetentionAPI(t *testing.T) {
err = os.Chmod(dirPath, 0001)
assert.NoError(t, err)
folderRetention := []common.FolderRetention{
folderRetention := []dataprovider.FolderRetention{
{
Path: "/adir",

View file

@ -40,11 +40,13 @@ const (
ActionTypeUserQuotaReset
ActionTypeFolderQuotaReset
ActionTypeTransferQuotaReset
ActionTypeDataRetentionCheck
)
var (
supportedEventActions = []int{ActionTypeHTTP, ActionTypeCommand, ActionTypeEmail, ActionTypeBackup,
ActionTypeUserQuotaReset, ActionTypeFolderQuotaReset, ActionTypeTransferQuotaReset}
ActionTypeUserQuotaReset, ActionTypeFolderQuotaReset, ActionTypeTransferQuotaReset,
ActionTypeDataRetentionCheck}
)
func isActionTypeValid(action int) bool {
@ -65,6 +67,8 @@ func getActionTypeAsString(action int) string {
return "Folder quota reset"
case ActionTypeTransferQuotaReset:
return "Transfer quota reset"
case ActionTypeDataRetentionCheck:
return "Data retention check"
default:
return "Command"
}
@ -149,13 +153,13 @@ type KeyValue struct {
// EventActionHTTPConfig defines the configuration for an HTTP event target
type EventActionHTTPConfig struct {
Endpoint string `json:"endpoint"`
Endpoint string `json:"endpoint,omitempty"`
Username string `json:"username,omitempty"`
Password *kms.Secret `json:"password,omitempty"`
Headers []KeyValue `json:"headers,omitempty"`
Timeout int `json:"timeout"`
Timeout int `json:"timeout,omitempty"`
SkipTLSVerify bool `json:"skip_tls_verify,omitempty"`
Method string `json:"method"`
Method string `json:"method,omitempty"`
QueryParameters []KeyValue `json:"query_parameters,omitempty"`
Body string `json:"post_body,omitempty"`
}
@ -218,9 +222,9 @@ func (c *EventActionHTTPConfig) GetHTTPClient() *http.Client {
// EventActionCommandConfig defines the configuration for a command event target
type EventActionCommandConfig struct {
Cmd string `json:"cmd"`
Timeout int `json:"timeout"`
EnvVars []KeyValue `json:"env_vars"`
Cmd string `json:"cmd,omitempty"`
Timeout int `json:"timeout,omitempty"`
EnvVars []KeyValue `json:"env_vars,omitempty"`
}
func (c *EventActionCommandConfig) validate() error {
@ -243,46 +247,111 @@ func (c *EventActionCommandConfig) validate() error {
// EventActionEmailConfig defines the configuration options for SMTP event actions
type EventActionEmailConfig struct {
Recipients []string `json:"recipients"`
Subject string `json:"subject"`
Body string `json:"body"`
Recipients []string `json:"recipients,omitempty"`
Subject string `json:"subject,omitempty"`
Body string `json:"body,omitempty"`
}
// GetRecipientsAsString returns the list of recipients as comma separated string
func (o EventActionEmailConfig) GetRecipientsAsString() string {
return strings.Join(o.Recipients, ",")
func (c EventActionEmailConfig) GetRecipientsAsString() string {
return strings.Join(c.Recipients, ",")
}
func (o *EventActionEmailConfig) validate() error {
if len(o.Recipients) == 0 {
func (c *EventActionEmailConfig) validate() error {
if len(c.Recipients) == 0 {
return util.NewValidationError("at least one email recipient is required")
}
o.Recipients = util.RemoveDuplicates(o.Recipients, false)
for _, r := range o.Recipients {
c.Recipients = util.RemoveDuplicates(c.Recipients, false)
for _, r := range c.Recipients {
if r == "" {
return util.NewValidationError("invalid email recipients")
}
}
if o.Subject == "" {
if c.Subject == "" {
return util.NewValidationError("email subject is required")
}
if o.Body == "" {
if c.Body == "" {
return util.NewValidationError("email body is required")
}
return nil
}
// FolderRetention defines a folder retention configuration
type FolderRetention struct {
// Path is the exposed virtual directory path, if no other specific retention is defined,
// the retention applies for sub directories too. For example if retention is defined
// for the paths "/" and "/sub" then the retention for "/" is applied for any file outside
// the "/sub" directory
Path string `json:"path"`
// Retention time in hours. 0 means exclude this path
Retention int `json:"retention"`
// DeleteEmptyDirs defines if empty directories will be deleted.
// The user need the delete permission
DeleteEmptyDirs bool `json:"delete_empty_dirs,omitempty"`
// IgnoreUserPermissions defines whether to delete files even if the user does not have the delete permission.
// The default is "false" which means that files will be skipped if the user does not have the permission
// to delete them. This applies to sub directories too.
IgnoreUserPermissions bool `json:"ignore_user_permissions,omitempty"`
}
// Validate returns an error if the configuration is not valid
func (f *FolderRetention) Validate() error {
f.Path = util.CleanPath(f.Path)
if f.Retention < 0 {
return util.NewValidationError(fmt.Sprintf("invalid folder retention %v, it must be greater or equal to zero",
f.Retention))
}
return nil
}
// EventActionDataRetentionConfig defines the configuration for a data retention check
type EventActionDataRetentionConfig struct {
Folders []FolderRetention `json:"folders,omitempty"`
}
func (c *EventActionDataRetentionConfig) validate() error {
folderPaths := make(map[string]bool)
nothingToDo := true
for idx := range c.Folders {
f := &c.Folders[idx]
if err := f.Validate(); err != nil {
return err
}
if f.Retention > 0 {
nothingToDo = false
}
if _, ok := folderPaths[f.Path]; ok {
return util.NewValidationError(fmt.Sprintf("duplicated folder path %#v", f.Path))
}
folderPaths[f.Path] = true
}
if nothingToDo {
return util.NewValidationError("nothing to delete!")
}
return nil
}
// BaseEventActionOptions defines the supported configuration options for a base event actions
type BaseEventActionOptions struct {
HTTPConfig EventActionHTTPConfig `json:"http_config"`
CmdConfig EventActionCommandConfig `json:"cmd_config"`
EmailConfig EventActionEmailConfig `json:"email_config"`
HTTPConfig EventActionHTTPConfig `json:"http_config"`
CmdConfig EventActionCommandConfig `json:"cmd_config"`
EmailConfig EventActionEmailConfig `json:"email_config"`
RetentionConfig EventActionDataRetentionConfig `json:"retention_config"`
}
func (o *BaseEventActionOptions) getACopy() BaseEventActionOptions {
o.SetEmptySecretsIfNil()
emailRecipients := make([]string, len(o.EmailConfig.Recipients))
copy(emailRecipients, o.EmailConfig.Recipients)
folders := make([]FolderRetention, 0, len(o.RetentionConfig.Folders))
for _, folder := range o.RetentionConfig.Folders {
folders = append(folders, FolderRetention{
Path: folder.Path,
Retention: folder.Retention,
DeleteEmptyDirs: folder.DeleteEmptyDirs,
IgnoreUserPermissions: folder.IgnoreUserPermissions,
})
}
return BaseEventActionOptions{
HTTPConfig: EventActionHTTPConfig{
@ -306,6 +375,9 @@ func (o *BaseEventActionOptions) getACopy() BaseEventActionOptions {
Subject: o.EmailConfig.Subject,
Body: o.EmailConfig.Body,
},
RetentionConfig: EventActionDataRetentionConfig{
Folders: folders,
},
}
}
@ -334,19 +406,28 @@ func (o *BaseEventActionOptions) validate(action int, name string) error {
case ActionTypeHTTP:
o.CmdConfig = EventActionCommandConfig{}
o.EmailConfig = EventActionEmailConfig{}
o.RetentionConfig = EventActionDataRetentionConfig{}
return o.HTTPConfig.validate(name)
case ActionTypeCommand:
o.HTTPConfig = EventActionHTTPConfig{}
o.EmailConfig = EventActionEmailConfig{}
o.RetentionConfig = EventActionDataRetentionConfig{}
return o.CmdConfig.validate()
case ActionTypeEmail:
o.HTTPConfig = EventActionHTTPConfig{}
o.CmdConfig = EventActionCommandConfig{}
o.RetentionConfig = EventActionDataRetentionConfig{}
return o.EmailConfig.validate()
case ActionTypeDataRetentionCheck:
o.HTTPConfig = EventActionHTTPConfig{}
o.CmdConfig = EventActionCommandConfig{}
o.EmailConfig = EventActionEmailConfig{}
return o.RetentionConfig.validate()
default:
o.HTTPConfig = EventActionHTTPConfig{}
o.CmdConfig = EventActionCommandConfig{}
o.EmailConfig = EventActionEmailConfig{}
o.RetentionConfig = EventActionDataRetentionConfig{}
}
return nil
}

View file

@ -71,6 +71,6 @@ func startRetentionCheck(w http.ResponseWriter, r *http.Request) {
http.StatusConflict)
return
}
go c.Start()
go c.Start() //nolint:errcheck
sendAPIResponse(w, r, err, "Check started", http.StatusAccepted)
}

View file

@ -1100,6 +1100,27 @@ func TestBasicActionRulesHandling(t *testing.T) {
}
assert.True(t, found)
a.Description = "new description"
a.Type = dataprovider.ActionTypeDataRetentionCheck
a.Options = dataprovider.BaseEventActionOptions{
RetentionConfig: dataprovider.EventActionDataRetentionConfig{
Folders: []dataprovider.FolderRetention{
{
Path: "/",
Retention: 144,
},
{
Path: "/p1",
Retention: 0,
},
{
Path: "/p2",
Retention: 12,
},
},
},
}
_, _, err = httpdtest.UpdateEventAction(a, http.StatusOK)
assert.NoError(t, err)
a.Type = dataprovider.ActionTypeCommand
a.Options = dataprovider.BaseEventActionOptions{
CmdConfig: dataprovider.EventActionCommandConfig{
@ -1526,6 +1547,51 @@ func TestEventActionValidation(t *testing.T) {
_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
assert.NoError(t, err)
assert.Contains(t, string(resp), "email body is required")
action.Type = dataprovider.ActionTypeDataRetentionCheck
action.Options.RetentionConfig = dataprovider.EventActionDataRetentionConfig{
Folders: nil,
}
_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
assert.NoError(t, err)
assert.Contains(t, string(resp), "nothing to delete")
action.Options.RetentionConfig = dataprovider.EventActionDataRetentionConfig{
Folders: []dataprovider.FolderRetention{
{
Path: "/",
Retention: 0,
},
},
}
_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
assert.NoError(t, err)
assert.Contains(t, string(resp), "nothing to delete")
action.Options.RetentionConfig = dataprovider.EventActionDataRetentionConfig{
Folders: []dataprovider.FolderRetention{
{
Path: "../path",
Retention: 1,
},
{
Path: "/path",
Retention: 10,
},
},
}
_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
assert.NoError(t, err)
assert.Contains(t, string(resp), "duplicated folder path")
action.Options.RetentionConfig = dataprovider.EventActionDataRetentionConfig{
Folders: []dataprovider.FolderRetention{
{
Path: "p",
Retention: -1,
},
},
}
_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
assert.NoError(t, err)
assert.Contains(t, string(resp), "invalid folder retention")
}
func TestEventRuleValidation(t *testing.T) {
@ -3235,7 +3301,7 @@ func TestRetentionAPI(t *testing.T) {
err = os.WriteFile(localFilePath, []byte("test data"), os.ModePerm)
assert.NoError(t, err)
folderRetention := []common.FolderRetention{
folderRetention := []dataprovider.FolderRetention{
{
Path: "/",
Retention: 0,
@ -3282,7 +3348,8 @@ func TestRetentionAPI(t *testing.T) {
_, err = httpdtest.StartRetentionCheck(user.Username, folderRetention, http.StatusConflict)
assert.NoError(t, err)
c.Start()
err = c.Start()
assert.NoError(t, err)
assert.Len(t, common.RetentionChecks.Get(), 0)
admin := getTestAdmin()
@ -18692,6 +18759,53 @@ func TestWebEventAction(t *testing.T) {
assert.Empty(t, actionGet.Options.CmdConfig.Cmd)
assert.Equal(t, 0, actionGet.Options.CmdConfig.Timeout)
assert.Len(t, actionGet.Options.CmdConfig.EnvVars, 0)
// change action type to data retention check
action.Type = dataprovider.ActionTypeDataRetentionCheck
form.Set("type", fmt.Sprintf("%d", action.Type))
form.Set("folder_retention_path10", "p1")
form.Set("folder_retention_val10", "a")
req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, action.Name),
bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
setJWTCookieForReq(req, webToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), "invalid retention for path")
form.Set("folder_retention_val10", "24")
form.Set("folder_retention_options10", "1")
form.Add("folder_retention_options10", "2")
form.Set("folder_retention_path11", "../p2")
form.Set("folder_retention_val11", "48")
form.Set("folder_retention_options11", "1")
form.Add("folder_retention_options12", "2") // ignored
req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, action.Name),
bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
setJWTCookieForReq(req, webToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusSeeOther, rr)
// check the update
actionGet, _, err = httpdtest.GetEventActionByName(action.Name, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, action.Type, actionGet.Type)
if assert.Len(t, actionGet.Options.RetentionConfig.Folders, 2) {
for _, folder := range actionGet.Options.RetentionConfig.Folders {
switch folder.Path {
case "/p1":
assert.Equal(t, 24, folder.Retention)
assert.True(t, folder.DeleteEmptyDirs)
assert.True(t, folder.IgnoreUserPermissions)
case "/p2":
assert.Equal(t, 48, folder.Retention)
assert.True(t, folder.DeleteEmptyDirs)
assert.False(t, folder.IgnoreUserPermissions)
default:
t.Errorf("unexpected folder path %v", folder.Path)
}
}
}
req, err = http.NewRequest(http.MethodDelete, path.Join(webAdminEventActionPath, action.Name), nil)
assert.NoError(t, err)

View file

@ -767,7 +767,7 @@ func TestRetentionInvalidTokenClaims(t *testing.T) {
user.Filters.AllowAPIKeyAuth = true
err := dataprovider.AddUser(&user, "", "")
assert.NoError(t, err)
folderRetention := []common.FolderRetention{
folderRetention := []dataprovider.FolderRetention{
{
Path: "/",
Retention: 0,

View file

@ -1836,6 +1836,30 @@ func getKeyValsFromPostFields(r *http.Request, key, val string) []dataprovider.K
return res
}
func getFoldersRetentionFromPostFields(r *http.Request) ([]dataprovider.FolderRetention, error) {
var res []dataprovider.FolderRetention
for k := range r.Form {
if strings.HasPrefix(k, "folder_retention_path") {
folderPath := r.Form.Get(k)
if folderPath != "" {
idx := strings.TrimPrefix(k, "folder_retention_path")
retention, err := strconv.Atoi(r.Form.Get(fmt.Sprintf("folder_retention_val%s", idx)))
if err != nil {
return nil, fmt.Errorf("invalid retention for path %q: %w", folderPath, err)
}
options := r.Form[fmt.Sprintf("folder_retention_options%s", idx)]
res = append(res, dataprovider.FolderRetention{
Path: folderPath,
Retention: retention,
DeleteEmptyDirs: util.Contains(options, "1"),
IgnoreUserPermissions: util.Contains(options, "2"),
})
}
}
}
return res, nil
}
func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEventActionOptions, error) {
httpTimeout, err := strconv.Atoi(r.Form.Get("http_timeout"))
if err != nil {
@ -1845,6 +1869,10 @@ func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEven
if err != nil {
return dataprovider.BaseEventActionOptions{}, fmt.Errorf("invalid command timeout: %w", err)
}
foldersRetention, err := getFoldersRetentionFromPostFields(r)
if err != nil {
return dataprovider.BaseEventActionOptions{}, err
}
options := dataprovider.BaseEventActionOptions{
HTTPConfig: dataprovider.EventActionHTTPConfig{
Endpoint: r.Form.Get("http_endpoint"),
@ -1867,6 +1895,9 @@ func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEven
Subject: r.Form.Get("email_subject"),
Body: r.Form.Get("email_body"),
},
RetentionConfig: dataprovider.EventActionDataRetentionConfig{
Folders: foldersRetention,
},
}
return options, nil
}

View file

@ -920,7 +920,7 @@ func GetRetentionChecks(expectedStatusCode int) ([]common.ActiveRetentionChecks,
}
// StartRetentionCheck starts a new retention check
func StartRetentionCheck(username string, retention []common.FolderRetention, expectedStatusCode int) ([]byte, error) {
func StartRetentionCheck(username string, retention []dataprovider.FolderRetention, expectedStatusCode int) ([]byte, error) {
var body []byte
asJSON, _ := json.Marshal(retention)
resp, err := sendHTTPRequest(http.MethodPost, buildURLRelativeToBase(retentionBasePath, username, "check"),
@ -1346,6 +1346,9 @@ func checkEventAction(expected, actual dataprovider.BaseEventAction) error {
if err := compareEventActionEmailConfigFields(expected.Options.EmailConfig, actual.Options.EmailConfig); err != nil {
return err
}
if err := compareEventActionDataRetentionFields(expected.Options.RetentionConfig, actual.Options.RetentionConfig); err != nil {
return err
}
return compareEventActionHTTPConfigFields(expected.Options.HTTPConfig, actual.Options.HTTPConfig)
}
@ -2248,6 +2251,34 @@ func compareEventActionCmdConfigFields(expected, actual dataprovider.EventAction
return nil
}
func compareEventActionDataRetentionFields(expected, actual dataprovider.EventActionDataRetentionConfig) error {
if len(expected.Folders) != len(actual.Folders) {
return errors.New("retention folders mismatch")
}
for _, f1 := range expected.Folders {
found := false
for _, f2 := range actual.Folders {
if f1.Path == f2.Path {
found = true
if f1.Retention != f2.Retention {
return fmt.Errorf("retention mismatch for folder %s", f1.Path)
}
if f1.DeleteEmptyDirs != f2.DeleteEmptyDirs {
return fmt.Errorf("delete_empty_dirs mismatch for folder %s", f1.Path)
}
if f1.IgnoreUserPermissions != f2.IgnoreUserPermissions {
return fmt.Errorf("ignore_user_permissions mismatch for folder %s", f1.Path)
}
break
}
}
if !found {
return errors.New("retention folders mismatch")
}
}
return nil
}
func compareEqualGroupSettingsFields(expected sdk.BaseGroupUserSettings, actual sdk.BaseGroupUserSettings) error {
if expected.HomeDir != actual.HomeDir {
return errors.New("home dir mismatch")

View file

@ -635,8 +635,10 @@ func (c *scpCommand) readProtocolMessage() (string, error) {
return command.String(), err
}
// send an error message and close the channel
//nolint:errcheck // we don't check write errors here, we have to close the channel anyway
// sendErrorMessage sends an error message and close the channel
// we don't check write errors here, we have to close the channel anyway
//
//nolint:errcheck
func (c *scpCommand) sendErrorMessage(fs vfs.Fs, err error) {
c.connection.channel.Write(errMsg)
if fs != nil {

View file

@ -13,9 +13,9 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package telemetry provides telemetry information for SFTPGo, such as:
// - health information (for health checks)
// - metrics
// - profiling information
// - health information (for health checks)
// - metrics
// - profiling information
package telemetry
import (

View file

@ -15,6 +15,7 @@
package vfs
import (
"errors"
"fmt"
"strconv"
"strings"
@ -156,6 +157,24 @@ func (v *BaseVirtualFolder) HasRedactedSecret() bool {
return v.FsConfig.HasRedactedSecret()
}
// hasPathPlaceholder returns true if the folder has a path placeholder
func (v *BaseVirtualFolder) hasPathPlaceholder() bool {
placeholder := "%username%"
switch v.FsConfig.Provider {
case sdk.S3FilesystemProvider:
return strings.Contains(v.FsConfig.S3Config.KeyPrefix, placeholder)
case sdk.GCSFilesystemProvider:
return strings.Contains(v.FsConfig.GCSConfig.KeyPrefix, placeholder)
case sdk.AzureBlobFilesystemProvider:
return strings.Contains(v.FsConfig.AzBlobConfig.KeyPrefix, placeholder)
case sdk.SFTPFilesystemProvider:
return strings.Contains(v.FsConfig.SFTPConfig.Prefix, placeholder)
case sdk.LocalFilesystemProvider, sdk.CryptedFilesystemProvider:
return strings.Contains(v.MappedPath, placeholder)
}
return false
}
// VirtualFolder defines a mapping between an SFTPGo exposed virtual path and a
// filesystem path outside the user home directory.
// The specified paths must be absolute and the virtual path cannot be "/",
@ -205,6 +224,9 @@ func (v *VirtualFolder) CheckMetadataConsistency() error {
// ScanQuota scans the folder and returns the number of files and their size
func (v *VirtualFolder) ScanQuota() (int, int64, error) {
if v.hasPathPlaceholder() {
return 0, 0, errors.New("cannot scan quota: this folder has a path placeholder")
}
fs, err := v.GetFilesystem("", nil)
if err != nil {
return 0, 0, err

View file

@ -6024,6 +6024,13 @@ components:
type: string
body:
type: string
EventActionDataRetentionConfig:
type: object
properties:
folders:
type: array
items:
$ref: '#/components/schemas/FolderRetention'
BaseEventActionOptions:
type: object
properties:
@ -6033,6 +6040,8 @@ components:
$ref: '#/components/schemas/EventActionCommandConfig'
email_config:
$ref: '#/components/schemas/EventActionEmailConfig'
retention_config:
$ref: '#/components/schemas/EventActionDataRetentionConfig'
BaseEventAction:
type: object
properties:

View file

@ -342,6 +342,67 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
</div>
<div class="card bg-light mb-3 action-type action-dataretention">
<div class="card-header">
<b>Data retention</b>
</div>
<div class="card-body">
<h6 class="card-title mb-4">Set the data retention, as hours, per path. Retention applies recursively. Setting 0 as retention means excluding the specified path. "Ignore user permissions" defines whether to delete files even if the user does not have the "delete" permission, by default files will be skipped if the user does not have the "delete" permission.</h6>
<div class="form-group row">
<div class="col-md-12 form_field_data_retention_outer">
{{range $idx, $val := .Action.Options.RetentionConfig.Folders}}
<div class="row form_field_data_retention_outer_row">
<div class="form-group col-md-4">
<input type="text" class="form-control" id="idFolderRetentionPath{{$idx}}" name="folder_retention_path{{$idx}}" placeholder="path, i.e. /dir" value="{{$val.Path}}">
</div>
<div class="form-group col-md-2">
<input type="number" min="0" class="form-control" id="idFolderRetentionVal{{$idx}}" name="folder_retention_val{{$idx}}" placeholder="Hours" value="{{$val.Retention}}">
</div>
<div class="form-group col-md-4">
<select class="form-control selectpicker" id="idFolderRetentionOptions{{$idx}}" name="folder_retention_options{{$idx}}" multiple>
<option value="1" {{if $val.DeleteEmptyDirs}}selected{{end}}>Delete empty dirs</option>
<option value="2" {{if $val.IgnoreUserPermissions}}selected{{end}}>Ignore user permissions</option>
</select>
</div>
<div class="form-group col-md-1"></div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_data_retention_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{else}}
<div class="row form_field_data_retention_outer_row">
<div class="form-group col-md-4">
<input type="text" class="form-control" id="idFolderRetentionPath0" name="folder_retention_path0" placeholder="path, i.e. /dir" value="">
</div>
<div class="form-group col-md-2">
<input type="number" min="0" class="form-control" id="idFolderRetentionVal0" name="folder_retention_val0" placeholder="Hours" value="">
</div>
<div class="form-group col-md-4">
<select class="form-control selectpicker" id="idFolderRetentionOptions0" name="folder_retention_options0" multiple>
<option value="1">Delete empty dirs</option>
<option value="2">Ignore user permissions</option>
</select>
</div>
<div class="form-group col-md-1"></div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_data_retention_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{end}}
</div>
</div>
<div class="row mx-1">
<button type="button" class="btn btn-secondary add_new_data_retention_field_btn">
<i class="fas fa-plus"></i> Add new path
</button>
</div>
</div>
</div>
<input type="hidden" name="_form_token" value="{{.CSRFToken}}">
<div class="col-sm-12 text-right px-0">
@ -501,6 +562,40 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
$(this).closest(".form_field_cmd_env_outer_row").remove();
});
$("body").on("click", ".add_new_data_retention_field_btn", function () {
var index = $(".form_field_data_retention_outer").find(".form_field_data_retention_outer_row").length;
while (document.getElementById("idFolderRetentionPath"+index) != null){
index++;
}
$(".form_field_data_retention_outer").append(`
<div class="row form_field_data_retention_outer_row">
<div class="form-group col-md-4">
<input type="text" class="form-control" id="idFolderRetentionPath${index}" name="folder_retention_path${index}" placeholder="path, i.e. /dir" value="">
</div>
<div class="form-group col-md-2">
<input type="number" min="0" class="form-control" id="idFolderRetentionVal${index}" name="folder_retention_val${index}" placeholder="Hours" value="">
</div>
<div class="form-group col-md-4">
<select class="form-control" id="idFolderRetentionOptions${index}" name="folder_retention_options${index}" multiple>
<option value="1">Delete empty dirs</option>
<option value="2">Ignore user permissions</option>
</select>
</div>
<div class="form-group col-md-1"></div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_data_retention_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
$("#idFolderRetentionOptions"+index).selectpicker();
});
$("body").on("click", ".remove_data_retention_btn_frm_field", function () {
$(this).closest(".form_field_data_retention_outer_row").remove();
});
function onTypeChanged(val){
$('.action-type').hide();
switch (val) {
@ -516,6 +611,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
case 3:
$('.action-smtp').show();
break;
case '8':
case 8:
$('.action-dataretention').show();
break;
}
}

View file

@ -520,7 +520,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{{- range .Actions}}
$("#idActionName"+index).append($('<option>').val('{{.Name}}').text('{{.Name}}'));
{{- end}}
console.log("index "+index);
$("#idActionName"+index).selectpicker({'liveSearch': true});
$("#idActionOptions"+index).selectpicker();
});