using Mocha2023.Auth; using Mocha2023.Classes; using Mocha2023.Classes.DBs; using Mocha2023.Classes.DBs.DBClasses; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using SixLabors.ImageSharp; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using static Mocha2023.Classes.DBs.DBClasses.PlayerDBClasses; using JsonSerializer = System.Text.Json.JsonSerializer; namespace Mocha2023.Controllers { [ApiController] [Mocha2023.Classes.ApiProtection] public class APIController : ControllerBase { private const int StoreItemPrice = 100; private const int FriendotronGiftContext = (int)PlayerDBClasses.GiftContext.Consumable; private const string WeeklyChallengeRewardEquipmentPrefab = "[Basketball]"; private const string WeeklyChallengeRewardModificationGuid = "WOWwmg7jg0mH2g4LJUp6fA"; private static readonly object CatalogLock = new(); private static readonly object EquipmentInventoryLock = new(); private static DateTime CatalogCacheTimestampUtc; private static DateTime EquipmentCatalogCacheTimestampUtc; private static DateTime ConsumableCatalogCacheTimestampUtc; private static List CatalogCache = new(); private static readonly object StorefrontAdminLock = new(); private static StorefrontAdminState? StorefrontAdminCache; private static readonly FriendotronConsumableReward[] FriendotronConsumableRewards = { new("Film (Black & White)", "frOMH6WxDEG1fBqC4_83vg", 0), new("Film (Dawn)", "m0bVLwWGj0GuIBSb6wCk6Q", 0), new("Film (Sepia)", "A5M-yf9tgUihq1uab3v58g", 0), new("High Five Potion (Golden)", "-hy0qD-iUk-v4NHxNzanmg", 0), new("High Five Potion (Magic)", "VQSgL2pTLkWx4B3kwYG7UA", 0), new("Assorted Donuts", "ZuvkidodzkuOfGLDnTOFyg", 20), new("Candy Apples", "EmPvh3I6L0uK_1i8Wy_ylQ", 20), new("Cheese Pizza", "5hIAZ9wg5EyG1cILf4FS2A", 0), new("Chocolate Donuts", "mMCGPgK3tki5S_15q2Z81A", 10), new("Glazed Donuts", "7OZ5AE3uuUyqa0P-2W1ptg", 0), new("Hawaiian Pizza", "_jnjYGBcyEWY5Ub4OezXcA", 10), new("Tray of Lattes", "P15H1ONBhk-5DYYjid1ttg", 10), new("Pepperoni Pizza", "mq23W-RSP0G8iGNLdrcpUw", 10), new("Root Beer", "JfnVXFmilU6ysv-VbTAe3A", 0), new("Salted Pretzels", "InQ25wQMGkG_bvuD5rf2Ag", 0), new("Supreme Pizza", "wUCIKdJSvEmiQHYMyx4X4w", 20), new("KO Icon - Grenade", "EAhk3ZZdXEmH5wRAXXT24Q", 20), new("KO Icon - Sword & Shield", "5AJin8T2iEG7BzOPOgx2HA", 20), new("KO Icon - Winged Skull", "U38Qe6rhEk6mFvArHfYjng", 20), new("KO Icon - Tire Track", "J1WqFNUWo0OBi4LGKPDHWw", 20) }; [HttpGet("/")] public IActionResult GetNS() { if (Request.Headers.Accept.Any(value => value?.Contains("text/html", StringComparison.OrdinalIgnoreCase) == true)) { return Redirect("/recnet/"); } return BuildNameServerResponse(); } [HttpGet("/nameserver")] public IActionResult GetNameServer([FromQuery] int? v = null) { return BuildNameServerResponse(); } private IActionResult BuildNameServerResponse() { string url = ServerConfig.BaseURL.TrimEnd('/'); return Ok(new { Accounts = url + "/acc", API = url, Auth = url + "/auth", BugReporting = url, Cards = url, CDN = url + "/cdn", Chat = url, Clubs = url, CMS = url, Commerce = url, Data = url, DataCollection = url, Discovery = url, Econ = url, GameLogs = url, Geo = url, Images = url + "/imageserver-v2", Leaderboard = url, Link = url, Lists = url, Matchmaking = url + "/match", Moderation = url, Notifications = url + "/noti", PlatformNotifications = url, PlayerSettings = url, RoomComments = url, Rooms = url + "/roomserver", Storage = url, Strings = url, StringsCDN = url, Studio = url, Thorn = url, Videos = url, WWW = url }); } [HttpPost("/api/gamesight/event")] public IActionResult StubGameSightEvent() { return Ok(new { success = true }); } [HttpGet("/api/storefronts/v3/giftdropstore/{storeId:int}")] public IActionResult StubGetGiftDropStore([FromRoute] int storeId) { List storeItems = storeId == 3 ? GetWatchStoreSkusWithHairDyes() : GetDailyStoreSkus(storeId); if (storeId == 3) { Console.WriteLine( $"[HAIR DYE STOREFRONT] total={storeItems.Count} " + $"dyes={storeItems.Count(IsHairDyeCatalogItem)}"); } return Ok(new CatalogStorefront { StorefrontType = storeId, NextUpdate = DateTime.Today.AddDays(1).ToUniversalTime(), StoreItems = storeItems }); } [HttpGet("/api/quickPlay/v1/getandclear")] public IActionResult StubGetAndClearQuickPlay() { return Ok(System.Array.Empty()); } [HttpGet("/api/challenge/v2/getCurrent")] public IActionResult GetCurrentChallenges() { var (challengeMapId, startsAt, endsAt) = RecNetDB.GetCurrentChallengeWindow(); long accountId = AuthStuff.GetPlayerId(Request) ?? 0; object[] challenges = { CurrentChallenge( accountId: accountId, challengeMapId: challengeMapId, id: 1, title: "Complete Crimson Cauldron", description: "Complete Curse of the Crimson Cauldron.", tooltip: "Complete ^CrimsonCauldron.", config: "{\"ct\":6,\"vs\":[2],\"ex\":true,\"in\":true}", goal: 1), CurrentChallenge( accountId: accountId, challengeMapId: challengeMapId, id: 2, title: "Complete Quest for the Golden Trophy", description: "Complete Quest for the Golden Trophy.", tooltip: "Complete ^GoldenTrophy.", config: "{\"ct\":6,\"vs\":[2],\"ex\":true,\"in\":true}", goal: 1), CurrentChallenge( accountId: accountId, challengeMapId: challengeMapId, id: 3, title: "Beat the Rise of the Jumbotron", description: "Complete The Rise of Jumbotron.", tooltip: "Complete ^TheRiseOfJumbotron.", config: "{\"ct\":6,\"vs\":[2],\"ex\":true,\"in\":true}", goal: 1), CurrentChallenge( accountId: accountId, challengeMapId: challengeMapId, id: 4, title: "Complete 5 games of Paintball", description: "Complete 5 games of Paintball.", tooltip: "Finish 5 full games in ^Paintball.", config: "{\"ct\":1,\"ctc\":[{\"ct\":6,\"vs\":[2],\"ex\":true,\"in\":true}],\"cc\":5}", goal: 5), CurrentChallenge( accountId: accountId, challengeMapId: challengeMapId, id: 5, title: "Complete 5 games of Dodgeball", description: "Complete 5 games of Dodgeball.", tooltip: "Finish 5 full games in ^Dodgeball.", config: "{\"ct\":1,\"ctc\":[{\"ct\":6,\"vs\":[2],\"ex\":true,\"in\":true}],\"cc\":5}", goal: 5) }; int completedChallengeCount = Enumerable.Range(1, 5) .Count(id => RecNetDB.GetChallengeProgress( accountId, challengeMapId, id, ChallengeGoal(id)).Complete); if (accountId > 0 && completedChallengeCount >= 3) { GrantPlayerEquipment( accountId, WeeklyChallengeRewardEquipmentPrefab, WeeklyChallengeRewardModificationGuid); } Response.Headers.CacheControl = "private, max-age=30"; return Ok(new { ChallengeMapId = challengeMapId, StartAt = startsAt, EndAt = endsAt, ServerTime = DateTime.UtcNow, Challenges = challenges, Gift = new { GiftDropId = 52, AvatarItemDesc = string.Empty, Xp = 750, Level = 0, EquipmentPrefabName = WeeklyChallengeRewardEquipmentPrefab, EquipmentModificationGuid = WeeklyChallengeRewardModificationGuid }, ChallengeThemeString = "Mocha Weekly Challenges" }); } private static object CurrentChallenge( long accountId, int challengeMapId, int id, string title, string description, string tooltip, string config, int goal) { var progress = RecNetDB.GetChallengeProgress(accountId, challengeMapId, id, goal); return new { ChallengeId = id, Name = title, Config = config, Description = description, Tooltip = tooltip, Complete = progress.Complete }; } private static int ChallengeGoal(int challengeId) => challengeId is 4 or 5 ? 5 : 1; [HttpPost("/api/challenge/v2/updateProgress")] [HttpPut("/api/challenge/v2/updateProgress")] public IActionResult UpdateChallengeProgress([FromBody] JsonElement body) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var (currentMapId, _, _) = RecNetDB.GetCurrentChallengeWindow(); int challengeMapId = FindJsonInt(body, "ChallengeMapId") ?? currentMapId; int challengeId = FindJsonInt(body, "ChallengeId") ?? 0; if (challengeId is < 1 or > 5) return BadRequest(new { success = false, error = "invalid_challenge" }); int goal = ChallengeGoal(challengeId); int? suppliedProgress = FindJsonInt(body, "Progress", "CurrentProgress", "Count"); bool? suppliedComplete = FindJsonBool(body, "Complete", "Completed", "IsCompleted"); var existing = RecNetDB.GetChallengeProgress( accountId.Value, challengeMapId, challengeId, goal); int progress = suppliedProgress ?? (suppliedComplete == true ? goal : existing.Progress + 1); bool? completionOverride = suppliedComplete == true ? true : null; var saved = RecNetDB.SetChallengeProgress( accountId.Value, challengeMapId, challengeId, progress, goal, completionOverride); return Ok(new { success = true, ChallengeMapId = saved.ChallengeMapId, ChallengeId = saved.ChallengeId, Progress = saved.Progress, Goal = saved.Goal, Complete = saved.Complete }); } private static int? FindJsonInt(JsonElement element, params string[] names) { if (element.ValueKind == JsonValueKind.Object) { foreach (var property in element.EnumerateObject()) { if (names.Any(name => string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) && ((property.Value.ValueKind == JsonValueKind.Number && property.Value.TryGetInt32(out int value)) || (property.Value.ValueKind == JsonValueKind.String && int.TryParse(property.Value.GetString(), out value)))) { return value; } int? nested = FindJsonInt(property.Value, names); if (nested.HasValue) return nested; } } else if (element.ValueKind == JsonValueKind.Array) { foreach (var item in element.EnumerateArray()) { int? nested = FindJsonInt(item, names); if (nested.HasValue) return nested; } } return null; } private static bool? FindJsonBool(JsonElement element, params string[] names) { if (element.ValueKind == JsonValueKind.Object) { foreach (var property in element.EnumerateObject()) { if (names.Any(name => string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase))) { if (property.Value.ValueKind == JsonValueKind.True) return true; if (property.Value.ValueKind == JsonValueKind.False) return false; } bool? nested = FindJsonBool(property.Value, names); if (nested.HasValue) return nested; } } else if (element.ValueKind == JsonValueKind.Array) { foreach (var item in element.EnumerateArray()) { bool? nested = FindJsonBool(item, names); if (nested.HasValue) return nested; } } return null; } private static long? FindJsonLong( JsonElement element, params string[] names) { if (element.ValueKind == JsonValueKind.Object) { foreach (JsonProperty property in element.EnumerateObject()) { bool nameMatches = names.Any(name => string.Equals( property.Name, name, StringComparison.OrdinalIgnoreCase)); if (nameMatches) { if (property.Value.ValueKind == JsonValueKind.Number && property.Value.TryGetInt64(out long numberValue)) { return numberValue; } if (property.Value.ValueKind == JsonValueKind.String && long.TryParse( property.Value.GetString(), out long stringValue)) { return stringValue; } } long? nestedValue = FindJsonLong(property.Value, names); if (nestedValue.HasValue) return nestedValue; } } else if (element.ValueKind == JsonValueKind.Array) { foreach (JsonElement item in element.EnumerateArray()) { long? nestedValue = FindJsonLong(item, names); if (nestedValue.HasValue) return nestedValue; } } return null; } private static string? FindJsonString( JsonElement element, params string[] names) { if (element.ValueKind == JsonValueKind.Object) { foreach (JsonProperty property in element.EnumerateObject()) { bool nameMatches = names.Any(name => string.Equals( property.Name, name, StringComparison.OrdinalIgnoreCase)); if (nameMatches) { string? result = property.Value.ValueKind switch { JsonValueKind.String => property.Value.GetString(), JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => property.Value.GetRawText(), _ => null }; if (!string.IsNullOrWhiteSpace(result)) return result; } string? nestedValue = FindJsonString(property.Value, names); if (!string.IsNullOrWhiteSpace(nestedValue)) return nestedValue; } } else if (element.ValueKind == JsonValueKind.Array) { foreach (JsonElement item in element.EnumerateArray()) { string? nestedValue = FindJsonString(item, names); if (!string.IsNullOrWhiteSpace(nestedValue)) return nestedValue; } } return null; } [HttpGet("/api/roomcurrencies/v1/getAllBalances")] public IActionResult StubGetAllRoomCurrencyBalances( [FromQuery] long roomId) { return Ok(System.Array.Empty()); } [HttpGet("/acc/parentalcontrol/me")] public IActionResult StubGetMyParentalControls() { return Ok(new { IsEnabled = false, IsJunior = false }); } [HttpGet("/subscription/mine/member")] public IActionResult GetMySubscriptionMembership() { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); bool hasRRPlus = PlayerDB.HasRRPlus(accountId.Value); return Ok(hasRRPlus ? new[] { new { IsActive = true, IsSubscribed = true, Status = "Active", ExpiresAt = "2099-12-31T23:59:59Z" } } : Array.Empty()); } [HttpPost("/api/CampusCard/v1/UpdateAndGetSubscription")] public IActionResult UpdateAndGetSubscription() { var rawAccountId = AuthStuff.GetPlayerId(Request); if (rawAccountId == null) return Unauthorized(); long playerId = (long)rawAccountId; int recNetPlayerId = checked((int)playerId); bool hasRRPlus = PlayerDB.HasRRPlus(playerId); if (!hasRRPlus) return Ok(new { subscription = (object?)null, platformAccountSubscribedPlayerId = (int?)null }); DateTime now = DateTime.UtcNow; var response = new { subscription = new { subscriptionId = playerId, recNetPlayerId = recNetPlayerId, platformType = 0, platformId = $"local-{playerId}", platformPurchaseId = $"local-rrplus-{playerId}", level = 1, period = 0, expirationDate = now.AddYears(10), isAutoRenewing = false, createdAt = now.AddDays(-1), modifiedAt = now }, platformAccountSubscribedPlayerId = (int?)null }; return Ok(response); } [HttpGet("/api/roomkeys/v1/room")] public IActionResult StubGetRoomKeys([FromQuery] long roomId) { return Ok(System.Array.Empty()); } [HttpGet("/api/keepsakes/categories")] [HttpGet("/api/keepsakes/v1/categories")] public IActionResult StubGetKeepsakeCategories() { return Ok(System.Array.Empty()); } [HttpGet("/api/influencerpartnerprogram/influencers")] public IActionResult GetInfluencers( [FromQuery] int take = 1000, [FromQuery] string? continuationToken = null) { int safeTake = Math.Clamp(take, 1, 1000); var influencerIds = PlayerDB.Players .FindAll() .Where(player => player.PlayerId > 0 && player.PlayerId <= int.MaxValue && player.PlayerRoles != null && player.PlayerRoles.Contains( PlayerDBClasses.PlayerRoles.Influencer)) .OrderBy(player => player.PlayerId) .Take(safeTake) .Select(player => (int)player.PlayerId) .ToList(); Console.WriteLine( $"[INFLUENCER] Returning {influencerIds.Count}: " + string.Join(", ", influencerIds) ); return Ok(new { influencerIds, continuationToken = (string?)null }); } [HttpGet("/api/incentivizedreferrals/progress")] public IActionResult StubGetIncentivizedReferralProgress() { return Ok(new { Progress = 0, Goal = 0, IsComplete = false, Rewards = System.Array.Empty() }); } [HttpGet("/api/progressionEvents/active")] [HttpGet("/api/progressionEvents/v1/active")] public IActionResult GetActiveProgressionEventId() { return Content("null", "application/json"); } [HttpGet("api/versioncheck/v4")] public IActionResult VersionCheck() { return Ok(new { ValidVersion = 0, VersionStatus = 0, UpdateNotificationStage = 0, IsVersionIslanded = false, IsCrossPlayDisabled = false }); } [HttpGet("api/gameconfigs/v1/all")] public IActionResult GetGameConfigs() { string path = Path.Combine(Program.dataDir, "APIS", "GameConfigs.json"); if (!System.IO.File.Exists(path)) return NotFound(); string json = System.IO.File.ReadAllText(path); try { if (JsonNode.Parse(json) is not JsonArray configs) return Content(json, "application/json"); SetGameConfigValue( configs, "Friendotron_AllowSelfGifting", "True"); SetGameConfigValue( configs, "Friendotron_MaxUsersPerSession", "1"); return Content(configs.ToJsonString(), "application/json"); } catch (JsonException) { return Content(json, "application/json"); } } private static void SetGameConfigValue( JsonArray configs, string key, string value) { bool found = false; foreach (JsonNode? node in configs) { if (node is not JsonObject config || !string.Equals( config["Key"]?.ToString(), key, StringComparison.Ordinal)) { continue; } config["Value"] = value; found = true; } if (!found) { configs.Add(new JsonObject { ["Key"] = key, ["Value"] = value }); } } [HttpGet("api/config/v1/amplitude")] public IActionResult GetAmplitude() { return Ok(new { AmplitudeKey = "cb2fb2ecb9953512c29af5bca58f2b4a", UseRudderStack = true, RudderStackKey = "23NiJHIgu3koaGNCZIiuYvIQNCu", UseStatSig = true, StatSigKey = "client-SBZkOrjD3r1Cat3f3W8K6sBd11WKlXZXIlCWj6l4Aje", StatSigEnvironment = 0 }); } [HttpGet("/api/avatar/")] [HttpGet("/api/avatar")] public IActionResult GetAvatarBase() { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); return Ok(new { AccountId = accountId.Value }); } [HttpGet("/api/config/")] [HttpGet("/api/config")] public IActionResult GetGeneralConfig() { return Ok(new { ServerName = "Mocha", GameVersion = ServerConfig.GameVersion, MaintenanceMinutes = 0 }); } [HttpGet("/api/config/v1/freegiftbutton")] public IActionResult GetFreeGiftButtonConfig() { return Ok(new { Enabled = false, Available = false }); } [HttpGet("/api/players/v4/current/contact")] public IActionResult GetMyContactInfo() { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var account = PlayerDB.Players.FindById(accountId.Value); if (account?.Player == null) return NotFound(); return Ok(new { Email = account.Player.Email ?? string.Empty, EmailVerified = !string.IsNullOrWhiteSpace(account.Player.Email), Phone = (string?)null, PhoneVerified = false }); } [HttpGet("/api/playerwarnings")] public IActionResult GetMyPlayerWarnings() { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var warnings = (PlayerDB.Players.FindById(accountId.Value)?.Player?.PlayerExtra?.Warnings ?? new List()) .Select(warningKey => new { WarningKey = warningKey, Acknowledged = false }) .ToList(); return Ok(warnings); } public class AcknowledgeWarningRequest { public string? WarningKey { get; set; } } [HttpPost("/api/playerwarnings/acknowledge")] public IActionResult AcknowledgePlayerWarning([FromBody] AcknowledgeWarningRequest request) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var account = PlayerDB.Players.FindById(accountId.Value); var warnings = account?.Player?.PlayerExtra?.Warnings; if (account?.Player?.PlayerExtra != null && warnings != null && !string.IsNullOrWhiteSpace(request.WarningKey)) { warnings.RemoveAll(value => value == request.WarningKey); PlayerDB.Players.Update(account); } return Ok(new { success = true }); } public class ConsumableTransferRequest { public string? ConsumableItemDesc { get; set; } public long ConsumableItemId { get; set; } public long ToAccountId { get; set; } public int Amount { get; set; } = 1; } [HttpPost("/api/consumables/v1/transfer")] public IActionResult TransferConsumable([FromBody] ConsumableTransferRequest request) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); if (request.ToAccountId == accountId.Value) return BadRequest(new { error = "Can't transfer to yourself." }); if (request.Amount <= 0) return BadRequest(new { error = "Amount must be positive." }); if (PlayerDB.Players.FindById(request.ToAccountId)?.Player == null) return NotFound(new { error = "Recipient not found." }); string descriptor = request.ConsumableItemDesc ?? string.Empty; int owned = PlayerInventoryStore.GetConsumableQuantity(accountId.Value, descriptor, request.ConsumableItemId); if (owned < request.Amount) return BadRequest(new { error = "You don't have enough of that item." }); PlayerInventoryStore.SetConsumableQuantity(accountId.Value, descriptor, request.ConsumableItemId, null, owned - request.Amount); PlayerInventoryStore.AddConsumable(request.ToAccountId, descriptor, request.ConsumableItemId, null, request.Amount); return Ok(new { success = true }); } [HttpGet("/api/customAvatarItems")] [HttpGet("/api/customAvatarItems/v1/me")] public IActionResult GetMyCustomAvatarItems() { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); return Ok(LoadCustomAvatarItems(accountId.Value)); } public class CustomAvatarItemsBulkRequest { public List? CreatorAccountIds { get; set; } } [HttpPost("/api/customAvatarItems/v1/bulk")] public IActionResult GetCustomAvatarItemsBulk([FromBody] CustomAvatarItemsBulkRequest request) { if (AuthStuff.GetPlayerId(Request) == null) return Unauthorized(); var items = (request.CreatorAccountIds ?? new List()) .Distinct() .SelectMany(LoadCustomAvatarItems) .ToList(); return Ok(items); } public class ItemWishlistRequest { public string? ItemDesc { get; set; } } [HttpGet("/api/itemWishlists")] public IActionResult GetMyItemWishlist() { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var wishlist = PlayerDB.Players.FindById(accountId.Value)?.Player?.PlayerExtra?.ItemWishlist ?? new List(); return Ok(wishlist); } [HttpPost("/api/itemWishlists/v1/wishlist/")] [HttpPost("/api/itemWishlists/v1/wishlist")] public IActionResult AddToItemWishlist([FromBody] ItemWishlistRequest request) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); if (string.IsNullOrWhiteSpace(request.ItemDesc)) return BadRequest(new { error = "Missing item." }); var account = PlayerDB.Players.FindById(accountId.Value); if (account?.Player == null) return Unauthorized(); account.Player.PlayerExtra ??= new PlayerDBClasses.PlayerExtra(); account.Player.PlayerExtra.ItemWishlist ??= new List(); if (!account.Player.PlayerExtra.ItemWishlist.Contains(request.ItemDesc)) { account.Player.PlayerExtra.ItemWishlist.Add(request.ItemDesc); PlayerDB.Players.Update(account); } return Ok(new { success = true, wishlist = account.Player.PlayerExtra.ItemWishlist }); } [HttpDelete("/api/itemWishlists/v1/wishlist/")] [HttpDelete("/api/itemWishlists/v1/wishlist")] public IActionResult RemoveFromItemWishlist([FromBody] ItemWishlistRequest request) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var account = PlayerDB.Players.FindById(accountId.Value); var wishlist = account?.Player?.PlayerExtra?.ItemWishlist; if (wishlist != null && !string.IsNullOrWhiteSpace(request.ItemDesc)) { wishlist.Remove(request.ItemDesc); PlayerDB.Players.Update(account!); } return Ok(new { success = true }); } public class GenericReportRequest { public long TargetId { get; set; } public string? Reason { get; set; } public string? Details { get; set; } } [HttpPost("/api/rooms/v2/report")] public IActionResult ReportRoomV2([FromBody] GenericReportRequest request) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var room = RoomDB.Rooms.FindById(request.TargetId); DiscordLogger.LogReport( $"🚩 **Room reported** - \"{room?.Name ?? $"Room {request.TargetId}"}\" (ID {request.TargetId}) " + $"by account {accountId.Value}: {request.Reason ?? "No reason given."} {request.Details}"); return Ok(new { success = true }); } [HttpPost("/api/clubreporting/v1/report")] public IActionResult ReportClub([FromBody] GenericReportRequest request) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); DiscordLogger.LogReport( $"🚩 **Club reported** - Club {request.TargetId} by account {accountId.Value}: " + $"{request.Reason ?? "No reason given."} {request.Details}"); return Ok(new { success = true }); } [HttpPost("/api/screensharereports/v1/report")] public IActionResult ReportScreenShare([FromBody] GenericReportRequest request) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); DiscordLogger.LogReport( $"🚩 **Screen share reported** - target account {request.TargetId} by account {accountId.Value}: " + $"{request.Reason ?? "No reason given."} {request.Details}"); return Ok(new { success = true }); } [HttpPost("/api/inventions/v1/report")] public IActionResult ReportInvention([FromBody] GenericReportRequest request) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); DiscordLogger.LogReport( $"🚩 **Invention reported** - Invention {request.TargetId} by account {accountId.Value}: " + $"{request.Reason ?? "No reason given."} {request.Details}"); return Ok(new { success = true }); } public class BugReportRequest { public string? Description { get; set; } public string? Category { get; set; } } [HttpPost("/api/bugreports/v1/report")] public IActionResult ReportBug([FromBody] BugReportRequest request) { var reporter = AuthStuff.GetCurrentPlayer(Request); if (reporter?.Player == null) return Unauthorized(); string description = request.Description?.Trim() ?? string.Empty; if (description.Length is < 3 or > 4000) return BadRequest(new { error = "Description must be 3-4000 characters." }); var bugReport = new ReportsDB.BugReport { ReporterId = reporter.PlayerId, ReporterUsername = reporter.Player.Username, Description = description, Category = request.Category?.Trim() }; ReportsDB.BugReports.Insert(bugReport); DiscordLogger.LogReport( $"🐛 **Bug report** from {reporter.Player.Username ?? reporter.PlayerId.ToString()} " + $"({(string.IsNullOrEmpty(bugReport.Category) ? "uncategorized" : bugReport.Category)}): " + $"{description}"); return Ok(new { success = true, id = bugReport.Id }); } [HttpGet("/api/roomconsumables/v1/roomConsumable")] public IActionResult GetRoomConsumableCatalog() { if (AuthStuff.GetPlayerId(Request) == null) return Unauthorized(); return Ok(ServerConfig.Bracket); } [HttpGet("/api/roomEarningsDistributions/v1/earningsDistribution")] public IActionResult GetRoomEarningsDistribution() { if (AuthStuff.GetPlayerId(Request) == null) return Unauthorized(); return Ok(new { TotalEarnings = 0, Distributions = Array.Empty() }); } [HttpPost("/api/roomCurrencies/v2/purchase")] [HttpPost("/api/roomconsumables/v1/roomconsumable/{skuId}/purchase/tokens")] public IActionResult PurchaseRoomCurrencyOrConsumable() { if (AuthStuff.GetPlayerId(Request) == null) return Unauthorized(); return Ok(new { success = true }); } [HttpGet("api/avatar/v1/defaultunlocked")] public IActionResult GetDefaultUnlocked() { return Ok(ServerConfig.Bracket); } [HttpGet("api/avatar/v1/defaultbaseavataritems")] public IActionResult GetDefaultBaseAvatarItems() { return Ok(Array.Empty()); } [HttpPost("/api/ageverification/generatecode")] [HttpGet("/api/ageverification/generatecode")] public IActionResult GenerateAgeVerificationCode() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); string code = AgeVerificationDB.GenerateCode(id.Value); string verificationUrl = $"{ServerConfig.BaseURL.TrimEnd('/')}/recnet/#ageverification?code={code}"; return Ok(new { ActionCode = code, VerificationUrl = verificationUrl }); } [HttpGet("/api/banappeal/generatecode")] public IActionResult GenerateBanAppealCodeDirect() { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(new { error = "You must be logged in." }); var account = PlayerDB.Players.FindById(accountId.Value); PlayerDBClasses.ModerationBlockDetails? details = account?.Player?.PlayerExtra?.ModerationBlockDetails; if (account?.Player == null || details == null || !PlayerDB.IsPlayerBanned(accountId.Value, out _)) { return BadRequest(new { error = "This account isn't currently banned." }); } if (string.IsNullOrWhiteSpace(details.AppealCode)) { const string appealCodeChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; Span buffer = stackalloc char[10]; for (int i = 0; i < buffer.Length; i++) buffer[i] = appealCodeChars[System.Random.Shared.Next(appealCodeChars.Length)]; details.AppealCode = new string(buffer); PlayerDB.Players.Update(account); } return Ok(new { code = details.AppealCode, appealUrl = $"{ServerConfig.BaseURL.TrimEnd('/')}/recnet/banappeal?code={details.AppealCode}", alreadySubmitted = details.AppealSubmitted }); } private static object ToPlayerEventDto(PlayerEventDB.PlayerEvent evt) { var creator = PlayerDB.Players.FindById(evt.CreatorAccountId); return new { Id = evt.EventId, EventId = evt.EventId, CreatorAccountId = evt.CreatorAccountId, CreatorDisplayName = creator?.Player?.DisplayName ?? creator?.Player?.Username ?? $"Player {evt.CreatorAccountId}", Name = evt.Name, Description = evt.Description, Image = string.IsNullOrWhiteSpace(evt.ImageName) ? null : $"{ServerConfig.BaseURL.TrimEnd('/')}/imageserver-v2/{evt.ImageName}", ImageName = evt.ImageName, StartsAt = evt.StartsAt, EndsAt = evt.EndsAt, RoomId = evt.RoomId, ClubId = evt.ClubId, Accessibility = evt.Accessibility, MultiInstance = evt.MultiInstance, Tags = evt.Tags, CreatedAt = evt.CreatedAt, UpdatedAt = evt.UpdatedAt }; } private bool CanManagePlayerEvent(PlayerEventDB.PlayerEvent evt, long accountId) { if (evt.CreatorAccountId == accountId) return true; var account = PlayerDB.Players.FindById(accountId); return account?.PlayerRoles?.Contains(PlayerDBClasses.PlayerRoles.Developer) == true || account?.PlayerRoles?.Contains(PlayerDBClasses.PlayerRoles.Moderator) == true; } public class PlayerEventCreateRequest { public string? Name { get; set; } public string? Description { get; set; } public string? ImageName { get; set; } public DateTime StartsAt { get; set; } public DateTime? EndsAt { get; set; } public long? RoomId { get; set; } public long? ClubId { get; set; } public string? Accessibility { get; set; } public bool MultiInstance { get; set; } public List? Tags { get; set; } } [HttpPost("/api/playerevents/v2")] public IActionResult CreatePlayerEvent([FromBody] PlayerEventCreateRequest request) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); string name = (request.Name ?? string.Empty).Trim(); if (name.Length is < 1 or > 100) return BadRequest(new { error = "Event name must be 1-100 characters." }); var evt = PlayerEventDB.Create(new PlayerEventDB.PlayerEvent { CreatorAccountId = accountId.Value, Name = name, Description = (request.Description ?? string.Empty).Trim(), ImageName = request.ImageName ?? string.Empty, StartsAt = request.StartsAt == default ? DateTime.UtcNow : request.StartsAt.ToUniversalTime(), EndsAt = request.EndsAt?.ToUniversalTime(), RoomId = request.RoomId, ClubId = request.ClubId, Accessibility = request.Accessibility is "Private" or "ClubOnly" ? request.Accessibility : "Public", MultiInstance = request.MultiInstance, Tags = (request.Tags ?? new List()).Take(20).ToList() }); return Ok(ToPlayerEventDto(evt)); } [HttpGet("/api/playerevents/v2/{eventId:long}")] [HttpGet("/api/playerevents/v1/{eventId:long}")] public IActionResult GetPlayerEvent(long eventId) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var evt = PlayerEventDB.Get(eventId); if (evt == null) return NotFound(new { error = "Event not found." }); return Ok(ToPlayerEventDto(evt)); } [HttpDelete("/api/playerevents/v2/delete/{eventId:long}")] public IActionResult DeletePlayerEvent(long eventId) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var evt = PlayerEventDB.Get(eventId); if (evt == null) return NotFound(new { error = "Event not found." }); if (!CanManagePlayerEvent(evt, accountId.Value)) return StatusCode(403); PlayerEventDB.Delete(eventId); return Ok(new { success = true }); } public class PlayerEventFieldUpdate { public string? Value { get; set; } } private IActionResult UpdatePlayerEventField(long eventId, Action apply) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var evt = PlayerEventDB.Get(eventId); if (evt == null) return NotFound(new { error = "Event not found." }); if (!CanManagePlayerEvent(evt, accountId.Value)) return StatusCode(403); apply(evt); PlayerEventDB.Update(evt); return Ok(ToPlayerEventDto(evt)); } [HttpPut("/api/playerevents/v2/{eventId:long}/name")] public IActionResult UpdatePlayerEventName(long eventId, [FromBody] PlayerEventFieldUpdate request) => UpdatePlayerEventField(eventId, evt => { string name = (request.Value ?? string.Empty).Trim(); if (name.Length is >= 1 and <= 100) evt.Name = name; }); [HttpPut("/api/playerevents/v2/{eventId:long}/description")] public IActionResult UpdatePlayerEventDescription(long eventId, [FromBody] PlayerEventFieldUpdate request) => UpdatePlayerEventField(eventId, evt => evt.Description = (request.Value ?? string.Empty).Trim()); [HttpPut("/api/playerevents/v2/{eventId:long}/image")] public IActionResult UpdatePlayerEventImage(long eventId, [FromBody] PlayerEventFieldUpdate request) => UpdatePlayerEventField(eventId, evt => evt.ImageName = request.Value ?? string.Empty); public class PlayerEventTimeUpdate { public DateTime StartsAt { get; set; } public DateTime? EndsAt { get; set; } } [HttpPut("/api/playerevents/v2/{eventId:long}/time")] public IActionResult UpdatePlayerEventTime(long eventId, [FromBody] PlayerEventTimeUpdate request) => UpdatePlayerEventField(eventId, evt => { if (request.StartsAt != default) evt.StartsAt = request.StartsAt.ToUniversalTime(); evt.EndsAt = request.EndsAt?.ToUniversalTime(); }); public class PlayerEventRoomUpdate { public long? RoomId { get; set; } } [HttpPut("/api/playerevents/v2/{eventId:long}/room")] public IActionResult UpdatePlayerEventRoom(long eventId, [FromBody] PlayerEventRoomUpdate request) => UpdatePlayerEventField(eventId, evt => evt.RoomId = request.RoomId); public class PlayerEventClubUpdate { public long? ClubId { get; set; } } [HttpPut("/api/playerevents/v2/{eventId:long}/club")] public IActionResult UpdatePlayerEventClub(long eventId, [FromBody] PlayerEventClubUpdate request) => UpdatePlayerEventField(eventId, evt => evt.ClubId = request.ClubId); [HttpPut("/api/playerevents/v2/{eventId:long}/accessibility")] public IActionResult UpdatePlayerEventAccessibility(long eventId, [FromBody] PlayerEventFieldUpdate request) => UpdatePlayerEventField(eventId, evt => { if (request.Value is "Public" or "Private" or "ClubOnly") evt.Accessibility = request.Value; }); public class PlayerEventMultiInstanceUpdate { public bool MultiInstance { get; set; } } [HttpPut("/api/playerevents/v2/{eventId:long}/multiinstance")] public IActionResult UpdatePlayerEventMultiInstance(long eventId, [FromBody] PlayerEventMultiInstanceUpdate request) => UpdatePlayerEventField(eventId, evt => evt.MultiInstance = request.MultiInstance); public class PlayerEventTagsUpdate { public List? Tags { get; set; } } [HttpPut("/api/playerevents/v2/{eventId:long}/tags")] public IActionResult UpdatePlayerEventTags(long eventId, [FromBody] PlayerEventTagsUpdate request) => UpdatePlayerEventField(eventId, evt => evt.Tags = (request.Tags ?? new List()).Take(20).ToList()); [HttpGet("/api/playerevents/v1/all/{accountId:long}")] public IActionResult GetPlayerEventsForAccount(long accountId) { if (AuthStuff.GetPlayerId(Request) == null) return Unauthorized(); return Ok(PlayerEventDB.GetByCreator(accountId).Select(ToPlayerEventDto).ToList()); } [HttpGet("/api/playerevents/v1/club/{clubId:long}")] public IActionResult GetPlayerEventsForClub(long clubId) { if (AuthStuff.GetPlayerId(Request) == null) return Unauthorized(); return Ok(PlayerEventDB.GetByClub(clubId).Select(ToPlayerEventDto).ToList()); } [HttpGet("/api/playerevents/v1/clubs")] public IActionResult GetPlayerEventsForMyClubs() { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var clubIds = ClubDB.GetMemberClubs(accountId.Value).Select(club => club.ClubId).ToHashSet(); var events = clubIds .SelectMany(PlayerEventDB.GetByClub) .DistinctBy(evt => evt.EventId) .Select(ToPlayerEventDto) .ToList(); return Ok(events); } public class PlayerEventBulkRequest { public List? EventIds { get; set; } } [HttpPost("/api/playerevents/v1/bulk")] public IActionResult GetPlayerEventsBulk([FromBody] PlayerEventBulkRequest request) { if (AuthStuff.GetPlayerId(Request) == null) return Unauthorized(); var ids = request.EventIds ?? new List(); return Ok(PlayerEventDB.GetByIds(ids).Select(ToPlayerEventDto).ToList()); } public class PlayerEventRespondRequest { public long EventId { get; set; } public string? ResponseType { get; set; } } [HttpPost("/api/playerevents/v1/respond")] public IActionResult RespondToPlayerEvent([FromBody] PlayerEventRespondRequest request) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var evt = PlayerEventDB.Get(request.EventId); if (evt == null) return NotFound(new { error = "Event not found." }); string responseType = request.ResponseType is "Going" or "Interested" or "NotGoing" ? request.ResponseType : "Going"; var response = PlayerEventDB.SetResponse(request.EventId, accountId.Value, responseType); return Ok(new { response.EventId, response.AccountId, response.ResponseType, response.CreatedAt }); } [HttpPost("/api/playerevents/v1/deleteResponse")] [HttpDelete("/api/playerevents/v1/deleteResponse")] public IActionResult DeletePlayerEventResponse([FromBody] PlayerEventRespondRequest request) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); PlayerEventDB.DeleteResponse(request.EventId, accountId.Value); return Ok(new { success = true }); } [HttpGet("/api/playerevents/v1/{eventId:long}/responses")] public IActionResult GetPlayerEventResponses(long eventId) { if (AuthStuff.GetPlayerId(Request) == null) return Unauthorized(); var responses = PlayerEventDB.GetResponses(eventId); var accountIds = responses.Select(value => value.AccountId).ToHashSet(); var players = PlayerDB.Players.FindAll() .Where(value => accountIds.Contains(value.PlayerId)) .ToDictionary(value => value.PlayerId); return Ok(responses.Select(response => { players.TryGetValue(response.AccountId, out var player); return new { response.AccountId, DisplayName = player?.Player?.DisplayName ?? player?.Player?.Username ?? $"Player {response.AccountId}", response.ResponseType, response.CreatedAt }; }).ToList()); } public class PlayerEventBulkInviteRequest { public long EventId { get; set; } public List? AccountIds { get; set; } } [HttpPost("/api/playerevents/v1/bulkInvite")] public IActionResult BulkInvitePlayerEvent([FromBody] PlayerEventBulkInviteRequest request) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var evt = PlayerEventDB.Get(request.EventId); if (evt == null) return NotFound(new { error = "Event not found." }); var targets = (request.AccountIds ?? new List()).Take(200).ToList(); int invited = 0; foreach (long targetId in targets) { if (targetId == accountId.Value) continue; if (PlayerEventDB.GetResponse(evt.EventId, targetId) == null) PlayerEventDB.SetResponse(evt.EventId, targetId, "Interested"); invited++; } return Ok(new { success = true, invited }); } [HttpPost("/api/playerevents/v1/broadcast")] public IActionResult BroadcastPlayerEvent([FromBody] PlayerEventBulkInviteRequest request) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var evt = PlayerEventDB.Get(request.EventId); if (evt == null || !CanManagePlayerEvent(evt, accountId.Value)) return NotFound(new { error = "Event not found." }); return Ok(new { success = true }); } public class PlayerEventReportRequest { public long EventId { get; set; } public string? Reason { get; set; } } [HttpPost("/api/playerevents/v1/report")] public IActionResult ReportPlayerEvent([FromBody] PlayerEventReportRequest request) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var evt = PlayerEventDB.Get(request.EventId); if (evt == null) return NotFound(new { error = "Event not found." }); DiscordLogger.LogReport( $"🚩 **Player event reported** - \"{evt.Name}\" (Event {evt.EventId}) " + $"by account {accountId.Value}: {request.Reason ?? "No reason given."}"); return Ok(new { success = true }); } [HttpGet("/api/objectives/v1/myprogress")] public IActionResult GetMyObjectiveProgress() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); return Ok(new { Objectives = new List(), ObjectiveGroups = new List() }); } [HttpGet("/api/avatar/v2")] public IActionResult GetMyAvatar() { var player = AuthStuff.GetCurrentPlayer(Request); if (player == null) return Unauthorized(); return Ok(player.Player.PlayerExtra.Avatar); } [HttpPost("/api/avatar/v2/set")] public IActionResult SetMyAvatar([FromBody] PlayerDBClasses.Avatar request) { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); return Ok(PlayerDB.SetAvatar((long)id, request)); } [HttpGet("/api/avatar/v4/items")] public IActionResult GetMyAvatarItems() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); string path = Path.Combine( Program.dataDir, "APIS", "Items", "AvatarItems.json"); if (!System.IO.File.Exists(path)) return NotFound(); return Ok(GetAvatarItemsVisibleToPlayer((long)id, path)); } [HttpGet("/api/avatar/v1/lockeditems")] public IActionResult GetMyLockedAvatarItems() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); string path = Path.Combine( Program.dataDir, "APIS", "Items", "AvatarItems.json"); if (!System.IO.File.Exists(path)) return Ok(Array.Empty()); return Ok(GetAvatarItemsNotOwnedByPlayer((long)id, path)); } private static List GetAvatarItemsNotOwnedByPlayer( long playerId, string path) { string json = System.IO.File.ReadAllText(path); var items = JsonSerializer.Deserialize>( json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new List(); var player = PlayerDB.Players.FindById(playerId); IEnumerable? legacyItems = player?.Player?.PlayerExtra?.AvatarItems; List ownedItems = PlayerInventoryStore.GetAvatarItems(playerId, legacyItems); HashSet ownedIds = ownedItems .Where(item => item.AvatarItemId > 0) .Select(item => item.AvatarItemId) .ToHashSet(); HashSet ownedDescriptors = ownedItems .Select(item => item.AvatarItemDesc) .Where(value => !string.IsNullOrWhiteSpace(value)) .ToHashSet(StringComparer.OrdinalIgnoreCase); return items .Where(item => { string descriptor = item.TryGetProperty("AvatarItemDesc", out JsonElement descElement) ? descElement.GetString() ?? string.Empty : string.Empty; long itemId = item.TryGetProperty("AvatarItemId", out JsonElement idElement) && idElement.TryGetInt64(out long parsedId) ? parsedId : 0; string friendlyName = item.TryGetProperty("FriendlyName", out JsonElement nameElement) ? nameElement.GetString() ?? string.Empty : string.Empty; int rarity = item.TryGetProperty("Rarity", out JsonElement rarityElement) && rarityElement.TryGetInt32(out int parsedRarity) ? parsedRarity : 0; bool owned = (itemId > 0 && ownedIds.Contains(itemId)) || (!string.IsNullOrWhiteSpace(descriptor) && ownedDescriptors.Contains(descriptor)); bool unfinished = rarity <= 0 && string.IsNullOrWhiteSpace(friendlyName); return !owned && !unfinished && !IsExcludedAvatarItemName(friendlyName); }) .ToList(); } private static List GetAvatarItemsVisibleToPlayer( long playerId, string path) { string json = System.IO.File.ReadAllText(path); var items = JsonSerializer.Deserialize>( json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new List(); var player = PlayerDB.Players.FindById(playerId); IEnumerable? legacyItems = player?.Player?.PlayerExtra?.AvatarItems; List ownedItems = PlayerInventoryStore.GetAvatarItems(playerId, legacyItems); HashSet ownedIds = ownedItems .Where(item => item.AvatarItemId > 0) .Select(item => item.AvatarItemId) .ToHashSet(); HashSet ownedDescriptors = ownedItems .Select(item => item.AvatarItemDesc) .Where(value => !string.IsNullOrWhiteSpace(value)) .ToHashSet(StringComparer.OrdinalIgnoreCase); return items .Where(item => { string descriptor = item.TryGetProperty("AvatarItemDesc", out JsonElement descElement) ? descElement.GetString() ?? string.Empty : string.Empty; long itemId = item.TryGetProperty("AvatarItemId", out JsonElement idElement) && idElement.TryGetInt64(out long parsedId) ? parsedId : 0; return (itemId > 0 && ownedIds.Contains(itemId)) || (!string.IsNullOrWhiteSpace(descriptor) && ownedDescriptors.Contains(descriptor)); }) .ToList(); } private static bool IsAlpacaShirt(JsonElement item) { if (item.TryGetProperty("AvatarItemId", out JsonElement itemId) && itemId.TryGetInt64(out long parsedItemId) && parsedItemId == PlayerDB.AlpacaShirtAvatarItemId) { return true; } return item.TryGetProperty("FriendlyName", out JsonElement name) && string.Equals( name.GetString(), "Alpaca Shirt", StringComparison.OrdinalIgnoreCase); } private IActionResult GetJsonItems(string fileName) { string path = Path.Combine(Program.dataDir, "APIS", "Items", fileName); return System.IO.File.Exists(path) ? Content(System.IO.File.ReadAllText(path), "application/json") : Ok(ServerConfig.Bracket); } private IActionResult GetNormalizedConsumables(long? accountId = null) { string path = Path.Combine( Program.dataDir, "APIS", "Items", "Consumables.json"); if (!System.IO.File.Exists(path)) return Ok(Array.Empty()); try { JsonNode? root = JsonNode.Parse(System.IO.File.ReadAllText(path)); if (root is not JsonArray sourceItems) { Console.WriteLine( "[CONSUMABLE NORMALIZE] Invalid root; expected JSON array."); return Ok(Array.Empty()); } var normalizedItems = new JsonArray(); int repairedCreatedAts = 0; int repairedKeys = 0; int skipped = 0; long returnedQuantity = 0; Dictionary? ownedConsumablesById = null; Dictionary? ownedConsumablesByDescriptor = null; if (accountId.HasValue) { List ownedConsumables = PlayerInventoryStore.GetConsumables(accountId.Value); ownedConsumablesById = ownedConsumables .Where(item => item.ConsumableItemId > 0) .GroupBy(item => item.ConsumableItemId) .ToDictionary( group => group.Key, group => (int)Math.Min( 100_000L, group.Sum(item => (long)Math.Max(0, item.Quantity)))); ownedConsumablesByDescriptor = ownedConsumables .Where(item => !string.IsNullOrWhiteSpace(item.ConsumableItemDesc)) .GroupBy( item => item.ConsumableItemDesc, StringComparer.OrdinalIgnoreCase) .ToDictionary( group => group.Key, group => (int)Math.Min( 100_000L, group.Sum(item => (long)Math.Max(0, item.Quantity))), StringComparer.OrdinalIgnoreCase); } foreach (JsonNode? node in sourceItems) { if (node is not JsonObject source) { skipped++; continue; } var item = source.DeepClone().AsObject(); List ids = ReadConsumableIds(item); if (ids.Count == 0) { skipped++; continue; } item["Ids"] = new JsonArray( ids.Select(id => JsonValue.Create(id)).ToArray()); List createdAts = ReadConsumableCreatedAts(item); string fallbackCreatedAt = NormalizeConsumableTimestamp(ReadString(item, "CreatedAt")) ?? "2023-04-06T00:00:00.0000000Z"; if (createdAts.Count != ids.Count) { repairedCreatedAts++; if (createdAts.Count > ids.Count) createdAts = createdAts.Take(ids.Count).ToList(); while (createdAts.Count < ids.Count) createdAts.Add(fallbackCreatedAt); } item["CreatedAts"] = new JsonArray( createdAts.Select(value => JsonValue.Create(value)).ToArray()); string? descriptor = ReadString(item, "ConsumableItemDesc"); string? friendlyName = ReadString(item, "FriendlyName"); if (string.IsNullOrWhiteSpace(descriptor)) { item["ConsumableItemDesc"] = $"legacy-consumable-{ids[0]}"; repairedKeys++; } if (string.IsNullOrWhiteSpace(friendlyName)) { friendlyName = $"Consumable {ids[0]}"; item["FriendlyName"] = friendlyName; repairedKeys++; } int ownedQuantity; if (accountId.HasValue) { ownedQuantity = 0; if (ownedConsumablesById != null && ownedConsumablesById.TryGetValue(ids[0], out int byId)) { ownedQuantity = Math.Max(ownedQuantity, byId); } if (!string.IsNullOrWhiteSpace(descriptor) && ownedConsumablesByDescriptor != null && ownedConsumablesByDescriptor.TryGetValue(descriptor, out int byDescriptor)) { ownedQuantity = Math.Max(ownedQuantity, byDescriptor); } } else { ownedQuantity = Math.Max(1, ids.Count); } if (accountId.HasValue && ownedQuantity <= 0) continue; ownedQuantity = Math.Max(1, ownedQuantity); long firstId = ids[0]; string firstCreatedAt = createdAts.FirstOrDefault() ?? fallbackCreatedAt; item["Ids"] = new JsonArray( JsonValue.Create(firstId)); item["CreatedAts"] = new JsonArray( JsonValue.Create(firstCreatedAt)); item["Count"] = ownedQuantity; item["InitialCount"] = ownedQuantity; item["Quantity"] = ownedQuantity; item["IsActive"] = false; item["ActiveDurationMinutes"] = 0; item["IsTransferable"] = false; item["IsOwned"] = true; item["Owned"] = true; item["Purchased"] = true; returnedQuantity += ownedQuantity; if (IsHairDyeFriendlyName(friendlyName)) { item["ConsumableItemType"] = 6; item["ItemType"] = 1; } normalizedItems.Add(item); } Console.WriteLine( $"[CONSUMABLE NORMALIZE] source={sourceItems.Count} " + $"returned={normalizedItems.Count} " + $"quantity={returnedQuantity} countContract=v25 " + $"createdAtsFixed={repairedCreatedAts} " + $"keysFixed={repairedKeys} skipped={skipped}"); return Content( normalizedItems.ToJsonString( new JsonSerializerOptions { PropertyNamingPolicy = null, WriteIndented = false }), "application/json"); } catch (Exception ex) { Console.WriteLine( $"[CONSUMABLE NORMALIZE] Failed: {ex.Message}"); return Ok(Array.Empty()); } } private static List ReadConsumableIds(JsonObject item) { var ids = new List(); if (item["Ids"] is JsonArray idArray) { foreach (JsonNode? idNode in idArray) { if (TryReadLong(idNode, out long id) && id > 0) ids.Add(id); } } if (ids.Count == 0) { foreach (string propertyName in new[] { "ConsumableItemId", "ItemId", "Id" }) { if (TryReadLong(item[propertyName], out long id) && id > 0) { ids.Add(id); break; } } } return ids.Distinct().ToList(); } private static List ReadConsumableCreatedAts(JsonObject item) { var values = new List(); if (item["CreatedAts"] is not JsonArray createdAtArray) return values; foreach (JsonNode? valueNode in createdAtArray) { string? value = null; try { value = valueNode?.GetValue(); } catch { value = valueNode?.ToJsonString().Trim('"'); } if (!string.IsNullOrWhiteSpace(value) && DateTime.TryParse( value, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind, out DateTime parsed)) { values.Add(parsed.ToUniversalTime().ToString("O")); } } return values; } private static string? NormalizeConsumableTimestamp(string? value) { if (string.IsNullOrWhiteSpace(value)) return null; return DateTime.TryParse( value, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind, out DateTime parsed) ? parsed.ToUniversalTime().ToString("O") : null; } private static string? ReadString( JsonObject item, string propertyName) { JsonNode? node = item[propertyName]; if (node == null) return null; try { return node.GetValue(); } catch { string raw = node.ToJsonString().Trim('"'); return string.Equals(raw, "null", StringComparison.OrdinalIgnoreCase) ? null : raw; } } private static bool TryReadLong( JsonNode? node, out long value) { value = 0; if (node == null) return false; try { if (node is JsonValue jsonValue && jsonValue.TryGetValue(out value)) { return true; } } catch { } return long.TryParse( node.ToJsonString().Trim('"'), out value); } private static string GetPlayerEquipmentPath(long accountId) { return EquipmentInventoryStore.GetPlayerEquipmentPath(accountId); } private static List GetOrCreatePlayerEquipment(long accountId) { return EquipmentInventoryStore.GetOrCreate(accountId); } private static void SavePlayerEquipment( long accountId, List equipment) { EquipmentInventoryStore.Save(accountId, equipment); } private static bool OwnsEquipmentItem( long accountId, string prefabName, string modificationGuid) { if (string.IsNullOrWhiteSpace(prefabName) || string.IsNullOrWhiteSpace(modificationGuid)) { return false; } return GetOrCreatePlayerEquipment(accountId).Any(item => string.Equals( item.PrefabName, prefabName, StringComparison.OrdinalIgnoreCase) && string.Equals( item.ModificationGuid, modificationGuid, StringComparison.OrdinalIgnoreCase)); } private static void GrantPlayerEquipment( long accountId, string prefabName, string modificationGuid) { var equipment = GetOrCreatePlayerEquipment(accountId); var existingReward = equipment.FirstOrDefault(item => string.Equals(item.PrefabName, prefabName, StringComparison.OrdinalIgnoreCase) && string.Equals(item.ModificationGuid, modificationGuid, StringComparison.OrdinalIgnoreCase)); if (existingReward != null) { existingReward.FriendlyName = "Ghost Bow Skin"; existingReward.Rarity = 50; SavePlayerEquipment(accountId, equipment); return; } string masterPath = Path.Combine(Program.dataDir, "APIS", "Items", "Equipment.json"); PlayerDBClasses.EquipmentItem? reward = null; if (System.IO.File.Exists(masterPath)) { var masterEquipment = JsonSerializer.Deserialize>( System.IO.File.ReadAllText(masterPath), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); reward = masterEquipment?.FirstOrDefault(item => string.Equals(item.PrefabName, prefabName, StringComparison.OrdinalIgnoreCase) && string.Equals(item.ModificationGuid, modificationGuid, StringComparison.OrdinalIgnoreCase)); } if (reward != null) { reward.FriendlyName = "Ghost Bow Skin"; reward.Rarity = 50; } equipment.Add(reward ?? new PlayerDBClasses.EquipmentItem { PrefabName = prefabName, ModificationGuid = modificationGuid, FriendlyName = "Ghost Bow Skin", Rarity = 50, PlatformMask = -1 }); SavePlayerEquipment(accountId, equipment); } private static bool GrantPurchasedEquipment( long accountId, CatalogSku sku) { if (!IsEquipmentSkin(sku)) return false; var equipment = GetOrCreatePlayerEquipment(accountId); if (equipment.Any(item => string.Equals( item.PrefabName, sku.EquipmentPrefabName, StringComparison.OrdinalIgnoreCase) && string.Equals( item.ModificationGuid, sku.EquipmentModificationGuid, StringComparison.OrdinalIgnoreCase))) { return true; } equipment.Add(new PlayerDBClasses.EquipmentItem { PrefabName = sku.EquipmentPrefabName, ModificationGuid = sku.EquipmentModificationGuid, UnlockedLevel = 0, Favorited = false, PlatformMask = sku.PlatformMask, FriendlyName = sku.FriendlyName, Tooltip = sku.Tooltip, Rarity = sku.Rarity, ThumbnailImage = sku.ThumbnailImage }); SavePlayerEquipment(accountId, equipment); return true; } [HttpGet("/api/equipment/v2/getUnlocked")] public IActionResult GetMyEquipment() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); var equipment = GetOrCreatePlayerEquipment((long)id); return Ok(equipment); } [HttpPost("/api/equipment/v1/update")] public IActionResult UpdateMyEquipment([FromBody] JsonElement request) { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); List updates; try { JsonElement items = request; if (request.ValueKind == JsonValueKind.Object) { foreach (JsonProperty property in request.EnumerateObject()) { if (property.Name.Equals("items", StringComparison.OrdinalIgnoreCase) || property.Name.Equals("equipment", StringComparison.OrdinalIgnoreCase) || property.Name.Equals("results", StringComparison.OrdinalIgnoreCase)) { items = property.Value; break; } } } updates = items.ValueKind switch { JsonValueKind.Array => JsonSerializer.Deserialize>( items.GetRawText(), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new List(), JsonValueKind.Object => new List { JsonSerializer.Deserialize( items.GetRawText(), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })! }, _ => new List() }; updates.RemoveAll(item => item == null); } catch (JsonException) { updates = new List(); } if (updates.Count == 0) return BadRequest(new { success = false, error = "Invalid request body." }); var equipment = GetOrCreatePlayerEquipment((long)id); foreach (var incoming in updates) { var item = equipment.FirstOrDefault(e => e.ModificationGuid == incoming.ModificationGuid && e.PrefabName == incoming.PrefabName); if (item != null) item.Favorited = incoming.Favorited; } SavePlayerEquipment((long)id, equipment); return Ok(new { success = true }); } [HttpPost("/api/gamerewards/v1/request")] public IActionResult RequestGameRewards() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); string path = Path.Combine(Program.dataDir, "APIS", "Items", "AvatarItems.json"); if (!System.IO.File.Exists(path)) return Ok(new { Rewards = Array.Empty() }); string json = System.IO.File.ReadAllText(path); var items = JsonSerializer.Deserialize>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new List(); if (items.Count == 0) return Ok(new { Rewards = Array.Empty() }); var random = new Random(); var rewards = items.OrderBy(_ => random.Next()).Take(Math.Min(3, items.Count)).ToList(); return Ok(new { Rewards = rewards }); } public class EquipmentUpdateRequest { public string PrefabName { get; set; } = ""; public string ModificationGuid { get; set; } = ""; public bool Favorited { get; set; } } [HttpGet("/api/PlayerReporting/v1/moderationBlockDetails")] public IActionResult GetMyModerationBlockDetails() { var player = AuthStuff.GetCurrentPlayer(Request); if (player == null) return Unauthorized(); return Ok(player.Player.PlayerExtra.ModerationBlockDetails); } [HttpPost("/api/freegifts/v1/sendmultiple")] [RequestSizeLimit(64 * 1024)] public async Task SendMultipleFreeGifts() { long? senderId = AuthStuff.GetPlayerId(Request); if (!senderId.HasValue) return Unauthorized(); ParsedFreeGiftRequest parsed = await ReadFreeGiftRequestAsync(); parsed.Message = parsed.Message.Trim(); if (parsed.Message.Length > 200) { return BadRequest(new { success = false, error = "gift_message_too_long" }); } Console.WriteLine( $"[FRIENDOTRON REQUEST] sender={senderId.Value} " + $"recipients=[{string.Join(",", parsed.RecipientIds)}] " + $"messageLength={parsed.Message.Length} " + $"ContentType={Request.ContentType}, ContentLength={Request.ContentLength ?? 0}"); List recipientIds = parsed.RecipientIds .Where(id => id > 0 && id <= int.MaxValue) .Distinct() .ToList(); if (recipientIds.Count == 0) { return BadRequest(new { success = false, error = "missing_recipient_ids", contentType = Request.ContentType }); } if (recipientIds.Count != 1) { return BadRequest(new { success = false, error = "friendotron_one_recipient_only" }); } long recipientId = recipientIds[0]; if (recipientId != senderId.Value) { PlayerRelationship? relationship = RelationshipDB.GetRelationship( senderId.Value, recipientId); if (relationship?.RelationshipType != RelationshipType.Friend) { return StatusCode(StatusCodes.Status403Forbidden, new { success = false, error = "friendotron_recipient_not_friend" }); } } HashSet installedConsumables = GetCatalogSkus() .Where(IsConsumableCatalogItem) .Select(item => item.ConsumableItemDesc) .ToHashSet(StringComparer.OrdinalIgnoreCase); List rewardPool = FriendotronConsumableRewards .Where(reward => installedConsumables.Contains( reward.ConsumableItemDesc)) .ToList(); if (rewardPool.Count == 0) { return StatusCode(500, new { success = false, error = "friendotron_consumable_pool_empty" }); } FriendotronConsumableReward reward = rewardPool[Random.Shared.Next(rewardPool.Count)]; var pendingGift = new GiftPackage { FromPlayerId = checked((int)senderId.Value), Message = parsed.Message, AvatarItemDesc = string.Empty, ConsumableItemDesc = reward.ConsumableItemDesc, ConsumableQuantity = 1, EquipmentPrefabName = string.Empty, EquipmentModificationGuid = string.Empty, CurrencyType = (int)CurrencyType.RecCenterTokens, GiftContext = FriendotronGiftContext, Rarity = reward.Rarity, Platform = -1, PlatformMask = -1, BalanceType = (int)BalanceType.NonPurchasedNotUsableInP2P, IsQuery = false, Unique = false }; PlayerDB.FriendotronGiftStatus queueStatus = PlayerDB.QueueFriendotronGift( senderId.Value, recipientId, pendingGift, out GiftPackage? package, out DateTime nextAvailableAtUtc); if (queueStatus == PlayerDB.FriendotronGiftStatus.DailyLimitReached) { long retryAfterSeconds = Math.Max( 1, (long)Math.Ceiling( (nextAvailableAtUtc - DateTime.UtcNow).TotalSeconds)); Response.Headers["Retry-After"] = retryAfterSeconds.ToString( System.Globalization.CultureInfo.InvariantCulture); Console.WriteLine( $"[FRIENDOTRON DENIED] sender={senderId.Value} " + $"recipient={recipientId} reason=daily_limit " + $"next={nextAvailableAtUtc:O}"); return StatusCode(StatusCodes.Status429TooManyRequests, new { success = false, error = "friendotron_daily_limit", nextGiftAt = nextAvailableAtUtc, retryAfterSeconds }); } if (queueStatus != PlayerDB.FriendotronGiftStatus.Success || package == null) { Console.WriteLine( $"[FRIENDOTRON ERROR] sender={senderId.Value} " + $"recipient={recipientId} status={queueStatus}"); return queueStatus switch { PlayerDB.FriendotronGiftStatus.RecipientNotFound => NotFound(new { success = false, error = "friendotron_recipient_not_found" }), PlayerDB.FriendotronGiftStatus.RecipientQueueFull => StatusCode(StatusCodes.Status409Conflict, new { success = false, error = "friendotron_recipient_gift_queue_full" }), _ => StatusCode( StatusCodes.Status500InternalServerError, new { success = false, error = "friendotron_delivery_failed" }) }; } try { await NotiController.NotifyGiftAsync( senderId.Value, recipientId, package); } catch (Exception exception) { Console.WriteLine( $"[FRIENDOTRON PUSH ERROR] sender={senderId.Value} " + $"recipient={recipientId} package={package.GiftPackageId} " + $"error={exception.Message}"); } Console.WriteLine( $"[FRIENDOTRON SENT] sender={senderId.Value} " + $"recipient={recipientId} reward={reward.FriendlyName} " + $"stars={reward.Stars} rarity={reward.Rarity} " + $"package={package.GiftPackageId} " + $"next={nextAvailableAtUtc:O}"); return Ok(); } [HttpPost("/api/avatar/v2/gifts/generate")] [HttpPost("/api/avatar/v3/gifts/generate")] [RequestSizeLimit(64 * 1024)] public async Task GenerateGiftPackage() { long? playerId = AuthStuff.GetPlayerId(Request); if (!playerId.HasValue) return Unauthorized(); var player = PlayerDB.Players.FindById(playerId.Value); if (player?.Player == null) return NotFound(); if (player.PlayerRoles?.Contains(PlayerRoles.Developer) != true) { return StatusCode(StatusCodes.Status403Forbidden, new { success = false, error = "developer_only_legacy_reward" }); } ParsedFreeGiftRequest parsed = await ReadFreeGiftRequestAsync(); parsed.Message = parsed.Message.Trim(); if (parsed.Message.Length > 200) return BadRequest(new { success = false, error = "gift_message_too_long" }); player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.AvatarItems ??= new List(); HashSet ownedItems = player.Player.PlayerExtra.AvatarItems.ToHashSet( StringComparer.OrdinalIgnoreCase); List availableGifts = GetCatalogSkus() .Where(IsUsableStoreItem) .Where(item => !string.IsNullOrWhiteSpace(item.AvatarItemDesc) && !ownedItems.Contains(item.AvatarItemDesc)) .ToList(); if (availableGifts.Count == 0) { return NotFound(new { success = false, error = "no_available_gifts" }); } CatalogSku gift = availableGifts[Random.Shared.Next(availableGifts.Count)]; GiftPackage? package = PlayerDB.QueueGiftPackage( playerId.Value, new GiftPackage { FromPlayerId = 0, Message = parsed.Message, AvatarItemDesc = gift.AvatarItemDesc, ConsumableItemDesc = gift.ConsumableItemDesc, EquipmentPrefabName = gift.EquipmentPrefabName, EquipmentModificationGuid = gift.EquipmentModificationGuid, CurrencyType = gift.CurrencyType, GiftContext = parsed.GiftContext, Rarity = gift.Rarity, Platform = -1, PlatformMask = gift.PlatformMask, IsQuery = gift.GiftDrop.IsQuery, Unique = gift.GiftDrop.Unique }); if (package == null) return StatusCode(500); await NotiController.NotifyGiftAsync( senderPlayerId: 0, receiverPlayerId: playerId.Value, package); return Ok(package); } private sealed class FriendotronConsumableReward { public FriendotronConsumableReward( string friendlyName, string consumableItemDesc, int rarity) { FriendlyName = friendlyName; ConsumableItemDesc = consumableItemDesc; Rarity = rarity; } public string FriendlyName { get; } public string ConsumableItemDesc { get; } public int Rarity { get; } public int Stars => (Rarity / 10) + 1; } private sealed class ParsedFreeGiftRequest { public HashSet RecipientIds { get; } = new(); public string Message { get; set; } = string.Empty; public int GiftContext { get; set; } } private static readonly string[] FreeGiftRecipientKeys = { "ToPlayerIds", "toPlayerIds", "to_player_ids", "PlayerIds", "playerIds", "RecipientIds", "recipientIds", "AccountIds", "accountIds", "ToPlayerId", "toPlayerId", "PlayerId", "playerId", "RecipientId", "recipientId", "AccountId", "accountId", "Ids", "ids" }; private async Task ReadFreeGiftRequestAsync() { var result = new ParsedFreeGiftRequest(); foreach (string key in FreeGiftRecipientKeys) { foreach (string? value in Request.Query[key]) AddIdsFromText(value, result.RecipientIds); } result.Message = Request.Query["Message"].FirstOrDefault() ?? Request.Query["message"].FirstOrDefault() ?? string.Empty; if (int.TryParse( Request.Query["GiftContext"].FirstOrDefault() ?? Request.Query["giftContext"].FirstOrDefault(), out int queryContext)) { result.GiftContext = queryContext; } if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); foreach (string key in FreeGiftRecipientKeys) { foreach (string? value in form[key]) AddIdsFromText(value, result.RecipientIds); } result.Message = form["Message"].FirstOrDefault() ?? form["message"].FirstOrDefault() ?? result.Message; if (int.TryParse( form["GiftContext"].FirstOrDefault() ?? form["giftContext"].FirstOrDefault(), out int formContext)) { result.GiftContext = formContext; } return result; } if (!Request.Body.CanRead) return result; using var reader = new StreamReader( Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: true); string body = await reader.ReadToEndAsync(); if (string.IsNullOrWhiteSpace(body)) return result; try { using JsonDocument document = JsonDocument.Parse(body); JsonElement root = document.RootElement; CollectRecipientIds(root, result.RecipientIds); result.Message = FindJsonString(root, "Message", "message") ?? result.Message; result.GiftContext = FindJsonInt(root, "GiftContext", "giftContext") ?? result.GiftContext; } catch (JsonException exception) { Console.WriteLine( $"[FREE GIFTS] Invalid JSON: {exception.Message}"); AddIdsFromText(body, result.RecipientIds); } return result; } private static void CollectRecipientIds( JsonElement element, HashSet recipientIds) { switch (element.ValueKind) { case JsonValueKind.Array: foreach (JsonElement child in element.EnumerateArray()) AddIdsFromJsonValue(child, recipientIds); break; case JsonValueKind.Object: bool foundKnownProperty = false; foreach (JsonProperty property in element.EnumerateObject()) { if (FreeGiftRecipientKeys.Any(key => string.Equals( key, property.Name, StringComparison.OrdinalIgnoreCase))) { foundKnownProperty = true; AddIdsFromJsonValue(property.Value, recipientIds); } } if (!foundKnownProperty) { foreach (JsonProperty property in element.EnumerateObject()) { if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array) { CollectRecipientIds( property.Value, recipientIds); } } } break; case JsonValueKind.Number: case JsonValueKind.String: AddIdsFromJsonValue(element, recipientIds); break; } } private static void AddIdsFromJsonValue( JsonElement value, HashSet recipientIds) { switch (value.ValueKind) { case JsonValueKind.Number: if (value.TryGetInt64(out long number)) recipientIds.Add(number); break; case JsonValueKind.String: AddIdsFromText(value.GetString(), recipientIds); break; case JsonValueKind.Array: foreach (JsonElement child in value.EnumerateArray()) AddIdsFromJsonValue(child, recipientIds); break; case JsonValueKind.Object: CollectRecipientIds(value, recipientIds); break; } } private static void AddIdsFromText( string? value, HashSet recipientIds) { if (string.IsNullOrWhiteSpace(value)) return; string cleaned = value .Replace("[", " ") .Replace("]", " ") .Replace("{", " ") .Replace("}", " ") .Replace("\"", " ") .Replace("'", " "); string[] pieces = cleaned.Split( new[] { ',', ';', '|', ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); foreach (string piece in pieces) { string candidate = piece; int equalsIndex = candidate.LastIndexOf('='); if (equalsIndex >= 0) candidate = candidate[(equalsIndex + 1)..]; int colonIndex = candidate.LastIndexOf(':'); if (colonIndex >= 0) candidate = candidate[(colonIndex + 1)..]; if (long.TryParse(candidate, out long playerId)) recipientIds.Add(playerId); } } [HttpGet("/api/relationships/v2/get")] [HttpGet("/api/relationships/")] [HttpGet("/api/relationships")] public IActionResult GetMyRelationships([FromQuery] long? id = null) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Ok(Array.Empty()); return Ok(RelationshipDB.GetClientRelationships(accountId.Value)); } [HttpPost("/api/relationships/sendfriendintroductions")] public IActionResult SendFriendIntroductions() { if (AuthStuff.GetPlayerId(Request) == null) return Unauthorized(); return Ok(new { success = true, introductions = Array.Empty() }); } public class AddFriendWithCodeRequest { public string? Code { get; set; } } [HttpPost("/api/relationships/v1/addfriendwithcode")] public IActionResult AddFriendWithCode([FromBody] AddFriendWithCodeRequest request) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); string username = (request.Code ?? string.Empty).Trim().TrimStart('@'); if (username.Length == 0) return BadRequest(new { error = "Enter a friend code." }); var target = PlayerDB.Players.FindAll().FirstOrDefault(value => string.Equals(value.Player?.Username, username, StringComparison.OrdinalIgnoreCase)); if (target?.Player == null) return NotFound(new { error = "No player found with that code." }); if (target.PlayerId == accountId.Value) return BadRequest(new { error = "That's your own code." }); RelationshipDB.AddFriend(accountId.Value, target.PlayerId); return Ok(RelationshipDB.GetClientRelationship(accountId.Value, target.PlayerId)); } [HttpGet("/api/messages/v2/get")] public IActionResult GetMyMessages() { long? id = AuthStuff.GetPlayerId(Request); if (!id.HasValue) return Unauthorized(); List messages = NotificationDB.GetMessages(id.Value); Console.WriteLine( $"[MESSAGES GET] player={id.Value} count={messages.Count} " + $"types=[{string.Join(",", messages.Select(value => value.Type))}]"); return Ok(messages); } [HttpPost("/api/messages/v2/send")] [RequestSizeLimit(64 * 1024)] public async Task SendPlayerMessage( [FromForm] long ToPlayerId, [FromForm] int Type, [FromForm] string? Data, [FromForm] long? RoomId) { long? senderPlayerId = AuthStuff.GetPlayerId(Request); if (!senderPlayerId.HasValue) return Unauthorized(); if (ToPlayerId <= 0 || ToPlayerId == senderPlayerId.Value || PlayerDB.Players.FindById(ToPlayerId)?.Player == null) { return BadRequest(new { success = false, error = "invalid_message_recipient" }); } var recipientRelationship = RelationshipDB.GetClientRelationship( ToPlayerId, senderPlayerId.Value); if ((Type is (int)NotificationDB.MessageType.GameInvite or (int)NotificationDB.MessageType.GameInviteV2) && (recipientRelationship?.Ignored is 1 or 3)) { return StatusCode(403, new { success = false, error = "gameplay_invites_not_allowed" }); } NotificationDB.ClientNotification message; if (Type is (int)NotificationDB.MessageType.GameInvite or (int)NotificationDB.MessageType.GameInviteV2) { RoomInstance? instance = PlayerDB .GetPlayerHeartbeat(senderPlayerId.Value) .roomInstance; if (instance == null) { return BadRequest(new { success = false, error = "sender_not_in_room" }); } if (!Sessions.IsConfirmedParticipant( senderPlayerId.Value, instance.roomInstanceId)) { return StatusCode(409, new { success = false, error = "room_instance_membership_not_confirmed" }); } if (RoomId.HasValue && RoomId.Value > 0 && RoomId.Value != instance.roomId) { return StatusCode(409, new { success = false, error = "room_invite_source_mismatch", requestedRoomId = RoomId.Value, currentRoomId = instance.roomId }); } message = await NotiController.NotifyRoomInviteAsync( senderPlayerId.Value, ToPlayerId, instance.roomId, instance.roomInstanceId, instance.photonRoomId); } else { NotificationDB.MessageType messageType = Enum.IsDefined(typeof(NotificationDB.MessageType), Type) ? (NotificationDB.MessageType)Type : NotificationDB.MessageType.TextMessage; message = NotificationDB.CreatePlayerMessage( senderPlayerId.Value, ToPlayerId, messageType, Data, RoomId); await NotiController.NotifyMessageAsync(ToPlayerId, message); } Console.WriteLine( $"[MESSAGES SEND] from={senderPlayerId.Value} " + $"to={ToPlayerId} type={Type} room={RoomId?.ToString() ?? "none"} " + $"messageId={message.Id}"); return NoContent(); } [HttpPost("/api/messages/v2/delete")] [HttpDelete("/api/messages/v2/delete")] [HttpPost("/api/messages/v3/delete")] [HttpDelete("/api/messages/v3/delete")] [RequestSizeLimit(64 * 1024)] public async Task DeleteMyMessages() { long? playerId = AuthStuff.GetPlayerId(Request); if (!playerId.HasValue) return Unauthorized(); HashSet ids = await ReadLongValuesAsync( "MessageIds", "messageIds", "MessageId", "messageId", "id"); int acceptedCheers = PlayerDB.ResolvePlayerCheers( playerId.Value, ids, CheerStatus.Accepted); int deleted = NotificationDB.DeleteMessages(playerId.Value, ids); return Ok(new { success = true, deleted, acceptedCheers }); } [HttpPost("/api/messages/v1/sendMultiple")] [RequestSizeLimit(64 * 1024)] public async Task SendMultipleMessages() { long? senderPlayerId = AuthStuff.GetPlayerId(Request); if (!senderPlayerId.HasValue) return Unauthorized(); HashSet recipientIds = await ReadLongValuesAsync( "ToPlayerIds", "toPlayerIds", "RecipientPlayerIds", "recipientPlayerIds", "RecipientIds", "recipientIds", "PlayerIds", "playerIds", "ToAccountIds", "toAccountIds"); HashSet aboutIds = await ReadLongValuesAsync( "AboutPlayerId", "aboutPlayerId", "AboutAccountId", "aboutAccountId"); long aboutPlayerId = aboutIds.FirstOrDefault(); if (aboutPlayerId > 0 && PlayerDB.Players.FindById(aboutPlayerId)?.Player == null) { Console.WriteLine( $"[MESSAGES SEND MULTIPLE] sender={senderPlayerId.Value} " + $"about={aboutPlayerId} rejected=about_player_not_found"); return NotFound(new { success = false, error = "about_player_not_found" }); } long[] validRecipients = recipientIds .Where(id => id > 0 && id <= int.MaxValue && id != senderPlayerId.Value && (aboutPlayerId <= 0 || id != aboutPlayerId) && PlayerDB.Players.FindById(id)?.Player != null) .Distinct() .Take(50) .ToArray(); if (validRecipients.Length == 0) { Console.WriteLine( $"[MESSAGES SEND MULTIPLE] sender={senderPlayerId.Value} " + $"about={aboutPlayerId} rejected=no_valid_recipients"); return BadRequest(new { success = false, error = "missing_recipient_ids" }); } if (aboutPlayerId > 0) { foreach (long receiverPlayerId in validRecipients) { await NotiController.NotifyFriendIntroductionAsync( senderPlayerId.Value, receiverPlayerId, aboutPlayerId); } Console.WriteLine( $"[MESSAGES SEND MULTIPLE] sender={senderPlayerId.Value} " + $"type={(int)NotificationDB.MessageType.FriendIntroduction} " + $"about={aboutPlayerId} recipients=" + $"[{string.Join(",", validRecipients)}] " + $"sent={validRecipients.Length}"); return Ok(); } (int type, string data) = await ReadMultipleMessagePayloadAsync(); NotificationDB.MessageType messageType = Enum.IsDefined(typeof(NotificationDB.MessageType), type) ? (NotificationDB.MessageType)type : NotificationDB.MessageType.TextMessage; bool isGameInvite = messageType is NotificationDB.MessageType.GameInvite or NotificationDB.MessageType.GameInviteV2; RoomInstance? inviteInstance = null; if (isGameInvite) { inviteInstance = PlayerDB .GetPlayerHeartbeat(senderPlayerId.Value) .roomInstance; if (inviteInstance == null) { return BadRequest(new { success = false, error = "sender_not_in_room" }); } if (!Sessions.IsConfirmedParticipant( senderPlayerId.Value, inviteInstance.roomInstanceId)) { return StatusCode(409, new { success = false, error = "room_instance_membership_not_confirmed" }); } } var deliveredRecipients = new List(); var blockedRecipients = new List(); foreach (long receiverPlayerId in validRecipients) { if (isGameInvite) { var relationship = RelationshipDB.GetClientRelationship( receiverPlayerId, senderPlayerId.Value); if (relationship?.Ignored is 1 or 3) { blockedRecipients.Add(receiverPlayerId); continue; } await NotiController.NotifyRoomInviteAsync( senderPlayerId.Value, receiverPlayerId, inviteInstance!.roomId, inviteInstance.roomInstanceId, inviteInstance.photonRoomId); deliveredRecipients.Add(receiverPlayerId); } else { NotificationDB.ClientNotification message = NotificationDB.CreatePlayerMessage( senderPlayerId.Value, receiverPlayerId, messageType, data); await NotiController.NotifyMessageAsync( receiverPlayerId, message); deliveredRecipients.Add(receiverPlayerId); } } Console.WriteLine( $"[MESSAGES SEND MULTIPLE] sender={senderPlayerId.Value} " + $"type={(int)messageType} recipients=" + $"[{string.Join(",", deliveredRecipients)}] " + $"sent={deliveredRecipients.Count} " + $"blocked={blockedRecipients.Count}"); return Ok(); } private async Task<(int Type, string Data)> ReadMultipleMessagePayloadAsync() { string? rawType = Request.Query["Type"].FirstOrDefault() ?? Request.Query["type"].FirstOrDefault() ?? Request.Query["MessageType"].FirstOrDefault() ?? Request.Query["messageType"].FirstOrDefault(); string data = Request.Query["Data"].FirstOrDefault() ?? Request.Query["data"].FirstOrDefault() ?? string.Empty; if (Request.HasFormContentType) { IFormCollection form = await Request.ReadFormAsync(HttpContext.RequestAborted); rawType = form["Type"].FirstOrDefault() ?? form["type"].FirstOrDefault() ?? form["MessageType"].FirstOrDefault() ?? form["messageType"].FirstOrDefault() ?? rawType; data = form["Data"].FirstOrDefault() ?? form["data"].FirstOrDefault() ?? data; } else if (Request.Body.CanRead) { Request.EnableBuffering(); if (Request.Body.CanSeek) Request.Body.Position = 0; using var reader = new StreamReader( Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); string body = await reader.ReadToEndAsync(); if (Request.Body.CanSeek) Request.Body.Position = 0; if (!string.IsNullOrWhiteSpace(body)) { try { using JsonDocument document = JsonDocument.Parse(body); JsonElement root = document.RootElement; rawType = FindJsonInt( root, "Type", "type", "MessageType", "messageType")? .ToString() ?? rawType; data = FindJsonString(root, "Data", "data") ?? data; } catch (JsonException) { } } } return ( int.TryParse(rawType, out int type) ? type : (int)NotificationDB.MessageType.TextMessage, data); } [HttpGet("/playersettings")] public IActionResult GetMySettings() { var player = AuthStuff.GetCurrentPlayer(Request); if (player == null) return Unauthorized(); return Ok(player.Player.PlayerExtra.Settings); } [HttpPut("/playersettings")] [RequestSizeLimit(16 * 1024)] public IActionResult SetMySettings([FromForm] string key, [FromForm] string value) { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); PlayerDB.SetPlayerSetting(key, value ?? "", (long)id); return NoContent(); } [HttpGet("/econ/customAvatarItems/v1/owned")] public IActionResult GetMyOwnedCustomAvatarItems([FromQuery] int skip, [FromQuery] int take) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); skip = Math.Max(0, skip); take = Math.Clamp(take <= 0 ? 100 : take, 1, 100); List items = LoadCustomAvatarItems(accountId.Value); List page = items.Skip(skip).Take(take).ToList(); return Ok(new { Results = page, TotalResults = items.Count }); } [HttpGet("/api/checklist/v1/current")] public IActionResult GetMyChecklist() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); return Ok(ServerConfig.Bracket); } [HttpGet("/api/players/v2/progression/bulk")] public IActionResult GetProgressionForPlayers([FromQuery] List id) { var authId = AuthStuff.GetPlayerId(Request); if (authId == null) return Unauthorized(); if (id == null || id.Count == 0) return Ok(new List()); var progressions = PlayerDB.GetProgressionBulk(id); return Ok(progressions); } [HttpPost("/api/players/v2/progression/update")] [HttpPost("/api/players/v1/progression/update")] [HttpPost("/api/progression/v1/update")] [RequestSizeLimit(32 * 1024)] public async Task UpdateMyProgression() { long? playerId = AuthStuff.GetPlayerId(Request); if (!playerId.HasValue) return Unauthorized(); var current = PlayerDB.Players.FindById(playerId.Value); if (current?.Player == null) return NotFound(new { success = false, error = "player_not_found" }); if (current.PlayerRoles?.Contains(PlayerRoles.Developer) != true) { return StatusCode(StatusCodes.Status403Forbidden, new { success = false, error = "server_authoritative_progression" }); } var values = new Dictionary( StringComparer.OrdinalIgnoreCase); foreach (var queryValue in Request.Query) values[queryValue.Key] = queryValue.Value.ToString(); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); foreach (var formValue in form) values[formValue.Key] = formValue.Value.ToString(); } else if ((Request.ContentLength ?? 0) > 0) { using var reader = new StreamReader(Request.Body); string body = await reader.ReadToEndAsync(); if (!string.IsNullOrWhiteSpace(body)) { try { using JsonDocument document = JsonDocument.Parse(body); AddJsonValues(document.RootElement, values); } catch (JsonException) { foreach (var bodyValue in Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery( "?" + body)) { values[bodyValue.Key] = bodyValue.Value.ToString(); } } } } int level = current.Player.Level; int xp = current.Player.XP; string? levelText = GetValue(values, "Level", "level", "NewLevel", "newLevel"); string? xpText = GetValue(values, "XP", "Xp", "xp", "Experience", "experience", "NewXP", "newXp"); string? xpDeltaText = GetValue(values, "XPDelta", "xpDelta", "ExperienceDelta", "experienceDelta", "AddXP", "addXp"); if (int.TryParse(levelText, out int parsedLevel)) level = parsedLevel; if (int.TryParse(xpText, out int parsedXp)) xp = parsedXp; if (int.TryParse(xpDeltaText, out int xpDelta)) xp = Math.Max(0, xp + xpDelta); var progression = PlayerDB.SetProgression( playerId.Value, level, xp); return progression == null ? StatusCode(500, new { success = false, error = "progression_update_failed" }) : Ok(progression); } [HttpGet("/api/playerReputation/v2/bulk")] public IActionResult GetReputationBulk([FromQuery] List id) { var authId = AuthStuff.GetPlayerId(Request); if (authId == null) return Unauthorized(); if (id == null || id.Count == 0) return Ok(new List()); var results = PlayerDB.GetReputationBulk(id); return Ok(results); } [HttpPost("/api/PlayerReporting/v1/hile")] public IActionResult PlayerReportingHile() { var authId = AuthStuff.GetPlayerId(Request); if (authId == null) return Unauthorized(); return Ok(false); } [HttpGet("api/config/v2")] public IActionResult GetConfigV2() { string path = Path.Combine(Program.dataDir, "APIS", "ConfigV2.json"); return System.IO.File.Exists(path) ? Content(System.IO.File.ReadAllText(path), "application/json") : NotFound(); } [HttpGet("/api/avatar/v3/saved")] public IActionResult GetSavedAvatars() { var player = AuthStuff.GetCurrentPlayer(Request); if (player?.Player == null) return Unauthorized(); player.Player.PlayerExtra ??= new PlayerDBClasses.PlayerExtra(); player.Player.PlayerExtra.SavedAvatars ??= new List(); var saved = player.Player.PlayerExtra.SavedAvatars; var photoEntries = saved.Where(s => !HasSavedOutfitState(s)).ToList(); foreach (var photoEntry in photoEntries) MigrateLegacyPhotoEntry(player, photoEntry); if (photoEntries.Count > 0) { foreach (var photoEntry in photoEntries) saved.Remove(photoEntry); PlayerDB.Players.Update(player); } var result = saved .OrderBy(s => s.Slot) .Select(s => new { Slot = s.Slot, OutfitSelections = s.OutfitSelections, FaceFeatures = s.FaceFeatures, SkinColor = s.SkinColor, HairColor = s.HairColor, PreviewImageName = s.PreviewImageName }).ToList(); return Ok(result); } [HttpPost("/api/avatar/v3/saved/set")] [HttpPost("/api/avatar/v4/saved/set")] [RequestSizeLimit(256 * 1024)] public IActionResult SetSavedAvatar([FromBody] PlayerDBClasses.SavedOutfit request) { var player = AuthStuff.GetCurrentPlayer(Request); if (player?.Player == null) return Unauthorized(); if (request == null) return BadRequest(new { success = false, error = "Invalid request body." }); if (request.Slot > 350) return BadRequest(new { success = false, error = "Invalid outfit slot." }); if (!HasSavedOutfitState(request)) { return BadRequest(new { success = false, error = "An outfit save must include avatar data." }); } if ((request.OutfitSelections?.Length ?? 0) > 64_000 || (request.FaceFeatures?.Length ?? 0) > 64_000 || (request.SkinColor?.Length ?? 0) > 1_024 || (request.HairColor?.Length ?? 0) > 1_024 || request.PreviewImageName?.Length > 260) { return BadRequest(new { success = false, error = "Outfit data is too large." }); } player.Player.PlayerExtra ??= new PlayerDBClasses.PlayerExtra(); player.Player.PlayerExtra.SavedAvatars ??= new List(); if (request.Slot < 0) { request.Slot = Enumerable.Range(0, 100).FirstOrDefault(slot => player.Player.PlayerExtra.SavedAvatars.All(saved => saved.Slot != slot), -1); if (request.Slot < 0) return Conflict(new { success = false, error = "All saved outfit slots are in use." }); } var existing = player.Player.PlayerExtra.SavedAvatars.FirstOrDefault(x => x.Slot == request.Slot); if (existing != null) { existing.OutfitSelections = request.OutfitSelections; existing.FaceFeatures = request.FaceFeatures; existing.SkinColor = request.SkinColor; existing.HairColor = request.HairColor; existing.PreviewImageName = request.PreviewImageName; } else { player.Player.PlayerExtra.SavedAvatars.Add(request); } PlayerDB.Players.Update(player); return Ok(new { success = true, slot = request.Slot, previewImageName = request.PreviewImageName, url = request.PreviewImageName != null ? $"{ServerConfig.BaseURL}/imageserver-v2/{request.PreviewImageName}" : null }); } private static bool HasSavedOutfitState(PlayerDBClasses.SavedOutfit outfit) { return !string.IsNullOrWhiteSpace(outfit.OutfitSelections) || !string.IsNullOrWhiteSpace(outfit.FaceFeatures) || !string.IsNullOrWhiteSpace(outfit.SkinColor) || !string.IsNullOrWhiteSpace(outfit.HairColor); } private static void MigrateLegacyPhotoEntry( PlayerDBClasses.FullPlayer player, PlayerDBClasses.SavedOutfit entry) { string? path = NormalizeLegacyImagePath(entry.PreviewImageName); if (path == null) return; string? profilePath = NormalizeLegacyImagePath(player.Player?.ProfileImage); if (string.Equals(path, profilePath, StringComparison.OrdinalIgnoreCase)) return; string fullPath = Path.Combine( Program.dataDir, "Images", path.Replace('/', Path.DirectorySeparatorChar)); if (!System.IO.File.Exists(fullPath)) return; RecNetDB.SavedImages.Upsert(new RecNetDB.SavedImage { PhotoPath = path, AccountId = player.PlayerId, SavedImageType = 1, Accessibility = 1, CreatedAt = System.IO.File.GetCreationTimeUtc(fullPath) }); } private static string? NormalizeLegacyImagePath(string? value) { if (string.IsNullOrWhiteSpace(value)) return null; string clean = value.Trim().Replace('\\', '/'); if (Uri.TryCreate(clean, UriKind.Absolute, out var uri)) { string? marker = new[] { "/imageserver-v2/", "/imageserver/" } .FirstOrDefault(candidate => uri.AbsolutePath.Contains( candidate, StringComparison.OrdinalIgnoreCase)); if (marker == null) return null; int markerIndex = uri.AbsolutePath.IndexOf(marker, StringComparison.OrdinalIgnoreCase); clean = Uri.UnescapeDataString(uri.AbsolutePath[(markerIndex + marker.Length)..]); } clean = clean.TrimStart('/'); if (!clean.Contains('/')) clean = $"PlayerImages/{Path.GetFileName(clean)}"; string[] segments = clean.Split('/', StringSplitOptions.RemoveEmptyEntries); if (clean.Length > 260 || segments.Length == 0 || clean.Any(char.IsControl) || clean.Contains(':') || Path.IsPathRooted(clean) || segments.Any(segment => segment is "." or "..")) { return null; } string extension = Path.GetExtension(clean); if (!new[] { ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp" } .Contains(extension, StringComparer.OrdinalIgnoreCase)) { return null; } return string.Join('/', segments); } [HttpGet("/api/PlayerReporting/v1/voteToKickReasons")] [HttpGet("/api/images/v2/named")] [HttpGet("/api/gamerewards/v1/pending")] [HttpGet("/api/roomkeys/v1/mine")] [HttpGet("/api/roomcurrencies/v1/currencies")] [HttpGet("/api/AppIntegrity/v1/iosproducts")] [HttpGet("/api/externalfriendinvite/v1/gettextmessagereferrers")] [HttpGet("/api/images/")] [HttpGet("/api/images")] [HttpGet("/api/incentivizedreferrals/")] [HttpGet("/api/incentivizedreferrals")] [HttpGet("/api/inventions/")] [HttpGet("/api/inventions")] [HttpGet("/api/inventions/v1/versions")] [HttpGet("/api/keepsakes")] [HttpGet("/api/keepsakes/events")] [HttpGet("/api/progressionEvents")] [HttpGet("/api/roomEarningsDistributions")] [HttpGet("/api/roomconsumables")] [HttpGet("/api/roomcurrencies")] [HttpGet("/api/roomkeys/")] [HttpGet("/api/roomkeys/v1/")] [HttpGet("/api/storefronts/")] [HttpGet("/api/storefronts")] [HttpGet("/api/storefronts/v1/objectives")] [HttpGet("/api/testcasemanagement/")] [HttpGet("/api/testcasemanagement/v1/testcase/")] [HttpGet("/api/testcasemanagement/v1/testplans")] [HttpGet("/api/testcasemanagement/v1/testpasssummary")] [HttpGet("/api/ugcPurchasables")] [HttpGet("/api/consumables/")] [HttpGet("/api/consumables")] public IActionResult TodoImplement() { return Ok(ServerConfig.Bracket); } [HttpGet("/api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers")] [HttpGet("/api/apple/musicpromotion/active")] [HttpGet("/api/playstationplus/membership")] [HttpGet("/api/roomkeys/v1/owns")] public IActionResult TodoImplementFalse() => Ok(false); [HttpGet("/api/roomcurrencies/v1/getBalance")] [HttpGet("/api/storefronts/v1/trialInvention/duration")] public IActionResult TodoImplementZero() => Ok(0); [HttpGet("/api/apple/musicpromotion/code")] [HttpGet("/api/inventions/v1/fulllineageowner")] [HttpGet("/api/royale/v1/current")] public IActionResult TodoImplementNull() => Ok(null); [HttpPost("/api/AppIntegrity/v1/iospaymentqueuefailed")] [HttpPost("/api/PlayerReporting/v1/instantKick")] [HttpPost("/api/PlayerReporting/v3/voteToKick")] [HttpPost("/api/checklist/v1/complete")] [HttpPost("/api/externalfriendinvite/v1/createplatforminvite")] [HttpPost("/api/externalfriendinvite/v1/sendtextmessageinvite")] [HttpPost("/api/gamerewards/v1/select")] [HttpPost("/api/incentivizedreferrals/claim")] [HttpPost("/api/inventions/v1/delete")] [HttpDelete("/api/inventions/v1/delete")] [HttpPost("/api/inventions/v1/dormskinsfromids")] [HttpPost("/api/inventions/v1/unpublish")] [HttpPost("/api/inventions/v1/updateprice")] [HttpPost("/api/inventions/v3/publish")] [HttpPost("/api/inventions/v4/addversion")] [HttpPost("/api/objectives/v1/completegroup")] [HttpPost("/api/objectives/v1/updateobjective")] [HttpPost("/api/playstationplus/expire")] [HttpPost("/api/roomconsumables/v1/roomConsumable/awardBulk")] [HttpPost("/api/roomcurrencies/v1/awardCurrency/bulk")] [HttpPost("/api/roomcurrencies/v1/createCurrency")] [HttpPost("/api/roomcurrencies/v1/createPurchaseOffer")] [HttpPost("/api/roomcurrencies/v1/deletePurchaseOffer")] [HttpPost("/api/roomcurrencies/v1/updateCurrency")] [HttpPost("/api/roomcurrencies/v1/updatePurchaseOffer")] [HttpPost("/api/roomkeys/v1/awardbulk")] [HttpPost("/api/roomkeys/v1/create")] [HttpPost("/api/roomkeys/v1/revoke")] [HttpPost("/api/royale/v2/matchcomplete")] [HttpPost("/api/storefronts/v1/PurchaseRoomKeyWithCurrency")] [HttpPost("/api/storefronts/v1/buyForFreeGiftButton")] [HttpPost("/api/storefronts/v1/buyProgressionEventXpBoost")] [HttpPost("/api/storefronts/v1/buyPurchaseReminder")] [HttpPost("/api/storefronts/v1/buyRoomKey")] [HttpPost("/api/storefronts/v1/trialInvention")] [HttpPost("/api/storefronts/v2/buyElite")] [HttpPost("/api/storefronts/v2/buyTier")] [HttpPost("/api/roomconsumables/v1/roomconsumable/{skuId}/purchase/currency")] public IActionResult AckOnly() { if (AuthStuff.GetPlayerId(Request) == null) return Unauthorized(); return Ok(new { success = true }); } [HttpGet("/api/roomcurrencies/v1/getPurchaseOffersBatch")] [HttpPost("/api/roomcurrencies/v1/getPurchaseOffersBatch")] [HttpPost("/api/roomkeys/v1/owns/bulk")] public IActionResult AckOnlyEmptyList() { if (AuthStuff.GetPlayerId(Request) == null) return Unauthorized(); return Ok(ServerConfig.Bracket); } [HttpGet("/api/avatar/v2/gifts")] public IActionResult GetPendingGiftPackages() { long? playerId = AuthStuff.GetPlayerId(Request); if (!playerId.HasValue) return Unauthorized(); List pendingGifts = PlayerDB.GetPendingGiftPackages(playerId.Value); object[] gifts = pendingGifts .Select(gift => ToClientGiftPackage(gift, playerId.Value)) .ToArray(); string packageSummary = string.Join(",", pendingGifts.Select(gift => $"{gift.GiftPackageId}:from{NormalizeGiftSenderId(gift.FromPlayerId, playerId.Value)}:" + GiftKind(gift))); Console.WriteLine( $"[GIFTS] pending player={playerId.Value} count={gifts.Length} " + $"packages=[{packageSummary}]"); return Ok(gifts); } [HttpPost("/api/avatar/v2/gifts/consume")] [HttpPost("/api/avatar/v2/gifts/consume/")] [RequestSizeLimit(32 * 1024)] public async Task ConsumeGiftPackage() { long? playerId = AuthStuff.GetPlayerId(Request); if (!playerId.HasValue) return Unauthorized(); long? giftPackageId = await ReadGiftPackageIdAsync(); if (!giftPackageId.HasValue || giftPackageId.Value <= 0) { return BadRequest(new { Success = false, Error = "missing_gift_package_id" }); } GiftPackage? consumed = PlayerDB.ConsumeGiftPackage( playerId.Value, giftPackageId.Value); if (consumed == null) { return NotFound(new { Success = false, Error = "gift_package_not_found" }); } List remaining = PlayerDB.GetPendingGiftPackages(playerId.Value); await NotiController.NotifyGiftConsumedAsync( playerId.Value, consumed.GiftPackageId, remaining); Console.WriteLine( $"[GIFTS] consumed player={playerId.Value} " + $"gift={consumed.GiftPackageId} remaining={remaining.Count}"); return Ok(ToClientGiftPackage(consumed, playerId.Value)); } private static object ToClientGiftPackage( GiftPackage gift, long recipientPlayerId) { return new { AvatarItemDesc = EmptyIfWhiteSpace(gift.AvatarItemDesc), AvatarItemType = 0, BalanceType = gift.BalanceType ?? (int)BalanceType.NonPurchasedNotUsableInP2P, ConsumableItemDesc = EmptyIfWhiteSpace(gift.ConsumableItemDesc), Currency = gift.Currency, CurrencyType = gift.CurrencyType, EquipmentModificationGuid = EmptyIfWhiteSpace(gift.EquipmentModificationGuid), EquipmentPrefabName = EmptyIfWhiteSpace(gift.EquipmentPrefabName), FromGiftDropId = 0, FromPlayerId = NormalizeGiftSenderId( gift.FromPlayerId, recipientPlayerId), GiftContext = NormalizeGiftContext(gift.GiftContext), GiftRarity = gift.Rarity, Id = gift.GiftPackageId, Level = 0, Message = gift.Message ?? string.Empty, Platform = gift.Platform, PlatformsToSpawnOn = gift.PlatformMask, Xp = gift.XP }; } private static int NormalizeGiftSenderId( int senderId, long recipientPlayerId) { if (senderId > 0 && senderId == recipientPlayerId) return recipientPlayerId == 1 ? 2 : 1; return senderId; } private static int NormalizeGiftContext(int context) => context is (int)PlayerDBClasses.GiftContext.Default or (int)PlayerDBClasses.GiftContext.Store_RecCenter ? (int)PlayerDBClasses.GiftContext.Game_Drop : context; private static string GiftKind(GiftPackage gift) => gift.Currency != 0 ? "tokens" : gift.XP > 0 ? "xp" : !string.IsNullOrWhiteSpace(gift.ConsumableItemDesc) ? "consumable" : !string.IsNullOrWhiteSpace(gift.EquipmentModificationGuid) ? "equipment" : !string.IsNullOrWhiteSpace(gift.AvatarItemDesc) ? "avatar" : "empty"; private static string EmptyIfWhiteSpace(string? value) => string.IsNullOrWhiteSpace(value) ? string.Empty : value; private async Task ReadGiftPackageIdAsync() { string[] names = { "GiftPackageId", "giftPackageId", "PackageId", "packageId", "Id", "id" }; foreach (string name in names) { if (long.TryParse( Request.Query[name].FirstOrDefault(), out long queryValue)) { return queryValue; } } if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); foreach (string name in names) { if (long.TryParse( form[name].FirstOrDefault(), out long formValue)) { return formValue; } } return null; } if (!Request.Body.CanRead || ((Request.ContentLength ?? 0) <= 0 && Request.Headers.TransferEncoding.Count == 0)) { return null; } try { using JsonDocument document = await JsonDocument.ParseAsync(Request.Body); return FindJsonLong(document.RootElement, names); } catch (JsonException) { return null; } } [HttpGet("/api/consumables/v2/getUnlocked")] public IActionResult GetUnlockedConsumables() { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); Response.Headers.CacheControl = "no-store, no-cache, must-revalidate"; Response.Headers.Pragma = "no-cache"; Response.Headers.Expires = "0"; PlayerInventoryStore.EnsureConsumablesInitialized(accountId.Value); return GetNormalizedConsumables(accountId.Value); } [HttpGet("/cdn/config/LoadingScreenTipData")] public IActionResult GetLoadingScreenTipData() { return GetLoadingScreenData(); } [HttpGet("/api/playerevents/v1")] [HttpGet("/api/playerevents/v1/all")] public IActionResult GetAllPlayerEvents() { return Ok(new { Created = Array.Empty(), Responses = Array.Empty() }); } [HttpGet("/api/customAvatarItems/v1/isCreationAllowedForAccount")] public IActionResult GetCustomAvatarItemsIsCreationAllowedForAccount() { return Ok(new { success = true, value = (object?)null }); } [HttpGet("/api/influencerpartnerprogram/myinfluencer")] [HttpGet("/api/influencerpartnerprogram/v1/myinfluencer")] public IActionResult GetMySupportedInfluencer() { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); FullPlayer? account = PlayerDB.Players.FindById(accountId.Value); int supportedInfluencerId = account?.Player?.SupportedInfluencerId ?? 0; Console.WriteLine( $"[INFLUENCER MY] account={accountId.Value} " + $"supported={supportedInfluencerId}"); return Ok(supportedInfluencerId); } [HttpGet("/api/influencerpartnerprogram/influencer")] [HttpGet("/api/influencerpartnerprogram/v1/influencer")] public IActionResult GetSupportedInfluencer( [FromQuery] int accountId) { if (accountId <= 0) return Content("null", "application/json"); FullPlayer? account = PlayerDB.Players.FindById((long)accountId); int supportedInfluencerId = account?.Player?.SupportedInfluencerId ?? 0; Console.WriteLine( $"[INFLUENCER GET] account={accountId} " + $"supported={supportedInfluencerId}"); return Ok(supportedInfluencerId); } [HttpGet("/api/influencerpartnerprogram/isinfluencer")] [HttpGet("/api/influencerpartnerprogram/accountisinfluencer")] [HttpGet("/api/influencerpartnerprogram/v1/isinfluencer")] public IActionResult GetAccountIsInfluencer( [FromQuery] int accountId) { if (accountId <= 0) return Ok(false); FullPlayer? account = PlayerDB.Players.FindById((long)accountId); bool isInfluencer = account?.PlayerRoles?.Contains( PlayerDBClasses.PlayerRoles.Influencer) == true; Console.WriteLine( $"[INFLUENCER CHECK] account={accountId} " + $"isInfluencer={isInfluencer}"); return Ok(isInfluencer); } [HttpPost("/api/influencerpartnerprogram/support")] [HttpPost("/api/influencerpartnerprogram/support/{influencerAccountId:int}")] public async Task SupportInfluencer( [FromRoute] int? influencerAccountId = null) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); int? requestedInfluencerId = await ReadInfluencerAccountIdAsync(influencerAccountId); if (!requestedInfluencerId.HasValue) { return BadRequest(new { error = "missing_influencer_account_id" }); } if (requestedInfluencerId.Value == 0) return SetSupportedInfluencer(accountId.Value, 0); if (requestedInfluencerId.Value < 0) { return BadRequest(new { error = "invalid_influencer_account_id" }); } if (accountId.Value == requestedInfluencerId.Value) { return BadRequest(new { error = "cannot_support_self" }); } FullPlayer? influencer = PlayerDB.Players.FindById((long)requestedInfluencerId.Value); if (influencer?.Player == null || influencer.PlayerRoles?.Contains( PlayerDBClasses.PlayerRoles.Influencer) != true) { return NotFound(new { error = "influencer_not_found" }); } return SetSupportedInfluencer( accountId.Value, requestedInfluencerId.Value); } [HttpDelete("/api/influencerpartnerprogram/support")] [HttpDelete("/api/influencerpartnerprogram/support/{influencerAccountId:int}")] [HttpPost("/api/influencerpartnerprogram/unsupport")] [HttpPost("/api/influencerpartnerprogram/remove")] public IActionResult RemoveSupportedInfluencer( [FromRoute] int? influencerAccountId = null) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); return SetSupportedInfluencer(accountId.Value, 0); } private IActionResult SetSupportedInfluencer( long accountId, int supportedInfluencerId) { FullPlayer? account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "account_not_found" }); account.Player.SupportedInfluencerId = supportedInfluencerId; if (!PlayerDB.Players.Update(account)) { return StatusCode( StatusCodes.Status500InternalServerError, new { error = "support_update_failed" }); } Console.WriteLine( $"[INFLUENCER SUPPORT] supporter={accountId} " + $"influencer={supportedInfluencerId} persisted=true"); return Ok(new { Value = true, Success = true, ErrorId = (string?)null, Error = (string?)null, LocalizationContext = (object?)null, InfluencerAccountId = supportedInfluencerId, SupportedInfluencerId = supportedInfluencerId }); } private async Task ReadInfluencerAccountIdAsync( int? routeInfluencerAccountId) { if (routeInfluencerAccountId.HasValue) return routeInfluencerAccountId.Value; string[] names = { "influencerAccountId", "InfluencerAccountId", "accountId", "AccountId", "id", "Id" }; foreach (string name in names) { if (int.TryParse( Request.Query[name].FirstOrDefault(), out int queryValue)) { return queryValue; } } if (Request.HasFormContentType) { var form = await Request.ReadFormAsync( HttpContext.RequestAborted); foreach (string name in names) { if (int.TryParse( form[name].FirstOrDefault(), out int formValue)) { return formValue; } } return null; } if (!Request.Body.CanRead || ((Request.ContentLength ?? 0) <= 0 && Request.Headers.TransferEncoding.Count == 0)) { return null; } using var reader = new StreamReader( Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4_096, leaveOpen: true); string rawBody = await reader.ReadToEndAsync( HttpContext.RequestAborted); rawBody = rawBody.Trim(); if (rawBody.Length == 0) return null; if (int.TryParse(rawBody.Trim('"'), out int rawValue)) return rawValue; try { using JsonDocument document = JsonDocument.Parse(rawBody); JsonElement root = document.RootElement; if (root.ValueKind == JsonValueKind.Number && root.TryGetInt32(out int numberValue)) { return numberValue; } if (root.ValueKind == JsonValueKind.String && int.TryParse(root.GetString(), out int stringValue)) { return stringValue; } return FindJsonInt(root, names); } catch (JsonException) { return null; } } [HttpPost("/api/consumables/v1/consume")] public async Task ConsumeConsumable() { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); JsonElement? request = null; try { if (Request.Body.CanRead && ((Request.ContentLength ?? 0) > 0 || Request.Headers.TransferEncoding.Count > 0)) { using JsonDocument document = await JsonDocument.ParseAsync( Request.Body, cancellationToken: HttpContext.RequestAborted); request = document.RootElement.Clone(); } } catch (JsonException) { } long? consumableId = request.HasValue ? FindJsonLong(request.Value, "Id", "id") : null; long? requestedDelta = request.HasValue ? FindJsonLong( request.Value, "DeltaCount", "deltaCount") : null; int delta = requestedDelta is > 0 and <= int.MaxValue ? (int)requestedDelta.Value : 1; string disposition = "missing_id"; int? loggedPreviousQuantity = null; int? loggedRemainingQuantity = null; if (consumableId is > 0) { bool resolved = TryGetConsumableInfo( consumableId.Value, out string descriptor, out string friendlyName, out bool isHairDye, out string createdAt); if (!resolved) { resolved = TryGetOwnedConsumableInfo( accountId.Value, consumableId.Value, out descriptor, out friendlyName, out isHairDye, out createdAt); } if (resolved) { bool consumed = PlayerInventoryStore.TryConsumeConsumable( accountId.Value, descriptor, consumableId.Value, delta, out int previousQuantity, out int remainingQuantity); disposition = consumed ? "consumed" : "already_spent"; if (consumed) { loggedPreviousQuantity = previousQuantity; loggedRemainingQuantity = remainingQuantity; if (isHairDye) { PlayerDB.SetPlayerSetting( $"Avatar.HairDye.{descriptor}", "True", accountId.Value); Console.WriteLine( $"[HAIR DYE CONSUME] player={accountId.Value} " + $"id={consumableId.Value} name={friendlyName} " + $"desc={descriptor}"); } if (remainingQuantity <= 0) { await NotiController.NotifyConsumableRemovedAsync( accountId.Value, consumableId.Value, descriptor, createdAt, previousQuantity, remainingQuantity); } } } else { disposition = "unknown_id"; } } Console.WriteLine( $"[CONSUMABLE CONSUME] player={accountId.Value} " + $"id={consumableId?.ToString() ?? "none"} delta={delta} " + $"result={disposition} " + $"before={loggedPreviousQuantity?.ToString() ?? "n/a"} " + $"remaining={loggedRemainingQuantity?.ToString() ?? "n/a"}"); return Ok(new { error = string.Empty, success = true, value = (object?)null }); } private static bool TryGetConsumableInfo( long consumableId, out string descriptor, out string friendlyName, out bool isHairDye, out string createdAt) { descriptor = string.Empty; friendlyName = string.Empty; isHairDye = false; createdAt = "2023-04-06T00:00:00.0000000Z"; string path = Path.Combine( Program.dataDir, "APIS", "Items", "Consumables.json"); if (!System.IO.File.Exists(path)) return false; try { JsonNode? root = JsonNode.Parse(System.IO.File.ReadAllText(path)); if (root is not JsonArray items) return false; foreach (JsonNode? node in items) { if (node is not JsonObject item) { continue; } List ids = ReadConsumableIds(item); int mappingIndex = ids.IndexOf(consumableId); if (mappingIndex < 0) continue; friendlyName = ReadString(item, "FriendlyName") ?? string.Empty; descriptor = ReadString(item, "ConsumableItemDesc") ?? string.Empty; if (string.IsNullOrWhiteSpace(descriptor)) descriptor = $"legacy-consumable-{consumableId}"; isHairDye = IsHairDyeFriendlyName(friendlyName); List createdAts = ReadConsumableCreatedAts(item); createdAt = mappingIndex < createdAts.Count ? createdAts[mappingIndex] : NormalizeConsumableTimestamp( ReadString(item, "CreatedAt")) ?? createdAt; return true; } } catch (Exception ex) when (ex is JsonException or IOException) { Console.WriteLine( $"[CONSUMABLE LOOKUP] Could not read Consumables.json: {ex.Message}"); } return false; } private static bool TryGetOwnedConsumableInfo( long accountId, long consumableId, out string descriptor, out string friendlyName, out bool isHairDye, out string createdAt) { descriptor = string.Empty; friendlyName = string.Empty; isHairDye = false; createdAt = "2023-04-06T00:00:00.0000000Z"; try { PlayerInventoryStore.ConsumableOwnership? owned = PlayerInventoryStore.GetConsumables(accountId) .FirstOrDefault(item => item.ConsumableItemId == consumableId && item.Quantity > 0); if (owned == null) return false; descriptor = string.IsNullOrWhiteSpace(owned.ConsumableItemDesc) ? $"legacy-consumable-{consumableId}" : owned.ConsumableItemDesc.Trim(); friendlyName = string.IsNullOrWhiteSpace(owned.FriendlyName) ? descriptor : owned.FriendlyName.Trim(); isHairDye = IsHairDyeFriendlyName(friendlyName); Console.WriteLine( $"[CONSUMABLE LOOKUP] player={accountId} id={consumableId} " + "source=owned_inventory_fallback"); return true; } catch (Exception ex) when (ex is JsonException or IOException) { Console.WriteLine( $"[CONSUMABLE LOOKUP] Could not read player inventory: {ex.Message}"); return false; } } private static bool TryGetHairDyeDescriptor( long consumableId, out string descriptor, out string friendlyName) { descriptor = string.Empty; friendlyName = string.Empty; string path = Path.Combine( Program.dataDir, "APIS", "Items", "Consumables.json"); if (!System.IO.File.Exists(path)) return false; try { JsonNode? root = JsonNode.Parse(System.IO.File.ReadAllText(path)); if (root is not JsonArray items) return false; foreach (JsonNode? node in items) { if (node is not JsonObject item || !ReadConsumableIds(item).Contains(consumableId)) { continue; } friendlyName = ReadString(item, "FriendlyName") ?? string.Empty; if (!IsHairDyeFriendlyName(friendlyName)) return false; descriptor = ReadString(item, "ConsumableItemDesc") ?? string.Empty; return !string.IsNullOrWhiteSpace(descriptor); } } catch (JsonException ex) { Console.WriteLine( $"[HAIR DYE CONSUME] Could not parse Consumables.json: {ex.Message}"); } catch (IOException ex) { Console.WriteLine( $"[HAIR DYE CONSUME] Could not read Consumables.json: {ex.Message}"); } return false; } [HttpGet("/api/storefronts/v4/balance/{currencyType:int}")] public IActionResult GetStorefrontBalance(int currencyType) { var accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); if (!Enum.IsDefined(typeof(CurrencyType), currencyType)) return BadRequest(new { error = "invalid_currency" }); var parsedCurrency = (CurrencyType)currencyType; int balance = PlayerDB.GetCurrencyBalance( accountId.Value, parsedCurrency); int balanceType = (int)BalanceType.NonPurchasedDefault; return Ok(new[] { new { Balance = (long)balance, CurrencyType = currencyType, BalanceType = balanceType } }); } [HttpPost("/api/storefronts/v2/balance")] public async Task GetStorefrontBalancesV2() { var accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); HashSet requestedTypes = await ReadLongValuesAsync( "currencyType", "CurrencyType", "currencyTypes", "CurrencyTypes", "type", "Type"); int balanceType = (int)BalanceType.NonPurchasedDefault; IEnumerable typesToReturn = requestedTypes.Count > 0 ? requestedTypes .Where(value => Enum.IsDefined(typeof(CurrencyType), (int)value)) .Select(value => (CurrencyType)value) .Distinct() : PlayerDB.GetAllCurrencyBalances(accountId.Value) .Select(entry => entry.CurrencyType); var balances = typesToReturn .Select(currency => new { Balance = (long)PlayerDB.GetCurrencyBalance(accountId.Value, currency), CurrencyType = (int)currency, BalanceType = balanceType }) .ToArray(); return Ok(balances); } [HttpGet("/api/storefronts/v1/balanceAddType/{currencyType:int}/{balanceAddType:int}")] public IActionResult GetStorefrontBalanceByAddType( int currencyType, int balanceAddType) { var account = AuthStuff.GetCurrentPlayer(Request); if (account?.Player == null) return Unauthorized(); if (!Enum.IsDefined(typeof(CurrencyType), currencyType) || !Enum.IsDefined(typeof(BalanceType), balanceAddType)) { return BadRequest(new { error = "invalid_balance_type" }); } int totalBalance = PlayerDB.GetCurrencyBalance( account.PlayerId, (CurrencyType)currencyType); bool isPlayStation = account.PlatformIds?.Any(platform => platform.Platform == Platforms.PlayStation) == true; int applicableBalanceType = (int)(isPlayStation ? BalanceType.PlayStationNonPurchasedP2P : BalanceType.NonPlayStationNonPurchasedP2P); long balance = balanceAddType == applicableBalanceType ? totalBalance : 0L; return Ok(new { Balance = balance, CurrencyType = currencyType, BalanceType = balanceAddType, BalanceAddType = balanceAddType }); } [HttpGet("/subscription/subscriberCount/{accountId}")] public IActionResult GetSubscriberCount(long accountId) { return Ok(PlayerDB.GetSubscriberCount(accountId)); } [HttpGet("/subscription/details/{accountId:long}")] public IActionResult GetSubscriptionDetails(long accountId) { if (accountId <= 0 || accountId > int.MaxValue) { return BadRequest(new { error = "invalid_account_id" }); } int subscriberCount = PlayerDB.GetSubscriberCount(accountId); Console.WriteLine( $"[SUBSCRIPTION DETAILS] account={accountId} " + $"club=0 subscribers={subscriberCount}"); return Ok(new { accountId, clubId = 0, subscriberCount }); } [HttpGet("/subscription/details/{subscription}")] public IActionResult GetNamedSubscriptionDetails(string subscription) { Console.WriteLine( $"[SUBSCRIPTION DETAILS] name={subscription} simulated=empty"); return Content("{}", "application/json"); } [HttpPost("/subscription/{accountId:long}")] [HttpPut("/subscription/{accountId:long}")] [HttpPost("/subscription/{accountId:long}/subscribe")] [HttpPut("/subscription/{accountId:long}/subscribe")] public IActionResult SubscribeToPlayer(long accountId) { var subscriberId = AuthStuff.GetPlayerId(Request); if (subscriberId == null) return Unauthorized(); if (!PlayerDB.SetSubscription( (long)subscriberId, accountId, subscribe: true)) return BadRequest(new { success = false, error = "invalid_player" }); return Ok(new { AccountId = accountId, SubscriberId = subscriberId, IsSubscribed = true, Subscribed = true, SubscriberCount = PlayerDB.GetSubscriberCount(accountId) }); } [HttpDelete("/subscription/{accountId:long}")] [HttpDelete("/subscription/{accountId:long}/unsubscribe")] [HttpPost("/subscription/{accountId:long}/unsubscribe")] [HttpPut("/subscription/{accountId:long}/unsubscribe")] public IActionResult UnsubscribeFromPlayer(long accountId) { var subscriberId = AuthStuff.GetPlayerId(Request); if (subscriberId == null) return Unauthorized(); if (!PlayerDB.SetSubscription( (long)subscriberId, accountId, subscribe: false)) return BadRequest(new { success = false, error = "invalid_player" }); return Ok(new { AccountId = accountId, SubscriberId = subscriberId, IsSubscribed = false, Subscribed = false, SubscriberCount = PlayerDB.GetSubscriberCount(accountId) }); } [HttpGet("/subscription/{accountId:long}")] [HttpGet("/subscription/{accountId:long}/status")] [HttpGet("/subscription/subscribed/{accountId:long}")] public IActionResult GetPlayerSubscription(long accountId) { var subscriberId = AuthStuff.GetPlayerId(Request); if (subscriberId == null) return Unauthorized(); bool subscribed = PlayerDB.IsSubscribed( (long)subscriberId, accountId); return Ok(new { AccountId = accountId, SubscriberId = subscriberId, IsSubscribed = subscribed, Subscribed = subscribed, SubscriberCount = PlayerDB.GetSubscriberCount(accountId) }); } [HttpGet("/api/customAvatarItems/v2/fromCreator/{creatorId}")] public IActionResult GetCustomAvatarItemsFromCreator(long creatorId) { List items = LoadCustomAvatarItems(creatorId); return Ok(new { CreatorId = creatorId, Results = items, TotalResults = items.Count }); } [HttpGet("/api/customAvatarItems/v1/isCreationEnabled")] [HttpGet("/api/customAvatarItems/v1/isRenderingEnabled")] public IActionResult CustomAvatarItemsIsEnabled() { return Ok(true); } [HttpGet("/api/roomconsumables/v1/roomConsumable/room/{roomId}")] public IActionResult GetRoomConsumablesForRoom(long roomId) { Console.WriteLine( $"[ROOM CONSUMABLES] room={roomId} definitions=0 compatibility=april-2023"); return Ok(Array.Empty()); } [HttpGet("/api/roomconsumables/v1/roomConsumable/room/{roomId}/me")] public IActionResult GetRoomConsumablesForRoomMe(long roomId) { long? accountId = AuthStuff.GetPlayerId(Request); Console.WriteLine( $"[ROOM CONSUMABLES] room={roomId} account={accountId?.ToString() ?? "unknown"} " + "inventory=0 compatibility=april-2023"); return Ok(Array.Empty()); } [HttpPost("/api/sanitize/v1")] public IActionResult SanitizeV1([FromBody] SanitizeRequest request) { var id = AuthStuff.GetPlayerId(Request); if (id != null) { var player = PlayerDB.Players.FindById((long)id); var username = player?.Player?.Username; var severity = ProfanityFilter.GetSeverity(request.Value); switch (severity) { case ProfanitySeverity.SevereBypass: PlayerDB.BanPlayer((long)id, 2147483647, "Attempted to bypass filter with slur/severe language"); DiscordLogger.LogSanitize($"🔨 **PERMA BANNED** — `{username ?? "unknown"}` (ID: `{id}`) tried to bypass filter: {request.Value}"); break; case ProfanitySeverity.Severe: PlayerDB.BanPlayer((long)id, 1814400, "Slur/severe language"); DiscordLogger.LogSanitize($"🔨 **3 WEEK BAN** — `{username ?? "unknown"}` (ID: `{id}`) said: {request.Value}"); break; default: DiscordLogger.LogSanitizeChat((long)id, request.Value, username); break; } } var cleaned = ProfanityFilter.Blur(request.Value); return Ok(JsonSerializer.Serialize(cleaned)); } [HttpPost("/api/sanitize/v1/isPure")] public IActionResult SanitizeV1IsPure() { return Ok(new { IsPure = true }); } [HttpGet("/api/AppIntegrity/v1/token")] [HttpPost("/api/AppIntegrity/v1/verify")] public IActionResult AppIntegrityStub() { return Ok(new { valid = true }); } [HttpGet("/api/PlayersBanned/v1/check/{accountId}")] public IActionResult PlayersBannedCheck(long accountId) { bool isBanned = PlayerDB.IsPlayerBanned(accountId, out var details); return Ok(new { accountId, isBanned, banExpiresAt = isBanned ? DateTimeOffset.FromUnixTimeSeconds(details!.ModerationSetUnixTime + details.Duration).UtcDateTime : (DateTime?)null, reason = isBanned ? details!.Message : (string?)null }); } [HttpGet("/api/challenge/v1/current")] [HttpGet("/api/challenge/v1/all")] public IActionResult ChallengeStub() { return Ok(ServerConfig.Bracket); } [HttpGet("/api/communityboard/v1/get")] public IActionResult CommunityBoardStub() { return Ok(new { Results = Array.Empty(), TotalResults = 0 }); } [HttpGet("/api/communityboard/v2/current")] public IActionResult CommunityBoardCurrent() { string path = Path.Combine(Program.dataDir, "communityboard.json"); if (!System.IO.File.Exists(path)) { string defaultJson = @"{ ""Id"": 1, ""Title"": ""Community Board"", ""Posts"": [] }"; System.IO.File.WriteAllText(path, defaultJson); Console.WriteLine($"[CommunityBoard] No communityboard.json found, created default template at {path}"); } try { string rewritten = LoadingScreenImageService.RewriteConfiguration( System.IO.File.ReadAllText(path)); return Content(rewritten, "application/json"); } catch (Exception ex) when (ex is IOException or JsonException) { Console.WriteLine($"[CommunityBoard] Could not rewrite image URLs: {ex.Message}"); return Content(System.IO.File.ReadAllText(path), "application/json"); } } [HttpPost("/api/communityboard/v2/current")] [HttpPut("/api/communityboard/v2/current")] [RequestSizeLimit(256 * 1024)] public async Task SetCommunityBoardCurrent([FromBody] JsonElement request) { var player = AuthStuff.GetCurrentPlayer(Request); if (player?.Player == null) return Unauthorized(); if (player.PlayerRoles?.Any(role => role is PlayerDBClasses.PlayerRoles.Developer or PlayerDBClasses.PlayerRoles.Moderator) != true) return StatusCode(403); string path = Path.Combine(Program.dataDir, "communityboard.json"); string json = JsonSerializer.Serialize(request, new JsonSerializerOptions { WriteIndented = true }); System.IO.File.WriteAllText(path, json); await NotiController.BroadcastAnnouncementsUpdatedAsync(); try { string rewritten = LoadingScreenImageService.RewriteConfiguration(json); return Content(rewritten, "application/json"); } catch (JsonException ex) { Console.WriteLine($"[CommunityBoard] Saved board but could not rewrite image URLs: {ex.Message}"); return Ok(request); } } [HttpPost("/api/externalfriendinvite/v1/send")] public IActionResult ExternalFriendInviteStub() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); return Ok(new { success = true }); } [HttpPost("/api/gamesight/v1/event")] public IActionResult GamesightStub() { return Ok(); } [HttpGet("/api/incentivizedreferrals/v1/status")] public IActionResult IncentivizedReferralsStub() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); return Ok(new { referralCode = (string?)null, referralsCompleted = 0, rewardsAvailable = Array.Empty() }); } [HttpGet("/api/keepsakes/v1/mine")] [HttpGet("/api/keepsakes/mine")] public IActionResult KeepsakesStub() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); return Ok(new { KeepsakeInstances = Array.Empty(), CollectionRecords = Array.Empty(), CollectedKeepsakeIds = Array.Empty() }); } [HttpGet("/api/itemWishlists/v1/wishlist/me")] public IActionResult ItemWishlistMe() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); return Ok(CreateWishlistItems(id.Value)); } [HttpPut("/api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")] public IActionResult AddItemToWishlist(int purchasableItemId) { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); if (!PlayerDB.SetWishlistItem(id.Value, purchasableItemId, wished: true)) return BadRequest(new { success = false, error = "wishlist_limit_or_invalid_item" }); return Ok(new { WishlistItemId = DeterministicWishlistId(id.Value, purchasableItemId), AccountId = id.Value, PurchasableItemId = purchasableItemId, CreatedAt = DateTime.UtcNow }); } [HttpDelete("/api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")] public IActionResult RemoveItemFromWishlist(int purchasableItemId) { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); PlayerDB.SetWishlistItem(id.Value, purchasableItemId, wished: false); return Ok(new { success = true, AccountId = id.Value, PurchasableItemId = purchasableItemId }); } [HttpGet("/api/itemWishlists/v1/wishlist/{accountId:long}")] public IActionResult GetAccountWishlist(long accountId) { if (PlayerDB.Players.FindById(accountId) == null) return NotFound(); return Ok(CreateWishlistItems(accountId)); } private static object[] CreateWishlistItems(long accountId) => PlayerDB.GetWishlist(accountId) .Select(purchasableItemId => (object)new { WishlistItemId = DeterministicWishlistId(accountId, purchasableItemId), AccountId = accountId, PurchasableItemId = purchasableItemId, CreatedAt = DateTime.UnixEpoch }) .ToArray(); private static Guid DeterministicWishlistId(long accountId, int itemId) { byte[] bytes = System.Security.Cryptography.MD5.HashData( Encoding.UTF8.GetBytes($"wishlist:{accountId}:{itemId}")); return new Guid(bytes); } [HttpPost("/api/offlineinvite/v1/send")] [HttpPost("/api/offlineinvite/send")] [HttpPost("/api/offlineinvite/{targetPlayerId:long}")] [HttpPost("/api/offlineinvite/v1/{targetPlayerId:long}")] [HttpPost("/api/offlineinvite/send/{targetPlayerId:long}")] [HttpPost("/api/offlineinvite/{targetPlayerId:long}/send")] [HttpPost("/api/offlineinvite/v1/send/{targetPlayerId:long}")] [HttpPost("/api/offlineinvite/{targetPlayerId:long}/{requestedRoomInstanceId:long}")] [HttpPost("/api/offlineinvite/v1/{targetPlayerId:long}/{requestedRoomInstanceId:long}")] [HttpPost("/api/offlineinvite/v1/send/{targetPlayerId:long}/{requestedRoomInstanceId:long}")] [HttpPut("/api/offlineinvite/{targetPlayerId:long}")] [HttpPut("/api/offlineinvite/v1/{targetPlayerId:long}")] [RequestSizeLimit(64 * 1024)] public async Task SendOfflineInvite( long? targetPlayerId = null, long? requestedRoomInstanceId = null, long? roomInstanceId = null) { long? inviterPlayerId = AuthStuff.GetPlayerId(Request); if (!inviterPlayerId.HasValue) return Unauthorized(); HashSet targetPlayerIds = await ReadLongValuesAsync( "id", "playerId", "PlayerId", "accountId", "AccountId", "targetPlayerId", "TargetPlayerId", "targetAccountId", "TargetAccountId", "inviteePlayerId", "InviteePlayerId", "inviteeAccountId", "InviteeAccountId", "playerIds", "PlayerIds", "accountIds", "AccountIds"); if (targetPlayerId.HasValue && targetPlayerId.Value > 0) targetPlayerIds.Add(targetPlayerId.Value); targetPlayerIds.Remove(inviterPlayerId.Value); HashSet claimedInstanceIds = await ReadLongValuesAsync( "roomInstanceId", "RoomInstanceId", "requestedRoomInstanceId", "RequestedRoomInstanceId", "targetRoomInstanceId", "TargetRoomInstanceId"); if (requestedRoomInstanceId is > 0) claimedInstanceIds.Add(requestedRoomInstanceId.Value); if (roomInstanceId is > 0) claimedInstanceIds.Add(roomInstanceId.Value); targetPlayerIds.ExceptWith(claimedInstanceIds); Heartbeat? heartbeat = PlayerDB.GetPlayerHeartbeat(inviterPlayerId.Value); RoomInstance? room = heartbeat?.roomInstance; if (room == null) return BadRequest(new { success = false, error = "inviter_not_in_room" }); if (!Sessions.IsConfirmedParticipant( inviterPlayerId.Value, room.roomInstanceId)) { return StatusCode(409, new { success = false, error = "room_instance_membership_not_confirmed" }); } if (claimedInstanceIds.Any(value => value != room.roomInstanceId)) { return StatusCode(409, new { success = false, error = "room_invite_source_mismatch", requestedRoomInstanceIds = claimedInstanceIds, currentRoomInstanceId = room.roomInstanceId }); } if (targetPlayerIds.Count == 0) return BadRequest(new { success = false, error = "missing_target_player" }); var invited = new List(); var missing = new List(); var blocked = new List(); foreach (long receiverPlayerId in targetPlayerIds.Take(100)) { if (PlayerDB.Players.FindById(receiverPlayerId)?.Player == null) { missing.Add(receiverPlayerId); continue; } var relationship = RelationshipDB.GetClientRelationship( receiverPlayerId, inviterPlayerId.Value); if (relationship?.Ignored is 1 or 3) { blocked.Add(receiverPlayerId); continue; } await NotiController.NotifyRoomInviteAsync( inviterPlayerId.Value, receiverPlayerId, room.roomId, room.roomInstanceId, room.photonRoomId); invited.Add(receiverPlayerId); } bool success = invited.Count > 0; Console.WriteLine( $"[OFFLINE INVITE] inviter={inviterPlayerId.Value} " + $"targets={string.Join(',', targetPlayerIds)} " + $"invited={string.Join(',', invited)} " + $"missing={string.Join(',', missing)} " + $"blocked={blocked.Count} " + $"room={room.roomId} instance={room.roomInstanceId} " + $"requestedInstance={string.Join(',', claimedInstanceIds)} " + $"path={Request.Path} contentType={Request.ContentType ?? "null"}"); return Ok(new { Success = success, Result = success ? 0 : 1, Error = success ? string.Empty : "invite_failed", RoomId = room.roomId, RoomInstanceId = room.roomInstanceId, InvitedPlayerIds = invited, MissingPlayerIds = missing, BlockedPlayerIds = blocked, Results = invited.Select(playerId => new { PlayerId = playerId, Error = string.Empty }).ToArray() }); } [HttpPost("/api/platformlogin/v1/link")] public IActionResult PlatformLoginStub() { return Ok(new { success = true }); } [HttpGet("/api/playerwarnings/v1/mine")] public IActionResult PlayerWarningsStub() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); return Ok(Array.Empty()); } [HttpGet("/api/playstationplus/v1/membership")] public IActionResult PlaystationPlusMembership() { return Ok(new { isMember = false, expiresAt = (DateTime?)null }); } [HttpPost("/api/playstationplus/v1/expire")] public IActionResult PlaystationPlusExpire() { return Ok(); } [HttpGet("/api/progressionEvents/v1/all")] public IActionResult ProgressionEventsStub() { return Ok(Array.Empty()); } [HttpGet("/api/roomEarningsDistributions/v1/mine")] public IActionResult RoomEarningsDistributionsStub() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); var results = RoomDB.Rooms.Find(room => room.CreatorAccountId == id.Value && !room.IsDorm) .Select(CreateRoomEarningsDistribution) .ToList(); return Ok(new { Results = results, TotalResults = results.Count }); } [HttpGet("/api/roomEarningsDistributions/v1/earningsDistribution/{roomId:long}")] public IActionResult GetRoomEarningsDistribution(long roomId) { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); var room = RoomDB.GetRoom(roomId); return room == null ? NotFound() : Ok(CreateRoomEarningsDistribution(room)); } [HttpPost("/api/rooms/v1/verifyRole")] public async Task VerifyRoomRole() { var accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); if (!Request.HasFormContentType) return BadRequest(new { success = false, error = "Expected room role form data." }); var form = await Request.ReadFormAsync(HttpContext.RequestAborted); if (!long.TryParse(form["roomId"].FirstOrDefault(), out long roomId)) return BadRequest(new { success = false, error = "A valid room ID is required." }); var room = RoomDB.GetRoom(roomId); if (room == null) return NotFound(); int.TryParse(form["role"].FirstOrDefault(), out int claimedRole); var assignedRole = room.CreatorAccountId == accountId.Value ? RoomDBClasses.Role.Creator : room.Roles?.FirstOrDefault(role => role.AccountId == accountId.Value)?.Role ?? RoomDBClasses.Role.None; string? context = form["context"].FirstOrDefault(); if (claimedRole != (int)assignedRole) { Console.WriteLine( $"[ROOM ROLE VERIFY] room={roomId} account={accountId.Value} " + $"claimed={claimedRole} assigned={(int)assignedRole} context={context ?? "null"}"); } return Ok(); } private static RoomEarningsDistributionResponse CreateRoomEarningsDistribution( RoomDBClasses.Room room) { var mapping = new Dictionary(); if (room.CreatorAccountId is > 0 and <= int.MaxValue) mapping[(int)room.CreatorAccountId] = 100; return new RoomEarningsDistributionResponse { roomId = room.RoomId, earningsDistributionMapping = mapping, earningsDistributionMethod = 0 }; } private sealed class RoomEarningsDistributionResponse { public long roomId { get; set; } public Dictionary earningsDistributionMapping { get; set; } = new(); public int earningsDistributionMethod { get; set; } } [HttpGet("/api/royale/v1/status")] public IActionResult RoyaleStatusStub() { return Ok(ServerConfig.Bracket); } [HttpGet("/api/subscriptionseasons/v1/seasons/current")] public IActionResult SubscriptionSeasonsCurrent() { return Ok(new { seasonId = 0, name = "", startsAt = DateTime.UtcNow, endsAt = DateTime.UtcNow.AddDays(30) }); } [HttpGet("/api/ugcPurchasables/v1/mine")] public IActionResult UgcPurchasablesStub() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); return Ok(new { Results = Array.Empty(), TotalResults = 0 }); } [HttpGet("/cdn/{*filePath}")] public IActionResult GetCDNFile(string filePath) { if (string.IsNullOrWhiteSpace(filePath)) return NotFound(); string normalized = filePath.Replace('\\', '/').TrimStart('/'); if (normalized.Contains("..", StringComparison.Ordinal) || Path.IsPathRooted(normalized)) { return BadRequest(); } if (normalized.StartsWith("room/invention/", StringComparison.OrdinalIgnoreCase)) normalized = normalized[5..]; if (normalized.StartsWith("invention/room/", StringComparison.OrdinalIgnoreCase)) normalized = normalized[10..]; string cdnRoot = Path.GetFullPath(Path.Combine(Program.dataDir, "CDN")); var candidates = new List { Path.Combine(cdnRoot, normalized.Replace('/', Path.DirectorySeparatorChar)) }; string safeName = Path.GetFileName(normalized); if (!string.IsNullOrWhiteSpace(safeName)) { candidates.Add(Path.Combine(cdnRoot, "assetbundles", safeName)); candidates.Add(Path.Combine(cdnRoot, "assetbundle", safeName)); candidates.Add(Path.Combine(cdnRoot, "unity", safeName)); candidates.Add(Path.Combine(cdnRoot, "invention", safeName)); candidates.Add(Path.Combine(cdnRoot, "room", safeName)); if (safeName.EndsWith(".inv", StringComparison.OrdinalIgnoreCase)) { string extensionless = safeName[..^4]; if (!string.IsNullOrWhiteSpace(extensionless)) { candidates.Add(Path.Combine(cdnRoot, "invention", extensionless)); candidates.Add(Path.Combine(cdnRoot, "room", extensionless)); } } else { string baseName = Path.GetFileNameWithoutExtension(safeName); if (string.IsNullOrWhiteSpace(baseName)) baseName = safeName; string invName = baseName + ".inv"; candidates.Add(Path.Combine(cdnRoot, "invention", invName)); candidates.Add(Path.Combine(cdnRoot, "room", invName)); } } string? path = null; foreach (string candidate in candidates) { string resolved; try { resolved = Path.GetFullPath(candidate); } catch (Exception exception) when ( exception is ArgumentException or NotSupportedException or PathTooLongException) { continue; } if (!resolved.StartsWith(cdnRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || !System.IO.File.Exists(resolved)) { continue; } path = resolved; break; } if (path == null) return NotFound(); var contentType = Path.GetExtension(path).ToLowerInvariant() switch { ".png" => "image/png", ".jpg" or ".jpeg" => "image/jpeg", ".gif" => "image/gif", ".webp" => "image/webp", ".mp4" or ".m4v" => "video/mp4", ".mov" => "video/quicktime", ".webm" => "video/webm", ".wav" => "audio/wav", ".assetbundle" => "application/octet-stream", ".json" => "application/json", ".txt" => "text/plain", _ => "application/octet-stream" }; Response.Headers.CacheControl = "public,max-age=31536000,immutable"; return PhysicalFile( path, contentType, enableRangeProcessing: contentType.StartsWith( "video/", StringComparison.OrdinalIgnoreCase)); } [HttpGet("/purchase/v1/hasspentmoney")] public IActionResult HasSpentMoney() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); return Ok(new { hasSpentMoney = false }); } [HttpGet("/purchasecampaign/allcurrent/v2")] public IActionResult PurchaseCampaignAllCurrent() { return Ok(Array.Empty()); } [HttpGet("/api/apple/musicpromotion")] public IActionResult AppleMusicPromotionStub() { return Ok(new { eligible = false }); } [HttpGet("/preferences")] public IActionResult GetPreferences() { long? playerId = AuthStuff.GetPlayerId(Request); if (!playerId.HasValue) return Unauthorized(); return Ok(new { NotificationPreferences = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 } }); } [HttpGet("api/storefronts/v1/adcarouselitems")] public IActionResult GetAdCarouselItems() { List catalog = GetCatalogSkus() .Where(IsUsableStoreItem) .ToList(); List purchasableIds = catalog .Select(item => item.PurchasableItemId) .Where(id => id > 0) .Distinct() .ToList(); int avatarItems = catalog.Count(IsAvatarCatalogItem); int equipmentSkins = catalog.Count(IsEquipmentSkin); int consumables = catalog.Count(IsConsumableCatalogItem); Console.WriteLine( $"[ALL-I-GOT STORE] items={purchasableIds.Count} " + $"avatar={avatarItems} skins={equipmentSkins} consumables={consumables}"); var items = new[] { new StorefrontAdCarouselItem { AdCarouselItemId = 1, ImageName = "DefaultPFP.png", Title = "The All-I-Got Store", Description = $"Everything Mocha has: {avatarItems} avatar items, " + $"{equipmentSkins} skins, and {consumables} consumables. " + $"100 tokens each.", PurchasableItemIds = purchasableIds, PurchaseReminderId = null } }; return Ok(items); } [HttpGet("/api/rooms/v1/filters")] public IActionResult GetRoomFilters() { string[] pinnedTags = { "rro", "quest", "base" }; string[] popularTags = { "rro", "quest", "base" }; string[] tags = { "rro", "quest", "base", "pvp", "game", "hangout", "art", "music", "sports", "parkour", "story" }; Console.WriteLine( $"[ROOM FILTERS] format=object pinned={pinnedTags.Length} " + $"popular={popularTags.Length} tags={tags.Length}"); return Ok(new { PinnedTags = pinnedTags, PopularTags = popularTags, Tags = tags }); } [HttpGet("/api/relationships/v2/sendfriendrequest")] [HttpPost("/api/relationships/v2/sendfriendrequest")] public async Task SendFriendRequest() { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); long targetId = (await ReadLongValuesAsync( "id", "playerId", "PlayerId", "accountId", "AccountId", "targetPlayerId", "TargetPlayerId", "targetAccountId", "TargetAccountId")) .FirstOrDefault(); if (targetId <= 0) return BadRequest(new { success = false, error = "missing_target_player" }); ClientRelationshipDTO? before = RelationshipDB.GetClientRelationship(accountId.Value, targetId); PlayerRelationship? relationship = PlayerDB.SendFriendRequest( accountId.Value, targetId); if (relationship == null) return BadRequest(new { success = false, error = "invalid_player" }); ClientRelationshipDTO? clientRelationship = RelationshipDB.GetClientRelationship(accountId.Value, targetId); if (clientRelationship == null) { return StatusCode(500, new { success = false, error = "relationship_not_persisted" }); } await NotifyRelationshipMutationAsync( accountId.Value, targetId, before, clientRelationship); return Ok(clientRelationship); } [HttpGet("/api/relationships/v2/addfriend")] [HttpPost("/api/relationships/v2/addfriend")] [HttpGet("/api/relationships/v2/acceptfriendrequest")] [HttpPost("/api/relationships/v2/acceptfriendrequest")] public async Task AcceptFriendRequest() { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); long requesterId = (await ReadLongValuesAsync( "id", "playerId", "PlayerId", "accountId", "AccountId", "requesterPlayerId", "RequesterPlayerId", "requesterAccountId", "RequesterAccountId")) .FirstOrDefault(); if (requesterId <= 0) return BadRequest(new { success = false, error = "missing_requester_player" }); ClientRelationshipDTO? before = RelationshipDB.GetClientRelationship(accountId.Value, requesterId); PlayerRelationship? relationship = PlayerDB.AcceptFriendRequest( accountId.Value, requesterId); relationship ??= PlayerDB.SendFriendRequest(accountId.Value, requesterId); if (relationship == null) return BadRequest(new { success = false, error = "invalid_player" }); ClientRelationshipDTO? clientRelationship = RelationshipDB.GetClientRelationship(accountId.Value, requesterId); if (clientRelationship == null) { return StatusCode(500, new { success = false, error = "relationship_not_persisted" }); } await NotifyRelationshipMutationAsync( accountId.Value, requesterId, before, clientRelationship); return Ok(clientRelationship); } [HttpPost("/api/relationships/v3/{id:long}")] public async Task AddFriendV3(long id) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); ClientRelationshipDTO? before = RelationshipDB.GetClientRelationship(accountId.Value, id); PlayerRelationship? relationship = PlayerDB.SendFriendRequest( accountId.Value, id); if (relationship == null) return BadRequest(new { success = false, error = "invalid_player" }); ClientRelationshipDTO? clientRelationship = RelationshipDB.GetClientRelationship(accountId.Value, id); if (clientRelationship == null) { return StatusCode(500, new { success = false, error = "relationship_not_persisted" }); } await NotifyRelationshipMutationAsync( accountId.Value, id, before, clientRelationship); return Ok(clientRelationship); } [HttpDelete("/api/relationships/v3/{id:long}")] [HttpDelete("/api/relationships/v2/removefriend/{id:long}")] public async Task RemoveFriend(long id) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); bool removed = PlayerDB.RemoveFriend(accountId.Value, id); if (!removed) return NotFound(); await NotiController.NotifyFriendRemovedAsync( accountId.Value, id); return NoContent(); } [HttpGet("/api/relationships/v2/removefriend")] [HttpPost("/api/relationships/v2/removefriend")] public async Task RemoveFriendV2([FromQuery] long id) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); if (id <= 0 || id == accountId.Value) return BadRequest(new { success = false, error = "invalid_player" }); bool removed = PlayerDB.RemoveFriend(accountId.Value, id); if (removed) { await NotiController.NotifyFriendRemovedAsync( accountId.Value, id); } Console.WriteLine( $"[RELATIONSHIP REMOVE] source={accountId.Value} target={id} " + $"removed={removed} route=v2-query live={removed}"); return Ok(); } private static async Task NotifyRelationshipMutationAsync( long actorPlayerId, long otherPlayerId, ClientRelationshipDTO? before, ClientRelationshipDTO after) { bool unchanged = before != null && before.PlayerID == after.PlayerID && before.RelationshipType == after.RelationshipType && before.Favorited == after.Favorited && before.Muted == after.Muted && before.Ignored == after.Ignored; if (unchanged) return; RelationshipType finalType = (RelationshipType)after.RelationshipType; RelationshipType? previousType = before == null ? null : (RelationshipType)before.RelationshipType; if (finalType == RelationshipType.Friend && previousType != RelationshipType.Friend) { await NotiController.NotifyFriendAcceptedAsync( actorPlayerId, otherPlayerId); return; } if (finalType == RelationshipType.OutgoingFriendRequest && previousType != RelationshipType.OutgoingFriendRequest) { await NotiController.NotifyFriendRequestAsync( actorPlayerId, otherPlayerId); return; } await NotiController.NotifyRelationshipFlagsChangedAsync( actorPlayerId, otherPlayerId, "friendship-change"); } [HttpGet("/api/relationships/mutualfriends")] public IActionResult GetMutualFriends([FromQuery] long id) { var accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var mine = PlayerDB.GetRelationships((long)accountId) .Where(value => value.RelationshipType == RelationshipType.Friend) .Select(value => value.PlayerID) .ToHashSet(); var theirs = PlayerDB.GetRelationships(id) .Where(value => value.RelationshipType == RelationshipType.Friend) .Select(value => value.PlayerID); return Ok(theirs.Where(mine.Contains).ToArray()); } [HttpPost("/api/PlayerReporting/v3/create")] [RequestSizeLimit(64 * 1024)] public async Task CreatePlayerReportV3() { var reporter = AuthStuff.GetCurrentPlayer(Request); if (reporter?.Player == null) return Unauthorized(); JsonElement requestData; try { if (Request.HasFormContentType) { var form = await Request.ReadFormAsync( HttpContext.RequestAborted); var formData = form.ToDictionary( entry => entry.Key, entry => entry.Value.ToString(), StringComparer.OrdinalIgnoreCase); requestData = JsonSerializer.SerializeToElement(formData); } else { using var reader = new StreamReader( Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true); string rawBody = await reader.ReadToEndAsync( HttpContext.RequestAborted); if (string.IsNullOrWhiteSpace(rawBody)) { requestData = JsonSerializer.SerializeToElement( new Dictionary()); } else { try { using JsonDocument document = JsonDocument.Parse(rawBody); requestData = document.RootElement.Clone(); } catch (JsonException) { var parsedData = Microsoft.AspNetCore.WebUtilities.QueryHelpers .ParseQuery(rawBody) .ToDictionary( entry => entry.Key, entry => entry.Value.ToString(), StringComparer.OrdinalIgnoreCase); if (parsedData.Count == 0) parsedData["RawBody"] = rawBody; requestData = JsonSerializer.SerializeToElement(parsedData); } } } } catch (Exception exception) { Console.WriteLine( $"[PLAYER REPORT] Failed reading request: {exception}"); return BadRequest(new { success = false, error = "invalid_report_body" }); } long reporterId = reporter.PlayerId; string? reporterUsername = reporter.Player.Username; long? reportedPlayerId = FindJsonLong( requestData, "PlayerIdReported", "ReportedPlayerId", "ReportedAccountId", "TargetPlayerId", "TargetAccountId", "SuspectPlayerId"); var reportedPlayer = reportedPlayerId.HasValue ? PlayerDB.Players.FindById(reportedPlayerId.Value) : null; string? reportedUsername = reportedPlayer?.Player?.Username ?? FindJsonString( requestData, "UsernameReported", "ReportedPlayerUsername", "ReportedUsername", "TargetUsername"); int? reportCategory = FindJsonInt( requestData, "ReportCategory", "ReportCategoryId", "CategoryId", "ReportType"); string? details = FindJsonString( requestData, "Details", "Description", "Comment", "Message", "AdditionalDetails", "Notes"); long? roomId = FindJsonLong( requestData, "RoomId", "SourceRoomId"); string? roomInstanceType = FindJsonString( requestData, "RoomInstanceType", "InstanceType", "RoomType"); Guid reportId = Guid.NewGuid(); var storedReport = new ReportsDB.PlayerReport { Id = reportId, ReporterId = reporterId, ReporterUsername = reporterUsername, ReportedPlayerId = reportedPlayerId, ReportedUsername = reportedUsername, ReportCategory = reportCategory, Details = details, RoomId = roomId, RoomInstanceType = roomInstanceType }; ReportsDB.PlayerReports.Insert(storedReport); DiscordLogger.LogPlayerReport( reportId, reporterId, reporterUsername, reportedPlayerId, reportedUsername, reportCategory, details, roomId, roomInstanceType); Console.WriteLine( $"[PLAYER REPORT] Report ID: {reportId}"); Console.WriteLine( $"[PLAYER REPORT] Reporter: " + $"{reporterUsername ?? "unknown"} ({reporterId})"); Console.WriteLine( $"[PLAYER REPORT] Reported: " + $"{reportedUsername ?? "unknown"} " + $"({reportedPlayerId?.ToString() ?? "unknown"})"); Console.WriteLine( $"[PLAYER REPORT] Payload: {requestData.GetRawText()}"); bool reporterIsDeveloper = reporter.PlayerRoles?.Contains(PlayerRoles.Developer) == true; bool reporterIsModerator = reporter.PlayerRoles?.Contains(PlayerRoles.Moderator) == true; if ((reporterIsDeveloper || reporterIsModerator) && TryParseReportBanCommand( details, out int banDurationSeconds, out string banReason)) { if (!reportedPlayerId.HasValue || reportedPlayer?.Player == null) { Console.WriteLine( $"[REPORT BAN] reporter={reporterId} rejected=true " + "reason=reported_player_not_found"); } else if (reportedPlayerId.Value == reporterId) { Console.WriteLine( $"[REPORT BAN] reporter={reporterId} rejected=true " + "reason=self_ban"); } else { bool targetIsDeveloper = reportedPlayer.PlayerRoles?.Contains( PlayerRoles.Developer) == true; if (targetIsDeveloper && !reporterIsDeveloper) { Console.WriteLine( $"[REPORT BAN] reporter={reporterId} " + $"target={reportedPlayerId.Value} rejected=true " + "reason=moderator_cannot_ban_developer"); } else { PlayerDB.BanPlayer( reportedPlayerId.Value, banDurationSeconds, banReason, checked((ulong)reporterId)); storedReport.Status = banDurationSeconds > 0 && banDurationSeconds < int.MaxValue ? "TimedOut" : "Banned"; storedReport.ResolvedByAccountId = reporterId; storedReport.ResolvedByUsername = reporterUsername; storedReport.ResolvedAt = DateTime.UtcNow; storedReport.ResolutionNote = banReason; storedReport.ActionDurationSeconds = banDurationSeconds; ReportsDB.PlayerReports.Update(storedReport); Console.WriteLine( $"[REPORT BAN] reporter={reporterId} " + $"target={reportedPlayerId.Value} " + $"durationSeconds={banDurationSeconds} " + $"reason={banReason}"); } } } return Ok(new { success = true }); } [HttpPut("/reports/{reportId:guid}")] [RequestSizeLimit(16 * 1024 * 1024)] public async Task UploadCrashReport(Guid reportId) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) { return Unauthorized(new { success = false, reportId, error = "authentication_required" }); } string reportsDirectory = Path.Combine( Program.dataDir, "Debug", "CrashReports"); Directory.CreateDirectory(reportsDirectory); bool isJson = Request.ContentType?.Contains( "json", StringComparison.OrdinalIgnoreCase) == true; string extension = isJson ? ".json" : ".bin"; string reportPath = Path.Combine( reportsDirectory, reportId.ToString("D") + extension); try { await using var reportFile = new FileStream( reportPath, FileMode.Create, FileAccess.Write, FileShare.Read, bufferSize: 81920, useAsync: true); await Request.Body.CopyToAsync( reportFile, HttpContext.RequestAborted); } catch (OperationCanceledException) { return StatusCode(499, new { success = false, reportId, error = "crash_report_upload_cancelled" }); } catch (Exception exception) { Console.WriteLine( $"[CRASH REPORT] Failed report={reportId}: {exception}"); return StatusCode( StatusCodes.Status500InternalServerError, new { success = false, reportId, error = "crash_report_storage_failed" }); } long storedBytes = new FileInfo(reportPath).Length; Console.WriteLine( $"[CRASH REPORT] Stored report={reportId} " + $"account={accountId.Value} bytes={storedBytes} " + $"contentType={Request.ContentType ?? "unknown"}"); return Ok(new { success = true, id = reportId, reportId }); } [HttpPut("/reports/{reportId:guid}/attachments/{fileName}")] [RequestSizeLimit(16 * 1024 * 1024)] public async Task UploadCrashReportAttachment( Guid reportId, string fileName) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) { return Unauthorized(new { success = false, reportId, error = "authentication_required" }); } string safeFileName = Path.GetFileName(fileName); if (string.IsNullOrWhiteSpace(safeFileName) || safeFileName != fileName) { return BadRequest(new { success = false, reportId, error = "invalid_attachment_name" }); } string attachmentsDirectory = Path.Combine( Program.dataDir, "Debug", "CrashReports", reportId.ToString("D") + "_attachments"); Directory.CreateDirectory(attachmentsDirectory); string attachmentPath = Path.Combine( attachmentsDirectory, safeFileName); try { await using var attachmentFile = new FileStream( attachmentPath, FileMode.Create, FileAccess.Write, FileShare.Read, bufferSize: 81920, useAsync: true); await Request.Body.CopyToAsync( attachmentFile, HttpContext.RequestAborted); } catch (OperationCanceledException) { return StatusCode(499, new { success = false, reportId, error = "crash_report_attachment_upload_cancelled" }); } catch (Exception exception) { Console.WriteLine( $"[CRASH REPORT] Failed attachment report={reportId} " + $"file={safeFileName}: {exception}"); return StatusCode( StatusCodes.Status500InternalServerError, new { success = false, reportId, error = "crash_report_attachment_storage_failed" }); } long storedBytes = new FileInfo(attachmentPath).Length; Console.WriteLine( $"[CRASH REPORT] Stored attachment report={reportId} " + $"file={safeFileName} account={accountId.Value} " + $"bytes={storedBytes}"); return Ok(new { success = true, id = reportId, reportId, fileName = safeFileName }); } private static bool TryParseReportBanCommand( string? details, out int durationSeconds, out string reason) { durationSeconds = 0; reason = string.Empty; string command = details?.Trim() ?? string.Empty; int separatorIndex = command.IndexOf(':'); if (separatorIndex <= 1 || separatorIndex == command.Length - 1) return false; string duration = command[..separatorIndex]; reason = command[(separatorIndex + 1)..].Trim(); if (duration.Any(char.IsWhiteSpace) || reason.Length is < 1 or > 500) { return false; } char unit = duration[^1]; string quantityText = duration[..^1]; if (quantityText.Length == 0 || quantityText.Any(character => !char.IsAsciiDigit(character)) || !long.TryParse(quantityText, out long quantity) || quantity <= 0) { return false; } long multiplier = unit switch { 'm' => 60, 'h' => 60 * 60, 'd' => 24 * 60 * 60, 'w' => 7 * 24 * 60 * 60, _ => 0 }; if (multiplier == 0 || quantity > int.MaxValue / multiplier) { return false; } durationSeconds = checked((int)(quantity * multiplier)); return true; } [HttpGet("/api/messages/v1/favoriteFriendOnlineStatus")] public IActionResult GetFavoriteFriendOnlineStatus() { var accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var statuses = PlayerDB.GetRelationships((long)accountId) .Where(value => value.RelationshipType == RelationshipType.Friend && value.Favorited) .Select(value => new { PlayerId = value.PlayerID, IsOnline = PlayerDB.GetPlayerHeartbeat(value.PlayerID).isOnline }); return Ok(statuses); } [HttpPost("purchase/v1/cleanuppending")] public IActionResult CleanupPendingPurchases() { return Ok(new { cleaned = 0, status = "ok" }); } [HttpGet("api/playerevents/v1/tagfilters")] [HttpGet("api/playerevents/v1/filters")] public IActionResult GetPlayerEventTagFilters() { string[] filters = { "social", "competitive", "creative" }; return Ok(new { PinnedTags = Array.Empty(), PopularTags = filters, Tags = filters }); } [HttpGet("api/playerevents/v1/room/{roomId}")] public IActionResult GetPlayerEventsForRoom(long roomId) { return Ok(Array.Empty()); } [HttpGet("api/inventions/v1/room")] public IActionResult GetInventionsForRoom( [FromQuery] long id, [FromQuery] long roomId = 0, [FromQuery] int skip = 0, [FromQuery] int take = 100) { long resolvedRoomId = roomId > 0 ? roomId : id; object[] results = CreatorFeatureDB.SearchInventions( roomId: resolvedRoomId > 0 ? resolvedRoomId : null, skip: skip, take: take) .Select(CreatorFeatureDB.ToClientInvention) .ToArray(); return Ok(results); } private static readonly string[] ChatMemberIdKeys = { "MemberIds", "memberIds", "Members", "members", "ThreadMembers", "threadMembers", "WithMembers", "withMembers", "AccountIds", "accountIds", "PlayerAccountIds", "playerAccountIds", "PlayerIds", "playerIds", "UserIds", "userIds", "Ids", "ids", "RecipientIds", "recipientIds", "Recipients", "recipients", "ReceiverIds", "receiverIds", "TargetIds", "targetIds", "TargetPlayerIds", "targetPlayerIds", "OtherPlayerIds", "otherPlayerIds", "ToAccountIds", "toAccountIds", "ToPlayerIds", "toPlayerIds", "WithPlayerIds", "withPlayerIds", "AccountId", "accountId", "PlayerAccountId", "playerAccountId", "PlayerId", "playerId", "UserId", "userId", "RecipientId", "recipientId", "ReceiverId", "receiverId", "TargetId", "targetId", "OtherPlayerId", "otherPlayerId", "Id", "id", "a" }; private static readonly string[] ChatMessageKeys = { "MessageContents", "messageContents", "Contents", "contents", "Message", "message", "Body", "body", "Text", "text", "Content", "content" }; [HttpGet("thread")] public IActionResult GetThreads( [FromQuery] int maxCount = 50, [FromQuery] int mode = 0) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); object[] threads = ChatDB .GetThreadsForPlayer(accountId.Value, maxCount) .Select(thread => BuildChatThreadDto(thread, accountId.Value, messageCount: 50)) .ToArray(); return Ok(threads); } [HttpPost("thread/withmembers")] [RequestSizeLimit(64 * 1024)] public async Task GetOrCreateThreadWithMembers() { return await CreateOrOpenChatThreadAsync(wrapResult: false); } [HttpPost("thread")] [RequestSizeLimit(64 * 1024)] public async Task CreateThread() { ParsedChatThreadRequest parsed = await ReadChatThreadRequestAsync(); string? messageContents = await ReadChatMessageBodyAsync(); if (!string.IsNullOrWhiteSpace(messageContents)) { return await SendOrCreateChatMessageAsync( parsed, messageContents); } return await CreateOrOpenChatThreadAsync(parsed); } [HttpGet("thread/{threadId:long}")] public IActionResult GetThread( long threadId, [FromQuery] int messageCount = 50) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); ChatDB.ChatThread? thread = ChatDB.GetThread(threadId, accountId.Value); if (thread == null) return NotFound(); messageCount = Math.Clamp(messageCount, 1, 100); object dto = BuildChatThreadDto( thread, accountId.Value, messageCount); Console.WriteLine( $"[CHAT OPEN] thread={threadId} viewer={accountId.Value} " + $"messages={ChatDB.GetMessages(threadId, accountId.Value, messageCount).Count}"); return Ok(dto); } [HttpGet("thread/{threadId:long}/messages")] [HttpGet("thread/{threadId:long}/message")] public IActionResult GetThreadMessages( long threadId, [FromQuery] int maxCount = 50, [FromQuery] long? beforeMessageId = null) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); if (ChatDB.GetThread(threadId, accountId.Value) == null) return NotFound(); object[] messages = ChatDB .GetMessages(threadId, accountId.Value, maxCount, beforeMessageId) .Select(BuildChatMessageDto) .ToArray(); return Ok(messages); } [HttpPost("thread/{threadId:long}/message/{messageId:long}/read")] [HttpPut("thread/{threadId:long}/message/{messageId:long}/read")] public IActionResult MarkThreadMessageRead(long threadId, long messageId) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); if (!ChatDB.MarkMessageRead(threadId, messageId, accountId.Value)) return NotFound(); Console.WriteLine( $"[CHAT READ] thread={threadId} message={messageId} " + $"viewer={accountId.Value}"); return NoContent(); } [HttpPost("thread/{threadId:long}/message")] [HttpPost("thread/{threadId:long}/messages")] [HttpPost("thread/{threadId:long}")] [RequestSizeLimit(32 * 1024)] public async Task SendThreadMessage(long threadId) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); ChatDB.ChatThread? thread = ChatDB.GetThread(threadId, accountId.Value); if (thread == null) { return NotFound(new { chatMessage = (object?)null, chatResult = 2 }); } string? rawContents = await ReadChatMessageBodyAsync(); if (string.IsNullOrWhiteSpace(rawContents)) { return BadRequest(new { chatMessage = (object?)null, chatResult = 1 }); } string contents = NormalizeChatMessageContents(rawContents); ChatDB.ChatMessage? message = ChatDB.AddMessage( threadId, accountId.Value, contents); if (message == null) { return BadRequest(new { chatMessage = (object?)null, chatResult = 6 }); } object messageDto = BuildChatMessageDto(message); long[] recipients = thread.MemberIds .Where(id => id != accountId.Value) .Distinct() .ToArray(); await Task.WhenAll(recipients.Select(memberId => NotiController.NotifyChatMessageReceivedAsync( memberId, messageDto))); Console.WriteLine( $"[CHAT LIVE] message={message.MessageId} thread={threadId} " + $"sender={accountId.Value} recipients={string.Join(',', recipients)}"); return Ok(new { chatMessage = messageDto, chatResult = 0 }); } [HttpDelete("thread/{threadId:long}")] public IActionResult LeaveOrDeleteThread(long threadId) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); return ChatDB.LeaveThread(threadId, accountId.Value) ? NoContent() : NotFound(); } private async Task CreateOrOpenChatThreadAsync( ParsedChatThreadRequest? parsedRequest = null, bool wrapResult = true) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); ParsedChatThreadRequest parsed = parsedRequest ?? await ReadChatThreadRequestAsync(); HashSet selectedIds = ResolveChatMemberIds( parsed, accountId.Value); LogChatRequest(parsed, selectedIds); if (selectedIds.Count == 0) { return BadRequest(new { chatThread = (object?)null, chatResult = 1 }); } if (selectedIds.Count > 99) { return BadRequest(new { chatThread = (object?)null, chatResult = 8 }); } ChatDB.ChatThread? existingThread = ChatDB.FindThreadWithMembers( accountId.Value, selectedIds); bool createdNewThread = existingThread == null; ChatDB.ChatThread thread = existingThread ?? ChatDB.GetOrCreateThread( accountId.Value, selectedIds, parsed.Name, parsed.Type); object dto = BuildChatThreadDto( thread, accountId.Value, messageCount: 50); if (createdNewThread) { ChatDB.ChatMessage? starterMessage = ChatDB .GetMessages( thread.ThreadId, accountId.Value, maxCount: 1) .FirstOrDefault(); if (starterMessage != null) { object starterMessageDto = BuildChatMessageDto(starterMessage); long[] liveRecipients = thread.MemberIds .Where(id => id > 0) .Distinct() .ToArray(); await Task.WhenAll(liveRecipients.Select(memberId => NotiController.NotifyChatMessageReceivedAsync( memberId, starterMessageDto))); Console.WriteLine( $"[CHAT LIVE] created thread={thread.ThreadId} " + $"starter={starterMessage.MessageId} " + $"recipients={string.Join(',', liveRecipients)}"); } } Console.WriteLine( $"[CHAT] opened thread={thread.ThreadId} requester={accountId.Value} " + $"members={string.Join(',', thread.MemberIds)} new={createdNewThread}"); if (!wrapResult) { Console.WriteLine( $"[CHAT RESPONSE] route=/thread/withmembers format=direct " + $"thread={thread.ThreadId}"); return Ok(dto); } Console.WriteLine( $"[CHAT RESPONSE] route=/thread format=wrapped " + $"thread={thread.ThreadId}"); return Ok(new { chatThread = dto, chatResult = 0 }); } private async Task SendOrCreateChatMessageAsync( ParsedChatThreadRequest parsed, string rawMessageContents) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); HashSet selectedIds = ResolveChatMemberIds( parsed, accountId.Value); LogChatRequest(parsed, selectedIds); if (selectedIds.Count == 0) { return BadRequest(new { chatThread = (object?)null, chatResult = 1 }); } if (selectedIds.Count > 99) { return BadRequest(new { chatThread = (object?)null, chatResult = 8 }); } ChatDB.ChatThread thread = ChatDB.GetOrCreateThread( accountId.Value, selectedIds, parsed.Name, parsed.Type); string contents = NormalizeChatMessageContents(rawMessageContents); ChatDB.ChatMessage? message = ChatDB.AddMessage( thread.ThreadId, accountId.Value, contents); if (message == null) { return BadRequest(new { chatThread = (object?)null, chatResult = 6 }); } object messageDto = BuildChatMessageDto(message); long[] recipients = thread.MemberIds .Where(id => id != accountId.Value) .Distinct() .ToArray(); await Task.WhenAll(recipients.Select(memberId => NotiController.NotifyChatMessageReceivedAsync( memberId, messageDto))); object threadDto = BuildChatThreadDto( thread, accountId.Value, messageCount: 50); Console.WriteLine( $"[CHAT LIVE] message={message.MessageId} thread={thread.ThreadId} " + $"sender={accountId.Value} recipients={string.Join(',', recipients)}"); return Ok(new { chatResult = 0, chatThread = threadDto }); } private static HashSet ResolveChatMemberIds( ParsedChatThreadRequest parsed, long requestingAccountId) { parsed.MemberIds.Remove(requestingAccountId); parsed.CandidateIds.Remove(requestingAccountId); HashSet validExplicitIds = parsed.MemberIds .Where(ChatPlayerExists) .ToHashSet(); HashSet selectedIds = validExplicitIds.Count > 0 ? validExplicitIds : parsed.CandidateIds .Where(ChatPlayerExists) .ToHashSet(); selectedIds.Remove(requestingAccountId); return selectedIds; } private void LogChatRequest( ParsedChatThreadRequest parsed, IEnumerable selectedIds) { Console.WriteLine( $"[CHAT REQUEST] contentType={Request.ContentType ?? "none"} " + $"contentLength={(Request.ContentLength?.ToString() ?? "null")} " + $"explicit={string.Join(',', parsed.MemberIds)} " + $"candidates={string.Join(',', parsed.CandidateIds)} " + $"selected={string.Join(',', selectedIds)} " + $"body={parsed.RawBodyPreview}"); } private static bool ChatPlayerExists(long playerId) => playerId > 0 && PlayerDB.Players.FindById(playerId)?.Player != null; private object BuildChatThreadDto( ChatDB.ChatThread thread, long viewerAccountId, int messageCount) { messageCount = Math.Clamp(messageCount, 1, 100); object[] messages = ChatDB .GetMessages(thread.ThreadId, viewerAccountId, messageCount) .Select(BuildChatMessageDto) .ToArray(); int[] playerIds = thread.MemberIds .Where(id => id is > 0 and <= int.MaxValue) .Select(id => checked((int)id)) .ToArray(); return new { chatThreadId = thread.ThreadId, playerIds, lastReadMessageId = ChatDB.GetLastReadMessageId( thread.ThreadId, viewerAccountId), messages, chatThreadName = thread.Name, snoozedUntil = (DateTime?)null, isFavorited = false }; } private static object BuildChatMessageDto(ChatDB.ChatMessage message) { int senderPlayerId = message.SenderAccountId is >= int.MinValue and <= int.MaxValue ? checked((int)message.SenderAccountId) : 0; return new { chatMessageId = message.MessageId, chatThreadId = message.ThreadId, senderPlayerId, timeSent = message.CreatedAt, contents = message.Body, moderationState = 0 }; } private async Task ReadChatThreadRequestAsync() { var parsed = new ParsedChatThreadRequest(); foreach (KeyValuePair entry in Request.Query) { bool memberKey = IsChatMemberKey(entry.Key); foreach (string? value in entry.Value) { if (memberKey) AddChatIds(value, parsed.MemberIds); AddChatIds(value, parsed.CandidateIds); } } parsed.Name = Request.Query["Name"].FirstOrDefault() ?? Request.Query["name"].FirstOrDefault(); if (int.TryParse( Request.Query["Type"].FirstOrDefault() ?? Request.Query["type"].FirstOrDefault(), out int queryType)) { parsed.Type = queryType; } if (Request.HasFormContentType) { IFormCollection form = await Request.ReadFormAsync(); foreach (KeyValuePair entry in form) { bool memberKey = IsChatMemberKey(entry.Key); if (memberKey || LooksLikeChatIdData(entry.Key)) { AddChatIds( entry.Key, memberKey ? parsed.MemberIds : parsed.CandidateIds); } foreach (string? value in entry.Value) { if (memberKey) AddChatIds(value, parsed.MemberIds); AddChatIds(value, parsed.CandidateIds); } } parsed.Name ??= form["Name"].FirstOrDefault() ?? form["name"].FirstOrDefault(); if (int.TryParse( form["Type"].FirstOrDefault() ?? form["type"].FirstOrDefault(), out int formType)) { parsed.Type = formType; } return parsed; } Request.EnableBuffering(); if (Request.Body.CanSeek) Request.Body.Position = 0; string rawBody; using (var reader = new StreamReader( Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true)) { rawBody = await reader.ReadToEndAsync(); } if (Request.Body.CanSeek) Request.Body.Position = 0; parsed.RawBodyPreview = MakeChatBodyPreview(rawBody); if (string.IsNullOrWhiteSpace(rawBody)) return parsed; try { using JsonDocument document = JsonDocument.Parse(rawBody); ReadChatThreadJson( document.RootElement, parsed, memberContext: document.RootElement.ValueKind != JsonValueKind.Object); } catch (JsonException) { foreach (string pair in rawBody.Split( '&', StringSplitOptions.RemoveEmptyEntries)) { string[] parts = pair.Split('=', 2); string key = Uri.UnescapeDataString(parts[0].Replace('+', ' ')); string value = parts.Length > 1 ? Uri.UnescapeDataString(parts[1].Replace('+', ' ')) : string.Empty; bool memberKey = IsChatMemberKey(key); if (memberKey) AddChatIds(value, parsed.MemberIds); AddChatIds(value, parsed.CandidateIds); if (string.Equals(key, "name", StringComparison.OrdinalIgnoreCase)) parsed.Name = value; else if (string.Equals(key, "type", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out int bodyType)) parsed.Type = bodyType; } } AddChatIds(rawBody, parsed.CandidateIds); return parsed; } private static void ReadChatThreadJson( JsonElement element, ParsedChatThreadRequest parsed, bool memberContext) { switch (element.ValueKind) { case JsonValueKind.Number: if (element.TryGetInt64(out long number) && number > 0) { parsed.CandidateIds.Add(number); if (memberContext) parsed.MemberIds.Add(number); } return; case JsonValueKind.String: { string? value = element.GetString(); AddChatIds(value, parsed.CandidateIds); if (memberContext) AddChatIds(value, parsed.MemberIds); if (!string.IsNullOrWhiteSpace(value) && (value.TrimStart().StartsWith('{') || value.TrimStart().StartsWith('['))) { try { using JsonDocument nested = JsonDocument.Parse(value); ReadChatThreadJson(nested.RootElement, parsed, memberContext); } catch (JsonException) { } } return; } case JsonValueKind.Array: foreach (JsonElement item in element.EnumerateArray()) ReadChatThreadJson(item, parsed, memberContext: true); return; case JsonValueKind.Object: foreach (JsonProperty property in element.EnumerateObject()) { if (string.Equals(property.Name, "name", StringComparison.OrdinalIgnoreCase) && property.Value.ValueKind == JsonValueKind.String) { parsed.Name = property.Value.GetString(); continue; } if (string.Equals(property.Name, "type", StringComparison.OrdinalIgnoreCase) && property.Value.TryGetInt32(out int type)) { parsed.Type = type; continue; } ReadChatThreadJson( property.Value, parsed, memberContext || IsChatMemberKey(property.Name)); } return; } } private static bool IsChatMemberKey(string? key) { if (string.IsNullOrWhiteSpace(key)) return false; if (ChatMemberIdKeys.Contains(key, StringComparer.OrdinalIgnoreCase)) return true; string normalized = new string(key .Where(char.IsLetterOrDigit) .Select(char.ToLowerInvariant) .ToArray()); return normalized.Contains("memberid") || normalized.Contains("playerid") || normalized.Contains("accountid") || normalized.Contains("userid") || normalized.Contains("recipientid") || normalized.Contains("receiverid") || normalized.Contains("targetid") || normalized is "members" or "players" or "accounts" or "recipients" or "receivers" or "ids"; } private static bool LooksLikeChatIdData(string? value) { if (string.IsNullOrWhiteSpace(value)) return false; string trimmed = value.Trim(); return char.IsDigit(trimmed[0]) || trimmed[0] is '[' or '{' or '"'; } private static string MakeChatBodyPreview(string? body) { if (string.IsNullOrWhiteSpace(body)) return ""; string oneLine = body .Replace("\r", " ") .Replace("\n", " ") .Trim(); return oneLine.Length <= 500 ? oneLine : oneLine[..500] + "..."; } private async Task ReadChatMessageBodyAsync() { foreach (string key in ChatMessageKeys) { string? value = Request.Query[key].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(value)) return value; } if (Request.HasFormContentType) { IFormCollection form = await Request.ReadFormAsync(); foreach (string key in ChatMessageKeys) { string? value = form[key].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(value)) return value; } return null; } Request.EnableBuffering(); if (Request.Body.CanSeek) Request.Body.Position = 0; string rawBody; using (var reader = new StreamReader( Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true)) { rawBody = await reader.ReadToEndAsync(); } if (Request.Body.CanSeek) Request.Body.Position = 0; if (string.IsNullOrWhiteSpace(rawBody)) return null; try { using JsonDocument document = JsonDocument.Parse(rawBody); string? found = FindChatContents( document.RootElement, ChatMessageKeys, allowBareString: true); if (!string.IsNullOrWhiteSpace(found)) return found; if (document.RootElement.ValueKind == JsonValueKind.Object && (TryGetJsonPropertyIgnoreCase( document.RootElement, "Data", out _) || TryGetJsonPropertyIgnoreCase( document.RootElement, "Type", out _))) { return document.RootElement.GetRawText(); } } catch (JsonException) { foreach (string pair in rawBody.Split( '&', StringSplitOptions.RemoveEmptyEntries)) { string[] parts = pair.Split('=', 2); string key = Uri.UnescapeDataString( parts[0].Replace('+', ' ')); string value = parts.Length > 1 ? Uri.UnescapeDataString(parts[1].Replace('+', ' ')) : string.Empty; if (ChatMessageKeys.Contains( key, StringComparer.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(value)) { return value; } } return rawBody.Trim().Trim('"'); } return null; } private static string? FindChatContents( JsonElement element, IReadOnlyCollection keys, bool allowBareString) { if (element.ValueKind == JsonValueKind.String) { return allowBareString ? element.GetString() : null; } if (element.ValueKind == JsonValueKind.Object) { foreach (JsonProperty property in element.EnumerateObject()) { if (!keys.Contains( property.Name, StringComparer.OrdinalIgnoreCase)) { continue; } return property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : property.Value.GetRawText(); } foreach (JsonProperty property in element.EnumerateObject()) { string? nested = FindChatContents( property.Value, keys, allowBareString: false); if (!string.IsNullOrWhiteSpace(nested)) return nested; } } if (element.ValueKind == JsonValueKind.Array) { foreach (JsonElement item in element.EnumerateArray()) { string? nested = FindChatContents( item, keys, allowBareString: false); if (!string.IsNullOrWhiteSpace(nested)) return nested; } } return null; } private static bool TryGetJsonPropertyIgnoreCase( JsonElement element, string name, out JsonElement value) { if (element.ValueKind == JsonValueKind.Object) { foreach (JsonProperty property in element.EnumerateObject()) { if (string.Equals( property.Name, name, StringComparison.OrdinalIgnoreCase)) { value = property.Value; return true; } } } value = default; return false; } private static string NormalizeChatMessageContents(string rawContents) { string value = rawContents.Trim(); try { using JsonDocument document = JsonDocument.Parse(value); if (document.RootElement.ValueKind == JsonValueKind.String) { value = document.RootElement.GetString() ?? string.Empty; } else if (document.RootElement.ValueKind is JsonValueKind.Object or JsonValueKind.Array) { return document.RootElement.GetRawText(); } } catch (JsonException) { } return JsonSerializer.Serialize(new { Data = value, Type = 0, Version = 1, Blocks = new[] { new { Placeholder = value, Type = "Text" } } }); } private static void AddChatIds(string? value, ISet output) { if (string.IsNullOrWhiteSpace(value)) return; foreach (System.Text.RegularExpressions.Match match in System.Text.RegularExpressions.Regex.Matches( value, @"(? 0) output.Add(id); } } private sealed class ParsedChatThreadRequest { public HashSet MemberIds { get; } = new(); public HashSet CandidateIds { get; } = new(); public string? Name { get; set; } public int Type { get; set; } public string RawBodyPreview { get; set; } = ""; } [HttpGet("api/inventions/v1/featured")] public IActionResult GetFeaturedInventions( [FromQuery] int skip = 0, [FromQuery] int take = 100) { return Ok(CreatorFeatureDB.SearchInventions(skip: skip, take: take) .Select(CreatorFeatureDB.ToClientInvention) .ToArray()); } [HttpPost("/api/inventions/v6/save")] [HttpPost("/api/inventions/v5/save")] [HttpPost("/api/inventions/v4/save")] [HttpPost("/api/inventions/v3/save")] [HttpPost("/api/inventions/v2/save")] [HttpPost("/api/inventions/v1/save")] [HttpPut("/api/inventions/v6/save")] [HttpPut("/api/inventions/v5/save")] [HttpPut("/api/inventions/v4/save")] [HttpPut("/api/inventions/v3/save")] [HttpPut("/api/inventions/v2/save")] [HttpPut("/api/inventions/v1/save")] [HttpGet("/api/inventions/v3/update")] [HttpGet("/api/inventions/v2/update")] [HttpGet("/api/inventions/v1/update")] [HttpPost("/api/inventions/v3/update")] [HttpPost("/api/inventions/v2/update")] [HttpPost("/api/inventions/v1/update")] [HttpPut("/api/inventions/v3/update")] [HttpPut("/api/inventions/v2/update")] [HttpPut("/api/inventions/v1/update")] [HttpPost("/api/inventions/v6")] [HttpPut("/api/inventions/v6")] [HttpPost("/api/inventions/v5")] [HttpPut("/api/inventions/v5")] [HttpPost("/api/inventions/v4")] [HttpPut("/api/inventions/v4")] [HttpPost("/api/inventions/v3")] [HttpPut("/api/inventions/v3")] [HttpPost("/api/inventions/v2")] [HttpPut("/api/inventions/v2")] [HttpPost("/api/inventions/v1")] [HttpPut("/api/inventions/v1")] [RequestSizeLimit(10 * 1024 * 1024)] [RequestFormLimits(MultipartBodyLengthLimit = 10 * 1024 * 1024)] public async Task SaveInvention() { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); JsonElement request; var createdFiles = new List(); try { if (Request.HasFormContentType) { IFormCollection form = await Request.ReadFormAsync( HttpContext.RequestAborted); var payload = new JsonObject(); foreach (KeyValuePair field in form) { if (field.Value.Count > 0) payload[field.Key] = field.Value[field.Value.Count - 1]; } IFormFile? previewFile = form.Files.FirstOrDefault( IsInventionPreviewFile); IFormFile? dataFile = form.Files .Where(file => !ReferenceEquals(file, previewFile) && !IsInventionPreviewFile(file)) .OrderByDescending(IsInventionDataFile) .ThenByDescending(file => file.Length) .FirstOrDefault(); if (previewFile == null && dataFile == null && form.Files.Count > 0) { IFormFile first = form.Files[0]; if (IsInventionPreviewFile(first)) previewFile = first; else dataFile = first; } if (previewFile != null && previewFile.Length > 0) { string imageName = await SaveInventionPreviewAsync( previewFile, createdFiles, HttpContext.RequestAborted); payload["ImageName"] = imageName; payload["Image"] = imageName; payload["PreviewImageName"] = imageName; } if (dataFile != null && dataFile.Length > 0) { string dataBlob = await SaveInventionDataAsync( dataFile, createdFiles, HttpContext.RequestAborted); payload["DataBlob"] = dataBlob; payload["DataBlobName"] = Path.GetFileName(dataBlob); payload["Filename"] = Path.GetFileName(dataBlob); } request = JsonSerializer.SerializeToElement(payload); } else if ((Request.ContentLength ?? 0) > 0) { using JsonDocument document = await JsonDocument.ParseAsync( Request.Body, cancellationToken: HttpContext.RequestAborted); request = document.RootElement.Clone(); } else { using JsonDocument emptyDocument = JsonDocument.Parse("{}"); request = emptyDocument.RootElement.Clone(); } request = NormalizeInventionRequest(request, createdFiles); request = MergeInventionQuery(request, Request.Query); } catch (JsonException) { DeleteNewInventionFiles(createdFiles); return BadRequest(new { Success = false, Error = "invalid_invention_payload" }); } catch (InvalidDataException exception) { DeleteNewInventionFiles(createdFiles); return BadRequest(new { Success = false, Error = exception.Message }); } catch (IOException exception) { DeleteNewInventionFiles(createdFiles); Console.WriteLine($"[INVENTIONS] upload failed: {exception.Message}"); return StatusCode( StatusCodes.Status500InternalServerError, new { Success = false, Error = "invention_file_save_failed" }); } try { CreatorFeatureDB.InventionRecord record = CreatorFeatureDB.SaveInvention(accountId.Value, request); Console.WriteLine( $"[INVENTIONS] saved={record.InventionId} " + $"creator={accountId.Value} room={record.RoomId} " + $"version={record.Version}"); bool hasValidObjects = CreatorFeatureDB.HasValidInventionBlob(record); object clientValue = CreatorFeatureDB.ToClientInvention(record); if (!hasValidObjects) { Console.WriteLine( $"[INVENTIONS] missing-object-blob id={record.InventionId} " + $"creator={record.CreatorAccountId} dataBlob={record.DataBlob}"); } object? clientVersion = CreatorFeatureDB.ToClientInventionVersion(record); return Ok(new { Result = hasValidObjects ? 0 : 8, InventionResponse = clientValue, InventionVersionResponse = clientVersion, InventionId = record.InventionId, Id = record.InventionId, Version = record.Version, Success = hasValidObjects, CanSpawn = hasValidObjects, Error = hasValidObjects ? null : "invention_object_blob_missing", Value = clientValue, Invention = clientValue, CurrentVersion = clientVersion }); } catch (UnauthorizedAccessException exception) { DeleteNewInventionFiles(createdFiles); return StatusCode( StatusCodes.Status403Forbidden, new { Success = false, Error = exception.Message }); } catch (ArgumentException exception) { DeleteNewInventionFiles(createdFiles); return BadRequest(new { Success = false, Error = exception.Message }); } } private static JsonElement MergeInventionQuery( JsonElement request, IQueryCollection query) { if (query.Count == 0) return request; JsonObject payload = request.ValueKind == JsonValueKind.Object ? JsonNode.Parse(request.GetRawText())?.AsObject() ?? new JsonObject() : new JsonObject { ["Value"] = request.GetRawText() }; foreach (KeyValuePair field in query) { if (payload.ContainsKey(field.Key) && payload[field.Key] != null) continue; if (field.Value.Count == 1) payload[field.Key] = field.Value[0]; else if (field.Value.Count > 1) payload[field.Key] = new JsonArray( field.Value.Select(value => JsonValue.Create(value)).ToArray()); } MergeNestedInventionMetadata(payload); return JsonSerializer.SerializeToElement(payload); } private static JsonElement NormalizeInventionRequest( JsonElement request, ICollection createdFiles) { JsonObject payload = request.ValueKind == JsonValueKind.Object ? JsonNode.Parse(request.GetRawText())?.AsObject() ?? new JsonObject() : new JsonObject { ["Value"] = request.GetRawText() }; MergeNestedInventionMetadata(payload); PromoteUploadedInventionBlob(payload, createdFiles); return JsonSerializer.SerializeToElement(payload); } private static void MergeNestedInventionMetadata(JsonObject payload) { foreach (string key in new[] { "invention", "Invention", "metadata", "Metadata", "inventionData", "InventionData", "inventionMetadata", "InventionMetadata", "request", "Request", "payload", "Payload", "data", "Data", "value", "Value", "json", "Json" }) { if (!payload.TryGetPropertyValue(key, out JsonNode? node) || node == null) continue; JsonObject? nested = node as JsonObject; if (nested == null && node is JsonValue value && value.TryGetValue(out string? raw) && !string.IsNullOrWhiteSpace(raw)) { try { nested = JsonNode.Parse(raw) as JsonObject; } catch (JsonException) { } } if (nested == null) continue; foreach (KeyValuePair property in nested.ToArray()) { if (!payload.ContainsKey(property.Key) || payload[property.Key] == null) payload[property.Key] = property.Value?.DeepClone(); } } } private static void PromoteUploadedInventionBlob( JsonObject payload, ICollection createdFiles) { string? raw = ReadJsonNodeString( payload, "DataBlob", "dataBlob", "DataBlobName", "dataBlobName", "Filename", "filename", "FileName", "fileName", "ObjectDataFilename", "objectDataFilename", "ObjectDataBlob", "objectDataBlob", "BlobName", "blobName"); if (string.IsNullOrWhiteSpace(raw)) return; string normalized = raw.Replace('\\', '/').Trim(); if (normalized.StartsWith("/cdn/", StringComparison.OrdinalIgnoreCase)) normalized = normalized[5..]; normalized = normalized.TrimStart('/'); if (normalized.Contains("..", StringComparison.Ordinal)) throw new InvalidDataException("The invention blob path is invalid."); string safeName = Path.GetFileName(normalized); if (safeName.Length == 0 || safeName.Length > 180) throw new InvalidDataException("The invention blob name is invalid."); string baseName = Path.GetFileNameWithoutExtension(safeName); if (string.IsNullOrWhiteSpace(baseName)) baseName = safeName; string canonicalName = safeName.EndsWith( ".inv", StringComparison.OrdinalIgnoreCase) ? safeName : baseName + ".inv"; string inventionDirectory = Path.Combine(Program.dataDir, "CDN", "invention"); Directory.CreateDirectory(inventionDirectory); string inventionPath = Path.Combine(inventionDirectory, canonicalName); string existingInventionPath = Path.Combine(inventionDirectory, safeName); string uploadedRoomPath = Path.Combine(Program.dataDir, "CDN", "room", safeName); string uploadedCanonicalRoomPath = Path.Combine( Program.dataDir, "CDN", "room", canonicalName); string? sourcePath = new[] { existingInventionPath, uploadedRoomPath, uploadedCanonicalRoomPath } .FirstOrDefault(System.IO.File.Exists); if (!System.IO.File.Exists(inventionPath) && sourcePath != null) { System.IO.File.Copy(sourcePath, inventionPath, overwrite: false); createdFiles.Add(inventionPath); } string storedPath = "invention/" + canonicalName; payload["DataBlob"] = storedPath; payload["dataBlob"] = storedPath; payload["DataBlobName"] = canonicalName; payload["Filename"] = canonicalName; payload["ObjectDataFilename"] = canonicalName; payload["ObjectDataBlob"] = canonicalName; } private static string? ReadJsonNodeString( JsonObject payload, params string[] keys) { foreach (string key in keys) { if (!payload.TryGetPropertyValue(key, out JsonNode? node) || node == null) continue; if (node is JsonValue value && value.TryGetValue(out string? text)) return text; string raw = node.ToJsonString().Trim(); if (raw.Length > 1 && raw[0] == '"' && raw[^1] == '"') { try { return JsonSerializer.Deserialize(raw); } catch (JsonException) { } } return raw; } return null; } private static bool IsInventionDataFile(IFormFile file) { string fieldName = file.Name ?? string.Empty; if (fieldName.Contains("object", StringComparison.OrdinalIgnoreCase) || fieldName.Contains("blob", StringComparison.OrdinalIgnoreCase) || fieldName.Contains("data", StringComparison.OrdinalIgnoreCase) || fieldName.Equals("file", StringComparison.OrdinalIgnoreCase) || fieldName.Contains("invention", StringComparison.OrdinalIgnoreCase)) { return true; } string extension = Path.GetExtension(file.FileName); return !extension.Equals(".png", StringComparison.OrdinalIgnoreCase) && !extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) && !extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) && !extension.Equals(".webp", StringComparison.OrdinalIgnoreCase) && !extension.Equals(".gif", StringComparison.OrdinalIgnoreCase) && !extension.Equals(".bmp", StringComparison.OrdinalIgnoreCase); } private static bool IsInventionPreviewFile(IFormFile file) { string fieldName = file.Name ?? string.Empty; if (fieldName.Contains("preview", StringComparison.OrdinalIgnoreCase) || fieldName.Contains("thumbnail", StringComparison.OrdinalIgnoreCase) || fieldName.Contains("image", StringComparison.OrdinalIgnoreCase) || fieldName.Contains("photo", StringComparison.OrdinalIgnoreCase) || fieldName.Contains("icon", StringComparison.OrdinalIgnoreCase)) { return true; } if (file.ContentType?.StartsWith( "image/", StringComparison.OrdinalIgnoreCase) == true) { return true; } string extension = Path.GetExtension(file.FileName); return extension.Equals(".png", StringComparison.OrdinalIgnoreCase) || extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) || extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) || extension.Equals(".webp", StringComparison.OrdinalIgnoreCase) || extension.Equals(".gif", StringComparison.OrdinalIgnoreCase) || extension.Equals(".bmp", StringComparison.OrdinalIgnoreCase); } private static async Task SaveInventionPreviewAsync( IFormFile file, ICollection createdFiles, CancellationToken cancellationToken) { const long maxPreviewBytes = 8L * 1024L * 1024L; if (file.Length <= 0 || file.Length > maxPreviewBytes) throw new InvalidDataException("Invention previews must be 8 MB or smaller."); byte[] bytes; await using (var buffer = new MemoryStream()) { await file.CopyToAsync(buffer, cancellationToken); bytes = buffer.ToArray(); } SixLabors.ImageSharp.ImageInfo? info = Image.Identify(bytes); SixLabors.ImageSharp.Formats.IImageFormat? format = Image.DetectFormat(bytes); if (info == null || format == null || info.Width <= 0 || info.Height <= 0 || info.Width > 8192 || info.Height > 8192 || (long)info.Width * info.Height > 40_000_000) { throw new InvalidDataException("The invention preview is not a safe image."); } string extension = format.FileExtensions .Select(value => value.Trim().TrimStart('.').ToLowerInvariant()) .FirstOrDefault(value => value.Length is > 0 and <= 8 && value.All(char.IsLetterOrDigit)) ?? "png"; string filename = $"{Guid.NewGuid():N}.{extension}"; string relativePath = $"Inventions/{filename}"; string directory = Path.Combine(Program.dataDir, "Images", "Inventions"); Directory.CreateDirectory(directory); string path = Path.Combine(directory, filename); await System.IO.File.WriteAllBytesAsync(path, bytes, cancellationToken); createdFiles.Add(path); return relativePath; } private static async Task SaveInventionDataAsync( IFormFile file, ICollection createdFiles, CancellationToken cancellationToken) { const long maxDataBytes = 9L * 1024L * 1024L; if (file.Length <= 0 || file.Length > maxDataBytes) throw new InvalidDataException("Invention data must be 9 MB or smaller."); string filename = $"{Guid.NewGuid():N}.inv"; string directory = Path.Combine(Program.dataDir, "CDN", "invention"); Directory.CreateDirectory(directory); string path = Path.Combine(directory, filename); await using (var output = new FileStream( path, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81_920, FileOptions.Asynchronous | FileOptions.SequentialScan)) { await file.CopyToAsync(output, cancellationToken); } createdFiles.Add(path); return $"invention/{filename}"; } private static void DeleteNewInventionFiles(IEnumerable paths) { foreach (string path in paths) { try { if (System.IO.File.Exists(path)) System.IO.File.Delete(path); } catch (IOException) { } catch (UnauthorizedAccessException) { } } } [HttpGet("api/customAvatarItems/v1/hot")] public IActionResult GetHotCustomAvatarItems() { var items = new[] { new { itemId = 101, name = "Fire Hoodie", tags = new[] { "hot", "new" } }, new { itemId = 102, name = "Drip Sneakers", tags = new[] { "hot" } }, new { itemId = 103, name = "Sus Hat", tags = new[] { "trending" } } }; return Ok(items); } [HttpGet("/config/categories")] public IActionResult GetConfigCategories() { string path = Path.Combine(Program.dataDir, "APIS", "GameConfigs.json"); if (!System.IO.File.Exists(path)) return Ok(new { Categories = Array.Empty() }); string json = System.IO.File.ReadAllText(path); var entries = JsonSerializer.Deserialize>>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (entries == null) return Ok(new { Categories = Array.Empty() }); var categoryEntry = entries.FirstOrDefault(entry => entry.TryGetValue("Key", out var key) && key?.ToString() == "Growth.RoomCategoriesJson"); if (categoryEntry == null || !categoryEntry.TryGetValue("Value", out var value) || value == null) return Ok(new { Categories = Array.Empty() }); try { JsonElement categories = JsonSerializer.Deserialize( value.ToString() ?? "[]"); return Ok(new { Categories = categories }); } catch { return Ok(new { Categories = Array.Empty() }); } } [HttpGet("/api/PlayerCheer/v1/images")] public IActionResult GetPlayerCheersForImages([FromQuery] List imageIds) { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); return Ok(new Dictionary()); } [HttpPost("/api/PlayerCheer/v1/create")] [RequestSizeLimit(64 * 1024)] public async Task CreatePlayerCheer() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); var values = new Dictionary( StringComparer.OrdinalIgnoreCase); foreach (var queryValue in Request.Query) values[queryValue.Key] = queryValue.Value.ToString(); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); foreach (var formValue in form) values[formValue.Key] = formValue.Value.ToString(); } else { string body; using (var reader = new StreamReader(Request.Body)) body = await reader.ReadToEndAsync(); if (string.IsNullOrWhiteSpace(body)) body = string.Empty; try { if (!string.IsNullOrWhiteSpace(body)) { using var document = JsonDocument.Parse(body); AddJsonValues(document.RootElement, values); } } catch (JsonException) { foreach (var bodyValue in Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery( "?" + body)) { values[bodyValue.Key] = bodyValue.Value.ToString(); } } } foreach (string embeddedValue in values.Values.ToArray()) { if (!embeddedValue.TrimStart().StartsWith('{')) continue; try { using var embedded = JsonDocument.Parse(embeddedValue); AddJsonValues(embedded.RootElement, values); } catch (JsonException) { } } string? targetText = GetValue(values, "ReceiverAccountId", "ReceiverPlayerId", "ReceiverId", "CheerReceiverAccountId", "CheerReceiverPlayerId", "CheeredAccountId", "CheeredPlayerId", "PlayerToCheerId", "TargetAccountId", "TargetPlayerId", "ToAccountId", "ToPlayerId", "PlayerId", "PlayerID", "AccountId", "id"); string? categoryText = GetValue(values, "CheerCategory", "CheerCategoryId", "CheerType", "Category", "CategoryId", "cheer"); string? anonymousText = GetValue(values, "Anonymous", "anonymous", "IsAnonymous", "isAnonymous", "SendAnonymously", "sendAnonymously"); if (!long.TryParse(targetText, out long receiverId)) { var targetValue = values.FirstOrDefault(value => (value.Key.Contains("player", StringComparison.OrdinalIgnoreCase) || value.Key.Contains("account", StringComparison.OrdinalIgnoreCase) || value.Key.Contains("receiver", StringComparison.OrdinalIgnoreCase) || value.Key.Contains("target", StringComparison.OrdinalIgnoreCase)) && long.TryParse(value.Value, out long candidate) && candidate != (long)id); if (!long.TryParse(targetValue.Value, out receiverId)) return BadRequest(new { success = false, error = "invalid_player" }); } if (!TryParseCheerCategory(categoryText, out CheerCategory category)) category = CheerCategory.General; bool anonymous = ParseLooseBoolean(anonymousText); var cheer = PlayerDB.GivePlayerCheer( (long)id, receiverId, category, out NotificationDB.ClientNotification? notification, anonymous); if (cheer != null && notification != null) { await NotiController.NotifyCheerAsync( receiverId, notification, (int)category); } Console.WriteLine( $"[PLAYER CHEER] sender={id.Value} receiver={receiverId} " + $"category={category} anonymous={anonymous} " + $"created={cheer != null}"); return cheer == null ? BadRequest(new { success = false, message = "Unable to send cheer. Check the player and your available cheers.", error = "invalid_player_or_no_cheer_credit" }) : Ok(new { success = true, message = "Cheer sent.", cheerId = cheer.CheerId, notificationId = cheer.NotificationId, remainingCheerCredits = PlayerDB .GetReputationBulk(new List { id.Value }) .FirstOrDefault()?.CheerCredit ?? 0 }); } [HttpGet("/api/PlayerCheer/v1/pending")] public IActionResult GetPendingPlayerCheers() { long? playerId = AuthStuff.GetPlayerId(Request); if (!playerId.HasValue) return Unauthorized(); return Ok(PlayerDB.GetPendingPlayerCheers(playerId.Value) .Select(cheer => new { cheerId = cheer.CheerId, notificationId = cheer.NotificationId, fromPlayerId = cheer.Anonymous ? 0 : cheer.GiverPlayerId, receiverPlayerId = cheer.ReceiverPlayerId, cheerCategory = (int)cheer.CheerCategory, anonymous = cheer.Anonymous, status = (int)cheer.Status, createdAt = cheer.CreatedAt })); } [HttpPost("/api/PlayerCheer/v1/accept")] [RequestSizeLimit(16 * 1024)] public Task AcceptPlayerCheer() => ResolvePlayerCheerAsync(CheerStatus.Accepted); [HttpPost("/api/PlayerCheer/v1/dismiss")] [RequestSizeLimit(16 * 1024)] public Task DismissPlayerCheer() => ResolvePlayerCheerAsync(CheerStatus.Dismissed); private async Task ResolvePlayerCheerAsync( CheerStatus status) { long? playerId = AuthStuff.GetPlayerId(Request); if (!playerId.HasValue) return Unauthorized(); HashSet ids = await ReadLongValuesAsync( "CheerIds", "cheerIds", "CheerId", "cheerId", "NotificationIds", "notificationIds", "NotificationId", "notificationId", "MessageIds", "messageIds", "MessageId", "messageId", "Id", "id"); if (ids.Count == 0) { return BadRequest(new { success = false, error = "missing_cheer_id" }); } List matching = PlayerDB.GetPendingPlayerCheers(playerId.Value) .Where(cheer => ids.Contains(cheer.CheerId) || ids.Contains(cheer.NotificationId)) .ToList(); int resolved = PlayerDB.ResolvePlayerCheers( playerId.Value, ids, status); int deleted = NotificationDB.DeleteMessages( playerId.Value, matching.Select(cheer => cheer.NotificationId)); return Ok(new { success = resolved > 0, resolved, deleted, status = status.ToString() }); } [HttpPost("/api/PlayerCheer/v1/SetSelectedCheer")] [RequestSizeLimit(16 * 1024)] public async Task SetSelectedCheer() { var player = AuthStuff.GetCurrentPlayer(Request); if (player?.Player == null) return Unauthorized(); string? categoryText = Request.Query["CheerCategory"].FirstOrDefault(); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); categoryText ??= form["CheerCategory"].FirstOrDefault(); } if (!int.TryParse(categoryText, out int rawCategory) || !Enum.IsDefined(typeof(CheerCategory), rawCategory)) return BadRequest(); bool isDeveloper = player.PlayerRoles?.Contains( PlayerDBClasses.PlayerRoles.Developer) == true; if (!isDeveloper && rawCategory == (int)CheerCategory.RecRoomDeveloper) return StatusCode(403); player.Player.Reputation ??= new Reputation(); player.Player.Reputation.IsCheerful = true; player.Player.Reputation.SelectedCheer = (CheerCategory)rawCategory; PlayerDB.Players.Update(player); return NoContent(); } private static string? GetValue( Dictionary values, params string[] keys) { foreach (string key in keys) { if (values.TryGetValue(key, out string? value) && !string.IsNullOrWhiteSpace(value)) return value; } return null; } private static void AddJsonValues( JsonElement element, Dictionary values) { if (element.ValueKind == JsonValueKind.Object) { foreach (var property in element.EnumerateObject()) { if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array) { AddJsonValues(property.Value, values); } else { values[property.Name] = property.Value.ToString(); } } return; } if (element.ValueKind == JsonValueKind.Array) { foreach (var item in element.EnumerateArray()) AddJsonValues(item, values); } } private static bool TryParseCheerCategory( string? value, out CheerCategory category) { category = CheerCategory.General; if (string.IsNullOrWhiteSpace(value)) return false; if (int.TryParse(value, out int rawCategory) && Enum.IsDefined(typeof(CheerCategory), rawCategory)) { category = (CheerCategory)rawCategory; return category is CheerCategory.General or CheerCategory.Helpful or CheerCategory.Sportmanship or CheerCategory.GreatHost or CheerCategory.Creative; } string normalized = value.Replace(" ", string.Empty) .Replace("Sportsmanship", "Sportmanship", StringComparison.OrdinalIgnoreCase) .Replace("GoodSport", "Sportmanship", StringComparison.OrdinalIgnoreCase); return Enum.TryParse(normalized, true, out category) && category is CheerCategory.General or CheerCategory.Helpful or CheerCategory.Sportmanship or CheerCategory.GreatHost or CheerCategory.Creative; } private static bool ParseLooseBoolean(string? value) { if (bool.TryParse(value, out bool parsed)) return parsed; return string.Equals(value, "1", StringComparison.Ordinal) || string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase) || string.Equals(value, "on", StringComparison.OrdinalIgnoreCase); } [HttpGet("/api/Leaderboard/v1/room/{roomId}")] public IActionResult GetRoomLeaderboard(long roomId) { return Ok(new { RoomId = roomId, Results = Array.Empty(), TotalResults = 0 }); } [HttpGet("/api/catalog/v1/all")] public IActionResult GetCatalogAll( [FromQuery] bool onlyAvailableSkus = false) { return Ok(GetCatalogSkus()); } [HttpPost("/api/storefronts/v1/purchase")] [HttpPost("/api/storefronts/v1/purchase/{skuId:long}")] [HttpPost("/api/storefronts/v2/purchase")] [HttpPost("/api/storefronts/v2/purchase/{skuId:long}")] [HttpPost("/api/storefronts/v2/buyItem")] [HttpPost("/api/storefronts/v3/purchase")] [HttpPost("/api/storefronts/v3/purchase/{skuId:long}")] [HttpPost("/api/storefronts/v4/purchase")] [HttpPost("/api/storefronts/v4/purchase/{skuId:long}")] [HttpPost("/api/catalog/v1/purchase")] [HttpPost("/api/catalog/v1/purchase/{skuId:long}")] [HttpPost("/purchase/v1/purchase")] [RequestSizeLimit(128 * 1024)] public async Task PurchaseCatalogItem() { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); ParsedCatalogPurchaseRequest purchaseRequest = await ReadCatalogPurchaseRequestAsync(); purchaseRequest.Message = purchaseRequest.Message.Trim(); if (purchaseRequest.Message.Length > 200) { return BadRequest(new { Success = false, Error = "gift_message_too_long" }); } long? requestedSkuId = purchaseRequest.SkuId; Console.WriteLine( $"[STORE PURCHASE] buyer={accountId.Value} " + $"sku={requestedSkuId?.ToString() ?? "null"} " + $"recipient={purchaseRequest.RecipientPlayerId?.ToString() ?? "none"} " + $"giftContext={purchaseRequest.GiftContext}"); if (!requestedSkuId.HasValue) return BadRequest(new { Success = false, Error = "missing_sku" }); CatalogSku? sku = GetCatalogSkus().FirstOrDefault(value => value.SkuId == requestedSkuId.Value || value.PurchasableItemId == requestedSkuId.Value || (value.AvatarItemId > 0 && value.AvatarItemId == requestedSkuId.Value) || (IsConsumableCatalogItem(value) && value.ItemId == requestedSkuId.Value)); if (sku == null) return NotFound(new { Success = false, Error = "sku_not_found" }); long recipientId = purchaseRequest.RecipientPlayerId.GetValueOrDefault(); bool isGift = recipientId > 0 && recipientId != accountId.Value; if (isGift) { if (recipientId > int.MaxValue || PlayerDB.Players.FindById(recipientId)?.Player == null) { return NotFound(new { Success = false, Error = "gift_recipient_not_found" }); } bool gifted = TryPurchaseCatalogSkuAsGift( accountId.Value, recipientId, sku, purchaseRequest.Message, purchaseRequest.GiftContext, out int giftBalance, out bool recipientAlreadyOwned, out GiftPackage? giftPackage); if (!gifted) { return BadRequest(new { Success = false, Error = "insufficient_funds", Balance = giftBalance, Price = StoreItemPrice }); } if (giftPackage != null) { await NotiController.NotifyGiftAsync( accountId.Value, recipientId, giftPackage); } Console.WriteLine( $"[STORE GIFT] from={accountId.Value} to={recipientId} " + $"sku={sku.SkuId} item={sku.FriendlyName} " + $"package={giftPackage?.GiftPackageId ?? 0} " + $"alreadyOwned={recipientAlreadyOwned}"); return Ok(new { Success = true, Result = 0, PurchaseResult = 0, WasGift = true, RecipientPlayerId = recipientId, GiftPackageId = giftPackage?.GiftPackageId ?? 0, AlreadyOwned = recipientAlreadyOwned, Balance = giftBalance, CurrencyType = (int)PlayerDBClasses.CurrencyType.RecCenterTokens, BalanceType = (int)PlayerDBClasses.BalanceType.NonPurchasedDefault, NewBalance = giftBalance, Price = StoreItemPrice, SkuId = sku.SkuId, PurchasableItemId = sku.PurchasableItemId, ItemId = sku.ItemId, AvatarItemId = sku.AvatarItemId, ConsumableItemDesc = sku.ConsumableItemDesc, BalanceUpdates = PlayerDB.GetAllCurrencyBalances(accountId.Value) .Select(entry => new { Result = 0, PurchaseResult = 0, Balance = entry.CurrencyType == PlayerDBClasses.CurrencyType.RecCenterTokens ? giftBalance : entry.Balance, CurrencyType = (int)entry.CurrencyType, Data = entry.CurrencyType == PlayerDBClasses.CurrencyType.RecCenterTokens ? new object[] { new { SkuId = sku.SkuId, ItemId = sku.ItemId, AvatarItemId = sku.AvatarItemId, PurchasableItemId = sku.PurchasableItemId, ConsumableItemDesc = sku.ConsumableItemDesc, AvatarItemDesc = sku.AvatarItemDesc, FriendlyName = sku.FriendlyName, Quantity = 1, Amount = 1, CurrencyType = (int)PlayerDBClasses.CurrencyType.RecCenterTokens, Result = 0 } } : Array.Empty() }) .ToArray() }); } bool purchased = TryPurchaseCatalogSku( accountId.Value, sku, out int newBalance, out bool alreadyOwned); if (!purchased) { return BadRequest(new { Success = false, Error = "insufficient_funds", Balance = newBalance, Price = StoreItemPrice }); } return Ok(new { Success = true, Result = 0, PurchaseResult = 0, WasGift = false, AlreadyOwned = alreadyOwned, Balance = newBalance, CurrencyType = (int)PlayerDBClasses.CurrencyType.RecCenterTokens, BalanceType = (int)PlayerDBClasses.BalanceType.NonPurchasedDefault, NewBalance = newBalance, Price = StoreItemPrice, SkuId = sku.SkuId, PurchasableItemId = sku.PurchasableItemId, ItemId = sku.ItemId, AvatarItemId = sku.AvatarItemId, ConsumableItemDesc = sku.ConsumableItemDesc, BalanceUpdates = PlayerDB.GetAllCurrencyBalances(accountId.Value) .Select(entry => new { Result = 0, PurchaseResult = 0, Balance = entry.CurrencyType == PlayerDBClasses.CurrencyType.RecCenterTokens ? newBalance : entry.Balance, CurrencyType = (int)entry.CurrencyType, Data = entry.CurrencyType == PlayerDBClasses.CurrencyType.RecCenterTokens ? new object[] { new { SkuId = sku.SkuId, ItemId = sku.ItemId, AvatarItemId = sku.AvatarItemId, PurchasableItemId = sku.PurchasableItemId, ConsumableItemDesc = sku.ConsumableItemDesc, AvatarItemDesc = sku.AvatarItemDesc, FriendlyName = sku.FriendlyName, Quantity = 1, Amount = 1, CurrencyType = (int)PlayerDBClasses.CurrencyType.RecCenterTokens, Result = 0 } } : Array.Empty() }) .ToArray() }); } private sealed class ParsedCatalogPurchaseRequest { public long? SkuId { get; set; } public long? RecipientPlayerId { get; set; } public string Message { get; set; } = string.Empty; public int GiftContext { get; set; } } private async Task ReadCatalogPurchaseRequestAsync() { string[] skuNames = { "skuId", "sku", "id", "itemId", "avatarItemId", "purchasableItemId" }; string[] recipientNames = { "giftRecipientPlayerId", "giftRecipientAccountId", "recipientPlayerId", "recipientAccountId", "recipientId", "receiverPlayerId", "receiverAccountId", "toPlayerId", "toAccountId", "targetPlayerId", "targetAccountId", "giftToPlayerId", "giftToAccountId", "giftPlayerId" }; string[] messageNames = { "giftMessage", "message", "note" }; string[] contextNames = { "giftContext", "context" }; var result = new ParsedCatalogPurchaseRequest(); if (Request.RouteValues.TryGetValue("skuId", out object? routeValue) && long.TryParse(routeValue?.ToString(), out long routeSkuId)) { result.SkuId = routeSkuId; } result.SkuId ??= ReadLongFromValues(Request.Query, skuNames); result.RecipientPlayerId ??= ReadLongFromValues(Request.Query, recipientNames); result.Message = ReadStringFromValues(Request.Query, messageNames) ?? result.Message; result.GiftContext = (int)(ReadLongFromValues(Request.Query, contextNames) ?? result.GiftContext); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); result.SkuId ??= ReadLongFromValues(form, skuNames); result.RecipientPlayerId ??= ReadLongFromValues(form, recipientNames); result.Message = ReadStringFromValues(form, messageNames) ?? result.Message; result.GiftContext = (int)(ReadLongFromValues(form, contextNames) ?? result.GiftContext); } else if (Request.Body.CanRead && (Request.ContentLength.GetValueOrDefault() > 0 || Request.Headers.TransferEncoding.Count > 0)) { try { using JsonDocument document = await JsonDocument.ParseAsync(Request.Body); JsonElement root = document.RootElement; result.SkuId ??= FindLongByNames(root, skuNames); result.RecipientPlayerId ??= FindLongByNames(root, recipientNames); result.RecipientPlayerId ??= FindGiftRecipientFromNestedObject(root); result.Message = FindStringByNames(root, messageNames) ?? result.Message; long? giftContext = FindLongByNames(root, contextNames); if (giftContext.HasValue) result.GiftContext = checked((int)giftContext.Value); } catch (JsonException exception) { Console.WriteLine( $"[STORE PURCHASE] invalid request JSON: {exception.Message}"); } } return result; } private static long? ReadLongFromValues( IEnumerable> values, IEnumerable names) { var nameSet = names.ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var pair in values) { if (nameSet.Contains(pair.Key) && long.TryParse(pair.Value.FirstOrDefault(), out long value)) { return value; } } return null; } private static string? ReadStringFromValues( IEnumerable> values, IEnumerable names) { var nameSet = names.ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var pair in values) { if (!nameSet.Contains(pair.Key)) continue; string? value = pair.Value.FirstOrDefault(); if (!string.IsNullOrWhiteSpace(value)) return value; } return null; } private static long? FindLongByNames( JsonElement element, IEnumerable names) { var nameSet = names.ToHashSet(StringComparer.OrdinalIgnoreCase); return FindLongByNames(element, nameSet); } private static long? FindLongByNames( JsonElement element, HashSet names) { if (element.ValueKind == JsonValueKind.Object) { foreach (JsonProperty property in element.EnumerateObject()) { if (names.Contains(property.Name)) { if (property.Value.ValueKind == JsonValueKind.Number && property.Value.TryGetInt64(out long numeric)) { return numeric; } if (property.Value.ValueKind == JsonValueKind.String && long.TryParse(property.Value.GetString(), out long text)) { return text; } } long? nested = FindLongByNames(property.Value, names); if (nested.HasValue) return nested; } } else if (element.ValueKind == JsonValueKind.Array) { foreach (JsonElement child in element.EnumerateArray()) { long? nested = FindLongByNames(child, names); if (nested.HasValue) return nested; } } return null; } private static long? FindGiftRecipientFromNestedObject( JsonElement element, bool recipientContext = false) { if (element.ValueKind == JsonValueKind.Object) { foreach (JsonProperty property in element.EnumerateObject()) { bool nextRecipientContext = recipientContext || property.Name.Contains("recipient", StringComparison.OrdinalIgnoreCase) || property.Name.Contains("receiver", StringComparison.OrdinalIgnoreCase) || property.Name.Contains("giftTo", StringComparison.OrdinalIgnoreCase) || string.Equals(property.Name, "to", StringComparison.OrdinalIgnoreCase) || string.Equals(property.Name, "target", StringComparison.OrdinalIgnoreCase); if (recipientContext && (string.Equals(property.Name, "PlayerId", StringComparison.OrdinalIgnoreCase) || string.Equals(property.Name, "AccountId", StringComparison.OrdinalIgnoreCase))) { if (property.Value.ValueKind == JsonValueKind.Number && property.Value.TryGetInt64(out long numeric)) { return numeric; } if (property.Value.ValueKind == JsonValueKind.String && long.TryParse(property.Value.GetString(), out long text)) { return text; } } long? nested = FindGiftRecipientFromNestedObject( property.Value, nextRecipientContext); if (nested.HasValue) return nested; } } else if (element.ValueKind == JsonValueKind.Array) { foreach (JsonElement child in element.EnumerateArray()) { long? nested = FindGiftRecipientFromNestedObject( child, recipientContext); if (nested.HasValue) return nested; } } return null; } private static string? FindStringByNames( JsonElement element, IEnumerable names) { var nameSet = names.ToHashSet(StringComparer.OrdinalIgnoreCase); return FindStringByNames(element, nameSet); } private static string? FindStringByNames( JsonElement element, HashSet names) { if (element.ValueKind == JsonValueKind.Object) { foreach (JsonProperty property in element.EnumerateObject()) { if (names.Contains(property.Name) && property.Value.ValueKind == JsonValueKind.String) { return property.Value.GetString(); } string? nested = FindStringByNames(property.Value, names); if (!string.IsNullOrWhiteSpace(nested)) return nested; } } else if (element.ValueKind == JsonValueKind.Array) { foreach (JsonElement child in element.EnumerateArray()) { string? nested = FindStringByNames(child, names); if (!string.IsNullOrWhiteSpace(nested)) return nested; } } return null; } private bool TryPurchaseCatalogSkuAsGift( long senderAccountId, long recipientAccountId, CatalogSku sku, string? message, int giftContext, out int newBalance, out bool alreadyOwned, out GiftPackage? giftPackage) { newBalance = PlayerDB.GetCurrencyBalance( senderAccountId, PlayerDBClasses.CurrencyType.RecCenterTokens); giftPackage = null; alreadyOwned = IsAvatarCatalogItem(sku) ? PlayerDB.OwnsAvatarItem(recipientAccountId, sku.AvatarItemDesc) : IsEquipmentSkin(sku) ? OwnsEquipmentItem( recipientAccountId, sku.EquipmentPrefabName, sku.EquipmentModificationGuid) : false; if (alreadyOwned) return true; if (!PlayerDB.TrySpendCurrency( senderAccountId, PlayerDBClasses.CurrencyType.RecCenterTokens, StoreItemPrice, out newBalance)) { return false; } var pendingGift = new GiftPackage { FromPlayerId = checked((int)senderAccountId), Message = message ?? string.Empty, AvatarItemDesc = sku.AvatarItemDesc, ConsumableItemDesc = sku.ConsumableItemDesc, EquipmentPrefabName = sku.EquipmentPrefabName, EquipmentModificationGuid = sku.EquipmentModificationGuid, CurrencyType = (int)PlayerDBClasses.CurrencyType.RecCenterTokens, GiftContext = giftContext == 0 ? (int)PlayerDBClasses.GiftContext.Store_RecCenter : giftContext, Rarity = sku.Rarity, Platform = -1, PlatformMask = sku.PlatformMask, BalanceType = (int)BalanceType.NonPurchasedNotUsableInP2P, IsQuery = sku.GiftDrop.IsQuery, Unique = !IsConsumableCatalogItem(sku) }; giftPackage = PlayerDB.QueueGiftPackage( recipientAccountId, pendingGift); if (giftPackage != null) return true; int? refunded = PlayerDB.SetCurrencyBalance( senderAccountId, PlayerDBClasses.CurrencyType.RecCenterTokens, StoreItemPrice, add: true); if (refunded.HasValue) newBalance = refunded.Value; return false; } private static List GetCatalogSkus() { string avatarPath = Path.Combine(Program.dataDir, "APIS", "Items", "AvatarItems.json"); string equipmentPath = Path.Combine(Program.dataDir, "APIS", "Items", "Equipment.json"); string consumablesPath = Path.Combine(Program.dataDir, "APIS", "Items", "Consumables.json"); bool hasAvatarCatalog = System.IO.File.Exists(avatarPath); bool hasEquipmentCatalog = System.IO.File.Exists(equipmentPath); bool hasConsumableCatalog = System.IO.File.Exists(consumablesPath); if (!hasAvatarCatalog && !hasEquipmentCatalog && !hasConsumableCatalog) return new List(); DateTime avatarTimestamp = hasAvatarCatalog ? System.IO.File.GetLastWriteTimeUtc(avatarPath) : DateTime.MinValue; DateTime equipmentTimestamp = hasEquipmentCatalog ? System.IO.File.GetLastWriteTimeUtc(equipmentPath) : DateTime.MinValue; DateTime consumableTimestamp = hasConsumableCatalog ? System.IO.File.GetLastWriteTimeUtc(consumablesPath) : DateTime.MinValue; lock (CatalogLock) { if (CatalogCache.Count > 0 && avatarTimestamp == CatalogCacheTimestampUtc && equipmentTimestamp == EquipmentCatalogCacheTimestampUtc && consumableTimestamp == ConsumableCatalogCacheTimestampUtc) return CatalogCache; var catalog = new List(); var jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; if (hasAvatarCatalog) { var avatarItems = JsonSerializer.Deserialize>(System.IO.File.ReadAllText(avatarPath), jsonOptions) ?? new(); catalog.AddRange(avatarItems.Select(CreateAvatarCatalogSku)); } if (hasEquipmentCatalog) { var equipmentItems = JsonSerializer.Deserialize>(System.IO.File.ReadAllText(equipmentPath), jsonOptions) ?? new(); catalog.AddRange(equipmentItems .Where(item => !string.IsNullOrWhiteSpace(item.PrefabName) && !string.IsNullOrWhiteSpace(item.ModificationGuid)) .Select(CreateEquipmentCatalogSku)); } if (hasConsumableCatalog) { using JsonDocument document = JsonDocument.Parse(System.IO.File.ReadAllText(consumablesPath)); IEnumerable consumables = document.RootElement.ValueKind == JsonValueKind.Array ? document.RootElement.EnumerateArray().Select(item => item.Clone()) : Enumerable.Empty(); catalog.AddRange(consumables.Select(CreateConsumableCatalogSku)); } CatalogCache = catalog.Where(IsUsableStoreItem) .GroupBy(GetCatalogIdentity, StringComparer.OrdinalIgnoreCase) .Select(group => group.First()).ToList(); CatalogCacheTimestampUtc = avatarTimestamp; EquipmentCatalogCacheTimestampUtc = equipmentTimestamp; ConsumableCatalogCacheTimestampUtc = consumableTimestamp; return CatalogCache; } } private static CatalogSku CreateAvatarCatalogSku( CatalogSourceItem item, int index) { int purchasableItemId = 1_000_000 + index; string friendlyName = string.IsNullOrWhiteSpace(item.FriendlyName) ? $"Avatar Item {item.AvatarItemId}" : item.FriendlyName.Trim(); CatalogGiftDrop giftDrop = CreateCatalogGiftDrop(item, index); return new CatalogSku { Id = purchasableItemId, SkuId = purchasableItemId, PurchasableItemId = purchasableItemId, Type = 0, IsFeatured = true, ItemId = item.AvatarItemId, AvatarItemId = item.AvatarItemId, AvatarItemType = item.AvatarItemType, ItemType = item.AvatarItemType, AvatarItemDesc = item.AvatarItemDesc ?? string.Empty, FriendlyName = friendlyName, Tooltip = item.Tooltip ?? string.Empty, ThumbnailImage = item.ThumbnailImage ?? string.Empty, Rarity = item.Rarity, PlatformMask = item.PlatformMask, IsBaseAvatarItem = item.IsBaseAvatarItem, HasRealName = !string.IsNullOrWhiteSpace(item.FriendlyName), CreatedAt = item.CreatedAt, Prices = CreateCatalogPrices(), GiftDrop = giftDrop, GiftDrops = new List { giftDrop } }; } private static CatalogSku CreateEquipmentCatalogSku( PlayerDBClasses.EquipmentItem item, int index) { int purchasableItemId = 2_000_000 + index; string friendlyName = GetEquipmentFriendlyName(item, index); CatalogGiftDrop giftDrop = CreateCatalogGiftDrop(item, index, friendlyName); return new CatalogSku { Id = purchasableItemId, SkuId = purchasableItemId, PurchasableItemId = purchasableItemId, Type = 0, IsFeatured = true, ItemId = purchasableItemId, AvatarItemId = 0, AvatarItemType = 0, ItemType = 0, AvatarItemDesc = string.Empty, EquipmentPrefabName = item.PrefabName.Trim(), EquipmentModificationGuid = item.ModificationGuid.Trim(), FriendlyName = friendlyName, Tooltip = item.Tooltip ?? string.Empty, ThumbnailImage = item.ThumbnailImage ?? string.Empty, Rarity = item.Rarity, PlatformMask = item.PlatformMask, IsBaseAvatarItem = false, HasRealName = !string.IsNullOrWhiteSpace(item.FriendlyName), Prices = CreateCatalogPrices(), GiftDrop = giftDrop, GiftDrops = new List { giftDrop } }; } private static CatalogSku CreateConsumableCatalogSku(JsonElement item, int index) { int purchasableItemId = 3_000_000 + index; string desc = GetJsonString(item, "ConsumableItemDesc", "ConsumableDesc", "ItemDesc", "DescriptionId"); if (string.IsNullOrWhiteSpace(desc)) desc = GetJsonString(item, "FriendlyName", "Name"); string friendlyName = GetJsonString(item, "FriendlyName", "Name"); bool hasRealName = !string.IsNullOrWhiteSpace(friendlyName); if (string.IsNullOrWhiteSpace(friendlyName)) friendlyName = $"Consumable {index + 1}"; bool isHairDye = IsHairDyeFriendlyName(friendlyName); int rarity = GetJsonInt(item, "Rarity"); int itemType = GetJsonInt(item, "ConsumableItemType", "ItemType", "Type"); if (isHairDye) itemType = 1; var giftDrop = new CatalogGiftDrop { GiftDropId = purchasableItemId, FriendlyName = friendlyName, Tooltip = GetJsonString(item, "Tooltip", "Description"), ConsumableItemDesc = desc, ItemType = itemType, ConsumableItemType = isHairDye ? 6 : itemType, Rarity = rarity, IsQuery = false, Unique = false, Level = rarity, Context = 100010, ThumbnailImage = GetJsonString(item, "ThumbnailImage", "Thumbnail", "ImageName") }; return new CatalogSku { Id = purchasableItemId, SkuId = purchasableItemId, PurchasableItemId = purchasableItemId, Type = 0, IsFeatured = true, ItemId = GetJsonLong( item, "ConsumableItemId", "ItemId", "Id", defaultValue: purchasableItemId), ItemType = itemType, ConsumableItemType = isHairDye ? 6 : itemType, ConsumableItemDesc = desc, FriendlyName = friendlyName, Tooltip = GetJsonString(item, "Tooltip", "Description"), ThumbnailImage = giftDrop.ThumbnailImage, Rarity = rarity, PlatformMask = GetJsonInt(item, "PlatformMask", defaultValue: -1), HasRealName = hasRealName, IsOwned = isHairDye, Owned = isHairDye, Purchased = isHairDye, Prices = CreateCatalogPrices(), GiftDrop = giftDrop, GiftDrops = new List { giftDrop } }; } private static string GetJsonString(JsonElement item, params string[] names) { foreach (JsonProperty property in item.EnumerateObject()) if (names.Any(name => string.Equals(name, property.Name, StringComparison.OrdinalIgnoreCase))) return property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() ?? string.Empty : property.Value.ToString(); return string.Empty; } private static int GetJsonInt(JsonElement item, string name, int defaultValue = 0) => GetJsonInt(item, new[] { name }, defaultValue); private static int GetJsonInt(JsonElement item, string name1, string name2, string name3) => GetJsonInt(item, new[] { name1, name2, name3 }, 0); private static int GetJsonInt(JsonElement item, string[] names, int defaultValue) { foreach (JsonProperty property in item.EnumerateObject()) if (names.Any(name => string.Equals(name, property.Name, StringComparison.OrdinalIgnoreCase))) { if (property.Value.TryGetInt32(out int value)) return value; if (int.TryParse(property.Value.ToString(), out value)) return value; } return defaultValue; } private static long GetJsonLong(JsonElement item, string name1, string name2, string name3, long defaultValue) { foreach (JsonProperty property in item.EnumerateObject()) if (property.Name.Equals(name1, StringComparison.OrdinalIgnoreCase) || property.Name.Equals(name2, StringComparison.OrdinalIgnoreCase) || property.Name.Equals(name3, StringComparison.OrdinalIgnoreCase)) { if (property.Value.TryGetInt64(out long value)) return value; if (long.TryParse(property.Value.ToString(), out value)) return value; } return defaultValue; } private static List CreateCatalogPrices() => new() { new CatalogPrice { CurrencyType = (int)PlayerDBClasses.CurrencyType.RecCenterTokens, Price = StoreItemPrice } }; private static string GetEquipmentFriendlyName( PlayerDBClasses.EquipmentItem item, int index) { if (!string.IsNullOrWhiteSpace(item.FriendlyName)) return item.FriendlyName.Trim(); string prefabName = item.PrefabName?.Trim().Trim('[', ']') ?? string.Empty; if (string.IsNullOrWhiteSpace(prefabName)) return $"Equipment Skin {index + 1}"; return prefabName.EndsWith("Skin", StringComparison.OrdinalIgnoreCase) ? prefabName : $"{prefabName} Skin"; } private async Task ReadPurchaseSkuIdAsync() { string[] names = { "skuId", "sku", "id", "itemId", "avatarItemId", "purchasableItemId" }; if (Request.RouteValues.TryGetValue("skuId", out object? routeValue) && long.TryParse(routeValue?.ToString(), out long routeSkuId)) return routeSkuId; foreach (string name in names) { if (long.TryParse(Request.Query[name].FirstOrDefault(), out long queryValue)) return queryValue; } if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); foreach (string name in names) { if (long.TryParse(form[name].FirstOrDefault(), out long formValue)) return formValue; } } else if (Request.Body.CanRead && (Request.ContentLength.GetValueOrDefault() > 0 || Request.Headers.TransferEncoding.Count > 0)) { try { using JsonDocument document = await JsonDocument.ParseAsync(Request.Body); return FindSkuId(document.RootElement, names); } catch (JsonException) { return null; } } return null; } private static long? FindSkuId(JsonElement element, string[] names) { if (element.ValueKind == JsonValueKind.Object) { foreach (JsonProperty property in element.EnumerateObject()) { if (names.Any(name => string.Equals( name, property.Name, StringComparison.OrdinalIgnoreCase))) { if (property.Value.ValueKind == JsonValueKind.Number && property.Value.TryGetInt64(out long number)) return number; if (property.Value.ValueKind == JsonValueKind.String && long.TryParse(property.Value.GetString(), out long textNumber)) return textNumber; } long? nested = FindSkuId(property.Value, names); if (nested.HasValue) return nested; } } else if (element.ValueKind == JsonValueKind.Array) { foreach (JsonElement child in element.EnumerateArray()) { long? nested = FindSkuId(child, names); if (nested.HasValue) return nested; } } return null; } private sealed class CatalogSourceItem { public string? AvatarItemDesc { get; set; } public int AvatarItemType { get; set; } public int PlatformMask { get; set; } = -1; public string? FriendlyName { get; set; } public string? Tooltip { get; set; } public int Rarity { get; set; } public long AvatarItemId { get; set; } public bool IsBaseAvatarItem { get; set; } public DateTime CreatedAt { get; set; } public string? ThumbnailImage { get; set; } } private static CatalogGiftDrop CreateCatalogGiftDrop( CatalogSourceItem item, int index) { return new CatalogGiftDrop { GiftDropId = 1_000_000 + index, FriendlyName = string.IsNullOrWhiteSpace(item.FriendlyName) ? $"Avatar Item {item.AvatarItemId}" : item.FriendlyName.Trim(), Tooltip = item.Tooltip ?? string.Empty, AvatarItemDesc = item.AvatarItemDesc ?? string.Empty, ConsumableItemDesc = string.Empty, EquipmentPrefabName = string.Empty, EquipmentModificationGuid = string.Empty, Rarity = item.Rarity, IsQuery = false, Unique = true, Level = item.Rarity, Context = 100010, ThumbnailImage = item.ThumbnailImage ?? string.Empty }; } private static CatalogGiftDrop CreateCatalogGiftDrop( PlayerDBClasses.EquipmentItem item, int index, string friendlyName) { return new CatalogGiftDrop { GiftDropId = 2_000_000 + index, FriendlyName = friendlyName, Tooltip = item.Tooltip ?? string.Empty, AvatarItemDesc = string.Empty, ConsumableItemDesc = string.Empty, EquipmentPrefabName = item.PrefabName?.Trim() ?? string.Empty, EquipmentModificationGuid = item.ModificationGuid?.Trim() ?? string.Empty, Rarity = item.Rarity, IsQuery = false, Unique = true, Level = item.Rarity, Context = 100010, ThumbnailImage = item.ThumbnailImage ?? string.Empty }; } private static List GetWatchStoreSkusWithHairDyes() { List storeItems = GetDailyStoreSkus(3); var identities = storeItems .Select(GetCatalogIdentity) .ToHashSet(StringComparer.OrdinalIgnoreCase); IEnumerable hairDyes = GetCatalogSkus() .Where(IsHairDyeCatalogItem) .GroupBy(GetCatalogIdentity, StringComparer.OrdinalIgnoreCase) .Select(group => group.First()) .OrderBy(item => item.FriendlyName, StringComparer.OrdinalIgnoreCase); foreach (CatalogSku hairDye in hairDyes) { if (identities.Add(GetCatalogIdentity(hairDye))) storeItems.Add(hairDye); } return storeItems; } private static bool IsHairDyeFriendlyName(string? friendlyName) => !string.IsNullOrWhiteSpace(friendlyName) && friendlyName.Contains("Hair Dye", StringComparison.OrdinalIgnoreCase); private static bool IsHairDyeCatalogItem(CatalogSku? item) => item != null && IsConsumableCatalogItem(item) && IsHairDyeFriendlyName(item.FriendlyName); private static List GetDailyStoreSkus(int storeId) { List catalog = GetCatalogSkus(); return catalog .Where(IsUsableStoreItem) .GroupBy(GetCatalogIdentity, StringComparer.OrdinalIgnoreCase) .Select(group => group.First()) .OrderBy(item => item.FriendlyName, StringComparer.OrdinalIgnoreCase) .ToList(); } private static bool IsEquipmentSkin(CatalogSku? item) => item != null && !string.IsNullOrWhiteSpace(item.EquipmentPrefabName) && !string.IsNullOrWhiteSpace(item.EquipmentModificationGuid); private static bool IsAvatarCatalogItem(CatalogSku? item) => item != null && !string.IsNullOrWhiteSpace(item.AvatarItemDesc); private static bool IsConsumableCatalogItem(CatalogSku? item) => item != null && !string.IsNullOrWhiteSpace(item.ConsumableItemDesc); private static bool IsExcludedAvatarItemName(string? friendlyName) => !string.IsNullOrWhiteSpace(friendlyName) && (friendlyName.Contains("Custom", StringComparison.OrdinalIgnoreCase) || friendlyName.Contains("Owner", StringComparison.OrdinalIgnoreCase) || friendlyName.Contains("Shirt", StringComparison.OrdinalIgnoreCase)); private static bool IsUnfinishedCatalogItem(CatalogSku item) => item.Rarity <= 0 && !item.HasRealName; private static bool IsUsableStoreItem(CatalogSku? item) => item != null && !string.IsNullOrWhiteSpace(item.FriendlyName) && (IsAvatarCatalogItem(item) || IsEquipmentSkin(item) || IsConsumableCatalogItem(item)) && !(IsAvatarCatalogItem(item) && IsExcludedAvatarItemName(item.FriendlyName)) && !IsUnfinishedCatalogItem(item); private static string GetCatalogIdentity(CatalogSku item) => IsEquipmentSkin(item) ? $"equipment|{item.EquipmentPrefabName}|{item.EquipmentModificationGuid}" : IsConsumableCatalogItem(item) ? $"consumable|{item.ConsumableItemDesc}" : $"avatar|{item.AvatarItemDesc}"; private static bool TryPurchaseCatalogSku( long accountId, CatalogSku sku, out int newBalance, out bool alreadyOwned) { if (IsConsumableCatalogItem(sku)) { alreadyOwned = false; if (!PlayerDB.TrySpendCurrency( accountId, PlayerDBClasses.CurrencyType.RecCenterTokens, StoreItemPrice, out newBalance)) { return false; } PlayerInventoryStore.AddConsumable( accountId, sku.ConsumableItemDesc, sku.ItemId, sku.FriendlyName, amount: 1); return true; } if (!IsEquipmentSkin(sku)) { return PlayerDB.TryPurchaseAvatarItem( accountId, sku.AvatarItemDesc, StoreItemPrice, out newBalance, out alreadyOwned); } lock (EquipmentInventoryLock) { newBalance = PlayerDB.GetCurrencyBalance( accountId, PlayerDBClasses.CurrencyType.RecCenterTokens); alreadyOwned = OwnsEquipmentItem( accountId, sku.EquipmentPrefabName, sku.EquipmentModificationGuid); if (alreadyOwned) return true; if (!PlayerDB.TrySpendCurrency( accountId, PlayerDBClasses.CurrencyType.RecCenterTokens, StoreItemPrice, out newBalance)) { return false; } try { if (GrantPurchasedEquipment(accountId, sku)) return true; } catch (IOException) { } catch (UnauthorizedAccessException) { } int? refundedBalance = PlayerDB.SetCurrencyBalance( accountId, PlayerDBClasses.CurrencyType.RecCenterTokens, StoreItemPrice, add: true); newBalance = refundedBalance ?? newBalance; return false; } } private static ulong GetDailyStoreSortKey( CatalogSku item, DateTime storeDate, int storeId, long rotationNonce) { string value = $"{storeDate:yyyyMMdd}|{storeId}|{rotationNonce}|{GetCatalogIdentity(item)}|{item.SkuId}"; unchecked { const ulong offset = 14695981039346656037UL; const ulong prime = 1099511628211UL; ulong hash = offset; foreach (char character in value) { hash ^= character; hash *= prime; } return hash; } } public static StorefrontAdminInfo GetStorefrontAdminInfo() { (long rotationNonce, List customSkuIds) = GetStorefrontSelectionState(); Dictionary catalog = GetCatalogSkus() .ToDictionary(item => item.SkuId); List customItems = customSkuIds .Select(skuId => catalog.GetValueOrDefault(skuId)) .Where(IsUsableStoreItem) .Cast() .Select(ToStorefrontAdminItem) .ToList(); return new StorefrontAdminInfo { RotationNonce = rotationNonce, CustomItems = customItems, NextRefresh = DateTime.Today.AddDays(1).ToUniversalTime() }; } public static List GetWebsiteStorefrontItems( int storeId = 3) => GetCatalogSkus() .Where(IsUsableStoreItem) .OrderBy(item => IsAvatarCatalogItem(item) ? 0 : IsEquipmentSkin(item) ? 1 : 2) .ThenBy(item => item.FriendlyName, StringComparer.OrdinalIgnoreCase) .Select(ToStorefrontAdminItem) .ToList(); public static bool TryPurchaseWebsiteStoreItem( long accountId, long skuId, out StorefrontAdminItem? purchasedItem, out int newBalance, out bool alreadyOwned, out string error) { CatalogSku? sku = GetCatalogSkus().FirstOrDefault(value => value.SkuId == skuId || value.PurchasableItemId == skuId || (value.AvatarItemId > 0 && value.AvatarItemId == skuId) || (IsConsumableCatalogItem(value) && value.ItemId == skuId)); if (sku == null) { purchasedItem = null; newBalance = PlayerDB.GetCurrencyBalance( accountId, PlayerDBClasses.CurrencyType.RecCenterTokens); alreadyOwned = false; error = "That shop item could not be found."; return false; } bool purchased = TryPurchaseCatalogSku( accountId, sku, out newBalance, out alreadyOwned); purchasedItem = ToStorefrontAdminItem(sku); error = purchased ? string.Empty : "You do not have enough tokens for that item."; return purchased; } public static (List Items, int Total) SearchStorefrontCatalog( string? search, string? type = null, int skip = 0, int take = 30) { string term = search?.Trim() ?? string.Empty; string requestedType = type?.Trim().ToLowerInvariant() ?? string.Empty; IEnumerable query = GetCatalogSkus() .Where(IsUsableStoreItem) .Where(item => string.IsNullOrWhiteSpace(term) || item.FriendlyName.Contains(term, StringComparison.OrdinalIgnoreCase) || item.AvatarItemDesc.Contains(term, StringComparison.OrdinalIgnoreCase) || item.ConsumableItemDesc.Contains(term, StringComparison.OrdinalIgnoreCase) || item.EquipmentPrefabName.Contains(term, StringComparison.OrdinalIgnoreCase) || item.EquipmentModificationGuid.Contains(term, StringComparison.OrdinalIgnoreCase) || item.AvatarItemId.ToString().Contains(term, StringComparison.OrdinalIgnoreCase) || item.SkuId.ToString().Contains(term, StringComparison.OrdinalIgnoreCase)) .Select(ToStorefrontAdminItem) .Where(item => requestedType switch { "avatar" => !string.IsNullOrWhiteSpace(item.AvatarItemDesc), "equipment" => !string.IsNullOrWhiteSpace(item.EquipmentPrefabName) && !string.IsNullOrWhiteSpace(item.EquipmentModificationGuid), "consumable" => !string.IsNullOrWhiteSpace(item.ConsumableItemDesc), _ => true }) .OrderByDescending(item => item.Rarity) .ThenBy(item => item.FriendlyName, StringComparer.OrdinalIgnoreCase); int total = query.Count(); List items = query .Skip(Math.Max(0, skip)) .Take(Math.Clamp(take, 1, 50)) .ToList(); return (items, total); } public static StorefrontAdminItem? GetStorefrontCatalogItem(long skuId) => GetCatalogSkus() .Where(IsUsableStoreItem) .Where(item => item.SkuId == skuId) .Select(ToStorefrontAdminItem) .FirstOrDefault(); public static bool IsWebsiteStoreItemOwned( long accountId, StorefrontAdminItem item) { if (!string.IsNullOrWhiteSpace(item.EquipmentPrefabName) && !string.IsNullOrWhiteSpace(item.EquipmentModificationGuid)) { return OwnsEquipmentItem( accountId, item.EquipmentPrefabName, item.EquipmentModificationGuid); } if (!string.IsNullOrWhiteSpace(item.ConsumableItemDesc)) { return PlayerInventoryStore.GetConsumableQuantity( accountId, item.ConsumableItemDesc) > 0; } return PlayerDB.OwnsAvatarItem(accountId, item.AvatarItemDesc); } public static bool TryAddCustomStoreItem(long skuId, out string error) { CatalogSku? item = GetCatalogSkus() .FirstOrDefault(candidate => candidate.SkuId == skuId); if (!IsUsableStoreItem(item)) { error = "That catalog item could not be found."; return false; } lock (StorefrontAdminLock) { StorefrontAdminState state = LoadStorefrontAdminState(); if (state.CustomSkuIds.Contains(skuId)) { error = "That item is already pinned in the shop."; return false; } if (state.CustomSkuIds.Count >= 10) { error = "The shop already has the maximum of 10 custom items."; return false; } state.CustomSkuIds.Add(skuId); SaveStorefrontAdminState(state); } error = string.Empty; return true; } public static bool RemoveCustomStoreItem(long skuId) { lock (StorefrontAdminLock) { StorefrontAdminState state = LoadStorefrontAdminState(); if (!state.CustomSkuIds.Remove(skuId)) return false; SaveStorefrontAdminState(state); return true; } } public static long RefreshStorefrontRotation() { lock (StorefrontAdminLock) { StorefrontAdminState state = LoadStorefrontAdminState(); state.RotationNonce = state.RotationNonce == long.MaxValue ? 1 : state.RotationNonce + 1; SaveStorefrontAdminState(state); return state.RotationNonce; } } private static StorefrontAdminItem ToStorefrontAdminItem(CatalogSku item) => new() { SkuId = item.SkuId, AvatarItemId = item.AvatarItemId, AvatarItemType = item.AvatarItemType, FriendlyName = item.FriendlyName, AvatarItemDesc = item.AvatarItemDesc, ConsumableItemDesc = item.ConsumableItemDesc, EquipmentPrefabName = item.EquipmentPrefabName, EquipmentModificationGuid = item.EquipmentModificationGuid, ThumbnailImage = item.ThumbnailImage, Rarity = item.Rarity, Price = StoreItemPrice }; private static (long rotationNonce, List customSkuIds) GetStorefrontSelectionState() { lock (StorefrontAdminLock) { StorefrontAdminState state = LoadStorefrontAdminState(); return (state.RotationNonce, state.CustomSkuIds.ToList()); } } private static StorefrontAdminState LoadStorefrontAdminState() { if (StorefrontAdminCache != null) return StorefrontAdminCache; string path = GetStorefrontAdminPath(); try { if (System.IO.File.Exists(path)) { StorefrontAdminCache = JsonSerializer.Deserialize( System.IO.File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); } } catch (JsonException) { StorefrontAdminCache = null; } StorefrontAdminCache ??= new StorefrontAdminState(); StorefrontAdminCache.CustomSkuIds ??= new List(); return StorefrontAdminCache; } private static void SaveStorefrontAdminState(StorefrontAdminState state) { string path = GetStorefrontAdminPath(); string? directory = Path.GetDirectoryName(path); if (!string.IsNullOrWhiteSpace(directory)) Directory.CreateDirectory(directory); System.IO.File.WriteAllText( path, JsonSerializer.Serialize(state, new JsonSerializerOptions { WriteIndented = true })); } private static string GetStorefrontAdminPath() => Path.Combine(Program.dataDir, "StorefrontAdmin.json"); private sealed class StorefrontAdminState { public long RotationNonce { get; set; } public List CustomSkuIds { get; set; } = new(); } public sealed class StorefrontAdminInfo { public long RotationNonce { get; set; } public DateTime NextRefresh { get; set; } public List CustomItems { get; set; } = new(); } public sealed class StorefrontAdminItem { public long SkuId { get; set; } public long AvatarItemId { get; set; } public int AvatarItemType { get; set; } public string FriendlyName { get; set; } = string.Empty; public string AvatarItemDesc { get; set; } = string.Empty; public string ConsumableItemDesc { get; set; } = string.Empty; public string EquipmentPrefabName { get; set; } = string.Empty; public string EquipmentModificationGuid { get; set; } = string.Empty; public string ThumbnailImage { get; set; } = string.Empty; public int Rarity { get; set; } public int Price { get; set; } } public sealed class CatalogStorefront { public int StorefrontType { get; set; } public DateTime NextUpdate { get; set; } public List StoreItems { get; set; } = new(); } public sealed class StorefrontAdCarouselItem { public int AdCarouselItemId { get; set; } public string ImageName { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; public List PurchasableItemIds { get; set; } = new(); public int? PurchaseReminderId { get; set; } } public sealed class CatalogPrice { public int CurrencyType { get; set; } public int Price { get; set; } } public sealed class CatalogGiftDrop { public int GiftDropId { get; set; } public string FriendlyName { get; set; } = string.Empty; public string Tooltip { get; set; } = string.Empty; public string AvatarItemDesc { get; set; } = string.Empty; public string ConsumableItemDesc { get; set; } = string.Empty; public string EquipmentPrefabName { get; set; } = string.Empty; public string EquipmentModificationGuid { get; set; } = string.Empty; public int ItemType { get; set; } public int ConsumableItemType { get; set; } public int Rarity { get; set; } public bool IsQuery { get; set; } public bool Unique { get; set; } public int Level { get; set; } public int Context { get; set; } public string ThumbnailImage { get; set; } = string.Empty; } public sealed class CatalogSku { public long Id { get; set; } public long SkuId { get; set; } public int PurchasableItemId { get; set; } public int Type { get; set; } public bool IsFeatured { get; set; } public long ItemId { get; set; } public long AvatarItemId { get; set; } public int ItemType { get; set; } public int ConsumableItemType { get; set; } public int AvatarItemType { get; set; } public string AvatarItemDesc { get; set; } = string.Empty; public string ConsumableItemDesc { get; set; } = string.Empty; public string EquipmentPrefabName { get; set; } = string.Empty; public string EquipmentModificationGuid { get; set; } = string.Empty; public string FriendlyName { get; set; } = string.Empty; public string Tooltip { get; set; } = string.Empty; public string ThumbnailImage { get; set; } = string.Empty; public int Rarity { get; set; } public int PlatformMask { get; set; } = -1; public int CurrencyType { get; set; } = (int)PlayerDBClasses.CurrencyType.RecCenterTokens; public int Price { get; set; } = StoreItemPrice; public int OriginalPrice { get; set; } = StoreItemPrice; public int DiscountedPrice { get; set; } = StoreItemPrice; public bool IsAvailable { get; set; } = true; public bool IsPurchasable { get; set; } = true; public bool IsEnabled { get; set; } = true; public bool IsOwned { get; set; } public bool Owned { get; set; } public bool Purchased { get; set; } public bool IsBaseAvatarItem { get; set; } public bool HasRealName { get; set; } = true; public DateTime CreatedAt { get; set; } public List Prices { get; set; } = new(); public CatalogGiftDrop GiftDrop { get; set; } = new(); public List GiftDrops { get; set; } = new(); } [HttpGet("/api/customAvatarItems/v1/featured")] public IActionResult GetFeaturedCustomAvatarItems() { return Ok(Array.Empty()); } [HttpGet("/api/inventions/v1/{inventionId:long}/version")] [HttpGet("/api/inventions/v2/{inventionId:long}/version")] public IActionResult GetInventionVersionByPath(long inventionId) { CreatorFeatureDB.InventionRecord? record = CreatorFeatureDB.GetInvention(inventionId); if (record == null) return NotFound(new { Success = false, Error = "invention_not_found" }); object? version = CreatorFeatureDB.ToClientInventionVersion(record); return version == null ? StatusCode( StatusCodes.Status409Conflict, new { Success = false, Error = "invention_object_blob_missing" }) : Ok(version); } [HttpGet("/api/inventions/v1/version")] public IActionResult GetInventionVersion( [FromQuery] long inventionId, [FromQuery] long id = 0, [FromQuery] int version = 0) { long resolvedId = inventionId > 0 ? inventionId : id; CreatorFeatureDB.InventionRecord? record = CreatorFeatureDB.GetInvention(resolvedId); if (record == null) return NotFound(); object? currentVersion = CreatorFeatureDB.ToClientInventionVersion(record); return currentVersion == null ? StatusCode( StatusCodes.Status409Conflict, new { Success = false, Error = "invention_object_blob_missing" }) : Ok(currentVersion); } [HttpGet("/api/storefronts/v1/toptoday")] public IActionResult GetTopStorefrontsToday() { return Ok(GetCatalogSkus()); } [HttpGet("/api/inventions/v1/toptoday")] public IActionResult GetTopInventionsToday( [FromQuery] int skip = 0, [FromQuery] int take = 100) { return Ok(CreatorFeatureDB.SearchInventions(skip: skip, take: take) .Select(CreatorFeatureDB.ToClientInvention) .ToArray()); } [HttpGet("/subscription/top/creators/today")] public IActionResult GetTopSubscriptionCreatorsToday() { return Ok(Array.Empty()); } private static object ToSavedImageDto(RecNetDB.SavedImage savedImage) { long imageId = ImageController.GetOrCreateSavedImageId(savedImage); return new { Id = imageId, ImageId = imageId, SavedImageId = imageId, AccountId = savedImage.AccountId, RoomId = savedImage.RoomId, PlayerEventId = savedImage.PlayerEventId, Url = $"{ServerConfig.BaseURL.TrimEnd('/')}/imageserver/{savedImage.PhotoPath}", LookupName = savedImage.LookupName, Accessibility = savedImage.Accessibility, CreatedAt = savedImage.CreatedAt }; } [HttpGet("/api/images/v5/player/{accountId:long}")] public IActionResult GetPlayerImagesV5( long accountId, [FromQuery] int sort = 0) { var images = RecNetDB.SavedImages.Find(value => value.AccountId == accountId) .OrderByDescending(value => value.CreatedAt) .Select(ToSavedImageDto) .ToList(); return Ok(images); } [HttpGet("/api/images/v1/listsaved")] public IActionResult ListSavedImages() { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var images = RecNetDB.SavedImages.Find(value => value.AccountId == accountId.Value) .OrderByDescending(value => value.CreatedAt) .Select(ToSavedImageDto) .ToList(); return Ok(images); } public class SavedImageBulkRequest { public List? PhotoPaths { get; set; } } [HttpPost("/api/images/v5/bulk")] public IActionResult GetSavedImagesBulk([FromBody] SavedImageBulkRequest request) { if (AuthStuff.GetPlayerId(Request) == null) return Unauthorized(); var paths = (request.PhotoPaths ?? new List()).ToHashSet(StringComparer.OrdinalIgnoreCase); var images = RecNetDB.SavedImages.Find(value => paths.Contains(value.PhotoPath)) .Select(ToSavedImageDto) .ToList(); return Ok(images); } public class DeleteSavedImageRequest { public string? PhotoPath { get; set; } } [HttpPost("/api/images/v1/deletesaved")] [HttpDelete("/api/images/v1/deletesaved")] public IActionResult DeleteSavedImage([FromBody] DeleteSavedImageRequest request) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); string path = request.PhotoPath ?? string.Empty; var image = RecNetDB.SavedImages.FindById(path); if (image == null || image.AccountId != accountId.Value) return NotFound(new { error = "Saved image not found." }); RecNetDB.SavedImages.Delete(path); return Ok(new { success = true }); } [HttpPost("/api/images/v1/sendlink")] public IActionResult SendSavedImageLink([FromBody] DeleteSavedImageRequest request) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); string path = request.PhotoPath ?? string.Empty; var image = RecNetDB.SavedImages.FindById(path); if (image == null) return NotFound(new { error = "Saved image not found." }); return Ok(new { success = true, url = $"{ServerConfig.BaseURL.TrimEnd('/')}/imageserver/{image.PhotoPath}" }); } [HttpGet("/api/images/v1/slideshow")] public IActionResult GetImageSlideshow() { string imageRoot = Path.GetFullPath( Path.Combine(Program.dataDir, "Images")); string requiredPrefix = imageRoot .TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; bool TryResolveSavedImage( RecNetDB.SavedImage savedImage, out string relativePath, out string fullPath) { relativePath = savedImage.PhotoPath .Replace('\\', '/') .TrimStart('/'); fullPath = string.Empty; if (string.IsNullOrWhiteSpace(relativePath) || relativePath.Split('/').Any(segment => segment is "." or "..")) { return false; } try { fullPath = Path.GetFullPath( Path.Combine(imageRoot, relativePath)); return fullPath.StartsWith( requiredPrefix, StringComparison.OrdinalIgnoreCase) && System.IO.File.Exists(fullPath); } catch { return false; } } var images = RecNetDB.SavedImages.FindAll() .Where(savedImage => savedImage.SavedImageType == 1 && savedImage.Accessibility == 1 && savedImage.AccountId is > 0 and <= int.MaxValue) .Select(savedImage => { if (!TryResolveSavedImage( savedImage, out string relativePath, out _)) { return null; } var player = PlayerDB.Players.FindById( savedImage.AccountId); if (player?.Player == null) return null; var room = savedImage.RoomId is > 0 ? RoomDB.Rooms.FindById(savedImage.RoomId.Value) : null; long imageId = ImageController.GetOrCreateSavedImageId(savedImage); return new SlideshowImageResponse { Id = imageId, ImageId = imageId, SavedImageId = imageId, ImageName = relativePath, Username = player.Player.Username ?? player.Player.DisplayName ?? $"Player{player.PlayerId}", PlayerName = player.Player.Username ?? player.Player.DisplayName ?? $"Player{player.PlayerId}", PlayerId = checked((int)player.PlayerId), RoomName = room?.Name ?? "Rec Room", RoomId = savedImage.RoomId }; }) .Where(image => image != null) .Cast() .GroupBy( image => image.ImageName, StringComparer.OrdinalIgnoreCase) .Select(group => group.First()) .ToList(); if (images.Count == 0) { var fallbackPlayer = (AuthStuff.GetPlayerId(Request) is long callerId ? PlayerDB.Players.FindById(callerId) : null) ?? PlayerDB.Players.FindAll() .FirstOrDefault(player => player.Player != null); int fallbackPlayerId = fallbackPlayer?.PlayerId is > 0 and <= int.MaxValue ? checked((int)fallbackPlayer.PlayerId) : 1; string fallbackPlayerName = fallbackPlayer?.Player?.Username ?? fallbackPlayer?.Player?.DisplayName ?? "Mocha"; string rroRoot = Path.Combine(imageRoot, "RROs"); if (Directory.Exists(rroRoot)) { images.AddRange(Directory .EnumerateFiles(rroRoot, "*", SearchOption.AllDirectories) .Where(file => new[] { ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp" }.Contains( Path.GetExtension(file), StringComparer.OrdinalIgnoreCase)) .Select(file => { long imageId = Math.Max( System.IO.File.GetCreationTimeUtc(file).Ticks, 1); return new SlideshowImageResponse { Id = imageId, ImageId = imageId, SavedImageId = imageId, ImageName = Path .GetRelativePath(imageRoot, file) .Replace('\\', '/'), Username = fallbackPlayerName, PlayerName = fallbackPlayerName, PlayerId = fallbackPlayerId, RoomName = Path.GetFileNameWithoutExtension(file), RoomId = null }; })); } } for (int index = images.Count - 1; index > 0; index--) { int swapIndex = Random.Shared.Next(index + 1); (images[index], images[swapIndex]) = (images[swapIndex], images[index]); } DateTime validTill = DateTime.UtcNow.AddMinutes(5); Console.WriteLine( $"[IMAGE SLIDESHOW] images={images.Count} " + $"order=random loop=wrap validTill={validTill:O}"); return Ok(new { ValidTill = validTill, Images = images }); } private sealed class SlideshowImageResponse { public long Id { get; init; } public long ImageId { get; init; } public long SavedImageId { get; init; } public string ImageName { get; init; } = string.Empty; public string Username { get; init; } = string.Empty; public string PlayerName { get; init; } = string.Empty; public int PlayerId { get; init; } public string RoomName { get; init; } = string.Empty; public long? RoomId { get; init; } } private IActionResult GetLoadingScreenData() { string path = Path.Combine(Program.dataDir, "loadingscreen.json"); if (System.IO.File.Exists(path)) { try { string rewritten = LoadingScreenImageService.RewriteConfiguration( System.IO.File.ReadAllText(path)); return Content(rewritten, "application/json"); } catch (Exception ex) when (ex is IOException or JsonException) { Console.WriteLine($"[loading screen config] {ex.Message}"); } } return Ok(new[] { new { ImageName = "RROs/DormRoom.jpg", Message = "Welcome to Mocha! Meet up with friends and explore community rooms.", PlatformMask = -1, RoomNames = Array.Empty(), Title = "Welcome to Mocha" }, new { ImageName = "RROs/RecCenter.jpg", Message = "Open the People menu to find, friend, and subscribe to other players.", PlatformMask = -1, RoomNames = Array.Empty(), Title = "Play together" } }); } [HttpPost("/api/objectives/v1/cleargroup")] public IActionResult ClearObjectiveGroup() { return Ok(new { success = true }); } [HttpGet("/cdn/sigs/{buildId}")] public IActionResult GetBuildSignature(string buildId) { Response.Headers.CacheControl = "public, max-age=3600"; return Content("p1", "text/plain"); } [HttpPost("/api/screensharereports")] public IActionResult CreateScreenShareReport([FromBody] JsonElement request) { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); DiscordLogger.Log($"🖥️ Screen share report submitted by player ID `{id}`"); return Ok(new { success = true }); } [HttpGet("/api/config/v1/azurespeech")] [HttpGet("/api/config/v1/backtrace")] public IActionResult DisabledFeatureConfigStub() { return Ok(new { enabled = false }); } [HttpGet("/api/keepsakes/globalconfig")] [HttpGet("/api/keepsakes/config")] [HttpGet("/api/keepsakes/v1/globalconfig")] [HttpGet("/api/keepsakes/v1/config")] public IActionResult KeepsakesGlobalConfig() { return Ok(new { Enabled = true, MaxKeepsakesPerRoom = 0, CollectionEnabled = true }); } [HttpGet("/api/keepsakes/rooms/{roomId}")] [HttpGet("/api/keepsakes/room/{roomId}")] [HttpGet("/api/keepsakes/v1/rooms/{roomId}")] [HttpGet("/api/keepsakes/v1/room/{roomId}")] public IActionResult KeepsakesForRoom(long roomId) { return Ok(new { RoomId = roomId, KeepsakeInstances = Array.Empty(), CollectionRecords = Array.Empty(), CollectedKeepsakeIds = Array.Empty() }); } private IActionResult ReadAnnouncementList() { string path = Path.Combine(Program.dataDir, "announcements.json"); if (!System.IO.File.Exists(path)) return Ok(Array.Empty()); string json = System.IO.File.ReadAllText(path); if (string.IsNullOrWhiteSpace(json)) return Ok(Array.Empty()); try { using JsonDocument document = JsonDocument.Parse(json); if (document.RootElement.ValueKind != JsonValueKind.Array) { Console.WriteLine("[Announcements] announcements.json must contain a JSON array."); return Ok(Array.Empty()); } Response.Headers.CacheControl = "no-store, no-cache, must-revalidate"; Response.Headers.Pragma = "no-cache"; Response.Headers.Expires = "0"; Console.WriteLine($"[Announcements] Returning: {json}"); return Content(json, "application/json"); } catch (JsonException exception) { Console.WriteLine($"[Announcements] Invalid JSON: {exception.Message}"); return Ok(Array.Empty()); } } [HttpGet("/announcements/v2/mine/unread")] public IActionResult GetUnreadAnnouncements() { return Ok(Array.Empty()); } [HttpGet("/announcements/v2/subscription/mine/unread")] public IActionResult GetUnreadSubscriptionAnnouncements() { return Ok(Array.Empty()); } [HttpGet("/api/announcement/v1/get")] public IActionResult GetAnnouncements() { return ReadAnnouncementList(); } [HttpPost("/announcements/v2/mine/read")] [HttpPost("/announcements/v2/mine/markread")] [HttpPost("/announcements/v2/subscription/mine/read")] public IActionResult MarkAnnouncementsRead() { long? playerId = AuthStuff.GetPlayerId(Request); if (!playerId.HasValue) return Unauthorized(); return Ok(new { Success = true }); } [HttpPost("/api/announcement/v1/refresh")] public async Task RefreshAnnouncements() { long? playerId = AuthStuff.GetPlayerId(Request); if (!playerId.HasValue) return Unauthorized(); await NotiController.NotifyAnnouncementsUpdatedAsync(playerId.Value); return Ok(new { Success = true }); } [HttpPost("/api/relationships/v1/bulkignoreplatformusers")] public IActionResult BulkIgnorePlatformUsers() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); return Ok(new { success = true }); } [HttpGet("/api/relationships/v1/favorite")] public IActionResult GetFavoriteRelationship([FromQuery] long id) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); ClientRelationshipDTO? relationship = RelationshipDB.GetClientRelationship(accountId.Value, id); return Ok(relationship != null && (relationship.Favorited & 1) != 0); } [HttpGet("/thread/club/{clubId:long}")] public IActionResult GetClubThreads( long clubId, [FromQuery] int maxCount = 10, [FromQuery] int mode = 0) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); return Ok(Array.Empty()); } [HttpPost("/pageview/consume")] public IActionResult ConsumePageView() => NoContent(); [HttpPost("/api/ugcPurchasables/v1/items/bulk")] public IActionResult UgcPurchasablesBulk() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); return Ok(Array.Empty()); } [HttpPost("/api/PlayerReporting/v1/deviceId")] public IActionResult PlayerReportingDeviceId() { var id = AuthStuff.GetPlayerId(Request); return Ok(new { accountId = id }); } [HttpGet("/api/testcasemanagement/v1/cases")] public IActionResult TestCaseManagementStub() { return Ok(ServerConfig.Bracket); } [HttpGet("/api/customAvatarItems/v1/design")] public IActionResult GetCustomAvatarItemDesign() { long? rawPlayerId = AuthStuff.GetPlayerId(Request); if (!rawPlayerId.HasValue) return Unauthorized(); long playerId = rawPlayerId.Value; string directory = Path.Combine( Program.dataDir, "CustomAvatarItems", playerId.ToString() ); string designPath = Path.Combine(directory, "design.json"); Console.WriteLine( $"[CUSTOM AVATAR ITEMS] Player ({playerId}) requested their design." ); if (!System.IO.File.Exists(designPath)) { return Ok(new { customAvatarItemId = 0, creatorPlayerId = playerId, design = new { } }); } try { string json = System.IO.File.ReadAllText(designPath); return Content( json, "application/json", Encoding.UTF8 ); } catch (Exception ex) { Console.WriteLine( $"[CUSTOM AVATAR ITEMS] Failed reading design: {ex}" ); return StatusCode(500, new { success = false, error = "Failed to load custom avatar item design." }); } } [HttpPost("/api/customAvatarItems/v1")] [RequestSizeLimit(12 * 1024 * 1024)] public async Task CreateCustomAvatarItem() { long? rawPlayerId = AuthStuff.GetPlayerId(Request); if (!rawPlayerId.HasValue) return Unauthorized(); long playerId = rawPlayerId.Value; string metadataJson = string.Empty; IFormFile? thumbnailImage = null; if (Request.HasFormContentType) { IFormCollection form; try { form = await Request.ReadFormAsync(HttpContext.RequestAborted); } catch (InvalidDataException ex) { Console.WriteLine( $"[CUSTOM AVATAR ITEMS] Invalid multipart request from player ({playerId}): {ex.Message}"); return BadRequest(new { success = false, error = "Invalid multipart form." }); } metadataJson = new[] { "metadata", "Metadata", "data", "Data", "json", "Json" } .Select(name => form[name].FirstOrDefault()) .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty; thumbnailImage = form.Files.GetFile("thumbnailImage") ?? form.Files.GetFile("ThumbnailImage") ?? form.Files.FirstOrDefault(); } else { using var reader = new StreamReader( Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true); metadataJson = await reader.ReadToEndAsync(); } Console.WriteLine( $"[CUSTOM AVATAR ITEMS] Player ({playerId}) metadata: {metadataJson}"); if (string.IsNullOrWhiteSpace(metadataJson)) { return BadRequest(new { success = false, error = "Custom avatar item metadata was empty." }); } JsonNode? receivedJson; try { receivedJson = JsonNode.Parse(metadataJson); } catch (JsonException ex) { Console.WriteLine( $"[CUSTOM AVATAR ITEMS] Invalid metadata JSON from player ({playerId}): {ex.Message}"); return BadRequest(new { success = false, error = "Custom avatar item metadata contained invalid JSON." }); } if (receivedJson is not JsonObject item) { return BadRequest(new { success = false, error = "Custom avatar item metadata must be a JSON object." }); } long customAvatarItemId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); while (LoadCustomAvatarItems(playerId).Any(existing => ReadCustomAvatarItemId(existing) == customAvatarItemId)) { customAvatarItemId++; } string createdAt = DateTime.UtcNow.ToString("O"); item["CustomAvatarItemId"] = customAvatarItemId; item["customAvatarItemId"] = customAvatarItemId; item["Id"] ??= customAvatarItemId; item["CreatorPlayerId"] = playerId; item["creatorPlayerId"] = playerId; item["CreatorAccountId"] ??= playerId; item["CreatedAt"] ??= createdAt; item["createdAt"] ??= createdAt; string directory = Path.Combine( Program.dataDir, "CustomAvatarItems", playerId.ToString()); string designPath = Path.Combine(directory, "design.json"); string itemsPath = Path.Combine(directory, "items.json"); try { Directory.CreateDirectory(directory); if (thumbnailImage is { Length: > 0 }) { string imageDirectory = Path.Combine( Program.dataDir, "Images", "CustomAvatarItems", playerId.ToString()); Directory.CreateDirectory(imageDirectory); string extension = string.Equals( thumbnailImage.ContentType, "image/jpeg", StringComparison.OrdinalIgnoreCase) ? ".jpg" : ".png"; string imageName = customAvatarItemId + extension; string imagePath = Path.Combine(imageDirectory, imageName); await using (FileStream stream = new( imagePath, FileMode.Create, FileAccess.Write, FileShare.None)) { await thumbnailImage.CopyToAsync( stream, HttpContext.RequestAborted); } string relativeImagePath = $"CustomAvatarItems/{playerId}/{imageName}"; item["ThumbnailImage"] = relativeImagePath; item["thumbnailImage"] = relativeImagePath; } JsonNode designToSave = item["Design"]?.DeepClone() ?? item["design"]?.DeepClone() ?? item.DeepClone(); await System.IO.File.WriteAllTextAsync( designPath, designToSave.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), HttpContext.RequestAborted); JsonArray items = new(); if (System.IO.File.Exists(itemsPath)) { try { items = JsonNode.Parse( await System.IO.File.ReadAllTextAsync( itemsPath, HttpContext.RequestAborted)) as JsonArray ?? new JsonArray(); } catch (Exception ex) { Console.WriteLine( $"[CUSTOM AVATAR ITEMS] Existing items file was invalid: {ex.Message}"); } } items.Add(item.DeepClone()); await System.IO.File.WriteAllTextAsync( itemsPath, items.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), HttpContext.RequestAborted); Console.WriteLine( $"[CUSTOM AVATAR ITEMS] Created item ({customAvatarItemId}) for player ({playerId})."); return Content( item.ToJsonString(), "application/json", Encoding.UTF8); } catch (Exception ex) { Console.WriteLine( $"[CUSTOM AVATAR ITEMS] Failed saving item: {ex}"); return StatusCode(500, new { success = false, error = "Failed to save custom avatar item." }); } } private static List LoadCustomAvatarItems(long playerId) { string itemsPath = Path.Combine( Program.dataDir, "CustomAvatarItems", playerId.ToString(), "items.json"); if (!System.IO.File.Exists(itemsPath)) return new List(); try { JsonNode? root = JsonNode.Parse(System.IO.File.ReadAllText(itemsPath)); if (root is not JsonArray array) return new List(); return array .OfType() .Select(value => value.DeepClone().AsObject()) .OrderByDescending(ReadCustomAvatarItemId) .ToList(); } catch (Exception ex) { Console.WriteLine( $"[CUSTOM AVATAR ITEMS] Could not read items for player ({playerId}): {ex.Message}"); return new List(); } } private static long ReadCustomAvatarItemId(JsonObject item) { foreach (string key in new[] { "CustomAvatarItemId", "customAvatarItemId", "Id", "id" }) { if (TryReadLong(item[key], out long id)) return id; } return 0; } [HttpGet("/reminder/currentTokenBundles/v2")] public IActionResult GetCurrentTokenBundles() { DateTime expiresAt = DateTime.UtcNow.AddYears(10); var bundles = new List { CreateTokenBundle( id: 9_000_001, productId: "mocha.tokens.2500", baseTokens: 2_500, bonusTokens: 0, displayPrice: "$1.99", expiresAt ), CreateTokenBundle( id: 9_000_002, productId: "mocha.tokens.5500", baseTokens: 5_000, bonusTokens: 500, displayPrice: "$3.99", expiresAt ), CreateTokenBundle( id: 9_000_003, productId: "mocha.tokens.12000", baseTokens: 10_000, bonusTokens: 2_000, displayPrice: "$7.99", expiresAt ), CreateTokenBundle( id: 9_000_004, productId: "mocha.tokens.35000", baseTokens: 25_000, bonusTokens: 10_000, displayPrice: "$19.99", expiresAt ), CreateTokenBundle( id: 9_000_005, productId: "mocha.tokens.70000", baseTokens: 50_000, bonusTokens: 20_000, displayPrice: "$34.99", expiresAt ) }; return Ok(bundles); } [HttpPost("/api/relationships/v1/ignore")] [HttpPost("/api/relationships/v2/ignore")] [HttpPost("/api/relationships/v1/block")] [HttpPost("/api/relationships/v2/block")] [HttpPut("/api/relationships/v1/ignore")] [HttpPut("/api/relationships/v2/ignore")] [HttpPut("/api/relationships/v1/block")] [HttpPut("/api/relationships/v2/block")] public Task IgnorePlayer() { return SetRelationshipFlag( RelationshipFlag.Ignored, forcedEnabled: null); } [HttpPost("/api/relationships/v1/mute")] [HttpPost("/api/relationships/v2/mute")] [HttpPut("/api/relationships/v1/mute")] [HttpPut("/api/relationships/v2/mute")] public Task MutePlayer() { return SetRelationshipFlag( RelationshipFlag.Muted, forcedEnabled: null); } [HttpPost("/api/relationships/v1/unignore")] [HttpPost("/api/relationships/v2/unignore")] [HttpPost("/api/relationships/v1/unblock")] [HttpPost("/api/relationships/v2/unblock")] [HttpDelete("/api/relationships/v1/ignore")] [HttpDelete("/api/relationships/v2/ignore")] [HttpDelete("/api/relationships/v1/block")] [HttpDelete("/api/relationships/v2/block")] public Task UnignorePlayer() { return SetRelationshipFlag( RelationshipFlag.Ignored, forcedEnabled: false); } [HttpPost("/api/relationships/v1/unmute")] [HttpPost("/api/relationships/v2/unmute")] [HttpDelete("/api/relationships/v1/mute")] [HttpDelete("/api/relationships/v2/mute")] public Task UnmutePlayer() { return SetRelationshipFlag( RelationshipFlag.Muted, forcedEnabled: false); } [HttpPost("/api/relationships/v1/favorite")] [HttpPost("/api/relationships/v2/favorite")] [HttpPut("/api/relationships/v1/favorite")] [HttpPut("/api/relationships/v2/favorite")] public Task FavoritePlayer() { return SetRelationshipFlag( RelationshipFlag.Favorited, forcedEnabled: null); } [HttpPost("/api/relationships/v1/unfavorite")] [HttpPost("/api/relationships/v2/unfavorite")] [HttpDelete("/api/relationships/v1/favorite")] [HttpDelete("/api/relationships/v2/favorite")] public Task UnfavoritePlayer() { return SetRelationshipFlag( RelationshipFlag.Favorited, forcedEnabled: false); } private enum RelationshipFlag { Ignored, Muted, Favorited } private async Task SetRelationshipFlag( RelationshipFlag flag, bool? forcedEnabled) { long? sourceId = AuthStuff.GetPlayerId(Request); if (sourceId == null) return Unauthorized(); var action = await ReadRelationshipAction( flag switch { RelationshipFlag.Ignored => new[] { "Ignored", "IsIgnored", "Ignore", "Blocked", "IsBlocked", "Block" }, RelationshipFlag.Muted => new[] { "Muted", "IsMuted", "Mute" }, RelationshipFlag.Favorited => new[] { "Favorited", "IsFavorited", "Favorite" }, _ => Array.Empty() }); if (action == null) { return BadRequest(new { success = false, error = "missing_target_player" }); } bool enabled = forcedEnabled ?? action.Value.Enabled; var relationship = PlayerDB.SetRelationshipFlags( sourceId.Value, action.Value.TargetId, ignored: flag == RelationshipFlag.Ignored ? enabled : null, muted: flag == RelationshipFlag.Muted ? enabled : null, favorited: flag == RelationshipFlag.Favorited ? enabled : null); if (relationship == null) { return BadRequest(new { success = false, error = "relationship_update_failed" }); } ClientRelationshipDTO? clientRelationship = RelationshipDB.GetClientRelationship( sourceId.Value, action.Value.TargetId); if (clientRelationship == null) { return StatusCode(500, new { success = false, error = "relationship_not_persisted" }); } string actionName = flag switch { RelationshipFlag.Ignored => enabled ? "block" : "unblock", RelationshipFlag.Muted => enabled ? "mute" : "unmute", RelationshipFlag.Favorited => enabled ? "favorite" : "unfavorite", _ => "relationship-change" }; await NotiController.NotifyRelationshipFlagsChangedAsync( sourceId.Value, action.Value.TargetId, actionName); Console.WriteLine( $"[RELATIONSHIP] source={sourceId.Value} " + $"target={action.Value.TargetId} action={actionName} " + $"ignored={clientRelationship.Ignored} " + $"muted={clientRelationship.Muted} " + $"favorited={clientRelationship.Favorited} live=true"); return Ok(clientRelationship); } private async Task<(long TargetId, bool Enabled)?> ReadRelationshipAction(string[] enabledKeys) { var values = new Dictionary( StringComparer.OrdinalIgnoreCase); foreach (var entry in Request.Query) values[entry.Key] = entry.Value.ToString(); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); foreach (var entry in form) values[entry.Key] = entry.Value.ToString(); } else { using var reader = new StreamReader(Request.Body); string body = await reader.ReadToEndAsync(); if (!string.IsNullOrWhiteSpace(body)) { try { using var document = JsonDocument.Parse(body); AddJsonValues(document.RootElement, values); } catch (JsonException) { foreach (var entry in Microsoft.AspNetCore.WebUtilities.QueryHelpers .ParseQuery("?" + body)) { values[entry.Key] = entry.Value.ToString(); } if (long.TryParse( body.Trim().Trim('"', '\''), out long rawTargetId)) { values["accountId"] = rawTargetId.ToString(); } } } } string? targetText = GetValue( values, "TargetAccountId", "TargetPlayerId", "AccountId", "PlayerId", "PlayerID", "targetId", "id"); if (!long.TryParse(targetText, out long targetId) || targetId <= 0) { return null; } bool enabled = true; string? enabledText = GetValue( values, enabledKeys .Concat(new[] { "Enabled", "Value", "State" }) .ToArray()); if (bool.TryParse(enabledText, out bool parsedBool)) { enabled = parsedBool; } else if (int.TryParse(enabledText, out int parsedInt)) { enabled = parsedInt != 0; } return (targetId, enabled); } private async Task> ReadLongValuesAsync(params string[] keys) { var result = new HashSet(); var keySet = keys.ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var entry in Request.Query) { if (keySet.Contains(entry.Key)) AddLongValuesFromText(entry.Value.ToString(), result); } if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(HttpContext.RequestAborted); foreach (var entry in form) { if (keySet.Contains(entry.Key)) AddLongValuesFromText(entry.Value.ToString(), result); } } else { Request.EnableBuffering(); if (Request.Body.CanSeek) Request.Body.Position = 0; using var reader = new StreamReader( Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); string body = await reader.ReadToEndAsync(); if (Request.Body.CanSeek) Request.Body.Position = 0; if (!string.IsNullOrWhiteSpace(body)) { try { using JsonDocument document = JsonDocument.Parse(body); CollectLongValues( document.RootElement, keySet, result, collectAll: document.RootElement.ValueKind == JsonValueKind.Array); } catch (JsonException) { foreach (var entry in Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery("?" + body)) { if (keySet.Contains(entry.Key)) AddLongValuesFromText(entry.Value.ToString(), result); } AddLongValuesFromText(body, result); } } } return result; } private static void CollectLongValues( JsonElement element, HashSet keys, HashSet result, bool collectAll) { switch (element.ValueKind) { case JsonValueKind.Object: foreach (JsonProperty property in element.EnumerateObject()) { bool propertyMatches = keys.Contains(property.Name); CollectLongValues( property.Value, keys, result, collectAll || propertyMatches); } break; case JsonValueKind.Array: foreach (JsonElement item in element.EnumerateArray()) CollectLongValues(item, keys, result, collectAll); break; case JsonValueKind.Number when collectAll: if (element.TryGetInt64(out long numericValue) && numericValue > 0) result.Add(numericValue); break; case JsonValueKind.String when collectAll: AddLongValuesFromText(element.GetString(), result); break; } } private static void AddLongValuesFromText( string? text, HashSet result) { if (string.IsNullOrWhiteSpace(text)) return; string cleaned = text.Replace("[", " ").Replace("]", " ") .Replace("{", " ").Replace("}", " ").Replace("\"", " ") .Replace("'", " "); foreach (string part in cleaned.Split( new[] { ',', ';', '|', ' ', '\t', '\r', '\n', '=', ':' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { if (long.TryParse(part, out long value) && value > 0) result.Add(value); } } private static TokenBundleResponse CreateTokenBundle( int id, string productId, int baseTokens, int bonusTokens, string displayPrice, DateTime expiresAt) { int totalTokens = baseTokens + bonusTokens; return new TokenBundleResponse { Id = id, TokenBundleId = id, BundleId = id, SkuId = id, PurchasableItemId = id, ProductId = productId, PlatformProductId = productId, Sku = productId, Name = $"{totalTokens:N0} Tokens", DisplayName = $"{totalTokens:N0} Tokens", TokenAmount = baseTokens, BaseTokenAmount = baseTokens, BonusTokenAmount = bonusTokens, BonusTokens = bonusTokens, Tokens = totalTokens, TotalTokens = totalTokens, Price = displayPrice, DisplayPrice = displayPrice, CurrencyCode = "CAD", ExpirationDate = expiresAt, ExpiresAt = expiresAt, EndDate = expiresAt, IsActive = true, IsAvailable = true, GiftDrops = Array.Empty(), BonusGiftDrops = Array.Empty() }; } public sealed class TokenBundleResponse { public int Id { get; set; } public int TokenBundleId { get; set; } public int BundleId { get; set; } public int SkuId { get; set; } public int PurchasableItemId { get; set; } public string ProductId { get; set; } = ""; public string PlatformProductId { get; set; } = ""; public string Sku { get; set; } = ""; public string Name { get; set; } = ""; public string DisplayName { get; set; } = ""; public int TokenAmount { get; set; } public int BaseTokenAmount { get; set; } public int BonusTokenAmount { get; set; } public int BonusTokens { get; set; } public int Tokens { get; set; } public int TotalTokens { get; set; } public string Price { get; set; } = ""; public string DisplayPrice { get; set; } = ""; public string CurrencyCode { get; set; } = ""; public DateTime ExpirationDate { get; set; } public DateTime ExpiresAt { get; set; } public DateTime EndDate { get; set; } public bool IsActive { get; set; } public bool IsAvailable { get; set; } public object[] GiftDrops { get; set; } = Array.Empty(); public object[] BonusGiftDrops { get; set; } = Array.Empty(); } public class SanitizeRequest { public string Value { get; set; } = string.Empty; } } }