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 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 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 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 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 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 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 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 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 { ["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 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 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 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 IsChoosingTheirAccountNameAsync(long accountId) => PlayerUtils.IsChoosingTheirAccountNameAsync(db, accountId); [HttpPut("account/me/username"), Authorize] public async Task 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 SetPersonalPronouns() => SetProfileIdentityValue(true, "pronounFlags", "PronounFlags", "pronouns", "Pronouns", "personalPronouns", "PersonalPronouns", "value", "Value"); [HttpPut("account/me/identityflags"), Authorize] public Task SetIdentityFlags() => SetProfileIdentityValue(false, "identityFlags", "IdentityFlags", "value", "Value"); private async Task 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 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 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 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 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(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 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 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 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 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 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 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 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 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 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 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 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(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(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(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> 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 ""; 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 ? "" : string.Join(", ", names); } catch { return "unparseable JSON"; } } [HttpPost("account/me/acceptcoc"), Authorize] public async Task 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 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 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(); 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().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) ? " @" + System.Net.WebUtility.HtmlEncode(profile.username) + "" : ""; var link = $"https://radicade.app/verify?code={plrData.EmailVerifyToken}"; var html = "
" + "\"Radicade\"" + "

Confirm your Radicade email

" + "

Hi there,

" + "

We received a request to set " + System.Net.WebUtility.HtmlEncode(address) + " as the email on your Radicade account" + atName + ". Confirm it's you by clicking the button below:

" + "

Verify my email

" + "

If you didn't request this, you can safely ignore this email.

"; _ = RadicadeMail.SendAsync(address, "Confirm your email - Radicade", html); } private static string VerificationPage(bool success, string message) { var color = success ? "#16a34a" : "#dc2626"; return "Radicade Email Verification" + "" + "
" + "
" + (success ? "✅" : "⚠️") + "
" + "

" + (success ? "Email verified" : "Verification failed") + "

" + "

" + message + "

" + "
"; } [HttpGet("account/bulk")] public async Task> GetBulk([FromQuery] int[] id) { List result = new List(); 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 GetAccount(long id) { Profile? profile = await db.Profiles.FirstOrDefaultAsync((prf) => prf.accountId == id); return profile ?? BuildMissingProfile(id); } [HttpGet("account/{id}/clubs")] public async Task 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) }); } } }