浏览代码

dataprovider: add timestamp fields for users and admins

Nicola Murino 4 年之前
父节点
当前提交
be3857d572
共有 52 个文件被更改,包括 724 次插入75 次删除
  1. 1 0
      cmd/portable.go
  2. 1 0
      cmd/portable_disabled.go
  3. 1 0
      config/config_linux.go
  4. 1 0
      config/config_nolinux.go
  5. 9 0
      dataprovider/admin.go
  6. 0 3
      dataprovider/apikey.go
  7. 79 2
      dataprovider/bolt.go
  8. 1 0
      dataprovider/bolt_disabled.go
  9. 26 4
      dataprovider/dataprovider.go
  10. 76 6
      dataprovider/memory.go
  11. 62 1
      dataprovider/mysql.go
  12. 1 0
      dataprovider/mysql_disabled.go
  13. 64 1
      dataprovider/pgsql.go
  14. 1 0
      dataprovider/pgsql_disabled.go
  15. 47 8
      dataprovider/sqlcommon.go
  16. 60 1
      dataprovider/sqlite.go
  17. 1 0
      dataprovider/sqlite_disabled.go
  18. 21 12
      dataprovider/sqlqueries.go
  19. 2 0
      dataprovider/user.go
  20. 4 4
      docker/README.md
  21. 2 2
      ftpd/server.go
  22. 5 5
      go.mod
  23. 10 9
      go.sum
  24. 167 1
      httpd/httpd_test.go
  25. 2 2
      httpd/middleware.go
  26. 20 0
      httpd/schema/openapi.yaml
  27. 4 2
      httpd/server.go
  28. 10 0
      httpdtest/httpdtest.go
  29. 1 0
      logger/journald.go
  30. 1 0
      logger/journald_nolinux.go
  31. 1 0
      metric/metric.go
  32. 1 0
      metric/metric_disabled.go
  33. 4 0
      sdk/user.go
  34. 8 8
      service/service.go
  35. 1 0
      service/service_portable.go
  36. 1 0
      service/signals_unix.go
  37. 1 0
      sftpd/cmd_unix.go
  38. 1 0
      sftpd/internal_unix_test.go
  39. 1 1
      sftpd/server.go
  40. 1 1
      sftpd/subsystem.go
  41. 1 0
      vfs/azblobfs.go
  42. 1 0
      vfs/azblobfs_disabled.go
  43. 1 0
      vfs/gcsfs.go
  44. 1 0
      vfs/gcsfs_disabled.go
  45. 1 0
      vfs/s3fs.go
  46. 1 0
      vfs/s3fs_disabled.go
  47. 1 0
      vfs/statvfs_fallback.go
  48. 1 0
      vfs/statvfs_linux.go
  49. 1 0
      vfs/statvfs_unix.go
  50. 1 0
      vfs/sys_unix.go
  51. 13 1
      webdavd/internal_test.go
  52. 1 1
      webdavd/server.go

+ 1 - 0
cmd/portable.go

@@ -1,3 +1,4 @@
+//go:build !noportable
 // +build !noportable
 
 package cmd

+ 1 - 0
cmd/portable_disabled.go

@@ -1,3 +1,4 @@
+//go:build noportable
 // +build noportable
 
 package cmd

+ 1 - 0
config/config_linux.go

@@ -1,3 +1,4 @@
+//go:build linux
 // +build linux
 
 package config

+ 1 - 0
config/config_nolinux.go

@@ -1,3 +1,4 @@
+//go:build !linux
 // +build !linux
 
 package config

+ 9 - 0
dataprovider/admin.go

@@ -68,6 +68,12 @@ type Admin struct {
 	Filters        AdminFilters `json:"filters,omitempty"`
 	Description    string       `json:"description,omitempty"`
 	AdditionalInfo string       `json:"additional_info,omitempty"`
+	// Creation time as unix timestamp in milliseconds. It will be 0 for admins created before v2.2.0
+	CreatedAt int64 `json:"created_at"`
+	// last update time as unix timestamp in milliseconds
+	UpdatedAt int64 `json:"updated_at"`
+	// Last login as unix timestamp in milliseconds
+	LastLogin int64 `json:"last_login"`
 }
 
 func (a *Admin) checkPassword() error {
@@ -260,6 +266,9 @@ func (a *Admin) getACopy() Admin {
 		Filters:        filters,
 		AdditionalInfo: a.AdditionalInfo,
 		Description:    a.Description,
+		LastLogin:      a.LastLogin,
+		CreatedAt:      a.CreatedAt,
+		UpdatedAt:      a.UpdatedAt,
 	}
 }
 

+ 0 - 3
dataprovider/apikey.go

@@ -125,9 +125,6 @@ func (k *APIKey) validate() error {
 	if err := k.checkKey(); err != nil {
 		return err
 	}
-	if k.CreatedAt == 0 {
-		k.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
-	}
 	if k.User != "" && k.Admin != "" {
 		return util.NewValidationError("an API key can be related to a user or an admin, not both")
 	}

+ 79 - 2
dataprovider/bolt.go

@@ -1,3 +1,4 @@
+//go:build !nobolt
 // +build !nobolt
 
 package dataprovider
@@ -19,7 +20,7 @@ import (
 )
 
 const (
-	boltDatabaseVersion = 11
+	boltDatabaseVersion = 12
 )
 
 var (
@@ -191,6 +192,36 @@ func (p *BoltProvider) updateAPIKeyLastUse(keyID string) error {
 	})
 }
 
+func (p *BoltProvider) setUpdatedAt(username string) {
+	p.dbHandle.Update(func(tx *bolt.Tx) error { //nolint:errcheck
+		bucket, err := getUsersBucket(tx)
+		if err != nil {
+			return err
+		}
+		var u []byte
+		if u = bucket.Get([]byte(username)); u == nil {
+			return util.NewRecordNotFoundError(fmt.Sprintf("username %#v does not exist, unable to update updated at", username))
+		}
+		var user User
+		err = json.Unmarshal(u, &user)
+		if err != nil {
+			return err
+		}
+		user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+		buf, err := json.Marshal(user)
+		if err != nil {
+			return err
+		}
+		err = bucket.Put([]byte(username), buf)
+		if err == nil {
+			providerLog(logger.LevelDebug, "updated at set for user %#v", username)
+		} else {
+			providerLog(logger.LevelWarn, "error setting updated_at for user %#v: %v", username, err)
+		}
+		return err
+	})
+}
+
 func (p *BoltProvider) updateLastLogin(username string) error {
 	return p.dbHandle.Update(func(tx *bolt.Tx) error {
 		bucket, err := getUsersBucket(tx)
@@ -221,6 +252,36 @@ func (p *BoltProvider) updateLastLogin(username string) error {
 	})
 }
 
+func (p *BoltProvider) updateAdminLastLogin(username string) error {
+	return p.dbHandle.Update(func(tx *bolt.Tx) error {
+		bucket, err := getAdminsBucket(tx)
+		if err != nil {
+			return err
+		}
+		var a []byte
+		if a = bucket.Get([]byte(username)); a == nil {
+			return util.NewRecordNotFoundError(fmt.Sprintf("admin %#v does not exist, unable to update last login", username))
+		}
+		var admin Admin
+		err = json.Unmarshal(a, &admin)
+		if err != nil {
+			return err
+		}
+		admin.LastLogin = util.GetTimeAsMsSinceEpoch(time.Now())
+		buf, err := json.Marshal(admin)
+		if err != nil {
+			return err
+		}
+		err = bucket.Put([]byte(username), buf)
+		if err == nil {
+			providerLog(logger.LevelDebug, "last login updated for admin %#v", username)
+			return err
+		}
+		providerLog(logger.LevelWarn, "error updating last login for admin %#v: %v", username, err)
+		return err
+	})
+}
+
 func (p *BoltProvider) updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error {
 	return p.dbHandle.Update(func(tx *bolt.Tx) error {
 		bucket, err := getUsersBucket(tx)
@@ -300,6 +361,9 @@ func (p *BoltProvider) addAdmin(admin *Admin) error {
 			return err
 		}
 		admin.ID = int64(id)
+		admin.LastLogin = 0
+		admin.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+		admin.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		buf, err := json.Marshal(admin)
 		if err != nil {
 			return err
@@ -330,6 +394,9 @@ func (p *BoltProvider) updateAdmin(admin *Admin) error {
 		}
 
 		admin.ID = oldAdmin.ID
+		admin.CreatedAt = oldAdmin.CreatedAt
+		admin.LastLogin = oldAdmin.LastLogin
+		admin.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		buf, err := json.Marshal(admin)
 		if err != nil {
 			return err
@@ -478,6 +545,8 @@ func (p *BoltProvider) addUser(user *User) error {
 		user.UsedQuotaSize = 0
 		user.UsedQuotaFiles = 0
 		user.LastLogin = 0
+		user.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+		user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		for idx := range user.VirtualFolders {
 			err = addUserToFolderMapping(&user.VirtualFolders[idx].BaseVirtualFolder, user, folderBucket)
 			if err != nil {
@@ -532,6 +601,8 @@ func (p *BoltProvider) updateUser(user *User) error {
 		user.UsedQuotaSize = oldUser.UsedQuotaSize
 		user.UsedQuotaFiles = oldUser.UsedQuotaFiles
 		user.LastLogin = oldUser.LastLogin
+		user.CreatedAt = oldUser.CreatedAt
+		user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		buf, err := json.Marshal(user)
 		if err != nil {
 			return err
@@ -916,7 +987,9 @@ func (p *BoltProvider) addAPIKey(apiKey *APIKey) error {
 			return err
 		}
 		apiKey.ID = int64(id)
+		apiKey.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		apiKey.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+		apiKey.LastUseAt = 0
 		buf, err := json.Marshal(apiKey)
 		if err != nil {
 			return err
@@ -1077,7 +1150,9 @@ func (p *BoltProvider) migrateDatabase() error {
 		logger.ErrorToConsole("%v", err)
 		return err
 	case version == 10:
-		return updateBoltDatabaseVersion(p.dbHandle, 11)
+		return updateBoltDatabaseVersion(p.dbHandle, 12)
+	case version == 11:
+		return updateBoltDatabaseVersion(p.dbHandle, 12)
 	default:
 		if version > boltDatabaseVersion {
 			providerLog(logger.LevelWarn, "database version %v is newer than the supported one: %v", version,
@@ -1099,6 +1174,8 @@ func (p *BoltProvider) revertDatabase(targetVersion int) error {
 		return errors.New("current version match target version, nothing to do")
 	}
 	switch dbVersion.Version {
+	case 12:
+		return updateBoltDatabaseVersion(p.dbHandle, 10)
 	case 11:
 		return updateBoltDatabaseVersion(p.dbHandle, 10)
 	default:

+ 1 - 0
dataprovider/bolt_disabled.go

@@ -1,3 +1,4 @@
+//go:build nobolt
 // +build nobolt
 
 package dataprovider

+ 26 - 4
dataprovider/dataprovider.go

@@ -392,6 +392,8 @@ type Provider interface {
 	getUsers(limit int, offset int, order string) ([]User, error)
 	dumpUsers() ([]User, error)
 	updateLastLogin(username string) error
+	updateAdminLastLogin(username string) error
+	setUpdatedAt(username string)
 	getFolders(limit, offset int, order string) ([]vfs.BaseVirtualFolder, error)
 	getFolderByName(name string) (vfs.BaseVirtualFolder, error)
 	addFolder(folder *vfs.BaseVirtualFolder) error
@@ -813,7 +815,7 @@ func UpdateAPIKeyLastUse(apiKey *APIKey) error {
 }
 
 // UpdateLastLogin updates the last login field for the given SFTPGo user
-func UpdateLastLogin(user *User) error {
+func UpdateLastLogin(user *User) {
 	lastLogin := util.GetTimeFromMsecSinceEpoch(user.LastLogin)
 	diff := -time.Until(lastLogin)
 	if diff < 0 || diff > lastLoginMinDelay {
@@ -821,9 +823,16 @@ func UpdateLastLogin(user *User) error {
 		if err == nil {
 			webDAVUsersCache.updateLastLogin(user.Username)
 		}
-		return err
 	}
-	return nil
+}
+
+// UpdateAdminLastLogin updates the last login field for the given SFTPGo admin
+func UpdateAdminLastLogin(admin *Admin) {
+	lastLogin := util.GetTimeFromMsecSinceEpoch(admin.LastLogin)
+	diff := -time.Until(lastLogin)
+	if diff < 0 || diff > lastLoginMinDelay {
+		provider.updateAdminLastLogin(admin.Username) //nolint:errcheck
+	}
 }
 
 // UpdateUserQuota updates the quota for the given SFTP user adding filesAdd and sizeAdd.
@@ -1026,7 +1035,14 @@ func UpdateFolder(folder *vfs.BaseVirtualFolder, users []string) error {
 	err := provider.updateFolder(folder)
 	if err == nil {
 		for _, user := range users {
-			RemoveCachedWebDAVUser(user)
+			provider.setUpdatedAt(user)
+			u, err := provider.userExists(user)
+			if err == nil {
+				webDAVUsersCache.swap(&u)
+				executeAction(operationUpdate, &u)
+			} else {
+				RemoveCachedWebDAVUser(user)
+			}
 		}
 	}
 	return err
@@ -1041,6 +1057,7 @@ func DeleteFolder(folderName string) error {
 	err = provider.deleteFolder(&folder)
 	if err == nil {
 		for _, user := range folder.Users {
+			provider.setUpdatedAt(user)
 			RemoveCachedWebDAVUser(user)
 		}
 		delayedQuotaUpdater.resetFolderQuota(folderName)
@@ -2252,6 +2269,7 @@ func executePreLoginHook(username, loginMethod, ip, protocol string) (User, erro
 	userUsedQuotaFiles := u.UsedQuotaFiles
 	userLastQuotaUpdate := u.LastQuotaUpdate
 	userLastLogin := u.LastLogin
+	userCreatedAt := u.CreatedAt
 	err = json.Unmarshal(out, &u)
 	if err != nil {
 		return u, fmt.Errorf("invalid pre-login hook response %#v, error: %v", string(out), err)
@@ -2261,9 +2279,11 @@ func executePreLoginHook(username, loginMethod, ip, protocol string) (User, erro
 	u.UsedQuotaFiles = userUsedQuotaFiles
 	u.LastQuotaUpdate = userLastQuotaUpdate
 	u.LastLogin = userLastLogin
+	u.CreatedAt = userCreatedAt
 	if userID == 0 {
 		err = provider.addUser(&u)
 	} else {
+		u.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		err = provider.updateUser(&u)
 		if err == nil {
 			webDAVUsersCache.swap(&u)
@@ -2464,6 +2484,8 @@ func doExternalAuth(username, password string, pubKey []byte, keyboardInteractiv
 		user.UsedQuotaFiles = u.UsedQuotaFiles
 		user.LastQuotaUpdate = u.LastQuotaUpdate
 		user.LastLogin = u.LastLogin
+		user.CreatedAt = u.CreatedAt
+		user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		err = provider.updateUser(&user)
 		if err == nil {
 			webDAVUsersCache.swap(&user)

+ 76 - 6
dataprovider/memory.go

@@ -158,6 +158,20 @@ func (p *MemoryProvider) updateAPIKeyLastUse(keyID string) error {
 	return nil
 }
 
+func (p *MemoryProvider) setUpdatedAt(username string) {
+	p.dbHandle.Lock()
+	defer p.dbHandle.Unlock()
+	if p.dbHandle.isClosed {
+		return
+	}
+	user, err := p.userExistsInternal(username)
+	if err != nil {
+		return
+	}
+	user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+	p.dbHandle.users[user.Username] = user
+}
+
 func (p *MemoryProvider) updateLastLogin(username string) error {
 	p.dbHandle.Lock()
 	defer p.dbHandle.Unlock()
@@ -173,6 +187,21 @@ func (p *MemoryProvider) updateLastLogin(username string) error {
 	return nil
 }
 
+func (p *MemoryProvider) updateAdminLastLogin(username string) error {
+	p.dbHandle.Lock()
+	defer p.dbHandle.Unlock()
+	if p.dbHandle.isClosed {
+		return errMemoryProviderClosed
+	}
+	admin, err := p.adminExistsInternal(username)
+	if err != nil {
+		return err
+	}
+	admin.LastLogin = util.GetTimeAsMsSinceEpoch(time.Now())
+	p.dbHandle.admins[admin.Username] = admin
+	return nil
+}
+
 func (p *MemoryProvider) updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error {
 	p.dbHandle.Lock()
 	defer p.dbHandle.Unlock()
@@ -235,6 +264,8 @@ func (p *MemoryProvider) addUser(user *User) error {
 	user.UsedQuotaSize = 0
 	user.UsedQuotaFiles = 0
 	user.LastLogin = 0
+	user.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+	user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 	user.VirtualFolders = p.joinVirtualFoldersFields(user)
 	p.dbHandle.users[user.Username] = user.getACopy()
 	p.dbHandle.usernames = append(p.dbHandle.usernames, user.Username)
@@ -268,6 +299,8 @@ func (p *MemoryProvider) updateUser(user *User) error {
 	user.UsedQuotaSize = u.UsedQuotaSize
 	user.UsedQuotaFiles = u.UsedQuotaFiles
 	user.LastLogin = u.LastLogin
+	user.CreatedAt = u.CreatedAt
+	user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 	user.ID = u.ID
 	// pre-login and external auth hook will use the passed *user so save a copy
 	p.dbHandle.users[user.Username] = user.getACopy()
@@ -407,6 +440,9 @@ func (p *MemoryProvider) addAdmin(admin *Admin) error {
 		return fmt.Errorf("admin %#v already exists", admin.Username)
 	}
 	admin.ID = p.getNextAdminID()
+	admin.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+	admin.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+	admin.LastLogin = 0
 	p.dbHandle.admins[admin.Username] = admin.getACopy()
 	p.dbHandle.adminsUsernames = append(p.dbHandle.adminsUsernames, admin.Username)
 	sort.Strings(p.dbHandle.adminsUsernames)
@@ -428,6 +464,9 @@ func (p *MemoryProvider) updateAdmin(admin *Admin) error {
 		return err
 	}
 	admin.ID = a.ID
+	admin.CreatedAt = a.CreatedAt
+	admin.LastLogin = a.LastLogin
+	admin.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 	p.dbHandle.admins[admin.Username] = admin.getACopy()
 	return nil
 }
@@ -825,7 +864,9 @@ func (p *MemoryProvider) addAPIKey(apiKey *APIKey) error {
 	if err == nil {
 		return fmt.Errorf("API key %#v already exists", apiKey.KeyID)
 	}
+	apiKey.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 	apiKey.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+	apiKey.LastUseAt = 0
 	p.dbHandle.apiKeys[apiKey.KeyID] = apiKey.getACopy()
 	p.dbHandle.apiKeysIDs = append(p.dbHandle.apiKeysIDs, apiKey.KeyID)
 	sort.Strings(p.dbHandle.apiKeysIDs)
@@ -1041,23 +1082,52 @@ func (p *MemoryProvider) reloadConfig() error {
 		return err
 	}
 
+	if err := p.restoreAPIKeys(&dump); err != nil {
+		return err
+	}
+
 	providerLog(logger.LevelDebug, "config loaded from file: %#v", p.dbHandle.configFile)
 	return nil
 }
 
+func (p *MemoryProvider) restoreAPIKeys(dump *BackupData) error {
+	for _, apiKey := range dump.APIKeys {
+		if apiKey.KeyID == "" {
+			return fmt.Errorf("cannot restore an empty API key: %+v", apiKey)
+		}
+		k, err := p.apiKeyExists(apiKey.KeyID)
+		apiKey := apiKey // pin
+		if err == nil {
+			apiKey.ID = k.ID
+			err = UpdateAPIKey(&apiKey)
+			if err != nil {
+				providerLog(logger.LevelWarn, "error updating API key %#v: %v", apiKey.KeyID, err)
+				return err
+			}
+		} else {
+			err = AddAPIKey(&apiKey)
+			if err != nil {
+				providerLog(logger.LevelWarn, "error adding API key %#v: %v", apiKey.KeyID, err)
+				return err
+			}
+		}
+	}
+	return nil
+}
+
 func (p *MemoryProvider) restoreAdmins(dump *BackupData) error {
 	for _, admin := range dump.Admins {
 		a, err := p.adminExists(admin.Username)
 		admin := admin // pin
 		if err == nil {
 			admin.ID = a.ID
-			err = p.updateAdmin(&admin)
+			err = UpdateAdmin(&admin)
 			if err != nil {
 				providerLog(logger.LevelWarn, "error updating admin %#v: %v", admin.Username, err)
 				return err
 			}
 		} else {
-			err = p.addAdmin(&admin)
+			err = AddAdmin(&admin)
 			if err != nil {
 				providerLog(logger.LevelWarn, "error adding admin %#v: %v", admin.Username, err)
 				return err
@@ -1073,14 +1143,14 @@ func (p *MemoryProvider) restoreFolders(dump *BackupData) error {
 		f, err := p.getFolderByName(folder.Name)
 		if err == nil {
 			folder.ID = f.ID
-			err = p.updateFolder(&folder)
+			err = UpdateFolder(&folder, f.Users)
 			if err != nil {
 				providerLog(logger.LevelWarn, "error updating folder %#v: %v", folder.Name, err)
 				return err
 			}
 		} else {
 			folder.Users = nil
-			err = p.addFolder(&folder)
+			err = AddFolder(&folder)
 			if err != nil {
 				providerLog(logger.LevelWarn, "error adding folder %#v: %v", folder.Name, err)
 				return err
@@ -1096,13 +1166,13 @@ func (p *MemoryProvider) restoreUsers(dump *BackupData) error {
 		u, err := p.userExists(user.Username)
 		if err == nil {
 			user.ID = u.ID
-			err = p.updateUser(&user)
+			err = UpdateUser(&user)
 			if err != nil {
 				providerLog(logger.LevelWarn, "error updating user %#v: %v", user.Username, err)
 				return err
 			}
 		} else {
-			err = p.addUser(&user)
+			err = AddUser(&user)
 			if err != nil {
 				providerLog(logger.LevelWarn, "error adding user %#v: %v", user.Username, err)
 				return err

+ 62 - 1
dataprovider/mysql.go

@@ -1,3 +1,4 @@
+//go:build !nomysql
 // +build !nomysql
 
 package dataprovider
@@ -46,6 +47,22 @@ const (
 		"ALTER TABLE `{{api_keys}}` ADD CONSTRAINT `{{prefix}}api_keys_admin_id_fk_admins_id` FOREIGN KEY (`admin_id`) REFERENCES `{{admins}}` (`id`) ON DELETE CASCADE;" +
 		"ALTER TABLE `{{api_keys}}` ADD CONSTRAINT `{{prefix}}api_keys_user_id_fk_users_id` FOREIGN KEY (`user_id`) REFERENCES `{{users}}` (`id`) ON DELETE CASCADE;"
 	mysqlV11DownSQL = "DROP TABLE `{{api_keys}}` CASCADE;"
+	mysqlV12SQL     = "ALTER TABLE `{{admins}}` ADD COLUMN `created_at` bigint DEFAULT 0 NOT NULL;" +
+		"ALTER TABLE `{{admins}}` ALTER COLUMN `created_at` DROP DEFAULT;" +
+		"ALTER TABLE `{{admins}}` ADD COLUMN `updated_at` bigint DEFAULT 0 NOT NULL;" +
+		"ALTER TABLE `{{admins}}` ALTER COLUMN `updated_at` DROP DEFAULT;" +
+		"ALTER TABLE `{{admins}}` ADD COLUMN `last_login` bigint DEFAULT 0 NOT NULL;" +
+		"ALTER TABLE `{{admins}}` ALTER COLUMN `last_login` DROP DEFAULT;" +
+		"ALTER TABLE `{{users}}` ADD COLUMN `created_at` bigint DEFAULT 0 NOT NULL;" +
+		"ALTER TABLE `{{users}}` ALTER COLUMN `created_at` DROP DEFAULT;" +
+		"ALTER TABLE `{{users}}` ADD COLUMN `updated_at` bigint DEFAULT 0 NOT NULL;" +
+		"ALTER TABLE `{{users}}` ALTER COLUMN `updated_at` DROP DEFAULT;" +
+		"CREATE INDEX `{{prefix}}users_updated_at_idx` ON `{{users}}` (`updated_at`);"
+	mysqlV12DownSQL = "ALTER TABLE `{{admins}}` DROP COLUMN `updated_at`;" +
+		"ALTER TABLE `{{admins}}` DROP COLUMN `created_at`;" +
+		"ALTER TABLE `{{admins}}` DROP COLUMN `last_login`;" +
+		"ALTER TABLE `{{users}}` DROP COLUMN `created_at`;" +
+		"ALTER TABLE `{{users}}` DROP COLUMN `updated_at`;"
 )
 
 // MySQLProvider auth provider for MySQL/MariaDB database
@@ -117,10 +134,18 @@ func (p *MySQLProvider) getUsedQuota(username string) (int, int64, error) {
 	return sqlCommonGetUsedQuota(username, p.dbHandle)
 }
 
+func (p *MySQLProvider) setUpdatedAt(username string) {
+	sqlCommonSetUpdatedAt(username, p.dbHandle)
+}
+
 func (p *MySQLProvider) updateLastLogin(username string) error {
 	return sqlCommonUpdateLastLogin(username, p.dbHandle)
 }
 
+func (p *MySQLProvider) updateAdminLastLogin(username string) error {
+	return sqlCommonUpdateAdminLastLogin(username, p.dbHandle)
+}
+
 func (p *MySQLProvider) userExists(username string) (User, error) {
 	return sqlCommonGetUserByUsername(username, p.dbHandle)
 }
@@ -276,6 +301,8 @@ func (p *MySQLProvider) migrateDatabase() error {
 		return err
 	case version == 10:
 		return updateMySQLDatabaseFromV10(p.dbHandle)
+	case version == 11:
+		return updateMySQLDatabaseFromV11(p.dbHandle)
 	default:
 		if version > sqlDatabaseVersion {
 			providerLog(logger.LevelWarn, "database version %v is newer than the supported one: %v", version,
@@ -298,6 +325,8 @@ func (p *MySQLProvider) revertDatabase(targetVersion int) error {
 	}
 
 	switch dbVersion.Version {
+	case 12:
+		return downgradeMySQLDatabaseFromV12(p.dbHandle)
 	case 11:
 		return downgradeMySQLDatabaseFromV11(p.dbHandle)
 	default:
@@ -306,13 +335,45 @@ func (p *MySQLProvider) revertDatabase(targetVersion int) error {
 }
 
 func updateMySQLDatabaseFromV10(dbHandle *sql.DB) error {
-	return updateMySQLDatabaseFrom10To11(dbHandle)
+	if err := updateMySQLDatabaseFrom10To11(dbHandle); err != nil {
+		return err
+	}
+	return updateMySQLDatabaseFromV11(dbHandle)
+}
+
+func updateMySQLDatabaseFromV11(dbHandle *sql.DB) error {
+	return updateMySQLDatabaseFrom11To12(dbHandle)
+}
+
+func downgradeMySQLDatabaseFromV12(dbHandle *sql.DB) error {
+	if err := downgradeMySQLDatabaseFrom12To11(dbHandle); err != nil {
+		return err
+	}
+	return downgradeMySQLDatabaseFromV11(dbHandle)
 }
 
 func downgradeMySQLDatabaseFromV11(dbHandle *sql.DB) error {
 	return downgradeMySQLDatabaseFrom11To10(dbHandle)
 }
 
+func updateMySQLDatabaseFrom11To12(dbHandle *sql.DB) error {
+	logger.InfoToConsole("updating database version: 11 -> 12")
+	providerLog(logger.LevelInfo, "updating database version: 11 -> 12")
+	sql := strings.ReplaceAll(mysqlV12SQL, "{{users}}", sqlTableUsers)
+	sql = strings.ReplaceAll(sql, "{{admins}}", sqlTableAdmins)
+	sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 12)
+}
+
+func downgradeMySQLDatabaseFrom12To11(dbHandle *sql.DB) error {
+	logger.InfoToConsole("downgrading database version: 12 -> 11")
+	providerLog(logger.LevelInfo, "downgrading database version: 12 -> 11")
+	sql := strings.ReplaceAll(mysqlV12DownSQL, "{{users}}", sqlTableUsers)
+	sql = strings.ReplaceAll(sql, "{{admins}}", sqlTableAdmins)
+	sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 11)
+}
+
 func updateMySQLDatabaseFrom10To11(dbHandle *sql.DB) error {
 	logger.InfoToConsole("updating database version: 10 -> 11")
 	providerLog(logger.LevelInfo, "updating database version: 10 -> 11")

+ 1 - 0
dataprovider/mysql_disabled.go

@@ -1,3 +1,4 @@
+//go:build nomysql
 // +build nomysql
 
 package dataprovider

+ 64 - 1
dataprovider/pgsql.go

@@ -1,3 +1,4 @@
+//go:build !nopgsql
 // +build !nopgsql
 
 package dataprovider
@@ -57,6 +58,24 @@ CREATE INDEX "{{prefix}}api_keys_admin_id_idx" ON "{{api_keys}}" ("admin_id");
 CREATE INDEX "{{prefix}}api_keys_user_id_idx" ON "{{api_keys}}" ("user_id");
 `
 	pgsqlV11DownSQL = `DROP TABLE "{{api_keys}}" CASCADE;`
+	pgsqlV12SQL     = `ALTER TABLE "{{admins}}" ADD COLUMN "created_at" bigint DEFAULT 0 NOT NULL;
+ALTER TABLE "{{admins}}" ALTER COLUMN "created_at" DROP DEFAULT;
+ALTER TABLE "{{admins}}" ADD COLUMN "updated_at" bigint DEFAULT 0 NOT NULL;
+ALTER TABLE "{{admins}}" ALTER COLUMN "updated_at" DROP DEFAULT;
+ALTER TABLE "{{admins}}" ADD COLUMN "last_login" bigint DEFAULT 0 NOT NULL;
+ALTER TABLE "{{admins}}" ALTER COLUMN "last_login" DROP DEFAULT;
+ALTER TABLE "{{users}}" ADD COLUMN "created_at" bigint DEFAULT 0 NOT NULL;
+ALTER TABLE "{{users}}" ALTER COLUMN "created_at" DROP DEFAULT;
+ALTER TABLE "{{users}}" ADD COLUMN "updated_at" bigint DEFAULT 0 NOT NULL;
+ALTER TABLE "{{users}}" ALTER COLUMN "updated_at" DROP DEFAULT;
+CREATE INDEX "{{prefix}}users_updated_at_idx" ON "{{users}}" ("updated_at");
+`
+	pgsqlV12DownSQL = `ALTER TABLE "{{users}}" DROP COLUMN "updated_at" CASCADE;
+ALTER TABLE "{{users}}" DROP COLUMN "created_at" CASCADE;
+ALTER TABLE "{{admins}}" DROP COLUMN "created_at" CASCADE;
+ALTER TABLE "{{admins}}" DROP COLUMN "updated_at" CASCADE;
+ALTER TABLE "{{admins}}" DROP COLUMN "last_login" CASCADE;
+`
 )
 
 // PGSQLProvider auth provider for PostgreSQL database
@@ -128,10 +147,18 @@ func (p *PGSQLProvider) getUsedQuota(username string) (int, int64, error) {
 	return sqlCommonGetUsedQuota(username, p.dbHandle)
 }
 
+func (p *PGSQLProvider) setUpdatedAt(username string) {
+	sqlCommonSetUpdatedAt(username, p.dbHandle)
+}
+
 func (p *PGSQLProvider) updateLastLogin(username string) error {
 	return sqlCommonUpdateLastLogin(username, p.dbHandle)
 }
 
+func (p *PGSQLProvider) updateAdminLastLogin(username string) error {
+	return sqlCommonUpdateAdminLastLogin(username, p.dbHandle)
+}
+
 func (p *PGSQLProvider) userExists(username string) (User, error) {
 	return sqlCommonGetUserByUsername(username, p.dbHandle)
 }
@@ -293,6 +320,8 @@ func (p *PGSQLProvider) migrateDatabase() error {
 		return err
 	case version == 10:
 		return updatePGSQLDatabaseFromV10(p.dbHandle)
+	case version == 11:
+		return updatePGSQLDatabaseFromV11(p.dbHandle)
 	default:
 		if version > sqlDatabaseVersion {
 			providerLog(logger.LevelWarn, "database version %v is newer than the supported one: %v", version,
@@ -315,6 +344,8 @@ func (p *PGSQLProvider) revertDatabase(targetVersion int) error {
 	}
 
 	switch dbVersion.Version {
+	case 12:
+		return downgradePGSQLDatabaseFromV12(p.dbHandle)
 	case 11:
 		return downgradePGSQLDatabaseFromV11(p.dbHandle)
 	default:
@@ -323,13 +354,45 @@ func (p *PGSQLProvider) revertDatabase(targetVersion int) error {
 }
 
 func updatePGSQLDatabaseFromV10(dbHandle *sql.DB) error {
-	return updatePGSQLDatabaseFrom10To11(dbHandle)
+	if err := updatePGSQLDatabaseFrom10To11(dbHandle); err != nil {
+		return err
+	}
+	return updatePGSQLDatabaseFromV11(dbHandle)
+}
+
+func updatePGSQLDatabaseFromV11(dbHandle *sql.DB) error {
+	return updatePGSQLDatabaseFrom11To12(dbHandle)
+}
+
+func downgradePGSQLDatabaseFromV12(dbHandle *sql.DB) error {
+	if err := downgradePGSQLDatabaseFrom12To11(dbHandle); err != nil {
+		return err
+	}
+	return downgradePGSQLDatabaseFromV11(dbHandle)
 }
 
 func downgradePGSQLDatabaseFromV11(dbHandle *sql.DB) error {
 	return downgradePGSQLDatabaseFrom11To10(dbHandle)
 }
 
+func updatePGSQLDatabaseFrom11To12(dbHandle *sql.DB) error {
+	logger.InfoToConsole("updating database version: 11 -> 12")
+	providerLog(logger.LevelInfo, "updating database version: 11 -> 12")
+	sql := strings.ReplaceAll(pgsqlV12SQL, "{{users}}", sqlTableUsers)
+	sql = strings.ReplaceAll(sql, "{{admins}}", sqlTableAdmins)
+	sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 12)
+}
+
+func downgradePGSQLDatabaseFrom12To11(dbHandle *sql.DB) error {
+	logger.InfoToConsole("downgrading database version: 12 -> 11")
+	providerLog(logger.LevelInfo, "downgrading database version: 12 -> 11")
+	sql := strings.ReplaceAll(pgsqlV12DownSQL, "{{users}}", sqlTableUsers)
+	sql = strings.ReplaceAll(sql, "{{admins}}", sqlTableAdmins)
+	sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 11)
+}
+
 func updatePGSQLDatabaseFrom10To11(dbHandle *sql.DB) error {
 	logger.InfoToConsole("updating database version: 10 -> 11")
 	providerLog(logger.LevelInfo, "updating database version: 10 -> 11")

+ 1 - 0
dataprovider/pgsql_disabled.go

@@ -1,3 +1,4 @@
+//go:build nopgsql
 // +build nopgsql
 
 package dataprovider

+ 47 - 8
dataprovider/sqlcommon.go

@@ -19,7 +19,7 @@ import (
 )
 
 const (
-	sqlDatabaseVersion     = 11
+	sqlDatabaseVersion     = 12
 	defaultSQLQueryTimeout = 10 * time.Second
 	longSQLQueryTimeout    = 60 * time.Second
 )
@@ -75,7 +75,7 @@ func sqlCommonAddAPIKey(apiKey *APIKey, dbHandle *sql.DB) error {
 	}
 	defer stmt.Close()
 
-	_, err = stmt.ExecContext(ctx, apiKey.KeyID, apiKey.Name, apiKey.Key, apiKey.Scope, apiKey.CreatedAt,
+	_, err = stmt.ExecContext(ctx, apiKey.KeyID, apiKey.Name, apiKey.Key, apiKey.Scope, util.GetTimeAsMsSinceEpoch(time.Now()),
 		util.GetTimeAsMsSinceEpoch(time.Now()), apiKey.LastUseAt, apiKey.ExpiresAt, apiKey.Description,
 		userID, adminID)
 	return err
@@ -251,7 +251,8 @@ func sqlCommonAddAdmin(admin *Admin, dbHandle *sql.DB) error {
 	}
 
 	_, err = stmt.ExecContext(ctx, admin.Username, admin.Password, admin.Status, admin.Email, string(perms),
-		string(filters), admin.AdditionalInfo, admin.Description)
+		string(filters), admin.AdditionalInfo, admin.Description, util.GetTimeAsMsSinceEpoch(time.Now()),
+		util.GetTimeAsMsSinceEpoch(time.Now()))
 	return err
 }
 
@@ -282,7 +283,7 @@ func sqlCommonUpdateAdmin(admin *Admin, dbHandle *sql.DB) error {
 	}
 
 	_, err = stmt.ExecContext(ctx, admin.Password, admin.Status, admin.Email, string(perms), string(filters),
-		admin.AdditionalInfo, admin.Description, admin.Username)
+		admin.AdditionalInfo, admin.Description, util.GetTimeAsMsSinceEpoch(time.Now()), admin.Username)
 	return err
 }
 
@@ -486,6 +487,43 @@ func sqlCommonUpdateAPIKeyLastUse(keyID string, dbHandle *sql.DB) error {
 	return err
 }
 
+func sqlCommonUpdateAdminLastLogin(username string, dbHandle *sql.DB) error {
+	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
+	defer cancel()
+	q := getUpdateAdminLastLoginQuery()
+	stmt, err := dbHandle.PrepareContext(ctx, q)
+	if err != nil {
+		providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
+		return err
+	}
+	defer stmt.Close()
+	_, err = stmt.ExecContext(ctx, util.GetTimeAsMsSinceEpoch(time.Now()), username)
+	if err == nil {
+		providerLog(logger.LevelDebug, "last login updated for admin %#v", username)
+	} else {
+		providerLog(logger.LevelWarn, "error updating last login for admin %#v: %v", username, err)
+	}
+	return err
+}
+
+func sqlCommonSetUpdatedAt(username string, dbHandle *sql.DB) {
+	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
+	defer cancel()
+	q := getSetUpdateAtQuery()
+	stmt, err := dbHandle.PrepareContext(ctx, q)
+	if err != nil {
+		providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
+		return
+	}
+	defer stmt.Close()
+	_, err = stmt.ExecContext(ctx, util.GetTimeAsMsSinceEpoch(time.Now()), username)
+	if err == nil {
+		providerLog(logger.LevelDebug, "updated_at set for user %#v", username)
+	} else {
+		providerLog(logger.LevelWarn, "error setting updated_at for user %#v: %v", username, err)
+	}
+}
+
 func sqlCommonUpdateLastLogin(username string, dbHandle *sql.DB) error {
 	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
 	defer cancel()
@@ -539,7 +577,8 @@ func sqlCommonAddUser(user *User, dbHandle *sql.DB) error {
 		}
 		_, err = stmt.ExecContext(ctx, user.Username, user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize,
 			user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate, string(filters),
-			string(fsConfig), user.AdditionalInfo, user.Description)
+			string(fsConfig), user.AdditionalInfo, user.Description, util.GetTimeAsMsSinceEpoch(time.Now()),
+			util.GetTimeAsMsSinceEpoch(time.Now()))
 		if err != nil {
 			return err
 		}
@@ -581,7 +620,7 @@ func sqlCommonUpdateUser(user *User, dbHandle *sql.DB) error {
 		}
 		_, err = stmt.ExecContext(ctx, user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize,
 			user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate,
-			string(filters), string(fsConfig), user.AdditionalInfo, user.Description, user.ID)
+			string(filters), string(fsConfig), user.AdditionalInfo, user.Description, util.GetTimeAsMsSinceEpoch(time.Now()), user.ID)
 		if err != nil {
 			return err
 		}
@@ -702,7 +741,7 @@ func getAdminFromDbRow(row sqlScanner) (Admin, error) {
 	var email, filters, additionalInfo, permissions, description sql.NullString
 
 	err := row.Scan(&admin.ID, &admin.Username, &admin.Password, &admin.Status, &email, &permissions,
-		&filters, &additionalInfo, &description)
+		&filters, &additionalInfo, &description, &admin.CreatedAt, &admin.UpdatedAt, &admin.LastLogin)
 
 	if err != nil {
 		if err == sql.ErrNoRows {
@@ -752,7 +791,7 @@ func getUserFromDbRow(row sqlScanner) (User, error) {
 	err := row.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions,
 		&user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate,
 		&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig,
-		&additionalInfo, &description)
+		&additionalInfo, &description, &user.CreatedAt, &user.UpdatedAt)
 	if err != nil {
 		if err == sql.ErrNoRows {
 			return user, util.NewRecordNotFoundError(err.Error())

+ 60 - 1
dataprovider/sqlite.go

@@ -1,3 +1,4 @@
+//go:build !nosqlite
 // +build !nosqlite
 
 package dataprovider
@@ -52,6 +53,20 @@ CREATE INDEX "{{prefix}}api_keys_admin_id_idx" ON "api_keys" ("admin_id");
 CREATE INDEX "{{prefix}}api_keys_user_id_idx" ON "api_keys" ("user_id");
 `
 	sqliteV11DownSQL = `DROP TABLE "{{api_keys}}";`
+	sqliteV12SQL     = `ALTER TABLE "{{admins}}" ADD COLUMN "created_at" bigint DEFAULT 0 NOT NULL;
+ALTER TABLE "{{admins}}" ADD COLUMN "updated_at" bigint DEFAULT 0 NOT NULL;
+ALTER TABLE "{{admins}}" ADD COLUMN "last_login" bigint DEFAULT 0 NOT NULL;
+ALTER TABLE "{{users}}" ADD COLUMN "created_at" bigint DEFAULT 0 NOT NULL;
+ALTER TABLE "{{users}}" ADD COLUMN "updated_at" bigint DEFAULT 0 NOT NULL;
+CREATE INDEX "{{prefix}}users_updated_at_idx" ON "{{users}}" ("updated_at");
+`
+	sqliteV12DownSQL = `DROP INDEX "{{prefix}}users_updated_at_idx";
+ALTER TABLE "{{users}}" DROP COLUMN "updated_at";
+ALTER TABLE "{{users}}" DROP COLUMN "created_at";
+ALTER TABLE "{{admins}}" DROP COLUMN "created_at";
+ALTER TABLE "{{admins}}" DROP COLUMN "updated_at";
+ALTER TABLE "{{admins}}" DROP COLUMN "last_login";
+`
 )
 
 // SQLiteProvider auth provider for SQLite database
@@ -115,10 +130,18 @@ func (p *SQLiteProvider) getUsedQuota(username string) (int, int64, error) {
 	return sqlCommonGetUsedQuota(username, p.dbHandle)
 }
 
+func (p *SQLiteProvider) setUpdatedAt(username string) {
+	sqlCommonSetUpdatedAt(username, p.dbHandle)
+}
+
 func (p *SQLiteProvider) updateLastLogin(username string) error {
 	return sqlCommonUpdateLastLogin(username, p.dbHandle)
 }
 
+func (p *SQLiteProvider) updateAdminLastLogin(username string) error {
+	return sqlCommonUpdateAdminLastLogin(username, p.dbHandle)
+}
+
 func (p *SQLiteProvider) userExists(username string) (User, error) {
 	return sqlCommonGetUserByUsername(username, p.dbHandle)
 }
@@ -274,6 +297,8 @@ func (p *SQLiteProvider) migrateDatabase() error {
 		return err
 	case version == 10:
 		return updateSQLiteDatabaseFromV10(p.dbHandle)
+	case version == 11:
+		return updateSQLiteDatabaseFromV11(p.dbHandle)
 	default:
 		if version > sqlDatabaseVersion {
 			providerLog(logger.LevelWarn, "database version %v is newer than the supported one: %v", version,
@@ -296,6 +321,8 @@ func (p *SQLiteProvider) revertDatabase(targetVersion int) error {
 	}
 
 	switch dbVersion.Version {
+	case 12:
+		return downgradeSQLiteDatabaseFromV12(p.dbHandle)
 	case 11:
 		return downgradeSQLiteDatabaseFromV11(p.dbHandle)
 	default:
@@ -304,13 +331,45 @@ func (p *SQLiteProvider) revertDatabase(targetVersion int) error {
 }
 
 func updateSQLiteDatabaseFromV10(dbHandle *sql.DB) error {
-	return updateSQLiteDatabaseFrom10To11(dbHandle)
+	if err := updateSQLiteDatabaseFrom10To11(dbHandle); err != nil {
+		return err
+	}
+	return updateSQLiteDatabaseFromV11(dbHandle)
+}
+
+func updateSQLiteDatabaseFromV11(dbHandle *sql.DB) error {
+	return updateSQLiteDatabaseFrom11To12(dbHandle)
+}
+
+func downgradeSQLiteDatabaseFromV12(dbHandle *sql.DB) error {
+	if err := downgradeSQLiteDatabaseFrom12To11(dbHandle); err != nil {
+		return err
+	}
+	return downgradeSQLiteDatabaseFromV11(dbHandle)
 }
 
 func downgradeSQLiteDatabaseFromV11(dbHandle *sql.DB) error {
 	return downgradeSQLiteDatabaseFrom11To10(dbHandle)
 }
 
+func updateSQLiteDatabaseFrom11To12(dbHandle *sql.DB) error {
+	logger.InfoToConsole("updating database version: 11 -> 12")
+	providerLog(logger.LevelInfo, "updating database version: 11 -> 12")
+	sql := strings.ReplaceAll(sqliteV12SQL, "{{users}}", sqlTableUsers)
+	sql = strings.ReplaceAll(sql, "{{admins}}", sqlTableAdmins)
+	sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 12)
+}
+
+func downgradeSQLiteDatabaseFrom12To11(dbHandle *sql.DB) error {
+	logger.InfoToConsole("downgrading database version: 12 -> 11")
+	providerLog(logger.LevelInfo, "downgrading database version: 12 -> 11")
+	sql := strings.ReplaceAll(sqliteV12DownSQL, "{{users}}", sqlTableUsers)
+	sql = strings.ReplaceAll(sql, "{{admins}}", sqlTableAdmins)
+	sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 11)
+}
+
 func updateSQLiteDatabaseFrom10To11(dbHandle *sql.DB) error {
 	logger.InfoToConsole("updating database version: 10 -> 11")
 	providerLog(logger.LevelInfo, "updating database version: 10 -> 11")

+ 1 - 0
dataprovider/sqlite_disabled.go

@@ -1,3 +1,4 @@
+//go:build nosqlite
 // +build nosqlite
 
 package dataprovider

+ 21 - 12
dataprovider/sqlqueries.go

@@ -11,9 +11,9 @@ import (
 const (
 	selectUserFields = "id,username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,used_quota_size," +
 		"used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,expiration_date,last_login,status,filters,filesystem," +
-		"additional_info,description"
+		"additional_info,description,created_at,updated_at"
 	selectFolderFields = "id,path,used_quota_size,used_quota_files,last_quota_update,name,description,filesystem"
-	selectAdminFields  = "id,username,password,status,email,permissions,filters,additional_info,description"
+	selectAdminFields  = "id,username,password,status,email,permissions,filters,additional_info,description,created_at,updated_at,last_login"
 	selectAPIKeyFields = "key_id,name,api_key,scope,created_at,updated_at,last_use_at,expires_at,description,user_id,admin_id"
 )
 
@@ -43,15 +43,16 @@ func getDumpAdminsQuery() string {
 }
 
 func getAddAdminQuery() string {
-	return fmt.Sprintf(`INSERT INTO %v (username,password,status,email,permissions,filters,additional_info,description)
-		VALUES (%v,%v,%v,%v,%v,%v,%v,%v)`, sqlTableAdmins, sqlPlaceholders[0], sqlPlaceholders[1],
-		sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7])
+	return fmt.Sprintf(`INSERT INTO %v (username,password,status,email,permissions,filters,additional_info,description,created_at,updated_at,last_login)
+		VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0)`, sqlTableAdmins, sqlPlaceholders[0], sqlPlaceholders[1],
+		sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7],
+		sqlPlaceholders[8], sqlPlaceholders[9])
 }
 
 func getUpdateAdminQuery() string {
-	return fmt.Sprintf(`UPDATE %v SET password=%v,status=%v,email=%v,permissions=%v,filters=%v,additional_info=%v,description=%v
+	return fmt.Sprintf(`UPDATE %v SET password=%v,status=%v,email=%v,permissions=%v,filters=%v,additional_info=%v,description=%v,updated_at=%v
 		WHERE username = %v`, sqlTableAdmins, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2],
-		sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7])
+		sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8])
 }
 
 func getDeleteAdminQuery() string {
@@ -156,10 +157,18 @@ func getUpdateQuotaQuery(reset bool) string {
 		WHERE username = %v`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3])
 }
 
+func getSetUpdateAtQuery() string {
+	return fmt.Sprintf(`UPDATE %v SET updated_at = %v WHERE username = %v`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1])
+}
+
 func getUpdateLastLoginQuery() string {
 	return fmt.Sprintf(`UPDATE %v SET last_login = %v WHERE username = %v`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1])
 }
 
+func getUpdateAdminLastLoginQuery() string {
+	return fmt.Sprintf(`UPDATE %v SET last_login = %v WHERE username = %v`, sqlTableAdmins, sqlPlaceholders[0], sqlPlaceholders[1])
+}
+
 func getUpdateAPIKeyLastUseQuery() string {
 	return fmt.Sprintf(`UPDATE %v SET last_use_at = %v WHERE key_id = %v`, sqlTableAPIKeys, sqlPlaceholders[0], sqlPlaceholders[1])
 }
@@ -172,20 +181,20 @@ func getQuotaQuery() string {
 func getAddUserQuery() string {
 	return fmt.Sprintf(`INSERT INTO %v (username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,
 		used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,status,last_login,expiration_date,filters,
-		filesystem,additional_info,description)
-		VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v,%v,0,%v,%v,%v,%v,%v)`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1],
+		filesystem,additional_info,description,created_at,updated_at)
+		VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v,%v,0,%v,%v,%v,%v,%v,%v,%v)`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1],
 		sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7],
 		sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13],
-		sqlPlaceholders[14], sqlPlaceholders[15], sqlPlaceholders[16], sqlPlaceholders[17])
+		sqlPlaceholders[14], sqlPlaceholders[15], sqlPlaceholders[16], sqlPlaceholders[17], sqlPlaceholders[18], sqlPlaceholders[19])
 }
 
 func getUpdateUserQuery() string {
 	return fmt.Sprintf(`UPDATE %v SET password=%v,public_keys=%v,home_dir=%v,uid=%v,gid=%v,max_sessions=%v,quota_size=%v,
 		quota_files=%v,permissions=%v,upload_bandwidth=%v,download_bandwidth=%v,status=%v,expiration_date=%v,filters=%v,filesystem=%v,
-		additional_info=%v,description=%v WHERE id = %v`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3],
+		additional_info=%v,description=%v,updated_at=%v WHERE id = %v`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3],
 		sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9],
 		sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], sqlPlaceholders[14], sqlPlaceholders[15],
-		sqlPlaceholders[16], sqlPlaceholders[17])
+		sqlPlaceholders[16], sqlPlaceholders[17], sqlPlaceholders[18])
 }
 
 func getDeleteUserQuery() string {

+ 2 - 0
dataprovider/user.go

@@ -1037,6 +1037,8 @@ func (u *User) getACopy() User {
 			Filters:           filters,
 			AdditionalInfo:    u.AdditionalInfo,
 			Description:       u.Description,
+			CreatedAt:         u.CreatedAt,
+			UpdatedAt:         u.UpdatedAt,
 		},
 		VirtualFolders: virtualFolders,
 		FsConfig:       u.FsConfig.GetACopy(),

+ 4 - 4
docker/README.md

@@ -20,12 +20,12 @@ SFTPGo provides an official Docker image, it is available on both [Docker Hub](h
 Starting a SFTPGo instance is simple:
 
 ```shell
-docker run --name some-sftpgo -p 127.0.0.1:8080:8080 -p 2022:2022 -d "drakkan/sftpgo:tag"
+docker run --name some-sftpgo -p 8080:8080 -p 2022:2022 -d "drakkan/sftpgo:tag"
 ```
 
 ... where `some-sftpgo` is the name you want to assign to your container, and `tag` is the tag specifying the SFTPGo version you want. See the list above for relevant tags.
 
-Now visit [http://localhost:8080/web/admin](http://localhost:8080/web/admin), create the first admin and then log in and create a new SFTPGo user. The SFTP service is available on port 2022.
+Now visit [http://localhost:8080/web/admin](http://localhost:8080/web/admin), replacing `localhost` with the appropriate IP address if SFTPGo is not reachable on localhost, create the first admin and a new SFTPGo user. The SFTP service is available on port 2022.
 
 If you don't want to persist any files, for example for testing purposes, you can run an SFTPGo instance like this:
 
@@ -102,7 +102,7 @@ The Docker documentation is a good starting point for understanding the differen
 
 ```shell
 docker run --name some-sftpgo \
-    -p 127.0.0.1:8080:8090 \
+    -p 8080:8090 \
     -p 2022:2022 \
     --mount type=bind,source=/my/own/sftpgodata,target=/srv/sftpgo \
     --mount type=bind,source=/my/own/sftpgohome,target=/var/lib/sftpgo \
@@ -150,7 +150,7 @@ With the above directory permissions, you can start a SFTPGo instance like this:
 ```shell
 docker run --name some-sftpgo \
     --user 1100:1100 \
-    -p 127.0.0.1:8080:8080 \
+    -p 8080:8080 \
     -p 2022:2022 \
     --mount type=bind,source="${PWD}/data",target=/srv/sftpgo \
     --mount type=bind,source="${PWD}/config",target=/var/lib/sftpgo \

+ 2 - 2
ftpd/server.go

@@ -203,7 +203,7 @@ func (s *Server) AuthUser(cc ftpserver.ClientContext, username, password string)
 	}
 	connection.Log(logger.LevelInfo, "User id: %d, logged in with FTP, username: %#v, home_dir: %#v remote addr: %#v",
 		user.ID, user.Username, user.HomeDir, ipAddr)
-	dataprovider.UpdateLastLogin(&user) //nolint:errcheck
+	dataprovider.UpdateLastLogin(&user)
 	return connection, nil
 }
 
@@ -249,7 +249,7 @@ func (s *Server) VerifyConnection(cc ftpserver.ClientContext, user string, tlsCo
 					}
 					connection.Log(logger.LevelInfo, "User id: %d, logged in with FTP using a TLS certificate, username: %#v, home_dir: %#v remote addr: %#v",
 						dbUser.ID, dbUser.Username, dbUser.HomeDir, ipAddr)
-					dataprovider.UpdateLastLogin(&dbUser) //nolint:errcheck
+					dataprovider.UpdateLastLogin(&dbUser)
 					return connection, nil
 				}
 			}

+ 5 - 5
go.mod

@@ -7,7 +7,7 @@ require (
 	github.com/Azure/azure-storage-blob-go v0.14.0
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/alexedwards/argon2id v0.0.0-20210511081203-7d35d68092b8
-	github.com/aws/aws-sdk-go v1.40.24
+	github.com/aws/aws-sdk-go v1.40.25
 	github.com/cockroachdb/cockroach-go/v2 v2.1.1
 	github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b
 	github.com/fatih/color v1.12.0 // indirect
@@ -61,17 +61,17 @@ require (
 	gocloud.dev v0.23.0
 	golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a
 	golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d
-	golang.org/x/sys v0.0.0-20210817190340-bfb29a6856f2
+	golang.org/x/sys v0.0.0-20210819072135-bce67f096156
 	golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac
 	google.golang.org/api v0.54.0
-	google.golang.org/genproto v0.0.0-20210816143620-e15ff196659d // indirect
+	google.golang.org/genproto v0.0.0-20210818220304-27ea9cc85d9f // indirect
 	google.golang.org/grpc v1.40.0
 	google.golang.org/protobuf v1.27.1
 	gopkg.in/natefinch/lumberjack.v2 v2.0.0
 )
 
 require (
-	cloud.google.com/go v0.92.3 // indirect
+	cloud.google.com/go v0.93.3 // indirect
 	cloud.google.com/go/kms v0.1.0 // indirect
 	github.com/Azure/azure-pipeline-go v0.2.3 // indirect
 	github.com/StackExchange/wmi v1.2.1 // indirect
@@ -82,7 +82,7 @@ require (
 	github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/decred/dcrd/dcrec/secp256k1/v3 v3.0.0 // indirect
-	github.com/fsnotify/fsnotify v1.4.9 // indirect
+	github.com/fsnotify/fsnotify v1.5.0 // indirect
 	github.com/go-ole/go-ole v1.2.5 // indirect
 	github.com/goccy/go-json v0.7.6 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect

+ 10 - 9
go.sum

@@ -26,8 +26,8 @@ cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSU
 cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY=
 cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
 cloud.google.com/go v0.92.2/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
-cloud.google.com/go v0.92.3 h1:VWuKmJ8pyOrb7doM0NnQDYngKv+zTicI8BaMsnIA9gA=
-cloud.google.com/go v0.92.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
+cloud.google.com/go v0.93.3 h1:wPBktZFzYBcCZVARvwVKqH1uEj+aLXofJEtrb4oOsio=
+cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
 cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
 cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
 cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
@@ -124,8 +124,8 @@ github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZo
 github.com/aws/aws-sdk-go v1.23.20/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.38.68/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
-github.com/aws/aws-sdk-go v1.40.24 h1:qtXDYFzAxEmmZaa+4JA9loBqOujO0vm4ZOJoEmjG21E=
-github.com/aws/aws-sdk-go v1.40.24/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
+github.com/aws/aws-sdk-go v1.40.25 h1:Depnx7O86HWgOCLD5nMto6F9Ju85Q1QuFDnbpZYQWno=
+github.com/aws/aws-sdk-go v1.40.25/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
 github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4=
 github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.5.0/go.mod h1:acH3+MQoiMzozT/ivU+DbRg7Ooo2298RdRaWcOv+4vM=
 github.com/aws/smithy-go v1.5.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
@@ -216,8 +216,9 @@ github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHqu
 github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
 github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
-github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
+github.com/fsnotify/fsnotify v1.5.0 h1:NO5hkcB+srp1x6QmwvNZLeaOgbM8cmBTN32THzjvu2k=
+github.com/fsnotify/fsnotify v1.5.0/go.mod h1:BX0DCEr5pT4jm2CnQdVP1lFV521fcCNcyEeNp4DQQDk=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
 github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do=
@@ -915,8 +916,8 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210817190340-bfb29a6856f2 h1:c8PlLMqBbOHoqtjteWm5/kbe6rNY2pbRfbIMVnepueo=
-golang.org/x/sys v0.0.0-20210817190340-bfb29a6856f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210819072135-bce67f096156 h1:f7XLk/QXGE6IM4HjJ4ttFFlPSwJ65A1apfDd+mmViR0=
+golang.org/x/sys v0.0.0-20210819072135-bce67f096156/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=
@@ -1112,8 +1113,8 @@ google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm
 google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
 google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
 google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w=
-google.golang.org/genproto v0.0.0-20210816143620-e15ff196659d h1:fPtHPeysWvGVJwQFKu3B7H2DB2sOEsW7UTayKkWESKw=
-google.golang.org/genproto v0.0.0-20210816143620-e15ff196659d/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
+google.golang.org/genproto v0.0.0-20210818220304-27ea9cc85d9f h1:enWPderunHptc5pzJkSYGx0olpF8goXzG0rY3kL0eSg=
+google.golang.org/genproto v0.0.0-20210818220304-27ea9cc85d9f/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
 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=

+ 167 - 1
httpd/httpd_test.go

@@ -395,6 +395,79 @@ func TestBasicUserHandling(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestUserTimestamps(t *testing.T) {
+	user, resp, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+	assert.NoError(t, err, string(resp))
+	createdAt := user.CreatedAt
+	updatedAt := user.UpdatedAt
+	assert.Equal(t, int64(0), user.LastLogin)
+	assert.Greater(t, createdAt, int64(0))
+	assert.Greater(t, updatedAt, int64(0))
+	mappedPath := filepath.Join(os.TempDir(), "mapped_dir")
+	folderName := filepath.Base(mappedPath)
+	user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{
+		BaseVirtualFolder: vfs.BaseVirtualFolder{
+			Name:       folderName,
+			MappedPath: mappedPath,
+		},
+		VirtualPath: "/vdir",
+	})
+	time.Sleep(10 * time.Millisecond)
+	user, resp, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err, string(resp))
+	assert.Equal(t, int64(0), user.LastLogin)
+	assert.Equal(t, createdAt, user.CreatedAt)
+	assert.Greater(t, user.UpdatedAt, updatedAt)
+	updatedAt = user.UpdatedAt
+	// after a folder update or delete the user updated_at field should change
+	folder, _, err := httpdtest.GetFolderByName(folderName, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Len(t, folder.Users, 1)
+	time.Sleep(10 * time.Millisecond)
+	_, _, err = httpdtest.UpdateFolder(folder, http.StatusOK)
+	assert.NoError(t, err)
+	user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Equal(t, int64(0), user.LastLogin)
+	assert.Equal(t, createdAt, user.CreatedAt)
+	assert.Greater(t, user.UpdatedAt, updatedAt)
+	updatedAt = user.UpdatedAt
+	time.Sleep(10 * time.Millisecond)
+	_, err = httpdtest.RemoveFolder(folder, http.StatusOK)
+	assert.NoError(t, err)
+	user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Equal(t, int64(0), user.LastLogin)
+	assert.Equal(t, createdAt, user.CreatedAt)
+	assert.Greater(t, user.UpdatedAt, updatedAt)
+
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+}
+
+func TestAdminTimestamps(t *testing.T) {
+	admin := getTestAdmin()
+	admin.Username = altAdminUsername
+	admin, _, err := httpdtest.AddAdmin(admin, http.StatusCreated)
+	assert.NoError(t, err)
+	createdAt := admin.CreatedAt
+	updatedAt := admin.UpdatedAt
+	assert.Equal(t, int64(0), admin.LastLogin)
+	assert.Greater(t, createdAt, int64(0))
+	assert.Greater(t, updatedAt, int64(0))
+	time.Sleep(10 * time.Millisecond)
+	admin, _, err = httpdtest.UpdateAdmin(admin, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Equal(t, int64(0), admin.LastLogin)
+	assert.Equal(t, createdAt, admin.CreatedAt)
+	assert.Greater(t, admin.UpdatedAt, updatedAt)
+
+	_, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
+	assert.NoError(t, err)
+}
+
 func TestHTTPUserAuthentication(t *testing.T) {
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	assert.NoError(t, err)
@@ -757,6 +830,26 @@ func TestAdminInvalidCredentials(t *testing.T) {
 	assert.Equal(t, dataprovider.ErrInvalidCredentials.Error(), responseHolder["error"].(string))
 }
 
+func TestAdminLastLogin(t *testing.T) {
+	a := getTestAdmin()
+	a.Username = altAdminUsername
+	a.Password = altAdminPassword
+
+	admin, _, err := httpdtest.AddAdmin(a, http.StatusCreated)
+	assert.NoError(t, err)
+	assert.Equal(t, int64(0), admin.LastLogin)
+
+	_, _, err = httpdtest.GetToken(altAdminUsername, altAdminPassword)
+	assert.NoError(t, err)
+
+	admin, _, err = httpdtest.GetAdminByUsername(altAdminUsername, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Greater(t, admin.LastLogin, int64(0))
+
+	_, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
+	assert.NoError(t, err)
+}
+
 func TestAdminAllowList(t *testing.T) {
 	a := getTestAdmin()
 	a.Username = altAdminUsername
@@ -1361,7 +1454,7 @@ func TestUpdateUserEmptyPassword(t *testing.T) {
 	assert.NoError(t, err)
 	userNoPwd, _, err := httpdtest.UpdateUserWithJSON(user, http.StatusOK, "", asJSON)
 	assert.NoError(t, err)
-	assert.Equal(t, user, userNoPwd) // the password is hidden so the user must be equal
+	assert.Equal(t, user.Password, userNoPwd.Password) // the password is hidden
 	// check the password within the data provider
 	dbUser, err = dataprovider.UserExists(u.Username)
 	assert.NoError(t, err)
@@ -1705,6 +1798,7 @@ func TestUserS3Config(t *testing.T) {
 	assert.NoError(t, err)
 	user.Password = defaultPassword
 	user.ID = 0
+	user.CreatedAt = 0
 	user.VirtualFolders = nil
 	secret := kms.NewSecret(kms.SecretStatusSecretBox, "Server-Access-Secret", "", "")
 	user.FsConfig.S3Config.AccessSecret = secret
@@ -1749,6 +1843,7 @@ func TestUserS3Config(t *testing.T) {
 	assert.NoError(t, err)
 	user.Password = defaultPassword
 	user.ID = 0
+	user.CreatedAt = 0
 	// shared credential test for add instead of update
 	user, _, err = httpdtest.AddUser(user, http.StatusCreated)
 	assert.NoError(t, err)
@@ -1795,6 +1890,7 @@ func TestUserGCSConfig(t *testing.T) {
 	assert.NoError(t, err)
 	user.Password = defaultPassword
 	user.ID = 0
+	user.CreatedAt = 0
 	user.FsConfig.GCSConfig.Credentials = kms.NewSecret(kms.SecretStatusSecretBox, "fake credentials", "", "")
 	_, _, err = httpdtest.AddUser(user, http.StatusCreated)
 	assert.Error(t, err)
@@ -1861,6 +1957,7 @@ func TestUserAzureBlobConfig(t *testing.T) {
 	assert.NoError(t, err)
 	user.Password = defaultPassword
 	user.ID = 0
+	user.CreatedAt = 0
 	secret := kms.NewSecret(kms.SecretStatusSecretBox, "Server-Account-Key", "", "")
 	user.FsConfig.AzBlobConfig.AccountKey = secret
 	_, _, err = httpdtest.AddUser(user, http.StatusCreated)
@@ -1901,6 +1998,7 @@ func TestUserAzureBlobConfig(t *testing.T) {
 	assert.NoError(t, err)
 	user.Password = defaultPassword
 	user.ID = 0
+	user.CreatedAt = 0
 	// sas test for add instead of update
 	user.FsConfig.AzBlobConfig = vfs.AzBlobFsConfig{
 		AzBlobFsConfig: sdk.AzBlobFsConfig{
@@ -1956,6 +2054,7 @@ func TestUserCryptFs(t *testing.T) {
 	assert.NoError(t, err)
 	user.Password = defaultPassword
 	user.ID = 0
+	user.CreatedAt = 0
 	secret := kms.NewSecret(kms.SecretStatusSecretBox, "invalid encrypted payload", "", "")
 	user.FsConfig.CryptConfig.Passphrase = secret
 	_, _, err = httpdtest.AddUser(user, http.StatusCreated)
@@ -2036,6 +2135,7 @@ func TestUserSFTPFs(t *testing.T) {
 	assert.NoError(t, err)
 	user.Password = defaultPassword
 	user.ID = 0
+	user.CreatedAt = 0
 	secret := kms.NewSecret(kms.SecretStatusSecretBox, "invalid encrypted payload", "", "")
 	user.FsConfig.SFTPConfig.Password = secret
 	_, _, err = httpdtest.AddUser(user, http.StatusCreated)
@@ -4120,6 +4220,69 @@ func TestUpdateAdminMock(t *testing.T) {
 	checkResponseCode(t, http.StatusOK, rr)
 }
 
+func TestAdminLastLoginWithAPIKey(t *testing.T) {
+	admin := getTestAdmin()
+	admin.Username = altAdminUsername
+	admin.Filters.AllowAPIKeyAuth = true
+	admin, resp, err := httpdtest.AddAdmin(admin, http.StatusCreated)
+	assert.NoError(t, err, string(resp))
+	assert.Equal(t, int64(0), admin.LastLogin)
+
+	apiKey := dataprovider.APIKey{
+		Name:  "admin API key",
+		Scope: dataprovider.APIKeyScopeAdmin,
+		Admin: altAdminUsername,
+	}
+
+	apiKey, resp, err = httpdtest.AddAPIKey(apiKey, http.StatusCreated)
+	assert.NoError(t, err, string(resp))
+
+	req, err := http.NewRequest(http.MethodGet, versionPath, nil)
+	assert.NoError(t, err)
+	setAPIKeyForReq(req, apiKey.Key, admin.Username)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	admin, _, err = httpdtest.GetAdminByUsername(altAdminUsername, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Greater(t, admin.LastLogin, int64(0))
+
+	_, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
+	assert.NoError(t, err)
+}
+
+func TestUserLastLoginWithAPIKey(t *testing.T) {
+	user := getTestUser()
+	user.Filters.AllowAPIKeyAuth = true
+	user, resp, err := httpdtest.AddUser(user, http.StatusCreated)
+	assert.NoError(t, err, string(resp))
+	assert.Equal(t, int64(0), user.LastLogin)
+
+	apiKey := dataprovider.APIKey{
+		Name:  "user API key",
+		Scope: dataprovider.APIKeyScopeUser,
+		User:  user.Username,
+	}
+
+	apiKey, resp, err = httpdtest.AddAPIKey(apiKey, http.StatusCreated)
+	assert.NoError(t, err, string(resp))
+
+	req, err := http.NewRequest(http.MethodGet, userDirsPath, nil)
+	assert.NoError(t, err)
+	setAPIKeyForReq(req, apiKey.Key, "")
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Greater(t, user.LastLogin, int64(0))
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestAdminHandlingWithAPIKeys(t *testing.T) {
 	sysAdmin, _, err := httpdtest.GetAdminByUsername(defaultTokenAuthUser, http.StatusOK)
 	assert.NoError(t, err)
@@ -4260,6 +4423,8 @@ func TestUserHandlingWithAPIKey(t *testing.T) {
 	setAPIKeyForReq(req, apiKey.Key, "")
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
 
 	_, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
 	assert.NoError(t, err)
@@ -4506,6 +4671,7 @@ func TestUpdateUserInvalidParamsMock(t *testing.T) {
 	checkResponseCode(t, http.StatusBadRequest, rr)
 	userID := user.ID
 	user.ID = 0
+	user.CreatedAt = 0
 	userAsJSON = getUserAsJSON(t, user)
 	req, _ = http.NewRequest(http.MethodPut, path.Join(userPath, user.Username), bytes.NewBuffer(userAsJSON))
 	setBearerForReq(req, token)

+ 2 - 2
httpd/middleware.go

@@ -341,7 +341,7 @@ func authenticateAdminWithAPIKey(username, keyID string, tokenAuth *jwtauth.JWTA
 		return err
 	}
 	r.Header.Set("Authorization", fmt.Sprintf("Bearer %v", resp["access_token"]))
-
+	dataprovider.UpdateAdminLastLogin(&admin)
 	return nil
 }
 
@@ -397,7 +397,7 @@ func authenticateUserWithAPIKey(username, keyID string, tokenAuth *jwtauth.JWTAu
 		return err
 	}
 	r.Header.Set("Authorization", fmt.Sprintf("Bearer %v", resp["access_token"]))
-	dataprovider.UpdateLastLogin(&user) //nolint:errcheck
+	dataprovider.UpdateLastLogin(&user)
 	updateLoginMetrics(&user, ipAddr, nil)
 
 	return nil

+ 20 - 0
httpd/schema/openapi.yaml

@@ -2969,6 +2969,14 @@ components:
           type: integer
           format: int32
           description: 'Maximum download bandwidth as KB/s, 0 means unlimited'
+        created_at:
+          type: integer
+          format: int64
+          description: 'creation time as unix timestamp in milliseconds. It will be 0 for users created before v2.2.0'
+        updated_at:
+          type: integer
+          format: int64
+          description: last update time as unix timestamp in milliseconds
         last_login:
           type: integer
           format: int64
@@ -3032,6 +3040,18 @@ components:
         additional_info:
           type: string
           description: Free form text field
+        created_at:
+          type: integer
+          format: int64
+          description: 'creation time as unix timestamp in milliseconds. It will be 0 for admins created before v2.2.0'
+        updated_at:
+          type: integer
+          format: int64
+          description: last update time as unix timestamp in milliseconds
+        last_login:
+          type: integer
+          format: int64
+          description: Last user login as unix timestamp in milliseconds. It is saved at most once every 10 minutes
     APIKey:
       type: object
       properties:

+ 4 - 2
httpd/server.go

@@ -197,7 +197,7 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re
 		return
 	}
 	updateLoginMetrics(&user, ipAddr, err)
-	dataprovider.UpdateLastLogin(&user) //nolint:errcheck
+	dataprovider.UpdateLastLogin(&user)
 	http.Redirect(w, r, webClientFilesPath, http.StatusFound)
 }
 
@@ -307,6 +307,7 @@ func (s *httpdServer) loginAdmin(w http.ResponseWriter, r *http.Request, admin *
 	}
 
 	http.Redirect(w, r, webUsersPath, http.StatusFound)
+	dataprovider.UpdateAdminLastLogin(admin)
 }
 
 func (s *httpdServer) logout(w http.ResponseWriter, r *http.Request) {
@@ -377,7 +378,7 @@ func (s *httpdServer) generateAndSendUserToken(w http.ResponseWriter, r *http.Re
 		return
 	}
 	updateLoginMetrics(&user, ipAddr, err)
-	dataprovider.UpdateLastLogin(&user) //nolint:errcheck
+	dataprovider.UpdateLastLogin(&user)
 
 	render.JSON(w, r, resp)
 }
@@ -413,6 +414,7 @@ func (s *httpdServer) generateAndSendToken(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
+	dataprovider.UpdateAdminLastLogin(&admin)
 	render.JSON(w, r, resp)
 }
 

+ 10 - 0
httpdtest/httpdtest.go

@@ -1058,6 +1058,11 @@ func checkAdmin(expected, actual *dataprovider.Admin) error {
 			return errors.New("admin ID mismatch")
 		}
 	}
+	if expected.CreatedAt > 0 {
+		if expected.CreatedAt != actual.CreatedAt {
+			return fmt.Errorf("created_at mismatch %v != %v", expected.CreatedAt, actual.CreatedAt)
+		}
+	}
 	if err := compareAdminEqualFields(expected, actual); err != nil {
 		return err
 	}
@@ -1116,6 +1121,11 @@ func checkUser(expected *dataprovider.User, actual *dataprovider.User) error {
 			return errors.New("user ID mismatch")
 		}
 	}
+	if expected.CreatedAt > 0 {
+		if expected.CreatedAt != actual.CreatedAt {
+			return fmt.Errorf("created_at mismatch %v != %v", expected.CreatedAt, actual.CreatedAt)
+		}
+	}
 	if len(expected.Permissions) != len(actual.Permissions) {
 		return errors.New("permissions mismatch")
 	}

+ 1 - 0
logger/journald.go

@@ -1,3 +1,4 @@
+//go:build linux
 // +build linux
 
 package logger

+ 1 - 0
logger/journald_nolinux.go

@@ -1,3 +1,4 @@
+//go:build !linux
 // +build !linux
 
 package logger

+ 1 - 0
metric/metric.go

@@ -1,3 +1,4 @@
+//go:build !nometrics
 // +build !nometrics
 
 // Package metrics provides Prometheus metrics support

+ 1 - 0
metric/metric_disabled.go

@@ -1,3 +1,4 @@
+//go:build nometrics
 // +build nometrics
 
 package metric

+ 4 - 0
sdk/user.go

@@ -166,6 +166,10 @@ type BaseUser struct {
 	DownloadBandwidth int64 `json:"download_bandwidth"`
 	// Last login as unix timestamp in milliseconds
 	LastLogin int64 `json:"last_login"`
+	// Creation time as unix timestamp in milliseconds. It will be 0 for admins created before v2.2.0
+	CreatedAt int64 `json:"created_at"`
+	// last update time as unix timestamp in milliseconds
+	UpdatedAt int64 `json:"updated_at"`
 	// Additional restrictions
 	Filters UserFilters `json:"filters"`
 	// optional description, for example full name

+ 8 - 8
service/service.go

@@ -286,21 +286,21 @@ func (s *Service) loadInitialData() error {
 }
 
 func (s *Service) restoreDump(dump *dataprovider.BackupData) error {
-	err := httpd.RestoreAPIKeys(dump.APIKeys, s.LoadDataFrom, s.LoadDataMode)
+	err := httpd.RestoreFolders(dump.Folders, s.LoadDataFrom, s.LoadDataMode, s.LoadDataQuotaScan)
 	if err != nil {
-		return fmt.Errorf("unable to restore API keys from file %#v: %v", s.LoadDataFrom, err)
+		return fmt.Errorf("unable to restore folders from file %#v: %v", s.LoadDataFrom, err)
 	}
-	err = httpd.RestoreAdmins(dump.Admins, s.LoadDataFrom, s.LoadDataMode)
+	err = httpd.RestoreUsers(dump.Users, s.LoadDataFrom, s.LoadDataMode, s.LoadDataQuotaScan)
 	if err != nil {
-		return fmt.Errorf("unable to restore admins from file %#v: %v", s.LoadDataFrom, err)
+		return fmt.Errorf("unable to restore users from file %#v: %v", s.LoadDataFrom, err)
 	}
-	err = httpd.RestoreFolders(dump.Folders, s.LoadDataFrom, s.LoadDataMode, s.LoadDataQuotaScan)
+	err = httpd.RestoreAdmins(dump.Admins, s.LoadDataFrom, s.LoadDataMode)
 	if err != nil {
-		return fmt.Errorf("unable to restore folders from file %#v: %v", s.LoadDataFrom, err)
+		return fmt.Errorf("unable to restore admins from file %#v: %v", s.LoadDataFrom, err)
 	}
-	err = httpd.RestoreUsers(dump.Users, s.LoadDataFrom, s.LoadDataMode, s.LoadDataQuotaScan)
+	err = httpd.RestoreAPIKeys(dump.APIKeys, s.LoadDataFrom, s.LoadDataMode)
 	if err != nil {
-		return fmt.Errorf("unable to restore users from file %#v: %v", s.LoadDataFrom, err)
+		return fmt.Errorf("unable to restore API keys from file %#v: %v", s.LoadDataFrom, err)
 	}
 	return nil
 }

+ 1 - 0
service/service_portable.go

@@ -1,3 +1,4 @@
+//go:build !noportable
 // +build !noportable
 
 package service

+ 1 - 0
service/signals_unix.go

@@ -1,3 +1,4 @@
+//go:build !windows
 // +build !windows
 
 package service

+ 1 - 0
sftpd/cmd_unix.go

@@ -1,3 +1,4 @@
+//go:build !windows
 // +build !windows
 
 package sftpd

+ 1 - 0
sftpd/internal_unix_test.go

@@ -1,3 +1,4 @@
+//go:build !windows
 // +build !windows
 
 package sftpd

+ 1 - 1
sftpd/server.go

@@ -406,7 +406,7 @@ func (c *Configuration) AcceptInboundConnection(conn net.Conn, config *ssh.Serve
 	logger.Log(logger.LevelDebug, common.ProtocolSSH, connectionID,
 		"User %#v, logged in with: %#v, from ip: %#v, client version %#v",
 		user.Username, loginType, ipAddr, string(sconn.ClientVersion()))
-	dataprovider.UpdateLastLogin(&user) //nolint:errcheck
+	dataprovider.UpdateLastLogin(&user)
 
 	sshConnection := common.NewSSHConnection(connectionID, conn)
 	common.Connections.AddSSHConnection(sshConnection)

+ 1 - 1
sftpd/subsystem.go

@@ -43,7 +43,7 @@ func ServeSubSystemConnection(user *dataprovider.User, connectionID string, read
 		logger.Warn(logSender, connectionID, "unable to check fs root: %v close fs error: %v", err, errClose)
 		return err
 	}
-	dataprovider.UpdateLastLogin(user) //nolint:errcheck
+	dataprovider.UpdateLastLogin(user)
 
 	connection := &Connection{
 		BaseConnection: common.NewBaseConnection(connectionID, common.ProtocolSFTP, "", "", *user),

+ 1 - 0
vfs/azblobfs.go

@@ -1,3 +1,4 @@
+//go:build !noazblob
 // +build !noazblob
 
 package vfs

+ 1 - 0
vfs/azblobfs_disabled.go

@@ -1,3 +1,4 @@
+//go:build noazblob
 // +build noazblob
 
 package vfs

+ 1 - 0
vfs/gcsfs.go

@@ -1,3 +1,4 @@
+//go:build !nogcs
 // +build !nogcs
 
 package vfs

+ 1 - 0
vfs/gcsfs_disabled.go

@@ -1,3 +1,4 @@
+//go:build nogcs
 // +build nogcs
 
 package vfs

+ 1 - 0
vfs/s3fs.go

@@ -1,3 +1,4 @@
+//go:build !nos3
 // +build !nos3
 
 package vfs

+ 1 - 0
vfs/s3fs_disabled.go

@@ -1,3 +1,4 @@
+//go:build nos3
 // +build nos3
 
 package vfs

+ 1 - 0
vfs/statvfs_fallback.go

@@ -1,3 +1,4 @@
+//go:build !darwin && !linux && !freebsd
 // +build !darwin,!linux,!freebsd
 
 package vfs

+ 1 - 0
vfs/statvfs_linux.go

@@ -1,3 +1,4 @@
+//go:build linux
 // +build linux
 
 package vfs

+ 1 - 0
vfs/statvfs_unix.go

@@ -1,3 +1,4 @@
+//go:build freebsd || darwin
 // +build freebsd darwin
 
 package vfs

+ 1 - 0
vfs/sys_unix.go

@@ -1,3 +1,4 @@
+//go:build !windows
 // +build !windows
 
 package vfs

+ 13 - 1
webdavd/internal_test.go

@@ -1118,10 +1118,22 @@ func TestCachedUserWithFolders(t *testing.T) {
 
 	folder, err := dataprovider.GetFolderByName(folderName)
 	assert.NoError(t, err)
-	// updating a used folder should invalidate the cache
+	// updating a used folder should invalidate the cache only if the fs changed
 	err = dataprovider.UpdateFolder(&folder, folder.Users)
 	assert.NoError(t, err)
 
+	_, isCached, _, loginMethod, err = server.authenticate(req, ipAddr)
+	assert.NoError(t, err)
+	assert.True(t, isCached)
+	assert.Equal(t, dataprovider.LoginMethodPassword, loginMethod)
+	cachedUser, ok = dataprovider.GetCachedWebDAVUser(username)
+	if assert.True(t, ok) {
+		assert.False(t, cachedUser.IsExpired())
+	}
+	// changing the folder path should invalidate the cache
+	folder.MappedPath = filepath.Join(os.TempDir(), "anotherpath")
+	err = dataprovider.UpdateFolder(&folder, folder.Users)
+	assert.NoError(t, err)
 	_, isCached, _, loginMethod, err = server.authenticate(req, ipAddr)
 	assert.NoError(t, err)
 	assert.False(t, isCached)

+ 1 - 1
webdavd/server.go

@@ -208,7 +208,7 @@ func (s *webDavServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 
-	dataprovider.UpdateLastLogin(&user) //nolint:errcheck
+	dataprovider.UpdateLastLogin(&user)
 
 	if s.checkRequestMethod(ctx, r, connection) {
 		w.Header().Set("Content-Type", "text/xml; charset=utf-8")