Files
2026-10-16 23:33:13 -07:00

1542 lines
42 KiB
C#

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<RecDBContext> 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<Success> 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<Profile> InsertNewAccountAsync(RecDBContext db, NewAccountRows rows)
{
var now = DateTime.Now;
var hasher = new PasswordHasher<PlayerData>();
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<string?> 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<bool> 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<Setting> 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<Setting> 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<PlayerData?> 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<HashSet<long>> FinishedAccountIdsAsync(RecDBContext db)
{
var settings = await db.PlayerSettings.AsNoTracking().ToListAsync();
var finished = new HashSet<long>();
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<Success> 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<Avatar>();
return avatar == null ? null : avatar.ToJson();
}
catch (JsonException)
{
return null;
}
}
public static async Task<bool> 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<PlayerBanInfo?> 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<string>();
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<bool> IsBanned(this RecDBContext ctx, long id)
{
return await ctx.ResolveBanAsync(id) != null;
}
public static async Task<bool> 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<bool> 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<bool> HasPendingWarningAsync(this RecDBContext ctx, long playerId) =>
ctx.PlayerWarnings.AnyAsync(x => x.WarnedPlayerId == playerId && x.AcknowledgedAt == null);
public static Task<PlayerWarning?> 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<MatchResponse?> 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<long, DeveloperDecision> _pendingDeveloperDecisions = new();
public static readonly TimeSpan DeveloperDecisionWindow = TimeSpan.FromMinutes(5);
public static async Task<bool> 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: <b>{appealCode}</b>";
}
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<string?> 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<string?> 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<BlockDetails> 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<bool> 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<bool> 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<bool> 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<string>();
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<RoomInstance?> 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<bool> 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<Player?> 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<RecNet.Auth.Cached.PlatformIds> { };
player.AdministrativeData = new AdministrativeData
{
LastLoginTime = DateTime.Now,
JoinData = profile.createdAt,
PermissionFactors = 0,
TrustFactor = 100
};
return player;
}
}
}