:3
This commit is contained in:
@@ -0,0 +1,11052 @@
|
||||
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<CatalogSku> 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<CatalogSku> 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<object>());
|
||||
}
|
||||
|
||||
[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<object>());
|
||||
}
|
||||
|
||||
[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<object>());
|
||||
}
|
||||
|
||||
[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<object>());
|
||||
}
|
||||
|
||||
[HttpGet("/api/keepsakes/categories")]
|
||||
[HttpGet("/api/keepsakes/v1/categories")]
|
||||
public IActionResult StubGetKeepsakeCategories()
|
||||
{
|
||||
return Ok(System.Array.Empty<object>());
|
||||
}
|
||||
|
||||
[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<object>()
|
||||
});
|
||||
}
|
||||
|
||||
[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<string>())
|
||||
.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<long>? 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<long>())
|
||||
.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<string>();
|
||||
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<string>();
|
||||
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<object>() });
|
||||
}
|
||||
|
||||
[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<object>());
|
||||
}
|
||||
|
||||
[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<char> 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<string>? 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<string>()).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<PlayerEventDB.PlayerEvent> 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<string>? 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<string>()).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<long>? 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<long>();
|
||||
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<long>? 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<long>()).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<object>(),
|
||||
ObjectiveGroups = new List<object>()
|
||||
});
|
||||
}
|
||||
|
||||
[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<object>());
|
||||
|
||||
return Ok(GetAvatarItemsNotOwnedByPlayer((long)id, path));
|
||||
}
|
||||
|
||||
private static List<JsonElement> GetAvatarItemsNotOwnedByPlayer(
|
||||
long playerId,
|
||||
string path)
|
||||
{
|
||||
string json = System.IO.File.ReadAllText(path);
|
||||
var items = JsonSerializer.Deserialize<List<JsonElement>>(
|
||||
json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? new List<JsonElement>();
|
||||
|
||||
var player = PlayerDB.Players.FindById(playerId);
|
||||
IEnumerable<string>? legacyItems = player?.Player?.PlayerExtra?.AvatarItems;
|
||||
|
||||
List<PlayerInventoryStore.AvatarItemOwnership> ownedItems =
|
||||
PlayerInventoryStore.GetAvatarItems(playerId, legacyItems);
|
||||
|
||||
HashSet<long> ownedIds = ownedItems
|
||||
.Where(item => item.AvatarItemId > 0)
|
||||
.Select(item => item.AvatarItemId)
|
||||
.ToHashSet();
|
||||
HashSet<string> 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<JsonElement> GetAvatarItemsVisibleToPlayer(
|
||||
long playerId,
|
||||
string path)
|
||||
{
|
||||
string json = System.IO.File.ReadAllText(path);
|
||||
var items = JsonSerializer.Deserialize<List<JsonElement>>(
|
||||
json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? new List<JsonElement>();
|
||||
|
||||
var player = PlayerDB.Players.FindById(playerId);
|
||||
IEnumerable<string>? legacyItems = player?.Player?.PlayerExtra?.AvatarItems;
|
||||
|
||||
List<PlayerInventoryStore.AvatarItemOwnership> ownedItems =
|
||||
PlayerInventoryStore.GetAvatarItems(playerId, legacyItems);
|
||||
|
||||
HashSet<long> ownedIds = ownedItems
|
||||
.Where(item => item.AvatarItemId > 0)
|
||||
.Select(item => item.AvatarItemId)
|
||||
.ToHashSet();
|
||||
HashSet<string> 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<object>());
|
||||
|
||||
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<object>());
|
||||
}
|
||||
|
||||
var normalizedItems = new JsonArray();
|
||||
int repairedCreatedAts = 0;
|
||||
int repairedKeys = 0;
|
||||
int skipped = 0;
|
||||
long returnedQuantity = 0;
|
||||
|
||||
Dictionary<long, int>? ownedConsumablesById = null;
|
||||
Dictionary<string, int>? ownedConsumablesByDescriptor = null;
|
||||
if (accountId.HasValue)
|
||||
{
|
||||
List<PlayerInventoryStore.ConsumableOwnership> 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<long> ids = ReadConsumableIds(item);
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
item["Ids"] = new JsonArray(
|
||||
ids.Select(id => JsonValue.Create(id)).ToArray());
|
||||
|
||||
List<string> 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<object>());
|
||||
}
|
||||
}
|
||||
|
||||
private static List<long> ReadConsumableIds(JsonObject item)
|
||||
{
|
||||
var ids = new List<long>();
|
||||
|
||||
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<string> ReadConsumableCreatedAts(JsonObject item)
|
||||
{
|
||||
var values = new List<string>();
|
||||
|
||||
if (item["CreatedAts"] is not JsonArray createdAtArray)
|
||||
return values;
|
||||
|
||||
foreach (JsonNode? valueNode in createdAtArray)
|
||||
{
|
||||
string? value = null;
|
||||
try
|
||||
{
|
||||
value = valueNode?.GetValue<string>();
|
||||
}
|
||||
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<string>();
|
||||
}
|
||||
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<long>(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<PlayerDBClasses.EquipmentItem> GetOrCreatePlayerEquipment(long accountId)
|
||||
{
|
||||
return EquipmentInventoryStore.GetOrCreate(accountId);
|
||||
}
|
||||
|
||||
private static void SavePlayerEquipment(
|
||||
long accountId,
|
||||
List<PlayerDBClasses.EquipmentItem> 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<List<PlayerDBClasses.EquipmentItem>>(
|
||||
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<PlayerDBClasses.EquipmentItem> 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<List<PlayerDBClasses.EquipmentItem>>(
|
||||
items.GetRawText(),
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ??
|
||||
new List<PlayerDBClasses.EquipmentItem>(),
|
||||
JsonValueKind.Object => new List<PlayerDBClasses.EquipmentItem>
|
||||
{
|
||||
JsonSerializer.Deserialize<PlayerDBClasses.EquipmentItem>(
|
||||
items.GetRawText(),
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!
|
||||
},
|
||||
_ => new List<PlayerDBClasses.EquipmentItem>()
|
||||
};
|
||||
|
||||
updates.RemoveAll(item => item == null);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
updates = new List<PlayerDBClasses.EquipmentItem>();
|
||||
}
|
||||
|
||||
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<object>() });
|
||||
|
||||
string json = System.IO.File.ReadAllText(path);
|
||||
var items = JsonSerializer.Deserialize<List<JsonElement>>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new List<JsonElement>();
|
||||
|
||||
if (items.Count == 0)
|
||||
return Ok(new { Rewards = Array.Empty<object>() });
|
||||
|
||||
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<IActionResult> 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<long> 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<string> installedConsumables = GetCatalogSkus()
|
||||
.Where(IsConsumableCatalogItem)
|
||||
.Select(item => item.ConsumableItemDesc)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
List<FriendotronConsumableReward> 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<IActionResult> 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<string>();
|
||||
|
||||
HashSet<string> ownedItems =
|
||||
player.Player.PlayerExtra.AvatarItems.ToHashSet(
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
List<CatalogSku> 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<long> 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<ParsedFreeGiftRequest> 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<long> 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<long> 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<long> 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<ClientRelationshipDTO>());
|
||||
|
||||
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<object>() });
|
||||
}
|
||||
|
||||
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<NotificationDB.ClientNotification> 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<IActionResult> 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<IActionResult> DeleteMyMessages()
|
||||
{
|
||||
long? playerId = AuthStuff.GetPlayerId(Request);
|
||||
if (!playerId.HasValue)
|
||||
return Unauthorized();
|
||||
|
||||
HashSet<long> 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<IActionResult> SendMultipleMessages()
|
||||
{
|
||||
long? senderPlayerId = AuthStuff.GetPlayerId(Request);
|
||||
if (!senderPlayerId.HasValue)
|
||||
return Unauthorized();
|
||||
|
||||
HashSet<long> recipientIds = await ReadLongValuesAsync(
|
||||
"ToPlayerIds",
|
||||
"toPlayerIds",
|
||||
"RecipientPlayerIds",
|
||||
"recipientPlayerIds",
|
||||
"RecipientIds",
|
||||
"recipientIds",
|
||||
"PlayerIds",
|
||||
"playerIds",
|
||||
"ToAccountIds",
|
||||
"toAccountIds");
|
||||
|
||||
HashSet<long> 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<long>();
|
||||
var blockedRecipients = new List<long>();
|
||||
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<JsonObject> items = LoadCustomAvatarItems(accountId.Value);
|
||||
List<JsonObject> 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<long> id)
|
||||
{
|
||||
var authId = AuthStuff.GetPlayerId(Request);
|
||||
if (authId == null)
|
||||
return Unauthorized();
|
||||
|
||||
if (id == null || id.Count == 0)
|
||||
return Ok(new List<PlayerDBClasses.PlayerProgressionDTO>());
|
||||
|
||||
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<IActionResult> 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<string, string>(
|
||||
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<long> id)
|
||||
{
|
||||
var authId = AuthStuff.GetPlayerId(Request);
|
||||
if (authId == null)
|
||||
return Unauthorized();
|
||||
|
||||
if (id == null || id.Count == 0)
|
||||
return Ok(new List<PlayerDBClasses.Reputation>());
|
||||
|
||||
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<PlayerDBClasses.SavedOutfit>();
|
||||
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<PlayerDBClasses.SavedOutfit>();
|
||||
|
||||
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<GiftPackage> 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<IActionResult> 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<GiftPackage> 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<long?> 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<object>(),
|
||||
Responses = Array.Empty<object>()
|
||||
});
|
||||
}
|
||||
|
||||
[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<IActionResult> 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<int?> 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<IActionResult> 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<long> 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<string> 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<IActionResult> GetStorefrontBalancesV2()
|
||||
{
|
||||
var accountId = AuthStuff.GetPlayerId(Request);
|
||||
if (accountId == null)
|
||||
return Unauthorized();
|
||||
|
||||
HashSet<long> requestedTypes = await ReadLongValuesAsync(
|
||||
"currencyType", "CurrencyType",
|
||||
"currencyTypes", "CurrencyTypes",
|
||||
"type", "Type");
|
||||
|
||||
int balanceType = (int)BalanceType.NonPurchasedDefault;
|
||||
|
||||
IEnumerable<CurrencyType> 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<JsonObject> 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<object>());
|
||||
}
|
||||
|
||||
[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<object>());
|
||||
}
|
||||
|
||||
[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<object>(),
|
||||
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<IActionResult> 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<object>()
|
||||
});
|
||||
}
|
||||
|
||||
[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<object>(),
|
||||
CollectionRecords = Array.Empty<object>(),
|
||||
CollectedKeepsakeIds = Array.Empty<long>()
|
||||
});
|
||||
}
|
||||
|
||||
[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<IActionResult> SendOfflineInvite(
|
||||
long? targetPlayerId = null,
|
||||
long? requestedRoomInstanceId = null,
|
||||
long? roomInstanceId = null)
|
||||
{
|
||||
long? inviterPlayerId = AuthStuff.GetPlayerId(Request);
|
||||
if (!inviterPlayerId.HasValue)
|
||||
return Unauthorized();
|
||||
|
||||
HashSet<long> 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<long> 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<long>();
|
||||
var missing = new List<long>();
|
||||
var blocked = new List<long>();
|
||||
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<object>());
|
||||
}
|
||||
|
||||
[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<object>());
|
||||
}
|
||||
|
||||
[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<IActionResult> 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<int, byte>();
|
||||
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<int, byte> 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<object>(),
|
||||
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<string>
|
||||
{
|
||||
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<object>());
|
||||
}
|
||||
|
||||
[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<CatalogSku> catalog = GetCatalogSkus()
|
||||
.Where(IsUsableStoreItem)
|
||||
.ToList();
|
||||
|
||||
List<int> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<string, string>());
|
||||
}
|
||||
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<IActionResult> 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<IActionResult> 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<string>(),
|
||||
PopularTags = filters,
|
||||
Tags = filters
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("api/playerevents/v1/room/{roomId}")]
|
||||
public IActionResult GetPlayerEventsForRoom(long roomId)
|
||||
{
|
||||
return Ok(Array.Empty<object>());
|
||||
}
|
||||
|
||||
[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<IActionResult> GetOrCreateThreadWithMembers()
|
||||
{
|
||||
|
||||
return await CreateOrOpenChatThreadAsync(wrapResult: false);
|
||||
}
|
||||
|
||||
[HttpPost("thread")]
|
||||
[RequestSizeLimit(64 * 1024)]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> CreateOrOpenChatThreadAsync(
|
||||
ParsedChatThreadRequest? parsedRequest = null,
|
||||
bool wrapResult = true)
|
||||
{
|
||||
long? accountId = AuthStuff.GetPlayerId(Request);
|
||||
if (!accountId.HasValue)
|
||||
return Unauthorized();
|
||||
|
||||
ParsedChatThreadRequest parsed =
|
||||
parsedRequest ?? await ReadChatThreadRequestAsync();
|
||||
HashSet<long> 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<IActionResult> SendOrCreateChatMessageAsync(
|
||||
ParsedChatThreadRequest parsed,
|
||||
string rawMessageContents)
|
||||
{
|
||||
long? accountId = AuthStuff.GetPlayerId(Request);
|
||||
if (!accountId.HasValue)
|
||||
return Unauthorized();
|
||||
|
||||
HashSet<long> 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<long> ResolveChatMemberIds(
|
||||
ParsedChatThreadRequest parsed,
|
||||
long requestingAccountId)
|
||||
{
|
||||
parsed.MemberIds.Remove(requestingAccountId);
|
||||
parsed.CandidateIds.Remove(requestingAccountId);
|
||||
|
||||
HashSet<long> validExplicitIds = parsed.MemberIds
|
||||
.Where(ChatPlayerExists)
|
||||
.ToHashSet();
|
||||
|
||||
HashSet<long> selectedIds = validExplicitIds.Count > 0
|
||||
? validExplicitIds
|
||||
: parsed.CandidateIds
|
||||
.Where(ChatPlayerExists)
|
||||
.ToHashSet();
|
||||
|
||||
selectedIds.Remove(requestingAccountId);
|
||||
return selectedIds;
|
||||
}
|
||||
|
||||
private void LogChatRequest(
|
||||
ParsedChatThreadRequest parsed,
|
||||
IEnumerable<long> 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<ParsedChatThreadRequest> ReadChatThreadRequestAsync()
|
||||
{
|
||||
var parsed = new ParsedChatThreadRequest();
|
||||
|
||||
foreach (KeyValuePair<string, Microsoft.Extensions.Primitives.StringValues> 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<string, Microsoft.Extensions.Primitives.StringValues> 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 "<empty>";
|
||||
|
||||
string oneLine = body
|
||||
.Replace("\r", " ")
|
||||
.Replace("\n", " ")
|
||||
.Trim();
|
||||
|
||||
return oneLine.Length <= 500
|
||||
? oneLine
|
||||
: oneLine[..500] + "...";
|
||||
}
|
||||
|
||||
private async Task<string?> 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<string> 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<long> output)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return;
|
||||
|
||||
foreach (System.Text.RegularExpressions.Match match in
|
||||
System.Text.RegularExpressions.Regex.Matches(
|
||||
value,
|
||||
@"(?<![0-9])-?[0-9]+"))
|
||||
{
|
||||
if (long.TryParse(match.Value, out long id) && id > 0)
|
||||
output.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ParsedChatThreadRequest
|
||||
{
|
||||
public HashSet<long> MemberIds { get; } = new();
|
||||
public HashSet<long> CandidateIds { get; } = new();
|
||||
public string? Name { get; set; }
|
||||
public int Type { get; set; }
|
||||
public string RawBodyPreview { get; set; } = "<not-read>";
|
||||
}
|
||||
|
||||
[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<IActionResult> SaveInvention()
|
||||
{
|
||||
long? accountId = AuthStuff.GetPlayerId(Request);
|
||||
if (!accountId.HasValue)
|
||||
return Unauthorized();
|
||||
|
||||
JsonElement request;
|
||||
var createdFiles = new List<string>();
|
||||
try
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
IFormCollection form = await Request.ReadFormAsync(
|
||||
HttpContext.RequestAborted);
|
||||
var payload = new JsonObject();
|
||||
foreach (KeyValuePair<string, Microsoft.Extensions.Primitives.StringValues> 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<string, Microsoft.Extensions.Primitives.StringValues> 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<string> 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<string>(out string? raw) &&
|
||||
!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
try
|
||||
{
|
||||
nested = JsonNode.Parse(raw) as JsonObject;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (nested == null)
|
||||
continue;
|
||||
|
||||
foreach (KeyValuePair<string, JsonNode?> 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<string> 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<string>(out string? text))
|
||||
return text;
|
||||
string raw = node.ToJsonString().Trim();
|
||||
if (raw.Length > 1 && raw[0] == '"' && raw[^1] == '"')
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<string>(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<string> SaveInventionPreviewAsync(
|
||||
IFormFile file,
|
||||
ICollection<string> 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<string> SaveInventionDataAsync(
|
||||
IFormFile file,
|
||||
ICollection<string> 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<string> 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<object>() });
|
||||
|
||||
string json = System.IO.File.ReadAllText(path);
|
||||
var entries = JsonSerializer.Deserialize<List<Dictionary<string, object>>>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
if (entries == null)
|
||||
return Ok(new { Categories = Array.Empty<object>() });
|
||||
|
||||
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<object>() });
|
||||
|
||||
try
|
||||
{
|
||||
JsonElement categories = JsonSerializer.Deserialize<JsonElement>(
|
||||
value.ToString() ?? "[]");
|
||||
return Ok(new { Categories = categories });
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Ok(new { Categories = Array.Empty<object>() });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("/api/PlayerCheer/v1/images")]
|
||||
public IActionResult GetPlayerCheersForImages([FromQuery] List<string> imageIds)
|
||||
{
|
||||
var id = AuthStuff.GetPlayerId(Request);
|
||||
if (id == null)
|
||||
return Unauthorized();
|
||||
|
||||
return Ok(new Dictionary<string, object>());
|
||||
}
|
||||
|
||||
[HttpPost("/api/PlayerCheer/v1/create")]
|
||||
[RequestSizeLimit(64 * 1024)]
|
||||
public async Task<IActionResult> CreatePlayerCheer()
|
||||
{
|
||||
var id = AuthStuff.GetPlayerId(Request);
|
||||
if (id == null)
|
||||
return Unauthorized();
|
||||
|
||||
var values = new Dictionary<string, string>(
|
||||
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<long> { 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<IActionResult> AcceptPlayerCheer() =>
|
||||
ResolvePlayerCheerAsync(CheerStatus.Accepted);
|
||||
|
||||
[HttpPost("/api/PlayerCheer/v1/dismiss")]
|
||||
[RequestSizeLimit(16 * 1024)]
|
||||
public Task<IActionResult> DismissPlayerCheer() =>
|
||||
ResolvePlayerCheerAsync(CheerStatus.Dismissed);
|
||||
|
||||
private async Task<IActionResult> ResolvePlayerCheerAsync(
|
||||
CheerStatus status)
|
||||
{
|
||||
long? playerId = AuthStuff.GetPlayerId(Request);
|
||||
if (!playerId.HasValue)
|
||||
return Unauthorized();
|
||||
|
||||
HashSet<long> 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<PlayerCheerRecord> 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<IActionResult> 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<string, string> 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<string, string> 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<object>(),
|
||||
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<IActionResult> 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<object>()
|
||||
})
|
||||
.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<object>()
|
||||
})
|
||||
.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<ParsedCatalogPurchaseRequest>
|
||||
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<KeyValuePair<string, Microsoft.Extensions.Primitives.StringValues>> values,
|
||||
IEnumerable<string> 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<KeyValuePair<string, Microsoft.Extensions.Primitives.StringValues>> values,
|
||||
IEnumerable<string> 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<string> names)
|
||||
{
|
||||
var nameSet = names.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return FindLongByNames(element, nameSet);
|
||||
}
|
||||
|
||||
private static long? FindLongByNames(
|
||||
JsonElement element,
|
||||
HashSet<string> 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<string> names)
|
||||
{
|
||||
var nameSet = names.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return FindStringByNames(element, nameSet);
|
||||
}
|
||||
|
||||
private static string? FindStringByNames(
|
||||
JsonElement element,
|
||||
HashSet<string> 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<CatalogSku> 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<CatalogSku>();
|
||||
|
||||
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<CatalogSku>();
|
||||
var jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
|
||||
if (hasAvatarCatalog)
|
||||
{
|
||||
var avatarItems = JsonSerializer.Deserialize<List<CatalogSourceItem>>(System.IO.File.ReadAllText(avatarPath), jsonOptions) ?? new();
|
||||
catalog.AddRange(avatarItems.Select(CreateAvatarCatalogSku));
|
||||
}
|
||||
|
||||
if (hasEquipmentCatalog)
|
||||
{
|
||||
var equipmentItems = JsonSerializer.Deserialize<List<PlayerDBClasses.EquipmentItem>>(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<JsonElement> consumables = document.RootElement.ValueKind == JsonValueKind.Array
|
||||
? document.RootElement.EnumerateArray().Select(item => item.Clone())
|
||||
: Enumerable.Empty<JsonElement>();
|
||||
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<CatalogGiftDrop> { 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<CatalogGiftDrop> { 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<CatalogGiftDrop> { 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<CatalogPrice> 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<long?> 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<CatalogSku> GetWatchStoreSkusWithHairDyes()
|
||||
{
|
||||
List<CatalogSku> storeItems = GetDailyStoreSkus(3);
|
||||
var identities = storeItems
|
||||
.Select(GetCatalogIdentity)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
IEnumerable<CatalogSku> 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<CatalogSku> GetDailyStoreSkus(int storeId)
|
||||
{
|
||||
|
||||
List<CatalogSku> 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<long> customSkuIds) = GetStorefrontSelectionState();
|
||||
Dictionary<long, CatalogSku> catalog = GetCatalogSkus()
|
||||
.ToDictionary(item => item.SkuId);
|
||||
List<StorefrontAdminItem> customItems = customSkuIds
|
||||
.Select(skuId => catalog.GetValueOrDefault(skuId))
|
||||
.Where(IsUsableStoreItem)
|
||||
.Cast<CatalogSku>()
|
||||
.Select(ToStorefrontAdminItem)
|
||||
.ToList();
|
||||
|
||||
return new StorefrontAdminInfo
|
||||
{
|
||||
RotationNonce = rotationNonce,
|
||||
CustomItems = customItems,
|
||||
NextRefresh = DateTime.Today.AddDays(1).ToUniversalTime()
|
||||
};
|
||||
}
|
||||
|
||||
public static List<StorefrontAdminItem> 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<StorefrontAdminItem> 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<StorefrontAdminItem> 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<StorefrontAdminItem> 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<long> 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<StorefrontAdminState>(
|
||||
System.IO.File.ReadAllText(path),
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
StorefrontAdminCache = null;
|
||||
}
|
||||
|
||||
StorefrontAdminCache ??= new StorefrontAdminState();
|
||||
StorefrontAdminCache.CustomSkuIds ??= new List<long>();
|
||||
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<long> CustomSkuIds { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StorefrontAdminInfo
|
||||
{
|
||||
public long RotationNonce { get; set; }
|
||||
public DateTime NextRefresh { get; set; }
|
||||
public List<StorefrontAdminItem> 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<CatalogSku> 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<int> 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<CatalogPrice> Prices { get; set; } = new();
|
||||
public CatalogGiftDrop GiftDrop { get; set; } = new();
|
||||
public List<CatalogGiftDrop> GiftDrops { get; set; } = new();
|
||||
}
|
||||
|
||||
[HttpGet("/api/customAvatarItems/v1/featured")]
|
||||
public IActionResult GetFeaturedCustomAvatarItems()
|
||||
{
|
||||
return Ok(Array.Empty<object>());
|
||||
}
|
||||
|
||||
[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<object>());
|
||||
}
|
||||
|
||||
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<string>? 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<string>()).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<SlideshowImageResponse>()
|
||||
.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<string>(),
|
||||
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<string>(),
|
||||
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<object>(),
|
||||
CollectionRecords = Array.Empty<object>(),
|
||||
CollectedKeepsakeIds = Array.Empty<long>()
|
||||
});
|
||||
}
|
||||
|
||||
private IActionResult ReadAnnouncementList()
|
||||
{
|
||||
string path = Path.Combine(Program.dataDir, "announcements.json");
|
||||
|
||||
if (!System.IO.File.Exists(path))
|
||||
return Ok(Array.Empty<object>());
|
||||
|
||||
string json = System.IO.File.ReadAllText(path);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return Ok(Array.Empty<object>());
|
||||
|
||||
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<object>());
|
||||
}
|
||||
|
||||
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<object>());
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("/announcements/v2/mine/unread")]
|
||||
public IActionResult GetUnreadAnnouncements()
|
||||
{
|
||||
|
||||
return Ok(Array.Empty<object>());
|
||||
}
|
||||
|
||||
[HttpGet("/announcements/v2/subscription/mine/unread")]
|
||||
public IActionResult GetUnreadSubscriptionAnnouncements()
|
||||
{
|
||||
return Ok(Array.Empty<object>());
|
||||
}
|
||||
|
||||
[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<IActionResult> 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<object>());
|
||||
}
|
||||
|
||||
[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<object>());
|
||||
}
|
||||
|
||||
[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<IActionResult> 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<JsonObject> LoadCustomAvatarItems(long playerId)
|
||||
{
|
||||
string itemsPath = Path.Combine(
|
||||
Program.dataDir,
|
||||
"CustomAvatarItems",
|
||||
playerId.ToString(),
|
||||
"items.json");
|
||||
|
||||
if (!System.IO.File.Exists(itemsPath))
|
||||
return new List<JsonObject>();
|
||||
|
||||
try
|
||||
{
|
||||
JsonNode? root = JsonNode.Parse(System.IO.File.ReadAllText(itemsPath));
|
||||
if (root is not JsonArray array)
|
||||
return new List<JsonObject>();
|
||||
|
||||
return array
|
||||
.OfType<JsonObject>()
|
||||
.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<JsonObject>();
|
||||
}
|
||||
}
|
||||
|
||||
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<TokenBundleResponse>
|
||||
{
|
||||
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> UnfavoritePlayer()
|
||||
{
|
||||
return SetRelationshipFlag(
|
||||
RelationshipFlag.Favorited,
|
||||
forcedEnabled: false);
|
||||
}
|
||||
|
||||
private enum RelationshipFlag
|
||||
{
|
||||
Ignored,
|
||||
Muted,
|
||||
Favorited
|
||||
}
|
||||
|
||||
private async Task<IActionResult> 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<string>()
|
||||
});
|
||||
|
||||
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<string, string>(
|
||||
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<HashSet<long>> ReadLongValuesAsync(params string[] keys)
|
||||
{
|
||||
var result = new HashSet<long>();
|
||||
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<string> keys,
|
||||
HashSet<long> 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<long> 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<object>(),
|
||||
BonusGiftDrops = Array.Empty<object>()
|
||||
};
|
||||
}
|
||||
|
||||
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<object>();
|
||||
public object[] BonusGiftDrops { get; set; } = Array.Empty<object>();
|
||||
}
|
||||
|
||||
public class SanitizeRequest
|
||||
{
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user