Browse Source

dataprovider: add timestamp fields for users and admins

Nicola Murino 4 years ago
parent
commit
be3857d572
52 changed files with 724 additions and 75 deletions
  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
 // +build !noportable
 
 
 package cmd
 package cmd

+ 1 - 0
cmd/portable_disabled.go

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

+ 1 - 0
config/config_linux.go

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

+ 1 - 0
config/config_nolinux.go

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

+ 9 - 0
dataprovider/admin.go

@@ -68,6 +68,12 @@ type Admin struct {
 	Filters        AdminFilters `json:"filters,omitempty"`
 	Filters        AdminFilters `json:"filters,omitempty"`
 	Description    string       `json:"description,omitempty"`
 	Description    string       `json:"description,omitempty"`
 	AdditionalInfo string       `json:"additional_info,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 {
 func (a *Admin) checkPassword() error {
@@ -260,6 +266,9 @@ func (a *Admin) getACopy() Admin {
 		Filters:        filters,
 		Filters:        filters,
 		AdditionalInfo: a.AdditionalInfo,
 		AdditionalInfo: a.AdditionalInfo,
 		Description:    a.Description,
 		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 {
 	if err := k.checkKey(); err != nil {
 		return err
 		return err
 	}
 	}
-	if k.CreatedAt == 0 {
-		k.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
-	}
 	if k.User != "" && k.Admin != "" {
 	if k.User != "" && k.Admin != "" {
 		return util.NewValidationError("an API key can be related to a user or an admin, not both")
 		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
 // +build !nobolt
 
 
 package dataprovider
 package dataprovider
@@ -19,7 +20,7 @@ import (
 )
 )
 
 
 const (
 const (
-	boltDatabaseVersion = 11
+	boltDatabaseVersion = 12
 )
 )
 
 
 var (
 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 {
 func (p *BoltProvider) updateLastLogin(username string) error {
 	return p.dbHandle.Update(func(tx *bolt.Tx) error {
 	return p.dbHandle.Update(func(tx *bolt.Tx) error {
 		bucket, err := getUsersBucket(tx)
 		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 {
 func (p *BoltProvider) updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error {
 	return p.dbHandle.Update(func(tx *bolt.Tx) error {
 	return p.dbHandle.Update(func(tx *bolt.Tx) error {
 		bucket, err := getUsersBucket(tx)
 		bucket, err := getUsersBucket(tx)
@@ -300,6 +361,9 @@ func (p *BoltProvider) addAdmin(admin *Admin) error {
 			return err
 			return err
 		}
 		}
 		admin.ID = int64(id)
 		admin.ID = int64(id)
+		admin.LastLogin = 0
+		admin.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+		admin.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		buf, err := json.Marshal(admin)
 		buf, err := json.Marshal(admin)
 		if err != nil {
 		if err != nil {
 			return err
 			return err
@@ -330,6 +394,9 @@ func (p *BoltProvider) updateAdmin(admin *Admin) error {
 		}
 		}
 
 
 		admin.ID = oldAdmin.ID
 		admin.ID = oldAdmin.ID
+		admin.CreatedAt = oldAdmin.CreatedAt
+		admin.LastLogin = oldAdmin.LastLogin
+		admin.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		buf, err := json.Marshal(admin)
 		buf, err := json.Marshal(admin)
 		if err != nil {
 		if err != nil {
 			return err
 			return err
@@ -478,6 +545,8 @@ func (p *BoltProvider) addUser(user *User) error {
 		user.UsedQuotaSize = 0
 		user.UsedQuotaSize = 0
 		user.UsedQuotaFiles = 0
 		user.UsedQuotaFiles = 0
 		user.LastLogin = 0
 		user.LastLogin = 0
+		user.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+		user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		for idx := range user.VirtualFolders {
 		for idx := range user.VirtualFolders {
 			err = addUserToFolderMapping(&user.VirtualFolders[idx].BaseVirtualFolder, user, folderBucket)
 			err = addUserToFolderMapping(&user.VirtualFolders[idx].BaseVirtualFolder, user, folderBucket)
 			if err != nil {
 			if err != nil {
@@ -532,6 +601,8 @@ func (p *BoltProvider) updateUser(user *User) error {
 		user.UsedQuotaSize = oldUser.UsedQuotaSize
 		user.UsedQuotaSize = oldUser.UsedQuotaSize
 		user.UsedQuotaFiles = oldUser.UsedQuotaFiles
 		user.UsedQuotaFiles = oldUser.UsedQuotaFiles
 		user.LastLogin = oldUser.LastLogin
 		user.LastLogin = oldUser.LastLogin
+		user.CreatedAt = oldUser.CreatedAt
+		user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		buf, err := json.Marshal(user)
 		buf, err := json.Marshal(user)
 		if err != nil {
 		if err != nil {
 			return err
 			return err
@@ -916,7 +987,9 @@ func (p *BoltProvider) addAPIKey(apiKey *APIKey) error {
 			return err
 			return err
 		}
 		}
 		apiKey.ID = int64(id)
 		apiKey.ID = int64(id)
+		apiKey.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		apiKey.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		apiKey.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+		apiKey.LastUseAt = 0
 		buf, err := json.Marshal(apiKey)
 		buf, err := json.Marshal(apiKey)
 		if err != nil {
 		if err != nil {
 			return err
 			return err
@@ -1077,7 +1150,9 @@ func (p *BoltProvider) migrateDatabase() error {
 		logger.ErrorToConsole("%v", err)
 		logger.ErrorToConsole("%v", err)
 		return err
 		return err
 	case version == 10:
 	case version == 10:
-		return updateBoltDatabaseVersion(p.dbHandle, 11)
+		return updateBoltDatabaseVersion(p.dbHandle, 12)
+	case version == 11:
+		return updateBoltDatabaseVersion(p.dbHandle, 12)
 	default:
 	default:
 		if version > boltDatabaseVersion {
 		if version > boltDatabaseVersion {
 			providerLog(logger.LevelWarn, "database version %v is newer than the supported one: %v", version,
 			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")
 		return errors.New("current version match target version, nothing to do")
 	}
 	}
 	switch dbVersion.Version {
 	switch dbVersion.Version {
+	case 12:
+		return updateBoltDatabaseVersion(p.dbHandle, 10)
 	case 11:
 	case 11:
 		return updateBoltDatabaseVersion(p.dbHandle, 10)
 		return updateBoltDatabaseVersion(p.dbHandle, 10)
 	default:
 	default:

+ 1 - 0
dataprovider/bolt_disabled.go

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

+ 26 - 4
dataprovider/dataprovider.go

@@ -392,6 +392,8 @@ type Provider interface {
 	getUsers(limit int, offset int, order string) ([]User, error)
 	getUsers(limit int, offset int, order string) ([]User, error)
 	dumpUsers() ([]User, error)
 	dumpUsers() ([]User, error)
 	updateLastLogin(username string) error
 	updateLastLogin(username string) error
+	updateAdminLastLogin(username string) error
+	setUpdatedAt(username string)
 	getFolders(limit, offset int, order string) ([]vfs.BaseVirtualFolder, error)
 	getFolders(limit, offset int, order string) ([]vfs.BaseVirtualFolder, error)
 	getFolderByName(name string) (vfs.BaseVirtualFolder, error)
 	getFolderByName(name string) (vfs.BaseVirtualFolder, error)
 	addFolder(folder *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
 // 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)
 	lastLogin := util.GetTimeFromMsecSinceEpoch(user.LastLogin)
 	diff := -time.Until(lastLogin)
 	diff := -time.Until(lastLogin)
 	if diff < 0 || diff > lastLoginMinDelay {
 	if diff < 0 || diff > lastLoginMinDelay {
@@ -821,9 +823,16 @@ func UpdateLastLogin(user *User) error {
 		if err == nil {
 		if err == nil {
 			webDAVUsersCache.updateLastLogin(user.Username)
 			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.
 // 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)
 	err := provider.updateFolder(folder)
 	if err == nil {
 	if err == nil {
 		for _, user := range users {
 		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
 	return err
@@ -1041,6 +1057,7 @@ func DeleteFolder(folderName string) error {
 	err = provider.deleteFolder(&folder)
 	err = provider.deleteFolder(&folder)
 	if err == nil {
 	if err == nil {
 		for _, user := range folder.Users {
 		for _, user := range folder.Users {
+			provider.setUpdatedAt(user)
 			RemoveCachedWebDAVUser(user)
 			RemoveCachedWebDAVUser(user)
 		}
 		}
 		delayedQuotaUpdater.resetFolderQuota(folderName)
 		delayedQuotaUpdater.resetFolderQuota(folderName)
@@ -2252,6 +2269,7 @@ func executePreLoginHook(username, loginMethod, ip, protocol string) (User, erro
 	userUsedQuotaFiles := u.UsedQuotaFiles
 	userUsedQuotaFiles := u.UsedQuotaFiles
 	userLastQuotaUpdate := u.LastQuotaUpdate
 	userLastQuotaUpdate := u.LastQuotaUpdate
 	userLastLogin := u.LastLogin
 	userLastLogin := u.LastLogin
+	userCreatedAt := u.CreatedAt
 	err = json.Unmarshal(out, &u)
 	err = json.Unmarshal(out, &u)
 	if err != nil {
 	if err != nil {
 		return u, fmt.Errorf("invalid pre-login hook response %#v, error: %v", string(out), err)
 		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.UsedQuotaFiles = userUsedQuotaFiles
 	u.LastQuotaUpdate = userLastQuotaUpdate
 	u.LastQuotaUpdate = userLastQuotaUpdate
 	u.LastLogin = userLastLogin
 	u.LastLogin = userLastLogin
+	u.CreatedAt = userCreatedAt
 	if userID == 0 {
 	if userID == 0 {
 		err = provider.addUser(&u)
 		err = provider.addUser(&u)
 	} else {
 	} else {
+		u.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		err = provider.updateUser(&u)
 		err = provider.updateUser(&u)
 		if err == nil {
 		if err == nil {
 			webDAVUsersCache.swap(&u)
 			webDAVUsersCache.swap(&u)
@@ -2464,6 +2484,8 @@ func doExternalAuth(username, password string, pubKey []byte, keyboardInteractiv
 		user.UsedQuotaFiles = u.UsedQuotaFiles
 		user.UsedQuotaFiles = u.UsedQuotaFiles
 		user.LastQuotaUpdate = u.LastQuotaUpdate
 		user.LastQuotaUpdate = u.LastQuotaUpdate
 		user.LastLogin = u.LastLogin
 		user.LastLogin = u.LastLogin
+		user.CreatedAt = u.CreatedAt
+		user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		err = provider.updateUser(&user)
 		err = provider.updateUser(&user)
 		if err == nil {
 		if err == nil {
 			webDAVUsersCache.swap(&user)
 			webDAVUsersCache.swap(&user)

+ 76 - 6
dataprovider/memory.go

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

+ 62 - 1
dataprovider/mysql.go

@@ -1,3 +1,4 @@
+//go:build !nomysql
 // +build !nomysql
 // +build !nomysql
 
 
 package dataprovider
 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_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;"
 		"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;"
 	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
 // 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)
 	return sqlCommonGetUsedQuota(username, p.dbHandle)
 }
 }
 
 
+func (p *MySQLProvider) setUpdatedAt(username string) {
+	sqlCommonSetUpdatedAt(username, p.dbHandle)
+}
+
 func (p *MySQLProvider) updateLastLogin(username string) error {
 func (p *MySQLProvider) updateLastLogin(username string) error {
 	return sqlCommonUpdateLastLogin(username, p.dbHandle)
 	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) {
 func (p *MySQLProvider) userExists(username string) (User, error) {
 	return sqlCommonGetUserByUsername(username, p.dbHandle)
 	return sqlCommonGetUserByUsername(username, p.dbHandle)
 }
 }
@@ -276,6 +301,8 @@ func (p *MySQLProvider) migrateDatabase() error {
 		return err
 		return err
 	case version == 10:
 	case version == 10:
 		return updateMySQLDatabaseFromV10(p.dbHandle)
 		return updateMySQLDatabaseFromV10(p.dbHandle)
+	case version == 11:
+		return updateMySQLDatabaseFromV11(p.dbHandle)
 	default:
 	default:
 		if version > sqlDatabaseVersion {
 		if version > sqlDatabaseVersion {
 			providerLog(logger.LevelWarn, "database version %v is newer than the supported one: %v", version,
 			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 {
 	switch dbVersion.Version {
+	case 12:
+		return downgradeMySQLDatabaseFromV12(p.dbHandle)
 	case 11:
 	case 11:
 		return downgradeMySQLDatabaseFromV11(p.dbHandle)
 		return downgradeMySQLDatabaseFromV11(p.dbHandle)
 	default:
 	default:
@@ -306,13 +335,45 @@ func (p *MySQLProvider) revertDatabase(targetVersion int) error {
 }
 }
 
 
 func updateMySQLDatabaseFromV10(dbHandle *sql.DB) 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 {
 func downgradeMySQLDatabaseFromV11(dbHandle *sql.DB) error {
 	return downgradeMySQLDatabaseFrom11To10(dbHandle)
 	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 {
 func updateMySQLDatabaseFrom10To11(dbHandle *sql.DB) error {
 	logger.InfoToConsole("updating database version: 10 -> 11")
 	logger.InfoToConsole("updating database version: 10 -> 11")
 	providerLog(logger.LevelInfo, "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
 // +build nomysql
 
 
 package dataprovider
 package dataprovider

+ 64 - 1
dataprovider/pgsql.go

@@ -1,3 +1,4 @@
+//go:build !nopgsql
 // +build !nopgsql
 // +build !nopgsql
 
 
 package dataprovider
 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");
 CREATE INDEX "{{prefix}}api_keys_user_id_idx" ON "{{api_keys}}" ("user_id");
 `
 `
 	pgsqlV11DownSQL = `DROP TABLE "{{api_keys}}" CASCADE;`
 	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
 // PGSQLProvider auth provider for PostgreSQL database
@@ -128,10 +147,18 @@ func (p *PGSQLProvider) getUsedQuota(username string) (int, int64, error) {
 	return sqlCommonGetUsedQuota(username, p.dbHandle)
 	return sqlCommonGetUsedQuota(username, p.dbHandle)
 }
 }
 
 
+func (p *PGSQLProvider) setUpdatedAt(username string) {
+	sqlCommonSetUpdatedAt(username, p.dbHandle)
+}
+
 func (p *PGSQLProvider) updateLastLogin(username string) error {
 func (p *PGSQLProvider) updateLastLogin(username string) error {
 	return sqlCommonUpdateLastLogin(username, p.dbHandle)
 	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) {
 func (p *PGSQLProvider) userExists(username string) (User, error) {
 	return sqlCommonGetUserByUsername(username, p.dbHandle)
 	return sqlCommonGetUserByUsername(username, p.dbHandle)
 }
 }
@@ -293,6 +320,8 @@ func (p *PGSQLProvider) migrateDatabase() error {
 		return err
 		return err
 	case version == 10:
 	case version == 10:
 		return updatePGSQLDatabaseFromV10(p.dbHandle)
 		return updatePGSQLDatabaseFromV10(p.dbHandle)
+	case version == 11:
+		return updatePGSQLDatabaseFromV11(p.dbHandle)
 	default:
 	default:
 		if version > sqlDatabaseVersion {
 		if version > sqlDatabaseVersion {
 			providerLog(logger.LevelWarn, "database version %v is newer than the supported one: %v", version,
 			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 {
 	switch dbVersion.Version {
+	case 12:
+		return downgradePGSQLDatabaseFromV12(p.dbHandle)
 	case 11:
 	case 11:
 		return downgradePGSQLDatabaseFromV11(p.dbHandle)
 		return downgradePGSQLDatabaseFromV11(p.dbHandle)
 	default:
 	default:
@@ -323,13 +354,45 @@ func (p *PGSQLProvider) revertDatabase(targetVersion int) error {
 }
 }
 
 
 func updatePGSQLDatabaseFromV10(dbHandle *sql.DB) 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 {
 func downgradePGSQLDatabaseFromV11(dbHandle *sql.DB) error {
 	return downgradePGSQLDatabaseFrom11To10(dbHandle)
 	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 {
 func updatePGSQLDatabaseFrom10To11(dbHandle *sql.DB) error {
 	logger.InfoToConsole("updating database version: 10 -> 11")
 	logger.InfoToConsole("updating database version: 10 -> 11")
 	providerLog(logger.LevelInfo, "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
 // +build nopgsql
 
 
 package dataprovider
 package dataprovider

+ 47 - 8
dataprovider/sqlcommon.go

@@ -19,7 +19,7 @@ import (
 )
 )
 
 
 const (
 const (
-	sqlDatabaseVersion     = 11
+	sqlDatabaseVersion     = 12
 	defaultSQLQueryTimeout = 10 * time.Second
 	defaultSQLQueryTimeout = 10 * time.Second
 	longSQLQueryTimeout    = 60 * time.Second
 	longSQLQueryTimeout    = 60 * time.Second
 )
 )
@@ -75,7 +75,7 @@ func sqlCommonAddAPIKey(apiKey *APIKey, dbHandle *sql.DB) error {
 	}
 	}
 	defer stmt.Close()
 	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,
 		util.GetTimeAsMsSinceEpoch(time.Now()), apiKey.LastUseAt, apiKey.ExpiresAt, apiKey.Description,
 		userID, adminID)
 		userID, adminID)
 	return err
 	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),
 	_, 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
 	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),
 	_, 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
 	return err
 }
 }
 
 
@@ -486,6 +487,43 @@ func sqlCommonUpdateAPIKeyLastUse(keyID string, dbHandle *sql.DB) error {
 	return err
 	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 {
 func sqlCommonUpdateLastLogin(username string, dbHandle *sql.DB) error {
 	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
 	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
 	defer cancel()
 	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,
 		_, 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),
 			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 {
 		if err != nil {
 			return err
 			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,
 		_, 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,
 			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 {
 		if err != nil {
 			return err
 			return err
 		}
 		}
@@ -702,7 +741,7 @@ func getAdminFromDbRow(row sqlScanner) (Admin, error) {
 	var email, filters, additionalInfo, permissions, description sql.NullString
 	var email, filters, additionalInfo, permissions, description sql.NullString
 
 
 	err := row.Scan(&admin.ID, &admin.Username, &admin.Password, &admin.Status, &email, &permissions,
 	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 != nil {
 		if err == sql.ErrNoRows {
 		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,
 	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.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate,
 		&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig,
 		&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 != nil {
 		if err == sql.ErrNoRows {
 		if err == sql.ErrNoRows {
 			return user, util.NewRecordNotFoundError(err.Error())
 			return user, util.NewRecordNotFoundError(err.Error())

+ 60 - 1
dataprovider/sqlite.go

@@ -1,3 +1,4 @@
+//go:build !nosqlite
 // +build !nosqlite
 // +build !nosqlite
 
 
 package dataprovider
 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");
 CREATE INDEX "{{prefix}}api_keys_user_id_idx" ON "api_keys" ("user_id");
 `
 `
 	sqliteV11DownSQL = `DROP TABLE "{{api_keys}}";`
 	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
 // SQLiteProvider auth provider for SQLite database
@@ -115,10 +130,18 @@ func (p *SQLiteProvider) getUsedQuota(username string) (int, int64, error) {
 	return sqlCommonGetUsedQuota(username, p.dbHandle)
 	return sqlCommonGetUsedQuota(username, p.dbHandle)
 }
 }
 
 
+func (p *SQLiteProvider) setUpdatedAt(username string) {
+	sqlCommonSetUpdatedAt(username, p.dbHandle)
+}
+
 func (p *SQLiteProvider) updateLastLogin(username string) error {
 func (p *SQLiteProvider) updateLastLogin(username string) error {
 	return sqlCommonUpdateLastLogin(username, p.dbHandle)
 	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) {
 func (p *SQLiteProvider) userExists(username string) (User, error) {
 	return sqlCommonGetUserByUsername(username, p.dbHandle)
 	return sqlCommonGetUserByUsername(username, p.dbHandle)
 }
 }
@@ -274,6 +297,8 @@ func (p *SQLiteProvider) migrateDatabase() error {
 		return err
 		return err
 	case version == 10:
 	case version == 10:
 		return updateSQLiteDatabaseFromV10(p.dbHandle)
 		return updateSQLiteDatabaseFromV10(p.dbHandle)
+	case version == 11:
+		return updateSQLiteDatabaseFromV11(p.dbHandle)
 	default:
 	default:
 		if version > sqlDatabaseVersion {
 		if version > sqlDatabaseVersion {
 			providerLog(logger.LevelWarn, "database version %v is newer than the supported one: %v", version,
 			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 {
 	switch dbVersion.Version {
+	case 12:
+		return downgradeSQLiteDatabaseFromV12(p.dbHandle)
 	case 11:
 	case 11:
 		return downgradeSQLiteDatabaseFromV11(p.dbHandle)
 		return downgradeSQLiteDatabaseFromV11(p.dbHandle)
 	default:
 	default:
@@ -304,13 +331,45 @@ func (p *SQLiteProvider) revertDatabase(targetVersion int) error {
 }
 }
 
 
 func updateSQLiteDatabaseFromV10(dbHandle *sql.DB) 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 {
 func downgradeSQLiteDatabaseFromV11(dbHandle *sql.DB) error {
 	return downgradeSQLiteDatabaseFrom11To10(dbHandle)
 	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 {
 func updateSQLiteDatabaseFrom10To11(dbHandle *sql.DB) error {
 	logger.InfoToConsole("updating database version: 10 -> 11")
 	logger.InfoToConsole("updating database version: 10 -> 11")
 	providerLog(logger.LevelInfo, "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
 // +build nosqlite
 
 
 package dataprovider
 package dataprovider

+ 21 - 12
dataprovider/sqlqueries.go

@@ -11,9 +11,9 @@ import (
 const (
 const (
 	selectUserFields = "id,username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,used_quota_size," +
 	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," +
 		"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"
 	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"
 	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 {
 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 {
 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],
 		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 {
 func getDeleteAdminQuery() string {
@@ -156,10 +157,18 @@ func getUpdateQuotaQuery(reset bool) string {
 		WHERE username = %v`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3])
 		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 {
 func getUpdateLastLoginQuery() string {
 	return fmt.Sprintf(`UPDATE %v SET last_login = %v WHERE username = %v`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1])
 	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 {
 func getUpdateAPIKeyLastUseQuery() string {
 	return fmt.Sprintf(`UPDATE %v SET last_use_at = %v WHERE key_id = %v`, sqlTableAPIKeys, sqlPlaceholders[0], sqlPlaceholders[1])
 	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 {
 func getAddUserQuery() string {
 	return fmt.Sprintf(`INSERT INTO %v (username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,
 	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,
 		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[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7],
 		sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13],
 		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 {
 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,
 	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,
 		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[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9],
 		sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], sqlPlaceholders[14], sqlPlaceholders[15],
 		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 {
 func getDeleteUserQuery() string {

+ 2 - 0
dataprovider/user.go

@@ -1037,6 +1037,8 @@ func (u *User) getACopy() User {
 			Filters:           filters,
 			Filters:           filters,
 			AdditionalInfo:    u.AdditionalInfo,
 			AdditionalInfo:    u.AdditionalInfo,
 			Description:       u.Description,
 			Description:       u.Description,
+			CreatedAt:         u.CreatedAt,
+			UpdatedAt:         u.UpdatedAt,
 		},
 		},
 		VirtualFolders: virtualFolders,
 		VirtualFolders: virtualFolders,
 		FsConfig:       u.FsConfig.GetACopy(),
 		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:
 Starting a SFTPGo instance is simple:
 
 
 ```shell
 ```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.
 ... 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:
 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
 ```shell
 docker run --name some-sftpgo \
 docker run --name some-sftpgo \
-    -p 127.0.0.1:8080:8090 \
+    -p 8080:8090 \
     -p 2022:2022 \
     -p 2022:2022 \
     --mount type=bind,source=/my/own/sftpgodata,target=/srv/sftpgo \
     --mount type=bind,source=/my/own/sftpgodata,target=/srv/sftpgo \
     --mount type=bind,source=/my/own/sftpgohome,target=/var/lib/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
 ```shell
 docker run --name some-sftpgo \
 docker run --name some-sftpgo \
     --user 1100:1100 \
     --user 1100:1100 \
-    -p 127.0.0.1:8080:8080 \
+    -p 8080:8080 \
     -p 2022:2022 \
     -p 2022:2022 \
     --mount type=bind,source="${PWD}/data",target=/srv/sftpgo \
     --mount type=bind,source="${PWD}/data",target=/srv/sftpgo \
     --mount type=bind,source="${PWD}/config",target=/var/lib/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",
 	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)
 		user.ID, user.Username, user.HomeDir, ipAddr)
-	dataprovider.UpdateLastLogin(&user) //nolint:errcheck
+	dataprovider.UpdateLastLogin(&user)
 	return connection, nil
 	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",
 					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)
 						dbUser.ID, dbUser.Username, dbUser.HomeDir, ipAddr)
-					dataprovider.UpdateLastLogin(&dbUser) //nolint:errcheck
+					dataprovider.UpdateLastLogin(&dbUser)
 					return connection, nil
 					return connection, nil
 				}
 				}
 			}
 			}

+ 5 - 5
go.mod

@@ -7,7 +7,7 @@ require (
 	github.com/Azure/azure-storage-blob-go v0.14.0
 	github.com/Azure/azure-storage-blob-go v0.14.0
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/alexedwards/argon2id v0.0.0-20210511081203-7d35d68092b8
 	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/cockroachdb/cockroach-go/v2 v2.1.1
 	github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b
 	github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b
 	github.com/fatih/color v1.12.0 // indirect
 	github.com/fatih/color v1.12.0 // indirect
@@ -61,17 +61,17 @@ require (
 	gocloud.dev v0.23.0
 	gocloud.dev v0.23.0
 	golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a
 	golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a
 	golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d
 	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
 	golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac
 	google.golang.org/api v0.54.0
 	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/grpc v1.40.0
 	google.golang.org/protobuf v1.27.1
 	google.golang.org/protobuf v1.27.1
 	gopkg.in/natefinch/lumberjack.v2 v2.0.0
 	gopkg.in/natefinch/lumberjack.v2 v2.0.0
 )
 )
 
 
 require (
 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
 	cloud.google.com/go/kms v0.1.0 // indirect
 	github.com/Azure/azure-pipeline-go v0.2.3 // indirect
 	github.com/Azure/azure-pipeline-go v0.2.3 // indirect
 	github.com/StackExchange/wmi v1.2.1 // 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/cpuguy83/go-md2man/v2 v2.0.1 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/decred/dcrd/dcrec/secp256k1/v3 v3.0.0 // 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/go-ole/go-ole v1.2.5 // indirect
 	github.com/goccy/go-json v0.7.6 // indirect
 	github.com/goccy/go-json v0.7.6 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // 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.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.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.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.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.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
 cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
 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.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.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.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 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/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=
 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/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/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.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.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/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-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=
 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-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-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-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 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 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=
 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-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-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-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.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.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)
 	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) {
 func TestHTTPUserAuthentication(t *testing.T) {
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -757,6 +830,26 @@ func TestAdminInvalidCredentials(t *testing.T) {
 	assert.Equal(t, dataprovider.ErrInvalidCredentials.Error(), responseHolder["error"].(string))
 	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) {
 func TestAdminAllowList(t *testing.T) {
 	a := getTestAdmin()
 	a := getTestAdmin()
 	a.Username = altAdminUsername
 	a.Username = altAdminUsername
@@ -1361,7 +1454,7 @@ func TestUpdateUserEmptyPassword(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	userNoPwd, _, err := httpdtest.UpdateUserWithJSON(user, http.StatusOK, "", asJSON)
 	userNoPwd, _, err := httpdtest.UpdateUserWithJSON(user, http.StatusOK, "", asJSON)
 	assert.NoError(t, err)
 	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
 	// check the password within the data provider
 	dbUser, err = dataprovider.UserExists(u.Username)
 	dbUser, err = dataprovider.UserExists(u.Username)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -1705,6 +1798,7 @@ func TestUserS3Config(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	user.Password = defaultPassword
 	user.Password = defaultPassword
 	user.ID = 0
 	user.ID = 0
+	user.CreatedAt = 0
 	user.VirtualFolders = nil
 	user.VirtualFolders = nil
 	secret := kms.NewSecret(kms.SecretStatusSecretBox, "Server-Access-Secret", "", "")
 	secret := kms.NewSecret(kms.SecretStatusSecretBox, "Server-Access-Secret", "", "")
 	user.FsConfig.S3Config.AccessSecret = secret
 	user.FsConfig.S3Config.AccessSecret = secret
@@ -1749,6 +1843,7 @@ func TestUserS3Config(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	user.Password = defaultPassword
 	user.Password = defaultPassword
 	user.ID = 0
 	user.ID = 0
+	user.CreatedAt = 0
 	// shared credential test for add instead of update
 	// shared credential test for add instead of update
 	user, _, err = httpdtest.AddUser(user, http.StatusCreated)
 	user, _, err = httpdtest.AddUser(user, http.StatusCreated)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -1795,6 +1890,7 @@ func TestUserGCSConfig(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	user.Password = defaultPassword
 	user.Password = defaultPassword
 	user.ID = 0
 	user.ID = 0
+	user.CreatedAt = 0
 	user.FsConfig.GCSConfig.Credentials = kms.NewSecret(kms.SecretStatusSecretBox, "fake credentials", "", "")
 	user.FsConfig.GCSConfig.Credentials = kms.NewSecret(kms.SecretStatusSecretBox, "fake credentials", "", "")
 	_, _, err = httpdtest.AddUser(user, http.StatusCreated)
 	_, _, err = httpdtest.AddUser(user, http.StatusCreated)
 	assert.Error(t, err)
 	assert.Error(t, err)
@@ -1861,6 +1957,7 @@ func TestUserAzureBlobConfig(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	user.Password = defaultPassword
 	user.Password = defaultPassword
 	user.ID = 0
 	user.ID = 0
+	user.CreatedAt = 0
 	secret := kms.NewSecret(kms.SecretStatusSecretBox, "Server-Account-Key", "", "")
 	secret := kms.NewSecret(kms.SecretStatusSecretBox, "Server-Account-Key", "", "")
 	user.FsConfig.AzBlobConfig.AccountKey = secret
 	user.FsConfig.AzBlobConfig.AccountKey = secret
 	_, _, err = httpdtest.AddUser(user, http.StatusCreated)
 	_, _, err = httpdtest.AddUser(user, http.StatusCreated)
@@ -1901,6 +1998,7 @@ func TestUserAzureBlobConfig(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	user.Password = defaultPassword
 	user.Password = defaultPassword
 	user.ID = 0
 	user.ID = 0
+	user.CreatedAt = 0
 	// sas test for add instead of update
 	// sas test for add instead of update
 	user.FsConfig.AzBlobConfig = vfs.AzBlobFsConfig{
 	user.FsConfig.AzBlobConfig = vfs.AzBlobFsConfig{
 		AzBlobFsConfig: sdk.AzBlobFsConfig{
 		AzBlobFsConfig: sdk.AzBlobFsConfig{
@@ -1956,6 +2054,7 @@ func TestUserCryptFs(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	user.Password = defaultPassword
 	user.Password = defaultPassword
 	user.ID = 0
 	user.ID = 0
+	user.CreatedAt = 0
 	secret := kms.NewSecret(kms.SecretStatusSecretBox, "invalid encrypted payload", "", "")
 	secret := kms.NewSecret(kms.SecretStatusSecretBox, "invalid encrypted payload", "", "")
 	user.FsConfig.CryptConfig.Passphrase = secret
 	user.FsConfig.CryptConfig.Passphrase = secret
 	_, _, err = httpdtest.AddUser(user, http.StatusCreated)
 	_, _, err = httpdtest.AddUser(user, http.StatusCreated)
@@ -2036,6 +2135,7 @@ func TestUserSFTPFs(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	user.Password = defaultPassword
 	user.Password = defaultPassword
 	user.ID = 0
 	user.ID = 0
+	user.CreatedAt = 0
 	secret := kms.NewSecret(kms.SecretStatusSecretBox, "invalid encrypted payload", "", "")
 	secret := kms.NewSecret(kms.SecretStatusSecretBox, "invalid encrypted payload", "", "")
 	user.FsConfig.SFTPConfig.Password = secret
 	user.FsConfig.SFTPConfig.Password = secret
 	_, _, err = httpdtest.AddUser(user, http.StatusCreated)
 	_, _, err = httpdtest.AddUser(user, http.StatusCreated)
@@ -4120,6 +4220,69 @@ func TestUpdateAdminMock(t *testing.T) {
 	checkResponseCode(t, http.StatusOK, rr)
 	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) {
 func TestAdminHandlingWithAPIKeys(t *testing.T) {
 	sysAdmin, _, err := httpdtest.GetAdminByUsername(defaultTokenAuthUser, http.StatusOK)
 	sysAdmin, _, err := httpdtest.GetAdminByUsername(defaultTokenAuthUser, http.StatusOK)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -4260,6 +4423,8 @@ func TestUserHandlingWithAPIKey(t *testing.T) {
 	setAPIKeyForReq(req, apiKey.Key, "")
 	setAPIKeyForReq(req, apiKey.Key, "")
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 	checkResponseCode(t, http.StatusOK, rr)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
 
 
 	_, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
 	_, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -4506,6 +4671,7 @@ func TestUpdateUserInvalidParamsMock(t *testing.T) {
 	checkResponseCode(t, http.StatusBadRequest, rr)
 	checkResponseCode(t, http.StatusBadRequest, rr)
 	userID := user.ID
 	userID := user.ID
 	user.ID = 0
 	user.ID = 0
+	user.CreatedAt = 0
 	userAsJSON = getUserAsJSON(t, user)
 	userAsJSON = getUserAsJSON(t, user)
 	req, _ = http.NewRequest(http.MethodPut, path.Join(userPath, user.Username), bytes.NewBuffer(userAsJSON))
 	req, _ = http.NewRequest(http.MethodPut, path.Join(userPath, user.Username), bytes.NewBuffer(userAsJSON))
 	setBearerForReq(req, token)
 	setBearerForReq(req, token)

+ 2 - 2
httpd/middleware.go

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

+ 20 - 0
httpd/schema/openapi.yaml

@@ -2969,6 +2969,14 @@ components:
           type: integer
           type: integer
           format: int32
           format: int32
           description: 'Maximum download bandwidth as KB/s, 0 means unlimited'
           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:
         last_login:
           type: integer
           type: integer
           format: int64
           format: int64
@@ -3032,6 +3040,18 @@ components:
         additional_info:
         additional_info:
           type: string
           type: string
           description: Free form text field
           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:
     APIKey:
       type: object
       type: object
       properties:
       properties:

+ 4 - 2
httpd/server.go

@@ -197,7 +197,7 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re
 		return
 		return
 	}
 	}
 	updateLoginMetrics(&user, ipAddr, err)
 	updateLoginMetrics(&user, ipAddr, err)
-	dataprovider.UpdateLastLogin(&user) //nolint:errcheck
+	dataprovider.UpdateLastLogin(&user)
 	http.Redirect(w, r, webClientFilesPath, http.StatusFound)
 	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)
 	http.Redirect(w, r, webUsersPath, http.StatusFound)
+	dataprovider.UpdateAdminLastLogin(admin)
 }
 }
 
 
 func (s *httpdServer) logout(w http.ResponseWriter, r *http.Request) {
 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
 		return
 	}
 	}
 	updateLoginMetrics(&user, ipAddr, err)
 	updateLoginMetrics(&user, ipAddr, err)
-	dataprovider.UpdateLastLogin(&user) //nolint:errcheck
+	dataprovider.UpdateLastLogin(&user)
 
 
 	render.JSON(w, r, resp)
 	render.JSON(w, r, resp)
 }
 }
@@ -413,6 +414,7 @@ func (s *httpdServer) generateAndSendToken(w http.ResponseWriter, r *http.Reques
 		return
 		return
 	}
 	}
 
 
+	dataprovider.UpdateAdminLastLogin(&admin)
 	render.JSON(w, r, resp)
 	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")
 			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 {
 	if err := compareAdminEqualFields(expected, actual); err != nil {
 		return err
 		return err
 	}
 	}
@@ -1116,6 +1121,11 @@ func checkUser(expected *dataprovider.User, actual *dataprovider.User) error {
 			return errors.New("user ID mismatch")
 			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) {
 	if len(expected.Permissions) != len(actual.Permissions) {
 		return errors.New("permissions mismatch")
 		return errors.New("permissions mismatch")
 	}
 	}

+ 1 - 0
logger/journald.go

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

+ 1 - 0
logger/journald_nolinux.go

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

+ 1 - 0
metric/metric.go

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

+ 1 - 0
metric/metric_disabled.go

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

+ 4 - 0
sdk/user.go

@@ -166,6 +166,10 @@ type BaseUser struct {
 	DownloadBandwidth int64 `json:"download_bandwidth"`
 	DownloadBandwidth int64 `json:"download_bandwidth"`
 	// Last login as unix timestamp in milliseconds
 	// Last login as unix timestamp in milliseconds
 	LastLogin int64 `json:"last_login"`
 	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
 	// Additional restrictions
 	Filters UserFilters `json:"filters"`
 	Filters UserFilters `json:"filters"`
 	// optional description, for example full name
 	// 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 {
 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 {
 	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 {
 	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 {
 	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 {
 	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
 	return nil
 }
 }

+ 1 - 0
service/service_portable.go

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

+ 1 - 0
service/signals_unix.go

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

+ 1 - 0
sftpd/cmd_unix.go

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

+ 1 - 0
sftpd/internal_unix_test.go

@@ -1,3 +1,4 @@
+//go:build !windows
 // +build !windows
 // +build !windows
 
 
 package sftpd
 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,
 	logger.Log(logger.LevelDebug, common.ProtocolSSH, connectionID,
 		"User %#v, logged in with: %#v, from ip: %#v, client version %#v",
 		"User %#v, logged in with: %#v, from ip: %#v, client version %#v",
 		user.Username, loginType, ipAddr, string(sconn.ClientVersion()))
 		user.Username, loginType, ipAddr, string(sconn.ClientVersion()))
-	dataprovider.UpdateLastLogin(&user) //nolint:errcheck
+	dataprovider.UpdateLastLogin(&user)
 
 
 	sshConnection := common.NewSSHConnection(connectionID, conn)
 	sshConnection := common.NewSSHConnection(connectionID, conn)
 	common.Connections.AddSSHConnection(sshConnection)
 	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)
 		logger.Warn(logSender, connectionID, "unable to check fs root: %v close fs error: %v", err, errClose)
 		return err
 		return err
 	}
 	}
-	dataprovider.UpdateLastLogin(user) //nolint:errcheck
+	dataprovider.UpdateLastLogin(user)
 
 
 	connection := &Connection{
 	connection := &Connection{
 		BaseConnection: common.NewBaseConnection(connectionID, common.ProtocolSFTP, "", "", *user),
 		BaseConnection: common.NewBaseConnection(connectionID, common.ProtocolSFTP, "", "", *user),

+ 1 - 0
vfs/azblobfs.go

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

+ 1 - 0
vfs/azblobfs_disabled.go

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

+ 1 - 0
vfs/gcsfs.go

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

+ 1 - 0
vfs/gcsfs_disabled.go

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

+ 1 - 0
vfs/s3fs.go

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

+ 1 - 0
vfs/s3fs_disabled.go

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

+ 1 - 0
vfs/statvfs_fallback.go

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

+ 1 - 0
vfs/statvfs_linux.go

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

+ 1 - 0
vfs/statvfs_unix.go

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

+ 1 - 0
vfs/sys_unix.go

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

+ 13 - 1
webdavd/internal_test.go

@@ -1118,10 +1118,22 @@ func TestCachedUserWithFolders(t *testing.T) {
 
 
 	folder, err := dataprovider.GetFolderByName(folderName)
 	folder, err := dataprovider.GetFolderByName(folderName)
 	assert.NoError(t, err)
 	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)
 	err = dataprovider.UpdateFolder(&folder, folder.Users)
 	assert.NoError(t, err)
 	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)
 	_, isCached, _, loginMethod, err = server.authenticate(req, ipAddr)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	assert.False(t, isCached)
 	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)
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 	defer common.Connections.Remove(connection.GetID())
 
 
-	dataprovider.UpdateLastLogin(&user) //nolint:errcheck
+	dataprovider.UpdateLastLogin(&user)
 
 
 	if s.checkRequestMethod(ctx, r, connection) {
 	if s.checkRequestMethod(ctx, r, connection) {
 		w.Header().Set("Content-Type", "text/xml; charset=utf-8")
 		w.Header().Set("Content-Type", "text/xml; charset=utf-8")