Files
Radicade/Server/Players/AccountsAPI.cs
T
2026-10-16 23:33:13 -07:00

1442 lines
46 KiB
C#

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)
});
}
}
}