mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 23:49:02 -07:00
Radicade Public Code
This commit is contained in:
@@ -0,0 +1,1441 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Identity.Client;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Web;
|
||||
using RadicadeServer.API.Notifications;
|
||||
using RadicadeServer.Auth;
|
||||
using RadicadeServer.Clubs;
|
||||
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.Enums.Messages;
|
||||
using RadicadeServer.RecNet.Enums.Players;
|
||||
using RadicadeServer.RecNet.Leveling;
|
||||
using RadicadeServer.RecNet.Moderation;
|
||||
using RadicadeServer.RecNet.Notifications;
|
||||
using RadicadeServer.RecNet.Progression;
|
||||
using RadicadeServer.RecNet.Responses;
|
||||
using RadicadeServer.Radicade.Mail;
|
||||
using RadicadeServer.Utils;
|
||||
|
||||
namespace RadicadeServer.API.Accounts
|
||||
{
|
||||
[Route("accounts/")]
|
||||
public class AccountsAPI : Controller
|
||||
{
|
||||
RecDBContext db;
|
||||
public AccountsAPI(RecDBContext context)
|
||||
{
|
||||
this.db = context;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpGet("namegen/options")]
|
||||
public object GetNameGenerationOptions()
|
||||
{
|
||||
return new
|
||||
{
|
||||
adjectives = new[]
|
||||
{
|
||||
"Adventurous", "Amazing", "Brave", "Bright", "Cheerful", "Clever", "Cool",
|
||||
"Daring", "Energetic", "Fast", "Friendly", "Funny", "Golden", "Happy",
|
||||
"Jolly", "Kind", "Lively", "Lucky", "Mighty", "Mystic", "Nimble", "Playful",
|
||||
"Quick", "Radiant", "Rainbow", "Rustic", "Silky", "Smart", "Sneaky",
|
||||
"Sparkling", "Steady", "Sunny", "Super", "Swift", "Tidy", "Tiny",
|
||||
"Victorious", "Wacky", "Wild", "Zany", "Zippy"
|
||||
},
|
||||
nouns = new[]
|
||||
{
|
||||
"Badger", "Bear", "Buffalo", "Camel", "Cheetah", "Deer", "Dolphin", "Eagle",
|
||||
"Elk", "Falcon", "Ferret", "Fox", "Gecko", "Hawk", "Hedgehog", "Jaguar",
|
||||
"Koala", "Lemur", "Lion", "Lynx", "Marmot", "Meerkat", "Newt", "Ocelot",
|
||||
"Otter", "Panda", "Penguin", "Pony", "Quail", "Rabbit", "Raccoon", "Raven",
|
||||
"Skunk", "Sloth", "Squirrel", "Tiger", "Turtle", "Vulture", "Walrus", "Wolf"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("parentalcontrol/me"), Authorize]
|
||||
public IActionResult GetParentalControls()
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
accountId = User.GetUserId(),
|
||||
disallowInAppPurchases = false,
|
||||
requirePasswordForPurchases = false
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("accountprivacysettings/{accountId:long}"), Authorize]
|
||||
public async Task<IActionResult> GetAccountPrivacySettings(long accountId)
|
||||
{
|
||||
var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == accountId);
|
||||
if (profile == null) return NotFound();
|
||||
|
||||
return Ok(BuildPrivacySettings(profile));
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> BuildPrivacySettings(Profile profile) => new()
|
||||
{
|
||||
["accountId"] = profile.accountId,
|
||||
["isJunior"] = profile.isJunior,
|
||||
["profileVisibility"] = 0,
|
||||
["whoCanContactMe"] = 0,
|
||||
["whoCanInviteMe"] = 0,
|
||||
["whoCanJoinMe"] = 0,
|
||||
["whoCanSeeMyActivity"] = 0,
|
||||
["showOnlineStatus"] = true,
|
||||
["allowFriendRequests"] = true,
|
||||
["isRecentHistoryVisible"] = profile.isRecentHistoryVisible,
|
||||
["isrecenthistoryvisible"] = profile.isRecentHistoryVisible
|
||||
};
|
||||
|
||||
[HttpPut("accountprivacysettings/recenthistoryvisibility"), Authorize]
|
||||
public async Task<IActionResult> SetRecentHistoryVisibility()
|
||||
{
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == User.GetUserId());
|
||||
if (profile == null) return NotFound();
|
||||
|
||||
var body = await ReadRawBodyAsync();
|
||||
var value = ReadBoolFromPayload(body, PrivacyVisibilityKeys);
|
||||
if (value == null)
|
||||
{
|
||||
return BadRequest(new Success(false, "Invalid recent history visibility value.", null));
|
||||
}
|
||||
|
||||
profile.isRecentHistoryVisible = value.Value;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(BuildPrivacySettings(profile));
|
||||
}
|
||||
|
||||
private static readonly string[] PrivacyVisibilityKeys =
|
||||
[
|
||||
"isRecentHistoryVisible", "isrecenthistoryvisible", "recentHistoryVisibility",
|
||||
"recenthistoryvisibility", "value", "isVisible", "visible"
|
||||
];
|
||||
|
||||
private async Task<string> ReadRawBodyAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true);
|
||||
var raw = await reader.ReadToEndAsync();
|
||||
Request.Body.Position = 0;
|
||||
return raw ?? string.Empty;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private bool? ReadBoolFromPayload(string body, string[] keys)
|
||||
{
|
||||
var trimmed = body?.Trim() ?? string.Empty;
|
||||
|
||||
if (trimmed.StartsWith('{'))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(trimmed);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in doc.RootElement.EnumerateObject())
|
||||
if (keys.Contains(property.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var named = ParseBoolElement(property.Value);
|
||||
if (named != null) return named;
|
||||
}
|
||||
|
||||
var booleanProperties = doc.RootElement.EnumerateObject()
|
||||
.Where(property => property.Value.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
.ToList();
|
||||
if (booleanProperties.Count == 1)
|
||||
return booleanProperties[0].Value.GetBoolean();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.Contains('='))
|
||||
{
|
||||
foreach (var pair in trimmed.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var kv = pair.Split('=', 2);
|
||||
var key = Uri.UnescapeDataString(kv[0]).Trim();
|
||||
if (!keys.Contains(key, StringComparer.OrdinalIgnoreCase)) continue;
|
||||
var value = ParseBoolText(kv.Length > 1 ? Uri.UnescapeDataString(kv[1]) : null);
|
||||
if (value != null) return value;
|
||||
}
|
||||
}
|
||||
|
||||
var bare = ParseBoolText(trimmed);
|
||||
if (bare != null) return bare;
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (Request.Query.TryGetValue(key, out var queryValue))
|
||||
{
|
||||
var value = ParseBoolText(queryValue.FirstOrDefault());
|
||||
if (value != null) return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool? ParseBoolElement(JsonElement element) => element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Number when element.TryGetInt64(out var number) => number != 0,
|
||||
JsonValueKind.String => ParseBoolText(element.GetString()),
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static bool? ParseBoolText(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
var trimmed = text.Trim().Trim('"');
|
||||
if (bool.TryParse(trimmed, out var parsed)) return parsed;
|
||||
if (trimmed is "1") return true;
|
||||
if (trimmed is "0") return false;
|
||||
if (trimmed.Equals("yes", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (trimmed.Equals("no", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
[HttpGet("account/me"), Authorize]
|
||||
public async Task<SelfAccount?> GetProfile()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == userId);
|
||||
if (profile == null) return null;
|
||||
|
||||
|
||||
|
||||
|
||||
var plrData = await db.Players.FirstOrDefaultAsync(x => x.AccountId == userId);
|
||||
if (plrData == null) return null;
|
||||
|
||||
return PlayerUtils.BuildSelfAccount(profile, plrData);
|
||||
}
|
||||
|
||||
[HttpPost("account/me"), Authorize]
|
||||
[HttpPut("account/me"), Authorize]
|
||||
public async Task<SelfAccount?> UpdateAccount()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == userId);
|
||||
var plrData = await db.Players.FirstOrDefaultAsync(x => x.AccountId == userId);
|
||||
if (profile == null || plrData == null) return null;
|
||||
|
||||
var body = await ReadRawBodyAsync();
|
||||
|
||||
var raw = ReadBirthdayFromPayload(body);
|
||||
string? rejection = null;
|
||||
if (!string.IsNullOrWhiteSpace(raw)) rejection = await ApplyBirthdayAsync(raw, plrData);
|
||||
|
||||
var rawUsername = ReadUsernameFromPayload(body);
|
||||
string? usernameRejection = null;
|
||||
if (!string.IsNullOrWhiteSpace(rawUsername)) usernameRejection = await ApplyUsernameAsync(rawUsername, profile);
|
||||
|
||||
return PlayerUtils.BuildSelfAccount(profile, plrData);
|
||||
}
|
||||
|
||||
[HttpPost("account/create")]
|
||||
[HttpPost("/account/create")]
|
||||
[HttpPost("/api/accounts/account/create")]
|
||||
public async Task<Success> CreateAccount([FromForm] AccountCreationData acc)
|
||||
{
|
||||
if (acc == null) return new Success(false, "No account details were sent.", null);
|
||||
|
||||
if (string.IsNullOrEmpty(acc.platformId)) acc.platformId = Request.Form["platform_id"].ToString();
|
||||
if (string.IsNullOrEmpty(acc.deviceId)) acc.deviceId = Request.Form["device_id"].ToString();
|
||||
|
||||
|
||||
|
||||
acc.platformId = acc.platformId?.Trim() ?? string.Empty;
|
||||
acc.deviceId = acc.deviceId?.Trim() ?? string.Empty;
|
||||
acc.username = acc.username?.Trim();
|
||||
|
||||
if (acc.platformId.Length == 0) return new Success(false, "A platform id is required.", null);
|
||||
if (acc.deviceId.Length == 0) return new Success(false, "A device id is required.", null);
|
||||
|
||||
var isWhitelisted = await AuthShared.IsWhitelisted(acc.platformId);
|
||||
if (!isWhitelisted) return new Success(false, "Error", "You are not whitelisted.");
|
||||
|
||||
var platformAuth = Request.Form["platform_auth"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(platformAuth)) platformAuth = Request.Form["platformAuth"].ToString();
|
||||
|
||||
var proof = await PlatformIdentity.ProveAsync(acc.platform, acc.platformId,
|
||||
Request.Headers["x-steam-ticket"].ToString(), platformAuth, HttpContext.RequestAborted);
|
||||
if (!proof.Verified)
|
||||
{
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Account] create refused: platform {acc.platform} identity " +
|
||||
$"{acc.platformId} device {acc.deviceId} from {Request.GetClientIp()}: {proof.Detail} " +
|
||||
$"(platform_auth {(string.IsNullOrWhiteSpace(platformAuth) ? "absent" : "present")}, " +
|
||||
$"x-steam-ticket {(Request.Headers.ContainsKey("x-steam-ticket") ? "present" : "absent")})");
|
||||
return new Success(false, "Error", proof.Detail);
|
||||
}
|
||||
|
||||
if (proof.SteamId != null)
|
||||
{
|
||||
var standing = await PlatformIdentity.CheckSteamStandingAsync(proof.SteamId, HttpContext.RequestAborted);
|
||||
if (standing != null)
|
||||
{
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Account] create refused: Steam account {proof.SteamId} " +
|
||||
$"is not in good standing: {standing}");
|
||||
return new Success(false, "Error", standing);
|
||||
}
|
||||
}
|
||||
|
||||
var clientIp = Request.GetClientIp();
|
||||
|
||||
if (await db.IsIpBanned(clientIp))
|
||||
return new Success(false, "Error", "This IP address is banned from creating new accounts.");
|
||||
|
||||
if (await db.IsAccountCreationBlocked(clientIp))
|
||||
return new Success(false, "Error", "This IP address is blocked from creating new accounts.");
|
||||
|
||||
if (await db.IsHardwareBanned(acc.deviceId))
|
||||
return new Success(false, "Error", "This device is banned.");
|
||||
|
||||
|
||||
|
||||
|
||||
using var identity = await RegistrationGuard.LockIdentityAsync(
|
||||
RegistrationGuard.IdentityKey(acc.platform.ToString(), acc.platformId, acc.deviceId));
|
||||
|
||||
var adoptable = await PlayerUtils.FindAdoptableAccountForDeviceAsync(db, acc.deviceId);
|
||||
if (adoptable != null)
|
||||
{
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Account] create adopted this device's account {adoptable.AccountId} " +
|
||||
$"for platform {acc.platform} identity {acc.platformId}");
|
||||
return await PlayerUtils.AdoptDeviceAccountAsync(db, adoptable.AccountId, acc, markStarted: true);
|
||||
}
|
||||
|
||||
return await PlayerUtils.CreateAccount(acc, clientIp, accountCreationInProgress: true);
|
||||
}
|
||||
|
||||
[HttpGet("account/search")]
|
||||
public async Task<object> GetByName([FromQuery] string name)
|
||||
{
|
||||
var profiles = await db.Profiles
|
||||
.Where(x => x.displayName.Contains(name) || x.username.Contains(name))
|
||||
.ToListAsync();
|
||||
|
||||
return profiles.Select(p => (object)new Dictionary<string, object?>
|
||||
{
|
||||
["accountId"] = p.accountId,
|
||||
["profileImage"] = p.profileImage,
|
||||
["isJunior"] = p.isJunior,
|
||||
["platforms"] = p.platforms,
|
||||
["username"] = p.username,
|
||||
["displayName"] = p.displayName,
|
||||
["createdAt"] = p.createdAt,
|
||||
["personalPronouns"] = p.personalPronouns,
|
||||
["identityFlags"] = p.identityFlags,
|
||||
["displayEmoji"] = (object?)null,
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
[HttpGet("account/{who}/bio"), Authorize]
|
||||
public async Task<BioResponse?> GetBioFromId(string who)
|
||||
{
|
||||
|
||||
long? userId;
|
||||
if (who == "me")
|
||||
userId = User.GetUserId();
|
||||
else
|
||||
userId = (await db.Profiles.FirstOrDefaultAsync(x => x.username == who))?.accountId;
|
||||
if (userId == null)
|
||||
userId = db.Profiles.FirstOrDefault(x => x.accountId.ToString() == who)?.accountId;
|
||||
|
||||
if (userId == null) return new BioResponse { accountId = 0, bio = string.Empty };
|
||||
|
||||
var bio = (await db.Profiles.FirstOrDefaultAsync(x => x.accountId == userId))?.bio;
|
||||
return new BioResponse { accountId = (long)userId!, bio = bio ?? string.Empty };
|
||||
}
|
||||
[HttpPut("account/me/bio"), Authorize]
|
||||
public async Task<Success?> SetBio(string bio)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == userId);
|
||||
if (profile == null) return new Success(false, "Invalid profile.", null);
|
||||
|
||||
|
||||
if (profile.BioLocked) return new Success(false, "Your bio is locked and cannot be changed.", null);
|
||||
|
||||
if (!bio.IsPure()) return new Success(false, "Bio contains offensive language.", null);
|
||||
|
||||
profile.bio = bio;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await SendAccountUpdate((long)userId!);
|
||||
|
||||
return new Success(true, null, bio);
|
||||
}
|
||||
[HttpPut("account/me/displayname"), Authorize]
|
||||
public async Task<Success> SetDisplayName(string displayName)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == userId);
|
||||
if (profile == null) return new Success(false, "Invalid profile.", null);
|
||||
|
||||
|
||||
if (profile.NameLocked) return new Success(false, "Your name is locked and cannot be changed.", null);
|
||||
|
||||
displayName = displayName?.Trim() ?? string.Empty;
|
||||
|
||||
if (displayName.Length == 0) return new Success(false, "Your name cannot be empty.", null);
|
||||
|
||||
if (!displayName.IsPure()) return new Success(false, "Name contains offensive language.", null);
|
||||
|
||||
if (await IsNameTakenAsync((long)userId!, displayName))
|
||||
return new Success(false, "That name is already taken.", null);
|
||||
|
||||
profile.displayName = displayName;
|
||||
|
||||
if (await IsChoosingTheirAccountNameAsync((long)userId!))
|
||||
{
|
||||
var previous = profile.username;
|
||||
profile.username = displayName;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await SendAccountUpdate((long)userId!);
|
||||
|
||||
return new Success(true, null, displayName);
|
||||
}
|
||||
|
||||
private Task<bool> IsChoosingTheirAccountNameAsync(long accountId) =>
|
||||
PlayerUtils.IsChoosingTheirAccountNameAsync(db, accountId);
|
||||
[HttpPut("account/me/username"), Authorize]
|
||||
public async Task<Success> SetUsername(string username)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == userId);
|
||||
if (profile == null) return new Success(false, "Invalid profile.", null);
|
||||
|
||||
|
||||
if (profile.NameLocked) return new Success(false, "Your name is locked and cannot be changed.", null);
|
||||
|
||||
username = username?.Trim() ?? string.Empty;
|
||||
|
||||
if (username.Length == 0) return new Success(false, "Your username cannot be empty.", null);
|
||||
|
||||
|
||||
|
||||
|
||||
var chosen = UsernameRules.Parse(username, out var rejection);
|
||||
if (chosen == null) return new Success(false, rejection ?? "Your username cannot be empty.", null);
|
||||
|
||||
if (await IsNameTakenAsync((long)userId!, chosen))
|
||||
return new Success(false, "That username is already taken.", null);
|
||||
|
||||
var previousUsername = profile.username;
|
||||
var displayNameWasTheUsername = string.IsNullOrWhiteSpace(profile.displayName)
|
||||
|| string.Equals(profile.displayName, previousUsername, StringComparison.Ordinal);
|
||||
var stillChoosing = await IsChoosingTheirAccountNameAsync((long)userId!);
|
||||
|
||||
profile.username = chosen;
|
||||
|
||||
|
||||
|
||||
|
||||
if (stillChoosing || displayNameWasTheUsername) profile.displayName = chosen;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (stillChoosing)
|
||||
await PlayerUtils.RefreshAccountCreationMarkersAsync(db, (long)userId!, usernameChosen: true);
|
||||
|
||||
await SendAccountUpdate((long)userId!);
|
||||
|
||||
return new Success(true, null, username);
|
||||
}
|
||||
|
||||
[HttpPut("account/me/personalpronouns"), Authorize]
|
||||
public Task<Success> SetPersonalPronouns() =>
|
||||
SetProfileIdentityValue(true, "pronounFlags", "PronounFlags", "pronouns", "Pronouns", "personalPronouns", "PersonalPronouns", "value", "Value");
|
||||
|
||||
[HttpPut("account/me/identityflags"), Authorize]
|
||||
public Task<Success> SetIdentityFlags() =>
|
||||
SetProfileIdentityValue(false, "identityFlags", "IdentityFlags", "value", "Value");
|
||||
|
||||
private async Task<Success> SetProfileIdentityValue(bool isPronouns, params string[] keys)
|
||||
{
|
||||
var p = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == User.GetUserId());
|
||||
if (p == null) return new Success(false, "Invalid profile.", null);
|
||||
|
||||
var body = await ReadRawBodyAsync();
|
||||
var raw = ReadStringFromPayload(body, keys);
|
||||
if (!int.TryParse(raw, out var value) || value < 0)
|
||||
return new Success(false, "A non-negative integer value is required.", null);
|
||||
|
||||
if (isPronouns) p.personalPronouns = value;
|
||||
else p.identityFlags = value;
|
||||
await db.SaveChangesAsync();
|
||||
await SendAccountUpdate(p.accountId);
|
||||
return new Success(true, null, value);
|
||||
}
|
||||
|
||||
[HttpPut("account/me/emoji"), Authorize]
|
||||
[HttpPost("account/me/emoji"), Authorize]
|
||||
public async Task<SelfAccount?> SetDisplayEmoji()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == userId);
|
||||
var plrData = await db.Players.FirstOrDefaultAsync(x => x.AccountId == userId);
|
||||
if (profile == null || plrData == null) return null;
|
||||
|
||||
var body = await ReadRawBodyAsync();
|
||||
var emoji = ReadStringFromPayload(body, "displayEmoji", "DisplayEmoji", "emoji", "Emoji", "value", "Value");
|
||||
if (emoji == null)
|
||||
{
|
||||
return PlayerUtils.BuildSelfAccount(profile, plrData);
|
||||
}
|
||||
|
||||
profile.displayEmoji = string.IsNullOrWhiteSpace(emoji) ? null : emoji.Trim();
|
||||
await db.SaveChangesAsync();
|
||||
await SendAccountUpdate(profile.accountId);
|
||||
return PlayerUtils.BuildSelfAccount(profile, plrData);
|
||||
}
|
||||
private async Task<bool> IsNameTakenAsync(long accountId, string name) =>
|
||||
await db.Profiles.AnyAsync(x => x.accountId != accountId &&
|
||||
(x.username == name || x.displayName == name));
|
||||
|
||||
[HttpPut("account/me/profileimage"), Authorize]
|
||||
public async Task<Success> SetProfileImage(string imageName)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == userId);
|
||||
if (profile == null) return new Success(false, "Invalid profile.", null);
|
||||
|
||||
profile.profileImage = imageName;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await SendAccountUpdate((long)userId!);
|
||||
|
||||
return new Success(true, null, imageName);
|
||||
}
|
||||
|
||||
private static readonly string[] ImageNameKeys =
|
||||
{
|
||||
"imageName", "ImageName", "image", "Image", "bannerImage", "BannerImage"
|
||||
};
|
||||
|
||||
private async Task<string?> ReadImageNameAsync(string? bound)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(bound)) return bound.Trim();
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in ImageNameKeys)
|
||||
{
|
||||
var value = form[key].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(value)) return value.Trim();
|
||||
}
|
||||
|
||||
// The client's own field name is not guaranteed to be one of the spellings above,
|
||||
// so a form that carries exactly one piece of text still names the banner.
|
||||
var named = form
|
||||
.Where(field => !string.IsNullOrWhiteSpace(field.Value.ToString()))
|
||||
.Select(field => field.Value.ToString().Trim())
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (named.Count == 1) return named[0];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// A chunked body has no Content-Length at all, so the old "is not > 0" guard refused
|
||||
// every name sent that way. Only a body that is explicitly empty is skipped now.
|
||||
if (Request.ContentLength == 0) return null;
|
||||
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
using var reader = new StreamReader(Request.Body, leaveOpen: true);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
if (Request.Body.CanSeek) Request.Body.Position = 0;
|
||||
if (string.IsNullOrWhiteSpace(body)) return null;
|
||||
|
||||
return ReadImageNameFromBody(body);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ReadImageNameFromBody(string body)
|
||||
{
|
||||
var trimmed = body.Trim();
|
||||
|
||||
if (trimmed.StartsWith('{'))
|
||||
{
|
||||
using var document = JsonDocument.Parse(trimmed);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
foreach (var key in ImageNameKeys)
|
||||
{
|
||||
if (!document.RootElement.TryGetProperty(key, out var element)) continue;
|
||||
if (element.ValueKind != JsonValueKind.String) continue;
|
||||
|
||||
var value = element.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(value)) return value.Trim();
|
||||
}
|
||||
|
||||
// One string member under a name we do not know is still the image name; two or
|
||||
// more and the payload is something else, so nothing is guessed.
|
||||
var strings = document.RootElement.EnumerateObject()
|
||||
.Where(property => property.Value.ValueKind == JsonValueKind.String)
|
||||
.Select(property => property.Value.GetString())
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value!.Trim())
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
return strings.Count == 1 ? strings[0] : null;
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith("\""))
|
||||
{
|
||||
var value = JsonSerializer.Deserialize<string>(trimmed);
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
if (trimmed.Contains('='))
|
||||
{
|
||||
foreach (var pair in trimmed.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var kv = pair.Split('=', 2);
|
||||
if (kv.Length < 2) continue;
|
||||
if (!ImageNameKeys.Contains(Uri.UnescapeDataString(kv[0]).Trim(), StringComparer.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var value = Uri.UnescapeDataString(kv[1]).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(value)) return value;
|
||||
}
|
||||
|
||||
// Form encoded, but nothing in it names an image: not a name to store.
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
[HttpPut("account/me/bannerimage"), Authorize]
|
||||
public async Task<IActionResult> SetBannerImage(string? imageName)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == userId);
|
||||
if (profile == null) return NotFound(new Success(false, "Invalid profile.", null));
|
||||
|
||||
var resolved = await ReadImageNameAsync(imageName);
|
||||
if (string.IsNullOrWhiteSpace(resolved))
|
||||
{
|
||||
return BadRequest(new Success(false, "An image name is required.", null));
|
||||
}
|
||||
|
||||
profile.bannerImage = resolved;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await SendAccountUpdate((long)userId!);
|
||||
|
||||
return Ok(new Success(true, null, resolved));
|
||||
}
|
||||
|
||||
[HttpPost("account/me/email"), Authorize]
|
||||
public async Task<Success> SetEmail()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
var plrData = await db.Players.FirstOrDefaultAsync(x => x.AccountId == userId);
|
||||
if (plrData == null) return new Success(false, "Invalid profile.", null);
|
||||
|
||||
|
||||
|
||||
|
||||
string? email = null;
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var raw = await reader.ReadToEndAsync();
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
if (raw.TrimStart().StartsWith("{"))
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var key in new[] { "email", "newEmail", "new_email" })
|
||||
{
|
||||
if (doc.RootElement.TryGetProperty(key, out var el) && el.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
email = el.GetString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var form = HttpUtility.ParseQueryString(raw);
|
||||
email = form["email"] ?? form["newEmail"] ?? form["new_email"];
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email)) email = Request.Form["email"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(email)) email = Request.Query["email"].ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email)) return new Success(false, "Email is required.", null);
|
||||
|
||||
email = email.Trim();
|
||||
if (email.Length > 254 || !email.Contains('@'))
|
||||
return new Success(false, "Invalid email address.", null);
|
||||
|
||||
|
||||
if (string.Equals(plrData.Email, email, StringComparison.OrdinalIgnoreCase) && plrData.EmailVerified)
|
||||
return new Success(true, null, email);
|
||||
|
||||
|
||||
|
||||
if (await IsEmailTaken(email, excludeAccountId: (long)userId!))
|
||||
return new Success(false, "This email is already registered to another account.", null);
|
||||
|
||||
|
||||
|
||||
var emailLimitError = CheckEmailVerificationLimit(userId);
|
||||
if (emailLimitError != null) return new Success(false, emailLimitError, null);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
var isResend = string.Equals(plrData.PendingEmail, email, StringComparison.OrdinalIgnoreCase);
|
||||
plrData.PendingEmail = email;
|
||||
plrData.EmailVerified = false;
|
||||
if (!isResend || string.IsNullOrWhiteSpace(plrData.EmailVerifyToken))
|
||||
plrData.EmailVerifyToken = Guid.NewGuid().ToString("N");
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await SendAccountUpdate((long)userId!);
|
||||
SendVerificationEmail(plrData);
|
||||
|
||||
return new Success(true, null, email);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private async Task<bool> IsEmailTaken(string email, long excludeAccountId)
|
||||
{
|
||||
var candidates = await db.Players
|
||||
.Where(x => x.AccountId != excludeAccountId)
|
||||
.Select(x => new { x.Email, x.PendingEmail })
|
||||
.ToListAsync();
|
||||
return candidates.Any(x =>
|
||||
!string.IsNullOrWhiteSpace(x.Email) && string.Equals(x.Email, email, StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.IsNullOrWhiteSpace(x.PendingEmail) && string.Equals(x.PendingEmail, email, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpGet("account/me/email"), Authorize]
|
||||
public async Task<IActionResult> GetEmail()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var plrData = await db.Players.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == userId);
|
||||
if (plrData == null) return NotFound();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
email = plrData.Email,
|
||||
verified = plrData.EmailVerified,
|
||||
|
||||
|
||||
pendingEmail = plrData.PendingEmail,
|
||||
pendingVerified = false
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpPost("account/me/email/resend"), Authorize]
|
||||
public async Task<Success> ResendEmail()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var plrData = await db.Players.FirstOrDefaultAsync(x => x.AccountId == userId);
|
||||
if (plrData == null) return new Success(false, "Invalid profile.", null);
|
||||
|
||||
|
||||
|
||||
var pending = plrData.PendingEmail;
|
||||
if (string.IsNullOrWhiteSpace(pending) || !pending.Contains('@'))
|
||||
return new Success(false, "No email address pending verification.", null);
|
||||
|
||||
if (plrData.EmailVerified)
|
||||
return new Success(true, null, plrData.Email);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(plrData.EmailVerifyToken))
|
||||
{
|
||||
plrData.EmailVerifyToken = Guid.NewGuid().ToString("N");
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
|
||||
|
||||
var emailLimitError = CheckEmailVerificationLimit(userId);
|
||||
if (emailLimitError != null) return new Success(false, emailLimitError, null);
|
||||
|
||||
SendVerificationEmail(plrData);
|
||||
return new Success(true, null, pending);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpGet("account/me/email/verify")]
|
||||
public async Task<IActionResult> VerifyEmail([FromQuery] string code)
|
||||
{
|
||||
var (success, message, email) = await TryVerifyEmail(code);
|
||||
return Content(VerificationPage(success, message), "text/html");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpGet("account/me/email/verify/result")]
|
||||
public async Task<IActionResult> VerifyEmailResult([FromQuery] string code)
|
||||
{
|
||||
var (success, message, email) = await TryVerifyEmail(code);
|
||||
return Ok(new
|
||||
{
|
||||
success,
|
||||
message,
|
||||
email
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<(bool Success, string Message, string? Email)> TryVerifyEmail(string code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return (false, "This verification link is missing its code.", null);
|
||||
|
||||
var plrData = await db.Players.FirstOrDefaultAsync(x => x.EmailVerifyToken == code);
|
||||
if (plrData == null)
|
||||
return (false, "This verification link is invalid or has already been used.", null);
|
||||
|
||||
|
||||
|
||||
var pending = plrData.PendingEmail;
|
||||
if (string.IsNullOrWhiteSpace(pending))
|
||||
return (false, "There is no email waiting to be verified on this account.", null);
|
||||
|
||||
|
||||
|
||||
|
||||
if (await IsEmailTaken(pending, plrData.AccountId))
|
||||
return (false, "This email is already registered to another account and can no longer be verified.", null);
|
||||
|
||||
plrData.Email = pending;
|
||||
plrData.PendingEmail = null;
|
||||
plrData.EmailVerified = true;
|
||||
plrData.EmailVerifyToken = null;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await SendAccountUpdate(plrData.AccountId);
|
||||
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == plrData.AccountId);
|
||||
var atName = profile != null && !string.IsNullOrWhiteSpace(profile.username) ? " @" + profile.username : "";
|
||||
return (true, $"{plrData.Email} is now verified on your Radicade account{atName}. You can close this page and return to the game.", plrData.Email);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpPost("account/me/birthday"), Authorize]
|
||||
[HttpPut("account/me/birthday"), Authorize]
|
||||
public async Task<Success> SetBirthday()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var plrData = await db.Players.FirstOrDefaultAsync(x => x.AccountId == userId);
|
||||
if (plrData == null) return new Success(false, "Invalid profile.", null);
|
||||
|
||||
var raw = await ReadBirthdayTextAsync();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(raw)) return new Success(false, "Birthday is required.", null);
|
||||
|
||||
var error = await ApplyBirthdayAsync(raw, plrData);
|
||||
if (error != null) return new Success(false, error, null);
|
||||
|
||||
return new Success(true, null, plrData.Birthday!.Value.ToString("yyyy-MM-dd"));
|
||||
}
|
||||
|
||||
private async Task<string?> ApplyBirthdayAsync(string raw, PlayerData plrData)
|
||||
{
|
||||
var birthday = PlayerUtils.ParseBirthday(raw);
|
||||
if (birthday == null) return "Invalid birthday.";
|
||||
|
||||
plrData.Birthday = birthday;
|
||||
plrData.IsJunior = PlayerUtils.IsJuniorByBirthday(birthday);
|
||||
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == plrData.AccountId);
|
||||
if (profile != null) profile.isJunior = plrData.IsJunior;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await SendAccountUpdate(plrData.AccountId);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<string?> ApplyUsernameAsync(string raw, Profile profile)
|
||||
{
|
||||
|
||||
|
||||
var chosen = UsernameRules.Parse(raw, out var rejection);
|
||||
if (chosen == null) return rejection ?? "A name is required.";
|
||||
|
||||
var alreadyItsName = string.Equals(chosen, profile.username, StringComparison.Ordinal);
|
||||
if (!alreadyItsName)
|
||||
{
|
||||
|
||||
|
||||
using var nameGate = await RegistrationGuard.LockNameAsync(chosen);
|
||||
if (await PlayerUtils.IsNameTakenAsync(db, chosen)) return "That name is already taken.";
|
||||
|
||||
profile.username = chosen;
|
||||
profile.displayName = chosen;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (await PlayerUtils.IsChoosingTheirAccountNameAsync(db, profile.accountId))
|
||||
await PlayerUtils.RefreshAccountCreationMarkersAsync(db, profile.accountId, usernameChosen: true);
|
||||
|
||||
if (!alreadyItsName) await SendAccountUpdate(profile.accountId);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ReadBirthdayPartsFromJson(string body)
|
||||
{
|
||||
var trimmed = body?.TrimStart() ?? string.Empty;
|
||||
if (!trimmed.StartsWith('{')) return null;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(trimmed);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
var fromFlat = BirthdayParts(doc.RootElement);
|
||||
if (fromFlat != null) return fromFlat;
|
||||
|
||||
foreach (var property in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (property.Value.ValueKind != JsonValueKind.Object) continue;
|
||||
var nested = BirthdayParts(property.Value);
|
||||
if (nested != null) return nested;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? BirthdayParts(JsonElement element)
|
||||
{
|
||||
int? year = null, month = null, day = null;
|
||||
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (property.Value.ValueKind != JsonValueKind.Number || !property.Value.TryGetInt32(out var value)) continue;
|
||||
|
||||
var name = property.Name;
|
||||
if (year == null && name.Equals("year", StringComparison.OrdinalIgnoreCase)) year = value;
|
||||
else if (month == null && (name.Equals("month", StringComparison.OrdinalIgnoreCase) || name.Equals("birthdayMonth", StringComparison.OrdinalIgnoreCase))) month = value;
|
||||
else if (day == null && (name.Equals("day", StringComparison.OrdinalIgnoreCase) || name.Equals("birthdayDay", StringComparison.OrdinalIgnoreCase))) day = value;
|
||||
}
|
||||
|
||||
if (year == null || month == null || day == null) return null;
|
||||
|
||||
var candidateYear = Math.Clamp(year.Value, 1, 9999);
|
||||
var clampedMonth = Math.Clamp(month.Value, 1, 12);
|
||||
var clampedDay = Math.Clamp(day.Value, 1, DateTime.DaysInMonth(candidateYear, clampedMonth));
|
||||
|
||||
return new DateTime(candidateYear, clampedMonth, clampedDay).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private string? ReadUsernameFromPayload(string body)
|
||||
{
|
||||
var trimmed = body?.TrimStart() ?? string.Empty;
|
||||
|
||||
if (trimmed.StartsWith('{'))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(trimmed);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in doc.RootElement.EnumerateObject())
|
||||
if (property.Value.ValueKind == JsonValueKind.String &&
|
||||
string.Equals(property.Name, "username", StringComparison.OrdinalIgnoreCase))
|
||||
return property.Value.GetString();
|
||||
|
||||
foreach (var property in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (property.Value.ValueKind != JsonValueKind.Object) continue;
|
||||
foreach (var nested in property.Value.EnumerateObject())
|
||||
if (nested.Value.ValueKind == JsonValueKind.String &&
|
||||
string.Equals(nested.Name, "username", StringComparison.OrdinalIgnoreCase))
|
||||
return nested.Value.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
var posted = HttpUtility.ParseQueryString(trimmed)["username"];
|
||||
if (!string.IsNullOrWhiteSpace(posted)) return posted;
|
||||
}
|
||||
|
||||
var query = Request.Query["username"].ToString();
|
||||
if (!string.IsNullOrWhiteSpace(query)) return query;
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = Request.Form["username"].ToString();
|
||||
if (!string.IsNullOrWhiteSpace(form)) return form;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<string?> ReadBirthdayTextAsync() => ReadBirthdayFromPayload(await ReadRawBodyAsync());
|
||||
|
||||
private static readonly string[] BirthdayKeys =
|
||||
[
|
||||
"birthday", "birthdayUTC", "birthdayUtc", "dateOfBirth", "dateofbirth",
|
||||
"dob", "birthdate", "birthDate"
|
||||
];
|
||||
|
||||
private string? ReadStringFromPayload(string body, params string[] keys)
|
||||
{
|
||||
var trimmed = body?.Trim() ?? string.Empty;
|
||||
if (trimmed.StartsWith('{'))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(trimmed);
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (!doc.RootElement.TryGetProperty(key, out var value)) continue;
|
||||
if (value.ValueKind == JsonValueKind.String) return value.GetString();
|
||||
if (value.ValueKind == JsonValueKind.Number) return value.GetRawText();
|
||||
if (value.ValueKind == JsonValueKind.Null) return string.Empty;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (Request.Query.TryGetValue(key, out var queryValue) && queryValue.Count > 0)
|
||||
return queryValue[0];
|
||||
}
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (Request.Form.TryGetValue(key, out var formValue) && formValue.Count > 0)
|
||||
return formValue[0];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private string? ReadBirthdayFromPayload(string body)
|
||||
{
|
||||
var raw = ReadBirthdayFromJson(body);
|
||||
if (!string.IsNullOrWhiteSpace(raw)) return raw;
|
||||
|
||||
var parts = ReadBirthdayPartsFromJson(body);
|
||||
if (!string.IsNullOrWhiteSpace(parts)) return parts;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(body) && !body.TrimStart().StartsWith('{'))
|
||||
{
|
||||
var form = HttpUtility.ParseQueryString(body);
|
||||
foreach (var key in BirthdayKeys)
|
||||
{
|
||||
raw = form[key];
|
||||
if (!string.IsNullOrWhiteSpace(raw)) return raw;
|
||||
}
|
||||
|
||||
var loose = BirthdayUnderAnUnknownName(
|
||||
form.AllKeys.Where(key => key != null).Select(key => new KeyValuePair<string, string?>(key!, form[key])));
|
||||
if (!string.IsNullOrWhiteSpace(loose)) return loose;
|
||||
}
|
||||
|
||||
foreach (var key in BirthdayKeys)
|
||||
{
|
||||
raw = Request.Query[key].ToString();
|
||||
if (!string.IsNullOrWhiteSpace(raw)) return raw;
|
||||
}
|
||||
|
||||
var looseQuery = BirthdayUnderAnUnknownName(
|
||||
Request.Query.Select(pair => new KeyValuePair<string, string?>(pair.Key, pair.Value.ToString())));
|
||||
if (!string.IsNullOrWhiteSpace(looseQuery)) return looseQuery;
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
foreach (var key in BirthdayKeys)
|
||||
{
|
||||
raw = Request.Form[key].ToString();
|
||||
if (!string.IsNullOrWhiteSpace(raw)) return raw;
|
||||
}
|
||||
|
||||
return BirthdayUnderAnUnknownName(
|
||||
Request.Form.Select(pair => new KeyValuePair<string, string?>(pair.Key, pair.Value.ToString())));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool LooksLikeBirthdayName(string name)
|
||||
{
|
||||
var cleaned = new string(name.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant();
|
||||
return cleaned.Contains("birthday") || cleaned is "dob" or "dateofbirth" or "birthdate";
|
||||
}
|
||||
|
||||
private static string? BirthdayUnderAnUnknownName(IEnumerable<KeyValuePair<string, string?>> fields)
|
||||
{
|
||||
foreach (var field in fields)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(field.Key) || string.IsNullOrWhiteSpace(field.Value)) continue;
|
||||
if (!LooksLikeBirthdayName(field.Key)) continue;
|
||||
if (PlayerUtils.ParseBirthday(field.Value) == null) continue;
|
||||
return field.Value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ReadBirthdayFromJson(string body, int depth = 0)
|
||||
{
|
||||
var trimmed = body?.TrimStart() ?? string.Empty;
|
||||
if (!trimmed.StartsWith('{')) return null;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(trimmed);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
foreach (var property in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (property.Value.ValueKind != JsonValueKind.String) continue;
|
||||
if (!BirthdayKeys.Contains(property.Name, StringComparer.OrdinalIgnoreCase)) continue;
|
||||
return property.Value.GetString();
|
||||
}
|
||||
|
||||
foreach (var property in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (property.Value.ValueKind != JsonValueKind.String) continue;
|
||||
if (!LooksLikeBirthdayName(property.Name)) continue;
|
||||
var candidate = property.Value.GetString();
|
||||
if (PlayerUtils.ParseBirthday(candidate) == null) continue;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
if (depth >= 1) return null;
|
||||
|
||||
foreach (var property in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (property.Value.ValueKind != JsonValueKind.Object) continue;
|
||||
|
||||
var nested = ReadBirthdayFromJson(property.Value.GetRawText(), depth + 1);
|
||||
if (!string.IsNullOrWhiteSpace(nested)) return nested;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string DescribeFields(string body)
|
||||
{
|
||||
var trimmed = body?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(trimmed)) return "<empty>";
|
||||
if (!trimmed.StartsWith('{')) return $"{trimmed.Length} bytes, not a JSON object";
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(trimmed);
|
||||
var names = doc.RootElement.EnumerateObject().Select(x => x.Name).Take(25).ToList();
|
||||
return names.Count == 0 ? "<no fields>" : string.Join(", ", names);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "unparseable JSON";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpPost("account/me/acceptcoc"), Authorize]
|
||||
public async Task<Success> AcceptCodeOfConduct()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var plrData = await db.Players.FirstOrDefaultAsync(x => x.AccountId == userId);
|
||||
if (plrData == null) return new Success(false, "Invalid profile.", null);
|
||||
|
||||
plrData.CodeOfConductAccepted = true;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (await PlayerUtils.IsChoosingTheirAccountNameAsync(db, (long)userId!))
|
||||
await PlayerUtils.RefreshAccountCreationMarkersAsync(db, (long)userId!);
|
||||
|
||||
await SendAccountUpdate((long)userId!);
|
||||
|
||||
return new Success(true, null, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpGet("account/me/haspassword"), Authorize]
|
||||
public async Task<bool?> GetHasPassword()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return null;
|
||||
|
||||
var user = await db.Players.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == userId.Value);
|
||||
if (user == null) return null;
|
||||
|
||||
return !string.IsNullOrEmpty(user.Hashword);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpPost("account/me/changepassword"), Authorize]
|
||||
public async Task<Success> ChangePassword([FromForm] string oldPassword, [FromForm] string newPassword)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return new Success(false, "Not signed in.", null);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(newPassword)) return new Success(false, "New password is required.", null);
|
||||
if (newPassword.Length < AuthAPI.MinPasswordLength) return new Success(false, $"Password must be at least {AuthAPI.MinPasswordLength} characters.", null);
|
||||
|
||||
var user = await db.Players.FirstOrDefaultAsync(x => x.AccountId == userId.Value);
|
||||
if (user == null) return new Success(false, "User does not exist.", null);
|
||||
|
||||
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(user.Hashword))
|
||||
{
|
||||
if (string.IsNullOrEmpty(oldPassword))
|
||||
return new Success(false, "Current password is required.", null);
|
||||
var hasher = new PasswordHasher<PlayerData>();
|
||||
var check = hasher.VerifyHashedPassword(user, user.Hashword, oldPassword);
|
||||
if (check != PasswordVerificationResult.Success && check != PasswordVerificationResult.SuccessRehashNeeded)
|
||||
return new Success(false, "Current password is incorrect.", null);
|
||||
}
|
||||
|
||||
user.Hashword = new PasswordHasher<PlayerData>().HashPassword(user, newPassword);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
|
||||
|
||||
if (await PlayerUtils.IsChoosingTheirAccountNameAsync(db, userId.Value))
|
||||
await PlayerUtils.RefreshAccountCreationMarkersAsync(db, userId.Value);
|
||||
|
||||
await SendAccountUpdate(userId.Value);
|
||||
|
||||
return new Success(true, null, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private string? CheckEmailVerificationLimit(long? userId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
private void SendVerificationEmail(PlayerData plrData)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(plrData.EmailVerifyToken)) return;
|
||||
|
||||
|
||||
|
||||
var address = plrData.PendingEmail;
|
||||
if (string.IsNullOrWhiteSpace(address)) return;
|
||||
|
||||
|
||||
|
||||
var profile = db.Profiles.FirstOrDefault(x => x.accountId == plrData.AccountId);
|
||||
var atName = profile != null && !string.IsNullOrWhiteSpace(profile.username)
|
||||
? " <b>@" + System.Net.WebUtility.HtmlEncode(profile.username) + "</b>"
|
||||
: "";
|
||||
|
||||
var link = $"https://radicade.app/verify?code={plrData.EmailVerifyToken}";
|
||||
var html =
|
||||
"<div style=\"font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:24px;\">" +
|
||||
"<img src=\"https://radicade.app/assests/RadicadeHorizontal.png\" alt=\"Radicade\" style=\"display:block;max-width:220px;height:auto;margin:0 auto 22px;\" />" +
|
||||
"<h2 style=\"color:#1a1a2e;margin-top:0;\">Confirm your Radicade email</h2>" +
|
||||
"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Hi there,</p>" +
|
||||
"<p style=\"color:#444;font-size:15px;line-height:1.6;\">We received a request to set <b>" + System.Net.WebUtility.HtmlEncode(address) + "</b> as the email on your Radicade account" + atName + ". Confirm it's you by clicking the button below:</p>" +
|
||||
"<p style=\"text-align:center;margin:28px 0;\"><a href=\"" + link + "\" style=\"background:#4a6cf7;color:#fff;text-decoration:none;padding:12px 28px;border-radius:6px;display:inline-block;\">Verify my email</a></p>" +
|
||||
"<p style=\"color:#999;font-size:12px;margin-top:24px;\">If you didn't request this, you can safely ignore this email.</p></div>";
|
||||
|
||||
_ = RadicadeMail.SendAsync(address, "Confirm your email - Radicade", html);
|
||||
}
|
||||
|
||||
private static string VerificationPage(bool success, string message)
|
||||
{
|
||||
var color = success ? "#16a34a" : "#dc2626";
|
||||
return "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Radicade Email Verification</title></head>" +
|
||||
"<body style=\"margin:0;background:#0f172a;font-family:Arial,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;\">" +
|
||||
"<div style=\"background:#fff;border-radius:12px;padding:40px;max-width:440px;text-align:center;box-shadow:0 10px 40px rgba(0,0,0,0.3);\">" +
|
||||
"<div style=\"font-size:44px;\">" + (success ? "✅" : "⚠️") + "</div>" +
|
||||
"<h1 style=\"color:" + color + ";font-size:22px;margin:12px 0;\">" + (success ? "Email verified" : "Verification failed") + "</h1>" +
|
||||
"<p style=\"color:#475569;font-size:15px;line-height:1.6;\">" + message + "</p>" +
|
||||
"</div></body></html>";
|
||||
}
|
||||
|
||||
[HttpGet("account/bulk")]
|
||||
public async Task<List<Profile>> GetBulk([FromQuery] int[] id)
|
||||
{
|
||||
List<Profile> result = new List<Profile>();
|
||||
foreach (int i in id)
|
||||
{
|
||||
Profile? profile = await db.Profiles.FirstOrDefaultAsync((prf) => prf.accountId == i);
|
||||
result.Add(profile ?? BuildMissingProfile(i));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
[HttpGet("account/{id}")]
|
||||
public async Task<Profile?> GetAccount(long id)
|
||||
{
|
||||
Profile? profile = await db.Profiles.FirstOrDefaultAsync((prf) => prf.accountId == id);
|
||||
return profile ?? BuildMissingProfile(id);
|
||||
}
|
||||
|
||||
[HttpGet("account/{id}/clubs")]
|
||||
public async Task<IActionResult> GetClubsForAccount(long id)
|
||||
{
|
||||
var clubs = await ClubStore.LoadAsync(db);
|
||||
return Ok(clubs.Where(x => x.ownerPlayerId == id)
|
||||
.Select(x => ClubStore.BuildClub(x, User.GetUserId() ?? 0))
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
private static Profile BuildMissingProfile(long accountId)
|
||||
{
|
||||
var name = $"Player{accountId}";
|
||||
return new Profile
|
||||
{
|
||||
accountId = accountId,
|
||||
profileImage = "DefaultProfileImage",
|
||||
isJunior = false,
|
||||
platforms = 1,
|
||||
username = name,
|
||||
displayName = name,
|
||||
bio = "",
|
||||
createdAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
public async Task SendAccountUpdate(long userId)
|
||||
{
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == userId);
|
||||
var plrData = await db.Players.FirstOrDefaultAsync(x => x.AccountId == userId);
|
||||
if (profile == null) return;
|
||||
if (plrData == null) return;
|
||||
|
||||
await Notify.SendNotify(userId, new Notification
|
||||
{
|
||||
Id = "AccountUpdate",
|
||||
Msg = profile
|
||||
});
|
||||
|
||||
await Notify.UpdateSubscriptions(userId, Notifs.SubscriptionUpdateProfile, profile);
|
||||
|
||||
await Notify.SendNotify(userId, new Notification
|
||||
{
|
||||
Id = "SelfAccountUpdate",
|
||||
Msg = PlayerUtils.BuildSelfAccount(profile, plrData)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace RadicadeServer.RecNet.Accounts
|
||||
{
|
||||
public class AdministrativeData
|
||||
{
|
||||
public DateTime LastLoginTime { get; set; }
|
||||
public DateTime JoinData { get; set; }
|
||||
public int PermissionFactors { get; set; }
|
||||
public int TrustFactor { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace RadicadeServer.Utils
|
||||
{
|
||||
public static class AuthSecurityMonitor
|
||||
{
|
||||
public static readonly TimeSpan Window = TimeSpan.FromMinutes(10);
|
||||
|
||||
public const int AccountFailureAlertThreshold = 8;
|
||||
|
||||
public const int SourceSprayAccountThreshold = 12;
|
||||
|
||||
public const int SourceFailureAlertThreshold = 40;
|
||||
|
||||
public const int MaxProgressiveDelayMs = 3000;
|
||||
|
||||
private static readonly ConcurrentDictionary<long, Counter> AccountFailures = new();
|
||||
private static readonly ConcurrentDictionary<string, SourceCounter> SourceFailures = new();
|
||||
|
||||
public static int RecordFailedLogin(long? accountId, string? ip)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var accountCount = 0;
|
||||
|
||||
if (accountId is > 0)
|
||||
{
|
||||
var counter = AccountFailures.GetOrAdd(accountId.Value, _ => new Counter());
|
||||
accountCount = counter.Record(now, Window);
|
||||
if (accountCount == AccountFailureAlertThreshold)
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] security alert: {accountCount} failed sign-ins for account {accountId.Value} within {Window.TotalMinutes:0} minutes - for staff review only, no automatic action taken");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ip))
|
||||
{
|
||||
var source = SourceFailures.GetOrAdd(ip!, _ => new SourceCounter());
|
||||
var (failures, accounts) = source.Record(now, Window, accountId);
|
||||
if (accounts == SourceSprayAccountThreshold)
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] security alert: suspected password spraying from {ip} - failed sign-ins for {accounts} different accounts within {Window.TotalMinutes:0} minutes");
|
||||
else if (failures == SourceFailureAlertThreshold)
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] security alert: {failures} failed sign-ins from {ip} within {Window.TotalMinutes:0} minutes");
|
||||
}
|
||||
|
||||
return accountCount;
|
||||
}
|
||||
|
||||
public static int ProgressiveDelayMs(int failureCount) =>
|
||||
failureCount <= 3 ? 0 : Math.Min(MaxProgressiveDelayMs, (failureCount - 3) * 300);
|
||||
|
||||
public static bool IsSourceThrottled(string? ip)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ip)) return false;
|
||||
if (!SourceFailures.TryGetValue(ip!, out var source)) return false;
|
||||
return source.DistinctAccounts(DateTime.UtcNow, Window) >= SourceSprayAccountThreshold;
|
||||
}
|
||||
|
||||
public static void RecordSuccessfulLogin(long accountId, string? ip, bool newDevice, bool newIp)
|
||||
{
|
||||
if (accountId > 0) AccountFailures.TryRemove(accountId, out _);
|
||||
|
||||
if (newDevice || newIp)
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] security note: account {accountId} signed in from a {(newDevice ? "new device" : "new IP")} - informational only, this never bans or locks by itself");
|
||||
}
|
||||
|
||||
public static void NoteCredentialChange(long accountId, string kind) =>
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] security note: {kind} for account {accountId} - verify it was requested by the account owner");
|
||||
|
||||
public static void Reset()
|
||||
{
|
||||
AccountFailures.Clear();
|
||||
SourceFailures.Clear();
|
||||
}
|
||||
|
||||
private sealed class Counter
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly List<DateTime> hits = new();
|
||||
|
||||
public int Record(DateTime now, TimeSpan window)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
hits.RemoveAll(t => now - t > window);
|
||||
hits.Add(now);
|
||||
return hits.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SourceCounter
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly List<(DateTime At, long AccountId)> hits = new();
|
||||
|
||||
public (int Failures, int Accounts) Record(DateTime now, TimeSpan window, long? accountId)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
hits.RemoveAll(h => now - h.At > window);
|
||||
hits.Add((now, accountId ?? 0));
|
||||
return (hits.Count, Distinct());
|
||||
}
|
||||
}
|
||||
|
||||
public int DistinctAccounts(DateTime now, TimeSpan window)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
hits.RemoveAll(h => now - h.At > window);
|
||||
return Distinct();
|
||||
}
|
||||
}
|
||||
|
||||
private int Distinct()
|
||||
{
|
||||
var set = new HashSet<long>();
|
||||
foreach (var hit in hits)
|
||||
if (hit.AccountId > 0) set.Add(hit.AccountId);
|
||||
return set.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace RadicadeServer.RecNet.Accounts
|
||||
{
|
||||
public class BioResponse
|
||||
{
|
||||
public long accountId { get; set; }
|
||||
public string bio { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace RadicadeServer.RecNet.Accounts
|
||||
{
|
||||
public class Currency
|
||||
{
|
||||
[Key, DatabaseGenerated(DatabaseGeneratedOption.None)]
|
||||
public long AccountId { get; set; }
|
||||
|
||||
public long RecTokens { get; set; } = 0;
|
||||
public long LaserTagTickets { get; set; } = 0;
|
||||
public long Gold { get; set; } = 0;
|
||||
public long DraculaSilver { get; set; } = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.RecNet.Accounts.Local;
|
||||
using RadicadeServer.RecNet.Accounts;
|
||||
using RadicadeServer.RecNet.Auth.Cached;
|
||||
using RadicadeServer.RecNet.Auth;
|
||||
using RadicadeServer.RecNet;
|
||||
using RadicadeServer.RecNet.Config;
|
||||
using RadicadeServer.RecNet.Enums;
|
||||
using RadicadeServer.RecNet.Leveling;
|
||||
using RadicadeServer.RecNet.Moderation;
|
||||
using RadicadeServer.RecNet.Rooms;
|
||||
using RadicadeServer.Extensions;
|
||||
using System;
|
||||
using RadicadeServer.RecNet.Enums.Players;
|
||||
using RadicadeServer.RecNet.Enums.Rooms;
|
||||
using RadicadeServer.RecNet.Progression;
|
||||
|
||||
namespace RadicadeServer.Utils
|
||||
{
|
||||
public static class FirstTimeUtils
|
||||
{
|
||||
|
||||
|
||||
public const string AutoMicMutingConfig = "{\"MicSpamVolumeThreshold\":1.125,\"MicVolumeSampleInterval\":0.25,\"MicVolumeSampleRollingWindowLength\":7,\"MicSpamSamplePercentageForWarning\":0.8,\"MicSpamSamplePercentageForWarningToEnd\":0.2,\"MicSpamSamplePercentageForForceMute\":0.8,\"MicSpamSamplePercentageForForceMuteToEnd\":0.2,\"MicSpamWarningStateVolumeMultiplier\":0.25}";
|
||||
public const string StorefrontConfig = "{\"MinPlayerLevelForGifting\":1,\"LatestStoreBadgeDateTime\":\"2025-11-13T17:27:21.5641043Z\"}";
|
||||
public const string LevelProgressionMap = "[{\"Level\":0,\"RequiredXp\":0,\"GiftDropId\":null},{\"Level\":1,\"RequiredXp\":10,\"GiftDropId\":3288},{\"Level\":2,\"RequiredXp\":10,\"GiftDropId\":3293},{\"Level\":3,\"RequiredXp\":10,\"GiftDropId\":3288},{\"Level\":4,\"RequiredXp\":20,\"GiftDropId\":3293},{\"Level\":5,\"RequiredXp\":20,\"GiftDropId\":3288},{\"Level\":6,\"RequiredXp\":20,\"GiftDropId\":3293},{\"Level\":7,\"RequiredXp\":20,\"GiftDropId\":3288},{\"Level\":8,\"RequiredXp\":20,\"GiftDropId\":3293},{\"Level\":9,\"RequiredXp\":20,\"GiftDropId\":3288},{\"Level\":10,\"RequiredXp\":20,\"GiftDropId\":3293},{\"Level\":11,\"RequiredXp\":45,\"GiftDropId\":3293},{\"Level\":12,\"RequiredXp\":45,\"GiftDropId\":3293},{\"Level\":13,\"RequiredXp\":45,\"GiftDropId\":3293},{\"Level\":14,\"RequiredXp\":45,\"GiftDropId\":3293},{\"Level\":15,\"RequiredXp\":45,\"GiftDropId\":3293},{\"Level\":16,\"RequiredXp\":45,\"GiftDropId\":3293},{\"Level\":17,\"RequiredXp\":45,\"GiftDropId\":3293},{\"Level\":18,\"RequiredXp\":45,\"GiftDropId\":3293},{\"Level\":19,\"RequiredXp\":45,\"GiftDropId\":3293},{\"Level\":20,\"RequiredXp\":45,\"GiftDropId\":3293},{\"Level\":21,\"RequiredXp\":115,\"GiftDropId\":3293},{\"Level\":22,\"RequiredXp\":115,\"GiftDropId\":3292},{\"Level\":23,\"RequiredXp\":115,\"GiftDropId\":3293},{\"Level\":24,\"RequiredXp\":115,\"GiftDropId\":3292},{\"Level\":25,\"RequiredXp\":115,\"GiftDropId\":3293},{\"Level\":26,\"RequiredXp\":115,\"GiftDropId\":3292},{\"Level\":27,\"RequiredXp\":115,\"GiftDropId\":3293},{\"Level\":28,\"RequiredXp\":115,\"GiftDropId\":3292},{\"Level\":29,\"RequiredXp\":115,\"GiftDropId\":3293},{\"Level\":30,\"RequiredXp\":115,\"GiftDropId\":3292},{\"Level\":31,\"RequiredXp\":360,\"GiftDropId\":3291},{\"Level\":32,\"RequiredXp\":360,\"GiftDropId\":3292},{\"Level\":33,\"RequiredXp\":360,\"GiftDropId\":3292},{\"Level\":34,\"RequiredXp\":360,\"GiftDropId\":3292},{\"Level\":35,\"RequiredXp\":360,\"GiftDropId\":3291},{\"Level\":36,\"RequiredXp\":360,\"GiftDropId\":3292},{\"Level\":37,\"RequiredXp\":360,\"GiftDropId\":3292},{\"Level\":38,\"RequiredXp\":360,\"GiftDropId\":3292},{\"Level\":39,\"RequiredXp\":360,\"GiftDropId\":3292},{\"Level\":40,\"RequiredXp\":360,\"GiftDropId\":3291},{\"Level\":41,\"RequiredXp\":1080,\"GiftDropId\":3291},{\"Level\":42,\"RequiredXp\":1080,\"GiftDropId\":3291},{\"Level\":43,\"RequiredXp\":1080,\"GiftDropId\":3291},{\"Level\":44,\"RequiredXp\":1080,\"GiftDropId\":3291},{\"Level\":45,\"RequiredXp\":1080,\"GiftDropId\":3291},{\"Level\":46,\"RequiredXp\":1080,\"GiftDropId\":3291},{\"Level\":47,\"RequiredXp\":1080,\"GiftDropId\":3291},{\"Level\":48,\"RequiredXp\":1080,\"GiftDropId\":3291},{\"Level\":49,\"RequiredXp\":1080,\"GiftDropId\":3291},{\"Level\":50,\"RequiredXp\":1080,\"GiftDropId\":3290},{\"Level\":51,\"RequiredXp\":270,\"GiftDropId\":33363},{\"Level\":52,\"RequiredXp\":270,\"GiftDropId\":3292},{\"Level\":53,\"RequiredXp\":297,\"GiftDropId\":33462},{\"Level\":54,\"RequiredXp\":297,\"GiftDropId\":3291},{\"Level\":55,\"RequiredXp\":297,\"GiftDropId\":18152},{\"Level\":56,\"RequiredXp\":356,\"GiftDropId\":3292},{\"Level\":57,\"RequiredXp\":356,\"GiftDropId\":33466},{\"Level\":58,\"RequiredXp\":356,\"GiftDropId\":3291},{\"Level\":59,\"RequiredXp\":356,\"GiftDropId\":3314},{\"Level\":60,\"RequiredXp\":356,\"GiftDropId\":3290},{\"Level\":61,\"RequiredXp\":428,\"GiftDropId\":3292},{\"Level\":62,\"RequiredXp\":428,\"GiftDropId\":33338},{\"Level\":63,\"RequiredXp\":428,\"GiftDropId\":3292},{\"Level\":64,\"RequiredXp\":428,\"GiftDropId\":33463},{\"Level\":65,\"RequiredXp\":428,\"GiftDropId\":4463},{\"Level\":66,\"RequiredXp\":428,\"GiftDropId\":3291},{\"Level\":67,\"RequiredXp\":428,\"GiftDropId\":3880},{\"Level\":68,\"RequiredXp\":428,\"GiftDropId\":3292},{\"Level\":69,\"RequiredXp\":428,\"GiftDropId\":33464},{\"Level\":70,\"RequiredXp\":428,\"GiftDropId\":33329},{\"Level\":71,\"RequiredXp\":556,\"GiftDropId\":3292},{\"Level\":72,\"RequiredXp\":556,\"GiftDropId\":3025},{\"Level\":73,\"RequiredXp\":556,\"GiftDropId\":3291},{\"Level\":74,\"RequiredXp\":556,\"GiftDropId\":33305},{\"Level\":75,\"RequiredXp\":556,\"GiftDropId\":33467},{\"Level\":76,\"RequiredXp\":556,\"GiftDropId\":3292},{\"Level\":77,\"RequiredXp\":556,\"GiftDropId\":33465},{\"Level\":78,\"RequiredXp\":556,\"GiftDropId\":3291},{\"Level\":79,\"RequiredXp\":556,\"GiftDropId\":33202},{\"Level\":80,\"RequiredXp\":556,\"GiftDropId\":33051},{\"Level\":81,\"RequiredXp\":778,\"GiftDropId\":3291},{\"Level\":82,\"RequiredXp\":778,\"GiftDropId\":33335},{\"Level\":83,\"RequiredXp\":778,\"GiftDropId\":3292},{\"Level\":84,\"RequiredXp\":778,\"GiftDropId\":33281},{\"Level\":85,\"RequiredXp\":778,\"GiftDropId\":33203},{\"Level\":86,\"RequiredXp\":778,\"GiftDropId\":3291},{\"Level\":87,\"RequiredXp\":778,\"GiftDropId\":33468}]";
|
||||
public const string DailyObjectives = "[[{\"type\":1030,\"score\":1,\"xp\":200,\"descriptionOverride\":null,\"tooltipOverride\":null},{\"type\":1033,\"score\":10,\"xp\":200,\"descriptionOverride\":null,\"tooltipOverride\":null},{\"type\":2000,\"score\":1,\"xp\":200,\"descriptionOverride\":null,\"tooltipOverride\":null}],[{\"type\":601,\"score\":2,\"xp\":200,\"descriptionOverride\":null,\"tooltipOverride\":null},{\"type\":32,\"score\":1,\"xp\":200,\"descriptionOverride\":null,\"tooltipOverride\":null},{\"type\":6,\"score\":1,\"xp\":200,\"descriptionOverride\":null,\"tooltipOverride\":null}],[{\"type\":301,\"score\":2,\"xp\":200,\"descriptionOverride\":null,\"tooltipOverride\":null},{\"type\":302,\"score\":2,\"xp\":200,\"descriptionOverride\":null,\"tooltipOverride\":null},{\"type\":6,\"score\":1,\"xp\":200,\"descriptionOverride\":null,\"tooltipOverride\":null}],[{\"type\":701,\"score\":2,\"xp\":200,\"descriptionOverride\":null,\"tooltipOverride\":null},{\"type\":702,\"score\":20,\"xp\":200,\"descriptionOverride\":null,\"tooltipOverride\":null},{\"type\":32,\"score\":1,\"xp\":200,\"descriptionOverride\":null,\"tooltipOverride\":null}],[{\"type\":1010,\"score\":1,\"xp\":200,\"descriptionOverride\":null,\"tooltipOverride\":null},{\"type\":1013,\"score\":10,\"xp\":200,\"descriptionOverride\":null,\"tooltipOverride\":null},{\"type\":6,\"score\":1,\"xp\":200,\"descriptionOverride\":null,\"tooltipOverride\":null}],[{\"type\":500,\"score\":2,\"xp\":200,\"descriptionOverride\":null,\"tooltipOverride\":null},{\"type\":502,\"score\":20,\"xp\":200,\"descriptionOverride\":null,\"tooltipOverride\":null},{\"type\":2000,\"score\":1,\"xp\":200,\"descriptionOverride\":null,\"tooltipOverride\":null}],[{\"type\":1020,\"score\":1,\"xp\":200,\"descriptionOverride\":null,\"tooltipOverride\":null},{\"type\":1023,\"score\":10,\"xp\":200,\"descriptionOverride\":null,\"tooltipOverride\":null},{\"type\":32,\"score\":1,\"xp\":200,\"descriptionOverride\":null,\"tooltipOverride\":null}]]";
|
||||
public const string NamedImages = "[{\"FriendlyImageName\":\"DormRoomBucket\",\"ImageName\":\"n/67gesMhbOkCd3-qO1cKkIg\",\"StartTime\":\"2018-09-27T18:00:00Z\",\"EndTime\":\"2222-02-22T22:22:00Z\"},{\"FriendlyImageName\":\"ControlSchemeTutorial_Creator\",\"ImageName\":\"n/877acd69ecf44493a21021fadb5f77e4\",\"StartTime\":\"2023-05-23T00:12:00Z\",\"EndTime\":\"2030-05-23T00:12:00Z\"},{\"FriendlyImageName\":\"UI_Menu_Token_Image_10\",\"ImageName\":\"n/54094cbd9d0b455fb949266d1679a834\",\"StartTime\":\"2025-06-02T07:00:00Z\",\"EndTime\":\"2099-06-02T07:00:00Z\"},{\"FriendlyImageName\":\"UI_Menu_Token_Image_25\",\"ImageName\":\"n/4e4e496810ae4bbcb61564c34df450b6\",\"StartTime\":\"2025-06-02T07:00:00Z\",\"EndTime\":\"2099-06-02T07:00:00Z\"},{\"FriendlyImageName\":\"UI_Menu_Token_Image_50\",\"ImageName\":\"n/ca1c08334a7a422b941ef2987b636edf\",\"StartTime\":\"2025-06-02T07:00:00Z\",\"EndTime\":\"2099-06-02T07:00:00Z\"},{\"FriendlyImageName\":\"UI_Menu_Token_Image_100\",\"ImageName\":\"n/979ea0d3d9e9487cb7ca56c37c7ece6b\",\"StartTime\":\"2025-06-02T07:00:00Z\",\"EndTime\":\"2099-06-02T07:00:00Z\"},{\"FriendlyImageName\":\"MintFloatieBetterThumbnail\",\"ImageName\":\"n/23998eb5a1714dcc94a97b9b6ea82d50\",\"StartTime\":\"2025-07-02T23:24:00Z\",\"EndTime\":\"2026-07-02T23:24:00Z\"},{\"FriendlyImageName\":\"IceCreamHatBetterThumbnail\",\"ImageName\":\"n/aec858dad86b42238338da60a5071dc2\",\"StartTime\":\"2025-07-02T23:25:00Z\",\"EndTime\":\"2026-07-02T23:25:00Z\"},{\"FriendlyImageName\":\"IceCreamShirtBetterThumbnail\",\"ImageName\":\"n/55a94ecff89543b0bef248bc1b232906\",\"StartTime\":\"2025-07-02T23:25:00Z\",\"EndTime\":\"2026-07-02T23:25:00Z\"},{\"FriendlyImageName\":\"IceCreamPantsBetterThumbnail\",\"ImageName\":\"n/3cd34681c224408e9f1a8d29ed3efd66\",\"StartTime\":\"2025-07-02T23:26:00Z\",\"EndTime\":\"2026-07-02T23:26:00Z\"},{\"FriendlyImageName\":\"IceCreamGlovesBetterThumbnail\",\"ImageName\":\"n/a68d8ae9938045ee895028f0be46e141\",\"StartTime\":\"2025-07-02T23:26:00Z\",\"EndTime\":\"2026-07-02T23:26:00Z\"}]";
|
||||
public static readonly string LoadingScreen = LoadingScreenImages.Tips.ToJson();
|
||||
public const string GiftDropStoreItems = "[{\"PurchasableItemId\":2597201,\"Type\":0,\"IsFeatured\":true,\"Prices\":[{\"CurrencyType\":2,\"Price\":100}],\"GiftDrops\":[{\"GiftDropId\":0,\"ItemSetId\":0,\"ItemSetFriendlyName\":\"rawdata\",\"FriendlyName\":\"rawdata\",\"Tooltip\":\"rawdata\",\"IsQuery\":false,\"Unique\":false,\"SubscribersOnly\":false,\"ConsumableItemDesc\":\"\",\"AvatarItemDesc\":\"274cb9b2-2f59-47ea-9a8d-a5b656d148c6,,,\",\"AvatarItemType\":0,\"EquipmentPrefabName\":\"\",\"EquipmentModificationGuid\":\"\",\"CurrencyType\":0,\"Currency\":0,\"Xp\":0,\"Platform\":0,\"PlatformsToSpawnOn\":0,\"Context\":0,\"Rarity\":0}]}]";
|
||||
public const string CommunityBoard = "{\"FeaturedPlayer\":{\"Id\":1,\"TitleOverride\":\"Community Star\",\"UrlOverride\":\"\"},\"FeaturedRoomGroup\":{\"FeaturedRoomGroupId\":1,\"Name\":\"Top Picks\",\"Rooms\":[{\"RoomId\":2,\"RoomName\":\"Rec Center\",\"ImageName\":\"\"},{\"RoomId\":4,\"RoomName\":\"Golden Trophy\",\"ImageName\":\"\"}]},\"CurrentAnnouncement\":{\"Message\":\"Welcome to Radicade!\",\"MoreInfoUrl\":\"\"},\"InstagramImages\":null,\"Videos\":null}";
|
||||
public const string CurrentFeatured = "{\"FeaturedRoomGroupId\":0,\"Name\":\"RecRoomOriginals\",\"Rooms\":[{\"RoomId\":2,\"RoomName\":\"RecCenter\",\"ImageName\":\"RecCenter\"}]}";
|
||||
|
||||
|
||||
private static Dictionary<string, string> Images = new();
|
||||
|
||||
private static void ParseImagesFile()
|
||||
{
|
||||
if (!File.Exists(DBConfig.DefaultImagesFile))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string readFile = File.ReadAllText(DBConfig.DefaultImagesFile);
|
||||
string[] keys = readFile.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (key.StartsWith("#")) continue;
|
||||
if (string.IsNullOrEmpty(key)) continue;
|
||||
|
||||
string[] values = key.Split('=', 2, StringSplitOptions.TrimEntries);
|
||||
if (values.Length != 2) continue;
|
||||
|
||||
Images[values[0]] = values[1];
|
||||
}
|
||||
}
|
||||
|
||||
public async static Task DownloadImages()
|
||||
{
|
||||
if (Images.Count == 0)
|
||||
ParseImagesFile();
|
||||
|
||||
HttpClient httpClient = new HttpClient
|
||||
{
|
||||
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
foreach (var image in Images.Keys)
|
||||
{
|
||||
string filePath = Path.Join(DBConfig.CDNImages, image);
|
||||
string url = Images[image];
|
||||
|
||||
if (File.Exists(filePath)) continue;
|
||||
|
||||
using var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||
await using var fileStream = File.Create(filePath);
|
||||
|
||||
await stream.CopyToAsync(fileStream);
|
||||
}
|
||||
httpClient.Dispose();
|
||||
}
|
||||
|
||||
public async static Task AddCoach(this RecDBContext ctx)
|
||||
{
|
||||
Profile profile = new Profile();
|
||||
|
||||
profile.platforms = 1;
|
||||
profile.username = "Coach";
|
||||
profile.profileImage = "default";
|
||||
profile.displayName = profile.username;
|
||||
profile.isJunior = false;
|
||||
profile.createdAt = DateTime.Now;
|
||||
|
||||
await ctx.Profiles.AddAsync(profile);
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
|
||||
PlayerAvatar playerAvatar = new PlayerAvatar();
|
||||
playerAvatar.Avatar = DBConfig.DefaultAvatar;
|
||||
playerAvatar.Outfits = "[]";
|
||||
playerAvatar.AccountId = profile.accountId;
|
||||
|
||||
|
||||
Reputation reputation = new Reputation();
|
||||
reputation.AccountId = profile.accountId;
|
||||
|
||||
|
||||
PlayerSettings settings = new PlayerSettings();
|
||||
settings.AccountId = profile.accountId;
|
||||
settings.Settings = DBConfig.DefaultSettings2023;
|
||||
|
||||
|
||||
ProgressionData progression = new ProgressionData();
|
||||
progression.PlayerId = profile.accountId;
|
||||
progression.XP = 0;
|
||||
progression.Level = 99;
|
||||
progression.Progress = new ProgressionJson().ToJson();
|
||||
|
||||
|
||||
RealPresence presence = new RealPresence();
|
||||
presence.playerId = profile.accountId;
|
||||
presence.deviceClass = DeviceClass.Unknown;
|
||||
presence.statusVisibility = StatusVisibility.Public;
|
||||
presence.vRMovementMode = VRMovementModes.TELEPORT;
|
||||
presence.roomInstance = null;
|
||||
presence.LoginLock = null;
|
||||
|
||||
|
||||
Currency currency = new Currency();
|
||||
currency.AccountId = profile.accountId;
|
||||
|
||||
|
||||
PlayerUnlocks unlocks = new PlayerUnlocks();
|
||||
unlocks.AccountId = profile.accountId;
|
||||
|
||||
|
||||
CachedLogin cachedLogin = new CachedLogin();
|
||||
|
||||
cachedLogin.accountId = profile.accountId;
|
||||
cachedLogin.platform = Platforms.All;
|
||||
cachedLogin.platformId = "0";
|
||||
cachedLogin.lastLoginTime = DateTime.Now;
|
||||
|
||||
|
||||
PlayerData playerData = new PlayerData();
|
||||
playerData.AccountId = profile.accountId;
|
||||
playerData.Phone = "";
|
||||
playerData.Birthday = DateTime.MinValue;
|
||||
playerData.Parent = null;
|
||||
playerData.AppVersion = DBConfig.GameVersion;
|
||||
playerData.DeviceClass = DeviceClass.Unknown;
|
||||
playerData.NameChanges = 99;
|
||||
playerData.Email = "";
|
||||
playerData.Hashword = "";
|
||||
playerData.JuniorState = JuniorState.BasedOnBirthday;
|
||||
playerData.Visibility = StatusVisibility.Public;
|
||||
|
||||
|
||||
await ctx.CachedLogins.AddAsync(cachedLogin);
|
||||
await ctx.Avatars.AddAsync(playerAvatar);
|
||||
await ctx.Reputations.AddAsync(reputation);
|
||||
await ctx.PlayerSettings.AddAsync(settings);
|
||||
await ctx.Presences.AddAsync(presence);
|
||||
await ctx.Currency.AddAsync(currency);
|
||||
await ctx.Unlocks.AddAsync(unlocks);
|
||||
await ctx.Players.AddAsync(playerData);
|
||||
await ctx.Progression.AddAsync(progression);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async static Task AddServerConfig(this RecDBContext ctx)
|
||||
{
|
||||
|
||||
await ctx.ServerConfig.AddAsync(new ServerConfigVal("AccountCreate.RequireSteamTicket", "false"));
|
||||
|
||||
|
||||
await ctx.ServerConfig.AddAsync(new ServerConfigVal("WhitelistOnly", "false"));
|
||||
await ctx.ServerConfig.AddAsync(new ServerConfigVal("SteamWhitelist", ""));
|
||||
|
||||
await ctx.ServerConfig.AddAsync(new ServerConfigVal("Announcement", "[]"));
|
||||
|
||||
await ctx.ServerConfig.AddAsync(new ServerConfigVal("NamedImages", NamedImages));
|
||||
await ctx.ServerConfig.AddAsync(new ServerConfigVal("LoadingScreenTipData", LoadingScreen));
|
||||
|
||||
await ctx.ServerConfig.AddAsync(new ServerConfigVal("CommunityBoard", CommunityBoard));
|
||||
await ctx.ServerConfig.AddAsync(new ServerConfigVal("CurrentFeaturedRooms", CurrentFeatured));
|
||||
|
||||
await ctx.ServerConfig.AddAsync(new ServerConfigVal("Filters", "rro"));
|
||||
|
||||
await ctx.ServerConfig.AddAsync(new ServerConfigVal("CharadesWords", "Dog;Cat;Bird;Fish;Monkey;Elephant;Dinosaur;Robot;Superhero;Pirate;Cowboy;Astronaut;Chef;Doctor;Police Officer;Firefighter;Teacher;Baby;Zombie;Ghost;Brushing Teeth;Taking a Shower;Eating Spaghetti;Making a Sandwich;Riding a Bike;Swimming;Running;Sleeping;Dancing;Singing;Fishing;Cooking;Driving a Car;Flying a Plane;Basketball;Soccer;Guitar;Taking a Selfie;Opening a Present;Walking a Dog;Chicken;LEGO;Banana Peel;Bees;Fly;Elevator;Phone;Jar;Spider;Dinosaur;Invisible Enemy;Wind;Mosquito;Falling;Reflection;Sneaking;Glass Door;Carrying Boxes;Revolving Door;Floor Is Lava;Magician;Detective;Ninja;Vampire;Werewolf;King;Queen;Knight;Scientist;Construction Worker;Photographer;Rock Star;News Reporter;Secret Agent;Treasure Hunter;Time Traveler;Supervillain;Circus Performer;Archaeologist;Lifeguard;Raccoon;Penguin;Pizza;Grandma;Alien;Wi-Fi;Police Officer;Superhero;Job;Horse;Fish;Ghost;Vampire;Monkey;Astronaut;Wizard;Shark;Tiny Door"));
|
||||
await ctx.ServerConfig.AddAsync(new ServerConfigVal("CharadesAprilFoolsWords", "Prank;Joke;Fool;Trick;Clown;Jester;Comedian;Confetti;Balloon;Pie;Banana;Chicken;Spider;Bug;Snake;Mustache;Disguise;Mask;Costume;Surprise;Laughter;Sneak;Hide;Scare;Troll;Mischief;Chaos;Fake;Lie;Trap;Decoy;Magic;Wizard;Detective;Thief;Rubber Chicken;Whoopee Cushion;Whistle;Air Horn;Party;Celebration;Secret;Shock;Confusion;Scream;Giggle;Smile;Prankster;Foolish;Trickster;Gotcha;April Fools"));
|
||||
await ctx.ServerConfig.AddAsync(new ServerConfigVal("Icebreakers", "Favorite Game;First Game;Favorite Room;Funniest Gaming Moment;Dream Game Feature;Favorite Platform;Favorite Game Genre;Worst Game;Favorite Soundtrack;Favorite Video Game World;Favorite Food;Weirdest Food;Favorite Animal;Weirdest Dream;Dream Skill;Useless Talent;Favorite Meal;Favorite Movie;Favorite TV Show;Favorite Song;Favorite Holiday;Morning Person;Night Owl;Dream Vacation;Dream Job;Favorite Season;Favorite Hobby;Favorite Sport;Favorite Color;Favorite Memory;Flying;Invisibility;Unlimited Money;Free Time;Underwater;Space;Internet;Video Games;Rewind;Pause;Future;Past;Luck;Intelligence;Theme Park;Video Game;Tiny Version;Giant Version;Loading Screen;Username;NPC;Avatar;Food Name;Superpower;Aliens;Pet;Dumbest Argument;Dream House;Dream Car;Favorite Character;Favorite YouTuber;Favorite Musician;Favorite Book;Favorite App;Favorite Website;Favorite Holiday;Favorite Game Character;Favorite Snack;Favorite Drink;Favorite Place;Favorite School Subject;Favorite Sport;Favorite Meme;Favorite Emoji;Favorite Childhood Game;Dream Pet;Dream Superpower;Dream Adventure;Best Friend;Biggest Fear;Hidden Talent;Bucket List;Perfect Day;Favorite Weather;Favorite Time of Day;Favorite Season;Favorite Number;Favorite Letter;Favorite Fruit;Favorite Candy;Favorite Restaurant;Favorite Pizza Topping;Favorite Ice Cream;Favorite Cereal;Favorite Breakfast;Favorite Fast Food;Favorite Dessert;Favorite Vegetable;Favorite Cartoon;Favorite Superhero;Favorite Villain;Favorite Movie Character;Favorite Game Developer"));
|
||||
|
||||
await ctx.ServerConfig.AddAsync(new ServerConfigVal("HighRanks", "1"));
|
||||
|
||||
}
|
||||
|
||||
public async static Task AddConfig(this RecDBContext ctx)
|
||||
{
|
||||
|
||||
MainConfig mainConfig = new MainConfig();
|
||||
|
||||
mainConfig.ShareBaseUrl = DBConfig.NameServer.WWW + "{0}";
|
||||
mainConfig.CdnBaseUri = DBConfig.NameServer.CDN;
|
||||
mainConfig.LevelProgressionMaps = LevelProgressionMap;
|
||||
mainConfig.AutoMicMutingConfig = AutoMicMutingConfig;
|
||||
mainConfig.StorefrontConfig = StorefrontConfig;
|
||||
mainConfig.DailyObjectives = DailyObjectives;
|
||||
mainConfig.ServerMaintenance = "{\"StartsInMinutes\":0}";
|
||||
mainConfig.RoomKeyConfig = "{\"MaxKeysPerRoom\":500}";
|
||||
mainConfig.RoomCurrencyConfig = "{}";
|
||||
mainConfig.GiftDropStoreItems = GiftDropStoreItems;
|
||||
mainConfig.FUCKYOUMICROSOFT = Guid.NewGuid().ToString();
|
||||
|
||||
await ctx.Config.AddAsync(mainConfig);
|
||||
}
|
||||
|
||||
public async static Task AddDefaultRooms(this RecDBContext ctx)
|
||||
{
|
||||
|
||||
await AddDormRoom(ctx);
|
||||
|
||||
await DefaultRooms.EnsureAsync(ctx);
|
||||
}
|
||||
|
||||
public async static Task AddDormRoom(this RecDBContext ctx)
|
||||
{
|
||||
|
||||
SubRoom dormSubRoom = new SubRoom();
|
||||
dormSubRoom.DataBlob = "";
|
||||
dormSubRoom.IsSandbox = true;
|
||||
dormSubRoom.MaxPlayers = 20;
|
||||
dormSubRoom.Accessibility = RecNet.Enums.Accessibility.Private;
|
||||
dormSubRoom.Name = "Home";
|
||||
dormSubRoom.UnitySceneId = "76d98498-60a1-430c-ab76-b54a29b7a163";
|
||||
dormSubRoom.DataBlob = "";
|
||||
|
||||
RoomStats roomStats = new RoomStats();
|
||||
|
||||
RoomData dormRoom = new RoomData();
|
||||
dormRoom.Name = "DormRoom";
|
||||
dormRoom.ImageName = DormRoomImageName;
|
||||
dormRoom.Description = "Your private room.";
|
||||
dormRoom.CreatorAccountId = 1;
|
||||
dormRoom.IsRRO = true;
|
||||
dormRoom.IsDorm = true;
|
||||
dormRoom.Accessibility = RecNet.Enums.Accessibility.Private;
|
||||
dormRoom.CreatedAt = DateTime.Now;
|
||||
dormRoom.State = RoomState.Active;
|
||||
dormRoom.CloningAllowed = true;
|
||||
dormRoom.Tags = StringTools.CreateTagString(new List<ItemTag> { new ItemTag { Type = TagType.General, Tag = "dormroom" } });
|
||||
|
||||
await ctx.Rooms.AddAsync(dormRoom);
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
dormSubRoom.RoomId = dormRoom.RoomId;
|
||||
roomStats.RoomId = dormRoom.RoomId;
|
||||
|
||||
await ctx.SubRooms.AddAsync(dormSubRoom);
|
||||
await ctx.RoomStats.AddAsync(roomStats);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public const string DormRoomImageName = "Dorm";
|
||||
|
||||
public async static Task NormalizeDormRoomImagesAsync(this RecDBContext ctx)
|
||||
{
|
||||
var unsetNames = new[] { "", "default.png", DBConfig.DefaultRoomImage };
|
||||
|
||||
var dorms = await ctx.Rooms.Where(room => room.IsDorm).ToListAsync();
|
||||
var missing = dorms
|
||||
.Where(room => unsetNames.Any(unset => string.Equals(unset, room.ImageName?.Trim() ?? string.Empty, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToList();
|
||||
if (missing.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var dorm in missing)
|
||||
dorm.ImageName = DormRoomImageName;
|
||||
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
|
||||
public async static Task AddGameConfigs(this RecDBContext ctx)
|
||||
{
|
||||
|
||||
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("Screens.ForceVerification", "1"));
|
||||
|
||||
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("SafePlaySFXAudioSourceSounds", "true"));
|
||||
|
||||
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("Backtrace.config", "0|0.05|0|20|50|1|0|0|1000"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("Backtrace.enabledLikelihood", "1.0|1.0|1.0|1.0|1.0|1.0|1.0|1.0"));
|
||||
|
||||
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("RRUI.UseRRUIHomeScreen", "true"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("RRUI.UseNewProfile", "true"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("UseRRUIEventsScreen", "true"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("ClickOnName.MaxRaycastDistance", "5"));
|
||||
|
||||
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("UGC.ScreenSharing.CompressionQualityMultiplier", "1.0"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("UGC.ScreenSharing.RefreshHzMultiplier", "30.0"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("UGC.ScreenSharing.ResolutionMultiplier", "1.0"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("UGC.ScreenSharing.EnableZipCompression", "true"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("UGC.ScreenSharing.MaxFragmentBytes", "15360"));
|
||||
|
||||
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("UGC.UseDeveloperMode", "true"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("UGC.Circuits.MaxChipResources", "3000"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("UGC.Persistence.MaxCloudVariables", "100"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("UGC.EnableRRUIConfigMenu", "true"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("UGC.ForbiddenSavedSpawnableTools", ""));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("UGC.RoomSavingEnabled", "true"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("UGC.InventionSavingEnabled", "true"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("UGC.MaxChipsVisible", "5000"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("UGC.MaxChipsUnculled", "200"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("UGC.IgnoreInkCost", "true"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("UGC.Persistence.Enabled", "true"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("UGC.Persistence.AutosaveIntervalSeconds", "300"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("UGC.EnableInventionsInBetaRooms", "true"));
|
||||
|
||||
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("Rendering.DisableSrpBatcher", ""));
|
||||
|
||||
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("splitTestSoftOverrides", ""));
|
||||
|
||||
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("RoomDetails.PhotoRollEnabled", "true"));
|
||||
|
||||
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("runningNetworkTimeout", "30"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("loadingNetworkTimeout", "180"));
|
||||
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("SynchronizedField.RemoveDefaultEntries", "true"));
|
||||
|
||||
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("Rewards.SelectionTimeout", "1800"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("Rewards.UseRewardSelection", "true"));
|
||||
|
||||
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("Door.Creative.Query", "^CreativeCampus | ^RecRoomGallery"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("Door.Creative.Title", "Creative Campus"));
|
||||
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("Door.Featured.Query", "#featured"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("Door.Featured.Title", "Featured"));
|
||||
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("Door.Quests.Query", "#quest #rro"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("Door.Quests.Title", "QUESTS"));
|
||||
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("Door.Shooters.Query", "#import"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("Door.Shooters.Title", "Import Rooms!"));
|
||||
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("Door.Sports.Query", "#sport #rro | #pvp #rro"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("Door.Sports.Title", "SPORTS & PVP"));
|
||||
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("Door.StuntRunner.Query", "#stuntrunner #community"));
|
||||
await ctx.GameConfig.AddAsync(new ConfigVal("Door.StuntRunner.Title", "COMMUNITY"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace RadicadeServer.API.Link;
|
||||
|
||||
[Route("link")]
|
||||
public class LinkAPI : Controller
|
||||
{
|
||||
[HttpGet("actionlink/{code}")]
|
||||
public IActionResult ConsumeActionLink(string code)
|
||||
{
|
||||
if (string.Equals(code?.Trim(), "COACH", StringComparison.OrdinalIgnoreCase))
|
||||
return Ok(new { Success = true, Code = "COACH", InfluencerAccountId = 1L });
|
||||
return Ok(new { Success = false, Code = code?.Trim() ?? string.Empty, InfluencerAccountId = 0L });
|
||||
}
|
||||
private static readonly HashSet<string> KnownDeepLinks = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"recroom://testlink",
|
||||
"recroom://solus",
|
||||
"recroom://winterwonderland",
|
||||
"recroom://reccenter",
|
||||
"recroom://testforcat"
|
||||
};
|
||||
|
||||
[HttpPost("actionlink")]
|
||||
public async Task<ActionResult<int>> CheckActionLink()
|
||||
{
|
||||
string? linkId = null;
|
||||
|
||||
using (var reader = new StreamReader(Request.Body))
|
||||
{
|
||||
var body = await reader.ReadToEndAsync();
|
||||
if (!string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (prop.Value.ValueKind == JsonValueKind.String &&
|
||||
(prop.Name.Equals("shortLinkId", StringComparison.OrdinalIgnoreCase) ||
|
||||
prop.Name.Equals("code", StringComparison.OrdinalIgnoreCase) ||
|
||||
prop.Name.Equals("link", StringComparison.OrdinalIgnoreCase) ||
|
||||
prop.Name.Equals("deepLink", StringComparison.OrdinalIgnoreCase) ||
|
||||
prop.Name.Equals("fullLink", StringComparison.OrdinalIgnoreCase) ||
|
||||
prop.Name.Equals("roomId", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
linkId = prop.Value.GetString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (linkId == null && Request.HasFormContentType)
|
||||
linkId = Request.Form["shortLinkId"].ToString();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(linkId))
|
||||
{
|
||||
var normalized = linkId.Trim();
|
||||
if (!normalized.StartsWith("recroom://", StringComparison.OrdinalIgnoreCase))
|
||||
normalized = $"recroom://{normalized}";
|
||||
if (KnownDeepLinks.Contains(normalized))
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using RadicadeServer.RecNet.Auth.Cached;
|
||||
using RadicadeServer.RecNet.Moderation;
|
||||
|
||||
namespace RadicadeServer.RecNet.Accounts
|
||||
{
|
||||
public class Player
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Username { get; set; } = null!;
|
||||
public string DisplayName { get; set; } = null!;
|
||||
public string Bio { get; set; } = null!;
|
||||
public int XP { get; set; }
|
||||
public int Level { get; set; }
|
||||
public bool Developer { get; set; }
|
||||
public bool Verified { get; set; }
|
||||
public bool CanReceiveInvites { get; set; }
|
||||
public string ProfileImageName { get; set; } = null!;
|
||||
public bool JuniorProfile { get; set; }
|
||||
public bool ForceJuniorImages { get; set; }
|
||||
public bool PendingJunior { get; set; }
|
||||
public bool HasBirthday { get; set; }
|
||||
|
||||
|
||||
public bool HasAcceptedCodeOfConduct { get; set; }
|
||||
public bool AvoidJuniors { get; set; }
|
||||
public Reputation PlayerReputation { get; set; } = null!;
|
||||
public List<PlatformIds> PlatformIds { get; set; } = null!;
|
||||
public AdministrativeData AdministrativeData { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Avatar;
|
||||
|
||||
namespace RadicadeServer.RecNet.Accounts.Local
|
||||
{
|
||||
public class PlayerAvatar
|
||||
{
|
||||
[Key, DatabaseGenerated(DatabaseGeneratedOption.None)]
|
||||
public long AccountId { get; set; }
|
||||
public string Avatar { get; set; } = null!;
|
||||
public string Outfits { get; set; } = null!;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public string EquipmentSlots { get; set; } = "[]";
|
||||
|
||||
public Avatar.Avatar? DeserializeAvatar()
|
||||
{
|
||||
return Avatar.FromJson<Avatar.Avatar>();
|
||||
}
|
||||
public string SerializeAvatar(Avatar.Avatar avatar)
|
||||
{
|
||||
Avatar = avatar.ToJson();
|
||||
return Avatar;
|
||||
}
|
||||
|
||||
public List<Avatar.Avatar>? DeserializeOutfits()
|
||||
{
|
||||
return Outfits.FromJson<List<Avatar.Avatar>>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using RadicadeServer.RecNet.Enums.Players;
|
||||
using RadicadeServer.RecNet.Rooms;
|
||||
|
||||
namespace RadicadeServer.RecNet.Accounts
|
||||
{
|
||||
public class PlayerHeartbeat
|
||||
{
|
||||
public long playerId { get; set; }
|
||||
public StatusVisibility statusVisibility { get; set; }
|
||||
public DeviceClass deviceClass { get; set; }
|
||||
public RoomInstance? roomInstance { get; set; }
|
||||
public bool isOnline { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using RadicadeServer.Extensions;
|
||||
|
||||
namespace RadicadeServer.RecNet.Accounts.Local
|
||||
{
|
||||
public class PlayerSettings
|
||||
{
|
||||
[Key, DatabaseGenerated(DatabaseGeneratedOption.None)]
|
||||
public long AccountId { get; set; }
|
||||
public string Settings { get; set; } = "[]";
|
||||
|
||||
public List<Setting> DeserializeSettings()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Settings))
|
||||
return new List<Setting>();
|
||||
|
||||
return Settings.FromJson<List<Setting>>() ?? new List<Setting>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Avatar;
|
||||
|
||||
namespace RadicadeServer.RecNet.Accounts
|
||||
{
|
||||
public class PlayerUnlocks
|
||||
{
|
||||
[Key, DatabaseGenerated(DatabaseGeneratedOption.None)]
|
||||
public long AccountId { get; set; }
|
||||
|
||||
public string AvatarItems { get; set; } = DBConfig.DefaultAvatarUnlocks;
|
||||
public string Consumables { get; set; } = DBConfig.DefaultConsumableUnlocks;
|
||||
public string Equipment { get; set; } = DBConfig.DefaultEquipmentUnlocks;
|
||||
|
||||
public string Inventions { get; set; } = "[]";
|
||||
public string RoomKeys { get; set; } = "[]";
|
||||
|
||||
public List<AvatarItem> GetAvatarItems()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(this.AvatarItems) || this.AvatarItems == "null")
|
||||
return new List<AvatarItem>();
|
||||
return JsonSerializer.Deserialize<List<AvatarItem>>(this.AvatarItems) ?? new List<AvatarItem>();
|
||||
}
|
||||
public void AddAvatarItem(AvatarItem avatarItem)
|
||||
{
|
||||
var items = GetAvatarItems();
|
||||
items.Add(avatarItem);
|
||||
this.AvatarItems = items.ToJson();
|
||||
}
|
||||
|
||||
public List<Equipment> GetEquipment()
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<Equipment>>(this.Equipment) ?? new List<Equipment>();
|
||||
}
|
||||
public void AddEquipment(Equipment equipment)
|
||||
{
|
||||
var items = GetEquipment();
|
||||
items.Add(equipment);
|
||||
this.Equipment = items.ToJson();
|
||||
}
|
||||
|
||||
public List<Consumable> GetConsumables()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(this.Consumables) || this.Consumables == "null")
|
||||
return new List<Consumable>();
|
||||
return JsonSerializer.Deserialize<List<Consumable>>(this.Consumables) ?? new List<Consumable>();
|
||||
}
|
||||
public void AddConsumable(Consumable consumable)
|
||||
{
|
||||
var consumables = GetConsumables();
|
||||
consumables.Add(consumable);
|
||||
this.Consumables = JsonSerializer.Serialize(consumables);
|
||||
}
|
||||
public void SetConsumables(List<Consumable> consumables)
|
||||
{
|
||||
this.Consumables = JsonSerializer.Serialize(consumables ?? new List<Consumable>());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,1541 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Accounts.Local;
|
||||
using RadicadeServer.RecNet.Progression;
|
||||
using RadicadeServer.RecNet.Responses;
|
||||
|
||||
namespace RadicadeServer.API.Players.V1
|
||||
{
|
||||
[Route("api/players/v1")]
|
||||
public class PlayersV1(RecDBContext db) : Controller
|
||||
{
|
||||
[HttpGet("progression/{id}")]
|
||||
public ProgressionResponse? GetProgression(long id)
|
||||
{
|
||||
var data = db.Progression.FirstOrDefault((x) => x.PlayerId == id);
|
||||
data ??= new ProgressionData { PlayerId = id };
|
||||
return new ProgressionResponse(data);
|
||||
}
|
||||
|
||||
[HttpPost("progression/bulk")]
|
||||
public async Task<List<ProgressionResponse>?> GetBulkProgression([FromForm] int[] Ids)
|
||||
{
|
||||
var res = new List<ProgressionResponse>();
|
||||
foreach (int id in Ids)
|
||||
{
|
||||
var data = await db.Progression.FirstOrDefaultAsync((x) => x.PlayerId == id);
|
||||
data ??= new ProgressionData { PlayerId = id };
|
||||
res.Add(new ProgressionResponse(data));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
private const string PhotoTaggingKey = "playerPhotoTaggingSetting";
|
||||
|
||||
[HttpGet("playerPhotoTaggingSetting"), Authorize]
|
||||
public async Task<IActionResult> GetPhotoTaggingSetting()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null)
|
||||
return Unauthorized();
|
||||
|
||||
var settings = await db.PlayerSettings.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.AccountId == userId);
|
||||
var value = settings?.DeserializeSettings()
|
||||
?.FirstOrDefault(x => string.Equals(x.Key, PhotoTaggingKey, StringComparison.OrdinalIgnoreCase))
|
||||
?.Value;
|
||||
|
||||
return Ok(int.TryParse(value, out var parsed) ? parsed : 0);
|
||||
}
|
||||
|
||||
[HttpPut("playerPhotoTaggingSetting"), Authorize]
|
||||
[HttpPost("playerPhotoTaggingSetting"), Authorize]
|
||||
public async Task<IActionResult> SetPhotoTaggingSetting()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null)
|
||||
return Unauthorized();
|
||||
|
||||
int? setting = null;
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in new[] { "Setting", "setting" })
|
||||
{
|
||||
if (int.TryParse(form[key], out var parsed))
|
||||
{
|
||||
setting = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await System.Text.Json.JsonDocument.ParseAsync(Request.Body);
|
||||
var root = doc.RootElement;
|
||||
foreach (var key in new[] { "Setting", "setting" })
|
||||
{
|
||||
if (root.ValueKind == System.Text.Json.JsonValueKind.Object &&
|
||||
root.TryGetProperty(key, out var prop) &&
|
||||
prop.ValueKind == System.Text.Json.JsonValueKind.Number &&
|
||||
prop.TryGetInt32(out var parsed))
|
||||
{
|
||||
setting = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.Text.Json.JsonException) { }
|
||||
}
|
||||
|
||||
var settings = await db.PlayerSettings.FirstOrDefaultAsync(x => x.AccountId == userId);
|
||||
if (settings == null)
|
||||
{
|
||||
settings = new PlayerSettings { AccountId = userId.Value, Settings = "[]" };
|
||||
await db.PlayerSettings.AddAsync(settings);
|
||||
}
|
||||
|
||||
var actualSettings = settings.DeserializeSettings();
|
||||
var found = actualSettings.FirstOrDefault(x =>
|
||||
string.Equals(x.Key, PhotoTaggingKey, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (setting.HasValue)
|
||||
{
|
||||
if (found != null)
|
||||
found.Value = setting.Value.ToString();
|
||||
else
|
||||
actualSettings.Add(new Setting { Key = PhotoTaggingKey, Value = setting.Value.ToString() });
|
||||
|
||||
settings.Settings = actualSettings.ToJson();
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var storedValue = actualSettings
|
||||
.FirstOrDefault(x => string.Equals(x.Key, PhotoTaggingKey, StringComparison.OrdinalIgnoreCase))
|
||||
?.Value;
|
||||
return Ok(int.TryParse(storedValue, out var stored) ? stored : 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.API.Royale;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Progression;
|
||||
using RadicadeServer.RecNet.Responses;
|
||||
|
||||
namespace RadicadeServer.API.Players.V2
|
||||
{
|
||||
[Route("api/players/v2")]
|
||||
public class PlayersV2(RecDBContext db) : Controller
|
||||
{
|
||||
[HttpGet("progression/bulk")]
|
||||
public async Task<List<ProgressionResponse>?> GetBulkProgressionV2([FromQuery] int[] id)
|
||||
{
|
||||
var res = new List<ProgressionResponse>();
|
||||
foreach (int i in id)
|
||||
{
|
||||
var data = await db.Progression.FirstOrDefaultAsync((x) => x.PlayerId == i);
|
||||
data ??= new ProgressionData { PlayerId = i };
|
||||
res.Add(new ProgressionResponse(data));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
[HttpPost("objectives"), Authorize]
|
||||
public async Task<ActionResult<List<ProgressionResponse>>> CompleteObjectives([FromBody] List<CompleteObjectivesDTO>? objectives)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (objectives == null) return BadRequest();
|
||||
|
||||
int additionalXp = objectives
|
||||
.Where(o => o.AdditionalXp > 0)
|
||||
.Sum(o => o.AdditionalXp);
|
||||
|
||||
var strings = new List<string>();
|
||||
await RoyaleShared.AwardXpAsync(db, userId.Value, additionalXp, "Objective", strings);
|
||||
|
||||
var stored = await db.Progression.AsNoTracking().FirstOrDefaultAsync(x => x.PlayerId == userId.Value)
|
||||
?? new ProgressionData { PlayerId = userId.Value };
|
||||
|
||||
return Ok(new List<ProgressionResponse> { new(stored) });
|
||||
}
|
||||
}
|
||||
|
||||
public class CompleteObjectivesDTO
|
||||
{
|
||||
public int ObjectiveType { get; set; }
|
||||
public int AdditionalXp { get; set; }
|
||||
public bool InParty { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using RadicadeServer.RecNet.Enums.Players;
|
||||
using RadicadeServer.RecNet.Enums.Rooms;
|
||||
using RadicadeServer.RecNet.Rooms;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.DB;
|
||||
|
||||
namespace RadicadeServer.RecNet.Accounts
|
||||
{
|
||||
public class Presence
|
||||
{
|
||||
[Key, DatabaseGenerated(DatabaseGeneratedOption.None)]
|
||||
public long playerId { get; set; }
|
||||
public bool isOnline { get; set; }
|
||||
public DeviceClass deviceClass { get; set; }
|
||||
public StatusVisibility statusVisibility { get; set; }
|
||||
public VRMovementModes vRMovementMode { get; set; }
|
||||
public JoinStatus errorCode { get; set; }
|
||||
public RoomInstance? roomInstance { get; set; }
|
||||
public string appVersion { get; set; } = DBConfig.GameVersion;
|
||||
|
||||
public Presence ForViewer(long? viewerId)
|
||||
{
|
||||
if (viewerId != playerId && (!isOnline || statusVisibility == StatusVisibility.Offline))
|
||||
{
|
||||
isOnline = false;
|
||||
roomInstance = null;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public static async Task<Presence> CreateAsync(RecDBContext db, RealPresence presence, RoomInstance? instance)
|
||||
{
|
||||
var version = await db.Players.Where(player => player.AccountId == presence.playerId)
|
||||
.Select(player => player.AppVersion).FirstOrDefaultAsync();
|
||||
return new Presence(presence, instance)
|
||||
{
|
||||
appVersion = string.IsNullOrWhiteSpace(version) ? DBConfig.GameVersion : version
|
||||
};
|
||||
}
|
||||
|
||||
public Presence(RealPresence presence, RoomInstance? instance)
|
||||
{
|
||||
this.playerId = presence.playerId;
|
||||
this.deviceClass = presence.deviceClass;
|
||||
this.isOnline = presence.isOnline;
|
||||
this.statusVisibility = presence.statusVisibility;
|
||||
|
||||
if (instance != null)
|
||||
{
|
||||
instance.eventId ??= 0;
|
||||
instance.clubId ??= 0;
|
||||
instance.roomCode ??= string.Empty;
|
||||
instance.location ??= string.Empty;
|
||||
}
|
||||
|
||||
this.roomInstance = instance;
|
||||
this.vRMovementMode = presence.vRMovementMode;
|
||||
this.errorCode = presence.errorCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.DB;
|
||||
|
||||
namespace RadicadeServer.API.Public
|
||||
{
|
||||
[Route("p/players")]
|
||||
public class PublicPlayerAPI(RecDBContext db) : Controller
|
||||
{
|
||||
[HttpGet("online")]
|
||||
public async Task<ActionResult<List<string>>> GetAllOnlinePlayers()
|
||||
{
|
||||
var list = new List<string>();
|
||||
var presences = await db.Presences.Where(x => x.isOnline).ToListAsync();
|
||||
foreach (var p in presences)
|
||||
{
|
||||
var player = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == p.playerId);
|
||||
if (player != null)
|
||||
list.Add($"{player.displayName} (@{player.username})");
|
||||
}
|
||||
return Json(list);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using RadicadeServer.RecNet.Enums.Players;
|
||||
using RadicadeServer.RecNet.Enums.Rooms;
|
||||
|
||||
namespace RadicadeServer.RecNet.Accounts
|
||||
{
|
||||
public class RealPresence
|
||||
{
|
||||
[Key, DatabaseGenerated(DatabaseGeneratedOption.None)]
|
||||
public long playerId { get; set; }
|
||||
public bool isOnline { get; set; }
|
||||
public DeviceClass deviceClass { get; set; }
|
||||
public StatusVisibility statusVisibility { get; set; }
|
||||
public VRMovementModes vRMovementMode { get; set; }
|
||||
public string? LoginLock { get; set; }
|
||||
public long? roomInstance { get; set; }
|
||||
public JoinStatus errorCode { get; set; }
|
||||
public string appVersion = DBConfig.GameVersion;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using RadicadeServer.RecNet.Auth;
|
||||
using RadicadeServer.RecNet.Enums.Players;
|
||||
|
||||
namespace RadicadeServer.RecNet.Accounts
|
||||
{
|
||||
|
||||
public class SelfAccount : Profile
|
||||
{
|
||||
public string email { get; set; } = null!;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public string pendingEmail { get; set; } = null!;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public bool emailVerified { get; set; }
|
||||
public string phone { get; set; } = null!;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public JuniorState juniorState { get; set; }
|
||||
public long? parentAccountId { get; set; }
|
||||
public int availableUsernameChanges { get; set; }
|
||||
|
||||
|
||||
public bool hasBirthday { get; set; }
|
||||
|
||||
public DateTime? birthdayUTC { get; set; }
|
||||
|
||||
|
||||
public bool hasAcceptedCodeOfConduct { get; set; }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public bool newPlayer { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace RadicadeServer.RecNet.Accounts.Local
|
||||
{
|
||||
public class Setting
|
||||
{
|
||||
public long Id { get; set; }
|
||||
[JsonPropertyName("accountId")]
|
||||
public long AccountId { get; set; }
|
||||
public string Key { get; set; } = null!;
|
||||
public string Value { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace RadicadeServer.Utils
|
||||
{
|
||||
public static class UsernameRules
|
||||
{
|
||||
public const int MinLength = 3;
|
||||
public const int MaxLength = 24;
|
||||
|
||||
private static readonly string[] ReservedNames =
|
||||
[
|
||||
"me", "you", "recroom", "recnet", "radicade", "againstgravity",
|
||||
"system", "admin", "administrator", "moderator", "mod", "support",
|
||||
"staff", "official", "developer", "owner", "root"
|
||||
];
|
||||
|
||||
public static string? Normalize(string? posted)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(posted)) return null;
|
||||
|
||||
var name = posted.Trim().TrimStart('@').Trim();
|
||||
return name.Length == 0 ? null : name;
|
||||
}
|
||||
|
||||
public static string? Parse(string? posted, out string? rejection)
|
||||
{
|
||||
rejection = null;
|
||||
|
||||
var name = Normalize(posted);
|
||||
if (name == null) return null;
|
||||
|
||||
rejection = Rejection(name);
|
||||
return rejection == null ? name : null;
|
||||
}
|
||||
|
||||
public static string? Rejection(string name)
|
||||
{
|
||||
if (name.Length < MinLength)
|
||||
return $"Names must be at least {MinLength} characters.";
|
||||
if (name.Length > MaxLength)
|
||||
return $"Names must be {MaxLength} characters or fewer.";
|
||||
|
||||
bool hasLetter = false;
|
||||
foreach (var character in name)
|
||||
{
|
||||
if (char.IsAsciiLetter(character)) { hasLetter = true; continue; }
|
||||
if (char.IsAsciiDigit(character)) continue;
|
||||
return "Names can only use letters and numbers.";
|
||||
}
|
||||
|
||||
if (!hasLetter)
|
||||
return "Names must contain at least one letter.";
|
||||
|
||||
if (ReservedNames.Any(reserved => string.Equals(reserved, name, StringComparison.OrdinalIgnoreCase)))
|
||||
return "That name is reserved. Please pick another one.";
|
||||
|
||||
if (!name.IsPure())
|
||||
return "That name is not allowed. Please pick another one.";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string ComparisonKey(string name) => name.ToUpperInvariant();
|
||||
|
||||
public static string Generate() => RecNet.Generators.GenerateUsername();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user