dataretention.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. // Copyright (C) 2019 Nicola Murino
  2. //
  3. // This program is free software: you can redistribute it and/or modify
  4. // it under the terms of the GNU Affero General Public License as published
  5. // by the Free Software Foundation, version 3.
  6. //
  7. // This program is distributed in the hope that it will be useful,
  8. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. // GNU Affero General Public License for more details.
  11. //
  12. // You should have received a copy of the GNU Affero General Public License
  13. // along with this program. If not, see <https://www.gnu.org/licenses/>.
  14. package common
  15. import (
  16. "bytes"
  17. "context"
  18. "encoding/json"
  19. "fmt"
  20. "net/http"
  21. "net/url"
  22. "os"
  23. "os/exec"
  24. "path"
  25. "path/filepath"
  26. "strings"
  27. "sync"
  28. "time"
  29. "github.com/wneessen/go-mail"
  30. "github.com/drakkan/sftpgo/v2/internal/command"
  31. "github.com/drakkan/sftpgo/v2/internal/dataprovider"
  32. "github.com/drakkan/sftpgo/v2/internal/httpclient"
  33. "github.com/drakkan/sftpgo/v2/internal/logger"
  34. "github.com/drakkan/sftpgo/v2/internal/smtp"
  35. "github.com/drakkan/sftpgo/v2/internal/util"
  36. )
  37. // RetentionCheckNotification defines the supported notification methods for a retention check result
  38. type RetentionCheckNotification = string
  39. // Supported notification methods
  40. const (
  41. // notify results using the defined "data_retention_hook"
  42. RetentionCheckNotificationHook = "Hook"
  43. // notify results by email
  44. RetentionCheckNotificationEmail = "Email"
  45. )
  46. var (
  47. // RetentionChecks is the list of active retention checks
  48. RetentionChecks ActiveRetentionChecks
  49. )
  50. // ActiveRetentionChecks holds the active retention checks
  51. type ActiveRetentionChecks struct {
  52. sync.RWMutex
  53. Checks []RetentionCheck
  54. }
  55. // Get returns the active retention checks
  56. func (c *ActiveRetentionChecks) Get(role string) []RetentionCheck {
  57. c.RLock()
  58. defer c.RUnlock()
  59. checks := make([]RetentionCheck, 0, len(c.Checks))
  60. for _, check := range c.Checks {
  61. if role == "" || role == check.Role {
  62. foldersCopy := make([]dataprovider.FolderRetention, len(check.Folders))
  63. copy(foldersCopy, check.Folders)
  64. notificationsCopy := make([]string, len(check.Notifications))
  65. copy(notificationsCopy, check.Notifications)
  66. checks = append(checks, RetentionCheck{
  67. Username: check.Username,
  68. StartTime: check.StartTime,
  69. Notifications: notificationsCopy,
  70. Email: check.Email,
  71. Folders: foldersCopy,
  72. })
  73. }
  74. }
  75. return checks
  76. }
  77. // Add a new retention check, returns nil if a retention check for the given
  78. // username is already active. The returned result can be used to start the check
  79. func (c *ActiveRetentionChecks) Add(check RetentionCheck, user *dataprovider.User) *RetentionCheck {
  80. c.Lock()
  81. defer c.Unlock()
  82. for _, val := range c.Checks {
  83. if val.Username == user.Username {
  84. return nil
  85. }
  86. }
  87. // we silently ignore file patterns
  88. user.Filters.FilePatterns = nil
  89. conn := NewBaseConnection("", "", "", "", *user)
  90. conn.SetProtocol(ProtocolDataRetention)
  91. conn.ID = fmt.Sprintf("data_retention_%v", user.Username)
  92. check.Username = user.Username
  93. check.Role = user.Role
  94. check.StartTime = util.GetTimeAsMsSinceEpoch(time.Now())
  95. check.conn = conn
  96. check.updateUserPermissions()
  97. c.Checks = append(c.Checks, check)
  98. return &check
  99. }
  100. // remove a user from the ones with active retention checks
  101. // and returns true if the user is removed
  102. func (c *ActiveRetentionChecks) remove(username string) bool {
  103. c.Lock()
  104. defer c.Unlock()
  105. for idx, check := range c.Checks {
  106. if check.Username == username {
  107. lastIdx := len(c.Checks) - 1
  108. c.Checks[idx] = c.Checks[lastIdx]
  109. c.Checks = c.Checks[:lastIdx]
  110. return true
  111. }
  112. }
  113. return false
  114. }
  115. type folderRetentionCheckResult struct {
  116. Path string `json:"path"`
  117. Retention int `json:"retention"`
  118. DeletedFiles int `json:"deleted_files"`
  119. DeletedSize int64 `json:"deleted_size"`
  120. Elapsed time.Duration `json:"-"`
  121. Info string `json:"info,omitempty"`
  122. Error string `json:"error,omitempty"`
  123. }
  124. // RetentionCheck defines an active retention check
  125. type RetentionCheck struct {
  126. // Username to which the retention check refers
  127. Username string `json:"username"`
  128. // retention check start time as unix timestamp in milliseconds
  129. StartTime int64 `json:"start_time"`
  130. // affected folders
  131. Folders []dataprovider.FolderRetention `json:"folders"`
  132. // how cleanup results will be notified
  133. Notifications []RetentionCheckNotification `json:"notifications,omitempty"`
  134. // email to use if the notification method is set to email
  135. Email string `json:"email,omitempty"`
  136. Role string `json:"-"`
  137. // Cleanup results
  138. results []folderRetentionCheckResult `json:"-"`
  139. conn *BaseConnection
  140. }
  141. // Validate returns an error if the specified folders are not valid
  142. func (c *RetentionCheck) Validate() error {
  143. folderPaths := make(map[string]bool)
  144. nothingToDo := true
  145. for idx := range c.Folders {
  146. f := &c.Folders[idx]
  147. if err := f.Validate(); err != nil {
  148. return err
  149. }
  150. if f.Retention > 0 {
  151. nothingToDo = false
  152. }
  153. if _, ok := folderPaths[f.Path]; ok {
  154. return util.NewValidationError(fmt.Sprintf("duplicated folder path %q", f.Path))
  155. }
  156. folderPaths[f.Path] = true
  157. }
  158. if nothingToDo {
  159. return util.NewValidationError("nothing to delete!")
  160. }
  161. for _, notification := range c.Notifications {
  162. switch notification {
  163. case RetentionCheckNotificationEmail:
  164. if !smtp.IsEnabled() {
  165. return util.NewValidationError("in order to notify results via email you must configure an SMTP server")
  166. }
  167. if c.Email == "" {
  168. return util.NewValidationError("in order to notify results via email you must add a valid email address to your profile")
  169. }
  170. case RetentionCheckNotificationHook:
  171. if Config.DataRetentionHook == "" {
  172. return util.NewValidationError("in order to notify results via hook you must define a data_retention_hook")
  173. }
  174. default:
  175. return util.NewValidationError(fmt.Sprintf("invalid notification %q", notification))
  176. }
  177. }
  178. return nil
  179. }
  180. func (c *RetentionCheck) updateUserPermissions() {
  181. for _, folder := range c.Folders {
  182. if folder.IgnoreUserPermissions {
  183. c.conn.User.Permissions[folder.Path] = []string{dataprovider.PermAny}
  184. }
  185. }
  186. }
  187. func (c *RetentionCheck) getFolderRetention(folderPath string) (dataprovider.FolderRetention, error) {
  188. dirsForPath := util.GetDirsForVirtualPath(folderPath)
  189. for _, dirPath := range dirsForPath {
  190. for _, folder := range c.Folders {
  191. if folder.Path == dirPath {
  192. return folder, nil
  193. }
  194. }
  195. }
  196. return dataprovider.FolderRetention{}, fmt.Errorf("unable to find folder retention for %q", folderPath)
  197. }
  198. func (c *RetentionCheck) removeFile(virtualPath string, info os.FileInfo) error {
  199. fs, fsPath, err := c.conn.GetFsAndResolvedPath(virtualPath)
  200. if err != nil {
  201. return err
  202. }
  203. return c.conn.RemoveFile(fs, fsPath, virtualPath, info)
  204. }
  205. func (c *RetentionCheck) cleanupFolder(folderPath string) error {
  206. deleteFilesPerms := []string{dataprovider.PermDelete, dataprovider.PermDeleteFiles}
  207. startTime := time.Now()
  208. result := folderRetentionCheckResult{
  209. Path: folderPath,
  210. }
  211. defer func() {
  212. c.results = append(c.results, result)
  213. }()
  214. if !c.conn.User.HasPerm(dataprovider.PermListItems, folderPath) || !c.conn.User.HasAnyPerm(deleteFilesPerms, folderPath) {
  215. result.Elapsed = time.Since(startTime)
  216. result.Info = "data retention check skipped: no permissions"
  217. c.conn.Log(logger.LevelInfo, "user %q does not have permissions to check retention on %q, retention check skipped",
  218. c.conn.User.Username, folderPath)
  219. return nil
  220. }
  221. folderRetention, err := c.getFolderRetention(folderPath)
  222. if err != nil {
  223. result.Elapsed = time.Since(startTime)
  224. result.Error = "unable to get folder retention"
  225. c.conn.Log(logger.LevelError, "unable to get folder retention for path %q", folderPath)
  226. return err
  227. }
  228. result.Retention = folderRetention.Retention
  229. if folderRetention.Retention == 0 {
  230. result.Elapsed = time.Since(startTime)
  231. result.Info = "data retention check skipped: retention is set to 0"
  232. c.conn.Log(logger.LevelDebug, "retention check skipped for folder %q, retention is set to 0", folderPath)
  233. return nil
  234. }
  235. c.conn.Log(logger.LevelDebug, "start retention check for folder %q, retention: %v hours, delete empty dirs? %v, ignore user perms? %v",
  236. folderPath, folderRetention.Retention, folderRetention.DeleteEmptyDirs, folderRetention.IgnoreUserPermissions)
  237. files, err := c.conn.ListDir(folderPath)
  238. if err != nil {
  239. result.Elapsed = time.Since(startTime)
  240. if err == c.conn.GetNotExistError() {
  241. result.Info = "data retention check skipped, folder does not exist"
  242. c.conn.Log(logger.LevelDebug, "folder %q does not exist, retention check skipped", folderPath)
  243. return nil
  244. }
  245. result.Error = fmt.Sprintf("unable to list directory %q", folderPath)
  246. c.conn.Log(logger.LevelError, result.Error)
  247. return err
  248. }
  249. for _, info := range files {
  250. virtualPath := path.Join(folderPath, info.Name())
  251. if info.IsDir() {
  252. if err := c.cleanupFolder(virtualPath); err != nil {
  253. result.Elapsed = time.Since(startTime)
  254. result.Error = fmt.Sprintf("unable to check folder: %v", err)
  255. c.conn.Log(logger.LevelError, "unable to cleanup folder %q: %v", virtualPath, err)
  256. return err
  257. }
  258. } else {
  259. retentionTime := info.ModTime().Add(time.Duration(folderRetention.Retention) * time.Hour)
  260. if retentionTime.Before(time.Now()) {
  261. if err := c.removeFile(virtualPath, info); err != nil {
  262. result.Elapsed = time.Since(startTime)
  263. result.Error = fmt.Sprintf("unable to remove file %q: %v", virtualPath, err)
  264. c.conn.Log(logger.LevelError, "unable to remove file %q, retention %v: %v",
  265. virtualPath, retentionTime, err)
  266. return err
  267. }
  268. c.conn.Log(logger.LevelDebug, "removed file %q, modification time: %v, retention: %v hours, retention time: %v",
  269. virtualPath, info.ModTime(), folderRetention.Retention, retentionTime)
  270. result.DeletedFiles++
  271. result.DeletedSize += info.Size()
  272. }
  273. }
  274. }
  275. if folderRetention.DeleteEmptyDirs {
  276. c.checkEmptyDirRemoval(folderPath)
  277. }
  278. result.Elapsed = time.Since(startTime)
  279. c.conn.Log(logger.LevelDebug, "retention check completed for folder %q, deleted files: %v, deleted size: %v bytes",
  280. folderPath, result.DeletedFiles, result.DeletedSize)
  281. return nil
  282. }
  283. func (c *RetentionCheck) checkEmptyDirRemoval(folderPath string) {
  284. if folderPath == "/" {
  285. return
  286. }
  287. for _, folder := range c.Folders {
  288. if folderPath == folder.Path {
  289. return
  290. }
  291. }
  292. if c.conn.User.HasAnyPerm([]string{
  293. dataprovider.PermDelete,
  294. dataprovider.PermDeleteDirs,
  295. }, path.Dir(folderPath),
  296. ) {
  297. files, err := c.conn.ListDir(folderPath)
  298. if err == nil && len(files) == 0 {
  299. err = c.conn.RemoveDir(folderPath)
  300. c.conn.Log(logger.LevelDebug, "tried to remove empty dir %q, error: %v", folderPath, err)
  301. }
  302. }
  303. }
  304. // Start starts the retention check
  305. func (c *RetentionCheck) Start() error {
  306. c.conn.Log(logger.LevelInfo, "retention check started")
  307. defer RetentionChecks.remove(c.conn.User.Username)
  308. defer c.conn.CloseFS() //nolint:errcheck
  309. startTime := time.Now()
  310. for _, folder := range c.Folders {
  311. if folder.Retention > 0 {
  312. if err := c.cleanupFolder(folder.Path); err != nil {
  313. c.conn.Log(logger.LevelError, "retention check failed, unable to cleanup folder %q", folder.Path)
  314. c.sendNotifications(time.Since(startTime), err)
  315. return err
  316. }
  317. }
  318. }
  319. c.conn.Log(logger.LevelInfo, "retention check completed")
  320. c.sendNotifications(time.Since(startTime), nil)
  321. return nil
  322. }
  323. func (c *RetentionCheck) sendNotifications(elapsed time.Duration, err error) {
  324. for _, notification := range c.Notifications {
  325. switch notification {
  326. case RetentionCheckNotificationEmail:
  327. c.sendEmailNotification(err) //nolint:errcheck
  328. case RetentionCheckNotificationHook:
  329. c.sendHookNotification(elapsed, err) //nolint:errcheck
  330. }
  331. }
  332. }
  333. func (c *RetentionCheck) sendEmailNotification(errCheck error) error {
  334. params := EventParams{}
  335. if len(c.results) > 0 || errCheck != nil {
  336. params.retentionChecks = append(params.retentionChecks, executedRetentionCheck{
  337. Username: c.conn.User.Username,
  338. ActionName: "Retention check",
  339. Results: c.results,
  340. })
  341. }
  342. var files []*mail.File
  343. f, err := params.getRetentionReportsAsMailAttachment()
  344. if err != nil {
  345. c.conn.Log(logger.LevelError, "unable to get retention report as mail attachment: %v", err)
  346. return err
  347. }
  348. f.Name = "retention-report.zip"
  349. files = append(files, f)
  350. startTime := time.Now()
  351. var subject string
  352. if errCheck == nil {
  353. subject = fmt.Sprintf("Successful retention check for user %q", c.conn.User.Username)
  354. } else {
  355. subject = fmt.Sprintf("Retention check failed for user %q", c.conn.User.Username)
  356. }
  357. body := "Further details attached."
  358. err = smtp.SendEmail([]string{c.Email}, nil, subject, body, smtp.EmailContentTypeTextPlain, files...)
  359. if err != nil {
  360. c.conn.Log(logger.LevelError, "unable to notify retention check result via email: %v, elapsed: %s", err,
  361. time.Since(startTime))
  362. return err
  363. }
  364. c.conn.Log(logger.LevelInfo, "retention check result successfully notified via email, elapsed: %s", time.Since(startTime))
  365. return nil
  366. }
  367. func (c *RetentionCheck) sendHookNotification(elapsed time.Duration, errCheck error) error {
  368. startNewHook()
  369. defer hookEnded()
  370. data := make(map[string]any)
  371. totalDeletedFiles := 0
  372. totalDeletedSize := int64(0)
  373. for _, result := range c.results {
  374. totalDeletedFiles += result.DeletedFiles
  375. totalDeletedSize += result.DeletedSize
  376. }
  377. data["username"] = c.conn.User.Username
  378. data["start_time"] = c.StartTime
  379. data["elapsed"] = elapsed.Milliseconds()
  380. if errCheck == nil {
  381. data["status"] = 1
  382. } else {
  383. data["status"] = 0
  384. }
  385. data["total_deleted_files"] = totalDeletedFiles
  386. data["total_deleted_size"] = totalDeletedSize
  387. data["details"] = c.results
  388. jsonData, _ := json.Marshal(data)
  389. startTime := time.Now()
  390. if strings.HasPrefix(Config.DataRetentionHook, "http") {
  391. var url *url.URL
  392. url, err := url.Parse(Config.DataRetentionHook)
  393. if err != nil {
  394. c.conn.Log(logger.LevelError, "invalid data retention hook %q: %v", Config.DataRetentionHook, err)
  395. return err
  396. }
  397. respCode := 0
  398. resp, err := httpclient.RetryablePost(url.String(), "application/json", bytes.NewBuffer(jsonData))
  399. if err == nil {
  400. respCode = resp.StatusCode
  401. resp.Body.Close()
  402. if respCode != http.StatusOK {
  403. err = errUnexpectedHTTResponse
  404. }
  405. }
  406. c.conn.Log(logger.LevelDebug, "notified result to URL: %q, status code: %v, elapsed: %v err: %v",
  407. url.Redacted(), respCode, time.Since(startTime), err)
  408. return err
  409. }
  410. if !filepath.IsAbs(Config.DataRetentionHook) {
  411. err := fmt.Errorf("invalid data retention hook %q", Config.DataRetentionHook)
  412. c.conn.Log(logger.LevelError, "%v", err)
  413. return err
  414. }
  415. timeout, env, args := command.GetConfig(Config.DataRetentionHook, command.HookDataRetention)
  416. ctx, cancel := context.WithTimeout(context.Background(), timeout)
  417. defer cancel()
  418. cmd := exec.CommandContext(ctx, Config.DataRetentionHook, args...)
  419. cmd.Env = append(env,
  420. fmt.Sprintf("SFTPGO_DATA_RETENTION_RESULT=%s", string(jsonData)))
  421. err := cmd.Run()
  422. c.conn.Log(logger.LevelDebug, "notified result using command: %q, elapsed: %s err: %v",
  423. Config.DataRetentionHook, time.Since(startTime), err)
  424. return err
  425. }