Radicade Public Code

This commit is contained in:
JogmansonReal
2026-10-16 23:33:13 -07:00
commit 55175acdf6
832 changed files with 363261 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
+95
View File
@@ -0,0 +1,95 @@
/[Tt]emp/
/[Oo]bj/
*.log
.vs/
.gradle/
ExportedObj/
.consulo/
*.suo
*.tmp
*.user
*.userprefs
*.pidb
*.booproj
*.svd
*.pdb
*.mdb
*.opendb
*.VC.db
/bin
# Logging
/logs
*.log
appsettings.json
# Local August client diagnostics and generated game-derived files
/artifacts/august-*
/artifacts/BepInEx.Unity.IL2CPP.dll
# Generated test/publish output and scratch notes (never publish these)
/artifacts/
_meta_scan.txt
**/bin/
**/obj/
Tests/**/TestResults/
*.trx
*.coverage
# Editor / tooling state
/.vs/
/.freebuff/
/.tmp-september-pack/
/RecNet-CDN
/Migrations
/RadicadeServer-Data
/.idea
/Server/bin
/Server/RadicadeServer-Data
/Server/.idea
/Server/obj
/Server/Discord/Webhooks/webhooks.json
/AntiCheat/bin
/AntiCheat/obj
/AntiCheat.Tests/bin
/AntiCheat.Tests/obj
/Server.Tests/bin
/Server.Tests/obj
# Local reference checkouts
/.external/
/.tmp-test-bin/
/2023Patch/RadicadePatch/bin
2023Patch/EacProbe/obj/Debug/net6.0/apphost.exe
*.exe
/2023Patch/EacProbe
/2023Patch/EacProbe/bin
/2023Patch/EacProbe/bin/Debug
/2023Patch/EacProbe/bin/Debug/net6.0
/2023Patch
!/2023Patch/
/2023Patch/*
!/2023Patch/RadicadeAugustClient/
/2023Patch/RadicadeAugustClient/bin/
/2023Patch/RadicadeAugustClient/obj/
!/2023Patch/RadicadePatch/
/2023Patch/RadicadePatch/bin/
/2023Patch/RadicadePatch/obj/
!/2023Patch/RadicadeEacBypass/
/2023Patch/RadicadeEacBypass/*
!/2023Patch/RadicadeEacBypass/HubSocketAuth.cs
!/2023Patch/RadicadeEacBypass/WebSocketRedirectPatches.cs
!/2023Patch/RadicadeEacBypass/RealtimeDiagnostics.cs
!/2023Patch/RadicadeEacBypass/RequestRouteLog.cs
!/2023Patch/RadicadeEacBypass/EmbeddedTlsPatches.cs
!/2023Patch/RadicadeEacBypass/NameserverConnectionPatches.cs
!/2023Patch/RadicadeEacBypass/NameserverRedirect.cs
!/2023Patch/RadicadeEacBypass/ImageSignatureRequest.cs
!/2023Patch/RadicadeEacBypass/Diag.cs
!/2023Patch/RadicadeEacBypass/ResponseRepair.cs
!/2023Patch/RadicadeEacBypass/HttpTolerancePatches.cs
@@ -0,0 +1,225 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
internal static class CrashAddressMap
{
private const long DefaultTarget = 0x29a79ec;
internal static bool Enabled => !_disabled;
internal static long TargetRva => _target;
private static readonly bool _disabled = IsDisabled();
private static readonly long _target = ReadTarget();
private static bool IsDisabled()
{
try
{
var raw = Environment.GetEnvironmentVariable("RADICADE_ADDR_MAP");
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
}
catch { return false; }
}
private static long ReadTarget()
{
try
{
var raw = Environment.GetEnvironmentVariable("RADICADE_ADDR_TARGET");
if (string.IsNullOrWhiteSpace(raw)) return DefaultTarget;
raw = raw.Trim();
if (raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) raw = raw.Substring(2);
return long.TryParse(raw, System.Globalization.NumberStyles.HexNumber,
System.Globalization.CultureInfo.InvariantCulture, out var value) ? value : DefaultTarget;
}
catch { return DefaultTarget; }
}
internal static void Start()
{
if (!Enabled) return;
var thread = new System.Threading.Thread(Run) { IsBackground = true, Name = "RadicadeAddrMap" };
thread.Start();
}
private static void Run()
{
try { System.Threading.Thread.Sleep(5000); } catch { }
var log = RadicadeEacBypassPlugin.LogSource;
var started = Environment.TickCount;
IntPtr baseAddress = IntPtr.Zero;
var moduleSize = 0L;
try
{
using (var self = System.Diagnostics.Process.GetCurrentProcess())
{
foreach (System.Diagnostics.ProcessModule module in self.Modules)
{
if (!string.Equals(module.ModuleName, "GameAssembly.dll", StringComparison.OrdinalIgnoreCase)) continue;
baseAddress = module.BaseAddress;
moduleSize = module.ModuleMemorySize;
break;
}
}
}
catch (Exception ex)
{
log.LogError("[AddrMap] could not locate GameAssembly.dll: " + ex.Message);
return;
}
if (baseAddress == IntPtr.Zero || moduleSize <= 0)
{
log.LogError("[AddrMap] GameAssembly.dll is not loaded - nothing to map");
return;
}
var gameRoot = Path.GetDirectoryName(UnityEngine.Application.dataPath) ?? ".";
var path = Path.Combine(gameRoot, "BepInEx", "radicade-addr-map.txt");
var baseValue = baseAddress.ToInt64();
var mapped = 0;
var images = 0;
var unmappable = 0;
var bestBelow = -1L;
string bestBelowName = null;
long bestAbove = -1;
string bestAboveName = null;
var failures = 0;
try
{
using (var writer = new StreamWriter(path, false))
{
writer.WriteLine("# il2cpp RVA -> method map, written by RadicadeAugustClient (run 24)");
writer.WriteLine("# GameAssembly.dll base 0x" + baseValue.ToString("x") + ", mapped size 0x" + moduleSize.ToString("x"));
writer.WriteLine("# rva<TAB>image<TAB>Namespace.Class::Method - rva is relative to the base above");
unsafe
{
var domain = Il2CppInterop.Runtime.IL2CPP.il2cpp_domain_get();
if (domain == IntPtr.Zero)
{
log.LogError("[AddrMap] no il2cpp domain; nothing to map");
return;
}
uint assemblyCount = 0;
var assemblies = Il2CppInterop.Runtime.IL2CPP.il2cpp_domain_get_assemblies(domain, ref assemblyCount);
for (uint a = 0; a < assemblyCount; a++)
{
var image = Il2CppInterop.Runtime.IL2CPP.il2cpp_assembly_get_image(assemblies[a]);
if (image == IntPtr.Zero) continue;
images++;
string imageName;
try { imageName = Marshal.PtrToStringAnsi(Il2CppInterop.Runtime.IL2CPP.il2cpp_image_get_name(image)) ?? "?"; }
catch { imageName = "?"; }
var classCount = Il2CppInterop.Runtime.IL2CPP.il2cpp_image_get_class_count(image);
for (uint c = 0; c < classCount; c++)
{
var klass = Il2CppInterop.Runtime.IL2CPP.il2cpp_image_get_class(image, c);
if (klass == IntPtr.Zero) continue;
string className;
try
{
var namespaze = Marshal.PtrToStringAnsi(Il2CppInterop.Runtime.IL2CPP.il2cpp_class_get_namespace(klass));
var name = Marshal.PtrToStringAnsi(Il2CppInterop.Runtime.IL2CPP.il2cpp_class_get_name(klass));
className = string.IsNullOrEmpty(namespaze) ? name : namespaze + "." + name;
}
catch { className = "?"; failures++; }
var iterator = IntPtr.Zero;
IntPtr methodInfo;
while ((methodInfo = Il2CppInterop.Runtime.IL2CPP.il2cpp_class_get_methods(klass, ref iterator)) != IntPtr.Zero)
{
long entry;
try { entry = Marshal.ReadIntPtr(methodInfo).ToInt64(); }
catch { entry = 0; }
if (entry <= baseValue || entry >= baseValue + moduleSize)
{
unmappable++;
continue;
}
var rva = entry - baseValue;
string methodName;
try { methodName = Marshal.PtrToStringAnsi(Il2CppInterop.Runtime.IL2CPP.il2cpp_method_get_name(methodInfo)) ?? "?"; }
catch { methodName = "?"; failures++; }
writer.WriteLine("0x" + rva.ToString("x") + "\t" + imageName + "\t" + className + "::" + methodName);
mapped++;
var full = className + "::" + methodName;
if (rva <= _target && rva > bestBelow) { bestBelow = rva; bestBelowName = full; }
if (rva > _target && (bestAbove < 0 || rva < bestAbove)) { bestAbove = rva; bestAboveName = full; }
}
}
}
}
}
}
catch (Exception ex)
{
log.LogError("[AddrMap] pass failed after " + mapped + " methods: " + ex);
return;
}
var elapsed = Environment.TickCount - started;
log.LogInfo($"[AddrMap] mapped {mapped} methods from {images} images in {elapsed} ms -> {path} ({unmappable} without a mappable entry point)");
if (bestBelow < 0)
{
log.LogWarning("[AddrMap] no method starts below the target 0x" + _target.ToString("x")
+ " - the crash is in code no managed method owns (il2cpp runtime or native plugin)."
+ (bestAboveName != null ? " Nearest above: 0x" + bestAbove.ToString("x") + " " + bestAboveName : string.Empty));
return;
}
var span = bestAbove > bestBelow ? bestAbove - bestBelow : -1;
log.LogWarning("[AddrMap] crash target 0x" + _target.ToString("x") + " -> containing method 0x" + bestBelow.ToString("x")
+ " " + bestBelowName + " (+0x" + (_target - bestBelow).ToString("x") + " into it)"
+ (span > 0 ? "; next method 0x" + bestAbove.ToString("x") + " " + bestAboveName + " (span 0x" + span.ToString("x") + ")" : string.Empty)
+ (failures > 0 ? "; " + failures + " name reads failed" : string.Empty));
}
}
+1430
View File
@@ -0,0 +1,1430 @@
using BepInEx;
using BepInEx.Configuration;
using BepInEx.Logging;
using BepInEx.Unity.IL2CPP;
using HarmonyLib;
using RecRoom.AntiCheat;
using System.Linq;
using System.Runtime.InteropServices;
[BepInPlugin("com.radicade.eacbypass", "Radicade August Client", "2.0.0")]
public sealed class RadicadeEacBypassPlugin : BasePlugin
{
internal static ManualLogSource LogSource;
internal static ConfigEntry<string> ServerUrl, TlsAllowedHosts;
internal static Il2CppSystem.Threading.SynchronizationContext MainThreadContext;
internal static string ImageKeyId;
public override void Load()
{
LogSource = Log;
if (UnityEngine.Application.version != "20230825")
throw new NotSupportedException("This plugin requires Rec Room 20230825.");
MainThreadContext = Il2CppSystem.Threading.SynchronizationContext.Current;
Log.LogInfo($"[August] main-thread sync context: {(MainThreadContext != null ? MainThreadContext.GetType().Name : "NULL")}");
ServerUrl = Config.Bind("Connection", "ServerUrl", "https://localhost:3030", "Private server URL.");
if (!Uri.TryCreate(ServerUrl.Value, UriKind.Absolute, out var server) || server.Scheme != "https")
throw new InvalidOperationException("The August client requires an HTTPS server URL.");
TlsAllowedHosts = Config.Bind("TLS", "AllowedHosts", server.Host, "Exact private server hosts allowed by the embedded TLS integration.");
if (!EmbeddedTlsPatches.IsHostAllowed(server.Host))
throw new InvalidOperationException("TLS AllowedHosts must include the configured private server host.");
var metadata = Path.Combine(UnityEngine.Application.dataPath, "il2cpp_data", "Metadata", "global-metadata.interop.dat");
ImageKeyId = ImageSignatureRequest.DetectKeyId(File.ReadAllBytes(metadata));
if (ModuleHidingPolicy.Enabled)
{
var hidden = PebModuleHider.HideModule("winhttp.dll");
Log.LogInfo(hidden
? "[ModuleHide] winhttp.dll unlinked from PEB module lists (scanner-invisible)"
: "[ModuleHide] WARNING: winhttp.dll not found in PEB lists - scanner will still see it");
var wiped = PeHeaderWiper.WipeSignatures("winhttp.dll");
Log.LogInfo(wiped
? "[ModuleHide] winhttp.dll PE signatures wiped (MZ+PE) - region scans cannot classify it"
: "[ModuleHide] WARNING: PE signature wipe did not apply (signatures missing or VirtualProtect failed)");
}
else
{
Log.LogInfo("[ModuleHide] disabled by default (RADICADE_MODULE_HIDE=1 re-arms runs 14/15): winhttp.dll keeps its loader-list entry and its PE headers, so a module scan has nothing to call obscured.");
}
Log.LogInfo(ModuleHidingPolicy.DescribeModuleList());
HileGate.Enabled = true;
Log.LogInfo("[HileGate] armed - anti-cheat report POSTs are " + (HileGate.DropEnabled
? "dropped at the HTTP layer (RADICADE_HILE_GATE=drop)"
: "captured in full and then allowed through, so the report body is on the record (RADICADE_HILE_GATE=drop restores the drop)") + ".");
var harmony = new Harmony("com.radicade.eacbypass");
EmbeddedTlsPatches.Apply(harmony);
harmony.PatchAll(typeof(NameserverConnectionPatches));
WebSocketRedirectPatches.Apply(harmony);
harmony.PatchAll(typeof(AugustChallengePatches));
harmony.PatchAll(typeof(AugustLaunchDiagnostics));
harmony.PatchAll(typeof(AugustLaunchValidatorPatch));
RsaVerifyNeutralizerPatches.Apply(harmony);
harmony.PatchAll(typeof(FileCheckEntryPatch));
harmony.PatchAll(typeof(ExitInitiatorTracerPatch));
Log.LogInfo("[ExitInit] armed - managed exit initiators are blocked once a hile report has been dropped, and traced.");
try
{
harmony.PatchAll(typeof(ExitStateVetoPatch));
Log.LogInfo("[ExitVeto] patches applied; blockExitState enabled=" + ExitStateVetoPatch.Enabled
+ " (opt-in fallback only; a normal quit and server logouts work either way. RADICADE_EXIT_VETO=1 arms it)");
}
catch (Exception vetoEx)
{
Log.LogError("[ExitVeto] failed to apply - continuing without it: " + vetoEx);
}
try
{
harmony.PatchAll(typeof(ExitSequenceTracePatch));
harmony.PatchAll(typeof(UnityQuitVetoPatch));
harmony.PatchAll(typeof(EacNativeClientStubPatch));
harmony.PatchAll(typeof(EacHealthQueryLiePatch));
harmony.PatchAll(typeof(CheatManagerTracePatch));
harmony.PatchAll(typeof(CheatScanSuppressPatch));
Log.LogInfo("[CheatScan] verdict suppression enabled=" + CheatScanSuppressPatch.Enabled);
Log.LogInfo("[CheatType] cheat-detection suppression enabled=" + CheatDetectionSuppressPatch.Enabled
+ " (RADICADE_CHEAT_TYPE=0 restores the detection and the dorm drop that follows it)");
Log.LogInfo("[CheatDrop] dorm-drop guard enabled=" + CheatDormDropGuardPatch.Enabled
+ " (RADICADE_CHEAT_DROP=0 lets every drop run)");
try
{
harmony.PatchAll(typeof(CheatDetectionSuppressPatch));
Log.LogInfo("[CheatType] verdict handlers guarded=" + CheatDetectionSuppressPatch.Guarded
+ (CheatDetectionSuppressPatch.Unguarded == null
? " (all three)"
: " - UNGUARDED, not found on CheatManager: " + CheatDetectionSuppressPatch.Unguarded));
}
catch (Exception cheatTypeEx)
{
Log.LogError("[CheatType] verdict suppression NOT armed: " + cheatTypeEx.Message);
}
try
{
harmony.PatchAll(typeof(CheatDormDropGuardPatch));
Log.LogInfo("[CheatDrop] dorm drop (EFLCHFDDPJP) held back for the anti-cheat reason; "
+ "every other reason still drops the player.");
}
catch (Exception cheatDropEx)
{
Log.LogError("[CheatDrop] dorm-drop guard NOT armed: " + cheatDropEx.Message);
}
Log.LogInfo("[EacStub] EasyAntiCheat Hydra runtime neutralisation enabled=" + EacGate.Enabled
+ " (RADICADE_EAC_NEUTRALIZE=0 before launch leaves EAC alone)");
Log.LogInfo("[ExitTrace] shutdown-request stack dump armed; [QuitVeto] wantsToQuit veto enabled="
+ UnityQuitVetoPatch.Enabled);
}
catch (Exception quitEx)
{
Log.LogError("[ExitTrace] failed to apply - continuing without them: " + quitEx);
}
try
{
CrashAddressMap.Start();
Log.LogInfo("[AddrMap] enabled=" + CrashAddressMap.Enabled
+ " target=0x" + CrashAddressMap.TargetRva.ToString("x")
+ " (RADICADE_ADDR_MAP=0 disables; RADICADE_ADDR_TARGET=0x... re-aims it)");
}
catch (Exception mapEx)
{
Log.LogError("[AddrMap] failed to start: " + mapEx);
}
try
{
harmony.PatchAll(typeof(ToolCollisionResetGuardPatch));
Log.LogInfo("[ToolGuard] Tool.ResetToDefaultCollisionLayer skipped=" + ToolCollisionResetGuardPatch.Enabled
+ " (RADICADE_TOOL_RESET=0 before launch restores the original reset)");
}
catch (Exception toolEx)
{
Log.LogError("[ToolGuard] failed to apply - the room crash is NOT guarded: " + toolEx);
}
Log.LogInfo($"[August] Connection patches loaded for {ServerUrl.Value}; image key {ImageKeyId ?? "unknown"}.");
}
}
[HarmonyPatch]
internal static class AugustLaunchDiagnostics
{
[HarmonyPrefix, HarmonyPatch(typeof(NDIIPBKGGIG), "FIBOEONALKB")]
[HarmonyPatch(typeof(NDIIPBKGGIG), "IKHPLCCBENN")]
private static void Trace(JKBPKDAGEHN __0)
{
if (__0 == JKBPKDAGEHN.LAUNCH_VALIDATION_FAILED)
RadicadeEacBypassPlugin.LogSource.LogWarning("[August launch diagnostic] " + Il2CppSystem.Environment.StackTrace);
}
}
[HarmonyPatch]
internal static class AugustChallengePatches
{
[HarmonyPrefix, HarmonyPatch(typeof(EACManager), nameof(EACManager.GenerateChallengeResponse))]
private static bool Challenge(ref string __result)
{
__result = "radicade-eac-bypass";
return false;
}
[HarmonyPrefix, HarmonyPatch(typeof(EACManager), "MGEAHKJIIMO")]
private static bool Ready(ref bool __result)
{
__result = true;
return false;
}
}
[HarmonyPatch(typeof(ADJOODNBCKG.NFPDGJNDFOF), "GBAMNAKGEGD")]
internal static class AugustLaunchValidatorPatch
{
static readonly System.Threading.CancellationTokenSource Pump = new();
[HarmonyPrefix]
internal static bool Bypass(ADJOODNBCKG.NFPDGJNDFOF __instance)
{
var promise = __instance.promise;
if (promise == null)
return true;
RadicadeEacBypassPlugin.LogSource.LogInfo("[August] Bypassing native launch validator (hash check cannot pass in a patched install).");
var context = RadicadeEacBypassPlugin.MainThreadContext;
try
{
var before = Il2CppSystem.Threading.SynchronizationContext.Current;
RadicadeEacBypassPlugin.LogSource.LogInfo(
$"[August] validator thread context: {(before != null ? before.GetType().Name : "null")}; installing main-thread context");
if (context != null)
Il2CppSystem.Threading.SynchronizationContext.SetSynchronizationContext(context);
try
{
RadicadeEacBypassPlugin.LogSource.LogInfo(
$"[August] resolving: done={SafeState(promise, "EPCPPDDJCMO")} handlers={SafeHandlerCount(promise)}");
promise.OLKGFACPIPC();
RadicadeEacBypassPlugin.LogSource.LogInfo("[August] Launch-validation promise resolved; boot continues.");
}
catch (Exception e)
{
RadicadeEacBypassPlugin.LogSource.LogError($"[August] Resolve failed: {e}");
}
finally
{
Il2CppSystem.Threading.SynchronizationContext.SetSynchronizationContext(before);
}
}
catch (Exception outer)
{
RadicadeEacBypassPlugin.LogSource.LogError($"[August] Context install failed: {outer}");
}
return false;
}
static int SafeHandlerCount(PFDOLDLEIBA promise)
{
try { return promise.HFPMLNDOGLF?.Count ?? 0; }
catch { return -1; }
}
static bool SafeState(PFDOLDLEIBA promise, string prop)
{
try
{
return prop switch
{
"EPCPPDDJCMO" => promise.EPCPPDDJCMO,
"MPJBGFFEEOP" => promise.MPJBGFFEEOP,
"NEIINCCHEPF" => promise.NEIINCCHEPF,
_ => false,
};
}
catch { return false; }
}
}
internal static class RsaVerifyNeutralizerPatches
{
static readonly byte[] P1KeyModulus = System.Convert.FromBase64String(
"X07yXkxaaLcZ1wVXfkWjgFkkqdoLhDFm0GPODsF+Q47pSUlbLvtXGqStnyEJEIrQmgDiicAvCdGRq4lovr2l5sIPMaoyizsbVHBdwLUrCsji0RvSBnmvN+8KqQ8STnB4DP4pAsPilfD35def4WuX/xMCXB5+hQUVhv27HPV8Dj9XzHuJAijIM9UwDZmvUcECyiO4wv+TaZi2+ELBtaLCQR8Gm1ZPeDEwP62Ch6MJy0jx5pkvvD0KdF9Wye+3/Wx31Zn/Trdo9HL4sGFWPDM9H9kQhZd5wkTHuxpwGIIhlzIwvY2/pBGdZKP6fi1D2jROEmVkBDyhmYY9nO+s3/bndQ==");
static int p1Calls, totalCalls;
internal static void Apply(Harmony harmony)
{
PatchVerify(harmony, typeof(Il2CppSystem.Security.Cryptography.RSACryptoServiceProvider), "VerifyHash",
new[]
{
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>),
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>),
typeof(Il2CppSystem.Security.Cryptography.HashAlgorithmName),
typeof(Il2CppSystem.Security.Cryptography.RSASignaturePadding)
});
PatchVerify(harmony, typeof(Il2CppSystem.Security.Cryptography.RSACryptoServiceProvider), "VerifyHash",
new[]
{
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>),
typeof(int),
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>)
});
PatchVerify(harmony, typeof(Il2CppSystem.Security.Cryptography.RSACryptoServiceProvider), "VerifyData",
new[]
{
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>),
typeof(Il2CppSystem.Object),
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>)
});
}
static void PatchVerify(Harmony harmony, Type target, string methodName, Type[] args)
{
try
{
var m = AccessTools.Method(target, methodName, args);
if (m == null)
{
RadicadeEacBypassPlugin.LogSource.LogWarning(
$"[RsaVerify] {target.Name}.{methodName}({args.Length} args) not found - skipped");
return;
}
harmony.Patch(m, prefix: new HarmonyMethod(typeof(RsaVerifyNeutralizerPatches).GetMethod(nameof(VerifyPrefix))));
RadicadeEacBypassPlugin.LogSource.LogInfo(
$"[RsaVerify] patched {target.Name}.{methodName}({string.Join(", ", Array.ConvertAll(args, t => t.Name))})");
}
catch (Exception e)
{
RadicadeEacBypassPlugin.LogSource.LogWarning(
$"[RsaVerify] failed to patch {target.Name}.{methodName}: {e.GetType().Name}: {e.Message}");
}
} public static bool VerifyPrefix(
Il2CppSystem.Security.Cryptography.RSACryptoServiceProvider __instance, ref bool __result)
{
totalCalls++;
var isP1 = false;
try
{
var modulus = __instance.ExportParameters(false).Modulus;
isP1 = modulus != null && modulus.Length == P1KeyModulus.Length && modulus.SequenceEqual(P1KeyModulus);
if (isP1)
p1Calls++;
}
catch
{
}
__result = isP1;
if (totalCalls <= 8)
RadicadeEacBypassPlugin.LogSource.LogWarning(
$"[ImageSig] RSA verify #{totalCalls} ({(isP1 ? "p1 key" : "game key")}) forced {(isP1 ? "true" : "false")}");
return false;
}
}
[HarmonyPatch]
internal static class FileCheckEntryPatch
{
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
{
yield return AccessTools.Method(typeof(KKHDHILFOGO), "GGIFLDPHCIP");
yield return AccessTools.Method(typeof(KKHDHILFOGO), "GOCCLPJDJIC");
}
[HarmonyPrefix]
internal static bool Bypass(ref MCFFIFIPNKE<KKHDHILFOGO.BJGODLEIDGE> __result)
{
var result = KKHDHILFOGO.BJGODLEIDGE.NOMPJGOIAEB("radicade: file check bypassed");
__result = PFDOLDLEIBA.PLJINLOCHGM(result);
RadicadeEacBypassPlugin.LogSource.LogInfo("[FileCheck] entry bypassed - resolved success promise returned");
return false;
}
}
internal static unsafe class PebModuleHider
{
[StructLayout(LayoutKind.Sequential)]
private struct PROCESS_BASIC_INFORMATION
{
public IntPtr Reserved1;
public IntPtr PebBaseAddress;
public IntPtr Reserved2_0;
public IntPtr Reserved2_1;
public IntPtr UniqueProcessId;
public IntPtr Reserved3;
}
[DllImport("ntdll.dll")]
private static extern int NtQueryInformationProcess(
IntPtr hProcess, int processInfoClass, ref PROCESS_BASIC_INFORMATION pbi,
int cb, out int returnLength);
private struct LIST_ENTRY { public IntPtr Flink, Blink; }
private const int OffInMemoryOrder = 0x10;
private const int OffInInitOrder = 0x20;
private const int OffBaseDllName = 0x58;
public static bool HideModule(string dllName)
{
var pbi = default(PROCESS_BASIC_INFORMATION);
int status = NtQueryInformationProcess(new IntPtr(-1), 0, ref pbi,
Marshal.SizeOf<PROCESS_BASIC_INFORMATION>(), out _);
if (status != 0 || pbi.PebBaseAddress == IntPtr.Zero)
return false;
byte* peb = (byte*)pbi.PebBaseAddress;
byte* ldr = *(byte**)(peb + 0x18);
if (ldr == null)
return false;
LIST_ENTRY* head = (LIST_ENTRY*)(ldr + 0x10);
byte* target = null;
for (LIST_ENTRY* n = (LIST_ENTRY*)head->Flink; n != head && target == null; n = (LIST_ENTRY*)n->Flink)
{
byte* entry = (byte*)n;
ushort* us = (ushort*)(entry + OffBaseDllName);
ushort lenBytes = *us;
char* buf = *(char**)(entry + OffBaseDllName + 8);
if (buf == null || lenBytes == 0)
continue;
string name = new string(buf, 0, lenBytes / 2);
if (name.Equals(dllName, StringComparison.OrdinalIgnoreCase))
target = entry;
}
if (target == null)
return false;
Unlink((LIST_ENTRY*)target);
Unlink((LIST_ENTRY*)(target + OffInMemoryOrder));
Unlink((LIST_ENTRY*)(target + OffInInitOrder));
return true;
}
private static void Unlink(LIST_ENTRY* e)
{
var flink = (LIST_ENTRY*)e->Flink;
var blink = (LIST_ENTRY*)e->Blink;
if (flink == e || blink == e)
return;
blink->Flink = e->Flink;
flink->Blink = e->Blink;
e->Flink = (IntPtr)e;
e->Blink = (IntPtr)e;
}
}
internal static unsafe class PeHeaderWiper
{
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern IntPtr GetModuleHandleW(string moduleName);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool VirtualProtect(IntPtr address, UIntPtr size, uint newProtect, out uint oldProtect);
public static bool WipeSignatures(string dllName)
{
IntPtr baseAddr = GetModuleHandleW(dllName);
if (baseAddr == IntPtr.Zero)
return false;
byte* p = (byte*)baseAddr;
int eLfanew = *(int*)(p + 0x3C);
if (eLfanew <= 0 || eLfanew > 0x1000)
return false;
if (!VirtualProtect(baseAddr, (UIntPtr)0x1000, 0x04 , out uint old))
return false;
bool wasMz = p[0] == (byte)'M' && p[1] == (byte)'Z';
bool wasPe = *(uint*)(p + eLfanew) == 0x00004550;
*(ushort*)p = 0;
*(uint*)(p + eLfanew) = 0;
VirtualProtect(baseAddr, (UIntPtr)0x1000, old, out _);
return wasMz && wasPe;
}
}
[HarmonyPatch]
internal static class ExitInitiatorTracerPatch
{
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
{
yield return AccessTools.Method(typeof(CCKNJCHHBLL), "BEFDPLAELIH");
yield return AccessTools.Method(typeof(CCKNJCHHBLL), "GIEEBIOOFML");
yield return AccessTools.Method(typeof(CCKNJCHHBLL), "FNOEHNDLHHG");
}
[HarmonyPrefix]
internal static bool TraceAndBlock()
{
var stack = "<unavailable>";
try { stack = Environment.StackTrace; } catch { }
var armed = HileGate.DroppedCount > 0;
RadicadeEacBypassPlugin.LogSource.LogWarning(armed
? "[ExitInit] blocked managed exit initiator - caller trace:\n" + stack
: "[ExitInit] allowed managed exit initiator (no report filed yet) - caller trace:\n" + stack);
return !armed;
}
}
[HarmonyPatch]
[HarmonyPatch(typeof(BestHTTP.HTTPManager), "SendRequest", new[] { typeof(BestHTTP.HTTPRequest) })]
internal static class ExitSequenceTracePatch
{
private static int _dumped;
[HarmonyPriority(Priority.First)]
private static void Prefix(BestHTTP.HTTPRequest request)
{
try
{
var path = request?.Uri?.AbsolutePath;
if (path == null) return;
if (path.IndexOf("/matchmaking/player/logout", StringComparison.OrdinalIgnoreCase) < 0 &&
path.IndexOf("/playersettings/playersettings", StringComparison.OrdinalIgnoreCase) < 0)
return;
if (++_dumped > 4) return;
RadicadeEacBypassPlugin.LogSource.LogWarning(
"[ExitTrace] shutdown request " + path + " - managed caller stack:" + ExitTraceHelper.SafeStack());
}
catch (Exception traceEx)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[ExitTrace] dump failed: " + traceEx.Message);
}
}
}
[HarmonyPatch]
internal static class UnityQuitVetoPatch
{
internal static bool Enabled => _enabled;
private static readonly bool _enabled = IsEnabled();
private static int _blocks;
private static bool IsEnabled()
{
try
{
var raw = Environment.GetEnvironmentVariable("RADICADE_QUIT_VETO");
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Trim().ToLowerInvariant() is "1" or "on" or "true" or "yes";
}
catch { return false; }
}
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
{
var noArg = AccessTools.Method(typeof(CCKNJCHHBLL), "GBGMBMGDGGD", Type.EmptyTypes);
if (noArg != null) yield return noArg;
var flag = AccessTools.Method(typeof(CCKNJCHHBLL), "GBGMBMGDGGD", new[] { typeof(bool) });
if (flag != null) yield return flag;
}
[HarmonyPrefix]
private static bool Veto(ref bool __result)
{
if (!Enabled) return true;
__result = false;
var count = ++_blocks;
if (count <= 3 || count % 25 == 0)
RadicadeEacBypassPlugin.LogSource.LogWarning(
"[QuitVeto] wantsToQuit answered false (block #" + count + ") - Unity's quit is cancelled. " +
"This is the anti-cheat shutdown, not a user quit; the veto is armed by RADICADE_QUIT_VETO=1, set it to 0 to disable." +
ExitTraceHelper.SafeStack());
return false;
}
}
internal static class ModuleHidingPolicy
{
internal static bool Enabled { get; } = ReadFlag("RADICADE_MODULE_HIDE");
private static bool ReadFlag(string name)
{
try
{
var raw = Environment.GetEnvironmentVariable(name);
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Trim().ToLowerInvariant() is "1" or "on" or "true" or "yes";
}
catch { return false; }
}
internal static string DescribeModuleList()
{
try
{
using (var self = System.Diagnostics.Process.GetCurrentProcess())
{
var seen = false;
var path = string.Empty;
foreach (System.Diagnostics.ProcessModule module in self.Modules)
{
if (!string.Equals(module.ModuleName, "winhttp.dll", StringComparison.OrdinalIgnoreCase)) continue;
seen = true;
path = module.FileName ?? string.Empty;
break;
}
return seen
? "[ModuleList] winhttp.dll is enumerated by a Toolhelp module walk: " + path
: "[ModuleList] winhttp.dll is NOT enumerated by a Toolhelp module walk - a loaded image the loader lists do not know about is the most literal definition of obscured.";
}
}
catch (Exception ex)
{
return "[ModuleList] enumeration failed: " + ex.Message;
}
}
}
[HarmonyPatch]
internal static class CheatScanSuppressPatch
{
internal static bool Enabled => !_disabled;
private static readonly bool _disabled = IsDisabled();
private static int _skips;
private static bool IsDisabled()
{
try
{
var raw = Environment.GetEnvironmentVariable("RADICADE_CHEAT_SCAN");
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
}
catch { return false; }
}
private static System.Reflection.MethodBase TargetMethod()
{
var type = AccessTools.TypeByName("CheatManager");
if (type == null) throw new System.MissingMemberException("CheatManager could not be resolved.");
var method = AccessTools.Method(type, "MJLAGDJLOBC", System.Type.EmptyTypes);
if (method == null) throw new System.MissingMethodException("CheatManager.MJLAGDJLOBC() is gone - the scan moved or was renamed.");
return method;
}
[HarmonyPrefix]
private static bool Skip()
{
if (!Enabled) return true;
var count = ++_skips;
if (count <= 3 || count % 25 == 0)
RadicadeEacBypassPlugin.LogSource.LogWarning("[CheatScan] skipped CheatManager.MJLAGDJLOBC() (#" + count
+ ") - this is the pass behind the Obscured/winhttp.dll verdict, so no report and no shutdown follow from it.");
return false;
}
}
[HarmonyPatch]
internal static class CheatDetectionSuppressPatch
{
internal static bool Enabled => !_disabled;
private static readonly bool _disabled = IsDisabled();
private static int _skips;
private static bool IsDisabled()
{
try
{
var raw = Environment.GetEnvironmentVariable("RADICADE_CHEAT_TYPE");
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
}
catch { return false; }
}
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
{
var type = AccessTools.TypeByName("CheatManager");
if (type == null) throw new System.MissingMemberException("CheatManager could not be resolved.");
var found = new System.Collections.Generic.List<string>();
var missing = new System.Collections.Generic.List<string>();
foreach (var name in new[] { "OnObscuredTypeCheatDetected", "OnTimeCheatDetected", "OnAdvancedMovementCheatDetected" })
{
var method = AccessTools.Method(type, name, System.Type.EmptyTypes);
if (method == null)
{
missing.Add(name);
continue;
}
found.Add(name);
yield return method;
}
_guarded = found.Count;
_unguarded = missing.Count == 0 ? null : string.Join(", ", missing);
}
internal static int Guarded => _guarded;
internal static string Unguarded => _unguarded;
private static int _guarded;
private static string _unguarded;
[HarmonyPrefix]
private static bool Skip(System.Reflection.MethodBase __originalMethod)
{
if (!Enabled) return true;
var count = ++_skips;
if (count <= 5 || count % 25 == 0)
RadicadeEacBypassPlugin.LogSource.LogWarning("[CheatType] skipped CheatManager." + __originalMethod.Name + "() (#" + count
+ ") - this is a verdict handler that ends in the dorm drop with EFLCHFDDPJP and the shutdown behind it, so the room survives the detection.");
return false;
}
}
[HarmonyPatch]
internal static class CheatDormDropGuardPatch
{
private const string AntiCheatReason = "385120";
internal static bool Enabled => !_disabled;
private static readonly bool _disabled = IsDisabled();
private static int _skips;
private static bool IsDisabled()
{
try
{
var raw = Environment.GetEnvironmentVariable("RADICADE_CHEAT_DROP");
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
}
catch { return false; }
}
private static System.Reflection.MethodBase TargetMethod()
{
var type = AccessTools.TypeByName("CheatManager");
if (type == null) throw new System.MissingMemberException("CheatManager could not be resolved.");
var method = AccessTools.Method(type, "EFLCHFDDPJP", new[] { typeof(string) });
if (method == null)
throw new System.MissingMethodException("CheatManager.EFLCHFDDPJP(string) is gone - the dorm drop moved or was renamed, so the guard is NOT armed.");
return method;
}
[HarmonyPrefix]
private static bool Skip(string JELLDPBCBLG)
{
if (!Enabled) return true;
if (string.IsNullOrEmpty(JELLDPBCBLG) || JELLDPBCBLG.IndexOf(AntiCheatReason, StringComparison.Ordinal) < 0)
return true;
var count = ++_skips;
if (count <= 5 || count % 25 == 0)
RadicadeEacBypassPlugin.LogSource.LogWarning("[CheatDrop] skipped CheatManager.EFLCHFDDPJP('" + JELLDPBCBLG + "') (#" + count
+ ") - the anti-cheat's own drop, so the player stays where they are instead of being sent to the dormroom mid-session.");
return false;
}
}
[HarmonyPatch]
internal static class CheatManagerTracePatch
{
private const int PerMethodLimit = 4;
private const int LineLimit = 90;
private static readonly System.Diagnostics.Stopwatch Clock = System.Diagnostics.Stopwatch.StartNew();
private static readonly System.Collections.Generic.Dictionary<string, int> Seen = new System.Collections.Generic.Dictionary<string, int>();
private static int _lines;
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
{
var outer = AccessTools.TypeByName("CheatManager");
if (outer == null)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[CheatTrace] CheatManager not resolvable - nothing traced.");
yield break;
}
var types = new System.Collections.Generic.List<System.Type> { outer };
foreach (var nested in outer.GetNestedTypes(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic))
types.Add(nested);
foreach (var type in types)
{
foreach (var method in type.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.DeclaredOnly))
{
if (method.IsAbstract || method.ContainsGenericParameters) continue;
yield return method;
}
}
}
[HarmonyPrefix]
private static void Trace(System.Reflection.MethodBase __originalMethod)
{
try
{
if (_lines >= LineLimit) return;
var key = __originalMethod.DeclaringType.Name + "." + __originalMethod.Name;
Seen.TryGetValue(key, out var count);
if (count >= PerMethodLimit) return;
Seen[key] = count + 1;
_lines++;
RadicadeEacBypassPlugin.LogSource.LogWarning("[CheatTrace] +" + Clock.ElapsedMilliseconds + "ms " + key);
}
catch { }
}
}
internal static class ExitTraceHelper
{
internal static string SafeStack()
{
try { return "\n" + Environment.StackTrace; } catch { return "\n<stack unavailable>"; }
}
}
internal static class EacGate
{
internal static bool Enabled => !_disabled;
private static readonly bool _disabled = IsDisabled();
private static int _logCount;
private static bool IsDisabled()
{
try
{
var raw = Environment.GetEnvironmentVariable("RADICADE_EAC_NEUTRALIZE");
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
}
catch { return false; }
}
internal static System.Type Find(string fullName)
{
try
{
var byName = System.Type.GetType(fullName + ", EasyAntiCheat.Client");
if (byName != null) return byName;
}
catch { }
try
{
var forced = typeof(EasyAntiCheat.Client.Hydra.Runtime).Assembly;
var direct = forced.GetType(fullName);
if (direct != null) return direct;
}
catch { }
try { return AccessTools.TypeByName(fullName); } catch { return null; }
}
private const int LogLimit = 8;
internal static void Log(string message)
{
try
{
if (++_logCount > LogLimit) return;
RadicadeEacBypassPlugin.LogSource.LogWarning(message);
}
catch { }
}
}
[HarmonyPatch]
internal static class EacNativeClientStubPatch
{
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
{
var type = EacGate.Find("EasyAntiCheat.Client.Hydra.NativeClient");
if (type == null)
{
EacGate.Log("[EacStub] Hydra.NativeClient not resolvable - EAC runtime left untouched.");
yield break;
}
foreach (var method in type.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
if (method.Name == "Initialize")
yield return method;
}
[HarmonyPrefix]
private static bool Skip(System.Reflection.MethodBase __originalMethod)
{
if (!EacGate.Enabled) return true;
EacGate.Log("[EacStub] skipped " + __originalMethod.DeclaringType + "." + __originalMethod.Name
+ " - EasyAntiCheat's native client never starts, so its module scanner never runs.");
return false;
}
}
[HarmonyPatch]
internal static class EacHealthQueryLiePatch
{
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
{
var native = EacGate.Find("EasyAntiCheat.Client.Hydra.NativeClient");
if (native != null)
foreach (var method in native.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
if (method.Name == "IsStartedWithEasyAntiCheat" && method.ReturnType == typeof(bool))
yield return method;
var runtime = EacGate.Find("EasyAntiCheat.Client.Hydra.Runtime");
if (runtime != null)
foreach (var method in runtime.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
if ((method.Name == "IsActive" || method.Name == "get_Initialized") && method.ReturnType == typeof(bool))
yield return method;
}
[HarmonyPrefix]
private static bool Lie(ref bool __result, System.Reflection.MethodBase __originalMethod)
{
if (!EacGate.Enabled) return true;
__result = true;
EacGate.Log("[EacStub] " + __originalMethod.DeclaringType + "." + __originalMethod.Name + " answered true.");
return false;
}
}
[HarmonyPatch]
[HarmonyPatch]
internal static class ToolCollisionResetGuardPatch
{
internal static bool Enabled => !_disabled;
private static readonly bool _disabled = IsDisabled();
private static int _skips;
private static bool IsDisabled()
{
try
{
var raw = Environment.GetEnvironmentVariable("RADICADE_TOOL_RESET");
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
}
catch { return false; }
}
private static System.Reflection.MethodBase TargetMethod()
{
var method = AccessTools.Method(typeof(Tool), "ResetToDefaultCollisionLayer", Type.EmptyTypes);
if (method == null)
throw new MissingMethodException(
"Tool.ResetToDefaultCollisionLayer() not found - the room crash guard is NOT armed.");
return method;
}
[HarmonyPrefix]
private static bool Skip(Tool __instance)
{
if (!Enabled) return true;
var count = ++_skips;
if (count <= 5 || count % 50 == 0)
RadicadeEacBypassPlugin.LogSource.LogWarning(
"[ToolGuard] skipped Tool.ResetToDefaultCollisionLayer on " + Describe(__instance)
+ " (#" + count + ") - this method is the frame directly above the access violation "
+ "at GameAssembly.dll+0x470728d, which walks the tool's ECS collision-layer components. "
+ "Tool resets still apply; only the collision layer is left as it was.");
return false;
}
private static string Describe(Tool tool)
{
try
{
if (tool == null) return "<null tool>";
var owner = tool.gameObject;
return (owner == null) ? "<no gameObject>" : "'" + owner.name + "'";
}
catch (Exception ex)
{
return "<describe failed: " + ex.GetType().Name + ">";
}
}
}
internal static class ExitStateVetoPatch
{
internal static bool Enabled => _enabled;
private static readonly bool _enabled = IsEnabled();
private static int _blocks;
private static bool IsEnabled()
{
try
{
var raw = Environment.GetEnvironmentVariable("RADICADE_EXIT_VETO");
if (string.IsNullOrWhiteSpace(raw)) return false;
raw = raw.Trim().ToLowerInvariant();
return raw is "1" or "on" or "true" or "yes";
}
catch { return false; }
}
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
{
var property = AccessTools.PropertySetter(typeof(CCKNJCHHBLL), "GLNIKJNLCIB");
if (property != null) yield return property;
var raw = AccessTools.Method(typeof(CCKNJCHHBLL), "KALEFEDAHCC",
new[] { typeof(CCKNJCHHBLL.CCFIKLBABNB) });
if (raw != null) yield return raw;
}
[HarmonyPrefix]
private static bool Block(CCKNJCHHBLL.CCFIKLBABNB __0)
{
if (!Enabled) return true;
if (__0 == CCKNJCHHBLL.CCFIKLBABNB.Running) return true;
var count = ++_blocks;
var caller = count == 1 ? ExitTraceHelper.SafeStack() : string.Empty;
if (count <= 3 || count % 25 == 0)
RadicadeEacBypassPlugin.LogSource.LogWarning(
$"[ExitVeto] blocked AppExitState -> {__0} (block #{count}); the session keeps running. " +
"This is the anti-cheat-driven exit, not a user quit - the veto is armed by RADICADE_EXIT_VETO=1, set it to 0 to disable." + caller);
return false;
}
}
+34
View File
@@ -0,0 +1,34 @@
# Radicade August Client
Startup integration for Rec Room **20230825**, compiled against that build's
generated BepInEx interop assemblies. Requires the metadata-reader repair in
`tools/AugustInterop` first. It does not use the incompatible March UI patches.
Build from the repository root:
```powershell
dotnet build 2023Patch/RadicadeAugustClient/RadicadeAugustClient.csproj -c Release
```
Override `-p:BepInExRoot=<game>/BepInEx` for a different installation. No automatic
deployment occurs. Install `bin/Release/net6.0/RadicadeAugustClient.dll` into
`BepInEx/plugins`, with the old RadicadeEacBypass.dll disabled/backed up. The two
plugins share a config identity and must not be installed together.
Local config (`BepInEx/config/com.radicade.eacbypass.cfg`):
```ini
[Connection]
ServerUrl = https://localhost:3030
[TLS]
AllowedHosts = localhost
```
TLS exceptions are limited to the configured host allowlist. The plugin checks
the game version and requires HTTPS. It reads the normalized metadata copy only
to identify the image signature key. Native game files are not modified.
Verified: build succeeds, plugin loads, TLS succeeds, and server configuration
downloads. **Not yet playable:** the next client error is launch validation;
login, Photon multiplayer, watch UI and room loading are not verified or fully
ported. See `docs/2023-AUGUST-BUILD.md` for the current status and backups.
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<LangVersion>latest</LangVersion>
<BepInExRoot Condition="'$(BepInExRoot)' == ''">C:\RecRoom\2023-08-29T06-53-33Z\BepInEx</BepInExRoot>
</PropertyGroup>
<ItemGroup>
<Reference Include="$(BepInExRoot)\core\BepInEx.Core.dll;$(BepInExRoot)\core\BepInEx.Unity.IL2CPP.dll;$(BepInExRoot)\core\0Harmony.dll;$(BepInExRoot)\core\Il2CppInterop.Runtime.dll" Private="false" />
<Reference Include="$(BepInExRoot)\interop\*.dll" Private="false" />
<Compile Include="..\RadicadeEacBypass\EmbeddedTlsPatches.cs" Link="Shared\EmbeddedTlsPatches.cs" />
<Compile Include="..\RadicadeEacBypass\NameserverConnectionPatches.cs" Link="Shared\NameserverConnectionPatches.cs" />
<Compile Include="..\RadicadeEacBypass\NameserverRedirect.cs" Link="Shared\NameserverRedirect.cs" />
<Compile Include="..\RadicadeEacBypass\ImageSignatureRequest.cs" Link="Shared\ImageSignatureRequest.cs" />
<Compile Include="..\RadicadeEacBypass\HubSocketAuth.cs" Link="Shared\HubSocketAuth.cs" />
<Compile Include="..\RadicadeEacBypass\WebSocketRedirectPatches.cs" Link="Shared\WebSocketRedirectPatches.cs" />
<Compile Include="..\RadicadeEacBypass\Diag.cs" Link="Shared\Diag.cs" />
<Compile Include="..\RadicadeEacBypass\ResponseRepair.cs" Link="Shared\ResponseRepair.cs" />
<Compile Include="..\RadicadeEacBypass\HttpTolerancePatches.cs" Link="Shared\HttpTolerancePatches.cs" />
</ItemGroup>
</Project>
+67
View File
@@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
/// <summary>
/// Single switch for the per-request chatter the patch used to print on every
/// login (the [Net], [Connection], [Auth], [HttpStatus] and per-connection [TLS]
/// lines). It is OFF by default: the log now carries patch status and real
/// problems only. Set RADICADE_DIAG=1 to get the full trace back when something
/// needs diagnosing.
/// </summary>
internal static class Diag
{
internal static readonly bool Verbose = ReadFlag("RADICADE_DIAG");
private static readonly object Gate = new object();
private static readonly HashSet<string> Seen = new HashSet<string>(StringComparer.Ordinal);
internal static bool ReadFlag(string name)
{
try
{
var raw = Environment.GetEnvironmentVariable(name);
if (string.IsNullOrWhiteSpace(raw)) return false;
var value = raw.Trim().ToLowerInvariant();
return !(value == "0" || value == "off" || value == "false" || value == "no");
}
catch
{
return false;
}
}
/// <summary>Per-request tracing: printed only with RADICADE_DIAG=1.</summary>
internal static void Trace(string message)
{
if (!Verbose) return;
Info(message);
}
internal static void Info(string message)
{
try { RadicadeEacBypassPlugin.LogSource.LogInfo(message); } catch { }
}
internal static void Warn(string message)
{
try { RadicadeEacBypassPlugin.LogSource.LogWarning(message); } catch { }
}
internal static void Error(string message)
{
try { RadicadeEacBypassPlugin.LogSource.LogError(message); } catch { }
}
/// <summary>True the first time this key is seen in the session.</summary>
internal static bool FirstTime(string key)
{
try
{
lock (Gate) return Seen.Add(key);
}
catch
{
return false;
}
}
}
@@ -0,0 +1,137 @@
using System;
using System.Linq;
using HarmonyLib;
using TLS = Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication;
using TimeValid = Org.BouncyCastle.Crypto.Tls.TimeValidVerifyer;
[HarmonyPatch]
internal static class EmbeddedTlsPatches
{
internal static void Apply(Harmony harmony)
{
try
{
harmony.Patch(AccessTools.Method(typeof(TLS), "NotifyServerCertificate"),
prefix: new HarmonyMethod(typeof(EmbeddedTlsPatches), nameof(NotifyServerCertificatePrefix)));
RadicadeEacBypassPlugin.LogSource.LogInfo("[TLS] Patched LegacyTlsAuthentication.NotifyServerCertificate (host-scoped accept)");
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning($"[TLS] NotifyServerCertificate patch failed: {ex.GetType().Name}: {ex.Message}");
}
try
{
harmony.Patch(AccessTools.Method(typeof(TimeValid), "IsValid"),
prefix: new HarmonyMethod(typeof(EmbeddedTlsPatches), nameof(IsValidPrefix)));
RadicadeEacBypassPlugin.LogSource.LogInfo("[TLS] Patched TimeValidVerifyer.IsValid (host-scoped accept)");
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning($"[TLS] TimeValidVerifyer patch failed: {ex.GetType().Name}: {ex.Message}");
}
}
static bool NotifyServerCertificatePrefix(TLS __instance)
{
try
{
var uri = __instance.TargetUri;
var host = uri?.Host;
var verifyerType = __instance.verifyer != null ? __instance.verifyer.GetType().FullName : "<null>";
Diag.Trace(
$"[TLS] NotifyServerCertificate: host={host ?? "<null>"}, verifyer={verifyerType}");
if (host != null && IsHostAllowed(host))
{
Diag.Trace(
$"[TLS] Accepting server certificate for allowed Radicade host '{host}' (embedded-stack bypass)");
return false;
}
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning($"[TLS] NotifyServerCertificate prefix error: {ex.GetType().Name}: {ex.Message}");
}
return true;
}
static bool IsValidPrefix(Il2CppSystem.Uri targetUri, ref bool __result)
{
try
{
var host = targetUri?.Host;
if (host != null && IsHostAllowed(host))
{
Diag.Trace(
$"[TLS] TimeValidVerifyer.IsValid forced TRUE for allowed Radicade host '{host}'");
__result = true;
return false;
}
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning($"[TLS] IsValid prefix error: {ex.GetType().Name}: {ex.Message}");
}
return true;
}
public static bool IsHostAllowed(string host)
{
if (string.IsNullOrWhiteSpace(host)) return false;
var raw = RadicadeEacBypassPlugin.TlsAllowedHosts?.Value;
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Split(',')
.Select(h => h.Trim().TrimEnd('.'))
.Where(h => h.Length > 0)
.Any(h => host.TrimEnd('.').Equals(h, StringComparison.OrdinalIgnoreCase));
}
}
@@ -0,0 +1,107 @@
using System;
using System.IO;
using System.Text;
using System.Reflection;
using BestHTTP;
using HarmonyLib;
/// <summary>
/// Fabric-level repairs for two things the game cannot survive as-is:
/// a chunked response the server truncated, and two responses whose shape the
/// client's reader rejects.
/// </summary>
internal static class HttpTolerancePatches
{
private static long _healed;
private static int _lastHealReport;
internal static void Apply(Harmony harmony)
{
try
{
var target = TargetMethod();
if (target == null)
{
Diag.Warn("[Http] BestHTTP.HTTPResponse.ReadChunkLength(Stream) was not found: " +
"a truncated chunked body will keep failing its request.");
return;
}
harmony.Patch(target,
finalizer: new HarmonyMethod(AccessTools.Method(typeof(HttpTolerancePatches), nameof(Finalizer))));
Diag.Info("[Http] truncated chunked responses are completed instead of failing their request.");
}
catch (Exception ex)
{
Diag.Warn("[Http] could not arm the tolerant chunked reader (" + ex.GetType().Name + ": " + ex.Message + ").");
}
}
internal static MethodBase TargetMethod() =>
AccessTools.Method(typeof(HTTPResponse), "ReadChunkLength", new[] { typeof(Stream) });
/// <summary>
/// "Can't parse '' as a hex number" is BestHTTP reading a chunk-size line from a
/// connection the server had already closed - the pooled keep-alive connection
/// it picked was dead. Ending the body there (chunk length 0) delivers the
/// response that did arrive instead of failing the request, which is what the
/// heartbeats need: their ack body is empty anyway.
/// </summary>
private static Exception Finalizer(Exception __exception, ref int __result)
{
try
{
if (__exception == null) return null;
if (!ResponseRepair.IsTruncatedChunkFailure(__exception.Message)) return __exception;
ReportHealed();
__result = 0;
__exception = null;
return null;
}
catch
{
return __exception;
}
}
private static void ReportHealed()
{
var count = System.Threading.Interlocked.Increment(ref _healed);
var now = Environment.TickCount;
if (count != 1 && unchecked(now - _lastHealReport) < 60000) return;
_lastHealReport = now;
Diag.Warn("[Http] a chunked response ended early (dead pooled connection); the request was completed "
+ "with the bytes that arrived instead of failing. Occurrences so far: " + count + ".");
}
/// <summary>
/// Applies <see cref="ResponseRepair"/> to a live response before the client's own
/// callback reads it. Called from the request funnel, always on the transport
/// thread that owns the response.
/// </summary>
internal static void RepairResponse(HTTPResponse response, string path, string query)
{
try
{
if (response == null || string.IsNullOrEmpty(path)) return;
if (response.StatusCode != 200) return;
string body;
try { body = response.DataAsText; }
catch { return; }
var repaired = ResponseRepair.RepairBody(path, query, body, out var note);
if (repaired == null) return;
response.Data = Encoding.UTF8.GetBytes(repaired);
if (Diag.FirstTime("repair:" + path))
Diag.Warn("[Repair] " + path + ": " + note + ".");
}
catch (Exception ex)
{
Diag.Warn("[Repair] could not repair a " + path + " response: " + ex.GetType().Name + ": " + ex.Message);
}
}
}
@@ -0,0 +1,124 @@
using System;
using System.Text.Json;
using BestHTTP;
using Il2CppInterop.Runtime;
internal static class HubSocketAuth
{
internal static string Token { get; private set; }
internal static string LastLoginRefusalCode { get; private set; }
private static readonly string[] AuthorizationHeaders = { "Authorization", "authorization", "AUTHORIZATION" };
private static readonly System.Collections.Generic.HashSet<string> Stated = new();
internal static bool IsServer(string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var request) ||
!Uri.TryCreate(RadicadeEacBypassPlugin.ServerUrl.Value, UriKind.Absolute, out var server)) return false;
return request.Host.Equals(server.Host, StringComparison.OrdinalIgnoreCase) && request.Port == server.Port &&
(request.Scheme == server.Scheme || request.Scheme == (server.Scheme == "https" ? "wss" : "ws"));
}
internal static void Capture(HTTPRequest request)
{
if (request?.Uri == null || !IsServer(request.Uri.ToString())) return;
foreach (var name in AuthorizationHeaders)
{
var values = request.GetHeaderValues(name);
if (values == null || values.Count == 0) continue;
var value = values[0];
if (value != null && value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
Token = value.Substring(7).Trim();
}
}
internal static void CaptureFromLoginResponse(HTTPRequest request, string url)
{
if (!IsServer(url) || !new Uri(url).AbsolutePath.EndsWith("/connect/token", StringComparison.OrdinalIgnoreCase)) return;
Token = null;
var original = request.Callback;
request.Callback = DelegateSupport.ConvertDelegate<OnRequestFinishedDelegate>(
new Action<HTTPRequest, HTTPResponse>((req, response) =>
{
try
{
if (response != null && response.StatusCode == 200)
{
using var document = JsonDocument.Parse(response.DataAsText);
if (document.RootElement.TryGetProperty("access_token", out var token) &&
token.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(token.GetString()))
{
Token = token.GetString();
LastLoginRefusalCode = null;
Diag.Trace("[Realtime] Login token captured (value omitted)");
}
else
{
Token = null;
var error = document.RootElement.TryGetProperty("error", out var errorElement) ? errorElement.GetString() : null;
var description = document.RootElement.TryGetProperty("error_description", out var descriptionElement) ? descriptionElement.GetString() : null;
LastLoginRefusalCode = error;
RadicadeEacBypassPlugin.LogSource.LogWarning(
"[Realtime] connect/token answered HTTP 200 without an access_token - that is a login REFUSAL, not a login. " +
"error='" + (error ?? "-") + "', error_description='" + (description ?? "-") + "'.");
}
}
}
catch (Exception ex) { RadicadeEacBypassPlugin.LogSource.LogWarning("[Realtime] Token parse failed: " + ex.GetType().Name); }
finally { original?.Invoke(req, response); }
}));
}
internal static void AuthenticateHubRequest(HTTPRequest request, string url)
{
if (!IsServer(url) || !NameserverRedirect.IsHubUrl(url)) return;
if (!string.IsNullOrWhiteSpace(Token))
{
request.Uri = new Il2CppSystem.Uri(NameserverRedirect.EnsureHubToken(url, Token));
request.SetHeader("Authorization", "Bearer " + Token);
}
Diag.Trace("[Realtime] Hub request " + new Uri(url).AbsolutePath +
"; token=" + (NameserverRedirect.HasHubToken(request.Uri.ToString()) ? "present" : "missing"));
}
internal static void AuthenticateServerRequest(HTTPRequest request, string url)
{
if (request == null || !IsServer(url) || string.IsNullOrWhiteSpace(Token)) return;
try
{
if (IsAnonymousPath(new Uri(url).AbsolutePath)) return;
foreach (var name in AuthorizationHeaders)
{
var values = request.GetHeaderValues(name);
if (values != null && values.Count > 0) return;
}
request.SetHeader("Authorization", "Bearer " + Token);
var path = new Uri(url).AbsolutePath;
if (Stated.Add(path))
Diag.Trace("[Auth] stated the session token on " + path + " (the client sent none)");
}
catch (Exception ex)
{
Diag.Warn("[Auth] could not state the token on " + url + ": " + ex.GetType().Name);
}
}
private static bool IsAnonymousPath(string path) =>
path.StartsWith("/auth/connect/token", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/auth/cachedlogin", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/auth/eac/", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/images/", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/cdn/", StringComparison.OrdinalIgnoreCase);
}
@@ -0,0 +1,187 @@
#nullable enable
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Linq;
using System.Text;
public static class ImageSignatureRequest
{
const int HeaderScanLimit = 0x100;
const int MinimumRecords = 32;
const int PrefixProbe = 8;
const int PackingProbe = 1024;
const int MaxLiteralLength = 1 << 20;
static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(false, true);
public static string? DetectKeyId(byte[]? metadata)
{
if (metadata == null || metadata.Length < 24 ||
BinaryPrimitives.ReadUInt32LittleEndian(metadata) != 0xFAB11BAF ||
BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(4)) != 27)
return null;
int table = BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(8));
int size = BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(12));
int data = BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(16));
int dataSize = BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(20));
if (table < 24 || size < 0 || size % 8 != 0 || (long)table + size > metadata.Length ||
data < 24 || dataSize < 0 || (long)data + dataSize > metadata.Length)
return null;
return ScanKeyTails(metadata, table, size / 8, data, dataSize);
}
public static string? DetectKeyIdFromGameMetadata(byte[]? metadata)
{
if (metadata == null || metadata.Length < 24) return null;
if (BinaryPrimitives.ReadUInt32LittleEndian(metadata) == 0xFAB11BAF &&
BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(4)) == 27)
return DetectKeyId(metadata);
return DetectKeyIdByHeaderScan(metadata);
}
static string? ScanKeyTails(byte[] metadata, int tableOffset, int recordCount, int dataOffset, int dataSize)
{
string? key = null;
for (int record = 0; record < recordCount; record++)
{
var (length, index) = Record(metadata, tableOffset, record);
if (length < 0 || index < 0 || (long)index + length > dataSize) return null;
if (length != 8 && length != 18) continue;
var literal = Encoding.UTF8.GetString(metadata, dataOffset + index, length);
if (literal != ".rec.net" && literal != ".radicade.internal") continue;
var candidate = "KEY:RSA:p1" + literal;
if (key != null && key != candidate) return null;
key = candidate;
}
return key;
}
static (int Length, int Index) Record(byte[] metadata, int tableOffset, int record)
{
int offset = tableOffset + 8 * record;
return (BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(offset)),
BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(offset + 4)));
}
public static string? DetectKeyIdByHeaderScan(byte[]? metadata)
{
if (metadata == null || metadata.Length < HeaderScanLimit + 8) return null;
var fields = new List<int>();
for (int offset = 0; offset + 4 <= HeaderScanLimit; offset += 4)
{
int value = BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(offset));
if (value > 0 && value <= metadata.Length && !fields.Contains(value)) fields.Add(value);
}
var tables = new List<(int Offset, int Count)>();
foreach (var tableOffset in fields)
foreach (var tableSize in fields)
{
if (tableSize % 8 != 0 || tableSize / 8 < MinimumRecords) continue;
if (tableOffset < HeaderScanLimit || (long)tableOffset + tableSize > metadata.Length) continue;
tables.Add((tableOffset, tableSize / 8));
}
var slices = new List<(int Offset, int Size)>();
foreach (var dataOffset in fields)
foreach (var dataSize in fields)
{
if (dataSize <= 0 || dataOffset < HeaderScanLimit) continue;
if ((long)dataOffset + dataSize > metadata.Length) continue;
slices.Add((dataOffset, dataSize));
}
tables.Sort((a, b) => b.Count.CompareTo(a.Count));
foreach (var (tableOffset, count) in tables)
foreach (var (dataOffset, dataSize) in slices)
{
if (!LooksLikeLiteralTable(metadata, tableOffset, count, dataOffset, dataSize, PrefixProbe)) continue;
if (!SampleDecodesAsText(metadata, tableOffset, count, dataOffset, dataSize)) continue;
if (!LooksLikeLiteralTable(metadata, tableOffset, count, dataOffset, dataSize, PackingProbe)) continue;
var key = ScanKeyTails(metadata, tableOffset, count, dataOffset, dataSize);
if (key != null) return key;
}
return null;
}
static bool LooksLikeLiteralTable(byte[] metadata, int tableOffset, int count, int dataOffset, int dataSize, int probe)
{
int examined = Math.Min(count, probe);
int contiguous = 0;
long running = 0;
for (int record = 0; record < examined; record++)
{
var (length, index) = Record(metadata, tableOffset, record);
if (length < 0 || length > MaxLiteralLength || index < 0 || (long)index + length > dataSize) return false;
if (index == running) contiguous++;
running = index + length;
}
return contiguous >= examined - Math.Max(1, examined / 100);
}
static bool SampleDecodesAsText(byte[] metadata, int tableOffset, int count, int dataOffset, int dataSize)
{
int step = Math.Max(1, count / 48);
for (int record = 0; record < count; record += step)
{
var (length, index) = Record(metadata, tableOffset, record);
if (length < 0 || index < 0 || (long)index + length > dataSize) return false;
if (length == 0) continue;
try
{
StrictUtf8.GetCharCount(metadata, dataOffset + index, length);
}
catch (ArgumentException)
{
return false;
}
}
return true;
}
public static string AddKeyId(string url, string serverUrl, string? keyId)
{
if (string.IsNullOrEmpty(keyId) ||
!Uri.TryCreate(url, UriKind.Absolute, out var request) ||
!Uri.TryCreate(serverUrl, UriKind.Absolute, out var server) ||
(server.Scheme != "http" && server.Scheme != "https") ||
request.Scheme != server.Scheme || request.Authority != server.Authority ||
!request.AbsolutePath.StartsWith(server.AbsolutePath.TrimEnd('/') + "/images/", StringComparison.OrdinalIgnoreCase))
return url;
var parameters = request.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)
.Where(p => !Uri.UnescapeDataString(p.Split('=')[0]).Equals("rcsig", StringComparison.OrdinalIgnoreCase))
.Append("rcsig=" + Uri.EscapeDataString(keyId));
return request.GetLeftPart(UriPartial.Path) + "?" + string.Join("&", parameters) + request.Fragment;
}
}
@@ -0,0 +1,510 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using BestHTTP;
using HarmonyLib;
using Il2CppInterop.Runtime;
internal static class HileGate
{
internal static bool Enabled;
internal static int DroppedCount;
public static bool DropEnabled { get; } = IsDropEnabled();
private static bool IsDropEnabled()
{
try
{
var raw = System.Environment.GetEnvironmentVariable("RADICADE_HILE_GATE");
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Trim().ToLowerInvariant() == "drop";
}
catch { return false; }
}
private static bool _dumped;
public static bool ShouldDrop(HTTPRequest request)
{
if (!Enabled || request?.Uri == null) return false;
var path = request.Uri.AbsolutePath;
if (path == null ||
!path.EndsWith("/api/PlayerReporting/v1/hile", System.StringComparison.OrdinalIgnoreCase))
return false;
Dump(request);
return DropEnabled;
}
public static void Dump(HTTPRequest request)
{
if (_dumped) return;
_dumped = true;
try
{
var body = "<unreadable>";
try
{
var raw = request.RawData;
if (raw != null && raw.Length > 0)
{
body = System.Text.Encoding.UTF8.GetString(raw);
if (body.Length > 2000) body = body.Substring(0, 2000) + "...[truncated]";
}
else
{
var entity = request.GetEntityBody();
if (entity != null && entity.Length > 0)
{
var text = System.Text.Encoding.UTF8.GetString(entity);
body = text + " (" + entity.Length + " bytes in the entity body)";
}
else
{
body = "<empty body>";
}
}
}
catch (System.Exception bodyEx)
{
body = "<body read failed: " + bodyEx.Message + ">";
}
var caller = "<unavailable>";
try { caller = System.Environment.StackTrace; } catch { }
RadicadeEacBypassPlugin.LogSource.LogWarning(
"[HileGate] anti-cheat report captured (full wire form):\n " + request.MethodType
+ " " + request.Uri + "\n body: " + body
+ "\n managed caller stack:" + caller);
}
catch (System.Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[HileGate] capture failed: " + ex.Message);
}
}
}
[HarmonyPatch(typeof(HTTPManager), "SendRequest", new[] { typeof(HTTPRequest) })]
internal static class NameserverConnectionPatches
{
[HarmonyPriority(Priority.Last)]
private static bool Prefix(HTTPRequest request, ref HTTPRequest __result)
{
if (RequestFunnel.Handle(request)) return true;
__result = request;
return false;
}
}
internal static class RequestFunnel
{
private static readonly HashSet<long> Seen = new();
private static bool IsSocketScheme(string url) =>
url.StartsWith("ws:", StringComparison.OrdinalIgnoreCase) ||
url.StartsWith("wss:", StringComparison.OrdinalIgnoreCase);
internal static bool Handle(HTTPRequest request)
{
if (HileGate.ShouldDrop(request))
{
HileGate.DroppedCount++;
RadicadeEacBypassPlugin.LogSource.LogWarning("[HileGate] dropped anti-cheat report POST (no response will arrive).");
return false;
}
try
{
var uri = request?.Uri?.ToString();
if (!string.IsNullOrEmpty(uri))
Diag.Trace("[Net] " + request.MethodType + " " + uri);
}
catch
{
}
if (request?.Uri == null) return true;
if (!Seen.Add(request.Pointer.ToInt64())) return true;
var original = request.Uri.ToString();
var rewritten = IsSocketScheme(original)
? NameserverRedirect.RewriteWebSocket(original, RadicadeEacBypassPlugin.ServerUrl.Value)
: NameserverRedirect.Rewrite(original, RadicadeEacBypassPlugin.ServerUrl.Value);
var withKeyId = ImageSignatureRequest.AddKeyId(rewritten, RadicadeEacBypassPlugin.ServerUrl.Value,
RadicadeEacBypassPlugin.ImageKeyId);
if (withKeyId != rewritten)
{
Diag.Trace("[ImageSig] stated key id on " + withKeyId);
}
rewritten = withKeyId;
if (rewritten != original)
{
request.Uri = new Il2CppSystem.Uri(rewritten);
AlignHostHeader(request, rewritten);
Diag.Trace("[Connection] Redirected nameserver request to the configured Radicade server.");
}
if (RealtimeDiagnostics.Verbose && IsSocketScheme(original))
{
try
{
var outgoing = new System.Text.StringBuilder("[Realtime] socket request: " + original);
var headers = request.Headers;
if (headers != null)
{
foreach (var header in headers)
{
outgoing.Append(" | ").Append(header.Key).Append("=");
var values = header.Value;
if (values != null)
foreach (var value in values) outgoing.Append(value).Append(",");
}
}
RadicadeEacBypassPlugin.LogSource.LogWarning(outgoing.ToString());
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[Realtime] socket request dump failed: "
+ ex.GetType().Name + ": " + ex.Message);
}
}
HubSocketAuth.Capture(request);
HubSocketAuth.CaptureFromLoginResponse(request, request.Uri.ToString());
HubSocketAuth.AuthenticateHubRequest(request, request.Uri.ToString());
HubSocketAuth.AuthenticateServerRequest(request, request.Uri.ToString());
if (Diag.Verbose) AuthGrantTrace.Report(request);
HttpOutcomeTrace.Wrap(request);
return true;
}
private static void AlignHostHeader(HTTPRequest request, string url)
{
if (request == null || string.IsNullOrEmpty(url)) return;
Uri target;
if (!Uri.TryCreate(url, UriKind.Absolute, out target) || string.IsNullOrEmpty(target.Host)) return;
var authority = target.IsDefaultPort ? target.Host : target.Host + ":" + target.Port;
try
{
var stated = request.GetHeaderValues("Host");
if (stated != null && stated.Count > 0)
{
if (stated.Count == 1 && stated[0] == authority) return;
request.RemoveHeader("Host");
}
request.SetHeader("Host", authority);
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[Connection] could not align the Host header on " + url +
": " + ex.GetType().Name + ": " + ex.Message);
}
}
}
internal static class AuthGrantTrace
{
private static readonly HashSet<long> Reported = new();
private static readonly string[] Fields =
{
"account_id", "username", "password", "birthday", "avatar", "avatarJson",
"email", "platform_auth", "device_id", "device_class", "client_id", "ver"
};
internal static void Report(HTTPRequest request)
{
try
{
if (request?.Uri == null) return;
if (!request.Uri.ToString().EndsWith("/auth/connect/token", StringComparison.OrdinalIgnoreCase)) return;
if (!Reported.Add(request.Pointer.ToInt64())) return;
var body = ReadBody(request);
if (string.IsNullOrEmpty(body))
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[AuthGrant] connect/token body was unreadable; the grant is unknown for this request.");
return;
}
string Value(string name)
{
foreach (var pair in body.Split('&'))
{
var separator = pair.IndexOf('=');
if (separator <= 0) continue;
if (!string.Equals(pair.Substring(0, separator), name, StringComparison.OrdinalIgnoreCase)) continue;
return pair.Substring(separator + 1);
}
return null;
}
bool Present(string name)
{
var value = Value(name);
return !string.IsNullOrWhiteSpace(value);
}
var grant = Value("grant_type") ?? "<absent>";
var present = new List<string>();
foreach (var field in Fields)
if (Present(field)) present.Add(field);
RadicadeEacBypassPlugin.LogSource.LogInfo("[AuthGrant] connect/token grant_type=" + grant
+ "; fields present: " + (present.Count == 0 ? "<none>" : string.Join(", ", present))
+ " (values omitted)");
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[AuthGrant] could not read the grant: " + ex.GetType().Name + ": " + ex.Message);
}
}
private static string ReadBody(HTTPRequest request)
{
try
{
var raw = request.RawData;
if (raw != null && raw.Length > 0) return System.Text.Encoding.UTF8.GetString(raw);
}
catch { }
try
{
var entity = request.GetEntityBody();
if (entity != null && entity.Length > 0) return System.Text.Encoding.UTF8.GetString(entity);
}
catch { }
return null;
}
}
[HarmonyPatch]
internal static class SendRequestImplFunnelPatch
{
private static IEnumerable<MethodBase> TargetMethods()
{
var impl = AccessTools.Method(typeof(HTTPManager), "SendRequestImpl", new[] { typeof(HTTPRequest) });
return impl == null ? Enumerable.Empty<MethodBase>() : new MethodBase[] { impl };
}
private static void Prefix(object[] __args)
{
try
{
if (__args == null || __args.Length == 0) return;
RequestFunnel.Handle(__args[0] as HTTPRequest);
}
catch (Exception ex)
{
Diag.Warn("[Net] funnel trace failed: " + ex.GetType().Name + ": " + ex.Message);
}
}
}
internal static class HttpOutcomeTrace
{
private static readonly HashSet<long> Wrapped = new();
private static int _completed;
internal static void Wrap(HTTPRequest request)
{
if (request == null) return;
string url;
string method;
string path;
string query;
try
{
if (request.Uri == null) return;
url = Shorten(request.Uri.ToString());
method = request.MethodType.ToString();
path = request.Uri.AbsolutePath;
query = request.Uri.Query;
}
catch
{
return;
}
try
{
if (!Wrapped.Add(request.Pointer.ToInt64())) return;
var original = request.Callback;
request.Callback = DelegateSupport.ConvertDelegate<OnRequestFinishedDelegate>(
new Action<HTTPRequest, HTTPResponse>((req, response) =>
{
try
{
HttpTolerancePatches.RepairResponse(response, path, query);
var completed = System.Threading.Interlocked.Increment(ref _completed);
var status = -1;
var bytes = 0;
var reason = string.Empty;
if (response != null)
{
status = response.StatusCode;
try { var data = response.Data; if (data != null) bytes = data.Length; } catch { }
try { if (!response.IsSuccess && !string.IsNullOrEmpty(response.Message)) reason = " reason=" + response.Message; } catch { }
}
Diag.Trace("[HttpStatus] #" + completed + " " + method + " " + url + " -> "
+ (response == null ? "NO RESPONSE" : status.ToString())
+ " bytes=" + bytes + reason);
if (RealtimeDiagnostics.Verbose && url.IndexOf("/notify/hub/v1", StringComparison.OrdinalIgnoreCase) >= 0)
{
try
{
var answer = new System.Text.StringBuilder();
answer.Append("[Realtime] socket answer: status=").Append(response == null ? -1 : status)
.Append(" message=").Append(response == null ? "<none>" : response.Message);
var answerHeaders = response?.Headers;
if (answerHeaders != null)
{
foreach (var header in answerHeaders)
{
answer.Append(" | ").Append(header.Key).Append("=");
var values = header.Value;
if (values != null)
foreach (var value in values) answer.Append(value).Append(",");
}
}
string body = null;
try { body = response?.DataAsText; } catch { }
if (!string.IsNullOrEmpty(body))
answer.Append(" | body=").Append(body.Length > 200 ? body.Substring(0, 200) : body);
RadicadeEacBypassPlugin.LogSource.LogWarning(answer.ToString());
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[Realtime] socket answer dump failed: "
+ ex.GetType().Name + ": " + ex.Message);
}
}
}
catch
{
}
finally
{
try { original?.Invoke(req, response); } catch { }
}
}));
}
catch (Exception ex)
{
Diag.Warn("[HttpStatus] could not instrument " + method + " " + url + ": "
+ ex.GetType().Name + ": " + ex.Message);
}
}
private static string Shorten(string url)
{
try
{
var uri = new Uri(url);
var path = uri.AbsolutePath;
if (path.StartsWith("/auth/cachedlogin/forplatformid/", StringComparison.OrdinalIgnoreCase))
return "/auth/cachedlogin/forplatformid/{redacted}";
return path + (string.IsNullOrEmpty(uri.Query) ? string.Empty : "?" + uri.Query.Length + " chars");
}
catch
{
return "<unparsable>";
}
}
}
@@ -0,0 +1,195 @@
using System;
using System.Collections.Generic;
public static class NameserverRedirect
{
private static readonly Dictionary<string, string> ServicePrefixes = new(StringComparer.OrdinalIgnoreCase)
{
["api.rec.net"] = "",
["cdn.rec.net"] = "",
["images.rec.net"] = "/images",
["img.rec.net"] = "/images",
["data.rec.net"] = "/data",
["storage.rec.net"] = "/storage",
["notify.rec.net"] = "/notify",
["notifications.rec.net"] = "/notify",
["notify.radicade.internal"] = "/notify",
["notifications.radicade.internal"] = "/notify",
["auth.rec.net"] = "/auth",
["accounts.rec.net"] = "/accounts",
["rooms.rec.net"] = "/rooms",
["matchmaking.rec.net"] = "/matchmaking",
["chat.rec.net"] = "/chat",
["api.radicade.app"] = "",
["cdn.radicade.app"] = "/cdn",
["ai.radicade.app"] = "/ai",
["auth.radicade.app"] = "/auth",
["www.radicade.app"] = "/www",
["cards.radicade.app"] = "/cards",
["notifications.radicade.app"] = "/notify",
["images.radicade.app"] = "/images",
["commerce.radicade.app"] = "/commerce",
["data.radicade.app"] = "/data",
["datacollection.radicade.app"] = "/datacollection",
["discovery.radicade.app"] = "/discovery",
["matchmaking.radicade.app"] = "/matchmaking",
["storage.radicade.app"] = "/storage",
["chat.radicade.app"] = "/chat",
["geo.radicade.app"] = "/geo",
["leaderboard.radicade.app"] = "/leaderboard",
["accounts.radicade.app"] = "/accounts",
["link.radicade.app"] = "/link",
["locker.radicade.app"] = "/locker",
["moderation.radicade.app"] = "/moderation",
["roomcomments.radicade.app"] = "/roomcomments",
["roomieintegrations.radicade.app"] = "/roomieintegrations",
["clubs.radicade.app"] = "/clubs",
["rooms.radicade.app"] = "/rooms",
["econ.radicade.app"] = "/econ",
["bugreporting.radicade.app"] = "/bugreporting",
["cms.radicade.app"] = "/cms",
["gamelogs.radicade.app"] = "/gamelogs",
["platformnotifications.radicade.app"] = "/platformnotifications",
["stringscdn.radicade.app"] = "/stringscdn",
["strings.radicade.app"] = "/strings",
["lists.radicade.app"] = "/lists",
["playersettings.radicade.app"] = "/playersettings",
["studio.radicade.app"] = "/studio",
["studiocdn.radicade.app"] = "/studiocdn",
["thorn.radicade.app"] = "/thorn",
["videos.radicade.app"] = "/videos",
};
public static string Rewrite(string requestUrl, string serverUrl)
{
if (!Uri.TryCreate(requestUrl, UriKind.Absolute, out var request) ||
(request.Scheme != "http" && request.Scheme != "https"))
return requestUrl;
bool nameserver = request.Host.Equals("ns.rec.net", StringComparison.OrdinalIgnoreCase) ||
request.Host.Equals("ns.radicade.internal", StringComparison.OrdinalIgnoreCase);
bool patchedServer = request.Host.Equals("ns.radicade.app", StringComparison.OrdinalIgnoreCase);
ServicePrefixes.TryGetValue(request.Host, out var servicePrefix);
if (!nameserver && !patchedServer && servicePrefix == null)
return requestUrl;
if (!Uri.TryCreate(serverUrl, UriKind.Absolute, out var server) ||
(server.Scheme != "http" && server.Scheme != "https") ||
!string.IsNullOrEmpty(server.UserInfo) || !string.IsNullOrEmpty(server.Query) ||
!string.IsNullOrEmpty(server.Fragment))
throw new ArgumentException("Connection.ServerUrl must be an HTTP(S) base URL without credentials, query or fragment.", nameof(serverUrl));
var path = request.AbsolutePath;
if (nameserver)
{
if (path == "/") path = "/ns";
}
else if (servicePrefix is { Length: > 0 })
{
if (servicePrefix == "/notify" && path.StartsWith("/notify/", StringComparison.OrdinalIgnoreCase))
path = path.Substring("/notify".Length);
else if (path.StartsWith("/images/", StringComparison.OrdinalIgnoreCase))
path = path.Substring("/images".Length);
else if (path.StartsWith("/img/", StringComparison.OrdinalIgnoreCase))
path = path.Substring("/img".Length);
path = servicePrefix + path;
}
return server.AbsoluteUri.TrimEnd('/') + path + request.Query;
}
public static string RewriteWebSocket(string requestUrl, string serverUrl)
{
if (!Uri.TryCreate(requestUrl, UriKind.Absolute, out var request))
return requestUrl;
var scheme = request.Scheme.ToLowerInvariant();
var secure = scheme == "wss" || scheme == "https";
if (scheme != "ws" && scheme != "wss" && scheme != "http" && scheme != "https")
return requestUrl;
var asHttp = new UriBuilder(request) { Scheme = secure ? "https" : "http" }.Uri.AbsoluteUri;
var rewritten = Rewrite(asHttp, serverUrl);
if (rewritten == asHttp) return requestUrl;
if (!Uri.TryCreate(rewritten, UriKind.Absolute, out var result)) return requestUrl;
return new UriBuilder(result) { Scheme = result.Scheme == "https" ? "wss" : "ws" }.Uri.AbsoluteUri;
}
public static bool IsHubUrl(string url)
{
if (string.IsNullOrWhiteSpace(url)) return false;
var lower = url.ToLowerInvariant();
var query = lower.IndexOf('?');
var path = query < 0 ? lower : lower.Substring(0, query);
return path.Contains("/hub/v1") || path.EndsWith("/hub")
|| path.Contains("/hub/")
|| path.Contains("notifyhub")
|| path.Contains("/notify/hub");
}
public static bool HasHubToken(string url)
{
if (string.IsNullOrWhiteSpace(url)) return false;
var query = url.IndexOf('?');
if (query < 0) return false;
foreach (var pair in url.Substring(query + 1).Split('&'))
{
var equals = pair.IndexOf('=');
if (equals <= 0 || equals + 1 >= pair.Length) continue;
if (pair.Substring(0, equals).Equals("access_token", StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
public static string EnsureHubToken(string url, string token)
{
if (!IsHubUrl(url)) return url;
if (HasHubToken(url)) return url;
if (string.IsNullOrWhiteSpace(token)) return url;
var raw = token.Trim();
if (raw.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) raw = raw.Substring(7).Trim();
else if (raw.Equals("Bearer", StringComparison.OrdinalIgnoreCase)) raw = string.Empty;
if (raw.Length == 0) return url;
var query = url.IndexOf('?');
var basePart = query < 0 ? url : url.Substring(0, query);
var rest = query < 0 ? string.Empty : url.Substring(query + 1).TrimStart('&');
var accessToken = "access_token=" + Uri.EscapeDataString(raw);
return basePart + "?" + accessToken + (rest.Length == 0 ? string.Empty : "&" + rest);
}
}
@@ -0,0 +1,903 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using HarmonyLib;
internal static class RealtimeDiagnostics
{
private static readonly object Gate = new object();
private static readonly Dictionary<string, int> Targets = new Dictionary<string, int>();
private static readonly Dictionary<string, int> NotificationIds = new Dictionary<string, int>();
private static readonly HashSet<string> KnownNotifications = new HashSet<string>();
private static readonly HashSet<string> HighFrequencyIds = new HashSet<string>(StringComparer.Ordinal)
{
"PresenceUpdate",
"AccountUpdate",
"SelfAccountUpdate",
"SubscriptionUpdatePresence",
"SubscriptionUpdateProfile",
"SubscriptionUpdateGameSession",
"SubscriptionUpdateRoom",
"SubscriptionUpdateRoomPlaylist"
};
private static int Frames;
private static int Failures;
private const int VerboseFrames = 40;
private const int PreviewChars = 400;
internal static void StartHandlerKeyProbe(Harmony harmony)
{
if (Interlocked.Exchange(ref HandlerKeyProbeStarted, 1) != 0) return;
var worker = new Thread(() =>
{
Type dispatcher = null;
bool hooksInstalled = false;
bool tablesDumped = false;
for (int attempt = 1; attempt <= HandlerKeyProbeAttempts; attempt++)
{
try
{
if (dispatcher == null) dispatcher = FindNotificationDispatcher();
if (!hooksInstalled && dispatcher != null)
hooksInstalled = InstallRegistrationHooks(harmony, dispatcher);
if (!tablesDumped && attempt <= TableDumpAttempts)
tablesDumped = DumpDispatcherKeys(attempt, dispatcher);
if (hooksInstalled && (tablesDumped || attempt >= TableDumpAttempts)) return;
}
catch (Exception ex)
{
if (attempt == 1)
RadicadeEacBypassPlugin.LogSource.LogWarning(
$"[Realtime] handler-key probe failed ({ex.GetType().Name}: {ex.Message}); retrying.");
}
Thread.Sleep(HandlerKeyProbeIntervalMs);
}
if (!hooksInstalled)
RadicadeEacBypassPlugin.LogSource.LogWarning(
$"[Realtime] no handler-registration method was located after {HandlerKeyProbeAttempts} attempts " +
"- notification ids cannot be checked against the client's own registrations in this build.");
})
{ IsBackground = true, Name = "RadicadeNotifyKeyProbe" };
worker.Start();
}
private const int HandlerKeyProbeAttempts = 60;
private const int HandlerKeyProbeIntervalMs = 2000;
private const int TableDumpAttempts = 3;
private static int HandlerKeyProbeStarted;
private static readonly HashSet<string> RegisteredHandlerKeys = new HashSet<string>(StringComparer.Ordinal);
private static bool ChatKeyReported;
private static bool InstallRegistrationHooks(Harmony harmony, Type dispatcher)
{
var hooked = 0;
var callbacks = 0;
var generics = 0;
foreach (var method in AccessTools.GetDeclaredMethods(dispatcher))
{
try
{
if (method.ReturnType != typeof(void)) continue;
var parameters = method.GetParameters();
if (parameters.Length != 1 && parameters.Length != 2) continue;
var keyType = parameters[0].ParameterType;
if (keyType != typeof(string) && !keyType.IsEnum) continue;
if (parameters.Length == 2)
{
if (!IsCallback(parameters[1].ParameterType)) continue;
callbacks++;
if (method.IsGenericMethodDefinition) { generics++; continue; }
}
harmony.Patch(method,
prefix: new HarmonyMethod(AccessTools.Method(typeof(RealtimeDiagnostics), nameof(RegistrationPrefix))));
hooked++;
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning(
$"[Realtime] could not hook {dispatcher.Name}.{method.Name} for key logging ({ex.GetType().Name}: {ex.Message}).");
}
}
if (hooked == 0) return false;
RadicadeEacBypassPlugin.LogSource.LogInfo(
$"[Realtime] watching {hooked} dispatcher method(s) on {dispatcher.FullName} " +
$"({callbacks} registration, {generics} generic overload(s) skipped but funnel through the core, rest channels): " +
"every notification id this client accepts will be printed as it registers.");
return true;
}
private static bool IsCallback(Type type)
{
try
{
if (typeof(Delegate).IsAssignableFrom(type)) return true;
for (var current = type; current != null; current = current.BaseType)
{
var name = current.Name;
if (name == "Delegate" || name == "MulticastDelegate") return true;
}
}
catch { }
return false;
}
private static void RegistrationPrefix(object[] __args, MethodBase __originalMethod)
{
try
{
if (__args == null || __args.Length == 0) return;
var key = DescribeKey(__args[0]);
if (key == null) return;
if (__args.Length == 1)
{
RadicadeEacBypassPlugin.LogSource.LogInfo(
$"[Realtime] the client called {__originalMethod?.Name}({key}) - " +
"a realtime channel/lookup name it expects the server to publish on.");
return;
}
bool first;
lock (Gate) first = RegisteredHandlerKeys.Add(key);
if (!first) return;
RadicadeEacBypassPlugin.LogSource.LogInfo(
$"[Realtime] the client registered a notification handler under {key} " +
$"({__originalMethod?.DeclaringType?.Name}.{__originalMethod?.Name}) - " +
$"a notification whose Id EQUALS {key} is handled, anything else is dropped.");
if (key.IndexOf("Progression", StringComparison.Ordinal) >= 0
|| key.IndexOf("AppVersion", StringComparison.Ordinal) >= 0
|| key.IndexOf("Announcement", StringComparison.Ordinal) >= 0)
RadicadeEacBypassPlugin.LogSource.LogInfo(
$"[Realtime] {key} binds a payload of type {DescribeCallback(__args.Length > 1 ? __args[1] : null)}");
ReportChatKeyMatch();
}
catch { }
}
private static string DescribeCallback(object? callback)
{
if (callback == null) return "(no callback)";
try
{
var type = callback.GetType();
var candidates = new List<Type>();
if (type.IsGenericType) candidates.AddRange(type.GetGenericArguments());
for (var current = type; current != null; current = current.BaseType)
{
MethodInfo? invoke = null;
try { invoke = current.GetMethod("Invoke", BindingFlags.Public | BindingFlags.Instance); }
catch { }
if (invoke == null) continue;
foreach (var parameter in invoke.GetParameters()) candidates.Add(parameter.ParameterType);
}
var payloads = candidates
.Where(t => t != null && t != typeof(void))
.Distinct()
.ToList();
if (payloads.Count == 0) return $"{type.FullName} (no payload readable)";
return string.Join(" | ", payloads.Select(DescribePayloadType));
}
catch (Exception ex)
{
return $"(unreadable: {ex.GetType().Name})";
}
}
private static string DescribePayloadType(Type type)
{
try
{
if (type.IsPrimitive || type == typeof(string)) return type.Name;
var members = new List<string>();
foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Instance))
members.Add(field.Name);
foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
members.Add(property.Name);
members = members.Distinct().OrderBy(m => m, StringComparer.Ordinal).ToList();
if (members.Count == 0) return type.FullName ?? type.Name;
return $"{type.FullName} {{{string.Join(", ", members)}}}";
}
catch
{
return type.FullName ?? type.Name;
}
}
private static string DescribeKey(object value)
{
if (value is string text) return Quote(text);
if (value == null) return null;
try
{
var type = value.GetType();
return type.IsEnum ? Quote($"{type.Name}.{value}") : null;
}
catch
{
return null;
}
}
private static void ReportChatKeyMatch()
{
lock (Gate)
{
if (ChatKeyReported) return;
var numeric = RegisteredHandlerKeys.Contains("\"90\"");
var named = RegisteredHandlerKeys.FirstOrDefault(
key => key.IndexOf("ChatMessage", StringComparison.Ordinal) >= 0);
if (!numeric && named == null) return;
ChatKeyReported = true;
RadicadeEacBypassPlugin.LogSource.LogInfo(
$"[Realtime] chat push key check: this client registered " +
$"{(numeric ? "a handler for the chat enum's number (\"90\")" : "NO handler for the chat enum's number (\"90\")")}" +
$"{(named == null ? string.Empty : $", and {named} by name")}. " +
"Only an exact match is delivered, so the server's notification Id has to be the " +
"spelling that was registered - for chat that is the name.");
}
}
private const int MaxTables = 25;
private const int MaxKeysPerTable = 60;
private static bool DumpDispatcherKeys(int attempt, Type dispatcher)
{
var tables = new List<string>();
if (dispatcher != null) CollectStringKeyedTables(dispatcher, tables);
if (tables.Count == 0)
{
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
var name = assembly.GetName().Name ?? string.Empty;
if (!name.StartsWith("RecNet", StringComparison.Ordinal)) continue;
Type[] types;
try { types = assembly.GetTypes(); }
catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t != null).ToArray(); }
catch { continue; }
foreach (var type in types)
{
try { CollectStringKeyedTables(type, tables); } catch { }
if (tables.Count >= MaxTables) break;
}
if (tables.Count >= MaxTables) break;
}
}
if (tables.Count == 0) return false;
RadicadeEacBypassPlugin.LogSource.LogInfo(
dispatcher != null
? $"[Realtime] the client's notification dispatcher ({dispatcher.FullName}) holds these string keys (attempt {attempt}). " +
"A notification Id must EQUAL one of them to be handled - this is the contract:"
: $"[Realtime] could not pin the notification dispatcher, so every string-keyed static table in RecNet.* is listed " +
$"instead (attempt {attempt}). The handler keys are among them; a notification Id must EQUAL one to be handled:");
foreach (var table in tables)
RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] {table}");
var flat = " " + string.Join(" ", tables) + " ";
RadicadeEacBypassPlugin.LogSource.LogInfo(
$"[Realtime] chat push key check: the client registered {Registered(flat, "\"90\"")} for \"90\" and " +
$"{Registered(flat, "ChatMessageReceived")} for \"ChatMessageReceived\". " +
"Whichever is registered is the string the server's chat notification Id must equal.");
return true;
}
private static string Registered(string flatKeys, string key)
=> flatKeys.Contains(" " + key, StringComparison.Ordinal) ? "a handler" : "NO handler";
private static void CollectStringKeyedTables(Type type, List<string> tables)
{
foreach (var field in type.GetFields(
BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
{
if (tables.Count >= MaxTables) return;
Type fieldType;
try { fieldType = field.FieldType; } catch { continue; }
if (!IsStringKeyed(fieldType)) continue;
object value;
try { value = field.GetValue(null); }
catch { continue; }
AddTable(tables, $"{type.Name}.{field.Name}", value);
}
}
private static bool IsStringKeyed(Type type)
{
try
{
return type.IsGenericType && type.GetGenericArguments()[0] == typeof(string);
}
catch
{
return false;
}
}
private static void AddTable(List<string> tables, string label, object value)
{
if (value == null) return;
var keys = new List<string>();
try
{
if (value is System.Collections.IDictionary map)
{
foreach (var key in map.Keys) keys.Add(Quote(key?.ToString()));
}
else if (value is System.Collections.IEnumerable items)
{
foreach (var item in items) keys.Add(KeyOf(item));
}
else return;
}
catch
{
return;
}
if (keys.Count == 0) return;
keys.Sort(StringComparer.Ordinal);
var shown = keys.Count <= MaxKeysPerTable
? string.Join(", ", keys)
: string.Join(", ", keys.Take(MaxKeysPerTable)) + $", … (+{keys.Count - MaxKeysPerTable} more)";
tables.Add($"{label}: {shown}");
}
private static string KeyOf(object item)
{
if (item == null) return "(null)";
try
{
var type = item.GetType();
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(KeyValuePair<,>))
{
var key = type.GetProperty("Key");
if (key != null) return Quote(key.GetValue(item)?.ToString());
}
}
catch { }
return Quote(item.ToString());
}
private static string Quote(string text) => text == null ? "(null)" : $"\"{text}\"";
private static Type FindNotificationDispatcher()
{
var byName = FindType("LMBGGGGNBOB");
if (byName != null && DeclaresNotificationEnum(byName)) return byName;
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
Type[] types;
try { types = assembly.GetTypes(); }
catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t != null).ToArray(); }
catch { continue; }
foreach (var type in types)
{
try
{
if (!type.IsEnum || !IsNotificationEnum(type)) continue;
RadicadeEacBypassPlugin.LogSource.LogInfo(
$"[Realtime] notification enum located: {type.FullName} " +
$"(message = {Convert.ToInt64(Enum.Parse(type, "MessageReceived"), System.Globalization.CultureInfo.InvariantCulture)}, " +
$"kick = {Convert.ToInt64(Enum.Parse(type, "ModerationKick"), System.Globalization.CultureInfo.InvariantCulture)})");
if (type.DeclaringType != null) return type.DeclaringType;
continue;
}
catch { }
}
}
return null;
}
private static bool DeclaresNotificationEnum(Type type)
{
try
{
foreach (var nested in type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic))
if (nested.IsEnum && IsNotificationEnum(nested)) return true;
}
catch { }
return false;
}
private static bool IsNotificationEnum(Type type)
{
var members = Enum.GetNames(type);
return Array.IndexOf(members, "MessageReceived") >= 0
&& Array.IndexOf(members, "ModerationKick") >= 0
&& Array.IndexOf(members, "PresenceHeartbeatResponse") >= 0;
}
internal static System.DateTime? ServerRequestedExitUtc;
private const int ExitRequestWindowSeconds = 120;
internal static bool ServerRequestedExitRecently()
=> ServerRequestedExitUtc.HasValue
&& (System.DateTime.UtcNow - ServerRequestedExitUtc.Value).TotalSeconds < ExitRequestWindowSeconds;
private static void NoteServerRequestedExit(string id)
{
if (id != "20" && id != "6") return;
if (ServerRequestedExitRecently()) return;
ServerRequestedExitUtc = System.DateTime.UtcNow;
RadicadeEacBypassPlugin.LogSource.LogInfo(
$"[Realtime] Id={id} arrived - the server asked this client to leave, so the AppExit guard will let the exit through.");
}
internal static void ApplyExitWatch(Harmony harmony)
{
try
{
var transport = FindType("BestHTTP.SignalRCore.Transports.WebSocketTransport");
var target = transport == null ? null : AccessTools.DeclaredMethod(transport, "OnMessage");
if (target == null)
{
RadicadeEacBypassPlugin.LogSource.LogWarning(
"[Realtime] could not watch for the server's quit/logout frames - a moderator's Quit Game may be suppressed as a room-load exit.");
return;
}
harmony.Patch(target, prefix: new HarmonyMethod(typeof(RealtimeDiagnostics), nameof(ExitWatchPrefix)));
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] exit watch failed to install: {ex.GetType().Name}: {ex.Message}");
}
}
private static void ExitWatchPrefix(string __1)
{
var text = __1;
if (string.IsNullOrEmpty(text)) return;
if (text.IndexOf("\"Notification\"", StringComparison.Ordinal) < 0) return;
if (text.IndexOf("\"Id\":\"20\"", StringComparison.Ordinal) >= 0 || text.IndexOf("\"Id\":20", StringComparison.Ordinal) >= 0)
{
NoteServerRequestedExit("20");
return;
}
if (text.IndexOf("\"Id\":\"6\"", StringComparison.Ordinal) >= 0 || text.IndexOf("\"Id\":6", StringComparison.Ordinal) >= 0)
NoteServerRequestedExit("6");
}
internal static bool Verbose = true;
internal static void Apply(Harmony harmony, bool verbose)
{
Verbose = verbose;
var transport = FindType("BestHTTP.SignalRCore.Transports.WebSocketTransport");
if (transport == null)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[Realtime] BestHTTP.SignalRCore.Transports.WebSocketTransport not found - notify frame logging stays inactive.");
}
else
{
Hook(harmony, transport, "OnOpen", nameof(OnOpenPrefix), "socket open");
Hook(harmony, transport, "HandleHandshakeResponse", nameof(HandshakePrefix), "SignalR handshake answer");
Hook(harmony, transport, "OnMessage", nameof(OnMessagePrefix), "inbound frames");
Hook(harmony, transport, "OnError", nameof(OnErrorPrefix), "transport errors");
Hook(harmony, transport, "OnClosed", nameof(OnClosedPrefix), "socket close with code/reason");
}
var hub = FindType("BestHTTP.SignalRCore.HubConnection");
if (hub == null)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[Realtime] BestHTTP.SignalRCore.HubConnection not found - hub-pump guard stays inactive.");
}
else
{
HookFinalizer(harmony, hub, "OnMessages", nameof(OnMessagesFinalizer));
}
RadicadeEacBypassPlugin.LogSource.LogInfo(Verbose
? "[Realtime] notify-hub tap armed: every inbound frame is read and logged."
: "[Realtime] notify-hub tap armed for moderation only - a voice chat restriction is applied from the frame it arrives in; frames are not logged (Diagnostics=false).");
if (Verbose) StartHandlerKeyProbe(harmony);
}
private static Type FindType(string name)
{
var direct = AccessTools.TypeByName(name);
if (direct != null) return direct;
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
try
{
var found = assembly.GetType(name, false);
if (found != null) return found;
}
catch { }
}
return null;
}
private static void Hook(Harmony harmony, Type type, string method, string prefix, string what)
{
try
{
var target = AccessTools.DeclaredMethod(type, method);
if (target == null)
{
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] {type.Name}.{method} not found - {what} will not be logged.");
return;
}
harmony.Patch(target, prefix: new HarmonyMethod(typeof(RealtimeDiagnostics), prefix));
if (Verbose)
RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] hooked {type.Name}.{method} ({what})");
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] could not hook {type.Name}.{method}: {ex.GetType().Name}: {ex.Message}");
}
}
private static void HookFinalizer(Harmony harmony, Type type, string method, string finalizer)
{
try
{
var target = AccessTools.DeclaredMethod(type, method);
if (target == null)
{
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] {type.Name}.{method} not found - the hub-pump guard stays inactive.");
return;
}
harmony.Patch(target, finalizer: new HarmonyMethod(typeof(RealtimeDiagnostics), finalizer));
if (Verbose)
RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] guarded {type.Name}.{method} (a frame the client cannot parse is logged, not thrown)");
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] could not guard {type.Name}.{method}: {ex.GetType().Name}: {ex.Message}");
}
}
private static void OnOpenPrefix()
{
if (Verbose)
RadicadeEacBypassPlugin.LogSource.LogInfo("[Realtime] socket open - the notify hub WebSocket upgraded and is ready.");
}
private static void HandshakePrefix(string __0)
{
var text = __0 ?? string.Empty;
if (text.IndexOf("error", StringComparison.OrdinalIgnoreCase) >= 0)
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] hub handshake answered with an error: {Trim(text, 300)}");
else if (Verbose)
RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] hub handshake accepted: {Trim(text, 120)}");
}
private static void OnMessagePrefix(string __1) => ObserveFrame(__1);
private static void OnErrorPrefix(string __1) =>
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] transport error: {Trim(__1, 300)}");
private static void OnClosedPrefix(ushort __1, string __2) =>
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] socket closed code={__1} reason={Trim(__2, 200)}");
private static Exception OnMessagesFinalizer(Exception __exception)
{
if (__exception == null) return null;
lock (Gate) Failures++;
RadicadeEacBypassPlugin.LogSource.LogWarning(
$"[Realtime] the client failed to handle a hub frame ({__exception.GetType().Name}: {__exception.Message}) - " +
$"frame swallowed so the notify socket and the rest of the batch survive (failures={Failures}). " +
"A parse error here means a notification payload the client's own parser rejects; the IdToken/summary lines " +
"above name which notification it was.");
return null;
}
private static void ObserveFrame(string data)
{
if (string.IsNullOrEmpty(data))
{
if (Verbose)
RadicadeEacBypassPlugin.LogSource.LogInfo("[Realtime] empty frame received (keep-alive).");
return;
}
foreach (var record in data.Split('\u001e'))
{
if (record.Length == 0) continue;
if (!Verbose && record.IndexOf(VoiceChatRestrictionPatches.NotificationId, StringComparison.Ordinal) < 0)
continue;
int index;
lock (Gate) index = ++Frames;
DescribeRecord(index, record);
}
}
private static void DescribeRecord(int index, string record)
{
try
{
using var document = JsonDocument.Parse(record);
var root = document.RootElement;
var type = root.TryGetProperty("type", out var typeElement) ? typeElement.GetRawText() : "?";
var target = root.TryGetProperty("target", out var targetElement) ? targetElement.GetString() : null;
if (string.Equals(target, "Notification", StringComparison.Ordinal))
{
DescribeNotification(index, root, record);
return;
}
Count(Targets, target ?? ("type " + type));
if (!Verbose) return;
if (index <= VerboseFrames)
RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] frame #{index} target={target ?? "-"} type={type} len={record.Length}: {Trim(record, PreviewChars)}");
else
Summarize(index);
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] frame #{index} is not readable as a hub record ({ex.GetType().Name}: {ex.Message}): {Trim(record, PreviewChars)}");
}
}
private static void DescribeNotification(int index, JsonElement root, string record)
{
string envelope = null;
if (root.TryGetProperty("arguments", out var arguments)
&& arguments.ValueKind == JsonValueKind.Array
&& arguments.GetArrayLength() > 0)
{
var first = arguments[0];
envelope = first.ValueKind == JsonValueKind.String ? first.GetString() : first.GetRawText();
}
string id = null;
string idToken = "(no Id)";
string msg = "(no Msg)";
if (!string.IsNullOrEmpty(envelope))
{
try
{
using var inner = JsonDocument.Parse(envelope);
var payload = inner.RootElement;
if (TryGetProperty(payload, "Id", out var idElement))
{
idToken = idElement.ValueKind.ToString();
id = idElement.ValueKind == JsonValueKind.String ? idElement.GetString() : idElement.GetRawText();
}
if (TryGetProperty(payload, "Msg", out var msgElement))
msg = Trim(msgElement.GetRawText(), 200);
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] Notification #{index} envelope did not parse at all ({ex.GetType().Name}: {ex.Message}): {Trim(envelope, PreviewChars)}");
envelope = null;
}
}
if (!string.IsNullOrEmpty(id)) NoteServerRequestedExit(id);
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(envelope))
VoiceChatRestrictionPatches.Observe(id, envelope);
Count(Targets, "Notification");
Count(NotificationIds, id ?? "(no Id)");
bool firstOfItsKind = false;
if (!string.IsNullOrEmpty(id))
lock (Gate) firstOfItsKind = KnownNotifications.Add(id);
if (!Verbose) return;
bool worthFullDetail = string.IsNullOrEmpty(id) || !HighFrequencyIds.Contains(id);
if (worthFullDetail || firstOfItsKind || index <= VerboseFrames)
RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] Notification #{index} Id={id ?? "(none)"} IdToken={idToken} Msg={msg}");
else
Summarize(index);
if (worthFullDetail || firstOfItsKind)
RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] {id ?? "envelope"} envelope: {Trim(envelope ?? record, PreviewChars)}");
}
private static void Summarize(int index)
{
if (index % 25 != 0) return;
string targets;
string ids;
int failures;
lock (Gate)
{
targets = Join(Targets);
ids = Join(NotificationIds);
failures = Failures;
} RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] {index} frames so far - targets: {targets}; notifications: {ids}; handler failures: {failures}");
}
private static bool TryGetProperty(JsonElement element, string name, out JsonElement value)
{
if (element.ValueKind == JsonValueKind.Object)
{
if (element.TryGetProperty(name, out value)) return true;
var lower = char.ToLowerInvariant(name[0]) + name.Substring(1);
if (element.TryGetProperty(lower, out value)) return true;
}
value = default;
return false;
}
private static void Count(Dictionary<string, int> counts, string key)
{
lock (Gate)
{
counts.TryGetValue(key, out var current);
counts[key] = current + 1;
}
}
private static string Join(Dictionary<string, int> counts)
{
var parts = new List<string>();
foreach (var pair in counts) parts.Add($"{pair.Key} x{pair.Value}");
return parts.Count == 0 ? "none" : string.Join(", ", parts);
}
private static string Trim(string text, int limit)
{
if (string.IsNullOrEmpty(text)) return string.Empty;
var flat = text.Replace("\r", " ").Replace("\n", " ");
return flat.Length <= limit ? flat : flat.Substring(0, limit) + "...";
}
}
@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using BestHTTP;
using HarmonyLib;
internal static class RequestRouteLog
{
private static readonly object Gate = new();
private static readonly HashSet<string> Seen = new();
private const int Limit = 300;
internal static void Apply(Harmony harmony)
{
try
{
var response = AccessTools.TypeByName("BestHTTP.HTTPResponse");
if (response == null)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[HttpLog] BestHTTP.HTTPResponse not found - REST routes stay unlogged.");
return;
}
System.Reflection.ConstructorInfo constructor = null;
foreach (var candidate in AccessTools.GetDeclaredConstructors(response))
{
var parameters = candidate.GetParameters();
if (parameters.Length == 4
&& parameters[0].ParameterType.Name.Contains("HTTPRequest")
&& parameters[2].ParameterType.Name == "Boolean"
&& parameters[3].ParameterType.Name == "Boolean")
{
constructor = candidate;
break;
}
}
if (constructor == null)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[HttpLog] HTTPResponse ctor not found - REST routes stay unlogged.");
return;
}
harmony.Patch(constructor, postfix: new HarmonyMethod(typeof(RequestRouteLog), nameof(ResponsePostfix)));
RadicadeEacBypassPlugin.LogSource.LogInfo("[HttpLog] hooked HTTPResponse(HTTPRequest, Stream) - every REST route and status is logged once");
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning($"[HttpLog] could not hook HTTPResponse: {ex.GetType().Name}: {ex.Message}");
}
}
private static void ResponsePostfix(HTTPResponse __instance, HTTPRequest __0)
{
try
{
var uri = __0?.Uri?.ToString();
if (string.IsNullOrEmpty(uri)) return;
if (!HubSocketAuth.IsServer(uri)) return;
var path = new Uri(uri).AbsolutePath;
if (NameserverRedirect.IsHubUrl(uri)) return;
if (path.Contains("/images/", StringComparison.OrdinalIgnoreCase)) return;
if (path.Contains("/cdn/", StringComparison.OrdinalIgnoreCase)) return;
int status;
try { status = __instance.StatusCode; }
catch { return; }
var method = "REQ";
try { method = __0.MethodType.ToString().ToUpperInvariant(); }
catch { }
var line = $"{status} {method} {path}";
lock (Gate)
{
if (Seen.Count >= Limit || !Seen.Add(line)) return;
}
RadicadeEacBypassPlugin.LogSource.LogInfo($"[HttpLog] {line}");
}
catch
{
}
}
}
@@ -0,0 +1,164 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
/// <summary>
/// Pure string surgery for two responses the client cannot cope with as the
/// server sends them. No engine or BestHTTP types live here so the rules can be
/// exercised outside the game.
/// </summary>
internal static class ResponseRepair
{
/// <summary>Loopback probe target: any transport fails (or answers) instantly, so a probe phase can never stall.</summary>
internal const string LoopbackProbeAddress = "127.0.0.1:50000";
private const string QosPathSuffix = "/matchmaking/player/qos";
private const string UserSegmentPathSuffix = "/data/commonquery/usersegment";
private const string AddressKey = "\"address\"";
/// <summary>
/// BestHTTP's chunked reader throws exactly this when the chunk-size line is
/// empty, which is what a keep-alive connection the server had already closed
/// looks like. It is not a bad request, so it must not fail one.
/// </summary>
internal static bool IsTruncatedChunkFailure(string message)
{
if (string.IsNullOrEmpty(message)) return false;
return message.IndexOf("as a hex number", StringComparison.OrdinalIgnoreCase) >= 0;
}
/// <summary>
/// Rewrites a response body when the client would otherwise mis-handle it.
/// Returns null when the body is left alone; <paramref name="note"/> explains
/// what changed when it did.
/// </summary>
internal static string RepairBody(string path, string query, string body, out string note)
{
note = null;
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(body)) return null;
if (EndsWith(path, QosPathSuffix)) return RepairProbeList(body, ref note);
if (EndsWith(path, UserSegmentPathSuffix)) return RepairUserSegment(query, body, ref note);
return null;
}
/// <summary>
/// The probe list points at retired GCP addresses: five of its six entries are
/// unreachable, so the client's region-probe phase waits out a ping timeout on
/// each boot and login sits there. Pinning every entry at loopback keeps the
/// list's shape but makes the probes answer (or refuse) immediately.
/// </summary>
private static string RepairProbeList(string body, ref string note)
{
var repaired = new StringBuilder(body.Length);
var index = 0;
var replaced = 0;
while (true)
{
var key = body.IndexOf(AddressKey, index, StringComparison.Ordinal);
if (key < 0)
{
repaired.Append(body, index, body.Length - index);
break;
}
repaired.Append(body, index, key - index + AddressKey.Length);
var cursor = key + AddressKey.Length;
while (cursor < body.Length && char.IsWhiteSpace(body[cursor])) cursor++;
if (cursor >= body.Length || body[cursor] != ':')
{
repaired.Append(body, key + AddressKey.Length, cursor - key - AddressKey.Length);
index = cursor;
continue;
}
repaired.Append(body, key + AddressKey.Length, cursor - key - AddressKey.Length);
repaired.Append(':');
cursor++;
while (cursor < body.Length && char.IsWhiteSpace(body[cursor])) cursor++;
if (cursor >= body.Length || body[cursor] != '"')
{
index = cursor;
continue;
}
var close = body.IndexOf('"', cursor + 1);
if (close < 0)
{
repaired.Append(body, cursor, body.Length - cursor);
break;
}
repaired.Append('"').Append(LoopbackProbeAddress).Append('"');
replaced++;
index = close + 1;
}
if (replaced == 0) return null;
note = replaced.ToString(CultureInfo.InvariantCulture) + " region probe address(es) pinned to "
+ LoopbackProbeAddress + " (the server's list points at retired hosts)";
return repaired.ToString();
}
/// <summary>
/// The client's model for this route is a name -> row map
/// (Dictionary&lt;string, ...&gt;), so the flat member object the server answers
/// with makes its reader demand '{' where a string sits and throw
/// "expected:'{', actual:'\"none\"'" (a crash report per login). Wrapping the
/// flat object under the row key(s) the request named gives it the map it wants.
/// </summary>
private static string RepairUserSegment(string query, string body, ref string note)
{
var trimmed = body.TrimStart();
if (trimmed.Length == 0 || trimmed[0] != '{') return null;
var keys = new List<string>();
foreach (var value in QueryValues(query))
{
AddKey(keys, value);
AddKey(keys, "user_segment:" + value);
}
if (keys.Count == 0) AddKey(keys, "user_segment");
var repaired = new StringBuilder(body.Length * (keys.Count + 1) + 16);
repaired.Append('{');
for (var i = 0; i < keys.Count; i++)
{
if (i > 0) repaired.Append(',');
repaired.Append('"').Append(keys[i]).Append("\":").Append(body);
}
repaired.Append('}');
note = "userSegment answered as a name -> row map (" + string.Join(", ", keys) + ")";
return repaired.ToString();
}
private static void AddKey(List<string> keys, string key)
{
if (string.IsNullOrEmpty(key)) return;
foreach (var existing in keys)
if (existing == key) return;
keys.Add(key);
}
internal static IEnumerable<string> QueryValues(string query)
{
if (string.IsNullOrEmpty(query)) yield break;
foreach (var pair in query.TrimStart('?').Split('&'))
{
if (pair.Length == 0) continue;
var separator = pair.IndexOf('=');
var value = separator < 0 ? pair : pair.Substring(separator + 1);
value = Uri.UnescapeDataString(value).Trim();
if (value.Length == 0) continue;
yield return value;
}
}
private static bool EndsWith(string value, string suffix) =>
value.EndsWith(suffix, StringComparison.OrdinalIgnoreCase);
}
@@ -0,0 +1,32 @@
using System;
using HarmonyLib;
internal static class WebSocketRedirectPatches
{
internal static void Apply(Harmony harmony)
{
var type = AccessTools.TypeByName("BestHTTP.WebSocket.WebSocket");
int count = 0;
if (type != null)
foreach (var constructor in AccessTools.GetDeclaredConstructors(type))
{
var parameters = constructor.GetParameters();
if (parameters.Length == 0 || parameters[0].ParameterType != typeof(Il2CppSystem.Uri)) continue;
harmony.Patch(constructor, prefix: new HarmonyMethod(typeof(WebSocketRedirectPatches), nameof(Prefix)));
count++;
}
RadicadeEacBypassPlugin.LogSource.LogInfo("[Realtime] WebSocket URI hooks installed: " + count);
}
private static void Prefix(ref Il2CppSystem.Uri __0)
{
if (__0 == null) return;
var url = NameserverRedirect.RewriteWebSocket(__0.ToString(), RadicadeEacBypassPlugin.ServerUrl.Value);
if (HubSocketAuth.IsServer(url) && NameserverRedirect.IsHubUrl(url))
{
url = NameserverRedirect.EnsureHubToken(url, HubSocketAuth.Token);
Diag.Trace("[Realtime] Hub socket constructed; token=" +
(NameserverRedirect.HasHubToken(url) ? "present" : "missing"));
}
__0 = new Il2CppSystem.Uri(url);
}
}
@@ -0,0 +1,200 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using HarmonyLib;
using Il2CppInterop.Runtime;
using Il2CppInterop.Runtime.InteropTypes;
using TMPro;
using UnityEngine.UI;
internal static class ClientTextRewrites
{
private static readonly (string From, string To)[] Rules =
{
("https://recroom.zendesk.com/home", "https://support.radicade.app/"),
("recroom.zendesk.com", "support.radicade.app"),
("https://www.recroom.com/privacy-policy/", "https://radicade.app/privacy-policy/"),
("https://recroom.com/terms-of-service", "https://radicade.app/terms-of-service/"),
("https://www.recroom.com/community/", "https://radicade.app/community/"),
};
private static readonly (string From, string To)[] ExactRules =
{
("This account has been locked for security reasons. Contact us at recroom.zendesk.com.",
LockedAccountSentence),
("This account has been locked for security reasons. Contact us at support.radicade.app.",
LockedAccountSentence),
};
private const string GenericLoginFailure = "Login failed. Please try again.";
private const string LockedAccountErrorCode = "ACCOUNT_PERMALOCKED";
internal const string LockedAccountSentence =
"This account has been locked for security reasons. Contact us at https://support.radicade.app.";
private static bool _rewriting;
internal static void Apply(Harmony harmony)
{
var mode = AsciiLower((Environment.GetEnvironmentVariable("RADICADE_CLIENT_TEXT") ?? string.Empty).Trim());
if (mode == "0" || mode == "off")
{
return;
}
bool onEnable = mode.Length == 0 || mode == "1" || mode == "all" || mode == "onenable";
bool onSet = mode.Length == 0 || mode == "1" || mode == "all" || mode == "settext";
var seen = new HashSet<MethodBase>();
var names = new List<string>();
if (onEnable)
{
Hook(harmony, seen, names, AccessTools.Method(typeof(TextMeshProUGUI), "OnEnable"), nameof(GraphicEvent), "TextMeshProUGUI.OnEnable");
Hook(harmony, seen, names, AccessTools.Method(typeof(Text), "OnEnable"), nameof(GraphicEvent), "UI.Text.OnEnable");
}
if (onSet)
{
Hook(harmony, seen, names, AccessTools.Method(typeof(TMP_Text), "set_text"), nameof(GraphicEvent), "TMP_Text.set_text");
Hook(harmony, seen, names, AccessTools.Method(typeof(TMP_Text), "SetTextInternal"), nameof(GraphicEvent), "TMP_Text.SetTextInternal");
Hook(harmony, seen, names, AccessTools.Method(typeof(Text), "set_text"), nameof(GraphicEvent), "UI.Text.set_text");
}
if (names.Count == 0)
RadicadeEacBypassPlugin.LogSource.LogWarning("[ClientText] no text hooks could be installed (mode=" + (mode.Length == 0 ? "all" : mode) + "); the watch UI keeps its original text.");
}
private static void Hook(Harmony harmony, HashSet<MethodBase> seen, List<string> names,
MethodBase target, string handler, string label)
{
if (target == null)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[ClientText] hook target missing: " + label);
return;
}
if (!seen.Add(target)) return;
try
{
var method = new HarmonyMethod(typeof(ClientTextRewrites)
.GetMethod(handler, BindingFlags.Static | BindingFlags.NonPublic));
harmony.Patch(target, postfix: method);
names.Add(label);
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[ClientText] could not hook " + label + ": "
+ ex.GetType().Name + ": " + ex.Message);
}
}
private static void GraphicEvent(object __instance)
{
try
{
if (__instance is not Il2CppObjectBase obj) return;
if (obj.TryCast<TMP_Text>() is { } tmp) { Fix(tmp); return; }
if (obj.TryCast<Text>() is { } txt) { Fix(txt); }
}
catch { }
}
private static void Fix(TMP_Text instance)
{
try
{
if (instance == null || _rewriting) return;
var current = instance.text;
var rewritten = Rewrite(current);
if (ReferenceEquals(current, rewritten) || string.Equals(rewritten, current, StringComparison.Ordinal)) return;
_rewriting = true;
try { instance.text = rewritten; } finally { _rewriting = false; }
}
catch { }
}
private static void Fix(Text instance)
{
try
{
if (instance == null || _rewriting) return;
var current = instance.text;
var rewritten = Rewrite(current);
if (ReferenceEquals(current, rewritten) || string.Equals(rewritten, current, StringComparison.Ordinal)) return;
_rewriting = true;
try { instance.text = rewritten; } finally { _rewriting = false; }
}
catch { }
}
private static string Rewrite(string value)
{
if (string.IsNullOrEmpty(value)) return value;
if (HubSocketAuth.LastLoginRefusalCode == LockedAccountErrorCode &&
IndexOfAsciiIgnoreCase(value, GenericLoginFailure, 0) >= 0)
{
var locked = Replace(value, GenericLoginFailure, LockedAccountSentence);
return locked;
}
if (IndexOfAsciiIgnoreCase(value, "recroom", 0) < 0 &&
IndexOfAsciiIgnoreCase(value, "radicade", 0) < 0) return value;
var result = value;
foreach (var (from, to) in ExactRules)
if (IndexOfAsciiIgnoreCase(result, from, 0) >= 0)
result = Replace(result, from, to);
foreach (var (from, to) in Rules)
if (IndexOfAsciiIgnoreCase(result, from, 0) >= 0)
result = Replace(result, from, to);
return result;
}
private static string Replace(string source, string from, string to)
{
var sb = new System.Text.StringBuilder(source.Length);
var i = 0;
while (i < source.Length)
{
var hit = IndexOfAsciiIgnoreCase(source, from, i);
if (hit < 0) { sb.Append(source, i, source.Length - i); break; }
sb.Append(source, i, hit - i).Append(to);
i = hit + from.Length;
}
return sb.ToString();
}
private static int IndexOfAsciiIgnoreCase(string haystack, string needle, int start)
{
if (haystack == null || needle == null) return -1;
var n = needle.Length;
var h = haystack.Length;
if (n == 0) return start <= h ? start : -1;
if (n > h) return -1;
var last = h - n;
for (var i = (start < 0 ? 0 : start); i <= last; i++)
{
var j = 0;
while (j < n && LowerAscii(haystack[i + j]) == LowerAscii(needle[j])) j++;
if (j == n) return i;
}
return -1;
}
private static char LowerAscii(char c) => c >= 'A' && c <= 'Z' ? (char)(c + 32) : c;
private static string AsciiLower(string s)
{
for (var i = 0; i < s.Length; i++)
if (s[i] >= 'A' && s[i] <= 'Z')
return string.Create(s.Length, s, (span, state) =>
{
for (var k = 0; k < state.Length; k++) span[k] = LowerAscii(state[k]);
});
return s;
}
}
@@ -0,0 +1,348 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using HarmonyLib;
using UnityEngine;
internal static class ColliderCastGuardPatch
{
internal static void Apply(Harmony harmony)
{
if (!Enabled) return;
int installed = 0;
foreach (var method in TargetMethods())
{
try
{
Install(harmony, method);
installed++;
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogError("[ColliderGuard] failed on " + Describe(method) + ": " + ex);
}
}
}
internal static void Install(Harmony harmony, MethodBase method)
{
var prefix = IsColliderFrame(method)
? new HarmonyMethod(typeof(ColliderCastGuardPatch), nameof(NoteCollider)) : null;
harmony.Patch(method, prefix: prefix,
finalizer: new HarmonyMethod(typeof(ColliderCastGuardPatch), nameof(Swallow)));
}
private static readonly bool _disabled = IsDisabled();
private static int _swallows;
private static readonly Dictionary<MethodBase, string> LastCollider = new Dictionary<MethodBase, string>();
private static readonly HashSet<MethodBase> ColliderFrames = new HashSet<MethodBase>();
private static readonly string[] ColliderParameterTypes = new string[2] { "AOCLEIAJHKP", "AKCGGCOFMHJ" };
private static long _inspections;
private static readonly string[] CastTypes = new string[3] { "BPJMFIFDPJO", "CBKENBPBBPK", "AOCLEIAJHKP" };
private const string RbexAssembly = "RecRoom.Rbex.Runtime";
private const string GameAssembly = "Assembly-CSharp";
private static readonly (string Assembly, string Type, string Method, int Args, string Role)[] Specs = new(string, string, string, int, string)[5]
{
("RecRoom.Rbex.Runtime", "NFMLBBHIOHJ", "JAMGDJKMPGM", 2, "locomotion: attach/detach a collider child"),
("RecRoom.Rbex.Runtime", "NPDJILHGCBI", "JAMGDJKMPGM", 2, "locomotion: collider-child update (twin implementation)"),
("RecRoom.Rbex.Runtime", "MOBAMIJAHPJ", "JDAFCHGGCBG", 1, "teardown: unregister a collider from its body"),
("RecRoom.Rbex.Runtime", "CBKENBPBBPK", "JDIBOGNGEFK", 0, "teardown: body Dispose -> collider walk"),
("Assembly-CSharp", "RecRoom.Core.Locomotion.PlayerMovement", "AuthorityUpdateMovement", 2, "locomotion: authority tick (backstop for every caller of the cast)")
};
internal static bool Enabled => !_disabled;
[HarmonyPrefix]
private static void NoteCollider(object __0, MethodBase __originalMethod)
{
try
{
if (__0 == null || __originalMethod == null || !ColliderFrames.Contains(__originalMethod))
{
return;
}
long num = ++_inspections;
if (num > 200 && num % 50 != 0L)
{
return;
}
string text = DescribeCollider(__0);
if (text != null)
{
if (__originalMethod != null)
{
LastCollider[__originalMethod] = text;
}
}
}
catch
{
}
}
private static string DescribeCollider(object collider)
{
string text = null;
try
{
CBKENBPBBPK val = (CBKENBPBBPK)((collider is CBKENBPBBPK) ? collider : null);
if (val != null)
{
text = NameOf(val.JPBNIBGFKJO);
}
else
{
BPJMFIFDPJO val2 = (BPJMFIFDPJO)((collider is BPJMFIFDPJO) ? collider : null);
if (val2 != null)
{
text = NameOf(val2.JPBNIBGFKJO);
}
}
}
catch
{
}
string name;
try
{
name = collider.GetType().Name;
}
catch
{
return null;
}
if (text != null)
{
return name + " on '" + text + "'";
}
return name;
}
private static string NameOf(GameObject gameObject)
{
try
{
return gameObject == null ? null : gameObject.name;
}
catch
{
return null;
}
}
private static bool IsDisabled()
{
try
{
string environmentVariable = Environment.GetEnvironmentVariable("RADICADE_COLLIDER_GUARD");
if (string.IsNullOrWhiteSpace(environmentVariable))
{
return false;
}
bool result;
switch (environmentVariable.Trim().ToLowerInvariant())
{
case "0":
case "off":
case "false":
case "no":
result = true;
break;
default:
result = false;
break;
}
return result;
}
catch
{
return false;
}
}
private static IEnumerable<MethodBase> TargetMethods()
{
List<MethodBase> list = new List<MethodBase>();
(string, string, string, int, string)[] specs = Specs;
for (int i = 0; i < specs.Length; i++)
{
(string, string, string, int, string) tuple = specs[i];
foreach (MethodBase item in Resolve(tuple.Item1, tuple.Item2, tuple.Item3, tuple.Item4))
{
list.Add(item);
if (IsColliderFrame(item))
{
ColliderFrames.Add(item);
}
}
}
if (list.Count == 0)
{
RadicadeEacBypassPlugin.LogSource.LogWarning((object)("[ColliderGuard] none of the " + Specs.Length + " known collider frames resolved - the physics cast failure is NOT guarded."));
}
return list;
}
private static bool IsColliderFrame(MethodBase method)
{
try
{
ParameterInfo[] parameters = method.GetParameters();
if (parameters.Length == 0)
{
return false;
}
string name = parameters[0].ParameterType.Name;
string[] colliderParameterTypes = ColliderParameterTypes;
foreach (string b in colliderParameterTypes)
{
if (string.Equals(name, b, StringComparison.Ordinal))
{
return true;
}
}
}
catch
{
}
return false;
}
private static Type FindType(string assemblyName, string typeName)
{
try
{
Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies();
foreach (Assembly assembly in assemblies)
{
try
{
if (string.Equals(assembly.GetName().Name, assemblyName, StringComparison.OrdinalIgnoreCase))
{
Type type = assembly.GetType(typeName, throwOnError: false);
if (type != null)
{
return type;
}
}
}
catch
{
}
}
}
catch
{
}
try
{
return Type.GetType(typeName + ", " + assemblyName, throwOnError: false);
}
catch
{
}
try
{
return Assembly.Load(new AssemblyName(assemblyName)).GetType(typeName, throwOnError: false);
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning((object)("[ColliderGuard] " + assemblyName + " could not be loaded: " + ex.Message));
return null;
}
}
private static IEnumerable<MethodBase> Resolve(string assemblyName, string typeName, string methodName, int args)
{
var type = FindType(assemblyName, typeName);
if (type == null) yield break;
foreach (var method in type.GetMethods(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic))
if (method.Name == methodName && method.GetParameters().Length == args)
yield return method;
}
[HarmonyFinalizer]
private static Exception Swallow(Exception __exception, MethodBase __originalMethod)
{
if (__exception == null || !Enabled)
{
return __exception;
}
if (!IsPhysicsCastFailure(__exception))
{
return __exception;
}
int num = ++_swallows;
if (num <= 5 || num % 100 == 0)
{
RadicadeEacBypassPlugin.LogSource.LogWarning((object)("[ColliderGuard] caught a collider cast failure in " + Describe(__originalMethod) + " (#" + num + ")" + DescribeArgument(__originalMethod) + ": " + __exception.GetType().Name + ": " + __exception.Message + " - the failed operation was aborted; remaining work in that method did not run."));
}
return null;
}
private static string DescribeArgument(MethodBase method)
{
try
{
if (method != null && LastCollider.TryGetValue(method, out var value))
{
return " collider=" + value;
}
}
catch
{
}
return string.Empty;
}
private static string Describe(MethodBase method)
{
try
{
return (method == null) ? "<unknown frame>" : (method.DeclaringType?.ToString() + "." + method.Name);
}
catch
{
return "<unknown frame>";
}
}
private static bool IsPhysicsCastFailure(Exception exception)
{
string text;
try
{
text = exception.Message ?? string.Empty;
}
catch
{
return false;
}
if (text.IndexOf("Unable to cast object of type", StringComparison.OrdinalIgnoreCase) < 0)
{
return false;
}
string[] castTypes = CastTypes;
foreach (string value in castTypes)
{
if (text.IndexOf(value, StringComparison.Ordinal) >= 0)
{
return true;
}
}
return false;
}
}
+47
View File
@@ -0,0 +1,47 @@
using System;
using System.Threading;
using HarmonyLib;
internal static class ConnectionGate
{
private const int WaitMilliseconds = 15000;
private static readonly ManualResetEventSlim Opened = new ManualResetEventSlim(false);
private static int _holds;
internal static void Install(Harmony harmony)
{
harmony.PatchAll(typeof(ConnectionGatePatch));
}
internal static void Open()
{
Opened.Set();
}
internal static void Hold()
{
if (Opened.IsSet) return;
var count = Interlocked.Increment(ref _holds);
RadicadeEacBypassPlugin.LogSource.LogWarning("[Patch] a server request arrived before the patch list finished; holding it until it does (hold #"
+ count + ").");
if (Opened.Wait(WaitMilliseconds)) return;
RadicadeEacBypassPlugin.LogSource.LogError("[Patch] the patch list did not finish within "
+ (WaitMilliseconds / 1000) + "s; releasing this request and every later one so the client is not held forever.");
Opened.Set();
}
}
[HarmonyPatch(typeof(BestHTTP.HTTPManager), "SendRequest", new[] { typeof(BestHTTP.HTTPRequest) })]
internal static class ConnectionGatePatch
{
[HarmonyPriority(Priority.First)]
internal static void Prefix()
{
ConnectionGate.Hold();
}
}
+191
View File
@@ -0,0 +1,191 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
internal static class CrashAddressMap
{
private const long DefaultTarget = 0x29a79ec;
internal static bool Enabled => !_disabled;
internal static long TargetRva => _target;
private static readonly bool _disabled = IsDisabled();
private static readonly long _target = ReadTarget();
private static bool IsDisabled()
{
try
{
var raw = Environment.GetEnvironmentVariable("RADICADE_ADDR_MAP");
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
}
catch { return false; }
}
private static long ReadTarget()
{
try
{
var raw = Environment.GetEnvironmentVariable("RADICADE_ADDR_TARGET");
if (string.IsNullOrWhiteSpace(raw)) return DefaultTarget;
raw = raw.Trim();
if (raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) raw = raw.Substring(2);
return long.TryParse(raw, System.Globalization.NumberStyles.HexNumber,
System.Globalization.CultureInfo.InvariantCulture, out var value) ? value : DefaultTarget;
}
catch { return DefaultTarget; }
}
internal static void Start()
{
if (!Enabled) return;
var thread = new System.Threading.Thread(Run) { IsBackground = true, Name = "RadicadeAddrMap" };
thread.Start();
}
private static void Run()
{
try { System.Threading.Thread.Sleep(5000); } catch { }
var log = RadicadeEacBypassPlugin.LogSource;
IntPtr baseAddress = IntPtr.Zero;
var moduleSize = 0L;
try
{
using (var self = System.Diagnostics.Process.GetCurrentProcess())
{
foreach (System.Diagnostics.ProcessModule module in self.Modules)
{
if (!string.Equals(module.ModuleName, "GameAssembly.dll", StringComparison.OrdinalIgnoreCase)) continue;
baseAddress = module.BaseAddress;
moduleSize = module.ModuleMemorySize;
break;
}
}
}
catch (Exception ex)
{
log.LogError("[AddrMap] could not locate GameAssembly.dll: " + ex.Message);
return;
}
if (baseAddress == IntPtr.Zero || moduleSize <= 0)
{
log.LogError("[AddrMap] GameAssembly.dll is not loaded - nothing to map");
return;
}
var gameRoot = Path.GetDirectoryName(UnityEngine.Application.dataPath) ?? ".";
var path = Path.Combine(gameRoot, "BepInEx", "radicade-addr-map.txt");
var baseValue = baseAddress.ToInt64();
var mapped = 0;
var images = 0;
var unmappable = 0;
var bestBelow = -1L;
string bestBelowName = null;
long bestAbove = -1;
string bestAboveName = null;
var failures = 0;
try
{
using (var writer = new StreamWriter(path, false))
{
writer.WriteLine("# il2cpp RVA -> method map, written by RadicadeAugustClient (run 24)");
writer.WriteLine("# GameAssembly.dll base 0x" + baseValue.ToString("x") + ", mapped size 0x" + moduleSize.ToString("x"));
writer.WriteLine("# rva<TAB>image<TAB>Namespace.Class::Method - rva is relative to the base above");
unsafe
{
var domain = Il2CppInterop.Runtime.IL2CPP.il2cpp_domain_get();
if (domain == IntPtr.Zero)
{
log.LogError("[AddrMap] no il2cpp domain; nothing to map");
return;
}
uint assemblyCount = 0;
var assemblies = Il2CppInterop.Runtime.IL2CPP.il2cpp_domain_get_assemblies(domain, ref assemblyCount);
for (uint a = 0; a < assemblyCount; a++)
{
var image = Il2CppInterop.Runtime.IL2CPP.il2cpp_assembly_get_image(assemblies[a]);
if (image == IntPtr.Zero) continue;
images++;
string imageName;
try { imageName = Marshal.PtrToStringAnsi(Il2CppInterop.Runtime.IL2CPP.il2cpp_image_get_name(image)) ?? "?"; }
catch { imageName = "?"; }
var classCount = Il2CppInterop.Runtime.IL2CPP.il2cpp_image_get_class_count(image);
for (uint c = 0; c < classCount; c++)
{
var klass = Il2CppInterop.Runtime.IL2CPP.il2cpp_image_get_class(image, c);
if (klass == IntPtr.Zero) continue;
string className;
try
{
var namespaze = Marshal.PtrToStringAnsi(Il2CppInterop.Runtime.IL2CPP.il2cpp_class_get_namespace(klass));
var name = Marshal.PtrToStringAnsi(Il2CppInterop.Runtime.IL2CPP.il2cpp_class_get_name(klass));
className = string.IsNullOrEmpty(namespaze) ? name : namespaze + "." + name;
}
catch { className = "?"; failures++; }
var iterator = IntPtr.Zero;
IntPtr methodInfo;
while ((methodInfo = Il2CppInterop.Runtime.IL2CPP.il2cpp_class_get_methods(klass, ref iterator)) != IntPtr.Zero)
{
long entry;
try { entry = Marshal.ReadIntPtr(methodInfo).ToInt64(); }
catch { entry = 0; }
if (entry <= baseValue || entry >= baseValue + moduleSize)
{
unmappable++;
continue;
}
var rva = entry - baseValue;
string methodName;
try { methodName = Marshal.PtrToStringAnsi(Il2CppInterop.Runtime.IL2CPP.il2cpp_method_get_name(methodInfo)) ?? "?"; }
catch { methodName = "?"; failures++; }
writer.WriteLine("0x" + rva.ToString("x") + "\t" + imageName + "\t" + className + "::" + methodName);
mapped++;
var full = className + "::" + methodName;
if (rva <= _target && rva > bestBelow) { bestBelow = rva; bestBelowName = full; }
if (rva > _target && (bestAbove < 0 || rva < bestAbove)) { bestAbove = rva; bestAboveName = full; }
}
}
}
}
}
}
catch (Exception ex)
{
log.LogError("[AddrMap] pass failed after " + mapped + " methods: " + ex);
return;
}
if (bestBelow < 0)
{
log.LogWarning("[AddrMap] no method starts below the target 0x" + _target.ToString("x")
+ " - the crash is in code no managed method owns (il2cpp runtime or native plugin)."
+ (bestAboveName != null ? " Nearest above: 0x" + bestAbove.ToString("x") + " " + bestAboveName : string.Empty));
return;
}
var span = bestAbove > bestBelow ? bestAbove - bestBelow : -1;
log.LogWarning("[AddrMap] crash target 0x" + _target.ToString("x") + " -> containing method 0x" + bestBelow.ToString("x")
+ " " + bestBelowName + " (+0x" + (_target - bestBelow).ToString("x") + " into it)"
+ (span > 0 ? "; next method 0x" + bestAbove.ToString("x") + " " + bestAboveName + " (span 0x" + span.ToString("x") + ")" : string.Empty)
+ (failures > 0 ? "; " + failures + " name reads failed" : string.Empty));
}
}
@@ -0,0 +1,53 @@
using HarmonyLib;
using Il2CppInterop.Runtime;
using RRUI;
using RRUI.Data;
using UnityEngine;
internal static class DynamicUiPrefabRepair
{
private static bool reportedMissing;
internal static void Apply(Harmony harmony)
{
harmony.Patch(AccessTools.PropertyGetter(typeof(DynamicUIConfig), "Instance"),
postfix: new HarmonyMethod(typeof(DynamicUiPrefabRepair), nameof(Repair)));
}
private static void Repair(DynamicUIConfig __result)
{
if (__result == null) return;
var standard = __result.DynamicFloatPrefab_MultiSelectConfig;
var pro = __result.DynamicProUXFloatPrefab_MultiSelectConfig;
var fallback = Compatible(standard) ? standard : Compatible(pro) ? pro : null;
if (fallback == null)
{
if (!reportedMissing)
{
reportedMissing = true;
RadicadeEacBypassPlugin.LogSource.LogWarning(
"[DynamicUI] No compatible multi-select float prefab is available; leaving the missing mapping visible.");
}
return;
}
_ = __result.LandscapePrefabsByType;
_ = __result.PortraitPrefabsByType;
_ = __result.ProUXPrefabsByType;
RepairMap(__result.landscapePrefabsByType, fallback, "landscape");
RepairMap(__result.portraitPrefabsByType, fallback, "portrait");
RepairMap(__result.proUXPrefabsByType, fallback, "ProUX");
}
private static bool Compatible(GameObject prefab) => prefab != null &&
prefab.GetComponentInChildren<DynamicFloatInputFieldModelController_MultiSelectConfig>(true) != null;
private static void RepairMap(Il2CppSystem.Collections.Generic.Dictionary<Il2CppSystem.Type, GameObject> map,
GameObject fallback, string layout)
{
if (map == null) return;
var key = Il2CppType.Of<JNHHEBGEAMP>();
if (map.ContainsKey(key) && map[key] != null) return;
map[key] = fallback;
}
}
@@ -0,0 +1,42 @@
using HarmonyLib;
internal static class InviteOnlineFriendsPatch
{
internal static void Apply(Harmony harmony)
{
harmony.Patch(AccessTools.PropertyGetter(typeof(ECFHJOKNJIJ), "OHKPFEMEPAE"),
prefix: new HarmonyMethod(typeof(InviteOnlineFriendsPatch), nameof(EnableDialog)));
harmony.Patch(AccessTools.PropertyGetter(typeof(ECFHJOKNJIJ), "EBCNNMAJGBN"),
prefix: new HarmonyMethod(typeof(InviteOnlineFriendsPatch), nameof(IncomingDialog)));
}
private static bool EnableDialog(ECFHJOKNJIJ __instance, ref bool __result)
{
return ReadServerOverride(__instance, "Radicade.Invites.ShowOnlineFriendsBeforeJoining", ref __result);
}
private static bool IncomingDialog(ECFHJOKNJIJ __instance, ref bool __result)
{
return ReadServerOverride(__instance, "Radicade.Invites.UseRRUIDialog", ref __result);
}
private static readonly System.Collections.Generic.HashSet<string> logged = new();
private static bool ReadServerOverride(ECFHJOKNJIJ instance, string key, ref bool result)
{
try
{
var configs = instance.CCNKOPKDIJI;
if (configs == null || !configs.FNBNNDMHFCK) return true;
if (!bool.TryParse(configs.KFNCGHMLMAI(key), out var value)) return true;
result = value;
return false;
}
catch (System.Exception ex)
{
if (logged.Add(key + ":error"))
RadicadeEacBypassPlugin.LogSource.LogWarning("[InviteUI] config lookup failed: " + ex.Message);
return true;
}
}
}
@@ -0,0 +1,461 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Text;
using HarmonyLib;
internal static class KeepsakeToolPatch
{
private static readonly Type Palette =
typeof(RecRoom.AGUI.ToolUIs.MakerPenPaletteMainScreen);
private static readonly Type Registry = typeof(PMJEHEPKBJL);
private static readonly Type ToolTypeEnum = typeof(AOCJJLANBMH);
private static readonly Type ToolConfig = typeof(RuntimeSpawnableToolConfig);
private static readonly Dictionary<string, int> RepeatedLines = new Dictionary<string, int>();
private static string Mode
=> (Environment.GetEnvironmentVariable("RADICADE_KEEPSAKE_TOOL")
?? (RadicadeEacBypassPlugin.KeepsakeToolEnabled?.Value == true ? "on" : "skip")).Trim();
private static bool Requested => Mode.ToLowerInvariant() switch
{
"on" or "true" or "1" or "yes" or "enable" or "enabled" => true,
"off" or "false" or "0" => true,
_ => false,
};
internal static bool Forcing => Mode.ToLowerInvariant() switch
{
"on" or "true" or "1" or "yes" or "enable" or "enabled" => true,
_ => false,
};
internal static void Apply(Harmony harmony)
{
if (!Requested)
{
return;
}
if (Palette == null || Registry == null || ToolTypeEnum == null)
{
RadicadeEacBypassPlugin.LogSource.LogWarning(
"[Keepsake] palette/registry/tool-type type not found (" +
$"palette={Palette?.FullName ?? "null"}, registry={Registry?.FullName ?? "null"}, " +
$"toolType={ToolTypeEnum?.FullName ?? "null"}) - keepsake patch skipped");
return;
}
Patch(harmony, Registry, "JBAINDGLMBO", new[] { ToolTypeEnum }, null, nameof(ConfigPostfix));
PatchByName(harmony, Registry, "LPBDCDIMBEN", nameof(PredicatePostfix));
PatchByNameOnFamily(harmony, Palette, "LKGMIHJOIID", nameof(PredicatePostfix));
PatchByNameOnFamily(harmony, Palette, "OLKPEGJNJGG", nameof(ConfigFilterPostfix));
PatchByNameOnFamily(harmony, Palette, "IBHJBFJMBFB", nameof(ConfigFilterPostfix));
Patch(harmony, Palette, "OnUsedPaletteItem", null, nameof(UsedPalettePrefix), null);
Audit(harmony);
}
private static void Audit(Harmony harmony)
{
var service = AccessTools.TypeByName("AMAOGLDGNLL");
foreach (var name in new[]
{
"MJDEEABFHPJ", "NCAKOLMAAEA", "CAFCLGFGKAO", "KHPJGNADGEE", "NGJMABOMHHB",
"FCOMKMPDEIO", "AEHHFFIKLG"
})
PatchByName(harmony, service, name, nameof(CallPostfix), 2);
if (!string.Equals(Environment.GetEnvironmentVariable("RADICADE_KEEPSAKE_AUDIT"), "1",
StringComparison.Ordinal)) return;
Log("[Keepsake] RADICADE_KEEPSAKE_AUDIT=1: instrumenting the room/progression models too");
foreach (var typeName in new[] { "RRUI.Data.RoomModel", "RRUI.Data.RoomListModel", "RRUI.Data.ProgressionEventsModel" })
{
var model = AccessTools.TypeByName(typeName);
foreach (var name in new[]
{
"IsKeepsakeRoom", "GetCachedAreAllKeepsakesCollected",
"GetTotalKeepsakeCountPerCategoryForRoom", "GetUncollectedKeepsakeCountPerCategoryForRoom",
"GetTotalKeepsakeCountForRoom", "GetUncollectedKeepsakeCountForRoom",
"IsKeepsakeRoomListUpsellItemOwned", "AHACLFABDEO", "CJPFGOBHGBB"
})
PatchByName(harmony, model, name, nameof(ModelPostfix), 4);
}
}
internal static object KeepsakeMember()
{
try
{
return ToolTypeEnum == null ? null : Enum.Parse(ToolTypeEnum, "KEEPSAKE");
}
catch (Exception e)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[Keepsake] KEEPSAKE enum member missing: " + e.Message);
return null;
}
}
internal static bool IsKeepsake(object toolType)
=> toolType != null && KeepsakeMember()?.Equals(toolType) == true;
internal static void Log(string message)
=> RadicadeEacBypassPlugin.LogSource.LogInfo(message);
private static void LogOnce(string message, int limit = 3)
{
var seen = RepeatedLines.TryGetValue(message, out var count) ? count + 1 : 1;
RepeatedLines[message] = seen;
if (seen <= limit) Log(message);
else if (seen == limit + 1) Log($"[Keepsake] (suppressing further copies of: {message})");
}
private static void ConfigPostfix(object[] __args, RuntimeSpawnableToolConfig __result)
{
var toolType = __args.Length > 0 ? __args[0] : null;
var keepsake = IsKeepsake(toolType);
if (!keepsake && !IsRestricted(__result)) return;
if (!keepsake || !Forcing) return;
var allowed = Field(__result, "spawnableToolTypesAllowedInRooms2");
if (Contains(allowed, KeepsakeMember())) return;
AddToCollection(allowed, KeepsakeMember());
}
private static void PredicatePostfix(MethodBase __originalMethod, object[] __args, ref bool __result)
{
var toolType = FirstToolType(__args);
var keepsake = IsKeepsake(toolType);
if (keepsake && Forcing && !__result)
{
__result = true;
}
}
private static void CallPostfix(MethodBase __originalMethod, object[] __args, object __result)
{
}
private static void ModelPostfix(MethodBase __originalMethod, object[] __args, object __result)
{
}
private static string DescribeResult(object result)
{
if (result == null) return "(null)";
var type = result.GetType();
try
{
if (type == typeof(bool) || type == typeof(int) || type == typeof(long))
return result.ToString();
var status = AccessTools.Property(type, "IsCompleted")?.GetValue(result) as bool?;
if (status == true)
{
var value = AccessTools.Property(type, "Result")?.GetValue(result);
return $"{type.Name}(completed) = {value}";
}
if (status == false) return $"{type.Name}(pending)";
}
catch
{
}
return type.Name;
}
private static void ConfigFilterPostfix(MethodBase __originalMethod, object[] __args, ref bool __result)
{
var config = __args.Length > 0 ? __args[0] : null;
var name = Field(config, "UniqueName")?.ToString() ?? "(unnamed tool)";
var keepsake = string.Equals(name, "KEEPSAKE", StringComparison.OrdinalIgnoreCase);
if (!keepsake) return;
if (!Forcing || __result) return;
__result = true;
}
private static void UsedPalettePrefix(object[] __args)
{
}
private static object FirstToolType(object[] args)
{
if (args == null) return null;
foreach (var arg in args)
{
if (arg == null) continue;
if (ToolTypeEnum.IsInstanceOfType(arg) || arg.GetType() == ToolTypeEnum) return arg;
}
return null;
}
private static bool IsRestricted(object config)
=> Field(config, "AvailableInMakerPen") is bool makerPen && !makerPen;
private static string Frames(int take = 10)
{
try
{
var frames = new StackTrace(true).GetFrames();
var sb = new StringBuilder();
var taken = 0;
foreach (var frame in frames)
{
var method = frame.GetMethod();
var owner = method?.DeclaringType?.Name ?? "?";
if (owner.Contains("KeepsakeToolPatch") || owner.Contains("Harmony")) continue;
sb.Append(owner).Append('.').Append(method?.Name ?? "?").Append(" <- ");
if (++taken >= take) break;
}
return sb.Length == 0 ? "(no frames)" : sb.ToString().TrimEnd(' ', '<', '-');
}
catch (Exception e)
{
return "(stack failed: " + e.GetType().Name + ")";
}
}
private static string DescribeList(object collection)
{
if (collection == null) return "(null)";
var type = collection.GetType();
var count = 0;
var keepsake = false;
var names = new StringBuilder();
try
{
var countValue = AccessTools.Property(type, "Count")?.GetValue(collection);
count = countValue is int n ? n : 0;
if (count == 0) return "0 item(s) []";
var indexer = type.GetProperty("Item");
for (var i = 0; i < count && i < 12; i++)
{
var item = indexer?.GetValue(collection, new object[] { i });
if (IsKeepsake(item)) keepsake = true;
names.Append(item).Append(',');
}
}
catch (Exception e)
{
return $"(list read failed: {e.GetType().Name}: {e.Message})";
}
return $"{count} item(s) [{names.ToString().TrimEnd(',')}]" + (keepsake ? " KEEPSAKE present" : "");
}
private static bool Contains(object collection, object item)
{
if (collection == null || item == null) return false;
try
{
var contains = AccessTools.Method(collection.GetType(), "Contains");
return contains?.Invoke(collection, new[] { item }) is true;
}
catch
{
return false;
}
}
private static bool AddToCollection(object collection, object item)
{
if (collection == null || item == null) return false;
try
{
var type = collection.GetType();
MethodInfo add = null;
foreach (var candidate in type.GetMethods(BindingFlags.Public | BindingFlags.Instance))
{
if (candidate.Name != "Add" || candidate.GetParameters().Length != 1) continue;
add = candidate;
break;
}
if (add == null) return false;
add.Invoke(collection, new[] { item });
return true;
}
catch (Exception e)
{
RadicadeEacBypassPlugin.LogSource.LogWarning(
$"[Keepsake] could not add to {collection.GetType().Name}: {e.GetType().Name}: {e.Message}");
return false;
}
}
private static string Describe(object config)
{
if (config == null) return "(null config)";
var sb = new StringBuilder(config.GetType().Name).Append('{');
foreach (var name in new[]
{
"UniqueName", "AvailableInMakerPen", "AvailableToDevsOnly", "IsBetaItem",
"AvailableInSpawner", "CanSaveIntoInventions", "LimitInventionSaveForJuniors"
})
{
var value = Field(config, name);
if (value != null) sb.Append(name).Append('=').Append(value).Append(' ');
}
var allowed = Field(config, "spawnableToolTypesAllowedInRooms2");
if (allowed != null)
sb.Append("allowedInRooms=").Append(DescribeList(allowed)).Append(' ');
return sb.Append('}').ToString();
}
private static object Field(object instance, string name)
{
if (instance == null) return null;
try
{
var type = instance.GetType();
var property = AccessTools.Property(type, name);
if (property != null) return property.GetValue(instance);
return AccessTools.Field(type, name)?.GetValue(instance);
}
catch
{
return null;
}
}
private static void PatchByNameOnFamily(Harmony harmony, Type root, string method, string postfix)
{
var patched = 0;
foreach (var type in Family(root))
{
foreach (var found in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic
| BindingFlags.Static | BindingFlags.Instance))
{
if (found.Name != method) continue;
if (Bind(harmony, found, null, postfix)) patched++;
}
}
if (patched == 0)
RadicadeEacBypassPlugin.LogSource.LogWarning(
$"[Keepsake] no method named {method} on {root.Name} or its nested types - not instrumented");
}
private static void PatchByName(Harmony harmony, Type target, string method, string postfix,
int maxArguments = 1)
{
if (target == null)
{
RadicadeEacBypassPlugin.LogSource.LogWarning(
$"[Keepsake] type for {method} not found - not instrumented");
return;
}
var patched = 0;
foreach (var found in target.GetMethods(BindingFlags.Public | BindingFlags.NonPublic
| BindingFlags.Static | BindingFlags.Instance))
{
if (found.Name != method) continue;
var parameters = found.GetParameters();
if (parameters.Length > maxArguments) continue;
if (parameters.Any(p => p.ParameterType.IsByRef || p.IsOut || p.ParameterType.IsPointer)) continue;
if (maxArguments == 1 && (parameters.Length != 1 ||
(!ToolTypeEnum.IsAssignableFrom(parameters[0].ParameterType)
&& parameters[0].ParameterType != ToolTypeEnum))) continue;
if (Bind(harmony, found, null, postfix)) patched++;
}
}
private static IEnumerable<Type> Family(Type root)
{
yield return root;
Type[] nested;
try
{
nested = root.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic);
}
catch
{
yield break;
}
foreach (var type in nested)
foreach (var deeper in Family(type))
yield return deeper;
}
private static void Patch(Harmony harmony, Type target, string method, Type[] args,
string prefix, string postfix)
{
if (target == null)
{
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Keepsake] type for {method} not found - not instrumented");
return;
}
try
{
var found = args == null
? AccessTools.Method(target, method)
: AccessTools.Method(target, method, args);
if (found == null)
{
RadicadeEacBypassPlugin.LogSource.LogWarning(
$"[Keepsake] {target.Name}.{method} not found - not instrumented");
return;
}
Bind(harmony, found, prefix, postfix);
}
catch (Exception e)
{
RadicadeEacBypassPlugin.LogSource.LogWarning(
$"[Keepsake] FAILED to instrument {target.Name}.{method}: {e.GetType().Name}: {e.Message}");
}
}
private static bool Bind(Harmony harmony, MethodBase found, string prefix, string postfix)
{
try
{
var self = typeof(KeepsakeToolPatch);
const BindingFlags flags = BindingFlags.Static | BindingFlags.NonPublic;
harmony.Patch(found,
prefix: prefix == null ? null : new HarmonyMethod(self.GetMethod(prefix, flags)),
postfix: postfix == null ? null : new HarmonyMethod(self.GetMethod(postfix, flags)));
return true;
}
catch (Exception e)
{
RadicadeEacBypassPlugin.LogSource.LogWarning(
$"[Keepsake] FAILED to instrument {found.DeclaringType?.Name}.{found.Name}: " +
$"{e.GetType().Name}: {e.Message}");
return false;
}
}
}
+205
View File
@@ -0,0 +1,205 @@
using HarmonyLib;
using System;
using System.Collections.Generic;
using UnityEngine;
internal static class LogFloodGate
{
internal const double WindowSeconds = 30.0;
internal static bool Enabled => !_disabled;
private static readonly bool _disabled = IsDisabled();
private static readonly Dictionary<string, Entry> Seen = new Dictionary<string, Entry>();
private static long _totalSuppressed;
private static bool _selfTestPending;
private static int _selfTestFired;
private sealed class Entry
{
public double WindowStart;
public int Suppressed;
public int ReportedAt;
}
private static bool IsDisabled()
{
try
{
var raw = Environment.GetEnvironmentVariable("RADICADE_LOG_GATE");
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
}
catch { return false; }
}
internal static void Apply(Harmony harmony)
{
try
{
var target = AccessTools.Method(typeof(ODGGOALOLEG), "LogException",
new[] { typeof(Il2CppSystem.Exception), typeof(UnityEngine.Object) });
if (target == null)
{
Warn("[LogGate] ODGGOALOLEG.LogException(Il2CppSystem.Exception, UnityEngine.Object) not found - " +
"not armed; the per-frame exception flood stays as-is.");
return;
}
var prefix = AccessTools.Method(typeof(LogFloodGate), nameof(Prefix));
if (prefix == null)
{
Warn("[LogGate] patch method Prefix not found - not armed.");
return;
}
harmony.Patch(target, prefix: new HarmonyMethod(prefix));
}
catch (Exception e)
{
Warn($"[LogGate] failed to arm ({e.GetType().Name}: {e.Message}); the exception flood stays as-is.");
}
}
[HarmonyPrefix]
internal static bool Prefix(Il2CppSystem.Exception __0)
{
if (!Enabled) return true;
try
{
if (_selfTestPending && System.Threading.Interlocked.Exchange(ref _selfTestFired, 1) == 0)
{
_selfTestPending = false;
RunSelfTest();
}
var signature = Signature(__0);
if (signature == null) return true;
var now = Time.realtimeSinceStartup;
lock (Seen)
{
if (Seen.Count > 512) Evict(now);
if (!Seen.TryGetValue(signature, out var entry))
{
Seen[signature] = new Entry { WindowStart = now };
return true;
}
if (now - entry.WindowStart < WindowSeconds)
{
entry.Suppressed++;
_totalSuppressed++;
if (entry.Suppressed == 1)
Warn($"[LogGate] repeats of this exception are now suppressed for {WindowSeconds:0}s at a time: {signature}");
else if (entry.Suppressed - entry.ReportedAt >= 500)
{
entry.ReportedAt = entry.Suppressed;
Warn($"[LogGate] {entry.Suppressed} identical repeats of this exception suppressed so far " +
$"(session total {_totalSuppressed}): {signature}");
}
return false;
}
var dropped = entry.Suppressed;
entry.WindowStart = now;
entry.Suppressed = 0;
entry.ReportedAt = 0;
if (dropped > 0)
Warn($"[LogGate] {dropped} identical repeats were collapsed in the previous {WindowSeconds:0}s window " +
$"(session total {_totalSuppressed}); logging this occurrence in full: {signature}");
return true;
}
}
catch
{
return true;
}
}
private static void Evict(double now)
{
var stale = new List<string>();
foreach (var pair in Seen)
if (now - pair.Value.WindowStart > 600.0)
stale.Add(pair.Key);
foreach (var key in stale)
Seen.Remove(key);
}
private static string Signature(Il2CppSystem.Exception exception)
{
string type, message, stack;
try { type = exception.GetType().Name; } catch { type = "Exception"; }
try { message = exception.Message; } catch { message = "<message unreadable>"; }
try { stack = exception.StackTrace; } catch { stack = null; }
if (string.IsNullOrEmpty(message) && string.IsNullOrEmpty(stack)) return null;
return Trim(type + ": " + message, 150) + " @ " + FirstFrame(stack);
}
private static string FirstFrame(string stack)
{
if (string.IsNullOrEmpty(stack)) return "<no stack>";
var start = stack.IndexOf(" at ", StringComparison.Ordinal);
if (start < 0)
{
var newline = stack.IndexOf('\n');
return Trim(newline < 0 ? stack : stack.Substring(0, newline), 120);
}
var end = stack.IndexOf('\n', start);
return Trim(end < 0 ? stack.Substring(start) : stack.Substring(start, end - start), 120);
}
private static string Trim(string value, int max)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
value = value.Replace('\r', ' ').Replace('\n', ' ').Trim();
return value.Length <= max ? value : value.Substring(0, max) + "...";
}
private static void Warn(string message)
{
try { RadicadeEacBypassPlugin.LogSource.LogWarning(message); } catch { }
}
internal static void RequestSelfTestIfAsked()
{
try
{
var raw = Environment.GetEnvironmentVariable("RADICADE_LOG_GATE_SELFTEST");
if (string.IsNullOrWhiteSpace(raw)) return;
if (raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no") return;
_selfTestPending = true;
RadicadeEacBypassPlugin.LogSource.LogInfo(
"[LogGate] self-test armed: the next exception through the game handler fires five identical ones to prove the gate (RADICADE_LOG_GATE_SELFTEST).");
}
catch (Exception e)
{
Warn($"[LogGate] self-test could not be armed: {e.GetType().Name}: {e.Message}");
}
}
private static void RunSelfTest()
{
try
{
var before = _totalSuppressed;
var exception = new Il2CppSystem.Exception("radicade log gate self-test");
const int shots = 5;
for (var i = 0; i < shots; i++)
UnityEngine.Debug.LogException(exception);
var collapsed = _totalSuppressed - before;
Warn($"[LogGate] self-test: fired {shots} identical exceptions through the live game handler; {collapsed} were collapsed, " +
$"{shots - collapsed} reached the log " +
$"({(collapsed == shots - 1 ? "PASS" : "FAIL")} - the first of a burst always logs in full, the rest are suppressed).");
}
catch (Exception e)
{
Warn($"[LogGate] self-test failed to run: {e.GetType().Name}: {e.Message}");
}
}
}
+100
View File
@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using HarmonyLib;
using UnityEngine;
/// <summary>
/// Drops known-noise log lines inside Unity's own logger, so they never reach the
/// console or BepInEx's LogOutput.log. The game funnels both Debug.LogError and
/// Debug.LogWarning through Engine.Logger.Log(LogType, object) - that is the
/// single place where a message can be refused before anyone formats it.
/// </summary>
internal static class LogNoiseFilter
{
private const string SelfTestProbe =
"The character used for Underline is not available in font asset [radicade-filter-self-test].";
internal static void Apply(Harmony harmony)
{
var prefix = AccessTools.Method(typeof(LogNoiseFilter), nameof(Prefix));
if (prefix == null)
{
Diag.Warn("[LogFilter] the filter method was not found; known-noise log spam stays as-is.");
return;
}
var patched = 0;
foreach (var target in Targets())
{
try
{
harmony.Patch(target, prefix: new HarmonyMethod(prefix));
patched++;
}
catch (Exception ex)
{
Diag.Warn("[LogFilter] could not patch " + target.DeclaringType?.Name + "." + target.Name +
" (" + ex.GetType().Name + ": " + ex.Message + ").");
}
}
if (patched == 0)
{
Diag.Warn("[LogFilter] no Unity log funnel could be patched; the RigidbodyEx and font-underline spam stays as-is.");
return;
}
Diag.Info("[LogFilter] armed on " + patched + " Unity log funnel(s): " + LogNoiseRules.PatternCount +
" known-noise pattern(s) are dropped before they are printed.");
SelfTest();
}
private static IEnumerable<MethodBase> Targets()
{
var log = AccessTools.Method(typeof(Logger), "Log",
new[] { typeof(LogType), typeof(Il2CppSystem.Object) });
if (log != null) yield return log;
var logWithContext = AccessTools.Method(typeof(Logger), "Log",
new[] { typeof(LogType), typeof(Il2CppSystem.Object), typeof(UnityEngine.Object) });
if (logWithContext != null) yield return logWithContext;
}
private static bool Prefix(object[] __args)
{
try
{
if (__args == null || __args.Length < 2) return true;
var message = __args[1];
if (message == null) return true;
return !LogNoiseRules.ShouldDrop(message.ToString());
}
catch
{
return true;
}
}
/// <summary>
/// Fires one probe line through Debug.LogWarning and checks that the rules saw
/// it. If they did not, the filter is not wired to this build's logger and the
/// log says so instead of pretending the spam is gone.
/// </summary>
private static void SelfTest()
{
try
{
var before = LogNoiseRules.DroppedCount;
UnityEngine.Debug.LogWarning(SelfTestProbe);
if (LogNoiseRules.DroppedCount > before)
Diag.Info("[LogFilter] self-test PASS: a probe log line was intercepted inside Unity's logger.");
else
Diag.Warn("[LogFilter] self-test FAIL: a probe log line reached the log, so this build's spam is not being filtered.");
}
catch (Exception ex)
{
Diag.Warn("[LogFilter] self-test could not run (" + ex.GetType().Name + ": " + ex.Message + ").");
}
}
}
+42
View File
@@ -0,0 +1,42 @@
using System;
using System.Threading;
/// <summary>
/// Log lines the client prints on every room load that say nothing a player can
/// act on and bury the lines that matter. Pure rules only, so they can be
/// exercised outside the game.
/// </summary>
internal static class LogNoiseRules
{
private static readonly string[] Noise =
{
// Every chair and couch the dorm/Rec Center spawns complains that its
// RigidbodyEx has no entity component - dozens of lines per room load,
// all identical in substance.
"has an RigidbodyEx but no",
// TMP asks for an underline glyph the legacy Diggity font does not carry;
// the text renders fine without it.
"The character used for Underline is not available in font asset",
};
private static int _dropped;
internal static int PatternCount => Noise.Length;
internal static int DroppedCount => Volatile.Read(ref _dropped);
internal static bool ShouldDrop(string message)
{
if (string.IsNullOrEmpty(message)) return false;
foreach (var pattern in Noise)
{
if (message.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) < 0) continue;
Interlocked.Increment(ref _dropped);
return true;
}
return false;
}
}
+1038
View File
@@ -0,0 +1,1038 @@
using BepInEx;
using BepInEx.Logging;
using BepInEx.Unity.IL2CPP;
using HarmonyLib;
using RecRoom.AntiCheat;
using System.Linq;
using System.Runtime.InteropServices;
internal sealed class FixedSetting<T>
{
internal T Value { get; }
internal FixedSetting(T value) => Value = value;
}
[BepInPlugin("com.radicade.patch", "RadicadePatch", "3.0.0")]
public sealed class RadicadeEacBypassPlugin : BasePlugin
{
internal static ManualLogSource LogSource;
internal const string ServerAddress = "https://ns.radicade.app/";
internal const string ServerHost = "ns.radicade.app";
internal static FixedSetting<string> ServerUrl = new(ServerAddress);
internal static FixedSetting<string> TlsAllowedHosts = new(ServerHost);
internal static FixedSetting<bool> RealtimeLog = new(false);
internal static FixedSetting<bool> KeepsakeToolEnabled = new(true);
internal static Il2CppSystem.Threading.SynchronizationContext MainThreadContext;
internal static string ImageKeyId;
static string ReadMetadataKey(string path)
{
try
{
return File.Exists(path)
? ImageSignatureRequest.DetectKeyIdFromGameMetadata(File.ReadAllBytes(path))
: null;
}
catch (Exception e)
{
LogSource.LogWarning($"[ImageSig] key id detection failed for {Path.GetFileName(path)}: {e.GetType().Name}: {e.Message}");
return null;
}
}
void LogFixedConnection()
{
Log.LogInfo($"[TLS] fixed server {ServerUrl.Value}; AllowedHosts = '{TlsAllowedHosts.Value}'");
if (!EmbeddedTlsPatches.IsHostAllowed(ServerHost))
Log.LogError($"[TLS] built-in AllowedHosts '{TlsAllowedHosts.Value}' does not cover '{ServerHost}' - the embedded TLS stack will reject the server certificate.");
}
void WarnIfMetadataPatchedForAnotherServer(Uri server)
{
try
{
var marker = Path.Combine(UnityEngine.Application.dataPath, "radicade-patch.json");
if (!File.Exists(marker))
{
Log.LogWarning($"[Meta] no {marker}: this install was never metadata-patched, so the client still resolves the official hosts.");
return;
}
var text = File.ReadAllText(marker);
var key = text.IndexOf("\"serverUrl\"", StringComparison.Ordinal);
var colon = key < 0 ? -1 : text.IndexOf(':', key);
var open = colon < 0 ? -1 : text.IndexOf('"', colon + 1);
var close = open < 0 ? -1 : text.IndexOf('"', open + 1);
if (close < 0)
{
Log.LogWarning($"[Meta] {marker} has no readable serverUrl field; cannot confirm the patched server matches {server}.");
return;
}
var patched = text[(open + 1)..close].TrimEnd('/');
var wanted = server.GetLeftPart(UriPartial.Authority).TrimEnd('/');
if (patched.Equals(wanted, StringComparison.OrdinalIgnoreCase))
{
Log.LogInfo($"[Meta] metadata patched for {patched}; matches Connection:ServerUrl.");
return;
}
Log.LogWarning($"[Meta] this install's metadata was patched for '{patched}' but Connection:ServerUrl is '{wanted}'. "
+ "The client's own literals still point at the patched address, so requests will not reach the configured server. "
+ $"Re-patch from the pristine copy: python tools/patch-0831-client-metadata.py <game dir> --server {wanted} --apply");
}
catch (Exception e)
{
Log.LogWarning($"[Meta] metadata marker check failed: {e.GetType().Name}: {e.Message}");
}
}
public override void Load()
{
LogSource = Log;
if (UnityEngine.Application.version != "20230831")
throw new NotSupportedException("This plugin requires Rec Room 20230831.");
MainThreadContext = Il2CppSystem.Threading.SynchronizationContext.Current;
if (MainThreadContext == null)
Log.LogWarning("[Patch] no main-thread synchronization context was captured; promise resolution may stall.");
if (!Uri.TryCreate(ServerUrl.Value, UriKind.Absolute, out var server) || server.Scheme != "https")
throw new InvalidOperationException("The RadicadePatch client requires an HTTPS server URL.");
LogFixedConnection();
WarnIfMetadataPatchedForAnotherServer(server);
var metadataRoot = Path.Combine(UnityEngine.Application.dataPath, "il2cpp_data", "Metadata");
var gameKeyId = ReadMetadataKey(Path.Combine(metadataRoot, "global-metadata.dat"));
var interopKeyId = gameKeyId == null ? ReadMetadataKey(Path.Combine(metadataRoot, "global-metadata.interop.dat")) : null;
ImageKeyId = gameKeyId ?? interopKeyId;
if (ImageKeyId == null)
Log.LogWarning("[ImageSig] no image key id was found in the game metadata; rewritten URLs will not state one.");
var harmony = new Harmony("com.radicade.patch");
var steps = new (string Label, Action Apply)[]
{
("module hiding", ApplyModuleHiding),
("hile gate", ArmHileGate),
("connection gate", () => ConnectionGate.Install(harmony)),
("august challenge", () => harmony.PatchAll(typeof(AugustChallengePatches))),
("august launch diagnostics", () => harmony.PatchAll(typeof(AugustLaunchDiagnostics))),
("august launch validator", () => harmony.PatchAll(typeof(AugustLaunchValidatorPatch))),
("rsa verify neutraliser", () => RsaVerifyNeutralizerPatches.Apply(harmony)),
("file check", () => harmony.PatchAll(typeof(FileCheckEntryPatch))),
("eac native client stub", () => harmony.PatchAll(typeof(EacNativeClientStubPatch))),
("eac health query", () => harmony.PatchAll(typeof(EacHealthQueryLiePatch))),
("cheat scan suppression", () => harmony.PatchAll(typeof(CheatScanSuppressPatch))),
("cheat detection suppression", () => harmony.PatchAll(typeof(CheatDetectionSuppressPatch))),
("cheat dorm drop guard", () => harmony.PatchAll(typeof(CheatDormDropGuardPatch))),
("cheat manager trace", () => harmony.PatchAll(typeof(CheatManagerTracePatch))),
("exit initiator tracer", () => harmony.PatchAll(typeof(ExitInitiatorTracerPatch))),
("exit sequence trace", () => harmony.PatchAll(typeof(ExitSequenceTracePatch))),
("unity quit veto", () => harmony.PatchAll(typeof(UnityQuitVetoPatch))),
("exit state veto", () => harmony.PatchAll(typeof(ExitStateVetoPatch))),
("crash address map", CrashAddressMap.Start),
("tool collision reset guard", () => harmony.PatchAll(typeof(ToolCollisionResetGuardPatch))),
("collider cast guard", () => ColliderCastGuardPatch.Apply(harmony)),
("unbound prop freeze", () => harmony.PatchAll(typeof(UnboundPropFreezePatch))),
("keepsake tool", () => KeepsakeToolPatch.Apply(harmony)),
("dynamic ui prefab repair", () => DynamicUiPrefabRepair.Apply(harmony)),
("client text rewrites", () => ClientTextRewrites.Apply(harmony)),
("voice chat restriction capture", () => VoiceChatRestrictionPatches.ArmInstanceCapture(harmony)),
("voice chat ban flags", () => VoiceChatBanFlagPatches.Apply(harmony)),
("invite online friends", () => InviteOnlineFriendsPatch.Apply(harmony)),
("log flood gate", () => ApplyLogFloodGate(harmony)),
("realtime exit watch", () => RealtimeDiagnostics.ApplyExitWatch(harmony)),
("realtime diagnostics", () => RealtimeDiagnostics.Apply(harmony, RealtimeLog?.Value ?? true)),
("embedded tls", () => EmbeddedTlsPatches.Apply(harmony)),
("http response tolerance", () => HttpTolerancePatches.Apply(harmony)),
("log noise filter", () => LogNoiseFilter.Apply(harmony)),
("nameserver connection", () => harmony.PatchAll(typeof(NameserverConnectionPatches))),
("websocket redirect", () => WebSocketRedirectPatches.Apply(harmony)),
};
var failed = 0;
for (var i = 0; i < steps.Length; i++)
{
try
{
steps[i].Apply();
Log.LogInfo($"[Patch] {i + 1}/{steps.Length} {steps[i].Label}");
}
catch (Exception stepEx)
{
failed++;
Log.LogError($"[Patch] {i + 1}/{steps.Length} {steps[i].Label} failed: {stepEx}");
}
}
Log.LogInfo("PATCH LIST END");
if (failed > 0)
Log.LogWarning($"[Patch] {failed} of {steps.Length} steps failed, so the client may not behave as configured.");
ConnectionGate.Open();
}
void ApplyModuleHiding()
{
if (!ModuleHidingPolicy.Enabled)
return;
var hidden = PebModuleHider.HideModule("winhttp.dll");
if (!hidden)
Log.LogWarning("[ModuleHide] winhttp.dll was not found in the PEB module lists; a module scan will still see it.");
var wiped = PeHeaderWiper.WipeSignatures("winhttp.dll");
if (!wiped)
Log.LogWarning("[ModuleHide] the winhttp.dll PE signature wipe did not apply (signatures missing or VirtualProtect failed).");
if (hidden && wiped)
Log.LogInfo("[ModuleHide] winhttp.dll is unlinked from the PEB module lists and its PE signatures are wiped.");
}
void ArmHileGate()
{
HileGate.Enabled = true;
Log.LogInfo(HileGate.DropEnabled
? "[HileGate] armed: anti-cheat report POSTs are dropped at the HTTP layer."
: "[HileGate] armed: anti-cheat report POSTs are captured in full and then allowed through (RADICADE_HILE_GATE=drop drops them instead).");
}
void ApplyLogFloodGate(Harmony harmony)
{
LogFloodGate.Apply(harmony);
LogFloodGate.RequestSelfTestIfAsked();
}
}
[HarmonyPatch]
internal static class AugustLaunchDiagnostics
{
[HarmonyPrefix, HarmonyPatch(typeof(MILEHJBNIKI), "LMMMGKJCPOF")]
[HarmonyPatch(typeof(MILEHJBNIKI), "MBNHJLNEIKL")]
private static void Trace(FDJFJOBADEI __0)
{
if (__0 == FDJFJOBADEI.LAUNCH_VALIDATION_FAILED)
RadicadeEacBypassPlugin.LogSource.LogWarning("[August launch diagnostic] " + Il2CppSystem.Environment.StackTrace);
}
}
[HarmonyPatch]
internal static class AugustChallengePatches
{
[HarmonyPrefix, HarmonyPatch(typeof(EACManager), nameof(EACManager.GenerateChallengeResponse))]
private static bool Challenge(ref string __result)
{
__result = "radicade-eac-bypass";
return false;
}
[HarmonyPrefix, HarmonyPatch(typeof(EACManager), "JGFKOKIDHIN")]
private static bool Ready(ref bool __result)
{
__result = true;
return false;
}
}
[HarmonyPatch(typeof(IDLGLOLKLEB.MKGADIPJDLK), "IDDOELEGIFB")]
internal static class AugustLaunchValidatorPatch
{
static readonly System.Threading.CancellationTokenSource Pump = new();
[HarmonyPrefix]
internal static bool Bypass(IDLGLOLKLEB.MKGADIPJDLK __instance)
{
var promise = __instance.promise;
if (promise == null)
return true;
var context = RadicadeEacBypassPlugin.MainThreadContext;
try
{
var before = Il2CppSystem.Threading.SynchronizationContext.Current;
if (context != null)
Il2CppSystem.Threading.SynchronizationContext.SetSynchronizationContext(context);
try
{
promise.BEJNCICKNOM();
}
catch (Exception e)
{
RadicadeEacBypassPlugin.LogSource.LogError($"[August] Resolve failed: {e}");
}
finally
{
Il2CppSystem.Threading.SynchronizationContext.SetSynchronizationContext(before);
}
}
catch (Exception outer)
{
RadicadeEacBypassPlugin.LogSource.LogError($"[August] Context install failed: {outer}");
}
return false;
}
}
internal static class RsaVerifyNeutralizerPatches
{
static readonly byte[] P1KeyModulus = System.Convert.FromBase64String(
"X07yXkxaaLcZ1wVXfkWjgFkkqdoLhDFm0GPODsF+Q47pSUlbLvtXGqStnyEJEIrQmgDiicAvCdGRq4lovr2l5sIPMaoyizsbVHBdwLUrCsji0RvSBnmvN+8KqQ8STnB4DP4pAsPilfD35def4WuX/xMCXB5+hQUVhv27HPV8Dj9XzHuJAijIM9UwDZmvUcECyiO4wv+TaZi2+ELBtaLCQR8Gm1ZPeDEwP62Ch6MJy0jx5pkvvD0KdF9Wye+3/Wx31Zn/Trdo9HL4sGFWPDM9H9kQhZd5wkTHuxpwGIIhlzIwvY2/pBGdZKP6fi1D2jROEmVkBDyhmYY9nO+s3/bndQ==");
static int p1Calls, totalCalls;
internal static void Apply(Harmony harmony)
{
PatchVerify(harmony, typeof(Il2CppSystem.Security.Cryptography.RSACryptoServiceProvider), "VerifyHash",
new[]
{
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>),
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>),
typeof(Il2CppSystem.Security.Cryptography.HashAlgorithmName),
typeof(Il2CppSystem.Security.Cryptography.RSASignaturePadding)
});
PatchVerify(harmony, typeof(Il2CppSystem.Security.Cryptography.RSACryptoServiceProvider), "VerifyHash",
new[]
{
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>),
typeof(int),
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>)
});
PatchVerify(harmony, typeof(Il2CppSystem.Security.Cryptography.RSACryptoServiceProvider), "VerifyData",
new[]
{
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>),
typeof(Il2CppSystem.Object),
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>)
});
}
static void PatchVerify(Harmony harmony, Type target, string methodName, Type[] args)
{
try
{
var m = AccessTools.Method(target, methodName, args);
if (m == null)
{
RadicadeEacBypassPlugin.LogSource.LogWarning(
$"[RsaVerify] {target.Name}.{methodName}({args.Length} args) not found - skipped");
return;
}
harmony.Patch(m, prefix: new HarmonyMethod(typeof(RsaVerifyNeutralizerPatches).GetMethod(nameof(VerifyPrefix))));
}
catch (Exception e)
{
RadicadeEacBypassPlugin.LogSource.LogWarning(
$"[RsaVerify] failed to patch {target.Name}.{methodName}: {e.GetType().Name}: {e.Message}");
}
} public static bool VerifyPrefix(
Il2CppSystem.Security.Cryptography.RSACryptoServiceProvider __instance, ref bool __result)
{
totalCalls++;
var isP1 = false;
try
{
var modulus = __instance.ExportParameters(false).Modulus;
isP1 = modulus != null && modulus.Length == P1KeyModulus.Length && modulus.SequenceEqual(P1KeyModulus);
if (isP1)
p1Calls++;
}
catch
{
}
__result = isP1;
if (totalCalls <= 8)
RadicadeEacBypassPlugin.LogSource.LogWarning(
$"[ImageSig] RSA verify #{totalCalls} ({(isP1 ? "p1 key" : "game key")}) forced {(isP1 ? "true" : "false")}");
return false;
}
}
[HarmonyPatch]
internal static class FileCheckEntryPatch
{
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
{
yield return AccessTools.Method(typeof(GBOAMDFCNJA), "PEEADMOKNLI");
yield return AccessTools.Method(typeof(GBOAMDFCNJA), "IBPPCNEAGDP");
}
[HarmonyPrefix]
internal static bool Bypass(ref MFBKDKPGMNP<GBOAMDFCNJA.CMIGBOACMAG> __result)
{
var result = GBOAMDFCNJA.CMIGBOACMAG.NDGAGMGBOMD("radicade: file check bypassed");
__result = NOKBHGDKGNF.BGFCCOEKHOE(result);
return false;
}
}
internal static unsafe class PebModuleHider
{
[StructLayout(LayoutKind.Sequential)]
private struct PROCESS_BASIC_INFORMATION
{
public IntPtr Reserved1;
public IntPtr PebBaseAddress;
public IntPtr Reserved2_0;
public IntPtr Reserved2_1;
public IntPtr UniqueProcessId;
public IntPtr Reserved3;
}
[DllImport("ntdll.dll")]
private static extern int NtQueryInformationProcess(
IntPtr hProcess, int processInfoClass, ref PROCESS_BASIC_INFORMATION pbi,
int cb, out int returnLength);
private struct LIST_ENTRY { public IntPtr Flink, Blink; }
private const int OffInMemoryOrder = 0x10;
private const int OffInInitOrder = 0x20;
private const int OffBaseDllName = 0x58;
public static bool HideModule(string dllName)
{
var pbi = default(PROCESS_BASIC_INFORMATION);
int status = NtQueryInformationProcess(new IntPtr(-1), 0, ref pbi,
Marshal.SizeOf<PROCESS_BASIC_INFORMATION>(), out _);
if (status != 0 || pbi.PebBaseAddress == IntPtr.Zero)
return false;
byte* peb = (byte*)pbi.PebBaseAddress;
byte* ldr = *(byte**)(peb + 0x18);
if (ldr == null)
return false;
LIST_ENTRY* head = (LIST_ENTRY*)(ldr + 0x10);
byte* target = null;
for (LIST_ENTRY* n = (LIST_ENTRY*)head->Flink; n != head && target == null; n = (LIST_ENTRY*)n->Flink)
{
byte* entry = (byte*)n;
ushort* us = (ushort*)(entry + OffBaseDllName);
ushort lenBytes = *us;
char* buf = *(char**)(entry + OffBaseDllName + 8);
if (buf == null || lenBytes == 0)
continue;
string name = new string(buf, 0, lenBytes / 2);
if (name.Equals(dllName, StringComparison.OrdinalIgnoreCase))
target = entry;
}
if (target == null)
return false;
Unlink((LIST_ENTRY*)target);
Unlink((LIST_ENTRY*)(target + OffInMemoryOrder));
Unlink((LIST_ENTRY*)(target + OffInInitOrder));
return true;
}
private static void Unlink(LIST_ENTRY* e)
{
var flink = (LIST_ENTRY*)e->Flink;
var blink = (LIST_ENTRY*)e->Blink;
if (flink == e || blink == e)
return;
blink->Flink = e->Flink;
flink->Blink = e->Blink;
e->Flink = (IntPtr)e;
e->Blink = (IntPtr)e;
}
}
internal static unsafe class PeHeaderWiper
{
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern IntPtr GetModuleHandleW(string moduleName);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool VirtualProtect(IntPtr address, UIntPtr size, uint newProtect, out uint oldProtect);
public static bool WipeSignatures(string dllName)
{
IntPtr baseAddr = GetModuleHandleW(dllName);
if (baseAddr == IntPtr.Zero)
return false;
byte* p = (byte*)baseAddr;
int eLfanew = *(int*)(p + 0x3C);
if (eLfanew <= 0 || eLfanew > 0x1000)
return false;
if (!VirtualProtect(baseAddr, (UIntPtr)0x1000, 0x04 , out uint old))
return false;
bool wasMz = p[0] == (byte)'M' && p[1] == (byte)'Z';
bool wasPe = *(uint*)(p + eLfanew) == 0x00004550;
*(ushort*)p = 0;
*(uint*)(p + eLfanew) = 0;
VirtualProtect(baseAddr, (UIntPtr)0x1000, old, out _);
return wasMz && wasPe;
}
}
[HarmonyPatch]
internal static class ExitInitiatorTracerPatch
{
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
{
yield return AccessTools.Method(typeof(KJAMNFCDBIO), "GMAKAKECEDK");
yield return AccessTools.Method(typeof(KJAMNFCDBIO), "FLMPEJAIKFO");
yield return AccessTools.Method(typeof(KJAMNFCDBIO), "LHNNEFHHPBG");
}
[HarmonyPrefix]
internal static bool TraceAndBlock()
{
var armed = HileGate.DroppedCount > 0;
if (armed)
{
var stack = "<unavailable>";
try { stack = Environment.StackTrace; } catch { }
RadicadeEacBypassPlugin.LogSource.LogWarning("[ExitInit] blocked a managed exit initiator - caller trace:\n" + stack);
}
return !armed;
}
}
[HarmonyPatch]
[HarmonyPatch(typeof(BestHTTP.HTTPManager), "SendRequest", new[] { typeof(BestHTTP.HTTPRequest) })]
internal static class ExitSequenceTracePatch
{
private static int _dumped;
[HarmonyPriority(Priority.First)]
private static void Prefix(BestHTTP.HTTPRequest request)
{
try
{
var path = request?.Uri?.AbsolutePath;
if (path == null) return;
if (path.IndexOf("/matchmaking/player/logout", StringComparison.OrdinalIgnoreCase) < 0 &&
path.IndexOf("/playersettings/playersettings", StringComparison.OrdinalIgnoreCase) < 0)
return;
if (++_dumped > 4) return;
RadicadeEacBypassPlugin.LogSource.LogWarning(
"[ExitTrace] shutdown request " + path + " - managed caller stack:" + ExitTraceHelper.SafeStack());
}
catch (Exception traceEx)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[ExitTrace] dump failed: " + traceEx.Message);
}
}
}
[HarmonyPatch]
internal static class UnityQuitVetoPatch
{
internal static bool Enabled => _enabled;
private static readonly bool _enabled = IsEnabled();
private static int _blocks;
private static bool IsEnabled()
{
try
{
var raw = Environment.GetEnvironmentVariable("RADICADE_QUIT_VETO");
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Trim().ToLowerInvariant() is "1" or "on" or "true" or "yes";
}
catch { return false; }
}
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
{
var noArg = AccessTools.Method(typeof(KJAMNFCDBIO), "OFBGCPAHADA", Type.EmptyTypes);
if (noArg != null) yield return noArg;
var flag = AccessTools.Method(typeof(KJAMNFCDBIO), "OFBGCPAHADA", new[] { typeof(bool) });
if (flag != null) yield return flag;
}
[HarmonyPrefix]
private static bool Veto(ref bool __result)
{
if (!Enabled) return true;
__result = false;
var count = ++_blocks;
if (count <= 3 || count % 25 == 0)
RadicadeEacBypassPlugin.LogSource.LogWarning(
"[QuitVeto] wantsToQuit answered false (block #" + count + ") - Unity's quit is cancelled. " +
"This is the anti-cheat shutdown, not a user quit; the veto is armed by RADICADE_QUIT_VETO=1, set it to 0 to disable." +
ExitTraceHelper.SafeStack());
return false;
}
}
internal static class ModuleHidingPolicy
{
internal static bool Enabled { get; } = ReadFlag("RADICADE_MODULE_HIDE");
private static bool ReadFlag(string name)
{
try
{
var raw = Environment.GetEnvironmentVariable(name);
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Trim().ToLowerInvariant() is "1" or "on" or "true" or "yes";
}
catch { return false; }
}
}
[HarmonyPatch]
internal static class CheatScanSuppressPatch
{
internal static bool Enabled => !_disabled;
private static readonly bool _disabled = IsDisabled();
private static int _skips;
private static bool IsDisabled()
{
try
{
var raw = Environment.GetEnvironmentVariable("RADICADE_CHEAT_SCAN");
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
}
catch { return false; }
}
private static System.Reflection.MethodBase TargetMethod()
{
var type = AccessTools.TypeByName("CheatManager");
if (type == null) throw new System.MissingMemberException("CheatManager could not be resolved.");
var method = AccessTools.Method(type, "EKGOMPDGNEK", System.Type.EmptyTypes);
if (method == null) throw new System.MissingMethodException("CheatManager.EKGOMPDGNEK() is gone - the scan moved or was renamed.");
return method;
}
[HarmonyPrefix]
private static bool Skip()
{
if (!Enabled) return true;
var count = ++_skips;
if (count <= 3 || count % 25 == 0)
RadicadeEacBypassPlugin.LogSource.LogWarning("[CheatScan] skipped CheatManager.EKGOMPDGNEK() (#" + count
+ ") - this is the pass behind the Obscured/winhttp.dll verdict, so no report and no shutdown follow from it.");
return false;
}
}
[HarmonyPatch]
internal static class CheatDetectionSuppressPatch
{
internal static bool Enabled => !_disabled;
private static readonly bool _disabled = IsDisabled();
private static int _skips;
private static bool IsDisabled()
{
try
{
var raw = Environment.GetEnvironmentVariable("RADICADE_CHEAT_TYPE");
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
}
catch { return false; }
}
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
{
var type = AccessTools.TypeByName("CheatManager");
if (type == null) throw new System.MissingMemberException("CheatManager could not be resolved.");
var found = new System.Collections.Generic.List<string>();
var missing = new System.Collections.Generic.List<string>();
foreach (var name in new[] { "OnObscuredTypeCheatDetected", "OnTimeCheatDetected", "OnAdvancedMovementCheatDetected" })
{
var method = AccessTools.Method(type, name, System.Type.EmptyTypes);
if (method == null)
{
missing.Add(name);
continue;
}
found.Add(name);
yield return method;
}
_guarded = found.Count;
_unguarded = missing.Count == 0 ? null : string.Join(", ", missing);
}
internal static int Guarded => _guarded;
internal static string Unguarded => _unguarded;
private static int _guarded;
private static string _unguarded;
[HarmonyPrefix]
private static bool Skip(System.Reflection.MethodBase __originalMethod)
{
if (!Enabled) return true;
var count = ++_skips;
if (count <= 5 || count % 25 == 0)
RadicadeEacBypassPlugin.LogSource.LogWarning("[CheatType] skipped CheatManager." + __originalMethod.Name + "() (#" + count
+ ") - this is a verdict handler that ends in the dorm drop with FOKOIBODNAD and the shutdown behind it, so the room survives the detection.");
return false;
}
}
[HarmonyPatch]
internal static class CheatDormDropGuardPatch
{
private const string AntiCheatReason = "385120";
internal static bool Enabled => !_disabled;
private static readonly bool _disabled = IsDisabled();
private static int _skips;
private static bool IsDisabled()
{
try
{
var raw = Environment.GetEnvironmentVariable("RADICADE_CHEAT_DROP");
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
}
catch { return false; }
}
private static System.Reflection.MethodBase TargetMethod()
{
var type = AccessTools.TypeByName("CheatManager");
if (type == null) throw new System.MissingMemberException("CheatManager could not be resolved.");
var method = AccessTools.Method(type, "FOKOIBODNAD", new[] { typeof(string) });
if (method == null)
throw new System.MissingMethodException("CheatManager.FOKOIBODNAD(string) is gone - the dorm drop moved or was renamed, so the guard is NOT armed.");
return method;
}
[HarmonyPrefix]
private static bool Skip(string __0)
{
if (!Enabled) return true;
if (string.IsNullOrEmpty(__0) || __0.IndexOf(AntiCheatReason, StringComparison.Ordinal) < 0)
return true;
var count = ++_skips;
if (count <= 5 || count % 25 == 0)
RadicadeEacBypassPlugin.LogSource.LogWarning("[CheatDrop] skipped CheatManager.FOKOIBODNAD('" + __0 + "') (#" + count
+ ") - the anti-cheat's own drop, so the player stays where they are instead of being sent to the dormroom mid-session.");
return false;
}
}
[HarmonyPatch]
internal static class CheatManagerTracePatch
{
private const int PerMethodLimit = 4;
private const int LineLimit = 90;
private static readonly System.Diagnostics.Stopwatch Clock = System.Diagnostics.Stopwatch.StartNew();
private static readonly System.Collections.Generic.Dictionary<string, int> Seen = new System.Collections.Generic.Dictionary<string, int>();
private static int _lines;
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
{
var outer = AccessTools.TypeByName("CheatManager");
if (outer == null)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[CheatTrace] CheatManager not resolvable - nothing traced.");
yield break;
}
var types = new System.Collections.Generic.List<System.Type> { outer };
foreach (var nested in outer.GetNestedTypes(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic))
types.Add(nested);
foreach (var type in types)
{
foreach (var method in type.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.DeclaredOnly))
{
if (method.IsAbstract || method.ContainsGenericParameters) continue;
yield return method;
}
}
}
private static readonly bool Enabled = IsEnabled();
private static bool IsEnabled()
{
try
{
var raw = Environment.GetEnvironmentVariable("RADICADE_CHEAT_TRACE");
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Trim().ToLowerInvariant() is "1" or "on" or "true" or "yes";
}
catch { return false; }
}
[HarmonyPrefix]
private static void Trace(System.Reflection.MethodBase __originalMethod)
{
if (!Enabled) return;
try
{
if (_lines >= LineLimit) return;
var key = __originalMethod.DeclaringType.Name + "." + __originalMethod.Name;
Seen.TryGetValue(key, out var count);
if (count >= PerMethodLimit) return;
Seen[key] = count + 1;
_lines++;
RadicadeEacBypassPlugin.LogSource.LogWarning("[CheatTrace] +" + Clock.ElapsedMilliseconds + "ms " + key);
}
catch { }
}
}
internal static class ExitTraceHelper
{
internal static string SafeStack()
{
try { return "\n" + Environment.StackTrace; } catch { return "\n<stack unavailable>"; }
}
}
internal static class EacGate
{
internal static bool Enabled => !_disabled;
private static readonly bool _disabled = IsDisabled();
private static int _logCount;
private static bool IsDisabled()
{
try
{
var raw = Environment.GetEnvironmentVariable("RADICADE_EAC_NEUTRALIZE");
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
}
catch { return false; }
}
internal static System.Type Find(string fullName)
{
try
{
var byName = System.Type.GetType(fullName + ", EasyAntiCheat.Client");
if (byName != null) return byName;
}
catch { }
try
{
var forced = typeof(EasyAntiCheat.Client.Hydra.Runtime).Assembly;
var direct = forced.GetType(fullName);
if (direct != null) return direct;
}
catch { }
try { return AccessTools.TypeByName(fullName); } catch { return null; }
}
private const int LogLimit = 8;
internal static void Log(string message)
{
try
{
if (++_logCount > LogLimit) return;
RadicadeEacBypassPlugin.LogSource.LogWarning(message);
}
catch { }
}
}
[HarmonyPatch]
internal static class EacNativeClientStubPatch
{
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
{
var type = EacGate.Find("EasyAntiCheat.Client.Hydra.NativeClient");
if (type == null)
{
EacGate.Log("[EacStub] Hydra.NativeClient not resolvable - EAC runtime left untouched.");
yield break;
}
foreach (var method in type.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
if (method.Name == "Initialize")
yield return method;
}
[HarmonyPrefix]
private static bool Skip(System.Reflection.MethodBase __originalMethod)
{
if (!EacGate.Enabled) return true;
EacGate.Log("[EacStub] skipped " + __originalMethod.DeclaringType + "." + __originalMethod.Name
+ " - EasyAntiCheat's native client never starts, so its module scanner never runs.");
return false;
}
}
[HarmonyPatch]
internal static class EacHealthQueryLiePatch
{
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
{
var native = EacGate.Find("EasyAntiCheat.Client.Hydra.NativeClient");
if (native != null)
foreach (var method in native.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
if (method.Name == "IsStartedWithEasyAntiCheat" && method.ReturnType == typeof(bool))
yield return method;
var runtime = EacGate.Find("EasyAntiCheat.Client.Hydra.Runtime");
if (runtime != null)
foreach (var method in runtime.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
if ((method.Name == "IsActive" || method.Name == "get_Initialized") && method.ReturnType == typeof(bool))
yield return method;
}
[HarmonyPrefix]
private static bool Lie(ref bool __result, System.Reflection.MethodBase __originalMethod)
{
if (!EacGate.Enabled) return true;
__result = true;
EacGate.Log("[EacStub] " + __originalMethod.DeclaringType + "." + __originalMethod.Name + " answered true.");
return false;
}
}
[HarmonyPatch]
[HarmonyPatch]
internal static class ToolCollisionResetGuardPatch
{
internal static bool Enabled => !_disabled;
private static readonly bool _disabled = IsDisabled();
private static bool IsDisabled()
{
try
{
var raw = Environment.GetEnvironmentVariable("RADICADE_TOOL_RESET");
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
}
catch { return false; }
}
private static System.Reflection.MethodBase TargetMethod()
{
var method = AccessTools.Method(typeof(Tool), "ResetToDefaultCollisionLayer", Type.EmptyTypes);
if (method == null)
throw new MissingMethodException(
"Tool.ResetToDefaultCollisionLayer() not found - the room crash guard is NOT armed.");
return method;
}
[HarmonyPrefix]
private static bool Skip(Tool __instance)
{
if (!Enabled) return true;
ToolCollisionLayerRestore.Restore(__instance);
return false;
}
private static string Describe(Tool tool)
{
try
{
if (tool == null) return "<null tool>";
var owner = tool.gameObject;
return (owner == null) ? "<no gameObject>" : "'" + owner.name + "'";
}
catch (Exception ex)
{
return "<describe failed: " + ex.GetType().Name + ">";
}
}
}
internal static class ExitStateVetoPatch
{
internal static bool Enabled => _enabled;
private static readonly bool _enabled = IsEnabled();
private static int _blocks;
private static bool IsEnabled()
{
try
{
var raw = Environment.GetEnvironmentVariable("RADICADE_EXIT_VETO");
if (string.IsNullOrWhiteSpace(raw)) return false;
raw = raw.Trim().ToLowerInvariant();
return raw is "1" or "on" or "true" or "yes";
}
catch { return false; }
}
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
{
var property = AccessTools.PropertySetter(typeof(KJAMNFCDBIO), "JGKJEDKNFHN");
if (property != null) yield return property;
var raw = AccessTools.Method(typeof(KJAMNFCDBIO), "EHPDJDJLPKM",
new[] { typeof(KJAMNFCDBIO.CNNHHNMPLJE) });
if (raw != null) yield return raw;
}
[HarmonyPrefix]
private static bool Block(KJAMNFCDBIO.CNNHHNMPLJE __0)
{
if (!Enabled) return true;
if (__0 == KJAMNFCDBIO.CNNHHNMPLJE.Running) return true;
var count = ++_blocks;
var caller = count == 1 ? ExitTraceHelper.SafeStack() : string.Empty;
if (count <= 3 || count % 25 == 0)
RadicadeEacBypassPlugin.LogSource.LogWarning(
$"[ExitVeto] blocked AppExitState -> {__0} (block #{count}); the session keeps running. " +
"This is the anti-cheat-driven exit, not a user quit - the veto is armed by RADICADE_EXIT_VETO=1, set it to 0 to disable." + caller);
return false;
}
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<LangVersion>latest</LangVersion>
<AssemblyName>RadicadePatch</AssemblyName>
<RootNamespace>RadicadePatch</RootNamespace>
<BepInExRoot Condition="'$(BepInExRoot)' == ''">C:\RadicadeBeta\BepInEx</BepInExRoot>
</PropertyGroup>
<ItemGroup>
<Reference Include="$(BepInExRoot)\core\BepInEx.Core.dll;$(BepInExRoot)\core\BepInEx.Unity.IL2CPP.dll;$(BepInExRoot)\core\0Harmony.dll;$(BepInExRoot)\core\Il2CppInterop.Runtime.dll" Private="false" />
<Reference Include="$(BepInExRoot)\interop\*.dll" Private="false" />
<Compile Include="..\RadicadeEacBypass\EmbeddedTlsPatches.cs" Link="Shared\EmbeddedTlsPatches.cs" />
<Compile Include="..\RadicadeEacBypass\NameserverConnectionPatches.cs" Link="Shared\NameserverConnectionPatches.cs" />
<Compile Include="..\RadicadeEacBypass\NameserverRedirect.cs" Link="Shared\NameserverRedirect.cs" />
<Compile Include="..\RadicadeEacBypass\ImageSignatureRequest.cs" Link="Shared\ImageSignatureRequest.cs" />
<Compile Include="..\RadicadeEacBypass\HubSocketAuth.cs" Link="Shared\HubSocketAuth.cs" />
<Compile Include="..\RadicadeEacBypass\WebSocketRedirectPatches.cs" Link="Shared\WebSocketRedirectPatches.cs" />
<Compile Include="..\RadicadeEacBypass\Diag.cs" Link="Shared\Diag.cs" />
<Compile Include="..\RadicadeEacBypass\ResponseRepair.cs" Link="Shared\ResponseRepair.cs" />
<Compile Include="..\RadicadeEacBypass\HttpTolerancePatches.cs" Link="Shared\HttpTolerancePatches.cs" />
<Compile Include="..\RadicadeEacBypass\RealtimeDiagnostics.cs" Link="Shared\RealtimeDiagnostics.cs" />
<Compile Include="..\RadicadeEacBypass\VoiceChatRestrictionPatches.cs" Link="Shared\VoiceChatRestrictionPatches.cs" />
</ItemGroup>
</Project>
@@ -0,0 +1,40 @@
using UnityEngine;
internal static class ToolCollisionLayerRestore
{
private static int _failures;
internal static void Restore(Tool tool)
{
if (tool == null) return;
try
{
if (tool.MEDCLKFANNC) return;
int layer = (int)tool.EHNBBIAIJIK;
if (layer < 0 || layer > 31)
throw new InvalidOperationException("Invalid default collision layer " + layer);
foreach (var collider in tool.GetComponentsInChildren<Collider>(true))
{
if (collider == null || collider.isTrigger) continue;
if (collider.GetComponentInParent<Tool>() != tool) continue;
var owner = collider.gameObject;
if (!ShouldRestore(owner.layer, (int)tool.HeldCollisionLayer,
(int)tool.EquippedCollisionLayer, (int)tool.AttachedCollisionLayer)) continue;
if (owner.layer == layer) continue;
owner.layer = layer;
}
}
catch (Exception ex)
{
if (++_failures <= 5)
RadicadeEacBypassPlugin.LogSource.LogWarning("[ToolGuard] collision-layer restore failed: " + ex);
}
}
private static bool ShouldRestore(int layer, int held, int equipped, int attached)
{
return layer == held || layer == equipped || layer == attached
|| layer is 0 or 10 or 11 or 13 or 17 or 18 or 23 or 24 or 27;
}
}
@@ -0,0 +1,147 @@
using System;
using HarmonyLib;
using Il2CppInterop.Runtime;
using RecRoom;
using RecRoom.ObjectModel;
using UnityEngine;
[HarmonyPatch]
internal static class UnboundPropFreezePatch
{
private static readonly bool _disabled = IsDisabled();
private static int _alreadyInert;
private static bool _loggedNoRigidbody;
private static bool _loggedFailure;
private static readonly Type[] Companions = new Type[3]
{
typeof(EmbodiedObject),
typeof(RbexEntity),
typeof(TransformEntity)
};
internal static bool Enabled => !_disabled;
private static bool IsDisabled()
{
try
{
string environmentVariable = Environment.GetEnvironmentVariable("RADICADE_UNBOUND_PROP_FREEZE");
if (string.IsNullOrWhiteSpace(environmentVariable))
{
return false;
}
bool result;
switch (environmentVariable.Trim().ToLowerInvariant())
{
case "0":
case "off":
case "false":
case "no":
result = true;
break;
default:
result = false;
break;
}
return result;
}
catch
{
return false;
}
}
[HarmonyPostfix]
[HarmonyPatch(typeof(RigidbodyEx), "Awake")]
private static void Postfix(RigidbodyEx __instance)
{
if (!Enabled || __instance == null)
{
return;
}
try
{
if (!IsBound(__instance))
{
Freeze(__instance);
}
}
catch (Exception ex)
{
if (!_loggedFailure)
{
_loggedFailure = true;
RadicadeEacBypassPlugin.LogSource.LogWarning((object)("[PropGuard] could not freeze an unbound prop (" + ex.GetType().Name + ": " + ex.Message + "); the prop is left exactly as the game built it."));
}
}
}
private static bool IsBound(RigidbodyEx rigidbodyEx)
{
Type[] companions = Companions;
foreach (Type type in companions)
{
try
{
if (rigidbodyEx.GetComponent(Il2CppType.From(type)) != null)
{
return true;
}
}
catch
{
return true;
}
}
return false;
}
private static void Freeze(RigidbodyEx rigidbodyEx)
{
Rigidbody unityRigidbody = rigidbodyEx.unityRigidbody;
if (unityRigidbody == null)
{
if (!_loggedNoRigidbody)
{
_loggedNoRigidbody = true;
}
try
{
rigidbodyEx.ClearUnityRigidbodyVelocity();
return;
}
catch
{
return;
}
}
if (unityRigidbody.isKinematic)
{
_alreadyInert++;
return;
}
unityRigidbody.isKinematic = true;
unityRigidbody.velocity = Vector3.zero;
unityRigidbody.angularVelocity = Vector3.zero;
}
private static string Describe(RigidbodyEx rigidbodyEx)
{
try
{
GameObject gameObject = ((Component)rigidbodyEx).gameObject;
return (gameObject == null) ? "<no game object>" : ("'" + gameObject.name + "'");
}
catch
{
return "<unnamed>";
}
}
}
@@ -0,0 +1,66 @@
using System;
using System.Reflection;
using HarmonyLib;
internal static class VoiceChatBanFlagPatches
{
private static bool _armed;
internal static void Apply(Harmony harmony)
{
if (_armed) return;
_armed = true;
Patch(harmony, "IsVoiceChatBanned", nameof(BannedPostfix));
Patch(harmony, "AllowedToVoiceChat", nameof(AllowedPostfix));
}
private static void Patch(Harmony harmony, string propertyName, string handler)
{
try
{
var getter = AccessTools.Method(typeof(SettingsModel), "get_" + propertyName)
?? AccessTools.Property(typeof(SettingsModel), propertyName)?.GetGetMethod(true);
if (getter == null)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[VoiceBanFlag] SettingsModel."
+ propertyName + " has no getter in this build, so the Audio page keeps its own value.");
return;
}
harmony.Patch(getter, postfix: new HarmonyMethod(typeof(VoiceChatBanFlagPatches)
.GetMethod(handler, BindingFlags.Static | BindingFlags.NonPublic)));
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[VoiceBanFlag] could not patch SettingsModel."
+ propertyName + ": " + ex.GetType().Name + ": " + ex.Message);
}
}
private static void BannedPostfix(ref bool __result)
{
try
{
if (!VoiceChatRestrictionPatches.RestrictionInForce) return;
__result = true;
}
catch { }
}
private static void AllowedPostfix(ref bool __result)
{
try
{
if (!VoiceChatRestrictionPatches.RestrictionInForce) return;
__result = false;
}
catch { }
}
}
+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0"?>
<Obfuscator>
<Var name="InPath" value="bin/Release/net6.0" />
<Var name="OutPath" value="bin/obfuscated" />
<Var name="BepInExRoot" value="C:\RadicadeBeta\BepInEx" />
<Var name="ExtraFrameworkFolders" value="$(BepInExRoot)\core;$(BepInExRoot)\interop" />
<Var name="KeepPublicApi" value="false" />
<Var name="HidePrivateApi" value="true" />
<Var name="HideStrings" value="true" />
<Var name="UseUnicodeNames" value="false" />
<Var name="ReuseNames" value="true" />
<Var name="RenameProperties" value="true" />
<Var name="RenameEvents" value="true" />
<Var name="RenameFields" value="true" />
<Var name="RegenerateDebugInfo" value="false" />
<Var name="SuppressIldasm" value="true" />
<Module file="$(InPath)/RadicadePatch.dll">
<SkipMethod name="Prepare" />
<SkipMethod name="Cleanup" />
<SkipMethod name="TargetMethod" />
<SkipMethod name="TargetMethods" />
<SkipMethod name="Prefix" />
<SkipMethod name="Postfix" />
<SkipMethod name="Transpiler" />
<SkipMethod name="Finalizer" />
<SkipMethod name="ReversePatch" />
<SkipMethod name="ILManipulator" />
<SkipMethod rx=".*(Prefix|Postfix|Transpiler|Finalizer)$" />
<SkipMethod name="EnableDialog" />
<SkipMethod name="GraphicEvent" />
<SkipMethod name="IncomingDialog" />
<SkipMethod name="NoteCollider" />
<SkipMethod name="Repair" />
<SkipMethod name="Swallow" />
<SkipMethod name="Block" />
<SkipMethod name="Bypass" />
<SkipMethod name="Challenge" />
<SkipMethod name="Lie" />
<SkipMethod name="Ready" />
<SkipMethod name="Skip" />
<SkipMethod name="Trace" />
<SkipMethod name="TraceAndBlock" />
<SkipMethod name="Veto" />
</Module>
</Obfuscator>
+4
View File
@@ -0,0 +1,4 @@
ns.radicade.app {
encode gzip
reverse_proxy 127.0.0.1:3030
}
+70
View File
@@ -0,0 +1,70 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.36705.20
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server", "Server\Server.csproj", "{9DDA5EBF-9DCD-45C3-83ED-2AC383109529}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server.Tests", "Server.Tests\Server.Tests.csproj", "{C108E570-E61B-49CF-8590-CF8B983ED6B0}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "2023Patch", "2023Patch", "{05A810AF-0524-D221-1DA2-A901B183F7A6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RadicadePatch", "2023Patch\RadicadePatch\RadicadePatch.csproj", "{A6E9037F-1069-44A3-BE64-4D52084AEF0A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{9DDA5EBF-9DCD-45C3-83ED-2AC383109529}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9DDA5EBF-9DCD-45C3-83ED-2AC383109529}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9DDA5EBF-9DCD-45C3-83ED-2AC383109529}.Debug|x64.ActiveCfg = Debug|Any CPU
{9DDA5EBF-9DCD-45C3-83ED-2AC383109529}.Debug|x64.Build.0 = Debug|Any CPU
{9DDA5EBF-9DCD-45C3-83ED-2AC383109529}.Debug|x86.ActiveCfg = Debug|Any CPU
{9DDA5EBF-9DCD-45C3-83ED-2AC383109529}.Debug|x86.Build.0 = Debug|Any CPU
{9DDA5EBF-9DCD-45C3-83ED-2AC383109529}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9DDA5EBF-9DCD-45C3-83ED-2AC383109529}.Release|Any CPU.Build.0 = Release|Any CPU
{9DDA5EBF-9DCD-45C3-83ED-2AC383109529}.Release|x64.ActiveCfg = Release|Any CPU
{9DDA5EBF-9DCD-45C3-83ED-2AC383109529}.Release|x64.Build.0 = Release|Any CPU
{9DDA5EBF-9DCD-45C3-83ED-2AC383109529}.Release|x86.ActiveCfg = Release|Any CPU
{9DDA5EBF-9DCD-45C3-83ED-2AC383109529}.Release|x86.Build.0 = Release|Any CPU
{C108E570-E61B-49CF-8590-CF8B983ED6B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C108E570-E61B-49CF-8590-CF8B983ED6B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C108E570-E61B-49CF-8590-CF8B983ED6B0}.Debug|x64.ActiveCfg = Debug|Any CPU
{C108E570-E61B-49CF-8590-CF8B983ED6B0}.Debug|x64.Build.0 = Debug|Any CPU
{C108E570-E61B-49CF-8590-CF8B983ED6B0}.Debug|x86.ActiveCfg = Debug|Any CPU
{C108E570-E61B-49CF-8590-CF8B983ED6B0}.Debug|x86.Build.0 = Debug|Any CPU
{C108E570-E61B-49CF-8590-CF8B983ED6B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C108E570-E61B-49CF-8590-CF8B983ED6B0}.Release|Any CPU.Build.0 = Release|Any CPU
{C108E570-E61B-49CF-8590-CF8B983ED6B0}.Release|x64.ActiveCfg = Release|Any CPU
{C108E570-E61B-49CF-8590-CF8B983ED6B0}.Release|x64.Build.0 = Release|Any CPU
{C108E570-E61B-49CF-8590-CF8B983ED6B0}.Release|x86.ActiveCfg = Release|Any CPU
{C108E570-E61B-49CF-8590-CF8B983ED6B0}.Release|x86.Build.0 = Release|Any CPU
{A6E9037F-1069-44A3-BE64-4D52084AEF0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A6E9037F-1069-44A3-BE64-4D52084AEF0A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A6E9037F-1069-44A3-BE64-4D52084AEF0A}.Debug|x64.ActiveCfg = Debug|Any CPU
{A6E9037F-1069-44A3-BE64-4D52084AEF0A}.Debug|x64.Build.0 = Debug|Any CPU
{A6E9037F-1069-44A3-BE64-4D52084AEF0A}.Debug|x86.ActiveCfg = Debug|Any CPU
{A6E9037F-1069-44A3-BE64-4D52084AEF0A}.Debug|x86.Build.0 = Debug|Any CPU
{A6E9037F-1069-44A3-BE64-4D52084AEF0A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A6E9037F-1069-44A3-BE64-4D52084AEF0A}.Release|Any CPU.Build.0 = Release|Any CPU
{A6E9037F-1069-44A3-BE64-4D52084AEF0A}.Release|x64.ActiveCfg = Release|Any CPU
{A6E9037F-1069-44A3-BE64-4D52084AEF0A}.Release|x64.Build.0 = Release|Any CPU
{A6E9037F-1069-44A3-BE64-4D52084AEF0A}.Release|x86.ActiveCfg = Release|Any CPU
{A6E9037F-1069-44A3-BE64-4D52084AEF0A}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{A6E9037F-1069-44A3-BE64-4D52084AEF0A} = {05A810AF-0524-D221-1DA2-A901B183F7A6}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {DCFE80C5-655C-43CB-872D-BE9F89D58EDE}
EndGlobalSection
EndGlobal
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,29 @@
using RadicadeServer.RecNet.Enums.Players;
namespace RadicadeServer.RecNet.Accounts
{
public class AccountCreationData
{
public Platforms platform { get; set; }
public string platformId { get; set; } = null!;
public string deviceId { get; set; } = null!;
public string? password { get; set; }
public DeviceClass deviceClass { get; set; }
public string? username { get; set; }
public DateTime? birthday { get; set; }
public DateTime? birthdayUTC { get; set; }
public string? email { get; set; }
public string? avatar { get; set; }
public string? avatarJson { get; set; }
}
}
+131
View File
@@ -0,0 +1,131 @@
using System.Collections.Concurrent;
namespace RadicadeServer.Utils
{
public static class RegistrationGuard
{
public static readonly TimeSpan Window = TimeSpan.FromMinutes(10);
private static int maxCreationsPerIp = 20;
private static int maxCreationsPerDevice = 10;
private static TimeSpan minimumDeviceInterval = TimeSpan.FromSeconds(10);
public static int MaxCreationsPerIp => maxCreationsPerIp;
public static int MaxCreationsPerDevice => maxCreationsPerDevice;
public static TimeSpan MinimumDeviceInterval => minimumDeviceInterval;
public static void Configure(int maxCreationsPerIp, int maxCreationsPerDevice, TimeSpan minimumDeviceInterval)
{
RegistrationGuard.maxCreationsPerIp = maxCreationsPerIp;
RegistrationGuard.maxCreationsPerDevice = maxCreationsPerDevice;
RegistrationGuard.minimumDeviceInterval = minimumDeviceInterval;
Reset();
}
private sealed class Counter
{
public readonly List<DateTime> Events = [];
public DateTime LastCreation = DateTime.MinValue;
}
private static readonly ConcurrentDictionary<string, Counter> ByIp = new(StringComparer.Ordinal);
private static readonly ConcurrentDictionary<string, Counter> ByDevice = new(StringComparer.Ordinal);
private static readonly ConcurrentDictionary<string, SemaphoreSlim> IdentityGates = new(StringComparer.Ordinal);
public static string? Check(string? ip, string? deviceId)
{
var now = DateTime.UtcNow;
if (!string.IsNullOrWhiteSpace(deviceId))
{
var device = ByDevice.GetOrAdd(deviceId, _ => new Counter());
lock (device)
{
device.Events.RemoveAll(evt => now - evt > Window);
if (now - device.LastCreation < MinimumDeviceInterval)
return "An account was just created on this device. Please wait a moment and try again.";
if (device.Events.Count >= MaxCreationsPerDevice)
return "Too many accounts have been created on this device. Please try again later.";
}
}
if (!string.IsNullOrWhiteSpace(ip))
{
var source = ByIp.GetOrAdd(ip, _ => new Counter());
lock (source)
{
source.Events.RemoveAll(evt => now - evt > Window);
if (source.Events.Count >= MaxCreationsPerIp)
return "Too many accounts have been created from this connection. Please try again later.";
}
}
return null;
}
public static void Record(string? ip, string? deviceId)
{
var now = DateTime.UtcNow;
if (!string.IsNullOrWhiteSpace(deviceId))
{
var device = ByDevice.GetOrAdd(deviceId, _ => new Counter());
lock (device)
{
device.Events.RemoveAll(evt => now - evt > Window);
device.Events.Add(now);
device.LastCreation = now;
}
}
if (!string.IsNullOrWhiteSpace(ip))
{
var source = ByIp.GetOrAdd(ip, _ => new Counter());
lock (source)
{
source.Events.RemoveAll(evt => now - evt > Window);
source.Events.Add(now);
}
}
}
public static string IdentityKey(string platform, string? platformId, string? deviceId) =>
platform + "|" + (platformId?.Trim() ?? string.Empty) + "|" + (deviceId?.Trim() ?? string.Empty);
public static Task<IDisposable> LockNameAsync(string name) =>
LockIdentityAsync("name|" + UsernameRules.ComparisonKey(name));
public static async Task<IDisposable> LockIdentityAsync(string key)
{
var gate = IdentityGates.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync();
return new Releaser(gate);
}
private sealed class Releaser(SemaphoreSlim gate) : IDisposable
{
private int released;
public void Dispose()
{
if (Interlocked.Exchange(ref released, 1) == 0) gate.Release();
}
}
public static void Reset()
{
ByIp.Clear();
ByDevice.Clear();
IdentityGates.Clear();
}
internal static string Describe(string? ip, string? deviceId) =>
"ip=" + (ip ?? "<none>") + " device=" + (deviceId ?? "<none>");
}
}
+30
View File
@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.RecNet;
using RadicadeServer.Utils;
namespace RadicadeServer.API.Activities.Charades.V1
{
[Route("api/activities/charades/v1")]
public class CharadesV1(RecDBContext db) : Controller
{
[HttpGet("words/{cardType}")]
public async Task<ActionResult<List<CardBoxWord>>> GetCardWords(string cardType)
{
switch (cardType)
{
case "Charades":
var charades = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == "Charades");
return Ok(charades?.Value.ParseCardBoxWords());
case "CharadesAprilFoolsDay":
var charadesAprilFools = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == "CharadesAprilFools");
return Ok(charadesAprilFools?.Value.ParseCardBoxWords());
case "Icebreakers":
var icebreakers = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == "Icebreakers");
return Ok(icebreakers?.Value.ParseCardBoxWords());
}
return Ok();
}
}
}
+127
View File
@@ -0,0 +1,127 @@
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.Auth;
using RadicadeServer.DB;
using RadicadeServer.Utils;
namespace RadicadeServer.Admin;
[Route("admin/auth")]
[EnableCors("AdminPanel")]
public class AdminAuthAPI(RecDBContext db) : Controller
{
private CookieOptions SessionCookieOptions => new()
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
MaxAge = AdminSession.Lifetime,
Path = "/"
};
public record LoginRequest(string? Username, string? Password);
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
var username = request.Username?.Trim() ?? "";
var ip = Request.GetClientIp();
if (AdminLoginThrottle.IsLocked(username, ip, out var remaining))
return LockoutResponse(remaining);
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password))
return Unauthorized(new { error = "invalid_credentials", message = "Invalid username or password." });
var user = await db.AdminUsers.FirstOrDefaultAsync(x => x.Username == username);
if (user == null || !AdminSecurity.VerifyPassword(user.PasswordHash, request.Password))
{
await Task.Delay(350);
AdminLoginThrottle.RecordFailure(username, ip);
if (AdminLoginThrottle.IsLocked(username, ip, out remaining))
return LockoutResponse(remaining);
return Unauthorized(new { error = "invalid_credentials", message = "Invalid username or password." });
}
if (AdminSecurity.NeedsRehash(user.PasswordHash))
{
user.PasswordHash = AdminSecurity.HashPassword(request.Password);
await db.SaveChangesAsync();
}
AdminLoginThrottle.Clear(username, ip);
await AdminSession.PruneAsync(db);
return await CompleteLoginAsync(user);
}
private async Task<IActionResult> CompleteLoginAsync(AdminUser user)
{
var token = await AdminSession.CreateAsync(db, user.Id);
user.LastLoginAt = DateTime.UtcNow;
await db.SaveChangesAsync();
Response.Cookies.Append(AdminSession.CookieName, token, SessionCookieOptions);
return Ok(new { ok = true, user = Identity(user) });
}
[HttpGet("session")]
public async Task<IActionResult> Session()
{
var user = await RequireSessionAsync();
if (user == null) return Unauthorized(new { error = "not_authenticated" });
var (rotated, didRotate) = AdminSession.TryRotate(db, CurrentToken());
if (didRotate && !string.IsNullOrEmpty(rotated))
Response.Cookies.Append(AdminSession.CookieName, rotated, SessionCookieOptions);
return Ok(new { user = Identity(user) });
}
[HttpGet("me")]
public async Task<IActionResult> Me()
{
var user = await RequireSessionAsync();
if (user == null) return Unauthorized(new { error = "not_authenticated" });
return Ok(new { user = Identity(user) });
}
[HttpPost("logout")]
public async Task<IActionResult> Logout()
{
await AdminSession.RevokeAsync(db, CurrentToken());
Response.Cookies.Delete(AdminSession.CookieName, new CookieOptions { Path = "/" });
return Ok(new { ok = true });
}
private string? CurrentToken() =>
Request.Cookies.TryGetValue(AdminSession.CookieName, out var token) ? token : null;
private async Task<AdminUser?> RequireSessionAsync()
{
var (ok, userId) = await AdminSession.ValidateAsync(db, CurrentToken());
if (!ok) return null;
return await db.AdminUsers.FirstOrDefaultAsync(x => x.Id == userId);
}
private static object Identity(AdminUser user) => new
{
id = user.Id,
username = user.Username
};
private static string Display(string ip) => string.IsNullOrWhiteSpace(ip) ? "an unknown address" : ip;
private IActionResult LockoutResponse(TimeSpan remaining)
{
var seconds = (int)Math.Ceiling(remaining.TotalSeconds);
Response.Headers["Retry-After"] = seconds.ToString();
var message = seconds > 60
? $"Too many failed sign-in attempts. Try again in {(int)Math.Ceiling(remaining.TotalMinutes)} minutes."
: $"Too many failed sign-in attempts. Try again in {seconds} seconds.";
return StatusCode(429, new { error = "locked_out", message, retry_after_seconds = seconds });
}
}
+6
View File
@@ -0,0 +1,6 @@
namespace RadicadeServer.Admin
{
public class AdminBoardAPI
{
}
}
+74
View File
@@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.Clubs;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.Utils;
namespace RadicadeServer.Admin
{
[Route("admin/clubs")]
[EnableCors("AdminPanel")]
public class AdminClubAPI(RecDBContext db) : Controller
{
private Task<IActionResult?> RejectInvalidKey(string key)
{
return Task.FromResult<IActionResult?>(key.IsAdminKey() ? null : Unauthorized("Invalid secret"));
}
[HttpPost("upload/image")]
public async Task<IActionResult> UploadImage([FromQuery] string key, [FromQuery] long clubId, [FromForm] IFormFile file)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var clubs = await ClubStore.LoadAsync(db);
var club = clubs.FirstOrDefault(x => x.clubId == clubId);
if (club == null) return NotFound("Club does not exist.");
if (file == null || file.Length == 0) return BadRequest("No file provided.");
var allowed = new[] { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp" };
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(ext) || !allowed.Contains(ext))
return BadRequest($"Unsupported image format '{(string.IsNullOrEmpty(ext) ? "no extension" : ext)}'. Use png/jpg/jpeg/gif/webp.");
Directory.CreateDirectory(DBConfig.CDNImages);
var name = Guid.NewGuid().ToString() + ext;
var path = Path.Join(DBConfig.CDNImages, name);
using (var stream = new FileStream(path, FileMode.Create))
{
await file.CopyToAsync(stream);
}
club.imageName = name;
await ClubStore.SaveAsync(db, clubs);
return Ok($"Uploaded {name}!\nURL: {DBConfig.ServerUrl}/images/img/{name}");
}
[HttpGet("set/image")]
public async Task<IActionResult> SetImage([FromQuery] string key, [FromQuery] long clubId, [FromQuery] string imgName)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var clubs = await ClubStore.LoadAsync(db);
var club = clubs.FirstOrDefault(x => x.clubId == clubId);
if (club == null) return NotFound("Club does not exist.");
imgName = Path.GetFileName(imgName ?? "").Trim();
if (string.IsNullOrEmpty(imgName)) imgName = ClubStore.DefaultImageName;
club.imageName = imgName;
await ClubStore.SaveAsync(db, clubs);
return Ok($"Set to {imgName}!\nURL: {DBConfig.ServerUrl}/images/img/{imgName}");
}
}
}
+56
View File
@@ -0,0 +1,56 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Cors;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Config;
using RadicadeServer.Utils;
namespace RadicadeServer.Admin
{
[Route("admin/config")]
[EnableCors("AdminPanel")]
public class AdminConfigAPI(RecDBContext db) : Controller
{
private Task<IActionResult?> RejectInvalidKey(string key)
{
return Task.FromResult<IActionResult?>(key.IsAdminKey() ? null : Unauthorized("Invalid secret"));
}
[HttpGet("forcerefresh")]
public async Task<IActionResult> ForceRefresh([FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
MainConfig? config = await db.Config.FirstOrDefaultAsync();
if (config == null) return StatusCode(500);
bool didChanges = false;
if (config.ExpectingReload)
{
didChanges = true;
config.SetAsActive();
config.ExpectingReload = false;
}
if (config.RefreshConfig(true))
didChanges = true;
if (didChanges)
await db.SaveChangesAsync();
return Ok(config?.ToActualConfig());
}
[HttpGet("full")]
public async Task<IActionResult> GetFullConfig([FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
MainConfig? config = await db.Config.FirstOrDefaultAsync();
return Ok(config);
}
}
}
+67
View File
@@ -0,0 +1,67 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RadicadeServer.Admin;
public class AdminUser
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long Id { get; set; }
[Required, MaxLength(64)]
public string Username { get; set; } = "";
[Required]
public string PasswordHash { get; set; } = "";
[MaxLength(64)]
public string? TotpSecret { get; set; }
public bool TotpEnabled { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastLoginAt { get; set; }
}
public class AdminSessionRecord
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long Id { get; set; }
public long AdminUserId { get; set; }
[Required, MaxLength(64)]
public string TokenHash { get; set; } = "";
[MaxLength(64)]
public string? PreviousTokenHash { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
public DateTime ExpiresAt { get; set; }
public DateTime? RevokedAt { get; set; }
}
+7032
View File
@@ -0,0 +1,7032 @@
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Identity;
using System.Globalization;
using RadicadeServer.Auth;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.API.Notifications;
using RadicadeServer.Radicade.Discord;
using RadicadeServer.Radicade.Mail;
using RadicadeServer.API.Storefront;
using RadicadeServer.DB;
using RadicadeServer.RecNet;
using RadicadeServer.RecNet.Enums.Players.Avatar;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Accounts.Local;
using RadicadeServer.RecNet.Auth;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.RecNet.Board;
using RadicadeServer.RecNet.Enums.Messages;
using RadicadeServer.RecNet.Enums.Moderation;
using RadicadeServer.RecNet.Enums;
using RadicadeServer.RecNet.Enums.Players;
using RadicadeServer.RecNet.Enums.Rooms;
using RadicadeServer.RecNet.Config;
using RadicadeServer.RecNet.VersionCheck;
using RadicadeServer.RecNet.DB;
using RadicadeServer.RecNet.Enums.Store;
using RadicadeServer.RecNet.Maintenance;
using RadicadeServer.RecNet.Messages;
using RadicadeServer.RecNet.Moderation;
using RadicadeServer.RecNet.Notifications;
using RadicadeServer.RecNet.Progression;
using RadicadeServer.RecNet.Relationships;
using RadicadeServer.RecNet.Rooms;
using RadicadeServer.RecNet.Responses;
using RadicadeServer.RecNet.Store;
using RadicadeServer.RecNet.Storefront;
using RadicadeServer.API.Moderation;
using RadicadeServer.Clubs;
using RadicadeServer.Utils;
namespace RadicadeServer.Admin;
[Route("admin/panel")]
[EnableCors("AdminPanel")]
public class AdminPanelAPI(RecDBContext db) : Controller
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private static readonly object StateLock = new();
private static readonly PasswordHasher<PlayerData> PasswordHasher = new();
private const long RadicadeTeamAccountId = 1;
private static string StatePath => Path.Join(DBConfig.PersistentLogs, "admin-panel-state.json");
private static readonly SemaphoreSlim WeeklySaveGate = new(1);
private static List<Gift> WeeklyRewards()
{
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var equipment = JsonSerializer.Deserialize<List<Equipment>>(DBConfig.DefaultEquipmentItems, options) ?? [];
var avatars = JsonSerializer.Deserialize<List<AvatarItem>>(DBConfig.DefaultAvatarCatalog, options) ?? [];
return equipment.Where(x => !string.IsNullOrWhiteSpace(x.PrefabName) && !string.IsNullOrWhiteSpace(x.ModificationGuid))
.Select(x => new Gift { EquipmentPrefabName = x.PrefabName, EquipmentModificationGuid = x.ModificationGuid,
FriendlyName = x.FriendlyName, Tooltip = x.Tooltip, Rarity = x.Rarity, Context = GiftContext.All_Weekly_Challenge_Complete })
.Concat(avatars.Where(x => !string.IsNullOrWhiteSpace(x.AvatarItemDesc)).Select(x => new Gift {
AvatarItemDesc = x.AvatarItemDesc, AvatarItemType = (AvatarItemType)x.AvatarItemType,
FriendlyName = x.FriendlyName, Tooltip = x.Tooltip, Rarity = (Rarity)x.Rarity, Context = GiftContext.All_Weekly_Challenge_Complete }))
.DistinctBy(WeeklyRewardKey).ToList();
}
private static string WeeklyRewardKey(Gift gift) => !string.IsNullOrWhiteSpace(gift.AvatarItemDesc)
? "avatar:" + gift.AvatarItemDesc : "equipment:" + gift.EquipmentPrefabName + ":" + gift.EquipmentModificationGuid;
[HttpGet("weekly-challenges/rewards")]
public async Task<IActionResult> GetWeeklyRewards([FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
return Ok(WeeklyRewards().Select(g => new { key = WeeklyRewardKey(g),
kind = string.IsNullOrWhiteSpace(g.AvatarItemDesc) ? "equipment" : "avatar",
name = string.IsNullOrWhiteSpace(g.FriendlyName) ? WeeklyRewardKey(g) : g.FriendlyName,
detail = string.IsNullOrWhiteSpace(g.AvatarItemDesc) ? g.EquipmentPrefabName + " · " + g.EquipmentModificationGuid : g.AvatarItemDesc }));
}
[HttpGet("weekly-challenges")]
public async Task<IActionResult> GetWeeklySchedule([FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var weeks = new List<RadicadeServer.RecNet.Progression.Challenges.WeeklyChallengeMap>();
var now = DateTime.UtcNow;
for (var i = 0; i < 4; i++) weeks.Add(await WeeklyChallenges.GetDefinitionAsync(db, now.AddDays(i * 7)));
return Ok(new { weeks, current = weeks[0], next = weeks[1], required = WeeklyChallenges.Required });
}
[HttpPost("weekly-challenges")]
public async Task<IActionResult> SaveWeeklySchedule([FromQuery] string key, [FromBody] WeeklyScheduleRequest? request)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var current = WeeklyChallenges.CreateMap(DateTime.UtcNow);
if (request == null) return BadRequest("Choose a weekly schedule.");
var offset = request.ChallengeMapId - current.ChallengeMapId;
if (offset < 0 || offset > 3) return Conflict("The weekly schedule changed. Reload the page.");
var map = WeeklyChallenges.CreateMap(current.StartAt.AddDays(offset * 7));
if (request.ChallengeIds == null || request.ChallengeIds.Count is < 3 or > 5
|| request.ChallengeIds.Distinct().Count() != request.ChallengeIds.Count)
return BadRequest("Choose three to five different challenges.");
if (string.IsNullOrWhiteSpace(request.Title) || request.Title.Length > 100 || request.FallbackTokens is < 1 or > 10000)
return BadRequest("Enter a title (up to 100 characters) and 1–10,000 fallback tokens.");
var reward = WeeklyRewards().FirstOrDefault(x => WeeklyRewardKey(x) == request.RewardKey);
if (reward == null) return BadRequest("Choose an equipment skin or avatar item from the catalog.");
var library = LoadState().Challenges;
foreach (var id in request.ChallengeIds)
{
var row = library.FirstOrDefault(x => x["id"]?.ToString() == id.ToString(CultureInfo.InvariantCulture));
var config = row?["config"]?.ToString() ?? "";
if (row == null || id <= 0 || id > int.MaxValue || string.IsNullOrWhiteSpace(row["name"]?.ToString())
|| !WeeklyChallenges.IsSupportedDefinition(config))
return BadRequest($"Challenge {id} is missing or unsupported. Edit it using a gameplay preset.");
map.Challenges.Add(new() { ChallengeId = (int)id, Name = row["name"]!.ToString(),
Description = row["description"]?.ToString() ?? "", Tooltip = row["tooltip"]?.ToString() ?? "", Config = config });
}
map.Enabled = request.Enabled;
map.ChallengeThemeString = request.Title.Trim();
map.Gift = reward.ForClient();
map.FallbackTokens = request.FallbackTokens;
map.FallbackGiftName = $"{request.FallbackTokens} Tokens";
map.Revision = Guid.NewGuid().ToString("N");
await WeeklySaveGate.WaitAsync();
try
{
return await db.Database.CreateExecutionStrategy().ExecuteAsync<IActionResult>(async () =>
{
db.ChangeTracker.Clear();
await using var tx = db.Database.IsSqlServer()
? await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable) : null;
var scheduleKey = WeeklyChallenges.ScheduleKey(map.ChallengeMapId);
var query = db.Database.IsSqlServer()
? db.ServerConfig.FromSqlInterpolated($"SELECT * FROM [ServerConfig] WITH (UPDLOCK, HOLDLOCK) WHERE [Key] = {scheduleKey}")
: db.ServerConfig.Where(x => x.Key == scheduleKey);
var record = await query.SingleOrDefaultAsync();
var revision = record == null ? "" : JsonSerializer.Deserialize<RadicadeServer.RecNet.Progression.Challenges.WeeklyChallengeMap>(record.Value)!.Revision;
if (revision != request.Revision) return Conflict("This weekly was edited elsewhere. Reload before saving.");
if (record == null) db.ServerConfig.Add(new ServerConfigVal(scheduleKey, JsonSerializer.Serialize(map)));
else record.Value = JsonSerializer.Serialize(map);
await db.SaveChangesAsync();
if (tx != null) await tx.CommitAsync();
return Ok(map);
});
}
finally { WeeklySaveGate.Release(); }
}
private static AdminPanelState LoadState()
{
lock (StateLock)
{
if (!System.IO.File.Exists(StatePath))
return new AdminPanelState();
try
{
var json = System.IO.File.ReadAllText(StatePath);
return JsonSerializer.Deserialize<AdminPanelState>(json, JsonOptions) ?? new AdminPanelState();
}
catch
{
return new AdminPanelState();
}
}
}
private static void SaveState(AdminPanelState state)
{
lock (StateLock)
{
Directory.CreateDirectory(DBConfig.PersistentLogs);
System.IO.File.WriteAllText(StatePath, JsonSerializer.Serialize(state, JsonOptions));
}
}
public static void AddModerationReport(long reporterId, long reportedId, string reportMsg, string details, ReportCategory category, long roomId,
string? reportType = null, long? targetId = null, string? targetName = null)
{
lock (StateLock)
{
var state = LoadState();
long nextId = 1;
foreach (var existing in state.ModerationReports)
{
var rawId = existing["id"]?.ToString();
if (rawId != null && long.TryParse(rawId, out var parsedId) && parsedId >= nextId)
nextId = parsedId + 1;
}
var report = new JsonObject
{
["id"] = nextId,
["report_type"] = reportType ?? "Moderator",
["reporter_id"] = reporterId,
["reported_id"] = reportedId,
["reason"] = reportMsg,
["details"] = details,
["category"] = (int)category,
["room_id"] = roomId,
["status"] = "Pending",
["resolution_notes"] = "",
["created_at"] = DateTime.UtcNow.ToString("O")
};
if (targetId.HasValue)
report["target_id"] = targetId.Value;
if (!string.IsNullOrWhiteSpace(targetName))
report["target_name"] = targetName;
state.ModerationReports.Add(report);
SaveState(state);
}
}
private Task<IActionResult?> RejectInvalidKey(string key)
{
return Task.FromResult<IActionResult?>(key.IsAdminKey() ? null : Unauthorized("Invalid secret"));
}
/// The first of these that carries something: an item's own name beats nothing, and a
/// catalogue's name beats a raw descriptor used as one.
private static string NonEmpty(params string?[] values)
{
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value)) return value.Trim();
}
return "";
}
private static string RarityLabel(int rarity) => ((Rarity)rarity).ToString();
private static string ImageUrl(string? name)
{
if (string.IsNullOrWhiteSpace(name) ||
name.Equals("default", StringComparison.OrdinalIgnoreCase) ||
name.Equals("default.png", StringComparison.OrdinalIgnoreCase) ||
name.Equals(DBConfig.DefaultProfileImage, StringComparison.OrdinalIgnoreCase) ||
name.Equals(DBConfig.DefaultRoomImage, StringComparison.OrdinalIgnoreCase))
return $"{DBConfig.ServerUrl}/images/default";
return $"{DBConfig.ServerUrl}/images/img/{name}";
}
private async Task<List<object>> GetAdminUsers(string? eqCol, string? eqVal)
{
var query = db.AdminUsers.AsNoTracking();
if (eqCol == "user_id" && !string.IsNullOrWhiteSpace(eqVal))
query = query.Where(x => x.Username == eqVal);
var rows = await query.ToListAsync();
return rows.Select(u => new
{
id = u.Id,
user_id = u.Username,
username = u.Username,
created_at = u.CreatedAt,
last_login_at = u.LastLoginAt
}).Cast<object>().ToList();
}
[HttpGet("session")]
public async Task<IActionResult> Session([FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
if (Request.Cookies.TryGetValue(AdminSession.CookieName, out var token))
{
var (ok, userId) = await AdminSession.ValidateAsync(db, token);
if (ok)
{
var user = await db.AdminUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId);
if (user != null)
return Ok(new { user = new { id = user.Id, username = user.Username } });
}
}
return Ok(new { user = (object?)null });
}
[HttpGet("appeals")]
public async Task<IActionResult> ListAppeals([FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var appeals = await db.Moderation
.AsNoTracking()
.Where(x => x.AppealCode != null && x.AppealText != null)
.OrderByDescending(x => x.AppealSubmittedAt)
.Select(x => new
{
id = x.Id,
player_id = x.PlayerId,
player_name = db.Profiles.Where(p => p.accountId == x.PlayerId).Select(p => p.username).FirstOrDefault() ?? "unknown",
code = x.AppealCode,
reason = x.Message,
category = x.Category.ToString(),
permanent = x.ExpireDuration >= DateTime.MaxValue.AddYears(-10),
expires_at = x.ExpireDuration,
appeal_text = x.AppealText,
appeal_contact = x.AppealContact,
email = db.Players.Where(pl => pl.AccountId == x.PlayerId).Select(pl => pl.Email).FirstOrDefault() ?? "",
submitted_at = x.AppealSubmittedAt,
resolution = x.AppealResolution,
resolved_at = x.AppealResolvedAt,
ban_active = x.IsBan && x.ExpireDuration > DateTime.UtcNow
})
.ToListAsync();
return Ok(appeals);
}
[HttpPost("appeals/{appealId:long}/accept")]
public async Task<IActionResult> AcceptAppeal(long appealId, [FromQuery] string key, [FromQuery(Name = "send_email")] string? sendEmail = null, [FromQuery(Name = "custom_email")] string? customEmail = null)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var wantsEmail = sendEmail == "1" || string.Equals(sendEmail, "true", StringComparison.OrdinalIgnoreCase);
var ban = await db.Moderation.FirstOrDefaultAsync(x => x.Id == appealId && x.AppealCode != null && x.AppealText != null);
if (ban == null) return NotFound("Appeal not found");
ban.IsBan = false;
ban.AppealResolution = "Accepted";
ban.AppealResolvedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await NotifyLoginRefresh(ban.PlayerId);
if (wantsEmail)
await SendAppealEmailAsync(ban, accepted: true, customText: customEmail);
return Ok(new OkResponse { Success = true, Message = "Appeal accepted - ban lifted and player notified." });
}
[HttpPost("appeals/{appealId:long}/reject")]
public async Task<IActionResult> RejectAppeal(long appealId, [FromQuery] string key, [FromQuery(Name = "send_email")] string? sendEmail = null, [FromQuery(Name = "custom_email")] string? customEmail = null)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var wantsEmail = sendEmail == "1" || string.Equals(sendEmail, "true", StringComparison.OrdinalIgnoreCase);
var ban = await db.Moderation.FirstOrDefaultAsync(x => x.Id == appealId && x.AppealCode != null && x.AppealText != null);
if (ban == null) return NotFound("Appeal not found");
ban.AppealResolution = "Rejected";
ban.AppealResolvedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
if (wantsEmail)
await SendAppealEmailAsync(ban, accepted: false, customText: customEmail);
return Ok(new OkResponse { Success = true, Message = "Appeal rejected - ban remains active." });
}
[HttpPost("appeals/{appealId:long}/reduce")]
public async Task<IActionResult> ReduceAppealBanTime(long appealId, [FromQuery] string key,
[FromQuery(Name = "send_email")] string? sendEmail = null, [FromQuery(Name = "custom_email")] string? customEmail = null, [FromQuery] int minutes = 0)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
if (minutes <= 0)
return BadRequest(new OkResponse { Success = false, Message = "Enter how many minutes to reduce the ban by." });
var ban = await db.Moderation.FirstOrDefaultAsync(x => x.Id == appealId && x.AppealCode != null && x.AppealText != null);
if (ban == null) return NotFound("Appeal not found");
if (!ban.IsBan) return BadRequest(new OkResponse { Success = false, Message = "This ban is no longer active." });
var now = DateTime.UtcNow;
var reduction = TimeSpan.FromMinutes(minutes);
if (ban.ExpireDuration >= DateTime.MaxValue.AddYears(-10))
{
ban.ExpireDuration = now.Add(reduction);
}
else
{
var target = ban.ExpireDuration.Subtract(reduction);
ban.ExpireDuration = target < now.AddMinutes(1) ? now.AddMinutes(1) : target;
}
ban.AppealResolution = "Reduced";
ban.AppealResolvedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await NotifyLoginRefresh(ban.PlayerId);
var wantsEmail = sendEmail == "1" || string.Equals(sendEmail, "true", StringComparison.OrdinalIgnoreCase);
if (wantsEmail)
await SendAppealTimeChangeEmailAsync(ban, reduced: true, customText: customEmail);
return Ok(new OkResponse { Success = true, Message = "Ban time reduced - player notified." });
}
[HttpPost("appeals/{appealId:long}/extend")]
public async Task<IActionResult> ExtendAppealBanTime(long appealId, [FromQuery] string key,
[FromQuery(Name = "send_email")] string? sendEmail = null, [FromQuery(Name = "custom_email")] string? customEmail = null, [FromQuery] int minutes = 0)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
if (minutes <= 0)
return BadRequest(new OkResponse { Success = false, Message = "Enter how many minutes to extend the ban by." });
var ban = await db.Moderation.FirstOrDefaultAsync(x => x.Id == appealId && x.AppealCode != null && x.AppealText != null);
if (ban == null) return NotFound("Appeal not found");
if (!ban.IsBan) return BadRequest(new OkResponse { Success = false, Message = "This ban is no longer active." });
if (ban.ExpireDuration >= DateTime.MaxValue.AddYears(-10))
return BadRequest(new OkResponse { Success = false, Message = "This ban is permanent and cannot be extended." });
ban.ExpireDuration = ban.ExpireDuration.AddMinutes(minutes);
ban.AppealResolution = "Extended";
ban.AppealResolvedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await NotifyLoginRefresh(ban.PlayerId);
var wantsEmail = sendEmail == "1" || string.Equals(sendEmail, "true", StringComparison.OrdinalIgnoreCase);
if (wantsEmail)
await SendAppealTimeChangeEmailAsync(ban, reduced: false, customText: customEmail);
return Ok(new OkResponse { Success = true, Message = "Ban time extended - player notified." });
}
private async Task NotifyLoginRefresh(long playerId)
{
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
if (profile != null)
{
await Notify.SendNotify(playerId, new Notification { Id = NotificationType.RefreshLogin, Msg = profile });
}
}
private async Task SendAppealEmailAsync(RecNet.Moderation.ModerationDB ban, bool accepted, string? customText = null)
{
if (string.IsNullOrWhiteSpace(DBConfig.MailApiKey))
{
return;
}
var player = await db.Players.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == ban.PlayerId);
var email = player?.Email;
if (string.IsNullOrWhiteSpace(email) || !email.Contains('@')) return;
var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == ban.PlayerId);
var name = string.IsNullOrWhiteSpace(profile?.username) ? "there" : profile.username;
var reason = string.IsNullOrWhiteSpace(ban.Message) ? "the listed reason" : ban.Message;
if (!string.IsNullOrWhiteSpace(customText))
{
var subject = accepted ? "Your Radicade ban appeal was approved" : "Your Radicade ban appeal was rejected";
await RadicadeMail.SendAsync(email, subject, BuildCustomEmailHtml(subject, customText));
return;
}
if (accepted)
{
var html =
$"<div style=\"font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:24px;\">" +
$"<img src=\"https://radicade.app/assests/RadicadeHorizontal.png\" alt=\"Radicade\" style=\"display:block;max-width:220px;height:auto;margin:0 auto 22px;\" />" +
$"<h2 style=\"color:#1a1a2e;margin-top:0;\">Your Radicade ban appeal was approved</h2>" +
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Hi {HtmlEncode(name)},</p>" +
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Your appeal against the ban for <b>{HtmlEncode(reason)}</b> has been <b>accepted</b>. The ban has been lifted and you can log back into Radicade.</p>" +
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Thanks for reaching out - enjoy the game!</p>" +
$"<p style=\"color:#999;font-size:12px;margin-top:24px;\">- Radicade Moderation</p></div>";
await RadicadeMail.SendAsync(email, "Your Radicade ban appeal was approved", html);
}
else
{
var html =
$"<div style=\"font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:24px;\">" +
$"<img src=\"https://radicade.app/assests/RadicadeHorizontal.png\" alt=\"Radicade\" style=\"display:block;max-width:220px;height:auto;margin:0 auto 22px;\" />" +
$"<h2 style=\"color:#1a1a2e;margin-top:0;\">Your Radicade ban appeal was rejected</h2>" +
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Hi {HtmlEncode(name)},</p>" +
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">We reviewed your appeal against the ban for <b>{HtmlEncode(reason)}</b> and it was <b>rejected</b>. The ban remains active.</p>" +
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">If you believe this decision was a mistake, you may submit another appeal once the cooldown period has passed.</p>" +
"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Take this time to review our Code Of Conduct:</p>" +
"<p style=\"text-align:center;margin:24px 0;\"><a href=\"https://radicade.app/code-of-conduct\" style=\"background:#4a6cf7;color:#fff;text-decoration:none;padding:12px 28px;border-radius:6px;display:inline-block;\">Code Of Conduct</a></p>" +
$"<p style=\"color:#999;font-size:12px;margin-top:24px;\">- Radicade Moderation</p></div>";
await RadicadeMail.SendAsync(email, "Your Radicade ban appeal was rejected", html);
}
}
private async Task SendAppealTimeChangeEmailAsync(RecNet.Moderation.ModerationDB ban, bool reduced, string? customText = null)
{
if (string.IsNullOrWhiteSpace(DBConfig.MailApiKey))
{
return;
}
var player = await db.Players.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == ban.PlayerId);
var email = player?.Email;
if (string.IsNullOrWhiteSpace(email) || !email.Contains('@')) return;
var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == ban.PlayerId);
var name = string.IsNullOrWhiteSpace(profile?.username) ? "there" : profile.username;
var reason = string.IsNullOrWhiteSpace(ban.Message) ? "the listed reason" : ban.Message;
var action = reduced ? "reduced" : "extended";
if (!string.IsNullOrWhiteSpace(customText))
{
var subject = $"Your Radicade ban time was {action}";
await RadicadeMail.SendAsync(email, subject, BuildCustomEmailHtml(subject, customText));
return;
}
var permanent = ban.ExpireDuration >= DateTime.MaxValue.AddYears(-10);
var when = permanent
? "permanent"
: DateTime.SpecifyKind(ban.ExpireDuration, DateTimeKind.Utc).ToString("MMM d, yyyy 'at' h:mm tt 'UTC'");
var cocCta = reduced
? ""
: "<p style=\"color:#444;font-size:15px;line-height:1.6;\">Take this time to review our Code Of Conduct:</p>" +
"<p style=\"text-align:center;margin:24px 0;\"><a href=\"https://radicade.app/code-of-conduct\" style=\"background:#4a6cf7;color:#fff;text-decoration:none;padding:12px 28px;border-radius:6px;display:inline-block;\">Code Of Conduct</a></p>";
var html =
$"<div style=\"font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:24px;\">" +
$"<img src=\"https://radicade.app/assests/RadicadeHorizontal.png\" alt=\"Radicade\" style=\"display:block;max-width:220px;height:auto;margin:0 auto 22px;\" />" +
$"<h2 style=\"color:#1a1a2e;margin-top:0;\">Your Radicade ban time was {action}</h2>" +
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Hi {HtmlEncode(name)},</p>" +
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">As part of your appeal, the ban for <b>{HtmlEncode(reason)}</b> has been <b>{action}</b>. It now ends <b>{when}</b>.</p>" +
cocCta +
$"<p style=\"color:#999;font-size:12px;margin-top:24px;\">- Radicade Moderation</p></div>";
await RadicadeMail.SendAsync(email, $"Your Radicade ban time was {action}", html);
}
private static string BuildCustomEmailHtml(string title, string customText)
{
var body = HtmlEncode(customText).Replace("\r", "").Replace("\n", "<br />");
return
$"<div style=\"font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:24px;\">" +
$"<img src=\"https://radicade.app/assests/RadicadeHorizontal.png\" alt=\"Radicade\" style=\"display:block;max-width:220px;height:auto;margin:0 auto 22px;\" />" +
$"<h2 style=\"color:#1a1a2e;margin-top:0;\">{HtmlEncode(title)}</h2>" +
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">{body}</p>" +
$"<p style=\"color:#999;font-size:12px;margin-top:24px;\">- Radicade Moderation</p></div>";
}
private static string HtmlEncode(string value)
{
return System.Net.WebUtility.HtmlEncode(value);
}
private static readonly Dictionary<string, RoleType> RoomRoleNames = new(StringComparer.OrdinalIgnoreCase)
{
["none"] = RoleType.None,
["banned"] = RoleType.Banned,
["host"] = RoleType.Host,
["moderator"] = RoleType.Moderator,
["co-owner"] = RoleType.CoOwner,
["coowner"] = RoleType.CoOwner,
["temporarycoowner"] = RoleType.TemporaryCoOwner,
["creator"] = RoleType.Creator
};
private static string RoomRoleKey(RoleType role) => role switch
{
RoleType.Creator => "creator",
RoleType.CoOwner => "co-owner",
RoleType.TemporaryCoOwner => "co-owner",
RoleType.Moderator => "moderator",
RoleType.Host => "host",
_ => "none"
};
private static bool TryParseRoomRole(string? raw, out RoleType role)
{
role = RoleType.None;
if (string.IsNullOrWhiteSpace(raw)) return false;
if (RoomRoleNames.TryGetValue(raw.Trim(), out role)) return true;
if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric) && Enum.IsDefined(typeof(RoleType), numeric))
{
role = (RoleType)numeric;
return true;
}
return Enum.TryParse(raw, true, out role);
}
private static bool TryParseRoomAccessibility(string? raw, out Accessibility value)
{
value = Accessibility.Public;
if (string.IsNullOrWhiteSpace(raw)) return false;
if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric))
{
if (!Enum.IsDefined(typeof(Accessibility), numeric)) return false;
value = (Accessibility)numeric;
return true;
}
return Enum.TryParse(raw, true, out value) && Enum.IsDefined(typeof(Accessibility), value);
}
private static Accessibility ParseRoomAccessibility(string? raw)
=> TryParseRoomAccessibility(raw, out var parsed) ? parsed : Accessibility.Public;
[HttpGet("rooms/{roomId:long}")]
public async Task<IActionResult> RoomPanelDetails(long roomId, [FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return NotFound("Room does not exist.");
var stats = await db.RoomStats.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == roomId);
var roleRows = await db.RoomRoles.AsNoTracking().Where(x => x.RoomId == roomId).ToListAsync();
var roleAccountIds = roleRows.Select(r => r.AccountId).Append(room.CreatorAccountId).Distinct().ToList();
var roleProfiles = await db.Profiles.AsNoTracking().Where(x => roleAccountIds.Contains(x.accountId)).ToDictionaryAsync(x => x.accountId);
object RoleRow(long accountId, RoleType role)
{
roleProfiles.TryGetValue(accountId, out var member);
var handle = member?.username ?? $"account_{accountId}";
return new
{
account_id = accountId,
username = handle,
display_name = string.IsNullOrWhiteSpace(member?.displayName) ? handle : member!.displayName,
profile_image = ImageUrl(member?.profileImage),
role = RoomRoleKey(role),
role_key = role.ToString(),
role_value = (int)role,
is_creator = accountId == room.CreatorAccountId
};
}
var members = new List<object>();
var seenAccounts = new HashSet<long>();
members.Add(RoleRow(room.CreatorAccountId, RoleType.Creator));
seenAccounts.Add(room.CreatorAccountId);
foreach (var row in roleRows.OrderBy(r => (int)r.Role).ThenBy(r => r.AccountId))
{
if (!seenAccounts.Add(row.AccountId)) continue;
members.Add(RoleRow(row.AccountId, row.Role));
}
var subRows = await db.SubRooms.AsNoTracking().Where(x => x.RoomId == roomId).OrderBy(x => x.SubRoomId).ToListAsync();
var subrooms = subRows.Select((sub, index) => (object)new
{
id = sub.SubRoomId,
name = sub.Name,
list = index + 1,
maxPlayers = sub.MaxPlayers,
max_players = sub.MaxPlayers,
accessibility = sub.Accessibility.ToString().ToLowerInvariant(),
sandbox = sub.IsSandbox,
save = sub.DataBlob ?? "",
unity_scene_id = sub.UnitySceneId,
created_at = DateTime.SpecifyKind(sub.CreatedAt, DateTimeKind.Utc)
}).ToList();
var saves = new List<object>();
try
{
if (Directory.Exists(DBConfig.CDNStorage))
{
saves = Directory.GetFiles(DBConfig.CDNStorage, "*.room")
.OrderByDescending(f => System.IO.File.GetLastWriteTimeUtc(f))
.Select(f => (object)new { id = Path.GetFileName(f), label = Path.GetFileName(f) })
.ToList();
}
}
catch
{
saves = new List<object>();
}
return Ok(new
{
room = new
{
id = room.RoomId,
name = room.Name,
description = room.Description,
image_name = room.ImageName,
custom_warning = room.CustomWarning,
creator_account_id = room.CreatorAccountId,
publish_state = room.State.ToString(),
accessibility = room.Accessibility.ToString(),
tags = room.Tags,
is_dorm = room.IsDorm,
is_rro = room.IsRRO,
cloning_allowed = room.CloningAllowed,
disable_mic_auto_mute = room.DisableMicAutoMute,
disable_room_comments = room.DisableRoomComments,
encrypt_voice_chat = room.EncryptVoiceChat,
supports_screens = room.SupportsScreens,
supports_walk_vr = room.SupportsWalkVR,
supports_teleport_vr = room.SupportsTeleportVR,
supports_quest2 = room.SupportsQuest2,
supports_mobile = room.SupportsMobile,
supports_juniors = room.SupportsJuniors,
min_level = room.MinLevel,
max_players = room.MaxPlayers,
max_player_calculation_mode = (int)room.MaxPlayerCalculationMode,
created_at = DateTime.SpecifyKind(room.CreatedAt, DateTimeKind.Utc),
published_at = room.PublishedAt == default ? (DateTime?)null : DateTime.SpecifyKind(room.PublishedAt, DateTimeKind.Utc),
featured = SafeRoomTags(room.Tags).Any(t => string.Equals(t, "featured", StringComparison.OrdinalIgnoreCase)),
cheer_count = stats?.CheerCount ?? 0,
favorite_count = stats?.FavoriteCount ?? 0,
visitor_count = stats?.VisitorCount ?? 0,
visit_count = stats?.VisitCount ?? 0,
join_gift_enabled = room.JoinGiftEnabled,
join_gift_once_per_player = room.JoinGiftOncePerPlayer,
join_gift_version = room.JoinGiftVersion,
join_gift_title = room.JoinGiftTitle,
join_gift_message = room.JoinGiftMessage,
join_gift_avatar_item = room.JoinGiftAvatarItem,
join_gift_equipment_item = room.JoinGiftEquipmentItem,
join_gift_consumable_item = room.JoinGiftConsumableItem,
join_gift_currency_amount = room.JoinGiftCurrencyAmount,
join_gift_currency_type = room.JoinGiftCurrencyType.ToString(),
join_gift_xp = room.JoinGiftXp,
join_gift_context = room.JoinGiftContext.ToString(),
join_gift_rarity = room.JoinGiftRarity.ToString(),
join_gift_avatar_item_type = room.JoinGiftAvatarItemType.ToString(),
join_gift_equipment_modification_guid = room.JoinGiftEquipmentModificationGuid,
// Digits in a string: an account id is eighteen digits and a JSON number would
// arrive in the panel rounded, so a saved id could name the wrong account.
join_gift_from_player_id = Math.Max(0, room.JoinGiftFromPlayerId).ToString(CultureInfo.InvariantCulture),
join_gifts = JoinGiftRows(room, await RoomJoinGiftUtils.ConfiguredGiftsAsync(db, room.RoomId))
},
roles = members,
subrooms,
saves
});
}
[HttpPost("rooms/{roomId:long}/name")]
public async Task<IActionResult> RenameRoom(long roomId, [FromQuery] string key, [FromQuery] string? name)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var next = (name ?? "").Trim();
if (string.IsNullOrWhiteSpace(next)) return BadRequest("A room has to have a name.");
if (next.Length > 64) return BadRequest("Room names are limited to 64 characters.");
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return NotFound("Room does not exist.");
var previous = room.Name;
room.Name = next;
await db.SaveChangesAsync();
AdminActionDiscord.LogAction("Rename Room", await GetPanelAdminUsernameAsync(), $"Renamed room {roomId} from \"{previous}\" to \"{next}\".");
return Ok(new { success = true, room_id = roomId, name = room.Name });
}
[HttpPost("rooms/{roomId:long}/roles")]
public async Task<IActionResult> SetRoomRole(long roomId, [FromQuery] string key, [FromQuery] long accountId, [FromQuery] string? role)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return NotFound("Room does not exist.");
if (!TryParseRoomRole(role, out var nextRole)) return BadRequest("Unknown room role.");
if (accountId == room.CreatorAccountId && nextRole != RoleType.Creator)
return BadRequest("The room's creator keeps the Creator role.");
var existing = await db.RoomRoles.FirstOrDefaultAsync(x => x.RoomId == roomId && x.AccountId == accountId);
if (existing == null)
{
db.RoomRoles.Add(new RoomRole
{
RoomId = roomId,
AccountId = accountId,
Role = nextRole,
InvitedRole = RoleType.None
});
}
else
{
if (existing.Role == nextRole) return Ok(new { success = true, room_id = roomId, account_id = accountId, role = RoomRoleKey(nextRole) });
existing.Role = nextRole;
}
await db.SaveChangesAsync();
try
{
var updatedRoom = await RoomUtils.GetRoom(roomId);
if (updatedRoom != null)
await Notify.SendNotify(accountId, new Notification { Id = "RoomUpdate", Msg = updatedRoom });
}
catch (Exception) { }
AdminActionDiscord.LogAction("Room Role", await GetPanelAdminUsernameAsync(),
$"Set account {accountId} to {nextRole} on room {roomId} ({room.Name}).");
return Ok(new { success = true, room_id = roomId, account_id = accountId, role = RoomRoleKey(nextRole) });
}
[HttpPost("rooms/{roomId:long}/subrooms/add")]
public async Task<IActionResult> AddSubroom(long roomId, [FromQuery] string key, [FromQuery] string? name,
[FromQuery] int maxPlayers = 0, [FromQuery] string? accessibility = null, [FromQuery] string? sandbox = null, [FromQuery] string? saveId = null)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return NotFound("Room does not exist.");
var subName = (name ?? "").Trim();
if (string.IsNullOrWhiteSpace(subName)) return BadRequest("A new subroom needs a name.");
var sub = new SubRoom
{
RoomId = roomId,
Name = subName,
MaxPlayers = maxPlayers > 0 ? maxPlayers : 20,
Accessibility = ParseRoomAccessibility(accessibility),
IsSandbox = string.Equals(sandbox, "true", StringComparison.OrdinalIgnoreCase) || sandbox == "1",
DataBlob = saveId ?? "",
SavedByAccountId = room.CreatorAccountId,
CreatedAt = DateTime.UtcNow
};
db.SubRooms.Add(sub);
await db.SaveChangesAsync();
var index = await db.SubRooms.CountAsync(x => x.RoomId == roomId && x.SubRoomId <= sub.SubRoomId);
AdminActionDiscord.LogAction("Add Subroom", await GetPanelAdminUsernameAsync(),
$"Added subroom \"{sub.Name}\" to room {roomId} ({room.Name}).");
return Ok(new { success = true, subroomId = sub.SubRoomId, subroomList = index, maxPlayers = sub.MaxPlayers });
}
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
public async Task<IActionResult> RenameSubroom(long roomId, long subRoomId, [FromQuery] string key, [FromQuery] string? name)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var next = (name ?? "").Trim();
if (string.IsNullOrWhiteSpace(next)) return BadRequest("A subroom has to have a name.");
var sub = await db.SubRooms.FirstOrDefaultAsync(x => x.SubRoomId == subRoomId && x.RoomId == roomId);
if (sub == null) return NotFound("Subroom does not exist.");
sub.Name = next;
await db.SaveChangesAsync();
AdminActionDiscord.LogAction("Rename Subroom", await GetPanelAdminUsernameAsync(), $"Renamed subroom {subRoomId} on room {roomId} to \"{next}\".");
return Ok(new { success = true, subroomId = sub.SubRoomId, name = sub.Name });
}
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/delete")]
public async Task<IActionResult> DeleteSubroom(long roomId, long subRoomId, [FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var sub = await db.SubRooms.FirstOrDefaultAsync(x => x.SubRoomId == subRoomId && x.RoomId == roomId);
if (sub == null) return NotFound("Subroom does not exist.");
var permissions = await db.SubRoomPermissions.Where(x => x.subRoomId == subRoomId).ToListAsync();
if (permissions.Count > 0) db.SubRoomPermissions.RemoveRange(permissions);
db.SubRooms.Remove(sub);
await db.SaveChangesAsync();
AdminActionDiscord.LogAction("Delete Subroom", await GetPanelAdminUsernameAsync(), $"Deleted subroom \"{sub.Name}\" ({subRoomId}) from room {roomId}.");
return Ok(new { success = true, subroomId = subRoomId });
}
[HttpPost("rooms/{roomId:long}/saves")]
public async Task<IActionResult> UploadRoomSave(long roomId, [FromQuery] string key, [FromForm] List<IFormFile> file)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return NotFound("Room does not exist.");
var chosen = file?.FirstOrDefault(f => f.Length > 0 && Path.GetExtension(f.FileName).Equals(".room", StringComparison.OrdinalIgnoreCase))
?? file?.FirstOrDefault(f => f.Length > 0);
if (chosen == null) return BadRequest("No .room save was uploaded.");
Directory.CreateDirectory(DBConfig.CDNStorage);
var stored = Guid.NewGuid().ToString("N") + Path.GetExtension(chosen.FileName).ToLowerInvariant();
using (var stream = new FileStream(Path.Join(DBConfig.CDNStorage, stored), FileMode.Create))
{
await chosen.CopyToAsync(stream);
}
AdminActionDiscord.LogAction("Upload Room Save", await GetPanelAdminUsernameAsync(),
$"Uploaded save \"{chosen.FileName}\" ({stored}) for room {roomId} ({room.Name}).");
return Ok(new { success = true, saveId = stored, fileName = chosen.FileName });
}
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/save")]
public async Task<IActionResult> SetSubroomSave(long roomId, long subRoomId, [FromQuery] string key,
[FromQuery] string? saveId, [FromForm] List<IFormFile>? file)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var sub = await db.SubRooms.FirstOrDefaultAsync(x => x.SubRoomId == subRoomId && x.RoomId == roomId);
if (sub == null) return NotFound("Subroom does not exist.");
var chosen = file?.FirstOrDefault(f => f.Length > 0 && Path.GetExtension(f.FileName).Equals(".room", StringComparison.OrdinalIgnoreCase))
?? file?.FirstOrDefault(f => f.Length > 0);
string stored;
if (chosen != null)
{
Directory.CreateDirectory(DBConfig.CDNStorage);
stored = Guid.NewGuid().ToString("N") + Path.GetExtension(chosen.FileName).ToLowerInvariant();
using var stream = new FileStream(Path.Join(DBConfig.CDNStorage, stored), FileMode.Create);
await chosen.CopyToAsync(stream);
}
else
{
stored = (saveId ?? "").Trim();
stored = Path.GetFileName(stored);
if (!string.IsNullOrEmpty(stored) && !System.IO.File.Exists(Path.Join(DBConfig.CDNStorage, stored)))
return BadRequest($"No uploaded save named '{stored}'.");
}
sub.DataBlob = stored;
await db.SaveChangesAsync();
AdminActionDiscord.LogAction("Set Subroom Save", await GetPanelAdminUsernameAsync(),
$"Pointed subroom \"{sub.Name}\" ({subRoomId}) on room {roomId} at save \"{(string.IsNullOrEmpty(stored) ? "none" : stored)}\".");
return Ok(new { success = true, subroomId = sub.SubRoomId, saveId = stored });
}
[HttpPost("players/{playerId:long}/{kind}")]
public Task<IActionResult> PlayerPanelAction(long playerId, string kind, [FromQuery] string key)
=> ApplyPlayerPanelAction(playerId, kind, key);
[HttpPost("players/{playerId:long}/{kind}/{sub}")]
public Task<IActionResult> PlayerPanelSubAction(long playerId, string kind, string sub, [FromQuery] string key)
=> ApplyPlayerPanelAction(playerId, $"{kind}/{sub}", key);
/// Take one saved photo off an account's gallery: the row that indexes it, the cheers
/// that point at it, and the file itself. A player can do this to their own gallery in
/// game (ImagesAPI's deletesaved), so the panel does it the same way rather than
/// leaving the file behind.
[HttpDelete("players/{playerId:long}/photos/{imageId:long}")]
public async Task<IActionResult> DeletePlayerPhoto(long playerId, long imageId, [FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var image = await db.Images.FirstOrDefaultAsync(x => x.Id == imageId && x.OwnerId == playerId);
if (image == null) return NotFound("That photo is not on this account's gallery.");
var cheers = db.ImageInteractions.Where(x => x.SavedImageId == image.Id);
db.ImageInteractions.RemoveRange(cheers);
db.Images.Remove(image);
await db.SaveChangesAsync();
var fullPath = Path.Join(DBConfig.CDNImages, image.Name);
if (System.IO.File.Exists(fullPath))
System.IO.File.Delete(fullPath);
AdminActionDiscord.LogAction("Delete Photo", await GetPanelAdminUsernameAsync(),
$"Deleted photo \"{image.Name}\" ({imageId}) from player {playerId}'s gallery.");
return Ok(new { success = true, player_id = playerId, image_id = imageId, name = image.Name });
}
/// Take one invention off the account that created it, along with the versions it was
/// saved in and every player's personal details about it. Requests and other accounts'
/// downloads of it stop resolving on the next read.
[HttpDelete("players/{playerId:long}/inventions/{inventionId:long}")]
public async Task<IActionResult> DeletePlayerInvention(long playerId, long inventionId, [FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var invention = await db.Inventions.FirstOrDefaultAsync(x => x.InventionId == inventionId && x.CreatorPlayerId == playerId);
if (invention == null) return NotFound("That invention does not belong to this account.");
var versions = await db.InventionVersions.Where(x => x.InventionId == inventionId).ToListAsync();
var details = await db.InventionPersonalDetails.Where(x => x.InventionId == inventionId).ToListAsync();
db.InventionVersions.RemoveRange(versions);
db.InventionPersonalDetails.RemoveRange(details);
db.Inventions.Remove(invention);
await db.SaveChangesAsync();
AdminActionDiscord.LogAction("Delete Invention", await GetPanelAdminUsernameAsync(),
$"Deleted invention \"{invention.Name}\" ({inventionId}) created by player {playerId}, with {versions.Count} saved version(s).");
return Ok(new { success = true, player_id = playerId, invention_id = inventionId, name = invention.Name });
}
private async Task<IActionResult> ApplyPlayerPanelAction(long playerId, string kind, string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == playerId);
if (profile == null) return NotFound("Player does not exist.");
var display = string.IsNullOrWhiteSpace(profile.displayName) ? profile.username : profile.displayName;
string? Q(string name)
{
var raw = Request.Query[name].ToString();
return string.IsNullOrEmpty(raw) ? null : raw;
}
bool QBool(string name, bool fallback = false)
{
var raw = Q(name);
if (raw == null) return fallback;
return raw.Equals("true", StringComparison.OrdinalIgnoreCase) || raw == "1";
}
int QInt(string name, int fallback = 0) => int.TryParse(Q(name), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback;
var payload = new Dictionary<string, object?>();
var audit = "";
switch (kind)
{
case "ban":
{
var reason = (Q("reason") ?? "").Trim();
var seconds = QInt("durationSeconds", -1);
payload["banned"] = true;
payload["ban_reason"] = reason;
payload["ban_method"] = Q("banMethod") ?? "Admin Panel";
var moderatorNote = (Q("moderatorNote") ?? "").Trim();
if (!string.IsNullOrWhiteSpace(moderatorNote)) payload["moderator_note"] = moderatorNote;
payload["ban_until"] = seconds <= 0
? "permanent"
: DateTime.UtcNow.AddSeconds(seconds).ToString("O", CultureInfo.InvariantCulture);
audit = seconds <= 0
? $"Banned player {playerId} ({display}) permanently - {reason}"
: $"Banned player {playerId} ({display}) for {seconds} second(s) - {reason}";
break;
}
case "unban":
payload["banned"] = false;
audit = $"Lifted player {playerId} ({display})'s ban";
break;
case "coach":
{
var message = (Q("message") ?? "").Trim();
if (string.IsNullOrWhiteSpace(message)) return BadRequest("A coach message cannot be empty.");
await Notify.SendMessage(playerId, new Message
{
Type = MessageType.CoachMessage,
Data = message,
FromPlayerId = 1
});
AdminActionDiscord.LogAction("Coach Message", await GetPanelAdminUsernameAsync(), $"Sent a coach message to player {playerId} ({display}): {message}");
return Ok(new { success = true, player_id = playerId, message });
}
case "roles/add":
{
var added = (Q("roles") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (added.Length == 0) return BadRequest("Pick at least one role to add.");
var merged = new List<string>(SplitRoles(profile.role));
foreach (var role in added)
{
if (!merged.Contains(role, StringComparer.OrdinalIgnoreCase)) merged.Add(role);
}
payload["roles"] = string.Join(",", merged);
audit = $"Added role(s) {string.Join(",", added)} to player {playerId} ({display})";
break;
}
case "roles/clear":
payload["roles"] = "";
audit = $"Cleared player {playerId} ({display})'s in-game roles";
break;
case "lockname":
payload["name_locked"] = QBool("locked");
audit = $"{(QBool("locked") ? "Locked" : "Unlocked")} player {playerId} ({display})'s name";
break;
case "lockbio":
payload["bio_locked"] = QBool("locked");
audit = $"{(QBool("locked") ? "Locked" : "Unlocked")} player {playerId} ({display})'s bio";
break;
case "junior":
payload["is_junior"] = QBool("junior");
audit = $"Marked player {playerId} ({display}) as {(QBool("junior") ? "a Junior account" : "no longer Junior")}";
break;
case "resetpassword":
{
var password = Q("password") ?? "";
if (password.Length < 8) return BadRequest("A reset password needs at least 8 characters.");
payload["password"] = password;
audit = $"Reset player {playerId} ({display})'s password";
break;
}
case "username":
{
var username = (Q("username") ?? "").Trim();
if (string.IsNullOrWhiteSpace(username)) return BadRequest("Enter the new username.");
if (username.Length < 3 || username.Length > 32) return BadRequest("Usernames are 3-32 characters.");
if (await db.Profiles.AnyAsync(x => x.accountId != playerId && x.username == username))
return BadRequest($"The username '{username}' is already taken.");
payload["username"] = username;
audit = $"Renamed player {playerId} ({display}) to {username}";
break;
}
case "reputation":
payload["reputation"] = QInt("reputation");
payload["noteriety"] = QInt("notoriety");
audit = $"Saved player {playerId} ({display})'s reputation";
break;
case "cheers":
payload["cheer_general"] = QInt("general");
payload["cheer_helpful"] = QInt("helpful");
payload["cheer_great_host"] = QInt("greatHost");
payload["cheer_sportsman"] = QInt("sportsman");
payload["cheer_creative"] = QInt("creative");
payload["cheer_credit"] = QInt("credit");
audit = $"Saved player {playerId} ({display})'s cheers";
break;
case "progression":
payload["level"] = QInt("level", 1);
payload["xp"] = QInt("xp");
audit = $"Set player {playerId} ({display}) to level {QInt("level", 1)} / {QInt("xp")} XP";
break;
case "notes":
{
var text = (Q("note") ?? "").Trim();
if (string.IsNullOrWhiteSpace(text)) return BadRequest("A note cannot be empty.");
var state = LoadState();
long nextId = 1;
foreach (var existing in state.Notes)
{
if (long.TryParse(existing["id"]?.ToString(), out var parsedId) && parsedId >= nextId)
nextId = parsedId + 1;
}
var note = new JsonObject
{
["id"] = nextId,
["player_id"] = playerId,
["text"] = text,
["author"] = await GetPanelAdminUsernameAsync(),
["created_at"] = DateTime.UtcNow.ToString("O")
};
state.Notes.Add(note);
SaveState(state);
return Ok(new { success = true, player_id = playerId, note = note });
}
case "settings":
{
var settingKey = (Q("setting_key") ?? "").Trim();
if (string.IsNullOrWhiteSpace(settingKey)) return BadRequest("A setting needs a key.");
return await ApplyPlayerSetting(playerId, new Dictionary<string, string?> { [settingKey] = Q("value") ?? "" });
}
case "settings/delete":
{
var settingKey = (Q("setting_key") ?? "").Trim();
if (string.IsNullOrWhiteSpace(settingKey)) return BadRequest("A setting needs a key.");
return await ApplyPlayerSetting(playerId, new Dictionary<string, string?> { [settingKey] = null });
}
case "settings/reset":
await ApplyPlayerSetting(playerId, null);
AdminActionDiscord.LogAction("Reset Settings", await GetPanelAdminUsernameAsync(), $"Reset every in-game setting for player {playerId} ({display}).");
return Ok(new { success = true, player_id = playerId });
case "settings/birthday":
return await ApplyPlayerSetting(playerId, new Dictionary<string, string?> { ["birthday"] = (Q("birthday") ?? "").Trim() });
case "settings/profilepicture":
{
var owner = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
if (owner == null) return NotFound("Player does not exist.");
owner.profileImage = DBConfig.DefaultProfileImage;
await db.SaveChangesAsync();
AdminActionDiscord.LogAction("Reset Profile Picture", await GetPanelAdminUsernameAsync(), $"Reset player {playerId} ({display})'s profile picture.");
return Ok(new { success = true, player_id = playerId });
}
case "items/revoke":
{
var itemKind = (Q("itemKind") ?? "").Trim().ToLowerInvariant();
var itemId = (Q("itemId") ?? "").Trim();
var modGuid = (Q("modGuid") ?? "").Trim();
if (itemId.Length == 0) return BadRequest("An item to revoke is required.");
var unlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == playerId);
if (unlocks == null) return NotFound("This account has no locker to take an item from.");
int removed;
string removedName;
switch (itemKind)
{
case "avatar":
{
var items = unlocks.GetAvatarItems();
removedName = items.FirstOrDefault(i => i != null && string.Equals((i.AvatarItemDesc ?? "").Trim(), itemId, StringComparison.OrdinalIgnoreCase))?.FriendlyName ?? itemId;
removed = items.RemoveAll(i => i != null && string.Equals((i.AvatarItemDesc ?? "").Trim(), itemId, StringComparison.OrdinalIgnoreCase));
unlocks.AvatarItems = items.ToJson();
break;
}
case "equipment":
{
var items = unlocks.GetEquipment();
bool Matches(Equipment i) => i != null
&& string.Equals((i.PrefabName ?? "").Trim(), itemId, StringComparison.OrdinalIgnoreCase)
&& (modGuid.Length == 0 || string.Equals((i.ModificationGuid ?? "").Trim(), modGuid, StringComparison.OrdinalIgnoreCase));
removedName = items.FirstOrDefault(Matches)?.FriendlyName ?? itemId;
removed = items.RemoveAll(Matches);
unlocks.Equipment = items.ToJson();
break;
}
case "consumable":
{
var items = unlocks.GetConsumables();
removedName = items.FirstOrDefault(i => i != null && string.Equals((i.ConsumableItemDesc ?? "").Trim(), itemId, StringComparison.OrdinalIgnoreCase))?.ConsumableItemDesc ?? itemId;
removed = items.RemoveAll(i => i != null && string.Equals((i.ConsumableItemDesc ?? "").Trim(), itemId, StringComparison.OrdinalIgnoreCase));
unlocks.SetConsumables(items);
break;
}
default:
return BadRequest("The item kind must be avatar, equipment or consumable.");
}
if (removed == 0) return NotFound("That item is not in this account's locker.");
await db.SaveChangesAsync();
AdminActionDiscord.LogAction("Revoke Item", await GetPanelAdminUsernameAsync(),
$"Took {removed} {itemKind} item(s) named \"{removedName}\" out of player {playerId} ({display})'s locker.");
return Ok(new { success = true, player_id = playerId, item_kind = itemKind, item_id = itemId, removed });
}
case "locker/reset":
{
var unlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == playerId);
if (unlocks == null) return NotFound("This account has no locker to reset.");
unlocks.AvatarItems = DBConfig.DefaultAvatarUnlocks;
unlocks.Consumables = DBConfig.DefaultConsumableUnlocks;
unlocks.Equipment = DBConfig.DefaultEquipmentUnlocks;
await db.SaveChangesAsync();
AdminActionDiscord.LogAction("Reset Locker", await GetPanelAdminUsernameAsync(),
$"Reset player {playerId} ({display})'s locker to the default inventory.");
return Ok(new { success = true, player_id = playerId });
}
case "lock":
{
var caseRef = (Q("case") ?? Q("case_ref") ?? "").Trim();
var result = await AccountLock.ApplyAsync(db, playerId, await GetPanelAdminUsernameAsync(),
string.IsNullOrWhiteSpace(caseRef) ? null : caseRef);
if (!result.Success)
return StatusCode(500, new { success = false, message = result.Error ?? "The account lock could not be applied." });
return Ok(new
{
success = true,
player_id = playerId,
locked = true,
ban_method = AccountLock.BanMethod,
ban_category = ReportCategory.Moderator.ToString(),
permanent = true,
changed = result.Changed
});
}
case "unlock":
{
var caseRef = (Q("case") ?? Q("case_ref") ?? "").Trim();
var result = await AccountLock.RemoveAsync(db, playerId, await GetPanelAdminUsernameAsync(),
string.IsNullOrWhiteSpace(caseRef) ? null : caseRef);
if (!result.Success)
return Conflict(new { success = false, message = result.Error ?? "The account lock could not be removed." });
return Ok(new { success = true, player_id = playerId, locked = false });
}
case "quit":
return await QuitPlayerGame(playerId, key);
case "forcelogout":
return await ForceLogoutPlayer(playerId, key);
default:
return NotFound($"Unknown player action '{kind}'.");
}
var applied = await UpdatePlayer(playerId, JsonSerializer.SerializeToElement(payload));
if (applied is IActionResult failure) return failure;
if (!string.IsNullOrEmpty(audit))
AdminActionDiscord.LogAction(kind, await GetPanelAdminUsernameAsync(), audit);
return Ok(new { success = true, player_id = playerId });
}
private async Task<IActionResult> ApplyPlayerSetting(long playerId, Dictionary<string, string?>? changes)
{
var row = await db.PlayerSettings.FirstOrDefaultAsync(x => x.AccountId == playerId);
var settings = row?.DeserializeSettings() ?? new List<Setting>();
if (changes != null)
{
foreach (var change in changes)
{
settings.RemoveAll(s => string.Equals(s.Key, change.Key, StringComparison.OrdinalIgnoreCase));
if (change.Value != null) settings.Add(new Setting { Key = change.Key, Value = change.Value });
}
}
else
{
settings = new List<Setting>();
}
var payload = new Dictionary<string, object>
{
["settings"] = settings.ToDictionary(s => s.Key, s => s.Value)
};
var applied = await UpdatePlayer(playerId, JsonSerializer.SerializeToElement(payload));
if (applied is IActionResult failure) return failure;
return Ok(new { success = true, player_id = playerId, settings = settings.Count });
}
[HttpGet("players/{playerId:long}/details")]
public async Task<IActionResult> PlayerDetails(long playerId, [FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var rooms = await db.Rooms
.Where(r => r.CreatorAccountId == playerId)
.Select(r => new
{
id = r.RoomId,
name = r.Name,
description = r.Description,
image_name = r.ImageName,
state = r.State.ToString(),
accessibility = r.Accessibility.ToString(),
created_at = r.CreatedAt,
max_players = r.MaxPlayers,
supports_juniors = r.SupportsJuniors,
cloning_allowed = r.CloningAllowed
})
.ToListAsync();
var inventions = await db.Inventions
.Where(i => i.CreatorPlayerId == playerId)
.Select(i => new
{
id = i.InventionId,
name = i.Name,
description = i.Description,
image_name = i.ImageName,
is_published = i.IsPublished,
num_downloads = i.NumDownloads,
cheer_count = i.CheerCount,
created_at = i.CreatedAt
})
.ToListAsync();
var events = await db.PlayerEvents
.Where(e => e.CreatorPlayerId == playerId)
.Select(e => new
{
id = e.PlayerEventId,
name = e.Name,
description = e.Description,
image_name = e.ImageName,
room_id = e.RoomId,
start_time = e.StartTime,
end_time = e.EndTime,
attendee_count = e.AttendeeCount,
accessibility = e.Accessibility.ToString()
})
.ToListAsync();
var currency = await db.Currency.FirstOrDefaultAsync(x => x.AccountId == playerId);
var balance = new
{
rec_tokens = currency?.RecTokens ?? 0,
laser_tag_tickets = currency?.LaserTagTickets ?? 0,
gold = currency?.Gold ?? 0,
dracula_silver = currency?.DraculaSilver ?? 0
};
// What the account actually owns: the unlocks row the game reads when it dresses an
// avatar. The panel shows this as the player's locker, so each entry is resolved
// against the server's own catalogues here - the saved entry often carries no name
// or picture of its own, and the catalogue is the only place that has them.
var unlocksRow = await db.Unlocks.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == playerId);
var lockerAvatar = unlocksRow?.GetAvatarItems() ?? new List<AvatarItem>();
var lockerEquipment = unlocksRow?.GetEquipment() ?? new List<Equipment>();
var lockerConsumables = unlocksRow?.GetConsumables() ?? new List<Consumable>();
var avatarCatalog = (DBConfig.DefaultAvatarCatalog.FromJson<List<AvatarItem>>() ?? new List<AvatarItem>())
.Where(x => x != null && !string.IsNullOrWhiteSpace(x.AvatarItemDesc))
.GroupBy(x => x.AvatarItemDesc.Trim(), StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var equipmentCatalog = (DBConfig.DefaultEquipmentItems.FromJson<List<Equipment>>(CatalogJsonOptions) ?? new List<Equipment>())
.Where(x => x != null && !string.IsNullOrWhiteSpace(x.PrefabName))
.GroupBy(x => x.PrefabName.Trim(), StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var consumableCatalog = (DBConfig.DefaultConsumableItems.FromJson<List<ConsumableItem>>(CatalogJsonOptions) ?? new List<ConsumableItem>())
.Where(x => x != null && !string.IsNullOrWhiteSpace(x.ConsumableItemDesc))
.GroupBy(x => x.ConsumableItemDesc.Trim(), StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var locker = new
{
avatar = lockerAvatar
.Where(i => i != null && !string.IsNullOrWhiteSpace(i.AvatarItemDesc))
.Select(i =>
{
avatarCatalog.TryGetValue(i.AvatarItemDesc.Trim(), out var meta);
return new
{
desc = i.AvatarItemDesc.Trim(),
name = NonEmpty(i.FriendlyName, meta?.FriendlyName, i.AvatarItemDesc),
rarity = RarityLabel(meta?.Rarity ?? i.Rarity),
thumbnail_image = NonEmpty(i.ThumbnailImage, meta?.ThumbnailImage, "")
};
})
.Cast<object>().ToList(),
equipment = lockerEquipment
.Where(i => i != null && !string.IsNullOrWhiteSpace(i.PrefabName))
.Select(i =>
{
equipmentCatalog.TryGetValue(i.PrefabName.Trim(), out var meta);
return new
{
prefab = i.PrefabName.Trim(),
guid = (i.ModificationGuid ?? "").Trim(),
name = NonEmpty(i.FriendlyName, meta?.FriendlyName, i.PrefabName),
rarity = (meta?.Rarity ?? i.Rarity).ToString()
};
})
.Cast<object>().ToList(),
consumables = lockerConsumables
.Where(i => i != null && !string.IsNullOrWhiteSpace(i.ConsumableItemDesc))
.Select(i =>
{
consumableCatalog.TryGetValue(i.ConsumableItemDesc.Trim(), out var meta);
return new
{
desc = i.ConsumableItemDesc.Trim(),
count = i.Count,
name = NonEmpty(meta?.FriendlyName, i.ConsumableItemDesc),
rarity = meta == null ? "None" : ((Rarity)meta.Rarity).ToString()
};
})
.Cast<object>().ToList()
};
var photos = await db.Images
.Where(i => i.OwnerId == playerId)
.OrderByDescending(i => i.CreatedAt)
.Select(i => new
{
id = i.Id,
name = i.Name,
image_url = $"{DBConfig.ServerUrl}/images/img/{i.Name}",
type = i.ImageType.ToString(),
accessibility = i.Accessibility.ToString(),
room_id = i.RoomId,
created_at = DateTime.SpecifyKind(i.CreatedAt, DateTimeKind.Utc)
})
.ToListAsync();
var roomIds = rooms.Select(r => r.id).ToList();
var roomReports = LoadState().ModerationReports
.Where(r => string.Equals(r["report_type"]?.ToString(), "Room", StringComparison.OrdinalIgnoreCase)
&& (string.Equals(r["reported_id"]?.ToString(), playerId.ToString(), StringComparison.Ordinal)
|| (r["target_id"]?.ToString() is string targetRaw
&& long.TryParse(targetRaw, out var targetId) && roomIds.Contains(targetId))))
.OrderByDescending(r => r["created_at"]?.ToString())
.ToList();
var moderation = (await db.Moderation
.Where(m => m.PlayerId == playerId)
.ToListAsync())
.Select(m => new
{
id = m.Id,
category = m.Category.ToString(),
message = m.Message,
moderator_note = m.ModeratorNote,
ban_method = m.BanMethod,
created_at = DateTime.SpecifyKind(m.CreatedAt, DateTimeKind.Utc),
is_ban = m.IsBan,
expire_duration = DateTime.SpecifyKind(m.ExpireDuration, DateTimeKind.Utc)
})
.ToList();
var voteKicks = await db.VoteKicks
.Where(v => v.PlayerId == playerId)
.OrderByDescending(v => v.CreatedAt)
.Select(v => new
{
id = v.Id,
reason = v.Reason,
category = v.Category.ToString(),
is_force = v.IsForce,
created_at = DateTime.SpecifyKind(v.CreatedAt, DateTimeKind.Utc),
expires_at = DateTime.SpecifyKind(v.ExpiresAt, DateTimeKind.Utc)
})
.ToListAsync();
var friendRows = await db.Relationships
.Where(r => r.AccountId == playerId && r.RelationshipType == RelationshipType.Friend)
.ToListAsync();
var friendIds = friendRows.Select(r => r.PlayerId).Distinct().ToList();
var friendProfiles = friendIds.Count > 0
? await db.Profiles.Where(p => friendIds.Contains(p.accountId)).ToDictionaryAsync(p => p.accountId)
: new Dictionary<long, Profile>();
var friendProgression = friendIds.Count > 0
? await db.Progression.Where(p => friendIds.Contains(p.PlayerId)).ToDictionaryAsync(p => p.PlayerId)
: new Dictionary<long, ProgressionData>();
var friends = friendRows.Select(r =>
{
friendProfiles.TryGetValue(r.PlayerId, out var fp);
friendProgression.TryGetValue(r.PlayerId, out var fprog);
return new
{
player_id = r.PlayerId,
username = fp?.username ?? "",
display_name = fp?.displayName ?? $"Player {r.PlayerId}",
profile_image = fp != null ? ImageUrl(fp.profileImage) : ImageUrl(null),
level = fprog?.Level ?? 0,
relationship_type = r.RelationshipType.ToString()
};
}).ToList();
var taggedImages = await db.Images.ToListAsync();
var photosOfMe = taggedImages
.Where(i =>
{
try
{
var ids = i.PlayerIds.FromJson<List<long>>() ?? new List<long>();
return ids.Contains(playerId);
}
catch
{
return false;
}
})
.OrderByDescending(i => i.CreatedAt)
.Select(i => new
{
id = i.Id,
name = i.Name,
image_url = $"{DBConfig.ServerUrl}/images/img/{i.Name}",
owner_id = i.OwnerId,
room_id = i.RoomId,
created_at = DateTime.SpecifyKind(i.CreatedAt, DateTimeKind.Utc)
})
.ToList();
var notes = LoadState().Notes
.Where(n => string.Equals(n["player_id"]?.ToString(), playerId.ToString(), StringComparison.Ordinal))
.OrderByDescending(n => n["created_at"]?.ToString())
.Cast<object>()
.ToList();
var clubRecords = await ClubStore.LoadAsync(db);
var clubs = clubRecords
.Where(c => c.ownerPlayerId == playerId)
.OrderByDescending(c => c.createdAt)
.Select(c => new
{
id = c.clubId,
name = c.name,
description = c.description,
primary_tag = c.primaryTag,
created_at = DateTime.SpecifyKind(c.createdAt, DateTimeKind.Utc)
})
.Cast<object>()
.ToList();
var progress = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == playerId);
var progressJson = progress?.GetProgressionJson();
var progression = new
{
level = progress?.Level ?? 0,
xp = progress?.XP ?? 0,
last_xp_earned_at = progressJson?.LastXpEarnedAt,
xp_earned_today = progressJson?.XpEarnedToday ?? 0
};
var accountLocked = moderation.Any(m => m.is_ban
&& string.Equals(m.ban_method, "Account Lock", StringComparison.OrdinalIgnoreCase)
&& m.expire_duration > DateTime.UtcNow);
var warningRows = await db.PlayerWarnings
.AsNoTracking()
.Where(w => w.WarnedPlayerId == playerId)
.OrderByDescending(w => w.Id)
.ToListAsync();
var warningCount = warningRows.Count;
var pendingWarningCount = warningRows.Count(w => w.AcknowledgedAt == null);
var warnings = warningRows.Take(25).Select(w => new
{
id = w.Id,
category = w.ReportCategory.ToString(),
report_category = (int)w.ReportCategory,
reason = w.DisplayReason ?? string.Empty,
moderator_id = w.ModeratorPlayerId,
note = w.ModeratorNote ?? string.Empty,
created_at = DateTime.SpecifyKind(w.CreatedAt, DateTimeKind.Utc),
acknowledged_at = w.AcknowledgedAt.HasValue
? DateTime.SpecifyKind(w.AcknowledgedAt.Value, DateTimeKind.Utc)
: (DateTime?)null
}).Cast<object>().ToList();
var voiceRestriction = await VoiceChatRestrictions.ActiveAsync(db, playerId);
return Ok(new
{
rooms,
inventions,
events,
balance,
locker,
moderation,
voice_restricted = voiceRestriction != null,
voice_restriction = voiceRestriction == null
? null
: new
{
reason = voiceRestriction.Reason,
category = voiceRestriction.Category.ToString(),
report_category = (int)voiceRestriction.Category,
issued_by = voiceRestriction.IssuedBy,
issued_by_username = voiceRestriction.IssuedByUsername,
note = voiceRestriction.ModeratorNote,
started_at = DateTime.SpecifyKind(voiceRestriction.StartedAt, DateTimeKind.Utc),
expires_at = DateTime.SpecifyKind(voiceRestriction.ExpiresAt, DateTimeKind.Utc),
minutes_remaining = (int)Math.Max(0, (voiceRestriction.ExpiresAt - DateTime.UtcNow).TotalMinutes)
},
vote_kicks = voteKicks,
photos,
photos_of_me = photosOfMe,
friends,
clubs,
notes,
progression,
room_reports = roomReports,
warn_count = warningCount,
pending_warn_count = pendingWarningCount,
warnings,
account_locked = accountLocked
});
}
[HttpGet("notify/status")]
public async Task<IActionResult> NotifyStatus([FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var accounts = Notify.ConnectedAccountIds();
return Ok(new
{
hub_context_ready = Notify.Context != null,
connected_connections = Notify.ConnectedConnectionCount,
connected_account_count = accounts.Count,
connected_accounts = accounts
});
}
[HttpGet("{table}")]
public async Task<IActionResult> List(string table, [FromQuery] string key, [FromQuery(Name = "eq_col")] string? eqCol, [FromQuery(Name = "eq_val")] string? eqVal, [FromQuery] string? order, [FromQuery] bool ascending = true)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
return table switch
{
"admin_users" => Ok(await GetAdminUsers(eqCol, eqVal)),
"players" => Ok(await GetPlayers(order, ascending)),
"gift_drops" => Ok(await GetGiftDrops(order, ascending)),
"storefront_items" => Ok(await GetStorefrontItems(eqCol, eqVal, order, ascending)),
"storefronts" => Ok(GetStorefronts(order, ascending)),
"challenges" => Ok(GetChallenges(order, ascending)),
"moderation_log" => Ok(FilterByPlayer(LoadState().ModerationLog, eqCol, eqVal, order, ascending)),
"gift_log" => Ok(await ProcessGiftLog(eqCol, eqVal, order, ascending)),
"notes" => Ok(FilterByPlayer(LoadState().Notes, eqCol, eqVal, order, ascending)),
"audit_log" => Ok(LoadState().AuditLog),
"rooms" => Ok(await GetRooms(order, ascending)),
"room_comments" => Ok(await GetRoomComments(order, ascending)),
"community_board" => Ok(GetCommunityBoard()),
"moderation_reports" => Ok(GetModerationReports(order, ascending)),
"player_events" => Ok(await GetPlayerEvents(order, ascending)),
"player_event_responses" => Ok(await GetPlayerEventResponses(eqCol, eqVal, order, ascending)),
"event_invitations" => Ok(await GetEventInvitations(eqCol, eqVal, order, ascending)),
"avatar_items" => Ok(GetAvatarItems()),
"equipment_items" => Ok(GetEquipmentItems()),
"consumable_items" => Ok(GetConsumableItems()),
"announcements" => Ok(GetAnnouncements()),
"ip_bans" => Ok(await GetIpBans(order, ascending)),
"hardware_bans" => Ok(await GetHardwareBans(order, ascending)),
"account_creation_blocks" => Ok(await GetAccountCreationBlocks(order, ascending)),
"leaderboard_scores" => Ok(await GetLeaderboardScores(eqCol, eqVal, order, ascending)),
"game_blocks" => Ok(await GetGameBlocks(order, ascending)),
"gameconfigs" => Ok(await db.GameConfig.ToListAsync()),
"serverconfigs" => Ok(await db.ServerConfig.ToListAsync()),
"clubs" => Ok(await GetClubs()),
"featured_rooms" => Ok(await GetFeaturedRooms()),
"room_recommendations" => Ok(await GetRoomRecommendations()),
"item_sets" => Ok(await GetItemSets()),
"playlists" => Ok(await GetPlaylists()),
"split_tests" => Ok(await GetSplitTests()),
"cohort_nux_buttons" => Ok(await GetCohortNuxButtons()),
"challenge_maps" => Ok(await GetChallengeMaps()),
"tags" => Ok(await GetTags()),
_ => GenericTableList(table)
};
}
private IActionResult GenericTableList(string table)
{
var prop = db.GetType().GetProperties()
.FirstOrDefault(p => p.PropertyType.IsGenericType
&& p.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>)
&& string.Equals(p.Name, table, StringComparison.OrdinalIgnoreCase));
if (prop == null) return NotFound($"Unknown admin panel table '{table}'.");
var set = prop.GetValue(db) as System.Collections.IEnumerable;
var rows = set?.Cast<object>().ToList() ?? new List<object>();
return Ok(rows);
}
private static List<string> SafeRoomTags(string? tags)
{
if (string.IsNullOrWhiteSpace(tags)) return new List<string>();
try { return StringTools.ReadTags(tags).Select(t => t.Tag).ToList(); }
catch { return new List<string>(); }
}
private async Task<List<object>> GetClubs()
{
var config = await db.ServerConfig.AsNoTracking().FirstOrDefaultAsync(x => x.Key == "Clubs");
if (config == null || string.IsNullOrWhiteSpace(config.Value)) return new List<object>();
try
{
var clubs = config.Value.FromJson<List<ClubRecord>>() ?? new List<ClubRecord>();
var clubReports = LoadState().ModerationReports
.Where(r => string.Equals(r["report_type"]?.ToString(), "Club", StringComparison.OrdinalIgnoreCase))
.Select(r => r["target_id"]?.ToString())
.Where(v => !string.IsNullOrWhiteSpace(v))
.GroupBy(v => v!, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
return clubs.Select(c =>
{
var categories = new List<string>();
if (!string.IsNullOrWhiteSpace(c.primaryTag)) categories.Add(c.primaryTag);
if (c.customTags != null) categories.AddRange(c.customTags.Where(t => !string.IsNullOrWhiteSpace(t)));
return new
{
club_id = c.clubId,
name = c.name,
description = c.description,
primary_tag = c.primaryTag,
categories = categories,
image_name = c.imageName,
banner_image_name = c.bannerImageName,
owner_player_id = c.ownerPlayerId,
created_at = c.createdAt,
privacy = c.visibility,
privacy_label = c.visibility == 0 ? "Private" : "Public",
member_access = c.joinability,
member_access_label = c.joinability switch { 1 => "Invite Only", 2 => "Ask to Join", _ => "Open" },
member_count = ClubStore.GetMemberCount(c),
report_count = clubReports.TryGetValue(c.clubId.ToString(), out var rc) ? rc : 0
};
}).Cast<object>().ToList();
}
catch
{
return new List<object>();
}
}
private async Task<List<object>> GetFeaturedRooms()
{
var rooms = await db.Rooms.AsNoTracking().ToListAsync();
return rooms
.Where(r => SafeRoomTags(r.Tags).Any(t => string.Equals(t, "featured", StringComparison.OrdinalIgnoreCase)))
.Select(r => new
{
room_id = r.RoomId,
name = r.Name,
creator_player_id = r.CreatorAccountId,
tags = r.Tags,
accessibility = r.Accessibility.ToString(),
is_rro = r.IsRRO,
is_dorm = r.IsDorm,
created_at = r.CreatedAt
}).Cast<object>().ToList();
}
private async Task<List<object>> GetRoomRecommendations()
{
var top = await db.Rooms
.AsNoTracking()
.Where(r => r.Accessibility == Accessibility.Public && !r.IsDorm)
.Select(r => new
{
r.RoomId,
r.Name,
r.CreatorAccountId,
r.Tags,
r.IsRRO,
r.CreatedAt,
Engagement = db.RoomInteractions
.Where(i => i.RoomId == r.RoomId)
.Sum(i => (i.Favorited ? 1 : 0) + (i.Cheered ? 1 : 0))
})
.OrderByDescending(x => x.Engagement)
.Take(10)
.ToListAsync();
var rank = 1;
return top.Select(x => new
{
rank = rank++,
room_id = x.RoomId,
name = x.Name,
creator_player_id = x.CreatorAccountId,
tags = x.Tags,
is_rro = x.IsRRO,
engagement = x.Engagement,
created_at = x.CreatedAt
}).Cast<object>().ToList();
}
private async Task<List<object>> GetItemSets()
{
var curated = await db.ItemSets.AsNoTracking().OrderBy(x => x.ItemSetId).ToListAsync();
var derived = await db.GiftItems
.AsNoTracking()
.Where(g => g.ItemSetId > 0)
.GroupBy(g => new { g.ItemSetId, g.ItemSetFriendlyName })
.Select(g => new { item_set_id = g.Key.ItemSetId, item_set_friendly_name = g.Key.ItemSetFriendlyName, item_count = g.Count() })
.OrderBy(x => x.item_set_id)
.ToListAsync();
var result = new List<object>();
var curatedIds = curated.Select(c => c.ItemSetId).ToHashSet();
foreach (var set in curated)
{
var count = derived.Where(d => d.item_set_id == set.ItemSetId).Sum(d => d.item_count);
result.Add(new { item_set_id = set.ItemSetId, item_set_friendly_name = set.FriendlyName, item_count = count, source = "curated" });
}
foreach (var set in derived.Where(d => !curatedIds.Contains(d.item_set_id)))
{
result.Add(new { item_set_id = set.item_set_id, item_set_friendly_name = set.item_set_friendly_name, item_count = set.item_count, source = "derived" });
}
return result;
}
private async Task<List<object>> GetPlaylists()
{
var playlists = await db.Playlists.AsNoTracking().OrderBy(x => x.PlaylistId).ToListAsync();
return playlists.Select(p => new
{
playlist_id = p.PlaylistId,
keywords = p.Keywords
}).Cast<object>().ToList();
}
private async Task<object> CreatePlaylist(string keywords)
{
var playlist = new PlaylistDB { Keywords = keywords };
db.Playlists.Add(playlist);
await db.SaveChangesAsync();
return new { playlist_id = playlist.PlaylistId, keywords = playlist.Keywords };
}
private async Task<object> CreateItemSet(string friendlyName)
{
var set = new ItemSetDB { FriendlyName = friendlyName };
db.ItemSets.Add(set);
await db.SaveChangesAsync();
return new { item_set_id = set.ItemSetId, item_set_friendly_name = set.FriendlyName, item_count = 0, source = "curated" };
}
private async Task<object> CreateTag(string tag, string description)
{
var row = new TagDB { Tag = tag, Description = description };
db.Tags.Add(row);
await db.SaveChangesAsync();
return new { tag_id = (long?)row.TagId, tag = row.Tag, description = row.Description, room_count = 0, source = "curated" };
}
private async Task<IActionResult> UpdatePlaylist(long id, JsonElement payload)
{
var playlist = await db.Playlists.FirstOrDefaultAsync(x => x.PlaylistId == id);
if (playlist == null) return NotFound("Playlist not found.");
var keywords = GetJsonString(payload, "keywords");
if (keywords != null) playlist.Keywords = keywords;
await db.SaveChangesAsync();
return Ok(new { playlist_id = playlist.PlaylistId, keywords = playlist.Keywords });
}
private async Task<IActionResult> UpdateItemSet(long id, JsonElement payload)
{
var set = await db.ItemSets.FirstOrDefaultAsync(x => x.ItemSetId == id);
if (set == null) return NotFound("Item set not found.");
var friendlyName = GetJsonString(payload, "item_set_friendly_name") ?? GetJsonString(payload, "friendly_name");
if (!string.IsNullOrWhiteSpace(friendlyName)) set.FriendlyName = friendlyName;
await db.SaveChangesAsync();
return Ok(new { item_set_id = set.ItemSetId, item_set_friendly_name = set.FriendlyName, source = "curated" });
}
private async Task<IActionResult> UpdateTag(long id, JsonElement payload)
{
var tag = await db.Tags.FirstOrDefaultAsync(x => x.TagId == id);
if (tag == null) return NotFound("Tag not found.");
var tagValue = GetJsonString(payload, "tag");
if (!string.IsNullOrWhiteSpace(tagValue)) tag.Tag = tagValue;
var description = GetJsonString(payload, "description");
if (description != null) tag.Description = description;
await db.SaveChangesAsync();
return Ok(new { tag_id = (long?)tag.TagId, tag = tag.Tag, description = tag.Description, source = "curated" });
}
private async Task<List<object>> GetSplitTests()
{
var configs = await db.GameConfig.AsNoTracking().ToListAsync();
var available = configs.FirstOrDefault(c => c.Key == "splitTestsAvailable")?.Value ?? "";
var hard = configs.FirstOrDefault(c => c.Key == "splitTestHardOverrides")?.Value ?? "";
var probs = configs.FirstOrDefault(c => c.Key == "splitTestSegmentProbabilities")?.Value ?? "";
var hardMap = new Dictionary<string, string>();
foreach (var part in hard.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var eq = part.IndexOf('=');
if (eq <= 0) continue;
hardMap[part[..eq].Trim()] = part[(eq + 1)..].Trim();
}
JsonNode? probNode = null;
try { probNode = JsonNode.Parse(probs); } catch { }
var rows = new List<object>();
foreach (var part in available.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var eq = part.IndexOf('=');
if (eq <= 0) continue;
var name = part[..eq].Trim();
var segments = part[(eq + 1)..].Trim();
string? probabilities = null;
if (probNode is JsonObject obj && obj.TryGetPropertyValue(name, out var segNode) && segNode != null)
probabilities = segNode.ToJsonString();
rows.Add(new
{
test_name = name,
available_segments = segments,
hard_override = hardMap.TryGetValue(name, out var h) ? h : "",
segment_probabilities = probabilities ?? ""
});
}
return rows;
}
private async Task<List<object>> GetCohortNuxButtons()
{
var rows = new List<object>();
var game = await db.GameConfig.AsNoTracking().ToListAsync();
var server = await db.ServerConfig.AsNoTracking().ToListAsync();
void AddFromConfig(string key, string value, string source)
{
if (MatchesNux(key))
{
rows.Add(new { config_key = key, value, source });
return;
}
foreach (var entry in value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (!MatchesNux(entry)) continue;
var eq = entry.IndexOf('=');
rows.Add(new
{
config_key = eq > 0 ? entry[..eq].Trim() : entry,
value = eq > 0 ? entry[(eq + 1)..].Trim() : "",
source
});
}
}
foreach (var c in game) AddFromConfig(c.Key, c.Value, "GameConfig");
foreach (var c in server) AddFromConfig(c.Key, c.Value, "ServerConfig");
return rows;
static bool MatchesNux(string s) =>
s.Contains("NUX", StringComparison.OrdinalIgnoreCase) || s.Contains("Cohort", StringComparison.OrdinalIgnoreCase);
}
private async Task<List<object>> GetChallengeMaps()
{
var rooms = await db.Rooms.AsNoTracking().ToListAsync();
return rooms
.Where(r => SafeRoomTags(r.Tags).Any(t =>
string.Equals(t, "challenge", StringComparison.OrdinalIgnoreCase) ||
string.Equals(t, "quest", StringComparison.OrdinalIgnoreCase)))
.Select(r => new
{
room_id = r.RoomId,
name = r.Name,
creator_player_id = r.CreatorAccountId,
tags = r.Tags,
accessibility = r.Accessibility.ToString(),
is_rro = r.IsRRO,
created_at = r.CreatedAt
}).Cast<object>().ToList();
}
private async Task<List<object>> GetTags()
{
var curated = await db.Tags.AsNoTracking().OrderBy(x => x.Tag).ToListAsync();
var rooms = await db.Rooms.AsNoTracking().ToListAsync();
var derived = rooms
.SelectMany(r => SafeRoomTags(r.Tags).Select(t => new { Tag = t, RoomId = r.RoomId }))
.Where(t => !string.IsNullOrWhiteSpace(t.Tag))
.GroupBy(t => t.Tag, StringComparer.OrdinalIgnoreCase)
.Select(g => new { tag = g.Key, room_count = g.Select(x => x.RoomId).Distinct().Count() })
.OrderByDescending(x => x.room_count)
.ThenBy(x => x.tag)
.ToList();
var result = new List<object>();
var curatedTags = curated.Select(c => c.Tag).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var tag in curated)
{
var count = derived.FirstOrDefault(d => string.Equals(d.tag, tag.Tag, StringComparison.OrdinalIgnoreCase))?.room_count ?? 0;
result.Add(new { tag_id = (long?)tag.TagId, tag = tag.Tag, description = tag.Description, room_count = count, source = "curated" });
}
foreach (var tag in derived.Where(d => !curatedTags.Contains(d.tag)))
{
result.Add(new { tag_id = (long?)null, tag = tag.tag, description = "", room_count = tag.room_count, source = "derived" });
}
return result;
}
private static string? SaveDataUrlFile(string? dataUrl, string directory)
{
if (string.IsNullOrWhiteSpace(dataUrl) || !dataUrl.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
return null;
var comma = dataUrl.IndexOf(',');
if (comma < 0) return null;
var header = dataUrl.Substring(0, comma);
string ext = header.Contains("png", StringComparison.OrdinalIgnoreCase) ? ".png"
: header.Contains("jpeg", StringComparison.OrdinalIgnoreCase) ? ".jpg"
: header.Contains("jpg", StringComparison.OrdinalIgnoreCase) ? ".jpg"
: header.Contains("gif", StringComparison.OrdinalIgnoreCase) ? ".gif"
: header.Contains("webp", StringComparison.OrdinalIgnoreCase) ? ".webp"
: ".png";
try
{
var bytes = Convert.FromBase64String(dataUrl.Substring(comma + 1));
Directory.CreateDirectory(directory);
var name = Guid.NewGuid().ToString("N") + ext;
System.IO.File.WriteAllBytes(Path.Join(directory, name), bytes);
return name;
}
catch
{
return null;
}
}
private static string FormatBanTime(TimeSpan timeRemaining)
{
if (timeRemaining.TotalDays > 365 * 10)
return "This ban is permanent.";
var parts = new List<string>();
if (timeRemaining.Days > 0)
parts.Add($"{timeRemaining.Days} Days");
if (timeRemaining.Hours > 0)
parts.Add($"{timeRemaining.Hours} Hours");
if (timeRemaining.Minutes > 0)
parts.Add($"{timeRemaining.Minutes} Minutes");
if (timeRemaining.Seconds > 0)
parts.Add($"{timeRemaining.Seconds} Seconds");
return parts.Count > 0 ? string.Join(", ", parts) : "0 Seconds";
}
[HttpPost("upload/video")]
[RequestSizeLimit(AdminUtils.MaxUploadBytes)]
public async Task<IActionResult> UploadVideo([FromQuery] string key, [FromForm] IFormFile file)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
if (file == null || file.Length == 0) return BadRequest("No file provided.");
var allowed = new[] { ".mp4", ".webm", ".mov", ".m4v", ".ogv" };
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!allowed.Contains(ext)) return BadRequest($"Unsupported video format '{ext}'. Use mp4/webm/mov.");
Directory.CreateDirectory(DBConfig.CDNVideos);
var name = Guid.NewGuid().ToString("N") + ext;
var path = Path.Join(DBConfig.CDNVideos, name);
using (var stream = new FileStream(path, FileMode.Create))
{
await file.CopyToAsync(stream);
}
return Ok(new { name, url = $"{DBConfig.ServerUrl}/cdn/video/{name}" });
}
[HttpPost("upload/image")]
[RequestSizeLimit(AdminUtils.MaxUploadBytes)]
public async Task<IActionResult> UploadImage([FromQuery] string key, [FromForm] IFormFile file)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
if (file == null || file.Length == 0) return BadRequest("No file provided.");
var allowed = new[] { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp" };
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!allowed.Contains(ext)) return BadRequest($"Unsupported image format '{ext}'. Use png/jpg/jpeg/gif/webp.");
Directory.CreateDirectory(DBConfig.CDNImages);
var name = Guid.NewGuid().ToString("N") + ext;
var path = Path.Join(DBConfig.CDNImages, name);
using (var stream = new FileStream(path, FileMode.Create))
{
await file.CopyToAsync(stream);
}
return Ok(new { name, url = $"{DBConfig.ServerUrl}/images/img/{name}" });
}
[HttpPost("upload/room")]
[RequestSizeLimit(AdminUtils.MaxUploadBytes)]
public async Task<IActionResult> UploadRoom([FromQuery] string key, [FromForm] IFormFile file, [FromQuery] string? name)
{
// An oversized or truncated body fails form binding, so it is reported as the bad
// request it is rather than as whatever a half-read form left behind.
if (!ModelState.IsValid) return BadRequest($"Invalid upload request. Use a multipart form no larger than {AdminUtils.MaxUploadLabel}.");
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
if (file == null || file.Length == 0) return BadRequest("No file provided.");
// A subroom is imported as three kinds of file, and each keeps the role its
// extension names: the .room is the save the subroom loads, the .assetbundle
// is the Unity scene its unity_assets metadata points at, and the .htr files
// are the audio/data blobs that save references by name. They are imported one
// file at a time, so every one of them lands in the CDN under the name it was
// uploaded as - the 2023 client asks for exactly that name (GET /data/<name>),
// which is why a file this route renamed to a GUID could never be found.
// An explicit ?name= wins; otherwise the uploaded file's own name is the one to
// store, and it is held to the same bare-name rule either way.
var candidate = !string.IsNullOrWhiteSpace(name) ? name : Path.GetFileName(file.FileName);
if (!AdminUtils.TryResolveUploadName(candidate, out var finalName, out var nameError))
return BadRequest(nameError);
if (string.IsNullOrEmpty(finalName))
return BadRequest("The uploaded file has no name to store it under.");
// The stored name decides which of the three a file is, so a renamed upload is
// classified by what it is being stored as.
var kind = RoomFileKind(finalName);
if (kind == null)
{
var ext = Path.GetExtension(finalName).ToLowerInvariant();
return BadRequest($"Unsupported room file format '{(string.IsNullOrEmpty(ext) ? "no extension" : ext)}'. Only .room, .htr and .assetbundle room files are allowed.");
}
Directory.CreateDirectory(DBConfig.CDNStorage);
var path = Path.Join(DBConfig.CDNStorage, finalName);
using (var stream = new FileStream(path, FileMode.Create))
{
await file.CopyToAsync(stream);
}
return Ok(new { name = finalName, kind, url = $"{DBConfig.ServerUrl}/cdn/data/{finalName}" });
}
/// The stored name decides which of the three a room file is: the extension is the role.
private static string? RoomFileKind(string fileName) => Path.GetExtension(fileName).ToLowerInvariant() switch
{
".room" => "room",
".htr" => "htr",
".assetbundle" => "assetbundle",
_ => null
};
/// <summary>
/// One part of a file too big to send in a single request.
///
/// A whole room file in one request does not survive the trip to this server: the edge
/// proxy in front of it refuses a request body over 100 MB and holds a streamed body in
/// memory, so importing a large .assetbundle died between the panel and here and surfaced
/// to the admin as a broken connection rather than as a size refusal. The panel therefore
/// posts anything large in parts, and this route appends each one to
/// "&lt;name&gt;.uploading" and promotes it to "&lt;name&gt;" when the last part lands, so
/// a half-imported file is never served, listed or bound.
///
/// The offset is checked against what is already stored, so a part retried after its
/// answer was lost either continues where it left off or is told exactly where to continue
/// (409, with the stored byte count) instead of corrupting the file.
/// </summary>
[HttpPost("upload/part")]
[RequestSizeLimit(AdminUtils.MaxUploadBytes)]
public async Task<IActionResult> UploadPart([FromQuery] string key, [FromQuery] string name, [FromQuery] long offset, [FromQuery] long total)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
if (!AdminUtils.TryResolveUploadName(name, out var finalName, out var nameError))
return BadRequest(nameError);
if (string.IsNullOrEmpty(finalName))
return BadRequest("The uploaded file has no name to store it under.");
if (AdminUtils.IsUploadPart(finalName))
return BadRequest($"'{finalName}' is a partial upload name; import the file under its final name.");
if (total <= 0 || total > AdminUtils.MaxUploadBytes)
return BadRequest($"A file of that size cannot be imported this way; the limit is {AdminUtils.MaxUploadLabel}.");
if (offset < 0 || offset > total)
return BadRequest("The part's offset is outside the file it belongs to.");
Directory.CreateDirectory(DBConfig.CDNStorage);
var partPath = Path.Join(DBConfig.CDNStorage, finalName + AdminUtils.PartSuffix);
var finalPath = Path.Join(DBConfig.CDNStorage, finalName);
// A part starting at zero begins the file over, whether or not an abandoned attempt left
// bytes behind; any other offset has to line up with what is stored or it is refused.
var stored = System.IO.File.Exists(partPath) ? new FileInfo(partPath).Length : 0;
if (offset == 0)
{
if (System.IO.File.Exists(partPath)) System.IO.File.Delete(partPath);
stored = 0;
}
if (stored != offset)
return Conflict(new
{
error = "part_out_of_order",
message = $"This import already holds {stored} of {total} bytes, not {offset}.",
stored,
total
});
long received = 0;
using (var stream = new FileStream(partPath, offset == 0 ? FileMode.Create : FileMode.Append, FileAccess.Write, FileShare.None))
{
var buffer = new byte[81920];
int read;
while ((read = await Request.Body.ReadAsync(buffer, HttpContext.RequestAborted)) > 0)
{
received += read;
if (offset + received > total)
{
stream.Dispose();
System.IO.File.Delete(partPath);
return BadRequest($"This part carried more than the {total} bytes it was sent for; the import was discarded.");
}
await stream.WriteAsync(buffer.AsMemory(0, read), HttpContext.RequestAborted);
}
}
var written = offset + received;
if (written < total)
return Ok(new { name = finalName, kind = RoomFileKind(finalName), received = written, total, complete = false });
// The last part: the file becomes the upload it was always meant to be.
if (System.IO.File.Exists(finalPath)) System.IO.File.Delete(finalPath);
System.IO.File.Move(partPath, finalPath);
return Ok(new { name = finalName, kind = RoomFileKind(finalName), url = $"{DBConfig.ServerUrl}/cdn/data/{finalName}", received = written, total, complete = true });
}
/// A part left behind by an import that never finished is not an upload and must not
/// accumulate: one older than a day was abandoned, so it goes.
private static void SweepAbandonedParts()
{
if (!Directory.Exists(DBConfig.CDNStorage)) return;
foreach (var file in Directory.GetFiles(DBConfig.CDNStorage, "*" + AdminUtils.PartSuffix))
{
try
{
if (System.IO.File.GetLastWriteTimeUtc(file) < DateTime.UtcNow.AddHours(-24)) System.IO.File.Delete(file);
}
catch (IOException) { }
}
}
[HttpPost("upload/datablob")]
[RequestSizeLimit(AdminUtils.MaxUploadBytes)]
public async Task<IActionResult> UploadDatablob([FromQuery] string key, [FromForm] IFormFile file, [FromQuery] string? name)
{
if (!ModelState.IsValid) return BadRequest($"Invalid upload request. Use a multipart form no larger than {AdminUtils.MaxUploadLabel}.");
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
if (file == null || file.Length == 0) return BadRequest("No file provided.");
// Same rule as a room file: the blob keeps the name it arrived under unless
// the caller names it, because the client can only ask for a name it knows.
var candidate = !string.IsNullOrWhiteSpace(name) ? name : Path.GetFileName(file.FileName);
if (!AdminUtils.TryResolveUploadName(candidate, out var requestedName, out var nameError))
return BadRequest(nameError);
if (string.IsNullOrEmpty(requestedName))
return BadRequest("The uploaded file has no name to store it under.");
Directory.CreateDirectory(DBConfig.CDNStorage);
var finalName = requestedName;
var path = Path.Join(DBConfig.CDNStorage, finalName);
using (var stream = new FileStream(path, FileMode.Create))
{
await file.CopyToAsync(stream);
}
return Ok(new { name = finalName, url = $"{DBConfig.ServerUrl}/cdn/data/{finalName}" });
}
[HttpGet("uploads")]
public async Task<IActionResult> ListUploads([FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
string[] Scan(string dir)
{
if (!Directory.Exists(dir)) return Array.Empty<string>();
SweepAbandonedParts();
return Directory.GetFiles(dir)
.Select(f => Path.GetFileName(f)!)
// A part of a file still being uploaded is not an upload: it is invisible until
// its last part lands and it becomes the file it was sent as.
.Where(n => !AdminUtils.IsUploadPart(n))
.OrderByDescending(f => System.IO.File.GetLastWriteTimeUtc(Path.Join(dir, f)))
.ToArray();
}
var images = Scan(DBConfig.CDNImages);
var videos = Scan(DBConfig.CDNVideos);
var storage = Scan(DBConfig.CDNStorage);
var bundles = storage.Where(n => n.EndsWith(".assetbundle", StringComparison.OrdinalIgnoreCase)).ToArray();
long SizeOf(string dir, string name)
{
try { return new FileInfo(Path.Join(dir, name)).Length; }
catch { return 0; }
}
return Ok(new
{
images = images.Select(n => new { name = n, url = $"{DBConfig.ServerUrl}/images/img/{n}", size = SizeOf(DBConfig.CDNImages, n) }).Cast<object>().ToList(),
videos = videos.Select(n => new { name = n, url = $"{DBConfig.ServerUrl}/cdn/video/{n}", size = SizeOf(DBConfig.CDNVideos, n) }).Cast<object>().ToList(),
rooms = storage.Select(n => new { name = n, url = $"{DBConfig.ServerUrl}/cdn/room/{n}", size = SizeOf(DBConfig.CDNStorage, n) }).Cast<object>().ToList(),
assetbundles = bundles.Select(n => new { name = n, url = $"{DBConfig.ServerUrl}/cdn/data/{n}", size = SizeOf(DBConfig.CDNStorage, n) }).Cast<object>().ToList(),
datablobs = storage.Select(n => new { name = n, url = $"{DBConfig.ServerUrl}/cdn/data/{n}", size = SizeOf(DBConfig.CDNStorage, n) }).Cast<object>().ToList()
});
}
[HttpDelete("uploads")]
public async Task<IActionResult> DeleteUpload([FromQuery] string key, [FromQuery] string type, [FromQuery] string name)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
if (string.IsNullOrWhiteSpace(name)) return BadRequest("No file name provided.");
name = Path.GetFileName(name);
string dir = type switch
{
"image" => DBConfig.CDNImages,
"video" => DBConfig.CDNVideos,
_ => DBConfig.CDNStorage
};
var path = Path.Join(dir, name);
if (!System.IO.File.Exists(path)) return NotFound("File not found.");
System.IO.File.Delete(path);
return Ok(new { deleted = name });
}
[HttpPost("{table}")]
public async Task<IActionResult> Insert(string table, [FromQuery] string key, [FromBody] JsonElement payload)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
if (table == "gift_drops" || table == "storefront_items")
return Ok(await CreateGiftDrop(payload));
if (table == "ip_bans")
return Ok(await AddIpBan(payload));
if (table == "hardware_bans")
return Ok(await AddHardwareBan(payload));
if (table == "account_creation_blocks")
return Ok(await AddAccountCreationBlock(payload));
if (table == "game_blocks")
{
var addGameBlockResult = await AddGameBlock(payload);
return addGameBlockResult is IActionResult actionResult ? actionResult : Ok(addGameBlockResult);
}
if (table == "gameconfigs" || table == "serverconfigs")
return Ok(await UpsertConfig(table, null, payload));
if (table == "playlists")
{
var keywords = GetJsonString(payload, "keywords") ?? "";
if (string.IsNullOrWhiteSpace(keywords)) return BadRequest("Keywords are required.");
return Ok(await CreatePlaylist(keywords));
}
if (table == "item_sets")
{
var friendlyName = GetJsonString(payload, "item_set_friendly_name") ?? GetJsonString(payload, "friendly_name") ?? "";
if (string.IsNullOrWhiteSpace(friendlyName)) return BadRequest("A friendly name is required.");
return Ok(await CreateItemSet(friendlyName));
}
if (table == "tags")
{
var tag = GetJsonString(payload, "tag") ?? "";
if (string.IsNullOrWhiteSpace(tag)) return BadRequest("A tag is required.");
var description = GetJsonString(payload, "description") ?? "";
return Ok(await CreateTag(tag, description));
}
var state = LoadState();
return table switch
{
"storefronts" => Ok(AddStorefront(state, payload)),
"challenges" => Ok(AddChallenge(state, payload)),
"moderation_log" => InsertLoggedRows(state, state.ModerationLog, payload, "moderation_log"),
"gift_log" => await InsertGiftLogRows(state, payload),
"notes" => Ok(RowsResponse(payload, AddRows(state, state.Notes, payload))),
"audit_log" => InsertLoggedRows(state, state.AuditLog, payload, "audit_log"),
"community_board" => Ok(await UpdateCommunityBoard(state, payload)),
"announcements" => await UpdateAnnouncements(payload),
_ => NotFound($"Unknown admin panel table '{table}'.")
};
}
public async Task<IActionResult> Update(string table, long id, [FromQuery] string key, [FromBody] JsonElement payload)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
if (table == "players")
{
var updatePlayerResult = await UpdatePlayer(id, payload);
return updatePlayerResult is IActionResult actionResult ? actionResult : Ok(updatePlayerResult);
}
if (table == "rooms")
{
var updateRoomResult = await UpdateRoom(id, payload);
return updateRoomResult is IActionResult roomActionResult ? roomActionResult : Ok(updateRoomResult);
}
if (table == "player_events")
return Ok(await UpdatePlayerEvent(id, payload));
if (table == "gift_drops" || table == "storefront_items")
return Ok(await UpdateGiftDrop(id, payload));
if (table == "playlists")
return await UpdatePlaylist(id, payload);
if (table == "item_sets")
return await UpdateItemSet(id, payload);
if (table == "tags")
return await UpdateTag(id, payload);
var state = LoadState();
return table switch
{
"storefronts" => Ok(UpdateRow(state, state.Storefronts, id, payload)),
"challenges" => Ok(UpdateRow(state, state.Challenges, id, payload)),
"moderation_reports" => Ok(UpdateRow(state, state.ModerationReports, id, payload)),
"community_board" => Ok(await UpdateCommunityBoard(state, payload)),
_ => NotFound($"Unknown admin panel table '{table}'.")
};
}
[HttpPatch("{table}")]
public async Task<IActionResult> UpdateNoId(string table, [FromQuery] string key, [FromBody] JsonElement payload)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var state = LoadState();
return table switch
{
"community_board" => Ok(await UpdateCommunityBoard(state, payload)),
_ => NotFound($"Unknown admin panel table '{table}'.")
};
}
[HttpPatch("{table}/{id}")]
public async Task<IActionResult> UpdateByKey(string table, string id, [FromQuery] string key, [FromBody] JsonElement payload)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
if (table == "gameconfigs" || table == "serverconfigs")
return Ok(await UpsertConfig(table, id, payload));
if (long.TryParse(id, out var rowId))
return await Update(table, rowId, key, payload);
return NotFound($"Unknown admin panel table '{table}'.");
}
private async Task<IActionResult> UpsertConfig(string table, string? fallbackKey, JsonElement payload)
{
string? key = null, value = null;
if (payload.ValueKind == JsonValueKind.Object)
{
foreach (var prop in payload.EnumerateObject())
{
if (prop.Name.Equals("Key", StringComparison.OrdinalIgnoreCase))
key = prop.Value.ValueKind == JsonValueKind.String ? prop.Value.GetString() : prop.Value.ToString();
else if (prop.Name.Equals("Value", StringComparison.OrdinalIgnoreCase))
value = prop.Value.ValueKind == JsonValueKind.String ? prop.Value.GetString() : prop.Value.ToString();
}
}
key ??= fallbackKey;
if (string.IsNullOrWhiteSpace(key)) return BadRequest("Config 'Key' is required.");
value ??= "";
if (table == "gameconfigs")
{
var row = await db.GameConfig.FirstOrDefaultAsync(x => x.Key == key);
if (row == null) db.GameConfig.Add(new ConfigVal(key, value));
else row.Value = value;
}
else
{
var row = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == key);
if (row == null) db.ServerConfig.Add(new ServerConfigVal(key, value));
else row.Value = value;
}
await db.SaveChangesAsync();
return Ok(new { Key = key, Value = value });
}
[HttpDelete("{table}")]
public async Task<IActionResult> Delete(string table, [FromQuery] string key, [FromQuery(Name = "in_col")] string? inCol, [FromQuery(Name = "in_vals")] string? inVals, [FromQuery(Name = "eq_col")] string? eqCol, [FromQuery(Name = "eq_val")] string? eqVal, [FromQuery(Name = "room_id")] long? roomId, [FromQuery(Name = "stat_channel")] int? statChannel, [FromQuery(Name = "player_id")] long? playerId)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var ids = ParseIds(inVals ?? eqVal);
if (table == "gameconfigs" || table == "serverconfigs")
{
var cfgKeys = (inVals ?? eqVal ?? "")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
if (table == "gameconfigs")
{
var rows = await db.GameConfig.Where(x => cfgKeys.Contains(x.Key)).ToListAsync();
db.GameConfig.RemoveRange(rows);
await db.SaveChangesAsync();
return Ok(rows);
}
else
{
var rows = await db.ServerConfig.Where(x => cfgKeys.Contains(x.Key)).ToListAsync();
db.ServerConfig.RemoveRange(rows);
await db.SaveChangesAsync();
return Ok(rows);
}
}
if (table == "gift_drops" || table == "storefront_items")
{
var gifts = await db.GiftItems.Where(x => ids.Contains(x.GiftDropId)).ToListAsync();
db.GiftItems.RemoveRange(gifts);
await db.SaveChangesAsync();
return Ok(gifts);
}
if (table == "playlists")
{
var playlists = await db.Playlists.Where(x => ids.Contains(x.PlaylistId)).ToListAsync();
db.Playlists.RemoveRange(playlists);
await db.SaveChangesAsync();
return Ok(playlists);
}
if (table == "item_sets")
{
var sets = await db.ItemSets.Where(x => ids.Contains(x.ItemSetId)).ToListAsync();
db.ItemSets.RemoveRange(sets);
await db.SaveChangesAsync();
return Ok(sets);
}
if (table == "tags")
{
var tags = await db.Tags.Where(x => ids.Contains(x.TagId)).ToListAsync();
db.Tags.RemoveRange(tags);
await db.SaveChangesAsync();
return Ok(tags);
}
if (table == "player_events")
return Ok(await DeletePlayerEvents(ids));
if (table == "ip_bans")
{
var ipRows = await db.IpBans.Where(x => ids.Contains(x.Id)).ToListAsync();
db.IpBans.RemoveRange(ipRows);
await db.SaveChangesAsync();
return Ok(ipRows);
}
if (table == "hardware_bans")
{
var hwRows = await db.HardwareBans.Where(x => ids.Contains(x.Id)).ToListAsync();
db.HardwareBans.RemoveRange(hwRows);
await db.SaveChangesAsync();
return Ok(hwRows);
}
if (table == "account_creation_blocks")
{
var blockRows = await db.AccountCreationBlocks.Where(x => ids.Contains(x.Id)).ToListAsync();
db.AccountCreationBlocks.RemoveRange(blockRows);
await db.SaveChangesAsync();
return Ok(blockRows);
}
if (table == "game_blocks")
{
var gameBlockRows = await db.GameBlocks.Where(x => ids.Contains(x.Id)).ToListAsync();
db.GameBlocks.RemoveRange(gameBlockRows);
await db.SaveChangesAsync();
if (gameBlockRows.Count > 0) GameBlockMonitor.Invalidate();
return Ok(gameBlockRows);
}
if (table == "room_comments")
{
var commentRows = await db.RoomComments.Where(x => ids.Contains(x.Id)).ToListAsync();
db.RoomComments.RemoveRange(commentRows);
await db.SaveChangesAsync();
return Ok(commentRows);
}
if (table == "leaderboard_scores")
{
if (roomId.HasValue && statChannel.HasValue)
{
var query = db.LeaderboardScores.Where(x => x.StatChannel == statChannel.Value);
if (roomId.Value > 0)
query = query.Where(x => x.RoomId == roomId.Value);
if (playerId.HasValue)
query = query.Where(x => x.PlayerId == playerId.Value);
var boardRows = await query.ToListAsync();
db.LeaderboardScores.RemoveRange(boardRows);
await db.SaveChangesAsync();
return Ok(boardRows);
}
var scoreRows = await db.LeaderboardScores.Where(x => ids.Contains(x.Id)).ToListAsync();
db.LeaderboardScores.RemoveRange(scoreRows);
await db.SaveChangesAsync();
return Ok(scoreRows);
}
var state = LoadState();
return table switch
{
"storefronts" => Ok(DeleteRows(state, state.Storefronts, ids)),
"challenges" => Ok(DeleteRows(state, state.Challenges, ids)),
"notes" => Ok(DeleteRows(state, state.Notes, ids)),
"announcements" => Ok(await DeleteAnnouncements(ids)),
"clubs" => Ok(await DeleteClubs(ids)),
_ => NotFound($"Unknown admin panel table '{table}'.")
};
}
private async Task<List<object>> DeleteClubs(List<long> ids)
{
var deleted = new List<object>();
foreach (var id in ids)
{
if (id <= 0) continue;
if (await ClubStore.DeleteAsync(db, id)) deleted.Add(new { club_id = id });
}
return deleted;
}
private async Task<List<object>> DeleteAnnouncements(List<long> ids)
{
var config = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == "Announcement");
if (config == null || string.IsNullOrWhiteSpace(config.Value))
return new List<object>();
try
{
var list = config.Value.FromJson<List<JsonObject>>() ?? new List<JsonObject>();
var removed = new List<JsonObject>();
var toRemove = ids.Count > 0
? list.Where(x => ids.Contains(ReadAnnouncementId(x) ?? 0)).ToList()
: list;
foreach (var item in toRemove)
{
list.Remove(item);
removed.Add(item);
}
config.Value = JsonSerializer.Serialize(list);
await db.SaveChangesAsync();
foreach (var deleted in toRemove)
{
try { await Notify.SendGlobalNotify(AnnouncementBroadcast.Delete(ToAnnouncement(deleted))); }
catch (Exception) { }
}
var touchedMaintenance = toRemove.Any(x => string.Equals(x["type"]?.ToString(), "Maintenance", StringComparison.OrdinalIgnoreCase))
|| list.Any(x => string.Equals(x["type"]?.ToString(), "Maintenance", StringComparison.OrdinalIgnoreCase));
if (touchedMaintenance)
await RecomputeMaintenanceFromAnnouncements(list);
return removed.Cast<object>().ToList();
}
catch
{
return new List<object>();
}
}
private async Task ApplyMaintenanceToConfig(int startsInMinutes)
{
var config = await db.Config.FirstOrDefaultAsync();
if (config == null) return;
config.ServerMaintenance = new ServerMaintenance { StartsInMinutes = Math.Clamp(startsInMinutes, 0, 61) }.ToJson();
await db.SaveChangesAsync();
if (MainConfig.ActiveConfig != null)
MainConfig.ActiveConfig.ServerMaintenance = config.ServerMaintenance;
}
private async Task RecomputeMaintenanceFromAnnouncements(List<JsonObject> list)
{
var config = await db.Config.FirstOrDefaultAsync();
if (config == null) return;
var now = DateTime.UtcNow;
int minutes = 0;
foreach (var item in list)
{
if (!string.Equals(item["type"]?.ToString(), "Maintenance", StringComparison.OrdinalIgnoreCase)) continue;
if (item["expires_at"]?.ToString() is string expiresRaw &&
DateTime.TryParse(expiresRaw, out var expiry) && expiry <= now) continue;
var itemMinutes = item["minutes"]?.GetValue<int>();
if (itemMinutes.HasValue && itemMinutes.Value > 0)
minutes = Math.Min(61, Math.Max(minutes, itemMinutes.Value));
}
config.ServerMaintenance = new ServerMaintenance { StartsInMinutes = minutes }.ToJson();
await db.SaveChangesAsync();
if (MainConfig.ActiveConfig != null)
MainConfig.ActiveConfig.ServerMaintenance = config.ServerMaintenance;
}
private async Task<List<object>> GetPlayers(string? order, bool ascending)
{
var profiles = await db.Profiles.ToListAsync();
var playerIds = profiles.Select(x => x.accountId).ToList();
var players = await db.Players.Where(x => playerIds.Contains(x.AccountId)).ToDictionaryAsync(x => x.AccountId);
var progression = await db.Progression.Where(x => playerIds.Contains(x.PlayerId)).ToDictionaryAsync(x => x.PlayerId);
var reputations = await db.Reputations.Where(x => playerIds.Contains(x.AccountId)).ToDictionaryAsync(x => x.AccountId);
var presences = await db.Presences.Where(x => playerIds.Contains(x.playerId)).ToDictionaryAsync(x => x.playerId);
var moderation = await db.Moderation.Where(x => playerIds.Contains(x.PlayerId) && x.IsBan).ToListAsync();
var expiredBans = moderation.Where(m => m.ExpireDuration < DateTime.UtcNow).ToList();
foreach (var expired in expiredBans)
{
db.Moderation.Remove(expired);
moderation.Remove(expired);
}
if (expiredBans.Count > 0) await db.SaveChangesAsync();
var activeBans = moderation.ToDictionary(x => x.PlayerId);
var hardwareBans = await db.HardwareBans.ToListAsync();
var ipBans = await db.IpBans.ToListAsync();
var accountCreationBlocks = await db.AccountCreationBlocks.ToListAsync();
var settings = await db.PlayerSettings.Where(x => playerIds.Contains(x.AccountId)).ToDictionaryAsync(x => x.AccountId);
var currencies = await db.Currency.Where(x => playerIds.Contains(x.AccountId)).ToDictionaryAsync(x => x.AccountId);
var cached = await db.CachedLogins.Where(x => playerIds.Contains(x.accountId)).ToListAsync();
var connectedAccounts = new HashSet<long>(Notify.SnapshotClients().Select(x => x.AccountId));
var rows = profiles.Select(profile =>
{
players.TryGetValue(profile.accountId, out var player);
progression.TryGetValue(profile.accountId, out var progress);
reputations.TryGetValue(profile.accountId, out var rep);
presences.TryGetValue(profile.accountId, out var presence);
activeBans.TryGetValue(profile.accountId, out var ban);
var progressJson = progress?.GetProgressionJson();
var hardwareBan = player != null && !string.IsNullOrWhiteSpace(player.DeviceId)
? hardwareBans.FirstOrDefault(h => h.DeviceId == player.DeviceId && (!h.ExpiresAt.HasValue || h.ExpiresAt.Value >= DateTime.UtcNow))
: null;
var ipBan = player != null && !string.IsNullOrWhiteSpace(player.LastIpAddress)
? ipBans.FirstOrDefault(ip => ip.IpAddress == player.LastIpAddress && (!ip.ExpiresAt.HasValue || ip.ExpiresAt.Value >= DateTime.UtcNow))
: null;
var ipBanned = ipBan != null;
var accountCreationBlocked = player != null && !string.IsNullOrWhiteSpace(player.LastIpAddress)
? accountCreationBlocks.Any(b => b.IpAddress == player.LastIpAddress && (!b.ExpiresAt.HasValue || b.ExpiresAt.Value >= DateTime.UtcNow))
: false;
settings.TryGetValue(profile.accountId, out var playerSettings);
currencies.TryGetValue(profile.accountId, out var currency);
var login = cached.FirstOrDefault(x => x.accountId == profile.accountId);
var birthday = player?.Birthday;
var hasRealBirthday = birthday != null && birthday.Value.Year > 1900;
var age = hasRealBirthday ? (int?)Math.Floor((DateTime.UtcNow - birthday!.Value).TotalDays / 365.2425) : null;
var settingsList = playerSettings?.DeserializeSettings() ?? new List<Setting>();
var settingsMap = settingsList
.Where(x => !string.IsNullOrWhiteSpace(x.Key))
.GroupBy(x => x.Key)
.ToDictionary(x => x.Key, x => x.Last().Value);
var nameHistoryList = new List<string>();
if (settingsMap.TryGetValue("NameHistory", out var nhRaw))
{
try { nameHistoryList = nhRaw.FromJson<List<string>>() ?? new List<string>(); } catch { nameHistoryList = new List<string>(); }
}
bool isOnline = presence?.isOnline == true && connectedAccounts.Contains(profile.accountId);
return new
{
id = profile.accountId,
name = profile.displayName,
original_name = profile.username,
username = profile.username,
platform = login?.platform.ToString() ?? "Steam",
platform_id = login?.platformId ?? "",
joined_at = DateTime.SpecifyKind(profile.createdAt, DateTimeKind.Utc),
last_online_at = isOnline ? DateTime.UtcNow : DateTime.SpecifyKind(login?.lastLoginTime ?? profile.createdAt, DateTimeKind.Utc),
bio = profile.bio,
email = player?.Email ?? "",
level = progress?.Level ?? 0,
xp = progress?.XP ?? 0,
last_xp_earned_at = progressJson?.LastXpEarnedAt,
xp_earned_today = progressJson?.XpEarnedToday ?? 0,
reputation = rep?.ReputationScore ?? Reputation.DefaultReputation,
trust = rep?.Trust ?? "Trusted",
report_count = rep?.ReportCount ?? 0,
noteriety = rep?.Noteriety ?? 0,
cheer_general = rep?.CheerGeneral ?? 0,
cheer_helpful = rep?.CheerHelpful ?? 0,
cheer_great_host = rep?.CheerGreatHost ?? 0,
cheer_sportsman = rep?.CheerSportsman ?? 0,
cheer_creative = rep?.CheerCreative ?? 0,
cheer_credit = rep?.CheerCredit ?? 0,
subscriber_count = 0,
subscribed_count = 0,
name_history = nameHistoryList,
settings = settingsMap,
avatar_image = ImageUrl(profile.profileImage),
avatar_color = (string?)null,
is_online = isOnline,
online = isOnline,
banned = ban != null || hardwareBan != null || ipBan != null,
ban_reason = ban?.Message ?? hardwareBan?.Reason ?? ipBan?.Reason ?? "",
ban_type = ban != null
? (ban.ExpireDuration >= DateTime.MaxValue.AddYears(-10) || ban.ExpireDuration - DateTime.UtcNow > TimeSpan.FromDays(365 * 10)
? "Permanent"
: "Temporary")
: (hardwareBan != null
? (hardwareBan.ExpiresAt.HasValue ? "Temporary" : "Permanent")
: (ipBan != null ? (ipBan.ExpiresAt.HasValue ? "Temporary" : "Permanent") : "Temporary")),
ban_until = ban != null
? DateTime.SpecifyKind(ban.ExpireDuration, DateTimeKind.Utc)
: (hardwareBan != null && hardwareBan.ExpiresAt.HasValue
? DateTime.SpecifyKind(hardwareBan.ExpiresAt.Value, DateTimeKind.Utc)
: (ipBan != null && ipBan.ExpiresAt.HasValue
? DateTime.SpecifyKind(ipBan.ExpiresAt.Value, DateTimeKind.Utc)
: (DateTime?)null)),
ban_method = ban != null && !string.IsNullOrWhiteSpace(ban.BanMethod)
? ban.BanMethod
: (hardwareBan != null ? "Hardware Ban" : (ipBan != null ? "IP Ban" : "None")),
ban_is_moderator = ban?.Message?.StartsWith("Moderator.", StringComparison.OrdinalIgnoreCase) == true,
ban_category = ban?.Category ?? (hardwareBan != null ? ReportCategory.Misc : (ipBan != null ? ReportCategory.Misc : ReportCategory.Unknown)),
appeal_code = ban?.AppealCode,
ban_action_type = ModerationPolicy.IsVoiceModeration(ban?.BanMethod) ? "VoiceModeration" : (ModerationPolicy.IsTimeout(ban?.BanMethod) ? "Timeout" : "Moderation"),
account_locked = ban != null && string.Equals(ban.BanMethod, "Account Lock", StringComparison.OrdinalIgnoreCase),
ban_display_reason = ban?.Message != null && ModerationPolicy.IsVoiceModeration(ban.BanMethod)
? $"{ModerationPolicy.AutomaticVoiceModerationBanText}\n\n{ban.Message}"
: ban?.Message,
ban_started_at = ban != null ? DateTime.SpecifyKind(ban.CreatedAt, DateTimeKind.Utc) : (hardwareBan != null ? DateTime.SpecifyKind(hardwareBan.CreatedAt, DateTimeKind.Utc) : (ipBan != null ? DateTime.SpecifyKind(ipBan.CreatedAt, DateTimeKind.Utc) : (DateTime?)null)),
ban_related_account_username = hardwareBan?.BannedAccountId.HasValue == true && hardwareBan.BannedAccountId.Value != profile.accountId
? profiles.FirstOrDefault(p => p.accountId == hardwareBan.BannedAccountId.Value)?.username
: null,
ban_note = ban?.ModeratorNote,
ip_banned = ipBanned,
hardware_banned = hardwareBan != null,
account_creation_blocked = accountCreationBlocked,
device_class = player?.DeviceClass switch
{
DeviceClass.VR or DeviceClass.VRLow or DeviceClass.Quest2 => "VR",
DeviceClass.Screen or DeviceClass.Mobile => "Screen",
_ => "Screen"
},
device_id = player?.DeviceId ?? "",
ip = player?.LastIpAddress ?? "",
is_junior = profile.isJunior,
jr_state = player?.JuniorState.ToString() ?? "N/A",
name_locked = profile.NameLocked,
bio_locked = profile.BioLocked,
parent_account_id = player?.Parent,
has_birthday = hasRealBirthday,
age = age,
is_parental_locked = settingsMap.ContainsKey("IsParentalLocked") && settingsMap["IsParentalLocked"] == "true",
is_coach = false,
coach_status = "",
roles = profile.role?.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(r => r.Trim()).ToList() ?? new List<string>(),
rec_tokens = currency?.RecTokens ?? 0,
laser_tag_tickets = currency?.LaserTagTickets ?? 0,
gold = currency?.Gold ?? 0,
dracula_silver = currency?.DraculaSilver ?? 0
};
}).Cast<object>().ToList();
return OrderRows(rows, order, ascending);
}
private async Task<List<object>> GetStorefrontItems(string? eqCol, string? eqVal, string? order, bool ascending)
{
var storefrontId = eqCol == "storefront_id" && long.TryParse(eqVal, out var parsed) ? parsed : (long?)null;
if (storefrontId is > 0 && StorefrontCatalog.StorefrontIds.Contains(storefrontId.Value))
return OrderRows(CatalogStorefrontItems(storefrontId.Value), order, ascending);
var query = db.GiftItems.AsQueryable();
if (storefrontId != null)
query = query.Where(x => x.StorefrontId == storefrontId.Value);
else
query = query.Where(x => x.StorefrontId > 0);
var giftDrops = await query.ToListAsync();
var storefrontNames = GetStorefrontDisplayNames();
var rows = giftDrops.Select(g => new
{
id = g.GiftDropId,
name = g.Name,
start_time = g.StartTime,
end_time = g.EndTime,
storefront_id = g.StorefrontId,
storefront_name = storefrontNames.TryGetValue(g.StorefrontId, out var sfName) ? sfName : "",
description = g.Description,
currency_amount = g.CurrencyAmount,
xp_amount = g.XPAmount,
avatar_items = g.AvatarItems,
equipment_items = g.EquipmentItems,
consumable_items = g.ConsumableItems,
purchasable_item_id = g.PurchasableItemId,
is_featured = g.IsFeatured,
price = g.Price,
currency_type = g.CurrencyType.ToString(),
rarity = g.Rarity.ToString(),
context = g.Context.ToString(),
avatar_item_type = g.AvatarItemType.ToString(),
equipment_modification_guid = g.EquipmentModificationGuid,
tooltip = g.Tooltip,
is_query = g.IsQuery,
unique = g.Unique,
subscribers_only = g.SubscribersOnly,
item_set_id = g.ItemSetId,
item_set_friendly_name = g.ItemSetFriendlyName
}).Cast<object>().ToList();
return OrderRows(rows, order, ascending);
}
private static List<object> CatalogStorefrontItems(long storefrontId)
{
var storefrontName = StorefrontDisplayName(storefrontId);
var rows = new List<object>();
foreach (var item in StorefrontCatalog.GetItems(storefrontId))
{
var gift = item.GiftDrop;
var price = item.Prices.FirstOrDefault() ?? item.SubscriberPrices.FirstOrDefault();
rows.Add(new
{
id = item.PurchasableItemId,
name = gift?.FriendlyName ?? "",
start_time = (DateTime?)null,
end_time = (DateTime?)null,
storefront_id = storefrontId,
storefront_name = storefrontName,
description = "",
currency_amount = gift?.Currency ?? 0,
xp_amount = gift?.Xp ?? 0,
avatar_items = gift?.AvatarItemDesc ?? "",
equipment_items = gift?.EquipmentPrefabName ?? "",
consumable_items = gift?.ConsumableItemDesc ?? "",
gift_drop_id = gift?.GiftDropId ?? 0,
purchasable_item_id = item.PurchasableItemId,
is_featured = item.IsFeatured,
price = price?.Price ?? 0,
currency_type = (price?.CurrencyType ?? CurrencyType.RecCenterTokens).ToString(),
rarity = (gift?.Rarity ?? Rarity.Common).ToString(),
context = (gift?.Context ?? GiftContext.Commerce_Purchase).ToString(),
avatar_item_type = (gift?.AvatarItemType ?? AvatarItemType.Outfit).ToString(),
equipment_modification_guid = gift?.EquipmentModificationGuid ?? "",
tooltip = gift?.Tooltip ?? "",
is_query = gift?.IsQuery ?? false,
unique = gift?.Unique ?? false,
subscribers_only = gift?.SubscribersOnly ?? false,
item_set_id = gift?.ItemSetId ?? 0,
item_set_friendly_name = gift?.ItemSetFriendlyName ?? "",
is_shipped = true
});
}
return rows;
}
private async Task<List<object>> GetGiftDrops(string? order, bool ascending)
{
var giftDrops = await db.GiftItems.ToListAsync();
var storefrontNames = GetStorefrontDisplayNames();
var rows = giftDrops.Select(g => new
{
id = g.GiftDropId,
name = g.Name,
start_time = g.StartTime,
end_time = g.EndTime,
storefront_id = g.StorefrontId,
storefront_name = storefrontNames.TryGetValue(g.StorefrontId, out var sfName) ? sfName : "",
description = g.Description,
currency_amount = g.CurrencyAmount,
xp_amount = g.XPAmount,
avatar_items = g.AvatarItems,
equipment_items = g.EquipmentItems,
consumable_items = g.ConsumableItems,
purchasable_item_id = g.PurchasableItemId,
is_featured = g.IsFeatured,
price = g.Price,
currency_type = g.CurrencyType.ToString(),
rarity = g.Rarity.ToString(),
context = g.Context.ToString(),
avatar_item_type = g.AvatarItemType.ToString(),
equipment_modification_guid = g.EquipmentModificationGuid,
tooltip = g.Tooltip,
is_query = g.IsQuery,
unique = g.Unique,
subscribers_only = g.SubscribersOnly,
item_set_id = g.ItemSetId,
item_set_friendly_name = g.ItemSetFriendlyName
}).Cast<object>().ToList();
return OrderRows(rows, order, ascending);
}
private static readonly JsonSerializerOptions CatalogJsonOptions = new()
{
PropertyNameCaseInsensitive = true,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
private List<object> GetEquipmentItems()
{
var defaultItems = DBConfig.DefaultEquipmentItems.FromJson<List<Equipment>>(CatalogJsonOptions) ?? new List<Equipment>();
var rows = defaultItems
.Where(x => x != null && !string.IsNullOrWhiteSpace(x.PrefabName))
.Select(item => new
{
equipment_prefab_name = item.PrefabName,
modification_guid = item.ModificationGuid ?? "",
friendly_name = string.IsNullOrWhiteSpace(item.FriendlyName) ? item.PrefabName : item.FriendlyName,
tooltip = item.Tooltip ?? "",
rarity = item.Rarity.ToString()
}).ToList();
if (rows.Count > 0)
return rows.Cast<object>().ToList();
var legacyItems = DBConfig.DefaultEquipmentItems.FromJson<List<EquipmentItem>>(CatalogJsonOptions) ?? new List<EquipmentItem>();
return legacyItems
.Where(x => x != null && !string.IsNullOrWhiteSpace(x.EquipmentPrefabName))
.Select(item => new
{
equipment_prefab_name = item.EquipmentPrefabName,
modification_guid = "",
friendly_name = string.IsNullOrWhiteSpace(item.FriendlyName) ? item.EquipmentPrefabName : item.FriendlyName,
tooltip = item.Tooltip ?? "",
rarity = item.Rarity.ToString()
}).Cast<object>().ToList();
}
private List<object> GetConsumableItems()
{
var items = DBConfig.DefaultConsumableItems.FromJson<List<ConsumableItem>>(CatalogJsonOptions) ?? new List<ConsumableItem>();
return items
.Where(x => x != null && !string.IsNullOrWhiteSpace(x.ConsumableItemDesc))
.Select(item => new
{
consumable_item_desc = item.ConsumableItemDesc,
friendly_name = string.IsNullOrWhiteSpace(item.FriendlyName) ? item.ConsumableItemDesc : item.FriendlyName,
tooltip = item.Tooltip ?? "",
rarity = item.Rarity.ToString()
}).Cast<object>().ToList();
}
private async Task<List<object>> GetIpBans(string? order, bool ascending)
{
var now = DateTime.UtcNow;
var expired = await db.IpBans.Where(x => x.ExpiresAt != null && x.ExpiresAt < now).ToListAsync();
if (expired.Count > 0)
{
db.IpBans.RemoveRange(expired);
await db.SaveChangesAsync();
}
var rows = await db.IpBans.AsNoTracking()
.Select(x => new
{
id = x.Id,
ip = x.IpAddress,
reason = x.Reason,
created_at = x.CreatedAt,
expires_at = x.ExpiresAt,
is_permanent = x.ExpiresAt == null
})
.ToListAsync();
return OrderRows(rows.Cast<object>().ToList(), order, ascending);
}
private async Task<List<object>> GetHardwareBans(string? order, bool ascending)
{
var now = DateTime.UtcNow;
var expired = await db.HardwareBans.Where(x => x.ExpiresAt != null && x.ExpiresAt < now).ToListAsync();
if (expired.Count > 0)
{
db.HardwareBans.RemoveRange(expired);
await db.SaveChangesAsync();
}
var rows = await db.HardwareBans.AsNoTracking()
.Select(x => new
{
id = x.Id,
device_id = x.DeviceId,
reason = x.Reason,
created_at = x.CreatedAt,
expires_at = x.ExpiresAt,
is_permanent = x.ExpiresAt == null
})
.ToListAsync();
return OrderRows(rows.Cast<object>().ToList(), order, ascending);
}
private async Task<JsonObject> AddIpBan(JsonElement payload)
{
var ip = GetJsonString(payload, "ip") ?? GetJsonString(payload, "ip_address") ?? "";
var reason = GetJsonString(payload, "reason") ?? "Banned via admin panel";
var expiresAt = TryGetJsonDate(payload, "expires_at");
var ipBan = new IpBan
{
IpAddress = ip,
Reason = reason,
CreatedAt = DateTime.UtcNow,
ExpiresAt = expiresAt
};
await db.IpBans.AddAsync(ipBan);
await db.SaveChangesAsync();
return new JsonObject
{
["id"] = ipBan.Id,
["ip"] = ipBan.IpAddress,
["reason"] = ipBan.Reason,
["created_at"] = ipBan.CreatedAt,
["expires_at"] = ipBan.ExpiresAt,
["is_permanent"] = ipBan.ExpiresAt == null
};
}
private async Task<JsonObject> AddHardwareBan(JsonElement payload)
{
var deviceId = GetJsonString(payload, "device_id") ?? "";
var reason = GetJsonString(payload, "reason") ?? "Banned via admin panel";
var expiresAt = TryGetJsonDate(payload, "expires_at");
var hardwareBan = new HardwareBan
{
DeviceId = deviceId,
Reason = reason,
CreatedAt = DateTime.UtcNow,
ExpiresAt = expiresAt
};
await db.HardwareBans.AddAsync(hardwareBan);
await db.SaveChangesAsync();
return new JsonObject
{
["id"] = hardwareBan.Id,
["device_id"] = hardwareBan.DeviceId,
["reason"] = hardwareBan.Reason,
["created_at"] = hardwareBan.CreatedAt,
["expires_at"] = hardwareBan.ExpiresAt,
["is_permanent"] = hardwareBan.ExpiresAt == null
};
}
private async Task<List<object>> GetAccountCreationBlocks(string? order, bool ascending)
{
var now = DateTime.UtcNow;
var expired = await db.AccountCreationBlocks.Where(x => x.ExpiresAt != null && x.ExpiresAt < now).ToListAsync();
if (expired.Count > 0)
{
db.AccountCreationBlocks.RemoveRange(expired);
await db.SaveChangesAsync();
}
var rows = await db.AccountCreationBlocks.AsNoTracking()
.Select(x => new
{
id = x.Id,
ip = x.IpAddress,
reason = x.Reason,
created_at = x.CreatedAt,
expires_at = x.ExpiresAt,
is_permanent = x.ExpiresAt == null
})
.ToListAsync();
return OrderRows(rows.Cast<object>().ToList(), order, ascending);
}
private async Task<JsonObject> AddAccountCreationBlock(JsonElement payload)
{
var ip = GetJsonString(payload, "ip") ?? GetJsonString(payload, "ip_address") ?? "";
var reason = GetJsonString(payload, "reason") ?? "Blocked via admin panel";
var expiresAt = TryGetJsonDate(payload, "expires_at");
var block = new AccountCreationBlock
{
IpAddress = ip,
Reason = reason,
CreatedAt = DateTime.UtcNow,
ExpiresAt = expiresAt
};
await db.AccountCreationBlocks.AddAsync(block);
await db.SaveChangesAsync();
return new JsonObject
{
["id"] = block.Id,
["ip"] = block.IpAddress,
["reason"] = block.Reason,
["created_at"] = block.CreatedAt,
["expires_at"] = block.ExpiresAt,
["is_permanent"] = block.ExpiresAt == null
};
}
private async Task<List<object>> GetGameBlocks(string? order, bool ascending)
{
var now = DateTime.UtcNow;
var expired = await db.GameBlocks.Where(x => x.ExpiresAt != null && x.ExpiresAt < now).ToListAsync();
if (expired.Count > 0)
{
db.GameBlocks.RemoveRange(expired);
await db.SaveChangesAsync();
}
var rows = await db.GameBlocks.AsNoTracking()
.Select(x => new
{
id = x.Id,
ip = x.IpAddress,
reason = x.Reason,
created_at = x.CreatedAt,
expires_at = x.ExpiresAt,
is_permanent = x.ExpiresAt == null
})
.ToListAsync();
return OrderRows(rows.Cast<object>().ToList(), order, ascending);
}
private async Task<object> AddGameBlock(JsonElement payload)
{
var ip = GameBlockMonitor.NormalizeIp(GetJsonString(payload, "ip") ?? GetJsonString(payload, "ip_address") ?? "");
if (string.IsNullOrWhiteSpace(ip))
return BadRequest("An IP address is required to create a game block.");
var reason = GetJsonString(payload, "reason") ?? "Blocked via admin panel";
var expiresAt = TryGetJsonDate(payload, "expires_at");
var block = new GameBlock
{
IpAddress = ip,
Reason = reason,
CreatedAt = DateTime.UtcNow,
ExpiresAt = expiresAt
};
await db.GameBlocks.AddAsync(block);
await db.SaveChangesAsync();
GameBlockMonitor.Invalidate();
return new JsonObject
{
["id"] = block.Id,
["ip"] = block.IpAddress,
["reason"] = block.Reason,
["created_at"] = block.CreatedAt,
["expires_at"] = block.ExpiresAt,
["is_permanent"] = block.ExpiresAt == null
};
}
private async Task<List<object>> GetLeaderboardScores(string? eqCol, string? eqVal, string? order, bool ascending)
{
var query = db.LeaderboardScores.AsNoTracking();
if (eqCol == "room_id" && long.TryParse(eqVal, out var roomId))
query = query.Where(x => x.RoomId == roomId);
var rows = await query.OrderByDescending(x => x.SubmittedAt).Take(5000).ToListAsync();
var playerNames = await LoadPlayerNames(rows.Select(x => x.PlayerId).Distinct().ToList());
var roomIds = rows.Select(x => x.RoomId).Distinct().ToList();
var rooms = await db.Rooms.AsNoTracking()
.Where(r => roomIds.Contains(r.RoomId))
.ToDictionaryAsync(r => r.RoomId, r => r.Name);
var result = rows.Select(s => (object)new
{
id = s.Id,
room_id = s.RoomId,
room_name = rooms.TryGetValue(s.RoomId, out var roomName) ? roomName : $"Room {s.RoomId}",
sub_room_id = s.SubRoomId,
player_id = s.PlayerId,
player_name = playerNames.GetValueOrDefault(s.PlayerId) ?? $"Player {s.PlayerId}",
stat_channel = s.StatChannel,
score = s.Score,
time = s.Time,
submitted_at = DateTime.SpecifyKind(s.SubmittedAt, DateTimeKind.Utc)
}).ToList();
return OrderRows(result, order, ascending);
}
private async Task<Dictionary<long, string>> LoadPlayerNames(List<long> playerIds)
{
if (playerIds.Count == 0) return new Dictionary<long, string>();
var profiles = await db.Profiles.AsNoTracking()
.Where(x => playerIds.Contains(x.accountId))
.ToDictionaryAsync(x => x.accountId);
return profiles.ToDictionary(
kv => kv.Key,
kv => kv.Value.displayName ?? kv.Value.username ?? $"Player {kv.Key}");
}
private List<object> GetStorefronts(string? order, bool ascending)
{
var state = LoadState();
var rows = new List<object>();
var seen = new HashSet<long>();
foreach (var id in StorefrontCatalog.StorefrontIds)
{
seen.Add(id);
var existing = state.Storefronts.FirstOrDefault(s => (s["id"]?.GetValue<long>() ?? 0) == id);
rows.Add(StorefrontRow(id, StorefrontLabel(id), existing, StorefrontCatalog.GetItems(id).Count, shipped: true));
}
foreach (var sf in state.Storefronts)
{
var id = sf["id"]?.GetValue<long>() ?? 0;
if (id <= 0 || !seen.Add(id)) continue;
rows.Add(StorefrontRow(id, sf["type"]?.ToString(), sf, db.GiftItems.Count(x => x.StorefrontId == id), shipped: false));
}
foreach (var id in db.GiftItems.Where(x => x.StorefrontId > 0).Select(x => x.StorefrontId).Distinct().ToList())
{
if (!seen.Add(id)) continue;
rows.Add(StorefrontRow(id, null, null, db.GiftItems.Count(x => x.StorefrontId == id), shipped: false));
}
return OrderRows(rows, order, ascending);
}
private static object StorefrontRow(long id, string? type, JsonObject? stateRow, int giftDropCount, bool shipped)
{
var label = string.IsNullOrWhiteSpace(type) ? StorefrontLabel(id) : type;
var friendlyName = stateRow?["friendly_name"]?.ToString();
return new
{
id,
type = label,
next_update = stateRow?["next_update"]?.ToString() ?? "",
new_until = stateRow?["new_until"]?.ToString() ?? "",
friendly_name = string.IsNullOrWhiteSpace(friendlyName) ? label : friendlyName,
gift_drop_count = giftDropCount,
is_auto_detected = shipped
};
}
private static string? StorefrontTypeName(long id)
{
if (id <= 0 || id > int.MaxValue) return null;
if (id == (long)StorefrontTypes.None) return null;
if (!Enum.IsDefined(typeof(StorefrontTypes), (int)id)) return null;
return ((StorefrontTypes)id).ToString();
}
private static string StorefrontLabel(long id) => StorefrontTypeName(id) ?? $"#{id}";
private static string StorefrontDisplayName(long id) =>
StorefrontTypeName(id) is { } name ? $"{name} ({id})" : $"#{id}";
private Dictionary<long, string> GetStorefrontDisplayNames()
{
var names = new Dictionary<long, string>();
foreach (var type in Enum.GetValues<StorefrontTypes>())
{
if (type == StorefrontTypes.None) continue;
names[(long)type] = $"{type} ({(long)type})";
}
foreach (var id in StorefrontCatalog.StorefrontIds)
names[id] = StorefrontDisplayName(id);
var state = LoadState();
foreach (var sf in state.Storefronts)
{
var id = sf["id"]?.GetValue<long>() ?? 0;
if (id > 0 && !names.ContainsKey(id))
{
names[id] = $"{sf["type"]?.ToString() ?? $"#{id}"} ({id})";
}
}
foreach (var id in db.GiftItems.Where(x => x.StorefrontId > 0).Select(x => x.StorefrontId).Distinct().ToList())
names.TryAdd(id, StorefrontDisplayName(id));
return names;
}
private List<object> GetChallenges(string? order, bool ascending)
{
var state = LoadState();
return OrderRows(state.Challenges.Cast<object>().ToList(), order, ascending);
}
private List<object> FilterByPlayer(List<JsonObject> rows, string? eqCol, string? eqVal, string? order, bool ascending)
{
if (!string.IsNullOrWhiteSpace(eqCol) && eqCol == "player_id" && eqVal != null)
{
rows = rows.Where(x => x["player_id"]?.ToString() == eqVal).ToList();
}
return OrderRows(rows.Cast<object>().ToList(), order, ascending);
}
private static List<object> OrderRows(List<object> rows, string? order, bool ascending)
{
if (string.IsNullOrWhiteSpace(order))
return rows;
var sorted = rows.OrderBy(x => 0);
if (rows.Count > 0 && rows[0] is JsonObject rowObj)
{
var first = rows[0] as JsonObject;
if (first != null && first.ContainsKey(order))
{
sorted = ascending
? rows.OrderBy(x => (x as JsonObject)?[order]?.ToString())
: rows.OrderByDescending(x => (x as JsonObject)?[order]?.ToString());
}
}
return sorted.ToList();
}
private static JsonObject AddStorefront(AdminPanelState state, JsonElement payload)
{
var row = new JsonObject();
foreach (var prop in payload.EnumerateObject())
{
try
{
row[prop.Name] = JsonNode.Parse(prop.Value.GetRawText());
}
catch
{
if (prop.Value.ValueKind == JsonValueKind.String)
row[prop.Name] = prop.Value.GetString();
else if (prop.Value.ValueKind == JsonValueKind.Number)
row[prop.Name] = prop.Value.GetRawText();
else if (prop.Value.ValueKind == JsonValueKind.True)
row[prop.Name] = true;
else if (prop.Value.ValueKind == JsonValueKind.False)
row[prop.Name] = false;
else if (prop.Value.ValueKind == JsonValueKind.Null)
row[prop.Name] = null;
else
row[prop.Name] = prop.Value.GetRawText();
}
}
if (!row.ContainsKey("id"))
{
var rawType = row["type"]?.ToString() ?? "";
long id = 0;
foreach (var type in Enum.GetValues<StorefrontTypes>())
{
if (type == StorefrontTypes.None) continue;
if (string.Equals(type.ToString(), rawType, StringComparison.OrdinalIgnoreCase))
{
id = (long)type;
break;
}
}
if (id == 0)
{
long maxId = 10000;
foreach (var existing in state.Storefronts)
{
var rawId = existing["id"]?.ToString();
if (rawId != null && long.TryParse(rawId, out var parsedId) && parsedId >= maxId)
maxId = parsedId + 1;
}
id = maxId;
}
row["id"] = id;
}
state.Storefronts.Add(row);
SaveState(state);
return row;
}
private static JsonObject AddChallenge(AdminPanelState state, JsonElement payload)
{
var row = new JsonObject();
foreach (var prop in payload.EnumerateObject())
{
try
{
row[prop.Name] = JsonNode.Parse(prop.Value.GetRawText());
}
catch
{
if (prop.Value.ValueKind == JsonValueKind.String)
row[prop.Name] = prop.Value.GetString();
else if (prop.Value.ValueKind == JsonValueKind.Number)
row[prop.Name] = prop.Value.GetRawText();
else if (prop.Value.ValueKind == JsonValueKind.True)
row[prop.Name] = true;
else if (prop.Value.ValueKind == JsonValueKind.False)
row[prop.Name] = false;
else if (prop.Value.ValueKind == JsonValueKind.Null)
row[prop.Name] = null;
else
row[prop.Name] = prop.Value.GetRawText();
}
}
if (!row.ContainsKey("id"))
{
row["id"] = state.Challenges.Count > 0 ? state.Challenges.Max(x => x["id"]?.GetValue<long>() ?? 0) + 1 : 1;
}
state.Challenges.Add(row);
SaveState(state);
return row;
}
private static object RowsResponse(JsonElement payload, List<JsonObject> rows)
{
if (payload.ValueKind == JsonValueKind.Array)
return rows;
return rows.Count > 0 ? rows[0] : new JsonObject();
}
private IActionResult InsertLoggedRows(AdminPanelState state, List<JsonObject> targetList, JsonElement payload, string table)
{
var rows = AddRows(state, targetList, payload);
NotifyPanelActionDiscord(table, payload);
return Ok(RowsResponse(payload, rows));
}
private async Task<IActionResult> InsertGiftLogRows(AdminPanelState state, JsonElement payload)
{
var rows = await AddGiftLogRows(state, payload);
NotifyPanelActionDiscord("gift_log", payload);
return Ok(RowsResponse(payload, rows));
}
private void NotifyPanelActionDiscord(string table, JsonElement payload)
{
var items = new List<JsonElement>();
if (payload.ValueKind == JsonValueKind.Array)
{
foreach (var el in payload.EnumerateArray()) items.Add(el);
}
else
{
items.Add(payload);
}
foreach (var item in items)
{
if (item.ValueKind != JsonValueKind.Object) continue;
var action = GetJsonString(item, "action") ?? table;
var admin = GetJsonString(item, "performed_by") ?? GetJsonString(item, "admin_id") ?? "Unknown";
var playerId = GetJsonString(item, "player_id") ?? "";
string? details = null;
if (item.TryGetProperty("details", out var d))
{
details = d.ValueKind == JsonValueKind.String ? d.GetString() : d.GetRawText();
}
else if (table == "gift_log")
{
var giftParts = new List<string>();
foreach (var name in new[] { "context", "rarity", "avatar", "equip", "cons", "currency", "currency_type", "xp", "message" })
{
var val = GetJsonString(item, name);
if (!string.IsNullOrWhiteSpace(val) && val != "null")
giftParts.Add($"{name}: {val}");
}
details = string.Join(", ", giftParts);
}
var fullDetails = string.IsNullOrWhiteSpace(playerId) ? details : $"Player {playerId} — {details}";
AdminActionDiscord.LogAction(action, admin, fullDetails);
}
}
private async Task<string> GetPanelAdminUsernameAsync()
{
if (Request.Cookies.TryGetValue(AdminSession.CookieName, out var token))
{
var (ok, userId) = await AdminSession.ValidateAsync(db, token);
if (ok)
{
var user = await db.AdminUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId);
if (user != null) return user.Username;
}
}
return "Admin";
}
private static List<JsonObject> AddRows(AdminPanelState state, List<JsonObject> targetList, JsonElement payload)
{
var items = new List<JsonElement>();
if (payload.ValueKind == JsonValueKind.Array)
{
foreach (var el in payload.EnumerateArray()) items.Add(el);
}
else
{
items.Add(payload);
}
var added = new List<JsonObject>();
foreach (var item in items)
{
if (item.ValueKind != JsonValueKind.Object) continue;
var row = new JsonObject();
foreach (var prop in item.EnumerateObject())
{
try
{
row[prop.Name] = JsonNode.Parse(prop.Value.GetRawText());
}
catch
{
if (prop.Value.ValueKind == JsonValueKind.String)
row[prop.Name] = prop.Value.GetString();
else if (prop.Value.ValueKind == JsonValueKind.Number)
row[prop.Name] = prop.Value.GetRawText();
else if (prop.Value.ValueKind == JsonValueKind.True)
row[prop.Name] = true;
else if (prop.Value.ValueKind == JsonValueKind.False)
row[prop.Name] = false;
else if (prop.Value.ValueKind == JsonValueKind.Null)
row[prop.Name] = null;
else
row[prop.Name] = prop.Value.GetRawText();
}
}
if (!row.ContainsKey("id"))
{
long nextId = 1;
if (targetList.Count > 0)
{
foreach (var existing in targetList)
{
var rawId = existing["id"]?.ToString();
if (rawId != null && long.TryParse(rawId, out var parsedId) && parsedId >= nextId)
nextId = parsedId + 1;
}
}
row["id"] = nextId;
}
if (!row.ContainsKey("created_at"))
row["created_at"] = DateTime.UtcNow.ToString("O");
targetList.Add(row);
added.Add(row);
}
SaveState(state);
return added;
}
private static JsonObject UpdateRow(AdminPanelState state, List<JsonObject> targetList, long id, JsonElement payload)
{
var row = targetList.FirstOrDefault(x => x["id"]?.GetValue<long>() == id);
if (row == null)
return new JsonObject { ["error"] = "Row not found" };
foreach (var prop in payload.EnumerateObject())
{
try
{
row[prop.Name] = JsonNode.Parse(prop.Value.GetRawText());
}
catch
{
if (prop.Value.ValueKind == JsonValueKind.String)
row[prop.Name] = prop.Value.GetString();
else if (prop.Value.ValueKind == JsonValueKind.Number)
row[prop.Name] = prop.Value.GetRawText();
else if (prop.Value.ValueKind == JsonValueKind.True)
row[prop.Name] = true;
else if (prop.Value.ValueKind == JsonValueKind.False)
row[prop.Name] = false;
else if (prop.Value.ValueKind == JsonValueKind.Null)
row[prop.Name] = null;
else
row[prop.Name] = prop.Value.GetRawText();
}
}
SaveState(state);
return row;
}
private static List<JsonObject> DeleteRows(AdminPanelState state, List<JsonObject> targetList, List<long> ids)
{
var toRemove = targetList.Where(x => ids.Contains(x["id"]?.GetValue<long>() ?? 0)).ToList();
foreach (var item in toRemove)
{
targetList.Remove(item);
}
SaveState(state);
return toRemove;
}
private static List<long> ParseIds(string? vals)
{
if (string.IsNullOrWhiteSpace(vals))
return new List<long>();
return vals.Split(',')
.Select(x => long.TryParse(x.Trim(), out var id) ? id : 0)
.Where(x => x > 0)
.ToList();
}
private async Task<JsonObject> CreateGiftDrop(JsonElement payload)
{
var giftDrop = new GiftDropDB
{
Name = GetJsonString(payload, "name") ?? "Gift Drop",
StartTime = TryGetJsonDate(payload, "start_time") ?? DateTime.UtcNow,
EndTime = TryGetJsonDate(payload, "end_time") ?? DateTime.UtcNow.AddDays(30),
StorefrontId = TryGetJsonLong(payload, "storefront_id") ?? 0,
Description = GetJsonString(payload, "description") ?? GetJsonString(payload, "tooltip") ?? "",
CurrencyAmount = TryGetJsonInt(payload, "currency_amount") ?? TryGetJsonInt(payload, "currency") ?? 0,
XPAmount = TryGetJsonInt(payload, "xp_amount") ?? 0,
AvatarItems = GetJsonString(payload, "avatar_items") ?? GetJsonString(payload, "avatar") ?? "",
EquipmentItems = GetJsonString(payload, "equipment_items") ?? GetJsonString(payload, "equip") ?? "",
ConsumableItems = GetJsonString(payload, "consumable_items") ?? GetJsonString(payload, "consumable") ?? "",
PurchasableItemId = TryGetJsonInt(payload, "purchasable_item_id") ?? 0,
IsFeatured = TryGetJsonBool(payload, "is_featured") ?? TryGetJsonBool(payload, "featured") ?? false,
Price = TryGetJsonInt(payload, "price") ?? 0,
CurrencyType = ParseCurrencyType(GetJsonString(payload, "currency_type")) ?? CurrencyType.RecCenterTokens,
Rarity = ParseRarity(GetJsonString(payload, "rarity")) ?? Rarity.Common,
Context = ParseGiftContext(GetJsonString(payload, "context")) ?? GiftContext.Game_Drop,
AvatarItemType = ParseAvatarItemType(GetJsonString(payload, "avatar_item_type")) ?? GetJsonString(payload, "item_type") switch
{
"HairDye" => AvatarItemType.HairDye,
_ => AvatarItemType.Outfit
},
EquipmentModificationGuid = GetJsonString(payload, "equipment_modification_guid") ?? GetJsonString(payload, "prefab_modify") ?? "",
Tooltip = GetJsonString(payload, "tooltip") ?? "",
IsQuery = TryGetJsonBool(payload, "is_query") ?? false,
Unique = TryGetJsonBool(payload, "unique") ?? false,
SubscribersOnly = TryGetJsonBool(payload, "subscribers_only") ?? TryGetJsonBool(payload, "subonly") ?? false,
ItemSetId = TryGetJsonLong(payload, "item_set_id") ?? 0,
ItemSetFriendlyName = GetJsonString(payload, "item_set_friendly_name") ?? ""
};
db.GiftItems.Add(giftDrop);
await db.SaveChangesAsync();
if (giftDrop.PurchasableItemId == 0)
{
giftDrop.PurchasableItemId = (int)(giftDrop.GiftDropId % int.MaxValue);
await db.SaveChangesAsync();
}
return GiftDropToJson(giftDrop);
}
private async Task<JsonObject> UpdateGiftDrop(long id, JsonElement payload)
{
var giftDrop = await db.GiftItems.FirstOrDefaultAsync(x => x.GiftDropId == id);
if (giftDrop == null)
return new JsonObject { ["error"] = "Gift drop not found" };
ApplyGiftDropPayload(giftDrop, payload);
if (giftDrop.PurchasableItemId == 0)
giftDrop.PurchasableItemId = (int)(giftDrop.GiftDropId % int.MaxValue);
await db.SaveChangesAsync();
return GiftDropToJson(giftDrop);
}
private static void ApplyGiftDropPayload(GiftDropDB giftDrop, JsonElement payload)
{
var name = GetJsonString(payload, "name");
if (name != null) giftDrop.Name = name;
var startTime = TryGetJsonDate(payload, "start_time");
if (startTime != null) giftDrop.StartTime = startTime.Value;
var endTime = TryGetJsonDate(payload, "end_time");
if (endTime != null) giftDrop.EndTime = endTime.Value;
var storefrontId = TryGetJsonLong(payload, "storefront_id");
if (storefrontId != null) giftDrop.StorefrontId = storefrontId.Value;
var description = GetJsonString(payload, "description") ?? GetJsonString(payload, "tooltip");
if (description != null) giftDrop.Description = description;
var currencyAmount = TryGetJsonInt(payload, "currency_amount") ?? TryGetJsonInt(payload, "currency");
if (currencyAmount != null) giftDrop.CurrencyAmount = currencyAmount.Value;
var xpAmount = TryGetJsonInt(payload, "xp_amount");
if (xpAmount != null) giftDrop.XPAmount = xpAmount.Value;
var avatarItems = GetJsonString(payload, "avatar_items") ?? GetJsonString(payload, "avatar");
if (avatarItems != null) giftDrop.AvatarItems = avatarItems;
var equipmentItems = GetJsonString(payload, "equipment_items") ?? GetJsonString(payload, "equip");
if (equipmentItems != null) giftDrop.EquipmentItems = equipmentItems;
var consumableItems = GetJsonString(payload, "consumable_items") ?? GetJsonString(payload, "consumable");
if (consumableItems != null) giftDrop.ConsumableItems = consumableItems;
var purchasableItemId = TryGetJsonInt(payload, "purchasable_item_id");
if (purchasableItemId != null && purchasableItemId > 0) giftDrop.PurchasableItemId = purchasableItemId.Value;
var isFeatured = TryGetJsonBool(payload, "is_featured") ?? TryGetJsonBool(payload, "featured");
if (isFeatured != null) giftDrop.IsFeatured = isFeatured.Value;
var price = TryGetJsonInt(payload, "price");
if (price != null) giftDrop.Price = price.Value;
var currencyType = ParseCurrencyType(GetJsonString(payload, "currency_type"));
if (currencyType != null) giftDrop.CurrencyType = currencyType.Value;
var rarity = ParseRarity(GetJsonString(payload, "rarity"));
if (rarity != null) giftDrop.Rarity = rarity.Value;
var context = ParseGiftContext(GetJsonString(payload, "context"));
if (context != null) giftDrop.Context = context.Value;
var avatarItemType = ParseAvatarItemType(GetJsonString(payload, "avatar_item_type"));
if (avatarItemType != null) giftDrop.AvatarItemType = avatarItemType.Value;
var equipMod = GetJsonString(payload, "equipment_modification_guid") ?? GetJsonString(payload, "prefab_modify");
if (equipMod != null) giftDrop.EquipmentModificationGuid = equipMod;
var tooltip = GetJsonString(payload, "tooltip");
if (tooltip != null) giftDrop.Tooltip = tooltip;
var isQuery = TryGetJsonBool(payload, "is_query");
if (isQuery != null) giftDrop.IsQuery = isQuery.Value;
var unique = TryGetJsonBool(payload, "unique");
if (unique != null) giftDrop.Unique = unique.Value;
var subscribersOnly = TryGetJsonBool(payload, "subscribers_only") ?? TryGetJsonBool(payload, "subonly");
if (subscribersOnly != null) giftDrop.SubscribersOnly = subscribersOnly.Value;
var itemSetId = TryGetJsonLong(payload, "item_set_id");
if (itemSetId != null) giftDrop.ItemSetId = itemSetId.Value;
var itemSetName = GetJsonString(payload, "item_set_friendly_name");
if (itemSetName != null) giftDrop.ItemSetFriendlyName = itemSetName;
}
private static JsonObject GiftDropToJson(GiftDropDB g)
{
return new JsonObject
{
["id"] = g.GiftDropId,
["name"] = g.Name,
["start_time"] = g.StartTime,
["end_time"] = g.EndTime,
["storefront_id"] = g.StorefrontId,
["description"] = g.Description,
["currency_amount"] = g.CurrencyAmount,
["xp_amount"] = g.XPAmount,
["avatar_items"] = g.AvatarItems,
["equipment_items"] = g.EquipmentItems,
["consumable_items"] = g.ConsumableItems,
["purchasable_item_id"] = g.PurchasableItemId,
["is_featured"] = g.IsFeatured,
["price"] = g.Price,
["currency_type"] = g.CurrencyType.ToString(),
["rarity"] = g.Rarity.ToString(),
["context"] = g.Context.ToString(),
["avatar_item_type"] = g.AvatarItemType.ToString(),
["equipment_modification_guid"] = g.EquipmentModificationGuid,
["tooltip"] = g.Tooltip,
["is_query"] = g.IsQuery,
["unique"] = g.Unique,
["subscribers_only"] = g.SubscribersOnly,
["item_set_id"] = g.ItemSetId,
["item_set_friendly_name"] = g.ItemSetFriendlyName
};
}
private static string? GetJsonString(JsonElement el, string name)
{
if (el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String)
return v.GetString();
return null;
}
private static int? TryGetJsonInt(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var v)) return null;
try
{
if (v.ValueKind == JsonValueKind.Number) return v.GetInt32();
if (v.ValueKind == JsonValueKind.String && int.TryParse(v.GetString(), out var i)) return i;
}
catch { }
return null;
}
private static long? TryGetJsonLong(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var v)) return null;
try
{
if (v.ValueKind == JsonValueKind.Number) return v.GetInt64();
if (v.ValueKind == JsonValueKind.String && long.TryParse(v.GetString(), out var l)) return l;
}
catch { }
return null;
}
private static DateTime? TryGetJsonDate(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var v)) return null;
try
{
if (v.ValueKind == JsonValueKind.String && DateTime.TryParse(v.GetString(), out var d)) return d.ToUniversalTime();
}
catch { }
return null;
}
private static bool? TryGetJsonBool(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var v)) return null;
try
{
if (v.ValueKind == JsonValueKind.True) return true;
if (v.ValueKind == JsonValueKind.False) return false;
if (v.ValueKind == JsonValueKind.String && bool.TryParse(v.GetString(), out var b)) return b;
}
catch { }
return null;
}
private static CurrencyType? ParseCurrencyType(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
if (Enum.TryParse<CurrencyType>(raw.Trim(), true, out var type) && Enum.IsDefined(type) && type != CurrencyType.Invalid)
return type;
return null;
}
private static Rarity? ParseRarity(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
if (Enum.TryParse<Rarity>(raw.Trim(), true, out var rarity) && Enum.IsDefined(rarity))
return rarity;
return null;
}
private static GiftContext? ParseGiftContext(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
if (Enum.TryParse<GiftContext>(raw.Trim(), true, out var context) && Enum.IsDefined(context))
return context;
return null;
}
private static AvatarItemType? ParseAvatarItemType(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
if (Enum.TryParse<AvatarItemType>(raw.Trim(), true, out var type) && Enum.IsDefined(type))
return type;
return null;
}
private static int? TryGetJsonInt(JsonObject obj, string key)
{
if (obj[key] is not JsonValue v) return null;
try
{
if (v.TryGetValue<int>(out var i)) return i;
if (v.TryGetValue<string>(out var s) && int.TryParse(s, out var parsed)) return parsed;
if (v.TryGetValue<double>(out var d)) return (int)d;
}
catch { }
return null;
}
private static long NewGiftDropId()
{
long value = BitConverter.ToInt64(Guid.NewGuid().ToByteArray(), 0);
return value switch
{
> 0 => value,
> long.MinValue => -value + 1,
_ => 1
};
}
private static GiftContext? TryParseGiftContext(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
if (Enum.TryParse<GiftContext>(raw.Trim(), true, out var context) && Enum.IsDefined(context))
return context;
return null;
}
private static Rarity? TryParseRarity(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
if (Enum.TryParse<Rarity>(raw.Trim(), true, out var rarity) && Enum.IsDefined(rarity))
return rarity;
return null;
}
private async Task<object> UpdatePlayer(long id, JsonElement payload)
{
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == id);
if (profile == null) return NotFound();
var player = await db.Players.FirstOrDefaultAsync(x => x.AccountId == id);
var progress = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == id);
int? priorLevel = progress?.Level;
var rep = await db.Reputations.FirstOrDefaultAsync(x => x.AccountId == id);
var settings = await db.PlayerSettings.FirstOrDefaultAsync(x => x.AccountId == id);
if (TryGetString(payload, "name", out var name)) profile.displayName = name;
if (TryGetString(payload, "original_name", out var username) || TryGetString(payload, "username", out username)) profile.username = username;
if (TryGetString(payload, "bio", out var bio)) profile.bio = bio;
if (payload.TryGetProperty("name_locked", out var nameLockedElem) && nameLockedElem.ValueKind == JsonValueKind.True)
profile.NameLocked = true;
else if (payload.TryGetProperty("name_locked", out nameLockedElem) && nameLockedElem.ValueKind == JsonValueKind.False)
profile.NameLocked = false;
if (payload.TryGetProperty("bio_locked", out var bioLockedElem) && bioLockedElem.ValueKind == JsonValueKind.True)
profile.BioLocked = true;
else if (payload.TryGetProperty("bio_locked", out bioLockedElem) && bioLockedElem.ValueKind == JsonValueKind.False)
profile.BioLocked = false;
if (TryGetString(payload, "password", out var newPassword) && !string.IsNullOrWhiteSpace(newPassword))
{
if (player == null) return BadRequest("Player data not found - cannot reset password.");
player.Hashword = PasswordHasher.HashPassword(player, newPassword);
var staleLogins = await db.CachedLogins.Where(x => x.accountId == id).ToListAsync();
if (staleLogins.Count > 0)
{
db.CachedLogins.RemoveRange(staleLogins);
}
player.TokenVersion++;
TokenRevocation.Bump(id, player.TokenVersion);
AdminActionDiscord.LogAction("Player Password Reset", await GetPanelAdminUsernameAsync(),
$"Player {id} ({(profile.displayName ?? profile.username) ?? id.ToString()})");
await Notify.SendNotify(id, new Notification { Id = NotificationType.ModerationQuitGame, Msg = null });
var presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == id);
if (presence != null)
{
presence.isOnline = false;
presence.roomInstance = null;
presence.LoginLock = null;
}
}
if (payload.TryGetProperty("avatar_image", out var avatarImageElem))
{
if (avatarImageElem.ValueKind == JsonValueKind.String)
{
var avatarImage = avatarImageElem.GetString();
if (!string.IsNullOrWhiteSpace(avatarImage))
{
if (avatarImage.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
var savedName = SaveDataUrlFile(avatarImage, DBConfig.CDNImages);
if (savedName != null) profile.profileImage = savedName;
}
else
{
var trimmed = avatarImage;
var schemeIndex = trimmed.IndexOf("://", StringComparison.Ordinal);
if (schemeIndex >= 0)
{
var lastSlash = trimmed.LastIndexOf('/');
if (lastSlash > schemeIndex) trimmed = trimmed.Substring(lastSlash + 1);
}
if (!string.IsNullOrWhiteSpace(trimmed))
profile.profileImage = trimmed;
}
}
}
else if (avatarImageElem.ValueKind == JsonValueKind.Null && !string.IsNullOrWhiteSpace(profile.profileImage))
{
if (profile.profileImage != DBConfig.DefaultProfileImage)
{
profile.profileImage = DBConfig.DefaultProfileImage;
}
}
}
if (payload.TryGetProperty("is_junior", out var isJunior))
{
var junior = isJunior.GetBoolean();
var wasJunior = profile.isJunior;
profile.isJunior = junior;
if (player != null)
{
player.IsJunior = junior;
player.JuniorState = junior ? JuniorState.ModerationOverride : JuniorState.BasedOnBirthday;
}
if (wasJunior != junior)
{
if (player != null) player.TokenVersion++;
await db.SaveChangesAsync();
if (player != null) TokenRevocation.Bump(id, player.TokenVersion);
await NotifyAccountChanged(id, profile, player, refreshSession: true);
}
}
if (payload.TryGetProperty("roles", out var roles))
{
var oldRoles = profile.role;
profile.role = JoinRoles(roles);
if (oldRoles != profile.role)
{
if (player != null)
{
player.TokenVersion++;
}
await db.SaveChangesAsync();
if (player != null)
TokenRevocation.Bump(id, player.TokenVersion);
await NotifyAccountChanged(id, profile, player, refreshSession: true);
}
}
bool banPenaltyApplied = false;
if (payload.TryGetProperty("banned", out var banned))
{
var isBanned = banned.GetBoolean();
var banReason = TryGetString(payload, "ban_reason", out var reason) ? reason : "Banned via admin panel";
var banIsModerator = payload.TryGetProperty("ban_is_moderator", out var isMod) && isMod.ValueKind == JsonValueKind.True;
if (banIsModerator && !string.IsNullOrWhiteSpace(banReason) &&
!banReason.StartsWith("Moderator.", StringComparison.OrdinalIgnoreCase))
{
banReason = "Moderator. " + banReason;
}
var banMethod = TryGetString(payload, "ban_method", out var method) && !string.IsNullOrWhiteSpace(method)
? method.Trim()
: (isBanned ? "Admin Panel" : "None");
var moderatorNote = TryGetString(payload, "moderator_note", out var note) ? note?.Trim() : null;
var banType = TryGetString(payload, "ban_type", out var type) ? type : (isBanned ? "Temporary" : "");
var banCategory = TryGetEnum(payload, "ban_category", out ReportCategory category) ? category : ReportCategory.Misc;
var banIsTimeout = isBanned
&& payload.TryGetProperty("ban_is_timeout", out var isTimeoutFlag)
&& isTimeoutFlag.ValueKind == JsonValueKind.True;
if (banIsTimeout)
{
banMethod = ModerationPolicy.AutomaticTimeoutBanMethod;
banType = "Temporary";
}
var isAccountLock = string.Equals(banMethod, "Account Lock", StringComparison.OrdinalIgnoreCase);
if (isAccountLock && isBanned)
{
banType = "Permanent";
banCategory = ReportCategory.Moderator;
banReason = AccountLock.Reason;
}
DateTime? banDuration = null;
if (isBanned)
{
if (string.Equals(banType, "Permanent", StringComparison.OrdinalIgnoreCase))
{
banDuration = DateTime.MaxValue;
}
else if (TryGetString(payload, "ban_until", out var banUntil))
{
if (string.Equals(banUntil, "permanent", StringComparison.OrdinalIgnoreCase) || banUntil == "-1")
{
banDuration = DateTime.MaxValue;
}
else if (DateTime.TryParse(banUntil, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsedUntil))
{
banDuration = parsedUntil;
}
else
{
banDuration = DateTime.UtcNow.AddDays(30);
}
}
else
{
banDuration = DateTime.UtcNow.AddDays(30);
}
if (banIsTimeout)
{
var requested = banDuration!.Value - DateTime.UtcNow;
banDuration = DateTime.UtcNow + ModerationPolicy.ClampTimeout(requested > TimeSpan.Zero ? requested : TimeSpan.Zero);
}
}
if (banIsTimeout)
banReason = PlayerReportingAPI.GetTimeoutDisplayReason(banMethod, banReason, banCategory);
string playerIp = TryGetString(payload, "ip", out var payloadIp) && !string.IsNullOrWhiteSpace(payloadIp)
? payloadIp
: (player?.LastIpAddress ?? "");
string playerDevice = TryGetString(payload, "device_id", out var payloadDevice) && !string.IsNullOrWhiteSpace(payloadDevice)
? payloadDevice
: (player?.DeviceId ?? "");
var existingBan = await db.Moderation.FirstOrDefaultAsync(x => x.PlayerId == id && x.IsBan);
bool banChanged = false;
if (isBanned)
{
if (string.Equals(banMethod, "IP Ban", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(playerIp))
{
return BadRequest("No IP address is known for this player. Ask them to log in once, then try the IP ban again.");
}
if (existingBan != null)
{
db.Moderation.Remove(existingBan);
existingBan = null;
}
var existingIpBan = await db.IpBans.FirstOrDefaultAsync(x => x.IpAddress == playerIp);
if (existingIpBan == null)
{
await db.IpBans.AddAsync(new IpBan
{
IpAddress = playerIp,
Reason = banReason,
CreatedAt = DateTime.UtcNow,
ExpiresAt = banDuration == DateTime.MaxValue ? null : banDuration
});
banChanged = true;
}
else
{
if (existingIpBan.Reason != banReason || existingIpBan.ExpiresAt != (banDuration == DateTime.MaxValue ? null : banDuration))
banChanged = true;
existingIpBan.Reason = banReason;
existingIpBan.ExpiresAt = banDuration == DateTime.MaxValue ? null : banDuration;
}
await db.SaveChangesAsync();
await db.VoteKicks.AddAsync(new VoteKickLog
{
PlayerId = id,
Reason = banReason,
Category = banCategory,
IsForce = true,
CreatedAt = DateTime.UtcNow,
ExpiresAt = banDuration ?? DateTime.MaxValue
});
await db.SaveChangesAsync();
await PlayerUtils.KickOrPromptAsync(db, id, PlayerUtils.BuildBlockDetails(true, banCategory, banReason, banDuration, startedAtUtc: DateTime.UtcNow));
}
else
{
var appealable = ModerationPolicy.IssuesAppealCode(banMethod);
var appealCode = appealable ? existingBan?.AppealCode : null;
string? appealError = null;
if (appealable && string.IsNullOrWhiteSpace(appealCode))
{
var (newCode, cooldownError) = await PlayerUtils.GetAppealForNewBanAsync(db, id);
appealCode = newCode;
appealError = cooldownError;
banChanged = true;
}
var banStartedAt = DateTime.UtcNow;
var banWasModified = existingBan == null
|| existingBan.Message != banReason
|| existingBan.ExpireDuration != banDuration!.Value
|| existingBan.BanMethod != banMethod
|| existingBan.ModeratorNote != moderatorNote
|| existingBan.Category != banCategory;
if (existingBan == null)
{
await db.Moderation.AddAsync(new ModerationDB
{
PlayerId = id,
Category = banCategory,
Message = banReason,
ModeratorNote = moderatorNote,
CreatedAt = banStartedAt,
ExpireDuration = banDuration!.Value,
IsBan = true,
BanMethod = banMethod,
AppealCode = appealCode
});
banChanged = true;
}
else
{
if (banWasModified)
{
banChanged = true;
existingBan.CreatedAt = banStartedAt;
}
existingBan.Category = banCategory;
existingBan.Message = banReason;
existingBan.ModeratorNote = moderatorNote;
existingBan.ExpireDuration = banDuration!.Value;
existingBan.IsBan = true;
existingBan.BanMethod = banMethod;
existingBan.AppealCode = appealCode;
}
if (string.Equals(banMethod, "Hardware Ban", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(playerDevice))
{
var existingHardwareBan = await db.HardwareBans.FirstOrDefaultAsync(x => x.DeviceId == playerDevice);
if (existingHardwareBan == null)
{
await db.HardwareBans.AddAsync(new HardwareBan
{
DeviceId = playerDevice,
Reason = banReason,
CreatedAt = DateTime.UtcNow,
ExpiresAt = banDuration == DateTime.MaxValue ? null : banDuration,
BannedAccountId = id
});
}
else
{
existingHardwareBan.Reason = banReason;
existingHardwareBan.ExpiresAt = banDuration == DateTime.MaxValue ? null : banDuration;
existingHardwareBan.BannedAccountId = id;
}
}
await db.SaveChangesAsync();
await db.VoteKicks.AddAsync(new VoteKickLog
{
PlayerId = id,
Reason = banReason,
Category = banCategory,
IsForce = true,
CreatedAt = DateTime.UtcNow,
ExpiresAt = banDuration ?? DateTime.MaxValue
});
await db.SaveChangesAsync();
var effectiveBan = existingBan ?? await db.Moderation.FirstOrDefaultAsync(x => x.PlayerId == id && x.IsBan);
var blockDetails = PlayerUtils.BuildBlockDetails(true, banCategory, banReason, banDuration, appealCode: appealCode, appealError: appealError, startedAtUtc: effectiveBan?.CreatedAt ?? DateTime.UtcNow);
PlayerUtils.ApplyClientBanKind(blockDetails, banMethod);
await PlayerUtils.KickOrPromptAsync(db, id, blockDetails);
}
if (banChanged)
{
if (!isAccountLock)
{
if (rep == null)
{
rep = new Reputation { AccountId = id };
await db.Reputations.AddAsync(rep);
}
rep.ApplyBanPenalty(
permanent: banDuration == DateTime.MaxValue,
cheating: string.Equals(banMethod, "Anti-Cheat", StringComparison.OrdinalIgnoreCase));
}
banPenaltyApplied = true;
await AdminActionDiscord.LogBanAsync(db, id,
await GetPanelAdminUsernameAsync(),
causedByReports: false,
reason: banReason,
category: banCategory,
expiry: banDuration,
isModerator: banIsModerator,
banMethod: banMethod);
}
}
else if (existingBan != null)
{
db.Moderation.Remove(existingBan);
if (string.Equals(existingBan.BanMethod, "IP Ban", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(playerIp))
await db.IpBans.Where(x => x.IpAddress == playerIp).ExecuteDeleteAsync();
if (string.Equals(existingBan.BanMethod, "Hardware Ban", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(playerDevice))
await db.HardwareBans.Where(x => x.DeviceId == playerDevice).ExecuteDeleteAsync();
await db.SaveChangesAsync();
await BestEffort("the refresh-login notification", id,
() => Notify.SendNotify(id, new Notification { Id = NotificationType.RefreshLogin, Msg = profile }));
await BestEffort("the moderation-clear push", id,
() => PlayerUtils.ReleaseModerationHoldAsync(id));
}
else
{
var clearedIpBan = !string.IsNullOrWhiteSpace(playerIp)
&& await db.IpBans.Where(x => x.IpAddress == playerIp).ExecuteDeleteAsync() > 0;
var clearedHwBan = !string.IsNullOrWhiteSpace(playerDevice)
&& await db.HardwareBans.Where(x => x.DeviceId == playerDevice).ExecuteDeleteAsync() > 0;
if (clearedIpBan || clearedHwBan)
{
await BestEffort("the refresh-login notification", id,
() => Notify.SendNotify(id, new Notification { Id = NotificationType.RefreshLogin, Msg = profile }));
await BestEffort("the moderation-clear push", id,
() => PlayerUtils.ReleaseModerationHoldAsync(id));
}
}
}
if (player != null)
{
if (TryGetString(payload, "email", out var email))
{
player.Email = email;
player.EmailVerified = !string.IsNullOrWhiteSpace(email);
player.PendingEmail = null;
player.EmailVerifyToken = null;
}
if (TryGetString(payload, "device_id", out var deviceId)) player.DeviceId = deviceId;
if (TryGetEnum(payload, "jr_state", out JuniorState juniorState)) player.JuniorState = juniorState;
if (payload.TryGetProperty("is_junior", out var playerIsJunior)) player.IsJunior = playerIsJunior.GetBoolean();
if (payload.TryGetProperty("parent_account_id", out var parentProp))
{
if (parentProp.ValueKind == JsonValueKind.Null || (parentProp.ValueKind == JsonValueKind.String && string.IsNullOrWhiteSpace(parentProp.GetString())))
{
player.Parent = null;
}
else if (parentProp.ValueKind == JsonValueKind.Number && parentProp.TryGetInt64(out var rawParent))
{
player.Parent = rawParent > 0 ? rawParent : null;
}
else if (parentProp.ValueKind == JsonValueKind.String && long.TryParse(parentProp.GetString(), out var parsedParent))
{
player.Parent = parsedParent > 0 ? parsedParent : null;
}
}
}
if (payload.TryGetProperty("name_history", out var nameHistory) && nameHistory.ValueKind == JsonValueKind.Array)
{
var history = nameHistory.EnumerateArray()
.Select(x => x.GetString() ?? string.Empty)
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToList();
if (settings == null)
{
settings = new PlayerSettings { AccountId = id };
await db.PlayerSettings.AddAsync(settings);
}
var existingSettings = settings.DeserializeSettings() ?? new List<Setting>();
existingSettings.RemoveAll(x => x.Key == "NameHistory");
existingSettings.Add(new Setting { Key = "NameHistory", Value = System.Text.Json.JsonSerializer.Serialize(history) });
settings.Settings = existingSettings.ToJson();
}
if (payload.TryGetProperty("settings", out var settingsProp) && settingsProp.ValueKind == JsonValueKind.Object)
{
var settingList = new List<Setting>();
foreach (var prop in settingsProp.EnumerateObject())
{
string? val = null;
if (prop.Value.ValueKind == JsonValueKind.String) val = prop.Value.GetString();
else if (prop.Value.ValueKind == JsonValueKind.True) val = "true";
else if (prop.Value.ValueKind == JsonValueKind.False) val = "false";
else if (prop.Value.ValueKind == JsonValueKind.Number) val = prop.Value.GetRawText();
else val = prop.Value.GetRawText();
if (val != null)
settingList.Add(new Setting { Key = prop.Name, Value = val });
}
if (settings == null)
{
settings = new PlayerSettings { AccountId = id };
await db.PlayerSettings.AddAsync(settings);
}
settings.Settings = settingList.ToJson();
}
var currency = await db.Currency.FirstOrDefaultAsync(x => x.AccountId == id);
bool hasBalanceField = HasAny(payload, "rec_tokens", "laser_tag_tickets", "gold", "dracula_silver");
if (currency == null && hasBalanceField)
{
currency = new Currency { AccountId = id };
await db.Currency.AddAsync(currency);
}
var balanceEdits = new List<(CurrencyType Type, long Balance, long Granted)>();
if (currency != null)
{
if (TryGetLong(payload, "rec_tokens", out var recTokens))
{
balanceEdits.Add((CurrencyType.RecCenterTokens, recTokens, recTokens - currency.RecTokens));
currency.RecTokens = recTokens;
}
if (TryGetLong(payload, "laser_tag_tickets", out var laserTickets))
{
balanceEdits.Add((CurrencyType.LaserTagTickets, laserTickets, laserTickets - currency.LaserTagTickets));
currency.LaserTagTickets = laserTickets;
}
if (TryGetLong(payload, "gold", out var gold))
{
balanceEdits.Add((CurrencyType.LostSkullsGold, gold, gold - currency.Gold));
currency.Gold = gold;
}
if (TryGetLong(payload, "dracula_silver", out var draculaSilver))
{
balanceEdits.Add((CurrencyType.DraculaSilver, draculaSilver, draculaSilver - currency.DraculaSilver));
currency.DraculaSilver = draculaSilver;
}
}
if (progress == null && (payload.TryGetProperty("level", out _) || payload.TryGetProperty("xp", out _)))
{
progress = new ProgressionData { PlayerId = id };
await db.Progression.AddAsync(progress);
}
if (progress != null)
{
if (TryGetInt(payload, "level", out var level)) progress.Level = level;
if (TryGetInt(payload, "xp", out var xp)) progress.XP = xp;
}
if (rep == null && HasAny(payload, "reputation", "noteriety", "cheer_general", "cheer_helpful", "cheer_great_host", "cheer_sportsman", "cheer_creative", "cheer_credit"))
{
rep = new Reputation { AccountId = id };
await db.Reputations.AddAsync(rep);
}
if (rep != null)
{
if (!banPenaltyApplied && TryGetDouble(payload, "reputation", out var reputationScore))
rep.ReputationScore = reputationScore;
if (TryGetDouble(payload, "noteriety", out var noteriety)) rep.Noteriety = noteriety;
if (TryGetInt(payload, "cheer_general", out var cheerGeneral)) rep.CheerGeneral = cheerGeneral;
if (TryGetInt(payload, "cheer_helpful", out var cheerHelpful)) rep.CheerHelpful = cheerHelpful;
if (TryGetInt(payload, "cheer_great_host", out var cheerGreatHost)) rep.CheerGreatHost = cheerGreatHost;
if (TryGetInt(payload, "cheer_sportsman", out var cheerSportsman)) rep.CheerSportsman = cheerSportsman;
if (TryGetInt(payload, "cheer_creative", out var cheerCreative)) rep.CheerCreative = cheerCreative;
if (TryGetInt(payload, "cheer_credit", out var cheerCredit)) rep.CheerCredit = cheerCredit;
}
await db.SaveChangesAsync();
foreach (var edit in balanceEdits)
await BestEffort($"the balance update notification ({edit.Type})", id,
() => StorefrontShared.SendBalanceUpdateAsync(id, edit.Type, edit.Balance, edit.Granted, StorefrontBalanceAddTypes.Manual_Thanks));
if (progress != null)
{
await BestEffort("level-up gift delivery", id,
() => LevelUpGiftService.DeliverLevelUpGiftsAsync(db, id, recomputeFromXp: true, fromLevel: priorLevel, notify: true));
await BestEffort("progression update push", id,
() => RoomProgressionService.PushProgressionUpdateAsync(db, id));
}
return Ok(new { success = true });
}
private static async Task BestEffort(string what, long playerId, Func<Task> action)
{
try
{
await action();
}
catch (Exception)
{
}
}
private static bool TryGetString(JsonElement payload, string prop, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? value)
{
if (payload.TryGetProperty(prop, out var elem) && elem.ValueKind == JsonValueKind.String)
{
value = elem.GetString()!;
return true;
}
value = null;
return false;
}
[HttpPost("players/{playerId:long}/quitgame")]
public async Task<IActionResult> QuitPlayerGame(long playerId, [FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
if (profile == null) return NotFound("Player does not exist.");
var display = !string.IsNullOrWhiteSpace(profile.displayName) ? profile.displayName : profile.username;
Notify.SendReliably(playerId, new Notification { Id = NotificationType.ModerationQuitGame, Msg = "" },
TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(1), Notify.EmptyObjectPayload(NotificationType.ModerationQuitGame));
var presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == playerId);
if (presence != null)
{
presence.isOnline = false;
presence.roomInstance = null;
await db.SaveChangesAsync();
}
AdminActionDiscord.LogAction("Quit Game", await GetPanelAdminUsernameAsync(),
$"Forced player {playerId} ({display}) to quit the game.");
return Ok(new { success = true, message = $"Sent quit-game to {display}." });
}
[HttpPost("players/{playerId:long}/forcelogout")]
public async Task<IActionResult> ForceLogoutPlayer(long playerId, [FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
if (profile == null) return NotFound("Player does not exist.");
var display = !string.IsNullOrWhiteSpace(profile.displayName) ? profile.displayName : profile.username;
await Notify.SendNotify(playerId, Notify.EmptyObjectPayload(NotificationType.Logout));
var player = await db.Players.FirstOrDefaultAsync(x => x.AccountId == playerId);
if (player != null)
{
player.TokenVersion++;
TokenRevocation.Bump(playerId, player.TokenVersion);
}
var presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == playerId);
if (presence != null)
{
presence.isOnline = false;
presence.roomInstance = null;
}
await db.SaveChangesAsync();
_ = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromMilliseconds(300));
Notify.DisconnectAccount(playerId, "force logout");
}
catch { }
});
AdminActionDiscord.LogAction("Force Logout", await GetPanelAdminUsernameAsync(),
$"Forced player {playerId} ({display}) to log out.");
return Ok(new { success = true, message = $"Sent force-logout to {display}." });
}
[HttpPost("players/{playerId:long}/forcejoin")]
public async Task<IActionResult> ForceJoinPlayer(long playerId, [FromQuery] string key, [FromQuery(Name = "target_player_id")] long targetPlayerId)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var hostProfile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
if (hostProfile == null) return NotFound("Host player does not exist.");
var targetProfile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == targetPlayerId);
if (targetProfile == null) return NotFound("Target player does not exist.");
var hostDisplay = !string.IsNullOrWhiteSpace(hostProfile.displayName) ? hostProfile.displayName : hostProfile.username;
var targetDisplay = !string.IsNullOrWhiteSpace(targetProfile.displayName) ? targetProfile.displayName : targetProfile.username;
var hostPresence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == playerId);
if (hostPresence == null || !hostPresence.roomInstance.HasValue)
return BadRequest(new { success = false, message = $"{hostDisplay} is not currently in a room." });
var roomInstanceId = hostPresence.roomInstance.Value;
var roomInstance = await db.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == roomInstanceId);
if (roomInstance == null)
return BadRequest(new { success = false, message = $"The room instance {roomInstanceId} could not be found." });
var matchResult = await MatchmakingUtils.JoinRoom(targetPlayerId, roomInstanceId);
if (matchResult.errorCode != JoinStatus.Success)
{
return BadRequest(new { success = false, message = $"Failed to move {targetDisplay} into the room: {matchResult.errorCode}." });
}
db.ChangeTracker.Clear();
var targetPresence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == targetPlayerId);
if (targetPresence != null)
{
try
{
await Notify.SendNotify(targetPlayerId, new Notification
{
Id = "PresenceUpdate",
Msg = await Presence.CreateAsync(db, targetPresence, roomInstance)
});
}
catch (Exception)
{
}
}
var targetBan = await db.ResolveBanAsync(targetPlayerId);
if (targetBan != null && !await db.IsDeveloper(targetPlayerId))
{
var banDetails = PlayerUtils.BuildBlockDetails(true, targetBan.Category, targetBan.Reason, targetBan.ExpiryUtc,
appealCode: targetBan.AppealCode, startedAtUtc: targetBan.StartedAtUtc,
relatedUsername: await PlayerUtils.RelatedAccountUsernameAsync(db, targetBan.BannedAccountId, targetPlayerId));
PlayerUtils.ApplyClientBanKind(banDetails, targetBan.BanMethod);
await Notify.SendNotify(targetPlayerId, new Notification { Id = NotificationType.ModerationKick, Msg = banDetails });
}
AdminActionDiscord.LogAction("Force Join", await GetPanelAdminUsernameAsync(),
$"Force-joined player {targetPlayerId} ({targetDisplay}) into room {roomInstance.name} (instance {roomInstanceId}) where player {playerId} ({hostDisplay}) is.");
return Ok(new
{
success = true,
message = $"{targetDisplay} has been moved to {hostDisplay}'s room ({roomInstance.name}).",
room_name = roomInstance.name,
room_instance_id = roomInstanceId
});
}
[HttpPost("players/{playerId:long}/warn")]
public async Task<IActionResult> WarnPlayer(long playerId, [FromQuery] string key, [FromQuery] int reportCategory, [FromQuery] string? reason, [FromQuery] string? note)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
if (profile == null) return NotFound("Player does not exist.");
var display = !string.IsNullOrWhiteSpace(profile.displayName) ? profile.displayName : profile.username;
if (!Enum.IsDefined(typeof(ReportCategory), reportCategory))
return BadRequest(new { success = false, message = $"Unknown report category {reportCategory}." });
var category = (ReportCategory)reportCategory;
var displayReason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim();
var moderatorNote = string.IsNullOrWhiteSpace(note) ? null : note.Trim();
var warning = await PlayerReportingAPI.IssueWarningAsync(db, playerId, category, displayReason, moderatorNote, 1);
if (warning == null)
{
var connected = Notify.GetClient(playerId) != null;
AdminActionDiscord.LogAction("Warn Player (awaiting decision)", await GetPanelAdminUsernameAsync(),
$"Warned developer {playerId} ({display}) - {category} - parked until they accept or deny" +
(connected ? string.Empty : " (no live notify connection at the time)"));
return Ok(new
{
success = true,
message = connected
? $"{display} is a developer: the accept/deny prompt was sent to their client and the warning is not applied unless they accept it."
: $"{display} is a developer and has no live connection right now, so the accept/deny prompt could not be shown. The warning is parked (nothing written) and prompts again on the next action aimed at them.",
warning_id = (long?)null
});
}
AdminActionDiscord.LogAction("Warn Player", await GetPanelAdminUsernameAsync(),
$"Warned player {playerId} ({display}) - {category}" +
(displayReason == null ? string.Empty : $": {displayReason}") +
(moderatorNote == null ? string.Empty : $" | note: {moderatorNote}"));
return Ok(new
{
success = true,
message = $"{display} was warned: moved to their dorm and shown the warning until they acknowledge it.",
warning_id = warning.Id
});
}
[HttpPost("players/{playerId:long}/voicerestriction")]
public async Task<IActionResult> ApplyVoiceChatRestriction(long playerId, [FromQuery] string key, [FromQuery] int minutes, [FromQuery] int? reportCategory, [FromQuery] string? reason, [FromQuery] string? note)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
if (profile == null) return NotFound("Player does not exist.");
var display = !string.IsNullOrWhiteSpace(profile.displayName) ? profile.displayName : profile.username;
var category = reportCategory.HasValue && Enum.IsDefined(typeof(ReportCategory), reportCategory.Value)
? (ReportCategory)reportCategory.Value
: ReportCategory.Misc;
var duration = TimeSpan.FromMinutes(minutes > 0 ? minutes : VoiceChatRestrictions.DefaultDuration.TotalMinutes);
var restriction = await VoiceChatRestrictions.ApplyAsync(db, playerId,
string.IsNullOrWhiteSpace(reason) ? "Restricted from voice chat for a Code of Conduct violation." : reason.Trim(),
category, duration, VoiceChatRestrictions.Method,
await GetPanelAdminUsernameAsync(), string.IsNullOrWhiteSpace(note) ? null : note.Trim());
await VoiceChatRestrictions.PushAsync(playerId, restriction);
AdminActionDiscord.LogAction("Voice Restriction", await GetPanelAdminUsernameAsync(),
$"Restricted {display} ({playerId}) from voice chat for {Math.Round((restriction.ExpiresAt - DateTime.UtcNow).TotalMinutes)} minutes ({category})" +
(string.IsNullOrWhiteSpace(reason) ? "" : $": {reason.Trim()}"));
return Ok(new
{
success = true,
message = $"{display} is restricted from voice chat until {restriction.ExpiresAt:u}.",
expires_at = DateTime.SpecifyKind(restriction.ExpiresAt, DateTimeKind.Utc),
minutes = (int)Math.Max(0, (restriction.ExpiresAt - DateTime.UtcNow).TotalMinutes)
});
}
[HttpDelete("players/{playerId:long}/voicerestriction")]
public async Task<IActionResult> ClearVoiceChatRestriction(long playerId, [FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
if (profile == null) return NotFound("Player does not exist.");
var display = !string.IsNullOrWhiteSpace(profile.displayName) ? profile.displayName : profile.username;
var cleared = await VoiceChatRestrictions.ClearAsync(db, playerId, await GetPanelAdminUsernameAsync());
if (!cleared)
return Ok(new { success = true, message = $"{display} is not restricted from voice chat." });
await VoiceChatRestrictions.PushAsync(playerId, null);
AdminActionDiscord.LogAction("Voice Restriction Lifted", await GetPanelAdminUsernameAsync(),
$"Lifted the voice chat restriction on {display} ({playerId}).");
return Ok(new { success = true, message = $"{display} can use voice chat again." });
}
[HttpPost("players/{playerId:long}/togglejunior")]
public async Task<IActionResult> ToggleJunior(long playerId, [FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
if (profile == null) return NotFound("Player does not exist.");
var player = await db.Players.FirstOrDefaultAsync(x => x.AccountId == playerId);
var display = !string.IsNullOrWhiteSpace(profile.displayName) ? profile.displayName : profile.username;
var makeJunior = !profile.isJunior;
profile.isJunior = makeJunior;
if (player != null)
{
player.IsJunior = makeJunior;
player.JuniorState = makeJunior ? JuniorState.ModerationOverride : JuniorState.BasedOnBirthday;
}
await db.SaveChangesAsync();
if (player != null) player.TokenVersion++;
await db.SaveChangesAsync();
if (player != null) TokenRevocation.Bump(playerId, player.TokenVersion);
await NotifyAccountChanged(playerId, profile, player, refreshSession: true);
AdminActionDiscord.LogAction(makeJunior ? "Junior Account" : "Lift Junior", await GetPanelAdminUsernameAsync(),
$"Set player {playerId} ({display}) junior status to {makeJunior}.");
return Ok(new
{
success = true,
is_junior = makeJunior,
jr_state = player?.JuniorState.ToString() ?? profile.isJunior.ToString(),
message = makeJunior ? $"{display} is now a Junior account." : $"Junior status removed from {display}."
});
}
private static bool TryGetInt(JsonElement payload, string prop, out int value)
{
value = 0;
if (!payload.TryGetProperty(prop, out var elem)) return false;
switch (elem.ValueKind)
{
case JsonValueKind.Number:
if (elem.TryGetInt64(out var asLong))
{
value = (int)Math.Clamp(asLong, int.MinValue, int.MaxValue);
return true;
}
if (elem.TryGetDouble(out var asDouble))
{
value = (int)Math.Clamp(asDouble, int.MinValue, int.MaxValue);
return true;
}
return false;
case JsonValueKind.String:
if (int.TryParse(elem.GetString(), out var parsed))
{
value = parsed;
return true;
}
if (double.TryParse(elem.GetString(), out var parsedDouble))
{
value = (int)Math.Clamp(parsedDouble, int.MinValue, int.MaxValue);
return true;
}
return false;
default:
return false;
}
}
private static bool TryGetLong(JsonElement payload, string prop, out long value)
{
value = 0;
if (!payload.TryGetProperty(prop, out var elem)) return false;
switch (elem.ValueKind)
{
case JsonValueKind.Number:
if (elem.TryGetInt64(out var asLong))
{
value = asLong;
return true;
}
if (elem.TryGetDouble(out var asDouble))
{
value = (long)Math.Clamp(asDouble, (double)long.MinValue, (double)long.MaxValue);
return true;
}
return false;
case JsonValueKind.String:
if (long.TryParse(elem.GetString(), out var parsed))
{
value = parsed;
return true;
}
if (double.TryParse(elem.GetString(), out var parsedDouble))
{
value = (long)Math.Clamp(parsedDouble, (double)long.MinValue, (double)long.MaxValue);
return true;
}
return false;
default:
return false;
}
}
/// <summary>
/// An account id out of a panel payload, where blank means "none". Distinct from
/// <see cref="TryGetLong"/> because a field the panel can clear has to accept "" (and a
/// cleared field must not silently keep the old id), and because a value that is neither a
/// number nor digits - the panel posts account ids as text to keep eighteen digits exact -
/// is reported rather than quietly dropped.
///
/// Returns false only when the property is absent, so a caller leaves the stored value alone
/// unless the panel actually sent the field. When it returns true, <paramref name="error"/> is
/// non-null for a value that cannot be an id, and the caller must not write it.
/// </summary>
private static bool TryGetAccountId(JsonElement payload, string prop, out long value, out string? error)
{
value = 0;
error = null;
if (!payload.TryGetProperty(prop, out var elem)) return false;
var text = elem.ValueKind switch
{
JsonValueKind.Number => elem.GetRawText(),
JsonValueKind.String => elem.GetString()?.Trim(),
JsonValueKind.Null => null,
_ => ""
};
if (string.IsNullOrEmpty(text)) return true;
if (long.TryParse(text, out var parsed) && parsed >= 0)
{
value = parsed;
return true;
}
error = $"'{prop}' must be an account id in digits, or blank for no sender.";
return true;
}
private static bool TryGetDouble(JsonElement payload, string prop, out double value)
{
value = 0;
if (!payload.TryGetProperty(prop, out var elem)) return false;
return elem.ValueKind switch
{
JsonValueKind.Number => elem.TryGetDouble(out value),
JsonValueKind.String => double.TryParse(elem.GetString(), out value),
_ => false,
};
}
private static bool TryGetEnum<T>(JsonElement payload, string prop, out T value) where T : struct
{
if (payload.TryGetProperty(prop, out var elem) && elem.ValueKind == JsonValueKind.String && Enum.TryParse<T>(elem.GetString(), true, out var parsed))
{
value = parsed;
return true;
}
value = default;
return false;
}
private static bool HasAny(JsonElement payload, params string[] props)
{
return props.Any(p => payload.TryGetProperty(p, out _));
}
private static string[] SplitRoles(string? roles) => string.IsNullOrWhiteSpace(roles) ? [] : roles.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
private async Task NotifyAccountChanged(long id, Profile profile, PlayerData? player, bool refreshSession = false)
{
await Notify.SendNotify(id, new Notification { Id = "AccountUpdate", Msg = profile });
await Notify.UpdateSubscriptions(id, Notifs.SubscriptionUpdateProfile, profile);
if (player != null)
await Notify.SendNotify(id, new Notification { Id = "SelfAccountUpdate", Msg = PlayerUtils.BuildSelfAccount(profile, player) });
if (refreshSession)
await Notify.SendNotify(id, new Notification { Id = NotificationType.RefreshLogin, Msg = profile });
}
private static string JoinRoles(JsonElement roles)
{
if (roles.ValueKind != JsonValueKind.Array) return roles.ToString();
return string.Join(",", roles.EnumerateArray().Select(x => x.ToString()).Where(x => !string.IsNullOrWhiteSpace(x)));
}
private async Task<List<object>> GetRooms(string? order, bool ascending)
{
var roomsList = await db.Rooms.ToListAsync();
var roomIds = roomsList.Select(r => r.RoomId).ToList();
var stats = await db.RoomStats.Where(s => roomIds.Contains(s.RoomId)).ToDictionaryAsync(s => s.RoomId);
var giftRows = await db.RoomJoinGifts.Where(g => roomIds.Contains(g.RoomId)).ToListAsync();
var giftsByRoom = giftRows.GroupBy(g => g.RoomId).ToDictionary(g => g.Key, g => g.ToList());
object RoomRow(RoomData r, RoomStats? stat, bool banned, string banReason) => new
{
id = r.RoomId,
name = r.Name,
description = r.Description,
image_name = r.ImageName,
custom_warning = r.CustomWarning,
creator_account_id = r.CreatorAccountId,
publish_state = r.State.ToString(),
accessibility = r.Accessibility.ToString(),
is_radicade_approved = false,
banned = banned,
ban_reason = banReason,
exclude_from_lists = false,
exclude_from_search = false,
supports_screens = r.SupportsScreens,
supports_walk_vr = r.SupportsWalkVR,
supports_teleport_vr = r.SupportsTeleportVR,
supports_vr_low = r.SupportsVRLow,
supports_quest2 = r.SupportsQuest2,
supports_mobile = r.SupportsMobile,
supports_juniors = r.SupportsJuniors,
min_level = r.MinLevel,
age_rating = 0,
created_at = r.CreatedAt,
published_at = r.PublishedAt,
cheer_count = stat?.CheerCount ?? 0,
favorite_count = stat?.FavoriteCount ?? 0,
visitor_count = stat?.VisitorCount ?? 0,
visit_count = stat?.VisitCount ?? 0,
ranking_context = "",
is_dorm = r.IsDorm,
is_place_play = false,
max_player_calculation_mode = (int)r.MaxPlayerCalculationMode,
max_players = r.MaxPlayers,
cloning_allowed = r.CloningAllowed,
disable_mic_auto_mute = r.DisableMicAutoMute,
disable_room_comments = r.DisableRoomComments,
encrypt_voice_chat = r.EncryptVoiceChat,
join_gift_enabled = r.JoinGiftEnabled,
join_gift_once_per_player = r.JoinGiftOncePerPlayer,
join_gift_version = r.JoinGiftVersion,
join_gift_title = r.JoinGiftTitle,
join_gift_message = r.JoinGiftMessage,
join_gift_avatar_item = r.JoinGiftAvatarItem,
join_gift_equipment_item = r.JoinGiftEquipmentItem,
join_gift_consumable_item = r.JoinGiftConsumableItem,
join_gift_currency_amount = r.JoinGiftCurrencyAmount,
join_gift_currency_type = r.JoinGiftCurrencyType.ToString(),
join_gift_xp = r.JoinGiftXp,
join_gift_context = r.JoinGiftContext.ToString(),
join_gift_rarity = r.JoinGiftRarity.ToString(),
join_gift_avatar_item_type = r.JoinGiftAvatarItemType.ToString(),
join_gift_equipment_modification_guid = r.JoinGiftEquipmentModificationGuid,
join_gift_from_player_id = Math.Max(0, r.JoinGiftFromPlayerId).ToString(CultureInfo.InvariantCulture),
// The room's whole list of boxes. A room saved before the list existed answers with
// the one box its row still describes, so the editor always opens on something real.
join_gifts = JoinGiftRows(r, giftsByRoom.GetValueOrDefault(r.RoomId)),
toxmod_enabled = false,
load_screen_locked = false,
persistence_version = 0,
min_ugc_sub_version = 0,
auto_localize_room = false,
is_developer_owned = false,
tags = r.Tags,
featured = SafeRoomTags(r.Tags).Any(t => string.Equals(t, "featured", StringComparison.OrdinalIgnoreCase))
};
var rooms = roomsList.Select(r =>
{
stats.TryGetValue(r.RoomId, out var stat);
return RoomRow(r, stat, false, "");
}).ToList();
var liveRoomIds = roomsList.Select(r => r.RoomId).ToHashSet();
foreach (var entry in RoomBanStore.Load())
{
if (liveRoomIds.Contains(entry.RoomId)) continue;
rooms.Add(RoomRow(entry.Room, entry.Stats, true, entry.Reason));
}
return OrderRows(rooms, order, ascending);
}
private async Task<List<object>> GetRoomComments(string? order, bool ascending)
{
var rows = await db.RoomComments
.AsNoTracking()
.Select(c => new
{
id = c.Id,
comment = c.Comment,
comment_type = c.CommentType,
room_id = c.RoomId,
creator_account_id = c.CreatorAccountId,
created_at = c.CreatedAt,
position_x = c.PositionX,
position_y = c.PositionY,
position_z = c.PositionZ
})
.ToListAsync();
return OrderRows(rows.Cast<object>().ToList(), order, ascending);
}
private List<object> GetCommunityBoard()
{
var state = LoadState();
if (state.CommunityBoard == null)
{
state.CommunityBoard = new JsonObject
{
["id"] = 1,
["featured_player_id"] = null,
["instagram_images"] = new JsonArray(),
["board_message"] = "",
["announcement_url"] = "",
["videos"] = new JsonArray(),
["featured_rooms"] = new JsonArray()
};
SaveState(state);
}
return new List<object> { state.CommunityBoard };
}
private async Task<JsonObject> UpdateCommunityBoard(AdminPanelState state, JsonElement payload)
{
if (state.CommunityBoard == null)
{
state.CommunityBoard = new JsonObject
{
["id"] = 1,
["featured_player_id"] = null,
["instagram_images"] = new JsonArray(),
["board_message"] = "",
["announcement_url"] = "",
["videos"] = new JsonArray(),
["featured_rooms"] = new JsonArray()
};
}
foreach (var prop in payload.EnumerateObject())
{
try
{
state.CommunityBoard[prop.Name] = JsonNode.Parse(prop.Value.GetRawText());
}
catch
{
if (prop.Value.ValueKind == JsonValueKind.String)
state.CommunityBoard[prop.Name] = prop.Value.GetString();
else if (prop.Value.ValueKind == JsonValueKind.Number)
state.CommunityBoard[prop.Name] = prop.Value.GetRawText();
else if (prop.Value.ValueKind == JsonValueKind.True)
state.CommunityBoard[prop.Name] = true;
else if (prop.Value.ValueKind == JsonValueKind.False)
state.CommunityBoard[prop.Name] = false;
else if (prop.Value.ValueKind == JsonValueKind.Null)
state.CommunityBoard[prop.Name] = null;
else
state.CommunityBoard[prop.Name] = prop.Value.GetRawText();
}
}
var instagramNode = state.CommunityBoard["instagram_images"];
if (instagramNode != null && instagramNode.GetValueKind() == JsonValueKind.Array)
{
foreach (var img in instagramNode.AsArray())
{
var url = img?["image_url"]?.ToString();
var existingName = img?["image_name"]?.ToString();
if (string.IsNullOrWhiteSpace(existingName)
&& url != null
&& url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
var saved = SaveDataUrlFile(url, DBConfig.CDNImages);
if (saved != null)
img!["image_name"] = saved;
}
}
}
SaveState(state);
var boardConfig = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == "CommunityBoard");
var instagramImages = new List<RecNet.Board.InstagramImage>();
if (instagramNode != null && instagramNode.GetValueKind() == JsonValueKind.Array)
{
foreach (var img in instagramNode.AsArray())
{
if (img == null) continue;
instagramImages.Add(new RecNet.Board.InstagramImage
{
ImageName = img["image_name"]?.ToString() ?? "",
ImageUrl = img["image_url"]?.ToString() ?? ""
});
}
}
var videos = new List<RecNet.Board.BoardVideo>();
var videosNode = state.CommunityBoard["videos"];
if (videosNode != null && videosNode.GetValueKind() == JsonValueKind.Array)
{
foreach (var vid in videosNode.AsArray())
{
if (vid == null) continue;
var blob = Path.GetFileName(vid["blob_name"]?.ToString() ?? "");
if (!string.IsNullOrWhiteSpace(blob) && !System.IO.File.Exists(Path.Join(DBConfig.CDNVideos, blob)))
blob = "";
var thumb = vid["thumbnail_blob_name"]?.ToString();
var thumbName = thumb != null && thumb.StartsWith("data:", StringComparison.OrdinalIgnoreCase)
? SaveDataUrlFile(thumb, DBConfig.CDNImages) ?? thumb
: thumb ?? "";
videos.Add(new RecNet.Board.BoardVideo
{
BlobName = blob,
Title = vid["title"]?.ToString() ?? "",
Description = vid["description"]?.ToString() ?? "",
ThumbnailBlobName = thumbName,
SourceUrl = vid["source_url"]?.ToString() ?? ""
});
}
}
var communityBoard = new RecNet.Board.CommunityBoard
{
FeaturedPlayer = new RecNet.Board.FeaturedPlayer
{
Id = (int)(state.CommunityBoard["featured_player_id"]?.GetValue<long>() ?? 0),
TitleOverride = state.CommunityBoard["featured_player_title"]?.ToString() ?? "",
UrlOverride = state.CommunityBoard["featured_player_url"]?.ToString() ?? ""
},
FeaturedRoomGroup = await BuildFeaturedRoomGroup(state.CommunityBoard),
CurrentAnnouncement = new RecNet.Board.BoardAnnouncement
{
Message = state.CommunityBoard["board_message"]?.ToString() ?? "",
MoreInfoUrl = state.CommunityBoard["announcement_url"]?.ToString() ?? ""
},
InstagramImages = instagramImages,
Videos = videos
};
if (boardConfig == null)
{
boardConfig = new RecNet.Config.ServerConfigVal { Key = "CommunityBoard", Value = JsonSerializer.Serialize(communityBoard) };
db.ServerConfig.Add(boardConfig);
}
else
{
boardConfig.Value = JsonSerializer.Serialize(communityBoard);
}
await db.SaveChangesAsync();
await CommunityBoardBroadcast.PushAsync(communityBoard);
return state.CommunityBoard;
}
private static long TryGetEntryId(JsonNode? entry)
{
if (entry == null) return 0;
foreach (var key in new[] { "id", "room_id", "RoomId" })
{
var raw = entry[key]?.ToString();
if (raw != null && long.TryParse(raw, out var parsed) && parsed > 0) return parsed;
}
return 0;
}
private async Task<RecNet.Board.FeaturedRoomGroup> BuildFeaturedRoomGroup(JsonObject board)
{
var rooms = new List<RecNet.Board.FeaturedRoom>();
var featuredRooms = board["featured_rooms"];
if (featuredRooms != null && featuredRooms.GetValueKind() == JsonValueKind.Array)
{
var roomIds = new List<long>();
foreach (var entry in featuredRooms.AsArray())
{
if (entry == null) continue;
var id = TryGetEntryId(entry);
if (id > 0) roomIds.Add(id);
}
var dbRooms = roomIds.Count > 0
? await db.Rooms.Where(x => roomIds.Contains(x.RoomId)).ToDictionaryAsync(x => x.RoomId)
: new Dictionary<long, RadicadeServer.RecNet.Rooms.RoomData>();
foreach (var entry in featuredRooms.AsArray())
{
if (entry == null) continue;
var id = TryGetEntryId(entry);
if (id <= 0) continue;
var name = entry["name"]?.ToString() ?? entry["room_name"]?.ToString() ?? entry["RoomName"]?.ToString() ?? "";
dbRooms.TryGetValue(id, out var dbRoom);
if (dbRoom != null)
{
if (string.IsNullOrWhiteSpace(name)) name = dbRoom.Name;
}
rooms.Add(new RecNet.Board.FeaturedRoom
{
RoomId = id,
RoomName = name,
ImageName = dbRoom?.ImageName ?? ""
});
}
}
return new RecNet.Board.FeaturedRoomGroup
{
FeaturedRoomGroupId = 1,
Name = board["featured_rooms_title"]?.ToString() ?? "Top Picks",
Rooms = rooms
};
}
private static Announcement ToAnnouncement(JsonObject row)
{
string? Text(string key) => row[key]?.ToString();
return new Announcement
{
AnnouncementId = ReadAnnouncementId(row) ?? 0,
AnnouncementStatus = TryGetJsonInt(row, "AnnouncementStatus") ?? 0,
AnnouncementType = TryGetJsonInt(row, "AnnouncementType") ?? 0,
Title = Text("Title") ?? Text("title") ?? string.Empty,
Body = Text("Body") ?? Text("message") ?? string.Empty,
ImageName = Text("ImageName") ?? string.Empty,
LinkName = Text("LinkName") ?? Text("link_name") ?? string.Empty,
LinkType = TryGetJsonInt(row, "LinkType") ?? 0,
LinkUri = Text("LinkUri") ?? Text("link_uri") ?? string.Empty,
Platform = Platforms.All,
CreatedAt = DateTime.UtcNow
};
}
private static long? ReadAnnouncementId(JsonObject row)
{
if (row["id"] is not JsonValue value) return null;
if (value.TryGetValue<long>(out var asLong)) return asLong;
if (value.TryGetValue<double>(out var asDouble) && asDouble <= long.MaxValue && asDouble >= long.MinValue) return (long)asDouble;
if (value.TryGetValue<string>(out var asString) && long.TryParse(asString, out var parsed)) return parsed;
return null;
}
private List<object> GetAnnouncements()
{
var config = db.ServerConfig.FirstOrDefault(x => x.Key == "Announcement");
if (config == null || string.IsNullOrWhiteSpace(config.Value))
{
return new List<object>();
}
try
{
var deserialized = config.Value.FromJson<List<JsonObject>>();
return deserialized?.Cast<object>().ToList() ?? new List<object>();
}
catch
{
return new List<object>();
}
}
private async Task<IActionResult> UpdateAnnouncements(JsonElement payload)
{
try
{
var items = new List<JsonElement>();
if (payload.ValueKind == JsonValueKind.Array)
{
foreach (var el in payload.EnumerateArray()) items.Add(el);
}
else
{
items.Add(payload);
}
if (items.Count == 0) return Ok(new JsonObject());
var config = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == "Announcement");
if (config == null)
{
config = new RecNet.Config.ServerConfigVal { Key = "Announcement", Value = "[]" };
db.ServerConfig.Add(config);
}
List<JsonObject> list;
try
{
list = (config.Value ?? "[]").FromJson<List<JsonObject>>() ?? new List<JsonObject>();
}
catch
{
list = new List<JsonObject>();
}
long nextId = 1;
foreach (var existing in list)
{
var existingId = ReadAnnouncementId(existing);
if (existingId == null) continue;
existing["id"] = existingId.Value;
if (existingId.Value >= nextId) nextId = existingId.Value + 1;
}
var pendingNotifs = new List<Notification>();
int? maintenanceMinutes = null;
JsonObject? created = null;
foreach (var announcement in items)
{
var type = announcement.TryGetProperty("type", out var typeProp)
? (typeProp.GetString() ?? "Regular Announcement")
: "Regular Announcement";
var message = announcement.TryGetProperty("message", out var msgProp) ? msgProp.GetString() ?? "" : "";
var imageRaw = announcement.TryGetProperty("image", out var imgProp) && imgProp.ValueKind == JsonValueKind.String
? imgProp.GetString()
: null;
var minutes = TryGetInt(announcement, "minutes", out var minutesValue) ? minutesValue : (int?)null;
var expiresAt = announcement.TryGetProperty("expires_at", out var expProp) && expProp.ValueKind == JsonValueKind.String
? expProp.GetString()
: null;
var linkName = announcement.TryGetProperty("link_name", out var lnProp) ? lnProp.GetString() : null;
var linkTypeRaw = TryGetInt(announcement, "link_type", out var linkTypeValue) ? linkTypeValue : (int?)null;
var linkUri = announcement.TryGetProperty("link_uri", out var luProp) ? luProp.GetString() : null;
var title = announcement.TryGetProperty("title", out var tProp) ? tProp.GetString() : null;
var normalized = type.ToLower().Replace(" ", "_");
string? imageName = null;
if (!string.IsNullOrWhiteSpace(imageRaw))
{
imageName = SaveDataUrlFile(imageRaw, DBConfig.CDNImages);
imageName ??= imageRaw;
}
var panelImage = imageRaw;
if (!string.IsNullOrWhiteSpace(imageName) && imageName != imageRaw && !imageName.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
panelImage = $"{DBConfig.ServerUrl}/images/img/{imageName}";
var buttonType = linkTypeRaw ?? 0;
var platformType = -1;
var buttonName = string.IsNullOrWhiteSpace(linkName)
? (normalized == "maintenance" || normalized == "game_update" ? string.Empty : "Learn More")
: linkName;
var buttonUri = string.IsNullOrWhiteSpace(linkUri) ? string.Empty : linkUri;
var annStatus = normalized == "maintenance" ? 4 : 0;
var row = new JsonObject
{
["id"] = nextId,
["type"] = type,
["message"] = message,
["image"] = panelImage,
["minutes"] = minutes,
["expires_at"] = expiresAt,
["created_at"] = DateTime.UtcNow,
["title"] = string.IsNullOrWhiteSpace(title) ? type : title,
["link_name"] = buttonName,
["link_type"] = buttonType,
["link_uri"] = buttonUri,
["AnnouncementId"] = nextId,
["AnnouncementStatus"] = annStatus,
["AnnouncementType"] = buttonType,
["Title"] = string.IsNullOrWhiteSpace(title) ? type : title,
["Body"] = message,
["ImageName"] = imageName ?? "",
["IsMostRecent"] = true,
["LinkName"] = buttonName,
["LinkType"] = platformType,
["LinkUri"] = buttonUri,
["Platform"] = -1,
["CreatedAt"] = DateTime.UtcNow
};
list.Add(row);
created = row;
var annObject = new Announcement
{
AnnouncementId = nextId,
AnnouncementStatus = annStatus,
AnnouncementType = buttonType,
Title = string.IsNullOrWhiteSpace(title) ? type : title,
Body = message,
ImageName = imageName ?? "",
IsMostRecent = true,
LinkName = buttonName,
LinkType = platformType,
LinkUri = buttonUri,
Platform = Platforms.All,
CreatedAt = DateTime.UtcNow
};
nextId++;
Notification? notif = null;
if (normalized == "maintenance")
{
var startsInMinutes = Math.Clamp(minutes ?? 30, 0, 61);
notif = new Notification
{
Id = NotificationType.ServerMaintenance,
Msg = new ServerMaintenance { StartsInMinutes = startsInMinutes }
};
maintenanceMinutes = startsInMinutes;
}
else if (normalized == "game_update")
{
notif = Notify.EmptyObjectPayload(GameUpdateVersionCheck.AppVersionUpdateNotificationId);
}
else
{
notif = AnnouncementBroadcast.Update(annObject);
}
if (notif != null) pendingNotifs.Add(notif);
}
config.Value = JsonSerializer.Serialize(list);
await db.SaveChangesAsync();
if (maintenanceMinutes.HasValue)
await ApplyMaintenanceToConfig(maintenanceMinutes.Value);
foreach (var notif in pendingNotifs)
{
try { await Notify.SendGlobalNotify(notif); }
catch (Exception) { }
}
return Ok(created ?? new JsonObject());
}
catch (Exception ex)
{
return StatusCode(StatusCodes.Status500InternalServerError, new JsonObject { ["error"] = ex.Message });
}
}
private List<object> GetModerationReports(string? order, bool ascending)
{
var state = LoadState();
if (state.ModerationReports.Count == 0)
{
state.ModerationReports = new List<JsonObject>();
SaveState(state);
}
return OrderRows(state.ModerationReports.Cast<object>().ToList(), order, ascending);
}
private List<object> GetAvatarItems()
{
var avatarItems = DBConfig.DefaultAvatarCatalog.FromJson<List<AvatarItem>>() ?? new List<AvatarItem>();
return avatarItems
.Where(item => item != null && !string.IsNullOrWhiteSpace(item.AvatarItemDesc))
.Select(item => new
{
avatar_item_desc = item.AvatarItemDesc,
unlocked_level = item.UnlockedLevel,
platform_mask = item.PlatformMask,
friendly_name = item.FriendlyName ?? "",
tooltip = item.Tooltip ?? "",
rarity = ((Rarity)item.Rarity).ToString(),
// The picture the game shows for this item. The panel builds its own URL from
// it (the same /images/img/<name> the server serves), so an item that has one
// is shown as a tile rather than as a line of text.
thumbnail_image = item.ThumbnailImage ?? ""
}).Cast<object>().ToList();
}
private async Task<List<object>> GetPlayerEvents(string? order, bool ascending)
{
var events = await db.PlayerEvents.ToListAsync();
var rows = events.Select(ev => new
{
id = ev.PlayerEventId,
creator_player_id = ev.CreatorPlayerId,
image_name = ev.ImageName,
room_id = ev.RoomId,
name = ev.Name,
description = ev.Description,
sub_room_id = ev.SubRoomId,
club_id = (long?)null,
start_time = ev.StartTime,
end_time = ev.EndTime,
attendee_count = ev.AttendeeCount,
state = 0,
moderation_state = ev.ModerationState,
accessibility = (int)ev.Accessibility,
is_multi_instance = false,
support_multi_instance_room_chat = false,
broadcasting_room_instance_id = (long?)null,
default_broadcast_permissions = 0,
can_request_broadcast_permissions = 0,
recurrence_schedule = (string?)null,
created_at = ev.CreatedAt
}).Cast<object>().ToList();
return OrderRows(rows, order, ascending);
}
private async Task<List<object>> GetPlayerEventResponses(string? eqCol, string? eqVal, string? order, bool ascending)
{
var query = db.PlayerEventResponses.AsQueryable();
if (eqCol == "player_event_id" && long.TryParse(eqVal, out var eventId))
query = query.Where(x => x.PlayerEventId == eventId);
var responses = await query.ToListAsync();
var playerIds = responses.Select(r => r.PlayerId).Distinct().ToList();
var profiles = await db.Profiles.Where(p => playerIds.Contains(p.accountId)).ToDictionaryAsync(p => p.accountId);
var rows = responses.Select(r => new
{
id = r.PlayerEventResponseId,
player_event_id = r.PlayerEventId,
player_id = r.PlayerId,
player_name = profiles.TryGetValue(r.PlayerId, out var profile) ? profile.displayName : ("Player " + r.PlayerId),
type = (int)r.Type,
type_name = r.Type.ToString(),
created_at = r.CreatedAt
}).Cast<object>().ToList();
return OrderRows(rows, order, ascending);
}
private async Task<List<object>> GetEventInvitations(string? eqCol, string? eqVal, string? order, bool ascending)
{
var query = db.Messages.Where(m => m.Type == MessageType.PlayerEventInvitation);
if (eqCol == "player_event_id" && long.TryParse(eqVal, out var eventId))
query = query.Where(m => m.PlayerEventId == eventId);
var invitations = await query.ToListAsync();
var playerIds = invitations.SelectMany(m => new[] { m.FromPlayerId, m.ToPlayerId }).Distinct().ToList();
var profiles = await db.Profiles.Where(p => playerIds.Contains(p.accountId)).ToDictionaryAsync(p => p.accountId);
var rows = invitations.Select(m => new
{
id = m.Id,
player_event_id = m.PlayerEventId,
from_player_id = m.FromPlayerId,
from_name = profiles.TryGetValue(m.FromPlayerId, out var from) ? from.displayName : ("Player " + m.FromPlayerId),
to_player_id = m.ToPlayerId,
to_name = profiles.TryGetValue(m.ToPlayerId, out var to) ? to.displayName : ("Player " + m.ToPlayerId),
sent_time = m.SentTime
}).Cast<object>().ToList();
return OrderRows(rows, order, ascending);
}
private async Task<object> UpdatePlayerEvent(long id, JsonElement payload)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(x => x.PlayerEventId == id);
if (ev == null) return NotFound();
if (TryGetString(payload, "name", out var name)) ev.Name = name;
if (TryGetString(payload, "description", out var description)) ev.Description = description;
if (TryGetString(payload, "image_name", out var imageName)) ev.ImageName = imageName;
if (TryGetLong(payload, "room_id", out var roomId) && roomId > 0) ev.RoomId = roomId;
if (TryGetDate(payload, "start_time", out var startTime)) ev.StartTime = startTime;
if (TryGetDate(payload, "end_time", out var endTime)) ev.EndTime = endTime;
if (TryGetInt(payload, "attendee_count", out var attendeeCount)) ev.AttendeeCount = attendeeCount;
if (TryGetInt(payload, "accessibility", out var accessibility)) ev.Accessibility = (Accessibility)accessibility;
if (TryGetLong(payload, "creator_player_id", out var creatorId) && creatorId > 0) ev.CreatorPlayerId = creatorId;
if (TryGetInt(payload, "moderation_state", out var moderationState)) ev.ModerationState = moderationState;
if (payload.TryGetProperty("sub_room_id", out var subRoomElem) && subRoomElem.ValueKind != JsonValueKind.Undefined)
{
if (subRoomElem.ValueKind == JsonValueKind.Null) ev.SubRoomId = null;
else if (subRoomElem.ValueKind == JsonValueKind.Number && subRoomElem.TryGetInt64(out var subRoomId)) ev.SubRoomId = subRoomId;
else if (subRoomElem.ValueKind == JsonValueKind.String && long.TryParse(subRoomElem.GetString(), out var subRoomStr)) ev.SubRoomId = subRoomStr;
}
await db.SaveChangesAsync();
return Ok(new { success = true });
}
private async Task<List<object>> DeletePlayerEvents(List<long> ids)
{
var events = await db.PlayerEvents.Where(x => ids.Contains(x.PlayerEventId)).ToListAsync();
if (events.Count == 0) return new List<object>();
var eventIds = events.Select(x => x.PlayerEventId).ToList();
var responses = await db.PlayerEventResponses.Where(x => eventIds.Contains(x.PlayerEventId)).ToListAsync();
db.PlayerEventResponses.RemoveRange(responses);
db.PlayerEvents.RemoveRange(events);
await db.SaveChangesAsync();
return events.Select(ev => new { id = ev.PlayerEventId, name = ev.Name }).Cast<object>().ToList();
}
private static bool TryGetDate(JsonElement payload, string prop, out DateTime value)
{
if (payload.TryGetProperty(prop, out var elem) && elem.ValueKind == JsonValueKind.String && DateTime.TryParse(elem.GetString(), out var parsed))
{
value = parsed.ToUniversalTime();
return true;
}
value = default;
return false;
}
/// <summary>
/// A room's join gift boxes as the panel reads them. A room with no list answers with the
/// single box its own row describes, so a room configured before the list existed opens on the
/// box it actually hands out rather than on an empty editor.
/// </summary>
private static List<object> JoinGiftRows(RoomData room, List<RoomJoinGift>? rows)
{
var source = rows is { Count: > 0 } ? rows : [RoomJoinGiftUtils.Snapshot(room)];
return source
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.RoomJoinGiftId)
.Select(JoinGiftRow)
.ToList();
}
private static object JoinGiftRow(RoomJoinGift gift) => new
{
id = gift.RoomJoinGiftId,
sort_order = gift.SortOrder,
version = gift.Version,
enabled = gift.Enabled,
once_per_player = gift.OncePerPlayer,
title = gift.Title,
message = gift.Message,
// Digits in a string, for the same reason the room's own sender is: an account id is
// eighteen digits and a JSON number would arrive in the panel rounded.
from_player_id = Math.Max(0, gift.FromPlayerId).ToString(CultureInfo.InvariantCulture),
avatar_item = gift.AvatarItem,
avatar_item_type = gift.AvatarItemType.ToString(),
equipment_item = gift.EquipmentItem,
equipment_modification_guid = gift.EquipmentModificationGuid,
consumable_item = gift.ConsumableItem,
currency_type = gift.CurrencyType.ToString(),
currency_amount = gift.CurrencyAmount,
xp = gift.Xp,
context = gift.Context.ToString(),
rarity = gift.Rarity.ToString()
};
/// <summary>
/// Replaces a room's list of join gift boxes with the list the panel sent. A box that is new is
/// inserted; a box whose contents changed keeps its identity but gets a new version, so players
/// who already opened the old box are handed the new one; a box the panel left out is deleted.
/// Anything that cannot be read refuses the save before a row is touched, so a mistyped id never
/// leaves the room half-written.
/// </summary>
private async Task<IActionResult?> ApplyJoinGiftListAsync(RoomData room, JsonElement list)
{
var wanted = new List<RoomJoinGift>();
var index = 0;
foreach (var element in list.EnumerateArray())
{
var gift = ReadJoinGift(element, index, out var error);
if (gift == null) return BadRequest(error);
gift.SortOrder = index;
wanted.Add(gift);
index++;
}
if (wanted.Count > MaxJoinGiftsPerRoom)
return BadRequest($"A room can hold at most {MaxJoinGiftsPerRoom} join gift boxes.");
var existing = await db.RoomJoinGifts.Where(x => x.RoomId == room.RoomId).ToListAsync();
var kept = new HashSet<long>();
var changed = false;
foreach (var gift in wanted)
{
RoomJoinGift? row = null;
if (gift.RoomJoinGiftId > 0)
{
row = existing.FirstOrDefault(x => x.RoomJoinGiftId == gift.RoomJoinGiftId);
if (row == null)
return BadRequest($"join_gifts[{gift.SortOrder}].id {gift.RoomJoinGiftId} is not one of this room's gift boxes.");
}
if (row == null)
{
var added = new RoomJoinGift
{
RoomId = room.RoomId,
SortOrder = gift.SortOrder,
Version = 1
};
CopyJoinGiftValues(gift, added);
db.RoomJoinGifts.Add(added);
changed = true;
continue;
}
kept.Add(row.RoomJoinGiftId);
if (!SameJoinGift(row, gift))
{
CopyJoinGiftValues(gift, row);
row.Version++;
changed = true;
}
// Moving a box up or down the list reissues nothing: the box itself did not change.
row.SortOrder = gift.SortOrder;
}
var removed = existing.Where(x => !kept.Contains(x.RoomJoinGiftId)).ToList();
if (removed.Count > 0)
{
db.RoomJoinGifts.RemoveRange(removed);
changed = true;
}
// The room's own fields describe its first box, so a reader that only knows those fields -
// and the editor of a room saved before the list existed - still sees what it hands out.
var first = wanted.OrderBy(x => x.SortOrder).FirstOrDefault();
room.JoinGiftEnabled = wanted.Any(x => x.Enabled);
room.JoinGiftOncePerPlayer = first?.OncePerPlayer ?? true;
room.JoinGiftTitle = first?.Title ?? "Room Visit Gift";
room.JoinGiftMessage = first?.Message ?? "";
room.JoinGiftAvatarItem = first?.AvatarItem ?? "";
room.JoinGiftAvatarItemType = first?.AvatarItemType ?? AvatarItemType.Outfit;
room.JoinGiftEquipmentItem = first?.EquipmentItem ?? "";
room.JoinGiftEquipmentModificationGuid = first?.EquipmentModificationGuid ?? "";
room.JoinGiftConsumableItem = first?.ConsumableItem ?? "";
room.JoinGiftCurrencyType = first?.CurrencyType ?? CurrencyType.RecCenterTokens;
room.JoinGiftCurrencyAmount = first?.CurrencyAmount ?? 0;
room.JoinGiftXp = first?.Xp ?? 0;
room.JoinGiftContext = first?.Context ?? GiftContext.Game_Drop;
room.JoinGiftRarity = first?.Rarity ?? Rarity.Common;
room.JoinGiftFromPlayerId = first?.FromPlayerId ?? 0;
if (changed) room.JoinGiftVersion++;
await db.SaveChangesAsync();
return null;
}
private const int MaxJoinGiftsPerRoom = 20;
/// <summary>One box as the panel sent it, or null with the reason it could not be read.</summary>
private static RoomJoinGift? ReadJoinGift(JsonElement element, int index, out string? error)
{
error = null;
if (element.ValueKind != JsonValueKind.Object)
{
error = $"join_gifts[{index}] must be a gift box.";
return null;
}
var gift = new RoomJoinGift { RoomJoinGiftId = Math.Max(0, TryGetJsonLong(element, "id") ?? 0) };
if (TryGetJsonBool(element, "enabled") is { } enabled) gift.Enabled = enabled;
if (TryGetJsonBool(element, "once_per_player") is { } oncePerPlayer) gift.OncePerPlayer = oncePerPlayer;
if (GetJsonString(element, "title") is { } title) gift.Title = title;
if (GetJsonString(element, "message") is { } message) gift.Message = message;
if (GetJsonString(element, "avatar_item") is { } avatarItem) gift.AvatarItem = avatarItem;
if (ParseAvatarItemType(GetJsonString(element, "avatar_item_type")) is { } avatarItemType) gift.AvatarItemType = avatarItemType;
if (GetJsonString(element, "equipment_item") is { } equipmentItem) gift.EquipmentItem = equipmentItem;
if (GetJsonString(element, "equipment_modification_guid") is { } modificationGuid) gift.EquipmentModificationGuid = modificationGuid;
if (GetJsonString(element, "consumable_item") is { } consumableItem) gift.ConsumableItem = consumableItem;
if (ParseCurrencyType(GetJsonString(element, "currency_type")) is { } currencyType) gift.CurrencyType = currencyType;
if (TryGetJsonInt(element, "currency_amount") is { } currencyAmount) gift.CurrencyAmount = Math.Max(0, currencyAmount);
if (TryGetJsonInt(element, "xp") is { } xp) gift.Xp = Math.Max(0, xp);
if (ParseGiftContext(GetJsonString(element, "context")) is { } context) gift.Context = context;
if (ParseRarity(GetJsonString(element, "rarity")) is { } rarity) gift.Rarity = rarity;
// Named with the box it belongs to: an eighteen-digit account id has to stay digits, and a
// refusal has to say which box it came from.
if (TryGetAccountId(element, "from_player_id", out var fromPlayerId, out var fromError))
{
if (fromError != null)
{
error = $"join_gifts[{index}].from_player_id must be an account id in digits, or blank for no sender.";
return null;
}
gift.FromPlayerId = fromPlayerId;
}
return gift;
}
private static bool SameJoinGift(RoomJoinGift row, RoomJoinGift sent)
{
return row.Enabled == sent.Enabled &&
row.OncePerPlayer == sent.OncePerPlayer &&
string.Equals(row.Title ?? "", sent.Title ?? "", StringComparison.Ordinal) &&
string.Equals(row.Message ?? "", sent.Message ?? "", StringComparison.Ordinal) &&
string.Equals(row.AvatarItem ?? "", sent.AvatarItem ?? "", StringComparison.Ordinal) &&
row.AvatarItemType == sent.AvatarItemType &&
string.Equals(row.EquipmentItem ?? "", sent.EquipmentItem ?? "", StringComparison.Ordinal) &&
string.Equals(row.EquipmentModificationGuid ?? "", sent.EquipmentModificationGuid ?? "", StringComparison.Ordinal) &&
string.Equals(row.ConsumableItem ?? "", sent.ConsumableItem ?? "", StringComparison.Ordinal) &&
row.CurrencyType == sent.CurrencyType &&
row.CurrencyAmount == sent.CurrencyAmount &&
row.Xp == sent.Xp &&
row.Context == sent.Context &&
row.Rarity == sent.Rarity &&
row.FromPlayerId == sent.FromPlayerId;
}
/// <summary>The fields that make a box a box - never its id, room, order or version.</summary>
private static void CopyJoinGiftValues(RoomJoinGift from, RoomJoinGift to)
{
to.Enabled = from.Enabled;
to.OncePerPlayer = from.OncePerPlayer;
to.Title = from.Title ?? "";
to.Message = from.Message ?? "";
to.AvatarItem = from.AvatarItem ?? "";
to.AvatarItemType = from.AvatarItemType;
to.EquipmentItem = from.EquipmentItem ?? "";
to.EquipmentModificationGuid = from.EquipmentModificationGuid ?? "";
to.ConsumableItem = from.ConsumableItem ?? "";
to.CurrencyType = from.CurrencyType;
to.CurrencyAmount = from.CurrencyAmount;
to.Xp = from.Xp;
to.Context = from.Context;
to.Rarity = from.Rarity;
to.FromPlayerId = from.FromPlayerId;
}
private async Task<object> UpdateRoom(long id, JsonElement payload)
{
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id);
if (room == null)
{
if (payload.TryGetProperty("banned", out var liftBan) && liftBan.ValueKind == JsonValueKind.False)
{
var restored = await RoomBanStore.RestoreAsync(db, id);
if (restored) return Ok(new { success = true, room_id = id, banned = false, restored = true });
}
return NotFound("Room does not exist.");
}
if (TryGetString(payload, "name", out var name)) room.Name = name;
if (TryGetString(payload, "description", out var description)) room.Description = description;
if (TryGetString(payload, "image_name", out var imageName)) room.ImageName = imageName;
if (TryGetString(payload, "custom_warning", out var customWarning)) room.CustomWarning = customWarning;
if (payload.TryGetProperty("supports_screens", out var supportsScreens)) room.SupportsScreens = supportsScreens.GetBoolean();
if (payload.TryGetProperty("supports_walk_vr", out var supportsWalk)) room.SupportsWalkVR = supportsWalk.GetBoolean();
if (payload.TryGetProperty("supports_teleport_vr", out var supportsTeleport)) room.SupportsTeleportVR = supportsTeleport.GetBoolean();
if (payload.TryGetProperty("supports_vr_low", out var supportsVrLow)) room.SupportsVRLow = supportsVrLow.GetBoolean();
if (payload.TryGetProperty("supports_quest2", out var supportsQuest2)) room.SupportsQuest2 = supportsQuest2.GetBoolean();
if (payload.TryGetProperty("supports_mobile", out var supportsMobile)) room.SupportsMobile = supportsMobile.GetBoolean();
if (payload.TryGetProperty("supports_juniors", out var supportsJuniors)) room.SupportsJuniors = supportsJuniors.GetBoolean();
if (payload.TryGetProperty("cloning_allowed", out var cloningAllowed)) room.CloningAllowed = cloningAllowed.GetBoolean();
if (payload.TryGetProperty("disable_mic_auto_mute", out var disableMicAutoMute)) room.DisableMicAutoMute = disableMicAutoMute.GetBoolean();
if (payload.TryGetProperty("disable_room_comments", out var disableRoomComments)) room.DisableRoomComments = disableRoomComments.GetBoolean();
if (payload.TryGetProperty("encrypt_voice_chat", out var encryptVoiceChat)) room.EncryptVoiceChat = encryptVoiceChat.GetBoolean();
if (payload.TryGetProperty("is_dorm", out var isDorm)) room.IsDorm = isDorm.GetBoolean();
if (TryGetInt(payload, "min_level", out var minLevel)) room.MinLevel = minLevel;
if (TryGetInt(payload, "max_players", out var maxPlayers)) room.MaxPlayers = maxPlayers;
if (TryGetInt(payload, "max_player_calculation_mode", out var maxCalcMode)) room.MaxPlayerCalculationMode = (MaxPlayerMode)maxCalcMode;
// A room's boxes arrive as a list. When the panel sends one, it is the whole truth about
// that room's boxes and the single-box fields below are not read: the list is written to
// RoomJoinGifts and the room's own fields are then filled in from its first box, so every
// older reader of those fields still sees the box the room hands out first.
if (payload.TryGetProperty("join_gifts", out var joinGiftList) && joinGiftList.ValueKind == JsonValueKind.Array)
{
var refusal = await ApplyJoinGiftListAsync(room, joinGiftList);
if (refusal != null) return refusal;
}
else
{
var giftChanged = false;
if (payload.TryGetProperty("join_gift_enabled", out var joinGiftEnabled)) room.JoinGiftEnabled = joinGiftEnabled.GetBoolean();
if (payload.TryGetProperty("join_gift_once_per_player", out var joinGiftOnce)) room.JoinGiftOncePerPlayer = joinGiftOnce.GetBoolean();
if (TryGetString(payload, "join_gift_title", out var giftTitle)) { room.JoinGiftTitle = giftTitle ?? ""; giftChanged = true; }
if (TryGetString(payload, "join_gift_message", out var giftMessage)) { room.JoinGiftMessage = giftMessage ?? ""; giftChanged = true; }
if (TryGetString(payload, "join_gift_avatar_item", out var giftAvatar)) { room.JoinGiftAvatarItem = giftAvatar ?? ""; giftChanged = true; }
if (TryGetString(payload, "join_gift_equipment_item", out var giftEquip)) { room.JoinGiftEquipmentItem = giftEquip ?? ""; giftChanged = true; }
if (TryGetString(payload, "join_gift_consumable_item", out var giftCons)) { room.JoinGiftConsumableItem = giftCons ?? ""; giftChanged = true; }
if (TryGetInt(payload, "join_gift_currency_amount", out var giftCurrencyAmount)) { room.JoinGiftCurrencyAmount = Math.Max(0, giftCurrencyAmount); giftChanged = true; }
if (TryGetInt(payload, "join_gift_xp", out var giftXp)) { room.JoinGiftXp = Math.Max(0, giftXp); giftChanged = true; }
if (TryGetString(payload, "join_gift_currency_type", out var giftCurrencyTypeRaw) &&
StorefrontShared.TryParseCurrencyType(giftCurrencyTypeRaw, out var giftCurrencyType))
{
room.JoinGiftCurrencyType = giftCurrencyType;
giftChanged = true;
}
if (TryGetString(payload, "join_gift_context", out var giftContextRaw) && ParseGiftContext(giftContextRaw) is { } giftContext)
{
room.JoinGiftContext = giftContext;
giftChanged = true;
}
if (TryGetString(payload, "join_gift_rarity", out var giftRarityRaw) && ParseRarity(giftRarityRaw) is { } giftRarity)
{
room.JoinGiftRarity = giftRarity;
giftChanged = true;
}
if (TryGetString(payload, "join_gift_avatar_item_type", out var giftAvatarTypeRaw) && ParseAvatarItemType(giftAvatarTypeRaw) is { } giftAvatarType)
{
room.JoinGiftAvatarItemType = giftAvatarType;
giftChanged = true;
}
if (TryGetString(payload, "join_gift_equipment_modification_guid", out var giftEquipMod))
{
room.JoinGiftEquipmentModificationGuid = giftEquipMod ?? "";
giftChanged = true;
}
if (TryGetAccountId(payload, "join_gift_from_player_id", out var giftFrom, out var giftFromError))
{
if (giftFromError != null) return BadRequest(giftFromError);
// Only a sender that actually changed re-issues the box: this field is not carried by
// every panel that saves a room, so it must not bump the gift version on its own.
if (room.JoinGiftFromPlayerId != giftFrom)
{
room.JoinGiftFromPlayerId = giftFrom;
giftChanged = true;
}
}
if (TryGetInt(payload, "join_gift_version", out var giftVersion) && giftVersion > 0)
room.JoinGiftVersion = giftVersion;
else if (giftChanged)
room.JoinGiftVersion++;
}
var stats = await db.RoomStats.FirstOrDefaultAsync(x => x.RoomId == room.RoomId);
if (stats == null && HasAny(payload, "cheer_count", "favorite_count", "visitor_count", "visit_count"))
{
stats = new RadicadeServer.RecNet.Rooms.RoomStats { RoomId = room.RoomId };
db.RoomStats.Add(stats);
}
if (stats != null)
{
if (TryGetInt(payload, "cheer_count", out var cheerCount)) stats.CheerCount = cheerCount;
if (TryGetInt(payload, "favorite_count", out var favoriteCount)) stats.FavoriteCount = favoriteCount;
if (TryGetInt(payload, "visitor_count", out var visitorCount)) stats.VisitorCount = visitorCount;
if (TryGetInt(payload, "visit_count", out var visitCount)) stats.VisitCount = visitCount;
}
if (TryGetString(payload, "publish_state", out var publishState) && Enum.TryParse<RoomState>(publishState, true, out var state))
room.State = state;
if (payload.TryGetProperty("banned", out var banned))
{
if (banned.ValueKind == JsonValueKind.True)
{
var banReason = TryGetString(payload, "ban_reason", out var reason) ? reason ?? "" : "";
await RoomBanStore.BanAsync(db, room, banReason, await GetPanelAdminUsernameAsync());
return Ok(new { success = true, room_id = id, banned = true, reason = banReason });
}
room.State = RoomState.Active;
}
if (payload.TryGetProperty("accessibility", out var accessibilityProp))
{
var accessibilityText = accessibilityProp.ValueKind switch
{
JsonValueKind.Number => accessibilityProp.GetRawText(),
JsonValueKind.String => accessibilityProp.GetString(),
_ => null
};
if (TryParseRoomAccessibility(accessibilityText, out var parsedAccessibility))
room.Accessibility = parsedAccessibility;
}
var featuredFlag = TryGetJsonBool(payload, "featured") ?? TryGetJsonBool(payload, "is_featured");
if (featuredFlag != null)
{
var tags = StringTools.ReadTags(room.Tags);
var featuredTag = tags.FirstOrDefault(x => string.Equals(x.Tag, "featured", StringComparison.OrdinalIgnoreCase));
if (featuredFlag.Value)
{
if (featuredTag == null)
tags.Add(new ItemTag { Type = TagType.Auto, Tag = "featured" });
}
else if (featuredTag != null)
{
tags.Remove(featuredTag);
}
room.Tags = StringTools.CreateTagString(tags);
}
else if (payload.TryGetProperty("tags", out var tagsElement) || payload.TryGetProperty("Tags", out tagsElement))
{
var tagString = ParseRoomTags(tagsElement);
if (tagString != null) room.Tags = tagString;
}
await db.SaveChangesAsync();
return Ok(new { success = true });
}
private static string? ParseRoomTags(JsonElement element)
{
try
{
var tags = new List<ItemTag>();
if (element.ValueKind == JsonValueKind.String)
{
AddTagsFromString(element.GetString());
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (var entry in element.EnumerateArray())
{
if (entry.ValueKind == JsonValueKind.String)
{
AddTagsFromString(entry.GetString());
continue;
}
if (entry.ValueKind != JsonValueKind.Object) continue;
var name = (GetJsonString(entry, "Tag") ?? GetJsonString(entry, "tag"))?.Trim().TrimStart('#');
if (string.IsNullOrWhiteSpace(name)) continue;
var type = TagType.General;
var rawType = GetJsonString(entry, "Type") ?? GetJsonString(entry, "type");
if (rawType != null)
{
if (int.TryParse(rawType, out var typeValue) && Enum.IsDefined(typeof(TagType), typeValue))
type = (TagType)typeValue;
else if (Enum.TryParse<TagType>(rawType, true, out var parsedType))
type = parsedType;
}
tags.Add(new ItemTag { Tag = name, Type = type });
}
}
else
{
return null;
}
return StringTools.CreateTagString(tags);
void AddTagsFromString(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return;
foreach (var part in raw.Split(new[] { ',', ' ', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var name = part.TrimStart('#');
if (!string.IsNullOrWhiteSpace(name))
tags.Add(new ItemTag { Tag = name, Type = TagType.General });
}
}
}
catch
{
return null;
}
}
private async Task<List<JsonObject>> AddGiftLogRows(AdminPanelState state, JsonElement payload)
{
var added = AddRows(state, state.GiftLog, payload);
foreach (var gift in added)
{
var playerId = long.TryParse(gift["player_id"]?.ToString(), out var pid) ? pid : 0;
if (playerId > 0)
{
await ProcessGiftRowAsync(playerId, gift);
}
else
{
gift["error"] = "Invalid or missing player_id.";
}
}
SaveState(state);
return added;
}
private async Task<long> ProcessGiftRowAsync(long playerId, JsonObject gift)
{
long giftDropId = 0;
try
{
var message = gift["message"]?.ToString() ?? "A gift from the Radicade Team!";
var currencyAmount = TryGetJsonInt(gift, "currency") ?? 0;
var xpAmount = TryGetJsonInt(gift, "xp") ?? 0;
var avatar = gift["avatar"]?.ToString() ?? "";
var cons = gift["cons"]?.ToString() ?? "";
var equip = gift["equip"]?.ToString() ?? "";
var currencyTypeRaw = gift["currency_type"]?.ToString() ?? gift["currencyType"]?.ToString() ?? "";
var fromPlayerId = long.TryParse(gift["from_player_id"]?.ToString(), out var typedFrom) && typedFrom > 0
? typedFrom
: RadicadeTeamAccountId;
var equipModificationGuid = gift["equipment_modification_guid"]?.ToString()
?? gift["prefab_modify"]?.ToString()
?? "";
bool isRoomCurrency = string.Equals(currencyTypeRaw, "RoomCurrency", StringComparison.OrdinalIgnoreCase);
var currencyType = StorefrontShared.TryParseCurrencyType(currencyTypeRaw, out var parsed) ? parsed : CurrencyType.Invalid;
if (currencyAmount > 0 && currencyType == CurrencyType.Invalid && !isRoomCurrency)
throw new InvalidOperationException($"Unknown currency type '{currencyTypeRaw}' - gift not routed to avoid crediting the wrong wallet.");
if (isRoomCurrency && currencyAmount > 0)
throw new InvalidOperationException("RoomCurrency gifts are not supported yet: no room currency storage exists and no room_id was supplied.");
bool hasReward = currencyAmount > 0 || xpAmount > 0
|| !string.IsNullOrWhiteSpace(avatar) || !string.IsNullOrWhiteSpace(cons) || !string.IsNullOrWhiteSpace(equip);
if (!hasReward)
throw new InvalidOperationException("Gift has no rewards (item, currency or XP).");
giftDropId = NewGiftDropId();
var giftBox = new Gift
{
GiftDropId = giftDropId,
ItemSetId = 0,
ItemSetFriendlyName = "Radicade Gift",
FriendlyName = gift["title"]?.ToString() ?? "A Gift from Radicade!",
Tooltip = message,
IsQuery = false,
Unique = false,
SubscribersOnly = false,
ConsumableItemDesc = cons,
AvatarItemDesc = avatar,
AvatarItemType = AvatarItemType.Outfit,
EquipmentPrefabName = equip,
EquipmentModificationGuid = equipModificationGuid,
CurrencyType = currencyType,
Currency = currencyAmount,
Xp = xpAmount,
Context = TryParseGiftContext(gift["context"]?.ToString()) ?? GiftContext.Game_Drop,
Rarity = TryParseRarity(gift["rarity"]?.ToString()) ?? Rarity.Common,
FromPlayerId = fromPlayerId
};
await PersistGiftBoxAsync(playerId, giftBox, gift);
gift["sent"] = true;
gift["sent_at"] = DateTime.UtcNow;
gift["gift_drop_id"] = giftDropId;
gift["boxed"] = true;
gift["from_player_id"] = fromPlayerId;
}
catch (Exception ex)
{
gift["error"] = ex.Message;
}
return giftDropId;
}
private async Task PersistGiftBoxAsync(long playerId, Gift giftBox, JsonObject? logRow)
{
var overwritten = await StorefrontShared.PersistGiftBoxAsync(db, playerId, giftBox);
if (overwritten != 0 && logRow != null)
logRow["overwrote_gift_drop_id"] = overwritten;
}
private async Task<List<object>> ProcessGiftLog(string? eqCol, string? eqVal, string? order, bool ascending)
{
var state = LoadState();
var filtered = state.GiftLog.AsEnumerable();
if (!string.IsNullOrWhiteSpace(eqCol) && eqVal != null)
filtered = filtered.Where(x => x[eqCol]?.ToString() == eqVal);
var pendingGifts = filtered.Where(x => !string.Equals(x["sent"]?.ToString(), "true", StringComparison.OrdinalIgnoreCase)).ToList();
foreach (var gift in pendingGifts)
{
var playerId = long.TryParse(gift["player_id"]?.ToString(), out var pid) ? pid : 0;
if (playerId > 0)
{
await ProcessGiftRowAsync(playerId, gift);
}
}
SaveState(state);
return OrderRows(filtered.Cast<object>().ToList(), order, ascending);
}
}
public class WeeklyScheduleRequest
{
public string Revision { get; set; } = "";
public string Title { get; set; } = "Weekly Challenges";
public bool Enabled { get; set; } = true;
public string RewardKey { get; set; } = "";
public int FallbackTokens { get; set; } = 300;
public int ChallengeMapId { get; set; }
public List<long> ChallengeIds { get; set; } = new();
}
public class AdminPanelState
{
public List<JsonObject> Storefronts { get; set; } = new();
public List<JsonObject> Challenges { get; set; } = new();
public List<JsonObject> ModerationLog { get; set; } = new();
public List<JsonObject> GiftLog { get; set; } = new();
public List<JsonObject> Notes { get; set; } = new();
public List<JsonObject> AuditLog { get; set; } = new();
public List<JsonObject> RoomComments { get; set; } = new();
public JsonObject? CommunityBoard { get; set; }
public List<JsonObject> ModerationReports { get; set; } = new();
public List<JsonObject> PlayerEvents { get; set; } = new();
}
+477
View File
@@ -0,0 +1,477 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Cors;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using RadicadeServer.API.Notifications;
using RadicadeServer.Auth;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.Radicade.Discord;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Auth;
using RadicadeServer.RecNet.Enums.Messages;
using RadicadeServer.RecNet.Enums.Moderation;
using RadicadeServer.RecNet.Enums.Players;
using RadicadeServer.RecNet.Messages;
using RadicadeServer.RecNet.Moderation;
using RadicadeServer.RecNet.Notifications;
using RadicadeServer.RecNet.Rooms;
using RadicadeServer.Utils;
namespace RadicadeServer.Admin
{
[Route("admin/profiles")]
[EnableCors("AdminPanel")]
public class AdminProfileAPI(RecDBContext db) : Controller
{
private Task<IActionResult?> RejectInvalidKey(string key)
{
return Task.FromResult<IActionResult?>(key.IsAdminKey() ? null : Unauthorized("Invalid secret"));
}
private async Task NotifyUser(long playerId)
{
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
if (profile == null)
return;
var plrData = await db.Players.FirstOrDefaultAsync(x => x.AccountId == playerId);
if (plrData == null)
return;
await Notify.SendNotify(playerId, new Notification
{
Id = "AccountUpdate",
Msg = profile
});
await Notify.UpdateSubscriptions(playerId, Notifs.SubscriptionUpdateProfile, profile);
await Notify.SendNotify(playerId, new Notification
{
Id = "SelfAccountUpdate",
Msg = new SelfAccount
{
juniorState = plrData.Birthday != null && plrData.Birthday.Value.Year > 1900
? JuniorState.BasedOnBirthday : JuniorState.PendingOverride,
accountId = playerId,
parentAccountId = plrData.Parent,
availableUsernameChanges = 99,
bio = profile.bio,
displayName = profile.displayName,
email = plrData.Email,
createdAt = profile.createdAt,
isJunior = profile.isJunior,
phone = plrData.Phone,
platforms = profile.platforms,
profileImage = profile.profileImage,
username = profile.username
}
});
}
[HttpGet("whitelist")]
public async Task<IActionResult> WhitelistUser([FromQuery] string key, [FromQuery] long playerId)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
if (profile == null) return NotFound();
var cachedLogins = await db.CachedLogins.Where(x => x.accountId == playerId).ToListAsync();
string addedIds = "";
List<string> ids = new();
foreach (var cached in cachedLogins)
{
if (cached.platform != Platforms.Steam) continue;
if (!SteamWhitelist.Contains(cached.platformId))
{
SteamWhitelist.Add(cached.platformId);
addedIds += cached.platformId + ",";
ids.Add(cached.platformId);
}
}
addedIds = addedIds.Trim().TrimEnd(',');
return Ok($"Whitelisted @{profile.username}! Added Steam IDs: {addedIds}");
}
[HttpGet("whitelist/toggle")]
public async Task<IActionResult> ToggleWhitelist([FromQuery] string key, [FromQuery] bool enabled)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
SteamWhitelist.Enabled = enabled;
return Ok(enabled
? "Steam whitelist is now ENABLED. Only approved Steam IDs can sign in.\nConfig: " + SteamWhitelist.FilePath
: "Steam whitelist is now DISABLED. Anyone can sign in.");
}
[HttpGet("whitelist/list")]
public async Task<IActionResult> GetWhitelist([FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
return Ok($"Whitelist {(SteamWhitelist.Enabled ? "ENABLED" : "disabled")} ({SteamWhitelist.SteamIds.Count} IDs)\nFilename: {SteamWhitelist.FilePath}\n```json\n{new SteamWhitelist.WhitelistFile { Enabled = SteamWhitelist.Enabled, SteamIds = SteamWhitelist.SteamIds.ToList() }.ToJson()}\n```");
}
[HttpGet("whitelist/add")]
public async Task<IActionResult> AddWhitelistId([FromQuery] string key, [FromQuery] string steamId)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
if (string.IsNullOrWhiteSpace(steamId)) return BadRequest("No steamId given.");
var added = SteamWhitelist.Add(steamId);
return Ok(added
? $"Steam ID {steamId} was added to the whitelist."
: $"Steam ID {steamId} is already on the whitelist.");
}
[HttpGet("whitelist/remove")]
public async Task<IActionResult> RemoveWhitelistId([FromQuery] string key, [FromQuery] string steamId)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
if (string.IsNullOrWhiteSpace(steamId)) return BadRequest("No steamId given.");
var removed = SteamWhitelist.Remove(steamId);
return Ok(removed
? $"Steam ID {steamId} was removed from the whitelist."
: $"Steam ID {steamId} is not on the whitelist.");
}
[HttpGet("set/uname")]
public async Task<IActionResult> SetUsername([FromQuery] string key, [FromQuery] long playerId, [FromQuery] string newName)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
if (profile == null) return NotFound();
profile.username = newName;
await db.SaveChangesAsync();
await NotifyUser(profile.accountId);
return Ok("Updated profile!\n```json" + profile.ToJson() + "```");
}
[HttpGet("set/dname")]
public async Task<IActionResult> SetDisplayName([FromQuery] string key, [FromQuery] long playerId, [FromQuery] string newName)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
if (profile == null) return NotFound();
profile.displayName = newName;
await db.SaveChangesAsync();
await NotifyUser(profile.accountId);
return Ok("Updated profile!\n```json" + profile.ToJson() + "```");
}
[HttpGet("set/image")]
public async Task<IActionResult> SetProfileImage([FromQuery] string key, [FromQuery] long playerId, [FromQuery] string imgName)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
if (profile == null) return NotFound();
profile.profileImage = imgName;
await db.SaveChangesAsync();
await NotifyUser(profile.accountId);
return Ok("Updated profile!\n```json" + profile.ToJson() + "```");
}
[HttpGet("profiles/{profileId}")]
public async Task<IActionResult> GetRoom([FromQuery] string key, long profileId)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == profileId);
if (profile == null) return NotFound();
return Ok(profile);
}
[HttpGet("ban")]
public async Task<IActionResult> BanUser([FromQuery] string key, [FromQuery] long userId, [FromQuery] ReportCategory category, [FromQuery] int duration, [FromQuery] string reason)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == userId);
if (profile == null) return NotFound("User does not exist.");
bool didBan = false;
DateTime banExpiry = DateTime.MaxValue;
string appealCode = string.Empty;
var existingModeration = await db.Moderation.FirstOrDefaultAsync(x => x.PlayerId == userId && x.IsBan);
if (existingModeration != null)
{
if (duration > 0)
{
var expireTime = DateTime.UtcNow.AddMinutes(duration);
existingModeration.ExpireDuration = expireTime;
existingModeration.Message = reason;
existingModeration.Category = category;
existingModeration.IsBan = true;
didBan = true;
banExpiry = expireTime;
}
else if (duration == -1)
{
existingModeration.ExpireDuration = DateTime.MaxValue;
existingModeration.Message = reason;
existingModeration.Category = category;
existingModeration.IsBan = true;
didBan = true;
banExpiry = DateTime.MaxValue;
}
else
{
db.Moderation.Remove(existingModeration);
didBan = false;
await Notify.SendNotify(userId, new Notification { Id = NotificationType.RefreshLogin, Msg = profile });
await PlayerUtils.ReleaseModerationHoldAsync(userId);
}
}
else
{
if (duration > 0)
{
var expireTime = DateTime.UtcNow.AddMinutes(duration);
var (newCode, _) = await PlayerUtils.GetAppealForNewBanAsync(db, userId);
var newModeration = new ModerationDB
{
Category = category,
ExpireDuration = expireTime,
IsBan = true,
Message = reason,
PlayerId = userId,
AppealCode = newCode
};
await db.Moderation.AddAsync(newModeration);
appealCode = newCode ?? string.Empty;
didBan = true;
banExpiry = expireTime;
}
else if (duration == -1)
{
var expireTime = DateTime.MaxValue;
var (newCode, _) = await PlayerUtils.GetAppealForNewBanAsync(db, userId);
var newModeration = new ModerationDB
{
Category = category,
ExpireDuration = expireTime,
IsBan = true,
Message = reason,
PlayerId = userId,
AppealCode = newCode
};
await db.Moderation.AddAsync(newModeration);
appealCode = newCode ?? string.Empty;
didBan = true;
banExpiry = expireTime;
}
}
if (didBan)
{
var activeBan = existingModeration ?? await db.Moderation.FirstOrDefaultAsync(x => x.PlayerId == userId && x.IsBan);
if (activeBan != null && string.IsNullOrWhiteSpace(activeBan.AppealCode))
{
var (newCode, _) = await PlayerUtils.GetAppealForNewBanAsync(db, userId);
activeBan.AppealCode = newCode;
appealCode = newCode ?? string.Empty;
}
else if (string.IsNullOrWhiteSpace(appealCode) && activeBan != null)
{
appealCode = activeBan.AppealCode ?? string.Empty;
}
await db.VoteKicks.AddAsync(new VoteKickLog
{
PlayerId = userId,
Reason = reason,
Category = category,
IsForce = true,
CreatedAt = DateTime.UtcNow,
ExpiresAt = banExpiry
});
var rep = await db.Reputations.FirstOrDefaultAsync(x => x.AccountId == userId);
if (rep != null)
rep.ApplyBanPenalty(permanent: duration == -1, cheating: false);
}
await db.SaveChangesAsync();
if (didBan)
{
await AdminActionDiscord.LogBanAsync(db, userId,
bannedBy: "Admin (Profile API)",
causedByReports: false,
reason: reason,
category: category,
expiry: banExpiry,
isModerator: false);
}
if (didBan)
{
var banStart = (await db.Moderation.AsNoTracking().FirstOrDefaultAsync(x => x.PlayerId == userId && x.IsBan))?.CreatedAt;
await PlayerUtils.KickOrPromptAsync(db, userId, PlayerUtils.BuildBlockDetails(true, category, reason, banExpiry, appealCode: appealCode, startedAtUtc: banStart ?? DateTime.UtcNow));
}
if (didBan)
{
return Ok(duration == -1
? $"Successfully banned @{profile.username} permanently!"
: $"Successfully banned @{profile.username} for {duration} minutes!");
}
else
return Ok($"Successfully unbanned @{profile.username}!");
}
[HttpGet("coach")]
public async Task<IActionResult> SendCoachMessage([FromQuery] string key, [FromQuery] long userId, [FromQuery] string message)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == userId);
if (profile == null) return NotFound("User does not exist.");
await Notify.SendMessage(userId, new Message
{
Type = MessageType.CoachMessage,
Data = message,
FromPlayerId = 1
});
return Ok($"Successfully sent coach message to @{profile.username}!");
}
[HttpGet("coach/all")]
public async Task<IActionResult> SendCoachMessageToAll([FromQuery] string key, [FromQuery] string message)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var profiles = await db.Profiles.ToListAsync();
long playerCount = 0;
foreach (var profile in profiles)
{
if (profile.accountId == 1) continue;
await Notify.SendMessage(profile.accountId, new Message
{
Type = MessageType.CoachMessage,
Data = message,
FromPlayerId = 1
});
playerCount++;
}
return Ok($"Successfully sent coach message to {playerCount} players!");
}
[HttpGet("forcequit")]
public async Task<IActionResult> ForceQuitPlayer([FromQuery] string key, [FromQuery] long userId)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == userId);
if (profile == null) return NotFound("User does not exist.");
Notify.SendReliably(userId, new Notification { Id = NotificationType.ModerationQuitGame, Msg = "" },
TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(1), Notify.EmptyObjectPayload(NotificationType.ModerationQuitGame));
var presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == userId);
if (presence != null)
{
presence.isOnline = false;
presence.roomInstance = null;
presence.LoginLock = null;
await db.SaveChangesAsync();
}
return Ok($"Successfully force quit @{profile.username}!");
}
}
}
+1042
View File
@@ -0,0 +1,1042 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Data;
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
using RadicadeServer.API.Notifications;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Auth;
using RadicadeServer.RecNet.Enums;
using RadicadeServer.RecNet.Enums.Messages;
using RadicadeServer.RecNet.Enums.Moderation;
using RadicadeServer.RecNet.Enums.Rooms;
using RadicadeServer.RecNet.Notifications;
using RadicadeServer.RecNet.Progression;
using RadicadeServer.RecNet.Responses;
using RadicadeServer.RecNet.Rooms;
using RadicadeServer.Utils;
namespace RadicadeServer.Admin
{
[Route("admin/rooms")]
[EnableCors("AdminPanel")]
public class AdminRoomAPI(RecDBContext db) : Controller
{
private Task<IActionResult?> RejectInvalidKey(string key)
{
return Task.FromResult<IActionResult?>(key.IsAdminKey() ? null : Unauthorized("Invalid secret"));
}
[HttpGet("room/{roomId}")]
public async Task<IActionResult> GetRoom([FromQuery] string key, long roomId)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var room = await RoomUtils.GetRoom(roomId);
if (room == null) return NotFound();
return Ok(room);
}
[HttpPost("create/room")]
public async Task<IActionResult> CreateRoom([FromQuery] string key, [FromQuery] long newOwner, [FromBody] JsonNode fullRoom)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var roomData = new RoomData
{
EncryptVoiceChat = fullRoom.Get("EncryptVoiceChat", false),
Accessibility = fullRoom.Get("Accessibility", Accessibility.Private),
CloningAllowed = fullRoom.Get("CloningAllowed", false),
CreatedAt = fullRoom["CreatedAt"]?.GetValue<DateTime>() ?? DateTime.UtcNow,
CreatorAccountId = newOwner,
CustomWarning = fullRoom.Get("CustomWarning", ""),
Description = fullRoom.Get("Description", ""),
DisableMicAutoMute = fullRoom.Get("DisableMicAutoMute", false),
DisableRoomComments = fullRoom.Get("DisableRoomComments", false),
ImageName = fullRoom.Get("ImageName", ""),
IsDorm = fullRoom.Get("IsDorm", false),
IsRRO = fullRoom.Get("IsRRO", false),
IsDeveloperOwned = fullRoom["IsDeveloperOwned"] != null
? fullRoom.Get("IsDeveloperOwned", false)
: await db.IsDeveloper(newOwner),
loadScreen = fullRoom["LoadScreens"]?.ToJson() ?? "[]",
MaxPlayerCalculationMode = fullRoom.Get("MaxPlayerCalculationMode", MaxPlayerMode.AllSubrooms),
MaxPlayers = fullRoom.Get("MaxPlayers", 10),
MinLevel = fullRoom.Get("MinLevel", 0),
Name = fullRoom.Get("Name", ""),
State = fullRoom.Get("State", RoomState.Active),
SupportsJuniors = fullRoom.Get("SupportsJuniors", false),
SupportsLevelVoting = fullRoom.Get("SupportsLevelVoting", false),
SupportsMobile = fullRoom.Get("SupportsMobile", false),
SupportsQuest2 = fullRoom.Get("SupportsQuest2", false),
SupportsScreens = fullRoom.Get("SupportsScreens", false),
SupportsTeleportVR = fullRoom.Get("SupportsTeleportVR", false),
SupportsVRLow = fullRoom.Get("SupportsVRLow", false),
SupportsWalkVR = fullRoom.Get("SupportsWalkVR", false),
Tags = StringTools.CreateTagString(fullRoom.Get<List<ItemTag>>("Tags", [])),
Version = 1,
WarningMask = fullRoom.Get("WarningMask", WarningMask.None)
};
await db.Rooms.AddAsync(roomData);
await db.SaveChangesAsync();
foreach (var subRoom in fullRoom["SubRooms"]?.AsArray() ?? new JsonArray())
{
if (subRoom == null) continue;
var subRoomSceneId = subRoom["UnitySceneId"]?.ToString() ?? SceneLocations.MakerRoom;
if (!SceneLocations.IsValid(subRoomSceneId))
subRoomSceneId = SceneLocations.MakerRoom;
// A new room's subroom can arrive with its Unity bundle already uploaded,
// named exactly as it was imported (see UploadRoom).
var bundleName = subRoom["UnityAssetId"]?.ToString();
if (!string.IsNullOrWhiteSpace(bundleName))
{
if (!AdminUtils.TryResolveBundleName(bundleName, out var resolvedBundle, out var bundleError))
return BadRequest(bundleError);
bundleName = resolvedBundle;
}
await db.AddAsync(new SubRoom
{
Accessibility = (Accessibility)(subRoom["Accessibility"]?.GetValue<int>() ?? 0),
CreatedAt = subRoom["CreatedAt"]?.GetValue<DateTime>() ?? DateTime.UtcNow,
SavedByAccountId = newOwner,
IsSandbox = subRoom["IsSandbox"]?.GetValue<bool>() ?? false,
MaxPlayers = subRoom["MaxPlayers"]?.GetValue<int>() ?? 0,
Name = subRoom["Name"]?.ToString() ?? "",
RoomId = roomData.RoomId,
UnitySceneId = subRoomSceneId,
DataBlob = subRoom["DataBlob"]?.ToString() ?? "",
UnityAssetId = bundleName ?? "",
});
}
await db.RoomRoles.AddAsync(new RoomRole
{
AccountId = newOwner,
InvitedRole = RoleType.None,
Role = RoleType.Creator,
RoomId = roomData.RoomId
});
await db.SaveChangesAsync();
return Ok($"Created as {roomData.RoomId}!");
}
[HttpGet("rename/room")]
public async Task<IActionResult> RenameRoom([FromQuery] string key, [FromQuery] long roomId, [FromQuery] string newName)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return NotFound("Room does not exist.");
room.Name = newName;
await db.SaveChangesAsync();
return Ok($"Changed to ^{newName}!");
}
[HttpGet("delete/room")]
public async Task<IActionResult> DeleteRoom([FromQuery] string key, [FromQuery] long roomId)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return NotFound("Room does not exist.");
if (room.IsRRO || room.IsDorm || room.CreatorAccountId == 1) return Ok("You cannot delete RROs.");
db.Rooms.Remove(room);
await db.SaveChangesAsync();
return Ok($"Deleted.");
}
[HttpGet("set/datablob")]
public async Task<IActionResult> SetDatablob([FromQuery] string key, [FromQuery] long roomId, [FromQuery] string subRoomName, [FromQuery] string datablobName)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.RoomId == roomId && x.Name == subRoomName);
if (subRoom == null) return NotFound("Subroom not found.");
subRoom.DataBlob = datablobName;
await db.SaveChangesAsync();
return Ok($"Set datablob to {datablobName} for {subRoom.Name}!");
}
// The other half of importing a subroom: its .assetbundle. The file name is
// the asset id - RoomServerAPI serves /rooms/unity_assets/<name> out of the CDN
// by that name - so binding means pointing the subroom at an uploaded bundle,
// and an empty name clears it again. BindAssetBundle below is the narrower,
// Studio-side counterpart for 40-character manifest ids; this one takes the
// '<name>.assetbundle' files an import actually produces.
[HttpGet("set/bundle")]
public async Task<IActionResult> SetBundle([FromQuery] string key, [FromQuery] long roomId, [FromQuery] string subRoomName, [FromQuery] string bundleName)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.RoomId == roomId && x.Name == subRoomName);
if (subRoom == null) return NotFound("Subroom not found.");
if (string.IsNullOrWhiteSpace(bundleName))
{
subRoom.UnityAssetId = "";
await db.SaveChangesAsync();
return Ok($"Cleared the Unity bundle for {subRoom.Name}!");
}
if (!AdminUtils.TryResolveBundleName(bundleName, out var resolvedBundle, out var bundleError))
return BadRequest(bundleError);
subRoom.UnityAssetId = resolvedBundle ?? "";
await db.SaveChangesAsync();
return Ok($"Set bundle to {subRoom.UnityAssetId} for {subRoom.Name}!");
}
[HttpPost("create/subroom")]
public async Task<IActionResult> CreateSubRoom([FromQuery] string key, [FromQuery] long roomId, [FromBody] JsonNode subRoom)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return NotFound("Room does not exist.");
var name = (subRoom.Get("Name", "") ?? "").Trim();
if (string.IsNullOrWhiteSpace(name)) return BadRequest("Subroom name is required.");
if (await db.SubRooms.AnyAsync(x => x.RoomId == roomId && x.Name == name))
return BadRequest($"A subroom named \"{name}\" already exists in this room.");
// The Unity bundle the subroom loads, if one was imported alongside the save.
var bundleName = subRoom.Get("UnityAssetId", "") ?? "";
if (!string.IsNullOrWhiteSpace(bundleName))
{
if (!AdminUtils.TryResolveBundleName(bundleName, out var resolvedBundle, out var bundleError))
return BadRequest(bundleError);
bundleName = resolvedBundle ?? "";
}
var mainSubRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
var maxPlayers = subRoom.Get("MaxPlayers", mainSubRoom?.MaxPlayers ?? 10);
if (maxPlayers <= 0) maxPlayers = mainSubRoom?.MaxPlayers ?? 10;
var unitySceneId = subRoom.Get("UnitySceneId", "");
if (string.IsNullOrWhiteSpace(unitySceneId))
unitySceneId = mainSubRoom?.UnitySceneId ?? "a75f7547-79eb-47c6-8986-6767abcb4f92";
var dbSubRoom = new SubRoom
{
Accessibility = (Accessibility)(subRoom["Accessibility"]?.GetValue<int>() ?? (int)Accessibility.Public),
CreatedAt = subRoom.Get("CreatedAt", DateTime.UtcNow),
SavedByAccountId = room.CreatorAccountId,
IsSandbox = subRoom.Get("IsSandbox", true),
MaxPlayers = maxPlayers,
Name = name,
RoomId = roomId,
UnitySceneId = unitySceneId,
DataBlob = subRoom.Get("DataBlob", ""),
UnityAssetId = bundleName,
};
await db.AddAsync(dbSubRoom);
await db.SaveChangesAsync();
return Ok($"Created as {dbSubRoom.SubRoomId}!");
}
[HttpGet("rename/subroom")]
public async Task<IActionResult> RenameSubRoom([FromQuery] string key, [FromQuery] long roomId, [FromQuery] long subRoomId, [FromQuery] string newName)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.SubRoomId == subRoomId && x.RoomId == roomId);
if (subRoom == null) return NotFound("Subroom not found.");
newName = (newName ?? "").Trim();
if (string.IsNullOrWhiteSpace(newName)) return BadRequest("Subroom name is required.");
if (await db.SubRooms.AnyAsync(x => x.RoomId == roomId && x.SubRoomId != subRoomId && x.Name == newName))
return BadRequest($"A subroom named \"{newName}\" already exists in this room.");
subRoom.Name = newName;
await db.SaveChangesAsync();
return Ok($"Renamed to {newName}!");
}
[HttpGet("delete/subroom")]
public async Task<IActionResult> DeleteSubRoom([FromQuery] string key, [FromQuery] long roomId, [FromQuery] long subRoomId)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.SubRoomId == subRoomId && x.RoomId == roomId);
if (subRoom == null) return NotFound("Subroom not found.");
var subroomCount = await db.SubRooms.CountAsync(x => x.RoomId == roomId);
if (subroomCount <= 1) return BadRequest("A room must keep at least one subroom.");
var history = await db.SubRoomHistory.Where(x => x.RoomId == roomId && x.SubRoomId == subRoomId).ToListAsync();
foreach (var historyItem in history)
db.SubRoomHistory.Remove(historyItem);
db.SubRooms.Remove(subRoom);
await db.SaveChangesAsync();
return Ok($"Deleted {subRoom.Name}!");
}
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/assetbundle")]
public async Task<IActionResult> BindAssetBundle([FromQuery] string key, long roomId, long subRoomId,
[FromBody] string assetId)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
if (!ModelState.IsValid || !RadicadeServer.API.Studio.StudioUnityAssetRegistry.IsValidAssetId(assetId))
return BadRequest("Expected a 40-character Unity asset ID.");
var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.RoomId == roomId && x.SubRoomId == subRoomId);
if (subRoom == null) return NotFound("Subroom not found in this room.");
if (!System.IO.File.Exists(Path.Join(DBConfig.CDNStorage, assetId + ".assetbundle")))
return BadRequest("Upload the bundle as <assetId>.assetbundle before linking it.");
if (System.IO.File.Exists(Path.Join(DBConfig.CDNStorage, assetId)))
return BadRequest("This ID belongs to a Studio manifest; choose an ID for the archived bundle.");
var previousAssetId = subRoom.UnityAssetId;
subRoom.UnityAssetId = assetId;
await db.SaveChangesAsync();
return Ok(new { RoomId = roomId, SubRoomId = subRoomId, PreviousUnityAssetId = previousAssetId, UnityAssetId = assetId });
}
[HttpPost("upload/datablob")]
[RequestSizeLimit(256L * 1024 * 1024)]
public async Task<IActionResult> UploadDatablob([FromQuery] string key, [FromQuery] string name, [FromForm] IFormFile file)
{
if (!ModelState.IsValid) return BadRequest("Invalid upload request. Use a multipart form no larger than 256 MiB.");
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var path = Path.Join(DBConfig.CDNStorage, name);
using (var stream = new FileStream(path, FileMode.Create))
{
await file.CopyToAsync(stream);
}
return Ok($"Uploaded {name}!\nURL: {DBConfig.ServerUrl}/cdn/room/{name}");
}
[HttpPost("upload/image")]
public async Task<IActionResult> UploadImage([FromQuery] string key, [FromQuery] long roomId, [FromForm] IFormFile file)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return NotFound("Room does not exist.");
if (file == null || file.Length == 0) return BadRequest("No file provided.");
var allowed = new[] { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp" };
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(ext) || !allowed.Contains(ext))
return BadRequest($"Unsupported image format '{(string.IsNullOrEmpty(ext) ? "no extension" : ext)}'. Use png/jpg/jpeg/gif/webp.");
string guid = Guid.NewGuid().ToString();
var path = Path.Join(DBConfig.CDNImages, guid + ext);
using (var stream = new FileStream(path, FileMode.Create))
{
await file.CopyToAsync(stream);
}
room.ImageName = guid + ext;
await db.SaveChangesAsync();
return Ok($"Uploaded {guid}{ext}!\nURL: {DBConfig.ServerUrl}/images/img/{guid}{ext}");
}
[HttpGet("set/image")]
public async Task<IActionResult> SetImage([FromQuery] string key, [FromQuery] long roomId, [FromQuery] string imgName)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return NotFound("Room does not exist.");
imgName = Path.GetFileName(imgName ?? "").Trim();
room.ImageName = imgName;
await db.SaveChangesAsync();
return Ok($"Set to {imgName}!\nURL: {DBConfig.ServerUrl}/images/img/{imgName}");
}
[HttpGet("set/role/{roleType}")]
public async Task<IActionResult> SetRoleOfUser([FromQuery] string key, [FromQuery] long roomId, [FromQuery] long target, RoleType roleType)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var existingRole = await db.RoomRoles.FirstOrDefaultAsync(x => x.AccountId == target && x.RoomId == roomId);
if (existingRole == null)
{
var newRole = new RoomRole
{
AccountId = target,
InvitedRole = RoleType.None,
Role = roleType,
RoomId = roomId
};
await db.RoomRoles.AddAsync(newRole);
}
else
{
if (existingRole.Role == roleType)
return Ok($"Player already has {Enum.GetName(roleType)}.");
existingRole.Role = roleType;
}
await db.SaveChangesAsync();
try
{
var updatedRoom = await RoomUtils.GetRoom(roomId);
if (updatedRoom != null)
await Notify.SendNotify(target, new Notification { Id = "RoomUpdate", Msg = updatedRoom });
}
catch (Exception) { }
return Ok($"Set to {Enum.GetName(roleType)}!");
}
[HttpGet("set/rro")]
public async Task<IActionResult> SetRRO([FromQuery] string key, [FromQuery] long roomId)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return NotFound("Room does not exist.");
room.IsRRO = true;
var tags = StringTools.ReadTags(room.Tags);
if (tags.FirstOrDefault(x => x.Tag == "rro" && x.Type == TagType.Auto) == null)
tags.Add(new ItemTag { Type = TagType.Auto, Tag = "rro" });
room.Tags = StringTools.CreateTagString(tags);
await db.SaveChangesAsync();
return Ok($"Set to RRO {room.Name}!");
}
[HttpGet("remove/rro")]
public async Task<IActionResult> RemoveRRO([FromQuery] string key, [FromQuery] long roomId)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return NotFound("Room does not exist.");
room.IsRRO = false;
var tags = StringTools.ReadTags(room.Tags);
var existingRROTag = tags.FirstOrDefault(x => x.Tag == "rro" && x.Type == TagType.Auto);
if (existingRROTag != null)
tags.Remove(existingRROTag);
room.Tags = StringTools.CreateTagString(tags);
await db.SaveChangesAsync();
return Ok($"Removed RRO from {room.Name}!");
}
[HttpGet("fulldelete/room")]
public async Task<IActionResult> FullyDeleteRoom([FromQuery] string key, [FromQuery] long roomId)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return NotFound("Room does not exist.");
if (room.IsRRO || room.IsDorm || room.CreatorAccountId == 1) return Ok("You cannot delete RROs.");
var roles = await db.RoomRoles.Where(x => x.RoomId == roomId).ToListAsync();
foreach (var role in roles)
{
db.RoomRoles.Remove(role);
}
var interactions = await db.RoomInteractions.Where(x => x.RoomId == roomId).ToListAsync();
foreach (var i in interactions)
{
db.RoomInteractions.Remove(i);
}
var keys = await db.RoomKeys.Where(x => x.RoomId == roomId).ToListAsync();
foreach (var k in keys)
{
db.RoomKeys.Remove(k);
}
var stats = await db.RoomStats.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (stats != null)
db.RoomStats.Remove(stats);
var subrooms = await db.SubRooms.Where(x => x.RoomId == roomId).ToListAsync();
foreach (var subroom in subrooms)
{
db.SubRooms.Remove(subroom);
}
var prevSubrooms = await db.SubRoomHistory.Where(x => x.RoomId == roomId).ToListAsync();
foreach (var subroom in prevSubrooms)
{
db.SubRoomHistory.Remove(subroom);
}
await db.SaveChangesAsync();
return Ok("Deleted.");
}
[HttpGet("set/owner")]
public async Task<IActionResult> SetRoomOwner([FromQuery] string key, [FromQuery] long roomId, [FromQuery] long target)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return NotFound("Room does not exist.");
long oldOwnerId = room.CreatorAccountId;
room.CreatorAccountId = target;
var existingRole = await db.RoomRoles.FirstOrDefaultAsync(x => x.AccountId == target && x.RoomId == roomId);
if (existingRole == null)
{
var newRole = new RoomRole
{
AccountId = target,
InvitedRole = RoleType.None,
Role = RoleType.Creator,
RoomId = roomId
};
await db.RoomRoles.AddAsync(newRole);
}
else
{
existingRole.Role = RoleType.Creator;
}
if (oldOwnerId != target)
{
var oldOwnerRole = await db.RoomRoles.FirstOrDefaultAsync(x => x.AccountId == oldOwnerId && x.RoomId == roomId);
if (oldOwnerRole != null && oldOwnerRole.Role == RoleType.Creator)
oldOwnerRole.Role = RoleType.CoOwner;
}
await db.SaveChangesAsync();
return Ok($"Set to owner!");
}
[HttpGet("instances/live")]
public async Task<IActionResult> GetLiveRoomInstances([FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var instances = await db.RoomInstances.ToListAsync();
var roomIds = instances.Select(i => i.roomId).Distinct().ToList();
var rooms = roomIds.Count > 0
? await db.Rooms.Where(r => roomIds.Contains(r.RoomId)).ToDictionaryAsync(r => r.RoomId)
: new Dictionary<long, RoomData>();
var presences = await db.Presences.Where(p => p.isOnline && p.roomInstance != null).ToListAsync();
var profileIds = presences.Select(p => p.playerId).Distinct().ToList();
var profiles = profileIds.Count > 0
? await db.Profiles.Where(x => profileIds.Contains(x.accountId)).ToDictionaryAsync(x => x.accountId)
: new Dictionary<long, RadicadeServer.RecNet.Auth.Profile>();
var result = instances.Select(instance =>
{
var players = presences
.Where(p => p.roomInstance == instance.roomInstanceId)
.Select(p => new
{
player_id = p.playerId,
name = profiles.TryGetValue(p.playerId, out var profile) ? profile.displayName : $"Player {p.playerId}"
})
.OrderBy(p => p.player_id)
.ToList();
return new
{
instance_id = instance.roomInstanceId,
room_id = instance.roomId,
room_name = rooms.TryGetValue(instance.roomId, out var room) ? room.Name : "Unknown Room",
sub_room_id = instance.subRoomId,
event_id = instance.eventId,
instance_type = instance.roomInstanceType.ToString(),
location = instance.location ?? "",
room_code = instance.roomCode ?? "",
photon_room_id = instance.photonRoomId,
is_private = instance.isPrivate,
is_in_progress = instance.isInProgress,
is_full = instance.isFull,
max_capacity = instance.maxCapacity,
player_count = players.Count,
players
};
})
.OrderByDescending(i => i.player_count)
.ToList();
return Ok(new { count = result.Count, instances = result });
}
[HttpGet("instances/{instanceId:long}")]
public async Task<IActionResult> GetLiveInstanceDetail(long instanceId, [FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var instance = await db.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == instanceId);
if (instance == null) return NotFound("Instance not found.");
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == instance.roomId);
var subrooms = await db.SubRooms.Where(s => s.RoomId == instance.roomId).ToListAsync();
var presences = await db.Presences.Where(p => p.isOnline && p.roomInstance == instanceId).ToListAsync();
var profileIds = presences.Select(p => p.playerId).Distinct().ToList();
var profiles = profileIds.Count > 0
? await db.Profiles.Where(x => profileIds.Contains(x.accountId)).ToDictionaryAsync(x => x.accountId)
: new Dictionary<long, Profile>();
var playerData = profileIds.Count > 0
? await db.Players.Where(x => profileIds.Contains(x.AccountId)).ToDictionaryAsync(x => x.AccountId)
: new Dictionary<long, PlayerData>();
var progression = profileIds.Count > 0
? await db.Progression.Where(x => profileIds.Contains(x.PlayerId)).ToDictionaryAsync(x => x.PlayerId)
: new Dictionary<long, ProgressionData>();
var playerRows = presences.Select(p =>
{
profiles.TryGetValue(p.playerId, out var profile);
playerData.TryGetValue(p.playerId, out var player);
progression.TryGetValue(p.playerId, out var progress);
return new
{
player_id = p.playerId,
name = profile?.displayName ?? $"Player {p.playerId}",
username = profile?.username ?? "",
level = progress?.Level ?? 0,
device_class = p.deviceClass.ToString(),
is_online = p.isOnline
};
}).OrderBy(p => p.player_id).ToList();
return Ok(new
{
instance = new
{
instance_id = instance.roomInstanceId,
room_id = instance.roomId,
room_name = room?.Name ?? "Unknown Room",
sub_room_id = instance.subRoomId,
instance_type = instance.roomInstanceType.ToString(),
location = instance.location ?? "",
room_code = instance.roomCode ?? "",
is_private = instance.isPrivate,
is_in_progress = instance.isInProgress,
is_full = instance.isFull,
max_capacity = instance.maxCapacity,
player_count = playerRows.Count
},
players = playerRows,
subrooms = subrooms.Select(s => new
{
sub_room_id = s.SubRoomId,
name = s.Name,
max_players = s.MaxPlayers
}).ToList()
});
}
[HttpPost("instances/{instanceId:long}/kick")]
public async Task<IActionResult> KickFromInstance(long instanceId, [FromQuery] string key, [FromQuery] long playerId, [FromQuery] string? reason = null)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var instance = await db.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == instanceId);
if (instance == null) return NotFound("Instance not found.");
var presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == playerId);
if (presence == null || presence.roomInstance != instanceId) return NotFound("Player is not in this instance.");
var dorm = await RoomUtils.GetOrCreateDorm(playerId);
RoomInstance? dormInstance = null;
if (dorm != null)
dormInstance = await MatchmakingUtils.CreateRoomInstance(dorm.RoomId, true, null, null, null);
if (dormInstance == null) return StatusCode(500, "Could not resolve a dorm instance for this player.");
presence.roomInstance = dormInstance.roomInstanceId;
presence.errorCode = JoinStatus.Success;
presence.isOnline = true;
MatchmakingUtils.MarkInRoom(playerId);
await RecomputeInstanceCapacity(instance);
await db.SaveChangesAsync();
try
{
await Notify.SendNotify(playerId, new Notification
{
Id = NotificationType.ModerationKick,
Msg = PlayerUtils.BuildBlockDetails(false, ReportCategory.Misc, reason ?? "Kicked from room", null, isHostKick: true)
});
}
catch (Exception) { }
try
{
await Notify.SendNotify(playerId, new Notification
{
Id = "PresenceUpdate",
Msg = await Presence.CreateAsync(db, presence, dormInstance)
});
}
catch (Exception) { }
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
return Ok(new
{
player_id = playerId,
name = profile?.displayName ?? $"Player {playerId}",
kicked = true,
instance_id = instanceId
});
}
[HttpPost("instances/{instanceId:long}/move")]
public async Task<IActionResult> MovePlayerToSubRoom(long instanceId, [FromQuery] string key, [FromQuery] long playerId, [FromQuery] long toSubRoomId)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var instance = await db.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == instanceId);
if (instance == null) return NotFound("Instance not found.");
var presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == playerId);
if (presence == null || presence.roomInstance != instanceId) return NotFound("Player is not in this instance.");
var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.RoomId == instance.roomId && x.SubRoomId == toSubRoomId);
if (subRoom == null) return BadRequest("Target subroom does not belong to this room.");
if (instance.subRoomId == toSubRoomId)
return Ok(new { player_id = playerId, already_in_subroom = true, instance_id = instanceId });
var targetInstance = await db.RoomInstances
.FirstOrDefaultAsync(x => x.roomId == instance.roomId && x.subRoomId == toSubRoomId && !x.isFull);
bool created = false;
if (targetInstance == null)
{
targetInstance = await MatchmakingUtils.CreateRoomInstance(instance.roomId, instance.isPrivate, toSubRoomId, instance.clubId, instance.eventId);
if (targetInstance == null) return StatusCode(500, "Could not create the target subroom instance.");
db.RoomInstances.Attach(targetInstance);
created = true;
}
presence.roomInstance = targetInstance.roomInstanceId;
presence.errorCode = JoinStatus.Success;
presence.isOnline = true;
MatchmakingUtils.MarkInRoom(playerId);
await RecomputeInstanceCapacity(instance);
await RecomputeInstanceCapacity(targetInstance);
await db.SaveChangesAsync();
try
{
await Notify.SendNotify(playerId, new Notification
{
Id = "PresenceUpdate",
Msg = await Presence.CreateAsync(db, presence, targetInstance)
});
}
catch (Exception) { }
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
return Ok(new
{
player_id = playerId,
name = profile?.displayName ?? $"Player {playerId}",
instance_id = targetInstance.roomInstanceId,
sub_room_id = toSubRoomId,
created
});
}
private async Task RecomputeInstanceCapacity(RoomInstance instance)
{
var count = await MatchmakingUtils.GetPlayerCountInInstance(instance.roomInstanceId);
instance.isFull = count >= instance.maxCapacity;
}
[HttpGet("roles")]
public async Task<IActionResult> GetRoomRoles([FromQuery] string key, [FromQuery] long roomId)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return NotFound("Room does not exist.");
var roles = await db.RoomRoles.Where(x => x.RoomId == roomId).ToListAsync();
var accountIds = roles.Select(r => r.AccountId).Distinct().ToList();
var profiles = accountIds.Count > 0
? await db.Profiles.Where(x => accountIds.Contains(x.accountId)).ToDictionaryAsync(x => x.accountId)
: new Dictionary<long, RadicadeServer.RecNet.Auth.Profile>();
string? NameOf(long id) => profiles.TryGetValue(id, out var p)
? $"{p.displayName} (@{p.username})"
: id.ToString();
var result = roles.Select(r => new
{
account_id = r.AccountId,
name = NameOf(r.AccountId),
role = r.Role.ToString(),
role_value = (int)r.Role,
invited_role = r.InvitedRole.ToString(),
is_creator = r.AccountId == room.CreatorAccountId || r.Role == RoleType.Creator
}).ToList();
if (!result.Any(x => x.account_id == room.CreatorAccountId))
{
result.Insert(0, new
{
account_id = room.CreatorAccountId,
name = NameOf(room.CreatorAccountId),
role = "Creator",
role_value = (int)RoleType.Creator,
invited_role = "None",
is_creator = true
});
}
return Ok(new { room_id = roomId, creator_account_id = room.CreatorAccountId, roles = result });
}
[HttpGet("room/{roomId}/bans")]
public async Task<IActionResult> GetRoomBans([FromQuery] string key, long roomId)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return NotFound("Room does not exist.");
var bans = await db.RoomBans
.Where(x => x.roomId == roomId && (!x.expiresAt.HasValue || x.expiresAt.Value > DateTime.UtcNow))
.OrderByDescending(x => x.bannedAt)
.ToListAsync();
var ids = bans.Select(b => b.bannedPlayerId).Concat(bans.Select(b => b.actorId)).Distinct().ToList();
var profiles = ids.Count > 0
? await db.Profiles.AsNoTracking().Where(x => ids.Contains(x.accountId)).ToDictionaryAsync(x => x.accountId)
: new Dictionary<long, Profile>();
string? NameOf(long id) => profiles.TryGetValue(id, out var p) ? p.displayName : null;
var result = bans.Select(b => new
{
ban_id = b.BanId,
room_id = b.roomId,
player_id = b.bannedPlayerId,
player_name = NameOf(b.bannedPlayerId),
banned_by_id = b.actorId,
banned_by_name = string.IsNullOrWhiteSpace(b.bannedByName) ? (NameOf(b.actorId) ?? $"Player {b.actorId}") : b.bannedByName,
reason = b.reason,
banned_at = b.bannedAt,
expires_at = b.expiresAt
}).ToList();
return Ok(new { room_id = roomId, room_name = room.Name, count = result.Count, bans = result });
}
[HttpPost("room/{roomId}/bans")]
public async Task<IActionResult> BanPlayerFromRoom([FromQuery] string key, long roomId, [FromBody] JsonNode body)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return NotFound("Room does not exist.");
long playerId = body["player_id"]?.GetValue<long>() ?? 0;
if (playerId <= 0) return BadRequest("player_id is required.");
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
if (profile == null) return NotFound("Player does not exist.");
var existing = await db.RoomBans.FirstOrDefaultAsync(x => x.roomId == roomId && x.bannedPlayerId == playerId &&
(!x.expiresAt.HasValue || x.expiresAt.Value > DateTime.UtcNow));
if (existing != null) return Ok($"Player is already banned from this room.");
var reason = body["reason"]?.GetValue<string>()?.Trim() ?? "";
var ban = new RoomBan
{
roomId = roomId,
actorId = 1,
bannedPlayerId = playerId,
bannedByName = "Admin",
reason = reason,
bannedAt = DateTime.UtcNow,
expiresAt = null
};
await db.RoomBans.AddAsync(ban);
await db.SaveChangesAsync();
await MatchmakingUtils.KickPlayerFromRoom(playerId, roomId,
string.IsNullOrWhiteSpace(reason) ? "You were banned from this room." : $"You were banned from this room: {reason}");
return Ok(new
{
ban_id = ban.BanId,
room_id = roomId,
player_id = playerId,
player_name = profile.displayName,
reason = reason,
banned_at = ban.bannedAt
});
}
[HttpDelete("room/{roomId}/bans/{playerId}")]
public async Task<IActionResult> UnbanPlayerFromRoom([FromQuery] string key, long roomId, long playerId)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return NotFound("Room does not exist.");
var ban = await db.RoomBans.FirstOrDefaultAsync(x => x.roomId == roomId && x.bannedPlayerId == playerId);
if (ban == null) return NotFound("Player is not banned from this room.");
db.RoomBans.Remove(ban);
await db.SaveChangesAsync();
return Ok($"Ban lifted for player {playerId}.");
}
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace RadicadeServer.Utils
{
public static class AdminRuntime
{
public static readonly DateTime ServerStartedAtUtc = DateTime.UtcNow;
}
}
+76
View File
@@ -0,0 +1,76 @@
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.Utils;
namespace RadicadeServer.Admin
{
[Route("admin/status")]
[EnableCors("AdminPanel")]
public class AdminStatusAPI(RecDBContext db) : Controller
{
private Task<IActionResult?> RejectInvalidKey(string key)
{
return Task.FromResult<IActionResult?>(key.IsAdminKey() ? null : Unauthorized("Invalid secret"));
}
[HttpGet("players/active")]
public async Task<IActionResult> GetActivePlayerCount([FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var activePlayerCount = await db.Presences.CountAsync(x => x.isOnline);
return Ok(new
{
activePlayerCount
});
}
[HttpGet("")]
public async Task<IActionResult> GetServerStatus([FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var activePlayerCount = await db.Presences.CountAsync(x => x.isOnline);
var playersInRoomsCount = await db.Presences.CountAsync(x => x.isOnline && x.roomInstance != null);
var activeRoomInstanceCount = await db.RoomInstances.CountAsync();
var gameVersion = await db.ServerConfig
.Where(x => x.Key == "GameVersion")
.Select(x => x.Value)
.FirstOrDefaultAsync() ?? DBConfig.GameVersion;
var barrelVersion = await db.ServerConfig
.Where(x => x.Key == "BarrelVersion")
.Select(x => x.Value)
.FirstOrDefaultAsync();
var barrelKey = await db.ServerConfig
.Where(x => x.Key == "BarrelKey")
.Select(x => x.Value)
.FirstOrDefaultAsync();
var barrelCheck = await db.ServerConfig
.Where(x => x.Key == "BarrelCheck")
.Select(x => x.Value)
.FirstOrDefaultAsync();
var config = await db.Config.FirstOrDefaultAsync();
return Ok(new
{
status = "online",
serverUrl = DBConfig.ServerUrl,
activePlayerCount,
playersInRoomsCount,
activeRoomInstanceCount,
gameVersion,
barrelVersion,
barrelKey,
barrelCheck,
maintenance = config?.ServerMaintenance,
startedAtUtc = AdminRuntime.ServerStartedAtUtc,
uptimeSeconds = (long)(DateTime.UtcNow - AdminRuntime.ServerStartedAtUtc).TotalSeconds
});
}
}
}
+51
View File
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Cors;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Store;
using RadicadeServer.Utils;
namespace RadicadeServer.Admin;
[Route("admin/store")]
[EnableCors("AdminPanel")]
public class AdminStorefrontsAPI(RecDBContext db) : Controller
{
private Task<IActionResult?> RejectInvalidKey(string key)
{
return Task.FromResult<IActionResult?>(key.IsAdminKey() ? null : Unauthorized("Invalid secret"));
}
[HttpGet("{key}/itemset/{itemSetId}")]
public async Task<IActionResult> GetItemSet(string key, long itemSetId)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
return Ok("Item sets not implemented for GiftDropDB.");
}
[HttpGet("{key}/gift/{giftId}")]
public async Task<IActionResult> GetGift(string key, long giftId)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var gift = await db.GiftItems.FirstOrDefaultAsync(x => x.GiftDropId == giftId);
if (gift == null) return Ok("Not found.");
return Ok(gift);
}
[HttpGet("{key}/gift/{giftId}/set")]
public async Task<ActionResult> SetGift()
{
return Ok("todo.");
}
[HttpGet("{key}/gift/new")]
public async Task<ActionResult> CreateGift()
{
return Ok("todo.");
}
}
+122
View File
@@ -0,0 +1,122 @@
using System.Security.Cryptography;
using System.Text;
using RadicadeServer.DB;
namespace RadicadeServer.Utils
{
public static class AdminUtils
{
/// <summary>
/// The ceiling every admin upload is held to, on the request and on the multipart form.
///
/// Kestrel's own default is 30 MB, and no room import fits under it: an .assetbundle or
/// a .room save routinely runs to tens or hundreds of megabytes. A route that does not
/// raise the limit has its connection killed part-way through the body, so the panel sees
/// a broken connection instead of an answer - which is what made importing a large room
/// look like an authentication failure. Named once here so the next upload route cannot
/// quietly forget it.
/// </summary>
public const long MaxUploadBytes = 256L * 1024 * 1024;
/// <summary>
/// A room file being assembled from parts lives beside its final name under this suffix
/// until the last part lands. Nothing serves or lists one, so a half-imported bundle can
/// never be fetched by the client, and a name ending in this suffix is refused outright.
/// </summary>
public const string PartSuffix = ".uploading";
/// The multipart ceiling this server answers with, in the words the panel shows an admin.
public const string MaxUploadLabel = "256 MiB";
public static bool IsUploadPart(string? name) =>
!string.IsNullOrEmpty(name) && name.EndsWith(PartSuffix, StringComparison.OrdinalIgnoreCase);
private static bool SecretMatches(string? given, string? stored)
{
if (string.IsNullOrEmpty(given) || string.IsNullOrEmpty(stored))
return false;
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(given),
Encoding.UTF8.GetBytes(stored));
}
public static bool IsAdminKey(this string? key)
{
return SecretMatches(key, DBConfig.AdminApiKey);
}
public static bool IsSupportKey(this string? key)
{
return SecretMatches(key, DBConfig.AdminApiKey)
|| SecretMatches(key, DBConfig.SupportApiKey);
}
public static bool TryResolveUploadName(string? requested, out string? name, out string? error)
{
name = null;
error = null;
if (string.IsNullOrWhiteSpace(requested)) return true;
var trimmed = requested.Trim();
if (trimmed.Length > 128)
{
error = "Name is too long.";
return false;
}
if (trimmed != Path.GetFileName(trimmed) || trimmed.Contains(".."))
{
error = "Name must be a bare file name.";
return false;
}
foreach (var c in trimmed)
{
if (!char.IsLetterOrDigit(c) && c != '-' && c != '_' && c != '.')
{
error = "Name may only contain letters, digits, '-', '_' and '.'.";
return false;
}
}
name = trimmed;
return true;
}
/// A subroom's Unity bundle is served under the name it is stored as
/// (RoomServerAPI's unity_assets route hands the client that file name, and
/// the client then fetches it from the CDN by name), so binding accepts any
/// uploaded '<name>.assetbundle'. That is deliberately wider than
/// StudioUnityAssetRegistry.IsValidAssetId, which only covers the
/// 40-character manifest ids the Studio pipeline mints.
public static bool TryResolveBundleName(string? requested, out string? name, out string? error)
{
if (!TryResolveUploadName(requested, out name, out error)) return false;
if (name == null)
{
error = "A bundle file name is required.";
return false;
}
if (!name.EndsWith(".assetbundle", StringComparison.OrdinalIgnoreCase))
{
error = $"'{name}' is not a .assetbundle file.";
name = null;
return false;
}
if (!System.IO.File.Exists(Path.Join(DBConfig.CDNStorage, name)))
{
error = $"No uploaded bundle named '{name}'.";
name = null;
return false;
}
return true;
}
}
}
+127
View File
@@ -0,0 +1,127 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Cors;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json.Linq;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Config;
using RadicadeServer.Utils;
namespace RadicadeServer.Admin
{
[Route("admin/version")]
[EnableCors("AdminPanel")]
public class AdminVersionAPI(RecDBContext db) : Controller
{
private Task<IActionResult?> RejectInvalidKey(string key)
{
return Task.FromResult<IActionResult?>(key.IsAdminKey() ? null : Unauthorized("Invalid secret"));
}
private async Task<IActionResult> SetServerConfigValue(string keyName, string key, string value)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var existingValue = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == keyName);
if (existingValue != null)
{
existingValue.Value = value;
}
else
{
await db.ServerConfig.AddAsync(new ServerConfigVal { Key = keyName, Value = value });
}
await db.SaveChangesAsync();
return Ok("Set to: " + value);
}
[HttpGet("setgameversion")]
public async Task<IActionResult> SetGameVersion([FromQuery] string key, [FromQuery] string value)
{
return await SetServerConfigValue("GameVersion", key, value);
}
[HttpGet("setversion")]
public async Task<IActionResult> SetVersion([FromQuery] string key, [FromQuery] string value)
{
return await SetServerConfigValue("BarrelVersion", key, value);
}
[HttpGet("setkey")]
public async Task<IActionResult> SetKey([FromQuery] string key, [FromQuery] string value)
{
return await SetServerConfigValue("BarrelKey", key, value);
}
[HttpGet("setcheck")]
public async Task<IActionResult> SetCheck([FromQuery] string key, [FromQuery] string value)
{
return await SetServerConfigValue("BarrelCheck", key, value);
}
[HttpGet("setmanifestversion")]
public async Task<IActionResult> SetManifestVersion([FromQuery] string key, [FromQuery] string value)
{
return await SetServerConfigValue("ApprovedManifestVersion", key, value);
}
[HttpGet("setmanifestsha256")]
public async Task<IActionResult> SetManifestSha256([FromQuery] string key, [FromQuery] string value)
{
return await SetServerConfigValue("ApprovedManifestSha256", key, value);
}
[HttpGet("setgamedownloadurl")]
public async Task<IActionResult> SetGameDownloadUrl([FromQuery] string key, [FromQuery] string value)
{
return await SetServerConfigValue("GameDownloadUrl", key, value);
}
[HttpGet("setlaunchticket")]
public async Task<IActionResult> SetLaunchTicket([FromQuery] string key, [FromQuery] string value)
{
return await SetServerConfigValue("LaunchTicket", key, value);
}
[HttpGet("setlaunchticketrequired")]
public async Task<IActionResult> SetLaunchTicketRequired([FromQuery] string key, [FromQuery] bool value)
{
return await SetServerConfigValue("LaunchTicketRequired", key, value.ToString());
}
[HttpGet("setlaunchticketurl")]
public async Task<IActionResult> SetLaunchTicketUrl([FromQuery] string key, [FromQuery] string value)
{
return await SetServerConfigValue("LaunchTicketUrl", key, value);
}
[HttpGet("setlaunchticketaudience")]
public async Task<IActionResult> SetLaunchTicketAudience([FromQuery] string key, [FromQuery] string value)
{
return await SetServerConfigValue("LaunchTicketAudience", key, value);
}
[HttpGet("setlaunchticketnonce")]
public async Task<IActionResult> SetLaunchTicketNonce([FromQuery] string key, [FromQuery] string value)
{
return await SetServerConfigValue("LaunchTicketNonce", key, value);
}
[HttpGet("setgamepackagesha256")]
public async Task<IActionResult> SetGamePackageSha256([FromQuery] string key, [FromQuery] string value)
{
return await SetServerConfigValue("GamePackageSha256", key, value);
}
[HttpGet("setgamepackagesize")]
public async Task<IActionResult> SetGamePackageSize([FromQuery] string key, [FromQuery] long value)
{
return await SetServerConfigValue("GamePackageSize", key, value.ToString());
}
[HttpGet("setgameextractsubdirectory")]
public async Task<IActionResult> SetGameExtractSubdirectory([FromQuery] string key, [FromQuery] string value)
{
return await SetServerConfigValue("GameExtractSubdirectory", key, value);
}
[HttpGet("setlaunchermanifestnotes")]
public async Task<IActionResult> SetLauncherManifestNotes([FromQuery] string key, [FromQuery] string value)
{
return await SetServerConfigValue("LauncherManifestNotes", key, value);
}
[HttpGet("setlaunchermanifestdelete")]
public async Task<IActionResult> SetLauncherManifestDelete([FromQuery] string key, [FromQuery] string value)
{
return await SetServerConfigValue("LauncherManifestDelete", key, value);
}
}
}
+201
View File
@@ -0,0 +1,201 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.API.Notifications;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Enums.Messages;
using RadicadeServer.RecNet.Messages;
using RadicadeServer.RecNet.Rooms;
namespace RadicadeServer.Admin;
public static class RoomBanStore
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private static readonly object FileLock = new();
private const long CoachAccountId = 1;
private static string ClosedMessage(string roomName, string reason) =>
$"We have closed the room '{roomName}' for: {reason}. We encourage creators to review our Creator Code of Conduct to help make Radicade a fun and safe place for everyone.";
private static string RestoredMessage(string roomName) =>
$"We have restored the room '{roomName}' after a thorough review of the previous accusations. Thank you for continuing to follow the Creator Code of Conduct, and making Radicade a better place.";
public static string FilePath => Path.Join(DBConfig.PersistentLogs, "banned-rooms.json");
public static List<BannedRoom> Load()
{
lock (FileLock) return LoadLocked();
}
public static BannedRoom? Find(long roomId) => Load().FirstOrDefault(x => x.RoomId == roomId);
public static bool IsBanned(long roomId) => Find(roomId) != null;
public static async Task<BannedRoom> BanAsync(RecDBContext db, RoomData room, string reason, string bannedBy)
{
var entry = new BannedRoom
{
RoomId = room.RoomId,
Name = room.Name,
Reason = reason,
BannedBy = bannedBy,
BannedAt = DateTime.UtcNow,
PublishedAt = room.PublishedAt,
Room = room,
SubRooms = await db.SubRooms.AsNoTracking().Where(x => x.RoomId == room.RoomId).ToListAsync(),
Roles = await db.RoomRoles.AsNoTracking().Where(x => x.RoomId == room.RoomId).ToListAsync(),
Stats = await db.RoomStats.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == room.RoomId)
};
Upsert(entry);
db.Rooms.Remove(room);
await db.SaveChangesAsync();
var why = string.IsNullOrWhiteSpace(reason) ? "a breach of the Creator Code of Conduct" : reason;
await TellOwnerAsync(room.CreatorAccountId, ClosedMessage(room.Name, why));
return entry;
}
public static async Task<bool> RestoreAsync(RecDBContext db, long roomId)
{
var entry = Find(roomId);
if (entry == null) return false;
await InsertRoomWithItsIdAsync(db, entry.Room, entry.PublishedAt);
Remove(roomId);
await TellOwnerAsync(entry.Room.CreatorAccountId, RestoredMessage(entry.Name));
return true;
}
private static async Task InsertRoomWithItsIdAsync(RecDBContext db, RoomData room, DateTime publishedAt)
{
var entity = db.Model.FindEntityType(typeof(RoomData))
?? throw new InvalidOperationException("RoomData is not part of the model.");
var table = entity.GetTableName() ?? nameof(RoomData);
var qualified = entity.GetSchema() is { Length: > 0 } schema ? $"[{schema}].[{table}]" : $"[{table}]";
var columns = new List<string>();
var cells = new List<string>();
var parameters = new List<object>();
foreach (var property in entity.GetProperties())
{
if (property.IsShadowProperty() || property.PropertyInfo == null) continue;
var value = property.Name == nameof(RoomData.PublishedAt)
? publishedAt
: property.PropertyInfo.GetValue(room);
columns.Add($"[{property.GetColumnName()}]");
var sqlValue = ToSqlValue(value);
if (sqlValue == null)
{
cells.Add("NULL");
}
else
{
cells.Add("{" + parameters.Count + "}");
parameters.Add(sqlValue);
}
}
var sql =
$"SET IDENTITY_INSERT {qualified} ON;\n" +
$"INSERT INTO {qualified} ({string.Join(", ", columns)}) VALUES ({string.Join(", ", cells)});\n" +
$"SET IDENTITY_INSERT {qualified} OFF;";
await db.Database.ExecuteSqlRawAsync(sql, parameters);
}
private static async Task TellOwnerAsync(long ownerAccountId, string message)
{
if (ownerAccountId <= 0) return;
try
{
await Notify.SendMessage(ownerAccountId, new Message
{
Type = MessageType.CoachMessage,
Data = message,
FromPlayerId = CoachAccountId
});
}
catch (Exception)
{
}
}
private static object? ToSqlValue(object? value) => value switch
{
null => null,
Enum enumValue => Convert.ChangeType(enumValue, Enum.GetUnderlyingType(enumValue.GetType())),
_ => value
};
private static void Upsert(BannedRoom entry)
{
lock (FileLock)
{
var rooms = LoadLocked();
rooms.RemoveAll(x => x.RoomId == entry.RoomId);
rooms.Add(entry);
SaveLocked(rooms);
}
}
private static void Remove(long roomId)
{
lock (FileLock)
{
var rooms = LoadLocked();
if (rooms.RemoveAll(x => x.RoomId == roomId) == 0) return;
SaveLocked(rooms);
}
}
private static List<BannedRoom> LoadLocked()
{
if (!File.Exists(FilePath)) return new List<BannedRoom>();
try
{
var json = File.ReadAllText(FilePath);
var rooms = JsonSerializer.Deserialize<List<BannedRoom>>(json, JsonOptions) ?? new List<BannedRoom>();
foreach (var room in rooms) room.Room.PublishedAt = room.PublishedAt;
return rooms;
}
catch (Exception)
{
return new List<BannedRoom>();
}
}
private static void SaveLocked(List<BannedRoom> rooms)
{
Directory.CreateDirectory(DBConfig.PersistentLogs);
File.WriteAllText(FilePath, JsonSerializer.Serialize(rooms, JsonOptions));
}
}
public class BannedRoom
{
public long RoomId { get; set; }
public string Name { get; set; } = "";
public string Reason { get; set; } = "";
public string BannedBy { get; set; } = "";
public DateTime BannedAt { get; set; }
public DateTime PublishedAt { get; set; }
public RoomData Room { get; set; } = new();
public List<SubRoom> SubRooms { get; set; } = new();
public List<RoomRole> Roles { get; set; } = new();
public RoomStats? Stats { get; set; }
}
+27
View File
@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Mvc;
namespace RadicadeServer.API.Analytics
{
[Route("datacollection")]
public class DataCollectionAPI : Controller
{
[HttpPost("data/event")]
[HttpPost("data/heartbeat")]
public IActionResult Event()
{
return Ok(new { });
}
[HttpPost("data/events")]
public IActionResult Events()
{
return Ok(Array.Empty<object>());
}
[HttpGet("sampling")]
public IActionResult Sampling()
{
return Ok(new { });
}
}
}
+101
View File
@@ -0,0 +1,101 @@
using Microsoft.AspNetCore.Mvc;
using RadicadeServer.DB;
namespace RadicadeServer.API.Logs
{
[Route("gamelogs")]
public class GameLogsAPI : Controller
{
private const int HeadChars = 400;
private const long MaxAttachmentBytes = 20 * 1024 * 1024;
[HttpPut("reports/{id}")]
[HttpPost("reports/{id}")]
public async Task<IActionResult> UploadReport(string id)
{
var body = await ReadAsync();
return Ok(new { ReportId = id, Success = true });
}
[HttpPut("reports/{id}/attachments/{name}")]
[HttpPost("reports/{id}/attachments/{name}")]
public async Task<IActionResult> UploadAttachment(string id, string name)
{
var body = await ReadBytesAsync();
var path = await StoreAsync(id, name, body);
return Ok(new { ReportId = id, AttachmentName = name, Size = body.Length, Success = true });
}
[HttpPost("reports")]
public async Task<IActionResult> CreateReport()
{
var body = await ReadAsync();
var id = Guid.NewGuid().ToString();
return Ok(new { ReportId = id, Success = true });
}
private async Task<string> ReadAsync()
{
using var reader = new StreamReader(Request.Body);
return await reader.ReadToEndAsync();
}
private async Task<byte[]> ReadBytesAsync()
{
using var buffer = new MemoryStream();
var chunk = new byte[81920];
int read;
while ((read = await Request.Body.ReadAsync(chunk)) > 0)
{
buffer.Write(chunk, 0, read);
if (buffer.Length >= MaxAttachmentBytes) break;
}
return buffer.ToArray();
}
private static async Task<string?> StoreAsync(string reportId, string name, byte[] body)
{
if (body.Length == 0 || body.Length > MaxAttachmentBytes) return null;
try
{
Directory.CreateDirectory(AttachmentFolder);
var path = Path.Combine(AttachmentFolder, $"{Sanitize(reportId)}-{Sanitize(name)}");
await System.IO.File.WriteAllBytesAsync(path, body);
return path;
}
catch (Exception)
{
return null;
}
}
private static string AttachmentFolder => Path.Combine(DBConfig.PrivateData, "BugReports");
private static string Sanitize(string value)
{
var clean = new string((value ?? string.Empty).Where(c => !Path.GetInvalidFileNameChars().Contains(c)).ToArray());
return clean.Length == 0 ? "upload" : clean;
}
private static string Summarize(byte[] body)
{
foreach (var b in body)
{
if (b == 0) return "(binary)";
}
return Head(System.Text.Encoding.UTF8.GetString(body, 0, Math.Min(body.Length, 4096)));
}
private static string Head(string body)
{
var flat = body.Replace('\r', ' ').Replace('\n', ' ').Trim();
if (flat.Length == 0) return string.Empty;
return flat.Length <= HeadChars ? flat : flat[..HeadChars] + "...";
}
}
}
+16
View File
@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Mvc;
using RadicadeServer.RecNet.GameSight;
using RadicadeServer.RecNet.Responses;
namespace RadicadeServer.API.Analytics
{
[Route("api/gamesight/")]
public class GameSight : Controller
{
[HttpPost("event")]
public Success SendEvent([FromForm] string type, [FromForm] string user_id)
{
return new Success(true, null, null);
}
}
}
+18
View File
@@ -0,0 +1,18 @@
namespace RadicadeServer.RecNet.GameSight
{
public class GameSightIdentifiers
{
public string resolution { get; set; } = null!;
public string language { get; set; } = null!;
public string timezone { get; set; } = null!;
}
public class GameSightEvent
{
public string type { get; set; } = null!;
public string user_id { get; set; } = null!;
public GameSightIdentifiers identifiers { get; set; } = null!;
public string ip { get; set; } = null!;
public string gameplay_session_id { get; set; } = null!;
public string transaction_id { get; set; } = null!;
}
}
+108
View File
@@ -0,0 +1,108 @@
using System.Collections.Concurrent;
namespace RadicadeServer.Auth;
public static class AdminLoginThrottle
{
public const int MaxFailedAttempts = 5;
public static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(15);
private sealed record Entry(int Failures, DateTime FirstFailureAt, DateTime LockedUntil);
private static readonly ConcurrentDictionary<string, Entry> Attempts = new();
public static bool IsLocked(string username, string ip, out TimeSpan remaining)
{
remaining = TimeSpan.Zero;
var byUser = LockedRemaining("u:" + username);
var byIp = LockedRemaining("ip:" + ip);
if (byUser == null && byIp == null) return false;
remaining = Max(byUser, byIp)!.Value;
return true;
}
public static void RecordFailure(string username, string ip)
{
var now = DateTime.UtcNow;
foreach (var key in Keys(username, ip))
{
Attempts.AddOrUpdate(key,
_ => new Entry(1, now, DateTime.MinValue),
(_, existing) =>
{
if (now - existing.FirstFailureAt > LockoutDuration)
return new Entry(1, now, DateTime.MinValue);
var failures = existing.Failures + 1;
var lockedUntil = failures >= MaxFailedAttempts ? now + LockoutDuration : DateTime.MinValue;
return new Entry(failures, existing.FirstFailureAt, lockedUntil);
});
}
Prune(now);
}
public static void Clear(string username, string ip)
{
foreach (var key in Keys(username, ip))
Attempts.TryRemove(key, out _);
}
private static TimeSpan? LockedRemaining(string key)
{
if (string.IsNullOrWhiteSpace(key)) return null;
if (!Attempts.TryGetValue(key, out var entry)) return null;
if (entry.Failures < MaxFailedAttempts) return null;
var now = DateTime.UtcNow;
if (now >= entry.LockedUntil)
{
Attempts.TryRemove(key, out _);
return null;
}
return entry.LockedUntil - now;
}
private static IEnumerable<string> Keys(string username, string ip)
{
if (!string.IsNullOrWhiteSpace(username)) yield return "u:" + username;
if (!string.IsNullOrWhiteSpace(ip)) yield return "ip:" + ip;
}
private static TimeSpan? Max(TimeSpan? a, TimeSpan? b)
{
if (a == null) return b;
if (b == null) return a;
return a > b ? a : b;
}
private static void Prune(DateTime now)
{
foreach (var kv in Attempts)
{
var e = kv.Value;
var lockActive = e.Failures >= MaxFailedAttempts && now < e.LockedUntil;
if (!lockActive && now - e.FirstFailureAt > LockoutDuration)
Attempts.TryRemove(kv.Key, out _);
}
}
}
+78
View File
@@ -0,0 +1,78 @@
using System.Security.Cryptography;
using System.Text;
namespace RadicadeServer.Auth;
public static class AdminSecurity
{
private const int Pbkdf2Iterations = 600_000;
private const int SaltSize = 16;
private const int HashSize = 32;
public static string HashPassword(string password)
{
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var hash = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password), salt, Pbkdf2Iterations, HashAlgorithmName.SHA256, HashSize);
return $"pbkdf2${Pbkdf2Iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
}
public static bool VerifyPassword(string storedHash, string password)
{
if (string.IsNullOrWhiteSpace(storedHash)) return false;
var parts = storedHash.Split('$');
if (parts.Length != 4 || parts[0] != "pbkdf2") return false;
if (!int.TryParse(parts[1], out var iterations)) return false;
try
{
var salt = Convert.FromBase64String(parts[2]);
var expected = Convert.FromBase64String(parts[3]);
var actual = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password), salt, iterations, HashAlgorithmName.SHA256, expected.Length);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
catch
{
return false;
}
}
public static bool NeedsRehash(string storedHash)
{
if (string.IsNullOrWhiteSpace(storedHash)) return false;
var parts = storedHash.Split('$');
if (parts.Length != 4 || parts[0] != "pbkdf2") return true;
return !int.TryParse(parts[1], out var iterations) || iterations < Pbkdf2Iterations;
}
public static string? ValidatePasswordPolicy(string? password, string? username = null)
{
if (string.IsNullOrWhiteSpace(password) || password.Length < 12)
return "Password must be at least 12 characters long.";
if (!password.Any(char.IsUpper))
return "Password must contain at least one uppercase letter.";
if (!password.Any(char.IsLower))
return "Password must contain at least one lowercase letter.";
if (!password.Any(char.IsDigit))
return "Password must contain at least one number.";
if (!password.Any(ch => !char.IsLetterOrDigit(ch)))
return "Password must contain at least one symbol (e.g. ! @ # $ %).";
if (!string.IsNullOrWhiteSpace(username) && username.Length >= 3 &&
password.Contains(username, StringComparison.OrdinalIgnoreCase))
return "Password must not contain your username.";
return null;
}
}
+162
View File
@@ -0,0 +1,162 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.Admin;
using RadicadeServer.DB;
namespace RadicadeServer.Auth;
public static class AdminSession
{
public const string CookieName = "radicade_admin_session";
public const string StateCookieName = "radicade_oauth_state";
public const string RedirectCookieName = "radicade_oauth_redirect";
public static readonly TimeSpan Lifetime = TimeSpan.FromHours(12);
public static readonly TimeSpan AbsoluteLifetime = TimeSpan.FromDays(7);
private static readonly TimeSpan RotationGrace = TimeSpan.FromMinutes(2);
public static async Task<string> CreateAsync(RecDBContext db, long adminUserId)
{
var token = NewToken();
db.AdminSessions.Add(new AdminSessionRecord
{
AdminUserId = adminUserId,
TokenHash = Hash(token),
CreatedAt = DateTime.UtcNow,
LastSeenAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.Add(Lifetime)
});
await db.SaveChangesAsync();
return token;
}
public static async Task<(bool Ok, long UserId)> ValidateAsync(RecDBContext db, string? token)
{
if (string.IsNullOrWhiteSpace(token)) return (false, 0);
var now = DateTime.UtcNow;
var hash = Hash(token);
var record = await db.AdminSessions.FirstOrDefaultAsync(s => s.TokenHash == hash);
if (record == null)
{
record = await db.AdminSessions.FirstOrDefaultAsync(s =>
s.PreviousTokenHash == hash && s.LastSeenAt > now - RotationGrace);
if (record == null) return (false, 0);
}
if (record.RevokedAt.HasValue || record.ExpiresAt <= now)
return (false, 0);
var ceiling = record.CreatedAt.Add(AbsoluteLifetime);
if (now >= ceiling) return (false, 0);
record.LastSeenAt = now;
record.ExpiresAt = now.Add(Lifetime) < ceiling ? now.Add(Lifetime) : ceiling;
await db.SaveChangesAsync();
return (true, record.AdminUserId);
}
private static readonly object RotateLock = new();
public static (string Token, bool Rotated) TryRotate(RecDBContext db, string? currentToken)
{
if (string.IsNullOrWhiteSpace(currentToken)) return ("", false);
lock (RotateLock)
{
var record = db.AdminSessions.FirstOrDefault(s => s.TokenHash == Hash(currentToken));
if (record == null)
{
record = db.AdminSessions.FirstOrDefault(s => s.PreviousTokenHash == Hash(currentToken));
if (record != null && record.LastSeenAt > DateTime.UtcNow - RotationGrace)
return (currentToken, false);
return (currentToken, false);
}
var now = DateTime.UtcNow;
var ceiling = record.CreatedAt.Add(AbsoluteLifetime);
if (now >= ceiling) return (currentToken, false);
var next = NewToken();
record.PreviousTokenHash = record.TokenHash;
record.TokenHash = Hash(next);
record.LastSeenAt = now;
record.ExpiresAt = now.Add(Lifetime) < ceiling ? now.Add(Lifetime) : ceiling;
db.SaveChanges();
return (next, true);
}
}
public static async Task RevokeAsync(RecDBContext db, string? token)
{
if (string.IsNullOrWhiteSpace(token)) return;
var record = await db.AdminSessions.FirstOrDefaultAsync(s => s.TokenHash == Hash(token));
if (record == null) return;
record.RevokedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public static async Task RevokeAllAsync(RecDBContext db, long adminUserId)
{
var sessions = await db.AdminSessions
.Where(s => s.AdminUserId == adminUserId && !s.RevokedAt.HasValue)
.ToListAsync();
foreach (var session in sessions)
session.RevokedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public static async Task PruneAsync(RecDBContext db)
{
var cutoff = DateTime.UtcNow.AddDays(-2);
var stale = await db.AdminSessions
.Where(s => s.RevokedAt.HasValue || s.ExpiresAt < cutoff)
.ToListAsync();
if (stale.Count == 0) return;
db.AdminSessions.RemoveRange(stale);
await db.SaveChangesAsync();
}
private static string NewToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
private static string Hash(string token) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
}
+1116
View File
@@ -0,0 +1,1116 @@
using Azure;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Accounts.Local;
using RadicadeServer.RecNet.Auth;
using RadicadeServer.RecNet.Auth.Cached;
using RadicadeServer.RecNet.Auth.Connection;
using RadicadeServer.RecNet.Enums.Players;
using RadicadeServer.RecNet.Moderation;
using RadicadeServer.RecNet.Progression;
using RadicadeServer.RecNet.Responses;
using RadicadeServer.Radicade.Mail;
using RadicadeServer.Radicade.Responses;
using RadicadeServer.Utils;
using static System.Runtime.InteropServices.JavaScript.JSType;
namespace RadicadeServer.Auth
{
[Route("auth")]
public class AuthAPI : Controller
{
public class WebLoginRequest
{
public string? Username { get; set; }
public string? Password { get; set; }
}
public const int MinPasswordLength = 6;
RecDBContext db;
PasswordHasher<PlayerData> hasher;
public AuthAPI(RecDBContext context) {
this.db = context;
this.hasher = new PasswordHasher<PlayerData>();
}
[HttpGet("account/me/haspassword"), Authorize]
public async Task<bool?> GetHasPassword()
{
var userId = User.GetUserId();
var user = await db.Players.FirstAsync(x => x.AccountId == userId);
return !string.IsNullOrEmpty(user.Hashword);
}
[HttpPost("account/me/changepassword"), Authorize]
public async Task<Success> ChangePassword([FromForm] string oldPassword, [FromForm] string newPassword)
{
if (string.IsNullOrWhiteSpace(newPassword)) return new Success(false, "New password is required.", null);
if (newPassword.Length < MinPasswordLength) return new Success(false, $"Password must be at least {MinPasswordLength} characters.", null);
var userId = User.GetUserId();
var user = await db.Players.FirstOrDefaultAsync(x => x.AccountId == userId);
if (user == null) return new Success(false, "User does not exist.", "damn.");
if (!string.IsNullOrWhiteSpace(user.Hashword))
{
if (string.IsNullOrEmpty(oldPassword))
return new Success(false, "Current password is required.", null);
var check = hasher.VerifyHashedPassword(user, user.Hashword, oldPassword);
if (check != PasswordVerificationResult.Success && check != PasswordVerificationResult.SuccessRehashNeeded)
return new Success(false, "Current password is incorrect.", null);
}
user.Hashword = hasher.HashPassword(user, newPassword);
await db.SaveChangesAsync();
AuthSecurityMonitor.NoteCredentialChange(user.AccountId, "password changed");
return new Success(true, null, null);
}
[HttpPost("account/recoverpassword")]
public async Task<Success> RecoverPassword()
{
var email = "";
if (Request.HasFormContentType)
email = Request.Form["email"].FirstOrDefault() ?? "";
if (string.IsNullOrWhiteSpace(email))
{
using var reader = new System.IO.StreamReader(Request.Body);
var raw = await reader.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(raw))
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(raw);
if (doc.RootElement.TryGetProperty("email", out var e) && e.ValueKind == System.Text.Json.JsonValueKind.String)
email = e.GetString() ?? "";
}
catch { }
}
}
email = email.Trim();
if (string.IsNullOrWhiteSpace(email) || !email.Contains('@'))
return new Success(false, "Enter a valid email address.", null);
var plrData = await db.Players.FirstOrDefaultAsync(x => x.Email.ToLower() == email.ToLower());
if (plrData != null)
{
plrData.PasswordResetToken = Guid.NewGuid().ToString("N");
plrData.PasswordResetExpires = DateTime.UtcNow.AddHours(1);
await db.SaveChangesAsync();
SendResetEmail(plrData);
AuthSecurityMonitor.NoteCredentialChange(plrData.AccountId, "password reset requested");
}
return new Success(true, null, null);
}
private void SendResetEmail(PlayerData plrData)
{
if (string.IsNullOrWhiteSpace(plrData.PasswordResetToken)) return;
var link = $"https://radicade.app/reset?code={plrData.PasswordResetToken}";
var html =
"<div style=\"font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:24px;\">" +
"<img src=\"https://radicade.app/assests/RadicadeHorizontal.png\" alt=\"Radicade\" style=\"display:block;max-width:220px;height:auto;margin:0 auto 22px;\" />" +
"<h2 style=\"color:#1a1a2e;margin-top:0;\">Reset your Radicade password</h2>" +
"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Hi there,</p>" +
"<p style=\"color:#444;font-size:15px;line-height:1.6;\">We received a request to reset the password on your Radicade account from the game. If that was you, click the button below to choose a new password:</p>" +
"<p style=\"text-align:center;margin:28px 0;\"><a href=\"" + link + "\" style=\"background:#00a0d0;color:#fff;text-decoration:none;padding:12px 28px;border-radius:6px;display:inline-block;\">Reset my password</a></p>" +
"<p style=\"color:#999;font-size:12px;margin-top:24px;\">This link expires in one hour. If you didn't request this, you can safely ignore this email.</p></div>";
_ = RadicadeMail.SendAsync(plrData.Email, "Reset your password - Radicade", html);
}
[HttpPost("web/login")]
public async Task<IActionResult> WebLogin([FromBody] WebLoginRequest req)
{
if (string.IsNullOrWhiteSpace(req?.Username)) return Ok(new { success = false, message = "No username was given." });
if (string.IsNullOrWhiteSpace(req?.Password)) return Ok(new { success = false, message = "No password was given." });
var loginName = req.Username.Trim().TrimStart('@');
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.username == loginName || x.displayName == loginName);
if (profile == null)
return Ok(new { success = false, message = "Invalid username or password." });
var plrData = await db.Players.FirstOrDefaultAsync(x => x.AccountId == profile.accountId);
if (plrData == null)
return Ok(new { success = false, message = "Invalid username or password." });
if (string.IsNullOrWhiteSpace(plrData.Hashword))
return Ok(new { success = false, message = "Please make a password in-game!" });
var loginIp = Request.GetClientIp();
var result = hasher.VerifyHashedPassword(plrData, plrData.Hashword, req.Password ?? "");
if (result != PasswordVerificationResult.Success)
{
AuthSecurityMonitor.RecordFailedLogin(profile.accountId, loginIp);
return Ok(new { success = false, message = "Invalid username or password." });
}
if (await AccountLock.IsLockedAsync(db, profile.accountId))
{
var lockedUsername = await AccountLock.ResolveUsernameAsync(db, profile.accountId) ?? profile.username;
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] refused web sign-in for locked account {profile.accountId}");
return Ok(new { success = false, error = AccountLock.ErrorCode, message = AccountLock.LoginErrorMessage(lockedUsername) });
}
AuthSecurityMonitor.RecordSuccessfulLogin(profile.accountId, loginIp, false, false);
if (!await db.Presences.AnyAsync(x => x.playerId == profile.accountId))
{
db.Presences.Add(new RealPresence
{
playerId = profile.accountId,
deviceClass = DeviceClass.Screen,
statusVisibility = StatusVisibility.Public,
vRMovementMode = VRMovementModes.TELEPORT
});
await db.SaveChangesAsync();
}
var newToken = await GenerateToken(db, profile.accountId, "web", DeviceClass.Screen, Platforms.Steam, "web:" + profile.accountId);
if (!newToken.success || newToken.value == null)
return Ok(new { success = false, message = newToken.error ?? "Failed to sign in." });
return Ok(new
{
success = true,
token = (string)newToken.value,
username = profile.username,
displayName = profile.displayName,
email = plrData.Email,
emailVerified = plrData.EmailVerified
});
}
[HttpPost("connect/token")]
public async Task<ActionResult<AuthResponse?>> ConnectWithToken([FromForm] TokenConnectData data)
{
data.platform_id = data.platform_id?.Trim();
data.device_id = data.device_id?.Trim();
var platformAuth = data.platform_auth?.Trim() ?? string.Empty;
if (data.platform == Platforms.Standalone)
{
if (string.IsNullOrWhiteSpace(data.device_id) ||
data.device_id.Length > 256 || data.client_id != "recroom")
return Unauthorized(new AuthResponse("Failed to sign in.", "Sign in with your Radicade username and password."));
data.platform_id = "radicade-quest:" + data.device_id;
if (data.grant_type == "password" && !string.IsNullOrWhiteSpace(data.username) &&
!string.IsNullOrWhiteSpace(data.password))
data.account_id = 0;
else if (data.grant_type is "cached_login" or "refresh_token")
{
var remembered = await QuestRememberedSession.ValidateAsync(db,
Request.Headers[QuestRememberedSession.Header].ToString(), data.device_id, data.account_id);
if (!remembered.HasValue)
return Unauthorized(new AuthResponse("invalid_grant", "Sign in with your Radicade username and password."));
data.account_id = remembered.Value;
data.grant_type = "refresh_token";
}
else return Unauthorized(new AuthResponse("invalid_grant", "Sign in with your Radicade username and password."));
}
if (data.platform == Platforms.Steam && !string.IsNullOrEmpty(platformAuth) &&
!TryValidateSteamPlatformAuth(platformAuth, out var platformAuthReason))
{
return SteamIdentityRefusal("platform_auth shape", platformAuthReason, data);
}
if (data.platform == Platforms.Steam && (data.grant_type is "cached_login" or "create_account" or "password"))
{
if (string.IsNullOrWhiteSpace(platformAuth))
{
return SteamIdentityRefusal("platform_auth",
"platform_auth was absent or blank", data);
}
if (string.IsNullOrWhiteSpace(DBConfig.SteamApiKey))
{
if (!DBConfig.TrustUnverifiedSteamIdentity)
{
return SteamIdentityRefusal("steam api key",
"no SteamApiKey is configured and TrustUnverifiedSteamIdentity is off", data);
}
}
else
{
if (!TryReadSteamTicket(platformAuth, out var steamTicket))
{
return SteamIdentityRefusal("platform_auth.Ticket",
"platform_auth carried no readable string 'Ticket'", data);
}
string? verifiedSteamId;
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(HttpContext.RequestAborted);
timeout.CancelAfter(TimeSpan.FromSeconds(10));
verifiedSteamId = await AuthenticateSteamTicketAsync(steamTicket, GetSteamAppId(platformAuth), timeout.Token);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
{
return SteamIdentityRefusal("steam ticket check",
$"Steam could not be reached ({ex.GetType().Name})", data);
}
if (string.IsNullOrWhiteSpace(verifiedSteamId))
{
return SteamIdentityRefusal("steam ticket check",
"Steam would not confirm the ticket (stale ticket, or one issued for an app it will not check)", data);
}
if (!string.Equals(verifiedSteamId, data.platform_id?.Trim(), StringComparison.Ordinal))
{
return SteamIdentityRefusal("identity match",
$"the ticket is for {verifiedSteamId}, not for the posted identity {data.platform_id?.Trim()}", data);
}
}
}
var isWhitelisted = await AuthShared.IsWhitelisted(data.platform_id ?? string.Empty);
if (!isWhitelisted)
{
return new AuthResponse("Error", "You are not whitelisted.");
}
AuthResponse response = new AuthResponse();
switch (data.grant_type)
{
default:
return Unauthorized();
case "cached_login":
{
if (data.account_id <= 0)
return StatusCode(400, new AuthResponse("invalid_request", "missing account_id"));
if (!await db.Players.AnyAsync(player => player.AccountId == data.account_id) ||
!await db.Profiles.AnyAsync(profile => profile.accountId == data.account_id) ||
!await db.Presences.AnyAsync(presence => presence.playerId == data.account_id))
return StatusCode(400, new AuthResponse("invalid_grant", "unknown or incomplete account_id"));
if (!await db.CachedLogins.AnyAsync(login => login.accountId == data.account_id &&
login.platform == data.platform && login.platformId == data.platform_id))
return StatusCode(400, new AuthResponse("invalid_grant", "no linked account for this platform identity"));
response = await TokenRefresh(data);
return Ok(response);
}
case "create_account":
{
var carriesSignUpPayload = HasSignUpPayload(data);
using var identity = await RegistrationGuard.LockIdentityAsync(
RegistrationGuard.IdentityKey(data.platform.ToString(), data.platform_id, data.device_id));
var existingLink = await db.CachedLogins
.AsNoTracking()
.Where(login => login.platform == data.platform && login.platformId == data.platform_id)
.Where(login => data.account_id <= 0 || login.accountId == data.account_id)
.OrderByDescending(login => login.lastLoginTime)
.FirstOrDefaultAsync();
if (data.account_id > 0 && existingLink == null)
return StatusCode(400, new AuthResponse("invalid_grant", "no linked account for this platform identity"));
if (existingLink != null && carriesSignUpPayload)
{
var linkedSettings = await db.PlayerSettings.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == existingLink.accountId);
bool finished = linkedSettings?.DeserializeSettings().Any(x =>
string.Equals(x.Key, "Recroom.AccountCreation.HasFinished", StringComparison.OrdinalIgnoreCase) &&
string.Equals(x.Value, "true", StringComparison.OrdinalIgnoreCase)) == true;
if (finished)
existingLink = null;
}
if (existingLink != null)
{
data.account_id = existingLink.accountId;
if (carriesSignUpPayload)
await ApplySignUpPayloadAsync(data, existingLink.accountId);
else if (!await PlayerUtils.HasFinishedAccountCreationAsync(db, existingLink.accountId))
{
await PlayerUtils.RefreshAccountCreationMarkersAsync(db, existingLink.accountId);
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Onboarding] account {existingLink.accountId} resumed " +
"before its creation flow finished; started marker restored so the client runs the flow");
}
}
else
{
var adoptable = await PlayerUtils.FindAdoptableAccountForDeviceAsync(db, data.device_id?.Trim());
if (adoptable != null)
{
data.account_id = adoptable.AccountId;
await PlayerUtils.AdoptDeviceAccountAsync(db, adoptable.AccountId, new AccountCreationData
{
platform = data.platform,
platformId = data.platform_id ?? string.Empty,
deviceId = data.device_id ?? string.Empty
}, markStarted: true);
if (carriesSignUpPayload)
await ApplySignUpPayloadAsync(data, adoptable.AccountId);
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Auth] create_account adopted this device's account " +
$"{adoptable.AccountId} for platform {data.platform} identity {data.platform_id}");
}
else
{
var refusal = await TryProvisionOnboardingAccountAsync(data);
if (refusal != null)
{
return TokenRefusal(refusal);
}
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Auth] create_account made account {data.account_id} " +
$"for platform {data.platform} identity {data.platform_id}");
}
}
response = await TokenRefresh(data);
return Ok(response);
}
case "refresh_token":
response = await TokenRefresh(data);
return Ok(response);
case "password":
if (data.username == null) return Ok(new AuthResponse { error = "Failed to sign in.", error_description = "No username was given." });
if (data.password == null) return Ok(new AuthResponse { error = "Failed to sign in.", error_description = "No password was given." });
var loginIp = Request.GetClientIp();
if (AuthSecurityMonitor.IsSourceThrottled(loginIp))
{
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] throttled sign-in source {loginIp} (suspected spraying)");
return StatusCode(429, new AuthResponse("too_many_requests", "Too many sign-in attempts from this connection. Please try again later."));
}
var loginName = data.username.Trim().TrimStart('@');
var existingProfile = await db.Profiles.FirstOrDefaultAsync(x => x.username == loginName || x.displayName == loginName);
if (existingProfile != null)
{
data.account_id = existingProfile.accountId;
}
var playerData = await db.Players.FirstOrDefaultAsync(x => x.AccountId == data.account_id);
if (playerData != null)
{
var result = hasher.VerifyHashedPassword(playerData, playerData.Hashword, data.password);
if (result == PasswordVerificationResult.Success)
{
var alreadyCached = await db.CachedLogins.FirstOrDefaultAsync(x => x.accountId == data.account_id && x.platform == data.platform && x.platformId == data.platform_id);
if (await AccountLock.IsLockedAsync(db, data.account_id) && alreadyCached == null)
{
var lockedUsername = await AccountLock.ResolveUsernameAsync(db, data.account_id);
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] refused password sign-in for locked account {data.account_id}");
return Ok(new AuthResponse(AccountLock.ErrorCode, AccountLock.LoginErrorMessage(lockedUsername)));
}
if (alreadyCached == null)
{
await db.AddAsync(new CachedLogin { lastLoginTime = DateTime.UtcNow, accountId = data.account_id, platform = data.platform, platformId = data.platform_id! });
await db.SaveChangesAsync();
}
var newDevice = !string.IsNullOrWhiteSpace(playerData.DeviceId)
&& !string.Equals(playerData.DeviceId, data.device_id, StringComparison.Ordinal);
var newIp = !string.IsNullOrWhiteSpace(playerData.LastIpAddress)
&& !string.Equals(playerData.LastIpAddress, loginIp, StringComparison.Ordinal);
AuthSecurityMonitor.RecordSuccessfulLogin(data.account_id, loginIp, newDevice, newIp);
response = await TokenRefresh(data, verifiedByPassword: true);
return Ok(response);
}
}
var failures = AuthSecurityMonitor.RecordFailedLogin(data.account_id, loginIp);
var failureDelay = AuthSecurityMonitor.ProgressiveDelayMs(failures);
if (failureDelay > 0) await Task.Delay(failureDelay);
return Ok(new AuthResponse { error = "Failed to sign in.", error_description = "Unknown error." });
}
}
[NonAction]
private async Task ApplySignUpPayloadAsync(TokenConnectData data, long accountId)
{
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == accountId);
var plrData = await db.Players.FirstOrDefaultAsync(x => x.AccountId == accountId);
if (profile == null || plrData == null)
{
return;
}
var applied = new List<string>();
var ignored = new List<string>();
bool isOnboarding = await PlayerUtils.IsChoosingTheirAccountNameAsync(db, accountId) || PlayerUtils.IsNewPlayer(plrData);
bool nameApplied = false;
bool nameChosen = false;
var chosen = UsernameRules.Parse(data.username, out var nameRejection);
if (nameRejection != null)
{
ignored.Add($"name=refused({nameRejection})");
}
else if (chosen == null)
{
ignored.Add("name=absent");
}
else if (string.Equals(chosen, profile.username, StringComparison.Ordinal))
{
ignored.Add($"name={chosen}(already its name)");
nameChosen = true;
}
else if (profile.NameLocked)
{
ignored.Add($"name={chosen}(locked)");
}
else if (!await PlayerUtils.IsChoosingTheirAccountNameAsync(db, accountId) && !PlayerUtils.IsNewPlayer(plrData))
{
ignored.Add($"name={chosen}(flow over for account '{profile.username}')");
}
else if (await PlayerUtils.IsNameTakenAsync(db, chosen))
{
ignored.Add($"name={chosen}(taken)");
}
else
{
var previous = profile.username;
profile.username = chosen;
profile.displayName = chosen;
applied.Add($"name={chosen}(was '{previous}')");
nameApplied = true;
nameChosen = true;
}
var birthday = PlayerUtils.ReadBirthday(data.birthday ?? data.birthdayUTC);
if (birthday == null)
{
ignored.Add("birthday=absent");
}
else if (plrData.Birthday != null && plrData.Birthday.Value.Year > 1900)
{
ignored.Add($"birthday={birthday:yyyy-MM-dd}(account already gave {plrData.Birthday:yyyy-MM-dd})");
}
else
{
plrData.Birthday = birthday;
plrData.IsJunior = PlayerUtils.IsJuniorByBirthday(birthday);
profile.isJunior = plrData.IsJunior;
applied.Add($"birthday={birthday:yyyy-MM-dd}");
}
if (string.IsNullOrWhiteSpace(data.password))
{
ignored.Add("password=absent");
}
else if (!string.IsNullOrWhiteSpace(plrData.Hashword))
{
ignored.Add("password=(account already has one)");
}
else
{
plrData.Hashword = new PasswordHasher<PlayerData>().HashPassword(plrData, data.password);
applied.Add("password=set");
}
if (applied.Count > 0 || isOnboarding) await db.SaveChangesAsync();
if (isOnboarding || nameApplied)
await PlayerUtils.RefreshAccountCreationMarkersAsync(db, accountId, nameChosen ? true : null);
var line =
$"[SignUp] account {accountId} resumed its sign-up on an identity that was already linked: " +
$"applied [{string.Join(", ", applied)}], ignored [{string.Join(", ", ignored)}]; " +
$"name is now '{profile.username}', birthday {(plrData.Birthday == null ? "none" : plrData.Birthday.Value.ToString("yyyy-MM-dd"))}, " +
$"password {(string.IsNullOrWhiteSpace(plrData.Hashword) ? "unset" : "set")}.";
}
[NonAction]
private async Task<AuthResponse?> TryProvisionOnboardingAccountAsync(TokenConnectData data)
{
var platformId = data.platform_id?.Trim() ?? string.Empty;
var deviceId = data.device_id?.Trim() ?? string.Empty;
if (platformId.Length == 0 || deviceId.Length == 0 || platformId.Length > 64 || deviceId.Length > 256)
{
return new AuthResponse("Account creation failed.", "A valid platform ID and device ID are required.");
}
var clientIp = Request.GetClientIp();
if (await db.IsIpBanned(clientIp))
return new AuthResponse("Account creation failed.", "This IP address is banned from creating new accounts.");
if (await db.IsAccountCreationBlocked(clientIp))
return new AuthResponse("Account creation failed.", "This IP address is blocked from creating new accounts.");
if (await db.IsHardwareBanned(deviceId))
return new AuthResponse("Account creation failed.", "This device is banned.");
var throttled = RegistrationGuard.Check(clientIp, deviceId);
if (throttled != null)
{
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Registration] refused a new account for platform " +
$"{data.platform} identity {platformId} ({RegistrationGuard.Describe(clientIp, deviceId)}): {throttled}");
return new AuthResponse("Account creation failed.", throttled);
}
data.platform_id = platformId;
data.device_id = deviceId;
var created = await PlayerUtils.CreateAccount(new AccountCreationData
{
platform = data.platform,
platformId = platformId,
deviceId = deviceId,
deviceClass = data.device_class,
password = data.password,
username = data.username,
birthday = data.birthday,
birthdayUTC = data.birthdayUTC,
email = data.email,
avatar = data.avatar,
avatarJson = data.avatarJson
}, clientIp, accountCreationInProgress: true);
if (!created.success || created.value is not Profile profile)
return new AuthResponse("Account creation failed.", created.error ?? "Could not create the account.");
data.account_id = profile.accountId;
return null;
}
[NonAction]
private static bool HasSignUpPayload(TokenConnectData data) =>
!string.IsNullOrWhiteSpace(data.username) ||
!string.IsNullOrWhiteSpace(data.password) ||
data.birthday != null ||
data.birthdayUTC != null ||
!string.IsNullOrWhiteSpace(data.avatar) ||
!string.IsNullOrWhiteSpace(data.avatarJson) ||
!string.IsNullOrWhiteSpace(data.email);
[NonAction]
private static ObjectResult TokenRefusal(AuthResponse refusal)
{
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Auth] sign-in refused: error={refusal.error} detail=\"{refusal.error_description}\"");
return new(refusal) { StatusCode = 400 };
}
private const string SteamAppId = "471710";
private static readonly string[] SteamAllowedAppIds = ["480", SteamAppId];
[NonAction]
protected virtual async Task<string?> AuthenticateSteamTicketAsync(string ticket, string appId, CancellationToken cancellationToken)
{
foreach (var candidate in CandidateSteamAppIds(appId))
{
var result = await PlatformIdentity.AuthenticateTicketAsync(ticket, candidate, cancellationToken);
if (result.OtherApp) continue;
return result.Ok ? result.SteamId : null;
}
return null;
}
[NonAction]
private static IEnumerable<string> CandidateSteamAppIds(string appId) =>
new[] { appId }.Concat(SteamAllowedAppIds)
.Where(candidate => !string.IsNullOrWhiteSpace(candidate))
.Distinct(StringComparer.Ordinal);
[NonAction]
private static string GetSteamAppId(string platformAuth)
{
using var document = JsonDocument.Parse(platformAuth);
var appId = document.RootElement.GetProperty("AppId");
return appId.ValueKind == JsonValueKind.String ? appId.GetString() ?? SteamAppId : appId.ToString();
}
[NonAction]
private static bool TryReadSteamTicket(string platformAuth, out string ticket)
{
ticket = string.Empty;
try
{
using var document = JsonDocument.Parse(platformAuth);
if (!document.RootElement.TryGetProperty("Ticket", out var ticketElement) ||
ticketElement.ValueKind != JsonValueKind.String)
return false;
ticket = ticketElement.GetString() ?? string.Empty;
return ticket.Length > 0;
}
catch (JsonException)
{
return false;
}
}
[NonAction]
private ActionResult SteamIdentityRefusal(string stage, string detail, TokenConnectData data)
{
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Auth] connect/token refused at {stage} for " +
$"platform {data.platform} identity {OrAbsent(data.platform_id)} " +
$"grant_type {OrAbsent(data.grant_type)}: {detail}");
return Unauthorized();
}
[NonAction]
private static string OrAbsent(string? value) =>
string.IsNullOrWhiteSpace(value) ? "<absent>" : value.Trim();
[NonAction]
private static bool TryValidateSteamPlatformAuth(string platformAuth, out string reason)
{
reason = "unknown";
if (platformAuth[0] != '{' || platformAuth[^1] != '}')
{
reason = $"not a JSON object (len={platformAuth.Length})";
return false;
}
try
{
using var doc = JsonDocument.Parse(platformAuth);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
reason = "platform_auth is valid JSON but not an object";
return false;
} if (!root.TryGetProperty("Ticket", out var ticketElement) || ticketElement.ValueKind != JsonValueKind.String)
{
reason = $"no string 'Ticket' property (properties: {DescribeProperties(root)})";
return false;
}
var ticket = ticketElement.GetString() ?? string.Empty;
if (ticket.Length == 0 || !ticket.StartsWith("140", StringComparison.Ordinal))
{
reason = $"'Ticket' has an unexpected format (len={ticket.Length})";
return false;
}
if (!root.TryGetProperty("AppId", out var appIdElement))
{
reason = $"no 'AppId' property (properties: {DescribeProperties(root)})";
return false;
}
var appId = appIdElement.ValueKind == JsonValueKind.String ? appIdElement.GetString() : appIdElement.ToString();
if (!SteamAllowedAppIds.Contains(appId, StringComparer.Ordinal))
{
reason = "'AppId' is not supported for Steam ticket verification";
return false;
}
reason = "ok";
return true;
}
catch (JsonException ex)
{
reason = $"invalid JSON ({ex.GetType().Name})";
return false;
}
}
[NonAction]
private static string DescribeProperties(JsonElement root)
{
var names = new List<string>();
foreach (var property in root.EnumerateObject())
names.Add(property.Name);
return names.Count == 0 ? "none" : string.Join(",", names);
}
[NonAction]
private async Task<AuthResponse> TokenRefresh(TokenConnectData data, bool verifiedByPassword = false)
{
if (data.account_id <= 0)
{
long resolvedAccountId = 0;
if (!string.IsNullOrWhiteSpace(data.refresh_token) &&
Guid.TryParse(data.refresh_token, out var tokenGuid))
{
var tokenBytes = tokenGuid.ToByteArray();
var embedded = BitConverter.ToInt64(tokenBytes, 0);
if (embedded > 0 && await db.Players.AnyAsync(x => x.AccountId == embedded))
resolvedAccountId = embedded;
}
if (resolvedAccountId <= 0)
{
var headerAuth = base.Request.Headers.Authorization.ToString();
if (!string.IsNullOrWhiteSpace(headerAuth))
{
var headerUserId = headerAuth.GetUserId();
if (headerUserId.HasValue && headerUserId.Value > 0 && await db.Players.AnyAsync(x => x.AccountId == headerUserId.Value))
resolvedAccountId = headerUserId.Value;
}
}
if (resolvedAccountId <= 0)
{
if (!string.IsNullOrWhiteSpace(data.device_id))
{
var devicePlayer = await db.Players
.Where(x => x.DeviceId == data.device_id)
.OrderByDescending(x => x.AccountId)
.FirstOrDefaultAsync();
if (devicePlayer != null) resolvedAccountId = devicePlayer.AccountId;
}
else if (!string.IsNullOrWhiteSpace(data.platform_id))
{
var recentLogin = await db.CachedLogins
.Where(x => x.platformId == data.platform_id && x.platform == data.platform)
.OrderByDescending(x => x.lastLoginTime)
.FirstOrDefaultAsync();
if (recentLogin != null) resolvedAccountId = recentLogin.accountId;
}
}
if (resolvedAccountId > 0) data.account_id = resolvedAccountId; } PlayerData? player = await db.Players.FirstOrDefaultAsync(x => x.AccountId == data.account_id);
if (player == null)
{
var hasIdentity = await db.CachedLogins.AnyAsync(l => l.accountId == data.account_id)
|| await db.Profiles.AnyAsync(p => p.accountId == data.account_id);
if (hasIdentity)
{
player = new PlayerData
{
AccountId = data.account_id,
Phone = "",
AppVersion = data.ver ?? DBConfig.GameVersion,
DeviceClass = data.device_class,
NameChanges = 99,
Email = "",
Hashword = "",
JuniorState = JuniorState.BasedOnBirthday,
Visibility = StatusVisibility.Public,
DeviceId = data.device_id ?? "",
LastIpAddress = Request.GetClientIp() ?? ""
};
await db.AddRangeAsync(
player,
new PlayerAvatar { Avatar = DBConfig.DefaultAvatar, Outfits = "[]", AccountId = data.account_id },
new Reputation { AccountId = data.account_id },
new PlayerSettings { AccountId = data.account_id, Settings = DBConfig.NewAccountSettings2023 },
new ProgressionData { PlayerId = data.account_id, XP = 0, Level = 1, Progress = new ProgressionJson().ToJson() },
new RealPresence { playerId = data.account_id, deviceClass = data.device_class, statusVisibility = StatusVisibility.Public, vRMovementMode = VRMovementModes.TELEPORT },
new Currency { AccountId = data.account_id },
new PlayerUnlocks { AccountId = data.account_id, AvatarItems = DBConfig.DefaultAvatarUnlocks }
);
await db.SaveChangesAsync();
await RoomUtils.GetOrCreateDorm(data.account_id);
}
}
CachedLogin? cachedLogin = await db.CachedLogins.FirstOrDefaultAsync(
l => l.accountId == data.account_id &&
l.platformId == data.platform_id &&
l.platform == data.platform);
if (data.account_id == 1)
{
return new AuthResponse("Failed to login.", "System account cannot log in.");
}
if (player == null)
{
return new AuthResponse("Failed to login.", "Missing player data.");
}
if (await AccountLock.IsLockedAsync(db, data.account_id) && cachedLogin == null)
{
var lockedUsername = await AccountLock.ResolveUsernameAsync(db, data.account_id);
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] refused {data.grant_type} sign-in for locked account {data.account_id} (platform={data.platform}, platformId={data.platform_id})");
return new AuthResponse(AccountLock.ErrorCode, AccountLock.LoginErrorMessage(lockedUsername));
}
if (cachedLogin == null)
{
return new AuthResponse("Failed to login.", "Missing cached login.");
}
AuthResponse response = new AuthResponse();
string authorization = data.platform == Platforms.Standalone ? string.Empty : base.Request.Headers.Authorization.ToString();
bool hasToken = !string.IsNullOrEmpty(authorization);
bool isPlatform = false;
if (cachedLogin != null)
{
if (cachedLogin.platformId == data.platform_id &&
cachedLogin.platform == data.platform &&
(player.DeviceId == data.device_id || string.IsNullOrEmpty(player.DeviceId)) &&
data.client_id == "recroom")
{
isPlatform = true;
}
}
if (!hasToken && !isPlatform && !verifiedByPassword)
{
base.Response.StatusCode = 401;
return new AuthResponse("Failed to login.", "Invalid platform session.");
}
else
{
long userId = data.account_id;
if (hasToken)
{
var headerUserId = authorization.GetUserId();
if (headerUserId.HasValue)
{
userId = headerUserId.Value;
}
else
{
hasToken = false;
}
}
Success newToken = await GenerateToken(db, data.account_id, data.ver!, data.device_class, data.platform, data.platform_id);
player.DeviceId = data.device_id!;
if (data.device_class != DeviceClass.Unknown)
player.DeviceClass = data.device_class;
var loginIp = Request.GetClientIp();
if (!string.IsNullOrWhiteSpace(loginIp))
player.LastIpAddress = loginIp;
await db.SaveChangesAsync();
if (!newToken.success || newToken.value == null)
{
response = new AuthResponse
{
error = "Error",
error_description = newToken.error
};
return response;
}
else
{
response.access_token = (string)newToken.value;
if (data.platform == Platforms.Standalone)
Response.Headers[QuestRememberedSession.Header] = response.access_token;
var accountBytes = BitConverter.GetBytes(data.account_id);
var refreshGuidBytes = new byte[16];
Array.Copy(accountBytes, 0, refreshGuidBytes, 0, 8); response.refresh_token = new Guid(refreshGuidBytes).ToString();
response.key = "c3Vja215ZGlja25nYQ==";
return response;
}
}
}
[NonAction]
internal static async Task<Success> GenerateToken(RecDBContext db, long userId, string version, DeviceClass deviceClass, Platforms platform, string platformId)
{
CachedLogin? cachedLogin = await db.CachedLogins.FirstOrDefaultAsync(
login => login.accountId == userId &&
login.platformId == platformId &&
login.platform == platform);
Profile? profile = await db.Profiles.FirstOrDefaultAsync((plr) => plr.accountId == userId);
if (profile == null)
return AuthResponses.MissingProfile;
if (await AccountLock.IsLockedAsync(db, userId) && cachedLogin == null)
return new Success(false, AccountLock.ErrorCode, null);
RealPresence? presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == userId);
if (presence == null) return AuthResponses.MissingPresence;
presence.deviceClass = deviceClass;
PlayerData? player = await db.Players.FirstOrDefaultAsync((plr) => plr.AccountId == userId);
if (player == null) return AuthResponses.MissingPlayer;
player.AppVersion = version;
if (cachedLogin != null)
{
cachedLogin.lastLoginTime = DateTime.UtcNow;
}
await db.SaveChangesAsync();
MatchmakingUtils.ClearInRoom(userId);
List<Claim> claims = new List<Claim>();
claims.Add(new Claim("iss", DBConfig.TokenIssuer));
claims.Add(new Claim("client_id", "recroom"));
claims.Add(new Claim("role", "gameClient"));
if (RadicadeServer.RecNet.Keepsakes.KeepsakeFeature.Enabled)
{
claims.Add(new Claim("role", "keepsake"));
claims.Add(new Claim("role", "livekeepsakeeventroomsaveoverride"));
}
claims.Add(new Claim("rn.plat", ((int)platform).ToString()));
claims.Add(new Claim("rn.platid", platformId));
claims.Add(new Claim("rn.deviceclass", deviceClass.ToString()));
claims.Add(new Claim("rn.ver", version));
claims.Add(new Claim("idp", "local"));
claims.Add(new Claim("userId", userId.ToString()));
claims.Add(new Claim(ClaimTypes.NameIdentifier, userId.ToString()));
claims.Add(new Claim("tver", player.TokenVersion.ToString()));
if (platform == Platforms.Standalone)
claims.Add(new Claim("q.pwd", QuestRememberedSession.PasswordVersion(player.Hashword)));
if (profile.isJunior || PlayerUtils.IsJuniorByBirthday(player.Birthday))
claims.Add(new Claim("role", "junior"));
if (profile.HasRole("developer")) claims.Add(new Claim("role", "developer"));
if (profile.HasRole("betastudio")) claims.Add(new Claim("role", "betastudio"));
if (profile.HasRole("moderator")) claims.Add(new Claim("role", "moderator"));
if (profile.HasRole("screenshare")) claims.Add(new Claim("role", "screenshare"));
if (profile.HasRole("influencer")) claims.Add(new Claim("role", "influencer"));
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(DBConfig.JwtKey);
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims.ToArray()),
Expires = DateTime.Now.AddDays(7),
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
var tokenWritten = tokenHandler.WriteToken(token);
return new Success(true, null, tokenWritten);
}
}
}
+18
View File
@@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
namespace RadicadeServer.Auth;
public class AuthShared
{
public static IDbContextFactory<RecDBContext> DbFactory = null!;
public static Task<bool> IsWhitelisted(string platformId)
{
if (!SteamWhitelist.Enabled) return Task.FromResult(true);
return Task.FromResult(SteamWhitelist.Contains(platformId));
}
}
+23
View File
@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using RadicadeServer.RecNet.Enums.Players;
namespace RadicadeServer.RecNet.Auth.Cached
{
public class CachedLogin
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity), JsonIgnore]
public long loginId { get; set; }
public long accountId { get; set; }
public Platforms platform { get; set; }
public string platformId { get; set; } = null!;
public DateTime lastLoginTime { get; set; }
public bool requirePassword { get; set; }
}
}
+55
View File
@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Auth.Cached;
using RadicadeServer.RecNet.Enums.Players;
namespace RadicadeServer.Auth.Cached
{
[Route("auth/cachedlogin")]
public class CachedLoginAPI : Controller
{
RecDBContext db;
public CachedLoginAPI(RecDBContext context)
{
this.db = context;
}
[HttpGet("forplatformid/{platform}/{platformId}")]
public async Task<IActionResult> GetCachedLoginsForPlatformId(Platforms platform, string platformId)
{
if (platform == Platforms.Standalone) return Ok(new List<CachedLogin>());
var cached = await db.CachedLogins.AsNoTracking()
.Where(login => login.platformId == platformId && login.platform == platform)
.Where(login => db.Players.Any(player => player.AccountId == login.accountId))
.Where(login => db.Profiles.Any(profile => profile.accountId == login.accountId))
.Where(login => db.Presences.Any(presence => presence.playerId == login.accountId))
.OrderByDescending(login => login.lastLoginTime)
.ToListAsync();
return Ok(cached);
}
[HttpPost("forplatformids")]
public async Task<List<CachedLogin>> GetCachedLoginsForPlatformIds([FromForm] string[] id)
{
if (id == null || id.Length <= 0) return [];
var requestedIds = id.Where(platformId => !string.IsNullOrWhiteSpace(platformId))
.Distinct(StringComparer.Ordinal).ToArray();
var cachedLogins = new List<CachedLogin>();
foreach (var platformId in requestedIds)
{
var cached = await db.CachedLogins.AsNoTracking()
.Where(login => login.platformId == platformId && login.platform != Platforms.Standalone)
.Where(login => db.Players.Any(player => player.AccountId == login.accountId))
.Where(login => db.Profiles.Any(profile => profile.accountId == login.accountId))
.Where(login => db.Presences.Any(presence => presence.playerId == login.accountId))
.OrderByDescending(login => login.lastLoginTime)
.FirstOrDefaultAsync();
if (cached != null) cachedLogins.Add(cached);
}
return cachedLogins;
}
}
}
+55
View File
@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Mvc;
using RadicadeServer.DB;
namespace RadicadeServer.Auth
{
[Route("/")]
public class DeviceApprovalAPI : Controller
{
public DeviceApprovalAPI() {
}
[HttpGet("device")]
public IActionResult Device([FromQuery(Name = "user_code")] string userCode)
{
var (found, approved, accountId, expiresUtc) = StudioAuthAPI.DescribeUserCode(userCode);
return Content(DevicePage(userCode, found, approved, accountId, expiresUtc), "text/html");
}
private static string DevicePage(string userCode, bool found, bool approved, long accountId, DateTime expiresUtc)
{
var glyph = found && approved ? "✅" : found ? "⏳" : "⚠️";
var color = found && approved ? "#16a34a" : found ? "#d97706" : "#dc2626";
var title = found && approved ? "Approved" : found ? "Waiting for approval" : "Code not recognised";
var body = found && approved
? "This server signs Rec Room Studio in automatically, so this code is already approved as account <b>" + accountId + "</b>. Close this tab and go back to Unity."
: found
? "The code is valid, but it has not been approved yet. Return to Unity and try again."
: "This server has no pending sign-in for that code - it may have expired, or it may already have been used (device codes are single use). Start the sign-in again from Unity.";
var codeBlock = string.IsNullOrWhiteSpace(userCode)
? "<p style=\"color:#475569;font-size:15px;\">No <b>user_code</b> was supplied. Unity sends one when it opens this page.</p>"
: "<p style=\"color:#475569;font-size:15px;\">Your code</p>" +
"<p style=\"font-family:Consolas,Menlo,monospace;font-size:26px;letter-spacing:3px;color:#0f172a;background:#f1f5f9;border-radius:8px;padding:12px 0;margin:0;\">" +
System.Net.WebUtility.HtmlEncode(userCode) + "</p>";
var expiry = found && expiresUtc > DateTime.UtcNow
? "<p style=\"color:#94a3b8;font-size:13px;margin-top:18px;\">Code expires " + expiresUtc.ToString("u") + " UTC.</p>"
: "";
return "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>" +
"Radicade - Rec Room Studio sign-in</title></head>" +
"<body style=\"margin:0;background:#0f172a;font-family:Arial,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;\">" +
"<div style=\"background:#fff;border-radius:12px;padding:40px;max-width:440px;text-align:center;box-shadow:0 10px 40px rgba(0,0,0,0.3);\">" +
"<div style=\"font-size:44px;\">" + glyph + "</div>" +
"<h1 style=\"color:" + color + ";font-size:22px;margin:12px 0;\">" + title + "</h1>" +
codeBlock +
"<p style=\"color:#475569;font-size:15px;line-height:1.6;margin-top:18px;\">" + body + "</p>" +
expiry +
"<p style=\"color:#94a3b8;font-size:12px;margin-top:24px;\">" + DBConfig.ServerUrl + " - local Radicade server</p>" +
"</div></body></html>";
}
}
}
+14
View File
@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Mvc;
namespace RadicadeServer.Auth
{
[Route("auth/eac")]
public class EACApi : Controller
{
[HttpGet("challenge")]
public IActionResult GetFakeEACChallenge()
{
return new JsonResult("AQAAAOmBNw8TMlSZs9NsuyGxsU7WMoKVcieTW8IyoSMEGcbTIPg7DZ4JGZTCfg4b1h58FOOkM7r0LUhRVwlq8wo528o3LR9g/kHzlrVJA6H0+HaYTQzjKEe+zl5/VupqSXk8PXQ0BlNiRED3x5YMCGiM+F0fYmd0NU4K0d5hY+A=");
}
}
}
+18
View File
@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RadicadeServer.RecNet.Auth.Photon;
namespace RadicadeServer.Auth.Photon
{
[ApiController, Route("auth/photon")]
public class PhotonAuthAPI : Controller
{
[AllowAnonymous]
[HttpGet("authenticate")]
[HttpPost("authenticate")]
public ActionResult<PhotonAuthResult> AuthenticatePhoton()
{
return Ok(new PhotonAuthResult { ResultCode = 1 });
}
}
}
+18
View File
@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace RadicadeServer.RecNet.Auth.Photon
{
public class PhotonAuthResult
{
public int ResultCode { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? UserId { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Message { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Nickname { get; set; }
}
}
+242
View File
@@ -0,0 +1,242 @@
using System.Text.Json;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Enums.Players;
namespace RadicadeServer.Auth;
public static class PlatformIdentity
{
public static readonly string[] AllowedSteamAppIds = ["480", "471710"];
public static HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(10) };
public sealed record Proof(bool Verified, string? SteamId, string Detail)
{
public static Proof Ok(string? steamId) => new(true, steamId, "proved");
public static Proof No(string detail) => new(false, null, detail);
}
public static async Task<Proof> ProveAsync(
Platforms platform,
string? platformId,
string? steamTicketHeader,
string? platformAuth,
CancellationToken cancellationToken = default)
{
var id = platformId?.Trim() ?? string.Empty;
if (id.Length == 0) return Proof.No("A platform id is required.");
return platform switch
{
Platforms.Steam => await ProveSteamAsync(id, steamTicketHeader, platformAuth, cancellationToken),
Platforms.Oculus => ProveOculus(id, platformAuth),
_ => Proof.No("Account creation is only available from Steam or Oculus on this server.")
};
}
private static async Task<Proof> ProveSteamAsync(
string platformId,
string? steamTicketHeader,
string? platformAuth,
CancellationToken cancellationToken)
{
var ticket = string.IsNullOrWhiteSpace(steamTicketHeader)
? ReadString(platformAuth, "Ticket")
: steamTicketHeader.Trim();
if (string.IsNullOrWhiteSpace(ticket))
return Proof.No("No Steam ticket was sent (x-steam-ticket header or platform_auth.Ticket).");
if (string.IsNullOrWhiteSpace(DBConfig.SteamApiKey))
{
if (!DBConfig.TrustUnverifiedSteamIdentity)
return Proof.No("Steam identity cannot be proved: no SteamApiKey is configured.");
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Identity] accepted Steam identity {platformId} " +
"UNPROVED - no SteamApiKey is configured and TrustUnverifiedSteamIdentity is on.");
return Proof.Ok(null);
}
var attempted = new List<string>();
foreach (var appId in CandidateAppIds(ReadString(platformAuth, "AppId")))
{
attempted.Add(appId);
var result = await AuthenticateTicketAsync(ticket, appId, cancellationToken);
if (result.OtherApp) continue;
if (!result.Ok)
return Proof.No(result.Detail);
if (!string.Equals(result.SteamId, platformId, StringComparison.Ordinal))
return Proof.No($"The Steam ticket is for {result.SteamId}, not for the identity it was sent with.");
return Proof.Ok(result.SteamId);
}
return Proof.No("The Steam ticket is not for an app this server accepts (tried " +
string.Join(", ", attempted) + ").");
}
private static Proof ProveOculus(string platformId, string? platformAuth)
{
var nonce = ReadString(platformAuth, "Nonce");
if (string.IsNullOrWhiteSpace(nonce))
return Proof.No("No Oculus nonce was sent (platform_auth.Nonce).");
return Proof.No("Oculus identity cannot be proved yet: no Oculus app secret is configured, " +
"so the nonce cannot be validated.");
}
public static async Task<string?> CheckSteamStandingAsync(string steamId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(DBConfig.SteamApiKey)) return null;
if (string.IsNullOrWhiteSpace(steamId)) return null;
var key = Uri.EscapeDataString(DBConfig.SteamApiKey);
var id = Uri.EscapeDataString(steamId);
try
{
using var summaryDoc = await GetJsonAsync(
$"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key={key}&steamids={id}",
cancellationToken);
if (summaryDoc == null) return null;
if (!summaryDoc.RootElement.TryGetProperty("response", out var summary) ||
!summary.TryGetProperty("players", out var players) ||
players.ValueKind != JsonValueKind.Array ||
players.GetArrayLength() == 0)
return "Your Steam profile was not found, so an account cannot be created for it.";
var player = players[0];
if (player.TryGetProperty("profilestate", out var profileState) &&
profileState.ValueKind == JsonValueKind.Number &&
profileState.TryGetInt32(out var state) && state != 1)
return "Your Steam profile has to be set up before you can create an account.";
using var bansDoc = await GetJsonAsync(
$"https://api.steampowered.com/ISteamUser/GetPlayerBans/v1/?key={key}&steamids={id}",
cancellationToken);
if (bansDoc == null) return null;
if (!bansDoc.RootElement.TryGetProperty("players", out var bans) ||
bans.ValueKind != JsonValueKind.Array || bans.GetArrayLength() == 0)
return "Steam would not report this account's ban status, so an account cannot be created for it.";
var entry = bans[0];
if (entry.TryGetProperty("CommunityBanned", out var banned) && banned.ValueKind == JsonValueKind.True)
return "Steam accounts with an active community ban are not allowed to create an account.";
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
{
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Identity] Steam standing check could not be " +
$"completed for {steamId}: {ex.GetType().Name}: {ex.Message}");
return null;
}
return null;
}
public sealed record TicketResult(bool Ok, bool OtherApp, string? SteamId, string Detail);
public static async Task<TicketResult> AuthenticateTicketAsync(string ticket, string appId, CancellationToken cancellationToken)
{
try
{
using var doc = await GetJsonAsync(
"https://api.steampowered.com/ISteamUserAuth/AuthenticateUserTicket/v1/?key=" +
$"{Uri.EscapeDataString(DBConfig.SteamApiKey)}&appid={Uri.EscapeDataString(appId)}" +
$"&ticket={Uri.EscapeDataString(ticket)}",
cancellationToken);
if (doc == null) return new TicketResult(false, false, null, "Steam did not answer the ticket check.");
if (!doc.RootElement.TryGetProperty("response", out var response))
return new TicketResult(false, false, null, "Steam did not answer the ticket check.");
if (response.TryGetProperty("error", out var error))
{
var code = error.TryGetProperty("errorcode", out var errorCode) && errorCode.TryGetInt32(out var value)
? value
: 0;
var description = error.TryGetProperty("errordesc", out var desc) ? desc.GetString() : null;
if (code == 102)
return new TicketResult(false, true, null, $"ticket for app {appId}");
return new TicketResult(false, false, null,
$"Steam refused the ticket ({(description ?? "no description")}).");
}
if (!response.TryGetProperty("params", out var parameters) ||
parameters.ValueKind != JsonValueKind.Object ||
!parameters.TryGetProperty("result", out var result) ||
result.GetString() != "OK" ||
!parameters.TryGetProperty("steamid", out var steamId) ||
steamId.ValueKind != JsonValueKind.String)
return new TicketResult(false, false, null, "Steam did not accept the ticket.");
return new TicketResult(true, false, steamId.GetString(), "ok");
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
{
return new TicketResult(false, false, null,
$"Steam could not be reached to check the ticket ({ex.GetType().Name}).");
}
}
private static async Task<JsonDocument?> GetJsonAsync(string url, CancellationToken cancellationToken)
{
using var response = await Client.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Identity] Steam answered {(int)response.StatusCode} for " +
$"{new Uri(url).AbsolutePath}.");
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
}
private static IEnumerable<string> CandidateAppIds(string? claimed)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
var ordered = new List<string>();
void Add(string? candidate)
{
var value = candidate?.Trim();
if (string.IsNullOrEmpty(value) || !seen.Add(value)) return;
ordered.Add(value);
}
Add(claimed);
foreach (var id in AllowedSteamAppIds) Add(id);
return ordered;
}
private static string? ReadString(string? json, string property)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
if (!doc.RootElement.TryGetProperty(property, out var value)) return null;
return value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Number => value.ToString(),
_ => null
};
}
catch (JsonException)
{
return null;
}
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace RadicadeServer.RecNet.Auth.Cached
{
public class PlatformIds
{
public long Platform { get; set; }
public long PlatformId { get; set; }
}
}
+90
View File
@@ -0,0 +1,90 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using RadicadeServer.RecNet.Enums.Players;
namespace RadicadeServer.RecNet.Auth
{
public class PlayerData
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.None)]
public long AccountId { get; set; }
public string Email { get; set; } = "";
public bool EmailVerified { get; set; }
[JsonIgnore]
public string? EmailVerifyToken { get; set; }
[JsonIgnore]
public string? PendingEmail { get; set; }
public string Phone { get; set; } = "";
public string AppVersion { get; set; } = "";
public long? Parent { get; set; }
public int NameChanges { get; set; } = 3;
public JuniorState JuniorState { get; set; }
public bool IsJunior { get; set; }
[JsonIgnore]
public string Hashword { get; set; } = "";
[JsonIgnore]
public int TokenVersion { get; set; }
[JsonIgnore]
public string? PasswordResetToken { get; set; }
[JsonIgnore]
public DateTime? PasswordResetExpires { get; set; }
public DateTime? Birthday { get; set; }
public bool CodeOfConductAccepted { get; set; }
public StatusVisibility Visibility { get; set; }
public DeviceClass DeviceClass { get; set; }
public bool Developer { get; set; }
[JsonIgnore]
public string DeviceId { get; set; } = "";
[JsonIgnore]
public string LastIpAddress { get; set; } = "";
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace RadicadeServer.RecNet.Auth.Enums;
public enum PlayerRoleTypes
{
none,
influencer,
screenshare,
moderator,
developer
}
+69
View File
@@ -0,0 +1,69 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Auth.Enums;
namespace RadicadeServer.RecNet.Auth
{
public class Profile
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long accountId { get; set; }
public string profileImage { get; set; } = null!;
public string? bannerImage { get; set; }
public bool isRecentHistoryVisible { get; set; } = true;
public bool isJunior { get; set; }
public int platforms { get; set; }
public string username { get; set; } = null!;
public string displayName { get; set; } = null!;
public string? bio { get; set; }
public int personalPronouns { get; set; }
public int identityFlags { get; set; }
public string? displayEmoji { get; set; }
public bool NameLocked { get; set; }
public bool BioLocked { get; set; }
public DateTime createdAt { get; set; }
public string? role { get; set; }
public bool developer => HasRole("developer");
public bool moderator => HasRole("moderator");
public bool screenshare => HasRole("screenshare");
public bool influencer => HasRole("influencer");
public bool HasRole(string expectedRoleName)
{
if (string.IsNullOrWhiteSpace(role)) return false;
var normalized = expectedRoleName switch
{
"screenshare" => "sharescreen",
"influencer" => "imfluencer",
_ => expectedRoleName
};
return role.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(r => r.Equals(expectedRoleName, StringComparison.OrdinalIgnoreCase) || r.Equals(normalized, StringComparison.OrdinalIgnoreCase));
}
}
}
+51
View File
@@ -0,0 +1,51 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using RadicadeServer.DB;
namespace RadicadeServer.Auth;
internal static class QuestRememberedSession
{
internal const string Header = "X-Radicade-Quest-Session";
internal static async Task<long?> ValidateAsync(RecDBContext db, string token, string deviceId, long requestedAccount)
{
if (string.IsNullOrWhiteSpace(token) || token.Length > 8192) return null;
try
{
var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
var principal = handler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(DBConfig.JwtKey)),
ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256 },
ValidateIssuer = true,
ValidIssuer = DBConfig.TokenIssuer,
ValidateAudience = false,
ValidateLifetime = true,
RequireExpirationTime = true,
ClockSkew = TimeSpan.Zero
}, out _);
string? Claim(string name) => principal.FindFirst(name)?.Value;
if (Claim("client_id") != "recroom" || Claim("rn.plat") != "7" ||
Claim("rn.platid") != "radicade-quest:" + deviceId ||
!long.TryParse(Claim("userId"), out var account) || account <= 0 ||
(requestedAccount > 0 && requestedAccount != account) ||
!int.TryParse(Claim("tver"), out var version)) return null;
var player = await db.Players.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == account);
return player != null && player.TokenVersion == version &&
Claim("q.pwd") == PasswordVersion(player.Hashword) ? account : null;
}
catch (Exception ex) when (ex is SecurityTokenException or ArgumentException)
{
return null;
}
}
internal static string PasswordVersion(string? hash) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(hash ?? "")));
}
+31
View File
@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using System.Reflection;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Auth;
namespace RadicadeServer.Utils.Attributes
{
}
+22
View File
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
namespace RadicadeServer.API
{
[Route("role")]
[Route("auth/role")]
public class RolesAPI(RecDBContext db) : Controller
{
[HttpGet("{roleType}/{id}")]
public async Task<bool> GetDeveloperRole(string roleType, long id)
{
var player = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == id);
if (player != null)
{
return player.HasRole(roleType);
}
return false;
}
}
}
+192
View File
@@ -0,0 +1,192 @@
using System.Text.Json;
namespace RadicadeServer.Auth;
public static class SteamWhitelist
{
public sealed class WhitelistFile
{
public bool Enabled { get; set; }
public List<string> SteamIds { get; set; } = new();
}
private static readonly JsonSerializerOptions CaseInsensitive = new()
{
PropertyNameCaseInsensitive = true
};
private static readonly object Lock = new();
private static WhitelistFile? _data;
private static string _filePath = "";
private static bool _loaded;
private static DateTime _lastWriteUtc = DateTime.MinValue;
public static string FilePath => _filePath;
public static bool CreatedEmptyConfig { get; private set; }
public static void Load()
{
lock (Lock)
{
if (_loaded) return;
_filePath = Path.GetFullPath(Path.Join(DBConfig.PrivateData, "steamwhitelist.json"));
_loaded = true;
CreatedEmptyConfig = !File.Exists(_filePath);
try
{
if (CreatedEmptyConfig)
{
_data = new WhitelistFile { Enabled = false, SteamIds = new List<string>() };
}
else
{
var raw = File.ReadAllText(_filePath);
_data = JsonSerializer.Deserialize<WhitelistFile>(raw, CaseInsensitive) ?? new WhitelistFile();
_data.SteamIds ??= new List<string>();
_lastWriteUtc = File.GetLastWriteTimeUtc(_filePath);
}
}
catch (Exception)
{
_data = new WhitelistFile { Enabled = false, SteamIds = new List<string>() };
}
}
}
public static bool Enabled
{
get { EnsureLoaded(); lock (Lock) return _data!.Enabled; }
set { EnsureLoaded(); lock (Lock) { _data!.Enabled = value; SaveLocked(); } }
}
public static IReadOnlyCollection<string> SteamIds
{
get { EnsureLoaded(); lock (Lock) return _data!.SteamIds.ToList(); }
}
public static void SetSteamIds(IEnumerable<string> steamIds)
{
EnsureLoaded();
lock (Lock)
{
_data!.SteamIds = steamIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.Distinct()
.ToList();
SaveLocked();
}
}
public static bool Contains(string steamId)
{
if (string.IsNullOrWhiteSpace(steamId)) return false;
EnsureLoaded();
lock (Lock) return _data!.SteamIds.Contains(steamId.Trim());
}
public static bool Add(string steamId)
{
if (string.IsNullOrWhiteSpace(steamId)) return false;
steamId = steamId.Trim();
EnsureLoaded();
lock (Lock)
{
if (_data!.SteamIds.Contains(steamId)) return false;
_data.SteamIds.Add(steamId);
SaveLocked();
return true;
}
}
public static bool Remove(string steamId)
{
if (string.IsNullOrWhiteSpace(steamId)) return false;
steamId = steamId.Trim();
EnsureLoaded();
lock (Lock)
{
var removed = _data!.SteamIds.Remove(steamId);
if (removed) SaveLocked();
return removed;
}
}
private static void EnsureLoaded()
{
if (!_loaded) { Load(); return; }
lock (Lock)
{
DateTime lastWriteUtc;
try { lastWriteUtc = File.GetLastWriteTimeUtc(_filePath); }
catch { return; }
if (lastWriteUtc > _lastWriteUtc)
{
try
{
var raw = File.ReadAllText(_filePath);
var reloaded = JsonSerializer.Deserialize<WhitelistFile>(raw, CaseInsensitive);
if (reloaded != null)
{
reloaded.SteamIds ??= new List<string>();
_data = reloaded;
_lastWriteUtc = lastWriteUtc;
}
}
catch (Exception)
{
}
}
}
}
private static void SaveLocked()
{
try
{
var dir = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
var options = new JsonSerializerOptions { WriteIndented = true };
File.WriteAllText(_filePath, JsonSerializer.Serialize(_data, options));
_lastWriteUtc = File.GetLastWriteTimeUtc(_filePath);
}
catch (Exception)
{
}
}
}
+317
View File
@@ -0,0 +1,317 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.Utils;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Enums.Players;
using RadicadeServer.RecNet.Enums.Rooms;
using System.Collections.Concurrent;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace RadicadeServer.Auth
{
[Route("auth")]
public class StudioAuthAPI : Controller
{
private const int DeviceCodeLifetimeSeconds = 900;
private const int PollIntervalSeconds = 2;
private const int TokenLifetimeSeconds = 604800;
private static readonly ConcurrentDictionary<string, PendingDeviceAuthorization> Pending = new(StringComparer.Ordinal);
private const string UserCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
private sealed class PendingDeviceAuthorization
{
public string UserCode = string.Empty;
public string Scope = "studio";
public DateTime ExpiresUtc = DateTime.UtcNow;
public long AccountId;
public bool Approved;
}
RecDBContext db;
private readonly IConfiguration config;
public StudioAuthAPI(RecDBContext context,IConfiguration configuration) {
this.db = context;
this.config = configuration;
}
[HttpGet("connect/deviceauthorization")]
[HttpPost("connect/deviceauthorization")]
public async Task<IActionResult> DeviceAuthorization()
{
var fields = await ReadRequestFields();
if (!IsDeviceFlowAllowed())
{
return BadRequest(new { error = "access_denied", error_description = "Rec Room Studio sign-in is only available from the machine running the server." });
}
var accountId = await ResolveStudioAccountId(fields);
if (accountId <= 0)
{
return BadRequest(new { error = "access_denied", error_description = "No account is configured for Rec Room Studio on this server." });
}
PruneExpired();
var deviceCode = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
var pending = new PendingDeviceAuthorization
{
UserCode = GenerateUserCode(),
Scope = fields.TryGetValue("scope", out var scope) && !string.IsNullOrWhiteSpace(scope) ? scope : "studio",
ExpiresUtc = DateTime.UtcNow.AddSeconds(DeviceCodeLifetimeSeconds),
AccountId = accountId,
Approved = true
};
Pending[deviceCode] = pending;
return Ok(new
{
device_code = deviceCode,
user_code = pending.UserCode,
verification_uri = $"{DBConfig.ServerUrl}/device",
verification_uri_complete = $"{DBConfig.ServerUrl}/device?user_code={pending.UserCode}",
expires_in = DeviceCodeLifetimeSeconds,
interval = PollIntervalSeconds,
scope = pending.Scope
});
}
[HttpGet("connect/studiotoken")]
[HttpPost("connect/studiotoken")]
public async Task<IActionResult> StudioToken()
{
var fields = await ReadRequestFields();
var grantType = fields.GetValueOrDefault("grant_type") ?? string.Empty;
if (IsDeviceCodeGrant(grantType))
return await CompleteDeviceAuthorization(fields);
if (IsRefreshGrant(grantType))
return await RefreshStudioToken(fields);
if (grantType.Length == 0)
{
return BadRequest(new { error = "invalid_request", error_description = "grant_type is required." });
}
return BadRequest(new { error = "unsupported_grant_type", error_description = $"grant_type '{grantType}' is not supported." });
}
private static bool IsDeviceCodeGrant(string grantType) =>
grantType.Replace("-", "_").Contains("device_code", StringComparison.OrdinalIgnoreCase);
private static bool IsRefreshGrant(string grantType) =>
grantType.Replace("-", "_").Contains("refresh_token", StringComparison.OrdinalIgnoreCase);
internal static (bool Found, bool Approved, long AccountId, DateTime ExpiresUtc) DescribeUserCode(string userCode)
{
if (string.IsNullOrWhiteSpace(userCode)) return (false, false, 0, default);
var wanted = NormalizeUserCode(userCode);
var now = DateTime.UtcNow;
foreach (var entry in Pending)
{
if (entry.Value.ExpiresUtc <= now) continue;
if (NormalizeUserCode(entry.Value.UserCode) != wanted) continue;
return (true, entry.Value.Approved, entry.Value.AccountId, entry.Value.ExpiresUtc);
}
return (false, false, 0, default);
}
private static string NormalizeUserCode(string userCode) =>
new string(userCode.Where(char.IsLetterOrDigit).ToArray()).ToUpperInvariant();
private async Task<IActionResult> CompleteDeviceAuthorization(Dictionary<string, string> fields)
{
var deviceCode = fields.GetValueOrDefault("device_code") ?? string.Empty;
if (!Pending.TryGetValue(deviceCode, out var pending))
return BadRequest(new { error = "invalid_grant", error_description = "Unknown device code." });
if (pending.ExpiresUtc <= DateTime.UtcNow)
{
Pending.TryRemove(deviceCode, out _);
return BadRequest(new { error = "expired_token", error_description = "The device code has expired." });
}
if (!pending.Approved)
return BadRequest(new { error = "authorization_pending", error_description = "The user has not approved this sign-in yet." });
Pending.TryRemove(deviceCode, out _);
return await IssueStudioTokens(pending.AccountId);
}
private async Task<IActionResult> RefreshStudioToken(Dictionary<string, string> fields)
{
var refreshToken = fields.GetValueOrDefault("refresh_token") ?? string.Empty;
var accountId = AccountIdFromRefreshToken(refreshToken);
if (accountId > 0 && !await db.Players.AnyAsync(x => x.AccountId == accountId))
accountId = 0;
if (accountId <= 0)
{
return BadRequest(new { error = "invalid_grant", error_description = "Unknown refresh token." });
}
return await IssueStudioTokens(accountId);
}
private async Task<IActionResult> IssueStudioTokens(long accountId)
{
await EnsurePresence(accountId);
var token = await AuthAPI.GenerateToken(db, accountId, DBConfig.GameVersion, DeviceClass.Screen, Platforms.Steam, $"studio:{accountId}");
if (!token.success || token.value is not string accessToken)
{
return BadRequest(new { error = "invalid_grant", error_description = token.error ?? "Could not create a studio token." });
}
return Ok(new
{
access_token = accessToken,
refresh_token = RefreshTokenFor(accountId),
token_type = "Bearer",
expires_in = TokenLifetimeSeconds,
scope = "studio"
});
}
private static string RefreshTokenFor(long accountId)
{
var bytes = new byte[16];
Array.Copy(BitConverter.GetBytes(accountId), 0, bytes, 0, 8);
return new Guid(bytes).ToString();
}
private static long AccountIdFromRefreshToken(string refreshToken)
{
if (!Guid.TryParse(refreshToken, out var guid)) return 0;
var embedded = BitConverter.ToInt64(guid.ToByteArray(), 0);
return embedded > 0 ? embedded : 0;
}
private async Task EnsurePresence(long accountId)
{
if (await db.Presences.AnyAsync(x => x.playerId == accountId)) return;
db.Presences.Add(new RealPresence
{
playerId = accountId,
deviceClass = DeviceClass.Screen,
statusVisibility = StatusVisibility.Public,
vRMovementMode = VRMovementModes.TELEPORT
});
await db.SaveChangesAsync();
}
private async Task<long> ResolveStudioAccountId(Dictionary<string, string> fields)
{
var configured = config["Radicade:Studio:AutoApproveAccountId"];
if (long.TryParse(configured, out var configuredId) && configuredId > 0 && await db.Players.AnyAsync(x => x.AccountId == configuredId))
return configuredId;
if (long.TryParse(fields.GetValueOrDefault("account_id"), out var requestedId) && requestedId > 0 && await db.Players.AnyAsync(x => x.AccountId == requestedId))
return requestedId;
var developer = await db.Profiles.Where(p => p.role != null && p.role.Contains("developer"))
.OrderBy(p => p.accountId)
.FirstOrDefaultAsync();
if (developer != null) return developer.accountId;
return 0;
}
private bool IsDeviceFlowAllowed()
{
if (bool.TryParse(config["Radicade:Studio:AllowRemoteDeviceAuth"], out var allowRemote) && allowRemote) return true;
var ip = Request.GetClientIp();
if (string.IsNullOrWhiteSpace(ip)) return false;
if (IPAddress.TryParse(ip, out var parsed)) return IPAddress.IsLoopback(parsed);
return ip.Equals("localhost", StringComparison.OrdinalIgnoreCase);
}
private static string GenerateUserCode()
{
var buffer = new char[9];
var bytes = RandomNumberGenerator.GetBytes(8);
for (var i = 0; i < 8; i++)
{
buffer[i < 4 ? i : i + 1] = UserCodeAlphabet[bytes[i] % UserCodeAlphabet.Length];
}
buffer[4] = '-';
return new string(buffer);
}
private static void PruneExpired()
{
var now = DateTime.UtcNow;
foreach (var entry in Pending)
{
if (entry.Value.ExpiresUtc <= now) Pending.TryRemove(entry.Key, out _);
}
}
private async Task<Dictionary<string, string>> ReadRequestFields()
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var query in Request.Query)
fields[query.Key] = query.Value.ToString();
if (Request.HasFormContentType)
{
foreach (var form in Request.Form)
fields[form.Key] = form.Value.ToString();
}
else
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true);
var raw = await reader.ReadToEndAsync();
Request.Body.Position = 0;
if (!string.IsNullOrWhiteSpace(raw))
{
try
{
using var document = JsonDocument.Parse(raw);
if (document.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var property in document.RootElement.EnumerateObject())
{
fields[property.Name] = property.Value.ValueKind == JsonValueKind.String
? property.Value.GetString() ?? string.Empty
: property.Value.ToString();
}
}
}
catch (JsonException)
{
fields["raw"] = raw;
}
}
}
return fields;
}
private static string Describe(Dictionary<string, string> fields)
{
var parts = fields.Select(field => field.Key.Contains("secret", StringComparison.OrdinalIgnoreCase)
|| field.Key.Contains("password", StringComparison.OrdinalIgnoreCase)
? $"{field.Key}=<redacted>"
: $"{field.Key}={field.Value}");
return string.Join(", ", parts);
}
}
}
+36
View File
@@ -0,0 +1,36 @@
using RadicadeServer.RecNet.Enums.Players;
namespace RadicadeServer.RecNet.Auth.Connection
{
public class TokenConnectData
{
public string grant_type { get; set; } = null!;
public long account_id { get; set; }
public string ver { get; set; } = null!;
public DeviceClass device_class { get; set; }
public string device_id { get; set; } = null!;
public Platforms platform { get; set; }
public string platform_id { get; set; } = null!;
public string client_id { get; set; } = null!;
public string? username { get; set; }
public string? password { get; set; }
public DateTime? birthday { get; set; }
public DateTime? birthdayUTC { get; set; }
public string? email { get; set; }
public string? avatar { get; set; }
public string? avatarJson { get; set; }
public string? platform_auth { get; set; }
public string? refresh_token { get; set; }
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace RadicadeServer.RecNet.Avatar
{
public class Avatar
{
public string OutfitSelections { get; set; } = null!;
public string HairColor { get; set; } = null!;
public string SkinColor { get; set; } = null!;
public string FaceFeatures { get; set; } = null!;
}
}
+18
View File
@@ -0,0 +1,18 @@
namespace RadicadeServer.RecNet.Avatar
{
public class AvatarItem
{
public string AvatarItemDesc { get; set; } = null!;
public int UnlockedLevel { get; set; }
public int PlatformMask { get; set; } = -1;
public string FriendlyName { get; set; } = string.Empty;
public string Tooltip { get; set; } = string.Empty;
public int Rarity { get; set; }
public int AvatarItemType { get; set; }
public int AvatarItemId { get; set; }
public bool IsBaseAvatarItem { get; set; }
public string? TagList { get; set; }
public DateTime CreatedAt { get; set; }
public string ThumbnailImage { get; set; } = string.Empty;
}
}
+69
View File
@@ -0,0 +1,69 @@
using System.Text;
namespace RadicadeServer.RecNet.Avatar;
public static class AvatarItemDescription
{
public static string Normalize(string? description)
{
if (string.IsNullOrWhiteSpace(description))
{
return description?.Trim() ?? string.Empty;
}
var trimmed = description.Trim();
var segments = trimmed.Split(',');
if (segments[0].Length == 0)
{
return trimmed;
}
var hasEmptySlot = false;
for (var index = 1; index < segments.Length - 1; index++)
{
if (segments[index].Length == 0)
{
hasEmptySlot = true;
break;
}
}
if (!hasEmptySlot)
{
return trimmed;
}
var builder = new StringBuilder(segments[0]).Append(',');
for (var index = 1; index < segments.Length; index++)
{
var material = segments[index].Trim();
if (material.Length == 0)
{
continue;
}
builder.Append(material).Append(',');
}
return builder.ToString();
}
public static bool HasEmptyMaterialSlot(string? description)
{
if (string.IsNullOrWhiteSpace(description))
{
return false;
}
var segments = description.Trim().Split(',');
for (var index = 1; index < segments.Length - 1; index++)
{
if (segments[index].Length == 0)
{
return true;
}
}
return false;
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace RadicadeServer.RecNet.Avatar;
public sealed class AvatarItemV4(AvatarItem item)
{
public int AvatarItemId { get; } = item.AvatarItemId;
public string AvatarItemDesc { get; } = item.AvatarItemDesc;
public int AvatarItemType { get; } = item.AvatarItemType;
public int PlatformMask { get; } = item.PlatformMask;
public string FriendlyName { get; } = item.FriendlyName ?? string.Empty;
public string Tooltip { get; } = item.Tooltip ?? string.Empty;
public int Rarity { get; } = item.Rarity;
public string TagList { get; } = item.TagList ?? string.Empty;
public bool IsBaseAvatarItem { get; } = item.IsBaseAvatarItem;
public string ThumbnailImage { get; } = item.ThumbnailImage ?? string.Empty;
public DateTime CreatedAt { get; } = item.CreatedAt;
}
+256
View File
@@ -0,0 +1,256 @@
using RadicadeServer.Extensions;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace RadicadeServer.RecNet.Avatar;
public static class AvatarUnlockCatalog
{
public static string LoadStarterJson()
{
using var stream = typeof(AvatarUnlockCatalog).Assembly.GetManifestResourceStream("StarterAvatarItems.json")
?? throw new InvalidOperationException("The starter avatar inventory resource is missing.");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
public static List<AvatarItem> GetStarterItems()
{
var starters = EnrichWithMetadata(ParseCatalog(LoadStarterJson()), DBConfig.DefaultAvatarCatalog);
starters.AddRange(ParseCatalog(DBConfig.DefaultAvatarCatalog).Where(item => item.IsBaseAvatarItem));
return KeepClientAvailableItems(KeepResolvableItems(starters))
.DistinctBy(item => item.AvatarItemDesc, StringComparer.OrdinalIgnoreCase).ToList();
}
private static readonly JsonSerializerOptions CatalogOptions = new()
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
NumberHandling = JsonNumberHandling.AllowReadingFromString
};
public static List<AvatarItem> ParseCatalog(string? json)
{
var items = new List<AvatarItem>();
if (string.IsNullOrWhiteSpace(json)) return items;
try
{
using var document = JsonDocument.Parse(json, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
});
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
return items;
}
var index = 0;
foreach (var entry in document.RootElement.EnumerateArray())
{
try
{
var item = entry.Deserialize<AvatarItem>(CatalogOptions);
if (item != null && !string.IsNullOrWhiteSpace(item.AvatarItemDesc))
items.Add(item);
}
catch (JsonException)
{
}
index++;
}
}
catch (JsonException)
{
}
return KeepSupportedItems(items);
}
public static bool NeedsMetadataEnrichment(IEnumerable<AvatarItem>? items)
{
return items != null && items.Any(item =>
item == null || item.AvatarItemId <= 0 || string.IsNullOrWhiteSpace(item.ThumbnailImage));
}
public static List<AvatarItem> EnrichWithMetadata(IEnumerable<AvatarItem> items, string? metadataJson)
{
var metadata = ParseCatalog(metadataJson).ToDictionary(x => x.AvatarItemDesc, StringComparer.OrdinalIgnoreCase);
return items.Select(item => metadata.TryGetValue(item.AvatarItemDesc, out var full)
? new AvatarItem
{
AvatarItemDesc = item.AvatarItemDesc, UnlockedLevel = item.UnlockedLevel,
PlatformMask = item.PlatformMask, FriendlyName = full.FriendlyName,
Tooltip = full.Tooltip, Rarity = full.Rarity, AvatarItemType = full.AvatarItemType,
AvatarItemId = full.AvatarItemId, IsBaseAvatarItem = full.IsBaseAvatarItem,
TagList = full.TagList, CreatedAt = full.CreatedAt, ThumbnailImage = full.ThumbnailImage
}
: item).ToList();
}
private static readonly string[] KnownUnsafeDescriptionParts =
[
"07116e9b-b182-40b4-90c2-99738e7aedd6",
"11b3413f-2c18-41cb-8ef2-55fb825ed476",
"UjSxxUK7z0GGdjvI_NsJ3Q",
"X0nzvhAtgEKqzjQPb9d3qQ",
"6taO0KRM_kuKoaTnOIsHRg",
"KUAMuM41hk-YLZoqTiKncA,LjQ1UKN2xkqJkok40Dxaew",
"5aa7be74-19a9-4dd8-b525-46c5aef34866",
];
private static readonly Lazy<List<AvatarItem>> SafeDefaultItems = new(() =>
DBConfig.SafeDefaultAvatarUnlocks2023.FromJson<List<AvatarItem>>()
?? new List<AvatarItem>());
private static readonly Lazy<HashSet<string>> SafeDefaultDescriptions = new(() =>
SafeDefaultItems.Value
.Where(item => !string.IsNullOrWhiteSpace(item.AvatarItemDesc))
.Select(item => item.AvatarItemDesc)
.ToHashSet(StringComparer.OrdinalIgnoreCase));
public static List<AvatarItem> GetSafeDefaultItems()
{
var catalogJson = string.Equals(DBConfig.DefaultAvatarCatalog, DBConfig.SafeDefaultAvatarUnlocks2023,
StringComparison.Ordinal) ? DBConfig.DefaultAvatarUnlocks : DBConfig.DefaultAvatarCatalog;
var items = ParseCatalog(catalogJson);
if (items.Count == 0)
items = ParseCatalog(DBConfig.DefaultAvatarUnlocks);
var supportedItems = KeepResolvableItems(items);
var candidates = supportedItems.Count > 0
? supportedItems
: SafeDefaultItems.Value.Select(Clone).ToList();
var available = KeepClientAvailableItems(candidates);
return available.Count > 0 ? available : candidates;
}
public static List<AvatarItem> KeepClientAvailableItems(IEnumerable<AvatarItem>? items)
{
if (items == null)
{
return new List<AvatarItem>();
}
var cutoff = DBConfig.AvatarItemReleaseCutoffUtc;
if (cutoff == default)
{
return items.Where(item => item != null).ToList();
}
return items.Where(item => item != null && IsAvailableToClient(item, cutoff)).ToList();
}
public static bool IsAvailableToClient(AvatarItem item)
{
return IsAvailableToClient(item, DBConfig.AvatarItemReleaseCutoffUtc);
}
private static bool IsAvailableToClient(AvatarItem? item, DateTime cutoffUtc)
{
if (item == null || item.CreatedAt == default)
{
return true;
}
var createdAt = item.CreatedAt.Kind == DateTimeKind.Unspecified
? DateTime.SpecifyKind(item.CreatedAt, DateTimeKind.Utc)
: item.CreatedAt.ToUniversalTime();
return createdAt < cutoffUtc;
}
public static List<AvatarItem> KeepSupportedItems(IEnumerable<AvatarItem>? items)
{
if (items == null)
{
return new List<AvatarItem>();
}
return items
.Where(item =>
item != null &&
!string.IsNullOrWhiteSpace(item.AvatarItemDesc) &&
!IsKnownUnsafe(item.AvatarItemDesc))
.Select(Clone)
.ToList();
}
public static List<AvatarItem> KeepResolvableItems(IEnumerable<AvatarItem>? items)
{
var supportedItems = KeepSupportedItems(items);
var resolvable = DBConfig.ResolvableAvatarItemDescs;
if (resolvable == null || resolvable.Count == 0)
{
return supportedItems;
}
return supportedItems.Where(item => IsClientResolvableDescription(item.AvatarItemDesc)).ToList();
}
public static bool IsClientResolvableDescription(string? description)
{
if (string.IsNullOrWhiteSpace(description)) return false;
var resolvable = DBConfig.ResolvableAvatarItemDescs;
return resolvable == null || resolvable.Count == 0 ||
resolvable.Contains(AvatarItemDescription.Normalize(description));
}
public static AvatarItem? FindItem(string? avatarItemDesc, string? catalogueJson = null)
{
if (string.IsNullOrWhiteSpace(avatarItemDesc)) return null;
var wanted = AvatarItemDescription.Normalize(avatarItemDesc);
if (string.IsNullOrWhiteSpace(wanted)) return null;
var json = string.IsNullOrWhiteSpace(catalogueJson) ? DBConfig.DefaultAvatarCatalog : catalogueJson;
return CatalogueByDescription(json).TryGetValue(wanted, out var item) ? Clone(item) : null;
}
private static string? _byDescriptionCacheKey;
private static Dictionary<string, AvatarItem>? _byDescriptionCache;
private static Dictionary<string, AvatarItem> CatalogueByDescription(string? json)
{
if (_byDescriptionCache != null && string.Equals(_byDescriptionCacheKey, json, StringComparison.Ordinal))
return _byDescriptionCache;
var byDescription = new Dictionary<string, AvatarItem>(StringComparer.OrdinalIgnoreCase);
foreach (var item in ParseCatalog(json))
{
if (item == null || string.IsNullOrWhiteSpace(item.AvatarItemDesc)) continue;
if (!byDescription.ContainsKey(item.AvatarItemDesc)) byDescription[item.AvatarItemDesc] = item;
}
_byDescriptionCache = byDescription;
_byDescriptionCacheKey = json;
return byDescription;
}
public static bool IsKnownSafe(string avatarItemDesc)
{
return SafeDefaultDescriptions.Value.Contains(avatarItemDesc);
}
public static bool IsKnownUnsafe(string avatarItemDesc)
{
return KnownUnsafeDescriptionParts.Any(part =>
avatarItemDesc.Contains(part, StringComparison.OrdinalIgnoreCase));
}
private static AvatarItem Clone(AvatarItem item)
{
return new AvatarItem
{
AvatarItemDesc = AvatarItemDescription.Normalize(item.AvatarItemDesc),
UnlockedLevel = item.UnlockedLevel,
PlatformMask = item.PlatformMask,
FriendlyName = item.FriendlyName,
Tooltip = item.Tooltip,
Rarity = item.Rarity,
AvatarItemType = item.AvatarItemType,
AvatarItemId = item.AvatarItemId,
IsBaseAvatarItem = item.IsBaseAvatarItem,
TagList = item.TagList,
CreatedAt = item.CreatedAt,
ThumbnailImage = item.ThumbnailImage
};
}
}
+42
View File
@@ -0,0 +1,42 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.Utils.Attributes;
namespace RadicadeServer.API.Avatars.V1
{
[Route("api/avatar/v1")]
[Route("econ/api/avatar/v1")]
public class AvatarV1 : Controller
{
RecDBContext db;
public AvatarV1(RecDBContext context)
{
this.db = context;
}
[HttpGet("defaultunlocked")]
public List<AvatarItem> GetDefaultUnlocks()
{
return AvatarUnlockCatalog.GetStarterItems();
}
[HttpGet("defaultbaseavataritems")]
public IActionResult GetDefaultBaseAvatarItems()
{
try
{
return Ok(AvatarUnlockCatalog.GetSafeDefaultItems()
.Where(item => item.IsBaseAvatarItem).ToList());
}
catch (Exception)
{
return Ok(Array.Empty<AvatarItem>());
}
}
}
}
+546
View File
@@ -0,0 +1,546 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using RadicadeServer.API.Storefront;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.Utils;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.RecNet.Enums.Store;
using RadicadeServer.RecNet.Progression;
using RadicadeServer.RecNet.Store;
namespace RadicadeServer.API.Avatars.V2;
[Route("api/avatar/v2")]
[Route("econ/api/avatar/v2")]
public class AvatarV2(RecDBContext db) : Controller
{
[HttpGet(""), Authorize]
public async Task<Avatar?> GetLocalAvatar()
{
var userId = User.GetUserId();
if (userId == null) return null;
var avatar = db.Avatars.FirstOrDefault(x => x.AccountId == userId);
if (avatar != null)
{
if (NormalizeSavedAvatar(avatar))
{
await db.SaveChangesAsync();
}
return avatar.DeserializeAvatar();
}
avatar = new RadicadeServer.RecNet.Accounts.Local.PlayerAvatar
{
AccountId = userId.Value,
Avatar = DBConfig.DefaultAvatar,
Outfits = "[]"
};
await db.Avatars.AddAsync(avatar);
await db.SaveChangesAsync();
return avatar.DeserializeAvatar();
}
[HttpGet("{accountId:long}")]
public async Task<Avatar> GetAvatarForAccount(long accountId)
{
var saved = await db.Avatars.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == accountId);
var avatar = saved?.DeserializeAvatar();
if (avatar != null) return avatar;
return DBConfig.DefaultAvatar.FromJson<Avatar>() ?? new Avatar();
}
[HttpPost("set"), Authorize]
public async Task<Avatar?> SetLocalAvatar([FromBody] Avatar newAvatar)
{
var userId = User.GetUserId();
if (userId == null) return null;
var avatar = await db.Avatars.FirstOrDefaultAsync(x => x.AccountId == userId);
if (avatar == null)
{
avatar = new RadicadeServer.RecNet.Accounts.Local.PlayerAvatar
{
AccountId = userId.Value,
Avatar = DBConfig.DefaultAvatar,
Outfits = "[]"
};
await db.Avatars.AddAsync(avatar);
}
avatar.Avatar = newAvatar.ToJson();
await db.SaveChangesAsync();
return newAvatar;
} [HttpGet("gifts"), Authorize]
public async Task<IActionResult> GetGifts()
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var gifts = await StorefrontShared.GetPendingGiftsAsync(db, userId.Value);
return Ok(gifts.Select(g => g.ForClient()).ToList());
}
[HttpPost("gifts/claim"), Authorize]
public async Task<IActionResult> ClaimGifts()
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var requestedIds = await ReadGiftIdsFromBodyAsync();
var gifts = await StorefrontShared.GetPendingGiftsAsync(db, userId.Value);
var toClaim = requestedIds.Count > 0
? gifts.Where(g => requestedIds.Contains(g.GiftDropId)).ToList()
: gifts;
if (toClaim.Count == 0)
{
return GiftClaimResponse(new List<Gift>(), await GetPlayerLevelAsync(userId.Value));
}
var claimed = new List<Gift>();
var levelUpGifts = new List<Gift>();
var balanceGrants = new List<StorefrontShared.BalanceGrant>();
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var tx = await db.Database.BeginTransactionAsync();
try
{
foreach (var gift in toClaim)
{
var grant = await GrantGift(userId.Value, gift);
if (grant != null) balanceGrants.Add(grant);
await StorefrontShared.RemovePendingGiftAsync(db, userId.Value, gift.GiftDropId);
claimed.Add(gift);
}
levelUpGifts.AddRange(await LevelUpGiftService.DeliverLevelUpGiftsAsync(db, userId.Value, recomputeFromXp: true, notify: false));
await tx.CommitAsync();
await AnnounceBalancesAsync(userId.Value, balanceGrants, StorefrontBalanceAddTypes.FromGiftBox);
}
catch
{
await tx.RollbackAsync();
throw;
}
});
foreach (var gift in levelUpGifts)
await StorefrontShared.SendGiftPackageNotificationAsync(userId.Value, gift);
return GiftClaimResponse(claimed, await GetPlayerLevelAsync(userId.Value));
}
[HttpPost("gifts/generate"), Authorize]
public async Task<IActionResult> GenerateGift()
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var fields = await ReadGiftGenerateFieldsAsync();
if (!TryReadGiftContext(fields, "MFPELEJLJLD", "GiftContext", out var giftContext) ||
!TryReadOptionalGiftContext(fields, "CIEHFJEIOJJ", "AlternateGiftContext", out var alternateContext))
return BadRequest(new { error = "invalid_gift_context" });
fields.TryGetValue("Message", out var message);
if (message == null && fields.TryGetValue("DPAJDIEBNKM", out var opaqueMessage))
message = opaqueMessage;
var xp = TryFindField(fields, out var rawXp, "Xp", "XP") && int.TryParse(rawXp, out var parsedXp)
? parsedXp
: 0;
var context = (GiftContext)giftContext;
if (context is GiftContext.All_Weekly_Challenge_Complete or GiftContext.Weekly_Challenge_Complete)
{
var mapId = WeeklyChallenges.CreateMap(DateTime.UtcNow).ChallengeMapId;
var state = await db.WeeklyChallenges.AsNoTracking().SingleOrDefaultAsync(x =>
x.PlayerId == userId.Value && x.ChallengeMapId == mapId);
var pendingWeekly = await StorefrontShared.GetPendingGiftsAsync(db, userId.Value);
var weeklyGift = pendingWeekly.FirstOrDefault(x => x.GiftDropId == state?.RewardGiftDropId);
return weeklyGift == null
? Conflict(new { error = "weekly_reward_not_pending" })
: Ok(weeklyGift.ForClient());
}
void Trace(string line) => RewardTrace.Log(userId.Value, line);
var room = RroRewardPools.RoomForContext(context);
var paysReward = room != null && RroRewardPools.IsRewardActivity(room);
RewardTrace.Log(userId.Value, $"stage=Generate claimed={RewardTrace.Context(context)} " +
$"room={(room ?? "<none>")} isActivityReward={paysReward} xp={xp} message=\"{message}\"");
var gift = await GameRewardRedemption.TakeOutstandingAsync(db, userId.Value, context, message, Trace);
var source = gift == null ? null : "outstanding reward";
if (gift == null)
{
gift = await GameRewardRedemption.TakePendingBoxAsync(db, userId.Value, context, Trace);
if (gift != null) source = "box the selection route already handed over";
}
if (gift == null)
{
gift = await GameRewardRedemption.TakeRecentlyHandedOverAsync(
db, userId.Value, context, TimeSpan.FromMinutes(10), Trace);
if (gift != null) source = "box the server already handed over for this reward";
}
if (gift == null && paysReward)
{
gift = await GameRewardRedemption.BuildRoomRewardAsync(db, userId.Value, context, message, Trace);
if (gift != null)
source = "the activity's own reward, selected by the server";
else
{
var sets = string.Join(", ", RroRewardPools.SetsFor(context)
.Select(x => string.IsNullOrEmpty(x.Colour) ? x.Family : $"{x.Family} {x.Colour}"));
RewardTrace.Error(userId.Value,
"Unable to resolve the reward for this context. " +
$"Activity={room} Context={RewardTrace.Context(context)} ExpectedPool={sets} " +
$"EligibleItems={RroRewardPools.Resolve(context).Count} " +
"Reason=the activity's own pool, its lower ranks and the non-exclusive pool all " +
"resolved to nothing.");
}
}
if (gift == null)
{
RewardTrace.Log(userId.Value, $"stage=Fallback claimed={RewardTrace.Context(context)} " +
"is not an activity this server pays a reward for; generating the ordinary box.");
gift = BuildGeneratedGift(userId.Value, context, message, xp);
source = "generated box (no activity reward)";
}
await StorefrontShared.PersistGiftBoxAsync(db, userId.Value, gift, notify: false);
RewardTrace.Log(userId.Value, $"stage=Grant source={source} box={RewardTrace.Box(gift)}");
return Ok(gift.ForClient());
}
private async Task<Dictionary<string, string>> ReadGiftGenerateFieldsAsync()
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var field in form) fields[field.Key] = field.Value.FirstOrDefault() ?? string.Empty;
return fields;
}
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var raw = (await reader.ReadToEndAsync()).Trim();
Request.Body.Position = 0;
if (raw.Length == 0) return fields;
if (raw.StartsWith('{'))
{
try
{
using var document = JsonDocument.Parse(raw);
foreach (var property in document.RootElement.EnumerateObject())
fields[property.Name] = property.Value.ValueKind == JsonValueKind.String
? property.Value.GetString() ?? string.Empty
: property.Value.ToString();
}
catch (JsonException) { }
}
else
{
foreach (var pair in raw.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var parts = pair.Split('=', 2);
fields[Uri.UnescapeDataString(parts[0].Replace('+', ' '))] =
parts.Length > 1 ? Uri.UnescapeDataString(parts[1].Replace('+', ' ')) : string.Empty;
}
}
return fields;
}
private static bool TryReadGiftContext(IReadOnlyDictionary<string, string> fields, string opaqueName, string friendlyName, out int value)
{
value = 0;
if (!TryFindField(fields, out var raw, opaqueName, friendlyName)) return false;
if (!int.TryParse(raw, out value)) return false;
return Enum.IsDefined(typeof(GiftContext), value) && (GiftContext)value != GiftContext.None;
}
private static bool TryReadOptionalGiftContext(IReadOnlyDictionary<string, string> fields, string opaqueName, string friendlyName, out int? value)
{
value = null;
if (!TryFindField(fields, out var raw, opaqueName, friendlyName) || string.IsNullOrWhiteSpace(raw)) return true;
if (!int.TryParse(raw, out var parsed) || !Enum.IsDefined(typeof(GiftContext), parsed)) return false;
value = parsed;
return true;
}
private static bool TryFindField(IReadOnlyDictionary<string, string> fields, out string raw, params string[] names)
{
foreach (var name in names)
{
if (fields.TryGetValue(name, out var value))
{
raw = value;
return true;
}
}
raw = string.Empty;
return false;
}
private static Gift BuildGeneratedGift(long playerId, GiftContext context, string? message, int xp)
{
int[] tokenAmounts = [10, 25, 50, 100, 250, 500];
return new Gift
{
GiftDropId = StorefrontShared.NewGiftDropId(),
FromPlayerId = playerId,
Tooltip = message ?? string.Empty,
CurrencyType = CurrencyType.RecCenterTokens,
Currency = tokenAmounts[Random.Shared.Next(tokenAmounts.Length)],
Xp = xp,
Context = context,
BalanceType = 0,
Rarity = Rarity.Rare
};
}
[HttpPost("gifts/consume/{giftDropId:long}"), Authorize]
public async Task<IActionResult> ConsumeGift(long giftDropId)
=> await ConsumePlayerGift(giftDropId, await ReadGiftIdsFromBodyAsync());
[HttpPost("gifts/consume"), Authorize]
public async Task<IActionResult> ConsumeGiftBody()
=> await ConsumePlayerGift(null, await ReadGiftIdsFromBodyAsync());
private async Task<List<long>> ReadGiftIdsFromBodyAsync()
{
var ids = new List<long>();
try
{
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var raw = (await reader.ReadToEndAsync()).Trim();
Request.Body.Position = 0;
if (raw.Length == 0) return ids;
if (raw.StartsWith('{'))
{
using var doc = JsonDocument.Parse(raw);
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!string.Equals(prop.Name, "Id", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(prop.Name, "giftDropId", StringComparison.OrdinalIgnoreCase))
continue;
if (TryReadGiftId(prop.Value, out var v)) ids.Add(v);
}
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!string.Equals(prop.Name, "giftDropIds", StringComparison.OrdinalIgnoreCase) ||
prop.Value.ValueKind != JsonValueKind.Array)
continue;
foreach (var el in prop.Value.EnumerateArray())
if (TryReadGiftId(el, out var v)) ids.Add(v);
}
}
else
{
foreach (var pair in raw.Split('&'))
{
var kv = pair.Split('=', 2);
var key = Uri.UnescapeDataString(kv[0]).Trim();
if (!string.Equals(key, "Id", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(key, "giftDropId", StringComparison.OrdinalIgnoreCase))
continue;
var val = kv.Length > 1 ? Uri.UnescapeDataString(kv[1]).Trim() : "";
if (long.TryParse(val, out var v)) ids.Add(v);
}
if (ids.Count == 0 && long.TryParse(raw, out var bare)) ids.Add(bare);
}
}
catch (Exception)
{
}
return ids.Distinct().ToList();
}
private static bool TryReadGiftId(JsonElement el, out long v)
{
if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out v)) return true;
if (el.ValueKind == JsonValueKind.String && long.TryParse(el.GetString(), out v)) return true;
v = 0;
return false;
}
private async Task<IActionResult> ConsumePlayerGift(long? giftDropId, List<long> bodyIds)
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var requestedIds = new List<long>();
if (giftDropId is > 0) requestedIds.Add(giftDropId.Value);
if (bodyIds is { Count: > 0 }) requestedIds.AddRange(bodyIds);
var gifts = await StorefrontShared.GetPendingGiftsAsync(db, userId.Value);
var toConsume = requestedIds.Count > 0
? gifts.Where(g => requestedIds.Contains(g.GiftDropId)).ToList()
: gifts;
if (toConsume.Count == 0)
{
var delivered = requestedIds.Count > 0
? await HandedOverBoxIdsAsync(userId.Value, requestedIds)
: new List<long>();
if (delivered.Count > 0)
{
return GiftClaimResponse(new List<Gift>(), await GetPlayerLevelAsync(userId.Value));
}
return NotFound(new { error = "unable_to_consume_missing_or_used_gift", giftDropId });
}
var consumed = new List<Gift>();
var levelUpGifts = new List<Gift>();
var balanceGrants = new List<StorefrontShared.BalanceGrant>();
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var tx = await db.Database.BeginTransactionAsync();
try
{
foreach (var gift in toConsume)
{
var grant = await GrantGift(userId.Value, gift);
if (grant != null) balanceGrants.Add(grant);
await StorefrontShared.RemovePendingGiftAsync(db, userId.Value, gift.GiftDropId);
consumed.Add(gift);
}
levelUpGifts.AddRange(await LevelUpGiftService.DeliverLevelUpGiftsAsync(db, userId.Value, recomputeFromXp: true, notify: false));
await tx.CommitAsync();
await AnnounceBalancesAsync(userId.Value, balanceGrants, StorefrontBalanceAddTypes.FromGiftBox);
}
catch
{
await tx.RollbackAsync();
throw;
}
});
foreach (var gift in levelUpGifts)
await StorefrontShared.SendGiftPackageNotificationAsync(userId.Value, gift);
if (consumed.Count > 0)
await ProgressionAwardService.AwardXpAsync(db, userId.Value,
consumed.Count * ProgressionAwardService.XpPerGiftBoxOpened, "gift box");
return GiftClaimResponse(consumed, await GetPlayerLevelAsync(userId.Value));
}
private async Task<int> GetPlayerLevelAsync(long playerId)
{
var progression = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == playerId);
return progression?.Level ?? 1;
}
private async Task<List<long>> HandedOverBoxIdsAsync(long playerId, List<long> requestedIds)
{
var data = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == playerId);
var rewards = data?.GetProgressionJson()?.PendingGameRewards;
if (rewards == null) return new List<long>();
return rewards
.Where(r => r.ConsumedGiftDropId != 0 && requestedIds.Contains(r.ConsumedGiftDropId))
.Select(r => r.ConsumedGiftDropId)
.Distinct()
.ToList();
}
private IActionResult GiftClaimResponse(List<Gift> claimed, int unlockedLevel)
{
var body = new Dictionary<string, object?>
{
["consumed"] = claimed.Select(g => g.ForClient()).ToList(),
["UnlockedLevel"] = unlockedLevel
};
var first = claimed.FirstOrDefault();
if (first != null)
{
foreach (var kv in first.ForClient().ToJson().FromJson<Dictionary<string, object?>>() ?? new Dictionary<string, object?>())
body[kv.Key] = kv.Value;
}
return Ok(body);
}
private async Task<StorefrontShared.BalanceGrant?> GrantGift(long playerId, Gift gift)
{
return await StorefrontShared.GrantGiftAsync(db, playerId, gift);
}
private static async Task AnnounceBalancesAsync(long playerId, List<StorefrontShared.BalanceGrant> grants, StorefrontBalanceAddTypes addType)
{
foreach (var grant in grants)
await StorefrontShared.SendBalanceUpdateAsync(playerId, grant.CurrencyType, grant.Balance, grant.Granted, addType);
}
private static bool NormalizeSavedAvatar(RadicadeServer.RecNet.Accounts.Local.PlayerAvatar avatar)
{
if (string.IsNullOrWhiteSpace(avatar.Avatar))
{
avatar.Avatar = DBConfig.DefaultAvatar;
return true;
}
var original = avatar.Avatar;
avatar.Avatar = RepairKnownUnsafeOutfitSelections(avatar.Avatar);
return avatar.Avatar != original;
}
private static string RepairKnownUnsafeOutfitSelections(string avatarJson)
{
const string body = "d0a9262f-5504-46a7-bb10-7507503db58e";
const string accessory = "95e4cc30-cb68-473d-a395-feadf5b51512";
const string repairedOutfitSelection = body + "," + accessory
+ ",0440f08f-ef1d-49d8-942b-523056e8bb45,,1";
string[] shortOutfitSelections =
[
body + ",,,,1",
body + "," + accessory + ",,,1"
];
foreach (var shortOutfitSelection in shortOutfitSelections)
{
avatarJson = avatarJson.Replace(
shortOutfitSelection,
repairedOutfitSelection,
StringComparison.OrdinalIgnoreCase);
}
return avatarJson;
}
}
public class ClaimGiftsRequest
{
public List<long>? GiftDropIds { get; set; }
public long? GiftDropId { get; set; }
}
public class GenerateGiftRequest
{
public long? RecipientPlayerId { get; set; }
public long? RecipientId { get; set; }
public int? PurchasableItemId { get; set; }
public int? ItemId { get; set; }
public long? GiftDropId { get; set; }
public StorefrontTypes? Storefront { get; set; }
public CurrencyType? Currency { get; set; }
public long? Price { get; set; }
public string? Message { get; set; }
}
+50
View File
@@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RadicadeServer.API.Avatars.V2;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Avatar;
namespace RadicadeServer.API.Avatars.V3;
[Route("api/avatar/v3")]
[Route("econ/api/avatar/v3")]
public class AvatarV3(RecDBContext db) : Controller
{
[HttpGet("saved"), Authorize]
public async Task<List<Outfit>> GetSavedOutfits()
{
var userId = User.GetUserId();
if (userId == null) return new List<Outfit>();
return await SavedOutfits.GetAsync(db, userId.Value);
}
[HttpGet("saved/{accountId:long}"), Authorize]
public async Task<List<Outfit>> GetSavedOutfitsForAccount(long accountId)
{
return await SavedOutfits.GetAsync(db, accountId);
}
[HttpPost("saved/set"), Authorize]
public async Task<IActionResult> SetSavedOutfit([FromBody] Outfit? outfit)
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var saved = await SavedOutfits.SaveAsync(db, userId.Value, outfit);
if (saved == null) return BadRequest(new { error = "invalid_slot" });
return Ok(saved);
}
[HttpPost("gifts/generate"), Authorize]
public Task<IActionResult> GenerateGift()
{
var compatibilityController = new AvatarV2(db)
{
ControllerContext = ControllerContext
};
return compatibilityController.GenerateGift();
}
}
+65
View File
@@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.RecNet.Enums.Players;
namespace RadicadeServer.API.Avatars.V4;
[Route("api/avatar/v4")]
[Route("econ/api/avatar/v4")]
public class AvatarV4(RecDBContext db) : Controller
{
[HttpGet("items")]
public async Task<ActionResult<List<AvatarItemV4>>> GetLocalAvatarItems()
{
var userId = User.GetUserId();
List<AvatarItem> defaultItems;
try
{
defaultItems = LockerAccess.HasFullInventory(db, userId)
? AvatarUnlockCatalog.GetSafeDefaultItems()
: AvatarUnlockCatalog.GetStarterItems();
}
catch (Exception)
{
defaultItems = new List<AvatarItem>();
}
var playerItems = new List<AvatarItem>();
if (userId.HasValue)
{
var playerUnlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == userId);
var unlockedItems = playerUnlocks?.GetAvatarItems();
playerItems = User.FindFirst("rn.plat")?.Value == ((int)Platforms.Standalone).ToString()
? AvatarUnlockCatalog.KeepResolvableItems(unlockedItems)
: AvatarUnlockCatalog.KeepSupportedItems(unlockedItems);
}
var items = defaultItems
.Concat(playerItems)
.Where(x => !string.IsNullOrWhiteSpace(x.AvatarItemDesc))
.GroupBy(x => x.AvatarItemDesc, StringComparer.OrdinalIgnoreCase)
.Select(x => x.First())
.ToList();
return Ok(items.Select(item => new AvatarItemV4(item)).ToList());
}
[HttpPost("saved/set"), Authorize]
public async Task<IActionResult> SetSavedOutfit([FromBody] Outfit? outfit)
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var saved = await SavedOutfits.SaveAsync(db, userId.Value, outfit);
if (saved == null) return BadRequest(new { error = "invalid_slot" });
return Ok(new { Success = true, Slot = saved.Slot });
}
}
+27
View File
@@ -0,0 +1,27 @@
using RadicadeServer.RecNet.Enums.Store;
namespace RadicadeServer.RecNet.Avatar
{
public class Consumable
{
public long Id { get; set; }
public string ConsumableItemDesc { get; set; } = null!;
public int Count { get; set; }
public DateTime CreatedAt { get; set; }
public int UnlockedLevel { get; set; }
public int InitialCount { get; set; }
public bool IsActive { get; set; }
public int ActiveDurationMinutes { get; set; }
public ConsumableCategories Category { get; set; }
public int PlatformMask => -1;
}
}
+171
View File
@@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.Linq;
using RadicadeServer.Extensions;
namespace RadicadeServer.RecNet.Avatar
{
public static class ConsumableCatalog
{
private static readonly Dictionary<string, string> FriendlyToToken =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "20% Off Coupon", "50oCPkzd3EerBd7nYNdkCw" },
{ "87 Flavor Cake", "xwQWBB_fekmTqRc2LB92cg" },
{ "Assorted Donuts", "ZuvkidodzkuOfGLDnTOFyg" },
{ "Black & White Camera Film", "frOMH6WxDEG1fBqC4_83vg" },
{ "Bubble Tea", "JxAl1wVJ1UGAjoT3m3ilug" },
{ "Bubbly", "iiGTvhOCHkOTNJhb16Zbyw" },
{ "Bucket Orange Hair Dye", "xr45B5QC4EyKXThtwZto_A" },
{ "Butterfly Blue Hair Dye", "JwJeh15cjkOc9WWaGRibDQ" },
{ "Cake Pops", "Efd3_dy4ZEqWr7i6j5x0aA" },
{ "Candy Apples", "EmPvh3I6L0uK_1i8Wy_ylQ" },
{ "Cauldron Crimson Hair Dye", "3R1bzI35fkChoFFRFbponQ" },
{ "Celebration Cake", "Sk_1sm88ZU2zpWn01Lv7hw" },
{ "Cheese Pizza", "5hIAZ9wg5EyG1cILf4FS2A" },
{ "Chocolate Cake", "BbJb1_0g4EGJP_CeNrjpew" },
{ "Chocolate Frosted Donuts", "mMCGPgK3tki5S_15q2Z81A" },
{ "Chocolate Popcorn", "lWN_g4OcN0S0C4n6cSZq4g" },
{ "Chocolate Variety Box", "K9RbXo-4U0q6NbGu8VL1Sw" },
{ "Circuit Cerulean Hair Dye", "x9ntSHto50GpNWPwYCLUEg" },
{ "Dawn Camera Film", "m0bVLwWGj0GuIBSb6wCk6Q" },
{ "Dracula Film", "oG7CdvW7p0-S8sVe9w5vRw" },
{ "EDM Emerald Hair Dye", "4L3JoaqkAUa1kz98nciPXw" },
{ "Ego Potion", "Tpxqe_lycUelySRHM8B0Vw" },
{ "Flaming Popcorn", "uswFSANaZES0EJMLfOgNSw" },
{ "Frontier Forest Hair Dye", "YaVzcoefhk6zjbNDELLx8A" },
{ "Ghost Beard Film", "1c0Djlp090uBDczEobYNQw" },
{ "Gizmo Grape Hair Dye", "iEtWDLmv7ES87J4NE_TGJQ" },
{ "Glazed Donuts", "7OZ5AE3uuUyqa0P-2W1ptg" },
{ "Goblin Green Hair Dye", "jA3zzS31zEitBxtRCk7oew" },
{ "Goblin King Film", "6SyCoJCgo0Wd6qlPlnMOtg" },
{ "H2Oasis Blue Hair Dye", "P7HixsdkskmzgFMdFJ-WdA" },
{ "Hawaiian Pizza", "_jnjYGBcyEWY5Ub4OezXcA" },
{ "High Five O'Gold", "-hy0qD-iUk-v4NHxNzanmg" },
{ "High Five O'Gunpowder", "YEfbJTnsR0yT_p7e7tb_kQ" },
{ "High Five O'Lasers", "xHOwjwpXd0GDkvBz2VqieA" },
{ "High Five O'Spells", "VQSgL2pTLkWx4B3kwYG7UA" },
{ "High Five O'Whip", "lag2tZyB90W04lQ7ol4vMw" },
{ "Invasion Donuts", "-_VNAj3P10aWCCJZoVTF4Q" },
{ "Jumbotron Film", "Il4VmrnjDkqjmjzddqoIEw" },
{ "Jungle Jade Hair Dye", "5Ugg8k19n0WH8GPFCDWTCQ" },
{ "KO - Bear Claw", "ZtZnYBpKkECJlhHmkj4MiA" },
{ "KO - Bees", "g3kxdlJv5kO8PuBXveM48w" },
{ "KO - Cola", "wx--2TPTdEuGAqLCjs9Qag" },
{ "KO - Fangs", "XOZcxx-Klkyhe-MDbTqiwA" },
{ "KO - FryingPan", "uGVFydNSokCXFAmXu3aceQ" },
{ "KO - Grenade", "EAhk3ZZdXEmH5wRAXXT24Q" },
{ "KO - Ranger", "Av-wvjXvvkmNVSz7ZZnTiA" },
{ "KO - Shield", "5AJin8T2iEG7BzOPOgx2HA" },
{ "KO - Skull Wings", "U38Qe6rhEk6mFvArHfYjng" },
{ "KO - Star Power", "yvqSbK2czkS2sUCRdrGaEw" },
{ "KO - Tire Track", "J1WqFNUWo0OBi4LGKPDHWw" },
{ "KREQ Red Hair Dye", "P1kCuPlRc0-NAg6JUlEH_g" },
{ "Latte", "P15H1ONBhk-5DYYjid1ttg" },
{ "Lounge Lavender Hair Dye", "Hh_O_RejGE-_PubyM8xHaw" },
{ "Maker Mauve Hair Dye", "s2pxukIEt0uDeGVJmZayOw" },
{ "Mousebot Magenta Hair Dye", "vXL0rrAk70SFxJlaJk0uqQ" },
{ "PVPeach Hair Dye", "AQTYAh1it0GIGdgGO5sUHQ" },
{ "Paula's Periwinkle Hair Dye", "UAOH6ccEbEmehJg8XW_D8w" },
{ "Pepperoni Pizza", "mq23W-RSP0G8iGNLdrcpUw" },
{ "Pirate Gold Hair Dye", "o0hG5O_eaUO0R8vPyW-Hvg" },
{ "Popcorn", "QRx0aSTT9keMFdAJMQHdTg" },
{ "Propulsion Purple Hair Dye", "xRtKWR-D40GUW43R0EFpNg" },
{ "Ranger Roy-Al Blue Hair Dye", "8vlVslaWaUyFS-iDHhxW9g" },
{ "Rebel Rose Hair Dye", "STFKahjHJ0SQJPfoDe4S1g" },
{ "Recreational Raspberry Hair Dye", "eJh_BQ5y4UWwiMJzBKcwMQ" },
{ "Red Apples", "uMHrUPLYFk2rJOW_uop5Aw" },
{ "Red Velvet Cake", "LwaotjVEBUir0-w8126n_g" },
{ "Root Beer", "JfnVXFmilU6ysv-VbTAe3A" },
{ "Salted Pretzels", "InQ25wQMGkG_bvuD5rf2Ag" },
{ "Sepia Camera Film", "A5M-yf9tgUihq1uab3v58g" },
{ "Snowman Donuts", "jdrRJ7deWk2P9aRRrPy3zw" },
{ "Sparklers", "hRdK6NGfAkGBmYSs_xGzXg" },
{ "Standee Sandy Hair Dye", "inIuPzhhOEmz1RI8mZtSLg" },
{ "Supreme Pizza", "wUCIKdJSvEmiQHYMyx4X4w" },
{ "Sushi", "4My42w1D1Uu3-98pXB-x_Q" },
{ "VR Cola", "YpL6eN6sKEquOYIYnbhvew" },
{ "Witch Film", "mL3zCEuy2UWZxAV4V-OsMA" },
};
public static string ToClientDesc(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return value ?? string.Empty;
var parts = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return string.Join(",", parts.Select(p =>
FriendlyToToken.TryGetValue(p, out var token) ? token : p));
}
public const int DefaultCount = 5;
private const long GrantedIdBase = 800_000_000;
private const long GrantedIdSpan = 100_000_000;
private static readonly DateTime GrantedAt = new(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static readonly System.Text.Json.JsonSerializerOptions CatalogReadOptions = new()
{
PropertyNameCaseInsensitive = true,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
public static List<ConsumableItem> ParseItems(string? catalogJson)
{
if (string.IsNullOrWhiteSpace(catalogJson)) return new List<ConsumableItem>();
try
{
return catalogJson.FromJson<List<ConsumableItem>>(CatalogReadOptions) ?? new List<ConsumableItem>();
}
catch (System.Text.Json.JsonException)
{
return new List<ConsumableItem>();
}
}
public static List<Consumable> DefaultInstances(string? catalogJson)
{
var catalog = ParseItems(catalogJson);
if (catalog.Count == 0) return new List<Consumable>();
var instances = new List<Consumable>();
var taken = new HashSet<long>();
foreach (var item in catalog)
{
var desc = ToClientDesc(item.ConsumableItemDesc);
if (string.IsNullOrWhiteSpace(desc)) continue;
var id = StableId(desc);
while (!taken.Add(id)) id++;
instances.Add(new Consumable
{
Id = id,
ConsumableItemDesc = desc,
Count = DefaultCount,
InitialCount = DefaultCount,
CreatedAt = GrantedAt,
UnlockedLevel = 0,
IsActive = false,
ActiveDurationMinutes = 0
});
}
return instances;
}
private static long StableId(string token)
{
uint hash = 2166136261;
foreach (var c in token)
{
hash ^= c;
hash *= 16777619;
}
return GrantedIdBase + (hash % GrantedIdSpan);
}
}
}
+19
View File
@@ -0,0 +1,19 @@
using RadicadeServer.RecNet.Enums.Store;
namespace RadicadeServer.RecNet.Avatar
{
public class ConsumableItem
{
public string ConsumableItemDesc { get; set; } = "";
public string FriendlyName { get; set; } = "";
public string Tooltip { get; set; } = "";
public Rarity Rarity { get; set; } = Rarity.Common;
}
}
+428
View File
@@ -0,0 +1,428 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.IO;
using System.Text.Json;
using RadicadeServer.API.Notifications;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.API.Storefront;
using RadicadeServer.RecNet.Enums.Store;
using RadicadeServer.RecNet.Responses;
namespace RadicadeServer.API.Avatars.V2
{
[Route("api/consumables/v2")]
[Route("econ/api/consumables/v2")]
[Route("api/consumables/v1")]
[Route("econ/api/consumables/v1")]
public class ConsumablesV2 : Controller
{
RecDBContext db;
public ConsumablesV2(RecDBContext context)
{
this.db = context;
}
[HttpGet("getUnlocked"), Authorize]
public async Task<List<BulkConsumableResponse>?> GetUnlockedConsumables()
{
var userId = User.GetUserId();
var consumables = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == userId);
var owned = consumables?.GetConsumables() ?? new List<Consumable>();
var merged = new Dictionary<string, BulkConsumableResponse>(StringComparer.OrdinalIgnoreCase);
if (LockerAccess.HasFullInventory(db, userId))
{
foreach (var item in ConsumableCatalog.DefaultInstances(DBConfig.DefaultConsumableItems))
Add(merged, item);
foreach (var item in owned)
merged.Remove(ConsumableCatalog.ToClientDesc(item.ConsumableItemDesc));
}
foreach (var instance in owned)
Add(merged, instance);
return merged.Values.ToList();
}
/// <summary>
/// The free gift button's list: the consumables that may be handed to the named player.
/// The client asks for this before it opens the box, so a 404 leaves the button sitting on
/// "Processing" with nothing behind it and the screen never resolves. The body is the same
/// bulk consumable the unlocked list serves - only IsTransferable differs, because that is
/// the member the button filters on - and it answers even for an account that owns nothing,
/// which is what keeps the box offered to a player who has never opened one.
/// </summary>
[HttpGet("getTransferable/{playerId:long}"), Authorize]
[HttpGet("getTransferable"), Authorize]
public async Task<List<BulkConsumableResponse>?> GetTransferable(long playerId = 0)
{
var owner = playerId > 0 ? playerId : User.GetUserId() ?? 0;
var merged = new Dictionary<string, BulkConsumableResponse>(StringComparer.OrdinalIgnoreCase);
foreach (var item in ConsumableCatalog.DefaultInstances(DBConfig.DefaultConsumableItems))
Add(merged, item);
if (owner > 0)
{
var unlocks = await db.Unlocks.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == owner);
var owned = unlocks?.GetConsumables() ?? new List<Consumable>();
foreach (var item in owned)
merged.Remove(ConsumableCatalog.ToClientDesc(item.ConsumableItemDesc));
foreach (var item in owned)
Add(merged, item);
}
var transferable = merged.Values.ToList();
foreach (var entry in transferable)
entry.IsTransferable = true;
return transferable;
}
/// <summary>
/// Hands one of the caller's consumables to another player - the half of the free gift
/// button that getTransferable only sets up. The client posts the entry it showed and then
/// waits for this answer; with no route the request 404s and the button stays on
/// "Processing" with the gift already looking sent. One unit leaves the sender's stack (the
/// entry is dropped when that was the last of it) and reaches the recipient as a gift
/// package, the same box the friend box arrives in, so it appears on their watch without a
/// relog. The answer is the two obfuscated members the client actually reads, not a
/// PascalCase body it would treat as a failure.
/// </summary>
[HttpPost("transfer"), Authorize]
public async Task<IActionResult> TransferConsumable()
{
var senderId = User.GetUserId();
if (senderId == null) return Unauthorized();
var fields = await ReadBodyFieldsAsync();
var toPlayerId = PickLong(fields, "toPlayerId", "playerId", "playerIdTo", "recipientId", "targetPlayerId", "accountId");
if (toPlayerId is not > 0)
return Ok(ClientAction.Failure("No player was named to receive the item."));
if (toPlayerId == senderId.Value)
return Ok(ClientAction.Failure("You cannot hand a consumable to yourself."));
if (!await db.Players.AsNoTracking().AnyAsync(player => player.AccountId == toPlayerId.Value))
return Ok(ClientAction.Failure($"Player {toPlayerId.Value} does not exist."));
var sender = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == senderId.Value);
var owned = sender?.GetConsumables() ?? new List<Consumable>();
var target = MatchConsumable(owned, fields);
if (target == null || target.Count <= 0)
return Ok(ClientAction.Failure("You do not own that consumable."));
var message = Pick(fields, "message", "tooltip", "note");
var box = new Gift
{
FriendlyName = target.ConsumableItemDesc,
Tooltip = string.IsNullOrWhiteSpace(message) ? "A gift for you <3" : message.Trim(),
FromPlayerId = senderId.Value,
ConsumableItemDesc = ConsumableCatalog.ToClientDesc(target.ConsumableItemDesc),
Context = GiftContext.Consumable
};
if (target.InitialCount > 1)
{
target.InitialCount -= 1;
target.Count = target.InitialCount;
}
else
{
owned.Remove(target);
}
sender!.SetConsumables(owned);
await StorefrontShared.PersistGiftBoxAsync(db, toPlayerId.Value, box, notify: true);
await Notify.SendAccountUpdate(senderId.Value);
return Ok(ClientAction.Ok());
}
/// <summary>
/// Marks which of the player's consumables is the active one. The client asks for this as it
/// enters a room, once per entry it wants switched on, and a 404 leaves the effect that the
/// consumable bought (a hair dye, a rank-up potion) unapplied. Only the named entry is turned
/// on, and turning one on is what turns the others off, so a second activation never leaves
/// two effects claiming the same slot. The answer has no body, which is what the route's own
/// wrapper returns.
/// </summary>
[HttpPost("updateActive"), Authorize]
public async Task<IActionResult> UpdateActive()
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var fields = await ReadBodyFieldsAsync();
var activates = PickBool(fields, "isActive", "active", "activate", "enabled") ?? true;
var unlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == userId.Value);
if (unlocks == null) return Ok();
var consumables = unlocks.GetConsumables();
var target = MatchConsumable(consumables, fields);
if (target == null) return Ok();
if (activates)
{
var targetDesc = ConsumableCatalog.ToClientDesc(target.ConsumableItemDesc);
foreach (var other in consumables)
{
if (!string.Equals(ConsumableCatalog.ToClientDesc(other.ConsumableItemDesc), targetDesc,
StringComparison.OrdinalIgnoreCase))
other.IsActive = false;
}
}
target.IsActive = activates;
unlocks.SetConsumables(consumables);
await db.SaveChangesAsync();
return Ok();
}
/// <summary>
/// The entry the request names, by the id the unlocked list handed out and - when the client
/// only echoed the description back - by its client token, alone or as one member of a list.
/// </summary>
private static Consumable? MatchConsumable(List<Consumable> owned, Dictionary<string, string> fields)
{
var id = PickLong(fields, "id", "consumableItemId", "consumableId", "itemId", "consumableMappingId");
if (id is > 0)
{
var byId = owned.FirstOrDefault(c => c.Id == id.Value);
if (byId != null) return byId;
}
var desc = Pick(fields, "consumableItemDesc", "desc", "descriptions", "consumableItem", "itemDesc");
if (string.IsNullOrWhiteSpace(desc)) return null;
var wanted = ConsumableCatalog.ToClientDesc(desc);
var candidates = wanted
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(part => !string.IsNullOrWhiteSpace(part))
.ToList();
if (candidates.Count == 0) candidates.Add(wanted);
foreach (var candidate in candidates)
{
var byDesc = owned.FirstOrDefault(c =>
string.Equals(ConsumableCatalog.ToClientDesc(c.ConsumableItemDesc), candidate, StringComparison.OrdinalIgnoreCase)
|| string.Equals(c.ConsumableItemDesc, candidate, StringComparison.OrdinalIgnoreCase));
if (byDesc != null) return byDesc;
}
return null;
}
/// <summary>
/// The request's members as a flat map. The body is a serialized request DTO whose members
/// are not published, and the entry the client names sits one level down in a nested
/// consumable, so every leaf is folded in by name instead of binding to a guessed shape; a
/// form-encoded body is read the same way.
/// </summary>
private async Task<Dictionary<string, string>> ReadBodyFieldsAsync()
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var raw = (await reader.ReadToEndAsync()).Trim();
if (Request.Body.CanSeek) Request.Body.Position = 0;
if (raw.Length == 0) return fields;
if (raw.StartsWith('{') || raw.StartsWith('['))
{
using var document = JsonDocument.Parse(raw);
Flatten(document.RootElement, fields);
return fields;
}
foreach (var pair in raw.Split('&'))
{
var kv = pair.Split(new[] { '=' }, 2);
if (kv.Length < 2) continue;
fields[Uri.UnescapeDataString(kv[0])] = Uri.UnescapeDataString(kv[1]);
}
}
catch (Exception)
{
}
return fields;
}
private static void Flatten(JsonElement element, Dictionary<string, string> into)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
foreach (var property in element.EnumerateObject())
{
if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
Flatten(property.Value, into);
else
into[property.Name] = property.Value.ValueKind == JsonValueKind.String
? property.Value.GetString() ?? string.Empty
: property.Value.ToString();
}
break;
case JsonValueKind.Array:
foreach (var item in element.EnumerateArray()) Flatten(item, into);
break;
}
}
private static string Pick(Dictionary<string, string> fields, params string[] names)
{
foreach (var name in names)
if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value))
return value.Trim();
return string.Empty;
}
private static long? PickLong(Dictionary<string, string> fields, params string[] names)
{
var raw = Pick(fields, names);
return long.TryParse(raw, out var parsed) ? parsed : null;
}
private static bool? PickBool(Dictionary<string, string> fields, params string[] names)
{
var raw = Pick(fields, names);
if (raw.Length == 0) return null;
return bool.TryParse(raw, out var parsed) ? parsed : null;
}
public class ConsumeRequest
{
public long Id { get; set; }
public int DeltaCount { get; set; } = 1;
}
[HttpPost("consume"), Authorize]
public async Task<IActionResult> ConsumeConsumable()
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var dto = await ReadConsumeRequestAsync();
if (dto == null || dto.Id <= 0) return Ok();
if (dto.DeltaCount <= 0) dto.DeltaCount = 1;
var unlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == userId.Value);
if (unlocks == null)
{
unlocks = new PlayerUnlocks { AccountId = userId.Value };
await db.Unlocks.AddAsync(unlocks);
}
var consumables = unlocks.GetConsumables();
var target = consumables.FirstOrDefault(c => c.Id == dto.Id);
if (target == null)
{
return Ok();
}
if (target.InitialCount > dto.DeltaCount)
{
target.InitialCount -= dto.DeltaCount;
target.Count = target.InitialCount;
}
else
{
consumables.Remove(target);
}
unlocks.SetConsumables(consumables);
await db.SaveChangesAsync();
return Ok();
}
private async Task<ConsumeRequest?> ReadConsumeRequestAsync()
{
try
{
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var raw = (await reader.ReadToEndAsync()).Trim();
Request.Body.Position = 0;
if (raw.Length == 0) return null;
var dto = new ConsumeRequest();
if (raw.StartsWith('{'))
{
using var doc = JsonDocument.Parse(raw);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (prop.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) && long.TryParse(prop.Value.GetRawText(), out var id))
dto.Id = id;
if (prop.Name.Equals("DeltaCount", StringComparison.OrdinalIgnoreCase) && int.TryParse(prop.Value.GetRawText(), out var delta))
dto.DeltaCount = delta;
}
return dto;
}
foreach (var pair in raw.Split('&'))
{
var kv = pair.Split(new[] { '=' }, 2);
if (kv.Length < 2) continue;
var key = Uri.UnescapeDataString(kv[0]);
var value = Uri.UnescapeDataString(kv[1]);
if (key.Equals("Id", StringComparison.OrdinalIgnoreCase) && long.TryParse(value, out var id))
dto.Id = id;
if (key.Equals("DeltaCount", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var delta))
dto.DeltaCount = delta;
}
return dto;
}
catch (Exception)
{
return null;
}
}
private static void Add(Dictionary<string, BulkConsumableResponse> merged, Consumable instance)
{
var desc = ConsumableCatalog.ToClientDesc(instance.ConsumableItemDesc);
if (string.IsNullOrWhiteSpace(desc)) return;
if (!merged.TryGetValue(desc, out var entry))
{
entry = new BulkConsumableResponse { ConsumableItemDesc = desc };
merged[desc] = entry;
}
entry.Ids.Add(instance.Id);
entry.CreatedAts.Add(instance.CreatedAt);
entry.Count += instance.Count;
entry.InitialCount += instance.InitialCount;
entry.IsActive |= instance.IsActive;
entry.ActiveDurationMinutes = Math.Max(
entry.ActiveDurationMinutes ?? 0, instance.ActiveDurationMinutes);
}
public class BulkConsumableResponse
{
public List<long> Ids { get; set; } = new List<long>();
public List<DateTime> CreatedAts { get; set; } = new List<DateTime>();
public string ConsumableItemDesc { get; set; } = "";
public int Count { get; set; }
public int InitialCount { get; set; }
public bool IsActive { get; set; }
public int? ActiveDurationMinutes { get; set; }
public bool IsTransferable { get; set; }
}
}
}
+16
View File
@@ -0,0 +1,16 @@
using RadicadeServer.RecNet.Enums.Store;
namespace RadicadeServer.RecNet.Avatar
{
public class Equipment
{
public string PrefabName { get; set; } = null!;
public string ModificationGuid { get; set; } = null!;
public int UnlockedLevel { get; set; }
public bool Favorited { get; set; }
public int PlatformMask => -1;
public string FriendlyName { get; set; } = null!;
public string Tooltip { get; set; } = null!;
public Rarity Rarity { get; set; }
}
}
+19
View File
@@ -0,0 +1,19 @@
using RadicadeServer.RecNet.Enums.Store;
namespace RadicadeServer.RecNet.Avatar
{
public class EquipmentItem
{
public string EquipmentPrefabName { get; set; } = "";
public string FriendlyName { get; set; } = "";
public string Tooltip { get; set; } = "";
public Rarity Rarity { get; set; } = Rarity.Common;
}
}
+123
View File
@@ -0,0 +1,123 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Enums.Store;
namespace RadicadeServer.RecNet.Avatar
{
public class EquipmentSkinItem
{
[JsonPropertyName("prefab_name")]
public string PrefabName { get; set; } = "";
[JsonPropertyName("modification_guid")]
public string ModificationGuid { get; set; } = "";
[JsonPropertyName("display_name")]
public string DisplayName { get; set; } = "";
[JsonPropertyName("tooltip")]
public string Tooltip { get; set; } = "";
[JsonPropertyName("rarity")]
public Rarity Rarity { get; set; } = Rarity.Common;
[JsonPropertyName("storefront")]
public string Storefront { get; set; } = "";
[JsonPropertyName("price")]
public long Price { get; set; }
[JsonPropertyName("image_name")]
public string ImageName { get; set; } = "";
}
public class EquipmentSkinFile
{
[JsonPropertyName("items")]
public List<EquipmentSkinItem> Items { get; set; } = new();
}
public static class EquipmentSkinCatalog
{
private static readonly JsonSerializerOptions ReadOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public static List<EquipmentSkinItem> Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new List<EquipmentSkinItem>();
try
{
var file = JsonSerializer.Deserialize<EquipmentSkinFile>(json, ReadOptions);
return (file?.Items ?? new List<EquipmentSkinItem>())
.Where(item => item != null
&& !string.IsNullOrWhiteSpace(item.PrefabName)
&& !string.IsNullOrWhiteSpace(item.ModificationGuid))
.ToList();
}
catch (JsonException)
{
return new List<EquipmentSkinItem>();
}
}
public static List<Equipment> ToEquipment(IEnumerable<EquipmentSkinItem> skins)
{
return skins.Select(skin => new Equipment
{
PrefabName = skin.PrefabName.Trim(),
ModificationGuid = skin.ModificationGuid.Trim(),
UnlockedLevel = 0,
Favorited = false,
FriendlyName = string.IsNullOrWhiteSpace(skin.DisplayName)
? skin.PrefabName.Trim()
: skin.DisplayName.Trim(),
Tooltip = skin.Tooltip ?? "",
Rarity = skin.Rarity
}).ToList();
}
public static string MergeInto(string? equipmentJson, string? skinsJson)
{
var skins = ToEquipment(Parse(skinsJson));
if (skins.Count == 0) return equipmentJson ?? "[]";
var equipment = ParseEquipment(equipmentJson);
if (equipment == null) return equipmentJson ?? "[]";
var known = new HashSet<string>(
equipment.Select(Key), StringComparer.OrdinalIgnoreCase);
var added = 0;
foreach (var skin in skins)
{
if (!known.Add(Key(skin))) continue;
equipment.Add(skin);
added++;
}
return added == 0 ? equipmentJson ?? "[]" : equipment.ToJson();
}
private static List<Equipment>? ParseEquipment(string? equipmentJson)
{
if (string.IsNullOrWhiteSpace(equipmentJson)) return new List<Equipment>();
try
{
return equipmentJson.FromJson<List<Equipment>>(ReadOptions) ?? new List<Equipment>();
}
catch (JsonException)
{
return null;
}
}
private static string Key(Equipment item) =>
$"{(item.PrefabName ?? "").Trim()}\u001f{(item.ModificationGuid ?? "").Trim()}";
}
}
+168
View File
@@ -0,0 +1,168 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Avatar;
namespace RadicadeServer.API.Avatars.V1;
[Route("api/equipment/v1")]
[Route("econ/api/equipment/v1")]
[Authorize]
public class EquipmentV1(RecDBContext db) : Controller
{
[HttpPost("update")]
[HttpPut("update")]
public async Task<IActionResult> UpdateEquipment([FromBody] JsonElement body)
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
if (!TryParse(body, out var entries))
return BadRequest();
var slots = entries.Where(entry => entry.HasSlot).ToList();
var favourites = entries.Where(entry => entry.Favorited.HasValue).ToList();
if (slots.Count == 0 && favourites.Count == 0)
return Ok();
if (slots.Count > 0)
{
var avatar = await db.Avatars.FirstOrDefaultAsync(x => x.AccountId == userId);
if (avatar != null)
{
avatar.EquipmentSlots = slots.Select(entry => (object)new
{
slotIndex = entry.SlotIndex,
equipmentPrefabName = entry.PrefabName ?? string.Empty,
equipmentModificationGuid = entry.ModificationGuid ?? string.Empty
}).ToList().ToJson();
}
}
if (favourites.Count > 0)
await ApplyFavouritesAsync(userId.Value, favourites);
await db.SaveChangesAsync();
return Ok();
}
private async Task ApplyFavouritesAsync(long userId, List<EquipmentUpdate> favourites)
{
var unlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == userId);
if (unlocks == null)
{
unlocks = new PlayerUnlocks { AccountId = userId };
db.Unlocks.Add(unlocks);
}
List<Equipment> equipment;
try
{
equipment = unlocks.GetEquipment() ?? new List<Equipment>();
}
catch (JsonException)
{
equipment = new List<Equipment>();
}
foreach (var update in favourites)
{
if (string.IsNullOrWhiteSpace(update.ModificationGuid)) continue;
var row = equipment.FirstOrDefault(item => string.Equals(
item.ModificationGuid, update.ModificationGuid, StringComparison.OrdinalIgnoreCase));
if (row == null)
{
continue;
}
row.Favorited = update.Favorited == true;
}
unlocks.Equipment = equipment.ToJson();
}
private sealed class EquipmentUpdate
{
public string? PrefabName { get; set; }
public string? ModificationGuid { get; set; }
public bool? Favorited { get; set; }
public int SlotIndex { get; set; }
public bool HasSlot { get; set; }
}
private static bool TryParse(JsonElement body, out List<EquipmentUpdate> entries)
{
entries = new List<EquipmentUpdate>();
JsonElement array;
if (body.ValueKind == JsonValueKind.Array)
{
array = body;
}
else if (body.ValueKind == JsonValueKind.Object)
{
var found = false;
array = default;
foreach (var prop in body.EnumerateObject())
{
if (string.Equals(prop.Name, "equipmentSlots", StringComparison.OrdinalIgnoreCase)
&& prop.Value.ValueKind == JsonValueKind.Array)
{
array = prop.Value;
found = true;
break;
}
}
if (!found) return false;
}
else
{
return false;
}
foreach (var item in array.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object) continue;
var entry = new EquipmentUpdate();
foreach (var prop in item.EnumerateObject())
{
if (string.Equals(prop.Name, "slotIndex", StringComparison.OrdinalIgnoreCase)
|| string.Equals(prop.Name, "slot", StringComparison.OrdinalIgnoreCase)
|| string.Equals(prop.Name, "index", StringComparison.OrdinalIgnoreCase))
{
if (prop.Value.ValueKind == JsonValueKind.Number) entry.SlotIndex = prop.Value.GetInt32();
entry.HasSlot = true;
}
else if (string.Equals(prop.Name, "favorited", StringComparison.OrdinalIgnoreCase)
|| string.Equals(prop.Name, "favourite", StringComparison.OrdinalIgnoreCase))
{
if (prop.Value.ValueKind == JsonValueKind.True) entry.Favorited = true;
else if (prop.Value.ValueKind == JsonValueKind.False) entry.Favorited = false;
}
else if (string.Equals(prop.Name, "modificationGuid", StringComparison.OrdinalIgnoreCase)
|| string.Equals(prop.Name, "equipmentModificationGuid", StringComparison.OrdinalIgnoreCase))
{
if (prop.Value.ValueKind == JsonValueKind.String) entry.ModificationGuid = prop.Value.GetString();
}
else if (string.Equals(prop.Name, "prefabName", StringComparison.OrdinalIgnoreCase)
|| string.Equals(prop.Name, "equipmentPrefabName", StringComparison.OrdinalIgnoreCase)
|| string.Equals(prop.Name, "equipment", StringComparison.OrdinalIgnoreCase))
{
if (prop.Value.ValueKind == JsonValueKind.String) entry.PrefabName = prop.Value.GetString();
}
}
entries.Add(entry);
}
return true;
}
}
+83
View File
@@ -0,0 +1,83 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Avatar;
namespace RadicadeServer.API.Avatars.V2
{
[Route("api/equipment/v2")]
[Route("econ/api/equipment/v2")]
public class EquipmentV2 : Controller
{
RecDBContext db;
public EquipmentV2(RecDBContext context)
{
this.db = context;
}
[HttpGet("getUnlocked"), Authorize]
public List<Equipment> GetUnlockedEquipment()
{
var userId = User.GetUserId();
var items = db.Unlocks.FirstOrDefault(x => x.AccountId == userId);
var defaultEquipment = TryReadEquipment(LockerAccess.HasFullInventory(db, userId)
? EquipmentSkinCatalog.MergeInto(DBConfig.DefaultEquipmentItems, DBConfig.DefaultEquipmentSkinItems)
: DBConfig.DefaultEquipmentUnlocks);
var playerEquipment = GetPlayerEquipment(items);
return MergeEquipment(defaultEquipment, playerEquipment);
}
private static List<Equipment> TryReadEquipment(string json)
{
try
{
return (json.FromJson<List<Equipment>>() ?? new List<Equipment>())
.Where(x => !string.IsNullOrWhiteSpace(x.PrefabName))
.Select(NormalizeEquipment)
.ToList();
}
catch
{
return new List<Equipment>();
}
}
private static List<Equipment> GetPlayerEquipment(PlayerUnlocks? items)
{
try
{
return items?.GetEquipment() ?? new List<Equipment>();
}
catch
{
return new List<Equipment>();
}
}
private static List<Equipment> MergeEquipment(IEnumerable<Equipment> defaults, IEnumerable<Equipment> playerEquipment)
{
var merged = new Dictionary<string, Equipment>();
foreach (var item in defaults.Concat(playerEquipment).Select(NormalizeEquipment))
{
if (string.IsNullOrWhiteSpace(item.PrefabName)) continue;
merged[$"{item.PrefabName}\u001f{item.ModificationGuid}"] = item;
}
return merged.Values.ToList();
}
private static Equipment NormalizeEquipment(Equipment item)
{
item.PrefabName ??= "";
item.ModificationGuid ??= "";
item.FriendlyName = string.IsNullOrWhiteSpace(item.FriendlyName) ? item.PrefabName : item.FriendlyName;
item.Tooltip ??= "";
return item;
}
}
}
+93
View File
@@ -0,0 +1,93 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Newtonsoft.Json;
using RadicadeServer.RecNet.Enums.Players.Avatar;
using RadicadeServer.RecNet.Enums.Store;
using RadicadeServer.Radicade.Gifts.Enums;
namespace RadicadeServer.RecNet.Avatar
{
public class Gift
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long GiftDropId { get; set; }
public long ItemSetId { get; set; } = 0;
public string ItemSetFriendlyName { get; set; } = string.Empty;
public string FriendlyName { get; set; } = string.Empty;
public string Tooltip { get; set; } = string.Empty;
public bool IsQuery { get; set; } = false;
public bool Unique { get; set; } = false;
public bool SubscribersOnly { get; set; } = false;
public string ConsumableItemDesc { get; set; } = string.Empty;
public string AvatarItemDesc { get; set; } = string.Empty;
public AvatarItemType AvatarItemType { get; set; }
public string EquipmentPrefabName { get; set; } = string.Empty;
public string EquipmentModificationGuid { get; set; } = string.Empty;
public CurrencyType CurrencyType { get; set; } = CurrencyType.RecCenterTokens;
public int Currency { get; set; } = 0;
public int Xp { get; set; } = 0;
public int Platform { get; set; } = 0;
public int PlatformsToSpawnOn { get; set; } = 0;
public long FromPlayerId { get; set; } = 0;
public int? BalanceType { get; set; } = null;
public RadicadeServer.RecNet.Enums.Store.GiftContext Context { get; set; } = RadicadeServer.RecNet.Enums.Store.GiftContext.Commerce_Purchase;
public Rarity Rarity { get; set; } = Rarity.Common;
[JsonIgnore] public bool IsFeatured { get; set; }
[JsonIgnore] public ItemAccessibility Accessibility { get; set; } = ItemAccessibility.Purchasable;
[System.Text.Json.Serialization.JsonPropertyName("Id")]
[Newtonsoft.Json.JsonProperty("Id")]
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
public long Id { get => GiftDropId; set => GiftDropId = value; }
[System.Text.Json.Serialization.JsonPropertyName("Message")]
[Newtonsoft.Json.JsonProperty("Message")]
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
public string Message { get => Tooltip; set => Tooltip = value ?? string.Empty; }
[System.Text.Json.Serialization.JsonPropertyName("GiftRarity")]
[Newtonsoft.Json.JsonProperty("GiftRarity")]
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
public int GiftRarity { get => (int)Rarity; set => Rarity = (Rarity)value; }
[System.Text.Json.Serialization.JsonPropertyName("GiftContext")]
[Newtonsoft.Json.JsonProperty("GiftContext")]
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
public int GiftContext
{
get => (int)Context;
set => Context = (RadicadeServer.RecNet.Enums.Store.GiftContext)value;
}
[System.Text.Json.Serialization.JsonPropertyName("GiftPackageId")]
[Newtonsoft.Json.JsonProperty("GiftPackageId")]
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
public long GiftPackageId { get => GiftDropId; set => GiftDropId = value; }
public Gift Clone() => (Gift)MemberwiseClone();
public Gift ForClient()
{
var copy = (Gift)MemberwiseClone();
copy.ConsumableItemDesc = ConsumableCatalog.ToClientDesc(ConsumableItemDesc);
copy.Platform = -1;
copy.PlatformsToSpawnOn = -1;
return copy;
}
}
}

Some files were not shown because too many files have changed in this diff Show More