Build & Deploy / build (push) Successful in 41s
Accounts were created as 'Unknown' and the client never sends a name, so the admin panel / friends / squad showed no names. Add SteamWebApiService (GetPlayerSummaries), wired into LoginWithSteam to backfill the DisplayName from the Steam persona when it's still 'Unknown'. Admin gets a LAN-only /admin/backfill-names action (+ button) to backfill existing accounts in one shot. Key read from config SteamWebApiKey (Vault -> env), inert when unset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
515 lines
22 KiB
C#
515 lines
22 KiB
C#
using System.Net.Mime;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Options;
|
|
using Prospect.Server.Api.Config;
|
|
using Prospect.Server.Api.Models.Client;
|
|
using Prospect.Server.Api.Models.Client.Data;
|
|
using Prospect.Server.Api.Models.Data;
|
|
using Prospect.Server.Api.Services.Auth;
|
|
using Prospect.Server.Api.Services.Auth.Extensions;
|
|
using Prospect.Server.Api.Services.Auth.User;
|
|
using Prospect.Server.Api.Services.Database;
|
|
using Prospect.Server.Api.Services.Database.Models;
|
|
using Prospect.Server.Api.Services.UserData;
|
|
using Prospect.Steam;
|
|
|
|
namespace Prospect.Server.Api.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("Client")]
|
|
public class ClientController : Controller
|
|
{
|
|
private const int AppIdDefault = 480;
|
|
private const int AppIdCycleBeta = 1600361;
|
|
private const int AppIdCycle = 868270;
|
|
|
|
private readonly ILogger<ClientController> _logger;
|
|
private readonly PlayFabSettings _settings;
|
|
private readonly AuthTokenService _authTokenService;
|
|
private readonly DbUserService _userService;
|
|
private readonly DbEntityService _entityService;
|
|
private readonly UserDataService _userDataService;
|
|
private readonly TitleDataService _titleDataService;
|
|
private readonly Prospect.Server.Api.Services.Steam.SteamWebApiService _steamWebApi;
|
|
|
|
public ClientController(ILogger<ClientController> logger,
|
|
IOptions<PlayFabSettings> settings,
|
|
AuthTokenService authTokenService,
|
|
DbUserService userService,
|
|
DbEntityService entityService,
|
|
UserDataService userDataService,
|
|
TitleDataService titleDataService,
|
|
Prospect.Server.Api.Services.Steam.SteamWebApiService steamWebApi)
|
|
{
|
|
_settings = settings.Value;
|
|
_logger = logger;
|
|
_authTokenService = authTokenService;
|
|
_userService = userService;
|
|
_entityService = entityService;
|
|
_userDataService = userDataService;
|
|
_titleDataService = titleDataService;
|
|
_steamWebApi = steamWebApi;
|
|
}
|
|
|
|
[AllowAnonymous]
|
|
[HttpPost("LoginWithSteam")]
|
|
[Produces(MediaTypeNames.Application.Json)]
|
|
public async Task<IActionResult> LoginWithSteam(FLoginWithSteamRequest request)
|
|
{
|
|
if (!string.IsNullOrEmpty(request.SteamTicket))
|
|
{
|
|
var ticket = AppTicket.Parse(request.SteamTicket);
|
|
if (ticket.IsValid && ticket.HasValidSignature && (ticket.AppId == AppIdDefault || ticket.AppId == AppIdCycleBeta || ticket.AppId == AppIdCycle))
|
|
{
|
|
var userSteamId = ticket.SteamId.ToString();
|
|
|
|
var user = await _userService.FindOrCreateAsync(PlayFabUserAuthType.Steam, userSteamId);
|
|
|
|
// Backfill the display name from the Steam persona when we don't have a real
|
|
// one yet (accounts are created as "Unknown"; the client never sends a name).
|
|
if (string.IsNullOrEmpty(user.DisplayName) || user.DisplayName == "Unknown")
|
|
{
|
|
var persona = await _steamWebApi.GetPersonaNameAsync(userSteamId);
|
|
if (!string.IsNullOrWhiteSpace(persona))
|
|
{
|
|
await _userService.UpdateDisplayNameAsync(user.Id, persona);
|
|
user.DisplayName = persona;
|
|
}
|
|
}
|
|
|
|
var entity = await _entityService.FindOrCreateAsync(user.Id);
|
|
|
|
var userTicket = _authTokenService.GenerateUser(entity);
|
|
var entityTicket = _authTokenService.GenerateEntity(entity);
|
|
|
|
await _userDataService.InitAsync(user.Id);
|
|
|
|
return Ok(new ClientResponse<FServerLoginResult>
|
|
{
|
|
Code = 200,
|
|
Status = "OK",
|
|
Data = new FServerLoginResult
|
|
{
|
|
EntityToken = new FEntityTokenResponse
|
|
{
|
|
Entity = new FEntityKey
|
|
{
|
|
Id = entity.Id,
|
|
Type = "title_player_account",
|
|
TypeString = "title_player_account"
|
|
},
|
|
EntityToken = entityTicket,
|
|
TokenExpiration = DateTime.UtcNow.AddDays(6), // TODO:
|
|
},
|
|
InfoResultPayload = new FGetPlayerCombinedInfoResultPayload
|
|
{
|
|
CharacterInventories = new List<object>(),
|
|
PlayerProfile = new FPlayerProfileModel
|
|
{
|
|
DisplayName = user.DisplayName,
|
|
PlayerId = user.Id,
|
|
PublisherId = _settings.PublisherId,
|
|
TitleId = _settings.TitleId
|
|
},
|
|
UserDataVersion = 0,
|
|
UserInventory = new List<object>(),
|
|
UserReadOnlyDataVersion = 0
|
|
},
|
|
LastLoginTime = DateTime.UtcNow, // TODO:
|
|
NewlyCreated = false, // TODO:
|
|
PlayFabId = user.Id,
|
|
SessionTicket = userTicket,
|
|
SettingsForUser = new FUserSettings
|
|
{
|
|
GatherDeviceInfo = true,
|
|
GatherFocusInfo = true,
|
|
NeedsAttribution = false,
|
|
},
|
|
TreatmentAssignment = new FTreatmentAssignment
|
|
{
|
|
Variables = new List<FVariable>(),
|
|
Variants = new List<string>()
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
_logger.LogWarning("Invalid steam ticket specified, IsExpired {IsExpired}, IsValid {IsValid}, HasValidSignature {Sig}, AppId {AppId}",
|
|
ticket.IsExpired,
|
|
ticket.IsValid,
|
|
ticket.HasValidSignature,
|
|
ticket.AppId);
|
|
}
|
|
|
|
return BadRequest(new ClientResponse
|
|
{
|
|
Code = 400,
|
|
Status = "BadRequest",
|
|
Error = "InvalidSteamTicket",
|
|
ErrorCode = 1010,
|
|
ErrorMessage = "Steam API AuthenticateUserTicket error response .."
|
|
});
|
|
}
|
|
|
|
[HttpPost("AddGenericID")]
|
|
[Produces(MediaTypeNames.Application.Json)]
|
|
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
|
|
public async Task<IActionResult> AddGenericId(FAddGenericIDRequest request)
|
|
{
|
|
// Persist the linked generic service id instead of dropping it (was a no-op stub).
|
|
var userId = User.FindAuthUserId();
|
|
if (request.GenericId != null && !string.IsNullOrEmpty(request.GenericId.ServiceName))
|
|
{
|
|
await _userDataService.UpdateAsync(userId, userId, new Dictionary<string, string>
|
|
{
|
|
["GenericId_" + request.GenericId.ServiceName] = request.GenericId.UserId ?? ""
|
|
});
|
|
}
|
|
|
|
return Ok(new ClientResponse<object>
|
|
{
|
|
Code = 200,
|
|
Status = "OK",
|
|
Data = new {}
|
|
});
|
|
}
|
|
|
|
[HttpPost("GetStoreItems")]
|
|
[Produces(MediaTypeNames.Application.Json)]
|
|
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
|
|
public IActionResult GetStoreItems(FGetStoreItems request)
|
|
{
|
|
// The vendors (Korolev / ICA / Osiris / QuickShop) and other stores are defined
|
|
// inside the Blueprints title data: each item lists a per-store entry under
|
|
// ItemShopsCraftingData keyed by the store id, with the currency price in its
|
|
// recipe ingredients. This endpoint was previously a stub returning {}, which is
|
|
// why every shop showed up empty. We build the store on the fly from that data.
|
|
var storeId = request.StoreId ?? "";
|
|
var store = new List<FStoreItem>();
|
|
try
|
|
{
|
|
var titleData = _titleDataService.Find(new List<string> { "Blueprints" });
|
|
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]);
|
|
if (blueprints != null && !string.IsNullOrEmpty(storeId))
|
|
{
|
|
uint position = 0;
|
|
foreach (var (itemId, blueprint) in blueprints)
|
|
{
|
|
if (blueprint.ItemShopsCraftingData == null) continue;
|
|
if (!blueprint.ItemShopsCraftingData.TryGetValue(storeId, out var shopData)) continue;
|
|
if (shopData?.ItemRecipeIngredients == null) continue;
|
|
|
|
// Only currency ingredients form a purchasable price. Entries whose
|
|
// recipe is item-based (crafting) are handled by the crafting UI from
|
|
// the blueprint data the client already has, not by the buy store.
|
|
var prices = new Dictionary<string, uint>();
|
|
foreach (var ingredient in shopData.ItemRecipeIngredients)
|
|
{
|
|
var code = ingredient.Currency switch
|
|
{
|
|
"SoftCurrency" => "SC",
|
|
"Aurum" => "AU",
|
|
"InsuranceCurrency" => "IN",
|
|
_ => null,
|
|
};
|
|
if (code == null || ingredient.Amount < 0) continue;
|
|
prices[code] = prices.TryGetValue(code, out var current)
|
|
? current + (uint)ingredient.Amount
|
|
: (uint)ingredient.Amount;
|
|
}
|
|
if (prices.Count == 0) continue;
|
|
|
|
store.Add(new FStoreItem
|
|
{
|
|
ItemId = itemId,
|
|
VirtualCurrencyPrices = prices,
|
|
RealCurrencyPrices = new Dictionary<string, uint>(),
|
|
DisplayPosition = position++,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
_logger.LogWarning(e, "Failed to build store items for store {StoreId}", storeId);
|
|
}
|
|
|
|
// Cosmetic / premium stores are backed by the Vanities title data (not Blueprints):
|
|
// each vanity carries its price in StoreData.Amount, paid in Aurum. AurumShop lists
|
|
// the whole priced catalogue; the daily/weekly rotations (ids from RequestStore
|
|
// RotationData) show a deterministic slice so they change over time but stay stable
|
|
// within a period.
|
|
var isAurumShop = storeId == "AurumShop";
|
|
var isDailyShop = storeId.StartsWith("DailyShop_");
|
|
var isWeeklyShop = storeId.StartsWith("WeeklyShop_");
|
|
if (store.Count == 0 && (isAurumShop || isDailyShop || isWeeklyShop))
|
|
{
|
|
try
|
|
{
|
|
var vanityData = _titleDataService.Find(new List<string> { "Vanities" });
|
|
var vanities = JsonSerializer.Deserialize<List<TitleDataVanityInfo>>(vanityData["Vanities"]);
|
|
var priced = vanities?
|
|
.Where(v => v.StoreData != null && v.StoreData.Amount > 0)
|
|
.OrderBy(v => v.Name, StringComparer.Ordinal)
|
|
.ToList() ?? new List<TitleDataVanityInfo>();
|
|
|
|
IEnumerable<TitleDataVanityInfo> selection = priced;
|
|
if ((isDailyShop || isWeeklyShop) && priced.Count > 0)
|
|
{
|
|
var size = isWeeklyShop ? 12 : 6;
|
|
int.TryParse(storeId[(storeId.IndexOf('_') + 1)..], out var rotation);
|
|
var start = (int)(((long)rotation * size) % priced.Count);
|
|
selection = Enumerable.Range(0, Math.Min(size, priced.Count))
|
|
.Select(i => priced[(start + i) % priced.Count]);
|
|
}
|
|
|
|
uint position = 0;
|
|
foreach (var vanity in selection)
|
|
{
|
|
store.Add(new FStoreItem
|
|
{
|
|
ItemId = vanity.Name,
|
|
VirtualCurrencyPrices = new Dictionary<string, uint> { ["AU"] = (uint)vanity.StoreData.Amount },
|
|
RealCurrencyPrices = new Dictionary<string, uint>(),
|
|
DisplayPosition = position++,
|
|
});
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
_logger.LogWarning(e, "Failed to build vanity store {StoreId}", storeId);
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("GetStoreItems '{StoreId}': {Count} item(s)", storeId, store.Count);
|
|
return Ok(new ClientResponse<FGetStoreItemsResult>
|
|
{
|
|
Code = 200,
|
|
Status = "OK",
|
|
Data = new FGetStoreItemsResult
|
|
{
|
|
StoreId = string.IsNullOrEmpty(storeId) ? null : storeId,
|
|
CatalogVersion = "StaticItems",
|
|
Store = store,
|
|
}
|
|
});
|
|
}
|
|
|
|
[HttpPost("UpdateUserTitleDisplayName")]
|
|
[Produces(MediaTypeNames.Application.Json)]
|
|
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
|
|
public async Task<IActionResult> UpdateUserTitleDisplayName(FUpdateUserTitleDisplayNameRequest request)
|
|
{
|
|
// Persist the rename, otherwise it is echoed back but lost on next login.
|
|
var userId = User.FindAuthUserId();
|
|
await _userService.UpdateDisplayNameAsync(userId, request.DisplayName);
|
|
|
|
return Ok(new ClientResponse<FUpdateUserTitleDisplayNameResult>
|
|
{
|
|
Code = 200,
|
|
Status = "OK",
|
|
Data = new FUpdateUserTitleDisplayNameResult
|
|
{
|
|
DisplayName = request.DisplayName
|
|
}
|
|
});
|
|
}
|
|
|
|
[HttpPost("UpdateUserData")]
|
|
[Produces(MediaTypeNames.Application.Json)]
|
|
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
|
|
public async Task<IActionResult> UpdateUserData(FUpdateUserDataRequest request)
|
|
{
|
|
var userId = User.FindAuthUserId();
|
|
await _userDataService.UpdateAsync(userId, request.PlayFabId, request.Data);
|
|
|
|
return Ok(new ClientResponse<FUpdateUserDataResult>
|
|
{
|
|
Code = 200,
|
|
Status = "OK",
|
|
Data = new FUpdateUserDataResult
|
|
{
|
|
DataVersion = 0
|
|
}
|
|
});
|
|
}
|
|
|
|
[HttpPost("GetUserData")]
|
|
[Produces(MediaTypeNames.Application.Json)]
|
|
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
|
|
public async Task<IActionResult> GetUserData(FGetUserDataRequest request)
|
|
{
|
|
var userId = User.FindAuthUserId();
|
|
var userData = await _userDataService.FindAsync(userId, request.PlayFabId, request.Keys);
|
|
|
|
return Ok(new ClientResponse<FGetUserDataResult>
|
|
{
|
|
Code = 200,
|
|
Status = "OK",
|
|
Data = new FGetUserDataResult
|
|
{
|
|
Data = userData,
|
|
DataVersion = 0
|
|
}
|
|
});
|
|
}
|
|
|
|
[HttpPost("GetUserReadOnlyData")]
|
|
[Produces(MediaTypeNames.Application.Json)]
|
|
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
|
|
public async Task<IActionResult> GetUserReadOnlyData(FGetUserDataRequest request)
|
|
{
|
|
var userId = User.FindAuthUserId();
|
|
var userData = await _userDataService.FindAsync(userId, request.PlayFabId, request.Keys);
|
|
|
|
return Ok(new ClientResponse<FGetUserDataResult>
|
|
{
|
|
Code = 200,
|
|
Status = "OK",
|
|
Data = new FGetUserDataResult
|
|
{
|
|
Data = userData,
|
|
DataVersion = 0
|
|
}
|
|
});
|
|
}
|
|
|
|
[HttpPost("GetUserInventory")]
|
|
[Produces(MediaTypeNames.Application.Json)]
|
|
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
|
|
public async Task<IActionResult> GetUserInventory(FGetUserInventoryRequest request)
|
|
{
|
|
var userId = User.FindAuthUserId();
|
|
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"Balance", "Inventory", "VanityItems", "CharacterVanity", "GlobalVanity"});
|
|
var inventory = JsonSerializer.Deserialize<FYCustomItemInfo[]>(userData["Inventory"].Value);
|
|
// Per-user vanity ownership: the player owns the cosmetics they've bought (VanityItems)
|
|
// plus whatever is currently equipped (CharacterVanity / GlobalVanity), so the starter
|
|
// outfit stays owned. Previously the whole Vanities catalogue was returned as owned,
|
|
// which made every item in the Aurum shop show up as already "owned".
|
|
var ownedVanityIds = CollectOwnedVanityIds(userData);
|
|
var titleData = _titleDataService.Find(new List<string>{"Blueprints"});
|
|
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]);
|
|
List<FItemInstance> items = new List<FItemInstance>();
|
|
foreach (var item in inventory) {
|
|
if (!blueprints.ContainsKey(item.BaseItemId)) {
|
|
_logger.LogWarning("Failed to find item with ID {ItemID}", item.BaseItemId);
|
|
continue;
|
|
}
|
|
var blueprintData = blueprints[item.BaseItemId];
|
|
items.Add(new FItemInstance {
|
|
ItemId = item.BaseItemId,
|
|
ItemInstanceId = item.ItemId,
|
|
ItemClass = blueprintData.Kind,
|
|
// PurchaseDate = DateTime.Now, // TODO
|
|
// UnitPrice = 0,
|
|
CatalogVersion = "StaticItems",
|
|
CustomData = new Dictionary<string, string> {
|
|
["mods"] = JsonSerializer.Serialize(item.ModData),
|
|
// ["insurance"] = "None", // TODO
|
|
["vanity"] = $"{{\"p\":{item.PrimaryVanityId},\"s\":{item.SecondaryVanityId}}}", // p - primary, s - secondary
|
|
["coreData"] = $"{{\"a\":{item.Amount},\"d\":{item.Durability}}}", // a - amount, d - durability
|
|
["origin"] = JsonSerializer.Serialize(item.Origin),
|
|
["rolledPerks"] = JsonSerializer.Serialize(item.RolledPerks),
|
|
}
|
|
});
|
|
}
|
|
|
|
foreach (var vanityId in ownedVanityIds) {
|
|
items.Add(new FItemInstance {
|
|
ItemId = vanityId,
|
|
// ItemInstanceId is not required since it's not a unique item.
|
|
ItemClass = "Vanity",
|
|
// PurchaseDate = DateTime.Now, // TODO
|
|
CatalogVersion = "StaticItems",
|
|
CustomData = new Dictionary<string, string> {
|
|
["coreData"] = "{}", // Vanity items don't seem to have any coreData. But it's still required since it's an inventory item.
|
|
["origin"] = "{}",
|
|
}
|
|
});
|
|
}
|
|
|
|
return Ok(new ClientResponse<FGetUserInventoryResult>
|
|
{
|
|
Code = 200,
|
|
Status = "OK",
|
|
Data = new FGetUserInventoryResult
|
|
{
|
|
Inventory = items,
|
|
VirtualCurrency = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value),
|
|
VirtualCurrencyRechargeTimes = new Dictionary<string, FVirtualCurrencyRechargeTime>()
|
|
}
|
|
});
|
|
}
|
|
|
|
// Collects the vanity ids a player actually owns: everything they've purchased
|
|
// (VanityItems) plus everything currently equipped (CharacterVanity slots and the
|
|
// GlobalVanity sprays / banner / emotes / drop pod). Parsed defensively with
|
|
// JsonDocument so malformed / partial data never breaks the inventory response.
|
|
private static HashSet<string> CollectOwnedVanityIds(Dictionary<string, FUserDataRecord> userData)
|
|
{
|
|
var owned = new HashSet<string>(StringComparer.Ordinal);
|
|
void AddId(string? id) { if (!string.IsNullOrEmpty(id)) owned.Add(id); }
|
|
|
|
if (userData.TryGetValue("VanityItems", out var viRec) && !string.IsNullOrWhiteSpace(viRec.Value))
|
|
{
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(viRec.Value);
|
|
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
|
foreach (var el in doc.RootElement.EnumerateArray())
|
|
if (el.ValueKind == JsonValueKind.Object && el.TryGetProperty("baseItemId", out var b))
|
|
AddId(b.GetString());
|
|
}
|
|
catch { /* ignore malformed VanityItems */ }
|
|
}
|
|
|
|
if (userData.TryGetValue("CharacterVanity", out var cvRec) && !string.IsNullOrWhiteSpace(cvRec.Value))
|
|
{
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(cvRec.Value);
|
|
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
|
foreach (var prop in doc.RootElement.EnumerateObject())
|
|
if (prop.Value.ValueKind == JsonValueKind.Object && prop.Value.TryGetProperty("id", out var id))
|
|
AddId(id.GetString());
|
|
}
|
|
catch { /* ignore malformed CharacterVanity */ }
|
|
}
|
|
|
|
if (userData.TryGetValue("GlobalVanity", out var gvRec) && !string.IsNullOrWhiteSpace(gvRec.Value))
|
|
{
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(gvRec.Value);
|
|
var root = doc.RootElement;
|
|
if (root.ValueKind == JsonValueKind.Object)
|
|
{
|
|
if (root.TryGetProperty("activeGlobalVanityIds", out var arr) && arr.ValueKind == JsonValueKind.Array)
|
|
foreach (var el in arr.EnumerateArray()) AddId(el.GetString());
|
|
if (root.TryGetProperty("droppodId", out var dp)) AddId(dp.GetString());
|
|
}
|
|
}
|
|
catch { /* ignore malformed GlobalVanity */ }
|
|
}
|
|
|
|
return owned;
|
|
}
|
|
|
|
[HttpPost("GetTitleData")]
|
|
[Produces(MediaTypeNames.Application.Json)]
|
|
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
|
|
public IActionResult GetTitleData(FGetTitleDataRequest request)
|
|
{
|
|
return Ok(new ClientResponse<FGetTitleDataResult>
|
|
{
|
|
Code = 200,
|
|
Status = "OK",
|
|
Data = new FGetTitleDataResult()
|
|
{
|
|
Data = _titleDataService.Find(request.Keys)
|
|
}
|
|
});
|
|
}
|
|
} |