mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
1542 lines
42 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|