Two issues caught in a real-client smoke run against the freshly
bootstrapped DB:
1. NRE in ShadowverseTranslationMiddleware for parameterless actions.
Five new actions (Sleeve.Info, LeaderSkin.{Ids,Products},
ItemPurchase.Info, SpotCardExchange.Top) took no parameters, but
the middleware does
`endpointDescriptor.Parameters.FirstOrDefault().ParameterType`
to discover the request DTO — `FirstOrDefault` returns null on a
zero-param action and `.ParameterType` NREs. Tests didn't catch it
because the test client POSTs plain JSON, bypassing this path.
Fix: each action now takes `BaseRequest _` matching the codebase
convention (PuzzleController.Info, BattlePassController.Info, etc.),
plus the middleware throws an actionable
InvalidOperationException pointing at the convention so the next
contributor doesn't repeat the mistake.
2. Leader-skin set sale showed up as "FREE / Claim" with empty
Includes panel after the viewer bought every skin in a series
with no configured bonus items. Root cause: ComputeRewardStatus
emitted status=1 (not_got) when set_sales_status != 0 regardless
of whether rewards.items was empty, and SkinPurchaseInfoTask.
CreateSetSaleInfo flags `is_free=true` on (is_completed &&
not_got). Prod ships status=0 when items is empty even with
set_sales_status==1 — we now mirror that.
504 tests still pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
236 lines
12 KiB
C#
236 lines
12 KiB
C#
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.Linq;
|
|
using SVSim.Database.Models;
|
|
using SVSim.EmulatedEntrypoint.Constants;
|
|
using SVSim.EmulatedEntrypoint.Extensions;
|
|
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Internal;
|
|
using SVSim.EmulatedEntrypoint.Security;
|
|
using SVSim.EmulatedEntrypoint.Services;
|
|
|
|
namespace SVSim.EmulatedEntrypoint.Middlewares;
|
|
|
|
/// <summary>
|
|
/// Translates incoming requests and outgoing responses from the Shadowverse client into the messagepack format.
|
|
/// </summary>
|
|
public class ShadowverseTranslationMiddleware : IMiddleware
|
|
{
|
|
private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider;
|
|
private readonly ShadowverseSessionService _sessionService;
|
|
private readonly ILogger<ShadowverseTranslationMiddleware> _logger;
|
|
|
|
// 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,
|
|
ILogger<ShadowverseTranslationMiddleware> logger)
|
|
{
|
|
_actionDescriptorCollectionProvider = actionDescriptorCollectionProvider;
|
|
_sessionService = sessionService;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
|
|
{
|
|
bool isUnity = context.Request.Headers.UserAgent.Any(agent => agent?.Contains("UnityPlayer") ?? false);
|
|
string path = context.Request.Path;
|
|
ActionDescriptor? endpointDescriptor =
|
|
_actionDescriptorCollectionProvider.ActionDescriptors.Items.FirstOrDefault(ad =>
|
|
$"/{ad.AttributeRouteInfo.Template}".Equals(path, StringComparison.InvariantCultureIgnoreCase));
|
|
if (!isUnity || endpointDescriptor == null)
|
|
{
|
|
await next.Invoke(context);
|
|
return;
|
|
}
|
|
|
|
// Replace response body stream to re-access it.
|
|
using MemoryStream tempResponseBody = new MemoryStream();
|
|
Stream originalResponsebody = context.Response.Body;
|
|
context.Response.Body = tempResponseBody;
|
|
|
|
// Pull out the request bytes into a stream
|
|
using MemoryStream requestBytesStream = new MemoryStream();
|
|
await context.Request.Body.CopyToAsync(requestBytesStream);
|
|
byte[] requestBytes = requestBytesStream.ToArray();
|
|
|
|
// Get encryption values for this request
|
|
string sid = context.Request.Headers[NetworkConstants.SessionIdHeaderName];
|
|
Guid? mappedUdid = _sessionService.GetUdidFromSessionId(sid);
|
|
if (mappedUdid is null)
|
|
{
|
|
// Per design (2026-05-25): warn and continue. Decrypt will fail with Guid.Empty as
|
|
// the AES key, surfacing as a msgpack/decrypt error below — but now the *root cause*
|
|
// (the SID wasn't in our dict, likely because the prior request didn't include a UDID
|
|
// header or the server was restarted between handshake and this call) is in the log.
|
|
_logger.LogWarning(
|
|
"No UDID mapping for SID on {Path} (sid={Sid}). Falling back to Guid.Empty — the following decrypt/msgpack error is almost certainly caused by this.",
|
|
path, sid);
|
|
}
|
|
string udid = mappedUdid.GetValueOrDefault().ToString();
|
|
|
|
// Decrypt incoming data.
|
|
byte[] decryptedBytes;
|
|
try
|
|
{
|
|
decryptedBytes = Encryption.Decrypt(requestBytes, udid);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex,
|
|
"Decrypt failed for {Path} (udid={Udid}, encryptedLen={EncryptedLen}). " +
|
|
"If udid is all-zero, see the preceding 'No UDID mapping' warning.",
|
|
path, udid, requestBytes.Length);
|
|
throw;
|
|
}
|
|
|
|
var firstParam = endpointDescriptor.Parameters.FirstOrDefault();
|
|
if (firstParam is null)
|
|
{
|
|
// Action method has no parameters — middleware can't bind the (encrypted+msgpacked)
|
|
// body to anything. The codebase convention is to take a BaseRequest even for body-
|
|
// less endpoints (see e.g. PuzzleController.Info(BaseRequest _)). Fail loud with a
|
|
// specific message rather than NREing below on .ParameterType.
|
|
throw new InvalidOperationException(
|
|
$"Action {endpointDescriptor.DisplayName} has no parameters; the SV translation " +
|
|
"middleware needs at least one to bind the decrypted body. Add a BaseRequest parameter " +
|
|
"(or a derived DTO) — see other *Info/*Top actions for the convention.");
|
|
}
|
|
Type requestType = firstParam.ParameterType;
|
|
object? data;
|
|
try
|
|
{
|
|
data = MessagePackSerializer.Deserialize(requestType, decryptedBytes);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// The most common cause is a Guid.Empty decrypt above producing garbage bytes — but
|
|
// it can also be a genuine schema mismatch (DTO missing [Key], wrong types, etc.),
|
|
// so include the first few bytes for triage.
|
|
string bytePrefix = Convert.ToHexString(decryptedBytes.AsSpan(0, Math.Min(16, decryptedBytes.Length)));
|
|
_logger.LogError(ex,
|
|
"Msgpack deserialize failed for {Path} into {RequestType} (udid={Udid}, decryptedLen={DecryptedLen}, firstBytes={BytePrefix}). " +
|
|
"If decrypted bytes look like noise, the SID→UDID mapping was missing (see warnings above).",
|
|
path, requestType.Name, udid, decryptedBytes.Length, bytePrefix);
|
|
throw;
|
|
}
|
|
// 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");
|
|
|
|
await next.Invoke(context);
|
|
|
|
Viewer? viewer = context.GetViewer();
|
|
|
|
// 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);
|
|
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
|
|
{
|
|
Data = responseData,
|
|
DataHeaders = new DataHeaders
|
|
{
|
|
Servertime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
|
// 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,
|
|
// Anonymous endpoints (e.g. /check/special_title with [AllowAnonymous]) reach this
|
|
// middleware without an authenticated viewer — the auth handler either declined or
|
|
// failed to find a Steam-linked viewer. The wire still needs short_udid / viewer_id
|
|
// populated (prod sends real numbers for the title check too, but 0 / 0 satisfies
|
|
// the client's BaseTask.Parse which only reads result_code + servertime here).
|
|
ShortUdid = viewer?.ShortUdid ?? 0,
|
|
ViewerId = viewer?.Id ?? 0,
|
|
// Echo the decrypted-against UDID. Most clients ignore this field; SignUpTask.Parse
|
|
// requires it (validates against Certification.Udid on the response). Comes from
|
|
// mappedUdid (the value used for AES); never from controller state.
|
|
Udid = mappedUdid?.ToString() ?? ""
|
|
}
|
|
};
|
|
|
|
// 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;
|
|
try
|
|
{
|
|
packedData = MessagePackSerializer.Serialize(wrappedResponseData, msgPackOptions);
|
|
packedData = Encryption.Encrypt(packedData, udid);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex,
|
|
"Response msgpack/encrypt failed for {Path} (viewerId={ViewerId}, udid={Udid}).",
|
|
path, viewer?.Id, udid);
|
|
throw;
|
|
}
|
|
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<string, object?></c>, arrays →
|
|
/// <c>List<object?></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()
|
|
};
|
|
}
|
|
}
|