data retention: allow to notify results via e-mail

This commit is contained in:
Nicola Murino 2021-10-02 22:25:41 +02:00
parent 1459150024
commit cc134cad9a
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
23 changed files with 417 additions and 42 deletions

View file

@ -48,6 +48,7 @@ COPY --from=builder /workspace/sftpgo /usr/local/bin/
ENV SFTPGO_LOG_FILE_PATH=""
# templates and static paths are inside the container
ENV SFTPGO_HTTPD__TEMPLATES_PATH=/usr/share/sftpgo/templates
ENV SFTPGO_SMTP__TEMPLATES_PATH=/usr/share/sftpgo/templates
ENV SFTPGO_HTTPD__STATIC_FILES_PATH=/usr/share/sftpgo/static
# Modify the default configuration file

View file

@ -53,6 +53,7 @@ COPY --from=builder /workspace/sftpgo /usr/local/bin/
ENV SFTPGO_LOG_FILE_PATH=""
# templates and static paths are inside the container
ENV SFTPGO_HTTPD__TEMPLATES_PATH=/usr/share/sftpgo/templates
ENV SFTPGO_SMTP__TEMPLATES_PATH=/usr/share/sftpgo/templates
ENV SFTPGO_HTTPD__STATIC_FILES_PATH=/usr/share/sftpgo/static
# Modify the default configuration file

View file

@ -47,6 +47,7 @@ COPY --from=builder /etc/mime.types /etc/mime.types
ENV SFTPGO_LOG_FILE_PATH=""
# templates and static paths are inside the container
ENV SFTPGO_HTTPD__TEMPLATES_PATH=/usr/share/sftpgo/templates
ENV SFTPGO_SMTP__TEMPLATES_PATH=/usr/share/sftpgo/templates
ENV SFTPGO_HTTPD__STATIC_FILES_PATH=/usr/share/sftpgo/static
# These env vars are required to avoid the following error when calling user.Current():
# unable to get the current user: user: Current requires cgo or $USER set in environment

View file

@ -29,7 +29,7 @@ If the SMTP configuration is correct you should receive this email.`,
os.Exit(1)
}
smtpConfig := config.GetSMTPConfig()
err = smtpConfig.Initialize()
err = smtpConfig.Initialize(configDir)
if err != nil {
logger.ErrorToConsole("unable to initialize SMTP configuration: %v", err)
os.Exit(1)

View file

@ -87,7 +87,7 @@ Command-line flags should be specified in the Subsystem declaration.
os.Exit(1)
}
smtpConfig := config.GetSMTPConfig()
err = smtpConfig.Initialize()
err = smtpConfig.Initialize(configDir)
if err != nil {
logger.Error(logSender, connectionID, "unable to initialize SMTP configuration: %v", err)
os.Exit(1)

View file

@ -1,6 +1,7 @@
package common
import (
"bytes"
"fmt"
"os"
"path"
@ -9,9 +10,20 @@ import (
"github.com/drakkan/sftpgo/v2/dataprovider"
"github.com/drakkan/sftpgo/v2/logger"
"github.com/drakkan/sftpgo/v2/smtp"
"github.com/drakkan/sftpgo/v2/util"
)
// RetentionCheckNotification defines the supported notification methods for a retention check result
type RetentionCheckNotification = string
const (
// no notification, the check results are recorded in the logs
RetentionCheckNotificationNone = "None"
// notify results by email
RetentionCheckNotificationEmail = "Email"
)
var (
// RetentionChecks is the list of active quota scans
RetentionChecks ActiveRetentionChecks
@ -35,6 +47,8 @@ func (c *ActiveRetentionChecks) Get() []RetentionCheck {
checks = append(checks, RetentionCheck{
Username: check.Username,
StartTime: check.StartTime,
Notification: check.Notification,
Email: check.Email,
Folders: foldersCopy,
})
}
@ -114,6 +128,16 @@ func (f *FolderRetention) isValid() error {
return nil
}
type folderRetentionCheckResult struct {
Path string
Retention int
DeletedFiles int
DeletedSize int64
Elapsed time.Duration
Info string
Error string
}
// RetentionCheck defines an active retention check
type RetentionCheck struct {
// Username to which the retention check refers
@ -122,7 +146,12 @@ type RetentionCheck struct {
StartTime int64 `json:"start_time"`
// affected folders
Folders []FolderRetention `json:"folders"`
// how cleanup results will be notified
Notification RetentionCheckNotification `json:"notification"`
// email to use if the notification method is set to email
Email string `json:"email,omitempty"`
// Cleanup results
results []*folderRetentionCheckResult `json:"-"`
conn *BaseConnection
}
@ -146,6 +175,17 @@ func (c *RetentionCheck) Validate() error {
if nothingToDo {
return util.NewValidationError("nothing to delete!")
}
switch c.Notification {
case RetentionCheckNotificationEmail:
if !smtp.IsEnabled() {
return util.NewValidationError("in order to notify results via email you must configure an SMTP server")
}
if c.Email == "" {
return util.NewValidationError("in order to notify results via email you must add a valid email address to your profile")
}
default:
c.Notification = RetentionCheckNotificationNone
}
return nil
}
@ -180,7 +220,14 @@ func (c *RetentionCheck) removeFile(virtualPath string, info os.FileInfo) error
func (c *RetentionCheck) cleanupFolder(folderPath string) error {
cleanupPerms := []string{dataprovider.PermListItems, dataprovider.PermDelete}
startTime := time.Now()
result := &folderRetentionCheckResult{
Path: folderPath,
}
c.results = append(c.results, result)
if !c.conn.User.HasPerms(cleanupPerms, folderPath) {
result.Elapsed = time.Since(startTime)
result.Info = "data retention check skipped: no permissions"
c.conn.Log(logger.LevelInfo, "user %#v does not have permissions to check retention on %#v, retention check skipped",
c.conn.User, folderPath)
return nil
@ -188,10 +235,15 @@ func (c *RetentionCheck) cleanupFolder(folderPath string) error {
folderRetention, err := c.getFolderRetention(folderPath)
if err != nil {
result.Elapsed = time.Since(startTime)
result.Error = "unable to get folder retention"
c.conn.Log(logger.LevelError, "unable to get folder retention for path %#v", folderPath)
return err
}
result.Retention = folderRetention.Retention
if folderRetention.Retention == 0 {
result.Elapsed = time.Since(startTime)
result.Info = "data retention check skipped: retention is set to 0"
c.conn.Log(logger.LevelDebug, "retention check skipped for folder %#v, retention is set to 0", folderPath)
return nil
}
@ -199,19 +251,22 @@ func (c *RetentionCheck) cleanupFolder(folderPath string) error {
folderPath, folderRetention.Retention, folderRetention.DeleteEmptyDirs, folderRetention.IgnoreUserPermissions)
files, err := c.conn.ListDir(folderPath)
if err != nil {
result.Elapsed = time.Since(startTime)
if err == c.conn.GetNotExistError() {
result.Info = "data retention check skipped, folder does not exist"
c.conn.Log(logger.LevelDebug, "folder %#v does not exist, retention check skipped", folderPath)
return nil
}
c.conn.Log(logger.LevelWarn, "unable to list directory %#v", folderPath)
result.Error = fmt.Sprintf("unable to list directory %#v", folderPath)
c.conn.Log(logger.LevelWarn, result.Error)
return err
}
deletedFiles := 0
deletedSize := int64(0)
for _, info := range files {
virtualPath := path.Join(folderPath, info.Name())
if info.IsDir() {
if err := c.cleanupFolder(virtualPath); err != nil {
result.Elapsed = time.Since(startTime)
result.Error = fmt.Sprintf("unable to check folder: %v", err)
c.conn.Log(logger.LevelWarn, "unable to cleanup folder %#v: %v", virtualPath, err)
return err
}
@ -219,14 +274,16 @@ func (c *RetentionCheck) cleanupFolder(folderPath string) error {
retentionTime := info.ModTime().Add(time.Duration(folderRetention.Retention) * time.Hour)
if retentionTime.Before(time.Now()) {
if err := c.removeFile(virtualPath, info); err != nil {
result.Elapsed = time.Since(startTime)
result.Error = fmt.Sprintf("unable to remove file %#v: %v", virtualPath, err)
c.conn.Log(logger.LevelWarn, "unable to remove file %#v, retention %v: %v",
virtualPath, retentionTime, err)
return err
}
c.conn.Log(logger.LevelDebug, "removed file %#v, modification time: %v, retention: %v hours, retention time: %v",
virtualPath, info.ModTime(), folderRetention.Retention, retentionTime)
deletedFiles++
deletedSize += info.Size()
result.DeletedFiles++
result.DeletedSize += info.Size()
}
}
}
@ -234,8 +291,9 @@ func (c *RetentionCheck) cleanupFolder(folderPath string) error {
if folderRetention.DeleteEmptyDirs {
c.checkEmptyDirRemoval(folderPath)
}
result.Elapsed = time.Since(startTime)
c.conn.Log(logger.LevelDebug, "retention check completed for folder %#v, deleted files: %v, deleted size: %v bytes",
folderPath, deletedFiles, deletedSize)
folderPath, result.DeletedFiles, result.DeletedSize)
return nil
}
@ -256,14 +314,54 @@ func (c *RetentionCheck) Start() {
defer RetentionChecks.remove(c.conn.User.Username)
defer c.conn.CloseFS() //nolint:errcheck
startTime := time.Now()
for _, folder := range c.Folders {
if folder.Retention > 0 {
if err := c.cleanupFolder(folder.Path); err != nil {
c.conn.Log(logger.LevelWarn, "retention check failed, unable to cleanup folder %#v", folder.Path)
c.sendNotification(startTime, err) //nolint:errcheck
return
}
}
}
c.conn.Log(logger.LevelInfo, "retention check completed")
c.sendNotification(startTime, nil) //nolint:errcheck
}
func (c *RetentionCheck) sendNotification(startTime time.Time, err error) error {
switch c.Notification {
case RetentionCheckNotificationEmail:
body := new(bytes.Buffer)
data := make(map[string]interface{})
data["Results"] = c.results
totalDeletedFiles := 0
totalDeletedSize := int64(0)
for _, result := range c.results {
totalDeletedFiles += result.DeletedFiles
totalDeletedSize += result.DeletedSize
}
data["HumanizeSize"] = util.ByteCountIEC
data["TotalFiles"] = totalDeletedFiles
data["TotalSize"] = totalDeletedSize
data["Elapsed"] = time.Since(startTime)
data["Username"] = c.conn.User.Username
data["StartTime"] = util.GetTimeFromMsecSinceEpoch(c.StartTime)
if err == nil {
data["Status"] = "Succeeded"
} else {
data["Status"] = "Failed"
}
if err := smtp.RenderRetentionReportTemplate(body, data); err != nil {
c.conn.Log(logger.LevelWarn, "unable to render retention check template: %v", err)
return err
}
subject := fmt.Sprintf("Retention check completed for user %#v", c.conn.User.Username)
if err := smtp.SendEmail(c.Email, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil {
c.conn.Log(logger.LevelWarn, "unable to notify retention check result via email: %v", err)
return err
}
c.conn.Log(logger.LevelInfo, "retention check result successfully notified via email")
}
return nil
}

View file

@ -1,14 +1,17 @@
package common
import (
"errors"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/drakkan/sftpgo/v2/dataprovider"
"github.com/drakkan/sftpgo/v2/sdk"
"github.com/drakkan/sftpgo/v2/smtp"
)
func TestRetentionValidation(t *testing.T) {
@ -62,6 +65,83 @@ func TestRetentionValidation(t *testing.T) {
}
err = check.Validate()
assert.NoError(t, err)
assert.Equal(t, RetentionCheckNotificationNone, check.Notification)
assert.Empty(t, check.Email)
check.Notification = RetentionCheckNotificationEmail
err = check.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "you must configure an SMTP server")
smtpCfg := smtp.Config{
Host: "mail.example.com",
Port: 25,
TemplatesPath: "templates",
}
err = smtpCfg.Initialize("..")
require.NoError(t, err)
err = check.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "you must add a valid email address")
check.Email = "admin@example.com"
err = check.Validate()
assert.NoError(t, err)
smtpCfg = smtp.Config{}
err = smtpCfg.Initialize("..")
require.NoError(t, err)
}
func TestEmailNotifications(t *testing.T) {
smtpCfg := smtp.Config{
Host: "127.0.0.1",
Port: 2525,
TemplatesPath: "templates",
}
err := smtpCfg.Initialize("..")
require.NoError(t, err)
user := dataprovider.User{
BaseUser: sdk.BaseUser{
Username: "user1",
},
}
user.Permissions = make(map[string][]string)
user.Permissions["/"] = []string{dataprovider.PermAny}
check := RetentionCheck{
Notification: RetentionCheckNotificationEmail,
Email: "notification@example.com",
results: []*folderRetentionCheckResult{
{
Path: "/",
Retention: 24,
DeletedFiles: 10,
DeletedSize: 32657,
Elapsed: 10 * time.Second,
},
},
}
conn := NewBaseConnection("", "", "", "", user)
conn.ID = fmt.Sprintf("retention_check_%v", user.Username)
check.conn = conn
err = check.sendNotification(time.Now(), nil)
assert.NoError(t, err)
err = check.sendNotification(time.Now(), errors.New("test error"))
assert.NoError(t, err)
smtpCfg.Port = 2626
err = smtpCfg.Initialize("..")
require.NoError(t, err)
err = check.sendNotification(time.Now(), nil)
assert.Error(t, err)
smtpCfg = smtp.Config{}
err = smtpCfg.Initialize("..")
require.NoError(t, err)
err = check.sendNotification(time.Now(), nil)
assert.Error(t, err)
}
func TestRetentionPermissionsAndGetFolder(t *testing.T) {

View file

@ -19,15 +19,15 @@ import (
_ "github.com/go-sql-driver/mysql"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
"github.com/mhale/smtpd"
"github.com/pkg/sftp"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
"github.com/rs/zerolog"
"github.com/drakkan/sftpgo/v2/common"
"github.com/drakkan/sftpgo/v2/config"
"github.com/drakkan/sftpgo/v2/dataprovider"
@ -45,6 +45,7 @@ const (
httpAddr = "127.0.0.1:9999"
httpProxyAddr = "127.0.0.1:7777"
sftpServerAddr = "127.0.0.1:4022"
smtpServerAddr = "127.0.0.1:2525"
defaultUsername = "test_common_sftp"
defaultPassword = "test_password"
defaultSFTPUsername = "test_common_sftpfs_user"
@ -162,8 +163,18 @@ func TestMain(m *testing.M) {
}
}()
go func() {
if err := smtpd.ListenAndServe(smtpServerAddr, func(remoteAddr net.Addr, from string, to []string, data []byte) error {
return nil
}, "SFTPGo test", "localhost"); err != nil {
logger.ErrorToConsole("could not start SMTP server: %v", err)
os.Exit(1)
}
}()
waitTCPListening(httpAddr)
waitTCPListening(httpProxyAddr)
waitTCPListening(smtpServerAddr)
waitTCPListening(sftpdConf.Bindings[0].GetAddress())
waitTCPListening(httpdConf.Bindings[0].GetAddress())

View file

@ -316,6 +316,7 @@ func Init() {
AuthType: 0,
Encryption: 0,
Domain: "",
TemplatesPath: "templates",
},
}
@ -1176,6 +1177,7 @@ func setViperDefaults() {
viper.SetDefault("smtp.auth_type", globalConf.SMTPConfig.AuthType)
viper.SetDefault("smtp.encryption", globalConf.SMTPConfig.Encryption)
viper.SetDefault("smtp.domain", globalConf.SMTPConfig.Domain)
viper.SetDefault("smtp.templates_path", globalConf.SMTPConfig.TemplatesPath)
}
func lookupBoolFromEnv(envName string) (bool, bool) {

View file

@ -263,12 +263,13 @@ The configuration file contains the following sections:
- **smtp**, SMTP configuration enables SFTPGo email sending capabilities
- `host`, string. Location of SMTP email server. Leavy empty to disable email sending capabilities. Default: empty.
- `port`, integer. Port of SMTP email server.
- `from`, string. From address, for example `SFTPGo <sftpgo@example.com>`. Default: empty
- `from`, string. From address, for example `SFTPGo <sftpgo@example.com>`. Many SMTP servers reject emails without a `From` header so, if not set, SFTPGo will try to use the username as fallback, this may or may not be appropriate. Default: empty
- `user`, string. SMTP username. Default: empty
- `password`, string. SMTP password. Leaving both username and password empty the SMTP authentication will be disabled. Default: empty
- `auth_type`, integer. 0 means `Plain`, 1 means `Login`, 2 means `CRAM-MD5`. Default: `0`.
- `encryption`, integer. 0 means no encryption, 1 means `TLS`, 2 means `STARTTLS`. Default: `0`.
- `domain`, string. Domain to use for `HELO` command, if empty `localhost` will be used. Default: empty.
- `templates_path`, string. Path to the email templates. This can be an absolute path or a path relative to the config dir. Templates are searched within a subdirectory named "email" in the specified path. You can customize the email templates by simply specifying an alternate path and putting your custom templates there.
- **plugins**, list of external plugins. Each plugin is configured using a struct with the following fields:
- `type`, string. Defines the plugin type. Supported types: `notifier`, `kms`, `auth`.
- `notifier_options`, struct. Defines the options for notifier plugins.

View file

@ -88,6 +88,7 @@ In the above example we asked to SFTPGo:
- to exclude `/folder1/subfolder`, no files will be deleted here
- to delete all the files with modification time older than 24 hours in `/folder2`
The check results can be, optionally, notified by e-mail.
You can find an example script that shows how to manage data retention [here](../examples/data-retention). Checks the REST API schema for full details.
:warning: Deleting files is an irreversible action, please make sure you fully understand what you are doing before using this feature, you may have users with overlapping home directories or virtual folders shared between multiple users, it is relatively easy to inadvertently delete files you need.

5
go.mod
View file

@ -34,6 +34,7 @@ require (
github.com/lithammer/shortuuid/v3 v3.0.7
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mattn/go-sqlite3 v1.14.8
github.com/mhale/smtpd v0.8.0
github.com/miekg/dns v1.1.43 // indirect
github.com/minio/sio v0.3.0
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
@ -62,10 +63,10 @@ require (
gocloud.dev v0.24.0
golang.org/x/crypto v0.0.0-20210915214749-c084706c2272
golang.org/x/net v0.0.0-20210929193557-e81a3d93ecf6
golang.org/x/sys v0.0.0-20211001092434-39dca1131b70
golang.org/x/sys v0.0.0-20211002104244-808efd93c36d
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac
google.golang.org/api v0.58.0
google.golang.org/genproto v0.0.0-20210930144712-2e2e1008e8a3 // indirect
google.golang.org/genproto v0.0.0-20211001223012-bfb93cce50d9 // indirect
google.golang.org/grpc v1.41.0
google.golang.org/protobuf v1.27.1
gopkg.in/natefinch/lumberjack.v2 v2.0.0

10
go.sum
View file

@ -595,6 +595,8 @@ github.com/mattn/go-sqlite3 v1.14.8 h1:gDp86IdQsN/xWjIEmr9MF6o9mpksUgh0fu+9ByFxz
github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mhale/smtpd v0.8.0 h1:5JvdsehCg33PQrZBvFyDMMUDQmvbzVpZgKob7eYBJc0=
github.com/mhale/smtpd v0.8.0/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
@ -960,8 +962,8 @@ golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211001092434-39dca1131b70 h1:pGleJoyD1yA5HfvuaksHxD0404gsEkNDerKsQ0N0y1s=
golang.org/x/sys v0.0.0-20211001092434-39dca1131b70/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211002104244-808efd93c36d h1:SABT8Vei3iTiu+Gy8KOzpSNz+W1EQ5YBCRtiEETxF+0=
golang.org/x/sys v0.0.0-20211002104244-808efd93c36d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -1162,8 +1164,8 @@ google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEc
google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210921142501-181ce0d877f6/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20210930144712-2e2e1008e8a3 h1:+F3FcO6LTrzNq5wp1Z6JtoBvnJzX6euyN70FoyMDXy4=
google.golang.org/genproto v0.0.0-20210930144712-2e2e1008e8a3/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211001223012-bfb93cce50d9 h1:eF1wcrhdz56Vugf8qNX5dD93ItkrhothojQyHXqloe0=
google.golang.org/genproto v0.0.0-20211001223012-bfb93cce50d9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=

View file

@ -29,8 +29,22 @@ func startRetentionCheck(w http.ResponseWriter, r *http.Request) {
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
return
}
check.Notification = r.URL.Query().Get("notify")
if check.Notification == common.RetentionCheckNotificationEmail {
claims, err := getTokenClaims(r)
if err != nil {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
}
admin, err := dataprovider.AdminExists(claims.Username)
if err != nil {
sendAPIResponse(w, r, err, "", getRespStatus(err))
return
}
check.Email = admin.Email
}
if err := check.Validate(); err != nil {
sendAPIResponse(w, r, err, "Invalid folders to check", http.StatusBadRequest)
sendAPIResponse(w, r, err, "Invalid retention check", http.StatusBadRequest)
return
}
c := common.RetentionChecks.Add(check, &user)

View file

@ -1584,7 +1584,7 @@ func TestRetentionAPI(t *testing.T) {
resp, err := httpdtest.StartRetentionCheck(user.Username, folderRetention, http.StatusBadRequest)
assert.NoError(t, err)
assert.Contains(t, string(resp), "Invalid folders to check")
assert.Contains(t, string(resp), "Invalid retention check")
folderRetention[0].Retention = 24
_, err = httpdtest.StartRetentionCheck(user.Username, folderRetention, http.StatusAccepted)
@ -1621,7 +1621,13 @@ func TestRetentionAPI(t *testing.T) {
c.Start()
assert.Len(t, common.RetentionChecks.Get(), 0)
token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
admin := getTestAdmin()
admin.Username = altAdminUsername
admin.Password = altAdminPassword
admin, _, err = httpdtest.AddAdmin(admin, http.StatusCreated)
assert.NoError(t, err)
token, err := getJWTAPITokenFromTestServer(altAdminUsername, altAdminPassword)
assert.NoError(t, err)
req, _ := http.NewRequest(http.MethodPost, retentionBasePath+"/"+user.Username+"/check",
bytes.NewBuffer([]byte("invalid json")))
@ -1629,6 +1635,23 @@ func TestRetentionAPI(t *testing.T) {
rr := executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
asJSON, err := json.Marshal(folderRetention)
assert.NoError(t, err)
req, _ = http.NewRequest(http.MethodPost, retentionBasePath+"/"+user.Username+"/check?notify=Email",
bytes.NewBuffer(asJSON))
setBearerForReq(req, token)
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
assert.Contains(t, rr.Body.String(), "to notify results via email")
_, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
assert.NoError(t, err)
req, _ = http.NewRequest(http.MethodPost, retentionBasePath+"/"+user.Username+"/check?notify=Email",
bytes.NewBuffer(asJSON))
setBearerForReq(req, token)
rr = executeRequest(req)
checkResponseCode(t, http.StatusNotFound, rr)
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())

View file

@ -480,6 +480,46 @@ func TestUpdateWebAdminInvalidClaims(t *testing.T) {
assert.Contains(t, rr.Body.String(), "Invalid token claims")
}
func TestRetentionInvalidTokenClaims(t *testing.T) {
username := "retentionuser"
user := dataprovider.User{
BaseUser: sdk.BaseUser{
Username: username,
Password: "pwd",
HomeDir: filepath.Join(os.TempDir(), username),
Status: 1,
},
}
user.Permissions = make(map[string][]string)
user.Permissions["/"] = []string{dataprovider.PermAny}
user.Filters.AllowAPIKeyAuth = true
err := dataprovider.AddUser(&user)
assert.NoError(t, err)
folderRetention := []common.FolderRetention{
{
Path: "/",
Retention: 0,
DeleteEmptyDirs: true,
},
}
asJSON, err := json.Marshal(folderRetention)
assert.NoError(t, err)
req, _ := http.NewRequest(http.MethodPost, retentionBasePath+"/"+username+"/check?notify=Email", bytes.NewBuffer(asJSON))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("username", username)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
req = req.WithContext(context.WithValue(req.Context(), jwtauth.ErrorCtxKey, errors.New("error")))
rr := httptest.NewRecorder()
startRetentionCheck(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
assert.Contains(t, rr.Body.String(), "Invalid token claims")
err = dataprovider.DeleteUser(username)
assert.NoError(t, err)
}
func TestCSRFToken(t *testing.T) {
// invalid token
err := verifyCSRFToken("token")

View file

@ -772,6 +772,11 @@ paths:
required: true
schema:
type: string
- name: notify
in: query
description: 'specify how notify results'
schema:
$ref: '#/components/schemas/RetentionCheckNotification'
post:
tags:
- data retention
@ -3223,6 +3228,15 @@ components:
* `password-change-disabled` - changing password is not allowed
* `api-key-auth-change-disabled` - enabling/disabling API key authentication is not allowed
* `info-change-disabled` - changing info such as email and description is not allowed
RetentionCheckNotification:
type: string
enum:
- None
- Email
description: |
Options:
* `None` - no notification, the results are recorded in the logs
* `Email` - notify results by email. The admin starting the retention check must have an associated email address and the SMTP server must be configured for this to work
APIKeyScope:
type: integer
enum:
@ -3991,6 +4005,12 @@ components:
type: integer
format: int64
description: check start time as unix timestamp in milliseconds
notification:
$ref: '#/components/schemas/RetentionCheckNotification'
email:
type: string
format: email
description: 'if the notification method is set to "Email", this is the e-mail address that receives the retention check report. This field is automatically set to the email address associated with the administrator starting the check'
QuotaScan:
type: object
properties:

View file

@ -296,7 +296,7 @@ func loadAdminTemplates(templatesPath string) {
filepath.Join(templatesPath, templateAdminDir, templateSetup),
}
fsBaseTpl := template.New("").Funcs(template.FuncMap{
fsBaseTpl := template.New("fsBaseTemplate").Funcs(template.FuncMap{
"ListFSProviders": sdk.ListProviders,
})
usersTmpl := util.LoadTemplate(nil, usersPaths...)

View file

@ -111,7 +111,7 @@ func (s *Service) Start() error {
os.Exit(1)
}
smtpConfig := config.GetSMTPConfig()
err = smtpConfig.Initialize()
err = smtpConfig.Initialize(s.ConfigDir)
if err != nil {
logger.Error(logSender, "", "unable to initialize SMTP configuration: %v", err)
logger.ErrorToConsole("unable to initialize SMTP configuration: %v", err)

View file

@ -262,7 +262,8 @@
"password": "",
"auth_type": 0,
"encryption": 0,
"domain": ""
"domain": "",
"templates_path": "templates"
},
"plugins": []
}

View file

@ -2,13 +2,17 @@
package smtp
import (
"bytes"
"errors"
"fmt"
"html/template"
"path/filepath"
"time"
mail "github.com/xhit/go-simple-mail/v2"
"github.com/drakkan/sftpgo/v2/logger"
"github.com/drakkan/sftpgo/v2/util"
)
const (
@ -24,18 +28,31 @@ const (
EmailContentTypeTextHTML
)
const (
templateEmailDir = "email"
templateRetentionCheckResult = "retention-check-report.html"
)
var (
smtpServer *mail.SMTPServer
from string
emailTemplates = make(map[string]*template.Template)
)
// IsEnabled returns true if an SMTP server is configured
func IsEnabled() bool {
return smtpServer != nil
}
// Config defines the SMTP configuration to use to send emails
type Config struct {
// Location of SMTP email server. Leavy empty to disable email sending capabilities
Host string `json:"host" mapstructure:"host"`
// Port of SMTP email server
Port int `json:"port" mapstructure:"port"`
// From address, for example "SFTPGo <sftpgo@example.com>"
// From address, for example "SFTPGo <sftpgo@example.com>".
// Many SMTP servers reject emails without a `From` header so, if not set,
// SFTPGo will try to use the username as fallback, this may or may not be appropriate
From string `json:"from" mapstructure:"from"`
// SMTP username
User string `json:"user" mapstructure:"user"`
@ -52,10 +69,13 @@ type Config struct {
Encryption int `json:"encryption" mapstructure:"encryption"`
// Domain to use for HELO command, if empty localhost will be used
Domain string `json:"domain" mapstructure:"domain"`
// Path to the email templates. This can be an absolute path or a path relative to the config dir.
// Templates are searched within a subdirectory named "email" in the specified path
TemplatesPath string `json:"templates_path" mapstructure:"templates_path"`
}
// Initialize initialized and validates the SMTP configuration
func (c *Config) Initialize() error {
func (c *Config) Initialize(configDir string) error {
smtpServer = nil
if c.Host == "" {
logger.Debug(logSender, "", "configuration disabled, email capabilities will not be available")
@ -70,6 +90,14 @@ func (c *Config) Initialize() error {
if c.Encryption < 0 || c.Encryption > 2 {
return fmt.Errorf("smtp: invalid encryption %v", c.Encryption)
}
templatesPath := c.TemplatesPath
if templatesPath == "" || !util.IsFileInputValid(templatesPath) {
return fmt.Errorf("smtp: invalid templates path %#v", templatesPath)
}
if !filepath.IsAbs(templatesPath) {
templatesPath = filepath.Join(configDir, templatesPath)
}
loadTemplates(filepath.Join(templatesPath, templateEmailDir))
from = c.From
smtpServer = mail.NewSMTPClient()
smtpServer.Host = c.Host
@ -114,6 +142,21 @@ func (c *Config) getAuthType() mail.AuthType {
}
}
func loadTemplates(templatesPath string) {
logger.Debug(logSender, "", "loading templates from %#v", templatesPath)
retentionCheckPath := filepath.Join(templatesPath, templateRetentionCheckResult)
retentionTmpl := util.LoadTemplate(nil, retentionCheckPath)
emailTemplates[templateRetentionCheckResult] = retentionTmpl
}
// RenderRetentionReportTemplate executes the retention report template
func RenderRetentionReportTemplate(buf *bytes.Buffer, data interface{}) error {
if smtpServer == nil {
return errors.New("smtp: not configured")
}
return emailTemplates[templateRetentionCheckResult].Execute(buf, data)
}
// SendEmail tries to send an email using the specified parameters.
func SendEmail(to, subject, body string, contentType EmailContentType) error {
if smtpServer == nil {
@ -127,6 +170,8 @@ func SendEmail(to, subject, body string, contentType EmailContentType) error {
email := mail.NewMSG()
if from != "" {
email.SetFrom(from)
} else {
email.SetFrom(smtpServer.Username)
}
email.AddTo(to).SetSubject(subject)
switch contentType {

View file

@ -0,0 +1,33 @@
Retention check report for user <b>"{{.Username}}"</b>
<br><br>
Status: <strong>{{.Status}}</strong>
<br>
Start time: {{.StartTime}}
<br>
Total files deleted: {{.TotalFiles}}
<br>
Total size deleted: {{call .HumanizeSize .TotalSize}}
<br>
Elapsed: {{.Elapsed}}
<br>
{{range .Results -}}
<p>
Path: {{.Path}}
{{- if .Error}}
<br>
Error: {{.Error}}
{{- end}}
{{- if .Info}}
<br>
Info: {{.Info}}
{{- end}}
<br>
Retention: {{.Retention}} hours
<br>
Files deleted: {{.DeletedFiles}}
<br>
Size deleted: {{call $.HumanizeSize .DeletedSize}}
<br>
Elapsed: {{.Elapsed}}
</p>
{{end}}

View file

@ -354,8 +354,8 @@ func CleanPath(p string) string {
}
// LoadTemplate parses the given template paths.
// it behaves like template.Must but it writes a log before exiting
// you can optionally provide a base template (e.g. to define some custom functions)
// It behaves like template.Must but it writes a log before exiting.
// You can optionally provide a base template (e.g. to define some custom functions)
func LoadTemplate(base *template.Template, paths ...string) *template.Template {
var t *template.Template
var err error