using System.Collections.Concurrent;
using System.Text;
using System.Text.RegularExpressions;
using Campus_live_rewrite.api.server;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.Middleware
{
///
/// Serves Quest clients the Android builds of first-party clothing, the way RecFlare does.
///
/// The first-party bundles (FirstPartyUGC.json saves: UnityAsset = meshes, UnityAsset2 =
/// textures/material) are Windows builds. RecFlare converted every one for Android and hosts
/// the result under avatar/quest/<same name>; its API hands a client that sends
/// unityAssetTarget=2 (Android/Quest) every bundle name as "quest/<name>", and any other
/// target the plain name (verified against api.recflare.net 2026-09-26). The HASHES must change
/// too: UnityAssetHash / UnityAsset2Hash are the sha256 of the file and the client rejects a
/// download that doesn't match (seen live: each quest/ bundle fetched 3x, then dropped), so
/// each gets the quest file's hash (BundleHashes.OfQuest). The files are mirrored to Data/CDN/AvatarBlobs/quest/ (CDNController fetches
/// a missing one from cdn.recflare.net/avatar/quest/ on demand).
///
/// Only some calls carry unityAssetTarget in the query (owned, search); the busiest one,
/// POST /api/customAvatarItems/v1/bulk, does not - and it is the FIRST clothing call after a
/// login (the worn outfit). So the target is remembered per account AND login platform (the
/// token's "platform" claim: Steam 0, Oculus 1...), and until that device session has sent
/// one, the platform decides: Oculus = Quest bundles, anything else = PC. Keyed per account
/// alone, a player who switched PC -> Quest got their worn items as PC bundles until they
/// logged in again (2026-09-26).
///
public class QuestBundleMiddleware
{
public const int QuestTarget = 2;
private static readonly string[] Paths =
{
"/api/customAvatarItems", "/econ/customAvatarItems", "/api/items/bulkpurchase", "/outfits",
};
private static readonly Regex BundleName = new(
@"""(UnityAsset2?)""\s*:\s*""(?!quest/)([^""/]+\.assetbundle)""", RegexOptions.Compiled);
private const int OculusPlatform = 1; // Platforms.Oculus
private static readonly ConcurrentDictionary<(ulong account, int platform), int> _targetBySession = new();
/// The login platform the token was issued for (its "platform" claim), -1 if unreadable.
/// Read without re-verifying: AuthHelper.ValidateToken has already checked the signature.
private static int TokenPlatform(HttpContext ctx)
{
try
{
string token = ctx.Request.Headers["Authorization"].ToString();
if (string.IsNullOrEmpty(token)) token = ctx.Request.Cookies["Authorization"] ?? "";
token = token.Replace("Bearer ", "").Trim();
// Our game tokens have more than the usual three segments, so find the one whose
// JSON carries the claim instead of assuming it is the second.
foreach (var part in token.Split('.'))
{
JObject payload;
try
{
string b64 = part.Replace('-', '+').Replace('_', '/');
b64 = b64.PadRight(b64.Length + (4 - b64.Length % 4) % 4, '=');
payload = JObject.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(b64)));
}
catch { continue; }
var pl = payload["platform"];
if (pl == null) continue;
if (pl.Type == JTokenType.Integer) return (int)pl;
return Enum.TryParse((string?)pl, true, out var e) ? (int)e : -1;
}
return -1;
}
catch { return -1; }
}
private readonly RequestDelegate _next;
public QuestBundleMiddleware(RequestDelegate next) => _next = next;
/// Point every save at the quest/ conversion, with that file's hash.
private static string RewriteJson(string body)
{
var root = JToken.Parse(body);
var objects = root is JContainer c ? c.DescendantsAndSelf().OfType().ToList() : new List();
foreach (var o in objects)
{
Swap(o, "UnityAsset", "UnityAssetHash");
Swap(o, "UnityAsset2", "UnityAsset2Hash");
}
return root.ToString(Newtonsoft.Json.Formatting.None);
}
private static void Swap(JObject save, string nameKey, string hashKey)
{
if (save[nameKey] is not JValue { Type: JTokenType.String } v) return;
string name = (string)v!;
if (!name.EndsWith(".assetbundle", StringComparison.OrdinalIgnoreCase) || name.Contains('/')) return;
save[nameKey] = "quest/" + name;
var h = BundleHashes.OfQuest(name);
if (h != null) save[hashKey] = h;
}
public async Task InvokeAsync(HttpContext ctx)
{
string path = ctx.Request.Path.Value ?? "";
if (!Paths.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
{
await _next(ctx);
return;
}
int? target = int.TryParse(ctx.Request.Query["unityAssetTarget"].ToString(), out var t) ? t : null;
ulong? account = null;
try { var (acc, err) = AuthHelper.ValidateToken(ctx); if (err == null && acc != null) account = acc.Id; }
catch { }
int platform = account.HasValue ? TokenPlatform(ctx) : -1;
if (account is ulong id && target is int tt)
{
var key = (id, platform);
int prev = _targetBySession.TryGetValue(key, out var p) ? p : (platform == OculusPlatform ? QuestTarget : 0);
_targetBySession[key] = tt;
if (prev != tt)
Console.WriteLine($"[quest-bundles] account {id} (login platform {platform}) unityAssetTarget={tt} - {(tt == QuestTarget ? "serving Quest (Android) clothing bundles" : "serving PC bundles")}");
}
bool quest;
if (target is int q) quest = q == QuestTarget;
else if (account is ulong a && _targetBySession.TryGetValue((a, platform), out var r)) quest = r == QuestTarget;
else quest = platform == OculusPlatform; // nothing sent yet this device: the login platform decides
if (!quest)
{
await _next(ctx);
return;
}
var original = ctx.Response.Body;
using var buffer = new MemoryStream();
ctx.Response.Body = buffer;
try
{
await _next(ctx);
}
finally
{
ctx.Response.Body = original;
}
buffer.Position = 0;
string ct = ctx.Response.ContentType ?? "";
if (ct.Contains("json", StringComparison.OrdinalIgnoreCase) || ct.StartsWith("text/", StringComparison.OrdinalIgnoreCase))
{
string body = await new StreamReader(buffer, Encoding.UTF8).ReadToEndAsync();
string rewritten;
try { rewritten = RewriteJson(body); }
catch { rewritten = BundleName.Replace(body, "\"$1\":\"quest/$2\""); }
byte[] bytes = Encoding.UTF8.GetBytes(rewritten);
ctx.Response.ContentLength = bytes.Length;
await original.WriteAsync(bytes);
}
else
{
await buffer.CopyToAsync(original);
}
}
}
}