using System.Collections.Concurrent; using CS2_SimpleAdmin.Database; using CS2_SimpleAdmin.Models; using Dapper; using Microsoft.Extensions.Logging; using ZLinq; namespace CS2_SimpleAdmin.Managers; internal class CacheManager: IDisposable { private readonly ConcurrentDictionary _banCache = []; private readonly ConcurrentDictionary> _steamIdIndex = []; private readonly ConcurrentDictionary> _ipIndex = []; private readonly ConcurrentDictionary> _playerIpsCache = []; private HashSet _cachedIgnoredIps = []; private DateTime _lastUpdateTime = DateTime.MinValue; private DateTime? _lastDatabaseTime = null; // Track actual time from database private bool _isInitialized; private bool _disposed; /// /// Initializes and builds the ban and IP cache from the database. Loads bans, player IP history, and config settings. /// /// Asynchronous task representing the initialization process. public async Task InitializeCacheAsync() { if (CS2_SimpleAdmin.DatabaseProvider == null) return; if (!CS2_SimpleAdmin.ServerLoaded) return; if (_isInitialized) return; try { Clear(); _cachedIgnoredIps = CS2_SimpleAdmin.Instance.Config.OtherSettings.IgnoredIps .AsValueEnumerable() .Select(IpHelper.IpToUint) .ToHashSet(); await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync(); List bans; if (CS2_SimpleAdmin.Instance.Config.MultiServerMode) { bans = (await connection.QueryAsync( """ SELECT id AS Id, player_name AS PlayerName, player_steamid AS PlayerSteamId, player_ip AS PlayerIp, status AS Status FROM sa_bans """)).ToList(); } else { bans = (await connection.QueryAsync( """ SELECT id AS Id, player_name AS PlayerName, player_steamid AS PlayerSteamId, player_ip AS PlayerIp, status AS Status FROM sa_bans WHERE server_id = @serverId """, new {serverId = CS2_SimpleAdmin.ServerId})).ToList(); } if (CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp) { // Optimization: Load IP history and build cache in single pass var ipHistory = await connection.QueryAsync<(ulong steamid, string? name, uint address, DateTime used_at)>( "SELECT steamid, name, address, used_at FROM sa_players_ips ORDER BY steamid, address, used_at DESC"); var unknownName = CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown"; var currentSteamId = 0UL; var currentIpSet = new HashSet(new IpRecordComparer()); var latestIpTimestamps = new Dictionary(); foreach (var record in ipHistory) { // When we encounter a new steamid, save the previous one if (record.steamid != currentSteamId && currentSteamId != 0) { _playerIpsCache[currentSteamId] = currentIpSet; currentIpSet = new HashSet(new IpRecordComparer()); latestIpTimestamps.Clear(); } currentSteamId = record.steamid; // Only keep the latest timestamp for each IP if (!latestIpTimestamps.TryGetValue(record.address, out var existingTimestamp) || record.used_at > existingTimestamp) { latestIpTimestamps[record.address] = record.used_at; currentIpSet.Add(new IpRecord( record.address, record.used_at, string.IsNullOrEmpty(record.name) ? unknownName : record.name )); } } // Don't forget the last steamid if (currentSteamId != 0) { _playerIpsCache[currentSteamId] = currentIpSet; } } foreach (var ban in bans.AsValueEnumerable()) _banCache.TryAdd(ban.Id, ban); RebuildIndexes(); _lastUpdateTime = Time.ActualDateTime().AddSeconds(-1); _isInitialized = true; } catch (Exception e) { Console.WriteLine(e.ToString()); } } /// /// Clears all cached data and reinitializes the cache from the database. /// /// Asynchronous task representing the reinitialization process. public async Task ForceReInitializeCacheAsync() { _isInitialized = false; _banCache.Clear(); _playerIpsCache.Clear(); _cachedIgnoredIps = []; _lastUpdateTime = DateTime.MinValue; await InitializeCacheAsync(); } /// /// Refreshes the in-memory cache with updated or new data from the database since the last update time. /// Also updates multi-account IP history if enabled. /// /// Asynchronous task representing the refresh operation. public async Task RefreshCacheAsync() { if (CS2_SimpleAdmin.DatabaseProvider == null) return; if (!_isInitialized) return; try { await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync(); IEnumerable updatedBans; // Get current time from database in local timezone (CURRENT_TIMESTAMP uses session timezone, not UTC) var currentDatabaseTime = await connection.QueryFirstAsync("SELECT CURRENT_TIMESTAMP"); // Optimization: Only get IDs for comparison if we need to check for deletions // Most of the time bans are just added/updated, not deleted HashSet? allIds = null; if (CS2_SimpleAdmin.Instance.Config.MultiServerMode) { // Use previous database time or start from far past if first run var lastCheckTime = _lastDatabaseTime ?? DateTime.MinValue; // Get recently updated bans by timestamp (using database time to avoid timezone issues) var updatedBans_Query = (await connection.QueryAsync( """ SELECT id AS Id, player_name AS PlayerName, player_steamid AS PlayerSteamId, player_ip AS PlayerIp, status AS Status FROM `sa_bans` WHERE updated_at > @lastUpdate OR created > @lastUpdate ORDER BY updated_at DESC """, new { lastUpdate = lastCheckTime } )).ToList(); // Detect changes: new bans or status changes var updatedList = new List(); foreach (var ban in updatedBans_Query) { if (!_banCache.TryGetValue(ban.Id, out var cachedBan)) { // New ban updatedList.Add(ban); } else if (cachedBan.Status != ban.Status) { // Status changed updatedList.Add(ban); } } if (updatedList.Count > 0) { allIds = (await connection.QueryAsync("SELECT id FROM sa_bans")).ToHashSet(); } updatedBans = updatedList; // Update last check time to current database time _lastDatabaseTime = currentDatabaseTime; } else { // Use previous database time or start from far past if first run var lastCheckTime = _lastDatabaseTime ?? DateTime.MinValue; // Get recently updated bans for this server by timestamp (using database time to avoid timezone issues) var updatedBans_Query = (await connection.QueryAsync( """ SELECT id AS Id, player_name AS PlayerName, player_steamid AS PlayerSteamId, player_ip AS PlayerIp, status AS Status FROM `sa_bans` WHERE server_id = @serverId AND (updated_at > @lastUpdate OR created > @lastUpdate) ORDER BY updated_at DESC """, new { serverId = CS2_SimpleAdmin.ServerId, lastUpdate = lastCheckTime } )).ToList(); // Detect changes: new bans or status changes var updatedList = new List(); foreach (var ban in updatedBans_Query) { if (!_banCache.TryGetValue(ban.Id, out var cachedBan)) { // New ban updatedList.Add(ban); } else if (cachedBan.Status != ban.Status) { // Status changed updatedList.Add(ban); } } if (updatedList.Count > 0) { allIds = (await connection.QueryAsync( "SELECT id FROM sa_bans WHERE server_id = @serverId", new { serverId = CS2_SimpleAdmin.ServerId } )).ToHashSet(); } updatedBans = updatedList; // Update last check time to current database time _lastDatabaseTime = currentDatabaseTime; } // Optimization: Only process deletions if we have the full ID list if (allIds != null) { foreach (var id in _banCache.Keys) { if (allIds.Contains(id) || !_banCache.TryRemove(id, out var ban)) continue; if (ban.PlayerSteamId != null && _steamIdIndex.TryGetValue(ban.PlayerSteamId.Value, out var steamBans)) { steamBans.RemoveAll(b => b.Id == id); if (steamBans.Count == 0) _steamIdIndex.TryRemove(ban.PlayerSteamId.Value, out _); } if (string.IsNullOrWhiteSpace(ban.PlayerIp) || !IpHelper.TryConvertIpToUint(ban.PlayerIp, out var ipUInt) || !_ipIndex.TryGetValue(ipUInt, out var ipBans)) continue; { ipBans.RemoveAll(b => b.Id == id); if (ipBans.Count == 0) _ipIndex.TryRemove(ipUInt, out _); } } } if (CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp) { var ipHistory = (await connection.QueryAsync<(ulong steamid, string? name, uint address, DateTime used_at)>( "SELECT steamid, name, address, used_at FROM sa_players_ips WHERE used_at >= @lastUpdate ORDER BY used_at DESC LIMIT 300", new { lastUpdate = _lastUpdateTime })); foreach (var group in ipHistory.AsValueEnumerable().GroupBy(x => x.steamid)) { var ipSet = new HashSet( group .GroupBy(x => x.address) .Select(g => { var latest = g.MaxBy(x => x.used_at); return new IpRecord( g.Key, latest.used_at, !string.IsNullOrEmpty(latest.name) ? latest.name : CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown" ); }), new IpRecordComparer() ); _playerIpsCache.AddOrUpdate( group.Key, _ => ipSet, (_, existingSet) => { foreach (var newEntry in ipSet) { existingSet.Remove(newEntry); existingSet.Add(newEntry); } return existingSet; }); } } // Update cache with new/modified bans var needsRebuild = false; foreach (var ban in updatedBans) { if (_banCache.TryGetValue(ban.Id, out var oldBan) && oldBan.Status != ban.Status) { // Ban status changed (e.g., ACTIVE -> EXPIRED/UNBANNED), need to rebuild indexes needsRebuild = true; } _banCache.AddOrUpdate(ban.Id, ban, (_, _) => ban); } // Rebuild indexes if there were updates or status changes if (updatedBans.Any() || needsRebuild) { RebuildIndexes(); } _lastUpdateTime = Time.ActualDateTime().AddSeconds(-1); } catch (Exception) { } } /// /// Rebuilds the internal indexes for fast lookup of active bans by Steam ID and IP address. /// Clears and repopulates both indexes based on the current in-memory ban cache. /// private void RebuildIndexes() { _steamIdIndex.Clear(); _ipIndex.Clear(); // Optimization: Cache config value to avoid repeated property access var banType = CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType; var checkIpBans = banType != 0; // Optimization: Pre-filter only ACTIVE bans to avoid checking status in loop var activeBans = _banCache.Values.Where(b => b.StatusEnum == BanStatus.ACTIVE); foreach (var ban in activeBans) { // Index by Steam ID if (ban.PlayerSteamId.HasValue) { var steamId = ban.PlayerSteamId.Value; if (!_steamIdIndex.TryGetValue(steamId, out var steamList)) { steamList = new List(); _steamIdIndex[steamId] = steamList; } steamList.Add(ban); } // Index by IP (only if IP bans are enabled) if (checkIpBans && !string.IsNullOrEmpty(ban.PlayerIp) && IpHelper.TryConvertIpToUint(ban.PlayerIp, out var ipUInt)) { if (!_ipIndex.TryGetValue(ipUInt, out var ipList)) { ipList = new List(); _ipIndex[ipUInt] = ipList; } ipList.Add(ban); } } } /// /// Retrieves all ban records currently stored in the cache. /// /// List of all objects. public List GetAllBans() => _banCache.Values.ToList(); /// /// Retrieves only active ban records from the cache. /// /// List of active objects. public List GetActiveBans() => _banCache.Values.Where(b => b.StatusEnum == BanStatus.ACTIVE).ToList(); /// /// Retrieves all ban records for a specific player by their Steam ID. /// /// 64-bit Steam ID of the player. /// List of objects associated with the Steam ID. public List GetPlayerBansBySteamId(ulong steamId) => _steamIdIndex.TryGetValue(steamId, out var bans) ? bans : []; /// /// Gets all known Steam accounts that have used the specified IP address. /// /// The IP address to search for, in string format. /// /// List of tuples containing the Steam ID, last used time, and player name for each matching entry. /// public List<(ulong SteamId, DateTime UsedAt, string PlayerName)> GetAccountsByIp(string ipAddress) { var ipAsUint = IpHelper.IpToUint(ipAddress); var results = new List<(ulong, DateTime, string)>(); // Optimization: Direct lookup using HashSet.Contains instead of TryGetValue var searchRecord = new IpRecord(ipAsUint, default, null!); foreach (var (steamId, ipSet) in _playerIpsCache) { // Optimization: Single pass through the set foreach (var entry in ipSet) { if (entry.Ip == ipAsUint) { results.Add((steamId, entry.UsedAt, entry.PlayerName)); } } } return results; } // public IEnumerable<(ulong SteamId, DateTime UsedAt, string PlayerName)> GetAccountsByIp(string ipAddress) // { // var ipAsUint = IpHelper.IpToUint(ipAddress); // // return _playerIpsCache.SelectMany(kvp => kvp.Value // .Where(entry => entry.Ip == ipAsUint) // .Select(entry => (kvp.Key, entry.UsedAt, entry.PlayerName))); // } private bool IsIpBanned(string ipAddress) { if (CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType == 0) return false; var ipUInt = IpHelper.IpToUint(ipAddress); return !_cachedIgnoredIps.Contains(ipUInt) && _ipIndex.ContainsKey(ipUInt); } // public bool IsPlayerBanned(ulong? steamId, string? ipAddress) // { // if (steamId != null && _steamIdIndex.ContainsKey(steamId.Value)) // return true; // // if (CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType == 0) // return false; // // if (string.IsNullOrEmpty(ipAddress) || !IpHelper.TryConvertIpToUint(ipAddress, out var ipUInt)) // return false; // // return !_cachedIgnoredIps.Contains(ipUInt) && _ipIndex.ContainsKey(ipUInt); // } /// /// Checks if a player is currently banned by Steam ID or IP address. /// If a partial ban record is found, updates it with the latest player information. /// /// Name of the player attempting to connect. /// Optional 64-bit Steam ID of the player. /// Optional IP address of the player. /// True if the player is banned, otherwise false. public bool IsPlayerBanned(string playerName, ulong? steamId, string? ipAddress) { BanRecord? record; if (steamId.HasValue && _steamIdIndex.TryGetValue(steamId.Value, out var steamRecords)) { record = steamRecords.FirstOrDefault(r => r.StatusEnum == BanStatus.ACTIVE); if (record != null) { // Double-check the ban is still active in cache (handle race conditions) if (_banCache.TryGetValue(record.Id, out var cachedBan) && cachedBan.StatusEnum == BanStatus.ACTIVE) { if ((string.IsNullOrEmpty(record.PlayerIp) && !string.IsNullOrEmpty(ipAddress)) || (!record.PlayerSteamId.HasValue)) { _ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress)); } return true; } } } if (CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType == 0 || string.IsNullOrEmpty(ipAddress)) return false; if (string.IsNullOrEmpty(ipAddress) || !IpHelper.TryConvertIpToUint(ipAddress, out var ipUInt) || _cachedIgnoredIps.Contains(ipUInt) || !_ipIndex.TryGetValue(ipUInt, out var ipRecords)) return false; record = ipRecords.FirstOrDefault(r => r.StatusEnum == BanStatus.ACTIVE); if (record == null) return false; // Double-check the ban is still active in cache (handle race conditions) if (!_banCache.TryGetValue(record.Id, out var cachedBanIp) || cachedBanIp.StatusEnum != BanStatus.ACTIVE) return false; if ((string.IsNullOrEmpty(record.PlayerIp) && !string.IsNullOrEmpty(ipAddress)) || (!record.PlayerSteamId.HasValue && steamId.HasValue)) { _ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress)); } return true; } // public bool IsPlayerOrAnyIpBanned(ulong steamId, string? ipAddress) // { // if (_steamIdIndex.ContainsKey(steamId)) // return true; // // if (CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType == 0) // return false; // // if (!_playerIpsCache.TryGetValue(steamId, out var ipData)) // return false; // // // var now = Time.ActualDateTime(); // var cutoff = Time.ActualDateTime().AddDays(-CS2_SimpleAdmin.Instance.Config.OtherSettings.ExpireOldIpBans); // var unknownName = CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown"; // // if (ipAddress != null && IpHelper.TryConvertIpToUint(ipAddress, out var ipAsUint)) // { // if (!_cachedIgnoredIps.Contains(ipAsUint)) // { // ipData.Add(new IpRecord( // ipAsUint, // Time.ActualDateTime().AddSeconds(-2), // unknownName // )); // } // } // // // foreach (var ipRecord in ipData) // // { // // // Skip if too old or in ignored list // // if (ipRecord.UsedAt < cutoff || _cachedIgnoredIps.Contains(ipRecord.Ip)) // // continue; // // // // // Check if IP is banned // // if (_ipIndex.ContainsKey(ipRecord.Ip)) // // return true; // // } // // foreach (var ipRecord in ipData) // { // if (ipRecord.UsedAt < cutoff || _cachedIgnoredIps.Contains(ipRecord.Ip)) // continue; // // if (!_ipIndex.TryGetValue(ipRecord.Ip, out var banRecords)) continue; // // var activeBan = banRecords.FirstOrDefault(r => r.StatusEnum == BanStatus.ACTIVE); // if (activeBan == null) continue; // // if (!string.IsNullOrEmpty(activeBan.PlayerName) && activeBan.PlayerSteamId.HasValue) return true; // // _ = Task.Run(() => UpdatePlayerData( // activeBan.PlayerName, // steamId, // ipAddress // )); // // if (string.IsNullOrEmpty(activeBan.PlayerName) && !string.IsNullOrEmpty(unknownName)) // activeBan.PlayerName = unknownName; // // activeBan.PlayerSteamId ??= steamId; // // return true; // } // // return false; // } /// /// Checks if the player or any IP previously associated with them is currently banned. /// Also updates ban records with missing player info if found. /// /// Current player name. /// 64-bit Steam ID of the player. /// Current IP address of the player (optional). /// True if the player or their known IPs are banned, otherwise false. public bool IsPlayerOrAnyIpBanned(string playerName, ulong steamId, string? ipAddress) { if (_steamIdIndex.TryGetValue(steamId, out var steamBans)) { var activeBan = steamBans.FirstOrDefault(b => b.StatusEnum == BanStatus.ACTIVE); if (activeBan != null) { // Double-check the ban is still active in cache (handle race conditions) if (_banCache.TryGetValue(activeBan.Id, out var cachedBan) && cachedBan.StatusEnum == BanStatus.ACTIVE) { if (string.IsNullOrEmpty(activeBan.PlayerName) || string.IsNullOrEmpty(activeBan.PlayerIp) && !string.IsNullOrEmpty(ipAddress)) _ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress)); return true; } } } if (CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType == 0 || string.IsNullOrEmpty(ipAddress)) return false; if (!IpHelper.TryConvertIpToUint(ipAddress, out var ipUInt)) return false; if (_cachedIgnoredIps.Contains(ipUInt)) return false; if (!_ipIndex.TryGetValue(ipUInt, out var ipBanRecords)) return false; var ipBan = ipBanRecords.FirstOrDefault(r => r.StatusEnum == BanStatus.ACTIVE); if (ipBan == null) return false; if (!_banCache.TryGetValue(ipBan.Id, out var cachedIpBan) || cachedIpBan.StatusEnum != BanStatus.ACTIVE) return false; var expireOldIpBans = CS2_SimpleAdmin.Instance.Config.OtherSettings.ExpireOldIpBans; if (expireOldIpBans > 0) { var cutoff = Time.ActualDateTime().AddDays(-expireOldIpBans); if (ipBan.Created < cutoff) return false; } var unknownName = CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown"; if (string.IsNullOrEmpty(ipBan.PlayerName)) ipBan.PlayerName = playerName; ipBan.PlayerSteamId ??= steamId; _ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress)); return true; } /// /// Checks if the given IP address is known (previously recorded) for the specified Steam ID. /// /// 64-bit Steam ID of the player. /// IP address to check. /// True if the IP is recorded for the player, otherwise false. public bool HasIpForPlayer(ulong steamId, string ipAddress) { if (string.IsNullOrWhiteSpace(ipAddress)) return false; if (!IpHelper.TryConvertIpToUint(ipAddress, out var ipUint)) return false; return _playerIpsCache.TryGetValue(steamId, out var ipData) && ipData.Contains(new IpRecord(ipUint, default, null!)); } // public bool HasIpForPlayer(ulong steamId, string ipAddress) // { // if (string.IsNullOrWhiteSpace(ipAddress)) // return false; // // return _playerIpsCache.TryGetValue(steamId, out var ipData) // && ipData.Any(x => x.Ip == IpHelper.IpToUint(ipAddress)); // } /// /// Updates existing active ban records in the database with the latest known player name and IP address. /// Also updates in-memory cache to reflect these changes. /// /// Current player name. /// Optional Steam ID of the player. /// Optional IP address of the player. /// Asynchronous task representing the update operation. private async Task UpdatePlayerData(string? playerName, ulong? steamId, string? ipAddress) { if (CS2_SimpleAdmin.DatabaseProvider == null) return; var baseSql = """ UPDATE sa_bans SET player_ip = COALESCE(player_ip, @PlayerIP), player_name = COALESCE(player_name, @PlayerName) WHERE (player_steamid = @PlayerSteamID OR player_ip = @PlayerIP) AND status = 'ACTIVE' AND (duration = 0 OR ends > @CurrentTime) """; if (!CS2_SimpleAdmin.Instance.Config.MultiServerMode) { baseSql += " AND server_id = @ServerId;"; } var parameters = new { PlayerSteamID = steamId, PlayerIP = CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType == 0 || string.IsNullOrEmpty(ipAddress) || CS2_SimpleAdmin.Instance.Config.OtherSettings.IgnoredIps.Contains(ipAddress) ? null : ipAddress, PlayerName = string.IsNullOrEmpty(playerName) ? string.Empty : playerName, CurrentTime = Time.ActualDateTime(), CS2_SimpleAdmin.ServerId }; await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync(); await connection.ExecuteAsync(baseSql, parameters); if (steamId.HasValue && _steamIdIndex.TryGetValue(steamId.Value, out var steamRecords)) { foreach (var rec in steamRecords.Where(r => r.StatusEnum == BanStatus.ACTIVE)) { if (string.IsNullOrEmpty(rec.PlayerIp) && !string.IsNullOrEmpty(ipAddress)) rec.PlayerIp = ipAddress; if (string.IsNullOrEmpty(rec.PlayerName) && !string.IsNullOrEmpty(playerName)) rec.PlayerName = playerName; } } if (!string.IsNullOrEmpty(ipAddress) && IpHelper.TryConvertIpToUint(ipAddress, out var ipUInt) && _ipIndex.TryGetValue(ipUInt, out var ipRecords)) { foreach (var rec in ipRecords.Where(r => r.StatusEnum == BanStatus.ACTIVE)) { if (!rec.PlayerSteamId.HasValue && steamId.HasValue) rec.PlayerSteamId = steamId; if (string.IsNullOrEmpty(rec.PlayerName) && !string.IsNullOrEmpty(playerName)) rec.PlayerName = playerName; } } } private void Clear() { _steamIdIndex.Clear(); _ipIndex.Clear(); _banCache.Clear(); _playerIpsCache.Clear(); _cachedIgnoredIps.Clear(); } /// /// Clears and disposes of all cached data and marks the object as disposed. /// public void Dispose() { if (_disposed) return; Clear(); _disposed = true; } } public class IpRecordComparer : IEqualityComparer { public bool Equals(IpRecord x, IpRecord y) => x.Ip == y.Ip; public int GetHashCode(IpRecord obj) => obj.Ip.GetHashCode(); }