using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Notifications; using RadicadeServer.DB; using RadicadeServer.RecNet.Auth; using RadicadeServer.RecNet.Enums.Messages; using RadicadeServer.RecNet.Enums.Moderation; using RadicadeServer.RecNet.Moderation; using RadicadeServer.RecNet.Notifications; namespace RadicadeServer.Utils { public class BanStatusMonitor : BackgroundService { private static readonly TimeSpan Interval = TimeSpan.FromSeconds(15); private readonly IDbContextFactory _dbFactory; private static readonly System.Collections.Concurrent.ConcurrentDictionary LastSeenBan = new(); private static readonly System.Collections.Concurrent.ConcurrentDictionary LastSeenRoom = new(); private static readonly System.Collections.Concurrent.ConcurrentDictionary LastSeenVoiceRestriction = new(); public BanStatusMonitor(IDbContextFactory dbFactory) { _dbFactory = dbFactory; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { await CheckConnectedPlayersAsync(stoppingToken); } catch (Exception) { } await Task.Delay(Interval, stoppingToken); } } private async Task CheckConnectedPlayersAsync(CancellationToken ct) { var clients = Notify.SnapshotClients(); if (clients.Count == 0) return; var accountIds = clients.Select(c => c.AccountId).Distinct().ToList(); await using var db = await _dbFactory.CreateDbContextAsync(ct); var players = await db.Players.AsNoTracking() .Where(x => accountIds.Contains(x.AccountId)).ToListAsync(ct); var presences = await db.Presences.AsNoTracking() .Where(x => accountIds.Contains(x.playerId)).ToListAsync(ct); var moderationBans = await db.Moderation .Where(x => x.IsBan && x.BanMethod != AccountLock.BanMethod && accountIds.Contains(x.PlayerId)) .ToListAsync(ct); var hardwareBans = await db.HardwareBans.ToListAsync(ct); var ipBans = await db.IpBans.ToListAsync(ct); var voiceRestrictions = await db.VoiceChatRestrictions.AsNoTracking() .Where(x => accountIds.Contains(x.PlayerId) && x.ClearedAt == null && x.ExpiresAt > DateTime.UtcNow) .ToListAsync(ct); var now = DateTime.UtcNow; bool cleaned = false; foreach (var m in moderationBans.Where(m => m.ExpireDuration < now).ToList()) { db.Moderation.Remove(m); moderationBans.Remove(m); cleaned = true; } foreach (var h in hardwareBans.Where(h => h.ExpiresAt.HasValue && h.ExpiresAt.Value < now).ToList()) { db.HardwareBans.Remove(h); hardwareBans.Remove(h); cleaned = true; } foreach (var i in ipBans.Where(i => i.ExpiresAt.HasValue && i.ExpiresAt.Value < now).ToList()) { db.IpBans.Remove(i); ipBans.Remove(i); cleaned = true; } if (cleaned) await db.SaveChangesAsync(ct); var connected = new HashSet(accountIds); foreach (var client in clients) { var id = client.AccountId; var player = players.FirstOrDefault(p => p.AccountId == id); if (player == null) continue; var moderation = moderationBans.FirstOrDefault(m => m.PlayerId == id); var hwBan = !string.IsNullOrWhiteSpace(player.DeviceId) ? hardwareBans.FirstOrDefault(h => h.DeviceId == player.DeviceId) : null; var ipBan = ResolveIpBan(ipBans, player); string? signature = null; string? reason = null; DateTime? startedAt = null; DateTime expiry = DateTime.MaxValue; var banCategory = ReportCategory.Misc; if (moderation != null) { signature = "mod:" + moderation.Id; reason = moderation.Message ?? ""; startedAt = moderation.CreatedAt; expiry = moderation.ExpireDuration; banCategory = moderation.Category; } else if (hwBan != null) { signature = "hw:" + hwBan.Id; reason = hwBan.Reason ?? ""; startedAt = hwBan.CreatedAt; expiry = hwBan.ExpiresAt ?? DateTime.MaxValue; } else if (ipBan != null) { signature = "ip:" + ipBan.IpAddress; reason = ipBan.Reason ?? ""; startedAt = ipBan.CreatedAt; expiry = ipBan.ExpiresAt ?? DateTime.MaxValue; } var presence = presences.FirstOrDefault(p => p.playerId == id); var last = LastSeenBan.GetValueOrDefault(id); var roomKey = presence?.roomInstance ?? -1; var lastRoom = LastSeenRoom.GetValueOrDefault(id, long.MinValue); if (signature != null) { var banChanged = last != signature; var roomChanged = lastRoom != roomKey; LastSeenBan[id] = signature; LastSeenRoom[id] = roomKey; if (banChanged || roomChanged) { if (!await db.IsDeveloper(id)) await KickIfInRoomAsync(db, id, reason ?? "", expiry, startedAt, hwBan?.BannedAccountId, banChanged, banCategory); } } else if (last != null) { LastSeenBan.TryRemove(id, out _); LastSeenRoom.TryRemove(id, out _); await PlayerUtils.ReleaseModerationHoldAsync(id, await db.IsDeveloper(id)); var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == id); if (profile != null) await Notify.SendNotify(id, new Notification { Id = NotificationType.RefreshLogin, Msg = profile }); } var restriction = voiceRestrictions.FirstOrDefault(r => r.PlayerId == id); var restrictionSignature = restriction == null ? null : $"vr:{restriction.Id}:{restriction.ExpiresAt.Ticks}"; var known = LastSeenVoiceRestriction.TryGetValue(id, out var lastRestriction) ? lastRestriction : null; if (known != restrictionSignature) { if (restrictionSignature == null) LastSeenVoiceRestriction.TryRemove(id, out _); else LastSeenVoiceRestriction[id] = restrictionSignature; await VoiceChatRestrictions.PushAsync(id, restriction); } } foreach (var key in LastSeenBan.Keys) { if (!connected.Contains(key)) LastSeenBan.TryRemove(key, out _); LastSeenRoom.TryRemove(key, out _); } foreach (var key in LastSeenVoiceRestriction.Keys) { if (!connected.Contains(key)) LastSeenVoiceRestriction.TryRemove(key, out _); } } private static IpBan? ResolveIpBan(List ipBans, PlayerData player) { if (string.IsNullOrWhiteSpace(player.LastIpAddress)) return null; return ipBans.FirstOrDefault(b => b.IpAddress == player.LastIpAddress); } private async Task KickIfInRoomAsync(RecDBContext db, long accountId, string reason, DateTime expiry, DateTime? startedAt = null, long? relatedAccountId = null, bool banChanged = false, ReportCategory category = ReportCategory.Misc) { var presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == accountId); if (presence?.roomInstance != null) { var roomInstance = await db.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == presence.roomInstance); var room = roomInstance != null ? await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomInstance.roomId) : null; bool isOwnDorm = room != null && room.IsDorm && room.CreatorAccountId == accountId; if (isOwnDorm) { bool relatedBan = relatedAccountId.HasValue && relatedAccountId.Value != accountId; if (!relatedBan || !banChanged) return; } else { await PlayerUtils.EjectToDormAsync(db, accountId); } } await Notify.SendNotify(accountId, new Notification { Id = NotificationType.ModerationKick, Msg = PlayerUtils.BuildBlockDetails(true, category, reason, expiry, startedAtUtc: startedAt, relatedUsername: await PlayerUtils.RelatedAccountUsernameAsync(db, relatedAccountId, accountId)) }); } } }