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; /// /// Translates incoming requests and outgoing responses from the Shadowverse client into the messagepack format. /// 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; _sessionService = sessionService; } 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]; string udid = _sessionService.GetUdidFromSessionId(sid).GetValueOrDefault().ToString(); // Decrypt incoming data. requestBytes = Encryption.Decrypt(requestBytes, udid); 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"); 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 } }; // 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; } /// /// Walks a parsed JSON tree into the plain CLR shape MessagePack-CSharp's contractless /// resolver understands: objects → Dictionary<string, object?>, arrays → /// List<object?>, 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. /// internal static object? ConvertJsonTreeToPlainObject(JToken? token) { if (token is null || token.Type == JTokenType.Null) return null; return token.Type switch { JTokenType.Object => token.Children() .ToDictionary(p => p.Name, p => ConvertJsonTreeToPlainObject(p.Value)), JTokenType.Array => token.Children().Select(ConvertJsonTreeToPlainObject).ToList(), JTokenType.Integer => token.Value(), JTokenType.Float => token.Value(), JTokenType.String => token.Value(), JTokenType.Boolean => token.Value(), JTokenType.Date => token.Value(), JTokenType.Bytes => token.Value(), _ => token.ToString() }; } }