mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 07:29:00 -07:00
243 lines
8.6 KiB
C#
243 lines
8.6 KiB
C#
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<ModerationDB?> 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<bool> 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<string?> 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<AccountLockResult> 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<string>();
|
|
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<AccountLockResult> 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<string> 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<Task> action)
|
|
{
|
|
try { await action(); }
|
|
catch { }
|
|
}
|
|
|
|
private static void Log(string message) =>
|
|
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] {message}");
|
|
}
|
|
}
|