using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Notifications; using RadicadeServer.DB; using RadicadeServer.Radicade.Discord; using RadicadeServer.RecNet.Enums.Messages; using RadicadeServer.RecNet.Enums.Moderation; using RadicadeServer.RecNet.Moderation; using RadicadeServer.RecNet.Notifications; namespace RadicadeServer.Utils { public sealed record AccountLockResult(bool Success, bool Changed, string? Error = null); public static class AccountLock { public const string BanMethod = "Account Lock"; public const string Reason = "Moderator (Account lock. Please submit a ticket with us to discuss. https://support.radicade.app/new/account-support/)"; public const string SupportUrl = "https://support.radicade.app"; public const string ErrorCode = "ACCOUNT_PERMALOCKED"; public const string NotAppealableMessage = "This ban cannot be appealed"; public static bool IsLockBan(ModerationDB ban) => string.Equals(ban.BanMethod, BanMethod, StringComparison.OrdinalIgnoreCase); public const string FallbackUsername = "Account"; public static string LoginErrorMessage(string? username) { var name = (username ?? string.Empty).Trim(); if (name.StartsWith("@", StringComparison.Ordinal)) name = name[1..].Trim(); if (string.IsNullOrWhiteSpace(name)) name = FallbackUsername; return $"@{name}: This account has been locked for security reasons. Contact us at {SupportUrl}."; } public static Task FindActiveLockAsync(RecDBContext db, long accountId) => db.Moderation.AsNoTracking() .Where(x => x.PlayerId == accountId && x.IsBan && x.BanMethod == BanMethod && x.ExpireDuration > DateTime.UtcNow) .OrderByDescending(x => x.Id) .FirstOrDefaultAsync(); public static async Task IsLockedAsync(RecDBContext db, long accountId) { try { return await FindActiveLockAsync(db, accountId) != null; } catch (Exception ex) { Log($"could not read lock state for account {accountId} - failing closed ({ex.GetType().Name})"); return true; } } public static async Task ResolveUsernameAsync(RecDBContext db, long accountId) { try { var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == accountId); return profile?.username; } catch { return null; } } public static async Task ApplyAsync(RecDBContext db, long accountId, string? staff, string? caseRef) { bool changed; try { var existing = await db.Moderation.FirstOrDefaultAsync(x => x.PlayerId == accountId && x.IsBan); var note = NormalizeCase(caseRef); if (existing == null) { existing = new ModerationDB { PlayerId = accountId, IsBan = true, Category = ReportCategory.Moderator, Message = Reason, BanMethod = BanMethod, ExpireDuration = DateTime.MaxValue, CreatedAt = DateTime.UtcNow, ModeratorNote = note }; await db.Moderation.AddAsync(existing); changed = true; } else { changed = !IsAlreadyLocked(existing) || note != existing.ModeratorNote; existing.IsBan = true; existing.Category = ReportCategory.Moderator; existing.Message = Reason; existing.BanMethod = BanMethod; existing.ExpireDuration = DateTime.MaxValue; existing.ModeratorNote = note ?? existing.ModeratorNote; } await db.SaveChangesAsync(); } catch (Exception ex) { Log($"failed to persist account lock for {accountId}: {ex.GetType().Name}"); AdminActionDiscord.LogAction("Account Lock", StaffName(staff), $"FAILED to lock account {accountId}: the permanent ban could not be saved ({ex.GetType().Name})."); return new AccountLockResult(false, false, "The lock could not be saved. Nothing was changed."); } var failures = new List(); await EnforceOnlineAsync(db, accountId, failures); var detail = $"Locked account {accountId}. Category: {ReportCategory.Moderator}. Method: {BanMethod}. Reason: {Reason}"; if (!string.IsNullOrWhiteSpace(caseRef)) detail += $" Case: {caseRef.Trim()}"; detail += failures.Count == 0 ? " Result: ban persisted, online state handled, new sign-ins refused." : $" Result: ban persisted; partial failures - {string.Join("; ", failures)}."; AdminActionDiscord.LogAction("Account Lock", StaffName(staff), detail); return new AccountLockResult(true, changed); } public static async Task RemoveAsync(RecDBContext db, long accountId, string? staff, string? caseRef) { ModerationDB? existing; try { existing = await db.Moderation.FirstOrDefaultAsync(x => x.PlayerId == accountId && x.IsBan); } catch (Exception ex) { Log($"failed to read ban state before unlock for {accountId}: {ex.GetType().Name}"); return new AccountLockResult(false, false, "The ban state could not be read."); } if (existing == null) return new AccountLockResult(false, false, "This account does not have an active ban."); if (!string.Equals(existing.BanMethod, BanMethod, StringComparison.OrdinalIgnoreCase)) return new AccountLockResult(false, false, $"The active ban is '{existing.BanMethod}', not an account lock. Refusing to remove an unrelated ban."); var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == accountId); try { db.Moderation.Remove(existing); await db.SaveChangesAsync(); } catch (Exception ex) { Log($"failed to persist account unlock for {accountId}: {ex.GetType().Name}"); AdminActionDiscord.LogAction("Account Unlock", StaffName(staff), $"FAILED to unlock account {accountId}: the ban row could not be removed ({ex.GetType().Name}). The account is still locked."); return new AccountLockResult(false, false, "The unlock could not be saved. The account is still locked."); } var detail = $"Unlocked account {accountId}: the account lock was removed; hardware/IP bans and unrelated records were left untouched."; if (!string.IsNullOrWhiteSpace(caseRef)) detail += $" Case: {caseRef.Trim()}"; AdminActionDiscord.LogAction("Account Unlock", StaffName(staff), detail); await BestEffortAsync(() => Notify.SendNotify(accountId, new Notification { Id = NotificationType.RefreshLogin, Msg = profile })); await BestEffortAsync(() => PlayerUtils.ReleaseModerationHoldAsync(accountId)); return new AccountLockResult(true, true); } private static bool IsAlreadyLocked(ModerationDB ban) => ban.IsBan && ban.Category == ReportCategory.Moderator && ban.Message == Reason && string.Equals(ban.BanMethod, BanMethod, StringComparison.OrdinalIgnoreCase) && ban.ExpireDuration >= DateTime.MaxValue.AddYears(-10); private static string? NormalizeCase(string? caseRef) { var trimmed = (caseRef ?? string.Empty).Trim(); return trimmed.Length == 0 ? null : trimmed; } private static string StaffName(string? staff) => string.IsNullOrWhiteSpace(staff) ? "Unknown" : staff.Trim(); private static async Task EnforceOnlineAsync(RecDBContext db, long accountId, List failures) { bool online; try { var liveClient = Notify.GetClient(accountId) != null; var presence = await db.Presences.AsNoTracking().FirstOrDefaultAsync(x => x.playerId == accountId); online = liveClient || presence?.isOnline == true; } catch (Exception ex) { failures.Add($"online-state check failed ({ex.GetType().Name})"); return; } if (!online) return; try { MatchmakingUtils.ClearInRoom(accountId); } catch (Exception ex) { failures.Add($"clearing the in-room session failed ({ex.GetType().Name})"); } var startedAt = DateTime.UtcNow; try { var lockBan = await db.Moderation.AsNoTracking() .FirstOrDefaultAsync(x => x.PlayerId == accountId && x.IsBan && x.BanMethod == BanMethod); if (lockBan != null) startedAt = lockBan.CreatedAt; } catch { } try { await PlayerUtils.EjectToDormAsync(db, accountId); } catch (Exception ex) { failures.Add($"dorm transition failed ({ex.GetType().Name})"); } try { var details = PlayerUtils.BuildBlockDetails(true, ReportCategory.Moderator, Reason, DateTime.MaxValue, startedAtUtc: startedAt); await Notify.SendNotify(accountId, new Notification { Id = NotificationType.ModerationKick, Msg = details }); } catch (Exception ex) { failures.Add($"moderation notification failed ({ex.GetType().Name})"); } } private static async Task BestEffortAsync(Func action) { try { await action(); } catch { } } private static void Log(string message) => Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] {message}"); } }