mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 07:29:00 -07:00
1442 lines
46 KiB
C#
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)
|
|
});
|
|
}
|
|
}
|
|
}
|