diff --git a/cmd/portable.go b/cmd/portable.go index bacc8f33..6d2a76f0 100644 --- a/cmd/portable.go +++ b/cmd/portable.go @@ -1,3 +1,4 @@ +//go:build !noportable // +build !noportable package cmd diff --git a/cmd/portable_disabled.go b/cmd/portable_disabled.go index c4a378c5..f9d9aa55 100644 --- a/cmd/portable_disabled.go +++ b/cmd/portable_disabled.go @@ -1,3 +1,4 @@ +//go:build noportable // +build noportable package cmd diff --git a/config/config_linux.go b/config/config_linux.go index 967c2122..7b84f4c9 100644 --- a/config/config_linux.go +++ b/config/config_linux.go @@ -1,3 +1,4 @@ +//go:build linux // +build linux package config diff --git a/config/config_nolinux.go b/config/config_nolinux.go index fe5d6aeb..a10c465f 100644 --- a/config/config_nolinux.go +++ b/config/config_nolinux.go @@ -1,3 +1,4 @@ +//go:build !linux // +build !linux package config diff --git a/dataprovider/admin.go b/dataprovider/admin.go index 9e747717..d6a43d02 100644 --- a/dataprovider/admin.go +++ b/dataprovider/admin.go @@ -68,6 +68,12 @@ type Admin struct { Filters AdminFilters `json:"filters,omitempty"` Description string `json:"description,omitempty"` AdditionalInfo string `json:"additional_info,omitempty"` + // Creation time as unix timestamp in milliseconds. It will be 0 for admins created before v2.2.0 + CreatedAt int64 `json:"created_at"` + // last update time as unix timestamp in milliseconds + UpdatedAt int64 `json:"updated_at"` + // Last login as unix timestamp in milliseconds + LastLogin int64 `json:"last_login"` } func (a *Admin) checkPassword() error { @@ -260,6 +266,9 @@ func (a *Admin) getACopy() Admin { Filters: filters, AdditionalInfo: a.AdditionalInfo, Description: a.Description, + LastLogin: a.LastLogin, + CreatedAt: a.CreatedAt, + UpdatedAt: a.UpdatedAt, } } diff --git a/dataprovider/apikey.go b/dataprovider/apikey.go index 78dd599f..e5ff1ecf 100644 --- a/dataprovider/apikey.go +++ b/dataprovider/apikey.go @@ -125,9 +125,6 @@ func (k *APIKey) validate() error { if err := k.checkKey(); err != nil { return err } - if k.CreatedAt == 0 { - k.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) - } if k.User != "" && k.Admin != "" { return util.NewValidationError("an API key can be related to a user or an admin, not both") } diff --git a/dataprovider/bolt.go b/dataprovider/bolt.go index a4a7508b..11cbfaf7 100644 --- a/dataprovider/bolt.go +++ b/dataprovider/bolt.go @@ -1,3 +1,4 @@ +//go:build !nobolt // +build !nobolt package dataprovider @@ -19,7 +20,7 @@ import ( ) const ( - boltDatabaseVersion = 11 + boltDatabaseVersion = 12 ) var ( @@ -191,6 +192,36 @@ func (p *BoltProvider) updateAPIKeyLastUse(keyID string) error { }) } +func (p *BoltProvider) setUpdatedAt(username string) { + p.dbHandle.Update(func(tx *bolt.Tx) error { //nolint:errcheck + bucket, err := getUsersBucket(tx) + if err != nil { + return err + } + var u []byte + if u = bucket.Get([]byte(username)); u == nil { + return util.NewRecordNotFoundError(fmt.Sprintf("username %#v does not exist, unable to update updated at", username)) + } + var user User + err = json.Unmarshal(u, &user) + if err != nil { + return err + } + user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) + buf, err := json.Marshal(user) + if err != nil { + return err + } + err = bucket.Put([]byte(username), buf) + if err == nil { + providerLog(logger.LevelDebug, "updated at set for user %#v", username) + } else { + providerLog(logger.LevelWarn, "error setting updated_at for user %#v: %v", username, err) + } + return err + }) +} + func (p *BoltProvider) updateLastLogin(username string) error { return p.dbHandle.Update(func(tx *bolt.Tx) error { bucket, err := getUsersBucket(tx) @@ -221,6 +252,36 @@ func (p *BoltProvider) updateLastLogin(username string) error { }) } +func (p *BoltProvider) updateAdminLastLogin(username string) error { + return p.dbHandle.Update(func(tx *bolt.Tx) error { + bucket, err := getAdminsBucket(tx) + if err != nil { + return err + } + var a []byte + if a = bucket.Get([]byte(username)); a == nil { + return util.NewRecordNotFoundError(fmt.Sprintf("admin %#v does not exist, unable to update last login", username)) + } + var admin Admin + err = json.Unmarshal(a, &admin) + if err != nil { + return err + } + admin.LastLogin = util.GetTimeAsMsSinceEpoch(time.Now()) + buf, err := json.Marshal(admin) + if err != nil { + return err + } + err = bucket.Put([]byte(username), buf) + if err == nil { + providerLog(logger.LevelDebug, "last login updated for admin %#v", username) + return err + } + providerLog(logger.LevelWarn, "error updating last login for admin %#v: %v", username, err) + return err + }) +} + func (p *BoltProvider) updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error { return p.dbHandle.Update(func(tx *bolt.Tx) error { bucket, err := getUsersBucket(tx) @@ -300,6 +361,9 @@ func (p *BoltProvider) addAdmin(admin *Admin) error { return err } admin.ID = int64(id) + admin.LastLogin = 0 + admin.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) + admin.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) buf, err := json.Marshal(admin) if err != nil { return err @@ -330,6 +394,9 @@ func (p *BoltProvider) updateAdmin(admin *Admin) error { } admin.ID = oldAdmin.ID + admin.CreatedAt = oldAdmin.CreatedAt + admin.LastLogin = oldAdmin.LastLogin + admin.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) buf, err := json.Marshal(admin) if err != nil { return err @@ -478,6 +545,8 @@ func (p *BoltProvider) addUser(user *User) error { user.UsedQuotaSize = 0 user.UsedQuotaFiles = 0 user.LastLogin = 0 + user.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) + user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) for idx := range user.VirtualFolders { err = addUserToFolderMapping(&user.VirtualFolders[idx].BaseVirtualFolder, user, folderBucket) if err != nil { @@ -532,6 +601,8 @@ func (p *BoltProvider) updateUser(user *User) error { user.UsedQuotaSize = oldUser.UsedQuotaSize user.UsedQuotaFiles = oldUser.UsedQuotaFiles user.LastLogin = oldUser.LastLogin + user.CreatedAt = oldUser.CreatedAt + user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) buf, err := json.Marshal(user) if err != nil { return err @@ -916,7 +987,9 @@ func (p *BoltProvider) addAPIKey(apiKey *APIKey) error { return err } apiKey.ID = int64(id) + apiKey.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) apiKey.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) + apiKey.LastUseAt = 0 buf, err := json.Marshal(apiKey) if err != nil { return err @@ -1077,7 +1150,9 @@ func (p *BoltProvider) migrateDatabase() error { logger.ErrorToConsole("%v", err) return err case version == 10: - return updateBoltDatabaseVersion(p.dbHandle, 11) + return updateBoltDatabaseVersion(p.dbHandle, 12) + case version == 11: + return updateBoltDatabaseVersion(p.dbHandle, 12) default: if version > boltDatabaseVersion { providerLog(logger.LevelWarn, "database version %v is newer than the supported one: %v", version, @@ -1099,6 +1174,8 @@ func (p *BoltProvider) revertDatabase(targetVersion int) error { return errors.New("current version match target version, nothing to do") } switch dbVersion.Version { + case 12: + return updateBoltDatabaseVersion(p.dbHandle, 10) case 11: return updateBoltDatabaseVersion(p.dbHandle, 10) default: diff --git a/dataprovider/bolt_disabled.go b/dataprovider/bolt_disabled.go index 32743675..43cef589 100644 --- a/dataprovider/bolt_disabled.go +++ b/dataprovider/bolt_disabled.go @@ -1,3 +1,4 @@ +//go:build nobolt // +build nobolt package dataprovider diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index d288a978..74a4ece6 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -392,6 +392,8 @@ type Provider interface { getUsers(limit int, offset int, order string) ([]User, error) dumpUsers() ([]User, error) updateLastLogin(username string) error + updateAdminLastLogin(username string) error + setUpdatedAt(username string) getFolders(limit, offset int, order string) ([]vfs.BaseVirtualFolder, error) getFolderByName(name string) (vfs.BaseVirtualFolder, error) addFolder(folder *vfs.BaseVirtualFolder) error @@ -813,7 +815,7 @@ func UpdateAPIKeyLastUse(apiKey *APIKey) error { } // UpdateLastLogin updates the last login field for the given SFTPGo user -func UpdateLastLogin(user *User) error { +func UpdateLastLogin(user *User) { lastLogin := util.GetTimeFromMsecSinceEpoch(user.LastLogin) diff := -time.Until(lastLogin) if diff < 0 || diff > lastLoginMinDelay { @@ -821,9 +823,16 @@ func UpdateLastLogin(user *User) error { if err == nil { webDAVUsersCache.updateLastLogin(user.Username) } - return err } - return nil +} + +// UpdateAdminLastLogin updates the last login field for the given SFTPGo admin +func UpdateAdminLastLogin(admin *Admin) { + lastLogin := util.GetTimeFromMsecSinceEpoch(admin.LastLogin) + diff := -time.Until(lastLogin) + if diff < 0 || diff > lastLoginMinDelay { + provider.updateAdminLastLogin(admin.Username) //nolint:errcheck + } } // UpdateUserQuota updates the quota for the given SFTP user adding filesAdd and sizeAdd. @@ -1026,7 +1035,14 @@ func UpdateFolder(folder *vfs.BaseVirtualFolder, users []string) error { err := provider.updateFolder(folder) if err == nil { for _, user := range users { - RemoveCachedWebDAVUser(user) + provider.setUpdatedAt(user) + u, err := provider.userExists(user) + if err == nil { + webDAVUsersCache.swap(&u) + executeAction(operationUpdate, &u) + } else { + RemoveCachedWebDAVUser(user) + } } } return err @@ -1041,6 +1057,7 @@ func DeleteFolder(folderName string) error { err = provider.deleteFolder(&folder) if err == nil { for _, user := range folder.Users { + provider.setUpdatedAt(user) RemoveCachedWebDAVUser(user) } delayedQuotaUpdater.resetFolderQuota(folderName) @@ -2252,6 +2269,7 @@ func executePreLoginHook(username, loginMethod, ip, protocol string) (User, erro userUsedQuotaFiles := u.UsedQuotaFiles userLastQuotaUpdate := u.LastQuotaUpdate userLastLogin := u.LastLogin + userCreatedAt := u.CreatedAt err = json.Unmarshal(out, &u) if err != nil { return u, fmt.Errorf("invalid pre-login hook response %#v, error: %v", string(out), err) @@ -2261,9 +2279,11 @@ func executePreLoginHook(username, loginMethod, ip, protocol string) (User, erro u.UsedQuotaFiles = userUsedQuotaFiles u.LastQuotaUpdate = userLastQuotaUpdate u.LastLogin = userLastLogin + u.CreatedAt = userCreatedAt if userID == 0 { err = provider.addUser(&u) } else { + u.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) err = provider.updateUser(&u) if err == nil { webDAVUsersCache.swap(&u) @@ -2464,6 +2484,8 @@ func doExternalAuth(username, password string, pubKey []byte, keyboardInteractiv user.UsedQuotaFiles = u.UsedQuotaFiles user.LastQuotaUpdate = u.LastQuotaUpdate user.LastLogin = u.LastLogin + user.CreatedAt = u.CreatedAt + user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) err = provider.updateUser(&user) if err == nil { webDAVUsersCache.swap(&user) diff --git a/dataprovider/memory.go b/dataprovider/memory.go index fef8bdd3..b949a906 100644 --- a/dataprovider/memory.go +++ b/dataprovider/memory.go @@ -158,6 +158,20 @@ func (p *MemoryProvider) updateAPIKeyLastUse(keyID string) error { return nil } +func (p *MemoryProvider) setUpdatedAt(username string) { + p.dbHandle.Lock() + defer p.dbHandle.Unlock() + if p.dbHandle.isClosed { + return + } + user, err := p.userExistsInternal(username) + if err != nil { + return + } + user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) + p.dbHandle.users[user.Username] = user +} + func (p *MemoryProvider) updateLastLogin(username string) error { p.dbHandle.Lock() defer p.dbHandle.Unlock() @@ -173,6 +187,21 @@ func (p *MemoryProvider) updateLastLogin(username string) error { return nil } +func (p *MemoryProvider) updateAdminLastLogin(username string) error { + p.dbHandle.Lock() + defer p.dbHandle.Unlock() + if p.dbHandle.isClosed { + return errMemoryProviderClosed + } + admin, err := p.adminExistsInternal(username) + if err != nil { + return err + } + admin.LastLogin = util.GetTimeAsMsSinceEpoch(time.Now()) + p.dbHandle.admins[admin.Username] = admin + return nil +} + func (p *MemoryProvider) updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error { p.dbHandle.Lock() defer p.dbHandle.Unlock() @@ -235,6 +264,8 @@ func (p *MemoryProvider) addUser(user *User) error { user.UsedQuotaSize = 0 user.UsedQuotaFiles = 0 user.LastLogin = 0 + user.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) + user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) user.VirtualFolders = p.joinVirtualFoldersFields(user) p.dbHandle.users[user.Username] = user.getACopy() p.dbHandle.usernames = append(p.dbHandle.usernames, user.Username) @@ -268,6 +299,8 @@ func (p *MemoryProvider) updateUser(user *User) error { user.UsedQuotaSize = u.UsedQuotaSize user.UsedQuotaFiles = u.UsedQuotaFiles user.LastLogin = u.LastLogin + user.CreatedAt = u.CreatedAt + user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) user.ID = u.ID // pre-login and external auth hook will use the passed *user so save a copy p.dbHandle.users[user.Username] = user.getACopy() @@ -407,6 +440,9 @@ func (p *MemoryProvider) addAdmin(admin *Admin) error { return fmt.Errorf("admin %#v already exists", admin.Username) } admin.ID = p.getNextAdminID() + admin.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) + admin.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) + admin.LastLogin = 0 p.dbHandle.admins[admin.Username] = admin.getACopy() p.dbHandle.adminsUsernames = append(p.dbHandle.adminsUsernames, admin.Username) sort.Strings(p.dbHandle.adminsUsernames) @@ -428,6 +464,9 @@ func (p *MemoryProvider) updateAdmin(admin *Admin) error { return err } admin.ID = a.ID + admin.CreatedAt = a.CreatedAt + admin.LastLogin = a.LastLogin + admin.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) p.dbHandle.admins[admin.Username] = admin.getACopy() return nil } @@ -825,7 +864,9 @@ func (p *MemoryProvider) addAPIKey(apiKey *APIKey) error { if err == nil { return fmt.Errorf("API key %#v already exists", apiKey.KeyID) } + apiKey.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) apiKey.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) + apiKey.LastUseAt = 0 p.dbHandle.apiKeys[apiKey.KeyID] = apiKey.getACopy() p.dbHandle.apiKeysIDs = append(p.dbHandle.apiKeysIDs, apiKey.KeyID) sort.Strings(p.dbHandle.apiKeysIDs) @@ -1041,23 +1082,52 @@ func (p *MemoryProvider) reloadConfig() error { return err } + if err := p.restoreAPIKeys(&dump); err != nil { + return err + } + providerLog(logger.LevelDebug, "config loaded from file: %#v", p.dbHandle.configFile) return nil } +func (p *MemoryProvider) restoreAPIKeys(dump *BackupData) error { + for _, apiKey := range dump.APIKeys { + if apiKey.KeyID == "" { + return fmt.Errorf("cannot restore an empty API key: %+v", apiKey) + } + k, err := p.apiKeyExists(apiKey.KeyID) + apiKey := apiKey // pin + if err == nil { + apiKey.ID = k.ID + err = UpdateAPIKey(&apiKey) + if err != nil { + providerLog(logger.LevelWarn, "error updating API key %#v: %v", apiKey.KeyID, err) + return err + } + } else { + err = AddAPIKey(&apiKey) + if err != nil { + providerLog(logger.LevelWarn, "error adding API key %#v: %v", apiKey.KeyID, err) + return err + } + } + } + return nil +} + func (p *MemoryProvider) restoreAdmins(dump *BackupData) error { for _, admin := range dump.Admins { a, err := p.adminExists(admin.Username) admin := admin // pin if err == nil { admin.ID = a.ID - err = p.updateAdmin(&admin) + err = UpdateAdmin(&admin) if err != nil { providerLog(logger.LevelWarn, "error updating admin %#v: %v", admin.Username, err) return err } } else { - err = p.addAdmin(&admin) + err = AddAdmin(&admin) if err != nil { providerLog(logger.LevelWarn, "error adding admin %#v: %v", admin.Username, err) return err @@ -1073,14 +1143,14 @@ func (p *MemoryProvider) restoreFolders(dump *BackupData) error { f, err := p.getFolderByName(folder.Name) if err == nil { folder.ID = f.ID - err = p.updateFolder(&folder) + err = UpdateFolder(&folder, f.Users) if err != nil { providerLog(logger.LevelWarn, "error updating folder %#v: %v", folder.Name, err) return err } } else { folder.Users = nil - err = p.addFolder(&folder) + err = AddFolder(&folder) if err != nil { providerLog(logger.LevelWarn, "error adding folder %#v: %v", folder.Name, err) return err @@ -1096,13 +1166,13 @@ func (p *MemoryProvider) restoreUsers(dump *BackupData) error { u, err := p.userExists(user.Username) if err == nil { user.ID = u.ID - err = p.updateUser(&user) + err = UpdateUser(&user) if err != nil { providerLog(logger.LevelWarn, "error updating user %#v: %v", user.Username, err) return err } } else { - err = p.addUser(&user) + err = AddUser(&user) if err != nil { providerLog(logger.LevelWarn, "error adding user %#v: %v", user.Username, err) return err diff --git a/dataprovider/mysql.go b/dataprovider/mysql.go index 859ca61e..8ecc035f 100644 --- a/dataprovider/mysql.go +++ b/dataprovider/mysql.go @@ -1,3 +1,4 @@ +//go:build !nomysql // +build !nomysql package dataprovider @@ -46,6 +47,22 @@ const ( "ALTER TABLE `{{api_keys}}` ADD CONSTRAINT `{{prefix}}api_keys_admin_id_fk_admins_id` FOREIGN KEY (`admin_id`) REFERENCES `{{admins}}` (`id`) ON DELETE CASCADE;" + "ALTER TABLE `{{api_keys}}` ADD CONSTRAINT `{{prefix}}api_keys_user_id_fk_users_id` FOREIGN KEY (`user_id`) REFERENCES `{{users}}` (`id`) ON DELETE CASCADE;" mysqlV11DownSQL = "DROP TABLE `{{api_keys}}` CASCADE;" + mysqlV12SQL = "ALTER TABLE `{{admins}}` ADD COLUMN `created_at` bigint DEFAULT 0 NOT NULL;" + + "ALTER TABLE `{{admins}}` ALTER COLUMN `created_at` DROP DEFAULT;" + + "ALTER TABLE `{{admins}}` ADD COLUMN `updated_at` bigint DEFAULT 0 NOT NULL;" + + "ALTER TABLE `{{admins}}` ALTER COLUMN `updated_at` DROP DEFAULT;" + + "ALTER TABLE `{{admins}}` ADD COLUMN `last_login` bigint DEFAULT 0 NOT NULL;" + + "ALTER TABLE `{{admins}}` ALTER COLUMN `last_login` DROP DEFAULT;" + + "ALTER TABLE `{{users}}` ADD COLUMN `created_at` bigint DEFAULT 0 NOT NULL;" + + "ALTER TABLE `{{users}}` ALTER COLUMN `created_at` DROP DEFAULT;" + + "ALTER TABLE `{{users}}` ADD COLUMN `updated_at` bigint DEFAULT 0 NOT NULL;" + + "ALTER TABLE `{{users}}` ALTER COLUMN `updated_at` DROP DEFAULT;" + + "CREATE INDEX `{{prefix}}users_updated_at_idx` ON `{{users}}` (`updated_at`);" + mysqlV12DownSQL = "ALTER TABLE `{{admins}}` DROP COLUMN `updated_at`;" + + "ALTER TABLE `{{admins}}` DROP COLUMN `created_at`;" + + "ALTER TABLE `{{admins}}` DROP COLUMN `last_login`;" + + "ALTER TABLE `{{users}}` DROP COLUMN `created_at`;" + + "ALTER TABLE `{{users}}` DROP COLUMN `updated_at`;" ) // MySQLProvider auth provider for MySQL/MariaDB database @@ -117,10 +134,18 @@ func (p *MySQLProvider) getUsedQuota(username string) (int, int64, error) { return sqlCommonGetUsedQuota(username, p.dbHandle) } +func (p *MySQLProvider) setUpdatedAt(username string) { + sqlCommonSetUpdatedAt(username, p.dbHandle) +} + func (p *MySQLProvider) updateLastLogin(username string) error { return sqlCommonUpdateLastLogin(username, p.dbHandle) } +func (p *MySQLProvider) updateAdminLastLogin(username string) error { + return sqlCommonUpdateAdminLastLogin(username, p.dbHandle) +} + func (p *MySQLProvider) userExists(username string) (User, error) { return sqlCommonGetUserByUsername(username, p.dbHandle) } @@ -276,6 +301,8 @@ func (p *MySQLProvider) migrateDatabase() error { return err case version == 10: return updateMySQLDatabaseFromV10(p.dbHandle) + case version == 11: + return updateMySQLDatabaseFromV11(p.dbHandle) default: if version > sqlDatabaseVersion { providerLog(logger.LevelWarn, "database version %v is newer than the supported one: %v", version, @@ -298,6 +325,8 @@ func (p *MySQLProvider) revertDatabase(targetVersion int) error { } switch dbVersion.Version { + case 12: + return downgradeMySQLDatabaseFromV12(p.dbHandle) case 11: return downgradeMySQLDatabaseFromV11(p.dbHandle) default: @@ -306,13 +335,45 @@ func (p *MySQLProvider) revertDatabase(targetVersion int) error { } func updateMySQLDatabaseFromV10(dbHandle *sql.DB) error { - return updateMySQLDatabaseFrom10To11(dbHandle) + if err := updateMySQLDatabaseFrom10To11(dbHandle); err != nil { + return err + } + return updateMySQLDatabaseFromV11(dbHandle) +} + +func updateMySQLDatabaseFromV11(dbHandle *sql.DB) error { + return updateMySQLDatabaseFrom11To12(dbHandle) +} + +func downgradeMySQLDatabaseFromV12(dbHandle *sql.DB) error { + if err := downgradeMySQLDatabaseFrom12To11(dbHandle); err != nil { + return err + } + return downgradeMySQLDatabaseFromV11(dbHandle) } func downgradeMySQLDatabaseFromV11(dbHandle *sql.DB) error { return downgradeMySQLDatabaseFrom11To10(dbHandle) } +func updateMySQLDatabaseFrom11To12(dbHandle *sql.DB) error { + logger.InfoToConsole("updating database version: 11 -> 12") + providerLog(logger.LevelInfo, "updating database version: 11 -> 12") + sql := strings.ReplaceAll(mysqlV12SQL, "{{users}}", sqlTableUsers) + sql = strings.ReplaceAll(sql, "{{admins}}", sqlTableAdmins) + sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix) + return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 12) +} + +func downgradeMySQLDatabaseFrom12To11(dbHandle *sql.DB) error { + logger.InfoToConsole("downgrading database version: 12 -> 11") + providerLog(logger.LevelInfo, "downgrading database version: 12 -> 11") + sql := strings.ReplaceAll(mysqlV12DownSQL, "{{users}}", sqlTableUsers) + sql = strings.ReplaceAll(sql, "{{admins}}", sqlTableAdmins) + sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix) + return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 11) +} + func updateMySQLDatabaseFrom10To11(dbHandle *sql.DB) error { logger.InfoToConsole("updating database version: 10 -> 11") providerLog(logger.LevelInfo, "updating database version: 10 -> 11") diff --git a/dataprovider/mysql_disabled.go b/dataprovider/mysql_disabled.go index 0c9772b3..1de5ef95 100644 --- a/dataprovider/mysql_disabled.go +++ b/dataprovider/mysql_disabled.go @@ -1,3 +1,4 @@ +//go:build nomysql // +build nomysql package dataprovider diff --git a/dataprovider/pgsql.go b/dataprovider/pgsql.go index 859cfa68..fbbf3da6 100644 --- a/dataprovider/pgsql.go +++ b/dataprovider/pgsql.go @@ -1,3 +1,4 @@ +//go:build !nopgsql // +build !nopgsql package dataprovider @@ -57,6 +58,24 @@ CREATE INDEX "{{prefix}}api_keys_admin_id_idx" ON "{{api_keys}}" ("admin_id"); CREATE INDEX "{{prefix}}api_keys_user_id_idx" ON "{{api_keys}}" ("user_id"); ` pgsqlV11DownSQL = `DROP TABLE "{{api_keys}}" CASCADE;` + pgsqlV12SQL = `ALTER TABLE "{{admins}}" ADD COLUMN "created_at" bigint DEFAULT 0 NOT NULL; +ALTER TABLE "{{admins}}" ALTER COLUMN "created_at" DROP DEFAULT; +ALTER TABLE "{{admins}}" ADD COLUMN "updated_at" bigint DEFAULT 0 NOT NULL; +ALTER TABLE "{{admins}}" ALTER COLUMN "updated_at" DROP DEFAULT; +ALTER TABLE "{{admins}}" ADD COLUMN "last_login" bigint DEFAULT 0 NOT NULL; +ALTER TABLE "{{admins}}" ALTER COLUMN "last_login" DROP DEFAULT; +ALTER TABLE "{{users}}" ADD COLUMN "created_at" bigint DEFAULT 0 NOT NULL; +ALTER TABLE "{{users}}" ALTER COLUMN "created_at" DROP DEFAULT; +ALTER TABLE "{{users}}" ADD COLUMN "updated_at" bigint DEFAULT 0 NOT NULL; +ALTER TABLE "{{users}}" ALTER COLUMN "updated_at" DROP DEFAULT; +CREATE INDEX "{{prefix}}users_updated_at_idx" ON "{{users}}" ("updated_at"); +` + pgsqlV12DownSQL = `ALTER TABLE "{{users}}" DROP COLUMN "updated_at" CASCADE; +ALTER TABLE "{{users}}" DROP COLUMN "created_at" CASCADE; +ALTER TABLE "{{admins}}" DROP COLUMN "created_at" CASCADE; +ALTER TABLE "{{admins}}" DROP COLUMN "updated_at" CASCADE; +ALTER TABLE "{{admins}}" DROP COLUMN "last_login" CASCADE; +` ) // PGSQLProvider auth provider for PostgreSQL database @@ -128,10 +147,18 @@ func (p *PGSQLProvider) getUsedQuota(username string) (int, int64, error) { return sqlCommonGetUsedQuota(username, p.dbHandle) } +func (p *PGSQLProvider) setUpdatedAt(username string) { + sqlCommonSetUpdatedAt(username, p.dbHandle) +} + func (p *PGSQLProvider) updateLastLogin(username string) error { return sqlCommonUpdateLastLogin(username, p.dbHandle) } +func (p *PGSQLProvider) updateAdminLastLogin(username string) error { + return sqlCommonUpdateAdminLastLogin(username, p.dbHandle) +} + func (p *PGSQLProvider) userExists(username string) (User, error) { return sqlCommonGetUserByUsername(username, p.dbHandle) } @@ -293,6 +320,8 @@ func (p *PGSQLProvider) migrateDatabase() error { return err case version == 10: return updatePGSQLDatabaseFromV10(p.dbHandle) + case version == 11: + return updatePGSQLDatabaseFromV11(p.dbHandle) default: if version > sqlDatabaseVersion { providerLog(logger.LevelWarn, "database version %v is newer than the supported one: %v", version, @@ -315,6 +344,8 @@ func (p *PGSQLProvider) revertDatabase(targetVersion int) error { } switch dbVersion.Version { + case 12: + return downgradePGSQLDatabaseFromV12(p.dbHandle) case 11: return downgradePGSQLDatabaseFromV11(p.dbHandle) default: @@ -323,13 +354,45 @@ func (p *PGSQLProvider) revertDatabase(targetVersion int) error { } func updatePGSQLDatabaseFromV10(dbHandle *sql.DB) error { - return updatePGSQLDatabaseFrom10To11(dbHandle) + if err := updatePGSQLDatabaseFrom10To11(dbHandle); err != nil { + return err + } + return updatePGSQLDatabaseFromV11(dbHandle) +} + +func updatePGSQLDatabaseFromV11(dbHandle *sql.DB) error { + return updatePGSQLDatabaseFrom11To12(dbHandle) +} + +func downgradePGSQLDatabaseFromV12(dbHandle *sql.DB) error { + if err := downgradePGSQLDatabaseFrom12To11(dbHandle); err != nil { + return err + } + return downgradePGSQLDatabaseFromV11(dbHandle) } func downgradePGSQLDatabaseFromV11(dbHandle *sql.DB) error { return downgradePGSQLDatabaseFrom11To10(dbHandle) } +func updatePGSQLDatabaseFrom11To12(dbHandle *sql.DB) error { + logger.InfoToConsole("updating database version: 11 -> 12") + providerLog(logger.LevelInfo, "updating database version: 11 -> 12") + sql := strings.ReplaceAll(pgsqlV12SQL, "{{users}}", sqlTableUsers) + sql = strings.ReplaceAll(sql, "{{admins}}", sqlTableAdmins) + sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix) + return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 12) +} + +func downgradePGSQLDatabaseFrom12To11(dbHandle *sql.DB) error { + logger.InfoToConsole("downgrading database version: 12 -> 11") + providerLog(logger.LevelInfo, "downgrading database version: 12 -> 11") + sql := strings.ReplaceAll(pgsqlV12DownSQL, "{{users}}", sqlTableUsers) + sql = strings.ReplaceAll(sql, "{{admins}}", sqlTableAdmins) + sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix) + return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 11) +} + func updatePGSQLDatabaseFrom10To11(dbHandle *sql.DB) error { logger.InfoToConsole("updating database version: 10 -> 11") providerLog(logger.LevelInfo, "updating database version: 10 -> 11") diff --git a/dataprovider/pgsql_disabled.go b/dataprovider/pgsql_disabled.go index 3e603dd0..ba5b26d6 100644 --- a/dataprovider/pgsql_disabled.go +++ b/dataprovider/pgsql_disabled.go @@ -1,3 +1,4 @@ +//go:build nopgsql // +build nopgsql package dataprovider diff --git a/dataprovider/sqlcommon.go b/dataprovider/sqlcommon.go index dc4593e1..f5f93243 100644 --- a/dataprovider/sqlcommon.go +++ b/dataprovider/sqlcommon.go @@ -19,7 +19,7 @@ import ( ) const ( - sqlDatabaseVersion = 11 + sqlDatabaseVersion = 12 defaultSQLQueryTimeout = 10 * time.Second longSQLQueryTimeout = 60 * time.Second ) @@ -75,7 +75,7 @@ func sqlCommonAddAPIKey(apiKey *APIKey, dbHandle *sql.DB) error { } defer stmt.Close() - _, err = stmt.ExecContext(ctx, apiKey.KeyID, apiKey.Name, apiKey.Key, apiKey.Scope, apiKey.CreatedAt, + _, err = stmt.ExecContext(ctx, apiKey.KeyID, apiKey.Name, apiKey.Key, apiKey.Scope, util.GetTimeAsMsSinceEpoch(time.Now()), util.GetTimeAsMsSinceEpoch(time.Now()), apiKey.LastUseAt, apiKey.ExpiresAt, apiKey.Description, userID, adminID) return err @@ -251,7 +251,8 @@ func sqlCommonAddAdmin(admin *Admin, dbHandle *sql.DB) error { } _, err = stmt.ExecContext(ctx, admin.Username, admin.Password, admin.Status, admin.Email, string(perms), - string(filters), admin.AdditionalInfo, admin.Description) + string(filters), admin.AdditionalInfo, admin.Description, util.GetTimeAsMsSinceEpoch(time.Now()), + util.GetTimeAsMsSinceEpoch(time.Now())) return err } @@ -282,7 +283,7 @@ func sqlCommonUpdateAdmin(admin *Admin, dbHandle *sql.DB) error { } _, err = stmt.ExecContext(ctx, admin.Password, admin.Status, admin.Email, string(perms), string(filters), - admin.AdditionalInfo, admin.Description, admin.Username) + admin.AdditionalInfo, admin.Description, util.GetTimeAsMsSinceEpoch(time.Now()), admin.Username) return err } @@ -486,6 +487,43 @@ func sqlCommonUpdateAPIKeyLastUse(keyID string, dbHandle *sql.DB) error { return err } +func sqlCommonUpdateAdminLastLogin(username string, dbHandle *sql.DB) error { + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + q := getUpdateAdminLastLoginQuery() + stmt, err := dbHandle.PrepareContext(ctx, q) + if err != nil { + providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err) + return err + } + defer stmt.Close() + _, err = stmt.ExecContext(ctx, util.GetTimeAsMsSinceEpoch(time.Now()), username) + if err == nil { + providerLog(logger.LevelDebug, "last login updated for admin %#v", username) + } else { + providerLog(logger.LevelWarn, "error updating last login for admin %#v: %v", username, err) + } + return err +} + +func sqlCommonSetUpdatedAt(username string, dbHandle *sql.DB) { + ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) + defer cancel() + q := getSetUpdateAtQuery() + stmt, err := dbHandle.PrepareContext(ctx, q) + if err != nil { + providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err) + return + } + defer stmt.Close() + _, err = stmt.ExecContext(ctx, util.GetTimeAsMsSinceEpoch(time.Now()), username) + if err == nil { + providerLog(logger.LevelDebug, "updated_at set for user %#v", username) + } else { + providerLog(logger.LevelWarn, "error setting updated_at for user %#v: %v", username, err) + } +} + func sqlCommonUpdateLastLogin(username string, dbHandle *sql.DB) error { ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) defer cancel() @@ -539,7 +577,8 @@ func sqlCommonAddUser(user *User, dbHandle *sql.DB) error { } _, err = stmt.ExecContext(ctx, user.Username, user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize, user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate, string(filters), - string(fsConfig), user.AdditionalInfo, user.Description) + string(fsConfig), user.AdditionalInfo, user.Description, util.GetTimeAsMsSinceEpoch(time.Now()), + util.GetTimeAsMsSinceEpoch(time.Now())) if err != nil { return err } @@ -581,7 +620,7 @@ func sqlCommonUpdateUser(user *User, dbHandle *sql.DB) error { } _, err = stmt.ExecContext(ctx, user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize, user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate, - string(filters), string(fsConfig), user.AdditionalInfo, user.Description, user.ID) + string(filters), string(fsConfig), user.AdditionalInfo, user.Description, util.GetTimeAsMsSinceEpoch(time.Now()), user.ID) if err != nil { return err } @@ -702,7 +741,7 @@ func getAdminFromDbRow(row sqlScanner) (Admin, error) { var email, filters, additionalInfo, permissions, description sql.NullString err := row.Scan(&admin.ID, &admin.Username, &admin.Password, &admin.Status, &email, &permissions, - &filters, &additionalInfo, &description) + &filters, &additionalInfo, &description, &admin.CreatedAt, &admin.UpdatedAt, &admin.LastLogin) if err != nil { if err == sql.ErrNoRows { @@ -752,7 +791,7 @@ func getUserFromDbRow(row sqlScanner) (User, error) { err := row.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions, &user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate, &user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig, - &additionalInfo, &description) + &additionalInfo, &description, &user.CreatedAt, &user.UpdatedAt) if err != nil { if err == sql.ErrNoRows { return user, util.NewRecordNotFoundError(err.Error()) diff --git a/dataprovider/sqlite.go b/dataprovider/sqlite.go index 5bc2974a..f131e16c 100644 --- a/dataprovider/sqlite.go +++ b/dataprovider/sqlite.go @@ -1,3 +1,4 @@ +//go:build !nosqlite // +build !nosqlite package dataprovider @@ -52,6 +53,20 @@ CREATE INDEX "{{prefix}}api_keys_admin_id_idx" ON "api_keys" ("admin_id"); CREATE INDEX "{{prefix}}api_keys_user_id_idx" ON "api_keys" ("user_id"); ` sqliteV11DownSQL = `DROP TABLE "{{api_keys}}";` + sqliteV12SQL = `ALTER TABLE "{{admins}}" ADD COLUMN "created_at" bigint DEFAULT 0 NOT NULL; +ALTER TABLE "{{admins}}" ADD COLUMN "updated_at" bigint DEFAULT 0 NOT NULL; +ALTER TABLE "{{admins}}" ADD COLUMN "last_login" bigint DEFAULT 0 NOT NULL; +ALTER TABLE "{{users}}" ADD COLUMN "created_at" bigint DEFAULT 0 NOT NULL; +ALTER TABLE "{{users}}" ADD COLUMN "updated_at" bigint DEFAULT 0 NOT NULL; +CREATE INDEX "{{prefix}}users_updated_at_idx" ON "{{users}}" ("updated_at"); +` + sqliteV12DownSQL = `DROP INDEX "{{prefix}}users_updated_at_idx"; +ALTER TABLE "{{users}}" DROP COLUMN "updated_at"; +ALTER TABLE "{{users}}" DROP COLUMN "created_at"; +ALTER TABLE "{{admins}}" DROP COLUMN "created_at"; +ALTER TABLE "{{admins}}" DROP COLUMN "updated_at"; +ALTER TABLE "{{admins}}" DROP COLUMN "last_login"; +` ) // SQLiteProvider auth provider for SQLite database @@ -115,10 +130,18 @@ func (p *SQLiteProvider) getUsedQuota(username string) (int, int64, error) { return sqlCommonGetUsedQuota(username, p.dbHandle) } +func (p *SQLiteProvider) setUpdatedAt(username string) { + sqlCommonSetUpdatedAt(username, p.dbHandle) +} + func (p *SQLiteProvider) updateLastLogin(username string) error { return sqlCommonUpdateLastLogin(username, p.dbHandle) } +func (p *SQLiteProvider) updateAdminLastLogin(username string) error { + return sqlCommonUpdateAdminLastLogin(username, p.dbHandle) +} + func (p *SQLiteProvider) userExists(username string) (User, error) { return sqlCommonGetUserByUsername(username, p.dbHandle) } @@ -274,6 +297,8 @@ func (p *SQLiteProvider) migrateDatabase() error { return err case version == 10: return updateSQLiteDatabaseFromV10(p.dbHandle) + case version == 11: + return updateSQLiteDatabaseFromV11(p.dbHandle) default: if version > sqlDatabaseVersion { providerLog(logger.LevelWarn, "database version %v is newer than the supported one: %v", version, @@ -296,6 +321,8 @@ func (p *SQLiteProvider) revertDatabase(targetVersion int) error { } switch dbVersion.Version { + case 12: + return downgradeSQLiteDatabaseFromV12(p.dbHandle) case 11: return downgradeSQLiteDatabaseFromV11(p.dbHandle) default: @@ -304,13 +331,45 @@ func (p *SQLiteProvider) revertDatabase(targetVersion int) error { } func updateSQLiteDatabaseFromV10(dbHandle *sql.DB) error { - return updateSQLiteDatabaseFrom10To11(dbHandle) + if err := updateSQLiteDatabaseFrom10To11(dbHandle); err != nil { + return err + } + return updateSQLiteDatabaseFromV11(dbHandle) +} + +func updateSQLiteDatabaseFromV11(dbHandle *sql.DB) error { + return updateSQLiteDatabaseFrom11To12(dbHandle) +} + +func downgradeSQLiteDatabaseFromV12(dbHandle *sql.DB) error { + if err := downgradeSQLiteDatabaseFrom12To11(dbHandle); err != nil { + return err + } + return downgradeSQLiteDatabaseFromV11(dbHandle) } func downgradeSQLiteDatabaseFromV11(dbHandle *sql.DB) error { return downgradeSQLiteDatabaseFrom11To10(dbHandle) } +func updateSQLiteDatabaseFrom11To12(dbHandle *sql.DB) error { + logger.InfoToConsole("updating database version: 11 -> 12") + providerLog(logger.LevelInfo, "updating database version: 11 -> 12") + sql := strings.ReplaceAll(sqliteV12SQL, "{{users}}", sqlTableUsers) + sql = strings.ReplaceAll(sql, "{{admins}}", sqlTableAdmins) + sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix) + return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 12) +} + +func downgradeSQLiteDatabaseFrom12To11(dbHandle *sql.DB) error { + logger.InfoToConsole("downgrading database version: 12 -> 11") + providerLog(logger.LevelInfo, "downgrading database version: 12 -> 11") + sql := strings.ReplaceAll(sqliteV12DownSQL, "{{users}}", sqlTableUsers) + sql = strings.ReplaceAll(sql, "{{admins}}", sqlTableAdmins) + sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix) + return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 11) +} + func updateSQLiteDatabaseFrom10To11(dbHandle *sql.DB) error { logger.InfoToConsole("updating database version: 10 -> 11") providerLog(logger.LevelInfo, "updating database version: 10 -> 11") diff --git a/dataprovider/sqlite_disabled.go b/dataprovider/sqlite_disabled.go index 37b7d8d7..559a74bd 100644 --- a/dataprovider/sqlite_disabled.go +++ b/dataprovider/sqlite_disabled.go @@ -1,3 +1,4 @@ +//go:build nosqlite // +build nosqlite package dataprovider diff --git a/dataprovider/sqlqueries.go b/dataprovider/sqlqueries.go index 0edc42f9..42824f2a 100644 --- a/dataprovider/sqlqueries.go +++ b/dataprovider/sqlqueries.go @@ -11,9 +11,9 @@ import ( const ( selectUserFields = "id,username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,used_quota_size," + "used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,expiration_date,last_login,status,filters,filesystem," + - "additional_info,description" + "additional_info,description,created_at,updated_at" selectFolderFields = "id,path,used_quota_size,used_quota_files,last_quota_update,name,description,filesystem" - selectAdminFields = "id,username,password,status,email,permissions,filters,additional_info,description" + selectAdminFields = "id,username,password,status,email,permissions,filters,additional_info,description,created_at,updated_at,last_login" selectAPIKeyFields = "key_id,name,api_key,scope,created_at,updated_at,last_use_at,expires_at,description,user_id,admin_id" ) @@ -43,15 +43,16 @@ func getDumpAdminsQuery() string { } func getAddAdminQuery() string { - return fmt.Sprintf(`INSERT INTO %v (username,password,status,email,permissions,filters,additional_info,description) - VALUES (%v,%v,%v,%v,%v,%v,%v,%v)`, sqlTableAdmins, sqlPlaceholders[0], sqlPlaceholders[1], - sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7]) + return fmt.Sprintf(`INSERT INTO %v (username,password,status,email,permissions,filters,additional_info,description,created_at,updated_at,last_login) + VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0)`, sqlTableAdmins, sqlPlaceholders[0], sqlPlaceholders[1], + sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], + sqlPlaceholders[8], sqlPlaceholders[9]) } func getUpdateAdminQuery() string { - return fmt.Sprintf(`UPDATE %v SET password=%v,status=%v,email=%v,permissions=%v,filters=%v,additional_info=%v,description=%v + return fmt.Sprintf(`UPDATE %v SET password=%v,status=%v,email=%v,permissions=%v,filters=%v,additional_info=%v,description=%v,updated_at=%v WHERE username = %v`, sqlTableAdmins, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], - sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7]) + sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8]) } func getDeleteAdminQuery() string { @@ -156,10 +157,18 @@ func getUpdateQuotaQuery(reset bool) string { WHERE username = %v`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3]) } +func getSetUpdateAtQuery() string { + return fmt.Sprintf(`UPDATE %v SET updated_at = %v WHERE username = %v`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1]) +} + func getUpdateLastLoginQuery() string { return fmt.Sprintf(`UPDATE %v SET last_login = %v WHERE username = %v`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1]) } +func getUpdateAdminLastLoginQuery() string { + return fmt.Sprintf(`UPDATE %v SET last_login = %v WHERE username = %v`, sqlTableAdmins, sqlPlaceholders[0], sqlPlaceholders[1]) +} + func getUpdateAPIKeyLastUseQuery() string { return fmt.Sprintf(`UPDATE %v SET last_use_at = %v WHERE key_id = %v`, sqlTableAPIKeys, sqlPlaceholders[0], sqlPlaceholders[1]) } @@ -172,20 +181,20 @@ func getQuotaQuery() string { func getAddUserQuery() string { return fmt.Sprintf(`INSERT INTO %v (username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions, used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,status,last_login,expiration_date,filters, - filesystem,additional_info,description) - VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v,%v,0,%v,%v,%v,%v,%v)`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1], + filesystem,additional_info,description,created_at,updated_at) + VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v,%v,0,%v,%v,%v,%v,%v,%v,%v)`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], - sqlPlaceholders[14], sqlPlaceholders[15], sqlPlaceholders[16], sqlPlaceholders[17]) + sqlPlaceholders[14], sqlPlaceholders[15], sqlPlaceholders[16], sqlPlaceholders[17], sqlPlaceholders[18], sqlPlaceholders[19]) } func getUpdateUserQuery() string { return fmt.Sprintf(`UPDATE %v SET password=%v,public_keys=%v,home_dir=%v,uid=%v,gid=%v,max_sessions=%v,quota_size=%v, quota_files=%v,permissions=%v,upload_bandwidth=%v,download_bandwidth=%v,status=%v,expiration_date=%v,filters=%v,filesystem=%v, - additional_info=%v,description=%v WHERE id = %v`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], + additional_info=%v,description=%v,updated_at=%v WHERE id = %v`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], sqlPlaceholders[14], sqlPlaceholders[15], - sqlPlaceholders[16], sqlPlaceholders[17]) + sqlPlaceholders[16], sqlPlaceholders[17], sqlPlaceholders[18]) } func getDeleteUserQuery() string { diff --git a/dataprovider/user.go b/dataprovider/user.go index c60d1f42..a40c465e 100644 --- a/dataprovider/user.go +++ b/dataprovider/user.go @@ -1037,6 +1037,8 @@ func (u *User) getACopy() User { Filters: filters, AdditionalInfo: u.AdditionalInfo, Description: u.Description, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, }, VirtualFolders: virtualFolders, FsConfig: u.FsConfig.GetACopy(), diff --git a/docker/README.md b/docker/README.md index db238575..b8c58047 100644 --- a/docker/README.md +++ b/docker/README.md @@ -20,12 +20,12 @@ SFTPGo provides an official Docker image, it is available on both [Docker Hub](h Starting a SFTPGo instance is simple: ```shell -docker run --name some-sftpgo -p 127.0.0.1:8080:8080 -p 2022:2022 -d "drakkan/sftpgo:tag" +docker run --name some-sftpgo -p 8080:8080 -p 2022:2022 -d "drakkan/sftpgo:tag" ``` ... where `some-sftpgo` is the name you want to assign to your container, and `tag` is the tag specifying the SFTPGo version you want. See the list above for relevant tags. -Now visit [http://localhost:8080/web/admin](http://localhost:8080/web/admin), create the first admin and then log in and create a new SFTPGo user. The SFTP service is available on port 2022. +Now visit [http://localhost:8080/web/admin](http://localhost:8080/web/admin), replacing `localhost` with the appropriate IP address if SFTPGo is not reachable on localhost, create the first admin and a new SFTPGo user. The SFTP service is available on port 2022. If you don't want to persist any files, for example for testing purposes, you can run an SFTPGo instance like this: @@ -102,7 +102,7 @@ The Docker documentation is a good starting point for understanding the differen ```shell docker run --name some-sftpgo \ - -p 127.0.0.1:8080:8090 \ + -p 8080:8090 \ -p 2022:2022 \ --mount type=bind,source=/my/own/sftpgodata,target=/srv/sftpgo \ --mount type=bind,source=/my/own/sftpgohome,target=/var/lib/sftpgo \ @@ -150,7 +150,7 @@ With the above directory permissions, you can start a SFTPGo instance like this: ```shell docker run --name some-sftpgo \ --user 1100:1100 \ - -p 127.0.0.1:8080:8080 \ + -p 8080:8080 \ -p 2022:2022 \ --mount type=bind,source="${PWD}/data",target=/srv/sftpgo \ --mount type=bind,source="${PWD}/config",target=/var/lib/sftpgo \ diff --git a/ftpd/server.go b/ftpd/server.go index d14c35fa..69ab6fa0 100644 --- a/ftpd/server.go +++ b/ftpd/server.go @@ -203,7 +203,7 @@ func (s *Server) AuthUser(cc ftpserver.ClientContext, username, password string) } connection.Log(logger.LevelInfo, "User id: %d, logged in with FTP, username: %#v, home_dir: %#v remote addr: %#v", user.ID, user.Username, user.HomeDir, ipAddr) - dataprovider.UpdateLastLogin(&user) //nolint:errcheck + dataprovider.UpdateLastLogin(&user) return connection, nil } @@ -249,7 +249,7 @@ func (s *Server) VerifyConnection(cc ftpserver.ClientContext, user string, tlsCo } connection.Log(logger.LevelInfo, "User id: %d, logged in with FTP using a TLS certificate, username: %#v, home_dir: %#v remote addr: %#v", dbUser.ID, dbUser.Username, dbUser.HomeDir, ipAddr) - dataprovider.UpdateLastLogin(&dbUser) //nolint:errcheck + dataprovider.UpdateLastLogin(&dbUser) return connection, nil } } diff --git a/go.mod b/go.mod index 692de1b7..991feb9b 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/Azure/azure-storage-blob-go v0.14.0 github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 github.com/alexedwards/argon2id v0.0.0-20210511081203-7d35d68092b8 - github.com/aws/aws-sdk-go v1.40.24 + github.com/aws/aws-sdk-go v1.40.25 github.com/cockroachdb/cockroach-go/v2 v2.1.1 github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b github.com/fatih/color v1.12.0 // indirect @@ -61,17 +61,17 @@ require ( gocloud.dev v0.23.0 golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d - golang.org/x/sys v0.0.0-20210817190340-bfb29a6856f2 + golang.org/x/sys v0.0.0-20210819072135-bce67f096156 golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac google.golang.org/api v0.54.0 - google.golang.org/genproto v0.0.0-20210816143620-e15ff196659d // indirect + google.golang.org/genproto v0.0.0-20210818220304-27ea9cc85d9f // indirect google.golang.org/grpc v1.40.0 google.golang.org/protobuf v1.27.1 gopkg.in/natefinch/lumberjack.v2 v2.0.0 ) require ( - cloud.google.com/go v0.92.3 // indirect + cloud.google.com/go v0.93.3 // indirect cloud.google.com/go/kms v0.1.0 // indirect github.com/Azure/azure-pipeline-go v0.2.3 // indirect github.com/StackExchange/wmi v1.2.1 // indirect @@ -82,7 +82,7 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v3 v3.0.0 // indirect - github.com/fsnotify/fsnotify v1.4.9 // indirect + github.com/fsnotify/fsnotify v1.5.0 // indirect github.com/go-ole/go-ole v1.2.5 // indirect github.com/goccy/go-json v0.7.6 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect diff --git a/go.sum b/go.sum index ed90dbcd..889d1882 100644 --- a/go.sum +++ b/go.sum @@ -26,8 +26,8 @@ cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSU cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= cloud.google.com/go v0.92.2/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= -cloud.google.com/go v0.92.3 h1:VWuKmJ8pyOrb7doM0NnQDYngKv+zTicI8BaMsnIA9gA= -cloud.google.com/go v0.92.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= +cloud.google.com/go v0.93.3 h1:wPBktZFzYBcCZVARvwVKqH1uEj+aLXofJEtrb4oOsio= +cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= @@ -124,8 +124,8 @@ github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZo github.com/aws/aws-sdk-go v1.23.20/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.38.68/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= -github.com/aws/aws-sdk-go v1.40.24 h1:qtXDYFzAxEmmZaa+4JA9loBqOujO0vm4ZOJoEmjG21E= -github.com/aws/aws-sdk-go v1.40.24/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= +github.com/aws/aws-sdk-go v1.40.25 h1:Depnx7O86HWgOCLD5nMto6F9Ju85Q1QuFDnbpZYQWno= +github.com/aws/aws-sdk-go v1.40.25/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4= github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.5.0/go.mod h1:acH3+MQoiMzozT/ivU+DbRg7Ooo2298RdRaWcOv+4vM= github.com/aws/smithy-go v1.5.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= @@ -216,8 +216,9 @@ github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHqu github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4= github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.5.0 h1:NO5hkcB+srp1x6QmwvNZLeaOgbM8cmBTN32THzjvu2k= +github.com/fsnotify/fsnotify v1.5.0/go.mod h1:BX0DCEr5pT4jm2CnQdVP1lFV521fcCNcyEeNp4DQQDk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do= @@ -915,8 +916,8 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210817190340-bfb29a6856f2 h1:c8PlLMqBbOHoqtjteWm5/kbe6rNY2pbRfbIMVnepueo= -golang.org/x/sys v0.0.0-20210817190340-bfb29a6856f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210819072135-bce67f096156 h1:f7XLk/QXGE6IM4HjJ4ttFFlPSwJ65A1apfDd+mmViR0= +golang.org/x/sys v0.0.0-20210819072135-bce67f096156/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1112,8 +1113,8 @@ google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w= -google.golang.org/genproto v0.0.0-20210816143620-e15ff196659d h1:fPtHPeysWvGVJwQFKu3B7H2DB2sOEsW7UTayKkWESKw= -google.golang.org/genproto v0.0.0-20210816143620-e15ff196659d/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210818220304-27ea9cc85d9f h1:enWPderunHptc5pzJkSYGx0olpF8goXzG0rY3kL0eSg= +google.golang.org/genproto v0.0.0-20210818220304-27ea9cc85d9f/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index cf9ac112..18f53fd9 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -395,6 +395,79 @@ func TestBasicUserHandling(t *testing.T) { assert.NoError(t, err) } +func TestUserTimestamps(t *testing.T) { + user, resp, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err, string(resp)) + createdAt := user.CreatedAt + updatedAt := user.UpdatedAt + assert.Equal(t, int64(0), user.LastLogin) + assert.Greater(t, createdAt, int64(0)) + assert.Greater(t, updatedAt, int64(0)) + mappedPath := filepath.Join(os.TempDir(), "mapped_dir") + folderName := filepath.Base(mappedPath) + user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + Name: folderName, + MappedPath: mappedPath, + }, + VirtualPath: "/vdir", + }) + time.Sleep(10 * time.Millisecond) + user, resp, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err, string(resp)) + assert.Equal(t, int64(0), user.LastLogin) + assert.Equal(t, createdAt, user.CreatedAt) + assert.Greater(t, user.UpdatedAt, updatedAt) + updatedAt = user.UpdatedAt + // after a folder update or delete the user updated_at field should change + folder, _, err := httpdtest.GetFolderByName(folderName, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, folder.Users, 1) + time.Sleep(10 * time.Millisecond) + _, _, err = httpdtest.UpdateFolder(folder, http.StatusOK) + assert.NoError(t, err) + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, int64(0), user.LastLogin) + assert.Equal(t, createdAt, user.CreatedAt) + assert.Greater(t, user.UpdatedAt, updatedAt) + updatedAt = user.UpdatedAt + time.Sleep(10 * time.Millisecond) + _, err = httpdtest.RemoveFolder(folder, http.StatusOK) + assert.NoError(t, err) + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, int64(0), user.LastLogin) + assert.Equal(t, createdAt, user.CreatedAt) + assert.Greater(t, user.UpdatedAt, updatedAt) + + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) +} + +func TestAdminTimestamps(t *testing.T) { + admin := getTestAdmin() + admin.Username = altAdminUsername + admin, _, err := httpdtest.AddAdmin(admin, http.StatusCreated) + assert.NoError(t, err) + createdAt := admin.CreatedAt + updatedAt := admin.UpdatedAt + assert.Equal(t, int64(0), admin.LastLogin) + assert.Greater(t, createdAt, int64(0)) + assert.Greater(t, updatedAt, int64(0)) + time.Sleep(10 * time.Millisecond) + admin, _, err = httpdtest.UpdateAdmin(admin, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, int64(0), admin.LastLogin) + assert.Equal(t, createdAt, admin.CreatedAt) + assert.Greater(t, admin.UpdatedAt, updatedAt) + + _, err = httpdtest.RemoveAdmin(admin, http.StatusOK) + assert.NoError(t, err) +} + func TestHTTPUserAuthentication(t *testing.T) { user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) assert.NoError(t, err) @@ -757,6 +830,26 @@ func TestAdminInvalidCredentials(t *testing.T) { assert.Equal(t, dataprovider.ErrInvalidCredentials.Error(), responseHolder["error"].(string)) } +func TestAdminLastLogin(t *testing.T) { + a := getTestAdmin() + a.Username = altAdminUsername + a.Password = altAdminPassword + + admin, _, err := httpdtest.AddAdmin(a, http.StatusCreated) + assert.NoError(t, err) + assert.Equal(t, int64(0), admin.LastLogin) + + _, _, err = httpdtest.GetToken(altAdminUsername, altAdminPassword) + assert.NoError(t, err) + + admin, _, err = httpdtest.GetAdminByUsername(altAdminUsername, http.StatusOK) + assert.NoError(t, err) + assert.Greater(t, admin.LastLogin, int64(0)) + + _, err = httpdtest.RemoveAdmin(admin, http.StatusOK) + assert.NoError(t, err) +} + func TestAdminAllowList(t *testing.T) { a := getTestAdmin() a.Username = altAdminUsername @@ -1361,7 +1454,7 @@ func TestUpdateUserEmptyPassword(t *testing.T) { assert.NoError(t, err) userNoPwd, _, err := httpdtest.UpdateUserWithJSON(user, http.StatusOK, "", asJSON) assert.NoError(t, err) - assert.Equal(t, user, userNoPwd) // the password is hidden so the user must be equal + assert.Equal(t, user.Password, userNoPwd.Password) // the password is hidden // check the password within the data provider dbUser, err = dataprovider.UserExists(u.Username) assert.NoError(t, err) @@ -1705,6 +1798,7 @@ func TestUserS3Config(t *testing.T) { assert.NoError(t, err) user.Password = defaultPassword user.ID = 0 + user.CreatedAt = 0 user.VirtualFolders = nil secret := kms.NewSecret(kms.SecretStatusSecretBox, "Server-Access-Secret", "", "") user.FsConfig.S3Config.AccessSecret = secret @@ -1749,6 +1843,7 @@ func TestUserS3Config(t *testing.T) { assert.NoError(t, err) user.Password = defaultPassword user.ID = 0 + user.CreatedAt = 0 // shared credential test for add instead of update user, _, err = httpdtest.AddUser(user, http.StatusCreated) assert.NoError(t, err) @@ -1795,6 +1890,7 @@ func TestUserGCSConfig(t *testing.T) { assert.NoError(t, err) user.Password = defaultPassword user.ID = 0 + user.CreatedAt = 0 user.FsConfig.GCSConfig.Credentials = kms.NewSecret(kms.SecretStatusSecretBox, "fake credentials", "", "") _, _, err = httpdtest.AddUser(user, http.StatusCreated) assert.Error(t, err) @@ -1861,6 +1957,7 @@ func TestUserAzureBlobConfig(t *testing.T) { assert.NoError(t, err) user.Password = defaultPassword user.ID = 0 + user.CreatedAt = 0 secret := kms.NewSecret(kms.SecretStatusSecretBox, "Server-Account-Key", "", "") user.FsConfig.AzBlobConfig.AccountKey = secret _, _, err = httpdtest.AddUser(user, http.StatusCreated) @@ -1901,6 +1998,7 @@ func TestUserAzureBlobConfig(t *testing.T) { assert.NoError(t, err) user.Password = defaultPassword user.ID = 0 + user.CreatedAt = 0 // sas test for add instead of update user.FsConfig.AzBlobConfig = vfs.AzBlobFsConfig{ AzBlobFsConfig: sdk.AzBlobFsConfig{ @@ -1956,6 +2054,7 @@ func TestUserCryptFs(t *testing.T) { assert.NoError(t, err) user.Password = defaultPassword user.ID = 0 + user.CreatedAt = 0 secret := kms.NewSecret(kms.SecretStatusSecretBox, "invalid encrypted payload", "", "") user.FsConfig.CryptConfig.Passphrase = secret _, _, err = httpdtest.AddUser(user, http.StatusCreated) @@ -2036,6 +2135,7 @@ func TestUserSFTPFs(t *testing.T) { assert.NoError(t, err) user.Password = defaultPassword user.ID = 0 + user.CreatedAt = 0 secret := kms.NewSecret(kms.SecretStatusSecretBox, "invalid encrypted payload", "", "") user.FsConfig.SFTPConfig.Password = secret _, _, err = httpdtest.AddUser(user, http.StatusCreated) @@ -4120,6 +4220,69 @@ func TestUpdateAdminMock(t *testing.T) { checkResponseCode(t, http.StatusOK, rr) } +func TestAdminLastLoginWithAPIKey(t *testing.T) { + admin := getTestAdmin() + admin.Username = altAdminUsername + admin.Filters.AllowAPIKeyAuth = true + admin, resp, err := httpdtest.AddAdmin(admin, http.StatusCreated) + assert.NoError(t, err, string(resp)) + assert.Equal(t, int64(0), admin.LastLogin) + + apiKey := dataprovider.APIKey{ + Name: "admin API key", + Scope: dataprovider.APIKeyScopeAdmin, + Admin: altAdminUsername, + } + + apiKey, resp, err = httpdtest.AddAPIKey(apiKey, http.StatusCreated) + assert.NoError(t, err, string(resp)) + + req, err := http.NewRequest(http.MethodGet, versionPath, nil) + assert.NoError(t, err) + setAPIKeyForReq(req, apiKey.Key, admin.Username) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + admin, _, err = httpdtest.GetAdminByUsername(altAdminUsername, http.StatusOK) + assert.NoError(t, err) + assert.Greater(t, admin.LastLogin, int64(0)) + + _, err = httpdtest.RemoveAdmin(admin, http.StatusOK) + assert.NoError(t, err) +} + +func TestUserLastLoginWithAPIKey(t *testing.T) { + user := getTestUser() + user.Filters.AllowAPIKeyAuth = true + user, resp, err := httpdtest.AddUser(user, http.StatusCreated) + assert.NoError(t, err, string(resp)) + assert.Equal(t, int64(0), user.LastLogin) + + apiKey := dataprovider.APIKey{ + Name: "user API key", + Scope: dataprovider.APIKeyScopeUser, + User: user.Username, + } + + apiKey, resp, err = httpdtest.AddAPIKey(apiKey, http.StatusCreated) + assert.NoError(t, err, string(resp)) + + req, err := http.NewRequest(http.MethodGet, userDirsPath, nil) + assert.NoError(t, err) + setAPIKeyForReq(req, apiKey.Key, "") + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + assert.Greater(t, user.LastLogin, int64(0)) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + func TestAdminHandlingWithAPIKeys(t *testing.T) { sysAdmin, _, err := httpdtest.GetAdminByUsername(defaultTokenAuthUser, http.StatusOK) assert.NoError(t, err) @@ -4260,6 +4423,8 @@ func TestUserHandlingWithAPIKey(t *testing.T) { setAPIKeyForReq(req, apiKey.Key, "") rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) _, err = httpdtest.RemoveAdmin(admin, http.StatusOK) assert.NoError(t, err) @@ -4506,6 +4671,7 @@ func TestUpdateUserInvalidParamsMock(t *testing.T) { checkResponseCode(t, http.StatusBadRequest, rr) userID := user.ID user.ID = 0 + user.CreatedAt = 0 userAsJSON = getUserAsJSON(t, user) req, _ = http.NewRequest(http.MethodPut, path.Join(userPath, user.Username), bytes.NewBuffer(userAsJSON)) setBearerForReq(req, token) diff --git a/httpd/middleware.go b/httpd/middleware.go index f72fa27f..4a6da512 100644 --- a/httpd/middleware.go +++ b/httpd/middleware.go @@ -341,7 +341,7 @@ func authenticateAdminWithAPIKey(username, keyID string, tokenAuth *jwtauth.JWTA return err } r.Header.Set("Authorization", fmt.Sprintf("Bearer %v", resp["access_token"])) - + dataprovider.UpdateAdminLastLogin(&admin) return nil } @@ -397,7 +397,7 @@ func authenticateUserWithAPIKey(username, keyID string, tokenAuth *jwtauth.JWTAu return err } r.Header.Set("Authorization", fmt.Sprintf("Bearer %v", resp["access_token"])) - dataprovider.UpdateLastLogin(&user) //nolint:errcheck + dataprovider.UpdateLastLogin(&user) updateLoginMetrics(&user, ipAddr, nil) return nil diff --git a/httpd/schema/openapi.yaml b/httpd/schema/openapi.yaml index 78c3bfa6..a359d16d 100644 --- a/httpd/schema/openapi.yaml +++ b/httpd/schema/openapi.yaml @@ -2969,6 +2969,14 @@ components: type: integer format: int32 description: 'Maximum download bandwidth as KB/s, 0 means unlimited' + created_at: + type: integer + format: int64 + description: 'creation time as unix timestamp in milliseconds. It will be 0 for users created before v2.2.0' + updated_at: + type: integer + format: int64 + description: last update time as unix timestamp in milliseconds last_login: type: integer format: int64 @@ -3032,6 +3040,18 @@ components: additional_info: type: string description: Free form text field + created_at: + type: integer + format: int64 + description: 'creation time as unix timestamp in milliseconds. It will be 0 for admins created before v2.2.0' + updated_at: + type: integer + format: int64 + description: last update time as unix timestamp in milliseconds + last_login: + type: integer + format: int64 + description: Last user login as unix timestamp in milliseconds. It is saved at most once every 10 minutes APIKey: type: object properties: diff --git a/httpd/server.go b/httpd/server.go index fa447d37..c061df2e 100644 --- a/httpd/server.go +++ b/httpd/server.go @@ -197,7 +197,7 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re return } updateLoginMetrics(&user, ipAddr, err) - dataprovider.UpdateLastLogin(&user) //nolint:errcheck + dataprovider.UpdateLastLogin(&user) http.Redirect(w, r, webClientFilesPath, http.StatusFound) } @@ -307,6 +307,7 @@ func (s *httpdServer) loginAdmin(w http.ResponseWriter, r *http.Request, admin * } http.Redirect(w, r, webUsersPath, http.StatusFound) + dataprovider.UpdateAdminLastLogin(admin) } func (s *httpdServer) logout(w http.ResponseWriter, r *http.Request) { @@ -377,7 +378,7 @@ func (s *httpdServer) generateAndSendUserToken(w http.ResponseWriter, r *http.Re return } updateLoginMetrics(&user, ipAddr, err) - dataprovider.UpdateLastLogin(&user) //nolint:errcheck + dataprovider.UpdateLastLogin(&user) render.JSON(w, r, resp) } @@ -413,6 +414,7 @@ func (s *httpdServer) generateAndSendToken(w http.ResponseWriter, r *http.Reques return } + dataprovider.UpdateAdminLastLogin(&admin) render.JSON(w, r, resp) } diff --git a/httpdtest/httpdtest.go b/httpdtest/httpdtest.go index a26e9263..4f344c61 100644 --- a/httpdtest/httpdtest.go +++ b/httpdtest/httpdtest.go @@ -1058,6 +1058,11 @@ func checkAdmin(expected, actual *dataprovider.Admin) error { return errors.New("admin ID mismatch") } } + if expected.CreatedAt > 0 { + if expected.CreatedAt != actual.CreatedAt { + return fmt.Errorf("created_at mismatch %v != %v", expected.CreatedAt, actual.CreatedAt) + } + } if err := compareAdminEqualFields(expected, actual); err != nil { return err } @@ -1116,6 +1121,11 @@ func checkUser(expected *dataprovider.User, actual *dataprovider.User) error { return errors.New("user ID mismatch") } } + if expected.CreatedAt > 0 { + if expected.CreatedAt != actual.CreatedAt { + return fmt.Errorf("created_at mismatch %v != %v", expected.CreatedAt, actual.CreatedAt) + } + } if len(expected.Permissions) != len(actual.Permissions) { return errors.New("permissions mismatch") } diff --git a/logger/journald.go b/logger/journald.go index ee036fd2..e7319b3e 100644 --- a/logger/journald.go +++ b/logger/journald.go @@ -1,3 +1,4 @@ +//go:build linux // +build linux package logger diff --git a/logger/journald_nolinux.go b/logger/journald_nolinux.go index 77cee1a4..d421798c 100644 --- a/logger/journald_nolinux.go +++ b/logger/journald_nolinux.go @@ -1,3 +1,4 @@ +//go:build !linux // +build !linux package logger diff --git a/metric/metric.go b/metric/metric.go index c3c7e1a2..2b639051 100644 --- a/metric/metric.go +++ b/metric/metric.go @@ -1,3 +1,4 @@ +//go:build !nometrics // +build !nometrics // Package metrics provides Prometheus metrics support diff --git a/metric/metric_disabled.go b/metric/metric_disabled.go index 93509d73..678e67b3 100644 --- a/metric/metric_disabled.go +++ b/metric/metric_disabled.go @@ -1,3 +1,4 @@ +//go:build nometrics // +build nometrics package metric diff --git a/sdk/user.go b/sdk/user.go index fb5998d6..cccabe9d 100644 --- a/sdk/user.go +++ b/sdk/user.go @@ -166,6 +166,10 @@ type BaseUser struct { DownloadBandwidth int64 `json:"download_bandwidth"` // Last login as unix timestamp in milliseconds LastLogin int64 `json:"last_login"` + // Creation time as unix timestamp in milliseconds. It will be 0 for admins created before v2.2.0 + CreatedAt int64 `json:"created_at"` + // last update time as unix timestamp in milliseconds + UpdatedAt int64 `json:"updated_at"` // Additional restrictions Filters UserFilters `json:"filters"` // optional description, for example full name diff --git a/service/service.go b/service/service.go index 7214480d..f61a6d62 100644 --- a/service/service.go +++ b/service/service.go @@ -286,15 +286,7 @@ func (s *Service) loadInitialData() error { } func (s *Service) restoreDump(dump *dataprovider.BackupData) error { - err := httpd.RestoreAPIKeys(dump.APIKeys, s.LoadDataFrom, s.LoadDataMode) - if err != nil { - return fmt.Errorf("unable to restore API keys from file %#v: %v", s.LoadDataFrom, err) - } - err = httpd.RestoreAdmins(dump.Admins, s.LoadDataFrom, s.LoadDataMode) - if err != nil { - return fmt.Errorf("unable to restore admins from file %#v: %v", s.LoadDataFrom, err) - } - err = httpd.RestoreFolders(dump.Folders, s.LoadDataFrom, s.LoadDataMode, s.LoadDataQuotaScan) + err := httpd.RestoreFolders(dump.Folders, s.LoadDataFrom, s.LoadDataMode, s.LoadDataQuotaScan) if err != nil { return fmt.Errorf("unable to restore folders from file %#v: %v", s.LoadDataFrom, err) } @@ -302,5 +294,13 @@ func (s *Service) restoreDump(dump *dataprovider.BackupData) error { if err != nil { return fmt.Errorf("unable to restore users from file %#v: %v", s.LoadDataFrom, err) } + err = httpd.RestoreAdmins(dump.Admins, s.LoadDataFrom, s.LoadDataMode) + if err != nil { + return fmt.Errorf("unable to restore admins from file %#v: %v", s.LoadDataFrom, err) + } + err = httpd.RestoreAPIKeys(dump.APIKeys, s.LoadDataFrom, s.LoadDataMode) + if err != nil { + return fmt.Errorf("unable to restore API keys from file %#v: %v", s.LoadDataFrom, err) + } return nil } diff --git a/service/service_portable.go b/service/service_portable.go index 699beda3..326d1bc0 100644 --- a/service/service_portable.go +++ b/service/service_portable.go @@ -1,3 +1,4 @@ +//go:build !noportable // +build !noportable package service diff --git a/service/signals_unix.go b/service/signals_unix.go index 37bb73ed..5f43e60b 100644 --- a/service/signals_unix.go +++ b/service/signals_unix.go @@ -1,3 +1,4 @@ +//go:build !windows // +build !windows package service diff --git a/sftpd/cmd_unix.go b/sftpd/cmd_unix.go index c2124778..5a02e05c 100644 --- a/sftpd/cmd_unix.go +++ b/sftpd/cmd_unix.go @@ -1,3 +1,4 @@ +//go:build !windows // +build !windows package sftpd diff --git a/sftpd/internal_unix_test.go b/sftpd/internal_unix_test.go index 893499fe..2fbcb20e 100644 --- a/sftpd/internal_unix_test.go +++ b/sftpd/internal_unix_test.go @@ -1,3 +1,4 @@ +//go:build !windows // +build !windows package sftpd diff --git a/sftpd/server.go b/sftpd/server.go index c9d7f3f1..cf96ca56 100644 --- a/sftpd/server.go +++ b/sftpd/server.go @@ -406,7 +406,7 @@ func (c *Configuration) AcceptInboundConnection(conn net.Conn, config *ssh.Serve logger.Log(logger.LevelDebug, common.ProtocolSSH, connectionID, "User %#v, logged in with: %#v, from ip: %#v, client version %#v", user.Username, loginType, ipAddr, string(sconn.ClientVersion())) - dataprovider.UpdateLastLogin(&user) //nolint:errcheck + dataprovider.UpdateLastLogin(&user) sshConnection := common.NewSSHConnection(connectionID, conn) common.Connections.AddSSHConnection(sshConnection) diff --git a/sftpd/subsystem.go b/sftpd/subsystem.go index 30ae4c5d..bcd0d1d2 100644 --- a/sftpd/subsystem.go +++ b/sftpd/subsystem.go @@ -43,7 +43,7 @@ func ServeSubSystemConnection(user *dataprovider.User, connectionID string, read logger.Warn(logSender, connectionID, "unable to check fs root: %v close fs error: %v", err, errClose) return err } - dataprovider.UpdateLastLogin(user) //nolint:errcheck + dataprovider.UpdateLastLogin(user) connection := &Connection{ BaseConnection: common.NewBaseConnection(connectionID, common.ProtocolSFTP, "", "", *user), diff --git a/vfs/azblobfs.go b/vfs/azblobfs.go index 37fc0b8c..3668276c 100644 --- a/vfs/azblobfs.go +++ b/vfs/azblobfs.go @@ -1,3 +1,4 @@ +//go:build !noazblob // +build !noazblob package vfs diff --git a/vfs/azblobfs_disabled.go b/vfs/azblobfs_disabled.go index 2f7dd6ee..1e8b948e 100644 --- a/vfs/azblobfs_disabled.go +++ b/vfs/azblobfs_disabled.go @@ -1,3 +1,4 @@ +//go:build noazblob // +build noazblob package vfs diff --git a/vfs/gcsfs.go b/vfs/gcsfs.go index acd3d907..342ef662 100644 --- a/vfs/gcsfs.go +++ b/vfs/gcsfs.go @@ -1,3 +1,4 @@ +//go:build !nogcs // +build !nogcs package vfs diff --git a/vfs/gcsfs_disabled.go b/vfs/gcsfs_disabled.go index adac9b36..d4bab6ab 100644 --- a/vfs/gcsfs_disabled.go +++ b/vfs/gcsfs_disabled.go @@ -1,3 +1,4 @@ +//go:build nogcs // +build nogcs package vfs diff --git a/vfs/s3fs.go b/vfs/s3fs.go index 89e55067..68531c33 100644 --- a/vfs/s3fs.go +++ b/vfs/s3fs.go @@ -1,3 +1,4 @@ +//go:build !nos3 // +build !nos3 package vfs diff --git a/vfs/s3fs_disabled.go b/vfs/s3fs_disabled.go index 4c7ddc6d..894e2273 100644 --- a/vfs/s3fs_disabled.go +++ b/vfs/s3fs_disabled.go @@ -1,3 +1,4 @@ +//go:build nos3 // +build nos3 package vfs diff --git a/vfs/statvfs_fallback.go b/vfs/statvfs_fallback.go index 4e273bf9..047da305 100644 --- a/vfs/statvfs_fallback.go +++ b/vfs/statvfs_fallback.go @@ -1,3 +1,4 @@ +//go:build !darwin && !linux && !freebsd // +build !darwin,!linux,!freebsd package vfs diff --git a/vfs/statvfs_linux.go b/vfs/statvfs_linux.go index 484cb649..5943a3ea 100644 --- a/vfs/statvfs_linux.go +++ b/vfs/statvfs_linux.go @@ -1,3 +1,4 @@ +//go:build linux // +build linux package vfs diff --git a/vfs/statvfs_unix.go b/vfs/statvfs_unix.go index 961b82d5..d29761ca 100644 --- a/vfs/statvfs_unix.go +++ b/vfs/statvfs_unix.go @@ -1,3 +1,4 @@ +//go:build freebsd || darwin // +build freebsd darwin package vfs diff --git a/vfs/sys_unix.go b/vfs/sys_unix.go index 7b44f1cc..b8283896 100644 --- a/vfs/sys_unix.go +++ b/vfs/sys_unix.go @@ -1,3 +1,4 @@ +//go:build !windows // +build !windows package vfs diff --git a/webdavd/internal_test.go b/webdavd/internal_test.go index a587f713..03e88197 100644 --- a/webdavd/internal_test.go +++ b/webdavd/internal_test.go @@ -1118,10 +1118,22 @@ func TestCachedUserWithFolders(t *testing.T) { folder, err := dataprovider.GetFolderByName(folderName) assert.NoError(t, err) - // updating a used folder should invalidate the cache + // updating a used folder should invalidate the cache only if the fs changed err = dataprovider.UpdateFolder(&folder, folder.Users) assert.NoError(t, err) + _, isCached, _, loginMethod, err = server.authenticate(req, ipAddr) + assert.NoError(t, err) + assert.True(t, isCached) + assert.Equal(t, dataprovider.LoginMethodPassword, loginMethod) + cachedUser, ok = dataprovider.GetCachedWebDAVUser(username) + if assert.True(t, ok) { + assert.False(t, cachedUser.IsExpired()) + } + // changing the folder path should invalidate the cache + folder.MappedPath = filepath.Join(os.TempDir(), "anotherpath") + err = dataprovider.UpdateFolder(&folder, folder.Users) + assert.NoError(t, err) _, isCached, _, loginMethod, err = server.authenticate(req, ipAddr) assert.NoError(t, err) assert.False(t, isCached) diff --git a/webdavd/server.go b/webdavd/server.go index fdeaab3b..7bd620eb 100644 --- a/webdavd/server.go +++ b/webdavd/server.go @@ -208,7 +208,7 @@ func (s *webDavServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { common.Connections.Add(connection) defer common.Connections.Remove(connection.GetID()) - dataprovider.UpdateLastLogin(&user) //nolint:errcheck + dataprovider.UpdateLastLogin(&user) if s.checkRequestMethod(ctx, r, connection) { w.Header().Set("Content-Type", "text/xml; charset=utf-8")