| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465 | 
							- // Copyright (C) 2019-2022  Nicola Murino
 
- //
 
- // This program is free software: you can redistribute it and/or modify
 
- // it under the terms of the GNU Affero General Public License as published
 
- // by the Free Software Foundation, version 3.
 
- //
 
- // This program is distributed in the hope that it will be useful,
 
- // but WITHOUT ANY WARRANTY; without even the implied warranty of
 
- // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 
- // GNU Affero General Public License for more details.
 
- //
 
- // You should have received a copy of the GNU Affero General Public License
 
- // along with this program.  If not, see <https://www.gnu.org/licenses/>.
 
- package common
 
- import (
 
- 	"bytes"
 
- 	"context"
 
- 	"encoding/json"
 
- 	"fmt"
 
- 	"net/http"
 
- 	"net/url"
 
- 	"os"
 
- 	"os/exec"
 
- 	"path"
 
- 	"path/filepath"
 
- 	"strings"
 
- 	"sync"
 
- 	"time"
 
- 	mail "github.com/xhit/go-simple-mail/v2"
 
- 	"github.com/drakkan/sftpgo/v2/internal/command"
 
- 	"github.com/drakkan/sftpgo/v2/internal/dataprovider"
 
- 	"github.com/drakkan/sftpgo/v2/internal/httpclient"
 
- 	"github.com/drakkan/sftpgo/v2/internal/logger"
 
- 	"github.com/drakkan/sftpgo/v2/internal/smtp"
 
- 	"github.com/drakkan/sftpgo/v2/internal/util"
 
- )
 
- // RetentionCheckNotification defines the supported notification methods for a retention check result
 
- type RetentionCheckNotification = string
 
- // Supported notification methods
 
- const (
 
- 	// notify results using the defined "data_retention_hook"
 
- 	RetentionCheckNotificationHook = "Hook"
 
- 	// notify results by email
 
- 	RetentionCheckNotificationEmail = "Email"
 
- )
 
- var (
 
- 	// RetentionChecks is the list of active retention checks
 
- 	RetentionChecks ActiveRetentionChecks
 
- )
 
- // ActiveRetentionChecks holds the active retention checks
 
- type ActiveRetentionChecks struct {
 
- 	sync.RWMutex
 
- 	Checks []RetentionCheck
 
- }
 
- // Get returns the active retention checks
 
- func (c *ActiveRetentionChecks) Get() []RetentionCheck {
 
- 	c.RLock()
 
- 	defer c.RUnlock()
 
- 	checks := make([]RetentionCheck, 0, len(c.Checks))
 
- 	for _, check := range c.Checks {
 
- 		foldersCopy := make([]dataprovider.FolderRetention, len(check.Folders))
 
- 		copy(foldersCopy, check.Folders)
 
- 		notificationsCopy := make([]string, len(check.Notifications))
 
- 		copy(notificationsCopy, check.Notifications)
 
- 		checks = append(checks, RetentionCheck{
 
- 			Username:      check.Username,
 
- 			StartTime:     check.StartTime,
 
- 			Notifications: notificationsCopy,
 
- 			Email:         check.Email,
 
- 			Folders:       foldersCopy,
 
- 		})
 
- 	}
 
- 	return checks
 
- }
 
- // Add a new retention check, returns nil if a retention check for the given
 
- // username is already active. The returned result can be used to start the check
 
- func (c *ActiveRetentionChecks) Add(check RetentionCheck, user *dataprovider.User) *RetentionCheck {
 
- 	c.Lock()
 
- 	defer c.Unlock()
 
- 	for _, val := range c.Checks {
 
- 		if val.Username == user.Username {
 
- 			return nil
 
- 		}
 
- 	}
 
- 	// we silently ignore file patterns
 
- 	user.Filters.FilePatterns = nil
 
- 	conn := NewBaseConnection("", "", "", "", *user)
 
- 	conn.SetProtocol(ProtocolDataRetention)
 
- 	conn.ID = fmt.Sprintf("data_retention_%v", user.Username)
 
- 	check.Username = user.Username
 
- 	check.StartTime = util.GetTimeAsMsSinceEpoch(time.Now())
 
- 	check.conn = conn
 
- 	check.updateUserPermissions()
 
- 	c.Checks = append(c.Checks, check)
 
- 	return &check
 
- }
 
- // remove a user from the ones with active retention checks
 
- // and returns true if the user is removed
 
- func (c *ActiveRetentionChecks) remove(username string) bool {
 
- 	c.Lock()
 
- 	defer c.Unlock()
 
- 	for idx, check := range c.Checks {
 
- 		if check.Username == username {
 
- 			lastIdx := len(c.Checks) - 1
 
- 			c.Checks[idx] = c.Checks[lastIdx]
 
- 			c.Checks = c.Checks[:lastIdx]
 
- 			return true
 
- 		}
 
- 	}
 
- 	return false
 
- }
 
- type folderRetentionCheckResult struct {
 
- 	Path         string        `json:"path"`
 
- 	Retention    int           `json:"retention"`
 
- 	DeletedFiles int           `json:"deleted_files"`
 
- 	DeletedSize  int64         `json:"deleted_size"`
 
- 	Elapsed      time.Duration `json:"-"`
 
- 	Info         string        `json:"info,omitempty"`
 
- 	Error        string        `json:"error,omitempty"`
 
- }
 
- // RetentionCheck defines an active retention check
 
- type RetentionCheck struct {
 
- 	// Username to which the retention check refers
 
- 	Username string `json:"username"`
 
- 	// retention check start time as unix timestamp in milliseconds
 
- 	StartTime int64 `json:"start_time"`
 
- 	// affected 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
 
- 	Email string `json:"email,omitempty"`
 
- 	// Cleanup results
 
- 	results []folderRetentionCheckResult `json:"-"`
 
- 	conn    *BaseConnection
 
- }
 
- // Validate returns an error if the specified folders are not valid
 
- func (c *RetentionCheck) 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!")
 
- 	}
 
- 	for _, notification := range c.Notifications {
 
- 		switch 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")
 
- 			}
 
- 		case RetentionCheckNotificationHook:
 
- 			if Config.DataRetentionHook == "" {
 
- 				return util.NewValidationError("in order to notify results via hook you must define a data_retention_hook")
 
- 			}
 
- 		default:
 
- 			return util.NewValidationError(fmt.Sprintf("invalid notification %#v", notification))
 
- 		}
 
- 	}
 
- 	return nil
 
- }
 
- func (c *RetentionCheck) updateUserPermissions() {
 
- 	for _, folder := range c.Folders {
 
- 		if folder.IgnoreUserPermissions {
 
- 			c.conn.User.Permissions[folder.Path] = []string{dataprovider.PermAny}
 
- 		}
 
- 	}
 
- }
 
- func (c *RetentionCheck) getFolderRetention(folderPath string) (dataprovider.FolderRetention, error) {
 
- 	dirsForPath := util.GetDirsForVirtualPath(folderPath)
 
- 	for _, dirPath := range dirsForPath {
 
- 		for _, folder := range c.Folders {
 
- 			if folder.Path == dirPath {
 
- 				return folder, nil
 
- 			}
 
- 		}
 
- 	}
 
- 	return dataprovider.FolderRetention{}, fmt.Errorf("unable to find folder retention for %#v", folderPath)
 
- }
 
- func (c *RetentionCheck) removeFile(virtualPath string, info os.FileInfo) error {
 
- 	fs, fsPath, err := c.conn.GetFsAndResolvedPath(virtualPath)
 
- 	if err != nil {
 
- 		return err
 
- 	}
 
- 	return c.conn.RemoveFile(fs, fsPath, virtualPath, info)
 
- }
 
- func (c *RetentionCheck) cleanupFolder(folderPath string) error {
 
- 	deleteFilesPerms := []string{dataprovider.PermDelete, dataprovider.PermDeleteFiles}
 
- 	startTime := time.Now()
 
- 	result := folderRetentionCheckResult{
 
- 		Path: folderPath,
 
- 	}
 
- 	defer func() {
 
- 		c.results = append(c.results, result)
 
- 	}()
 
- 	if !c.conn.User.HasPerm(dataprovider.PermListItems, folderPath) || !c.conn.User.HasAnyPerm(deleteFilesPerms, 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.Username, folderPath)
 
- 		return nil
 
- 	}
 
- 	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
 
- 	}
 
- 	c.conn.Log(logger.LevelDebug, "start retention check for folder %#v, retention: %v hours, delete empty dirs? %v, ignore user perms? %v",
 
- 		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
 
- 		}
 
- 		result.Error = fmt.Sprintf("unable to list directory %#v", folderPath)
 
- 		c.conn.Log(logger.LevelError, result.Error)
 
- 		return err
 
- 	}
 
- 	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.LevelError, "unable to cleanup folder %#v: %v", virtualPath, err)
 
- 				return err
 
- 			}
 
- 		} else {
 
- 			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.LevelError, "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)
 
- 				result.DeletedFiles++
 
- 				result.DeletedSize += info.Size()
 
- 			}
 
- 		}
 
- 	}
 
- 	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, result.DeletedFiles, result.DeletedSize)
 
- 	return nil
 
- }
 
- func (c *RetentionCheck) checkEmptyDirRemoval(folderPath string) {
 
- 	if folderPath == "/" {
 
- 		return
 
- 	}
 
- 	for _, folder := range c.Folders {
 
- 		if folderPath == folder.Path {
 
- 			return
 
- 		}
 
- 	}
 
- 	if c.conn.User.HasAnyPerm([]string{
 
- 		dataprovider.PermDelete,
 
- 		dataprovider.PermDeleteDirs,
 
- 	}, path.Dir(folderPath),
 
- 	) {
 
- 		files, err := c.conn.ListDir(folderPath)
 
- 		if err == nil && len(files) == 0 {
 
- 			err = c.conn.RemoveDir(folderPath)
 
- 			c.conn.Log(logger.LevelDebug, "tryed to remove empty dir %#v, error: %v", folderPath, err)
 
- 		}
 
- 	}
 
- }
 
- // Start starts the retention check
 
- 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
 
- 	startTime := time.Now()
 
- 	for _, folder := range c.Folders {
 
- 		if folder.Retention > 0 {
 
- 			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 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) {
 
- 	for _, notification := range c.Notifications {
 
- 		switch notification {
 
- 		case RetentionCheckNotificationEmail:
 
- 			c.sendEmailNotification(err) //nolint:errcheck
 
- 		case RetentionCheckNotificationHook:
 
- 			c.sendHookNotification(elapsed, err) //nolint:errcheck
 
- 		}
 
- 	}
 
- }
 
- func (c *RetentionCheck) sendEmailNotification(errCheck error) error {
 
- 	params := EventParams{}
 
- 	if len(c.results) > 0 || errCheck != nil {
 
- 		params.retentionChecks = append(params.retentionChecks, executedRetentionCheck{
 
- 			Username:   c.conn.User.Username,
 
- 			ActionName: "Retention check",
 
- 			Results:    c.results,
 
- 		})
 
- 	}
 
- 	var files []mail.File
 
- 	f, err := params.getRetentionReportsAsMailAttachment()
 
- 	if err != nil {
 
- 		c.conn.Log(logger.LevelError, "unable to get retention report as mail attachment: %v", err)
 
- 		return err
 
- 	}
 
- 	f.Name = "retention-report.zip"
 
- 	files = append(files, f)
 
- 	startTime := time.Now()
 
- 	var subject string
 
- 	if errCheck == nil {
 
- 		subject = fmt.Sprintf("Successful retention check for user %q", c.conn.User.Username)
 
- 	} else {
 
- 		subject = fmt.Sprintf("Retention check failed for user %q", c.conn.User.Username)
 
- 	}
 
- 	body := "Further details attached."
 
- 	err = smtp.SendEmail([]string{c.Email}, subject, body, smtp.EmailContentTypeTextPlain, files...)
 
- 	if err != nil {
 
- 		c.conn.Log(logger.LevelError, "unable to notify retention check result via email: %v, elapsed: %v", err,
 
- 			time.Since(startTime))
 
- 		return err
 
- 	}
 
- 	c.conn.Log(logger.LevelInfo, "retention check result successfully notified via email, elapsed: %v", time.Since(startTime))
 
- 	return nil
 
- }
 
- func (c *RetentionCheck) sendHookNotification(elapsed time.Duration, errCheck error) error {
 
- 	startNewHook()
 
- 	defer hookEnded()
 
- 	data := make(map[string]any)
 
- 	totalDeletedFiles := 0
 
- 	totalDeletedSize := int64(0)
 
- 	for _, result := range c.results {
 
- 		totalDeletedFiles += result.DeletedFiles
 
- 		totalDeletedSize += result.DeletedSize
 
- 	}
 
- 	data["username"] = c.conn.User.Username
 
- 	data["start_time"] = c.StartTime
 
- 	data["elapsed"] = elapsed.Milliseconds()
 
- 	if errCheck == nil {
 
- 		data["status"] = 1
 
- 	} else {
 
- 		data["status"] = 0
 
- 	}
 
- 	data["total_deleted_files"] = totalDeletedFiles
 
- 	data["total_deleted_size"] = totalDeletedSize
 
- 	data["details"] = c.results
 
- 	jsonData, _ := json.Marshal(data)
 
- 	startTime := time.Now()
 
- 	if strings.HasPrefix(Config.DataRetentionHook, "http") {
 
- 		var url *url.URL
 
- 		url, err := url.Parse(Config.DataRetentionHook)
 
- 		if err != nil {
 
- 			c.conn.Log(logger.LevelError, "invalid data retention hook %#v: %v", Config.DataRetentionHook, err)
 
- 			return err
 
- 		}
 
- 		respCode := 0
 
- 		resp, err := httpclient.RetryablePost(url.String(), "application/json", bytes.NewBuffer(jsonData))
 
- 		if err == nil {
 
- 			respCode = resp.StatusCode
 
- 			resp.Body.Close()
 
- 			if respCode != http.StatusOK {
 
- 				err = errUnexpectedHTTResponse
 
- 			}
 
- 		}
 
- 		c.conn.Log(logger.LevelDebug, "notified result to URL: %#v, status code: %v, elapsed: %v err: %v",
 
- 			url.Redacted(), respCode, time.Since(startTime), err)
 
- 		return err
 
- 	}
 
- 	if !filepath.IsAbs(Config.DataRetentionHook) {
 
- 		err := fmt.Errorf("invalid data retention hook %#v", Config.DataRetentionHook)
 
- 		c.conn.Log(logger.LevelError, "%v", err)
 
- 		return err
 
- 	}
 
- 	timeout, env, args := command.GetConfig(Config.DataRetentionHook, command.HookDataRetention)
 
- 	ctx, cancel := context.WithTimeout(context.Background(), timeout)
 
- 	defer cancel()
 
- 	cmd := exec.CommandContext(ctx, Config.DataRetentionHook, args...)
 
- 	cmd.Env = append(env,
 
- 		fmt.Sprintf("SFTPGO_DATA_RETENTION_RESULT=%v", string(jsonData)))
 
- 	err := cmd.Run()
 
- 	c.conn.Log(logger.LevelDebug, "notified result using command: %v, elapsed: %v err: %v",
 
- 		Config.DataRetentionHook, time.Since(startTime), err)
 
- 	return err
 
- }
 
 
  |