Need to fix index load issues

This commit is contained in:
gamer147
2026-05-23 14:50:16 -04:00
parent bf6ddf5428
commit 631e42289a
12 changed files with 351 additions and 119 deletions

View File

@@ -1,10 +1,13 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using MessagePack;
using MessagePack.Resolvers;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.Primitives;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using SVSim.Database.Models;
using SVSim.EmulatedEntrypoint.Constants;
using SVSim.EmulatedEntrypoint.Extensions;
@@ -23,6 +26,16 @@ public class ShadowverseTranslationMiddleware : IMiddleware
private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider;
private readonly ShadowverseSessionService _sessionService;
// Serialization policy MUST match what AddJsonOptions configured on the controllers, or the
// model binder won't find the snake_case keys we write into the synthetic request body and
// every request 400s with empty ModelState. WhenWritingNull is irrelevant for request
// serialization but kept here for symmetry.
private static readonly JsonSerializerOptions ControllerJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public ShadowverseTranslationMiddleware(IActionDescriptorCollectionProvider actionDescriptorCollectionProvider, ShadowverseSessionService sessionService)
{
_actionDescriptorCollectionProvider = actionDescriptorCollectionProvider;
@@ -58,9 +71,12 @@ public class ShadowverseTranslationMiddleware : IMiddleware
// Decrypt incoming data.
requestBytes = Encryption.Decrypt(requestBytes, udid);
object? data = MessagePackSerializer.Deserialize(endpointDescriptor.Parameters.FirstOrDefault().ParameterType,
requestBytes);
string json = JsonConvert.SerializeObject(data);
Type requestType = endpointDescriptor.Parameters.FirstOrDefault().ParameterType;
object? data = MessagePackSerializer.Deserialize(requestType, requestBytes);
// Re-serialize via System.Text.Json with the SAME options the controllers use, so the
// model binder sees snake_case keys it can match. Using JsonConvert here writes the
// CLR property names (PascalCase) and every property silently binds to default → 400.
string json = JsonSerializer.Serialize(data, requestType, ControllerJsonOptions);
StringContent newStream = new StringContent(json, Encoding.UTF8, "application/json");
context.Request.Body = newStream.ReadAsStream();
context.Request.Headers.ContentType = new StringValues("application/json");
@@ -68,19 +84,20 @@ public class ShadowverseTranslationMiddleware : IMiddleware
await next.Invoke(context);
Viewer? viewer = context.GetViewer();
// Grab the response object
Type responseType = ((ControllerActionDescriptor)endpointDescriptor).MethodInfo.ReturnType;
if (responseType.IsGenericType && responseType.GetGenericTypeDefinition() == typeof(Task<>))
{
responseType = responseType.GetGenericArguments()[0];
}
// Read the controller's JSON response body. System.Text.Json was configured with
// SnakeCaseLower + WhenWritingNull, so the JSON keys are already in the wire shape and
// null/optional properties have been omitted. Parse to a JToken tree to preserve that
// "absent vs null" information — going back through a typed DTO via JsonConvert would
// re-introduce nulls for missing properties and they'd reach the client as msgpack Nil.
using MemoryStream responseBytesStream = new MemoryStream();
context.Response.Body.Seek(0, SeekOrigin.Begin);
await context.Response.Body.CopyToAsync(responseBytesStream);
byte[] responseBytes = responseBytesStream.ToArray();
object? responseData = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(responseBytes), responseType);
string responseJson = Encoding.UTF8.GetString(responseBytesStream.ToArray());
object? responseData = string.IsNullOrEmpty(responseJson)
? null
: ConvertJsonTreeToPlainObject(JToken.Parse(responseJson));
// Wrap the response in a datawrapper
DataWrapper wrappedResponseData = new DataWrapper
{
@@ -88,8 +105,14 @@ public class ShadowverseTranslationMiddleware : IMiddleware
DataHeaders = new DataHeaders
{
Servertime = DateTime.UtcNow.Ticks,
Sid =
context.Request.Headers[NetworkConstants.SessionIdHeaderName].FirstOrDefault(),
// SID intentionally empty. See docs/api-spec/common/envelope.md §"SID
// rotation" — the client's SessionId is a hash-on-read property, so echoing
// the request's SID poisons its backing field and the next request hashes
// the hash, missing our SID→UDID dict and crashing decryption. To rotate
// sessions in the future, use the "stable-prefix + counter" pattern from
// that doc (Option B), and pre-hash the rotated value to index the map by
// what the client will actually send back on the next request.
Sid = "",
// TODO error handling
ResultCode = 1,
ShortUdid = viewer.ShortUdid,
@@ -97,10 +120,39 @@ public class ShadowverseTranslationMiddleware : IMiddleware
}
};
// Convert the response into a messagepack, encrypt it
byte[] packedData = MessagePackSerializer.Serialize<DataWrapper>(wrappedResponseData);
// Convert the response into a messagepack, encrypt it. ContractlessStandardResolver
// walks the DataWrapper's typed properties (DataHeaders) AND the boxed object/list/
// primitive tree under Data — emitting only the keys present in the dictionary.
var msgPackOptions = MessagePackSerializerOptions.Standard
.WithResolver(ContractlessStandardResolver.Instance);
byte[] packedData = MessagePackSerializer.Serialize(wrappedResponseData, msgPackOptions);
packedData = Encryption.Encrypt(packedData, udid);
await originalResponsebody.WriteAsync(Encoding.UTF8.GetBytes(Convert.ToBase64String(packedData)));
context.Response.Body = originalResponsebody;
}
/// <summary>
/// Walks a parsed JSON tree into the plain CLR shape MessagePack-CSharp's contractless
/// resolver understands: objects → <c>Dictionary&lt;string, object?&gt;</c>, arrays →
/// <c>List&lt;object?&gt;</c>, scalars unboxed to their nearest primitive. Crucially, JSON
/// objects that lacked a key DON'T get one in the dictionary — preserving "absent" as a
/// distinct state from "null" all the way to the msgpack writer.
/// </summary>
internal static object? ConvertJsonTreeToPlainObject(JToken? token)
{
if (token is null || token.Type == JTokenType.Null) return null;
return token.Type switch
{
JTokenType.Object => token.Children<JProperty>()
.ToDictionary(p => p.Name, p => ConvertJsonTreeToPlainObject(p.Value)),
JTokenType.Array => token.Children().Select(ConvertJsonTreeToPlainObject).ToList(),
JTokenType.Integer => token.Value<long>(),
JTokenType.Float => token.Value<double>(),
JTokenType.String => token.Value<string>(),
JTokenType.Boolean => token.Value<bool>(),
JTokenType.Date => token.Value<DateTime>(),
JTokenType.Bytes => token.Value<byte[]>(),
_ => token.ToString()
};
}
}

View File

@@ -48,13 +48,20 @@ public class GameStartResponse
[Key("transition_account_data")]
public List<TransitionAccountData> TransitionAccountData { get; set; } = new();
// INTENTIONALLY OMITTED: `rewrite_viewer_id` and `account_delete_reservation_status`.
// Both are presence-checked by the client via `Keys.Contains(...)` + `.ToInt()` with no
// null guard. MessagePack-CSharp writes [Key] properties unconditionally (null → Nil),
// and the System.Text.Json `WhenWritingNull` ignore only affects the plain-JSON path.
// So including these as nullable properties is a guaranteed NRE on the encrypted client
// path. We don't need them — the client tolerates their absence — so don't declare them.
// Re-add only if we have a real value to send.
/// <summary>
/// When present, client overwrites <c>Certification.ViewerId</c> with this value. Optional
/// — leave null to omit. The serialization pipeline (JSON + msgpack via the translation
/// middleware) drops null properties end-to-end, so the client sees the key as absent.
/// </summary>
[Key("rewrite_viewer_id")]
public long? RewriteViewerId { get; set; }
/// <summary>
/// Presence indicates the user has applied for account deletion (value ignored by client at
/// this stage). Optional — leave null to omit.
/// </summary>
[Key("account_delete_reservation_status")]
public int? AccountDeleteReservationStatus { get; set; }
// --- Agreement / consent state (all required) ---

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
@@ -22,10 +23,16 @@ public class Program
builder.Services.AddControllers().AddJsonOptions(opt =>
{
// Wire-format congruence: the encrypted msgpack path uses snake_case [Key("...")]
// names; the plain-JSON path runs through System.Text.Json. Match them by using
// SnakeCaseLower naming policy here so both paths emit identical key names — and
// so the translation middleware can hand JSON keys straight through to msgpack
// without per-property name remapping.
opt.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
// Production omits null/optional fields entirely; the client uses
// `Keys.Contains(name)` as a presence check and calls `.ToInt()` (etc.) on the
// value without a null guard. Emitting `"key":null` makes Contains return true and
// crashes the client. Match prod by dropping nulls during serialization.
// crashes the client. Drop nulls during serialization so missing == absent.
opt.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle

View File

@@ -28,6 +28,10 @@
</Content>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="SVSim.UnitTests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SVSim.Database\SVSim.Database.csproj" />
</ItemGroup>

View File

@@ -1,9 +1,9 @@
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Viewer;
@@ -16,6 +16,14 @@ namespace SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
public class SteamSessionAuthenticationHandler : AuthenticationHandler<SteamAuthenticationHandlerOptions>
{
// Must mirror the controller-side JSON options — the translation middleware rewrites the
// request body in snake_case, and we have to read it back the same way or every property
// binds to null and we NRE downstream against the Steam ticket.
private static readonly JsonSerializerOptions RequestJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
private readonly SteamSessionService _sessionService;
private readonly IViewerRepository _viewerRepository;
public SteamSessionAuthenticationHandler(IOptionsMonitor<SteamAuthenticationHandlerOptions> options, ILoggerFactory logger, UrlEncoder encoder, SteamSessionService sessionService, IViewerRepository viewerRepository) : base(options, logger, encoder)
@@ -48,13 +56,21 @@ public class SteamSessionAuthenticationHandler : AuthenticationHandler<SteamAuth
// Convert bytes to json
string requestString = Encoding.UTF8.GetString(requestBytes);
BaseRequest? requestJson = JsonConvert.DeserializeObject<BaseRequest>(requestString);
if (requestJson is null)
BaseRequest? requestJson;
try
{
requestJson = JsonSerializer.Deserialize<BaseRequest>(requestString, RequestJsonOptions);
}
catch (JsonException)
{
return AuthenticateResult.Fail("Invalid request body.");
}
if (requestJson is null || string.IsNullOrEmpty(requestJson.SteamSessionTicket))
{
return AuthenticateResult.Fail("Invalid request body.");
}
// Check steam session validity
bool sessionIsValid = _sessionService.IsTicketValidForUser(requestJson.SteamSessionTicket, requestJson.SteamId);
if (!sessionIsValid)

View File

@@ -19,6 +19,14 @@ public class SteamSessionService : IDisposable
/// <returns>whether the ticket is valid for the given steamid</returns>
public bool IsTicketValidForUser(string ticket, ulong steamId)
{
if (string.IsNullOrEmpty(ticket))
{
// Caller already shouldn't pass null/empty here, but a misshaped request body
// (e.g. wrong casing) used to NRE on the ConcurrentDictionary lookup below.
// Fail cleanly so the auth pipeline returns 401 instead of crashing the request.
return false;
}
if (_validatedSessionTickets.TryGetValue(ticket, out ulong storedSteamId))
{
return storedSteamId == steamId;