using Azure; using System.Collections.Concurrent; using System.Globalization; using System.Security.Cryptography; using System.Text.Json; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Notifications; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Accounts.Local; using RadicadeServer.RecNet.Auth; using RadicadeServer.RecNet.Auth.Cached; using RadicadeServer.RecNet.Auth.Enums; using RadicadeServer.RecNet.Avatar; using RadicadeServer.RecNet.Enums.Messages; using RadicadeServer.RecNet.Enums.Moderation; using RadicadeServer.RecNet.Enums.Players; using RadicadeServer.RecNet.Enums.Rooms; using RadicadeServer.RecNet.Leveling; using RadicadeServer.RecNet.Moderation; using RadicadeServer.RecNet.Notifications; using RadicadeServer.RecNet.Progression; using RadicadeServer.RecNet.Responses; using RadicadeServer.RecNet.Rooms; namespace RadicadeServer.Utils { public static class PlayerUtils { public static IDbContextFactory DbFactory = null!; private const int MaxPlatformIdLength = 64; private const int MaxDeviceIdLength = 256; private const int MaxPasswordLength = 128; private const int UsernameAttempts = 12; public static async Task CreateAccount(AccountCreationData acc, string clientIp = "", bool accountCreationInProgress = false) { if (acc == null) return new Success(false, "No account details were sent.", null); if (!TryReadRegistrationFields(acc, out var rejection, out var platformId, out var deviceId, out var birthday, out var email, out var avatar, out var password)) { return new Success(false, rejection, null); } await using var db = await DbFactory.CreateDbContextAsync(); var chosenUsername = UsernameRules.Parse(acc.username, out var usernameRejection); if (usernameRejection != null) return new Success(false, usernameRejection, null); bool usernameChosen = chosenUsername != null; string? username = null; IDisposable? acquiredNameGate = null; if (usernameChosen) { acquiredNameGate = await RegistrationGuard.LockNameAsync(chosenUsername!); if (!await IsNameTakenAsync(db, chosenUsername!)) username = chosenUsername; } else { for (var attempt = 0; attempt < UsernameAttempts && username == null; attempt++) { var generated = await GenerateFreeUsernameAsync(db); if (generated == null) break; acquiredNameGate = await RegistrationGuard.LockNameAsync(generated); if (await IsNameTakenAsync(db, generated)) { acquiredNameGate.Dispose(); acquiredNameGate = null; continue; } username = generated; } } if (username == null) { acquiredNameGate?.Dispose(); return new Success(false, usernameChosen ? "That name is already taken." : "Could not find an available name. Please choose one.", null); } using var nameGate = acquiredNameGate; if (email.Length > 0 && await db.Players.AnyAsync(x => x.Email.ToLower() == email.ToLower())) { email = string.Empty; } Profile? profile = null; try { await db.Database.CreateExecutionStrategy().ExecuteAsync(async () => { await using var attemptDb = await DbFactory.CreateDbContextAsync(); await using var transaction = attemptDb.Database.IsRelational() ? await attemptDb.Database.BeginTransactionAsync() : null; profile = await InsertNewAccountAsync(attemptDb, new NewAccountRows( platform: acc.platform, platformId: platformId, deviceId: deviceId, clientIp: clientIp, deviceClass: acc.deviceClass, username: username, usernameChosen: usernameChosen, password: password, birthday: birthday, email: email, avatar: avatar, accountCreationInProgress: accountCreationInProgress)); if (transaction != null) await transaction.CommitAsync(); }); } catch (Exception ex) { Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Registration] account insert failed: {ex.GetType().Name}: {ex.Message}"); return new Success(false, "Account creation failed. Nothing was saved - please try again.", null); } if (profile == null) { Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Registration] the account insert finished without producing a profile"); return new Success(false, "Account creation failed. Nothing was saved - please try again.", null); } try { await RoomUtils.GetOrCreateDorm(profile.accountId); } catch (Exception ex) { Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Registration] account {profile.accountId} dorm threw: {ex.GetType().Name}: {ex.Message}"); } RegistrationGuard.Record(clientIp, deviceId); Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Registration] created account {profile.accountId} " + $"('{profile.username}', platform={acc.platform}, {RegistrationGuard.Describe(clientIp, deviceId)}, " + $"nameChosen={usernameChosen}, password={password != null}, junior={profile.isJunior})"); return new Success(true, "", profile); } private sealed record NewAccountRows( Platforms platform, string platformId, string deviceId, string clientIp, DeviceClass deviceClass, string username, bool usernameChosen, string? password, DateTime? birthday, string email, string? avatar, bool accountCreationInProgress); private static async Task InsertNewAccountAsync(RecDBContext db, NewAccountRows rows) { var now = DateTime.Now; var hasher = new PasswordHasher(); var profile = new Profile { platforms = PlatformMask(rows.platform), username = rows.username, profileImage = "DefaultProfileImage", displayName = rows.username, isJunior = IsJuniorByBirthday(rows.birthday), createdAt = now }; await db.Profiles.AddAsync(profile); await db.SaveChangesAsync(); var accountId = profile.accountId; var playerAvatar = new PlayerAvatar { Avatar = rows.avatar ?? DBConfig.DefaultAvatar, Outfits = "[]", AccountId = accountId }; var reputation = new Reputation { AccountId = accountId }; var settings = new PlayerSettings { AccountId = accountId, Settings = rows.accountCreationInProgress ? DBConfig.AccountCreationInProgressSettings2023(rows.usernameChosen, rows.password != null) : DBConfig.NewAccountSettings2023 }; var progression = new ProgressionData() { PlayerId = accountId, XP = 0, Level = 1, Progress = new ProgressionJson().ToJson() }; var presence = new RealPresence { playerId = accountId, deviceClass = rows.deviceClass, statusVisibility = StatusVisibility.Public, vRMovementMode = VRMovementModes.TELEPORT }; var currency = new Currency { AccountId = accountId }; var unlocks = new PlayerUnlocks { AccountId = accountId, AvatarItems = DBConfig.DefaultAvatarUnlocks }; var cachedLogin = new CachedLogin { accountId = accountId, platform = rows.platform, platformId = rows.platformId, lastLoginTime = now }; var playerData = new PlayerData { AccountId = accountId, Phone = "", Birthday = rows.birthday, Parent = null, AppVersion = DBConfig.GameVersion, DeviceClass = rows.deviceClass, NameChanges = 99, Email = rows.email, Hashword = rows.password == null ? "" : hasher.HashPassword(new PlayerData(), rows.password), IsJunior = IsJuniorByBirthday(rows.birthday), JuniorState = JuniorState.BasedOnBirthday, Visibility = StatusVisibility.Public, DeviceId = rows.deviceId, LastIpAddress = rows.clientIp ?? "" }; await db.AddRangeAsync( cachedLogin, playerAvatar, reputation, settings, presence, currency, unlocks, playerData, progression ); await db.SaveChangesAsync(); if (rows.accountCreationInProgress || rows.usernameChosen || rows.password != null) await RefreshAccountCreationMarkersAsync(db, accountId, rows.usernameChosen); return profile; } private static bool TryReadRegistrationFields(AccountCreationData acc, out string? rejection, out string platformId, out string deviceId, out DateTime? birthday, out string email, out string? avatar, out string? password) { rejection = null; platformId = string.Empty; deviceId = string.Empty; birthday = null; email = string.Empty; avatar = null; password = null; platformId = acc.platformId?.Trim() ?? string.Empty; if (platformId.Length == 0) { rejection = "A platform id is required."; return false; } if (platformId.Length > MaxPlatformIdLength || !IsSafeIdentity(platformId)) { rejection = "That platform id is not valid."; return false; } deviceId = acc.deviceId?.Trim() ?? string.Empty; if (deviceId.Length == 0) { rejection = "A device id is required."; return false; } if (deviceId.Length > MaxDeviceIdLength || !IsSafeIdentity(deviceId)) { rejection = "That device id is not valid."; return false; } password = string.IsNullOrEmpty(acc.password) ? null : acc.password; if (password != null) { if (password.Length < RadicadeServer.Auth.AuthAPI.MinPasswordLength) { rejection = $"Passwords must be at least {RadicadeServer.Auth.AuthAPI.MinPasswordLength} characters."; return false; } if (password.Length > MaxPasswordLength) { rejection = $"Passwords must be {MaxPasswordLength} characters or fewer."; return false; } } var postedBirthday = acc.birthday ?? acc.birthdayUTC; birthday = ReadBirthday(acc.birthday, acc.birthdayUTC); if (birthday == null && postedBirthday != null) { rejection = "That birthday is not valid."; return false; } email = ReadEmail(acc.email); avatar = ReadAvatar(acc.avatar ?? acc.avatarJson); return true; } private static bool IsSafeIdentity(string value) { foreach (var character in value) { if (char.IsAsciiLetterOrDigit(character)) continue; if (character is ':' or '-' or '_' or '.' or '|') continue; return false; } return true; } public static int PlatformMask(Platforms platform) => platform == Platforms.All ? 1 : 1 << (int)platform; private static async Task GenerateFreeUsernameAsync(RecDBContext db) { for (var attempt = 0; attempt < UsernameAttempts; attempt++) { var candidate = UsernameRules.Generate(); if (UsernameRules.Rejection(candidate) != null) continue; if (await IsNameTakenAsync(db, candidate)) continue; return candidate; } return null; } public static async Task RefreshAccountCreationMarkersAsync(RecDBContext db, long accountId, bool? usernameChosen = null) { var player = await db.Players.FirstOrDefaultAsync(x => x.AccountId == accountId); if (player == null) return; var settings = await db.PlayerSettings.FirstOrDefaultAsync(x => x.AccountId == accountId); if (settings == null) { settings = new PlayerSettings { AccountId = accountId, Settings = DBConfig.AccountCreationInProgressSettings2023(false, false) }; db.PlayerSettings.Add(settings); } var values = settings.DeserializeSettings(); SetSettingFlag(values, "Recroom.AccountCreation.HasStarted", true); SetSettingFlag(values, "Recroom.AccountCreation.HasChosenUsername", usernameChosen ?? HasSettingFlag(values, "Recroom.AccountCreation.HasChosenUsername")); SetSettingFlag(values, "Recroom.AccountCreation.HasCreatedPassword", !string.IsNullOrWhiteSpace(player.Hashword)); SetSettingFlag(values, "Recroom.AccountCreation.AcceptedCodeOfConduct", player.CodeOfConductAccepted); settings.Settings = values.ToJson(); await db.SaveChangesAsync(); } public static async Task HasFinishedAccountCreationAsync(RecDBContext db, long accountId) { var settings = await db.PlayerSettings.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == accountId); return settings != null && HasSettingFlag(settings.DeserializeSettings(), "Recroom.AccountCreation.HasFinished"); } public static bool HasSettingFlag(List values, string key) => values.Any(setting => string.Equals(setting.Key, key, StringComparison.OrdinalIgnoreCase) && string.Equals(setting.Value, "true", StringComparison.OrdinalIgnoreCase)); public static void SetSettingFlag(List values, string key, bool value) { var existing = values.FirstOrDefault(setting => string.Equals(setting.Key, key, StringComparison.OrdinalIgnoreCase)); if (existing == null) values.Add(new Setting { Key = key, Value = value ? "True" : "False" }); else existing.Value = value ? "True" : "False"; } public static async Task FindAdoptableAccountForDeviceAsync(RecDBContext db, string? deviceId) { if (string.IsNullOrWhiteSpace(deviceId)) return null; var finished = await FinishedAccountIdsAsync(db); return await db.Players .Where(player => player.DeviceId == deviceId && !finished.Contains(player.AccountId)) .OrderByDescending(player => player.AccountId) .FirstOrDefaultAsync(); } private static async Task> FinishedAccountIdsAsync(RecDBContext db) { var settings = await db.PlayerSettings.AsNoTracking().ToListAsync(); var finished = new HashSet(); foreach (var row in settings) { if (row.DeserializeSettings().Any(setting => string.Equals(setting.Key, "Recroom.AccountCreation.HasFinished", StringComparison.OrdinalIgnoreCase) && string.Equals(setting.Value, "true", StringComparison.OrdinalIgnoreCase))) { finished.Add(row.AccountId); } } return finished; } public static async Task AdoptDeviceAccountAsync(RecDBContext db, long accountId, AccountCreationData acc, bool markStarted) { var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == accountId); var player = await db.Players.FirstOrDefaultAsync(x => x.AccountId == accountId); if (profile == null || player == null) return new Success(false, "Missing account data.", null); if (!await db.CachedLogins.AnyAsync(login => login.accountId == accountId && login.platform == acc.platform && login.platformId == acc.platformId)) { db.CachedLogins.Add(new CachedLogin { accountId = accountId, platform = acc.platform, platformId = acc.platformId, lastLoginTime = DateTime.Now }); } if (!string.IsNullOrWhiteSpace(acc.deviceId)) player.DeviceId = acc.deviceId; if (markStarted) { var settings = await db.PlayerSettings.FirstOrDefaultAsync(x => x.AccountId == accountId); if (settings == null) { db.PlayerSettings.Add(new PlayerSettings { AccountId = accountId, Settings = DBConfig.AccountCreationInProgressSettings2023(false, false) }); } else { var values = settings.DeserializeSettings(); var started = values.FirstOrDefault(x => string.Equals(x.Key, "Recroom.AccountCreation.HasStarted", StringComparison.OrdinalIgnoreCase)); if (started == null) values.Add(new Setting { Key = "Recroom.AccountCreation.HasStarted", Value = "True" }); else started.Value = "True"; settings.Settings = values.ToJson(); } } await db.SaveChangesAsync(); return new Success(true, "", profile); } public static string? ReadChosenUsername(string? posted) => UsernameRules.Parse(posted, out _); private static DateTime? ReadBirthday(DateTime? birthday, DateTime? birthdayUTC) => ValidBirthday(AsUtc(birthday ?? birthdayUTC)); public static DateTime? ReadBirthday(DateTime? posted) => ValidBirthday(AsUtc(posted)); public static DateTime? ParseBirthday(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return null; if (!DateTime.TryParse( raw, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed)) return null; return ValidBirthday(parsed); } private static DateTime? AsUtc(DateTime? value) { if (value == null) return null; return value.Value.Kind switch { DateTimeKind.Utc => value.Value, DateTimeKind.Local => value.Value.ToUniversalTime(), _ => DateTime.SpecifyKind(value.Value, DateTimeKind.Utc) }; } private static DateTime? ValidBirthday(DateTime? value) { if (value == null) return null; if (value.Value.Year < 1900 || value.Value > DateTime.UtcNow) return null; return value; } private static string ReadEmail(string? posted) { var email = posted?.Trim() ?? string.Empty; return email.Length <= 254 && email.Contains('@') ? email : string.Empty; } private static string? ReadAvatar(string? posted) { if (string.IsNullOrWhiteSpace(posted)) return null; try { var avatar = posted.FromJson(); return avatar == null ? null : avatar.ToJson(); } catch (JsonException) { return null; } } public static async Task IsNameTakenAsync(RecDBContext db, string name) { var key = UsernameRules.ComparisonKey(name); return await db.Profiles.AnyAsync(x => x.username.ToUpper() == key || x.displayName.ToUpper() == key); } public sealed record PlayerBanInfo( string Kind, string Reason, DateTime ExpiryUtc, string Signature, ReportCategory Category = ReportCategory.Misc, long? BannedAccountId = null, string? AppealCode = null, DateTime? StartedAtUtc = null, string? BanMethod = null, string? ModeratorNote = null) { public bool IsPermanent => ExpiryUtc >= DateTime.MaxValue.AddYears(-10); } public static async Task ResolveBanAsync(this RecDBContext ctx, long accountId, string? requestIp = null) { var player = await ctx.Players.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == accountId); var existingModeration = await ctx.Moderation.FirstOrDefaultAsync(x => x.PlayerId == accountId && x.IsBan); if (existingModeration != null) { if (existingModeration.ExpireDuration < DateTime.UtcNow) { ctx.Moderation.Remove(existingModeration); await ctx.SaveChangesAsync(); } else { var now = DateTime.UtcNow; var relatedHardwareBan = player != null && !string.IsNullOrWhiteSpace(player.DeviceId) ? await ctx.HardwareBans.FirstOrDefaultAsync(x => x.DeviceId == player.DeviceId && (!x.ExpiresAt.HasValue || x.ExpiresAt.Value >= now)) : null; return new PlayerBanInfo("Moderation", existingModeration.Message ?? "", existingModeration.ExpireDuration, "mod:" + existingModeration.Id, existingModeration.Category, BannedAccountId: relatedHardwareBan?.BannedAccountId, AppealCode: existingModeration.AppealCode, StartedAtUtc: existingModeration.CreatedAt, BanMethod: existingModeration.BanMethod, ModeratorNote: existingModeration.ModeratorNote); } } if (player != null && !string.IsNullOrWhiteSpace(player.DeviceId)) { var hardwareBan = await ctx.HardwareBans.FirstOrDefaultAsync(x => x.DeviceId == player.DeviceId); if (hardwareBan != null) { if (hardwareBan.ExpiresAt.HasValue && hardwareBan.ExpiresAt.Value < DateTime.UtcNow) { ctx.HardwareBans.Remove(hardwareBan); await ctx.SaveChangesAsync(); } else { return new PlayerBanInfo("Hardware", hardwareBan.Reason ?? "", hardwareBan.ExpiresAt ?? DateTime.MaxValue, "hw:" + hardwareBan.Id, ReportCategory.Misc, hardwareBan.BannedAccountId, StartedAtUtc: hardwareBan.CreatedAt, BanMethod: "Hardware Ban"); } } } var candidateIps = new List(); if (player != null && !string.IsNullOrWhiteSpace(player.LastIpAddress)) candidateIps.Add(player.LastIpAddress); if (!string.IsNullOrWhiteSpace(requestIp) && !candidateIps.Contains(requestIp)) candidateIps.Add(requestIp); foreach (var ip in candidateIps) { var ipBan = await ctx.IpBans.FirstOrDefaultAsync(x => x.IpAddress == ip); if (ipBan == null) continue; if (ipBan.ExpiresAt.HasValue && ipBan.ExpiresAt.Value < DateTime.UtcNow) { ctx.IpBans.Remove(ipBan); await ctx.SaveChangesAsync(); continue; } return new PlayerBanInfo("Ip", ipBan.Reason ?? "", ipBan.ExpiresAt ?? DateTime.MaxValue, "ip:" + ip, StartedAtUtc: ipBan.CreatedAt, BanMethod: "IP Ban"); } return null; } public static async Task IsBanned(this RecDBContext ctx, long id) { return await ctx.ResolveBanAsync(id) != null; } public static async Task IsDeveloper(this RecDBContext ctx, long accountId) { var profile = await ctx.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == accountId); return profile != null && profile.HasRole("developer"); } public static async Task KickOrPromptAsync(RecDBContext db, long accountId, BlockDetails details, bool devDecisionResolved = false) { if (!devDecisionResolved && await db.IsDeveloper(accountId)) { await Notify.SendNotify(accountId, new Notification { Id = NotificationType.ModerationKick, Msg = details }); return false; } await EjectToDormAsync(db, accountId); await Notify.SendNotify(accountId, new Notification { Id = NotificationType.ModerationKick, Msg = details }); return true; } public static readonly TimeSpan WarningScreenHold = TimeSpan.FromSeconds(60); public static readonly TimeSpan WarningPromptLeadIn = TimeSpan.FromMilliseconds(500); public static Task HasPendingWarningAsync(this RecDBContext ctx, long playerId) => ctx.PlayerWarnings.AnyAsync(x => x.WarnedPlayerId == playerId && x.AcknowledgedAt == null); public static Task GetPendingWarningAsync(this RecDBContext ctx, long playerId) => ctx.PlayerWarnings.AsNoTracking() .Where(x => x.WarnedPlayerId == playerId && x.AcknowledgedAt == null) .OrderBy(x => x.Id) .FirstOrDefaultAsync(); public static BlockDetails BuildWarningBlockDetails(PlayerWarning warning, int holdSeconds = 0) => new() { Duration = holdSeconds, GameSessionId = 0, IsBan = false, IsHostKick = false, IsWarning = true, Message = string.IsNullOrWhiteSpace(warning.DisplayReason) ? DefaultWarningMessage : warning.DisplayReason!, DisplayReason = warning.DisplayReason ?? string.Empty, PlayerIdReporter = warning.ModeratorPlayerId, ReportCategory = warning.ReportCategory, VoteKickReason = string.Empty, TimeoutStartedAt = warning.CreatedAt }; public const string DefaultWarningMessage = "You have been warned by a moderator."; public static BlockDetails BuildClearedBlockDetails() => new() { Duration = 0, GameSessionId = 0, PlayerIdReporter = 0, IsBan = false, IsHostKick = false, IsWarning = false, Message = string.Empty, DisplayReason = string.Empty, VoteKickReason = string.Empty, ReportCategory = ReportCategory.Unknown, TimeoutStartedAt = null }; public static async Task ReleaseModerationHoldAsync(long playerId, bool isDeveloper = false) { var cleared = BuildClearedBlockDetails(); for (int attempt = 1; attempt <= 2; attempt++) { try { if (!isDeveloper) { await Notify.SendNotify(playerId, new Notification { Id = NotificationType.ModerationKick, Msg = cleared }); } await Notify.SendNotify(playerId, new Notification { Id = NotificationType.ModerationUpdateRequired, Msg = cleared }); } catch (Exception) { } if (attempt == 1) { try { await Task.Delay(TimeSpan.FromSeconds(1)); } catch { } } } } public static async Task DeliverWarningAsync(long playerId, BlockDetails details) { try { await Notify.SendNotify(playerId, new Notification { Id = NotificationType.ModerationUpdateRequired, Msg = details }); } catch (Exception) { } if (details.Duration > 0) { try { await Task.Delay(WarningPromptLeadIn); } catch { } } try { await Notify.SendNotify(playerId, new Notification { Id = NotificationType.ModerationKick, Msg = details }); } catch (Exception) { } } public static async Task BlockedByPendingWarningAsync(RecDBContext db, long playerId, bool targetIsOwnDorm, string targetDescription) { try { if (targetIsOwnDorm) return null; var warning = await db.GetPendingWarningAsync(playerId); if (warning == null) return null; var dormInstance = await PinToDormAsync(db, playerId); await DeliverWarningAsync(playerId, BuildWarningBlockDetails(warning, (int)WarningScreenHold.TotalSeconds)); return dormInstance == null ? MatchResponse.Error(JoinStatus.BlockedFromRoom) : new MatchResponse { roomInstance = dormInstance, errorCode = JoinStatus.Success }; } catch (Exception) { return null; } } public enum DeveloperDecisionKind { Kick, Warning } public sealed record DeveloperDecision( DeveloperDecisionKind Kind, ReportCategory Category, string Reason, string? DisplayReason, string? ModeratorNote, long IssuedBy, DateTime RequestedAt); private static readonly ConcurrentDictionary _pendingDeveloperDecisions = new(); public static readonly TimeSpan DeveloperDecisionWindow = TimeSpan.FromMinutes(5); public static async Task DeferForDeveloperAsync(RecDBContext db, long playerId, DeveloperDecisionKind kind, BlockDetails details) { if (!await db.IsDeveloper(playerId)) return false; var decision = new DeveloperDecision( kind, details.ReportCategory, string.IsNullOrWhiteSpace(details.VoteKickReason) ? details.Message : details.VoteKickReason, string.IsNullOrWhiteSpace(details.DisplayReason) ? details.Message : details.DisplayReason, details.ModeratorNote, details.PlayerIdReporter, DateTime.UtcNow); if (_pendingDeveloperDecisions.TryGetValue(playerId, out var stale)) { var expired = DateTime.UtcNow - stale.RequestedAt > DeveloperDecisionWindow; var unreachable = Notify.GetClient(playerId) == null; if (expired || unreachable) { _pendingDeveloperDecisions.TryRemove(playerId, out _); } } var parked = _pendingDeveloperDecisions.GetOrAdd(playerId, decision); if (!ReferenceEquals(parked, decision)) { return true; } if (Notify.GetClient(playerId) == null) { return true; } try { await Notify.SendNotify(playerId, new Notification { Id = NotificationType.ModerationKick, Msg = details }); } catch (Exception) { } return true; } public static DeveloperDecision? TakeDeveloperDecision(long playerId) { if (!_pendingDeveloperDecisions.TryRemove(playerId, out var decision)) return null; if (DateTime.UtcNow - decision.RequestedAt > DeveloperDecisionWindow) { return null; } return decision; } public static string GenerateAppealCode() { const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; return RandomNumberGenerator.GetString(chars, 8); } public static string AppealUrl() { return "https://support.radicade.app/new/ban-appeal/"; } public static string BuildBanAppealText(string appealCode) { return $"Appeal your ban at {AppealUrl()} with code: {appealCode}"; } public static string PendingAppealText => "Your ban appeal has been submitted and is pending review by a moderator. You will be notified when it is accepted or denied."; public const string SupportSiteAppealText = "Submitted through the support site."; public static readonly TimeSpan AppealCooldownDuration = TimeSpan.FromDays(30); public const string AppealCooldownPrefix = "You cannot file another ban appeal for: "; public static async Task GetAppealCooldownErrorAsync(RecDBContext db, long playerId) { var now = DateTime.UtcNow; var resolved = await db.Moderation.AsNoTracking() .Where(x => x.PlayerId == playerId && (x.AppealResolution == "Rejected" || x.AppealResolution == "Reduced" || x.AppealResolution == "Extended" || x.AppealResolution == "Reviewed") && x.AppealResolvedAt.HasValue && x.AppealResolvedAt.Value > now.Subtract(AppealCooldownDuration)) .OrderByDescending(x => x.AppealResolvedAt) .FirstOrDefaultAsync(); if (resolved?.AppealResolvedAt == null) return null; var remaining = resolved.AppealResolvedAt.Value.Add(AppealCooldownDuration) - now; return $"{AppealCooldownPrefix}{FormatAppealCooldown(remaining)}"; } public static async Task<(string? Code, string? CooldownError)> GetAppealForNewBanAsync(RecDBContext db, long playerId) { var cooldownError = await GetAppealCooldownErrorAsync(db, playerId); return cooldownError == null ? (GenerateAppealCode(), null) : (null, cooldownError); } public static string FormatAppealCooldown(TimeSpan remaining) { if (remaining <= TimeSpan.Zero) return "0 minutes"; if (remaining.TotalDays >= 1) { var days = (int)Math.Floor(remaining.TotalDays); var hours = (int)remaining.TotalHours % 24; var dayText = $"{days} day{(days == 1 ? "" : "s")}"; return hours > 0 ? $"{dayText}, {hours} hour{(hours == 1 ? "" : "s")}" : dayText; } if (remaining.TotalHours >= 1) { var hours = (int)Math.Ceiling(remaining.TotalHours); return $"{hours} hour{(hours == 1 ? "" : "s")}"; } var mins = Math.Max(1, (int)Math.Ceiling(remaining.TotalMinutes)); return $"{mins} minute{(mins == 1 ? "" : "s")}"; } public static async Task RelatedAccountUsernameAsync(RecDBContext db, long? bannedAccountId, long accountId) { if (!bannedAccountId.HasValue || bannedAccountId.Value == accountId) return null; var profile = await db.Profiles.AsNoTracking() .FirstOrDefaultAsync(x => x.accountId == bannedAccountId.Value); return profile?.username; } public static async Task BuildBanBlockDetailsAsync(RecDBContext db, PlayerBanInfo ban, long accountId) => ApplyClientBanKind( BuildBlockDetails(true, ban.Category, ban.Reason, ban.ExpiryUtc, appealCode: ban.AppealCode, startedAtUtc: ban.StartedAtUtc, relatedUsername: await RelatedAccountUsernameAsync(db, ban.BannedAccountId, accountId)), ban.BanMethod); public static BlockDetails ApplyClientBanKind(BlockDetails details, string? banMethod) { if (ModerationPolicy.IsVoiceModeration(banMethod)) { details.BanType = "VoiceModeration"; details.IsVoiceModAutoban = true; details.BanAppealText = AccountLock.NotAppealableMessage; } else if (ModerationPolicy.IsTimeout(banMethod)) { details.BanType = "Timeout"; details.IsVoiceModAutoban = false; details.BanAppealCode = string.Empty; details.BanAppealText = string.Empty; } else { details.BanType = "Moderation"; details.IsVoiceModAutoban = false; } return details; } public static BlockDetails BuildBlockDetails( bool isBan, ReportCategory category, string? message, DateTime? expiryUtc = null, long reporterId = 1, bool isHostKick = false, string? appealCode = null, string? appealError = null, DateTime? startedAtUtc = null, string? relatedUsername = null) { bool isPermanent = expiryUtc.HasValue && (expiryUtc.Value >= DateTime.MaxValue.AddYears(-10) || (expiryUtc.Value - DateTime.UtcNow).TotalDays > 365 * 10); int duration = 0; int? bannedUntil = null; if (expiryUtc.HasValue) { var remaining = expiryUtc.Value - DateTime.UtcNow; if (remaining > TimeSpan.Zero && !isPermanent) duration = (int)remaining.TotalSeconds; if (isPermanent) duration = int.MaxValue; else { var unix = new DateTimeOffset(DateTime.SpecifyKind(expiryUtc.Value, DateTimeKind.Utc), TimeSpan.Zero) .ToUnixTimeSeconds(); if (unix <= int.MaxValue) bannedUntil = (int)unix; } } var reason = message ?? string.Empty; if (!string.IsNullOrWhiteSpace(relatedUsername)) reason = reason + $"\nRelated account: @{relatedUsername}"; var code = appealCode ?? string.Empty; return new BlockDetails { Duration = duration, GameSessionId = 0, PlayerIdReporter = reporterId, IsBan = isBan, IsHostKick = isHostKick, Message = reason, ReportCategory = category, VoteKickReason = isHostKick ? string.Empty : reason, BannedUntil = bannedUntil, DisplayReason = reason, BanType = isBan ? "Moderation" : string.Empty, TimeoutStartedAt = isBan ? startedAtUtc : null, BanAppealCode = code, BanAppealText = code.Length > 0 ? BuildBanAppealText(code) : (appealError ?? string.Empty) }; } public static string GetClientIp(this HttpRequest request) { try { static string NormalizeIp(string ip) { if (string.IsNullOrWhiteSpace(ip)) return ""; ip = ip.Trim(); if (ip.StartsWith("::ffff:", StringComparison.OrdinalIgnoreCase)) ip = ip.Substring("::ffff:".Length); return ip; } static bool IsLoopback(string ip) { if (string.IsNullOrWhiteSpace(ip)) return false; return ip == "::1" || ip == "127.0.0.1" || ip.StartsWith("::ffff:127.", StringComparison.OrdinalIgnoreCase) || ip.StartsWith("127.", StringComparison.Ordinal); } var cfConnecting = NormalizeIp(request.Headers["CF-Connecting-IP"].FirstOrDefault() ?? ""); if (!string.IsNullOrWhiteSpace(cfConnecting)) return cfConnecting; var remoteIp = request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""; if (IsLoopback(remoteIp)) { var forwarded = request.Headers["X-Forwarded-For"].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(forwarded)) { var last = NormalizeIp(forwarded.Split(',').LastOrDefault()?.Trim() ?? ""); if (!string.IsNullOrWhiteSpace(last)) return last; } } return NormalizeIp(remoteIp); } catch { return ""; } } public static async Task IsIpBanned(this RecDBContext ctx, string ipAddress) { if (string.IsNullOrWhiteSpace(ipAddress)) return false; var ipBan = await ctx.IpBans.FirstOrDefaultAsync(x => x.IpAddress == ipAddress); if (ipBan == null) return false; if (ipBan.ExpiresAt.HasValue && ipBan.ExpiresAt.Value < DateTime.UtcNow) { ctx.IpBans.Remove(ipBan); await ctx.SaveChangesAsync(); return false; } return true; } public static async Task IsHardwareBanned(this RecDBContext ctx, string deviceId) { if (string.IsNullOrWhiteSpace(deviceId)) return false; var hardwareBan = await ctx.HardwareBans.FirstOrDefaultAsync(x => x.DeviceId == deviceId); if (hardwareBan == null) return false; if (hardwareBan.ExpiresAt.HasValue && hardwareBan.ExpiresAt.Value < DateTime.UtcNow) { ctx.HardwareBans.Remove(hardwareBan); await ctx.SaveChangesAsync(); return false; } return true; } public static async Task IsAccountCreationBlocked(this RecDBContext ctx, string ipAddress) { if (string.IsNullOrWhiteSpace(ipAddress)) return false; var block = await ctx.AccountCreationBlocks.FirstOrDefaultAsync(x => x.IpAddress == ipAddress); if (block == null) return false; if (block.ExpiresAt.HasValue && block.ExpiresAt.Value < DateTime.UtcNow) { ctx.AccountCreationBlocks.Remove(block); await ctx.SaveChangesAsync(); return false; } return true; } public static string FormatBanTime(TimeSpan timeRemaining) { if (timeRemaining.TotalDays > 365 * 10) return "This ban is permanent."; var parts = new List(); if (timeRemaining.Days > 0) parts.Add($"{timeRemaining.Days} Days"); if (timeRemaining.Hours > 0) parts.Add($"{timeRemaining.Hours} Hours"); if (timeRemaining.Minutes > 0) parts.Add($"{timeRemaining.Minutes} Minutes"); if (timeRemaining.Seconds > 0) parts.Add($"{timeRemaining.Seconds} Seconds"); return parts.Count > 0 ? string.Join(", ", parts) : "0 Seconds"; } public static string BanKickMessage(string reason, DateTime expiryUtc) { return expiryUtc >= DateTime.MaxValue.AddYears(-10) ? $"{reason}\nThis ban is permanent." : $"{reason}\nBan expires in: {FormatBanTime(expiryUtc - DateTime.UtcNow)}"; } public static async Task EjectToDormAsync(RecDBContext db, long playerId) { var presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == playerId); if (presence == null || !presence.roomInstance.HasValue) return; await PinToDormAsync(db, playerId); } public static async Task PinToDormAsync(RecDBContext db, long playerId) { try { var presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == playerId); if (presence == null) { return null; } if (presence.roomInstance.HasValue) { var currentInstance = await db.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == presence.roomInstance.Value); if (currentInstance != null) { var currentRoom = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == currentInstance.roomId); if (currentRoom != null && currentRoom.IsDorm && currentRoom.CreatorAccountId == playerId) return currentInstance; } } var dorm = await RoomUtils.GetOrCreateDorm(playerId); RoomInstance? dormInstance = null; if (dorm != null) dormInstance = await MatchmakingUtils.CreateRoomInstance(dorm.RoomId, true, null, null, null); if (dormInstance == null) { return null; } presence.roomInstance = dormInstance.roomInstanceId; presence.errorCode = JoinStatus.Success; presence.isOnline = true; MatchmakingUtils.MarkInRoom(playerId); await db.SaveChangesAsync(); await Notify.SendNotify(playerId, new Notification { Id = "PresenceUpdate", Msg = await Presence.CreateAsync(db, presence, dormInstance) }); return dormInstance; } catch (Exception) { return null; } } public static bool IsJuniorByBirthday(DateTime? birthday) { if (birthday == null || birthday.Value.Year <= 1900) return false; var ageYears = (DateTime.UtcNow - birthday.Value).TotalDays / 365.2425; return ageYears < 13; } private static bool HasBirthday(PlayerData plrData) => plrData.Birthday != null && plrData.Birthday.Value.Year > 1900; public static SelfAccount BuildSelfAccount(Profile profile, PlayerData plrData) { bool hasBirthday = HasBirthday(plrData); var juniorState = plrData.JuniorState == JuniorState.ModerationOverride ? JuniorState.ModerationOverride : hasBirthday ? JuniorState.BasedOnBirthday : JuniorState.PendingOverride; return new SelfAccount { juniorState = juniorState, accountId = profile.accountId, parentAccountId = plrData.Parent, availableUsernameChanges = 99, bio = profile.bio, displayName = profile.displayName, displayEmoji = profile.displayEmoji, personalPronouns = profile.personalPronouns, identityFlags = profile.identityFlags, email = plrData.Email ?? "", pendingEmail = plrData.PendingEmail ?? "", emailVerified = plrData.EmailVerified, createdAt = profile.createdAt, isJunior = profile.isJunior || plrData.JuniorState == JuniorState.ModerationOverride || IsJuniorByBirthday(plrData.Birthday), phone = plrData.Phone ?? "", platforms = profile.platforms, profileImage = profile.profileImage, bannerImage = profile.bannerImage, username = profile.username, hasBirthday = hasBirthday, birthdayUTC = hasBirthday ? AsBirthdayUtc(plrData.Birthday) : null, hasAcceptedCodeOfConduct = plrData.CodeOfConductAccepted, newPlayer = IsNewPlayer(plrData) }; } public static bool IsNewPlayer(PlayerData plrData) => !(plrData.Birthday != null && plrData.Birthday.Value.Year > 1900) || !plrData.CodeOfConductAccepted || string.IsNullOrWhiteSpace(plrData.Hashword); public static async Task IsChoosingTheirAccountNameAsync(RecDBContext db, long accountId) { var settings = await db.PlayerSettings.AsNoTracking() .FirstOrDefaultAsync(x => x.AccountId == accountId); if (settings == null) return false; var list = settings.DeserializeSettings(); bool SettingIs(string key, string value) => list.Any(setting => string.Equals(setting.Key, key, StringComparison.OrdinalIgnoreCase) && string.Equals(setting.Value, value, StringComparison.OrdinalIgnoreCase)); return SettingIs("Recroom.AccountCreation.HasStarted", "true") && !SettingIs("Recroom.AccountCreation.HasFinished", "true"); } private static DateTime? AsBirthdayUtc(DateTime? birthday) { if (birthday == null) return null; return birthday.Value.Kind == DateTimeKind.Utc ? birthday.Value : DateTime.SpecifyKind(birthday.Value, DateTimeKind.Utc); } public static async Task GetPlayer(this RecDBContext ctx, long id) { Player player = new Player(); ProgressionData? progressionData = await ctx.Progression.FirstOrDefaultAsync(x => x.PlayerId == id); if (progressionData == null) return null; Reputation? reputation = await ctx.Reputations.FirstOrDefaultAsync(x => x.AccountId == id); if (reputation == null) return null; Profile? profile = await ctx.Profiles.FirstOrDefaultAsync(x => x.accountId == id); if (profile == null) return null; PlayerData? plrData = await ctx.Players.FirstOrDefaultAsync(x => x.AccountId == id); if (plrData == null) return null; player.Id = id; player.XP = LevelUpGiftService.XpIntoCurrentLevel(progressionData); player.Level = LevelUpGiftService.DisplayLevel(progressionData.Level); player.Username = profile.username; player.DisplayName = profile.displayName; player.Developer = profile.HasRole("developer"); player.AvoidJuniors = false; player.CanReceiveInvites = true; player.ForceJuniorImages = false; player.HasBirthday = HasBirthday(plrData); player.HasAcceptedCodeOfConduct = plrData.CodeOfConductAccepted; player.PendingJunior = plrData.JuniorState != JuniorState.ModerationOverride && !player.HasBirthday; player.JuniorProfile = IsJuniorByBirthday(plrData.Birthday); player.Bio = profile.bio!; player.ProfileImageName = profile.profileImage; player.PlayerReputation = reputation.ForClient(); player.PlatformIds = new List { }; player.AdministrativeData = new AdministrativeData { LastLoginTime = DateTime.Now, JoinData = profile.createdAt, PermissionFactors = 0, TrustFactor = 100 }; return player; } } }