mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ad6735e93 | |||
| a07e9f41ea | |||
| a23284ce56 | |||
| 926a5c9bba | |||
| 8ec79ad06c | |||
| b55627a10a | |||
| af29fc3e8e | |||
| 4cdc5ee6d0 | |||
| 1e0015c18e | |||
| e5fedb7376 | |||
| 704abe8df4 | |||
| ab435c3f45 | |||
| 7617ca0367 | |||
| fbfabaf26e | |||
| 7da20f2047 | |||
| 50e03af296 | |||
| a1a29bb64d | |||
| ac1dcf3ad7 | |||
| 273352db08 | |||
| 159ca2c271 | |||
| 5af3fa8fc5 | |||
| c9ed2df278 | |||
| 80f0691516 | |||
| 9d33b1f4d6 | |||
| 44fc9047e0 | |||
| 98fa7b89cf | |||
| ff55baa0c5 | |||
| 312ecbbdc8 | |||
| 1d4777e3da | |||
| 38fbd0faf1 | |||
| 99d3faec5b | |||
| 02f7bc386d | |||
| 27e5e0064c | |||
| 4034449a6d | |||
| 142f52569a | |||
| df73507ee9 | |||
| 70980bf3b6 | |||
| 157cc457a9 | |||
| fa7486915b | |||
| 1794d4e777 | |||
| 8426091e91 | |||
| fa7dac9ec1 | |||
| 67970e5d23 | |||
| fc5b116263 | |||
| e2ec948c74 | |||
| c6a4357c6f | |||
| 6704505dae | |||
| 269a600869 | |||
| b3a06ce5e8 | |||
| 1b0a9a62b4 | |||
| babfd2833f | |||
| 78ff4481db | |||
| dccefb0158 | |||
| 49cf91f77d | |||
| 46e2d879cd | |||
| 26d183af7d | |||
| 94bad2912b | |||
| 44f53e80b8 | |||
| bd4ceada65 | |||
| 86be2d004f | |||
| 1b9afcd0c6 | |||
| 4f41253cdc |
@@ -0,0 +1,78 @@
|
||||
# Disabled template. GitHub ignores files outside .github/workflows/.
|
||||
# Move this file to .github/workflows/endpoint-contract.yml when it should run.
|
||||
name: Endpoint Contract
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
endpoint-contract:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
ASPNETCORE_ENVIRONMENT: Development
|
||||
ASPNETCORE_URLS: http://127.0.0.1:18080
|
||||
Database__Provider: sqlite
|
||||
Database__SqlitePath: ${{ runner.temp }}/dorknet-endpoint-contract.db
|
||||
DORKNET_JWT_SECRET: endpoint-contract-secret-000000000000000000000000000000000000000000
|
||||
S3__Endpoint: ""
|
||||
S3__AccessKey: ""
|
||||
S3__SecretKey: ""
|
||||
PROTOBUF_PROTOC: /usr/bin/protoc
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Install protobuf compiler
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y protobuf-compiler
|
||||
|
||||
- name: Build server
|
||||
run: dotnet build DorkNet.Server/DorkNet.Server.csproj
|
||||
|
||||
- name: Build endpoint contract runner
|
||||
run: dotnet build tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-restore
|
||||
|
||||
- name: Start server
|
||||
run: |
|
||||
dotnet DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
|
||||
echo "$!" > "$RUNNER_TEMP/dorknet-server.pid"
|
||||
|
||||
- name: Wait for server readiness
|
||||
run: |
|
||||
for attempt in {1..60}; do
|
||||
if curl --fail --silent http://127.0.0.1:18080/healthz; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
cat "$RUNNER_TEMP/dorknet-server.log"
|
||||
exit 1
|
||||
|
||||
- name: Run endpoint contract
|
||||
run: |
|
||||
dotnet run --project tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-build -- \
|
||||
--server-assembly DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll \
|
||||
--base-url http://127.0.0.1:18080 \
|
||||
--report "$RUNNER_TEMP/endpoint-contract-report.json"
|
||||
|
||||
- name: Upload endpoint contract artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: endpoint-contract
|
||||
path: |
|
||||
${{ runner.temp }}/endpoint-contract-report.json
|
||||
${{ runner.temp }}/dorknet-server.log
|
||||
|
||||
- name: Stop server
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f "$RUNNER_TEMP/dorknet-server.pid" ]; then
|
||||
kill "$(cat "$RUNNER_TEMP/dorknet-server.pid")" || true
|
||||
fi
|
||||
@@ -11,6 +11,9 @@ on:
|
||||
tags:
|
||||
- 'v1-december-*'
|
||||
|
||||
permissions:
|
||||
contents: write # create the GitHub release + upload assets
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: windows-latest
|
||||
|
||||
+3
-1
@@ -21,7 +21,7 @@ tmp/
|
||||
## OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.backups
|
||||
## Reverse-engineered artifacts (Il2CppDumper / Cpp2IL output, not for commit)
|
||||
dump.cs
|
||||
il2cpp.h
|
||||
@@ -86,3 +86,5 @@ Recovered(*)/
|
||||
|
||||
|
||||
local-releases/**
|
||||
DorkNet.Models/bin-verify/**
|
||||
DorkNet.Models/bin-verify2/**
|
||||
|
||||
+4
-4
@@ -1,14 +1,14 @@
|
||||
# DorkNet.Server container image — Coolify deploy target.
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Multi-stage: SDK image builds + publishes, runtime image is the
|
||||
# slimmer aspnet:9.0 with only the published output.
|
||||
# slimmer aspnet:10.0 with only the published output.
|
||||
#
|
||||
# Build context: repo root (so Tools/ and DorkNet.Models/ are visible
|
||||
# to ProjectReference + dotnet restore). The .dockerignore at the
|
||||
# repo root keeps the bin/, obj/, and other dev junk out of the
|
||||
# context.
|
||||
|
||||
ARG DOTNET_VERSION=9.0
|
||||
ARG DOTNET_VERSION=10.0
|
||||
ARG NODE_VERSION=20
|
||||
|
||||
# ── admin SPA build stage ──────────────────────────────────────────
|
||||
@@ -76,13 +76,13 @@ WORKDIR /app
|
||||
|
||||
# curl + wget for healthcheck probes — Coolify v4 defaults to wget for
|
||||
# its own probe regardless of what the image's HEALTHCHECK uses, so
|
||||
# both have to be present. The aspnet:9.0 base image strips both by
|
||||
# both have to be present. The aspnet:10.0 base image strips both by
|
||||
# default to keep the surface area small.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Run as a non-root user — aspnet:9.0 ships with the `app` UID 1654.
|
||||
# Run as a non-root user — aspnet:10.0 ships with the `app` UID 1654.
|
||||
# Coolify mounts persistent volumes with write permission for this UID.
|
||||
USER app
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
ARG DOTNET_VERSION=10.0
|
||||
ARG NODE_VERSION=20
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine AS admin-ui
|
||||
WORKDIR /ui
|
||||
COPY DorkNet.Server/admin-ui/package.json DorkNet.Server/admin-ui/package-lock.json* ./
|
||||
RUN npm install --no-fund --no-audit --loglevel=error
|
||||
COPY DorkNet.Server/admin-ui/ ./
|
||||
RUN mkdir -p /wwwroot/admin \
|
||||
&& npm run build
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine AS site
|
||||
WORKDIR /ui
|
||||
COPY DorkNet.Server/site/package.json DorkNet.Server/site/package-lock.json* ./
|
||||
RUN npm install --no-fund --no-audit --loglevel=error
|
||||
COPY DorkNet.Server/site/ ./
|
||||
RUN mkdir -p /wwwroot/site \
|
||||
&& npm run build
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
|
||||
ARG PROJECT_PATH
|
||||
WORKDIR /src
|
||||
|
||||
COPY . .
|
||||
COPY --from=admin-ui /wwwroot/admin/ DorkNet.Server/wwwroot/admin/
|
||||
COPY --from=site /wwwroot/site/ DorkNet.Server/wwwroot/site/
|
||||
RUN dotnet publish "${PROJECT_PATH}" -c Release \
|
||||
-p:SkipAdminUIBuild=true \
|
||||
-p:DorkNetServiceContainerPublish=true \
|
||||
-o /app/publish
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION} AS runtime
|
||||
ARG APP_DLL
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER app
|
||||
|
||||
COPY --from=build --chown=app:app /app/publish .
|
||||
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production \
|
||||
ASPNETCORE_URLS=http://0.0.0.0:8080 \
|
||||
DOTNET_RUNNING_IN_CONTAINER=true \
|
||||
DOTNET_PRINT_TELEMETRY_MESSAGE=false \
|
||||
APP_DLL=${APP_DLL}
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD curl --fail --silent http://localhost:8080/healthz || exit 1
|
||||
|
||||
ENTRYPOINT ["sh", "-c", "dotnet \"$APP_DLL\""]
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
Native Android/iOS admin app for DorkNet, built with .NET MAUI.
|
||||
|
||||
In the microservices deployment, point the app at the public admin host
|
||||
for the stack, usually `https://admin.<domain>`. Cloudflare and
|
||||
`DorkNet.Gateway` keep the public URL stable. Requests made through the
|
||||
admin host are handled by the `web` service's same-origin admin route;
|
||||
the `/api/admin/*` path family is also exposed by the moderation slice
|
||||
for non-admin-host routing.
|
||||
|
||||
The browser admin page, route map, and troubleshooting flow are
|
||||
documented in [`../docs/admin.md`](../docs/admin.md).
|
||||
|
||||
## Authentication
|
||||
|
||||
The app stores sensitive values in platform secure storage:
|
||||
|
||||
+2426
-635
@@ -3,9 +3,7 @@
|
||||
// server:
|
||||
//
|
||||
// 1. URI rewrite — `.rec.net` → user-configured host
|
||||
// 2. Photon AppId — override the baked-in Photon Cloud AppId
|
||||
// 3. AuthValues — inject `userid` + `LoginLock` for /photon/customauth
|
||||
// 4. TLS trust bypass — make BouncyCastle never reject server certs
|
||||
// 2. TLS trust bypass — make BouncyCastle never reject server certs
|
||||
//
|
||||
// Config lives in <MelonLoader>\UserData\dorknet-clientmod.json. The
|
||||
// install-melon.ps1 wrapper writes that file on install; you can also
|
||||
@@ -22,13 +20,21 @@
|
||||
// Il2CppAssemblies directory just to write the source.
|
||||
|
||||
using HarmonyLib;
|
||||
using Il2CppInterop.Runtime;
|
||||
using Il2CppInterop.Runtime.InteropTypes;
|
||||
using Il2CppInterop.Runtime.InteropTypes.Arrays;
|
||||
using MelonLoader;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
[assembly: MelonInfo(typeof(DorkNet.ClientMod.Mod), "DorkNet ClientMod", "1.0.0", "Dork Squad")]
|
||||
[assembly: MelonGame(null, null)]
|
||||
@@ -43,11 +49,19 @@ public class Mod : MelonMod
|
||||
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
|
||||
private readonly HashSet<string> _diagnosticPatchLabels = new();
|
||||
private bool _networkPatchesRegistered;
|
||||
private bool _httpDiagnosticsRegistered;
|
||||
private bool _joinTracePatchesRegistered;
|
||||
private bool _quitTraceCoreRegistered;
|
||||
private bool _quitTraceGameComplete;
|
||||
private int _quitTraceRetryFrame;
|
||||
private bool _antiTamperCallbackPatchesComplete;
|
||||
private int _antiTamperCallbackRetryFrame;
|
||||
private bool _toxModPatchesComplete;
|
||||
private int _toxModRetryFrame;
|
||||
private bool _diagnosticCoreRegistered;
|
||||
private bool _diagnosticGameComplete;
|
||||
private int _diagnosticRetryFrame;
|
||||
private bool _firstUpdateLogged;
|
||||
private bool _processExceptionHandlersRegistered;
|
||||
|
||||
// Config backed by a plain JSON file under MelonLoader/UserData.
|
||||
// The defaults below match the recommended install, so dropping this
|
||||
@@ -55,20 +69,6 @@ public class Mod : MelonMod
|
||||
public static class Cfg
|
||||
{
|
||||
public static string ServerHost = "localhost";
|
||||
public static string PhotonAppId = "";
|
||||
public static string PhotonVoiceAppId = "";
|
||||
// Forced PhotonServerSettings.PreferredRegion (with HostType
|
||||
// switched to PhotonCloud) so two watches don't auto-ping
|
||||
// themselves into different "best" regions and end up in
|
||||
// parallel Photon rooms sharing only a name. PhotonPatches
|
||||
// .ApplyForcedRegion prefers the server-told region from
|
||||
// Matchmaking.LocalRoomInstance.PhotonRegionId when available
|
||||
// and falls back to this config value on initial boot before
|
||||
// any /goto has returned. Default matches the server's
|
||||
// appsettings.json Photon:CloudRegion ("eu").
|
||||
public static string PhotonCloudRegion = "eu";
|
||||
// Photon Custom Auth injector parked 2026-05-28; see
|
||||
// attic/AuthValuesInjector.cs.attic for the code + restore notes.
|
||||
public static bool EnableTlsTrustBypass = true;
|
||||
// Log-only trace for the stuck first-dorm "Change Username?"
|
||||
// dialog. When true, RegistrationPatches hooks the dorm
|
||||
@@ -87,6 +87,18 @@ public class Mod : MelonMod
|
||||
// is a UnityEngine.KeyCode name; BackQuote is the `~` key.
|
||||
public static bool EnableDebugConsole = false;
|
||||
public static string DebugConsoleToggleKey = "BackQuote";
|
||||
public static string[] DevCommands = Array.Empty<string>();
|
||||
public static bool DiagnoseDevMenu = false;
|
||||
public static bool EnableBuiltInDevDebugTools = false;
|
||||
// Master switch for the verbose, non-essential DIAGNOSTIC patches
|
||||
// (HTTP header tracing, join/quit traces, studio traces, the deep
|
||||
// RegisterDiagnostics path). OFF by default so the shipping client
|
||||
// mod installs only the patches needed to connect + survive
|
||||
// anti-cheat — the safest possible footprint. Turned ON automatically
|
||||
// when DorkNet.DebugMod.dll is present in the Mods folder (drop it in
|
||||
// to debug, remove it to ship), or by "EnableDiagnostics": true in
|
||||
// the config. Core connectivity/anti-cheat patches are NEVER gated.
|
||||
public static bool EnableDiagnostics = false;
|
||||
|
||||
// ── Desktop Screen Sharing gadget FPS ──
|
||||
// RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts
|
||||
@@ -111,29 +123,6 @@ public class Mod : MelonMod
|
||||
public static int DesktopScreenShareResolution = 0; // horizontal px
|
||||
public static int DesktopScreenShareQuality = 0; // JPEG quality
|
||||
|
||||
// One-shot dev-UI diagnostic (no behaviour change). When true,
|
||||
// ~5s after load the mod logs whether the watch's native developer
|
||||
// menu content actually exists in this build (HomeScreenFlow.
|
||||
// devMenuPanel / devButtonPrefab present vs NULL), the dev-flow list
|
||||
// counts, and whether RecRoom.Debugging.DebugConsole is in the scene
|
||||
// + whether its static Execute() command path works. This tells us
|
||||
// stripped-vs-gated so we know whether to un-gate the game's own dev
|
||||
// menu or build a custom watch entry. Output goes to the MelonLoader
|
||||
// console + dorknet-diagnostics.log.
|
||||
public static bool DiagnoseDevMenu = false;
|
||||
|
||||
// Inject a "Dev Console" button into the watch's native dev-menu
|
||||
// panel (HomeScreenFlow.devMenuPanel) by cloning the game's own
|
||||
// devButtonPrefab. The panel/prefab exist in this build (just gated
|
||||
// off), so we instantiate + activate + label, then wire its click to
|
||||
// run console commands. Native Button3D → works in VR and Screen Mode.
|
||||
public static bool EnableDevWatchButton = false;
|
||||
// Preset dev commands shown as watch buttons (when EnableDevWatchButton
|
||||
// is on). Each entry is "Label=command"; clicking runs
|
||||
// DebugConsole.Execute(command). Edit to match this build's actual
|
||||
// DebugConsoleCommandConfig command names/args.
|
||||
public static string[] DevCommands = { "Help=help", "Fly=fly", "NoClip=noclip" };
|
||||
|
||||
// Raise RRO quest party size past the baked 4. The cap lives in each
|
||||
// quest GameConfigurationAsset.TeamConfigurations[].MaxTeamSize
|
||||
// ScriptableObject (baked in the client bundle; no server lever). The
|
||||
@@ -157,46 +146,19 @@ public class Mod : MelonMod
|
||||
Log.Msg("=== DorkNet ClientMod loading ===");
|
||||
LoadConfig();
|
||||
RegisterNetworkPatches();
|
||||
DiagnosticPatches.Write("[lifecycle] OnInitializeMelon");
|
||||
}
|
||||
|
||||
public override void OnLateInitializeMelon()
|
||||
{
|
||||
DiagnosticPatches.Write("[lifecycle] OnLateInitializeMelon");
|
||||
Log.Msg("=== Registering client patches ===");
|
||||
RegisterNetworkPatches();
|
||||
TryPatchByName("PhotonNetwork", "ConnectUsingSettings",
|
||||
prefix: nameof(PhotonPatches.PhotonAppIdOverride_Prefix));
|
||||
// Hook the watch's own AppSettings + AuthValues builders directly.
|
||||
// Both are private PUNNetworkManager methods with obfuscated 2020.12
|
||||
// names — we know they always run before any Photon connect because
|
||||
// ConnectUsingSettings reads their return values. Postfix logs them
|
||||
// after construction.
|
||||
//
|
||||
// FJOLIPKKIBE returns Photon.Realtime.AppSettings — verified at
|
||||
// PUNNetworkManager.txt:7046. One bool arg (DOJLKMCEFFB — likely
|
||||
// "for voice"). Two call paths in the binary.
|
||||
// EMGGKBALALH returns Photon.Realtime.AuthenticationValues —
|
||||
// verified at PUNNetworkManager.txt:7560. Builds AuthValues with
|
||||
// environment + accessToken AuthGetParameters.
|
||||
//
|
||||
// Quest-Dec uses the same obfuscation seed as PC-Dec, but discover by
|
||||
// return type first so a future platform/reseed does not make these
|
||||
// two critical hooks depend on the private method names.
|
||||
TryPatchDeclaredMethodByReturnType("PUNNetworkManager", "AppSettings",
|
||||
fallbackMethodName: "FJOLIPKKIBE",
|
||||
postfix: nameof(PhotonPatches.LogAppSettings_Postfix));
|
||||
TryPatchDeclaredMethodByReturnType("PUNNetworkManager", "AuthenticationValues",
|
||||
fallbackMethodName: "EMGGKBALALH",
|
||||
postfix: nameof(PhotonPatches.LogAuthValues_Postfix));
|
||||
// PUNNetworkManager.OnCustomAuthenticationFailed — accepts whatever
|
||||
// string-like arg the IL2CPP method takes (declared `string` in
|
||||
// dump.cs but Il2CppInterop wraps it in a way that breaks
|
||||
// Harmony's IL emit when we declare `string` directly). Using
|
||||
// `object` lets Harmony pass through whatever wrapper the IL2CPP
|
||||
// proxy uses without compile-error.
|
||||
TryPatchByName("PUNNetworkManager", "OnCustomAuthenticationFailed",
|
||||
prefix: nameof(PhotonPatches.OnCustomAuthenticationFailed_Prefix));
|
||||
TryPatchByName("RecRoom.AntiCheat.EACManager",
|
||||
"GenerateChallengeResponse",
|
||||
args: new[] { typeof(string) },
|
||||
prefix: nameof(AntiCheatPatches.GenerateChallengeResponse_Prefix));
|
||||
TryPatchByName("LBNJFPOLCDL", "JHLKGBGJKKK",
|
||||
args: new[] { typeof(string), typeof(string), typeof(string) },
|
||||
prefix: nameof(AuthPatches.CaptureTokenArgs_Prefix));
|
||||
// TLS cert-validation bypass — gated behind Cfg.EnableTlsTrustBypass.
|
||||
// Even when the prefix's return value is "let original run", the
|
||||
// detour itself still has to be JIT-installed when BouncyCastle
|
||||
@@ -214,145 +176,14 @@ public class Mod : MelonMod
|
||||
"NotifyServerCertificate",
|
||||
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
|
||||
}
|
||||
// Post-save blob-name refresh. The 2020 watch's
|
||||
// RoomPersistenceManager.RoomDataBlobName is only ever set by
|
||||
// /goto-driven flows — neither the SubscriptionUpdateRoom push
|
||||
// nor SubscriptionUpdatePresence pushes the server fires after
|
||||
// save propagate into this field. So the PostSaveReloading
|
||||
// sub-state's MasterReloadRoomAsync deserializes the cached
|
||||
// bytes from the OLD blob name (the one /goto returned at boot),
|
||||
// wiping any MakerPen object the user just placed even though
|
||||
// the new blob is already on S3.
|
||||
//
|
||||
// Verified live: after a dorm save with object placement, server
|
||||
// pushes SubscriptionUpdateRoom + PresenceUpdate both carrying
|
||||
// dataBlob="dorm_p<id>_v(N+1).dat", but the watch's
|
||||
// set_RoomDataBlobName trace immediately after the save shows
|
||||
// old="v(N)" new="v(N)" — no propagation, no fresh download,
|
||||
// no respawn. Leave+return triggers /goto which DOES update
|
||||
// RoomDataBlobName, so the fix is to do the same write
|
||||
// ourselves: when entering the SavingRoomSuccess state on the
|
||||
// RoomPersistenceManager, read the just-pushed
|
||||
// Rooms.LocalRoomScene.DataBlobName and force-set
|
||||
// __instance.RoomDataBlobName to it. That fires the real
|
||||
// setter → OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync
|
||||
// for the new blob → PostSaveReloading picks up the fresh
|
||||
// bytes and respawns the MakerPen objects.
|
||||
TryPatchByName("RecRoom.Persistence.RoomPersistenceManager",
|
||||
"set_RoomDataBlobName",
|
||||
args: new[] { typeof(string) },
|
||||
prefix: nameof(SavePatches.SetRoomDataBlobName_Prefix));
|
||||
// Intercept the SubscriptionUpdateRoom push BEFORE the watch's
|
||||
// (cachedRoomId, cachedSubRoomId) early-out gate drops it. We
|
||||
// stash the just-arrived Scenes[0].DataBlobName into a static
|
||||
// so the set_RoomDataBlobName hijack can use it — otherwise
|
||||
// LocalRoomDetails / LocalRoomScene never get the new blob
|
||||
// name on the second-or-later push for the same (room, sub)
|
||||
// tuple (= every post-save push in a dorm).
|
||||
TryPatchByName("RecNet.Rooms",
|
||||
"OnSubscriptionUpdateRoom",
|
||||
prefix: nameof(SavePatches.OnSubscriptionUpdateRoom_Prefix));
|
||||
// Force AccountExtensions.CanLocalPlayerChat to return true. The
|
||||
// 2020 watch's room-chat send path (PlayerEmotes.SendChatEmote
|
||||
// Coroutine d__53 offset 040) gates on this — if false, the
|
||||
// coroutine bails before raising the Photon RPC, so the sender
|
||||
// doesn't even see their own message. The check ANDs three
|
||||
// signals: LocalAccount.TreatAsJunior (ObscuredBool at offset
|
||||
// 0x40), a PlatformManager flag, and a ModerationManager
|
||||
// singleton field. At least one returns the wrong value against
|
||||
// our server. Force-allow — single-tenant private server, text
|
||||
// moderation isn't a concern.
|
||||
TryPatchByName("RecNet.AccountExtensions",
|
||||
"CanLocalPlayerChat",
|
||||
args: Type.EmptyTypes,
|
||||
postfix: nameof(ChatPatches.CanLocalPlayerChat_Postfix));
|
||||
// PlayerEmotes.RemoveInvalidCharactersFromMessage walks the chat
|
||||
// font asset's character table via TMP_FontAsset.HasCharacters
|
||||
// to strip glyphs the font can't render. On 2020 Rec Room the
|
||||
// chat font field can be null at the moment this fires (font
|
||||
// asset bundle hasn't loaded yet?) and HasCharacters NREs deep
|
||||
// inside Unity's TMP — which propagates out through the chat
|
||||
// send callback and aborts the RPC. The watch's own ISIL has
|
||||
// "skipping invalid character check" log paths for missing
|
||||
// assets but doesn't actually short-circuit on the null field
|
||||
// we hit. Skip the method entirely; chat lines will just keep
|
||||
// any glyphs the font can't render (most common: emoji).
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"RemoveInvalidCharactersFromMessage",
|
||||
prefix: nameof(ChatPatches.RemoveInvalidCharactersFromMessage_Prefix));
|
||||
// Trace hooks so we can see exactly where chat dies. Send path:
|
||||
// KeyboardInputField.submit → RoomChatMenu.SendEmoteMessage(string)
|
||||
// → PlayerEmotes.SendChatEmote(msg, true)
|
||||
// → SendChatEmoteCoroutine d__53 → PurifyString HTTP call
|
||||
// → b__0 callback (error, cleanVersion) — fires Photon RPC
|
||||
// AND local-echoes via ProcessNewChatMessageReceived
|
||||
// → on each receiver (including self): RpcChatEmote(msg) →
|
||||
// ProcessNewChatMessageReceived → ReceiveRoomChat appends to
|
||||
// the chat log buffer.
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"SendChatEmote",
|
||||
args: new[] { typeof(string), typeof(bool) },
|
||||
prefix: nameof(ChatPatches.SendChatEmote_Prefix));
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"ProcessNewChatMessageReceived",
|
||||
args: new[] { typeof(string), typeof(bool) },
|
||||
prefix: nameof(ChatPatches.ProcessNewChatMessageReceived_Prefix));
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"RpcChatEmote",
|
||||
args: new[] { typeof(string) },
|
||||
prefix: nameof(ChatPatches.RpcChatEmote_Prefix));
|
||||
|
||||
// Diagnostic trace for the stuck first-dorm "Change Username?"
|
||||
// dialog (new accounts can't get past it; "Okay" highlights but
|
||||
// never dismisses the modal, on every device). This is LOG-ONLY —
|
||||
// no behaviour change. It bisects the failure for a brand-new
|
||||
// account that reproduces the freeze, writing to
|
||||
// MelonLoader/UserData/dorknet-diagnostics.log:
|
||||
//
|
||||
// 1. RegistrationModel.IsFullyRegistered() return value — if
|
||||
// false, the dorm prompt path runs; if true, no prompt (so a
|
||||
// freeze here would be something else entirely).
|
||||
// 2. DormroomSceneManager.<PromptForRegistration>b__6_* lambdas —
|
||||
// which prompt branch executed (b__6_1 = a dialog was raised).
|
||||
// 3. ConfirmUIDialog.Button_Affirmative/Negative/NotNow/Cancel —
|
||||
// whether the click actually reaches the dialog's button
|
||||
// handler when the user taps Okay. If these FIRE but the modal
|
||||
// stays, the break is in the resolve/hide completer
|
||||
// (UIDialog`1.KHKDGPHPIND's guard); if they DON'T fire, the
|
||||
// click never routes to the button (input/raycast) and the fix
|
||||
// is elsewhere. Either way the next step is unambiguous.
|
||||
if (Cfg.TraceRegistrationDialog)
|
||||
{
|
||||
TryPatchByName("RRUI.Data.NUX.RegistrationModel",
|
||||
"IsFullyRegistered",
|
||||
args: Type.EmptyTypes,
|
||||
postfix: nameof(RegistrationPatches.IsFullyRegistered_Postfix));
|
||||
|
||||
// The two registration dialogs are raised from
|
||||
// DormroomSceneManager's PromptForRegistration via its
|
||||
// compiler-generated <>c lambdas (b__6_0..b__6_3). Reuse the
|
||||
// same nested-lambda patcher the join trace uses.
|
||||
PatchNestedLambdas("DormroomSceneManager", "PromptForRegistration",
|
||||
nameof(RegistrationPatches.PromptLambda_Prefix));
|
||||
|
||||
// The confirm-dialog buttons. Clean, non-generic, no-arg names
|
||||
// on AGUI.StackedUI.Dialog.ConfirmUIDialog — Okay maps to
|
||||
// Button_Affirmative, the screenshot's only button.
|
||||
foreach (var (method, label) in new[]
|
||||
{
|
||||
("Button_Affirmative", nameof(RegistrationPatches.ButtonAffirmative_Prefix)),
|
||||
("Button_Negative", nameof(RegistrationPatches.ButtonNegative_Prefix)),
|
||||
("Button_NotNow", nameof(RegistrationPatches.ButtonNotNow_Prefix)),
|
||||
("Button_Cancel", nameof(RegistrationPatches.ButtonCancel_Prefix)),
|
||||
})
|
||||
{
|
||||
TryPatchByName("AGUI.StackedUI.Dialog.ConfirmUIDialog",
|
||||
method, args: Type.EmptyTypes, prefix: label);
|
||||
}
|
||||
}
|
||||
|
||||
RegisterJoinTracePatches();
|
||||
|
||||
// Anti-tamper / ToxMod / file-hash-checker are anti-cheat BYPASS
|
||||
// patches — core, always on, or the modded client gets kicked.
|
||||
// Quit-trace is pure diagnostics, so it's gated.
|
||||
if (Cfg.EnableDiagnostics)
|
||||
RegisterQuitTracePatches(logMisses: true);
|
||||
RegisterAntiTamperCallbackPatches(logMisses: true);
|
||||
RegisterToxModPatches(logMisses: true);
|
||||
PatchFileHashCheckerCallback();
|
||||
// Desktop Screen Sharing FPS override. Only register when a target
|
||||
// is set; the postfix on the gadget's Awake rewrites the baked
|
||||
// refresh-frequency field (and, if enabled, raises the Photon
|
||||
@@ -366,11 +197,6 @@ public class Mod : MelonMod
|
||||
$"(raisePhotonRate={Cfg.DesktopScreenShareRaisePhotonRate})");
|
||||
}
|
||||
|
||||
TryPatchByName("RecRoom.Tools.MakerPenVisuals",
|
||||
"set_LaserPointerEnabled",
|
||||
args: new[] { typeof(bool) },
|
||||
prefix: nameof(MakerPenGiftPreviewPatches.LaserPointerEnabled_Prefix));
|
||||
|
||||
// RRO quest party-size bump. Prefix the runtime config-apply path so
|
||||
// we can read the config's Name (scope to the allowlisted quests) and
|
||||
// bump GameConfigurationData.TeamConfigurations[].TeamSize before the
|
||||
@@ -393,10 +219,33 @@ public class Mod : MelonMod
|
||||
Log.Msg($"[questsize] armed: target {Cfg.QuestMaxTeamSize} for [{string.Join(", ", Cfg.QuestMaxTeamSizeRooms)}]");
|
||||
}
|
||||
|
||||
RegisterDiagnostics();
|
||||
if (Cfg.EnableDiagnostics)
|
||||
RegisterStudioTracePatches();
|
||||
|
||||
if (Cfg.EnableBuiltInDevDebugTools)
|
||||
RegisterBuiltInDevDebugTools();
|
||||
|
||||
Log.Msg("=== Client patches registered ===");
|
||||
}
|
||||
|
||||
private void RegisterBuiltInDevDebugTools()
|
||||
{
|
||||
Log.Msg("=== Registering built-in Dev Debug Tools hooks ===");
|
||||
TryPatchByName("RRUI.Data.GlobalModelController+HideIfNotDeveloperImpl",
|
||||
"DGFIJKPCBLK", args: Type.EmptyTypes,
|
||||
prefix: nameof(BuiltInDevDebugToolsPatches.KeepVisible_Prefix));
|
||||
TryPatchByName("RRUI.Data.GlobalModelController+HideIfNotDeveloperAndNotDebugBuildImpl",
|
||||
"DGFIJKPCBLK", args: Type.EmptyTypes,
|
||||
prefix: nameof(BuiltInDevDebugToolsPatches.KeepVisible_Prefix));
|
||||
TryPatchByName("RRUI.Data.GlobalModelController+HideIfNotDebugBuildImpl",
|
||||
"DGFIJKPCBLK", args: Type.EmptyTypes,
|
||||
prefix: nameof(BuiltInDevDebugToolsPatches.KeepVisible_Prefix));
|
||||
TryPatchByName("RRUI.Data.DevDebugBuildInformationModel",
|
||||
"get_BuildInfoFormatted", args: Type.EmptyTypes,
|
||||
postfix: nameof(BuiltInDevDebugToolsPatches.BuildInfoFormatted_Postfix));
|
||||
Log.Msg("=== Built-in Dev Debug Tools hooks registered ===");
|
||||
}
|
||||
|
||||
// ── Room-join + watch-button trace ────────────────────────────────
|
||||
// Traces the full "press the Dorm Room button → join the room" chain
|
||||
// so a stuck join (e.g. the watch's Dorm Room button doing nothing)
|
||||
@@ -531,6 +380,146 @@ public class Mod : MelonMod
|
||||
return patched > 0;
|
||||
}
|
||||
|
||||
private bool PatchFileHashCheckerCallback()
|
||||
{
|
||||
var prefixMethod = GetPatchMethod(nameof(FileHashCheckerPatches.InitializeCallback_Prefix));
|
||||
var patched = 0;
|
||||
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
foreach (var type in GetLoadableTypes(asm))
|
||||
{
|
||||
if (!MatchesFileHashCheckerCallbackType(type)) continue;
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
if (!MatchesFileHashCheckerCallbackMethod(method)) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
|
||||
patched++;
|
||||
Log.Msg($"[patch-ok] file hash checker callback {type.FullName}.{method.Name}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] file hash checker callback {type.FullName}.{method.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
Log.Warning("[patch-miss] file hash checker callback");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void RegisterAntiTamperCallbackPatches(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
complete &= PatchCallbackMethod(
|
||||
"hile warning callback",
|
||||
type => TypeNameContains(type, "FPIBGPIAOBI") && TypeNameContains(type, "KHIIHOIFDMP"),
|
||||
method => MethodNameContains(method, "CreateHileWarning") && MethodNameContains(method, "b__0"),
|
||||
nameof(AntiTamperPatches.CreateHileWarningCallback_Prefix),
|
||||
logMisses);
|
||||
complete &= PatchCallbackMethod(
|
||||
"unknown DLL callback",
|
||||
type => TypeNameContains(type, "CheatManager") && TypeNameContains(type, "PNHDGEKCJCH"),
|
||||
method => MethodNameContains(method, "OnUnknownDLLDetected") && MethodNameContains(method, "b__0"),
|
||||
nameof(AntiTamperPatches.UnknownDllDetectedCallback_Prefix),
|
||||
logMisses);
|
||||
_antiTamperCallbackPatchesComplete = complete;
|
||||
}
|
||||
|
||||
private void RegisterToxModPatches(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
complete &= TryPatchDiagnosticByName("ToxMod.ToxModVoiceComponent",
|
||||
"CanInitializeToxMod",
|
||||
args: Type.EmptyTypes,
|
||||
prefix: nameof(ToxModPatches.CanInitializeToxMod_Prefix),
|
||||
logMiss: logMisses);
|
||||
_toxModPatchesComplete = complete;
|
||||
}
|
||||
|
||||
private bool PatchCallbackMethod(string label,
|
||||
Func<Type, bool> typePredicate,
|
||||
Func<MethodInfo, bool> methodPredicate,
|
||||
string prefix,
|
||||
bool logMiss)
|
||||
{
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
var prefixMethod = GetPatchMethod(prefix);
|
||||
var patched = 0;
|
||||
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
foreach (var type in GetLoadableTypes(asm))
|
||||
{
|
||||
if (!typePredicate(type)) continue;
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
if (!methodPredicate(method)) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
|
||||
patched++;
|
||||
Log.Msg($"[patch-ok] {label} {type.FullName}.{method.Name}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] {label} {type.FullName}.{method.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}");
|
||||
return false;
|
||||
}
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TypeNameContains(Type type, string value)
|
||||
{
|
||||
var fullName = type.FullName ?? type.Name;
|
||||
return fullName.Contains(value, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool MethodNameContains(MethodInfo method, string value)
|
||||
=> method.Name.Contains(value, StringComparison.Ordinal);
|
||||
|
||||
private static bool MatchesFileHashCheckerCallbackType(Type type)
|
||||
{
|
||||
var fullName = type.FullName ?? type.Name;
|
||||
return fullName.Contains("BEPEAHOOLDB", StringComparison.Ordinal)
|
||||
&& fullName.Contains("PBENKLNHBHL", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool MatchesFileHashCheckerCallbackMethod(MethodInfo method)
|
||||
{
|
||||
if (method.ReturnType != typeof(void)) return false;
|
||||
var parameters = method.GetParameters();
|
||||
if (parameters.Length != 1) return false;
|
||||
|
||||
var name = method.Name;
|
||||
if (name == "<InitializeFileHashChecker>b__0") return true;
|
||||
if (name == "_InitializeFileHashChecker_b__0") return true;
|
||||
return name.Contains("InitializeFileHashChecker", StringComparison.Ordinal)
|
||||
&& name.Contains("b__0", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private void RegisterNetworkPatches()
|
||||
{
|
||||
if (_networkPatchesRegistered) return;
|
||||
@@ -542,6 +531,7 @@ public class Mod : MelonMod
|
||||
complete &= TryPatch("Uri ctor (string, UriKind)", typeof(Uri), "ctor",
|
||||
new[] { typeof(string), typeof(UriKind) },
|
||||
prefix: nameof(UriPatches.UriStringCtor_Prefix));
|
||||
complete &= TryPatchHttpRequestUriConstructors();
|
||||
complete &= TryPatchByName("BestHTTP.HTTPRequest", "Send",
|
||||
prefix: nameof(UriPatches.HttpRequestSend_Prefix));
|
||||
complete &= TryPatchHttpManagerStringSendRequestOverloads();
|
||||
@@ -549,9 +539,130 @@ public class Mod : MelonMod
|
||||
args: new[] { ResolveType("BestHTTP.HTTPRequest") ?? typeof(object) },
|
||||
prefix: nameof(UriPatches.HttpManagerSendRequestObject_Prefix));
|
||||
|
||||
// HTTP header/callback tracing is pure diagnostics (and the noisiest
|
||||
// thing in the log) — only install it when diagnostics are enabled.
|
||||
if (Cfg.EnableDiagnostics)
|
||||
RegisterHttpDiagnostics();
|
||||
_networkPatchesRegistered = complete;
|
||||
}
|
||||
|
||||
private void RegisterHttpDiagnostics()
|
||||
{
|
||||
if (_httpDiagnosticsRegistered) return;
|
||||
|
||||
var complete = true;
|
||||
complete &= TryPatchBndRequestBuilderDiagnostics();
|
||||
complete &= TryPatchDiagnosticAllOverloads("BestHTTP.HTTPRequest", "CallCallback",
|
||||
postfix: nameof(HttpTracePatches.CallCallback_Postfix),
|
||||
finalizer: nameof(HttpTracePatches.CallCallback_Finalizer));
|
||||
complete &= TryPatchByName("BestHTTP.HTTPRequest", "AddHeader",
|
||||
args: new[] { typeof(string), typeof(string) },
|
||||
prefix: nameof(HttpTracePatches.HeaderSet_Prefix));
|
||||
complete &= TryPatchByName("BestHTTP.HTTPRequest", "SetHeader",
|
||||
args: new[] { typeof(string), typeof(string) },
|
||||
prefix: nameof(HttpTracePatches.HeaderSet_Prefix));
|
||||
|
||||
_httpDiagnosticsRegistered = complete;
|
||||
}
|
||||
|
||||
private bool TryPatchBndRequestBuilderDiagnostics()
|
||||
{
|
||||
const string label = "BNDIAONDFFF.request-builder";
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
var type = ResolveType("BNDIAONDFFF");
|
||||
if (type is null)
|
||||
{
|
||||
Log.Warning("[patch-miss] BNDIAONDFFF request-builder: type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var prefix = new HarmonyMethod(GetPatchMethod(nameof(HttpTracePatches.RecNetRequestBuilderCtor_Prefix)));
|
||||
var postfix = new HarmonyMethod(GetPatchMethod(nameof(HttpTracePatches.RecNetRequestBuilderCtor_Postfix)));
|
||||
var patched = 0;
|
||||
|
||||
foreach (var ctor in type.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
var parameters = ctor.GetParameters();
|
||||
if (parameters.Length != 3 || parameters[2].ParameterType != typeof(string)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(ctor, prefix: prefix, postfix: postfix);
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] BNDIAONDFFF..ctor({FormatParameterTypes(parameters)}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.Name != "FILJELOLBKK") continue;
|
||||
var parameters = method.GetParameters();
|
||||
if (parameters.Length != 3 || parameters[2].ParameterType != typeof(string)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method, prefix: prefix, postfix: postfix);
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] BNDIAONDFFF.FILJELOLBKK({FormatParameterTypes(parameters)}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
Log.Warning("[patch-miss] BNDIAONDFFF request-builder overloads");
|
||||
return false;
|
||||
}
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
Log.Msg($"[patch-ok] BNDIAONDFFF request-builder x{patched}");
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryPatchHttpRequestUriConstructors()
|
||||
{
|
||||
var type = ResolveType("BestHTTP.HTTPRequest");
|
||||
if (type is null)
|
||||
{
|
||||
Log.Warning("[patch-miss] BestHTTP.HTTPRequest..ctor(Uri): type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var prefix = GetPatchMethod(nameof(UriPatches.HttpRequestCtorUri_Prefix));
|
||||
var patched = 0;
|
||||
foreach (var ctor in type.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
var parameters = ctor.GetParameters();
|
||||
if (parameters.Length == 0 || !IsUriType(parameters[0].ParameterType)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(ctor, prefix: new HarmonyMethod(prefix));
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] BestHTTP.HTTPRequest..ctor({FormatParameterTypes(parameters)}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
Log.Warning("[patch-miss] BestHTTP.HTTPRequest..ctor(Uri)");
|
||||
return false;
|
||||
}
|
||||
|
||||
Log.Msg($"[patch-ok] BestHTTP.HTTPRequest..ctor(Uri first) overloads x{patched}");
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryPatchHttpManagerStringSendRequestOverloads()
|
||||
{
|
||||
var type = ResolveType("BestHTTP.HTTPManager");
|
||||
@@ -582,6 +693,19 @@ public class Mod : MelonMod
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsUriType(Type type)
|
||||
{
|
||||
return type == typeof(Uri) || type.FullName == typeof(Uri).FullName || type.Name == nameof(Uri);
|
||||
}
|
||||
|
||||
private static string FormatParameterTypes(ParameterInfo[] parameters)
|
||||
{
|
||||
var names = new List<string>();
|
||||
foreach (var parameter in parameters)
|
||||
names.Add(parameter.ParameterType.Name);
|
||||
return string.Join(",", names);
|
||||
}
|
||||
|
||||
public override void OnUpdate()
|
||||
{
|
||||
if (!_firstUpdateLogged)
|
||||
@@ -590,19 +714,33 @@ public class Mod : MelonMod
|
||||
Log.Msg("[lifecycle] first OnUpdate tick (Unity frame loop is live)");
|
||||
}
|
||||
|
||||
if (_diagnosticGameComplete) return;
|
||||
if (_diagnosticRetryFrame++ > 3600) return;
|
||||
if ((_diagnosticRetryFrame % 60) != 0) return;
|
||||
if (Cfg.EnableDiagnostics && !_quitTraceGameComplete && ++_quitTraceRetryFrame >= 300)
|
||||
{
|
||||
_quitTraceRetryFrame = 0;
|
||||
RegisterQuitTracePatches(logMisses: false);
|
||||
}
|
||||
|
||||
var logMisses = (_diagnosticRetryFrame % 300) == 0;
|
||||
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses);
|
||||
if (_diagnosticGameComplete)
|
||||
DiagnosticPatches.Write("[diagnostics] all game-side diagnostic hooks registered");
|
||||
if (!_antiTamperCallbackPatchesComplete && ++_antiTamperCallbackRetryFrame >= 300)
|
||||
{
|
||||
_antiTamperCallbackRetryFrame = 0;
|
||||
RegisterAntiTamperCallbackPatches(logMisses: false);
|
||||
}
|
||||
|
||||
if (!_toxModPatchesComplete && ++_toxModRetryFrame >= 300)
|
||||
{
|
||||
_toxModRetryFrame = 0;
|
||||
RegisterToxModPatches(logMisses: false);
|
||||
}
|
||||
|
||||
if (Cfg.EnableDebugConsole)
|
||||
DebugConsolePatches.PollToggleKey();
|
||||
|
||||
if (Cfg.DiagnoseDevMenu)
|
||||
DevMenuProbe.Tick();
|
||||
}
|
||||
|
||||
public override void OnApplicationQuit()
|
||||
{
|
||||
DiagnosticPatches.Write("[lifecycle] OnApplicationQuit");
|
||||
}
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────
|
||||
@@ -610,19 +748,25 @@ public class Mod : MelonMod
|
||||
{
|
||||
try
|
||||
{
|
||||
// Compute the UserData path ourselves rather than calling
|
||||
// MelonLoader.MelonUtils.UserDataDirectory (which is renamed
|
||||
// to MelonEnvironment.UserDataDirectory in newer 0.6.x builds
|
||||
// and shifts again in 0.7.x). Anchoring off the mod DLL's
|
||||
// location keeps this stable across versions: the DLL lives
|
||||
// in <game>/Mods/, so two dirname()'s reach the game root,
|
||||
// then MelonLoader/UserData/ is right next to it.
|
||||
var modDll = Assembly.GetExecutingAssembly().Location;
|
||||
var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty;
|
||||
var gameDir = Path.GetDirectoryName(modsDir) ?? string.Empty;
|
||||
var userData = Path.Combine(gameDir, "MelonLoader", "UserData");
|
||||
var userData = ResolveUserDataDirectory();
|
||||
Directory.CreateDirectory(userData);
|
||||
DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log");
|
||||
|
||||
// Auto-enable diagnostics when the separate DorkNet.DebugMod.dll is
|
||||
// dropped in alongside this mod. Done before the config read (and
|
||||
// before the no-config-file early return) so presence alone is
|
||||
// enough — no config edit required to debug.
|
||||
try
|
||||
{
|
||||
var modsDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
|
||||
if (modsDir != null && File.Exists(Path.Combine(modsDir, "DorkNet.DebugMod.dll")))
|
||||
{
|
||||
Cfg.EnableDiagnostics = true;
|
||||
Log.Msg("[config] DorkNet.DebugMod.dll present → diagnostics ENABLED");
|
||||
}
|
||||
}
|
||||
catch { /* presence detection is best-effort */ }
|
||||
|
||||
var path = Path.Combine(userData, "dorknet-clientmod.json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
@@ -631,21 +775,30 @@ public class Mod : MelonMod
|
||||
}
|
||||
var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
var r = doc.RootElement;
|
||||
if (r.TryGetProperty("ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost;
|
||||
if (r.TryGetProperty("PhotonAppId", out v)) Cfg.PhotonAppId = v.GetString() ?? Cfg.PhotonAppId;
|
||||
if (r.TryGetProperty("PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId;
|
||||
if (r.TryGetProperty("PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion;
|
||||
if (r.TryGetProperty("EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
|
||||
if (r.TryGetProperty("TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean();
|
||||
if (r.TryGetProperty("EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
|
||||
if (r.TryGetProperty("DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
|
||||
if (r.TryGetProperty("DesktopScreenShareFps", out v)) Cfg.DesktopScreenShareFps = (float)v.GetDouble();
|
||||
if (r.TryGetProperty("DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
|
||||
if (r.TryGetProperty("DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
|
||||
if (r.TryGetProperty("DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32();
|
||||
if (r.TryGetProperty("DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
|
||||
if (r.TryGetProperty("QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
|
||||
if (r.TryGetProperty("QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
if (TryGetConfigValue(r, "ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost;
|
||||
if (TryGetConfigValue(r, "EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
|
||||
if (TryGetConfigValue(r, "DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "EnableBuiltInDevDebugTools", out v)) Cfg.EnableBuiltInDevDebugTools = v.GetBoolean();
|
||||
// Config can also enable diagnostics; OR with the debug-mod
|
||||
// presence check above so either path turns them on.
|
||||
if (TryGetConfigValue(r, "EnableDiagnostics", out v)) Cfg.EnableDiagnostics |= v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var commands = new List<string>();
|
||||
foreach (var e in v.EnumerateArray())
|
||||
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
|
||||
commands.Add(e.GetString()!);
|
||||
Cfg.DevCommands = commands.ToArray();
|
||||
}
|
||||
if (TryGetConfigValue(r, "DesktopScreenShareFps", out v)) Cfg.DesktopScreenShareFps = (float)v.GetDouble();
|
||||
if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
|
||||
if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32();
|
||||
if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
|
||||
if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var rooms = new List<string>();
|
||||
foreach (var e in v.EnumerateArray())
|
||||
@@ -653,20 +806,7 @@ public class Mod : MelonMod
|
||||
rooms.Add(e.GetString()!);
|
||||
Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none)
|
||||
}
|
||||
if (r.TryGetProperty("EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean();
|
||||
if (r.TryGetProperty("DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var list = new List<string>();
|
||||
foreach (var e in v.EnumerateArray())
|
||||
{
|
||||
var s = e.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(s)) list.Add(s!);
|
||||
}
|
||||
if (list.Count > 0) Cfg.DevCommands = list.ToArray();
|
||||
}
|
||||
// "InjectAuthValues" key in the template is now ignored —
|
||||
// see attic/AuthValuesInjector.cs.attic.
|
||||
Log.Msg($"[config] loaded: ServerHost={Cfg.ServerHost}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "<unset>" : "<set>")}, " +
|
||||
Log.Msg($"[config] loaded from {path}: ServerHost={Cfg.ServerHost}, " +
|
||||
$"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -675,6 +815,44 @@ public class Mod : MelonMod
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveUserDataDirectory()
|
||||
{
|
||||
foreach (var typeName in new[]
|
||||
{
|
||||
"MelonLoader.MelonEnvironment, MelonLoader",
|
||||
"MelonLoader.MelonUtils, MelonLoader",
|
||||
})
|
||||
{
|
||||
var type = Type.GetType(typeName);
|
||||
var prop = type?.GetProperty("UserDataDirectory", BindingFlags.Public | BindingFlags.Static);
|
||||
if (prop?.GetValue(null) is string value && !string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
}
|
||||
|
||||
var modDll = Assembly.GetExecutingAssembly().Location;
|
||||
var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty;
|
||||
var gameDir = Path.GetDirectoryName(modsDir) ?? AppContext.BaseDirectory;
|
||||
return Path.Combine(gameDir, "MelonLoader", "UserData");
|
||||
}
|
||||
|
||||
private static bool TryGetConfigValue(JsonElement root, string name, out JsonElement value)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = prop.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Patch dispatch helpers ────────────────────────────────────────
|
||||
private bool TryPatch(string label, Type target, string methodName,
|
||||
Type[]? args, string? prefix = null, string? postfix = null,
|
||||
@@ -792,6 +970,8 @@ public class Mod : MelonMod
|
||||
{
|
||||
var t = asm.GetType(name, throwOnError: false);
|
||||
if (t is not null) return CacheResolvedType(name, t);
|
||||
var dottedPrefixed = asm.GetType("Il2Cpp." + name, throwOnError: false);
|
||||
if (dottedPrefixed is not null) return CacheResolvedType(name, dottedPrefixed);
|
||||
var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false);
|
||||
if (prefixed is not null) return CacheResolvedType(name, prefixed);
|
||||
// For namespaced types like "RecNet.Core", also try with
|
||||
@@ -847,18 +1027,22 @@ public class Mod : MelonMod
|
||||
private static bool MatchesRequestedType(Type type, string requested)
|
||||
{
|
||||
var prefixed = "Il2Cpp" + requested;
|
||||
if (type.FullName == requested || type.FullName == prefixed) return true;
|
||||
var dottedPrefixed = "Il2Cpp." + requested;
|
||||
if (type.FullName == requested || type.FullName == prefixed || type.FullName == dottedPrefixed) return true;
|
||||
if (type.Name == requested || type.Name == prefixed) return true;
|
||||
return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
|
||||
type.FullName?.EndsWith(".Il2Cpp." + requested, StringComparison.Ordinal) == true ||
|
||||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
|
||||
}
|
||||
|
||||
private static bool ReturnTypeMatches(Type type, string requested)
|
||||
{
|
||||
var prefixed = "Il2Cpp" + requested;
|
||||
if (type.FullName == requested || type.FullName == prefixed) return true;
|
||||
var dottedPrefixed = "Il2Cpp." + requested;
|
||||
if (type.FullName == requested || type.FullName == prefixed || type.FullName == dottedPrefixed) return true;
|
||||
if (type.Name == requested || type.Name == prefixed) return true;
|
||||
return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
|
||||
type.FullName?.EndsWith(".Il2Cpp." + requested, StringComparison.Ordinal) == true ||
|
||||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
|
||||
}
|
||||
|
||||
@@ -875,7 +1059,7 @@ public class Mod : MelonMod
|
||||
{
|
||||
// Look in all three patch holder classes — small enough that a
|
||||
// linear scan is cheaper than per-class lookups.
|
||||
foreach (var holder in new[] { typeof(UriPatches), typeof(PhotonPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) })
|
||||
foreach (var holder in new[] { typeof(UriPatches), typeof(HttpTracePatches), typeof(AuthPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(QuitTracePatches), typeof(DiagnosticPatches), typeof(RoomLoadDiagnostics), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(BuiltInDevDebugToolsPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) })
|
||||
{
|
||||
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (m is not null) return m;
|
||||
@@ -883,6 +1067,30 @@ public class Mod : MelonMod
|
||||
throw new MissingMethodException($"patch method '{name}' not found on any holder");
|
||||
}
|
||||
|
||||
private void RegisterQuitTracePatches(bool logMisses)
|
||||
{
|
||||
if (!_quitTraceCoreRegistered)
|
||||
{
|
||||
TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes,
|
||||
prefix: nameof(QuitTracePatches.ApplicationQuit_Prefix),
|
||||
logMiss: logMisses);
|
||||
TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) },
|
||||
prefix: nameof(QuitTracePatches.ApplicationQuitInt_Prefix),
|
||||
logMiss: logMisses);
|
||||
_quitTraceCoreRegistered = true;
|
||||
}
|
||||
|
||||
var complete = true;
|
||||
complete &= TryPatchDiagnosticByName("OPEOLPDLEBO", "GDNAOLMIDLM",
|
||||
prefix: nameof(QuitTracePatches.ApplicationWantsToQuit_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("OPEOLPDLEBO", "DGHJGMBNHCL",
|
||||
prefix: nameof(QuitTracePatches.CreateExitTask_Prefix),
|
||||
logMiss: logMisses);
|
||||
|
||||
_quitTraceGameComplete = complete;
|
||||
}
|
||||
|
||||
private void RegisterDiagnostics()
|
||||
{
|
||||
Log.Msg("=== Registering diagnostics ===");
|
||||
@@ -901,8 +1109,6 @@ public class Mod : MelonMod
|
||||
|
||||
private bool RegisterGameDiagnostics(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
|
||||
// CheatManager fires these when its anti-cheat heuristics trip —
|
||||
// logging them surfaces "client thinks it's been tampered with",
|
||||
// a common cause of silent dorm-drops / kicks on a modded client.
|
||||
@@ -919,14 +1125,12 @@ public class Mod : MelonMod
|
||||
"OnAdvancedMovementCheatDetected",
|
||||
})
|
||||
{
|
||||
complete &= TryPatchDiagnosticByName("CheatManager", method,
|
||||
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
|
||||
logMiss: logMisses);
|
||||
TryPatchDiagnosticByName("CheatManager", method,
|
||||
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
|
||||
logMiss: logMisses);
|
||||
}
|
||||
|
||||
complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit",
|
||||
prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix),
|
||||
logMiss: logMisses);
|
||||
RegisterDeepRoomDiagnostics(logMisses);
|
||||
// SteamManager.Awake + SteamPlatformManager.*LoginInitialize were
|
||||
// pure-logging diagnostics that fired during Unity's very first
|
||||
// wake-up tick. On december (EAC build, IL2CPP wrapped Steam
|
||||
@@ -935,7 +1139,7 @@ public class Mod : MelonMod
|
||||
// hooks gave us no behavioral leverage — only a `[call] …` log
|
||||
// line — so they're disabled. If anyone needs Steam-side visibility
|
||||
// later, route it through the game's own log lines instead.
|
||||
return complete;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryPatchDiagnosticByName(string typeName, string methodName,
|
||||
@@ -952,6 +1156,211 @@ public class Mod : MelonMod
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryPatchDiagnosticAllOverloads(string typeName, string methodName,
|
||||
string? prefix = null, string? postfix = null,
|
||||
string? finalizer = null, bool logMiss = true)
|
||||
{
|
||||
var label = $"{typeName}.{methodName}(*)";
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
var type = ResolveType(typeName);
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var patched = 0;
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.Name != methodName) continue;
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method,
|
||||
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
|
||||
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)),
|
||||
finalizer: finalizer is null ? null : new HarmonyMethod(GetPatchMethod(finalizer)));
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] {type.FullName}.{method.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}: no overloads matched");
|
||||
return false;
|
||||
}
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
Log.Msg($"[patch-ok] {label} x{patched}");
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool RegisterDeepRoomDiagnostics(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
|
||||
complete &= TryPatchDiagnosticByName("UnityEngine.Debug", "LogError",
|
||||
args: new[] { typeof(object) },
|
||||
prefix: nameof(RoomLoadDiagnostics.LogError_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("UnityEngine.Debug", "LogException",
|
||||
args: new[] { typeof(Exception) },
|
||||
prefix: nameof(RoomLoadDiagnostics.LogException_Prefix),
|
||||
logMiss: logMisses);
|
||||
|
||||
foreach (var method in new[]
|
||||
{
|
||||
"ConnectUsingSettings",
|
||||
"ConnectToRegion",
|
||||
"JoinRoom",
|
||||
"JoinOrCreateRoom",
|
||||
"CreateRoom",
|
||||
"LeaveRoom",
|
||||
"Disconnect",
|
||||
})
|
||||
{
|
||||
complete &= TryPatchDiagnosticAllOverloads("PhotonNetwork", method,
|
||||
prefix: nameof(RoomLoadDiagnostics.NamedCall_Prefix),
|
||||
finalizer: nameof(RoomLoadDiagnostics.NamedCall_Finalizer),
|
||||
logMiss: logMisses);
|
||||
}
|
||||
|
||||
foreach (var item in new (string Type, string Method)[]
|
||||
{
|
||||
("CMMIJKCDKPL", "CDNPLAGEDBK"),
|
||||
("KGHEDLNPANK", "CDNPLAGEDBK"),
|
||||
("HMPNINFEDFB", "IJIOJAIBPKE"),
|
||||
("PhotonNetwork", "Disconnect"),
|
||||
})
|
||||
{
|
||||
complete &= TryPatchDiagnosticAllOverloads(item.Type, item.Method,
|
||||
prefix: nameof(RoomLoadDiagnostics.NamedCall_Prefix),
|
||||
finalizer: nameof(RoomLoadDiagnostics.NamedCall_Finalizer),
|
||||
logMiss: logMisses);
|
||||
}
|
||||
|
||||
complete &= PatchKnownRoomLoadMoveNext(logMisses);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Studio baked-asset load trace. Registered directly from the
|
||||
/// live client-patch flow (the deep diagnostics path
|
||||
/// <see cref="RegisterDiagnostics"/> is not wired into init, so probes
|
||||
/// placed there never run). Pinpoints where the baked-Studio-scene chain
|
||||
/// breaks: the unity-asset getter (does the client ever request a
|
||||
/// bundle), prefab registration (does a bundle ever load), and the prefab
|
||||
/// lookups that fail with "Expected a prefab, but found none".
|
||||
/// RecRoomObjectPrefabManager is name-preserved; the getter is the
|
||||
/// obfuscated NLDBPDCNNCF.FKLDMPMFLBD. Each is a single overload.</summary>
|
||||
private void RegisterStudioTracePatches()
|
||||
{
|
||||
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "RegisterPrefabs",
|
||||
postfix: nameof(RoomLoadDiagnostics.StudioRegisterPrefabs_Postfix));
|
||||
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "TryGetPrefab",
|
||||
postfix: nameof(RoomLoadDiagnostics.StudioTryGetPrefab_Postfix));
|
||||
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "IsKnownPrefab",
|
||||
postfix: nameof(RoomLoadDiagnostics.StudioIsKnownPrefab_Postfix));
|
||||
TryPatchByName("NLDBPDCNNCF", "FKLDMPMFLBD",
|
||||
prefix: nameof(RoomLoadDiagnostics.StudioUnityAssetFetch_Prefix));
|
||||
}
|
||||
|
||||
private bool PatchKnownRoomLoadMoveNext(bool logMiss)
|
||||
{
|
||||
const string label = "room-load-state-machines.MoveNext";
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
var nameNeedles = new[]
|
||||
{
|
||||
"OMJKHJLFOCO", "AFMPFLKGABO",
|
||||
"AOEBBIFGIKI", "FINJCCNPHEP", "FDDFOKNJIFL", "GMNHJNAHAAI",
|
||||
"MHDOLJJPEAD", "LFGLDJCMJDN", "PFDIKJHMBDI", "ICAGCHKPIOM",
|
||||
"JEBACILNLJI", "DINBAFNHLPJ", "HLHPNABABME", "PAPLNIPKAMG",
|
||||
"IBEOONPEELF", "JHPHMNGOFLB", "NLDBPDCNNCF", "LONOBOBHMJL",
|
||||
"BNDIAONDFFF", "HOONLPFFADG", "PHODPEHLHGM",
|
||||
};
|
||||
|
||||
var patched = 0;
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
foreach (var type in GetLoadableTypes(asm))
|
||||
{
|
||||
var fullName = type.FullName ?? type.Name;
|
||||
var match = false;
|
||||
foreach (var needle in nameNeedles)
|
||||
{
|
||||
if (fullName.IndexOf(needle, StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
match = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!match) continue;
|
||||
|
||||
IEnumerable<MethodInfo> methods;
|
||||
try { methods = AccessTools.GetDeclaredMethods(type); }
|
||||
catch { continue; }
|
||||
|
||||
foreach (var method in methods)
|
||||
{
|
||||
if (method.Name != "MoveNext") continue;
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method,
|
||||
finalizer: new HarmonyMethod(GetPatchMethod(nameof(RoomLoadDiagnostics.MoveNext_Finalizer))));
|
||||
patched++;
|
||||
Log.Msg($"[patch-ok] room-load MoveNext {type.FullName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] room-load MoveNext {type.FullName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
if (logMiss) Log.Warning("[patch-miss] room-load state-machine MoveNext hooks");
|
||||
return false;
|
||||
}
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void RegisterProcessExceptionHandlers()
|
||||
{
|
||||
if (_processExceptionHandlersRegistered) return;
|
||||
_processExceptionHandlersRegistered = true;
|
||||
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var ex = e.ExceptionObject as Exception;
|
||||
DiagnosticPatches.Write($"[process-exception] unhandled terminating={e.IsTerminating} {RoomLoadDiagnostics.FormatException(ex, e.ExceptionObject)}");
|
||||
}
|
||||
catch { }
|
||||
};
|
||||
|
||||
TaskScheduler.UnobservedTaskException += (_, e) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write($"[process-exception] unobserved-task {RoomLoadDiagnostics.FormatException(e.Exception, e.Exception)}");
|
||||
}
|
||||
catch { }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Patch holders ─────────────────────────────────────────────────────
|
||||
@@ -959,7 +1368,369 @@ public class Mod : MelonMod
|
||||
// Harmony invokes. Kept separate from Mod for readability and so the
|
||||
// reflection lookup in GetPatchMethod stays simple.
|
||||
|
||||
internal static class RoomLoadDiagnostics
|
||||
{
|
||||
private static readonly Dictionary<string, int> Counts = new();
|
||||
private static int _globalCount;
|
||||
|
||||
public static void LogError_Prefix(object? message)
|
||||
{
|
||||
DiagnosticPatches.Write($"[unity-error] LogError {Sanitize(message?.ToString() ?? "<null>", 1600)}");
|
||||
WriteStack("unity-error");
|
||||
}
|
||||
|
||||
public static void LogException_Prefix(Exception exception)
|
||||
{
|
||||
DiagnosticPatches.Write($"[unity-exception] {FormatException(exception, exception)}");
|
||||
WriteStack("unity-exception");
|
||||
}
|
||||
|
||||
public static void NamedCall_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
var name = FullMethodName(__originalMethod);
|
||||
if (!ShouldLog("call:" + name, 80)) return;
|
||||
|
||||
DiagnosticPatches.Write($"[runtime-call] ENTER {name} [{Signature(__originalMethod)}]");
|
||||
if (IsImportantCall(name))
|
||||
WriteStack("runtime-call");
|
||||
}
|
||||
|
||||
public static Exception? NamedCall_Finalizer(Exception? __exception, MethodBase __originalMethod)
|
||||
{
|
||||
if (__exception is not null)
|
||||
{
|
||||
DiagnosticPatches.Write($"[runtime-call] EXCEPTION {FullMethodName(__originalMethod)} {FormatException(__exception, __exception)}");
|
||||
WriteStack("runtime-call-exception");
|
||||
}
|
||||
return __exception;
|
||||
}
|
||||
|
||||
public static void MoveNext_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
var name = FullMethodName(__originalMethod);
|
||||
if (!ShouldLog("enter:" + name, 120)) return;
|
||||
DiagnosticPatches.Write($"[room-load] ENTER {name} state={DumpInstanceFields(__instance, 28)}");
|
||||
}
|
||||
|
||||
public static void MoveNext_Postfix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
var name = FullMethodName(__originalMethod);
|
||||
if (!ShouldLog("exit:" + name, 120)) return;
|
||||
DiagnosticPatches.Write($"[room-load] EXIT {name} state={DumpInstanceFields(__instance, 28)}");
|
||||
}
|
||||
|
||||
public static Exception? MoveNext_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (__exception is not null)
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[room-load] EXCEPTION {FullMethodName(__originalMethod)} " +
|
||||
$"{FormatException(__exception, __exception)} state={DumpInstanceFields(__instance, 40)}");
|
||||
WriteStack("room-load-exception");
|
||||
}
|
||||
return __exception;
|
||||
}
|
||||
|
||||
// ── Studio baked-asset load trace ──────────────────────────────────
|
||||
// Pinpoints where the baked-Studio-scene load chain breaks. The room
|
||||
// joins but renders no custom geometry and logs "Expected a prefab,
|
||||
// but found none"; the server serves everything but the client never
|
||||
// requests a bundle. These probes answer: (1) does the client ever
|
||||
// call the RecNet "get unity asset" API (FKLDMPMFLBD); (2) does it
|
||||
// ever register baked prefabs; (3) which prefab GUIDs does it look up
|
||||
// and miss.
|
||||
|
||||
/// <summary>RecNet unity-asset getter
|
||||
/// <c>NLDBPDCNNCF.FKLDMPMFLBD(string unityAssetId, byte target, int version, CancellationToken)</c>
|
||||
/// — the call that builds <c>unity_assets/{id}/{target}/{version}</c>. If
|
||||
/// this never fires, the client decided not to fetch the bundle at all
|
||||
/// (trigger missing upstream); if it fires, the URL/host/response is the
|
||||
/// problem.</summary>
|
||||
public static void StudioUnityAssetFetch_Prefix(object? __0, object? __1, object? __2)
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[studio] UNITY-ASSET FETCH id={__0?.ToString() ?? "<null>"} " +
|
||||
$"target={__1?.ToString() ?? "?"} version={__2?.ToString() ?? "?"}");
|
||||
WriteStack("studio-assetfetch");
|
||||
}
|
||||
|
||||
/// <summary><c>RecRoomObjectPrefabManager.RegisterPrefabs</c> — baked
|
||||
/// prefabs being registered after a bundle loads. If this never fires,
|
||||
/// no bundle was loaded.</summary>
|
||||
public static void StudioRegisterPrefabs_Postfix(object? __0)
|
||||
{
|
||||
int count = -1;
|
||||
try
|
||||
{
|
||||
if (__0 is not null)
|
||||
{
|
||||
var p = __0.GetType().GetProperty("Count");
|
||||
if (p?.GetValue(__0) is int c) count = c;
|
||||
}
|
||||
}
|
||||
catch { /* best-effort count */ }
|
||||
DiagnosticPatches.Write($"[studio] RegisterPrefabs count={count}");
|
||||
WriteStack("studio-registerprefabs");
|
||||
}
|
||||
|
||||
/// <summary><c>RecRoomObjectPrefabManager.TryGetPrefab(Guid, out GameObject)</c>
|
||||
/// — the lookup that fails with "Expected a prefab, but found none".
|
||||
/// Logs the GUID + whether it resolved.</summary>
|
||||
public static void StudioTryGetPrefab_Postfix(object? __0, bool __result)
|
||||
{
|
||||
if (!ShouldLog("studio-trygetprefab", 200)) return;
|
||||
DiagnosticPatches.Write(
|
||||
$"[studio] TryGetPrefab guid={__0?.ToString() ?? "<null>"} found={__result}");
|
||||
}
|
||||
|
||||
/// <summary><c>RecRoomObjectPrefabManager.IsKnownPrefab(Guid)</c>.</summary>
|
||||
public static void StudioIsKnownPrefab_Postfix(object? __0, bool __result)
|
||||
{
|
||||
if (!ShouldLog("studio-isknownprefab", 200)) return;
|
||||
DiagnosticPatches.Write(
|
||||
$"[studio] IsKnownPrefab guid={__0?.ToString() ?? "<null>"} known={__result}");
|
||||
}
|
||||
|
||||
public static string FormatException(Exception? ex, object? fallback)
|
||||
{
|
||||
if (ex is null) return fallback?.ToString() ?? "<null>";
|
||||
var builder = new StringBuilder();
|
||||
AppendException(builder, ex, 0);
|
||||
return Sanitize(builder.ToString(), 5000);
|
||||
}
|
||||
|
||||
private static void AppendException(StringBuilder builder, Exception ex, int depth)
|
||||
{
|
||||
if (depth > 4) return;
|
||||
if (builder.Length > 0) builder.Append(" | ");
|
||||
builder.Append(ex.GetType().FullName).Append(": ").Append(ex.Message);
|
||||
if (!string.IsNullOrWhiteSpace(ex.StackTrace))
|
||||
builder.Append(" stack=").Append(ex.StackTrace);
|
||||
if (ex is AggregateException aggregate)
|
||||
{
|
||||
foreach (var inner in aggregate.InnerExceptions)
|
||||
AppendException(builder, inner, depth + 1);
|
||||
}
|
||||
else if (ex.InnerException is not null)
|
||||
{
|
||||
AppendException(builder, ex.InnerException, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldLog(string key, int perKeyLimit)
|
||||
{
|
||||
lock (Counts)
|
||||
{
|
||||
_globalCount++;
|
||||
if (_globalCount > 12000)
|
||||
return false;
|
||||
|
||||
Counts.TryGetValue(key, out var count);
|
||||
if (count >= perKeyLimit)
|
||||
return false;
|
||||
|
||||
Counts[key] = count + 1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string DumpInstanceFields(object? instance, int maxFields)
|
||||
{
|
||||
if (instance is null) return "<null>";
|
||||
try
|
||||
{
|
||||
var type = instance.GetType();
|
||||
var parts = new List<string>();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (parts.Count >= maxFields)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
|
||||
object? value;
|
||||
try { value = field.GetValue(instance); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
|
||||
continue;
|
||||
}
|
||||
|
||||
var described = DescribeValue(value, 400);
|
||||
if (ShouldExpandField(field, value))
|
||||
described += " " + DumpNestedFields(value, 16);
|
||||
parts.Add($"{field.Name}={described}");
|
||||
}
|
||||
|
||||
return $"{type.FullName} {{{string.Join("; ", parts)}}}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"<dump failed {ex.GetType().Name}: {Sanitize(ex.Message, 400)}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldExpandField(FieldInfo field, object? value)
|
||||
{
|
||||
if (value is null) return false;
|
||||
if (value is string) return false;
|
||||
|
||||
var valueType = value.GetType();
|
||||
if (valueType.IsPrimitive || valueType.IsEnum || value is decimal)
|
||||
return false;
|
||||
if (value is IEnumerable)
|
||||
return false;
|
||||
|
||||
var fieldName = field.Name ?? string.Empty;
|
||||
var typeName = valueType.FullName ?? valueType.Name;
|
||||
return fieldName.IndexOf("this", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
typeName.IndexOf("OMJKHJLFOCO", StringComparison.Ordinal) >= 0 ||
|
||||
typeName.IndexOf("RoomPermissions", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private static string DumpNestedFields(object value, int maxFields)
|
||||
{
|
||||
try
|
||||
{
|
||||
var type = value.GetType();
|
||||
var parts = new List<string>();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (parts.Count >= maxFields)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
|
||||
object? nested;
|
||||
try { nested = field.GetValue(value); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
|
||||
continue;
|
||||
}
|
||||
|
||||
parts.Add($"{field.Name}={DescribeValue(nested, 220)}");
|
||||
}
|
||||
|
||||
return "{nested " + string.Join("; ", parts) + "}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"{{nested dump failed {ex.GetType().Name}: {Sanitize(ex.Message, 220)}}}";
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribeValue(object? value, int limit)
|
||||
{
|
||||
if (value is null) return "null";
|
||||
|
||||
var type = value.GetType();
|
||||
if (value is string text) return "\"" + Sanitize(text, limit) + "\"";
|
||||
if (value is Exception ex) return FormatException(ex, ex);
|
||||
if (type.IsPrimitive || type.IsEnum || value is decimal)
|
||||
return Sanitize(value.ToString() ?? "<null>", limit);
|
||||
if (value is IEnumerable enumerable && value is not string)
|
||||
return DescribeEnumerable(enumerable, type, limit);
|
||||
|
||||
var rendered = value.ToString();
|
||||
if (string.IsNullOrWhiteSpace(rendered) || rendered == type.FullName)
|
||||
return "<" + type.FullName + ">";
|
||||
return "<" + type.FullName + " " + Sanitize(rendered, limit) + ">";
|
||||
}
|
||||
|
||||
private static string DescribeEnumerable(IEnumerable enumerable, Type type, int limit)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
var count = 0;
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
if (count++ >= 6)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
parts.Add(DescribeValue(item, 120));
|
||||
}
|
||||
return "<" + type.FullName + " [" + Sanitize(string.Join(", ", parts), limit) + "]>";
|
||||
}
|
||||
|
||||
private static string Signature(MethodBase method)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parameters = method.GetParameters();
|
||||
var parts = new List<string>(parameters.Length);
|
||||
foreach (var parameter in parameters)
|
||||
parts.Add(parameter.ParameterType.FullName ?? parameter.ParameterType.Name);
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "<signature unread>";
|
||||
}
|
||||
}
|
||||
|
||||
private static string FullMethodName(MethodBase method) =>
|
||||
$"{method.DeclaringType?.FullName ?? "<null>"}.{method.Name}";
|
||||
|
||||
private static bool IsImportantCall(string name) =>
|
||||
name.IndexOf("Disconnect", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
name.IndexOf("Join", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
name.IndexOf("Photon", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
name.IndexOf("CDNPLAGEDBK", StringComparison.Ordinal) >= 0 ||
|
||||
name.IndexOf("IJIOJAIBPKE", StringComparison.Ordinal) >= 0;
|
||||
|
||||
private static void WriteStack(string tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write($"[{tag}-stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DiagnosticPatches.Write($"[{tag}-stack] failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string Sanitize(string value, int limit)
|
||||
{
|
||||
var redacted = Regex.Replace(value, @"(?i)(Bearer\s+)[-._~+/=A-Za-z0-9]+", "$1<redacted>");
|
||||
redacted = Regex.Replace(redacted, @"(?i)(access_token|accessToken|refresh_token|refreshToken|authorization|token|key)=([^&\s]+)", "$1=<redacted>");
|
||||
redacted = redacted.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t");
|
||||
return redacted.Length <= limit ? redacted : redacted[..limit] + "...<truncated>";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Debug console access ───────────────────────────────────────────────
|
||||
// Built-in watch Dev Debug Tools hooks. The 2023 RRUI page already exists,
|
||||
// but retail rows are hidden by GlobalModelController hide gates. We keep
|
||||
// those rows visible and append the debug-console command table to the
|
||||
// built-in Build Information text so the watch page reflects the real client
|
||||
// command config.
|
||||
internal static class BuiltInDevDebugToolsPatches
|
||||
{
|
||||
public static bool KeepVisible_Prefix() => false;
|
||||
|
||||
public static void BuildInfoFormatted_Postfix(ref string __result)
|
||||
{
|
||||
try
|
||||
{
|
||||
var commands = DebugConsolePatches.BuildCommandReferenceText("Debug Console Commands", maxCommands: 60);
|
||||
__result = string.IsNullOrWhiteSpace(__result)
|
||||
? commands
|
||||
: __result.TrimEnd() + "\n\n" + commands;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[devtools] failed to append command list: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The 2020 client ships RecRoom.Debugging.DebugConsole — a real in-game
|
||||
// dev console (text input + a DebugConsoleCommandConfig command table:
|
||||
// SetTimeScale, Fly, Teleport, GoToRoom, KillAllEnemies, DebugCompleteDaily,
|
||||
@@ -970,12 +1741,13 @@ public class Mod : MelonMod
|
||||
//
|
||||
// Everything is resolved by reflection at runtime so the SAME code works on
|
||||
// both client builds despite their differing obfuscation:
|
||||
// * The show/hide toggle is the one non-compiler-generated private
|
||||
// instance void(bool) on DebugConsole — ShowInputField on 2020.03,
|
||||
// MCNBJFECHLJ on 2020.12. We try the readable name, then the obfuscated
|
||||
// one, then fall back to that structural match.
|
||||
// * The live component is found via UnityEngine.Object.FindObjectOfType
|
||||
// (name-stable) rather than the SingletonMonoBehaviour<T> accessor.
|
||||
// * The show/hide toggle is the one private instance void(bool) on
|
||||
// DebugConsole — ShowInputField on 2020.03, MCNBJFECHLJ on 2020.12,
|
||||
// KIAMDEDKFBC on 2023.03. We try the known names, then fall back to
|
||||
// a structural match.
|
||||
// * The live component is found via Resources.FindObjectsOfTypeAll
|
||||
// using an Il2CppSystem.Type argument; this interop build does not
|
||||
// expose UnityEngine.Object.FindObjectOfType(System.Type).
|
||||
// * Execute(string) keeps its readable name on both builds (kept here as
|
||||
// a scripted-command entry point even though the hotkey drives the UI).
|
||||
internal static class DebugConsolePatches
|
||||
@@ -992,12 +1764,24 @@ internal static class DebugConsolePatches
|
||||
private static bool _consoleResolved;
|
||||
private static Type? _consoleType;
|
||||
private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool)
|
||||
private static MethodInfo? _findObjectOfType; // UnityEngine.Object.FindObjectOfType(Type)
|
||||
private static MethodInfo? _setConsoleText; // DebugConsole.SetConsoleText(string)
|
||||
private static List<ConfiguredCommand>? _commandCache;
|
||||
|
||||
// Harmony prefix returning false → skips the original CheatManager
|
||||
// detector, so a tripped heuristic never runs its punish/drop path.
|
||||
public static bool SuppressCheatDetected_Prefix() => false;
|
||||
|
||||
public static string FormatConfiguredCommandsForLog()
|
||||
{
|
||||
var commands = GetAvailableCommands();
|
||||
if (commands.Count == 0) return "<none>";
|
||||
|
||||
var parts = new List<string>();
|
||||
foreach (var command in commands)
|
||||
parts.Add($"{command.Label}={command.Command}");
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
|
||||
// Polled every frame from Mod.OnUpdate while EnableDebugConsole is set.
|
||||
public static void PollToggleKey()
|
||||
{
|
||||
@@ -1033,7 +1817,7 @@ internal static class DebugConsolePatches
|
||||
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] probe: DebugConsole type NOT resolved"); return; }
|
||||
try
|
||||
{
|
||||
var active = _findObjectOfType?.Invoke(null, new object[] { _consoleType });
|
||||
var active = FindConsoleInstance();
|
||||
Mod.Log.Msg($"[debugconsole] probe: type={_consoleType.FullName}, toggleMethod={(_toggleMethod?.Name ?? "<none>")}, activeInstance={(active is null ? "null" : "found")}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] probe failed: {ex.Message}"); }
|
||||
@@ -1086,13 +1870,9 @@ internal static class DebugConsolePatches
|
||||
// name, then a structural fallback that fits both builds.
|
||||
_toggleMethod = AccessTools.Method(_consoleType, "ShowInputField", new[] { typeof(bool) })
|
||||
?? AccessTools.Method(_consoleType, "MCNBJFECHLJ", new[] { typeof(bool) })
|
||||
?? AccessTools.Method(_consoleType, "KIAMDEDKFBC", new[] { typeof(bool) })
|
||||
?? FindToggleByShape(_consoleType);
|
||||
if (_toggleMethod is null) Mod.Log.Warning("[debugconsole] show/hide method not found");
|
||||
|
||||
var objType = Mod.ResolveType("UnityEngine.Object");
|
||||
_findObjectOfType = objType is null ? null
|
||||
: AccessTools.Method(objType, "FindObjectOfType", new[] { typeof(Type) });
|
||||
if (_findObjectOfType is null) Mod.Log.Warning("[debugconsole] Object.FindObjectOfType(Type) not found");
|
||||
}
|
||||
|
||||
// The console's show/hide is the single private, instance, void method
|
||||
@@ -1118,10 +1898,10 @@ internal static class DebugConsolePatches
|
||||
public static void Toggle()
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null || _toggleMethod is null || _findObjectOfType is null) return;
|
||||
if (_consoleType is null || _toggleMethod is null) return;
|
||||
try
|
||||
{
|
||||
var instance = _findObjectOfType.Invoke(null, new object[] { _consoleType });
|
||||
var instance = FindConsoleInstance();
|
||||
if (instance is null)
|
||||
{
|
||||
Mod.Log.Warning("[debugconsole] no live DebugConsole in scene — it may not be instantiated on this build");
|
||||
@@ -1134,10 +1914,236 @@ internal static class DebugConsolePatches
|
||||
_consoleVisible = !_consoleVisible;
|
||||
_toggleMethod.Invoke(instance, new object[] { _consoleVisible });
|
||||
Mod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
|
||||
if (_consoleVisible)
|
||||
ShowConfiguredCommands(instance);
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static object? FindConsoleInstance()
|
||||
{
|
||||
if (_consoleType is null) return null;
|
||||
try
|
||||
{
|
||||
var il2cppType = ToIl2CppType(_consoleType);
|
||||
if (il2cppType is null) return null;
|
||||
|
||||
var resources = Mod.ResolveType("UnityEngine.Resources");
|
||||
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType });
|
||||
var count = ArrLen(all);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var item = ArrItem(all, i);
|
||||
if (item is not null) return item;
|
||||
}
|
||||
|
||||
var objType = Mod.ResolveType("UnityEngine.Object");
|
||||
var findOne = OneArgMethod(objType, "FindObjectOfType", il2cppType);
|
||||
return findOne?.Invoke(null, new[] { il2cppType });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[debugconsole] FindConsoleInstance failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowConfiguredCommands(object instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_consoleType is null) return;
|
||||
_setConsoleText ??= AccessTools.Method(_consoleType, "SetConsoleText", new[] { typeof(string) });
|
||||
if (_setConsoleText is null)
|
||||
{
|
||||
Mod.Log.Warning($"[debugconsole] SetConsoleText(string) not found; configured commands: {FormatConfiguredCommandsForLog()}");
|
||||
return;
|
||||
}
|
||||
|
||||
_setConsoleText.Invoke(instance, new object[] { BuildConfiguredCommandText() });
|
||||
Mod.Log.Msg($"[debugconsole] displayed configured commands: {FormatConfiguredCommandsForLog()}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[debugconsole] failed to display configured commands: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildConfiguredCommandText()
|
||||
{
|
||||
return BuildCommandReferenceText("DorkNet debug commands", maxCommands: 80) + "\n\nType a command and press Enter.";
|
||||
}
|
||||
|
||||
public static string BuildCommandReferenceText(string header, int maxCommands = int.MaxValue)
|
||||
{
|
||||
var commands = GetAvailableCommands();
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(header);
|
||||
sb.AppendLine();
|
||||
if (commands.Count == 0)
|
||||
{
|
||||
sb.AppendLine("No DebugConsoleCommandConfig commands found.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var visible = Math.Min(commands.Count, maxCommands);
|
||||
for (var i = 0; i < visible; i++)
|
||||
sb.AppendLine($"{i + 1}. {commands[i].Label}: {commands[i].Command}");
|
||||
if (visible < commands.Count)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"+ {commands.Count - visible} more commands available through the debug console.");
|
||||
}
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
internal static List<ConfiguredCommand> GetAvailableCommands()
|
||||
{
|
||||
if (_commandCache is not null) return _commandCache;
|
||||
|
||||
var commands = new List<ConfiguredCommand>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
AddConfiguredCommands(commands, seen);
|
||||
AddRuntimeCommands(commands, seen);
|
||||
commands.Sort((a, b) => string.Compare(a.Label, b.Label, StringComparison.OrdinalIgnoreCase));
|
||||
_commandCache = commands;
|
||||
Mod.Log.Msg($"[debugconsole] command table loaded: {commands.Count} command(s)");
|
||||
return commands;
|
||||
}
|
||||
|
||||
private static void AddConfiguredCommands(List<ConfiguredCommand> commands, HashSet<string> seen)
|
||||
{
|
||||
foreach (var entry in Mod.Cfg.DevCommands)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry)) continue;
|
||||
var split = entry.IndexOf('=');
|
||||
var label = split >= 0 ? entry[..split].Trim() : entry.Trim();
|
||||
var command = split >= 0 ? entry[(split + 1)..].Trim() : entry.Trim();
|
||||
if (string.IsNullOrWhiteSpace(command)) continue;
|
||||
if (string.IsNullOrWhiteSpace(label)) label = command;
|
||||
AddCommand(commands, seen, label, command);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddRuntimeCommands(List<ConfiguredCommand> commands, HashSet<string> seen)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cfgType = Mod.ResolveType("RecRoom.Debugging.DebugConsoleCommandConfig");
|
||||
if (cfgType is null)
|
||||
{
|
||||
Mod.Log.Warning("[debugconsole] DebugConsoleCommandConfig type not found");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var method in cfgType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
|
||||
{
|
||||
if (method.GetParameters().Length != 0) continue;
|
||||
if (!cfgType.IsAssignableFrom(method.ReturnType)) continue;
|
||||
try { AddCommandConfig(method.Invoke(null, Array.Empty<object>()), commands, seen); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
var il2cppType = ToIl2CppType(cfgType);
|
||||
var resources = Mod.ResolveType("UnityEngine.Resources");
|
||||
if (il2cppType is not null && resources is not null)
|
||||
{
|
||||
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType });
|
||||
var count = ArrLen(all);
|
||||
for (var i = 0; i < count; i++)
|
||||
AddCommandConfig(ArrItem(all, i), commands, seen);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[debugconsole] runtime command scan failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddCommandConfig(object? config, List<ConfiguredCommand> commands, HashSet<string> seen)
|
||||
{
|
||||
if (config is null) return;
|
||||
var metas = GetMemberValue(config, "Metas");
|
||||
var count = ArrLen(metas);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var meta = ArrItem(metas, i);
|
||||
if (meta is null) continue;
|
||||
var methodName = ReadStringMember(meta, "MethodName");
|
||||
if (string.IsNullOrWhiteSpace(methodName)) continue;
|
||||
var declaringType = ReadStringMember(meta, "DeclaringType");
|
||||
var label = string.IsNullOrWhiteSpace(declaringType)
|
||||
? methodName
|
||||
: $"{ShortTypeName(declaringType)}.{methodName}";
|
||||
AddCommand(commands, seen, label, methodName);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddCommand(List<ConfiguredCommand> commands, HashSet<string> seen, string label, string command)
|
||||
{
|
||||
label = CleanLabel(label);
|
||||
command = (command ?? string.Empty).Trim();
|
||||
if (command.Length == 0 || !seen.Add(command)) return;
|
||||
commands.Add(new ConfiguredCommand(label, command));
|
||||
}
|
||||
|
||||
private static object? GetMemberValue(object instance, string name)
|
||||
{
|
||||
var type = instance.GetType();
|
||||
var prop = type.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (prop is not null && prop.CanRead && prop.GetIndexParameters().Length == 0)
|
||||
{
|
||||
try { return prop.GetValue(instance); } catch { }
|
||||
}
|
||||
|
||||
var field = type.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (field is not null)
|
||||
{
|
||||
try { return field.GetValue(instance); } catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ReadStringMember(object instance, string name)
|
||||
{
|
||||
return GetMemberValue(instance, name)?.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string CleanLabel(string label)
|
||||
{
|
||||
label = (label ?? string.Empty).Trim();
|
||||
if (label.Length == 0) return "Command";
|
||||
|
||||
var cleaned = new char[label.Length];
|
||||
for (var i = 0; i < label.Length; i++)
|
||||
{
|
||||
var ch = label[i];
|
||||
cleaned[i] = char.IsLetterOrDigit(ch) || ch is '.' or '_' or '-' or ' ' ? ch : '_';
|
||||
}
|
||||
|
||||
return new string(cleaned);
|
||||
}
|
||||
|
||||
private static string ShortTypeName(string typeName)
|
||||
{
|
||||
var idx = typeName.LastIndexOf('.');
|
||||
return idx >= 0 && idx < typeName.Length - 1 ? typeName[(idx + 1)..] : typeName;
|
||||
}
|
||||
|
||||
internal readonly struct ConfiguredCommand
|
||||
{
|
||||
public ConfiguredCommand(string label, string command)
|
||||
{
|
||||
Label = label;
|
||||
Command = command;
|
||||
}
|
||||
|
||||
public string Label { get; }
|
||||
public string Command { get; }
|
||||
}
|
||||
|
||||
private static void EnableShowButton()
|
||||
{
|
||||
if (_consoleType is null) return;
|
||||
@@ -1153,6 +2159,52 @@ internal static class DebugConsolePatches
|
||||
}
|
||||
catch { /* best-effort — the toggle still works without it */ }
|
||||
}
|
||||
|
||||
private static object? ToIl2CppType(Type managed)
|
||||
{
|
||||
try
|
||||
{
|
||||
var il2cppTypeType = Mod.ResolveType("Il2CppInterop.Runtime.Il2CppType");
|
||||
var from = il2cppTypeType?.GetMethod("From", new[] { typeof(Type), typeof(bool) })
|
||||
?? il2cppTypeType?.GetMethod("From", new[] { typeof(Type) });
|
||||
if (from is null) return null;
|
||||
var args = from.GetParameters().Length == 2
|
||||
? new object[] { managed, true }
|
||||
: new object[] { managed };
|
||||
return from.Invoke(null, args);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static MethodInfo? OneArgMethod(Type? type, string name, object arg)
|
||||
{
|
||||
if (type is null) return null;
|
||||
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (method.Name != name) continue;
|
||||
var parameters = method.GetParameters();
|
||||
if (parameters.Length == 1 && parameters[0].ParameterType.IsInstanceOfType(arg))
|
||||
return method;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ArrLen(object? array)
|
||||
{
|
||||
if (array is null) return 0;
|
||||
if (array is Array managedArray) return managedArray.Length;
|
||||
var type = array.GetType();
|
||||
var length = type.GetProperty("Length") ?? type.GetProperty("Count");
|
||||
return (length?.GetValue(array) as int?) ?? 0;
|
||||
}
|
||||
|
||||
private static object? ArrItem(object? array, int index)
|
||||
{
|
||||
if (array is null) return null;
|
||||
if (array is Array managedArray) return managedArray.GetValue(index);
|
||||
var getItem = array.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
return getItem?.Invoke(array, new object[] { index });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1692,40 +2744,929 @@ internal static class MakerPenGiftPreviewPatches
|
||||
}
|
||||
}
|
||||
|
||||
internal static class AuthPatches
|
||||
{
|
||||
private static string? _latestAccessToken;
|
||||
private static int _tokenLogCount;
|
||||
|
||||
public static void CaptureTokenArgs_Prefix(object? __0, object? __1, object? __2)
|
||||
{
|
||||
try
|
||||
{
|
||||
var accessToken = __0?.ToString();
|
||||
if (LooksLikeJwt(accessToken))
|
||||
_latestAccessToken = accessToken;
|
||||
else if (string.IsNullOrWhiteSpace(__0?.ToString()) && string.IsNullOrWhiteSpace(__1?.ToString()))
|
||||
_latestAccessToken = null;
|
||||
|
||||
if (_tokenLogCount++ < 3)
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
"[auth-trace] token setter " +
|
||||
$"access={Presence(__0)} refresh={Presence(__1)} key={Presence(__2)}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[auth-trace] token capture failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool LooksLikeJwt(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return false;
|
||||
|
||||
var dots = 0;
|
||||
foreach (var ch in value)
|
||||
if (ch == '.') dots++;
|
||||
return dots == 2;
|
||||
}
|
||||
|
||||
private static string Presence(object? value) =>
|
||||
string.IsNullOrWhiteSpace(value?.ToString()) ? "missing" : "present";
|
||||
|
||||
public static void ApplyBearerTokenHeader(object? request)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (request is null || string.IsNullOrWhiteSpace(_latestAccessToken)) return;
|
||||
var uri = TryReadUri(request);
|
||||
if (uri is null || !IsDorkNetServiceHost(uri.Host)) return;
|
||||
|
||||
var type = request.GetType();
|
||||
var setHeader = AccessTools.Method(type, "SetHeader", new[] { typeof(string), typeof(string) })
|
||||
?? AccessTools.Method(type, "AddHeader", new[] { typeof(string), typeof(string) });
|
||||
setHeader?.Invoke(request, new object[] { "Authorization", "Bearer " + _latestAccessToken });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_tokenLogCount++ < 6)
|
||||
Mod.Log.Warning($"[auth-trace] bearer propagation failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Uri? TryReadUri(object request)
|
||||
{
|
||||
var type = request.GetType();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
if (TryGetUri(type.GetProperty("Uri", flags), request, out var uri)) return uri;
|
||||
if (TryGetUri(type.GetField("<Uri>k__BackingField", flags), request, out uri)) return uri;
|
||||
|
||||
foreach (var prop in type.GetProperties(flags))
|
||||
{
|
||||
if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
if (TryGetUri(prop, request, out uri)) return uri;
|
||||
}
|
||||
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
if (TryGetUri(field, request, out uri)) return uri;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryGetUri(PropertyInfo? prop, object request, out Uri? uri)
|
||||
{
|
||||
uri = null;
|
||||
if (prop is null || !prop.CanRead || prop.GetIndexParameters().Length != 0) return false;
|
||||
uri = prop.GetValue(request) as Uri;
|
||||
return uri is not null;
|
||||
}
|
||||
|
||||
private static bool TryGetUri(FieldInfo? field, object request, out Uri? uri)
|
||||
{
|
||||
uri = null;
|
||||
if (field is null) return false;
|
||||
uri = field.GetValue(request) as Uri;
|
||||
return uri is not null;
|
||||
}
|
||||
|
||||
private static bool IsDorkNetServiceHost(string host)
|
||||
{
|
||||
var configuredHost = Mod.Cfg.ServerHost?.Trim().TrimStart('.');
|
||||
if (string.IsNullOrWhiteSpace(configuredHost)) return false;
|
||||
|
||||
return host.Equals(configuredHost, StringComparison.OrdinalIgnoreCase)
|
||||
|| host.EndsWith("." + configuredHost, StringComparison.OrdinalIgnoreCase)
|
||||
|| host.EndsWith("-" + configuredHost, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class HttpTracePatches
|
||||
{
|
||||
private const int BodyPreviewBytes = 700;
|
||||
private static readonly Regex SensitivePairRegex = new(
|
||||
@"(?i)(password|client_secret|access_token|accessToken|refresh_token|refreshToken|authorization|token|key)=([^&\s]+)",
|
||||
RegexOptions.Compiled);
|
||||
private static readonly Regex BearerRegex = new(
|
||||
@"(?i)Bearer\s+[-._~+/=A-Za-z0-9]+",
|
||||
RegexOptions.Compiled);
|
||||
private static readonly Regex SensitiveHeaderRegex = new(
|
||||
@"(?im)^(Authorization|Cookie|Set-Cookie|X-RNSIG):\s*[^\r\n]+",
|
||||
RegexOptions.Compiled);
|
||||
private static readonly Regex SensitiveJsonStringRegex = new(
|
||||
@"(?i)""([^""]*(?:access_token|accessToken|refresh_token|refreshToken|authorization|token|secret|client_secret|key)[^""]*)""\s*:\s*""[^""]*""",
|
||||
RegexOptions.Compiled);
|
||||
private static readonly Dictionary<IntPtr, string> RecNetRequestInfoByPtr = new();
|
||||
private static int _requestId;
|
||||
private static int _failureLogCount;
|
||||
private static int _responseTupleLogCount;
|
||||
private static int _builderSendLogCount;
|
||||
private static int _callbackLogCount;
|
||||
private static int _uriUnreadDumpCount;
|
||||
|
||||
public static void RecNetRequestSend_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
TraceRequestObject(__instance, $"{__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}");
|
||||
}
|
||||
|
||||
public static void RecNetRequestBuilderCtor_Prefix(object? __0, object? __1, object? __2)
|
||||
{
|
||||
var id = NextRequestId();
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-trace] #{id} BNDIAONDFFF.ctor method={SanitizeInline(__0?.ToString() ?? "<null>")} " +
|
||||
$"service={SanitizeInline(__1?.ToString() ?? "<null>")} path={SanitizeInline(__2?.ToString() ?? "<null>")}");
|
||||
}
|
||||
|
||||
public static void RecNetRequestBuilderCtor_Postfix(object __instance, object? __0, object? __1, object? __2)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ptr = TryGetIl2CppPtr(__instance);
|
||||
if (ptr == IntPtr.Zero) return;
|
||||
|
||||
var info =
|
||||
$"ptr=0x{ptr.ToInt64():X} method={SanitizeInline(__0?.ToString() ?? "<null>")} " +
|
||||
$"service={SanitizeInline(__1?.ToString() ?? "<null>")} path={SanitizeInline(__2?.ToString() ?? "<null>")}";
|
||||
lock (RecNetRequestInfoByPtr)
|
||||
{
|
||||
RecNetRequestInfoByPtr[ptr] = info;
|
||||
if (RecNetRequestInfoByPtr.Count > 256)
|
||||
RecNetRequestInfoByPtr.Clear();
|
||||
}
|
||||
|
||||
DiagnosticPatches.Write($"[recnet-request-map] {info}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-request-map", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecNetBuilderSend_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (System.Threading.Interlocked.Increment(ref _builderSendLogCount) > 120) return;
|
||||
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-send] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-send-enter", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecNetBuilderSend_Postfix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (_builderSendLogCount > 120) return;
|
||||
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-send] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-send-exit", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecNetResponseTuple_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (System.Threading.Interlocked.Increment(ref _responseTupleLogCount) > 80) return;
|
||||
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-response-map] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)} {DumpObjectFields(__instance, 20, 600)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-response-map-enter", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecNetResponseTuple_Postfix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (_responseTupleLogCount > 80) return;
|
||||
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-response-map] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)} {DumpObjectFields(__instance, 20, 600)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-response-map-exit", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static Exception? RecNetResponseTuple_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (__exception is not null)
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-response-map] EXCEPTION {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)} {RoomLoadDiagnostics.FormatException(__exception, __exception)} " +
|
||||
$"state={DumpObjectFields(__instance, 36, 1200)}");
|
||||
}
|
||||
return __exception;
|
||||
}
|
||||
|
||||
public static void HeaderSet_Prefix(object __instance, object? __0, object? __1, MethodBase __originalMethod)
|
||||
{
|
||||
try
|
||||
{
|
||||
var name = __0?.ToString() ?? "<null>";
|
||||
var value = RedactHeaderValue(name, __1?.ToString());
|
||||
var uri = TryReadRequestUri(__instance)?.ToString() ?? "<uri unread>";
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-header] {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name} " +
|
||||
$"{SanitizeInline(uri)} {name}: {value}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("header", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void CallCallback_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-callback] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{DescribeRequestAndResponse(__instance)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("callback-enter", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void CallCallback_Postfix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!ShouldLogCallback(__instance)) return;
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-callback] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{DescribeRequestAndResponse(__instance)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("callback-exit", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static Exception? CallCallback_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (__exception is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-callback] EXCEPTION {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{DescribeRequestAndResponse(__instance)} " +
|
||||
$"{RoomLoadDiagnostics.FormatException(__exception, __exception)} " +
|
||||
$"state={DumpObjectFields(__instance, 32, 900)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("callback-finalizer", ex);
|
||||
}
|
||||
}
|
||||
|
||||
return __exception;
|
||||
}
|
||||
|
||||
public static void TraceUrl(string? url, string source)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return;
|
||||
if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
|
||||
!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
var id = NextRequestId();
|
||||
DiagnosticPatches.Write($"[http-trace] #{id} {source} url={SanitizeInline(url)}");
|
||||
}
|
||||
|
||||
public static void TraceRequestObject(object? request, string source)
|
||||
{
|
||||
if (request is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var id = NextRequestId();
|
||||
var uriValue = TryReadRequestUri(request);
|
||||
var uri = uriValue?.ToString() ?? "<uri unread>";
|
||||
var method = TryReadHttpMethod(request);
|
||||
var headers = TryReadHeaders(request);
|
||||
var body = TryReadBodyPreview(request);
|
||||
var fallback = uriValue is null && System.Threading.Interlocked.Increment(ref _uriUnreadDumpCount) <= 40
|
||||
? $" requestFields={DumpObjectFields(request, 24, 260)}"
|
||||
: "";
|
||||
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-trace] #{id} {source} method={method} uri={SanitizeInline(uri)} " +
|
||||
$"headers={headers} body={body}{fallback}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure(source, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static int NextRequestId() => System.Threading.Interlocked.Increment(ref _requestId);
|
||||
|
||||
private static Uri? TryReadRequestUri(object request)
|
||||
{
|
||||
if (TryInvokeUriMethod(request, "KPKNHFBHPHO", out var uri)) return uri;
|
||||
if (TryInvokeUriMethod(request, "IBLHNHHBNNE", out uri)) return uri;
|
||||
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
var type = request.GetType();
|
||||
|
||||
if (TryReadUriValue(type.GetProperty("Uri", flags)?.GetValue(request), out uri)) return uri;
|
||||
if (TryReadUriValue(type.GetProperty("CurrentUri", flags)?.GetValue(request), out uri)) return uri;
|
||||
if (TryReadUriValue(type.GetField("<Uri>k__BackingField", flags)?.GetValue(request), out uri)) return uri;
|
||||
|
||||
foreach (var prop in type.GetProperties(flags))
|
||||
{
|
||||
if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
if (!prop.CanRead || prop.GetIndexParameters().Length != 0) continue;
|
||||
try
|
||||
{
|
||||
if (TryReadUriValue(prop.GetValue(request), out uri)) return uri;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
try
|
||||
{
|
||||
if (TryReadUriValue(field.GetValue(request), out uri)) return uri;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryInvokeUriMethod(object request, string methodName, out Uri? uri)
|
||||
{
|
||||
uri = null;
|
||||
try
|
||||
{
|
||||
var method = AccessTools.Method(request.GetType(), methodName, Type.EmptyTypes);
|
||||
if (method is null) return false;
|
||||
return TryReadUriValue(method.Invoke(request, Array.Empty<object>()), out uri);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadUriValue(object? value, out Uri? uri)
|
||||
{
|
||||
uri = null;
|
||||
if (value is null) return false;
|
||||
if (value is Uri direct)
|
||||
{
|
||||
uri = direct;
|
||||
return true;
|
||||
}
|
||||
|
||||
var text = value.ToString();
|
||||
if (Uri.TryCreate(text, UriKind.Absolute, out var parsed))
|
||||
{
|
||||
uri = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string TryReadHttpMethod(object request)
|
||||
{
|
||||
foreach (var name in new[] { "MethodType", "Method", "LNJILMKOBIP" })
|
||||
{
|
||||
if (TryReadMemberValue(request, name, out var value))
|
||||
return SanitizeInline(value?.ToString() ?? "<null>");
|
||||
}
|
||||
|
||||
return "<method unread>";
|
||||
}
|
||||
|
||||
private static string TryReadHeaders(object request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dump = AccessTools.Method(request.GetType(), "DumpHeaders", Type.EmptyTypes);
|
||||
if (dump?.Invoke(request, Array.Empty<object>()) is object rawDump)
|
||||
return SanitizeInline(rawDump.ToString() ?? "<empty>");
|
||||
}
|
||||
catch { }
|
||||
|
||||
var known = new List<string>();
|
||||
foreach (var name in new[]
|
||||
{
|
||||
"Authorization", "Cookie", "X-RNSIG", "Accept-Language",
|
||||
"Content-Type", "User-Agent"
|
||||
})
|
||||
{
|
||||
var value = TryReadHeaderValue(request, name);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
known.Add($"{name}:{RedactHeaderValue(name, value)}");
|
||||
}
|
||||
|
||||
return known.Count == 0 ? "<headers unread>" : string.Join("; ", known);
|
||||
}
|
||||
|
||||
private static string? TryReadHeaderValue(object request, string name)
|
||||
{
|
||||
foreach (var methodName in new[] { "GetFirstHeaderValue", "FKHEHIMHHBP" })
|
||||
{
|
||||
try
|
||||
{
|
||||
var method = AccessTools.Method(request.GetType(), methodName, new[] { typeof(string) });
|
||||
var value = method?.Invoke(request, new object[] { name })?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(value)) return value;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string TryReadBodyPreview(object request)
|
||||
{
|
||||
foreach (var methodName in new[] { "BGMFJOKNJAJ", "GetEntityBody" })
|
||||
{
|
||||
try
|
||||
{
|
||||
var method = AccessTools.Method(request.GetType(), methodName, Type.EmptyTypes);
|
||||
if (method is null) continue;
|
||||
var body = method.Invoke(request, Array.Empty<object>());
|
||||
var bytes = TryCopyBytes(body, BodyPreviewBytes + 1);
|
||||
if (bytes is null) continue;
|
||||
if (bytes.Length == 0) return "<empty>";
|
||||
|
||||
var shown = Math.Min(bytes.Length, BodyPreviewBytes);
|
||||
var text = Encoding.UTF8.GetString(bytes, 0, shown);
|
||||
var suffix = bytes.Length > BodyPreviewBytes ? "...<truncated>" : "";
|
||||
return $"{bytes.Length} bytes \"{SanitizeInline(text)}{suffix}\"";
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return "<body unread>";
|
||||
}
|
||||
|
||||
private static bool ShouldLogCallback(object request)
|
||||
{
|
||||
var response = TryReadResponseObject(request);
|
||||
var status = response is null ? null : TryReadStatusCode(response);
|
||||
if (status >= 400) return true;
|
||||
return System.Threading.Interlocked.Increment(ref _callbackLogCount) <= 80;
|
||||
}
|
||||
|
||||
private static string DescribeRequestAndResponse(object request)
|
||||
{
|
||||
var uri = TryReadRequestUri(request)?.ToString() ?? "<uri unread>";
|
||||
var method = TryReadHttpMethod(request);
|
||||
var response = TryReadResponseObject(request);
|
||||
var responseText = response is null ? "<response unread>" : DescribeResponseObject(response);
|
||||
var requestState = DescribeKnownMembers(request, "State", "Exception", "Error", "IsCancellationRequested");
|
||||
return $"method={method} uri={SanitizeInline(uri)} request={requestState} response={responseText}";
|
||||
}
|
||||
|
||||
private static object? TryReadResponseObject(object request)
|
||||
{
|
||||
foreach (var name in new[] { "Response", "response", "_response", "<Response>k__BackingField" })
|
||||
{
|
||||
if (TryReadMemberValue(request, name, out var value) && value is not null)
|
||||
return value;
|
||||
}
|
||||
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
foreach (var prop in request.GetType().GetProperties(flags))
|
||||
{
|
||||
if (prop.Name.IndexOf("Response", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
if (!prop.CanRead || prop.GetIndexParameters().Length != 0) continue;
|
||||
try
|
||||
{
|
||||
var value = prop.GetValue(request);
|
||||
if (value is not null) return value;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
foreach (var field in request.GetType().GetFields(flags))
|
||||
{
|
||||
if (field.Name.IndexOf("Response", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
try
|
||||
{
|
||||
var value = field.GetValue(request);
|
||||
if (value is not null) return value;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? TryReadStatusCode(object response)
|
||||
{
|
||||
if (TryReadMemberValue(response, "StatusCode", out var value))
|
||||
{
|
||||
try { return Convert.ToInt32(value); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string DescribeResponseObject(object response)
|
||||
{
|
||||
var known = DescribeKnownMembers(response,
|
||||
"StatusCode", "Message", "IsSuccess", "DataAsText", "Data", "Exception", "Error");
|
||||
return $"{response.GetType().FullName} {{{known}}}";
|
||||
}
|
||||
|
||||
private static string DescribeKnownMembers(object obj, params string[] names)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (!TryReadMemberValue(obj, name, out var value)) continue;
|
||||
parts.Add($"{name}={FormatValue(value, 700)}");
|
||||
}
|
||||
return parts.Count == 0 ? "<no known members>" : string.Join("; ", parts);
|
||||
}
|
||||
|
||||
private static string FormatValue(object? value, int limit)
|
||||
{
|
||||
if (value is null) return "null";
|
||||
if (value is byte[] bytes)
|
||||
return $"byte[{bytes.Length}]";
|
||||
if (value is IEnumerable enumerable && value is not string)
|
||||
return FormatEnumerable(enumerable, value.GetType(), limit);
|
||||
var text = value is Exception ex
|
||||
? $"{ex.GetType().Name}: {ex.Message}"
|
||||
: value.ToString() ?? "<null>";
|
||||
text = SanitizeInline(text);
|
||||
return text.Length <= limit ? text : text[..limit] + "...<truncated>";
|
||||
}
|
||||
|
||||
private static string FormatEnumerable(IEnumerable enumerable, Type type, int limit)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parts = new List<string>();
|
||||
var countText = TryReadEnumerableCount(enumerable, out var count) ? count.ToString() : "?";
|
||||
var shown = 0;
|
||||
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
if (shown++ >= 6)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
|
||||
parts.Add(FormatValue(item, 180));
|
||||
}
|
||||
|
||||
var rendered = $"{type.FullName} count={countText} [{string.Join(", ", parts)}]";
|
||||
return rendered.Length <= limit ? rendered : rendered[..limit] + "...<truncated>";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"{type.FullName} <enumeration failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadEnumerableCount(IEnumerable enumerable, out int count)
|
||||
{
|
||||
count = 0;
|
||||
if (enumerable is ICollection collection)
|
||||
{
|
||||
count = collection.Count;
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var prop = enumerable.GetType().GetProperty("Count", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (prop?.GetValue(enumerable) is int value)
|
||||
{
|
||||
count = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static byte[]? TryCopyBytes(object? value, int limit)
|
||||
{
|
||||
if (value is null) return null;
|
||||
if (value is byte[] direct)
|
||||
return CopyLimited(direct, limit);
|
||||
|
||||
if (value is IEnumerable enumerable)
|
||||
{
|
||||
var bytes = new List<byte>();
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
if (item is null) continue;
|
||||
try { bytes.Add(Convert.ToByte(item)); }
|
||||
catch { return null; }
|
||||
if (bytes.Count >= limit) break;
|
||||
}
|
||||
return bytes.ToArray();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] CopyLimited(byte[] source, int limit)
|
||||
{
|
||||
var count = Math.Min(source.Length, limit);
|
||||
var copy = new byte[count];
|
||||
Array.Copy(source, copy, count);
|
||||
return copy;
|
||||
}
|
||||
|
||||
private static bool TryReadMemberValue(object request, string name, out object? value)
|
||||
{
|
||||
value = null;
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
var type = request.GetType();
|
||||
|
||||
try
|
||||
{
|
||||
var prop = type.GetProperty(name, flags);
|
||||
if (prop is not null && prop.CanRead && prop.GetIndexParameters().Length == 0)
|
||||
{
|
||||
value = prop.GetValue(request);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
var field = type.GetField(name, flags);
|
||||
if (field is not null)
|
||||
{
|
||||
value = field.GetValue(request);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
var method = AccessTools.Method(type, name, Type.EmptyTypes);
|
||||
if (method is not null)
|
||||
{
|
||||
value = method.Invoke(request, Array.Empty<object>());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string DumpObjectFields(object? instance, int maxFields, int valueLimit)
|
||||
{
|
||||
if (instance is null) return "<null>";
|
||||
|
||||
try
|
||||
{
|
||||
var type = instance.GetType();
|
||||
var parts = new List<string>();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (parts.Count >= maxFields)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
|
||||
object? value;
|
||||
try { value = field.GetValue(instance); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
|
||||
continue;
|
||||
}
|
||||
|
||||
parts.Add($"{field.Name}={FormatValue(value, valueLimit)}");
|
||||
}
|
||||
|
||||
return $"{type.FullName} {{{string.Join("; ", parts)}}}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"<field dump failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static string LookupRecNetRequestInfo(object instance)
|
||||
{
|
||||
var ptr = TryGetIl2CppPtr(instance);
|
||||
if (ptr == IntPtr.Zero) return "ptr=<unread>";
|
||||
|
||||
lock (RecNetRequestInfoByPtr)
|
||||
{
|
||||
return RecNetRequestInfoByPtr.TryGetValue(ptr, out var info)
|
||||
? info
|
||||
: $"ptr=0x{ptr.ToInt64():X} request=<unmapped>";
|
||||
}
|
||||
}
|
||||
|
||||
private static string DumpBndNativeFields(object? instance)
|
||||
{
|
||||
var ptr = TryGetIl2CppPtr(instance);
|
||||
if (ptr == IntPtr.Zero) return "native=<unread>";
|
||||
|
||||
try
|
||||
{
|
||||
var method = Marshal.ReadByte(ptr, 16);
|
||||
var service = Marshal.ReadInt32(ptr, 20);
|
||||
var pathPtr = Marshal.ReadIntPtr(ptr, 24);
|
||||
var fieldsPtr = Marshal.ReadIntPtr(ptr, 32);
|
||||
var filesPtr = Marshal.ReadIntPtr(ptr, 40);
|
||||
var objectFieldsPtr = Marshal.ReadIntPtr(ptr, 48);
|
||||
var bodyKind = Marshal.ReadInt32(ptr, 56);
|
||||
var callbackPtr = Marshal.ReadIntPtr(ptr, 64);
|
||||
var path = ReadIl2CppString(pathPtr);
|
||||
|
||||
return
|
||||
"native={" +
|
||||
$"method={method}; service={service}; path={SanitizeInline(path)}; " +
|
||||
$"pathPtr=0x{pathPtr.ToInt64():X}; fieldsPtr=0x{fieldsPtr.ToInt64():X}; " +
|
||||
$"filesPtr=0x{filesPtr.ToInt64():X}; objectFieldsPtr=0x{objectFieldsPtr.ToInt64():X}; " +
|
||||
$"bodyKind={bodyKind}; callbackPtr=0x{callbackPtr.ToInt64():X}" +
|
||||
"}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"native=<dump failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadIl2CppString(IntPtr ptr)
|
||||
{
|
||||
if (ptr == IntPtr.Zero) return "<null>";
|
||||
|
||||
try
|
||||
{
|
||||
return IL2CPP.Il2CppStringToManaged(ptr) ?? "<null>";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"<string read failed 0x{ptr.ToInt64():X} {ex.GetType().Name}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static IntPtr TryGetIl2CppPtr(object? instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
return instance is Il2CppObjectBase il2CppObject
|
||||
? IL2CPP.Il2CppObjectBaseToPtrNotNull(il2CppObject)
|
||||
: IntPtr.Zero;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
private static string RedactHeaderValue(string name, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return "<empty>";
|
||||
if (name.Equals("Authorization", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Equals("Cookie", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Equals("X-RNSIG", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "<redacted>";
|
||||
}
|
||||
|
||||
return SanitizeInline(value);
|
||||
}
|
||||
|
||||
private static string SanitizeInline(string value)
|
||||
{
|
||||
var redacted = SensitivePairRegex.Replace(value, "$1=<redacted>");
|
||||
redacted = BearerRegex.Replace(redacted, "Bearer <redacted>");
|
||||
redacted = SensitiveHeaderRegex.Replace(redacted, "$1: <redacted>");
|
||||
redacted = SensitiveJsonStringRegex.Replace(redacted, "\"$1\":\"<redacted>\"");
|
||||
redacted = redacted.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t");
|
||||
return redacted.Length <= 1200 ? redacted : redacted[..1200] + "...<truncated>";
|
||||
}
|
||||
|
||||
private static void LogFailure(string source, Exception ex)
|
||||
{
|
||||
if (_failureLogCount++ < 10)
|
||||
Mod.Log.Warning($"[http-trace] {source} failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
internal static class UriPatches
|
||||
{
|
||||
public static void HttpRequestCtorUri_Prefix(object __0)
|
||||
{
|
||||
HttpTracePatches.TraceUrl(__0?.ToString(), "BestHTTP.HTTPRequest..ctor(Uri)");
|
||||
}
|
||||
|
||||
public static void HttpRequestSetUri_Prefix(ref Uri __0)
|
||||
{
|
||||
RewriteUriArg(ref __0, "httprequest-seturi-rewrite");
|
||||
}
|
||||
|
||||
public static void HttpRequestPrepareUri_Prefix(ref Uri __0)
|
||||
{
|
||||
RewriteUriArg(ref __0, "httprequest-prepareuri-rewrite");
|
||||
}
|
||||
|
||||
public static void HttpManagerSendRequestString_Prefix(ref string url)
|
||||
{
|
||||
RewriteString(ref url, "httpmanager-rewrite");
|
||||
HttpTracePatches.TraceUrl(url, "BestHTTP.HTTPManager.SendRequest(string)");
|
||||
}
|
||||
|
||||
public static void HttpManagerSendRequestObject_Prefix(object request)
|
||||
{
|
||||
RewriteRequestUri(request, "httpmanager-request-rewrite");
|
||||
AuthPatches.ApplyBearerTokenHeader(request);
|
||||
}
|
||||
|
||||
public static void UriStringCtor_Prefix(ref string uriString)
|
||||
{
|
||||
RewriteString(ref uriString, "uri-rewrite");
|
||||
HttpTracePatches.TraceUrl(uriString, "System.Uri..ctor(string)");
|
||||
}
|
||||
|
||||
public static void HttpRequestSend_Prefix(object __instance)
|
||||
{
|
||||
RewriteRequestUri(__instance, "http-rewrite");
|
||||
AuthPatches.ApplyBearerTokenHeader(__instance);
|
||||
}
|
||||
|
||||
private static void RewriteRequestUri(object request, string label)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uriProp = request.GetType().GetProperty("Uri", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (uriProp?.GetValue(request) is not Uri uri) return;
|
||||
if (request is null) return;
|
||||
var type = request.GetType();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return;
|
||||
if (TryRewriteUriProperty(request, type.GetProperty("Uri", flags), label)) return;
|
||||
if (TryRewriteUriField(request, type.GetField("<Uri>k__BackingField", flags), label)) return;
|
||||
|
||||
uriProp.SetValue(request, rewritten);
|
||||
Mod.Log.Msg($"[{label}] {uri} → {rewritten}");
|
||||
foreach (var prop in type.GetProperties(flags))
|
||||
{
|
||||
if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
if (TryRewriteUriProperty(request, prop, label)) return;
|
||||
}
|
||||
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
if (TryRewriteUriField(request, field, label)) return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1733,13 +3674,53 @@ internal static class UriPatches
|
||||
}
|
||||
}
|
||||
|
||||
private static void RewriteUriArg(ref Uri uri, string label)
|
||||
{
|
||||
if (uri is null) return;
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return;
|
||||
|
||||
Mod.Log.Msg($"[{label}] {uri} -> {rewritten}");
|
||||
uri = rewritten;
|
||||
}
|
||||
|
||||
private static bool TryRewriteUriProperty(object request, PropertyInfo? prop, string label)
|
||||
{
|
||||
if (prop is null || !prop.CanRead || !prop.CanWrite) return false;
|
||||
if (prop.GetIndexParameters().Length != 0) return false;
|
||||
if (prop.GetValue(request) is not Uri uri) return false;
|
||||
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return false;
|
||||
|
||||
prop.SetValue(request, rewritten);
|
||||
Mod.Log.Msg($"[{label}] {uri} -> {rewritten}");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryRewriteUriField(object request, FieldInfo? field, string label)
|
||||
{
|
||||
if (field is null || field.IsInitOnly) return false;
|
||||
if (field.GetValue(request) is not Uri uri) return false;
|
||||
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return false;
|
||||
|
||||
field.SetValue(request, rewritten);
|
||||
Mod.Log.Msg($"[{label}] {uri} -> {rewritten}");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void RewriteString(ref string uriString, string label)
|
||||
{
|
||||
if (string.IsNullOrEmpty(uriString)) return;
|
||||
var host = Mod.Cfg.ServerHost;
|
||||
if (string.IsNullOrEmpty(host)) return;
|
||||
if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return;
|
||||
var rewritten = uriString.Replace(".rec.net", "." + host);
|
||||
if (!Uri.TryCreate(uriString, UriKind.Absolute, out var uri)) return;
|
||||
|
||||
var rewrittenUri = RewriteUri(uri);
|
||||
if (rewrittenUri is null) return;
|
||||
|
||||
var rewritten = rewrittenUri.ToString();
|
||||
if (rewritten == uriString) return;
|
||||
Mod.Log.Msg($"[{label}] {uriString} → {rewritten}");
|
||||
uriString = rewritten;
|
||||
@@ -1761,333 +3742,6 @@ internal static class UriPatches
|
||||
}
|
||||
}
|
||||
|
||||
internal static class PhotonPatches
|
||||
{
|
||||
public static void PhotonAppIdOverride_Prefix()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Mod.Cfg.PhotonAppId)) return;
|
||||
try
|
||||
{
|
||||
// PhotonNetwork.PhotonServerSettings — accessed via reflection
|
||||
// so the type-name resolution stays in one place. Mutating the
|
||||
// ScriptableObject in-place is what the BepInEx plugin does
|
||||
// too; the Photon Connect path reads AppID / VoiceAppID off
|
||||
// this object immediately after.
|
||||
var photonNetwork = Mod.Instance is null ? null : FindGameType("PhotonNetwork");
|
||||
if (photonNetwork is null) return;
|
||||
var settingsProp = photonNetwork.GetProperty("PhotonServerSettings", BindingFlags.Public | BindingFlags.Static);
|
||||
var settings = settingsProp?.GetValue(null);
|
||||
if (settings is null) return;
|
||||
|
||||
var settingsType = settings.GetType();
|
||||
var appIdProp = settingsType.GetProperty("AppID") ?? settingsType.GetProperty("AppId");
|
||||
var voiceProp = settingsType.GetProperty("VoiceAppID") ?? settingsType.GetProperty("VoiceAppId");
|
||||
|
||||
var newId = Mod.Cfg.PhotonAppId;
|
||||
var newVoice = string.IsNullOrEmpty(Mod.Cfg.PhotonVoiceAppId) ? newId : Mod.Cfg.PhotonVoiceAppId;
|
||||
|
||||
// Diagnostic: dump every readable AppSettings field before
|
||||
// we mutate, so a Photon Custom Auth failure on first connect
|
||||
// is debuggable from a single log dump. Lists every public
|
||||
// property + field on PhotonServerSettings (Server, Port,
|
||||
// NameServer, AuthMode, HostType, PreferredRegion, AppID,
|
||||
// VoiceAppID, BestRegionSummary, etc.) — covers both 2020.03
|
||||
// (Server/Port) and 2020.12 (AppSettings) layouts.
|
||||
Mod.Log.Msg("[photon-diag] PhotonServerSettings BEFORE override:");
|
||||
DumpSettings(settings, settingsType, indent: " ");
|
||||
|
||||
appIdProp?.SetValue(settings, newId);
|
||||
voiceProp?.SetValue(settings, newVoice);
|
||||
Mod.Log.Msg($"[photon-appid] override AppID/VoiceAppID → {newId} / {newVoice}");
|
||||
|
||||
// Force a deterministic Photon region so two watches running
|
||||
// this mod end up on the same Photon master and "JoinByName"
|
||||
// produces ONE shared room (not two parallel ones in eu/us
|
||||
// because each watch auto-picked a different best region).
|
||||
//
|
||||
// The 2020 watch's default HostType is BestRegion(4) — every
|
||||
// launch pings all Photon Cloud regions and picks the lowest
|
||||
// latency. Two players geographically apart hit different
|
||||
// "best" regions; same room name, two regions = two rooms.
|
||||
// Server-side our matchmaking response carries
|
||||
// `photonRegionId` but the watch's Photon code never reads
|
||||
// it; the only consumer is Matchmaking.SetPhotonRegionPings
|
||||
// which only reports pings *back* to the server.
|
||||
//
|
||||
// Strategy: switch HostType to PhotonCloud(1) and set
|
||||
// PreferredRegion. Prefer the server-told region (the most
|
||||
// recent Matchmaking.LocalRoomInstance.PhotonRegionId, if
|
||||
// any) over the config default — that way a server-side
|
||||
// region change propagates without redeploying the mod.
|
||||
ApplyForcedRegion(settings, settingsType);
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-appid] override failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Forces <c>PhotonServerSettings.HostType = PhotonCloud</c>
|
||||
/// and writes <c>PreferredRegion</c>. Honors any region the server
|
||||
/// has handed us in <c>Matchmaking.LocalRoomInstance.PhotonRegionId</c>
|
||||
/// first; falls back to <c>Cfg.PhotonCloudRegion</c> when no room
|
||||
/// instance has been received yet (initial boot).</summary>
|
||||
private static void ApplyForcedRegion(object settings, Type settingsType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hostTypeProp = settingsType.GetField("HostType")
|
||||
?? (System.Reflection.MemberInfo?)settingsType.GetProperty("HostType");
|
||||
var preferredProp = settingsType.GetField("PreferredRegion")
|
||||
?? (System.Reflection.MemberInfo?)settingsType.GetProperty("PreferredRegion");
|
||||
if (hostTypeProp is null || preferredProp is null)
|
||||
{
|
||||
Mod.Log.Warning("[photon-region] PhotonServerSettings.HostType/PreferredRegion not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// ServerSettings.HostingOption.PhotonCloud = 1 (verified in
|
||||
// Cpp2IL_CS/.../ServerSettings.cs:8). Resolve the actual enum
|
||||
// type from the field/property so we don't depend on a
|
||||
// specific Il2Cpp proxy namespace.
|
||||
Type? hostTypeEnum = (hostTypeProp as FieldInfo)?.FieldType
|
||||
?? ((PropertyInfo)hostTypeProp).PropertyType;
|
||||
Type? regionEnum = (preferredProp as FieldInfo)?.FieldType
|
||||
?? ((PropertyInfo)preferredProp).PropertyType;
|
||||
|
||||
// Prefer server-provided region over config. We read it via
|
||||
// the same reflection style as the existing patches so the
|
||||
// Il2Cpp type-name suffix doesn't bite us.
|
||||
string region = ReadServerProvidedRegion() ?? Mod.Cfg.PhotonCloudRegion ?? "us";
|
||||
|
||||
object hostValue = Enum.ToObject(hostTypeEnum!, 1); // PhotonCloud
|
||||
object regionValue;
|
||||
try { regionValue = Enum.Parse(regionEnum!, region.ToLowerInvariant(), ignoreCase: true); }
|
||||
catch
|
||||
{
|
||||
Mod.Log.Warning($"[photon-region] '{region}' is not a CloudRegionCode enum value; defaulting to us");
|
||||
regionValue = Enum.ToObject(regionEnum!, 1); // CloudRegionCode.us = 1
|
||||
region = "us";
|
||||
}
|
||||
|
||||
if (hostTypeProp is FieldInfo hostField) hostField.SetValue(settings, hostValue);
|
||||
else ((PropertyInfo)hostTypeProp).SetValue(settings, hostValue);
|
||||
if (preferredProp is FieldInfo prefField) prefField.SetValue(settings, regionValue);
|
||||
else ((PropertyInfo)preferredProp).SetValue(settings, regionValue);
|
||||
|
||||
Mod.Log.Msg($"[photon-region] forced HostType=PhotonCloud PreferredRegion={region}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-region] apply failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Reads
|
||||
/// <c>RecNet.Matchmaking.LocalRoomInstance.PhotonRegionId</c> as a
|
||||
/// lowercase enum name (eu, us, asia, …). Returns null if no room
|
||||
/// instance has been received yet, or if any link in the chain is
|
||||
/// null. Server-told region wins over the static config default
|
||||
/// because the server is authoritative about which Photon region
|
||||
/// hosts the room the watch is about to join.</summary>
|
||||
private static string? ReadServerProvidedRegion()
|
||||
{
|
||||
try
|
||||
{
|
||||
var matchmaking = FindGameType("RecNet.Matchmaking");
|
||||
var localRoom = matchmaking?.GetProperty("LocalRoomInstance", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localRoom is null) return null;
|
||||
var regionObj = localRoom.GetType().GetProperty("PhotonRegionId")?.GetValue(localRoom);
|
||||
if (regionObj is null) return null;
|
||||
// CloudRegionCode enum.ToString() gives the lowercase region
|
||||
// name (eu, us, asia, …) — see CloudRegionCode.cs.
|
||||
return regionObj.ToString();
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// AuthValuesInjector_Prefix parked 2026-05-28 — never wired to a
|
||||
// harmony hook in this branch anyway. See
|
||||
// attic/AuthValuesInjector.cs.attic.
|
||||
|
||||
/// <summary>Postfix on <c>PUNNetworkManager.FJOLIPKKIBE</c> — the
|
||||
/// 2020.12 watch's private AppSettings builder. <c>__result</c> is the
|
||||
/// <c>Photon.Realtime.AppSettings</c> it just built.
|
||||
///
|
||||
/// **CRITICAL OVERRIDE**: replaces <c>AppIdRealtime</c> and
|
||||
/// <c>AppIdVoice</c> with our configured values before the AppSettings
|
||||
/// reaches <c>PhotonNetwork.ConnectUsingSettings</c>. The 2020.12
|
||||
/// watch hardcodes Rec Room's official AppIds as IL2CPP string
|
||||
/// literals (verified at <c>PUNNetworkManager.txt:7205,7208</c>:
|
||||
/// <c>"9372aa8d-..."</c> and <c>"e93ae440-..."</c>). Patching
|
||||
/// resources.assets at offsets 0x7962480 / 0x79624ac doesn't help —
|
||||
/// those are unrelated leftover strings, NOT the AppIds the SDK
|
||||
/// actually reads. The only reliable swap point is here, after
|
||||
/// FJOLIPKKIBE constructs the AppSettings object but before the
|
||||
/// LoadBalancingClient consumes it.
|
||||
///
|
||||
/// Then dumps every readable field/property for diagnostics.</summary>
|
||||
public static void LogAppSettings_Postfix(object __result)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__result is null) { Mod.Log.Msg("[photon-diag] FJOLIPKKIBE returned null AppSettings"); return; }
|
||||
|
||||
// ── AppId injection ────────────────────────────────────────
|
||||
var t = __result.GetType();
|
||||
var newPun = string.IsNullOrEmpty(Mod.Cfg.PhotonAppId) ? null : Mod.Cfg.PhotonAppId;
|
||||
var newVoice = string.IsNullOrEmpty(Mod.Cfg.PhotonVoiceAppId)
|
||||
? (newPun ?? null)
|
||||
: Mod.Cfg.PhotonVoiceAppId;
|
||||
if (newPun is not null)
|
||||
{
|
||||
var realtimeProp = t.GetProperty("AppIdRealtime") ?? t.GetProperty("AppIdFusion");
|
||||
var oldPun = realtimeProp?.GetValue(__result) as string;
|
||||
realtimeProp?.SetValue(__result, newPun);
|
||||
Mod.Log.Msg($"[photon-override] AppIdRealtime: {oldPun} → {newPun}");
|
||||
}
|
||||
if (newVoice is not null)
|
||||
{
|
||||
var voiceProp = t.GetProperty("AppIdVoice");
|
||||
var oldVoice = voiceProp?.GetValue(__result) as string;
|
||||
voiceProp?.SetValue(__result, newVoice);
|
||||
Mod.Log.Msg($"[photon-override] AppIdVoice: {oldVoice} → {newVoice}");
|
||||
}
|
||||
// Also force FixedRegion to the configured region so two clients
|
||||
// never end up on different regions of the same room name.
|
||||
if (!string.IsNullOrEmpty(Mod.Cfg.PhotonCloudRegion))
|
||||
{
|
||||
var fixedRegionProp = t.GetProperty("FixedRegion");
|
||||
if (fixedRegionProp is not null && fixedRegionProp.CanWrite)
|
||||
{
|
||||
fixedRegionProp.SetValue(__result, Mod.Cfg.PhotonCloudRegion);
|
||||
Mod.Log.Msg($"[photon-override] FixedRegion → {Mod.Cfg.PhotonCloudRegion}");
|
||||
}
|
||||
}
|
||||
|
||||
Mod.Log.Msg("[photon-diag] FJOLIPKKIBE built AppSettings (post-override):");
|
||||
DumpSettings(__result, t, indent: " ");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] LogAppSettings/override failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Postfix on <c>PUNNetworkManager.EMGGKBALALH</c> — the
|
||||
/// 2020.12 watch's private AuthenticationValues builder. <c>__result</c>
|
||||
/// is the AuthValues it just built with environment + accessToken
|
||||
/// AuthGetParameters. Logs AuthType, UserId, and the full
|
||||
/// AuthGetParameters / AuthPostData payload that Photon Cloud will
|
||||
/// forward to the configured Custom Auth URL.</summary>
|
||||
public static void LogAuthValues_Postfix(object __result)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__result is null)
|
||||
{
|
||||
Mod.Log.Msg("[photon-diag] EMGGKBALALH returned null AuthValues (anonymous connect)");
|
||||
return;
|
||||
}
|
||||
var t = __result.GetType();
|
||||
string Read(string name) => t.GetProperty(name)?.GetValue(__result)?.ToString() ?? "<null>";
|
||||
var authType = Read("AuthType");
|
||||
var userId = Read("UserId");
|
||||
var getParams = t.GetProperty("AuthGetParameters")?.GetValue(__result)?.ToString() ?? "<null>";
|
||||
var postData = t.GetProperty("AuthPostData")?.GetValue(__result);
|
||||
var postStr = postData is byte[] bytes
|
||||
? $"<{bytes.Length} bytes: {System.Text.Encoding.UTF8.GetString(bytes)}>"
|
||||
: postData?.ToString() ?? "<null>";
|
||||
Mod.Log.Msg($"[photon-diag] EMGGKBALALH built AuthValues: AuthType={authType} UserId={userId}");
|
||||
Mod.Log.Msg($"[photon-diag] AuthGetParameters = {getParams}");
|
||||
Mod.Log.Msg($"[photon-diag] AuthPostData = {postStr}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] LogAuthValues failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Prefix on <c>PUNNetworkManager.OnCustomAuthenticationFailed</c>
|
||||
/// — the watch's UI-error formatter only logs a truncated Unity-
|
||||
/// stringified version of the debugMessage. Capturing the raw
|
||||
/// string before the watch's handler runs gives us the FULL
|
||||
/// Photon error including the URL Photon Cloud actually tried to
|
||||
/// call (which is the smoking-gun answer to "is the dashboard URL
|
||||
/// saved or is Photon hitting a stale one").</summary>
|
||||
public static void OnCustomAuthenticationFailed_Prefix(object __0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var debugMessage = __0?.ToString() ?? "<null>";
|
||||
Mod.Log.Error("[photon-diag] OnCustomAuthenticationFailed FULL MESSAGE:");
|
||||
Mod.Log.Error(debugMessage);
|
||||
// Also dump current AppSettings — if Photon rejected the auth
|
||||
// we want to know what the watch's actual final config looked
|
||||
// like (post-override).
|
||||
var photonNetwork = FindGameType("PhotonNetwork");
|
||||
var settings = photonNetwork?.GetProperty("PhotonServerSettings", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (settings is not null)
|
||||
{
|
||||
Mod.Log.Error("[photon-diag] PhotonServerSettings at failure:");
|
||||
DumpSettings(settings, settings.GetType(), indent: " ");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] OnCustomAuthenticationFailed log failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Logs every public field + property on <paramref name="obj"/>
|
||||
/// — used by the photon-diag patches above to dump AppSettings in one
|
||||
/// pass without coupling to specific field names that differ across
|
||||
/// PUN versions (Server/Port vs AppSettings.NameServer in newer
|
||||
/// PUN 2.x, etc.).</summary>
|
||||
private static void DumpSettings(object obj, Type t, string indent)
|
||||
{
|
||||
// DeclaredOnly so we don't walk Il2CppObjectBase's properties
|
||||
// (ObjectClass, Pointer, WasCollected) — and, importantly, so
|
||||
// we don't reflect over any property whose getter internally
|
||||
// constructs a System.Uri. The Uri ctor is harmony-patched, so
|
||||
// the FIRST call to a Uri-returning getter triggers MonoMod's
|
||||
// lazy CompileMethodHook for the ctor, which fatal-CLRs
|
||||
// (0x80131506) on this build. The override has already run by
|
||||
// the time DumpSettings is called — the dump is diagnostic
|
||||
// only, so it's safer to stay scoped to user-land properties.
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly;
|
||||
try
|
||||
{
|
||||
foreach (var p in t.GetProperties(flags))
|
||||
{
|
||||
if (p.GetIndexParameters().Length > 0) continue;
|
||||
if (!p.CanRead) continue;
|
||||
// Defensively skip any property whose declared return
|
||||
// type is Uri (would still trigger ctor on .ToString()
|
||||
// path even if we tried to read it).
|
||||
if (p.PropertyType == typeof(Uri)) continue;
|
||||
object? v;
|
||||
try { v = p.GetValue(obj); } catch { continue; }
|
||||
if (v is Uri) continue;
|
||||
var str = v is null ? "<null>" : v.ToString();
|
||||
if (v is not null && v.GetType().Name.IndexOf("AppSettings", StringComparison.Ordinal) >= 0 && indent.Length < 6)
|
||||
{
|
||||
Mod.Log.Msg($"{indent}{p.Name} (nested):");
|
||||
DumpSettings(v, v.GetType(), indent + " ");
|
||||
continue;
|
||||
}
|
||||
Mod.Log.Msg($"{indent}{p.Name} = {str}");
|
||||
}
|
||||
foreach (var f in t.GetFields(flags))
|
||||
{
|
||||
if (f.FieldType == typeof(Uri)) continue;
|
||||
object? v;
|
||||
try { v = f.GetValue(obj); } catch { continue; }
|
||||
if (v is Uri) continue;
|
||||
var str = v is null ? "<null>" : v.ToString();
|
||||
if (v is not null && v.GetType().Name.IndexOf("AppSettings", StringComparison.Ordinal) >= 0 && indent.Length < 6)
|
||||
{
|
||||
Mod.Log.Msg($"{indent}{f.Name} (nested):");
|
||||
DumpSettings(v, v.GetType(), indent + " ");
|
||||
continue;
|
||||
}
|
||||
Mod.Log.Msg($"{indent}{f.Name} = {str}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] DumpSettings failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// GetCurrentLockToken parked alongside the injector — see
|
||||
// attic/AuthValuesInjector.cs.attic.
|
||||
|
||||
private static Type? FindGameType(string name) => Mod.ResolveType(name);
|
||||
}
|
||||
|
||||
internal static class SavePatches
|
||||
{
|
||||
// Last DataBlobName parsed off a SubscriptionUpdateRoom push for
|
||||
@@ -2336,6 +3990,50 @@ internal static class SavePatches
|
||||
private static Type? FindGameType(string name) => Mod.ResolveType(name);
|
||||
}
|
||||
|
||||
internal static class AntiCheatPatches
|
||||
{
|
||||
public static bool GenerateChallengeResponse_Prefix(string FIMBPCACMKJ, ref string __result)
|
||||
{
|
||||
__result = "AAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
Mod.Log.Msg("[eac] using local challenge response for copied test client");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class AntiTamperPatches
|
||||
{
|
||||
public static bool CreateHileWarningCallback_Prefix(bool shouldQuit)
|
||||
{
|
||||
Mod.Log.Msg($"[hile] suppressed warning callback shouldQuit={shouldQuit}");
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool UnknownDllDetectedCallback_Prefix()
|
||||
{
|
||||
Mod.Log.Msg("[cheatmanager] suppressed unknown DLL fatal callback");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class FileHashCheckerPatches
|
||||
{
|
||||
public static bool InitializeCallback_Prefix(object? result)
|
||||
{
|
||||
Mod.Log.Msg($"[filecheck] suppressed verifier callback result={result?.GetType().FullName ?? "null"}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ToxModPatches
|
||||
{
|
||||
public static bool CanInitializeToxMod_Prefix(ref bool __result)
|
||||
{
|
||||
__result = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal static class TlsPatches
|
||||
{
|
||||
// Both BouncyCastle TLS authenticators have the same `void
|
||||
@@ -2349,6 +4047,83 @@ internal static class TlsPatches
|
||||
}
|
||||
}
|
||||
|
||||
internal static class QuitTracePatches
|
||||
{
|
||||
public static bool ApplicationQuit_Prefix()
|
||||
{
|
||||
Write("[quit-trace] UnityEngine.Application.Quit()");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool ApplicationQuitInt_Prefix(int exitCode)
|
||||
{
|
||||
Write($"[quit-trace] UnityEngine.Application.Quit({exitCode})");
|
||||
WriteStack();
|
||||
if (exitCode == 1550502249 || exitCode == 533223478)
|
||||
{
|
||||
Write($"[quit-blocked] UnityEngine.Application.Quit fatal client exit {exitCode}");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {FormatOriginal(__originalMethod)}");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SessionManagerTryApplicationQuitInt_Prefix(int DKOGJICFAEP, MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {FormatOriginal(__originalMethod)}({DKOGJICFAEP})");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SessionManagerFatalApplicationQuit_Prefix(int DKOGJICFAEP, string EPAIMCEIMPA, MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {FormatOriginal(__originalMethod)}({DKOGJICFAEP}, {EPAIMCEIMPA})");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void ApplicationWantsToQuit_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {FormatOriginal(__originalMethod)} wants-to-quit handler entered");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void CreateExitTask_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {FormatOriginal(__originalMethod)} creating exit task");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
private static string FormatOriginal(MethodBase method)
|
||||
{
|
||||
return $"{method.DeclaringType?.FullName}.{method.Name}";
|
||||
}
|
||||
|
||||
private static void Write(string message)
|
||||
{
|
||||
try { Mod.Log.Msg(message); } catch { }
|
||||
}
|
||||
|
||||
private static void WriteStack()
|
||||
{
|
||||
try
|
||||
{
|
||||
Write("[quit-trace-stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Write("[quit-trace-stack] failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class DiagnosticPatches
|
||||
{
|
||||
public static void Write(string message)
|
||||
@@ -2363,16 +4138,18 @@ internal static class DiagnosticPatches
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static void ApplicationQuit_Prefix()
|
||||
public static bool ApplicationQuit_Prefix()
|
||||
{
|
||||
Write("[quit] UnityEngine.Application.Quit()");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void ApplicationQuitInt_Prefix(int exitCode)
|
||||
public static bool ApplicationQuitInt_Prefix(int exitCode)
|
||||
{
|
||||
Write($"[quit] UnityEngine.Application.Quit({exitCode})");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void AnalyticsCheat_Prefix(MethodBase __originalMethod)
|
||||
@@ -2389,9 +4166,23 @@ internal static class DiagnosticPatches
|
||||
|
||||
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-blocked] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SessionManagerTryApplicationQuitInt_Prefix(int DKOGJICFAEP, MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}({DKOGJICFAEP})");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SessionManagerFatalApplicationQuit_Prefix(int DKOGJICFAEP, string EPAIMCEIMPA, MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}({DKOGJICFAEP}, {EPAIMCEIMPA})");
|
||||
WriteStack();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void WriteStack()
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// AuthValuesInjector — PARKED 2026-05-28 (december-2020-12-18)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Filename suffix is .cs.attic on purpose so the .NET SDK's globber
|
||||
// doesn't pick it up. Don't rename to .cs unless you're putting the
|
||||
// feature back.
|
||||
//
|
||||
// What this used to do
|
||||
// --------------------
|
||||
// Built a Photon AuthenticationValues with `userid` + `LoginLock`
|
||||
// custom-auth parameters from the in-game RecNet account state. The
|
||||
// server's /photon/customauth controller (PhotonCustomAuthController.cs)
|
||||
// validated those parameters against the player's /player/login lock.
|
||||
//
|
||||
// Status in this branch
|
||||
// ---------------------
|
||||
// The method existed but was **never actually wired to a harmony
|
||||
// hook** in this branch's OnLateInitializeMelon. December's Photon
|
||||
// AuthValues path goes through PhotonPatches.LogAuthValues_Postfix
|
||||
// (postfix on PUNNetworkManager.EMGGKBALALH), which only logs — it
|
||||
// doesn't inject. So this code was effectively dead in december.
|
||||
//
|
||||
// Removed because:
|
||||
// 1. Photon Cloud is currently configured with Custom Authentication
|
||||
// disabled at the AppId level (anonymous connects allowed), so
|
||||
// no server-side auth probe.
|
||||
// 2. Even when the watch did send AuthValues, the upstream Cloudflare
|
||||
// / Localtunnel layer 403'd Photon's auth probe before our
|
||||
// controller could decide anything.
|
||||
//
|
||||
// How to put it back
|
||||
// ------------------
|
||||
// 1. Rename this file to AuthValuesInjector.cs (drop the .attic suffix).
|
||||
// 2. Reinstate the `InjectAuthValues` field on Mod.Cfg + the LoadConfig
|
||||
// line.
|
||||
// 3. Wire the harmony hook in OnLateInitializeMelon. The march branch's
|
||||
// pick of NetworkingPeer.CallAuthenticate doesn't fire in IL2CPP, so
|
||||
// consider hooking PUNNetworkManager.EMGGKBALALH postfix instead and
|
||||
// mutating its returned AuthValues in place — that's the mangled
|
||||
// method december verifies fires (LogAuthValues_Postfix already
|
||||
// proves this entry point works).
|
||||
// 4. Put `"InjectAuthValues": true` back in dorknet-clientmod.json.template.
|
||||
// 5. If you also want the server to enforce the lock, set
|
||||
// Photon__RequireCustomAuth=true on the server.
|
||||
|
||||
using System.Reflection;
|
||||
|
||||
namespace DorkNet.ClientMod.Attic;
|
||||
|
||||
internal static class AuthValuesInjector
|
||||
{
|
||||
public static void AuthValuesInjector_Prefix(object __instance)
|
||||
{
|
||||
if (!Mod.Cfg.InjectAuthValues) return;
|
||||
try
|
||||
{
|
||||
// Photon's AuthValues live on PhotonNetwork.AuthValues. The 2020
|
||||
// watch never sets them itself, so we set them right before
|
||||
// NetworkingPeer.CallAuthenticate forwards to the wire op. The
|
||||
// userid is RecNet.Core.LocalAccountId, the LoginLock comes
|
||||
// from PlayerPrefs key LoginLockTokenV2.
|
||||
// RecNet.Core.LocalAccountId is declared `int` in the game
|
||||
// build (dump.cs:573622 → 0x30 backing field). Convert via
|
||||
// Convert.ToInt64 so boxing as int still gives us the long we
|
||||
// need below; a direct (long) cast on boxed int would throw.
|
||||
var coreType = FindGameType("RecNet.Core");
|
||||
var accountIdObj = coreType?.GetProperty("LocalAccountId", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
var accountId = accountIdObj is null ? 0L : Convert.ToInt64(accountIdObj);
|
||||
if (accountId <= 0)
|
||||
{
|
||||
Mod.Log.Msg("[auth-injector] skip — no LocalAccountId yet (pre-login Photon ping)");
|
||||
return;
|
||||
}
|
||||
|
||||
var photonNetwork = FindGameType("PhotonNetwork");
|
||||
var avProp = photonNetwork?.GetProperty("AuthValues", BindingFlags.Public | BindingFlags.Static);
|
||||
var existing = avProp?.GetValue(null);
|
||||
if (existing is not null)
|
||||
{
|
||||
var userIdProp = existing.GetType().GetProperty("UserId");
|
||||
var existingUserId = userIdProp?.GetValue(existing) as string;
|
||||
if (!string.IsNullOrEmpty(existingUserId)) return;
|
||||
}
|
||||
|
||||
var token = GetCurrentLockToken();
|
||||
|
||||
var authValuesType = FindGameType("ExitGames.Client.Photon.LoadBalancing.AuthenticationValues");
|
||||
if (authValuesType is null) { Mod.Log.Warning("[auth-injector] AuthenticationValues type not found"); return; }
|
||||
var av = Activator.CreateInstance(authValuesType);
|
||||
var authTypeProp = authValuesType.GetProperty("AuthType");
|
||||
if (authTypeProp is not null)
|
||||
{
|
||||
var customAuthValue = authTypeProp.PropertyType.IsEnum
|
||||
? Enum.ToObject(authTypeProp.PropertyType, 0)
|
||||
: Convert.ChangeType(0, authTypeProp.PropertyType);
|
||||
authTypeProp.SetValue(av, customAuthValue);
|
||||
}
|
||||
authValuesType.GetProperty("UserId")?.SetValue(av, accountId.ToString());
|
||||
var addParam = authValuesType.GetMethod("AddAuthParameter", new[] { typeof(string), typeof(string) });
|
||||
addParam?.Invoke(av, new object[] { "userid", accountId.ToString() });
|
||||
addParam?.Invoke(av, new object[] { "LoginLock", token ?? string.Empty });
|
||||
|
||||
avProp?.SetValue(null, av);
|
||||
Mod.Log.Msg($"[auth-injector] set AuthValues userid={accountId} LoginLock={(string.IsNullOrEmpty(token) ? "<missing>" : "<set>")}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[auth-injector] failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static string? GetCurrentLockToken()
|
||||
{
|
||||
try
|
||||
{
|
||||
var playerPrefs = FindGameType("UnityEngine.PlayerPrefs");
|
||||
var getString = playerPrefs?.GetMethod("GetString", new[] { typeof(string), typeof(string) });
|
||||
var raw = (string?)getString?.Invoke(null, new object[] { "LoginLockTokenV2", string.Empty });
|
||||
if (string.IsNullOrEmpty(raw)) return null;
|
||||
const string key = "\"LockToken\":\"";
|
||||
var ix = raw.IndexOf(key, StringComparison.Ordinal);
|
||||
if (ix < 0) return null;
|
||||
ix += key.Length;
|
||||
var end = raw.IndexOf('"', ix);
|
||||
return end < 0 ? null : raw[ix..end];
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// FindGameType in the live Mod.cs is a delegate to Mod.ResolveType.
|
||||
// Use that on restore; this is just a placeholder to keep the file
|
||||
// compilable if someone untreats it without re-checking the wiring.
|
||||
private static Type? FindGameType(string name) => null;
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
{
|
||||
"ServerHost": "{HOST}",
|
||||
"PhotonAppId": "{PHOTON_APPID}",
|
||||
"PhotonVoiceAppId": "{PHOTON_VOICE_APPID}",
|
||||
"PhotonCloudRegion": "{PHOTON_REGION}",
|
||||
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
|
||||
"QuestMaxTeamSize": 0,
|
||||
"QuestMaxTeamSizeRooms": [
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,298 @@
|
||||
namespace DorkNet.Contracts;
|
||||
|
||||
public static class DorkNetRouteOwnership
|
||||
{
|
||||
private const string AdminHost = "admin";
|
||||
|
||||
private static readonly DorkNetRouteGroup[] ServiceRouteGroups =
|
||||
[
|
||||
new(
|
||||
ServiceNames.Identity,
|
||||
["auth", "accounts"],
|
||||
[
|
||||
"/.well-known",
|
||||
"/account",
|
||||
"/api/account",
|
||||
"/api/auth",
|
||||
"/api/createaccount",
|
||||
"/api/platformlogin",
|
||||
"/api/playerreputation",
|
||||
"/api/players",
|
||||
"/api/relationships",
|
||||
"/api/settings",
|
||||
"/cachedlogin",
|
||||
"/connect",
|
||||
"/eac",
|
||||
"/parentalcontrol",
|
||||
"/photon",
|
||||
"/profileimage",
|
||||
"/settings",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Rooms,
|
||||
["rooms", "match", "discovery", "lists", "roomcomments", "roomieintegrations", "studio"],
|
||||
[
|
||||
"/api/activities",
|
||||
"/api/curatedroomplaylists",
|
||||
"/api/gameconfigs",
|
||||
"/api/gameconfigurations",
|
||||
"/api/gamesessions",
|
||||
"/api/leaderboard",
|
||||
"/api/playerelo",
|
||||
"/api/playlists",
|
||||
"/api/roomkeys",
|
||||
"/api/rooms",
|
||||
"/api/royale",
|
||||
"/discover",
|
||||
"/featuredrooms",
|
||||
"/goto",
|
||||
"/hot_rooms",
|
||||
"/hot_roomsandplaylists",
|
||||
"/invite",
|
||||
"/playlists",
|
||||
"/player",
|
||||
"/room",
|
||||
"/roominstance",
|
||||
"/rooms",
|
||||
"/roomsandplaylists",
|
||||
"/roomserver",
|
||||
"/search_rooms",
|
||||
"/search_roomsandplaylists",
|
||||
"/v1/find",
|
||||
"/v1/join",
|
||||
"/v1/leave",
|
||||
"/v1/session",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Notify,
|
||||
["notify", "chat", "platformnotifications"],
|
||||
[
|
||||
"/api/messages",
|
||||
"/api/notification",
|
||||
"/api/offlineinvite",
|
||||
"/hub",
|
||||
"/notify",
|
||||
"/thread",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Content,
|
||||
["cdn", "data", "img", "storage", "strings-cdn", "videos"],
|
||||
[
|
||||
"/api/images",
|
||||
"/api/photos",
|
||||
"/config",
|
||||
"/data",
|
||||
"/img",
|
||||
"/invention",
|
||||
"/upload",
|
||||
"/video",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Social,
|
||||
["clubs"],
|
||||
[
|
||||
"/announcements",
|
||||
"/api/announcement",
|
||||
"/api/clubreporting",
|
||||
"/api/communityboard",
|
||||
"/api/groups",
|
||||
"/api/playercheer",
|
||||
"/api/playerevents",
|
||||
"/api/playersubscriptions",
|
||||
"/club",
|
||||
"/members",
|
||||
"/subscription",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Commerce,
|
||||
["commerce", "econ"],
|
||||
[
|
||||
"/api/avatar",
|
||||
"/api/catalog",
|
||||
"/api/compatibility",
|
||||
"/api/consumables",
|
||||
"/api/equipment",
|
||||
"/api/gamerewards",
|
||||
"/api/inventions",
|
||||
"/api/purchase",
|
||||
"/api/storefronts",
|
||||
"/commerce",
|
||||
"/econ",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Platform,
|
||||
["cards", "cms", "datacollection", "gamelogs", "geo", "leaderboard", "link", "ns", "playersettings", "strings", "thorn"],
|
||||
[
|
||||
"/api/cards",
|
||||
"/api/config",
|
||||
"/api/events",
|
||||
"/api/quickplay",
|
||||
"/api/role",
|
||||
"/api/version",
|
||||
"/api/versioncheck",
|
||||
"/motd",
|
||||
"/pageview",
|
||||
"/ping",
|
||||
"/role",
|
||||
"/strings",
|
||||
"/v1/ping",
|
||||
"/v1/player",
|
||||
"/v1/regions",
|
||||
"/v1/services",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Moderation,
|
||||
["bugreporting", "moderation"],
|
||||
[
|
||||
"/api/admin",
|
||||
"/api/banappeal",
|
||||
"/api/bugreporting",
|
||||
"/api/moderation",
|
||||
"/api/playerreporting",
|
||||
"/api/playersbanned",
|
||||
"/api/sanitize",
|
||||
"/api/testcasemanagement",
|
||||
]),
|
||||
new(
|
||||
ServiceNames.Web,
|
||||
["www", AdminHost, "feed"],
|
||||
[
|
||||
"/api/site",
|
||||
],
|
||||
IncludesApexHost: true),
|
||||
];
|
||||
|
||||
public static IReadOnlyList<DorkNetRouteGroup> RouteGroups => ServiceRouteGroups;
|
||||
|
||||
public static bool IsInfrastructurePath(string path)
|
||||
{
|
||||
return path.Equals("/healthz", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.StartsWith("/internal/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static string ResolvePublicService(string? host, string path, string apexDomain)
|
||||
{
|
||||
var normalizedPath = NormalizePath(path);
|
||||
var subdomain = Subdomain(host, apexDomain);
|
||||
|
||||
if (string.Equals(subdomain, AdminHost, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ServiceNames.Web;
|
||||
}
|
||||
|
||||
foreach (var group in ServiceRouteGroups.Where(group => group.ServiceName != ServiceNames.Web))
|
||||
{
|
||||
if (MatchesHost(subdomain, group.HostSubdomains))
|
||||
{
|
||||
return group.ServiceName;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var group in ServiceRouteGroups)
|
||||
{
|
||||
if (StartsWithAny(normalizedPath, group.PathPrefixes))
|
||||
{
|
||||
return group.ServiceName;
|
||||
}
|
||||
}
|
||||
|
||||
var web = ServiceRouteGroups.First(group => group.ServiceName == ServiceNames.Web);
|
||||
if ((subdomain is null && web.IncludesApexHost) || MatchesHost(subdomain, web.HostSubdomains))
|
||||
{
|
||||
return ServiceNames.Web;
|
||||
}
|
||||
|
||||
return ServiceNames.Monolith;
|
||||
}
|
||||
|
||||
public static bool IsOwnedBy(string serviceName, string? host, string path, string apexDomain)
|
||||
{
|
||||
if (IsInfrastructurePath(path))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return string.Equals(
|
||||
ResolvePublicService(host, path, apexDomain),
|
||||
serviceName,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static string? Subdomain(string? host, string apexDomain)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedHost = host.Split(':', 2)[0].Trim().ToLowerInvariant();
|
||||
var normalizedApex = apexDomain.Split(':', 2)[0].Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(normalizedHost)
|
||||
|| string.IsNullOrWhiteSpace(normalizedApex)
|
||||
|| string.Equals(normalizedHost, normalizedApex, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var suffix = "." + normalizedApex;
|
||||
if (normalizedHost.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return normalizedHost[..^suffix.Length];
|
||||
}
|
||||
|
||||
var dot = normalizedHost.IndexOf('.');
|
||||
return dot > 0 ? normalizedHost[..dot] : null;
|
||||
}
|
||||
|
||||
private static bool StartsWithAny(string path, IEnumerable<string> prefixes)
|
||||
{
|
||||
foreach (var prefix in prefixes)
|
||||
{
|
||||
if (StartsWithPathSegment(path, prefix))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool StartsWithPathSegment(string path, string prefix)
|
||||
{
|
||||
var normalizedPath = NormalizePath(path);
|
||||
var normalizedPrefix = NormalizePath(prefix).TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(normalizedPrefix))
|
||||
{
|
||||
normalizedPrefix = "/";
|
||||
}
|
||||
|
||||
return normalizedPath.Equals(normalizedPrefix, StringComparison.OrdinalIgnoreCase)
|
||||
|| normalizedPath.StartsWith(normalizedPrefix + "/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool MatchesHost(string? subdomain, IEnumerable<string> candidates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subdomain))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return candidates.Contains(subdomain, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return "/";
|
||||
}
|
||||
|
||||
return path[0] == '/' ? path : "/" + path;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DorkNetRouteGroup(
|
||||
string ServiceName,
|
||||
string[] HostSubdomains,
|
||||
string[] PathPrefixes,
|
||||
bool IncludesApexHost = false);
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace DorkNet.Contracts;
|
||||
|
||||
public sealed class DorkNetServiceMapOptions
|
||||
{
|
||||
public string Identity { get; set; } = "http://localhost:8081";
|
||||
public string Rooms { get; set; } = "http://localhost:8082";
|
||||
public string Notify { get; set; } = "http://localhost:8083";
|
||||
public string Content { get; set; } = "http://localhost:8084";
|
||||
public string Social { get; set; } = "http://localhost:8085";
|
||||
public string Commerce { get; set; } = "http://localhost:8086";
|
||||
public string Platform { get; set; } = "http://localhost:8087";
|
||||
public string Moderation { get; set; } = "http://localhost:8088";
|
||||
public string Web { get; set; } = "http://localhost:8089";
|
||||
public string Monolith { get; set; } = "http://localhost:8090";
|
||||
|
||||
public IReadOnlyList<DorkNetServiceEndpoint> Endpoints()
|
||||
{
|
||||
DorkNetServiceEndpoint[] endpoints =
|
||||
[
|
||||
new(ServiceNames.Identity, Identity),
|
||||
new(ServiceNames.Rooms, Rooms),
|
||||
new(ServiceNames.Notify, Notify),
|
||||
new(ServiceNames.Content, Content),
|
||||
new(ServiceNames.Social, Social),
|
||||
new(ServiceNames.Commerce, Commerce),
|
||||
new(ServiceNames.Platform, Platform),
|
||||
new(ServiceNames.Moderation, Moderation),
|
||||
new(ServiceNames.Web, Web),
|
||||
new(ServiceNames.Monolith, Monolith),
|
||||
];
|
||||
|
||||
return endpoints
|
||||
.Where(endpoint => !string.IsNullOrWhiteSpace(endpoint.BaseUrl))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DorkNetServiceEndpoint(string Name, string BaseUrl);
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace DorkNet.Contracts;
|
||||
|
||||
public sealed record ServiceHealthResponse(
|
||||
string Service,
|
||||
string Status,
|
||||
DateTimeOffset CheckedAtUtc);
|
||||
|
||||
public sealed record ServiceCapabilityResponse(
|
||||
string Service,
|
||||
string[] Owns,
|
||||
string[] PlannedPublicRoutes);
|
||||
|
||||
public sealed record ServiceProbeResponse(
|
||||
string Service,
|
||||
string BaseUrl,
|
||||
string Status,
|
||||
int? StatusCode,
|
||||
string? Error,
|
||||
DateTimeOffset CheckedAtUtc);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace DorkNet.Contracts;
|
||||
|
||||
public static class ServiceNames
|
||||
{
|
||||
public const string Gateway = "gateway";
|
||||
public const string Identity = "identity";
|
||||
public const string Rooms = "rooms";
|
||||
public const string Notify = "notify";
|
||||
public const string Content = "content";
|
||||
public const string Social = "social";
|
||||
public const string Commerce = "commerce";
|
||||
public const string Platform = "platform";
|
||||
public const string Moderation = "moderation";
|
||||
public const string Web = "web";
|
||||
public const string Monolith = "monolith";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// DorkNet.DebugMod — opt-in debugging switch for DorkNet.ClientMod.
|
||||
//
|
||||
// This mod intentionally does almost nothing on its own. Its job is to
|
||||
// EXIST: when its DLL sits in the Mods folder next to DorkNet.ClientMod.dll,
|
||||
// the ClientMod detects it at startup (Cfg.EnableDiagnostics) and installs
|
||||
// its verbose diagnostic patches — HTTP header/callback tracing, join/quit
|
||||
// /studio traces, and the deep RegisterDiagnostics probes. Remove this DLL
|
||||
// to ship a quiet, minimal client that only carries the connect + anti-cheat
|
||||
// patches.
|
||||
//
|
||||
// Keeping the switch as a separate assembly (rather than a config flag baked
|
||||
// into the shipping mod) means the diagnostics literally cannot turn
|
||||
// themselves on in a normal install — you have to deliberately drop this
|
||||
// file in. That is the "as safe as possible" default the main mod wants.
|
||||
|
||||
using MelonLoader;
|
||||
|
||||
[assembly: MelonInfo(typeof(DorkNet.DebugMod.DebugMod), "DorkNet DebugMod", "1.0.0", "Dork Squad")]
|
||||
[assembly: MelonGame(null, null)]
|
||||
|
||||
namespace DorkNet.DebugMod;
|
||||
|
||||
public class DebugMod : MelonMod
|
||||
{
|
||||
public override void OnInitializeMelon()
|
||||
{
|
||||
LoggerInstance.Msg(
|
||||
"=== DorkNet DebugMod present — DorkNet.ClientMod diagnostics are ENABLED. " +
|
||||
"Remove DorkNet.DebugMod.dll from Mods to ship a quiet client. ===");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
DorkNet.DebugMod — the opt-in debugging companion to DorkNet.ClientMod.
|
||||
|
||||
The shipping ClientMod installs ONLY the patches needed to connect and
|
||||
survive anti-cheat; all of its verbose diagnostic patches (HTTP header
|
||||
tracing, join/quit/studio traces, the deep RegisterDiagnostics path) are
|
||||
gated OFF by default. Dropping THIS dll into the Mods folder next to
|
||||
DorkNet.ClientMod.dll flips Cfg.EnableDiagnostics on (the ClientMod
|
||||
detects this dll's presence at startup), turning all of that tracing
|
||||
back on. Remove it to ship a quiet, minimal client.
|
||||
|
||||
Build: dotnet build DorkNet.DebugMod/DorkNet.DebugMod.csproj -c Release
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework Condition="'$(TargetFramework)' == ''">net6.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>DorkNet.DebugMod</RootNamespace>
|
||||
<AssemblyName>DorkNet.DebugMod</AssemblyName>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Reuse the MelonLoader the ClientMod build already downloads. -->
|
||||
<PropertyGroup>
|
||||
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MSBuildThisFileDirectory)..\DorkNet.ClientMod\build\melonloader\MelonLoader\net6</MelonLoaderDir>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="MelonLoader">
|
||||
<HintPath>$(MelonLoaderDir)\MelonLoader.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
|
||||
<ProjectReference Include="..\DorkNet.ServiceDefaults\DorkNet.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,351 @@
|
||||
using DorkNet.Contracts;
|
||||
using DorkNet.ServiceDefaults;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Yarp.ReverseProxy.Configuration;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Configuration.AddJsonFile("appsettings.Local.json",
|
||||
optional: true, reloadOnChange: true);
|
||||
|
||||
builder.AddDorkNetServiceDefaults(ServiceNames.Gateway);
|
||||
builder.Services.Configure<DorkNetServiceMapOptions>(
|
||||
builder.Configuration.GetSection("DorkNet:Services"));
|
||||
var serviceMap = builder.Configuration
|
||||
.GetSection("DorkNet:Services")
|
||||
.Get<DorkNetServiceMapOptions>() ?? new DorkNetServiceMapOptions();
|
||||
var apexDomain = builder.Configuration["Domain:Apex"]
|
||||
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN")
|
||||
?? "localhost";
|
||||
builder.Services
|
||||
.AddReverseProxy()
|
||||
.LoadFromMemory(
|
||||
BuildRoutes(apexDomain, serviceMap),
|
||||
BuildClusters(serviceMap));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapDorkNetServiceDefaults();
|
||||
|
||||
app.MapGet("/internal/services", (IOptions<DorkNetServiceMapOptions> options) =>
|
||||
Results.Ok(options.Value.Endpoints()));
|
||||
app.MapGet("/internal/routes", () =>
|
||||
Results.Ok(new
|
||||
{
|
||||
ApexDomain = apexDomain,
|
||||
Routes = BuildRoutes(apexDomain, serviceMap).Select(r => new
|
||||
{
|
||||
r.RouteId,
|
||||
r.ClusterId,
|
||||
r.Match.Hosts,
|
||||
r.Match.Path,
|
||||
r.Order,
|
||||
}),
|
||||
}));
|
||||
|
||||
app.MapGet("/internal/services/health", async (
|
||||
IOptions<DorkNetServiceMapOptions> options,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var client = httpClientFactory.CreateClient();
|
||||
var probes = new List<ServiceProbeResponse>();
|
||||
|
||||
foreach (var endpoint in options.Value.Endpoints())
|
||||
{
|
||||
probes.Add(await ProbeServiceAsync(client, endpoint, ct));
|
||||
}
|
||||
|
||||
return Results.Ok(probes);
|
||||
});
|
||||
|
||||
app.MapReverseProxy();
|
||||
|
||||
app.Run();
|
||||
|
||||
static async Task<ServiceProbeResponse> ProbeServiceAsync(
|
||||
HttpClient client,
|
||||
DorkNetServiceEndpoint endpoint,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var checkedAt = DateTimeOffset.UtcNow;
|
||||
var baseUrl = endpoint.BaseUrl.TrimEnd('/');
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await client.GetAsync($"{baseUrl}/internal/healthz", ct);
|
||||
return new ServiceProbeResponse(
|
||||
endpoint.Name,
|
||||
endpoint.BaseUrl,
|
||||
response.IsSuccessStatusCode ? "ok" : "unhealthy",
|
||||
(int)response.StatusCode,
|
||||
null,
|
||||
checkedAt);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
return new ServiceProbeResponse(
|
||||
endpoint.Name,
|
||||
endpoint.BaseUrl,
|
||||
"unreachable",
|
||||
null,
|
||||
ex.Message,
|
||||
checkedAt);
|
||||
}
|
||||
}
|
||||
|
||||
static IReadOnlyList<RouteConfig> BuildRoutes(
|
||||
string apexDomain,
|
||||
DorkNetServiceMapOptions services)
|
||||
{
|
||||
var routes = new List<RouteConfig>();
|
||||
var order = 0;
|
||||
var webGroup = DorkNetRouteOwnership.RouteGroups
|
||||
.First(group => group.ServiceName == ServiceNames.Web);
|
||||
|
||||
AddHostRoute(
|
||||
routes,
|
||||
ref order,
|
||||
webGroup.ServiceName,
|
||||
BaseUrlFor(services, webGroup.ServiceName),
|
||||
"web-admin-host",
|
||||
[SubdomainHost("admin", apexDomain)]);
|
||||
|
||||
AddHostRoute(
|
||||
routes,
|
||||
ref order,
|
||||
webGroup.ServiceName,
|
||||
BaseUrlFor(services, webGroup.ServiceName),
|
||||
"web-feed-host",
|
||||
[SubdomainHost("feed", apexDomain)]);
|
||||
|
||||
AddHostPathRoutes(
|
||||
routes,
|
||||
ref order,
|
||||
webGroup.ServiceName,
|
||||
BaseUrlFor(services, webGroup.ServiceName),
|
||||
WebBrowserHosts(apexDomain),
|
||||
[
|
||||
"/admin",
|
||||
"/assets",
|
||||
"/brand",
|
||||
"/feed",
|
||||
"/join",
|
||||
"/p",
|
||||
"/photo",
|
||||
"/players",
|
||||
"/rooms",
|
||||
"/u",
|
||||
]);
|
||||
|
||||
foreach (var group in DorkNetRouteOwnership.RouteGroups.Where(group => group.ServiceName != ServiceNames.Web))
|
||||
{
|
||||
AddHostRoute(
|
||||
routes,
|
||||
ref order,
|
||||
group.ServiceName,
|
||||
BaseUrlFor(services, group.ServiceName),
|
||||
$"{group.ServiceName}-hosts",
|
||||
group.HostSubdomains.Select(subdomain => SubdomainHost(subdomain, apexDomain)).ToArray());
|
||||
}
|
||||
|
||||
foreach (var group in DorkNetRouteOwnership.RouteGroups)
|
||||
{
|
||||
AddPathRoutes(
|
||||
routes,
|
||||
ref order,
|
||||
group.ServiceName,
|
||||
BaseUrlFor(services, group.ServiceName),
|
||||
group.PathPrefixes);
|
||||
}
|
||||
|
||||
AddHostRoute(
|
||||
routes,
|
||||
ref order,
|
||||
webGroup.ServiceName,
|
||||
BaseUrlFor(services, webGroup.ServiceName),
|
||||
"web-hosts",
|
||||
BuildWebHosts(webGroup, apexDomain));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(services.Monolith))
|
||||
{
|
||||
routes.Add(Route(ServiceNames.Monolith, "monolith-fallback", "/{**catch-all}", order++));
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
static IReadOnlyList<ClusterConfig> BuildClusters(DorkNetServiceMapOptions services)
|
||||
{
|
||||
var clusters = new List<ClusterConfig>();
|
||||
foreach (var endpoint in services.Endpoints())
|
||||
{
|
||||
AddCluster(clusters, endpoint.Name, endpoint.BaseUrl);
|
||||
}
|
||||
|
||||
return clusters;
|
||||
}
|
||||
|
||||
static string[] WebBrowserHosts(string apexDomain)
|
||||
{
|
||||
return [apexDomain, SubdomainHost("www", apexDomain)];
|
||||
}
|
||||
|
||||
static void AddHostRoute(
|
||||
List<RouteConfig> routes,
|
||||
ref int order,
|
||||
string serviceName,
|
||||
string baseUrl,
|
||||
string routeId,
|
||||
string[] hosts)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl) || hosts.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
routes.Add(Route(
|
||||
serviceName,
|
||||
routeId,
|
||||
"/{**catch-all}",
|
||||
order++,
|
||||
hosts));
|
||||
}
|
||||
|
||||
static void AddPathRoutes(
|
||||
List<RouteConfig> routes,
|
||||
ref int order,
|
||||
string serviceName,
|
||||
string baseUrl,
|
||||
IEnumerable<string> paths)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
var routeId = $"{serviceName}-{path.Trim('/').Replace('/', '-').Replace("{**catch-all}", "all")}";
|
||||
var exactPath = NormalizeRoutePrefix(path);
|
||||
routes.Add(Route(serviceName, $"{routeId}-exact", exactPath, order++));
|
||||
routes.Add(Route(serviceName, routeId, $"{exactPath}/{{**catch-all}}", order++));
|
||||
}
|
||||
}
|
||||
|
||||
static void AddHostPathRoutes(
|
||||
List<RouteConfig> routes,
|
||||
ref int order,
|
||||
string serviceName,
|
||||
string baseUrl,
|
||||
string[] hosts,
|
||||
IEnumerable<string> paths)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl) || hosts.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
var routeId = $"{serviceName}-browser-{path.Trim('/').Replace('/', '-').Replace("{**catch-all}", "all")}";
|
||||
var exactPath = NormalizeRoutePrefix(path);
|
||||
routes.Add(Route(serviceName, $"{routeId}-exact", exactPath, order++, hosts));
|
||||
routes.Add(Route(serviceName, routeId, $"{exactPath}/{{**catch-all}}", order++, hosts));
|
||||
}
|
||||
}
|
||||
|
||||
static string BaseUrlFor(DorkNetServiceMapOptions services, string serviceName)
|
||||
{
|
||||
return serviceName switch
|
||||
{
|
||||
ServiceNames.Identity => services.Identity,
|
||||
ServiceNames.Rooms => services.Rooms,
|
||||
ServiceNames.Notify => services.Notify,
|
||||
ServiceNames.Content => services.Content,
|
||||
ServiceNames.Social => services.Social,
|
||||
ServiceNames.Commerce => services.Commerce,
|
||||
ServiceNames.Platform => services.Platform,
|
||||
ServiceNames.Moderation => services.Moderation,
|
||||
ServiceNames.Web => services.Web,
|
||||
ServiceNames.Monolith => services.Monolith,
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
static string[] BuildWebHosts(DorkNetRouteGroup group, string apexDomain)
|
||||
{
|
||||
var hosts = new List<string>();
|
||||
if (group.IncludesApexHost)
|
||||
{
|
||||
hosts.Add(apexDomain);
|
||||
}
|
||||
|
||||
hosts.AddRange(group.HostSubdomains
|
||||
.Where(subdomain => !string.Equals(subdomain, "admin", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(subdomain => SubdomainHost(subdomain, apexDomain)));
|
||||
|
||||
return hosts.ToArray();
|
||||
}
|
||||
|
||||
static string SubdomainHost(string subdomain, string apexDomain)
|
||||
{
|
||||
return $"{subdomain}.{apexDomain}";
|
||||
}
|
||||
|
||||
static string NormalizeRoutePrefix(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || path == "/")
|
||||
{
|
||||
return "/";
|
||||
}
|
||||
|
||||
return (path[0] == '/' ? path : "/" + path).TrimEnd('/');
|
||||
}
|
||||
|
||||
static RouteConfig Route(
|
||||
string clusterId,
|
||||
string routeId,
|
||||
string path,
|
||||
int order,
|
||||
string[]? hosts = null)
|
||||
{
|
||||
return new RouteConfig
|
||||
{
|
||||
RouteId = routeId,
|
||||
ClusterId = clusterId,
|
||||
Order = order,
|
||||
Match = new RouteMatch
|
||||
{
|
||||
Hosts = hosts,
|
||||
Path = path,
|
||||
},
|
||||
Transforms =
|
||||
[
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["RequestHeaderOriginalHost"] = "true",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
static void AddCluster(
|
||||
List<ClusterConfig> clusters,
|
||||
string serviceName,
|
||||
string baseUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var address = baseUrl.EndsWith('/') ? baseUrl : baseUrl + "/";
|
||||
clusters.Add(new ClusterConfig
|
||||
{
|
||||
ClusterId = serviceName,
|
||||
Destinations = new Dictionary<string, DestinationConfig>
|
||||
{
|
||||
["primary"] = new() { Address = address },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"System.Net.Http.HttpClient": "Warning"
|
||||
}
|
||||
},
|
||||
"DorkNet": {
|
||||
"Services": {
|
||||
"Identity": "http://localhost:8081",
|
||||
"Rooms": "http://localhost:8082",
|
||||
"Notify": "http://localhost:8083",
|
||||
"Content": "http://localhost:8084",
|
||||
"Social": "http://localhost:8085",
|
||||
"Commerce": "http://localhost:8086",
|
||||
"Platform": "http://localhost:8087",
|
||||
"Moderation": "http://localhost:8088",
|
||||
"Web": "http://localhost:8089",
|
||||
"Monolith": "http://localhost:8090"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Standalone MelonLoader IL2CPP mod whose ONLY job is to dump every
|
||||
Google.Protobuf FileDescriptor that the running client has loaded.
|
||||
|
||||
It carries no Harmony patches, so it is safe to drop onto ANY Rec Room
|
||||
build (2020, 2023, …) without the version-specific ClientMod patches
|
||||
misfiring. Everything game-side is reached through plain
|
||||
System.Reflection over the Il2CppInterop proxy assemblies, so no game
|
||||
DLLs are referenced at compile time (they would be derivative works).
|
||||
|
||||
Build (desktop net6 MelonLoader, same pin as the ClientMod):
|
||||
|
||||
dotnet build DorkNet.ProtoDumper/DorkNet.ProtoDumper.csproj -c Release
|
||||
|
||||
Drop bin\Release\DorkNet.ProtoDumper.dll into <RecRoom>\Mods\ on a
|
||||
MelonLoader-patched build and launch. Output lands in
|
||||
<RecRoom>\DorkNet-ProtoDump\.
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework Condition="'$(TargetFramework)' == ''">net6.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>DorkNet.ProtoDumper</RootNamespace>
|
||||
<AssemblyName>DorkNet.ProtoDumper</AssemblyName>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Reuse the MelonLoader DLLs the ClientMod project already downloads. -->
|
||||
<PropertyGroup>
|
||||
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
|
||||
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)..\DorkNet.ClientMod\build\melonloader</MelonLoaderCacheRoot>
|
||||
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
|
||||
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
|
||||
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == '' and '$(TargetFramework)' == 'net8.0'">net8</MelonLoaderReferenceTfm>
|
||||
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == ''">net6</MelonLoaderReferenceTfm>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="EnsureMelonLoaderReferences"
|
||||
BeforeTargets="ResolveAssemblyReferences"
|
||||
Condition="'$(MelonLoaderReferenceTfm)' == 'net6' and !Exists('$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll')">
|
||||
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
|
||||
<DownloadFile
|
||||
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
|
||||
DestinationFolder="$(MelonLoaderCacheRoot)"
|
||||
DestinationFileName="MelonLoader.x64.$(MelonLoaderVersion).zip"
|
||||
SkipUnchangedFiles="true" />
|
||||
<Unzip SourceFiles="$(MelonLoaderZip)"
|
||||
DestinationFolder="$(MelonLoaderCacheRoot)"
|
||||
OverwriteReadOnlyFiles="true" />
|
||||
</Target>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="MelonLoader">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Runtime">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Runtime.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Common">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Common.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,259 @@
|
||||
// DorkNet ProtoDumper — runtime Google.Protobuf schema extractor.
|
||||
//
|
||||
// Rec Room compiles its room-save / creation / circuits data model from
|
||||
// .proto files into Google.Protobuf C# classes. In the shipped IL2CPP build
|
||||
// those classes are name-obfuscated and the descriptor base64 is split into
|
||||
// scattered string-literal chunks, so static reconstruction from
|
||||
// global-metadata.dat is unreliable.
|
||||
//
|
||||
// At RUNTIME every FileDescriptor still carries its original, un-obfuscated
|
||||
// FileDescriptorProto (message names, field names, numbers, types intact).
|
||||
// The catch: these descriptors are built LAZILY and in a specific dependency
|
||||
// order by the game. Force-building them ourselves (by reading a message
|
||||
// type's static descriptor property out of order) throws inside the type's
|
||||
// static constructor and the failure is cached — poisoning the type. So we do
|
||||
// NOT enumerate types. Instead we Harmony-postfix the one library method every
|
||||
// generated cctor funnels through — Google.Protobuf.Reflection.FileDescriptor
|
||||
// .BuildFrom / .FromGeneratedCode — and capture each descriptor as the game
|
||||
// builds it. Completely passive: just play the client through the dorm, a
|
||||
// created room, the Maker Pen and a circuits board, and every descriptor that
|
||||
// loads is recorded.
|
||||
//
|
||||
// Output (under <RecRoom>\DorkNet-ProtoDump\):
|
||||
// manifest.tsv — one "<proto path>\t<base64>" line per file
|
||||
// files\<sanitised>.b64 — same base64, one file each
|
||||
// dump.log — capture log
|
||||
//
|
||||
// Decode offline with decode_dump.py (see README.md).
|
||||
|
||||
using HarmonyLib;
|
||||
using MelonLoader;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
|
||||
[assembly: MelonInfo(typeof(DorkNet.ProtoDumper.ProtoDumper), "DorkNet ProtoDumper", "2.0.0", "Dork Squad")]
|
||||
[assembly: MelonGame(null, null)]
|
||||
|
||||
namespace DorkNet.ProtoDumper;
|
||||
|
||||
public class ProtoDumper : MelonMod
|
||||
{
|
||||
private static MelonLogger.Instance? _log;
|
||||
|
||||
// proto file name -> descriptor base64 (accumulated as the game builds them)
|
||||
private static readonly Dictionary<string, string> Files = new(StringComparer.Ordinal);
|
||||
private static readonly object Gate = new();
|
||||
private static string _outDir = "";
|
||||
private static string _filesDir = "";
|
||||
private static volatile bool _dirty;
|
||||
private int _frame;
|
||||
|
||||
public override void OnInitializeMelon()
|
||||
{
|
||||
_log = LoggerInstance;
|
||||
try
|
||||
{
|
||||
string baseDir = AppDomain.CurrentDomain.BaseDirectory ?? Directory.GetCurrentDirectory();
|
||||
_outDir = Path.Combine(baseDir, "DorkNet-ProtoDump");
|
||||
_filesDir = Path.Combine(_outDir, "files");
|
||||
Directory.CreateDirectory(_filesDir);
|
||||
_log.Msg($"ProtoDumper ready. Output dir: {_outDir}");
|
||||
}
|
||||
catch (Exception e) { _log?.Error($"ProtoDumper init failed: {e}"); }
|
||||
|
||||
InstallHooks();
|
||||
}
|
||||
|
||||
private void InstallHooks()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Google.Protobuf library type — NOT obfuscated (it's the library,
|
||||
// not game code), reached by its original name via every loaded
|
||||
// assembly so we never reference the interop DLL at compile time.
|
||||
Type? fd = ResolveType("Google.Protobuf.Reflection.FileDescriptor");
|
||||
if (fd == null)
|
||||
{
|
||||
_log?.Error("ProtoDumper: FileDescriptor type not found — cannot hook.");
|
||||
return;
|
||||
}
|
||||
|
||||
var postfix = new HarmonyMethod(
|
||||
typeof(ProtoDumper).GetMethod(nameof(Captured_Postfix),
|
||||
BindingFlags.Public | BindingFlags.Static));
|
||||
|
||||
int patched = 0;
|
||||
// BuildFrom is the lower-level builder every descriptor passes
|
||||
// through; FromGeneratedCode is the generated-code entry point.
|
||||
// Patch both (deduped downstream) so nothing is missed.
|
||||
foreach (string name in new[] { "BuildFrom", "FromGeneratedCode" })
|
||||
{
|
||||
foreach (MethodInfo m in fd.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (m.Name != name) continue;
|
||||
try { HarmonyInstance.Patch(m, postfix: postfix); patched++; }
|
||||
catch (Exception e) { _log?.Warning($"ProtoDumper: patch {name} failed: {e.Message}"); }
|
||||
}
|
||||
}
|
||||
_log?.Msg($"ProtoDumper: installed {patched} descriptor-build hooks. Play through your content; descriptors are captured as they load.");
|
||||
}
|
||||
catch (Exception e) { _log?.Error($"ProtoDumper hook install error: {e}"); }
|
||||
}
|
||||
|
||||
// Resolve a type by its ORIGINAL (game-side) name, accounting for the
|
||||
// Il2Cpp prefix Il2CppInterop puts on the namespace (so
|
||||
// "Google.Protobuf.Reflection.FileDescriptor" ->
|
||||
// "Il2CppGoogle.Protobuf.Reflection.FileDescriptor").
|
||||
private static Type? ResolveType(string name)
|
||||
{
|
||||
foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = asm.GetType(name, false) ?? asm.GetType("Il2Cpp" + name, false);
|
||||
if (t != null) return t;
|
||||
int dot = name.IndexOf('.');
|
||||
if (dot > 0)
|
||||
{
|
||||
var ns = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), false);
|
||||
if (ns != null) return ns;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Harmony postfix on FileDescriptor.BuildFrom / FromGeneratedCode.
|
||||
// __result is the freshly-built FileDescriptor (Il2Cpp proxy, boxed as
|
||||
// object — same pattern the ClientMod uses for il2cpp reference returns).
|
||||
public static void Captured_Postfix(object? __result)
|
||||
{
|
||||
if (__result == null) return;
|
||||
try { CollectFile(__result); }
|
||||
catch { /* never let our hook disturb the game */ }
|
||||
}
|
||||
|
||||
private static void CollectFile(object file)
|
||||
{
|
||||
string? name = GetProp(file, "Name") as string;
|
||||
if (string.IsNullOrEmpty(name)) return;
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
if (Files.ContainsKey(name!)) return;
|
||||
|
||||
string b64 = DescriptorBase64(file);
|
||||
Files[name!] = b64;
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
// Capture import dependencies too (closure of well-known + leaf protos).
|
||||
try
|
||||
{
|
||||
object? deps = GetProp(file, "Dependencies");
|
||||
if (deps != null)
|
||||
{
|
||||
var dt = deps.GetType();
|
||||
var countP = dt.GetProperty("Count");
|
||||
var itemM = dt.GetMethod("get_Item", new[] { typeof(int) });
|
||||
if (countP != null && itemM != null)
|
||||
{
|
||||
int n = (int)(countP.GetValue(deps) ?? 0);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
object? dep = itemM.Invoke(deps, new object[] { i });
|
||||
if (dep != null) CollectFile(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static string DescriptorBase64(object file)
|
||||
{
|
||||
try
|
||||
{
|
||||
// FileDescriptor exposes no public SerializedData property — only
|
||||
// the Il2CppInterop backing field (a ByteString with the original
|
||||
// descriptor bytes). Fall back to re-serialising the parsed Proto.
|
||||
object? sd = GetProp(file, "_SerializedData_k__BackingField")
|
||||
?? GetProp(file, "SerializedData");
|
||||
if (sd != null)
|
||||
{
|
||||
string b = ByteStringToBase64(sd);
|
||||
if (b.Length > 0) return b;
|
||||
}
|
||||
object? proto = GetProp(file, "Proto");
|
||||
if (proto != null)
|
||||
{
|
||||
MethodInfo? toBs = proto.GetType().GetMethod("ToByteString", Type.EmptyTypes);
|
||||
object? bs = toBs?.Invoke(proto, null);
|
||||
if (bs != null) return ByteStringToBase64(bs);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return "";
|
||||
}
|
||||
|
||||
private static string ByteStringToBase64(object byteString)
|
||||
{
|
||||
MethodInfo? toB64 = byteString.GetType().GetMethod("ToBase64", Type.EmptyTypes);
|
||||
return toB64?.Invoke(byteString, null) as string ?? "";
|
||||
}
|
||||
|
||||
private static object? GetProp(object obj, string propName)
|
||||
{
|
||||
var p = obj.GetType().GetProperty(propName,
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
return p?.GetValue(obj);
|
||||
}
|
||||
|
||||
public override void OnUpdate()
|
||||
{
|
||||
// Flush to disk shortly after new descriptors are captured (every ~1s).
|
||||
if (++_frame % 60 == 0 && _dirty)
|
||||
Flush();
|
||||
}
|
||||
|
||||
public override void OnApplicationQuit() => Flush();
|
||||
|
||||
private static void Flush()
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (!_dirty) return;
|
||||
_dirty = false;
|
||||
try
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
int withData = 0;
|
||||
foreach (var kv in Files)
|
||||
{
|
||||
if (kv.Value.Length == 0) continue;
|
||||
withData++;
|
||||
sb.Append(kv.Key).Append('\t').Append(kv.Value).Append('\n');
|
||||
string fp = Path.Combine(_filesDir, Sanitize(kv.Key) + ".b64");
|
||||
if (!File.Exists(fp)) File.WriteAllText(fp, kv.Value);
|
||||
}
|
||||
File.WriteAllText(Path.Combine(_outDir, "manifest.tsv"), sb.ToString());
|
||||
string line = $"captured files={Files.Count} (with-data={withData})";
|
||||
File.AppendAllText(Path.Combine(_outDir, "dump.log"), line + "\n");
|
||||
_log?.Msg($"ProtoDumper: {line} -> {_outDir}");
|
||||
}
|
||||
catch (Exception e) { _log?.Error($"ProtoDumper flush error: {e}"); }
|
||||
}
|
||||
}
|
||||
|
||||
private static string Sanitize(string protoPath)
|
||||
{
|
||||
var sb = new StringBuilder(protoPath.Length);
|
||||
foreach (char c in protoPath)
|
||||
sb.Append(char.IsLetterOrDigit(c) || c == '_' || c == '-' || c == '.' ? c : '_');
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# DorkNet ProtoDumper
|
||||
|
||||
Runtime extractor for Rec Room's Google.Protobuf schema (room save /
|
||||
creation / circuits data model). Produces clean `.proto` files for any
|
||||
client build — including obfuscated ones like 2023.03.21 where static
|
||||
reconstruction from `global-metadata.dat` is unreliable (the descriptor
|
||||
base64 is split into scattered string-literal chunks whose concat order
|
||||
only lives in IL).
|
||||
|
||||
At runtime every `FileDescriptor` still carries its original,
|
||||
un-obfuscated `FileDescriptorProto` (real message names, field names,
|
||||
numbers and types intact). The catch: Rec Room builds these descriptors
|
||||
**lazily and in a specific dependency order** as content loads. Trying to
|
||||
force-build them by reading a message type's static descriptor out of
|
||||
order throws inside the type's static constructor, and the failure is
|
||||
cached — *poisoning* the type (and crashing the client). So this mod does
|
||||
**not** enumerate types.
|
||||
|
||||
Instead it Harmony-postfixes the one library method every generated cctor
|
||||
funnels through —
|
||||
`Google.Protobuf.Reflection.FileDescriptor.BuildFrom` /
|
||||
`.FromGeneratedCode` — and records each descriptor as the game builds it.
|
||||
Completely passive: **play the client through the content you care about**
|
||||
and every descriptor that loads is captured.
|
||||
|
||||
## Pipeline
|
||||
|
||||
1. **Build the mod**
|
||||
|
||||
```sh
|
||||
dotnet build DorkNet.ProtoDumper/DorkNet.ProtoDumper.csproj -c Release
|
||||
```
|
||||
|
||||
Output: `DorkNet.ProtoDumper/bin/Release/DorkNet.ProtoDumper.dll`.
|
||||
|
||||
2. **Run it on a *runnable* 2023.03.21 client (MelonLoader 0.6.6)**
|
||||
|
||||
> The `GameAssembly.dll` + `il2cpp_data`-only copy under
|
||||
> `Recnet-old/dist/RecRoom-2023.03.21-il2cpp/…/staging/` is an
|
||||
> **offline-analysis payload, not a runnable build**. Use a full build
|
||||
> with the exe + UnityPlayer + MelonLoader (e.g. the devdork package).
|
||||
|
||||
- Drop `DorkNet.ProtoDumper.dll` into `<RecRoom>\Mods\`. On launch it
|
||||
installs the descriptor-build hooks (see `dump.log` / MelonLoader
|
||||
console for "installed N descriptor-build hooks").
|
||||
- **Log in and play through the content whose schema you want:** your
|
||||
dorm, a created room, the Maker Pen, and a circuits (CV2) board. Each
|
||||
subsystem builds its protos the first time it loads. Descriptors are
|
||||
captured the instant they build — no scene/timer scanning, nothing is
|
||||
force-built, so the client stays stable.
|
||||
- Output appears under `<RecRoom>\DorkNet-ProtoDump\` and is flushed to
|
||||
disk ~1 s after new descriptors load, and again on quit:
|
||||
- `manifest.tsv` — `<proto path>\t<base64>` per file
|
||||
- `files\*.b64` — same, one file each
|
||||
- `dump.log` — running capture count
|
||||
|
||||
3. **Decode to .proto**
|
||||
|
||||
```sh
|
||||
pip install protobuf
|
||||
python DorkNet.ProtoDumper/decode_dump.py <path>/manifest.tsv proto_out
|
||||
```
|
||||
|
||||
Emits one `.proto` per descriptor under `proto_out/`, plus a combined
|
||||
`proto_out/recroom_2023.proto` (every rec_room/circuits message in one
|
||||
file) for an easy diff against
|
||||
`DorkNet.Server/Protos/recroom_2020.proto`.
|
||||
|
||||
## Notes
|
||||
|
||||
- No game DLLs are referenced at compile time. `FileDescriptor` is the
|
||||
Google.Protobuf *library* type (not obfuscated); it's reached by its
|
||||
original name with the Il2Cpp namespace-prefix fallback, and its members
|
||||
are read via reflection. The same DLL works across builds and stays clean
|
||||
of derivative game code.
|
||||
- The only Harmony patches are the two read-only descriptor-build
|
||||
postfixes above — safe to run alongside (or without) `DorkNet.ClientMod`.
|
||||
- The descriptor base64 is the same `FileDescriptorProto` wire format
|
||||
Google's own tooling emits, so `decode_dump.py` (or `protoc
|
||||
--decode=google.protobuf.FileDescriptorProto descriptor.proto`) reads it
|
||||
directly.
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Decode a DorkNet ProtoDumper manifest into .proto text.
|
||||
|
||||
Usage:
|
||||
python decode_dump.py manifest.tsv [out_dir]
|
||||
|
||||
manifest.tsv has one "<proto path>\\t<base64 FileDescriptorProto>" line per
|
||||
file (as written by the ProtoDumper MelonLoader mod). This emits one .proto
|
||||
file per descriptor under out_dir/ (default: ./proto_out), plus a combined
|
||||
recroom_2023.proto with every rec_room/circuits message in one file for an
|
||||
easy diff against DorkNet.Server/Protos/recroom_2020.proto.
|
||||
|
||||
Requires: pip install protobuf
|
||||
"""
|
||||
import sys, os, base64, re
|
||||
from google.protobuf import descriptor_pb2 as D
|
||||
|
||||
FDP = D.FileDescriptorProto
|
||||
|
||||
# proto3 scalar type names keyed by FieldDescriptorProto.Type
|
||||
SCALAR = {
|
||||
1: "double", 2: "float", 3: "int64", 4: "uint64", 5: "int32",
|
||||
6: "fixed64", 7: "fixed32", 8: "bool", 9: "string", 12: "bytes",
|
||||
13: "uint32", 15: "sfixed32", 16: "sfixed64", 17: "sint32", 18: "sint64",
|
||||
}
|
||||
LABEL_REPEATED = 3
|
||||
|
||||
|
||||
def type_name(f):
|
||||
if f.type in (11, 14): # message / enum -> use type_name (strip leading dot)
|
||||
return f.type_name.lstrip(".")
|
||||
return SCALAR.get(f.type, f"type{f.type}")
|
||||
|
||||
|
||||
def emit_enum(e, indent):
|
||||
pad = " " * indent
|
||||
out = [f"{pad}enum {e.name} {{"]
|
||||
for v in e.value:
|
||||
out.append(f"{pad} {v.name} = {v.number};")
|
||||
out.append(f"{pad}}}")
|
||||
return out
|
||||
|
||||
|
||||
def emit_message(m, indent=0):
|
||||
pad = " " * indent
|
||||
out = [f"{pad}message {m.name} {{"]
|
||||
|
||||
# map<> detection: synthetic nested *Entry types with map_entry option
|
||||
map_entries = {}
|
||||
for nt in m.nested_type:
|
||||
if nt.options.map_entry:
|
||||
kt = type_name(nt.field[0])
|
||||
vt = type_name(nt.field[1])
|
||||
map_entries[nt.name] = (kt, vt)
|
||||
|
||||
# which oneofs are real (not proto3 optional synthetic)
|
||||
for nt in m.nested_type:
|
||||
if nt.name in map_entries:
|
||||
continue
|
||||
out += emit_message(nt, indent + 1)
|
||||
for en in m.enum_type:
|
||||
out += emit_enum(en, indent + 1)
|
||||
|
||||
# group fields by oneof
|
||||
printed_oneof = set()
|
||||
for f in m.field:
|
||||
# map field?
|
||||
if f.type == 11 and f.type_name.split(".")[-1] in map_entries:
|
||||
kt, vt = map_entries[f.type_name.split(".")[-1]]
|
||||
out.append(f"{pad} map<{kt}, {vt}> {f.name} = {f.number};")
|
||||
continue
|
||||
|
||||
if f.HasField("oneof_index") and not f.proto3_optional:
|
||||
idx = f.oneof_index
|
||||
if idx not in printed_oneof:
|
||||
printed_oneof.add(idx)
|
||||
out.append(f"{pad} oneof {m.oneof_decl[idx].name} {{")
|
||||
for g in m.field:
|
||||
if g.HasField("oneof_index") and g.oneof_index == idx and not g.proto3_optional:
|
||||
out.append(f"{pad} {type_name(g)} {g.name} = {g.number};")
|
||||
out.append(f"{pad} }}")
|
||||
continue
|
||||
|
||||
label = "repeated " if f.label == LABEL_REPEATED else ""
|
||||
opt = "optional " if f.proto3_optional else ""
|
||||
out.append(f"{pad} {label}{opt}{type_name(f)} {f.name} = {f.number};")
|
||||
|
||||
out.append(f"{pad}}}")
|
||||
return out
|
||||
|
||||
|
||||
def render_file(fdp):
|
||||
out = []
|
||||
syntax = fdp.syntax or "proto2"
|
||||
out.append(f'syntax = "{syntax}";')
|
||||
out.append("")
|
||||
if fdp.package:
|
||||
out.append(f"package {fdp.package};")
|
||||
for dep in fdp.dependency:
|
||||
out.append(f'import "{dep}";')
|
||||
if fdp.package or fdp.dependency:
|
||||
out.append("")
|
||||
for en in fdp.enum_type:
|
||||
out += emit_enum(en, 0)
|
||||
out.append("")
|
||||
for m in fdp.message_type:
|
||||
out += emit_message(m, 0)
|
||||
out.append("")
|
||||
return "\n".join(out).rstrip() + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__); sys.exit(1)
|
||||
manifest = sys.argv[1]
|
||||
out_dir = sys.argv[2] if len(sys.argv) > 2 else "proto_out"
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
files = []
|
||||
with open(manifest, "r", encoding="utf-8") as fh:
|
||||
for ln in fh:
|
||||
ln = ln.rstrip("\n")
|
||||
if not ln or "\t" not in ln:
|
||||
continue
|
||||
name, b64 = ln.split("\t", 1)
|
||||
try:
|
||||
raw = base64.b64decode(b64)
|
||||
fdp = FDP(); fdp.ParseFromString(raw)
|
||||
except Exception as e:
|
||||
print(f" ! failed to parse {name}: {e}")
|
||||
continue
|
||||
files.append((name, fdp))
|
||||
|
||||
print(f"parsed {len(files)} descriptors")
|
||||
|
||||
# per-file .proto
|
||||
for name, fdp in files:
|
||||
safe = re.sub(r"[^A-Za-z0-9_.\-]", "_", name)
|
||||
with open(os.path.join(out_dir, safe), "w", encoding="utf-8") as w:
|
||||
w.write(render_file(fdp))
|
||||
|
||||
# combined rec_room/circuits view (skip google/* well-known imports)
|
||||
rr = [ (n,f) for n,f in files if not n.startswith("google/") ]
|
||||
seen = set(); combo = ['syntax = "proto3";', "", "package recroom;", ""]
|
||||
total_msgs = 0
|
||||
for name, fdp in sorted(rr, key=lambda x: x[0]):
|
||||
combo.append(f"// ===== {name} (package {fdp.package or '-'}) =====")
|
||||
for m in fdp.message_type:
|
||||
if m.name in seen:
|
||||
continue
|
||||
seen.add(m.name)
|
||||
combo += emit_message(m, 0)
|
||||
combo.append("")
|
||||
total_msgs += 1
|
||||
for en in fdp.enum_type:
|
||||
combo += emit_enum(en, 0)
|
||||
combo.append("")
|
||||
with open(os.path.join(out_dir, "recroom_2023.proto"), "w", encoding="utf-8") as w:
|
||||
w.write("\n".join(combo).rstrip() + "\n")
|
||||
print(f"wrote {out_dir}/recroom_2023.proto with {total_msgs} unique messages "
|
||||
f"from {len(rr)} rec_room/circuits descriptors")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DorkNet.Server\DorkNet.Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.HostFiltering;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed class DorkNetServerFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private const string JwtSecret = "endpoint-contract-secret-000000000000000000000000000000000000000000";
|
||||
private readonly string _dataRoot;
|
||||
|
||||
public DorkNetServerFactory()
|
||||
{
|
||||
_dataRoot = Path.Combine(Path.GetTempPath(), "dorknet-server-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_dataRoot);
|
||||
}
|
||||
|
||||
public string ApexDomain => "dork.test";
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
builder.UseSetting("Domain:Apex", ApexDomain);
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AllowedHosts"] = "*",
|
||||
["Database:Provider"] = "sqlite",
|
||||
["Database:SqlitePath"] = Path.Combine(_dataRoot, "dorknet-test.db"),
|
||||
["Domain:Apex"] = ApexDomain,
|
||||
["Jwt:Secret"] = JwtSecret,
|
||||
["S3:Endpoint"] = "",
|
||||
["S3:AccessKey"] = "",
|
||||
["S3:SecretKey"] = "",
|
||||
["DorkNet:DefaultClientVersion"] = "march_2023_03_21",
|
||||
["DorkNet:SupportedVersions:0"] = "march_2023_03_21",
|
||||
});
|
||||
});
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.PostConfigure<HostFilteringOptions>(options =>
|
||||
{
|
||||
options.AllowedHosts =
|
||||
[
|
||||
ApexDomain,
|
||||
$"*.{ApexDomain}",
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
];
|
||||
options.AllowEmptyHosts = true;
|
||||
options.IncludeFailureMessage = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_dataRoot))
|
||||
{
|
||||
Directory.Delete(_dataRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Test cleanup must not hide the original test failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public static partial class EndpointContractDiscovery
|
||||
{
|
||||
public static IReadOnlyList<EndpointContract> Discover()
|
||||
{
|
||||
var serverAssembly = typeof(Program).Assembly;
|
||||
var controllerBaseType = typeof(ControllerBase);
|
||||
return serverAssembly.GetTypes()
|
||||
.Where(t => !t.IsAbstract && controllerBaseType.IsAssignableFrom(t))
|
||||
.SelectMany(DiscoverControllerRoutes)
|
||||
.OrderBy(route => route.Method, StringComparer.Ordinal)
|
||||
.ThenBy(route => route.SamplePath, StringComparer.Ordinal)
|
||||
.ThenBy(route => route.Source, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IEnumerable<EndpointContract> DiscoverControllerRoutes(Type controller)
|
||||
{
|
||||
var controllerName = ControllerName(controller);
|
||||
var controllerRouteTemplates = controller.GetCustomAttributes(inherit: true)
|
||||
.OfType<IRouteTemplateProvider>()
|
||||
.Select(a => a.Template)
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||
.Cast<string>()
|
||||
.DefaultIfEmpty("")
|
||||
.ToArray();
|
||||
|
||||
foreach (var method in controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
var httpAttrs = method.GetCustomAttributes(inherit: true)
|
||||
.OfType<HttpMethodAttribute>()
|
||||
.ToArray();
|
||||
if (httpAttrs.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var attr in httpAttrs)
|
||||
{
|
||||
var attrMethods = attr.HttpMethods.Any()
|
||||
? attr.HttpMethods
|
||||
: new[] { "ANY" };
|
||||
|
||||
foreach (var httpMethod in attrMethods)
|
||||
{
|
||||
foreach (var controllerTemplate in controllerRouteTemplates)
|
||||
{
|
||||
var template = CombineTemplates(controllerTemplate, attr.Template);
|
||||
template = ReplaceRouteTokens(template, controllerName, method.Name);
|
||||
yield return new EndpointContract(
|
||||
httpMethod.ToUpperInvariant(),
|
||||
template,
|
||||
ToSamplePath(template),
|
||||
$"{controller.FullName}.{method.Name}",
|
||||
RequiresAuthorization(controller, method),
|
||||
IsAdminEndpoint(controller, method),
|
||||
ConsumesContentTypes(controller, method));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RequiresAuthorization(Type controller, MethodInfo method)
|
||||
{
|
||||
var attributes = controller.GetCustomAttributes(inherit: true)
|
||||
.Concat(method.GetCustomAttributes(inherit: true))
|
||||
.ToArray();
|
||||
|
||||
if (attributes.OfType<IAllowAnonymous>().Any())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return attributes.OfType<IAuthorizeData>().Any();
|
||||
}
|
||||
|
||||
private static bool IsAdminEndpoint(Type controller, MethodInfo method)
|
||||
{
|
||||
return controller.FullName?.Contains(".Controllers.Admin.", StringComparison.Ordinal) == true
|
||||
|| method.Name.Contains("Admin", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string[] ConsumesContentTypes(Type controller, MethodInfo method)
|
||||
{
|
||||
var consumes = method.GetCustomAttributes(inherit: true)
|
||||
.Concat(controller.GetCustomAttributes(inherit: true))
|
||||
.OfType<ConsumesAttribute>()
|
||||
.SelectMany(a => a.ContentTypes)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
return consumes.Length == 0
|
||||
? ["application/json"]
|
||||
: consumes;
|
||||
}
|
||||
|
||||
private static string ControllerName(Type controller)
|
||||
{
|
||||
const string suffix = "Controller";
|
||||
return controller.Name.EndsWith(suffix, StringComparison.Ordinal)
|
||||
? controller.Name[..^suffix.Length]
|
||||
: controller.Name;
|
||||
}
|
||||
|
||||
private static string CombineTemplates(string? controllerTemplate, string? actionTemplate)
|
||||
{
|
||||
controllerTemplate ??= "";
|
||||
actionTemplate ??= "";
|
||||
|
||||
if (actionTemplate.StartsWith("~/", StringComparison.Ordinal))
|
||||
{
|
||||
return "/" + actionTemplate[2..].TrimStart('/');
|
||||
}
|
||||
|
||||
if (actionTemplate.StartsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
return "/" + actionTemplate.TrimStart('/');
|
||||
}
|
||||
|
||||
var left = controllerTemplate.Trim('/');
|
||||
var right = actionTemplate.Trim('/');
|
||||
|
||||
if (string.IsNullOrEmpty(left) && string.IsNullOrEmpty(right))
|
||||
{
|
||||
return "/";
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(left))
|
||||
{
|
||||
return "/" + right;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(right))
|
||||
{
|
||||
return "/" + left;
|
||||
}
|
||||
|
||||
return "/" + left + "/" + right;
|
||||
}
|
||||
|
||||
private static string ReplaceRouteTokens(string template, string controllerName, string actionName)
|
||||
{
|
||||
return template
|
||||
.Replace("[controller]", controllerName, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("[action]", actionName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string ToSamplePath(string template)
|
||||
{
|
||||
var path = RouteParameterRegex().Replace(template, match =>
|
||||
{
|
||||
var name = match.Groups["name"].Value.ToLowerInvariant();
|
||||
var constraint = match.Groups["constraint"].Value.ToLowerInvariant();
|
||||
|
||||
if (name.Contains("playerid") || name.Contains("accountid") || name == "id")
|
||||
{
|
||||
return EndpointContract.TestPlayerToken;
|
||||
}
|
||||
|
||||
if (constraint.Contains("long") || constraint.Contains("int") || name.EndsWith("id"))
|
||||
{
|
||||
return "1";
|
||||
}
|
||||
|
||||
if (constraint.Contains("bool"))
|
||||
{
|
||||
return "true";
|
||||
}
|
||||
|
||||
if (constraint.Contains("guid"))
|
||||
{
|
||||
return "00000000-0000-0000-0000-000000000001";
|
||||
}
|
||||
|
||||
if (name.Contains("roomname"))
|
||||
{
|
||||
return "DormRoom";
|
||||
}
|
||||
|
||||
if (name.Contains("locale"))
|
||||
{
|
||||
return "en";
|
||||
}
|
||||
|
||||
return "sample";
|
||||
});
|
||||
|
||||
path = Regex.Replace(path, "/+", "/");
|
||||
return path.StartsWith('/') ? path : "/" + path;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\{[*]?(?<name>[^}:=\?]+)(?<constraint>:[^}=\?]+)?(?<optional>\?)?(?<default>=[^}]+)?\}")]
|
||||
private static partial Regex RouteParameterRegex();
|
||||
}
|
||||
|
||||
public sealed record EndpointContract(
|
||||
string Method,
|
||||
string Template,
|
||||
string SamplePath,
|
||||
string Source,
|
||||
bool RequiresAuthorization,
|
||||
bool IsAdminEndpoint,
|
||||
string[] Consumes)
|
||||
{
|
||||
public const string TestPlayerToken = "__test_player_id__";
|
||||
|
||||
public string PathFor(GameClientSession session)
|
||||
{
|
||||
return SamplePath.Replace(
|
||||
TestPlayerToken,
|
||||
session.PlayerId.ToString(),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed class GameClientEndpointContractTests :
|
||||
IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public GameClientEndpointContractTests(DorkNetServerFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Game_client_contract_probe_reaches_every_controller_route()
|
||||
{
|
||||
var routes = EndpointContractDiscovery.Discover();
|
||||
Assert.NotEmpty(routes);
|
||||
|
||||
using var client = _factory.CreateClient(new()
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
});
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(20);
|
||||
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
var failures = new List<string>();
|
||||
|
||||
foreach (var route in routes)
|
||||
{
|
||||
var result = await ProbeAsync(client, route, session, _factory.ApexDomain);
|
||||
if (!result.Passed)
|
||||
{
|
||||
failures.Add(
|
||||
$"{route.Method,-6} {result.Host}{result.Path} {route.Source} expected {result.Expected}, got {result.Observed}"
|
||||
+ (string.IsNullOrWhiteSpace(result.ResponseExcerpt) ? "" : $" body={result.ResponseExcerpt}"));
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
failures.Count == 0,
|
||||
$"Endpoint contract failures ({failures.Count}/{routes.Count}):{Environment.NewLine}"
|
||||
+ string.Join(Environment.NewLine, failures));
|
||||
}
|
||||
|
||||
private static async Task<EndpointProbeResult> ProbeAsync(
|
||||
HttpClient client,
|
||||
EndpointContract route,
|
||||
GameClientSession session,
|
||||
string apexDomain)
|
||||
{
|
||||
var path = route.PathFor(session);
|
||||
path = AddClientQuery(path, route, session);
|
||||
var host = HostFor(path, apexDomain);
|
||||
using var request = new HttpRequestMessage(
|
||||
ToHttpMethod(route.Method),
|
||||
new Uri($"http://{host}{path}"));
|
||||
request.Headers.Accept.ParseAdd("application/json");
|
||||
request.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
|
||||
if (NeedsRequestBody(request.Method))
|
||||
{
|
||||
request.Content = CreateContent(route, session);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await client.SendAsync(request);
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var passed = ResponseMatchesContract(route, response.StatusCode);
|
||||
return new EndpointProbeResult(
|
||||
route.Method,
|
||||
host,
|
||||
path,
|
||||
ExpectedContract(route),
|
||||
statusCode,
|
||||
null,
|
||||
Excerpt(responseText),
|
||||
passed);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
return new EndpointProbeResult(
|
||||
route.Method,
|
||||
host,
|
||||
path,
|
||||
ExpectedContract(route),
|
||||
null,
|
||||
ex.Message,
|
||||
null,
|
||||
false);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ResponseMatchesContract(
|
||||
EndpointContract route,
|
||||
HttpStatusCode statusCode)
|
||||
{
|
||||
if ((int)statusCode >= 500)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (statusCode is HttpStatusCode.MethodNotAllowed
|
||||
or HttpStatusCode.NotImplemented
|
||||
or HttpStatusCode.UpgradeRequired)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (route.RequiresAuthorization
|
||||
&& statusCode is HttpStatusCode.Unauthorized)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ExpectedContract(EndpointContract route)
|
||||
{
|
||||
if (route.IsAdminEndpoint)
|
||||
{
|
||||
return "admin endpoint may reject non-admin test player, but must not 5xx/405/426";
|
||||
}
|
||||
|
||||
if (route.RequiresAuthorization)
|
||||
{
|
||||
return "authenticated test player should reach the action without 401/5xx/405/426; 403 is valid for owner/admin-only resources";
|
||||
}
|
||||
|
||||
return "game-client request should not 5xx/405/426";
|
||||
}
|
||||
|
||||
private static HttpMethod ToHttpMethod(string method)
|
||||
{
|
||||
return string.Equals(method, "ANY", StringComparison.OrdinalIgnoreCase)
|
||||
? HttpMethod.Get
|
||||
: new HttpMethod(method);
|
||||
}
|
||||
|
||||
private static bool NeedsRequestBody(HttpMethod method)
|
||||
{
|
||||
return method == HttpMethod.Post
|
||||
|| method == HttpMethod.Put
|
||||
|| method.Method.Equals("PATCH", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static HttpContent CreateContent(
|
||||
EndpointContract route,
|
||||
GameClientSession session)
|
||||
{
|
||||
var consumes = route.Consumes.Select(c => c.ToLowerInvariant()).ToArray();
|
||||
if (consumes.Any(c => c.Contains("application/x-www-form-urlencoded")))
|
||||
{
|
||||
return new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["account_id"] = session.PlayerId.ToString(),
|
||||
["AccountId"] = session.PlayerId.ToString(),
|
||||
["clubId"] = "1",
|
||||
["ClubId"] = "1",
|
||||
["description"] = "Endpoint contract description",
|
||||
["deviceId"] = session.DeviceId,
|
||||
["grant_type"] = "password",
|
||||
["image"] = "endpoint-contract.png",
|
||||
["imageName"] = "endpoint-contract.png",
|
||||
["fileName"] = "endpoint-contract.png",
|
||||
["message"] = "Endpoint contract message",
|
||||
["name"] = "EndpointContract",
|
||||
["newPassword"] = "endpoint-contract-password",
|
||||
["oldPassword"] = "",
|
||||
["password"] = "endpoint-contract-password",
|
||||
["ping"] = "42",
|
||||
["platformId"] = session.PlatformId,
|
||||
["playerId"] = session.PlayerId.ToString(),
|
||||
["playerIds"] = session.PlayerId.ToString(),
|
||||
["query"] = "DormRoom",
|
||||
["region"] = "us",
|
||||
["targetPlayerId"] = session.PlayerId.ToString(),
|
||||
["text"] = "Endpoint contract message",
|
||||
["username"] = "EndpointContract",
|
||||
["device_id"] = session.DeviceId,
|
||||
["platform"] = "0",
|
||||
["platform_id"] = session.PlatformId,
|
||||
["Ids"] = session.PlayerId.ToString(),
|
||||
["PlayerIds"] = session.PlayerId.ToString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (consumes.Any(c => c.Contains("multipart/form-data")))
|
||||
{
|
||||
var content = new MultipartFormDataContent();
|
||||
content.Add(new StringContent("endpoint-contract"), "name");
|
||||
content.Add(new StringContent(session.PlayerId.ToString()), "playerId");
|
||||
content.Add(new StringContent("endpoint-contract.png"), "imageName");
|
||||
content.Add(new StringContent("endpoint-contract.png"), "fileName");
|
||||
content.Add(new ByteArrayContent([]), "file", "endpoint-contract.bin");
|
||||
return content;
|
||||
}
|
||||
|
||||
if (consumes.Any(c => c.Contains("text/plain")))
|
||||
{
|
||||
return new StringContent("", Encoding.UTF8, "text/plain");
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
PlayerId = session.PlayerId,
|
||||
AccountId = session.PlayerId,
|
||||
ClubId = 1,
|
||||
Description = "Endpoint contract description",
|
||||
DeviceId = session.DeviceId,
|
||||
FileName = "endpoint-contract.png",
|
||||
Id = session.PlayerId,
|
||||
Image = "endpoint-contract.png",
|
||||
ImageName = "endpoint-contract.png",
|
||||
Message = "Endpoint contract message",
|
||||
Platform = 0,
|
||||
PlatformId = session.PlatformId,
|
||||
PlayerIds = new[] { session.PlayerId },
|
||||
Name = "EndpointContract",
|
||||
Password = "endpoint-contract-password",
|
||||
Query = "DormRoom",
|
||||
RoomName = "DormRoom",
|
||||
TargetPlayerId = session.PlayerId,
|
||||
Text = "Endpoint contract message",
|
||||
});
|
||||
return new StringContent(json, Encoding.UTF8, "application/json");
|
||||
}
|
||||
|
||||
private static string AddClientQuery(
|
||||
string path,
|
||||
EndpointContract route,
|
||||
GameClientSession session)
|
||||
{
|
||||
var separator = path.Contains('?', StringComparison.Ordinal) ? "&" : "?";
|
||||
var lower = path.ToLowerInvariant();
|
||||
var query = new List<string>();
|
||||
|
||||
if (lower.Contains("search", StringComparison.Ordinal)
|
||||
|| lower.Contains("query", StringComparison.Ordinal))
|
||||
{
|
||||
query.Add("query=EndpointContract");
|
||||
}
|
||||
|
||||
if (lower == "/members/bulk")
|
||||
{
|
||||
query.Add("clubId=1");
|
||||
query.Add($"playerIds={session.PlayerId}");
|
||||
}
|
||||
|
||||
if (lower == "/player/photonregionpings")
|
||||
{
|
||||
query.Add("region=us");
|
||||
query.Add("ping=42");
|
||||
}
|
||||
|
||||
if (route.Source.Contains("CachedLogins", StringComparison.Ordinal)
|
||||
|| lower.Contains("platformlogin/cached", StringComparison.Ordinal)
|
||||
|| lower.Contains("savedlogins", StringComparison.Ordinal))
|
||||
{
|
||||
query.Add("platform=0");
|
||||
query.Add($"platformId={Uri.EscapeDataString(session.PlatformId)}");
|
||||
}
|
||||
|
||||
if (route.Source.Contains("Bulk", StringComparison.Ordinal)
|
||||
&& !query.Any(q => q.StartsWith("Ids=", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
query.Add($"Ids={session.PlayerId}");
|
||||
}
|
||||
|
||||
if (query.Count == 0)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
return path + separator + string.Join("&", query);
|
||||
}
|
||||
|
||||
private static string HostFor(string path, string apexDomain)
|
||||
{
|
||||
var lower = path.ToLowerInvariant();
|
||||
|
||||
if (lower.StartsWith("/connect/")
|
||||
|| lower.StartsWith("/.well-known/")
|
||||
|| lower.StartsWith("/eac/")
|
||||
|| lower.StartsWith("/cachedlogin/")
|
||||
|| lower.StartsWith("/photon/")
|
||||
|| lower.StartsWith("/api/platformlogin/")
|
||||
|| lower.StartsWith("/api/version")
|
||||
|| lower.StartsWith("/api/auth/"))
|
||||
{
|
||||
return $"auth.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/account/"))
|
||||
{
|
||||
return $"accounts.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/roomserver/")
|
||||
|| lower.StartsWith("/rooms")
|
||||
|| lower.StartsWith("/playlists")
|
||||
|| lower.StartsWith("/featuredrooms")
|
||||
|| lower.StartsWith("/hot_rooms")
|
||||
|| lower.StartsWith("/search_rooms")
|
||||
|| lower.StartsWith("/api/rooms")
|
||||
|| lower.StartsWith("/api/roomkeys")
|
||||
|| lower.StartsWith("/api/gamesessions")
|
||||
|| lower.StartsWith("/api/curatedroomplaylists"))
|
||||
{
|
||||
return $"rooms.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/v1/find")
|
||||
|| lower.StartsWith("/v1/join")
|
||||
|| lower.StartsWith("/v1/leave")
|
||||
|| lower.StartsWith("/v1/session")
|
||||
|| lower.StartsWith("/roominstance/")
|
||||
|| lower.StartsWith("/goto/")
|
||||
|| lower.StartsWith("/player/"))
|
||||
{
|
||||
return $"match.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/thread"))
|
||||
{
|
||||
return $"chat.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/club")
|
||||
|| lower.StartsWith("/announcements/")
|
||||
|| lower.StartsWith("/subscription/")
|
||||
|| lower.StartsWith("/members/")
|
||||
|| lower.StartsWith("/api/clubreporting/"))
|
||||
{
|
||||
return $"clubs.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/api/storefronts/")
|
||||
|| lower.StartsWith("/api/purchase/")
|
||||
|| lower.StartsWith("/api/subscription/"))
|
||||
{
|
||||
return $"commerce.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/hub/") || lower.StartsWith("/api/notification/"))
|
||||
{
|
||||
return $"notify.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/v1/regions"))
|
||||
{
|
||||
return $"geo.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/v1/services") || lower.StartsWith("/v1/ping"))
|
||||
{
|
||||
return $"ns.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/upload"))
|
||||
{
|
||||
return $"storage.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/img/") || lower.StartsWith("/room/") || lower.StartsWith("/data/"))
|
||||
{
|
||||
return $"cdn.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/api/admin/"))
|
||||
{
|
||||
return $"admin.{apexDomain}";
|
||||
}
|
||||
|
||||
return $"api.{apexDomain}";
|
||||
}
|
||||
|
||||
private static string? Excerpt(string? body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var compact = body.ReplaceLineEndings(" ").Trim();
|
||||
return compact.Length <= 240 ? compact : compact[..240] + "...";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record EndpointProbeResult(
|
||||
string Method,
|
||||
string Host,
|
||||
string Path,
|
||||
string Expected,
|
||||
int? StatusCode,
|
||||
string? Error,
|
||||
string? ResponseExcerpt,
|
||||
bool Passed)
|
||||
{
|
||||
public string Observed => StatusCode is int code ? code.ToString() : Error ?? "no response";
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed record GameClientSession(
|
||||
long PlayerId,
|
||||
string AccessToken,
|
||||
string DeviceId,
|
||||
string PlatformId);
|
||||
|
||||
public static class GameClientSessionFactory
|
||||
{
|
||||
public static async Task<GameClientSession> CreateAsync(
|
||||
HttpClient client,
|
||||
string apexDomain)
|
||||
{
|
||||
var deviceId = $"endpoint-contract-device-{Guid.NewGuid():N}";
|
||||
var platformId = $"endpoint-contract-platform-{Guid.NewGuid():N}";
|
||||
var tokenRequest = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
new Uri($"http://auth.{apexDomain}/connect/token"));
|
||||
tokenRequest.Headers.Accept.ParseAdd("application/json");
|
||||
tokenRequest.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
tokenRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "password",
|
||||
["client_id"] = "recroom",
|
||||
["client_secret"] = "VxZ53kgbbEaRoZAeMe00MagtgD12GLL2",
|
||||
["username"] = "EndpointContract",
|
||||
["device_id"] = deviceId,
|
||||
["platform"] = "0",
|
||||
["platform_id"] = platformId,
|
||||
});
|
||||
|
||||
using var tokenResponse = await client.SendAsync(tokenRequest);
|
||||
if (!tokenResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await tokenResponse.Content.ReadAsStringAsync();
|
||||
throw new InvalidOperationException(
|
||||
$"Test player token request failed with {(int)tokenResponse.StatusCode} {tokenResponse.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
using var tokenJson = await JsonDocument.ParseAsync(
|
||||
await tokenResponse.Content.ReadAsStreamAsync());
|
||||
var accessToken = tokenJson.RootElement.GetProperty("access_token").GetString()
|
||||
?? throw new InvalidOperationException("Token response did not include access_token.");
|
||||
|
||||
var userInfoRequest = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
new Uri($"http://auth.{apexDomain}/connect/userinfo"));
|
||||
userInfoRequest.Headers.Accept.ParseAdd("application/json");
|
||||
userInfoRequest.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
userInfoRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
using var userInfoResponse = await client.SendAsync(userInfoRequest);
|
||||
userInfoResponse.EnsureSuccessStatusCode();
|
||||
|
||||
using var userInfoJson = await JsonDocument.ParseAsync(
|
||||
await userInfoResponse.Content.ReadAsStreamAsync());
|
||||
var playerId = long.Parse(userInfoJson.RootElement.GetProperty("sub").GetString()!);
|
||||
|
||||
return new GameClientSession(playerId, accessToken, deviceId, platformId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Compat;
|
||||
using DorkNet.Server.Controllers.Auth;
|
||||
using DorkNet.Server.Versions.Late2020;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed class VersionCheckControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void March_2023_build_is_valid_with_minimal_service_config()
|
||||
{
|
||||
var controller = CreateController();
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(controller.Check("20230321", "0"));
|
||||
|
||||
Assert.Equal(0, ReadVersionStatus(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_build_still_requires_update()
|
||||
{
|
||||
var controller = CreateController();
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(controller.Check("unknown", "0"));
|
||||
|
||||
Assert.Equal(2, ReadVersionStatus(result.Value));
|
||||
}
|
||||
|
||||
private static VersionCheckController CreateController()
|
||||
{
|
||||
var config = new ConfigurationBuilder().Build();
|
||||
var registry = new VersionRegistry(
|
||||
[new Late2020VersionPlugin()],
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"march_2023_03_21",
|
||||
},
|
||||
"march_2023_03_21");
|
||||
|
||||
return new VersionCheckController(
|
||||
config,
|
||||
registry,
|
||||
NullLogger<VersionCheckController>.Instance);
|
||||
}
|
||||
|
||||
private static int ReadVersionStatus(object? value)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return document.RootElement.GetProperty("VersionStatus").GetInt32();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Open.IdentityServer;
|
||||
using Open.IdentityServer.Models;
|
||||
using Open.IdentityServer.ResponseHandling;
|
||||
using Open.IdentityServer.Services;
|
||||
using Open.IdentityServer.Stores;
|
||||
using Open.IdentityServer.Validation;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Auth;
|
||||
|
||||
public sealed class IdentityServerSigningKeyProvider
|
||||
{
|
||||
private readonly string _legacySecret;
|
||||
|
||||
public IdentityServerSigningKeyProvider(IConfiguration config)
|
||||
{
|
||||
_legacySecret =
|
||||
Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
|
||||
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
|
||||
?? config["Jwt:Secret"]
|
||||
?? throw new InvalidOperationException(
|
||||
"JWT secret not configured. Set DORKNET_JWT_SECRET env var or Jwt:Secret in appsettings.Local.json.");
|
||||
|
||||
CertificatePath = ResolveCertificatePath(config);
|
||||
var password = config["IdentityServer:SigningCertificatePassword"] ?? _legacySecret;
|
||||
var certificateBase64 = config["IdentityServer:SigningCertificateBase64"];
|
||||
if (!string.IsNullOrWhiteSpace(certificateBase64))
|
||||
{
|
||||
LoadedFromBase64 = true;
|
||||
Certificate = LoadCertificateFromBase64(certificateBase64, password);
|
||||
}
|
||||
else
|
||||
{
|
||||
Certificate = LoadOrCreateCertificate(CertificatePath, password);
|
||||
}
|
||||
SigningKey = new X509SecurityKey(Certificate);
|
||||
LegacyJwtKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_legacySecret));
|
||||
}
|
||||
|
||||
public string CertificatePath { get; }
|
||||
public bool LoadedFromBase64 { get; }
|
||||
public string CertificateSource => LoadedFromBase64
|
||||
? "IdentityServer:SigningCertificateBase64"
|
||||
: CertificatePath;
|
||||
public X509Certificate2 Certificate { get; }
|
||||
public SecurityKey SigningKey { get; }
|
||||
public SecurityKey LegacyJwtKey { get; }
|
||||
public SecurityKey[] ValidationKeys => new[] { SigningKey, LegacyJwtKey };
|
||||
|
||||
private static string ResolveCertificatePath(IConfiguration config)
|
||||
{
|
||||
var path = config["IdentityServer:SigningCertificatePath"];
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
path = Path.Combine(AppContext.BaseDirectory, "data", "identityserver-signing.pfx");
|
||||
return path;
|
||||
}
|
||||
|
||||
private static X509Certificate2 LoadCertificateFromBase64(string certificateBase64, string password)
|
||||
{
|
||||
var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
|
||||
var compact = NormalizeCertificateBase64(certificateBase64);
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(compact);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"IdentityServer signing certificate base64 is invalid. " +
|
||||
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64, " +
|
||||
"without a file path or shell assignment text.",
|
||||
ex);
|
||||
}
|
||||
try
|
||||
{
|
||||
return new X509Certificate2(bytes, password, flags);
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
if (TryLoadPemCertificate(bytes, password, flags, out var certificate))
|
||||
return certificate;
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"IdentityServer signing certificate base64 decoded successfully, but it is not a valid PFX/PKCS#12 certificate. " +
|
||||
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64 and make sure " +
|
||||
"IdentityServer__SigningCertificatePassword matches the PFX password.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryLoadPemCertificate(
|
||||
byte[] bytes,
|
||||
string password,
|
||||
X509KeyStorageFlags flags,
|
||||
out X509Certificate2 certificate)
|
||||
{
|
||||
certificate = null!;
|
||||
|
||||
string pem;
|
||||
try
|
||||
{
|
||||
pem = Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!pem.Contains("-----BEGIN", StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
using var pemCertificate = X509Certificate2.CreateFromPem(pem, pem);
|
||||
if (!pemCertificate.HasPrivateKey)
|
||||
return false;
|
||||
|
||||
var pfxBytes = pemCertificate.Export(X509ContentType.Pfx, password);
|
||||
certificate = new X509Certificate2(pfxBytes, password, flags);
|
||||
return true;
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeCertificateBase64(string value)
|
||||
{
|
||||
var text = value.Trim().Trim('"', '\'');
|
||||
var equals = text.IndexOf('=');
|
||||
if (equals > 0)
|
||||
{
|
||||
var key = text[..equals].Trim();
|
||||
if (key.Equals("IdentityServer__SigningCertificateBase64", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Equals("IdentityServer:SigningCertificateBase64", StringComparison.OrdinalIgnoreCase))
|
||||
text = text[(equals + 1)..].Trim().Trim('"', '\'');
|
||||
}
|
||||
|
||||
if (text.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var comma = text.IndexOf(',');
|
||||
if (comma >= 0)
|
||||
text = text[(comma + 1)..];
|
||||
}
|
||||
|
||||
if (text.StartsWith("base64:", StringComparison.OrdinalIgnoreCase))
|
||||
text = text["base64:".Length..];
|
||||
|
||||
var compact = new string(text.Where(c => !char.IsWhiteSpace(c)).ToArray())
|
||||
.Replace('-', '+')
|
||||
.Replace('_', '/');
|
||||
return (compact.Length % 4) switch
|
||||
{
|
||||
2 => compact + "==",
|
||||
3 => compact + "=",
|
||||
_ => compact,
|
||||
};
|
||||
}
|
||||
|
||||
private static X509Certificate2 LoadOrCreateCertificate(string path, string password)
|
||||
{
|
||||
var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
if (File.Exists(path))
|
||||
return new X509Certificate2(path, password, flags);
|
||||
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest(
|
||||
"CN=DorkNet IdentityServer Signing",
|
||||
rsa,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
request.CertificateExtensions.Add(
|
||||
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: false));
|
||||
|
||||
using var created = request.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
DateTimeOffset.UtcNow.AddYears(10));
|
||||
var bytes = created.Export(X509ContentType.Pfx, password);
|
||||
File.WriteAllBytes(path, bytes);
|
||||
return new X509Certificate2(bytes, password, flags);
|
||||
}
|
||||
}
|
||||
|
||||
public static class RecRoomIdentityServerRegistration
|
||||
{
|
||||
public static void AddRecRoomIdentityServer(
|
||||
this IServiceCollection services,
|
||||
IConfiguration config,
|
||||
DomainConfig domain,
|
||||
IdentityServerSigningKeyProvider signingKeyProvider)
|
||||
{
|
||||
services
|
||||
.AddIdentityServer(options =>
|
||||
{
|
||||
options.IssuerUri = domain.AuthIssuer.TrimEnd('/');
|
||||
})
|
||||
.AddInMemoryIdentityResources(RecRoomIdentityServerConfig.IdentityResources)
|
||||
.AddInMemoryApiScopes(RecRoomIdentityServerConfig.ApiScopes)
|
||||
.AddInMemoryClients(RecRoomIdentityServerConfig.Clients)
|
||||
.AddSigningCredential(signingKeyProvider.SigningKey, SecurityAlgorithms.RsaSha256)
|
||||
.AddResourceOwnerValidator<RecRoomPasswordValidator>()
|
||||
.AddExtensionGrantValidator<CachedLoginGrantValidator>()
|
||||
.AddProfileService<RecRoomProfileService>();
|
||||
|
||||
services.AddTransient<ITokenResponseGenerator, RecRoomTokenResponseGenerator>();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class RecRoomIdentityServerConfig
|
||||
{
|
||||
public const string ClientId = "recroom";
|
||||
public const string ClientSecret = "VxZ53kgbbEaRoZAeMe00MagtgD12GLL2";
|
||||
public const string GameClientScope = "gameClient";
|
||||
public const string CachedLoginGrantType = "cached_login";
|
||||
|
||||
public static readonly IdentityResource[] IdentityResources =
|
||||
{
|
||||
new IdentityResources.OpenId(),
|
||||
new IdentityResources.Profile(),
|
||||
};
|
||||
|
||||
public static readonly ApiScope[] ApiScopes =
|
||||
{
|
||||
new(GameClientScope, "Game client")
|
||||
{
|
||||
UserClaims = RecRoomIdentityClaims.UserClaimTypes,
|
||||
},
|
||||
new("api", "DorkNet API")
|
||||
{
|
||||
UserClaims = RecRoomIdentityClaims.UserClaimTypes,
|
||||
},
|
||||
};
|
||||
|
||||
public static readonly Client[] Clients =
|
||||
{
|
||||
new()
|
||||
{
|
||||
ClientId = ClientId,
|
||||
ClientName = "Rec Room game client",
|
||||
RequireClientSecret = true,
|
||||
ClientSecrets = { new Secret(ClientSecret.Sha256()) },
|
||||
AllowedGrantTypes = { GrantType.ResourceOwnerPassword, "refresh_token", CachedLoginGrantType },
|
||||
AllowedScopes = RecRoomScopes(),
|
||||
AllowOfflineAccess = true,
|
||||
AccessTokenLifetime = 60 * 60 * 12,
|
||||
AbsoluteRefreshTokenLifetime = 60 * 60 * 24 * 30,
|
||||
SlidingRefreshTokenLifetime = 60 * 60 * 24 * 30,
|
||||
RefreshTokenUsage = TokenUsage.ReUse,
|
||||
RefreshTokenExpiration = TokenExpiration.Absolute,
|
||||
UpdateAccessTokenClaimsOnRefresh = true,
|
||||
AlwaysIncludeUserClaimsInIdToken = true,
|
||||
},
|
||||
};
|
||||
|
||||
private static ICollection<string> RecRoomScopes() =>
|
||||
[
|
||||
IdentityServerConstants.StandardScopes.OpenId,
|
||||
IdentityServerConstants.StandardScopes.Profile,
|
||||
IdentityServerConstants.StandardScopes.OfflineAccess,
|
||||
GameClientScope,
|
||||
"api",
|
||||
];
|
||||
}
|
||||
|
||||
public sealed class IdentityServerGameTokenRequestMiddleware(
|
||||
RequestDelegate next,
|
||||
DomainConfig domain,
|
||||
IConfiguration config,
|
||||
ILogger<IdentityServerGameTokenRequestMiddleware> logger)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (!IsTokenRequest(context))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var form = await context.Request.ReadFormAsync();
|
||||
var values = form.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => kvp.Value,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var changed = false;
|
||||
|
||||
if (IsDevicePasswordGrant(values))
|
||||
{
|
||||
if (!values.ContainsKey("username") || string.IsNullOrWhiteSpace(values["username"].ToString()))
|
||||
{
|
||||
values["username"] = First(
|
||||
values.TryGetValue("deviceId", out var deviceId) ? deviceId.ToString() : null,
|
||||
values.TryGetValue("device_id", out var deviceIdSnake) ? deviceIdSnake.ToString() : null,
|
||||
values.TryGetValue("platformId", out var platformId) ? platformId.ToString() : null,
|
||||
values.TryGetValue("platform_id", out var platformIdSnake) ? platformIdSnake.ToString() : null,
|
||||
"anonymous");
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!values.ContainsKey("password") || string.IsNullOrWhiteSpace(values["password"].ToString()))
|
||||
{
|
||||
values["password"] = "__dorknet_device_login__";
|
||||
values["dorknet_device_login"] = "true";
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
var scopes = values.TryGetValue("scope", out var rawScope)
|
||||
? rawScope.ToString()
|
||||
: string.Empty;
|
||||
var mergedScopes = MergeScopes(scopes);
|
||||
if (!string.Equals(scopes, mergedScopes, StringComparison.Ordinal))
|
||||
{
|
||||
values["scope"] = mergedScopes;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
context.Features.Set<IFormFeature>(new FormFeature(new FormCollection(values)));
|
||||
|
||||
await InvokeAndPersistAccessTokenCookieAsync(context);
|
||||
}
|
||||
|
||||
private async Task InvokeAndPersistAccessTokenCookieAsync(HttpContext context)
|
||||
{
|
||||
var originalBody = context.Response.Body;
|
||||
await using var bufferedBody = new MemoryStream();
|
||||
context.Response.Body = bufferedBody;
|
||||
|
||||
try
|
||||
{
|
||||
await next(context);
|
||||
|
||||
bufferedBody.Position = 0;
|
||||
if (IsSuccessfulJsonResponse(context.Response))
|
||||
TryPersistAccessTokenCookie(context, bufferedBody);
|
||||
|
||||
bufferedBody.Position = 0;
|
||||
await bufferedBody.CopyToAsync(originalBody);
|
||||
}
|
||||
finally
|
||||
{
|
||||
context.Response.Body = originalBody;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSuccessfulJsonResponse(HttpResponse response) =>
|
||||
response.StatusCode is >= 200 and < 300
|
||||
&& response.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
private void TryPersistAccessTokenCookie(HttpContext context, Stream responseBody)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(responseBody);
|
||||
if (!document.RootElement.TryGetProperty("access_token", out var accessTokenElement))
|
||||
return;
|
||||
var accessToken = accessTokenElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(accessToken))
|
||||
return;
|
||||
|
||||
var expires = DateTimeOffset.UtcNow.AddHours(12);
|
||||
if (document.RootElement.TryGetProperty("expires_in", out var expiresInElement) &&
|
||||
expiresInElement.TryGetInt32(out var expiresIn) &&
|
||||
expiresIn > 0)
|
||||
{
|
||||
expires = DateTimeOffset.UtcNow.AddSeconds(expiresIn);
|
||||
}
|
||||
|
||||
var options = new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = string.Equals(domain.Scheme, "https", StringComparison.OrdinalIgnoreCase),
|
||||
SameSite = SameSiteMode.None,
|
||||
Expires = expires,
|
||||
IsEssential = true,
|
||||
};
|
||||
|
||||
var cookieDomain = ResolveCookieDomain(context.Request.Host.Host);
|
||||
if (cookieDomain is not null)
|
||||
options.Domain = cookieDomain;
|
||||
|
||||
context.Response.Cookies.Append(AuthService.AccessCookieName, accessToken, options);
|
||||
logger.LogInformation(
|
||||
"[auth-cookie] issued access cookie host={Host} domain={Domain} secure={Secure} sameSite={SameSite} essential={IsEssential}",
|
||||
context.Request.Host.Host,
|
||||
cookieDomain ?? "<host-only>",
|
||||
options.Secure,
|
||||
options.SameSite,
|
||||
options.IsEssential);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Leave the token response untouched if the upstream payload is not JSON.
|
||||
}
|
||||
}
|
||||
|
||||
private string? ResolveCookieDomain(string host)
|
||||
{
|
||||
var configuredCookieDomain =
|
||||
Environment.GetEnvironmentVariable("DORKNET_AUTH_COOKIE_DOMAIN")
|
||||
?? config["Auth:CookieDomain"]
|
||||
?? config["IdentityServer:CookieDomain"];
|
||||
if (!string.IsNullOrWhiteSpace(configuredCookieDomain))
|
||||
return configuredCookieDomain.Trim().TrimStart('.') is { Length: > 0 } value ? "." + value : null;
|
||||
|
||||
var apex = domain.Apex.Trim().TrimStart('.');
|
||||
if (string.IsNullOrWhiteSpace(apex) ||
|
||||
apex.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
|
||||
apex.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase) ||
|
||||
apex.Equals("::1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (host.Equals(apex, StringComparison.OrdinalIgnoreCase) ||
|
||||
host.EndsWith("." + apex, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "." + apex;
|
||||
}
|
||||
|
||||
if (domain.UsesHyphenSubdomains)
|
||||
{
|
||||
var parent = HyphenCookieParent(apex, host);
|
||||
if (parent is not null)
|
||||
return "." + parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? HyphenCookieParent(string apex, string host)
|
||||
{
|
||||
var parts = apex.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length < 3)
|
||||
return null;
|
||||
|
||||
var environmentLabel = parts[0];
|
||||
var parent = string.Join('.', parts.Skip(1));
|
||||
if (!host.EndsWith("." + parent, StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
var hostLabel = host[..^(parent.Length + 1)];
|
||||
return hostLabel.EndsWith("-" + environmentLabel, StringComparison.OrdinalIgnoreCase)
|
||||
? parent
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool IsTokenRequest(HttpContext context) =>
|
||||
HttpMethods.IsPost(context.Request.Method)
|
||||
&& context.Request.Path.Equals("/connect/token", StringComparison.OrdinalIgnoreCase)
|
||||
&& context.Request.HasFormContentType;
|
||||
|
||||
private static bool IsDevicePasswordGrant(Dictionary<string, StringValues> values)
|
||||
{
|
||||
if (!values.TryGetValue("grant_type", out var grantType) ||
|
||||
!string.Equals(grantType.ToString(), GrantType.ResourceOwnerPassword, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
return !values.TryGetValue("password", out var password) ||
|
||||
string.IsNullOrWhiteSpace(password.ToString());
|
||||
}
|
||||
|
||||
private static string First(params string?[] values) =>
|
||||
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "anonymous";
|
||||
|
||||
private static string MergeScopes(string existing)
|
||||
{
|
||||
var scopes = existing
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
scopes.Add(IdentityServerConstants.StandardScopes.OpenId);
|
||||
scopes.Add(IdentityServerConstants.StandardScopes.Profile);
|
||||
scopes.Add(IdentityServerConstants.StandardScopes.OfflineAccess);
|
||||
scopes.Add(RecRoomIdentityServerConfig.GameClientScope);
|
||||
return string.Join(' ', scopes);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RecRoomTokenResponseGenerator(
|
||||
TimeProvider clock,
|
||||
ITokenService tokenService,
|
||||
IRefreshTokenService refreshTokenService,
|
||||
IScopeParser scopeParser,
|
||||
IResourceStore resources,
|
||||
IClientStore clients,
|
||||
ILogger<TokenResponseGenerator> logger) : ITokenResponseGenerator
|
||||
{
|
||||
private readonly TokenResponseGenerator _inner = new(
|
||||
clock,
|
||||
tokenService,
|
||||
refreshTokenService,
|
||||
scopeParser,
|
||||
resources,
|
||||
clients,
|
||||
logger);
|
||||
|
||||
public async Task<TokenResponse> ProcessAsync(TokenRequestValidationResult request)
|
||||
{
|
||||
var response = await _inner.ProcessAsync(request);
|
||||
AddLegacyGameFields(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
private static void AddLegacyGameFields(TokenResponse response)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(response.AccessToken))
|
||||
return;
|
||||
|
||||
response.Custom ??= new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
AddIfMissing(response.Custom, "Access_token", response.AccessToken);
|
||||
AddIfMissing(response.Custom, "accessToken", response.AccessToken);
|
||||
AddIfMissing(response.Custom, "AccessToken", response.AccessToken);
|
||||
AddIfMissing(response.Custom, "token", response.AccessToken);
|
||||
AddIfMissing(response.Custom, "Token", response.AccessToken);
|
||||
|
||||
AddIfMissing(response.Custom, "tokenType", "Bearer");
|
||||
AddIfMissing(response.Custom, "TokenType", "Bearer");
|
||||
AddIfMissing(response.Custom, "expiresIn", response.AccessTokenLifetime);
|
||||
AddIfMissing(response.Custom, "ExpiresIn", response.AccessTokenLifetime);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(response.RefreshToken))
|
||||
{
|
||||
AddIfMissing(response.Custom, "Refresh_token", response.RefreshToken);
|
||||
AddIfMissing(response.Custom, "refreshToken", response.RefreshToken);
|
||||
AddIfMissing(response.Custom, "RefreshToken", response.RefreshToken);
|
||||
}
|
||||
|
||||
var signingKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
AddIfMissing(response.Custom, "key", signingKey);
|
||||
AddIfMissing(response.Custom, "Key", signingKey);
|
||||
}
|
||||
|
||||
private static void AddIfMissing(IDictionary<string, object> values, string key, object value)
|
||||
{
|
||||
if (!values.ContainsKey(key))
|
||||
values[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RecRoomPasswordValidator(
|
||||
PlayerService playerService,
|
||||
LevelService level,
|
||||
ServerSettingsService settings,
|
||||
DorkNetDbContext db,
|
||||
ILogger<RecRoomPasswordValidator> logger) : IResourceOwnerPasswordValidator
|
||||
{
|
||||
public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
|
||||
{
|
||||
var raw = context.Request.Raw;
|
||||
var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
|
||||
var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
|
||||
var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
|
||||
var deviceLogin = string.Equals(raw.Get("dorknet_device_login"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!deviceLogin && !string.IsNullOrEmpty(context.Password) && !string.IsNullOrEmpty(context.UserName))
|
||||
{
|
||||
var byName = await playerService.GetByUsernameAsync(context.UserName);
|
||||
if (byName is null || byName.PasswordHash is null)
|
||||
{
|
||||
context.Result = new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidGrant,
|
||||
"unknown_user_or_no_password");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!BCrypt.Net.BCrypt.Verify(context.Password, byName.PasswordHash))
|
||||
{
|
||||
context.Result = new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidGrant,
|
||||
"password_mismatch");
|
||||
return;
|
||||
}
|
||||
|
||||
await playerService.TagPlatformAsync(byName.Id, platform, platformId);
|
||||
await TryAwardLoginXpAsync(byName.Id);
|
||||
context.Result = await CreateSuccessAsync(byName, "password");
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveDeviceId = !string.IsNullOrWhiteSpace(deviceId)
|
||||
? deviceId
|
||||
: $"legacy-{(context.UserName ?? "anonymous").ToLowerInvariant()}";
|
||||
|
||||
if (await settings.AreSignupsDisabledAsync())
|
||||
{
|
||||
var existing = await playerService.GetByDeviceAsync(effectiveDeviceId);
|
||||
if (existing is null)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[auth] IdentityServer password grant device fallback refused - signups disabled (device={Device})",
|
||||
effectiveDeviceId);
|
||||
context.Result = new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidGrant,
|
||||
"signups_disabled");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var player = await playerService.GetOrCreateByDeviceAsync(
|
||||
deviceId: effectiveDeviceId,
|
||||
platform: platform,
|
||||
platformId: platformId,
|
||||
displayName: context.UserName);
|
||||
await TryAwardLoginXpAsync(player.Id);
|
||||
context.Result = await CreateSuccessAsync(player, "device_password");
|
||||
}
|
||||
|
||||
private async Task<GrantValidationResult> CreateSuccessAsync(PlayerEntity player, string authMethod) =>
|
||||
new(
|
||||
subject: player.Id.ToString(),
|
||||
authenticationMethod: authMethod,
|
||||
claims: await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
|
||||
|
||||
private async Task TryAwardLoginXpAsync(long playerId)
|
||||
{
|
||||
var lastSeen = await db.Players
|
||||
.Where(p => p.Id == playerId)
|
||||
.Select(p => p.LastSeenAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (lastSeen.Date < DateTime.UtcNow.Date)
|
||||
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
|
||||
}
|
||||
|
||||
private static string? First(params string?[] values) =>
|
||||
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
|
||||
}
|
||||
|
||||
public sealed class CachedLoginGrantValidator(
|
||||
PlayerService playerService,
|
||||
LevelService level,
|
||||
OrphanAccountTracker orphans,
|
||||
DorkNetDbContext db,
|
||||
ILogger<CachedLoginGrantValidator> logger) : IExtensionGrantValidator
|
||||
{
|
||||
public string GrantType => RecRoomIdentityServerConfig.CachedLoginGrantType;
|
||||
|
||||
public async Task ValidateAsync(ExtensionGrantValidationContext context)
|
||||
{
|
||||
var raw = context.Request.Raw;
|
||||
if (!long.TryParse(raw.Get("account_id"), out var pickedId))
|
||||
{
|
||||
context.Result = new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidGrant,
|
||||
"missing account_id");
|
||||
return;
|
||||
}
|
||||
|
||||
var picked = await playerService.GetByIdAsync(pickedId);
|
||||
if (picked is null)
|
||||
{
|
||||
context.Result = new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidGrant,
|
||||
"unknown account_id");
|
||||
return;
|
||||
}
|
||||
|
||||
var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
|
||||
var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
|
||||
var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
|
||||
|
||||
var pendingId = orphans.PeekPending(deviceId, platformId);
|
||||
if (pendingId is long orphanId && orphanId != pickedId)
|
||||
{
|
||||
await playerService.DeleteOrphanAsync(orphanId);
|
||||
logger.LogInformation(
|
||||
"[orphan-cleanup] cached_login picked {PickedId}; deleted just-created {OrphanId}",
|
||||
pickedId,
|
||||
orphanId);
|
||||
}
|
||||
orphans.Clear(deviceId, platformId);
|
||||
|
||||
await TryAwardLoginXpAsync(picked.Id);
|
||||
await playerService.TagPlatformAsync(picked.Id, platform, platformId);
|
||||
context.Result = new GrantValidationResult(
|
||||
subject: picked.Id.ToString(),
|
||||
authenticationMethod: GrantType,
|
||||
claims: await RecRoomIdentityClaims.CreateAsync(db, picked.Id, picked.Username));
|
||||
}
|
||||
|
||||
private async Task TryAwardLoginXpAsync(long playerId)
|
||||
{
|
||||
var lastSeen = await db.Players
|
||||
.Where(p => p.Id == playerId)
|
||||
.Select(p => p.LastSeenAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (lastSeen.Date < DateTime.UtcNow.Date)
|
||||
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
|
||||
}
|
||||
|
||||
private static string? First(params string?[] values) =>
|
||||
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
|
||||
}
|
||||
|
||||
public sealed class RecRoomProfileService(PlayerService playerService, DorkNetDbContext db) : IProfileService
|
||||
{
|
||||
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
|
||||
{
|
||||
if (!TryGetSubjectId(context.Subject, out var playerId))
|
||||
return;
|
||||
|
||||
var player = await playerService.GetByIdAsync(playerId);
|
||||
if (player is null)
|
||||
return;
|
||||
|
||||
context.IssuedClaims.AddRange(
|
||||
await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
|
||||
}
|
||||
|
||||
public async Task IsActiveAsync(IsActiveContext context)
|
||||
{
|
||||
context.IsActive =
|
||||
TryGetSubjectId(context.Subject, out var playerId)
|
||||
&& await playerService.GetByIdAsync(playerId) is not null;
|
||||
}
|
||||
|
||||
private static bool TryGetSubjectId(ClaimsPrincipal subject, out long playerId)
|
||||
{
|
||||
var value = subject.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
||||
?? subject.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
return long.TryParse(value, out playerId);
|
||||
}
|
||||
}
|
||||
|
||||
public static class RecRoomIdentityClaims
|
||||
{
|
||||
public static readonly List<string> UserClaimTypes =
|
||||
[
|
||||
JwtRegisteredClaimNames.Sub,
|
||||
ClaimTypes.NameIdentifier,
|
||||
"name",
|
||||
"role",
|
||||
ClaimTypes.Role,
|
||||
"roles",
|
||||
"accountId",
|
||||
"account_id",
|
||||
"accountid",
|
||||
];
|
||||
|
||||
public static async Task<List<Claim>> CreateAsync(DorkNetDbContext db, long playerId, string? username = null)
|
||||
{
|
||||
var playerInfo = await db.Players
|
||||
.Where(p => p.Id == playerId)
|
||||
.Select(p => new { p.Username, IsDev = p.IsDeveloper || p.IsAdmin })
|
||||
.FirstOrDefaultAsync();
|
||||
var name = username ?? playerInfo?.Username ?? playerId.ToString();
|
||||
var isDev = playerInfo?.IsDev ?? false;
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, playerId.ToString()),
|
||||
new(JwtRegisteredClaimNames.Sub, playerId.ToString()),
|
||||
new("name", name),
|
||||
new("accountId", playerId.ToString()),
|
||||
new("account_id", playerId.ToString()),
|
||||
new("accountid", playerId.ToString()),
|
||||
new(ClaimTypes.Role, "gameClient"),
|
||||
new("role", "gameClient"),
|
||||
new("roles", "gameClient"),
|
||||
};
|
||||
|
||||
if (isDev)
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Role, "developer"));
|
||||
claims.Add(new Claim("role", "developer"));
|
||||
claims.Add(new Claim("roles", "developer"));
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
}
|
||||
@@ -106,9 +106,11 @@ public class AccountApiController(
|
||||
private async Task<PlayerEntity?> GetCurrentPlayerAsync()
|
||||
{
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
|
||||
? auth["Bearer ".Length..].Trim()
|
||||
: Request.Cookies[AuthService.AccessCookieName];
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return null;
|
||||
var token = auth["Bearer ".Length..].Trim();
|
||||
var id = authService.ValidateToken(token);
|
||||
if (id is not long playerId) return null;
|
||||
return await playerService.GetByIdAsync(playerId);
|
||||
@@ -201,12 +203,12 @@ public class AccountApiController(
|
||||
|
||||
[HttpPost("api/account/v1/profileimage")]
|
||||
[HttpPost("api/account/me/profileimage")]
|
||||
[HttpPost("account/me/profileimage")]
|
||||
[HttpPost("profileimage")]
|
||||
[HttpPost("account/me/profileimage", Order = 10)]
|
||||
[HttpPost("profileimage", Order = 10)]
|
||||
[HttpPut("api/account/v1/profileimage")]
|
||||
[HttpPut("api/account/me/profileimage")]
|
||||
[HttpPut("account/me/profileimage")]
|
||||
[HttpPut("profileimage")]
|
||||
[HttpPut("account/me/profileimage", Order = 10)]
|
||||
[HttpPut("profileimage", Order = 10)]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> SetProfileImage(
|
||||
[FromForm(Name = "imageName")] string? imageName,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Cryptography;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.AgeVerification;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/ageverification")]
|
||||
[Authorize]
|
||||
public class AgeVerificationController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpPost("generateCode")]
|
||||
[HttpGet("generateCode")]
|
||||
public async Task<IActionResult> GenerateCode()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var code = RandomNumberGenerator.GetInt32(100000, 999999).ToString();
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
Key = $"ageverification:{DateTime.UtcNow:yyyyMMddHHmmss}",
|
||||
Value = code,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Code = code, VerificationCode = code, ExpiresAt = DateTime.UtcNow.AddMinutes(15) });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.AppIntegrity;
|
||||
|
||||
[ApiController]
|
||||
public class AppIntegrityController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/AppIntegrity/v1/iosproducts")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> IosProducts()
|
||||
{
|
||||
var rows = await db.StoreItems
|
||||
.Where(i => i.IsActive)
|
||||
.OrderBy(i => i.Id)
|
||||
.Take(100)
|
||||
.Select(i => new
|
||||
{
|
||||
ProductId = i.Slug,
|
||||
i.Price,
|
||||
i.CurrencyType,
|
||||
Name = i.DisplayName,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpPost("api/AppIntegrity/v1/iospaymentqueuefailed")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> IosPaymentQueueFailed([FromForm] string? reason)
|
||||
{
|
||||
db.BugReports.Add(new BugReportEntity
|
||||
{
|
||||
ReporterPlayerId = this.RequireCurrentPlayerId(),
|
||||
Title = "iOS payment queue failed",
|
||||
Body = reason ?? Request.Query["reason"].FirstOrDefault() ?? string.Empty,
|
||||
Category = "appintegrity",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Apple;
|
||||
|
||||
[ApiController]
|
||||
public class AppleMusicPromotionController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/apple/musicpromotion/active")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Active()
|
||||
{
|
||||
var start = new DateTime(DateTime.UtcNow.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var end = start.AddYears(1);
|
||||
return Ok(new { Active = true, StartAt = start, EndAt = end });
|
||||
}
|
||||
|
||||
[HttpPost("api/apple/musicpromotion/code")]
|
||||
[HttpGet("api/apple/musicpromotion/code")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Code()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "applemusic:promotion_code");
|
||||
if (row is null)
|
||||
{
|
||||
row = new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = me,
|
||||
Key = "applemusic:promotion_code",
|
||||
Value = $"RR-{Guid.NewGuid():N}"[..16],
|
||||
};
|
||||
db.PlayerSettings.Add(row);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok(new { Code = row.Value, Redeemed = false });
|
||||
}
|
||||
}
|
||||
@@ -85,16 +85,17 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
||||
if (!config.GetValue("Avatar:EnableUnlockedItems", true))
|
||||
return Ok(new List<UnlockedAvatarItemDto>());
|
||||
|
||||
// Defaults raised (30 → 100, 4 → 20) — the old 4-per-slot silently
|
||||
// dropped any newly-purchased item once a slot held 4+ entries (the
|
||||
// "I bought it but it's gone after relog" symptom). The clamp
|
||||
// ranges stay at [1,120] / [1,20] — those reflect empirical watch
|
||||
// testing on render-budget headroom (per the prior maintainer note
|
||||
// about BrowsableAvatarItem / DynamicAvatarItemImposter thrash
|
||||
// above this count); raising the clamp ceiling without retesting
|
||||
// could crash the watch's wardrobe drawer.
|
||||
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 100), 1, 120);
|
||||
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 20), 1, 20);
|
||||
// The old [1,120] / [1,20] ceilings reflected the 2020 *watch's*
|
||||
// render-budget headroom — feeding it too many BrowsableAvatarItem /
|
||||
// DynamicAvatarItemImposter rows could crash its wardrobe drawer.
|
||||
// This branch (march-2023-03-21) serves the 2023 DESKTOP client, which
|
||||
// has no such limit, and the watch-era cap was silently truncating the
|
||||
// owned wardrobe to 100 ("most of my items don't show"). Raise the
|
||||
// ceilings well above the full catalog (882 base items + variants) so
|
||||
// nothing the player owns is dropped; the config keys still let an
|
||||
// operator dial it back down for a watch build.
|
||||
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 4000), 1, 8000);
|
||||
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 1000), 1, 2000);
|
||||
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>());
|
||||
@@ -186,6 +187,22 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("api/avatar/v1/defaultbaseavataritems")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult DefaultBaseAvatarItems()
|
||||
{
|
||||
var rows = _catalog.Value.Items.Values
|
||||
.OrderBy(i => i.AvatarItemType)
|
||||
.ThenBy(i => i.FriendlyName)
|
||||
.Take(100)
|
||||
.ToList();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpGet("api/avatar/v1/defaultunlocked")]
|
||||
[AllowAnonymous]
|
||||
public Task<ActionResult<List<UnlockedAvatarItemDto>>> DefaultUnlocked() => Get();
|
||||
|
||||
/// <summary>The set of GUIDs the bundled
|
||||
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
|
||||
/// contains. Exposed so other controllers (StoreService seed,
|
||||
|
||||
@@ -60,19 +60,42 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
// 2020.12 deserializer for this response expects `subscription` to be a
|
||||
// nested OBJECT (Subscription DTO with subscriptionId/recNetPlayerId/...
|
||||
// fields), NOT a boolean. Sending a bool throws InvalidCastException
|
||||
// on the client ("Unable to cast object of type 'Boolean' to type
|
||||
// 'Dictionary`2'"). Send null when there's no active subscription —
|
||||
// the client's generic-cast helper returns default(T) which is fine.
|
||||
return Ok(new
|
||||
var active = targetId == pid || await db.Subscriptions.AnyAsync(s =>
|
||||
s.SubscriberPlayerId == pid && s.TargetPlayerId == targetId);
|
||||
var now = DateTime.UtcNow;
|
||||
var subscriptionId = unchecked((pid * 397L) ^ targetId);
|
||||
var subscription = new Dictionary<string, object?>
|
||||
{
|
||||
subscription = (object?)null,
|
||||
platformAccountSubscribedPlayerId = targetId,
|
||||
["subscriptionId"] = subscriptionId,
|
||||
["recNetPlayerId"] = pid,
|
||||
["platformAccountSubscribedPlayerId"] = targetId,
|
||||
["isActive"] = active,
|
||||
["startedAt"] = now,
|
||||
["currentPeriodEnd"] = active ? now.AddYears(10) : now,
|
||||
["renewalDate"] = active ? now.AddYears(10) : now,
|
||||
["SubscriptionId"] = subscriptionId,
|
||||
["RecNetPlayerId"] = pid,
|
||||
["PlatformAccountSubscribedPlayerId"] = targetId,
|
||||
["IsActive"] = active,
|
||||
["StartedAt"] = now,
|
||||
["CurrentPeriodEnd"] = active ? now.AddYears(10) : now,
|
||||
["RenewalDate"] = active ? now.AddYears(10) : now,
|
||||
};
|
||||
|
||||
return Ok(new Dictionary<string, object?>
|
||||
{
|
||||
["subscription"] = subscription,
|
||||
["platformAccountSubscribedPlayerId"] = targetId,
|
||||
["Subscription"] = subscription,
|
||||
["PlatformAccountSubscribedPlayerId"] = targetId,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Ps5RecRoomPlusEnabledForAllPlayers()
|
||||
=> Content("true", "application/json");
|
||||
|
||||
[HttpPost("api/clubreporting/v1/report")]
|
||||
[Authorize]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
|
||||
@@ -21,6 +21,102 @@ public class ConfigController(ConfigService configService) : ControllerBase
|
||||
[HttpGet("amplitude")]
|
||||
public ActionResult<object> GetAmplitude() => Ok(new { AmplitudeKey = string.Empty });
|
||||
|
||||
/// <summary>
|
||||
/// GET <c>api/config/v1/backtrace</c> — 2023 boot downloads this before
|
||||
/// audio/core initialization. Returning 404 leaves the static backtrace
|
||||
/// config null and later crashes <c>ConfigSettings</c> consumers. Keep
|
||||
/// crash upload/deobfuscation disabled locally while preserving the
|
||||
/// object shape and readable config keys the client inspects.
|
||||
/// </summary>
|
||||
[HttpGet("backtrace")]
|
||||
public ActionResult<object> GetBacktrace(
|
||||
[FromQuery(Name = "platformType")] string? platformType,
|
||||
[FromQuery(Name = "allocate")] bool? allocate)
|
||||
{
|
||||
var baseUrl = $"{Request.Scheme}://{Request.Host}";
|
||||
var submissionUrl = $"{baseUrl}/backtrace/submit";
|
||||
var minidumpSubmissionUrl = $"{baseUrl}/backtrace/minidump";
|
||||
var config = new Dictionary<string, object>
|
||||
{
|
||||
["Backtrace.SampleRate"] = 0.0,
|
||||
["Backtrace.ReportBudget"] = 0,
|
||||
["Backtrace.deobfuscationRate"] = 0.0,
|
||||
["Backtrace.Deobfuscate"] = false,
|
||||
["Backtrace.DeobfuscationUrl"] = string.Empty,
|
||||
["Backtrace.Enabled"] = false,
|
||||
["Backtrace.PlatformType"] = platformType ?? string.Empty,
|
||||
["Backtrace.Allocate"] = allocate ?? false,
|
||||
};
|
||||
|
||||
return Ok(new Dictionary<string, object>
|
||||
{
|
||||
["MaxMessagesPerMinute"] = 0,
|
||||
["MaxReportsPerMinute"] = 0,
|
||||
["SampleRate"] = 0.0,
|
||||
["ReportBudget"] = 0,
|
||||
["ErrorReportBudget"] = 0,
|
||||
["WarningReportBudget"] = 0,
|
||||
["LogReportBudget"] = 0,
|
||||
["SubmissionUrl"] = submissionUrl,
|
||||
["MinidumpSubmissionUrl"] = minidumpSubmissionUrl,
|
||||
["ServerUrl"] = submissionUrl,
|
||||
["DatabasePath"] = "backtrace/database",
|
||||
["Config"] = config,
|
||||
["maxMessagesPerMinute"] = 0,
|
||||
["maxReportsPerMinute"] = 0,
|
||||
["sampleRate"] = 0.0,
|
||||
["reportBudget"] = 0,
|
||||
["errorReportBudget"] = 0,
|
||||
["warningReportBudget"] = 0,
|
||||
["logReportBudget"] = 0,
|
||||
["submissionUrl"] = submissionUrl,
|
||||
["minidumpSubmissionUrl"] = minidumpSubmissionUrl,
|
||||
["serverUrl"] = submissionUrl,
|
||||
["databasePath"] = "backtrace/database",
|
||||
["config"] = config,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("azurespeech")]
|
||||
public IActionResult GetAzureSpeech()
|
||||
{
|
||||
return Ok(new Dictionary<string, object>
|
||||
{
|
||||
["Enabled"] = false,
|
||||
["SpeechRecognitionEnabled"] = false,
|
||||
["SpeechSynthesisEnabled"] = false,
|
||||
["Region"] = string.Empty,
|
||||
["SubscriptionKey"] = string.Empty,
|
||||
["TokenEndpoint"] = string.Empty,
|
||||
["SpeechRecognitionEndpoint"] = string.Empty,
|
||||
["SpeechSynthesisEndpoint"] = string.Empty,
|
||||
["enabled"] = false,
|
||||
["speechRecognitionEnabled"] = false,
|
||||
["speechSynthesisEnabled"] = false,
|
||||
["region"] = string.Empty,
|
||||
["subscriptionKey"] = string.Empty,
|
||||
["tokenEndpoint"] = string.Empty,
|
||||
["speechRecognitionEndpoint"] = string.Empty,
|
||||
["speechSynthesisEndpoint"] = string.Empty,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/voice/config")]
|
||||
public IActionResult GetVoiceConfig()
|
||||
{
|
||||
return Ok(new Dictionary<string, object>
|
||||
{
|
||||
["ToxMod.Enabled"] = false,
|
||||
["ToxMod.SampleRate"] = 0.0,
|
||||
["ToxMod.SessionRecordingEnabled"] = false,
|
||||
["ToxMod.Endpoint"] = string.Empty,
|
||||
["ToxMod.ApiKey"] = string.Empty,
|
||||
["Enabled"] = false,
|
||||
["enabled"] = false,
|
||||
["toxModEnabled"] = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/config/v1/freegiftbutton</c> — the
|
||||
/// 2020.12 client deserializes this endpoint as a raw Boolean.
|
||||
/// This server does not run the timed free-gift campaign, so the
|
||||
|
||||
@@ -15,5 +15,5 @@ public class ServiceUrlsController(DomainConfig domain) : ControllerBase
|
||||
{
|
||||
[HttpGet("services")]
|
||||
public ActionResult<Dictionary<string, string>> Get()
|
||||
=> Ok(ConfigService.BuildServiceUrlMap(domain.Apex));
|
||||
=> Ok(ConfigService.BuildServiceUrlMap(domain));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using DorkNet.Server.Services;
|
||||
namespace DorkNet.Server.Controllers.API.Config.V2;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/config")]
|
||||
[Route("api/[controller]/v2")]
|
||||
public class ConfigController(ConfigService configService) : ControllerBase
|
||||
{
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.CustomAvatarItems;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/customAvatarItems")]
|
||||
[Route("api/customAvatarItems/v1")]
|
||||
[Route("api/customAvatarItems/v2")]
|
||||
public class CustomAvatarItemsController(
|
||||
DorkNetDbContext db,
|
||||
LevelService level,
|
||||
DomainConfig domain) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] string? q = null,
|
||||
[FromQuery] string? query = null,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
skip = Math.Max(0, skip);
|
||||
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
|
||||
IQueryable<CustomAvatarItemEntity> rows = db.CustomAvatarItems
|
||||
.Where(i => i.IsPublic);
|
||||
if (needle.Length > 0)
|
||||
{
|
||||
rows = rows.Where(i =>
|
||||
i.Name.ToLower().Contains(needle) ||
|
||||
i.Description.ToLower().Contains(needle));
|
||||
}
|
||||
|
||||
var result = await rows
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(result.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
{
|
||||
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
|
||||
if (item is null) return NotFound();
|
||||
if (!item.IsPublic && item.CreatorPlayerId != this.CurrentPlayerId()) return Forbid();
|
||||
return Ok(ToWire(item));
|
||||
}
|
||||
|
||||
[HttpGet("bulk")]
|
||||
[HttpPost("bulk")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Bulk([FromBody] BulkRequest? body)
|
||||
{
|
||||
var ids = await ReadIdsAsync(body);
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
var rows = await db.CustomAvatarItems
|
||||
.Where(i => ids.Contains(i.PublicId) && (i.IsPublic || i.CreatorPlayerId == this.CurrentPlayerId()))
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("featured")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Featured([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.CustomAvatarItems
|
||||
.Where(i => i.IsPublic && i.IsFeatured)
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("hot")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Hot([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.CustomAvatarItems
|
||||
.Where(i => i.IsPublic)
|
||||
.OrderByDescending(i => i.CheerCount)
|
||||
.ThenByDescending(i => i.PurchaseCount)
|
||||
.ThenByDescending(i => i.UpdatedAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("new")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> New([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.CustomAvatarItems
|
||||
.Where(i => i.IsPublic)
|
||||
.OrderByDescending(i => i.CreatedAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("fromCreator/{creatorId:long}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> FromCreator(
|
||||
long creatorId,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 100)
|
||||
{
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var currentPlayerId = this.CurrentPlayerId();
|
||||
var rows = await db.CustomAvatarItems
|
||||
.Where(i => i.CreatorPlayerId == creatorId
|
||||
&& (i.IsPublic || i.CreatorPlayerId == currentPlayerId))
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Me([FromQuery] int skip = 0, [FromQuery] int take = 100)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
|
||||
var createdQuery = db.CustomAvatarItems
|
||||
.Where(i => i.CreatorPlayerId == pid)
|
||||
.OrderByDescending(i => i.UpdatedAt);
|
||||
var total = await createdQuery.CountAsync();
|
||||
var created = await createdQuery
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
var ownedIds = await db.CustomAvatarItemOwnership
|
||||
.Where(o => o.PlayerId == pid)
|
||||
.Select(o => o.CustomAvatarItemId)
|
||||
.ToListAsync();
|
||||
var owned = ownedIds.Count == 0
|
||||
? new List<CustomAvatarItemEntity>()
|
||||
: await db.CustomAvatarItems
|
||||
.Where(i => ownedIds.Contains(i.Id))
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Results = created.Select(ToWire),
|
||||
TotalResults = total,
|
||||
Created = created.Select(ToWire),
|
||||
Owned = owned.Select(ToWire),
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("owned")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Owned([FromQuery] int skip = 0, [FromQuery] int take = 100)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
|
||||
var query = db.CustomAvatarItemOwnership
|
||||
.Where(o => o.PlayerId == pid)
|
||||
.Join(db.CustomAvatarItems, o => o.CustomAvatarItemId, i => i.Id, (o, i) => i)
|
||||
.OrderByDescending(i => i.UpdatedAt);
|
||||
var total = await query.CountAsync();
|
||||
var rows = await query
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(new
|
||||
{
|
||||
Results = rows.Select(ToWire),
|
||||
TotalResults = total,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("isCreationAllowedForAccount")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> IsCreationAllowedForAccount()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var player = await db.Players
|
||||
.Where(p => p.Id == pid)
|
||||
.Select(p => new { p.IsJunior, p.BannedUntil })
|
||||
.FirstOrDefaultAsync();
|
||||
var allowed = player is not null
|
||||
&& !player.IsJunior
|
||||
&& (player.BannedUntil is null || player.BannedUntil <= DateTime.UtcNow);
|
||||
return Ok(new { IsCreationAllowed = allowed, Allowed = allowed });
|
||||
}
|
||||
|
||||
[HttpGet("isCreationEnabled")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult IsCreationEnabled() => Content("true", "application/json");
|
||||
|
||||
[HttpGet("isRenderingEnabled")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult IsRenderingEnabled() => Content("true", "application/json");
|
||||
|
||||
[HttpGet("minPriceForPublicItem")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult MinPriceForPublicItem() => Ok(new { MinPrice = 100, Price = 100 });
|
||||
|
||||
[HttpPost("design")]
|
||||
[Authorize]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> Design()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var req = await ReadDesignRequestAsync();
|
||||
var name = (req.Name ?? string.Empty).Trim();
|
||||
if (name.Length < 3) return BadRequest(new { error = "name_too_short" });
|
||||
if (name.Length > 128) name = name[..128];
|
||||
|
||||
var description = (req.Description ?? string.Empty).Trim();
|
||||
if (description.Length > 1024) description = description[..1024];
|
||||
|
||||
CustomAvatarItemEntity item;
|
||||
if (req.Id is Guid id)
|
||||
{
|
||||
item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id)
|
||||
?? new CustomAvatarItemEntity { PublicId = id, CreatorPlayerId = pid };
|
||||
if (item.Id != 0 && item.CreatorPlayerId != pid) return Forbid();
|
||||
if (item.Id == 0) db.CustomAvatarItems.Add(item);
|
||||
}
|
||||
else
|
||||
{
|
||||
item = new CustomAvatarItemEntity { CreatorPlayerId = pid };
|
||||
db.CustomAvatarItems.Add(item);
|
||||
}
|
||||
|
||||
item.Name = name;
|
||||
item.Description = description;
|
||||
item.Price = Math.Max(100, req.Price ?? item.Price);
|
||||
item.ItemType = req.ItemType ?? item.ItemType;
|
||||
item.BaseAvatarItemId = req.BaseAvatarItemId ?? item.BaseAvatarItemId;
|
||||
item.Color = Trim(req.Color, 64);
|
||||
item.ImageName = Trim(req.ImageName, 256);
|
||||
item.AssetName = Trim(req.AssetName, 256);
|
||||
item.IsPublic = req.IsPublic ?? item.IsPublic;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await EnsureOwnershipAsync(pid, item.Id);
|
||||
return Ok(ToWire(item));
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/purchase")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Purchase(Guid id)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id && i.IsPublic);
|
||||
if (item is null) return NotFound();
|
||||
if (await db.CustomAvatarItemOwnership.AnyAsync(o => o.PlayerId == pid && o.CustomAvatarItemId == item.Id))
|
||||
return Ok(new { Success = true, AlreadyOwned = true, Item = ToWire(item) });
|
||||
|
||||
var balance = await level.GetBalanceAsync(pid, 2);
|
||||
if (balance < item.Price)
|
||||
return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance });
|
||||
|
||||
var newBalance = await level.GrantCurrencyAsync(pid, 2, -item.Price, $"customAvatarItem:{item.PublicId}");
|
||||
await EnsureOwnershipAsync(pid, item.Id);
|
||||
item.PurchaseCount += 1;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Balance = newBalance, Item = ToWire(item) });
|
||||
}
|
||||
|
||||
private async Task EnsureOwnershipAsync(long playerId, long itemId)
|
||||
{
|
||||
if (await db.CustomAvatarItemOwnership.AnyAsync(o =>
|
||||
o.PlayerId == playerId && o.CustomAvatarItemId == itemId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
db.CustomAvatarItemOwnership.Add(new CustomAvatarItemOwnershipEntity
|
||||
{
|
||||
PlayerId = playerId,
|
||||
CustomAvatarItemId = itemId,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task<List<Guid>> ReadIdsAsync(BulkRequest? body)
|
||||
{
|
||||
var result = new List<Guid>();
|
||||
if (body?.Ids is { Count: > 0 })
|
||||
result.AddRange(body.Ids);
|
||||
if (body?.ItemIds is { Count: > 0 })
|
||||
result.AddRange(body.ItemIds);
|
||||
|
||||
foreach (var pair in Request.Query)
|
||||
foreach (var value in pair.Value)
|
||||
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (Guid.TryParse(part, out var id)) result.Add(id);
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in new[] { "ids", "Ids", "itemIds", "ItemIds" })
|
||||
foreach (var value in form[key])
|
||||
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (Guid.TryParse(part, out var id)) result.Add(id);
|
||||
}
|
||||
|
||||
return result.Distinct().Take(200).ToList();
|
||||
}
|
||||
|
||||
private async Task<DesignRequest> ReadDesignRequestAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
return new DesignRequest
|
||||
{
|
||||
Id = Guid.TryParse(form["id"].FirstOrDefault() ?? form["Id"].FirstOrDefault(), out var id) ? id : null,
|
||||
Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(),
|
||||
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
|
||||
Price = int.TryParse(form["price"].FirstOrDefault() ?? form["Price"].FirstOrDefault(), out var price) ? price : null,
|
||||
ItemType = int.TryParse(form["itemType"].FirstOrDefault() ?? form["ItemType"].FirstOrDefault(), out var itemType) ? itemType : null,
|
||||
BaseAvatarItemId = int.TryParse(form["baseAvatarItemId"].FirstOrDefault() ?? form["BaseAvatarItemId"].FirstOrDefault(), out var baseId) ? baseId : null,
|
||||
Color = form["color"].FirstOrDefault() ?? form["Color"].FirstOrDefault(),
|
||||
ImageName = form["imageName"].FirstOrDefault() ?? form["ImageName"].FirstOrDefault(),
|
||||
AssetName = form["assetName"].FirstOrDefault() ?? form["AssetName"].FirstOrDefault(),
|
||||
IsPublic = bool.TryParse(form["isPublic"].FirstOrDefault() ?? form["IsPublic"].FirstOrDefault(), out var pub) ? pub : null,
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await JsonSerializer.DeserializeAsync<DesignRequest>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? new DesignRequest();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new DesignRequest();
|
||||
}
|
||||
}
|
||||
|
||||
private object ToWire(CustomAvatarItemEntity item) => new
|
||||
{
|
||||
Id = item.PublicId,
|
||||
CustomAvatarItemId = item.PublicId,
|
||||
CreatorId = (int)item.CreatorPlayerId,
|
||||
CreatorPlayerId = (int)item.CreatorPlayerId,
|
||||
item.Name,
|
||||
item.Description,
|
||||
item.Price,
|
||||
item.ItemType,
|
||||
item.BaseAvatarItemId,
|
||||
item.Color,
|
||||
item.ImageName,
|
||||
ImageUrl = string.IsNullOrWhiteSpace(item.ImageName) ? string.Empty : $"https://{domain.Sub("cdn")}/{item.ImageName}",
|
||||
item.AssetName,
|
||||
item.IsPublic,
|
||||
item.IsFeatured,
|
||||
item.CheerCount,
|
||||
item.PurchaseCount,
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt,
|
||||
Status = item.IsPublic ? 1 : 0,
|
||||
};
|
||||
|
||||
private static string Trim(string? value, int max)
|
||||
{
|
||||
var s = (value ?? string.Empty).Trim();
|
||||
return s.Length <= max ? s : s[..max];
|
||||
}
|
||||
|
||||
public sealed class BulkRequest
|
||||
{
|
||||
public List<Guid>? Ids { get; set; }
|
||||
public List<Guid>? ItemIds { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DesignRequest
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public int? Price { get; set; }
|
||||
public int? ItemType { get; set; }
|
||||
public int? BaseAvatarItemId { get; set; }
|
||||
public string? Color { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
public string? AssetName { get; set; }
|
||||
public bool? IsPublic { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,34 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Events.V3;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v3")]
|
||||
public class EventsController : ControllerBase
|
||||
public class EventsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("list")]
|
||||
public ActionResult<List<object>> GetActiveEvents() => Ok(new List<object>());
|
||||
public async Task<ActionResult<List<object>>> GetActiveEvents([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => e.EndsAt > DateTime.UtcNow)
|
||||
.OrderBy(e => e.StartsAt)
|
||||
.Take(take)
|
||||
.Select(e => new
|
||||
{
|
||||
EventId = e.Id,
|
||||
PlayerEventId = e.Id,
|
||||
Name = e.Title,
|
||||
e.Description,
|
||||
StartTime = e.StartsAt,
|
||||
EndTime = e.EndsAt,
|
||||
CreatorPlayerId = (int)e.CreatorPlayerId,
|
||||
e.RoomId,
|
||||
e.Capacity,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(rows.Cast<object>().ToList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.ExternalFriendInvite;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class ExternalFriendInviteController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpPost("api/externalfriendinvite/v1/createplatforminvite")]
|
||||
public async Task<IActionResult> CreatePlatformInvite([FromForm] int platform = 0, [FromForm] string? platformId = null)
|
||||
{
|
||||
var code = Guid.NewGuid().ToString("N")[..10];
|
||||
var key = $"externalinvite:platform:{code}";
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
Key = key,
|
||||
Value = $"{platform}|{platformId ?? string.Empty}|{DateTime.UtcNow:O}",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var url = $"{Request.Scheme}://{Request.Host}/invite/{code}";
|
||||
return Ok(new { InviteCode = code, InviteUrl = url, Platform = platform, PlatformId = platformId ?? string.Empty });
|
||||
}
|
||||
|
||||
[HttpPost("api/externalfriendinvite/v1/sendtextmessageinvite")]
|
||||
public async Task<IActionResult> SendTextMessageInvite([FromForm] string? phoneNumber, [FromForm] string? message)
|
||||
{
|
||||
var code = Guid.NewGuid().ToString("N")[..10];
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
Key = $"externalinvite:text:{code}",
|
||||
Value = $"{phoneNumber ?? string.Empty}|{message ?? string.Empty}|{DateTime.UtcNow:O}",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, InviteCode = code, PhoneNumber = phoneNumber ?? string.Empty });
|
||||
}
|
||||
|
||||
[HttpGet("api/externalfriendinvite/v1/getplatformreferrers")]
|
||||
[HttpPost("api/externalfriendinvite/v1/getplatformreferrers")]
|
||||
public async Task<IActionResult> GetPlatformReferrers()
|
||||
{
|
||||
var rows = await db.PlayerSettings
|
||||
.Where(s => s.PlayerId == Me && s.Key.StartsWith("externalinvite:platform:"))
|
||||
.OrderByDescending(s => s.Id)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToInviteWire));
|
||||
}
|
||||
|
||||
[HttpGet("api/externalfriendinvite/v1/gettextmessagereferrers")]
|
||||
[HttpPost("api/externalfriendinvite/v1/gettextmessagereferrers")]
|
||||
public async Task<IActionResult> GetTextMessageReferrers()
|
||||
{
|
||||
var rows = await db.PlayerSettings
|
||||
.Where(s => s.PlayerId == Me && s.Key.StartsWith("externalinvite:text:"))
|
||||
.OrderByDescending(s => s.Id)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToInviteWire));
|
||||
}
|
||||
|
||||
private static object ToInviteWire(PlayerSettingEntity row)
|
||||
{
|
||||
var parts = row.Value.Split('|');
|
||||
return new
|
||||
{
|
||||
InviteCode = row.Key.Split(':').Last(),
|
||||
Kind = row.Key.Contains(":text:") ? "text" : "platform",
|
||||
Value = parts.FirstOrDefault() ?? string.Empty,
|
||||
CreatedAt = parts.LastOrDefault() ?? string.Empty,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.FreeGifts;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class FreeGiftsController(DorkNetDbContext db, NotificationService notifications) : ControllerBase
|
||||
{
|
||||
[HttpPost("api/freegifts/v1/sendmultiple")]
|
||||
public async Task<IActionResult> SendMultiple()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var sender = this.RequireCurrentPlayerId();
|
||||
var recipients = ReadLongList(fields, "recipientPlayerIds", "RecipientPlayerIds", "playerIds", "PlayerIds");
|
||||
if (recipients.Count == 0) return BadRequest("missing_recipients");
|
||||
|
||||
var currencyType = ReadInt(fields, "currencyType", "CurrencyType") ?? 2;
|
||||
var currency = Math.Max(0, ReadInt(fields, "currency", "Currency") ?? 0);
|
||||
var xp = Math.Max(0, ReadInt(fields, "xp", "Xp") ?? 0);
|
||||
var message = ReadString(fields, "message", "Message") ?? string.Empty;
|
||||
var created = new List<(long RecipientPlayerId, GiftPackageEntity Gift)>();
|
||||
|
||||
foreach (var recipient in recipients.Distinct().Take(100))
|
||||
{
|
||||
if (!await db.Players.AnyAsync(p => p.Id == recipient)) continue;
|
||||
var gift = new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = recipient,
|
||||
FromPlayerId = (int)sender,
|
||||
CurrencyType = currency > 0 ? currencyType : 0,
|
||||
Currency = currency,
|
||||
Xp = xp,
|
||||
Level = 1,
|
||||
GiftContext = 0,
|
||||
GiftRarity = 0,
|
||||
Message = message,
|
||||
Platform = -1,
|
||||
PackageVariant = "FreeGift",
|
||||
PackageMaterial = string.Empty,
|
||||
Consumed = false,
|
||||
IsValid = true,
|
||||
SupportsCurrentPlatform = true,
|
||||
};
|
||||
db.GiftPackages.Add(gift);
|
||||
created.Add((recipient, gift));
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
foreach (var row in created)
|
||||
{
|
||||
await notifications.NotifyAsync(row.RecipientPlayerId,
|
||||
PushNotificationId.GiftPackageReceived,
|
||||
new { FromPlayerId = sender, CurrencyType = currencyType, Currency = currency, Xp = xp, Message = message });
|
||||
}
|
||||
|
||||
return Ok(new { Success = true, Sent = created.Count });
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> ReadFieldsAsync()
|
||||
{
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in Request.Query)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var pair in form)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
else if ((Request.ContentLength ?? 0) > 0
|
||||
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
fields[prop.Name] = prop.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())),
|
||||
JsonValueKind.String => prop.Value.GetString() ?? string.Empty,
|
||||
_ => prop.Value.GetRawText(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static string? ReadString(Dictionary<string, string> fields, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? ReadInt(Dictionary<string, string> fields, params string[] names) =>
|
||||
int.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||
|
||||
private static List<long> ReadLongList(Dictionary<string, string> fields, params string[] names) =>
|
||||
names.SelectMany(name => fields.TryGetValue(name, out var value)
|
||||
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
: Array.Empty<string>())
|
||||
.Select(v => long.TryParse(v, out var id) ? id : 0)
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(100)
|
||||
.ToList();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.GameSight;
|
||||
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
public class GameSightController : ControllerBase
|
||||
{
|
||||
[HttpPost("/api/gamesight/event")]
|
||||
[HttpPost("/data/event")]
|
||||
[HttpPost("/data/heartbeat")]
|
||||
public IActionResult Event() => Ok(new { Success = true });
|
||||
}
|
||||
@@ -274,6 +274,48 @@ public class ImagesController(
|
||||
return Ok(rows.Select(p => BuildImageInfo(p, pid)));
|
||||
}
|
||||
|
||||
public sealed class ImageBulkRequest
|
||||
{
|
||||
public List<long>? ImageIds { get; set; }
|
||||
public List<long>? SavedImageIds { get; set; }
|
||||
}
|
||||
|
||||
[HttpGet("api/images/v5/bulk")]
|
||||
[HttpPost("api/images/v5/bulk")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> BulkImages([FromBody] ImageBulkRequest? body)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var ids = (body?.ImageIds ?? body?.SavedImageIds ?? new List<long>())
|
||||
.Concat(Request.Query.SelectMany(q => q.Value)
|
||||
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Select(v => long.TryParse(v, out var id) ? id : 0))
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(200)
|
||||
.ToList();
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var rows = await db.Photos
|
||||
.Where(p => ids.Contains(p.Id) && p.DeletedAt == null
|
||||
&& (p.IsPublic || p.UploaderPlayerId == pid))
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
|
||||
}
|
||||
|
||||
[HttpGet("api/images/v6")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> ImagesV6([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.Photos
|
||||
.Where(p => p.IsPublic && p.DeletedAt == null)
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/images/v2/named</c> — the watch's
|
||||
/// <c>Images.DownloadNamedImageMappings</c> endpoint. Returns a
|
||||
/// list of <c>NamedImageDTO</c> entries; an empty list is a valid
|
||||
@@ -354,19 +396,63 @@ public class ImagesController(
|
||||
[HttpGet("api/images/v3/byaccount/{accountId:long}")]
|
||||
[HttpGet("api/images/v4/byaccount/{accountId:long}")]
|
||||
[HttpGet("api/images/v5/byaccount/{accountId:long}")]
|
||||
[HttpGet("api/images/v2/player/{accountId:long}")]
|
||||
[HttpGet("api/images/v3/player/{accountId:long}")]
|
||||
[HttpGet("api/images/v4/player/{accountId:long}")]
|
||||
[HttpGet("api/images/v5/player/{accountId:long}")]
|
||||
public async Task<IActionResult> GetByAccount(long accountId,
|
||||
[FromQuery] int take = 50, [FromQuery] int skip = 0)
|
||||
[FromQuery] int take = 50, [FromQuery] int skip = 0, [FromQuery] int sort = 0)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 200);
|
||||
skip = Math.Max(0, skip);
|
||||
var rows = await db.Photos
|
||||
var query = db.Photos
|
||||
.Where(p => p.UploaderPlayerId == accountId && p.IsPublic && p.DeletedAt == null)
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Skip(skip).Take(take)
|
||||
.AsQueryable();
|
||||
query = sort switch
|
||||
{
|
||||
1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt),
|
||||
2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt),
|
||||
_ => query.OrderByDescending(p => p.CreatedAt),
|
||||
};
|
||||
|
||||
var rows = await query
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(p => BuildImageInfo(p, accountId)));
|
||||
}
|
||||
|
||||
[HttpGet("api/images/v2/room/{roomId:long}")]
|
||||
[HttpGet("api/images/v3/room/{roomId:long}")]
|
||||
[HttpGet("api/images/v4/room/{roomId:long}")]
|
||||
[HttpGet("api/images/v5/room/{roomId:long}")]
|
||||
public async Task<IActionResult> GetByRoom(
|
||||
long roomId,
|
||||
[FromQuery] int take = 50,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int sort = 0,
|
||||
[FromQuery] int filter = 0)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 200);
|
||||
skip = Math.Max(0, skip);
|
||||
|
||||
var query = db.Photos
|
||||
.Where(p => p.RoomId == roomId && p.IsPublic && p.DeletedAt == null)
|
||||
.AsQueryable();
|
||||
query = sort switch
|
||||
{
|
||||
1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt),
|
||||
2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt),
|
||||
_ => query.OrderByDescending(p => p.CreatedAt),
|
||||
};
|
||||
|
||||
var rows = await query
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
|
||||
}
|
||||
|
||||
/// <summary>POST api/images/v{2-5}/share/{id} — flip a saved photo
|
||||
/// to public so it shows up on feed.rec.net. The watch's "Share"
|
||||
/// button hits this; if the watch posts to a different URL we'll
|
||||
@@ -424,6 +510,7 @@ public class ImagesController(
|
||||
}
|
||||
|
||||
[HttpPost("api/images/v4/cheered/bulk")]
|
||||
[HttpPost("api/images/v5/cheered/bulk")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CheeredBulk([FromBody] BulkCheeredRequest? body)
|
||||
{
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.IncentivizedReferrals;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/incentivizedreferrals")]
|
||||
public class IncentivizedReferralsController(DorkNetDbContext db, LevelService level) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpGet("")]
|
||||
[HttpGet("progress")]
|
||||
public async Task<IActionResult> Progress()
|
||||
{
|
||||
var referralCount = await db.PlayerSettings
|
||||
.CountAsync(s => s.Key.StartsWith($"referral:credited:{Me}:"));
|
||||
var claimed = await db.PlayerSettings
|
||||
.AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed");
|
||||
return Ok(new
|
||||
{
|
||||
ReferralCount = referralCount,
|
||||
RequiredReferralCount = 1,
|
||||
CanClaim = referralCount >= 1 && !claimed,
|
||||
Claimed = claimed,
|
||||
RewardCurrencyType = 2,
|
||||
RewardCurrency = 250,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("claim")]
|
||||
public async Task<IActionResult> Claim()
|
||||
{
|
||||
var referralCount = await db.PlayerSettings
|
||||
.CountAsync(s => s.Key.StartsWith($"referral:credited:{Me}:"));
|
||||
if (referralCount < 1)
|
||||
return Ok(new { Success = false, Error = "not_enough_referrals" });
|
||||
if (await db.PlayerSettings.AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed"))
|
||||
return Ok(new { Success = false, Error = "already_claimed" });
|
||||
|
||||
var balance = await level.GrantCurrencyAsync(Me, 2, 250, "incentivized_referral");
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
Key = "referral:reward_claimed",
|
||||
Value = DateTime.UtcNow.ToString("O"),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Balance = balance, CurrencyType = 2, Currency = 250 });
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Auth;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.InfluencerPartnerProgram;
|
||||
|
||||
[ApiController]
|
||||
public class InfluencerPartnerProgramController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/influencerpartnerprogram/influencers")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Influencers([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var creatorIds = await db.Rooms.Select(r => r.CreatorPlayerId)
|
||||
.Concat(db.Inventions.Where(i => !i.IsDeleted).Select(i => i.CreatorPlayerId))
|
||||
.Concat(db.CustomAvatarItems.Select(i => i.CreatorPlayerId))
|
||||
.Distinct()
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
var players = await db.Players
|
||||
.Where(p => creatorIds.Contains(p.Id))
|
||||
.OrderBy(p => p.Username)
|
||||
.ToListAsync();
|
||||
var influencers = players.Select(ToInfluencerWire).ToList();
|
||||
return Ok(new Dictionary<string, object>
|
||||
{
|
||||
["Influencers"] = influencers,
|
||||
["influencers"] = influencers,
|
||||
["Accounts"] = influencers,
|
||||
["accounts"] = influencers,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("api/influencerpartnerprogram/influencer")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Influencer([FromQuery] long? accountId = null, [FromQuery] string? username = null)
|
||||
{
|
||||
var player = accountId is long id
|
||||
? await db.Players.FirstOrDefaultAsync(p => p.Id == id)
|
||||
: await db.Players.FirstOrDefaultAsync(p => p.Username == username);
|
||||
if (player is null) return NotFound();
|
||||
return Ok(ToInfluencerWire(player));
|
||||
}
|
||||
|
||||
[HttpGet("api/influencerpartnerprogram/myinfluencer")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> MyInfluencer()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var setting = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "influencer:supported");
|
||||
if (setting is null || !long.TryParse(setting.Value, out var influencerId))
|
||||
return Ok(new { Influencer = (object?)null });
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == influencerId);
|
||||
return Ok(new { Influencer = player is null ? null : ToInfluencerWire(player) });
|
||||
}
|
||||
|
||||
[HttpPost("api/influencerpartnerprogram/support")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Support([FromForm] long? influencerAccountId, [FromForm] string? username)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var influencer = influencerAccountId is long id
|
||||
? await db.Players.FirstOrDefaultAsync(p => p.Id == id)
|
||||
: await db.Players.FirstOrDefaultAsync(p => p.Username == username);
|
||||
if (influencer is null) return NotFound();
|
||||
|
||||
var setting = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "influencer:supported");
|
||||
if (setting is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = me,
|
||||
Key = "influencer:supported",
|
||||
Value = influencer.Id.ToString(),
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
setting.Value = influencer.Id.ToString();
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Influencer = ToInfluencerWire(influencer) });
|
||||
}
|
||||
|
||||
[HttpPost("api/influencerpartnerprogram/remove")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Remove()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
await db.PlayerSettings
|
||||
.Where(s => s.PlayerId == me && s.Key == "influencer:supported")
|
||||
.ExecuteDeleteAsync();
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
|
||||
private static RecNetAccount ToInfluencerWire(PlayerEntity player) => new()
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
RawUsername = player.Username,
|
||||
Username = player.Username,
|
||||
DisplayName = player.DisplayName ?? player.Username,
|
||||
ProfileImage = player.ProfileImageName ?? string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
CreatedAt = player.CreatedAt == default ? DateTime.UtcNow : player.CreatedAt,
|
||||
};
|
||||
}
|
||||
@@ -40,6 +40,9 @@ public class InventionsController(
|
||||
|
||||
// ── Browse ───────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("api/inventions/v1/featured")]
|
||||
[HttpGet("api/inventions/v1/featureddormskins")]
|
||||
[HttpGet("api/inventions/v1/toptoday")]
|
||||
[HttpGet("api/inventions/v3/popular")]
|
||||
public async Task<ActionResult> Popular([FromQuery] int take = 50)
|
||||
{
|
||||
@@ -78,6 +81,7 @@ public class InventionsController(
|
||||
public Task<ActionResult> Mine() => Saved(100);
|
||||
|
||||
[HttpGet("api/inventions/v1/search")]
|
||||
[HttpGet("api/inventions/v2/search")]
|
||||
public async Task<ActionResult> Search([FromQuery] string value = "")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
@@ -127,17 +131,62 @@ public class InventionsController(
|
||||
public sealed record InventionBatchRequest(List<long> InventionIds);
|
||||
|
||||
[HttpPost("api/inventions/v1/batch")]
|
||||
[HttpPost("api/inventions/v2/batch")]
|
||||
[HttpPost("api/inventions/v1/dormskinsfromids")]
|
||||
public async Task<ActionResult> Batch([FromBody] InventionBatchRequest req)
|
||||
{
|
||||
if (req?.InventionIds is null || req.InventionIds.Count == 0)
|
||||
return await BatchIdsAsync(req?.InventionIds ?? []);
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/batch")]
|
||||
[HttpGet("api/inventions/v2/batch")]
|
||||
[HttpGet("api/inventions/v1/dormskinsfromids")]
|
||||
public async Task<ActionResult> BatchFromQuery()
|
||||
{
|
||||
var ids = Request.Query
|
||||
.SelectMany(q => q.Value)
|
||||
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Select(v => long.TryParse(v, out var id) ? id : 0L)
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(200)
|
||||
.ToList();
|
||||
|
||||
return await BatchIdsAsync(ids);
|
||||
}
|
||||
|
||||
private async Task<ActionResult> BatchIdsAsync(IReadOnlyCollection<long> requestedIds)
|
||||
{
|
||||
if (requestedIds.Count == 0)
|
||||
return Ok(Array.Empty<object>());
|
||||
var ids = req.InventionIds.Take(200).ToList();
|
||||
var ids = requestedIds.Take(200).ToList();
|
||||
var rows = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/fromcreators")]
|
||||
public async Task<ActionResult> FromCreators()
|
||||
{
|
||||
var creatorIds = Request.Query
|
||||
.SelectMany(q => q.Value)
|
||||
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Select(v => long.TryParse(v, out var id) ? id : 0L)
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(200)
|
||||
.ToList();
|
||||
|
||||
if (creatorIds.Count == 0) return Ok(Array.Empty<object>());
|
||||
var rows = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && i.IsPublished && creatorIds.Contains(i.CreatorPlayerId))
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.Take(200)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/inventions/v1/tagfilters</c> — drives
|
||||
/// <c>RecNet.Inventions.GetTags</c>. The watch deserialises the
|
||||
/// response as <c>RecNet.GetFiltersResponse</c> (an
|
||||
@@ -356,6 +405,7 @@ public class InventionsController(
|
||||
/// save flow. v4 wraps the new invention plus its first version in
|
||||
/// a Status/Invention/InventionVersion object.</summary>
|
||||
[HttpPost("api/inventions/v4/save")]
|
||||
[HttpPost("api/inventions/v6/save")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> SaveV4([FromBody] SaveInventionV4Request req)
|
||||
{
|
||||
@@ -426,6 +476,7 @@ public class InventionsController(
|
||||
int? InstantiationCost, int? LightsCost);
|
||||
|
||||
[HttpPost("api/inventions/v3/addversion")]
|
||||
[HttpPost("api/inventions/v4/addversion")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> AddVersion([FromBody] AddVersionRequest req)
|
||||
{
|
||||
@@ -491,6 +542,22 @@ public class InventionsController(
|
||||
return Ok(ToWire(inv));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/updateprice")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> UpdatePrice(
|
||||
[FromQuery] long inventionId,
|
||||
[FromQuery] int price)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
if (inv.CreatorPlayerId != pid) return Forbid();
|
||||
inv.Price = Math.Clamp(price, 0, 1000000);
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/delete")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Delete([FromQuery] long inventionId)
|
||||
@@ -685,7 +752,7 @@ public class InventionsController(
|
||||
i.CreatorPermission,
|
||||
i.GeneralPermission,
|
||||
IsAGInvention = i.IsAgInvention,
|
||||
Price = 0,
|
||||
i.Price,
|
||||
HideFromPlayer = false,
|
||||
};
|
||||
|
||||
|
||||
@@ -66,6 +66,13 @@ public class InventoryController(
|
||||
public string PrefabName { get; set; } = string.Empty;
|
||||
public string ModificationGuid { get; set; } = string.Empty;
|
||||
public bool Favorited { get; set; }
|
||||
// The 2023 client sends Equipped/IsEquipped when a weapon skin is
|
||||
// equipped (distinct from Favorited). We only have one IsActive flag,
|
||||
// and for skins "active" == "equipped", so honor whichever the client
|
||||
// sent. Without this the equip was dropped (only Favorited was read)
|
||||
// and the skin never stuck.
|
||||
public bool? Equipped { get; set; }
|
||||
public bool? IsEquipped { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/equipment/v1/update</c> — toggle the
|
||||
@@ -103,7 +110,27 @@ public class InventoryController(
|
||||
// to themselves).
|
||||
return Forbid();
|
||||
}
|
||||
row.IsActive = req.Favorited;
|
||||
// "equipped" is the meaningful state for weapon skins; prefer it,
|
||||
// fall back to Favorited for plain tool-pin updates.
|
||||
var active = req.Equipped ?? req.IsEquipped ?? req.Favorited;
|
||||
|
||||
// One skin equipped per weapon: when equipping a catalog-backed skin,
|
||||
// clear the equipped flag on the player's OTHER skins for the SAME
|
||||
// prefab (slugs share the "{prefix}{prefab}:" head) so the weapon
|
||||
// doesn't end up with two "equipped" skins.
|
||||
if (active && !string.IsNullOrWhiteSpace(req.ModificationGuid)
|
||||
&& !string.IsNullOrWhiteSpace(req.PrefabName))
|
||||
{
|
||||
var prefabPrefix = StoreService.EquipmentSkinSlug(req.PrefabName, string.Empty);
|
||||
var siblings = await db.PlayerInventory
|
||||
.Where(inv => inv.PlayerId == pid && inv.IsActive
|
||||
&& inv.ItemSlug != slug
|
||||
&& inv.ItemSlug.StartsWith(prefabPrefix))
|
||||
.ToListAsync();
|
||||
foreach (var sib in siblings) sib.IsActive = false;
|
||||
}
|
||||
|
||||
row.IsActive = active;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Updated = true });
|
||||
}
|
||||
@@ -182,6 +209,44 @@ public class InventoryController(
|
||||
return Ok(new { row.Id, Count = row.Quantity });
|
||||
}
|
||||
|
||||
[HttpPost("api/consumables/v1/transfer")]
|
||||
public async Task<IActionResult> TransferConsumable(
|
||||
[FromForm] long? id,
|
||||
[FromForm] long? recipientPlayerId,
|
||||
[FromForm] int quantity = 1)
|
||||
{
|
||||
var pid = Me;
|
||||
var inventoryId = id ?? (long.TryParse(Request.Query["id"], out var qId) ? qId : 0);
|
||||
var recipient = recipientPlayerId ?? (long.TryParse(Request.Query["recipientPlayerId"], out var qRecipient) ? qRecipient : 0);
|
||||
var amount = Math.Max(1, quantity);
|
||||
if (inventoryId <= 0 || recipient <= 0 || recipient == pid)
|
||||
return BadRequest("invalid_transfer");
|
||||
|
||||
var source = await db.PlayerInventory.FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == inventoryId);
|
||||
if (source is null || source.Quantity < amount) return NotFound();
|
||||
var target = await db.PlayerInventory
|
||||
.FirstOrDefaultAsync(p => p.PlayerId == recipient && p.ItemSlug == source.ItemSlug);
|
||||
if (target is null)
|
||||
{
|
||||
target = new PlayerInventoryEntity
|
||||
{
|
||||
PlayerId = recipient,
|
||||
ItemSlug = source.ItemSlug,
|
||||
Quantity = 0,
|
||||
};
|
||||
db.PlayerInventory.Add(target);
|
||||
}
|
||||
|
||||
source.Quantity -= amount;
|
||||
target.Quantity += amount;
|
||||
if (source.Quantity == 0) db.PlayerInventory.Remove(source);
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.NotifyAsync(recipient,
|
||||
PushNotificationId.ConsumableMappingAdded,
|
||||
new { target.Id, target.ItemSlug, target.Quantity });
|
||||
return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity });
|
||||
}
|
||||
|
||||
public sealed class ActivateConsumableRequest
|
||||
{
|
||||
public long Id { get; set; }
|
||||
@@ -223,7 +288,11 @@ public class InventoryController(
|
||||
FriendlyName = item.DisplayName,
|
||||
Tooltip = item.Description,
|
||||
Rarity = rarity,
|
||||
// IsActive is the equipped/active flag; surface it under every key
|
||||
// the 2023 client reads so the equipped skin shows on reload.
|
||||
Favorited = inv.IsActive,
|
||||
Equipped = inv.IsActive,
|
||||
IsEquipped = inv.IsActive,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.ItemWishlists;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/itemWishlists")]
|
||||
[Authorize]
|
||||
public class ItemWishlistsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpGet]
|
||||
[HttpGet("v1/wishlist/me")]
|
||||
public async Task<IActionResult> Mine()
|
||||
{
|
||||
var rows = await db.ItemWishlists
|
||||
.Where(w => w.PlayerId == Me)
|
||||
.OrderByDescending(w => w.CreatedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("v1/wishlist")]
|
||||
public async Task<IActionResult> ForPlayer([FromQuery] long? playerId)
|
||||
{
|
||||
var pid = playerId is > 0 ? playerId.Value : Me;
|
||||
var rows = await db.ItemWishlists
|
||||
.Where(w => w.PlayerId == pid)
|
||||
.OrderByDescending(w => w.CreatedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[HttpPost("v1/wishlist")]
|
||||
public async Task<IActionResult> Add()
|
||||
{
|
||||
var req = await ReadRequestAsync();
|
||||
var key = (req.ItemKey ?? req.ItemId ?? string.Empty).Trim();
|
||||
if (key.Length == 0) return BadRequest(new { error = "missing_item" });
|
||||
if (key.Length > 128) key = key[..128];
|
||||
var itemType = req.ItemType ?? 0;
|
||||
|
||||
var exists = await db.ItemWishlists.AnyAsync(w =>
|
||||
w.PlayerId == Me && w.ItemKey == key && w.ItemType == itemType);
|
||||
if (!exists)
|
||||
{
|
||||
db.ItemWishlists.Add(new ItemWishlistEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
ItemKey = key,
|
||||
ItemType = itemType,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return await Mine();
|
||||
}
|
||||
|
||||
[HttpDelete("v1/wishlist")]
|
||||
[HttpPost("v1/wishlist/remove")]
|
||||
public async Task<IActionResult> Remove()
|
||||
{
|
||||
var req = await ReadRequestAsync();
|
||||
var key = (req.ItemKey ?? req.ItemId ?? string.Empty).Trim();
|
||||
if (key.Length == 0) return BadRequest(new { error = "missing_item" });
|
||||
var itemType = req.ItemType ?? 0;
|
||||
await db.ItemWishlists
|
||||
.Where(w => w.PlayerId == Me && w.ItemKey == key && w.ItemType == itemType)
|
||||
.ExecuteDeleteAsync();
|
||||
return await Mine();
|
||||
}
|
||||
|
||||
private async Task<WishlistRequest> ReadRequestAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
return new WishlistRequest
|
||||
{
|
||||
ItemKey = form["itemKey"].FirstOrDefault() ?? form["ItemKey"].FirstOrDefault(),
|
||||
ItemId = form["itemId"].FirstOrDefault() ?? form["ItemId"].FirstOrDefault(),
|
||||
ItemType = int.TryParse(form["itemType"].FirstOrDefault() ?? form["ItemType"].FirstOrDefault(), out var type) ? type : null,
|
||||
};
|
||||
}
|
||||
|
||||
var queryKey = Request.Query["itemKey"].FirstOrDefault() ?? Request.Query["ItemKey"].FirstOrDefault();
|
||||
var queryId = Request.Query["itemId"].FirstOrDefault() ?? Request.Query["ItemId"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(queryKey) || !string.IsNullOrWhiteSpace(queryId))
|
||||
{
|
||||
return new WishlistRequest
|
||||
{
|
||||
ItemKey = queryKey,
|
||||
ItemId = queryId,
|
||||
ItemType = int.TryParse(Request.Query["itemType"].FirstOrDefault() ?? Request.Query["ItemType"].FirstOrDefault(), out var type) ? type : null,
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await JsonSerializer.DeserializeAsync<WishlistRequest>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? new WishlistRequest();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new WishlistRequest();
|
||||
}
|
||||
}
|
||||
|
||||
private static object ToWire(ItemWishlistEntity row) => new
|
||||
{
|
||||
row.Id,
|
||||
row.PlayerId,
|
||||
row.ItemKey,
|
||||
ItemId = row.ItemKey,
|
||||
row.ItemType,
|
||||
row.CreatedAt,
|
||||
};
|
||||
|
||||
public sealed class WishlistRequest
|
||||
{
|
||||
public string? ItemKey { get; set; }
|
||||
public string? ItemId { get; set; }
|
||||
public int? ItemType { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Keepsakes;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/keepsakes")]
|
||||
[Authorize]
|
||||
public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Mine()
|
||||
{
|
||||
await EnsureLoginKeepsakeAsync(Me);
|
||||
var rows = await db.Keepsakes
|
||||
.Where(k => k.PlayerId == Me)
|
||||
.OrderByDescending(k => k.EarnedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("categories")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Categories()
|
||||
{
|
||||
var results = new[]
|
||||
{
|
||||
new { KeepsakeCategoryId = 0, VisualId = "account", LimitPerRoom = 0, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty },
|
||||
new { KeepsakeCategoryId = 1, VisualId = "event", LimitPerRoom = 0, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty },
|
||||
new { KeepsakeCategoryId = 2, VisualId = "room", LimitPerRoom = 64, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty },
|
||||
};
|
||||
return Ok(new
|
||||
{
|
||||
Results = results,
|
||||
TotalResults = results.Length,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("events")]
|
||||
public async Task<IActionResult> Events()
|
||||
{
|
||||
var rows = await db.Keepsakes
|
||||
.Where(k => k.PlayerId == Me && k.Category == "event")
|
||||
.OrderByDescending(k => k.EarnedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("events/{eventId:long}")]
|
||||
public async Task<IActionResult> EventInstances(long eventId)
|
||||
{
|
||||
var eventKey = eventId.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
var rows = await db.Keepsakes
|
||||
.Where(k => k.PlayerId == Me
|
||||
&& k.Category == "event"
|
||||
&& (k.EventKey == eventKey
|
||||
|| k.EventKey == $"event:{eventKey}"
|
||||
|| k.EventKey == $"event/{eventKey}"
|
||||
|| EF.Functions.Like(k.EventKey, $"event:{eventKey}:%")
|
||||
|| EF.Functions.Like(k.EventKey, $"event/{eventKey}/%")))
|
||||
.OrderByDescending(k => k.EarnedAt)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
KeepsakeProgressionEventId = eventId,
|
||||
Instances = rows.Select(ToWire),
|
||||
CollectionRecords = Array.Empty<object>(),
|
||||
KeepsakeProgressionEventIds = new[] { eventId },
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("rooms/{roomId:long}")]
|
||||
public async Task<IActionResult> RoomKeepsakes(long roomId)
|
||||
{
|
||||
var roomExists = await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId);
|
||||
if (!roomExists) return NotFound();
|
||||
|
||||
var roomIdText = roomId.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
var rows = await db.Keepsakes
|
||||
.Where(k => k.Category == "room"
|
||||
&& (k.EventKey == $"room:{roomIdText}"
|
||||
|| k.EventKey == $"room/{roomIdText}"
|
||||
|| k.EventKey == roomIdText
|
||||
|| EF.Functions.Like(k.EventKey, $"room:{roomIdText}:%")
|
||||
|| EF.Functions.Like(k.EventKey, $"room/{roomIdText}/%")))
|
||||
.OrderByDescending(k => k.EarnedAt)
|
||||
.ToListAsync();
|
||||
var instances = rows.Select(row => ToRoomInstanceWire(row, roomId)).ToList();
|
||||
return Ok(new
|
||||
{
|
||||
Instances = instances,
|
||||
CollectionRecords = Array.Empty<object>(),
|
||||
KeepsakeProgressionEventIds = Array.Empty<long>(),
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("globalconfig")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult GlobalConfig() => Ok(new
|
||||
{
|
||||
KeepsakeFeatureEnabled = true,
|
||||
KeepsakeRoomLimit = 64,
|
||||
SocialXpBoostEnabled = false,
|
||||
});
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
var req = await ReadRequestAsync();
|
||||
var key = Trim(req.EventKey, 128);
|
||||
if (key.Length == 0) key = $"manual:{Guid.NewGuid():N}";
|
||||
var category = Trim(req.Category, 64);
|
||||
if (category.Length == 0) category = "event";
|
||||
|
||||
var existing = await db.Keepsakes.FirstOrDefaultAsync(k =>
|
||||
k.PlayerId == Me && k.EventKey == key);
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new KeepsakeEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
EventKey = key,
|
||||
};
|
||||
db.Keepsakes.Add(existing);
|
||||
}
|
||||
|
||||
existing.Category = category;
|
||||
existing.Title = Trim(req.Title, 128);
|
||||
existing.Description = Trim(req.Description, 1024);
|
||||
existing.ImageName = Trim(req.ImageName, 256);
|
||||
existing.EarnedAt = req.EarnedAt ?? DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(existing));
|
||||
}
|
||||
|
||||
private async Task EnsureLoginKeepsakeAsync(long playerId)
|
||||
{
|
||||
const string key = "first-login";
|
||||
if (await db.Keepsakes.AnyAsync(k => k.PlayerId == playerId && k.EventKey == key))
|
||||
return;
|
||||
db.Keepsakes.Add(new KeepsakeEntity
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Category = "account",
|
||||
EventKey = key,
|
||||
Title = "First Login",
|
||||
Description = "Joined this server.",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task<KeepsakeRequest> ReadRequestAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
return new KeepsakeRequest
|
||||
{
|
||||
Category = form["category"].FirstOrDefault() ?? form["Category"].FirstOrDefault(),
|
||||
EventKey = form["eventKey"].FirstOrDefault() ?? form["EventKey"].FirstOrDefault(),
|
||||
Title = form["title"].FirstOrDefault() ?? form["Title"].FirstOrDefault(),
|
||||
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
|
||||
ImageName = form["imageName"].FirstOrDefault() ?? form["ImageName"].FirstOrDefault(),
|
||||
EarnedAt = DateTime.TryParse(form["earnedAt"].FirstOrDefault() ?? form["EarnedAt"].FirstOrDefault(), out var earned) ? earned : null,
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await JsonSerializer.DeserializeAsync<KeepsakeRequest>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? new KeepsakeRequest();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new KeepsakeRequest();
|
||||
}
|
||||
}
|
||||
|
||||
private object ToWire(KeepsakeEntity row) => new
|
||||
{
|
||||
row.Id,
|
||||
row.PlayerId,
|
||||
row.Category,
|
||||
row.EventKey,
|
||||
row.Title,
|
||||
row.Description,
|
||||
row.ImageName,
|
||||
ImageUrl = string.IsNullOrWhiteSpace(row.ImageName) ? string.Empty : $"https://{domain.Sub("cdn")}/{row.ImageName}",
|
||||
row.EarnedAt,
|
||||
};
|
||||
|
||||
private static object ToRoomInstanceWire(KeepsakeEntity row, long roomId)
|
||||
{
|
||||
var placedByAccountId = row.PlayerId is > 0 and <= int.MaxValue
|
||||
? (int)row.PlayerId
|
||||
: 0;
|
||||
return new
|
||||
{
|
||||
KeepsakeInstanceId = StableKeepsakeInstanceId(row.Id).ToString("D"),
|
||||
KeepsakeCategoryConfigId = CategoryId(row.Category),
|
||||
PlacedByAccountId = placedByAccountId,
|
||||
RoomId = roomId,
|
||||
SubRoomId = (long?)null,
|
||||
};
|
||||
}
|
||||
|
||||
private static int CategoryId(string? category) =>
|
||||
string.Equals(category, "event", StringComparison.OrdinalIgnoreCase) ? 1 :
|
||||
string.Equals(category, "room", StringComparison.OrdinalIgnoreCase) ? 2 :
|
||||
0;
|
||||
|
||||
private static Guid StableKeepsakeInstanceId(long id)
|
||||
{
|
||||
var hash = MD5.HashData(Encoding.UTF8.GetBytes($"keepsakeInstance:{id}"));
|
||||
return new Guid(hash);
|
||||
}
|
||||
|
||||
private static string Trim(string? value, int max)
|
||||
{
|
||||
var s = (value ?? string.Empty).Trim();
|
||||
return s.Length <= max ? s : s[..max];
|
||||
}
|
||||
|
||||
public sealed class KeepsakeRequest
|
||||
{
|
||||
public string? Category { get; set; }
|
||||
public string? EventKey { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
public DateTime? EarnedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ public class NotificationController(DomainConfig domain) : ControllerBase
|
||||
public IActionResult Connect() => StatusCode(StatusCodes.Status410Gone, new
|
||||
{
|
||||
error = "use_signalr_hub",
|
||||
hint = $"connect to {domain.Sub("notify")}/hub/v1 instead",
|
||||
hint = $"connect to {domain.Url("notify", "/hub/v1")} instead",
|
||||
});
|
||||
|
||||
/// <summary>GET <c>api/notification/hub/v1</c> — returns the
|
||||
@@ -27,7 +27,7 @@ public class NotificationController(DomainConfig domain) : ControllerBase
|
||||
[HttpGet("/api/notification/hub/v1")]
|
||||
public IActionResult HubInfo() => Ok(new
|
||||
{
|
||||
Url = $"https://{domain.Sub("notify")}/hub/v1",
|
||||
Url = domain.Url("notify", "/hub/v1"),
|
||||
Protocol = "signalr",
|
||||
Version = 1,
|
||||
});
|
||||
|
||||
@@ -41,9 +41,9 @@ public class PageviewController(ILogger<PageviewController> logger) : Controller
|
||||
/// intentionally return an empty url + a 1-hour freshness — the
|
||||
/// private server has no in-app deep-link system, so there's
|
||||
/// nothing to navigate to and no reason to be polled more often.</summary>
|
||||
[HttpPost("pageview/consume")]
|
||||
[HttpPost("pageview/consume", Order = 10)]
|
||||
[HttpPost("/pageview/consume")]
|
||||
[HttpGet("pageview/consume")]
|
||||
[HttpGet("pageview/consume", Order = 10)]
|
||||
[HttpGet("/pageview/consume")]
|
||||
public IActionResult Consume()
|
||||
{
|
||||
|
||||
@@ -114,7 +114,18 @@ public class PlatformLoginController(
|
||||
// Login.RemoveCachedLogin → DELETE api/platformlogin/cached
|
||||
[HttpDelete("cached")]
|
||||
[HttpDelete("cachedlogins")]
|
||||
public IActionResult RemoveCachedLogin() => Ok(new { });
|
||||
public async Task<IActionResult> RemoveCachedLogin([FromQuery] long? accountId = null)
|
||||
{
|
||||
var targetId = accountId ?? this.CurrentPlayerId();
|
||||
if (targetId is not long playerId) return Unauthorized();
|
||||
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||
if (player is null) return NotFound();
|
||||
player.LastPlatform = 0;
|
||||
player.LastPlatformId = string.Empty;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, AccountId = (int)player.Id });
|
||||
}
|
||||
|
||||
// Login.RefreshLogin → POST api/platformlogin/refresh
|
||||
// Response is Login.RefreshLoginResponse { Token } — single field, the
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.PlayStationPlus;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class PlayStationPlusController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/playstationplus/membership")]
|
||||
public async Task<IActionResult> Membership()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "playstationplus:expires");
|
||||
var expires = row is not null && DateTime.TryParse(row.Value, out var parsed)
|
||||
? parsed
|
||||
: DateTime.MinValue;
|
||||
var active = expires > DateTime.UtcNow;
|
||||
return Ok(new
|
||||
{
|
||||
IsMember = active,
|
||||
MembershipActive = active,
|
||||
ExpiresAt = active ? expires : (DateTime?)null,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("api/playstationplus/expire")]
|
||||
public async Task<IActionResult> Expire()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "playstationplus:expires");
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = me,
|
||||
Key = "playstationplus:expires",
|
||||
Value = DateTime.UtcNow.AddSeconds(-1).ToString("O"),
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = DateTime.UtcNow.AddSeconds(-1).ToString("O");
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, IsMember = false });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
@@ -45,6 +46,7 @@ public class PlayerEventsController(
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v2/{eventId:long}")]
|
||||
[HttpGet("api/playerevents/v1/{eventId:long}")]
|
||||
public async Task<ActionResult> GetOne(long eventId)
|
||||
{
|
||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
|
||||
@@ -52,6 +54,121 @@ public class PlayerEventsController(
|
||||
return Ok(ToWire(ev));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/search")]
|
||||
[HttpGet("api/playerevents/v1/searchlive")]
|
||||
public async Task<ActionResult> Search(
|
||||
[FromQuery] string? q = null,
|
||||
[FromQuery] string? query = null,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 50)
|
||||
{
|
||||
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = db.PlayerEvents
|
||||
.Where(e => e.EndsAt > DateTime.UtcNow);
|
||||
if (needle.Length > 0)
|
||||
{
|
||||
rows = rows.Where(e =>
|
||||
e.Title.ToLower().Contains(needle) ||
|
||||
e.Description.ToLower().Contains(needle));
|
||||
}
|
||||
|
||||
var result = await rows
|
||||
.OrderBy(e => e.StartsAt)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(result.Select(ToWire));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/room/{roomId:long}")]
|
||||
public async Task<ActionResult> ForRoom(long roomId)
|
||||
{
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => e.RoomId == roomId && e.EndsAt > DateTime.UtcNow)
|
||||
.OrderBy(e => e.StartsAt)
|
||||
.Take(100)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/club/{clubId:long}")]
|
||||
public async Task<ActionResult> ForClub(long clubId)
|
||||
{
|
||||
var memberIds = await db.ClubMemberships
|
||||
.Where(m => m.ClubId == clubId)
|
||||
.Select(m => m.PlayerId)
|
||||
.ToListAsync();
|
||||
if (memberIds.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var roomIds = await db.Rooms
|
||||
.Where(r => memberIds.Contains(r.CreatorPlayerId))
|
||||
.Select(r => r.Id)
|
||||
.ToListAsync();
|
||||
if (roomIds.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow)
|
||||
.OrderBy(e => e.StartsAt)
|
||||
.Take(100)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/clubs")]
|
||||
public async Task<ActionResult> ClubEvents([FromQuery] int take = 100)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 200);
|
||||
var clubPlayerIds = await db.ClubMemberships
|
||||
.Select(m => m.PlayerId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
if (clubPlayerIds.Count == 0) return Ok(Array.Empty<object>());
|
||||
var roomIds = await db.Rooms
|
||||
.Where(r => clubPlayerIds.Contains(r.CreatorPlayerId))
|
||||
.Select(r => r.Id)
|
||||
.ToListAsync();
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow)
|
||||
.OrderBy(e => e.StartsAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
public sealed class EventBulkRequest
|
||||
{
|
||||
public List<long>? PlayerEventIds { get; set; }
|
||||
public List<long>? EventIds { get; set; }
|
||||
public List<long>? Ids { get; set; }
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("api/playerevents/v1/bulk")]
|
||||
[HttpGet("api/playerevents/v1/bulk")]
|
||||
public async Task<ActionResult> Bulk()
|
||||
{
|
||||
var ids = await ReadEventIdsAsync();
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => ids.Contains(e.Id))
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/tagfilters")]
|
||||
public IActionResult TagFilters() => Ok(new
|
||||
{
|
||||
PinnedFilters = new[] { "game", "social", "competition", "class", "club" },
|
||||
PopularFilters = new[] { "game", "social", "competition", "class", "club" },
|
||||
});
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1/all</c> — the watch's
|
||||
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd
|
||||
/// events). Response keys (both required, via
|
||||
@@ -93,6 +210,39 @@ public class PlayerEventsController(
|
||||
return Ok(new { Created = created, Responses = responses });
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/all/{accountId:long}")]
|
||||
public async Task<ActionResult> AllPlayerEventsForAccount(long accountId)
|
||||
{
|
||||
var created = await db.PlayerEvents
|
||||
.Where(e => e.CreatorPlayerId == accountId)
|
||||
.OrderByDescending(e => e.StartsAt)
|
||||
.Select(e => new
|
||||
{
|
||||
e.Id,
|
||||
e.Title,
|
||||
e.Description,
|
||||
e.RoomId,
|
||||
e.StartsAt,
|
||||
e.EndsAt,
|
||||
e.Capacity,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var responses = await db.PlayerEventResponses
|
||||
.Where(r => r.PlayerId == accountId)
|
||||
.Select(r => new
|
||||
{
|
||||
r.Id,
|
||||
r.EventId,
|
||||
r.Response,
|
||||
r.CreatedAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { Created = created, Responses = responses });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
|
||||
/// alternate RSVP route the watch uses for the response-list
|
||||
/// flow (older / sub-room view). Body is the same RsvpRequest
|
||||
@@ -233,6 +383,123 @@ public class PlayerEventsController(
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/name")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/name")]
|
||||
public async Task<IActionResult> UpdateName(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadStringFieldAsync("name", "Name", "value");
|
||||
if (string.IsNullOrWhiteSpace(value)) return BadRequest("missing_name");
|
||||
evt.Title = value.Trim()[..Math.Min(value.Trim().Length, 128)];
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/description")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/description")]
|
||||
public async Task<IActionResult> UpdateDescription(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadStringFieldAsync("description", "Description", "value") ?? string.Empty;
|
||||
evt.Description = value.Trim()[..Math.Min(value.Trim().Length, 2000)];
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/room")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/room")]
|
||||
public async Task<IActionResult> UpdateRoom(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var roomId = await ReadLongFieldAsync("roomId", "RoomId", "value");
|
||||
if (roomId is not long id || id <= 0) return BadRequest("missing_room");
|
||||
if (!await db.Rooms.AnyAsync(r => r.Id == id)) return NotFound("room_not_found");
|
||||
evt.RoomId = id;
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/time")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/time")]
|
||||
public async Task<IActionResult> UpdateTime(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var start = await ReadDateFieldAsync("startTime", "StartTime", "startsAt", "StartsAt");
|
||||
var end = await ReadDateFieldAsync("endTime", "EndTime", "endsAt", "EndsAt");
|
||||
if (start is DateTime s) evt.StartsAt = s;
|
||||
if (end is DateTime e) evt.EndsAt = e > evt.StartsAt ? e : evt.StartsAt.AddHours(1);
|
||||
if (start is null && end is null) return BadRequest("missing_time");
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/image")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/image")]
|
||||
public async Task<IActionResult> UpdateImage(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadStringFieldAsync("imageName", "ImageName", "value") ?? string.Empty;
|
||||
await SetEventSettingAsync(evt, "image", value.Trim());
|
||||
return Ok(ToWire(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/accessibility")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/accessibility")]
|
||||
public async Task<IActionResult> UpdateAccessibility(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadIntFieldAsync("accessibility", "Accessibility", "value");
|
||||
if (value is not int accessibility) return BadRequest("missing_accessibility");
|
||||
await SetEventSettingAsync(evt, "accessibility", accessibility.ToString());
|
||||
return Ok(ToWire(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/tags")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/tags")]
|
||||
public async Task<IActionResult> UpdateTags(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadStringFieldAsync("tags", "Tags", "value") ?? string.Empty;
|
||||
await SetEventSettingAsync(evt, "tags", value.Trim());
|
||||
return Ok(ToWire(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/multiinstance")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/multiinstance")]
|
||||
public async Task<IActionResult> UpdateMultiInstance(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadBoolFieldAsync("multiInstance", "MultiInstance", "value");
|
||||
await SetEventSettingAsync(evt, "multiinstance", (value ?? false) ? "true" : "false");
|
||||
return Ok(ToWire(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/club")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/club")]
|
||||
public async Task<IActionResult> UpdateClub(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var clubId = await ReadLongFieldAsync("clubId", "ClubId", "value");
|
||||
if (clubId is not long id || id <= 0) return BadRequest("missing_club");
|
||||
if (!await db.ClubMemberships.AnyAsync(m => m.ClubId == id && m.PlayerId == Me))
|
||||
return Forbid();
|
||||
await SetEventSettingAsync(evt, "club", id.ToString());
|
||||
return Ok(ToWire(evt));
|
||||
}
|
||||
|
||||
[HttpDelete("api/playerevents/v1/{eventId:long}")]
|
||||
public async Task<ActionResult> Delete(long eventId)
|
||||
{
|
||||
@@ -297,6 +564,44 @@ public class PlayerEventsController(
|
||||
return Ok(new { FailedInvites = failed, Result = 0 });
|
||||
}
|
||||
|
||||
public sealed class BroadcastRequest
|
||||
{
|
||||
public long PlayerEventId { get; set; }
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v1/broadcast")]
|
||||
public async Task<IActionResult> Broadcast([FromBody] BroadcastRequest req)
|
||||
{
|
||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
|
||||
if (ev is null) return NotFound();
|
||||
if (ev.CreatorPlayerId != Me) return Forbid();
|
||||
|
||||
var recipients = await db.PlayerEventResponses
|
||||
.Where(r => r.EventId == ev.Id && r.Response != 2)
|
||||
.Select(r => r.PlayerId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
var body = string.IsNullOrWhiteSpace(req.Message)
|
||||
? $"Update for '{ev.Title}'"
|
||||
: req.Message.Trim();
|
||||
foreach (var playerId in recipients.Where(id => id != Me))
|
||||
{
|
||||
db.Messages.Add(new MessageEntity
|
||||
{
|
||||
SenderPlayerId = Me,
|
||||
RecipientPlayerId = playerId,
|
||||
Body = body,
|
||||
});
|
||||
await notifications.NotifyAsync(playerId,
|
||||
PushNotificationId.PlayerEventResponseChanged,
|
||||
new { ev.Id, ev.Title, Message = body });
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Sent = recipients.Count });
|
||||
}
|
||||
|
||||
// ── Report (Phase 8) ─────────────────────────────────────────────────
|
||||
|
||||
public sealed class PlayerEventReportRequest
|
||||
@@ -306,6 +611,174 @@ public class PlayerEventsController(
|
||||
public string? Details { get; set; }
|
||||
}
|
||||
|
||||
private async Task<PlayerEventEntity?> GetOwnedEventAsync(long eventId)
|
||||
=> await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me);
|
||||
|
||||
private async Task<List<long>> ReadEventIdsAsync()
|
||||
{
|
||||
var ids = new List<long>();
|
||||
|
||||
foreach (var value in Request.Query.SelectMany(q => q.Value))
|
||||
AddDelimitedIds(ids, value);
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
|
||||
foreach (var value in form[key])
|
||||
AddDelimitedIds(ids, value);
|
||||
}
|
||||
else if ((Request.ContentLength ?? 0) > 0
|
||||
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
|
||||
{
|
||||
if (doc.RootElement.TryGetProperty(key, out var prop))
|
||||
AddJsonIds(ids, prop);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AddJsonIds(ids, doc.RootElement);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return ids.Distinct().Take(200).ToList();
|
||||
}
|
||||
|
||||
private static void AddDelimitedIds(List<long> ids, string? value)
|
||||
{
|
||||
foreach (var part in (value ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddJsonIds(List<long> ids, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Array:
|
||||
foreach (var item in element.EnumerateArray())
|
||||
AddJsonIds(ids, item);
|
||||
break;
|
||||
case JsonValueKind.Number:
|
||||
if (element.TryGetInt64(out var id) && id > 0) ids.Add(id);
|
||||
break;
|
||||
case JsonValueKind.String:
|
||||
AddDelimitedIds(ids, element.GetString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> ReadStringFieldAsync(params string[] names)
|
||||
{
|
||||
var fields = await ReadRequestFieldsAsync();
|
||||
foreach (var name in names)
|
||||
if (fields.TryGetValue(name, out var value))
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<long?> ReadLongFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return long.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private async Task<int?> ReadIntFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return int.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private async Task<bool?> ReadBoolFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return bool.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private async Task<DateTime?> ReadDateFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return DateTime.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> ReadRequestFieldsAsync()
|
||||
{
|
||||
const string itemKey = "__playerevent_fields";
|
||||
if (HttpContext.Items.TryGetValue(itemKey, out var cached)
|
||||
&& cached is Dictionary<string, string> existing)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in Request.Query)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var pair in form)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
else if ((Request.ContentLength ?? 0) > 0
|
||||
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
fields[prop.Name] = prop.Value.ValueKind == JsonValueKind.String
|
||||
? prop.Value.GetString() ?? string.Empty
|
||||
: prop.Value.GetRawText();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
HttpContext.Items[itemKey] = fields;
|
||||
return fields;
|
||||
}
|
||||
|
||||
private async Task SetEventSettingAsync(PlayerEventEntity evt, string key, string value)
|
||||
{
|
||||
var settingKey = $"playerevent:{evt.Id}:{key}";
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == evt.CreatorPlayerId && s.Key == settingKey);
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = evt.CreatorPlayerId,
|
||||
Key = settingKey,
|
||||
Value = value,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = value;
|
||||
}
|
||||
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/report</c> — file a
|
||||
/// moderation report against an event. Wire response is
|
||||
/// <c>KLAMKCBENEA{Success, Message}</c>; returning a bare
|
||||
|
||||
@@ -23,7 +23,7 @@ namespace DorkNet.Server.Controllers.API.PlayerReporting;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class ReportsController(DorkNetDbContext db) : ControllerBase
|
||||
public class ReportsController(DorkNetDbContext db, ILogger<ReportsController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/PlayerReporting/v1/voteToKickReasons")]
|
||||
[AllowAnonymous]
|
||||
@@ -202,7 +202,7 @@ public class ReportsController(DorkNetDbContext db) : ControllerBase
|
||||
private async Task<IActionResult> SaveHileAsync(string? rawDetail, int? type, long? gameSessionId)
|
||||
{
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is not long me) return Ok(new { success = true });
|
||||
if (pid is not long me) return HileResult();
|
||||
var detail = (rawDetail ?? string.Empty).Trim();
|
||||
if (type is int hileType)
|
||||
detail = detail.Length == 0 ? $"Type={hileType}" : $"Type={hileType}; {detail}";
|
||||
@@ -215,7 +215,22 @@ public class ReportsController(DorkNetDbContext db) : ControllerBase
|
||||
GameSessionId = gameSessionId ?? 0,
|
||||
Category = "hile",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { success = true });
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException ex) when (SqliteErrors.IsBusy(ex))
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
logger.LogWarning(ex, "[hile] dropping low-priority hile heartbeat because sqlite is busy");
|
||||
}
|
||||
return HileResult();
|
||||
}
|
||||
|
||||
private static IActionResult HileResult() => new ContentResult
|
||||
{
|
||||
Content = "true",
|
||||
ContentType = "application/json",
|
||||
StatusCode = StatusCodes.Status200OK,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ public class PlayerStateController(
|
||||
// values from PlayerEntity.Level / .XP. Default 1/0 when the
|
||||
// account hasn't been seen yet (synthetic ids passed in queries).
|
||||
[HttpGet("api/players/v1/progression/{accountId:long}")]
|
||||
[HttpGet("api/players/v2/progression/{accountId:long}")]
|
||||
public async Task<ActionResult<Progression>> GetProgression(long accountId)
|
||||
{
|
||||
var p = await db.Players
|
||||
@@ -51,12 +52,25 @@ public class PlayerStateController(
|
||||
}
|
||||
|
||||
[HttpPost("api/players/v1/progression/bulk")]
|
||||
[HttpPost("api/players/v2/progression/bulk")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<ActionResult<List<Progression>>> GetProgressionBulk(
|
||||
[FromForm(Name = "Ids")] string? ids)
|
||||
{
|
||||
var idList = ParseIds(ids).Select(i => (long)i).ToList();
|
||||
if (idList.Count == 0) return Ok(new List<Progression>());
|
||||
return Ok(await BuildProgressionBulkAsync(idList));
|
||||
}
|
||||
|
||||
[HttpGet("api/players/v2/progression/bulk")]
|
||||
public async Task<ActionResult<List<Progression>>> GetProgressionBulkV2Get()
|
||||
{
|
||||
var idList = ParseQueryIds().ToList();
|
||||
return Ok(await BuildProgressionBulkAsync(idList));
|
||||
}
|
||||
|
||||
private async Task<List<Progression>> BuildProgressionBulkAsync(IReadOnlyCollection<long> idList)
|
||||
{
|
||||
if (idList.Count == 0) return new List<Progression>();
|
||||
var rows = await db.Players
|
||||
.Where(p => idList.Contains(p.Id))
|
||||
.Select(p => new Progression
|
||||
@@ -72,26 +86,40 @@ public class PlayerStateController(
|
||||
rows.AddRange(idList
|
||||
.Where(id => !found.Contains((int)id))
|
||||
.Select(id => new Progression { PlayerId = (int)id }));
|
||||
return Ok(rows);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// RecNet.Reputations.GetReputationById — aggregates CheerEntity
|
||||
// by category. Each Cheer* count is the sum of distinct cheers
|
||||
// received from other players in that category.
|
||||
[HttpGet("api/playerReputation/v1/{accountId:long}")]
|
||||
[HttpGet("api/playerReputation/v2/{accountId:long}")]
|
||||
public async Task<ActionResult<Reputation>> GetReputation(long accountId) =>
|
||||
Ok(await BuildReputationAsync(accountId));
|
||||
|
||||
[HttpPost("api/playerReputation/v1/bulk")]
|
||||
[HttpPost("api/playerReputation/v2/bulk")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<ActionResult<List<Reputation>>> GetReputationBulk(
|
||||
[FromForm(Name = "Ids")] string? ids)
|
||||
{
|
||||
var idList = ParseIds(ids).Select(i => (long)i).ToList();
|
||||
return Ok(await BuildReputationBulkAsync(idList));
|
||||
}
|
||||
|
||||
[HttpGet("api/playerReputation/v2/bulk")]
|
||||
public async Task<ActionResult<List<Reputation>>> GetReputationBulkV2Get()
|
||||
{
|
||||
var idList = ParseQueryIds().ToList();
|
||||
return Ok(await BuildReputationBulkAsync(idList));
|
||||
}
|
||||
|
||||
private async Task<List<Reputation>> BuildReputationBulkAsync(IReadOnlyCollection<long> idList)
|
||||
{
|
||||
var result = new List<Reputation>(idList.Count);
|
||||
foreach (var id in idList)
|
||||
result.Add(await BuildReputationAsync(id));
|
||||
return Ok(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>POST api/playerReputation/v1/cheer/{playerId} — cheer
|
||||
@@ -216,6 +244,23 @@ public class PlayerStateController(
|
||||
.Select(s => int.TryParse(s, out var v) ? v : 0)
|
||||
.Where(v => v != 0);
|
||||
|
||||
private IEnumerable<long> ParseQueryIds()
|
||||
{
|
||||
foreach (var (key, values) in Request.Query)
|
||||
{
|
||||
if (long.TryParse(key, out var keyId) && keyId > 0)
|
||||
yield return keyId;
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (long.TryParse(value, out var valueId) && valueId > 0)
|
||||
yield return valueId;
|
||||
foreach (var part in value.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(part, out var partId) && partId > 0)
|
||||
yield return partId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RecNet.PlayerReporting.GetModerationBlockDetails
|
||||
// IMPORTANT: returns a single object, not an array. Defaults indicate
|
||||
// "user is not currently blocked / kicked".
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.PlayerWarnings;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/playerwarnings")]
|
||||
public async Task<IActionResult> Warnings()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var rows = await db.PlayerSettings
|
||||
.Where(s => s.PlayerId == me && s.Key.StartsWith("warning:"))
|
||||
.OrderByDescending(s => s.Id)
|
||||
.ToListAsync();
|
||||
return Ok(rows
|
||||
.Select(ToWarningWire)
|
||||
.Where(w => !w.Acknowledged)
|
||||
.ToList());
|
||||
}
|
||||
|
||||
[HttpPost("api/playerwarnings/acknowledge")]
|
||||
public async Task<IActionResult> Acknowledge([FromForm] string? warningId)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var id = warningId ?? Request.Query["warningId"].FirstOrDefault() ?? Request.Query["id"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(id)) return BadRequest("missing_warning");
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == $"warning:{id}");
|
||||
if (row is null) return NotFound();
|
||||
var parts = row.Value.Split('|');
|
||||
row.Value = string.Join('|', parts.Take(3).Append("true"));
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, WarningId = id });
|
||||
}
|
||||
|
||||
private static WarningWire ToWarningWire(DorkNet.Server.Data.Entities.PlayerSettingEntity row)
|
||||
{
|
||||
var parts = row.Value.Split('|');
|
||||
return new WarningWire
|
||||
{
|
||||
WarningId = row.Key["warning:".Length..],
|
||||
Message = parts.ElementAtOrDefault(0) ?? string.Empty,
|
||||
Category = int.TryParse(parts.ElementAtOrDefault(1), out var category) ? category : 0,
|
||||
CreatedAt = parts.ElementAtOrDefault(2) ?? string.Empty,
|
||||
Acknowledged = bool.TryParse(parts.ElementAtOrDefault(3), out var acknowledged) && acknowledged,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class WarningWire
|
||||
{
|
||||
public string WarningId { get; set; } = string.Empty;
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public int Category { get; set; }
|
||||
public string CreatedAt { get; set; } = string.Empty;
|
||||
public bool Acknowledged { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,20 @@ public class PlayersController(PlayerService playerService, NotificationService
|
||||
return Ok(PlayerService.ToProfile(player));
|
||||
}
|
||||
|
||||
[HttpGet("/api/players/v4/current/contact")]
|
||||
public async Task<IActionResult> CurrentContact()
|
||||
{
|
||||
var player = await playerService.GetByIdAsync(CurrentPlayerId);
|
||||
if (player is null) return NotFound();
|
||||
return Ok(new
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
Email = player.Email ?? string.Empty,
|
||||
Phone = player.Phone ?? string.Empty,
|
||||
CanReceiveInvites = player.CanReceiveInvites,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("{id:long}")]
|
||||
public async Task<ActionResult<PlayerProfile>> GetById(long id)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.ProgressionEvents;
|
||||
|
||||
[ApiController]
|
||||
public class ProgressionEventsController(DorkNetDbContext db, ServerSettingsService serverSettings) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/progressionEvents")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Progress()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var rows = await db.ObjectiveProgress
|
||||
.Where(o => o.PlayerId == me && o.Key.StartsWith("progressionEvent:"))
|
||||
.OrderByDescending(o => o.ClearedAt)
|
||||
.Take(100)
|
||||
.Select(o => new
|
||||
{
|
||||
EventKey = o.Key,
|
||||
o.IsCompleted,
|
||||
o.ClearedAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpGet("api/progressionEvents/event/{eventId:long}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Event(long eventId)
|
||||
{
|
||||
var activeId = CurrentProgressionEventId();
|
||||
if (eventId != activeId)
|
||||
return NotFound();
|
||||
|
||||
var weekly = await serverSettings.GetWeeklyChallengesAsync();
|
||||
var (start, end) = CurrentWeekWindow();
|
||||
return Ok(new
|
||||
{
|
||||
ProgressionEventId = activeId,
|
||||
Name = "Weekly Progression",
|
||||
Rewards = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
ProgressionEventRewardId = activeId * 1000L,
|
||||
GiftDropId = weekly.Reward.GiftDropId != 0 ? weekly.Reward.GiftDropId : activeId,
|
||||
ImageName = string.Empty,
|
||||
Xp = weekly.Reward.Xp,
|
||||
RewardIndex = 0,
|
||||
IsBonus = false,
|
||||
},
|
||||
},
|
||||
KeepsakeRoomLists = Array.Empty<object>(),
|
||||
StartTime = start,
|
||||
EndTime = end,
|
||||
CollectionEndTime = end,
|
||||
UsesBoost = false,
|
||||
BoostDailyGameplayMinutesLimit = 0,
|
||||
BoostXpMultiplier = 1.0,
|
||||
PurchasableXpBoostId = (Guid?)null,
|
||||
ActiveExperiment = string.Empty,
|
||||
ChallengesIconImageName = string.Empty,
|
||||
RewardsPipImageName = string.Empty,
|
||||
EventInfoImageName = string.Empty,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("api/progressionEvents/active")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Active() => Ok(CurrentProgressionEventId());
|
||||
|
||||
/// <summary>
|
||||
/// The current player's progress record for a given progression event.
|
||||
/// The 2023 client fetches this during InitialRoomLoad
|
||||
/// (<c>api/progressionEvents/record/{id}</c>, where <c>{id}</c> is the
|
||||
/// <c>yyyyMMdd</c> event id). A 404 here faults the room-load coroutine
|
||||
/// with a NullReferenceException and traps the client in a dorm
|
||||
/// matchmaking loop, so we always return a non-null record.
|
||||
/// </summary>
|
||||
[HttpGet("api/progressionEvents/record/{progressionEventId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Record(long progressionEventId)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
|
||||
// Count this player's completed progression-event objectives so the
|
||||
// record reflects real progress rather than a flat zero.
|
||||
var completed = await db.ObjectiveProgress
|
||||
.CountAsync(o => o.PlayerId == me
|
||||
&& o.IsCompleted
|
||||
&& o.Key.StartsWith("progressionEvent:"));
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
ProgressionEventId = progressionEventId,
|
||||
Xp = 0,
|
||||
ClaimedRewardIndex = completed > 0 ? completed - 1 : -1,
|
||||
PurchasedXpBoostCount = 0,
|
||||
DailyBoostGameplayMinutes = 0,
|
||||
XpBoostExpiresAt = (DateTime?)null,
|
||||
});
|
||||
}
|
||||
|
||||
private static long CurrentProgressionEventId()
|
||||
{
|
||||
var (start, _) = CurrentWeekWindow();
|
||||
return long.Parse(start.ToString("yyyyMMdd"));
|
||||
}
|
||||
|
||||
private static (DateTime Start, DateTime End) CurrentWeekWindow()
|
||||
{
|
||||
var start = DateTime.UtcNow.Date.AddDays(-(int)DateTime.UtcNow.DayOfWeek);
|
||||
return (start, start.AddDays(7));
|
||||
}
|
||||
}
|
||||
@@ -25,15 +25,29 @@ public class QuickPlayController(DorkNetDbContext db) : ControllerBase
|
||||
/// immediately teleports — that's WHY users were randomly
|
||||
/// spawning in Paddleball etc.
|
||||
///
|
||||
/// Correct behaviour: return empty {} unless we actually have a
|
||||
/// queued match for the caller. The /set endpoint is what
|
||||
/// queues; getandclear is the consume.
|
||||
/// Correct behaviour: return the full no-target launch DTO unless
|
||||
/// we actually have a queued match for the caller. The /set endpoint
|
||||
/// is what queues; getandclear is the consume.
|
||||
///
|
||||
/// We don't persist queue state on a single-region private
|
||||
/// server (no real matchmaking pool), so always return empty.</summary>
|
||||
[HttpGet("api/quickPlay/v1/getandclear")]
|
||||
[HttpGet("api/quickPlay/v2/getandclear")]
|
||||
public IActionResult GetAndClear() => Ok(new { });
|
||||
public IActionResult GetAndClear() => Ok(QuickPlayLaunchTarget.Empty);
|
||||
|
||||
private sealed class QuickPlayLaunchTarget
|
||||
{
|
||||
public static readonly QuickPlayLaunchTarget Empty = new();
|
||||
|
||||
public bool OKCGKFJELAC { get; init; }
|
||||
public string INMKAFBAOPC { get; init; } = string.Empty;
|
||||
public int? FIOPJIIOCGA { get; init; }
|
||||
public string ICKOEFDNOPM { get; init; } = string.Empty;
|
||||
public int? KLDCJBLLDKN { get; init; }
|
||||
public bool OFBMKDAONNE { get; init; }
|
||||
public object? FMKHKNCEBCN { get; init; }
|
||||
public long? EEFNKAFLPLG { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SetQuickPlayRequest
|
||||
{
|
||||
|
||||
@@ -451,8 +451,76 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
||||
return Ok(new { Imported = added });
|
||||
}
|
||||
|
||||
[HttpGet("api/relationships/mutualfriends")]
|
||||
public async Task<IActionResult> MutualFriends([FromQuery] long playerId)
|
||||
{
|
||||
var me = CurrentPlayerId;
|
||||
if (playerId <= 0 || playerId == me) return Ok(Array.Empty<object>());
|
||||
var mine = await FriendIdsAsync(me);
|
||||
var theirs = await FriendIdsAsync(playerId);
|
||||
var mutualIds = mine.Intersect(theirs).Take(100).ToList();
|
||||
var players = await db.Players
|
||||
.Where(p => mutualIds.Contains(p.Id))
|
||||
.Select(p => new
|
||||
{
|
||||
AccountId = (int)p.Id,
|
||||
p.Username,
|
||||
p.DisplayName,
|
||||
ProfileImage = p.ProfileImageName ?? string.Empty,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(players);
|
||||
}
|
||||
|
||||
[HttpPost("api/relationships/sendfriendintroductions")]
|
||||
public async Task<IActionResult> SendFriendIntroductions(
|
||||
[FromForm] long playerId,
|
||||
[FromForm] long introducedPlayerId)
|
||||
{
|
||||
var me = CurrentPlayerId;
|
||||
if (playerId <= 0 || introducedPlayerId <= 0)
|
||||
return BadRequest("missing_player");
|
||||
db.Messages.Add(new MessageEntity
|
||||
{
|
||||
SenderPlayerId = me,
|
||||
RecipientPlayerId = playerId,
|
||||
Type = 30,
|
||||
Body = $"Friend introduction:{introducedPlayerId}",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
|
||||
[HttpPost("api/relationships/v1/addfriendwithcode")]
|
||||
[HttpGet("api/relationships/v1/addfriendwithcode")]
|
||||
public async Task<ActionResult> AddFriendWithCode([FromQuery] string? code, [FromForm] string? formCode)
|
||||
{
|
||||
var raw = (code ?? formCode ?? string.Empty).Trim();
|
||||
if (raw.Length == 0) return Ok(EmptyRelationship(0));
|
||||
long? id = long.TryParse(raw, out var parsed) ? parsed : null;
|
||||
if (id is null)
|
||||
{
|
||||
id = await db.Players
|
||||
.Where(p => p.Username == raw)
|
||||
.Select(p => (long?)p.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
if (id is not long playerId) return Ok(EmptyRelationship(0));
|
||||
return await AddFriend(playerId);
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private async Task<HashSet<long>> FriendIdsAsync(long playerId)
|
||||
{
|
||||
var rows = await db.Relationships
|
||||
.Where(r => r.Status == EntityStatus.Friend
|
||||
&& (r.RequesterId == playerId || r.TargetId == playerId))
|
||||
.Select(r => r.RequesterId == playerId ? r.TargetId : r.RequesterId)
|
||||
.ToListAsync();
|
||||
return rows.ToHashSet();
|
||||
}
|
||||
|
||||
/// <summary>Map a storage row to the watch's wire RelationshipType
|
||||
/// from <paramref name="me"/>'s perspective. PendingSent flips
|
||||
/// direction depending on which side originated.</summary>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.RoomConsumables;
|
||||
|
||||
[ApiController]
|
||||
public class RoomConsumablesController(DorkNetDbContext db, LevelService level) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/roomconsumables")]
|
||||
[HttpGet("api/roomconsumables/v1/roomConsumable")]
|
||||
[HttpGet("api/roomconsumables/v1/roomConsumable/room/{roomId:long}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> List([FromRoute] long? roomId = null, [FromQuery] long? id = null)
|
||||
{
|
||||
var query = db.StoreItems
|
||||
.Where(i => i.IsActive && i.Category.ToLower() == "consumable");
|
||||
if (id is long itemId && itemId > 0)
|
||||
query = query.Where(i => i.Id == itemId);
|
||||
var rows = await query
|
||||
.OrderBy(i => i.DisplayName)
|
||||
.Take(200)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("api/roomconsumables/v1/roomConsumable/room/{roomId:long}/me")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> MineForRoom(long roomId)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var rows = await (from inventory in db.PlayerInventory
|
||||
join item in db.StoreItems on inventory.ItemSlug equals item.Slug
|
||||
where inventory.PlayerId == me
|
||||
&& inventory.Quantity > 0
|
||||
&& item.IsActive
|
||||
&& item.Category.ToLower() == "consumable"
|
||||
orderby item.DisplayName
|
||||
select new { Inventory = inventory, Item = item })
|
||||
.Take(200)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(row => ToInventoryWire(row.Item, row.Inventory)));
|
||||
}
|
||||
|
||||
[HttpPost("api/roomconsumables/v1/roomconsumable/{itemId:long}/purchase/currency")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> PurchaseCurrency(long itemId, [FromQuery] int? currencyType = null) =>
|
||||
Purchase(itemId, currencyType);
|
||||
|
||||
[HttpPost("api/roomconsumables/v1/roomconsumable/{itemId:long}/purchase/tokens")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> PurchaseTokens(long itemId) => Purchase(itemId, 2);
|
||||
|
||||
private async Task<IActionResult> Purchase(long itemId, int? currencyType)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var item = await db.StoreItems
|
||||
.FirstOrDefaultAsync(i => i.Id == itemId && i.IsActive && i.Category.ToLower() == "consumable");
|
||||
if (item is null) return NotFound();
|
||||
|
||||
var spendCurrency = currencyType ?? item.CurrencyType;
|
||||
var balance = await level.GetBalanceAsync(me, spendCurrency);
|
||||
if (balance < item.Price)
|
||||
return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance, CurrencyType = spendCurrency });
|
||||
|
||||
var newBalance = await level.GrantCurrencyAsync(me, spendCurrency, -item.Price, $"roomConsumable:{item.Slug}");
|
||||
var inventory = await db.PlayerInventory
|
||||
.FirstOrDefaultAsync(i => i.PlayerId == me && i.ItemSlug == item.Slug);
|
||||
if (inventory is null)
|
||||
{
|
||||
inventory = new PlayerInventoryEntity
|
||||
{
|
||||
PlayerId = me,
|
||||
ItemSlug = item.Slug,
|
||||
Quantity = 0,
|
||||
};
|
||||
db.PlayerInventory.Add(inventory);
|
||||
}
|
||||
inventory.Quantity += 1;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Balance = newBalance,
|
||||
CurrencyType = spendCurrency,
|
||||
Item = ToWire(item),
|
||||
Quantity = inventory.Quantity,
|
||||
});
|
||||
}
|
||||
|
||||
private static object ToWire(StoreItemEntity item)
|
||||
{
|
||||
var roomConsumableId = StableRoomConsumableId(item.Id).ToString("D");
|
||||
return new
|
||||
{
|
||||
RoomConsumableId = roomConsumableId,
|
||||
Name = item.DisplayName,
|
||||
item.ImageName,
|
||||
item.Description,
|
||||
item.Price,
|
||||
CurrencyId = (Guid?)null,
|
||||
CreatedAt = DateTimeOffset.UnixEpoch,
|
||||
ItemId = item.Id,
|
||||
item.Slug,
|
||||
item.CurrencyType,
|
||||
item.Category,
|
||||
};
|
||||
}
|
||||
|
||||
private static object ToInventoryWire(StoreItemEntity item, PlayerInventoryEntity inventory)
|
||||
{
|
||||
var createdAt = inventory.AcquiredAt == default
|
||||
? DateTimeOffset.UnixEpoch
|
||||
: new DateTimeOffset(DateTime.SpecifyKind(inventory.AcquiredAt, DateTimeKind.Utc));
|
||||
return new
|
||||
{
|
||||
Id = StableRoomConsumableId(item.Id).ToString("D"),
|
||||
ConsumableItemDesc = ToWire(item),
|
||||
Count = Math.Max(0, inventory.Quantity),
|
||||
InitialCount = Math.Max(0, inventory.Quantity),
|
||||
CreatedAt = createdAt,
|
||||
ActiveDurationMinutes = 0,
|
||||
IsActive = inventory.IsActive,
|
||||
IsTransferable = false,
|
||||
};
|
||||
}
|
||||
|
||||
private static Guid StableRoomConsumableId(long itemId)
|
||||
{
|
||||
var hash = MD5.HashData(Encoding.UTF8.GetBytes($"roomConsumable:{itemId}"));
|
||||
return new Guid(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.RoomCurrencies;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpGet("api/roomcurrencies/v1/currencies")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Currencies([FromQuery] long roomId)
|
||||
{
|
||||
var rows = await db.RoomCurrencies
|
||||
.Where(c => !c.IsDeleted && (roomId <= 0 || c.RoomId == roomId))
|
||||
.OrderBy(c => c.Name)
|
||||
.Take(100)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpPost("api/roomcurrencies/v1/createCurrency")]
|
||||
public async Task<IActionResult> CreateCurrency()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var roomId = ReadLong(fields, "roomId", "RoomId");
|
||||
if (roomId is not long rid || rid <= 0) return BadRequest("missing_room");
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == rid);
|
||||
if (room is null) return NotFound("room_not_found");
|
||||
if (!await CanManageRoomAsync(room, Me)) return Forbid();
|
||||
|
||||
var name = Trim(ReadString(fields, "name", "Name") ?? "Room Currency", 64);
|
||||
var currency = new RoomCurrencyEntity
|
||||
{
|
||||
RoomId = room.Id,
|
||||
CreatorPlayerId = Me,
|
||||
Name = name,
|
||||
Description = Trim(ReadString(fields, "description", "Description"), 512),
|
||||
ImageName = Trim(ReadString(fields, "imageName", "ImageName"), 256),
|
||||
DailyLimit = Math.Max(0, ReadInt(fields, "dailyLimit", "DailyLimit") ?? 0),
|
||||
};
|
||||
db.RoomCurrencies.Add(currency);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(currency));
|
||||
}
|
||||
|
||||
[HttpPost("api/roomcurrencies/v1/updateCurrency")]
|
||||
[HttpPut("api/roomcurrencies/v1/updateCurrency")]
|
||||
public async Task<IActionResult> UpdateCurrency()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var currency = await FindCurrencyAsync(fields);
|
||||
if (currency is null) return NotFound();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
|
||||
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
|
||||
|
||||
if (ReadString(fields, "name", "Name") is { } name) currency.Name = Trim(name, 64);
|
||||
if (ReadString(fields, "description", "Description") is { } description) currency.Description = Trim(description, 512);
|
||||
if (ReadString(fields, "imageName", "ImageName") is { } imageName) currency.ImageName = Trim(imageName, 256);
|
||||
if (ReadInt(fields, "dailyLimit", "DailyLimit") is int dailyLimit) currency.DailyLimit = Math.Max(0, dailyLimit);
|
||||
currency.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(currency));
|
||||
}
|
||||
|
||||
[HttpGet("api/roomcurrencies/v1/getBalance")]
|
||||
public async Task<IActionResult> GetBalance()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var currency = await FindCurrencyAsync(fields);
|
||||
if (currency is null) return NotFound();
|
||||
var playerId = ReadLong(fields, "playerId", "PlayerId") ?? Me;
|
||||
var balance = await GetBalanceAsync(playerId, currency.Id);
|
||||
return Ok(new
|
||||
{
|
||||
PlayerId = (int)playerId,
|
||||
RoomCurrencyId = currency.PublicId,
|
||||
Balance = balance,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("api/roomcurrencies/v1/getAllBalances")]
|
||||
public async Task<IActionResult> GetAllBalances([FromQuery] long? roomId = null)
|
||||
{
|
||||
var playerId = Me;
|
||||
var query = db.RoomCurrencyBalances
|
||||
.Where(b => b.PlayerId == playerId)
|
||||
.Join(db.RoomCurrencies, b => b.RoomCurrencyId, c => c.Id, (b, c) => new { b, c })
|
||||
.Where(x => !x.c.IsDeleted);
|
||||
if (roomId is long rid && rid > 0)
|
||||
query = query.Where(x => x.c.RoomId == rid);
|
||||
|
||||
var rows = await query
|
||||
.OrderBy(x => x.c.RoomId)
|
||||
.ThenBy(x => x.c.Name)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(x => new
|
||||
{
|
||||
PlayerId = (int)playerId,
|
||||
RoomCurrencyId = x.c.PublicId,
|
||||
x.b.Balance,
|
||||
}));
|
||||
}
|
||||
|
||||
[HttpPost("api/roomcurrencies/v1/awardCurrency/bulk")]
|
||||
public async Task<IActionResult> AwardCurrencyBulk()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var currency = await FindCurrencyAsync(fields);
|
||||
if (currency is null) return NotFound();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
|
||||
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
|
||||
|
||||
var playerIds = ReadLongList(fields, "playerIds", "PlayerIds", "accountIds", "AccountIds");
|
||||
var amount = ReadLong(fields, "amount", "Amount") ?? 0;
|
||||
if (playerIds.Count == 0 || amount == 0) return BadRequest("missing_awards");
|
||||
|
||||
var results = new List<object>();
|
||||
foreach (var playerId in playerIds)
|
||||
{
|
||||
var balance = await AddBalanceAsync(playerId, currency.Id, amount);
|
||||
results.Add(new { PlayerId = (int)playerId, RoomCurrencyId = currency.PublicId, Balance = balance });
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
[HttpPost("api/roomcurrencies/v1/createPurchaseOffer")]
|
||||
public async Task<IActionResult> CreatePurchaseOffer()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var currency = await FindCurrencyAsync(fields);
|
||||
if (currency is null) return NotFound();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
|
||||
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
|
||||
|
||||
var offer = new RoomCurrencyPurchaseOfferEntity
|
||||
{
|
||||
RoomCurrencyId = currency.Id,
|
||||
Name = Trim(ReadString(fields, "name", "Name") ?? currency.Name, 64),
|
||||
Amount = Math.Max(1, ReadInt(fields, "amount", "Amount") ?? 1),
|
||||
Price = Math.Max(0, ReadInt(fields, "price", "Price") ?? 0),
|
||||
CurrencyType = Math.Max(0, ReadInt(fields, "currencyType", "CurrencyType") ?? 2),
|
||||
};
|
||||
db.RoomCurrencyPurchaseOffers.Add(offer);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToOfferWire(offer, currency));
|
||||
}
|
||||
|
||||
[HttpPost("api/roomcurrencies/v1/updatePurchaseOffer")]
|
||||
[HttpPut("api/roomcurrencies/v1/updatePurchaseOffer")]
|
||||
public async Task<IActionResult> UpdatePurchaseOffer()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var offer = await FindOfferAsync(fields);
|
||||
if (offer is null) return NotFound();
|
||||
var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId);
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
|
||||
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
|
||||
|
||||
if (ReadString(fields, "name", "Name") is { } name) offer.Name = Trim(name, 64);
|
||||
if (ReadInt(fields, "amount", "Amount") is int amount) offer.Amount = Math.Max(1, amount);
|
||||
if (ReadInt(fields, "price", "Price") is int price) offer.Price = Math.Max(0, price);
|
||||
if (ReadInt(fields, "currencyType", "CurrencyType") is int currencyType) offer.CurrencyType = Math.Max(0, currencyType);
|
||||
offer.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToOfferWire(offer, currency));
|
||||
}
|
||||
|
||||
[HttpPost("api/roomcurrencies/v1/deletePurchaseOffer")]
|
||||
[HttpDelete("api/roomcurrencies/v1/deletePurchaseOffer")]
|
||||
public async Task<IActionResult> DeletePurchaseOffer()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var offer = await FindOfferAsync(fields);
|
||||
if (offer is null) return NotFound();
|
||||
var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId);
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
|
||||
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
|
||||
|
||||
offer.IsDeleted = true;
|
||||
offer.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToOfferWire(offer, currency));
|
||||
}
|
||||
|
||||
[HttpPost("api/roomcurrencies/v1/getPurchaseOffersBatch")]
|
||||
[HttpGet("api/roomcurrencies/v1/getPurchaseOffersBatch")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetPurchaseOffersBatch()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var offerIds = ReadGuidList(fields, "purchaseOfferIds", "PurchaseOfferIds", "offerIds", "OfferIds");
|
||||
var currencyIds = ReadGuidList(fields, "roomCurrencyIds", "RoomCurrencyIds", "currencyIds", "CurrencyIds");
|
||||
|
||||
var query = db.RoomCurrencyPurchaseOffers
|
||||
.Where(o => !o.IsDeleted)
|
||||
.Join(db.RoomCurrencies, o => o.RoomCurrencyId, c => c.Id, (o, c) => new { o, c })
|
||||
.Where(x => !x.c.IsDeleted);
|
||||
if (offerIds.Count > 0) query = query.Where(x => offerIds.Contains(x.o.PublicId));
|
||||
if (currencyIds.Count > 0) query = query.Where(x => currencyIds.Contains(x.c.PublicId));
|
||||
|
||||
var rows = await query.Take(200).ToListAsync();
|
||||
return Ok(rows.Select(x => ToOfferWire(x.o, x.c)));
|
||||
}
|
||||
|
||||
[HttpPost("api/roomCurrencies/v2/purchase")]
|
||||
[HttpGet("api/roomCurrencies/v2/purchase")]
|
||||
public async Task<IActionResult> Purchase()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var offer = await FindOfferAsync(fields);
|
||||
if (offer is null) return NotFound();
|
||||
var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId);
|
||||
var pid = Me;
|
||||
var balance = await level.GetBalanceAsync(pid, offer.CurrencyType);
|
||||
if (balance < offer.Price)
|
||||
return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance, offer.CurrencyType });
|
||||
var newBalance = offer.Price == 0
|
||||
? balance
|
||||
: await level.GrantCurrencyAsync(pid, offer.CurrencyType, -offer.Price, $"roomCurrency:{offer.PublicId}");
|
||||
var customBalance = await AddBalanceAsync(pid, currency.Id, offer.Amount);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Balance = newBalance,
|
||||
offer.CurrencyType,
|
||||
RoomCurrencyId = currency.PublicId,
|
||||
RoomCurrencyBalance = customBalance,
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<RoomCurrencyEntity?> FindCurrencyAsync(Dictionary<string, string> fields)
|
||||
{
|
||||
var guid = ReadGuid(fields, "roomCurrencyId", "RoomCurrencyId", "currencyId", "CurrencyId", "id", "Id");
|
||||
if (guid is Guid publicId)
|
||||
return await db.RoomCurrencies.FirstOrDefaultAsync(c => c.PublicId == publicId && !c.IsDeleted);
|
||||
var localId = ReadLong(fields, "roomCurrencyInternalId", "internalId");
|
||||
if (localId is long id)
|
||||
return await db.RoomCurrencies.FirstOrDefaultAsync(c => c.Id == id && !c.IsDeleted);
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<RoomCurrencyPurchaseOfferEntity?> FindOfferAsync(Dictionary<string, string> fields)
|
||||
{
|
||||
var guid = ReadGuid(fields, "purchaseOfferId", "PurchaseOfferId", "offerId", "OfferId", "id", "Id");
|
||||
if (guid is Guid publicId)
|
||||
return await db.RoomCurrencyPurchaseOffers.FirstOrDefaultAsync(o => o.PublicId == publicId && !o.IsDeleted);
|
||||
var localId = ReadLong(fields, "purchaseOfferInternalId", "internalId");
|
||||
if (localId is long id)
|
||||
return await db.RoomCurrencyPurchaseOffers.FirstOrDefaultAsync(o => o.Id == id && !o.IsDeleted);
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<long> GetBalanceAsync(long playerId, long currencyId) =>
|
||||
await db.RoomCurrencyBalances
|
||||
.Where(b => b.PlayerId == playerId && b.RoomCurrencyId == currencyId)
|
||||
.Select(b => b.Balance)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
private async Task<long> AddBalanceAsync(long playerId, long currencyId, long amount)
|
||||
{
|
||||
var row = await db.RoomCurrencyBalances
|
||||
.FirstOrDefaultAsync(b => b.PlayerId == playerId && b.RoomCurrencyId == currencyId);
|
||||
if (row is null)
|
||||
{
|
||||
row = new RoomCurrencyBalanceEntity { PlayerId = playerId, RoomCurrencyId = currencyId };
|
||||
db.RoomCurrencyBalances.Add(row);
|
||||
}
|
||||
|
||||
row.Balance = Math.Max(0, row.Balance + amount);
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
return row.Balance;
|
||||
}
|
||||
|
||||
private async Task<bool> CanManageRoomAsync(RoomEntity room, long playerId)
|
||||
{
|
||||
if (room.CreatorPlayerId == playerId) return true;
|
||||
if (await db.RoomRoles.AnyAsync(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Role == 0 && r.Accepted))
|
||||
return true;
|
||||
return await db.Players.Where(p => p.Id == playerId).Select(p => p.IsAdmin).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> ReadFieldsAsync()
|
||||
{
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in Request.Query)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var pair in form)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
else if ((Request.ContentLength ?? 0) > 0
|
||||
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
fields[prop.Name] = prop.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())),
|
||||
JsonValueKind.String => prop.Value.GetString() ?? string.Empty,
|
||||
_ => prop.Value.GetRawText(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static string? ReadString(Dictionary<string, string> fields, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static long? ReadLong(Dictionary<string, string> fields, params string[] names) =>
|
||||
long.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||
|
||||
private static int? ReadInt(Dictionary<string, string> fields, params string[] names) =>
|
||||
int.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||
|
||||
private static Guid? ReadGuid(Dictionary<string, string> fields, params string[] names) =>
|
||||
Guid.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||
|
||||
private static List<long> ReadLongList(Dictionary<string, string> fields, params string[] names) =>
|
||||
names.SelectMany(name => fields.TryGetValue(name, out var value)
|
||||
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
: Array.Empty<string>())
|
||||
.Select(v => long.TryParse(v, out var id) ? id : 0)
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(200)
|
||||
.ToList();
|
||||
|
||||
private static List<Guid> ReadGuidList(Dictionary<string, string> fields, params string[] names) =>
|
||||
names.SelectMany(name => fields.TryGetValue(name, out var value)
|
||||
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
: Array.Empty<string>())
|
||||
.Select(v => Guid.TryParse(v, out var id) ? id : Guid.Empty)
|
||||
.Where(id => id != Guid.Empty)
|
||||
.Distinct()
|
||||
.Take(200)
|
||||
.ToList();
|
||||
|
||||
private static string Trim(string? value, int max)
|
||||
{
|
||||
var s = (value ?? string.Empty).Trim();
|
||||
return s.Length <= max ? s : s[..max];
|
||||
}
|
||||
|
||||
private static object ToWire(RoomCurrencyEntity currency) => new
|
||||
{
|
||||
RoomCurrencyId = currency.PublicId,
|
||||
Id = currency.PublicId,
|
||||
InternalId = currency.Id,
|
||||
currency.RoomId,
|
||||
CreatorPlayerId = (int)currency.CreatorPlayerId,
|
||||
currency.Name,
|
||||
currency.Description,
|
||||
currency.ImageName,
|
||||
currency.DailyLimit,
|
||||
currency.CreatedAt,
|
||||
currency.UpdatedAt,
|
||||
};
|
||||
|
||||
private static object ToOfferWire(RoomCurrencyPurchaseOfferEntity offer, RoomCurrencyEntity currency) => new
|
||||
{
|
||||
PurchaseOfferId = offer.PublicId,
|
||||
RoomCurrencyPackageId = offer.PublicId,
|
||||
Id = offer.PublicId,
|
||||
InternalId = offer.Id,
|
||||
RoomCurrencyId = currency.PublicId,
|
||||
offer.Name,
|
||||
offer.Amount,
|
||||
offer.Price,
|
||||
offer.CurrencyType,
|
||||
offer.CreatedAt,
|
||||
offer.UpdatedAt,
|
||||
};
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.RoomEarningsDistributions;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class RoomEarningsDistributionsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/roomEarningsDistributions")]
|
||||
[HttpGet("api/roomEarningsDistributions/v1/earningsDistribution")]
|
||||
public async Task<IActionResult> EarningsDistribution([FromQuery] long? roomId = null)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var rooms = db.Rooms.Where(r => r.CreatorPlayerId == me);
|
||||
if (roomId is long rid && rid > 0)
|
||||
rooms = rooms.Where(r => r.Id == rid);
|
||||
|
||||
var roomRows = await rooms
|
||||
.Select(r => new { r.Id, r.Name })
|
||||
.ToListAsync();
|
||||
var roomIds = roomRows.Select(r => r.Id).ToList();
|
||||
var earnings = await db.RoomKeyPurchases
|
||||
.Join(db.RoomKeys, p => p.RoomKeyId, k => k.Id, (p, k) => new { p, k })
|
||||
.Where(x => roomIds.Contains(x.k.RoomId))
|
||||
.GroupBy(x => x.k.RoomId)
|
||||
.Select(g => new
|
||||
{
|
||||
RoomId = g.Key,
|
||||
Gross = g.Sum(x => x.p.PaidPrice),
|
||||
PurchaseCount = g.Count(),
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var byRoom = earnings.ToDictionary(e => e.RoomId);
|
||||
return Ok(roomRows.Select(room =>
|
||||
{
|
||||
byRoom.TryGetValue(room.Id, out var value);
|
||||
var gross = value?.Gross ?? 0;
|
||||
return new
|
||||
{
|
||||
RoomId = room.Id,
|
||||
room.Name,
|
||||
GrossEarnings = gross,
|
||||
NetEarnings = gross,
|
||||
PurchaseCount = value?.PurchaseCount ?? 0,
|
||||
CurrencyType = 2,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
}).ToList());
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,22 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("api/roomkeys")]
|
||||
[HttpGet("api/roomkeys/v1")]
|
||||
public async Task<IActionResult> List([FromQuery] long? roomId = null)
|
||||
{
|
||||
var rows = db.RoomKeys.Where(k => !k.IsDeleted);
|
||||
if (roomId is long id && id > 0)
|
||||
rows = rows.Where(k => k.RoomId == id);
|
||||
|
||||
var result = await rows
|
||||
.OrderBy(k => k.RoomId)
|
||||
.ThenBy(k => k.Price)
|
||||
.Take(200)
|
||||
.ToListAsync();
|
||||
return Ok(result.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpPost("api/roomkeys/v1/create")]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
@@ -132,6 +148,29 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
|
||||
return Content(owns ? "true" : "false", "application/json");
|
||||
}
|
||||
|
||||
[HttpGet("api/roomkeys/v1/owns")]
|
||||
public async Task<IActionResult> Owns([FromQuery] long roomKeyId)
|
||||
{
|
||||
var pid = Me;
|
||||
var owns = await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyId && p.PlayerId == pid);
|
||||
return Content(owns ? "true" : "false", "application/json");
|
||||
}
|
||||
|
||||
[HttpGet("api/roomkeys/v1/owns/bulk")]
|
||||
[HttpPost("api/roomkeys/v1/owns/bulk")]
|
||||
public async Task<IActionResult> OwnsBulk()
|
||||
{
|
||||
var ids = await ReadRoomKeyIdsAsync();
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
var pid = Me;
|
||||
var owned = await db.RoomKeyPurchases
|
||||
.Where(p => p.PlayerId == pid && ids.Contains(p.RoomKeyId))
|
||||
.Select(p => p.RoomKeyId)
|
||||
.ToListAsync();
|
||||
var ownedSet = owned.ToHashSet();
|
||||
return Ok(ids.Select(id => new { RoomKeyId = id, Owns = ownedSet.Contains(id) }).ToList());
|
||||
}
|
||||
|
||||
public static object RoomKeyResponse(RoomKeyStatus status, RoomKeyEntity? key) => new
|
||||
{
|
||||
Status = (int)status,
|
||||
@@ -173,6 +212,25 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
private async Task<List<long>> ReadRoomKeyIdsAsync()
|
||||
{
|
||||
var ids = new List<long>();
|
||||
foreach (var value in Request.Query.SelectMany(q => q.Value))
|
||||
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in new[] { "roomKeyIds", "RoomKeyIds", "ids", "Ids" })
|
||||
foreach (var value in form[key])
|
||||
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
|
||||
}
|
||||
|
||||
return ids.Distinct().Take(200).ToList();
|
||||
}
|
||||
|
||||
private async Task<T> ReadBodyAsync<T>() where T : new()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
|
||||
@@ -48,12 +48,121 @@ public class RoomsController(
|
||||
/// TagsCsv column (e.g. `#community`, `#recroomoriginal`).
|
||||
/// </summary>
|
||||
[HttpGet("api/rooms/v1/hot")]
|
||||
[HttpGet("rooms/hot")]
|
||||
public async Task<IActionResult> Hot(
|
||||
public async Task<IActionResult> HotV1(
|
||||
[FromQuery] string? roomScoreType,
|
||||
[FromQuery] string? tags)
|
||||
=> Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList());
|
||||
|
||||
[HttpGet("rooms/hot")]
|
||||
public async Task<IActionResult> HotRoomServer(
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery] string? tags,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 100)
|
||||
{
|
||||
var all = await rooms.HotAsync(tag ?? tags, take: 200);
|
||||
var page = all
|
||||
.Skip(Math.Max(skip, 0))
|
||||
.Take(Math.Clamp(take, 1, 100))
|
||||
.Select(RoomService.ToWireRoom)
|
||||
.ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Results = page,
|
||||
TotalResults = all.Count,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("rooms/topcreators")]
|
||||
[HttpGet("roomserver/rooms/topcreators")]
|
||||
public async Task<IActionResult> TopCreators([FromQuery] int skip = 0, [FromQuery] int take = 100)
|
||||
{
|
||||
var rows = await PublicRoomQuery()
|
||||
.OrderByDescending(r => r.HotScore)
|
||||
.ThenByDescending(r => r.VisitCount)
|
||||
.Take(250)
|
||||
.ToListAsync();
|
||||
var page = rows
|
||||
.GroupBy(r => r.CreatorPlayerId)
|
||||
.Select(g => g.First())
|
||||
.Skip(Math.Max(skip, 0))
|
||||
.Take(Math.Clamp(take, 1, 100))
|
||||
.ToList();
|
||||
return Ok(await BuildRoomServerListAsync(page));
|
||||
}
|
||||
|
||||
[HttpGet("rooms/contestwinners")]
|
||||
[HttpGet("roomserver/rooms/contestwinners")]
|
||||
public async Task<IActionResult> ContestWinners([FromQuery] int skip = 0, [FromQuery] int take = 100)
|
||||
{
|
||||
var rows = await PublicRoomQuery()
|
||||
.Where(r =>
|
||||
EF.Functions.Like(r.TagsCsv, "%contest%") ||
|
||||
EF.Functions.Like(r.TagsCsv, "%winner%") ||
|
||||
EF.Functions.Like(r.TagsCsv, "%featured%"))
|
||||
.OrderByDescending(r => r.HotScore)
|
||||
.ThenBy(r => r.Name)
|
||||
.Skip(Math.Max(skip, 0))
|
||||
.Take(Math.Clamp(take, 1, 100))
|
||||
.ToListAsync();
|
||||
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
rows = await PublicRoomQuery()
|
||||
.OrderByDescending(r => r.HotScore)
|
||||
.ThenBy(r => r.Name)
|
||||
.Skip(Math.Max(skip, 0))
|
||||
.Take(Math.Clamp(take, 1, 100))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
return Ok(await BuildRoomServerListAsync(rows));
|
||||
}
|
||||
|
||||
[HttpGet("rooms/fromcreators")]
|
||||
[HttpPost("rooms/fromcreators")]
|
||||
[HttpGet("roomserver/rooms/fromcreators")]
|
||||
[HttpPost("roomserver/rooms/fromcreators")]
|
||||
public async Task<IActionResult> FromCreators([FromQuery] int skip = 0, [FromQuery] int take = 100)
|
||||
{
|
||||
var creatorIds = Request.Query["creatorId"]
|
||||
.Concat(Request.Query["creatorIds"])
|
||||
.Concat(Request.Query["accountId"])
|
||||
.Concat(Request.Query["accountIds"])
|
||||
.Concat(Request.Query["id"])
|
||||
.SelectMany(v => (v ?? string.Empty).Split(',',
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Select(v => long.TryParse(v, out var id) ? id : 0)
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var query = PublicRoomQuery();
|
||||
if (creatorIds.Count > 0)
|
||||
query = query.Where(r => creatorIds.Contains(r.CreatorPlayerId));
|
||||
|
||||
var rows = await query
|
||||
.OrderByDescending(r => r.HotScore)
|
||||
.ThenByDescending(r => r.UpdatedAt)
|
||||
.Skip(Math.Max(skip, 0))
|
||||
.Take(Math.Clamp(take, 1, 100))
|
||||
.ToListAsync();
|
||||
return Ok(await BuildRoomServerListAsync(rows));
|
||||
}
|
||||
|
||||
[HttpGet("rooms/magic_door")]
|
||||
[HttpGet("roomserver/rooms/magic_door")]
|
||||
public async Task<IActionResult> MagicDoor()
|
||||
{
|
||||
var room = await PublicRoomQuery()
|
||||
.OrderByDescending(r => r.HotScore)
|
||||
.ThenBy(r => r.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (room is null) return NotFound();
|
||||
return Ok((await BuildRoomServerListAsync(new[] { room })).First());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET <c>/roomserver/rooms/recommendations</c> — the home-tab
|
||||
/// "Recommended" carousel. <b>Wire shape is NOT a flat
|
||||
@@ -107,6 +216,38 @@ public class RoomsController(
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("rooms/{roomId:long}/similar")]
|
||||
[HttpGet("roomserver/rooms/{roomId:long}/similar")]
|
||||
public async Task<IActionResult> SimilarRooms(long roomId, [FromQuery] int take = 12)
|
||||
{
|
||||
var seed = await rooms.GetByIdAsync(roomId);
|
||||
if (seed is null) return NotFound();
|
||||
|
||||
var tags = (seed.TagsCsv ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToArray();
|
||||
var query = PublicRoomQuery().Where(r => r.Id != roomId);
|
||||
if (tags.Length > 0)
|
||||
{
|
||||
var primaryTag = tags[0];
|
||||
query = query.Where(r => EF.Functions.Like(r.TagsCsv, $"%{primaryTag}%"));
|
||||
}
|
||||
|
||||
var similar = await query
|
||||
.OrderByDescending(r => r.HotScore)
|
||||
.ThenBy(r => r.Name)
|
||||
.Take(Math.Clamp(take, 1, 100))
|
||||
.ToListAsync();
|
||||
|
||||
var seedWire = (await BuildRoomServerListAsync(new[] { seed })).First();
|
||||
var similarWire = await BuildRoomServerListAsync(similar);
|
||||
return Ok(new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["SeedRoom"] = seedWire,
|
||||
["Rooms"] = similarWire,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// `Rooms.SearchForRooms(query)` — free-text room search.
|
||||
/// </summary>
|
||||
@@ -152,6 +293,7 @@ public class RoomsController(
|
||||
{
|
||||
PinnedFilters = tags.PinnedTags,
|
||||
PopularFilters = tags.PopularTags,
|
||||
TrendingFilters = tags.TrendingTags,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -159,7 +301,11 @@ public class RoomsController(
|
||||
public async Task<IActionResult> Tags()
|
||||
{
|
||||
var tags = await serverSettings.GetPlayMenuTagsAsync();
|
||||
return Ok(tags.PinnedTags.Concat(tags.PopularTags).Distinct(StringComparer.OrdinalIgnoreCase).ToArray());
|
||||
return Ok(tags.PinnedTags
|
||||
.Concat(tags.PopularTags)
|
||||
.Concat(tags.TrendingTags)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
[HttpGet("api/rooms/v1/pinnedtags")]
|
||||
@@ -170,6 +316,10 @@ public class RoomsController(
|
||||
public async Task<IActionResult> PopularTags()
|
||||
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).PopularTags);
|
||||
|
||||
[HttpGet("api/rooms/v1/trendingtags")]
|
||||
public async Task<IActionResult> TrendingTags()
|
||||
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).TrendingTags);
|
||||
|
||||
// ── Single room lookups ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -187,17 +337,39 @@ public class RoomsController(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// `Rooms.GetById(roomId)` — same shape as ByName.
|
||||
/// `Rooms.GetById(roomId)` — same flat shape as ByName.
|
||||
/// </summary>
|
||||
[HttpGet("api/rooms/v2/{roomId:long}")]
|
||||
[HttpGet("api/rooms/v3/{roomId:long}")]
|
||||
[HttpGet("rooms/{roomId:long}")]
|
||||
public async Task<IActionResult> ById(long roomId)
|
||||
{
|
||||
var r = await rooms.GetByIdAsync(roomId);
|
||||
return Ok(RoomService.ToWireRoom(r ?? Synthetic($"Room_{roomId}", roomId)));
|
||||
}
|
||||
|
||||
[HttpGet("rooms/{roomId:long}")]
|
||||
public async Task<IActionResult> RoomServerById(
|
||||
long roomId,
|
||||
[FromQuery] int? unityAssetTarget,
|
||||
[FromQuery] int? unityAssetVersion)
|
||||
{
|
||||
var room = await rooms.GetByIdAsync(roomId) ?? Synthetic($"Room_{roomId}", roomId);
|
||||
var sceneRows = await db.RoomScenes
|
||||
.Where(s => s.RoomId == room.Id)
|
||||
.OrderBy(s => s.OrderIndex)
|
||||
.ToListAsync();
|
||||
var roles = await db.RoomRoles
|
||||
.Where(r => r.RoomId == room.Id)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(BuildRoomServerDetails(
|
||||
room,
|
||||
sceneRows,
|
||||
roles: roles,
|
||||
unityAssetTarget: unityAssetTarget,
|
||||
unityAssetVersion: unityAssetVersion));
|
||||
}
|
||||
|
||||
// ── RoomDetails (the boot-critical one) ─────────────────────────────
|
||||
//
|
||||
// /v4/details/{roomId} is hit *during* the matchmaking goto flow.
|
||||
@@ -278,6 +450,270 @@ public class RoomsController(
|
||||
return Ok(blobs);
|
||||
}
|
||||
|
||||
/// <summary>GET <c>rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId}</c>
|
||||
/// — Studio room save metadata. The 2023 client can load baked Studio
|
||||
/// scenes by first asking this endpoint which Unity asset bundle file
|
||||
/// belongs to the save, then downloading it from
|
||||
/// <c>cdn.../room/{Filename}.assetbundle</c>. The importer persists
|
||||
/// the exact bundle filenames from the Studio dump on RoomScenes, so
|
||||
/// this response is backed by the imported archive bytes rather than a
|
||||
/// placeholder.</summary>
|
||||
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/saves/{saveId:long}")]
|
||||
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/saves/{saveId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> StudioSubRoomSave(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
long saveId,
|
||||
[FromQuery] int? unityAssetTarget,
|
||||
[FromQuery] int? unityAssetVersion)
|
||||
{
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||
|
||||
var sceneRows = await db.RoomScenes
|
||||
.Where(s => s.RoomId == roomId)
|
||||
.OrderBy(s => s.OrderIndex)
|
||||
.ToListAsync();
|
||||
var scene = sceneRows.FirstOrDefault(s => s.OrderIndex == (int)subRoomId)
|
||||
?? sceneRows.FirstOrDefault(s => s.StudioSubRoomDataSaveId == saveId);
|
||||
if (scene is null) return NotFound(new { error = "subroom_not_found", roomId, subRoomId });
|
||||
|
||||
return Ok(BuildStudioSaveWire(room, scene, subRoomId, saveId, unityAssetTarget, unityAssetVersion));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>rooms/{roomId}/subrooms</c> — the subroom list the
|
||||
/// 2023 client / Studio editor fetches
|
||||
/// (<c>String.Format("rooms/{0}/subrooms", roomId)</c>). Emits the same
|
||||
/// per-subroom shape as the <c>SubRooms[]</c> entries inside
|
||||
/// <c>/roomserver/rooms/{id}</c>, so a single-scene room synthesises one
|
||||
/// "Home" entry and a multi-scene imported room enumerates its
|
||||
/// RoomScenes.</summary>
|
||||
[HttpGet("rooms/{roomId:long}/subrooms")]
|
||||
[HttpGet("roomserver/rooms/{roomId:long}/subrooms")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SubRooms(long roomId)
|
||||
{
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||
|
||||
var sceneRows = await db.RoomScenes
|
||||
.Where(s => s.RoomId == roomId)
|
||||
.OrderBy(s => s.OrderIndex)
|
||||
.ToListAsync();
|
||||
|
||||
var wire = sceneRows.Count > 0
|
||||
? sceneRows.Select(s => BuildSubRoomWire(room, s)).ToArray()
|
||||
: new[] { BuildSubRoomWire(room, null) };
|
||||
return Ok(wire);
|
||||
}
|
||||
|
||||
/// <summary>GET <c>rooms/{roomId}/subrooms/{subRoomId}</c> — one subroom
|
||||
/// (<c>String.Format("rooms/{0}/subrooms/{1}", roomId, subRoomId)</c>).
|
||||
/// <paramref name="subRoomId"/> is the RoomScene OrderIndex.</summary>
|
||||
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
|
||||
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SubRoom(long roomId, long subRoomId)
|
||||
{
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||
|
||||
var scene = await db.RoomScenes
|
||||
.Where(s => s.RoomId == roomId && s.OrderIndex == (int)subRoomId)
|
||||
.FirstOrDefaultAsync();
|
||||
// A single-scene room has no RoomScene row; subRoom 0 is the
|
||||
// synthesised "Home" scene.
|
||||
if (scene is null && subRoomId != 0)
|
||||
return NotFound(new { error = "subroom_not_found", roomId, subRoomId });
|
||||
|
||||
return Ok(BuildSubRoomWire(room, scene));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>rooms/{roomId}/subrooms/{subRoomId}/data</c> — the
|
||||
/// current persisted save for the subroom
|
||||
/// (<c>String.Format("rooms/{0}/subrooms/{1}/data", roomId, subRoomId)</c>).
|
||||
/// Returns the same <c>SubRoomDataSave</c> shape as
|
||||
/// <c>/saves/{saveId}</c> resolved to the scene's current save id.</summary>
|
||||
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
|
||||
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SubRoomData(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
[FromQuery] int? unityAssetTarget,
|
||||
[FromQuery] int? unityAssetVersion)
|
||||
{
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||
|
||||
var scene = await db.RoomScenes
|
||||
.Where(s => s.RoomId == roomId && s.OrderIndex == (int)subRoomId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (scene is null && subRoomId != 0)
|
||||
return NotFound(new { error = "subroom_not_found", roomId, subRoomId });
|
||||
|
||||
var saveId = scene?.StudioSubRoomDataSaveId ?? 0L;
|
||||
return Ok(BuildStudioSaveWire(room, scene, subRoomId, saveId, unityAssetTarget, unityAssetVersion));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>rooms/{roomId}/subrooms/{subRoomId}/saves</c> — the
|
||||
/// list of saves for a subroom
|
||||
/// (<c>String.Format("rooms/{0}/subrooms/{1}/saves", roomId, subRoomId)</c>).
|
||||
/// Returns the current save plus any historical blobs recorded for the
|
||||
/// subroom, newest first, each in the <c>SubRoomDataSave</c> shape.</summary>
|
||||
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/saves")]
|
||||
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/saves")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SubRoomSaves(long roomId, long subRoomId)
|
||||
{
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||
|
||||
var scene = await db.RoomScenes
|
||||
.Where(s => s.RoomId == roomId && s.OrderIndex == (int)subRoomId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (scene is null && subRoomId != 0)
|
||||
return NotFound(new { error = "subroom_not_found", roomId, subRoomId });
|
||||
|
||||
var currentSaveId = scene?.StudioSubRoomDataSaveId ?? 0L;
|
||||
var saves = new List<object>
|
||||
{
|
||||
BuildStudioSaveWire(room, scene, subRoomId, currentSaveId, null, null),
|
||||
};
|
||||
|
||||
// Historical blobs for this subroom (the same rows the datahistory
|
||||
// endpoint surfaces), surfaced as prior saves so the Studio editor's
|
||||
// version picker has the full set. Distinct by blob name, current
|
||||
// save excluded (already first in the list).
|
||||
var history = await db.RoomDataBlobs
|
||||
.Where(b => b.RoomId == roomId && b.SubRoomId == subRoomId)
|
||||
.OrderByDescending(b => b.UploadedAt)
|
||||
.Take(50)
|
||||
.ToListAsync();
|
||||
foreach (var h in history)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(scene?.DataBlobName) &&
|
||||
string.Equals(h.BlobName, scene!.DataBlobName, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
saves.Add(new
|
||||
{
|
||||
SubRoomDataSaveId = currentSaveId,
|
||||
SubRoomId = subRoomId,
|
||||
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||
ReferencedUnityAssetIds = Array.Empty<string>(),
|
||||
DataBlob = h.BlobName,
|
||||
DataBlobName = h.BlobName,
|
||||
PersistenceVersion = 41,
|
||||
OMVersion = 0,
|
||||
SavedByAccountId = h.UploadedByPlayerId,
|
||||
SavedOnPlatform = 7,
|
||||
SavedOnDeviceClass = 2,
|
||||
SavedAt = (h.UploadedAt == default ? DateTime.UtcNow : h.UploadedAt)
|
||||
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(saves);
|
||||
}
|
||||
|
||||
/// <summary>Build one subroom entry in the wire shape the watch's
|
||||
/// <c>SubRooms[]</c> array uses (see <c>BuildRoomServerDetails</c>). A
|
||||
/// null <paramref name="scene"/> yields the synthesised "Home" scene for
|
||||
/// single-scene rooms.</summary>
|
||||
private object BuildSubRoomWire(RoomEntity room, RoomSceneEntity? scene)
|
||||
{
|
||||
var fallback = CurrentOrSyntheticDataBlobName(room);
|
||||
var orderIndex = scene?.OrderIndex ?? 0;
|
||||
var dataBlob = scene is null
|
||||
? fallback
|
||||
: SceneOrSyntheticDataBlobName(room, scene.DataBlobName, fallback);
|
||||
var modified = scene?.DataModifiedAt ?? room.UpdatedAt;
|
||||
return new
|
||||
{
|
||||
SubRoomId = (long)orderIndex,
|
||||
RoomId = room.Id,
|
||||
Name = scene?.Name ?? "Home",
|
||||
DataBlob = dataBlob,
|
||||
DataSavedAt = (modified == default ? DateTime.UtcNow : modified)
|
||||
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
IsSandbox = scene?.IsSandbox ?? false,
|
||||
MaxPlayers = scene?.MaxPlayers ?? 8,
|
||||
Accessibility = room.Accessibility,
|
||||
UnitySceneId = scene?.RoomSceneLocationId ?? room.LocationReplicationId,
|
||||
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||
CurrentSave = new
|
||||
{
|
||||
SubRoomDataSaveId = scene?.StudioSubRoomDataSaveId ?? 0L,
|
||||
SubRoomId = (long)orderIndex,
|
||||
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||
ReferencedUnityAssetIds = Array.Empty<string>(),
|
||||
DataBlob = dataBlob,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Build the <c>SubRoomDataSave</c> wire object for a Studio
|
||||
/// save — shared by <c>/saves/{saveId}</c>, <c>/data</c>, and the first
|
||||
/// entry of <c>/saves</c>. A null <paramref name="scene"/> (single-scene
|
||||
/// room) returns a save with no baked bundles, pointing at the room's
|
||||
/// current data blob.</summary>
|
||||
private object BuildStudioSaveWire(
|
||||
RoomEntity room,
|
||||
RoomSceneEntity? scene,
|
||||
long subRoomId,
|
||||
long saveId,
|
||||
int? unityAssetTarget,
|
||||
int? unityAssetVersion)
|
||||
{
|
||||
var bundles = StudioBundlesForScene(scene, saveId);
|
||||
|
||||
if (bundles.Count == 0 && scene is not null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"[rooms-studio-save] no asset bundles room={RoomId} subRoom={SubRoomId} save={SaveId} scene={Scene} csvLen={CsvLen}",
|
||||
room.Id, subRoomId, saveId, scene.Name, scene.StudioAssetBundleNamesCsv?.Length ?? 0);
|
||||
}
|
||||
|
||||
var unityAssetId = scene?.StudioUnityAssetId ?? string.Empty;
|
||||
var bakedAssets = BuildStudioBakedAssets(room, scene, saveId, unityAssetTarget, unityAssetVersion);
|
||||
var primary = bakedAssets.FirstOrDefault();
|
||||
var unityAsset = BuildStudioUnityAsset(room, scene, bakedAssets);
|
||||
var dataBlob = !string.IsNullOrWhiteSpace(scene?.DataBlobName)
|
||||
? scene!.DataBlobName
|
||||
: CurrentOrSyntheticDataBlobName(room);
|
||||
|
||||
logger.LogInformation(
|
||||
"[rooms-studio-save] room={RoomId} subRoom={SubRoomId} save={SaveId} scene={Scene} target={Target} version={Version} primary={Primary} bundles={BundleCount}",
|
||||
room.Id, subRoomId, saveId, scene?.Name ?? "Home", unityAssetTarget, unityAssetVersion,
|
||||
primary?.Filename ?? string.Empty, bundles.Count);
|
||||
|
||||
return new
|
||||
{
|
||||
SubRoomDataSaveId = saveId,
|
||||
SubRoomId = subRoomId,
|
||||
UnityAssetId = unityAssetId,
|
||||
SubRoomUnityAssetId = unityAssetId,
|
||||
ReferencedUnityAssetIds = Array.Empty<string>(),
|
||||
DataBlob = dataBlob,
|
||||
DataBlobName = dataBlob,
|
||||
PersistenceVersion = 41,
|
||||
OMVersion = 0,
|
||||
SavedByAccountId = room.CreatorPlayerId,
|
||||
SavedOnPlatform = 7,
|
||||
SavedOnDeviceClass = 2,
|
||||
CreatedByAccountId = room.CreatorPlayerId,
|
||||
UnityAssetHash = string.Empty,
|
||||
UnityAsset = unityAsset,
|
||||
UnityAssetFilename = primary?.Filename ?? string.Empty,
|
||||
BakedUnityAssets = bakedAssets,
|
||||
UnitySubAssets = Array.Empty<object>(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>POST <c>roomserver/rooms/{roomId}/subrooms/{subRoomId}/restoredata</c>
|
||||
/// — the watch's "Go" button in the Restore Room dialog. Body is
|
||||
/// form-urlencoded with a single <c>filename</c> field carrying the
|
||||
@@ -615,7 +1051,11 @@ public class RoomsController(
|
||||
EncryptVoiceChat = currentPresence.EncryptVoiceChat,
|
||||
},
|
||||
isOnline = true,
|
||||
appVersion = 20201210,
|
||||
// 2023.03.21 client build version (baked "20230317"). MUST match
|
||||
// the joining client's own version or it reports a version
|
||||
// mismatch and refuses to join. Was a stale December-2020 value
|
||||
// (20201210) carried over when this branch forked.
|
||||
appVersion = 20230317,
|
||||
};
|
||||
await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdatePresence, presencePayload);
|
||||
}
|
||||
@@ -1082,10 +1522,19 @@ public class RoomsController(
|
||||
SupportsWalkVR = src.SupportsWalkVR,
|
||||
SupportsTeleportVR = src.SupportsTeleportVR,
|
||||
AllowsJuniors = src.AllowsJuniors,
|
||||
AllowNewUsers = src.AllowNewUsers,
|
||||
MinLevel = src.MinLevel,
|
||||
MaxPlayerCalculationMode = src.MaxPlayerCalculationMode,
|
||||
LoadScreensJson = src.LoadScreensJson,
|
||||
PromoImagesJson = src.PromoImagesJson,
|
||||
PromoExternalContentJson = src.PromoExternalContentJson,
|
||||
RoomWarningMask = src.RoomWarningMask,
|
||||
CustomRoomWarning = src.CustomRoomWarning,
|
||||
DisableMicAutoMute = src.DisableMicAutoMute,
|
||||
LocationReplicationId = src.LocationReplicationId,
|
||||
IsStudioRoom = src.IsStudioRoom,
|
||||
IsRoomLinkedToRecRoomStudio = src.IsRoomLinkedToRecRoomStudio,
|
||||
StudioSessionId = src.StudioSessionId,
|
||||
TagsCsv = src.TagsCsv,
|
||||
CheerCount = src.CheerCount,
|
||||
FavoriteCount = src.FavoriteCount,
|
||||
@@ -1119,7 +1568,12 @@ public class RoomsController(
|
||||
? Array.Empty<object>()
|
||||
: room.TagsCsv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(t => (object)new { Type = 0, Tag = t })
|
||||
.Select(t => (object)new
|
||||
{
|
||||
Type = RoomTagTypeForWire(t),
|
||||
Tag = t,
|
||||
IsPrimaryGenre = false,
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
// Scenes population: every room must produce a Scenes[0] entry
|
||||
@@ -1150,32 +1604,14 @@ public class RoomsController(
|
||||
// no DeserializeRoomDataBlobCoroutine, the master init
|
||||
// proceeds with default-zero permissions.
|
||||
//
|
||||
// • Saved dorm / customisable rooms: DataBlobName is the
|
||||
// latest uploaded blob name (RoomEntity.CurrentDataBlobName,
|
||||
// written by StorageController on /upload). Unsaved dorms
|
||||
// deliberately use "" so the baked DormRoom scene loads
|
||||
// without deserializing the synthetic all-perms blob. The
|
||||
// role-only default blob made December wait forever on
|
||||
// StandaloneShapeContainer big-data during first-dorm entry.
|
||||
// • AG-Original rooms: DataBlobName="" → completed-promise
|
||||
// short-circuit → no download → no leaked stale-blob
|
||||
// behaviour → master flow proceeds → spawn fires. Maker
|
||||
// Pen stays locked, which matches public-server semantics
|
||||
// (you can't edit the rec center).
|
||||
//
|
||||
// Heuristic: "customisable" iff it's the dorm OR a user-created
|
||||
// room (CreatorPlayerId != 1, the seeded system account that
|
||||
// owns AG-Originals like the rec center). User clones inherit
|
||||
// the all-perms default blob until their first save replaces
|
||||
// it via StorageController; the rec center / paintball / etc.
|
||||
// serve "" so the persistence flow short-circuits.
|
||||
// If a room has a saved blob, use it. Otherwise point at
|
||||
// a synthetic CDN key, which misses S3 and is served by
|
||||
// RoomDataBlobService.GetDefaultBlob. That default is a captured
|
||||
// PersistedRoomData (v38); the older synthetic role-only blob and
|
||||
// empty DataBlobName path both leave the 2023 room-permissions
|
||||
// runtime without data it dereferences during spawn.
|
||||
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
|
||||
var editableRro = room.IsAGRoom && roleList.Any(r => r.Accepted);
|
||||
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro;
|
||||
var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName)
|
||||
? room.CurrentDataBlobName
|
||||
: room.IsDormRoom ? ""
|
||||
: customisable ? $"room_{room.Id}_v1.dat" : "";
|
||||
var dataBlobName = CurrentOrSyntheticDataBlobName(room);
|
||||
|
||||
// Multi-scene rooms (imported via tools/import-room.ps1) carry one
|
||||
// RoomSceneEntity row per sub-room. Emit the full Scenes[] array
|
||||
@@ -1197,8 +1633,7 @@ public class RoomsController(
|
||||
DataBlobName = !string.IsNullOrWhiteSpace(overrideDataBlobName) &&
|
||||
(overrideSceneId == s.OrderIndex || sceneRows.Count == 1)
|
||||
? overrideDataBlobName
|
||||
: room.IsDormRoom ? dataBlobName
|
||||
: s.DataBlobName,
|
||||
: SceneOrSyntheticDataBlobName(room, s.DataBlobName, dataBlobName),
|
||||
MaxPlayers = s.MaxPlayers,
|
||||
CanMatchmakeInto = s.CanMatchmakeInto,
|
||||
DataModifiedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
|
||||
@@ -1285,78 +1720,150 @@ public class RoomsController(
|
||||
RoomEntity room,
|
||||
IReadOnlyList<RoomSceneEntity>? sceneRows = null,
|
||||
string? overrideDataBlobName = null,
|
||||
IReadOnlyList<RoomRoleEntity>? roles = null)
|
||||
IReadOnlyList<RoomRoleEntity>? roles = null,
|
||||
int? unityAssetTarget = null,
|
||||
int? unityAssetVersion = null)
|
||||
{
|
||||
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
|
||||
var editableRro = room.IsAGRoom && roleList.Any(r => r.Accepted);
|
||||
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro;
|
||||
var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName)
|
||||
? room.CurrentDataBlobName
|
||||
: room.IsDormRoom ? ""
|
||||
: customisable ? $"room_{room.Id}_v1.dat" : "";
|
||||
// 2023's room-permissions runtime needs a real PersistedRoomData
|
||||
// source during spawn. If no saved blob exists, use the captured
|
||||
// default via the synthetic CDN path.
|
||||
var dataBlobName = CurrentOrSyntheticDataBlobName(room);
|
||||
var updatedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt)
|
||||
.ToString("yyyy-MM-ddTHH:mm:ssZ");
|
||||
|
||||
object[] subRooms = sceneRows is { Count: > 0 }
|
||||
? sceneRows.Select(s => (object)new
|
||||
? sceneRows.Select(s =>
|
||||
{
|
||||
SubRoomId = (long)s.OrderIndex,
|
||||
RoomId = room.Id,
|
||||
Name = s.Name,
|
||||
DataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1
|
||||
var dataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1
|
||||
? overrideDataBlobName
|
||||
: room.IsDormRoom ? dataBlobName
|
||||
: s.DataBlobName,
|
||||
DataSavedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
|
||||
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
IsSandbox = s.IsSandbox,
|
||||
MaxPlayers = s.MaxPlayers,
|
||||
Accessibility = room.Accessibility,
|
||||
UnitySceneId = s.RoomSceneLocationId,
|
||||
: SceneOrSyntheticDataBlobName(room, s.DataBlobName, dataBlobName);
|
||||
// A scene with a baked Studio asset (non-empty UnityAssetId)
|
||||
// must advertise a Studio-era PersistenceVersion on its
|
||||
// CurrentSave. The 2023 client gates the baked-Addressables
|
||||
// load path on this: a version of 0 (the old default) makes
|
||||
// it treat the save as a pre-baked legacy blob and it never
|
||||
// fetches unity_assets/{id}/{target}/{version}, so the
|
||||
// persistence views reference prefabs that were never loaded
|
||||
// ("Expected a prefab, but found none"). 41 matches the real
|
||||
// RecRocks export (room.json PersistenceVersion=41).
|
||||
var baked = !string.IsNullOrWhiteSpace(s.StudioUnityAssetId);
|
||||
var saveVersion = baked ? StudioPersistenceVersion : 0;
|
||||
var bakedAssets = BuildStudioBakedAssets(
|
||||
room,
|
||||
s,
|
||||
s.StudioSubRoomDataSaveId ?? 0L,
|
||||
unityAssetTarget,
|
||||
unityAssetVersion);
|
||||
var unityAsset = bakedAssets.FirstOrDefault();
|
||||
var unityAssetParent = BuildStudioUnityAsset(room, s, bakedAssets);
|
||||
return (object)new
|
||||
{
|
||||
ReplicationId = s.RoomSceneLocationId,
|
||||
PersistenceVersion = saveVersion,
|
||||
SupportsJoinInProgress = true,
|
||||
UseLevelBasedMatchmaking = false,
|
||||
UseAgeBasedMatchmaking = false,
|
||||
UseRecRoyaleMatchmaking = false,
|
||||
SubRoomId = (long)s.OrderIndex,
|
||||
RoomId = room.Id,
|
||||
Name = s.Name,
|
||||
UnityAssetId = s.StudioUnityAssetId,
|
||||
SubRoomUnityAssetId = s.StudioUnityAssetId,
|
||||
UnityAsset = unityAsset?.Filename ?? string.Empty,
|
||||
UnityAssetHash = string.Empty,
|
||||
DataBlob = dataBlob,
|
||||
DataBlobHash = (string?)null,
|
||||
DataSavedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
|
||||
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
IsSandbox = s.IsSandbox,
|
||||
MaxPlayers = s.MaxPlayers,
|
||||
Accessibility = room.Accessibility,
|
||||
UnitySceneId = s.RoomSceneLocationId,
|
||||
CurrentSave = new
|
||||
{
|
||||
SubRoomDataSaveId = s.StudioSubRoomDataSaveId ?? 0L,
|
||||
SubRoomId = (long)s.OrderIndex,
|
||||
UnityAssetId = s.StudioUnityAssetId,
|
||||
SubRoomUnityAssetId = s.StudioUnityAssetId,
|
||||
CreatedByAccountId = room.CreatorPlayerId,
|
||||
ReferencedUnityAssetIds = Array.Empty<string>(),
|
||||
DataBlob = dataBlob,
|
||||
DataBlobHash = (string?)null,
|
||||
PersistenceVersion = saveVersion,
|
||||
OMVersion = 0,
|
||||
SavedByAccountId = room.CreatorPlayerId,
|
||||
SavedOnPlatform = 0,
|
||||
SavedOnDeviceClass = 2,
|
||||
Description = string.Empty,
|
||||
ModerationState = 0,
|
||||
CreatedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
|
||||
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
UgcSubVersion = saveVersion,
|
||||
UnityAssetHash = string.Empty,
|
||||
UnityAsset = unityAssetParent,
|
||||
UnityAssetFilename = unityAsset?.Filename ?? string.Empty,
|
||||
BakedUnityAssets = bakedAssets,
|
||||
UnitySubAssets = Array.Empty<object>(),
|
||||
},
|
||||
LastModeratedSaveModerationState = 0,
|
||||
DefaultMatchmakingPolicy = 0,
|
||||
ShouldAutoStageSaves = true,
|
||||
StagedSubRoomDataSaveId = (long?)null,
|
||||
};
|
||||
}).ToArray()
|
||||
: new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
ReplicationId = room.LocationReplicationId,
|
||||
PersistenceVersion = 0,
|
||||
SupportsJoinInProgress = true,
|
||||
UseLevelBasedMatchmaking = false,
|
||||
UseAgeBasedMatchmaking = false,
|
||||
UseRecRoyaleMatchmaking = false,
|
||||
SubRoomId = 0L,
|
||||
RoomId = room.Id,
|
||||
Name = "Home",
|
||||
UnityAssetId = string.Empty,
|
||||
SubRoomUnityAssetId = string.Empty,
|
||||
UnityAsset = string.Empty,
|
||||
UnityAssetHash = string.Empty,
|
||||
DataBlob = dataBlobName,
|
||||
DataBlobHash = (string?)null,
|
||||
DataSavedAt = updatedAt,
|
||||
IsSandbox = false,
|
||||
MaxPlayers = 8,
|
||||
Accessibility = room.Accessibility,
|
||||
UnitySceneId = room.LocationReplicationId,
|
||||
CurrentSave = new
|
||||
{
|
||||
SubRoomDataSaveId = 0L,
|
||||
SubRoomId = 0L,
|
||||
UnityAssetId = string.Empty,
|
||||
SubRoomUnityAssetId = string.Empty,
|
||||
ReferencedUnityAssetIds = Array.Empty<string>(),
|
||||
DataBlob = dataBlobName,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
static int WireRole(int role) => role switch
|
||||
{
|
||||
0 => 30, // CoOwner
|
||||
1 => 20, // Moderator
|
||||
2 => 10, // Host
|
||||
_ => 0,
|
||||
};
|
||||
var wireRoles = new List<object>
|
||||
{
|
||||
new { AccountId = room.CreatorPlayerId, Role = 255, InvitedRole = 0 },
|
||||
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
|
||||
};
|
||||
wireRoles.AddRange(roleList.Select(r =>
|
||||
{
|
||||
var role = WireRole(r.Role);
|
||||
return (object)new
|
||||
{
|
||||
AccountId = r.PlayerId,
|
||||
Role = r.Accepted ? role : 0,
|
||||
InvitedRole = r.Accepted ? 0 : role,
|
||||
};
|
||||
}));
|
||||
wireRoles.AddRange(roleList.Select(BuildRoomRoleGrantWire));
|
||||
|
||||
var tags = string.IsNullOrEmpty(room.TagsCsv)
|
||||
? Array.Empty<object>()
|
||||
: room.TagsCsv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(t => (object)new { Type = 0, Tag = t })
|
||||
.Select(t => (object)new
|
||||
{
|
||||
Type = RoomTagTypeForWire(t),
|
||||
Tag = t,
|
||||
IsPrimaryGenre = false,
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
return new
|
||||
@@ -1379,6 +1886,9 @@ public class RoomsController(
|
||||
SupportsQuest2 = true,
|
||||
SupportsMobile = room.SupportsMobile,
|
||||
SupportsJuniors = room.AllowsJuniors,
|
||||
AllowNewUsers = room.AllowNewUsers,
|
||||
AllowsNewUsers = room.AllowNewUsers,
|
||||
MinLevel = room.MinLevel,
|
||||
CreatedAt = (room.CreatedAt == default ? DateTime.UtcNow : room.CreatedAt)
|
||||
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
Stats = new
|
||||
@@ -1389,19 +1899,105 @@ public class RoomsController(
|
||||
VisitCount = room.VisitCount,
|
||||
},
|
||||
IsDorm = room.IsDormRoom,
|
||||
IsStudioRoom = room.IsStudioRoom,
|
||||
IsRoomLinkedToRecRoomStudio = room.IsRoomLinkedToRecRoomStudio,
|
||||
StudioSessionId = room.StudioSessionId,
|
||||
CloningAllowed = room.CloningAllowed,
|
||||
DisableMicAutoMute = room.DisableMicAutoMute,
|
||||
DisableRoomComments = false,
|
||||
EncryptVoiceChat = false,
|
||||
LoadScreenLocked = false,
|
||||
// Studio/UGC version family. The 2023 client reads UgcVersion to
|
||||
// decide the asset-bundle version it requests
|
||||
// (unity_assets/{id}/{target}/{UgcVersion}) and PersistenceVersion
|
||||
// to recognise a baked Studio room at all. Matches the real
|
||||
// RecRocks room.json (UgcVersion=1, PersistenceVersion=41). For a
|
||||
// non-Studio room these stay at the legacy 0 so behaviour is
|
||||
// unchanged.
|
||||
PersistenceVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0,
|
||||
UgcVersion = room.IsStudioRoom ? StudioUgcVersion : 0,
|
||||
UgcSubVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0,
|
||||
MinUgcSubVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0,
|
||||
IsDeveloperOwned = room.IsStudioRoom || HasRoomTag(room, "developer"),
|
||||
IsJuniorCreated = false,
|
||||
IsRecRoomApproved = false,
|
||||
ExcludeFromLists = false,
|
||||
BoostCount = 0,
|
||||
RestrictedCircuitsAllowListNames = Array.Empty<string>(),
|
||||
PublishStateAvailability = new
|
||||
{
|
||||
CanSaveAsBeta = false,
|
||||
CanSaveAsUpdate = true,
|
||||
AvailableUpdateTokenCount = 3,
|
||||
NextAvailableUpdateDateTimeUtc = (string?)null,
|
||||
},
|
||||
PublishState = 0,
|
||||
MaxPlayerCalculationMode = room.MaxPlayerCalculationMode,
|
||||
SubRooms = subRooms,
|
||||
Roles = wireRoles,
|
||||
LoadScreens = Array.Empty<object>(),
|
||||
PromoImages = Array.Empty<string>(),
|
||||
PromoExternalContent = Array.Empty<object>(),
|
||||
LoadScreens = JsonArrayOrEmpty(room.LoadScreensJson),
|
||||
PromoImages = JsonStringArrayOrEmpty(room.PromoImagesJson),
|
||||
PromoExternalContent = JsonArrayOrEmpty(room.PromoExternalContentJson),
|
||||
Tags = tags,
|
||||
};
|
||||
}
|
||||
|
||||
private static int RoomTagTypeForWire(string tag)
|
||||
{
|
||||
if (string.Equals(tag, "beta", StringComparison.OrdinalIgnoreCase)) return 1;
|
||||
return tag.ToLowerInvariant() switch
|
||||
{
|
||||
"rrstudio" or "community" or "screen" or "walkvr" or "teleportvr" or "junior" or "pickup" => 2,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasRoomTag(RoomEntity room, string tag)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(room.TagsCsv) &&
|
||||
room.TagsCsv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Any(t => string.Equals(t, tag, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static JsonElement[] JsonArrayOrEmpty(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<JsonElement>();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<JsonElement[]>(json) ?? Array.Empty<JsonElement>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Array.Empty<JsonElement>();
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] JsonStringArrayOrEmpty(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<string>();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<string[]>(json) ?? Array.Empty<string>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>PersistenceVersion / UgcSubVersion stamped on a baked Studio
|
||||
/// scene's CurrentSave. The 2023 client's room-load gate treats anything
|
||||
/// below the Studio-asset-bundle era as a legacy non-baked save and skips
|
||||
/// the Addressables bundle fetch. 41 matches the real RecRocks export
|
||||
/// (room.json + the per-save sidecars).</summary>
|
||||
public const int StudioPersistenceVersion = 41;
|
||||
|
||||
/// <summary>Room-level UgcVersion for a Studio room. The client uses this
|
||||
/// as the asset-bundle version in unity_assets/{id}/{target}/{version}
|
||||
/// (RecRocks bundles bake at v1, room.json UgcVersion=1).</summary>
|
||||
public const int StudioUgcVersion = 1;
|
||||
|
||||
|
||||
// ── Clone / create ───────────────────────────────────────────────────
|
||||
|
||||
@@ -1466,7 +2062,9 @@ public class RoomsController(
|
||||
[HttpGet("api/rooms/v2/createdby/{otherPlayerId:long}")]
|
||||
[HttpGet("api/rooms/v3/createdby/{otherPlayerId:long}")]
|
||||
[HttpGet("roomserver/rooms/createdby/{otherPlayerId:long}")]
|
||||
[HttpGet("roomserver/rooms/ownedby/{otherPlayerId:long}")]
|
||||
[HttpGet("rooms/createdby/{otherPlayerId:long}")]
|
||||
[HttpGet("rooms/ownedby/{otherPlayerId:long}")]
|
||||
public async Task<IActionResult> CreatedByOther(long otherPlayerId)
|
||||
=> Ok((await rooms.CreatedByAsync(otherPlayerId)).Select(RoomService.ToWireRoom).ToList());
|
||||
|
||||
@@ -1476,7 +2074,36 @@ public class RoomsController(
|
||||
[HttpGet("roomserver/rooms/visitedby/me")]
|
||||
[HttpGet("rooms/visitedby/me")]
|
||||
[Authorize]
|
||||
public IActionResult VisitedByMe() => Ok(Array.Empty<object>());
|
||||
public async Task<IActionResult> VisitedByMe()
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
if (pid is null) return Ok(Array.Empty<object>());
|
||||
return await VisitedBy(pid.Value);
|
||||
}
|
||||
|
||||
[HttpGet("rooms/visitedby/{playerId:long}")]
|
||||
[HttpGet("roomserver/rooms/visitedby/{playerId:long}")]
|
||||
public async Task<IActionResult> VisitedBy(long playerId)
|
||||
{
|
||||
var ids = await db.RoomVisits.AsNoTracking()
|
||||
.Where(v => v.PlayerId == playerId)
|
||||
.OrderByDescending(v => v.LastVisitAt)
|
||||
.Select(v => v.RoomId)
|
||||
.Take(100)
|
||||
.ToListAsync();
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var rows = await db.Rooms.AsNoTracking()
|
||||
.Where(r => ids.Contains(r.Id))
|
||||
.ToListAsync();
|
||||
var byId = rows.ToDictionary(r => r.Id);
|
||||
var ordered = ids
|
||||
.Select(id => byId.TryGetValue(id, out var room) ? room : null)
|
||||
.Where(r => r is not null)
|
||||
.Select(r => r!)
|
||||
.ToList();
|
||||
return Ok(await BuildRoomServerListAsync(ordered));
|
||||
}
|
||||
|
||||
[HttpGet("api/rooms/v1/moderatedby/me")]
|
||||
[HttpGet("api/rooms/v2/moderatedby/me")]
|
||||
@@ -1484,7 +2111,23 @@ public class RoomsController(
|
||||
[HttpGet("roomserver/rooms/moderatedby/me")]
|
||||
[HttpGet("rooms/moderatedby/me")]
|
||||
[Authorize]
|
||||
public IActionResult ModeratedByMe() => Ok(Array.Empty<object>());
|
||||
public async Task<IActionResult> ModeratedByMe()
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
if (pid is null) return Ok(Array.Empty<object>());
|
||||
var ids = await db.RoomRoles.AsNoTracking()
|
||||
.Where(r => r.PlayerId == pid.Value && r.Accepted)
|
||||
.OrderByDescending(r => r.GrantedAt)
|
||||
.Select(r => r.RoomId)
|
||||
.Distinct()
|
||||
.Take(100)
|
||||
.ToListAsync();
|
||||
var rows = await db.Rooms.AsNoTracking()
|
||||
.Where(r => ids.Contains(r.Id))
|
||||
.OrderByDescending(r => r.UpdatedAt)
|
||||
.ToListAsync();
|
||||
return Ok(await BuildRoomServerListAsync(rows));
|
||||
}
|
||||
|
||||
[HttpGet("api/rooms/v1/favoritedby/me")]
|
||||
[HttpGet("api/rooms/v2/favoritedby/me")]
|
||||
@@ -1492,7 +2135,13 @@ public class RoomsController(
|
||||
[HttpGet("roomserver/rooms/favoritedby/me")]
|
||||
[HttpGet("rooms/favoritedby/me")]
|
||||
[Authorize]
|
||||
public IActionResult FavoritedByMe() => Ok(Array.Empty<object>());
|
||||
public async Task<IActionResult> FavoritedByMe()
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
if (pid is null) return Ok(Array.Empty<object>());
|
||||
var rows = await rooms.BookmarkedByAsync(pid.Value);
|
||||
return Ok(await BuildRoomServerListAsync(rows));
|
||||
}
|
||||
|
||||
[HttpGet("api/rooms/v1/cheeredby/me")]
|
||||
[HttpGet("api/rooms/v2/cheeredby/me")]
|
||||
@@ -1500,7 +2149,23 @@ public class RoomsController(
|
||||
[HttpGet("roomserver/rooms/cheeredby/me")]
|
||||
[HttpGet("rooms/cheeredby/me")]
|
||||
[Authorize]
|
||||
public IActionResult CheeredByMe() => Ok(Array.Empty<object>());
|
||||
public async Task<IActionResult> CheeredByMe()
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
if (pid is null) return Ok(Array.Empty<object>());
|
||||
var ids = await db.Cheers.AsNoTracking()
|
||||
.Where(c => c.FromPlayerId == pid.Value && c.TargetRoomId != 0)
|
||||
.OrderByDescending(c => c.CheeredAt)
|
||||
.Select(c => c.TargetRoomId)
|
||||
.Distinct()
|
||||
.Take(100)
|
||||
.ToListAsync();
|
||||
var rows = await db.Rooms.AsNoTracking()
|
||||
.Where(r => ids.Contains(r.Id))
|
||||
.OrderByDescending(r => r.HotScore)
|
||||
.ToListAsync();
|
||||
return Ok(await BuildRoomServerListAsync(rows));
|
||||
}
|
||||
|
||||
[HttpGet("api/rooms/v2/mybookmarks")]
|
||||
[HttpGet("api/rooms/v2/mybookmarkedrooms")]
|
||||
@@ -1518,6 +2183,146 @@ public class RoomsController(
|
||||
[HttpGet("api/rooms/v2/myrecent")]
|
||||
public IActionResult MyOtherTabs() => Ok(Array.Empty<object>());
|
||||
|
||||
[HttpGet("player_room_data/{roomId:long}")]
|
||||
[HttpGet("rooms/{roomId:long}/playerdata/me")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> PlayerDataForMe(long roomId)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
if (pid is null) return Unauthorized();
|
||||
|
||||
var room = await db.Rooms.AsNoTracking()
|
||||
.Where(r => r.Id == roomId)
|
||||
.Select(r => new { r.Id, r.VisitCount, r.CheerCount, r.FavoriteCount })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var favorited = await db.RoomBookmarks
|
||||
.AnyAsync(b => b.RoomId == roomId && b.PlayerId == pid.Value);
|
||||
var cheered = await db.Cheers
|
||||
.AnyAsync(c => c.TargetRoomId == roomId && c.FromPlayerId == pid.Value);
|
||||
var visit = await db.RoomVisits.AsNoTracking()
|
||||
.Where(v => v.RoomId == roomId && v.PlayerId == pid.Value)
|
||||
.Select(v => new { v.VisitCount, v.FirstVisitAt, v.LastVisitAt })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Data = "CAE=",
|
||||
RoomId = room?.Id ?? roomId,
|
||||
PlayerId = pid.Value,
|
||||
Favorite = favorited,
|
||||
Favorited = favorited,
|
||||
IsFavorite = favorited,
|
||||
Cheer = cheered,
|
||||
Cheered = cheered,
|
||||
IsCheered = cheered,
|
||||
IsBookmarked = favorited,
|
||||
IsCheering = cheered,
|
||||
VisitCount = visit?.VisitCount ?? 0,
|
||||
FirstVisitedAt = visit?.FirstVisitAt,
|
||||
LastVisitedAt = visit?.LastVisitAt,
|
||||
RoomVisitCount = room?.VisitCount ?? 0,
|
||||
RoomCheerCount = room?.CheerCount ?? 0,
|
||||
RoomFavoriteCount = room?.FavoriteCount ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("rooms/requiring/{restriction}")]
|
||||
[HttpGet("roomserver/rooms/requiring/{restriction}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> RoomsRequiring(string restriction)
|
||||
{
|
||||
var key = (restriction ?? string.Empty).Trim().TrimStart('#').ToLowerInvariant();
|
||||
if (key.Length == 0) return Ok(new List<string>());
|
||||
|
||||
IQueryable<RoomEntity> query = db.Rooms.AsNoTracking()
|
||||
.Where(r => !r.HiddenFromBrowse && r.State == 0);
|
||||
|
||||
query = key switch
|
||||
{
|
||||
"developer" or "studio" or "rrstudio" => query.Where(r =>
|
||||
r.IsStudioRoom
|
||||
|| r.IsRoomLinkedToRecRoomStudio
|
||||
|| EF.Functions.Like(r.TagsCsv, "%developer%")
|
||||
|| EF.Functions.Like(r.TagsCsv, "%studio%")),
|
||||
"rrplus" or "recroomplus" => query.Where(r =>
|
||||
EF.Functions.Like(r.TagsCsv, "%rrplus%")
|
||||
|| EF.Functions.Like(r.TagsCsv, "%recroomplus%")),
|
||||
_ => query.Where(r => EF.Functions.Like(r.TagsCsv, $"%{key}%")),
|
||||
};
|
||||
|
||||
var rows = await query
|
||||
.OrderByDescending(r => r.HotScore)
|
||||
.ThenBy(r => r.Name)
|
||||
.Select(r => r.Name)
|
||||
.Take(100)
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpGet("rooms/curated_playlists")]
|
||||
[HttpGet("roomserver/rooms/curated_playlists")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CuratedPlaylistsCompat()
|
||||
{
|
||||
var curated = await playlists.CuratedAsync();
|
||||
return Ok(curated.Select(p => p.Id).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("clubhousesearch/mostactivenow")]
|
||||
[HttpGet("roomserver/clubhousesearch/mostactivenow")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> MostActiveClubhouses()
|
||||
{
|
||||
var rows = await db.Clubs.AsNoTracking()
|
||||
.Where(c => c.State == 0 && c.ClubhouseRoomId != null)
|
||||
.GroupJoin(
|
||||
db.ClubMemberships.AsNoTracking(),
|
||||
c => c.Id,
|
||||
m => m.ClubId,
|
||||
(club, memberships) => new
|
||||
{
|
||||
Club = club,
|
||||
MemberCount = memberships.Count(),
|
||||
})
|
||||
.Join(
|
||||
db.Rooms.AsNoTracking(),
|
||||
c => c.Club.ClubhouseRoomId!.Value,
|
||||
r => r.Id,
|
||||
(c, room) => new
|
||||
{
|
||||
c.Club,
|
||||
c.MemberCount,
|
||||
Room = room,
|
||||
})
|
||||
.OrderByDescending(x => x.MemberCount)
|
||||
.ThenByDescending(x => x.Room.HotScore)
|
||||
.Take(50)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(rows.Select(x => new
|
||||
{
|
||||
ClubId = x.Club.Id,
|
||||
x.Club.Name,
|
||||
x.Club.Description,
|
||||
MainImageName = x.Club.ImageName,
|
||||
ImageName = x.Club.ImageName,
|
||||
State = x.Club.State,
|
||||
CreatorAccountId = x.Club.CreatorPlayerId,
|
||||
Category = x.Club.Category,
|
||||
Visibility = x.Club.Visibility,
|
||||
Joinability = x.Club.Joinability,
|
||||
x.Club.AllowJuniors,
|
||||
MemberCount = x.MemberCount,
|
||||
x.Club.IsRRO,
|
||||
x.Club.ClubhouseRoomId,
|
||||
x.Club.ClubType,
|
||||
Room = RoomService.ToWireRoom(x.Room),
|
||||
RoomId = x.Room.Id,
|
||||
RoomName = x.Room.Name,
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
// ── Bookmark mutations ───────────────────────────────────────────────
|
||||
|
||||
[HttpPost("api/rooms/v1/bookmark/{roomId:long}")]
|
||||
@@ -1542,6 +2347,40 @@ public class RoomsController(
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
private IQueryable<RoomEntity> PublicRoomQuery() =>
|
||||
db.Rooms.AsNoTracking().Where(r =>
|
||||
r.State == 0 &&
|
||||
r.Accessibility == 1 &&
|
||||
!r.IsDormRoom &&
|
||||
!r.HiddenFromBrowse);
|
||||
|
||||
private async Task<List<object>> BuildRoomServerListAsync(IReadOnlyCollection<RoomEntity> roomRows)
|
||||
{
|
||||
if (roomRows.Count == 0) return new List<object>();
|
||||
|
||||
var ids = roomRows.Select(r => r.Id).Distinct().ToList();
|
||||
var sceneRows = await db.RoomScenes.AsNoTracking()
|
||||
.Where(s => ids.Contains(s.RoomId))
|
||||
.OrderBy(s => s.OrderIndex)
|
||||
.ToListAsync();
|
||||
var scenesByRoom = sceneRows
|
||||
.GroupBy(s => s.RoomId)
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
|
||||
var roleRows = await db.RoomRoles.AsNoTracking()
|
||||
.Where(r => ids.Contains(r.RoomId))
|
||||
.ToListAsync();
|
||||
var rolesByRoom = roleRows
|
||||
.GroupBy(r => r.RoomId)
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomRoleEntity>)g.ToList());
|
||||
|
||||
return roomRows
|
||||
.Select(r => BuildRoomServerDetails(
|
||||
r,
|
||||
scenesByRoom.GetValueOrDefault(r.Id),
|
||||
roles: rolesByRoom.GetValueOrDefault(r.Id)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>api/rooms/v1/agRoomIds</c> — list of every
|
||||
@@ -1573,17 +2412,25 @@ public class RoomsController(
|
||||
/// <see cref="Featured"/> shape here throws KeyNotFoundException on
|
||||
/// the watch's group-id lookup.</summary>
|
||||
[HttpGet("featuredrooms/current")]
|
||||
public async Task<IActionResult> FeaturedRoomsCurrent() => Ok(new
|
||||
public async Task<IActionResult> FeaturedRoomsCurrent()
|
||||
{
|
||||
FeaturedRoomGroupId = 1L,
|
||||
Name = "Featured",
|
||||
// NMPFCIJPODA.PPGFHEDFBEA (NMPFCIJPODA.txt:100-125) reads
|
||||
// 3 strict keys, not 2 — FeaturedRoomGroupId, Name, AND
|
||||
// Rooms (List<PPKJFAAAGDO>). Returning without the third
|
||||
// key throws KeyNotFoundException on the watch and the
|
||||
// Featured carousel stays empty.
|
||||
Rooms = await rooms.FeaturedAgRoomIdsAsync(12),
|
||||
});
|
||||
var ids = await rooms.FeaturedAgRoomIdsAsync(12);
|
||||
var featuredRooms = await db.Rooms
|
||||
.Where(r => ids.Contains(r.Id))
|
||||
.ToListAsync();
|
||||
var byId = featuredRooms.ToDictionary(r => r.Id);
|
||||
var wireRooms = ids
|
||||
.Select(id => byId.TryGetValue(id, out var room) ? RoomService.ToWireRoom(room) : null)
|
||||
.Where(room => room is not null)
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
return Ok(new
|
||||
{
|
||||
FeaturedRoomGroupId = 1L,
|
||||
Name = "Featured",
|
||||
Rooms = wireRooms,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/rooms/v1/bookmark</c> — toggle bookmark
|
||||
/// state for a room. Body: <c>RoomId</c> + optional <c>Bookmark</c>
|
||||
@@ -1687,6 +2534,8 @@ public class RoomsController(
|
||||
/// verifying ownership and intra-room uniqueness.</summary>
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
|
||||
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> RenameSubroom(
|
||||
long roomId,
|
||||
@@ -2058,7 +2907,12 @@ public class RoomsController(
|
||||
? new List<object>()
|
||||
: p.TagsCsv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(t => (object)new { Type = 0, Tag = t })
|
||||
.Select(t => (object)new
|
||||
{
|
||||
Type = RoomTagTypeForWire(t),
|
||||
Tag = t,
|
||||
IsPrimaryGenre = false,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Start from BuildPlaylistUnionEntry for base + PlaylistId, then
|
||||
@@ -2338,19 +3192,128 @@ public class RoomsController(
|
||||
public string? RoomSceneLocationId { get; set; }
|
||||
}
|
||||
public sealed class SubRoomMoveRequest { public int? NewIndex { get; set; } }
|
||||
public sealed class PublishSubRoomSaveRequest
|
||||
{
|
||||
public long? SubRoomDataSaveId { get; set; }
|
||||
public long? SaveId { get; set; }
|
||||
public long? StagedSubRoomDataSaveId { get; set; }
|
||||
public string? DataBlob { get; set; }
|
||||
public string? DataBlobName { get; set; }
|
||||
public string? Filename { get; set; }
|
||||
}
|
||||
|
||||
// The "Max Player Count in This Subroom" slider (SubroomModel.
|
||||
// ChangeSubRoomMaxPlayers) POSTs here on the roomserver host. Like its
|
||||
// siblings (accessibility/permissions/modify) the client prefixes the
|
||||
// path with "roomserver/", so the bare-only routes 404'd and the slider's
|
||||
// change silently never persisted — i.e. the limit couldn't be changed.
|
||||
// The value flows back to the host via the subroom details MaxPlayers,
|
||||
// which sets the Photon room cap.
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
|
||||
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> SubRoomMaxPlayers(long roomId, long subRoomId, [FromBody] SubRoomIntRequest req) =>
|
||||
MutateScene(roomId, subRoomId, s => { if (req.Value is int v) s.MaxPlayers = Math.Max(1, v); });
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
|
||||
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> SubRoomAccessibility(long roomId, long subRoomId, [FromBody] SubRoomBoolRequest req) =>
|
||||
MutateScene(roomId, subRoomId, s => { if (req.Value is bool v) s.CanMatchmakeInto = v; });
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
||||
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> SubRoomPermissions(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest? req) =>
|
||||
MutateScene(roomId, subRoomId, s =>
|
||||
{
|
||||
if (req?.CanMatchmakeInto is bool canMatchmakeInto) s.CanMatchmakeInto = canMatchmakeInto;
|
||||
if (req?.MaxPlayers is int maxPlayers) s.MaxPlayers = Math.Max(1, maxPlayers);
|
||||
if (req?.IsSandbox is bool isSandbox) s.IsSandbox = isSandbox;
|
||||
});
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
|
||||
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> PublishSubRoomSave(long roomId, long subRoomId)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
|
||||
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
|
||||
if (scene is null) return NotFound();
|
||||
|
||||
var request = await ReadPublishSubRoomSaveRequestAsync();
|
||||
var saveId = request.SubRoomDataSaveId ?? request.SaveId ?? request.StagedSubRoomDataSaveId;
|
||||
var dataBlob = request.DataBlobName ?? request.DataBlob ?? request.Filename;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(dataBlob))
|
||||
{
|
||||
var blobExists = await db.RoomDataBlobs.AnyAsync(b => b.RoomId == roomId && b.BlobName == dataBlob);
|
||||
if (!blobExists) return NotFound(new { error = "blob_not_found", dataBlob });
|
||||
scene.DataBlobName = dataBlob;
|
||||
if (scene.OrderIndex == 0) room.CurrentDataBlobName = dataBlob;
|
||||
}
|
||||
|
||||
if (saveId is > 0)
|
||||
scene.StudioSubRoomDataSaveId = saveId.Value;
|
||||
|
||||
scene.DataModifiedAt = DateTime.UtcNow;
|
||||
room.UpdatedAt = scene.DataModifiedAt;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(SceneWire(scene));
|
||||
}
|
||||
|
||||
private async Task<PublishSubRoomSaveRequest> ReadPublishSubRoomSaveRequestAsync()
|
||||
{
|
||||
var request = new PublishSubRoomSaveRequest();
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
if (long.TryParse(FirstFormValue(form, "subRoomDataSaveId", "SubRoomDataSaveId", "saveId", "SaveId", "stagedSubRoomDataSaveId", "StagedSubRoomDataSaveId"), out var saveId))
|
||||
request.SubRoomDataSaveId = saveId;
|
||||
request.DataBlobName = FirstFormValue(form, "dataBlobName", "DataBlobName", "dataBlob", "DataBlob", "filename", "Filename");
|
||||
return request;
|
||||
}
|
||||
|
||||
if (long.TryParse(Request.Query["subRoomDataSaveId"].FirstOrDefault()
|
||||
?? Request.Query["SubRoomDataSaveId"].FirstOrDefault()
|
||||
?? Request.Query["saveId"].FirstOrDefault()
|
||||
?? Request.Query["SaveId"].FirstOrDefault()
|
||||
?? Request.Query["stagedSubRoomDataSaveId"].FirstOrDefault()
|
||||
?? Request.Query["StagedSubRoomDataSaveId"].FirstOrDefault(), out var querySaveId))
|
||||
request.SubRoomDataSaveId = querySaveId;
|
||||
request.DataBlobName = Request.Query["dataBlobName"].FirstOrDefault()
|
||||
?? Request.Query["DataBlobName"].FirstOrDefault()
|
||||
?? Request.Query["dataBlob"].FirstOrDefault()
|
||||
?? Request.Query["DataBlob"].FirstOrDefault()
|
||||
?? Request.Query["filename"].FirstOrDefault()
|
||||
?? Request.Query["Filename"].FirstOrDefault();
|
||||
|
||||
if ((Request.ContentLength ?? 0) <= 0) return request;
|
||||
try
|
||||
{
|
||||
var body = await JsonSerializer.DeserializeAsync<PublishSubRoomSaveRequest>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
return body ?? request;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/clone</c> — duplicate
|
||||
/// a scene (DataBlobName + location pointer). New scene goes at the
|
||||
/// end with " Copy" suffix; per-room name uniqueness preserved.</summary>
|
||||
@@ -2392,8 +3355,13 @@ public class RoomsController(
|
||||
return Ok(SceneWire(clone));
|
||||
}
|
||||
|
||||
// Subroom settings save POSTs here on the roomserver host ("failed to
|
||||
// modify room" = the bare-only routes 404'd). Same roomserver/ prefix as
|
||||
// the sibling subroom mutations.
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
|
||||
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> ModifySubRoom(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest req) =>
|
||||
MutateScene(roomId, subRoomId, s =>
|
||||
@@ -2409,6 +3377,7 @@ public class RoomsController(
|
||||
/// scene. Body NewIndex is the target OrderIndex; the in-between
|
||||
/// rows shift to fill the gap.</summary>
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> MoveSubRoom(long roomId, long subRoomId, [FromBody] SubRoomMoveRequest req)
|
||||
{
|
||||
@@ -2506,8 +3475,7 @@ public class RoomsController(
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
var scene = await db.RoomScenes
|
||||
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
|
||||
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
|
||||
if (scene is null) return NotFound();
|
||||
mutator(scene);
|
||||
scene.DataModifiedAt = DateTime.UtcNow;
|
||||
@@ -2515,6 +3483,28 @@ public class RoomsController(
|
||||
return Ok(SceneWire(scene));
|
||||
}
|
||||
|
||||
private async Task<RoomSceneEntity?> GetOrCreateSceneForMutationAsync(RoomEntity room, long subRoomId)
|
||||
{
|
||||
var scene = await db.RoomScenes
|
||||
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == subRoomId);
|
||||
if (scene is not null || subRoomId != 0) return scene;
|
||||
|
||||
scene = new RoomSceneEntity
|
||||
{
|
||||
RoomId = room.Id,
|
||||
OrderIndex = 0,
|
||||
Name = "Home",
|
||||
RoomSceneLocationId = room.LocationReplicationId,
|
||||
DataBlobName = CurrentOrSyntheticDataBlobName(room),
|
||||
MaxPlayers = room.MaxCapacity,
|
||||
IsSandbox = false,
|
||||
CanMatchmakeInto = true,
|
||||
DataModifiedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.RoomScenes.Add(scene);
|
||||
return scene;
|
||||
}
|
||||
|
||||
private static object SceneWire(RoomSceneEntity s) => new
|
||||
{
|
||||
Result = 0,
|
||||
@@ -2531,9 +3521,8 @@ public class RoomsController(
|
||||
|
||||
// ── Bare-path roles ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>rooms/{id}/roles</c> — accepted role grants on
|
||||
/// the room. Owner is implicit (CreatorPlayerId) and surfaces under
|
||||
/// Role=30; co-owner/mod/host rows come from RoomRoles.</summary>
|
||||
/// <summary>GET <c>rooms/{id}/roles</c> — account role DTOs consumed by
|
||||
/// 2023's EFHPLDPNGIM deserializer.</summary>
|
||||
[HttpGet("rooms/{roomId:long}/roles")]
|
||||
public async Task<IActionResult> RolesList(long roomId)
|
||||
{
|
||||
@@ -2542,29 +3531,132 @@ public class RoomsController(
|
||||
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
|
||||
var list = new List<object>(rows.Count + 1)
|
||||
{
|
||||
new { RoomId = roomId, PlayerId = (int)room.CreatorPlayerId, Role = 30 /*Owner*/, Accepted = true },
|
||||
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
|
||||
};
|
||||
list.AddRange(rows.Select(r => (object)new
|
||||
{
|
||||
RoomId = roomId,
|
||||
PlayerId = (int)r.PlayerId,
|
||||
Role = r.Role,
|
||||
Accepted = r.Accepted,
|
||||
}));
|
||||
list.AddRange(rows.Select(BuildRoomRoleGrantWire));
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
private static string CurrentOrSyntheticDataBlobName(RoomEntity room)
|
||||
{
|
||||
if (room.IsDormRoom)
|
||||
return RoomService.ResolveWireRoomDataBlobName(room.Id, room.CurrentDataBlobName);
|
||||
|
||||
if (RoomService.IsBakedOriginalRoom(room) && string.IsNullOrWhiteSpace(room.CurrentDataBlobName))
|
||||
return string.Empty;
|
||||
|
||||
return !string.IsNullOrWhiteSpace(room.CurrentDataBlobName)
|
||||
? room.CurrentDataBlobName
|
||||
: RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
|
||||
}
|
||||
|
||||
private static string SceneOrSyntheticDataBlobName(RoomEntity room, string? sceneBlobName, string fallbackBlobName)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(sceneBlobName) &&
|
||||
!(room.IsDormRoom && RoomService.IsLegacySyntheticDefaultRoomDataBlobName(room.Id, sceneBlobName)))
|
||||
return sceneBlobName;
|
||||
|
||||
return fallbackBlobName;
|
||||
}
|
||||
|
||||
private sealed record StudioBundleInfo(long SaveId, int Target, int Version, string Filename);
|
||||
|
||||
private sealed record BakedUnityAssetWire(
|
||||
string UnityAssetId,
|
||||
long CreatedByAccountId,
|
||||
string Filename,
|
||||
int Target,
|
||||
int Version,
|
||||
string Hash);
|
||||
|
||||
private sealed record UnityAssetWire(
|
||||
string UnityAssetId,
|
||||
long CreatedByAccountId,
|
||||
string Filename,
|
||||
BakedUnityAssetWire[] BakedUnityAssets);
|
||||
|
||||
private static BakedUnityAssetWire[] BuildStudioBakedAssets(
|
||||
RoomEntity room,
|
||||
RoomSceneEntity? scene,
|
||||
long saveId,
|
||||
int? unityAssetTarget,
|
||||
int? unityAssetVersion)
|
||||
{
|
||||
var bundles = StudioBundlesForScene(scene, saveId);
|
||||
if (unityAssetTarget is int target)
|
||||
bundles = bundles.Where(b => b.Target == target).ToList();
|
||||
if (unityAssetVersion is int requestedVersion && bundles.Any(b => b.Version == requestedVersion))
|
||||
bundles = bundles.Where(b => b.Version == requestedVersion).ToList();
|
||||
|
||||
var unityAssetId = scene?.StudioUnityAssetId ?? string.Empty;
|
||||
return bundles
|
||||
.OrderBy(b => b.Target)
|
||||
.ThenByDescending(b => b.Version)
|
||||
.Select(b => new BakedUnityAssetWire(
|
||||
unityAssetId,
|
||||
room.CreatorPlayerId,
|
||||
b.Filename,
|
||||
b.Target,
|
||||
b.Version,
|
||||
string.Empty))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static UnityAssetWire? BuildStudioUnityAsset(
|
||||
RoomEntity room,
|
||||
RoomSceneEntity? scene,
|
||||
BakedUnityAssetWire[] bakedAssets)
|
||||
{
|
||||
var unityAssetId = scene?.StudioUnityAssetId ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(unityAssetId))
|
||||
return null;
|
||||
|
||||
var filename = bakedAssets.FirstOrDefault()?.Filename ?? string.Empty;
|
||||
return new UnityAssetWire(unityAssetId, room.CreatorPlayerId, filename, bakedAssets);
|
||||
}
|
||||
|
||||
private static List<StudioBundleInfo> StudioBundlesForScene(RoomSceneEntity? scene, long saveId)
|
||||
{
|
||||
return (scene?.StudioAssetBundleNamesCsv ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(ParseStudioBundleName)
|
||||
.Where(b => b is not null)
|
||||
.Cast<StudioBundleInfo>()
|
||||
.Where(b => b.SaveId == saveId || scene?.StudioSubRoomDataSaveId == saveId)
|
||||
.OrderBy(b => b.Target)
|
||||
.ThenByDescending(b => b.Version)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static StudioBundleInfo? ParseStudioBundleName(string fileName)
|
||||
{
|
||||
var name = Path.GetFileName(fileName);
|
||||
var match = System.Text.RegularExpressions.Regex.Match(
|
||||
name,
|
||||
@"^(?<save>\d+)__bundle_t(?<target>\d+)(?:_v(?<version>\d+))?\.assetbundle$",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant);
|
||||
if (!match.Success) return null;
|
||||
if (!long.TryParse(match.Groups["save"].Value, out var saveId)) return null;
|
||||
if (!int.TryParse(match.Groups["target"].Value, out var target)) return null;
|
||||
var version = 0;
|
||||
if (match.Groups["version"].Success &&
|
||||
!int.TryParse(match.Groups["version"].Value, out version))
|
||||
return null;
|
||||
return new StudioBundleInfo(saveId, target, version, name);
|
||||
}
|
||||
|
||||
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
public async Task<IActionResult> RoleForPlayer(long roomId, long playerId)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId == playerId)
|
||||
return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = 30, Accepted = true });
|
||||
return Ok(BuildRoomAccountRoleWire(playerId, 30));
|
||||
var row = await db.RoomRoles
|
||||
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId);
|
||||
if (row is null) return NotFound();
|
||||
return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = row.Role, Accepted = row.Accepted });
|
||||
return Ok(BuildRoomRoleGrantWire(row));
|
||||
}
|
||||
|
||||
public sealed class GrantRoleRequest { public int? Role { get; set; } }
|
||||
@@ -2732,6 +3824,44 @@ public class RoomsController(
|
||||
return roomRole >= 0;
|
||||
}
|
||||
|
||||
private static object BuildRoomRoleGrantWire(RoomRoleEntity role)
|
||||
=> BuildRoomAccountRoleWire(
|
||||
role.PlayerId,
|
||||
ToClientRoomRole(role.Role),
|
||||
role.Accepted,
|
||||
role.GrantedByPlayerId);
|
||||
|
||||
private static object BuildRoomAccountRoleWire(
|
||||
long accountId,
|
||||
int role,
|
||||
bool accepted = true,
|
||||
long? lastChangedByAccountId = null)
|
||||
=> new
|
||||
{
|
||||
AccountId = ToClientAccountId(accountId),
|
||||
Role = accepted ? role : 0,
|
||||
LastChangedByAccountId = ToClientAccountId(lastChangedByAccountId),
|
||||
InvitedRole = accepted ? 0 : role,
|
||||
};
|
||||
|
||||
private static int ToClientRoomRole(int roomRole) => roomRole switch
|
||||
{
|
||||
0 => 30, // CoOwner
|
||||
1 => 20, // Moderator
|
||||
2 => 10, // Host
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
private static int ToClientAccountId(long accountId)
|
||||
=> accountId > int.MaxValue
|
||||
? int.MaxValue
|
||||
: accountId < int.MinValue
|
||||
? int.MinValue
|
||||
: (int)accountId;
|
||||
|
||||
private static int? ToClientAccountId(long? accountId)
|
||||
=> accountId.HasValue ? ToClientAccountId(accountId.Value) : null;
|
||||
|
||||
private async Task<object> BuildRoomServerDetailsWithRolesAsync(long roomId)
|
||||
{
|
||||
var room = await db.Rooms.FirstAsync(r => r.Id == roomId);
|
||||
|
||||
@@ -527,6 +527,204 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<long?> ReadBareLongAsync(params string[] keys)
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in keys)
|
||||
if (long.TryParse(form[key].FirstOrDefault(), out var value)) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var key in keys)
|
||||
if (long.TryParse(Request.Query[key].FirstOrDefault(), out var value)) return value;
|
||||
|
||||
if ((Request.ContentLength ?? 0) <= 0) return null;
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (!doc.RootElement.TryGetProperty(key, out var prop)) continue;
|
||||
if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt64(out var n)) return n;
|
||||
if (prop.ValueKind == JsonValueKind.String && long.TryParse(prop.GetString(), out n)) return n;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<bool?> ReadBareBoolAsync(params string[] keys)
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in keys)
|
||||
if (TryParseBool(form[key].FirstOrDefault(), out var value)) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var key in keys)
|
||||
if (TryParseBool(Request.Query[key].FirstOrDefault(), out var value)) return value;
|
||||
|
||||
if ((Request.ContentLength ?? 0) <= 0) return null;
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
||||
return doc.RootElement.ValueKind == JsonValueKind.True
|
||||
? true
|
||||
: doc.RootElement.ValueKind == JsonValueKind.False
|
||||
? false
|
||||
: null;
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (!doc.RootElement.TryGetProperty(key, out var prop)) continue;
|
||||
if (prop.ValueKind == JsonValueKind.True) return true;
|
||||
if (prop.ValueKind == JsonValueKind.False) return false;
|
||||
if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var n)) return n != 0;
|
||||
if (prop.ValueKind == JsonValueKind.String && TryParseBool(prop.GetString(), out var parsed)) return parsed;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<string?> ReadStringValueAsync(params string[] keys)
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in keys)
|
||||
{
|
||||
var value = form[key].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(value)) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
var value = Request.Query[key].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(value)) return value;
|
||||
}
|
||||
|
||||
if ((Request.ContentLength ?? 0) <= 0) return null;
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.String)
|
||||
return doc.RootElement.GetString();
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (!doc.RootElement.TryGetProperty(key, out var prop)) continue;
|
||||
if (prop.ValueKind == JsonValueKind.String) return prop.GetString();
|
||||
if (prop.ValueKind == JsonValueKind.Number || prop.ValueKind == JsonValueKind.True || prop.ValueKind == JsonValueKind.False)
|
||||
return prop.ToString();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<JsonElement?> ReadJsonElementAsync(params string[] stringKeys)
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
var dict = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var key in form.Keys)
|
||||
{
|
||||
var value = form[key].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(value)) dict[key] = value;
|
||||
}
|
||||
if (dict.Count == 0) return null;
|
||||
return JsonSerializer.SerializeToElement(dict);
|
||||
}
|
||||
|
||||
if ((Request.ContentLength ?? 0) > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var key in stringKeys)
|
||||
{
|
||||
var value = Request.Query[key].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(value)) return JsonSerializer.SerializeToElement(value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseBool(string? raw, out bool value)
|
||||
{
|
||||
if (bool.TryParse(raw, out value)) return true;
|
||||
if (int.TryParse(raw, out var n))
|
||||
{
|
||||
value = n != 0;
|
||||
return true;
|
||||
}
|
||||
value = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<JsonElement> ReadJsonElementList(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return new List<JsonElement>();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<JsonElement[]>(json)?.ToList() ?? new List<JsonElement>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new List<JsonElement>();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> ReadStringList(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return new List<string>();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<string[]>(json)?.Where(v => !string.IsNullOrWhiteSpace(v)).ToList()
|
||||
?? new List<string>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeLimited<T>(IEnumerable<T> values, int maxChars = 4096)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(values);
|
||||
return json.Length <= maxChars ? json : "[]";
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/cloning")]
|
||||
[HttpPut("rooms/{roomId:long}/cloning")]
|
||||
public async Task<IActionResult> BareCloning(long roomId, [FromBody] BareBoolRequest? body,
|
||||
@@ -569,6 +767,171 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
||||
if (body?.CustomRoomWarning is not null) r.CustomRoomWarning = body.CustomRoomWarning;
|
||||
});
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/creator")]
|
||||
[HttpPut("rooms/{roomId:long}/creator")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/creator")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/creator")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BareCreator(long roomId)
|
||||
{
|
||||
var creatorId = await ReadBareLongAsync(
|
||||
"creatorAccountId", "CreatorAccountId",
|
||||
"creatorPlayerId", "CreatorPlayerId",
|
||||
"accountId", "AccountId",
|
||||
"value", "Value");
|
||||
if (creatorId is null or <= 0) return BadRequest(new { error = "missing_creator" });
|
||||
var exists = await db.Players.AnyAsync(p => p.Id == creatorId.Value);
|
||||
if (!exists) return NotFound(new { error = "creator_not_found", creatorId });
|
||||
return await ApplyAndReturn(roomId, r => r.CreatorPlayerId = creatorId.Value);
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/allow_new_users")]
|
||||
[HttpPut("rooms/{roomId:long}/allow_new_users")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/allow_new_users")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/allow_new_users")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BareAllowNewUsers(long roomId)
|
||||
{
|
||||
var value = await ReadBareBoolAsync("allowNewUsers", "AllowNewUsers", "value", "Value");
|
||||
return await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
if (value is bool v) r.AllowNewUsers = v;
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/min_level")]
|
||||
[HttpPut("rooms/{roomId:long}/min_level")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/min_level")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/min_level")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BareMinLevel(long roomId)
|
||||
{
|
||||
var value = await ReadBareIntAsync("minLevel", "MinLevel", "level", "Level", "value", "Value");
|
||||
return await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
if (value is int v) r.MinLevel = Math.Max(0, v);
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/max_player_calculation_mode")]
|
||||
[HttpPut("rooms/{roomId:long}/max_player_calculation_mode")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BareMaxPlayerCalculationMode(long roomId)
|
||||
{
|
||||
var value = await ReadBareIntAsync(
|
||||
"maxPlayerCalculationMode", "MaxPlayerCalculationMode",
|
||||
"mode", "Mode",
|
||||
"value", "Value");
|
||||
return await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
if (value is int v) r.MaxPlayerCalculationMode = Math.Max(0, v);
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/loadscreen")]
|
||||
[HttpPut("rooms/{roomId:long}/loadscreen")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/loadscreen")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/loadscreen")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BareLoadScreen(long roomId)
|
||||
{
|
||||
var payload = await ReadJsonElementAsync("loadScreen", "LoadScreen", "value", "Value");
|
||||
return await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
if (payload is JsonElement p)
|
||||
r.LoadScreensJson = p.ValueKind == JsonValueKind.Array
|
||||
? p.GetRawText()
|
||||
: SerializeLimited(new[] { p });
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/promo_images")]
|
||||
[HttpPut("rooms/{roomId:long}/promo_images")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/promo_images")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/promo_images")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BarePromoImagesAdd(long roomId)
|
||||
{
|
||||
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
|
||||
if (string.IsNullOrWhiteSpace(image)) return BadRequest(new { error = "missing_image" });
|
||||
return await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
var images = ReadStringList(r.PromoImagesJson);
|
||||
if (!images.Contains(image, StringComparer.OrdinalIgnoreCase))
|
||||
images.Add(image);
|
||||
r.PromoImagesJson = SerializeLimited(images);
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("rooms/{roomId:long}/promo_images")]
|
||||
[HttpDelete("roomserver/rooms/{roomId:long}/promo_images")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BarePromoImagesRemove(long roomId)
|
||||
{
|
||||
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
|
||||
if (string.IsNullOrWhiteSpace(image)) return BadRequest(new { error = "missing_image" });
|
||||
return await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
var images = ReadStringList(r.PromoImagesJson)
|
||||
.Where(v => !string.Equals(v, image, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
r.PromoImagesJson = SerializeLimited(images);
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/promo_external")]
|
||||
[HttpPut("rooms/{roomId:long}/promo_external")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/promo_external")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/promo_external")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BarePromoExternalAdd(long roomId)
|
||||
{
|
||||
var payload = await ReadJsonElementAsync("url", "Url", "value", "Value");
|
||||
if (payload is null) return BadRequest(new { error = "missing_promo_external" });
|
||||
return await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
var items = ReadJsonElementList(r.PromoExternalContentJson);
|
||||
var raw = payload.Value.GetRawText();
|
||||
if (!items.Any(i => string.Equals(i.GetRawText(), raw, StringComparison.Ordinal)))
|
||||
items.Add(payload.Value);
|
||||
r.PromoExternalContentJson = SerializeLimited(items);
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("rooms/{roomId:long}/promo_external")]
|
||||
[HttpDelete("roomserver/rooms/{roomId:long}/promo_external")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BarePromoExternalRemove(long roomId)
|
||||
{
|
||||
var match = await ReadStringValueAsync("id", "Id", "url", "Url", "value", "Value");
|
||||
if (string.IsNullOrWhiteSpace(match)) return BadRequest(new { error = "missing_promo_external" });
|
||||
return await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
var items = ReadJsonElementList(r.PromoExternalContentJson)
|
||||
.Where(i => !PromoExternalMatches(i, match))
|
||||
.ToList();
|
||||
r.PromoExternalContentJson = SerializeLimited(items);
|
||||
});
|
||||
}
|
||||
|
||||
private static bool PromoExternalMatches(JsonElement item, string match)
|
||||
{
|
||||
if (string.Equals(item.GetRawText(), match, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
return string.Equals(item.GetString(), match, StringComparison.OrdinalIgnoreCase);
|
||||
if (item.ValueKind != JsonValueKind.Object) return false;
|
||||
foreach (var key in new[] { "Id", "id", "Url", "url", "Value", "value" })
|
||||
{
|
||||
if (item.TryGetProperty(key, out var prop) &&
|
||||
prop.ValueKind == JsonValueKind.String &&
|
||||
string.Equals(prop.GetString(), match, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary><c>rooms/{id}/comments</c> + <c>voice_chat_encryption</c>
|
||||
/// — fields not persisted in the 2020 RoomEntity. Owner-gated
|
||||
/// acknowledgement so the watch's settings panel doesn't surface
|
||||
@@ -639,10 +1002,19 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
||||
SupportsWalkVR = source.SupportsWalkVR,
|
||||
SupportsTeleportVR = source.SupportsTeleportVR,
|
||||
AllowsJuniors = source.AllowsJuniors,
|
||||
AllowNewUsers = source.AllowNewUsers,
|
||||
MinLevel = source.MinLevel,
|
||||
MaxPlayerCalculationMode = source.MaxPlayerCalculationMode,
|
||||
LoadScreensJson = source.LoadScreensJson,
|
||||
PromoImagesJson = source.PromoImagesJson,
|
||||
PromoExternalContentJson = source.PromoExternalContentJson,
|
||||
RoomWarningMask = source.RoomWarningMask,
|
||||
CustomRoomWarning = source.CustomRoomWarning,
|
||||
DisableMicAutoMute = source.DisableMicAutoMute,
|
||||
LocationReplicationId = source.LocationReplicationId,
|
||||
IsStudioRoom = source.IsStudioRoom,
|
||||
IsRoomLinkedToRecRoomStudio = source.IsRoomLinkedToRecRoomStudio,
|
||||
StudioSessionId = source.StudioSessionId,
|
||||
TagsCsv = source.TagsCsv,
|
||||
CurrentDataBlobName = source.CurrentDataBlobName,
|
||||
};
|
||||
|
||||
@@ -84,6 +84,11 @@ public class SanitizeController(ILogger<SanitizeController> logger) : Controller
|
||||
public string? Text { get; set; }
|
||||
public string? Input { get; set; }
|
||||
public string? Value { get; set; }
|
||||
public string? Context { get; set; }
|
||||
public int? Intent { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("ruleset")]
|
||||
public int? Ruleset { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/sanitize/v1/purifyString</c> — returns
|
||||
@@ -116,6 +121,32 @@ public class SanitizeController(ILogger<SanitizeController> logger) : Controller
|
||||
return Ok(ProfanityFilter.IsClean(input));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/sanitize/v1/isPure</c> — 2023 route used
|
||||
/// by CV2 maker pen text such as port names. It imports an object
|
||||
/// field named <c>IsPure</c>.</summary>
|
||||
[HttpPost("api/sanitize/v1/isPure")]
|
||||
public IActionResult IsPureV1(
|
||||
[FromBody] SanitizeBody? body,
|
||||
[FromForm(Name = "Text")] string? textForm,
|
||||
[FromForm(Name = "Input")] string? inputForm,
|
||||
[FromForm(Name = "Value")] string? valueForm,
|
||||
[FromQuery(Name = "text")] string? textQuery)
|
||||
{
|
||||
var input = body?.Text ?? body?.Input ?? body?.Value
|
||||
?? textForm ?? inputForm ?? valueForm ?? textQuery ?? string.Empty;
|
||||
var isPure = ProfanityFilter.IsClean(input);
|
||||
logger.LogInformation(
|
||||
"[sanitize] isPure context={Context} intent={Intent} ruleset={Ruleset} valueLength={ValueLength} isPure={IsPure}",
|
||||
body?.Context ?? string.Empty,
|
||||
body?.Intent,
|
||||
body?.Ruleset,
|
||||
input.Length,
|
||||
isPure);
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(new { IsPure = isPure });
|
||||
return Content(json, "application/json");
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/sanitize/isPure</c> — December 2020 route
|
||||
/// used by room/settings forms. Its importer reads an <c>IsPure</c>
|
||||
/// object field, unlike the newer bare-bool route above.</summary>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.ScreenShareReports;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class ScreenShareReportsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpPost("api/screensharereports/v1/report")]
|
||||
public async Task<IActionResult> Report(
|
||||
[FromForm] long? targetPlayerId,
|
||||
[FromForm] long? roomId,
|
||||
[FromForm] int? reportCategory,
|
||||
[FromForm] string? details)
|
||||
{
|
||||
var reporter = this.RequireCurrentPlayerId();
|
||||
var target = targetPlayerId
|
||||
?? (long.TryParse(Request.Query["targetPlayerId"].FirstOrDefault(), out var qTarget) ? qTarget : 0);
|
||||
var room = roomId
|
||||
?? (long.TryParse(Request.Query["roomId"].FirstOrDefault(), out var qRoom) ? qRoom : 0);
|
||||
var category = reportCategory
|
||||
?? (int.TryParse(Request.Query["reportCategory"].FirstOrDefault(), out var qCategory) ? qCategory : 5);
|
||||
var message = details ?? Request.Query["details"].FirstOrDefault() ?? string.Empty;
|
||||
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
ReporterPlayerId = reporter,
|
||||
TargetPlayerId = target,
|
||||
RoomId = room,
|
||||
Category = category,
|
||||
Message = message[..Math.Min(1000, message.Length)],
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Error = string.Empty });
|
||||
}
|
||||
}
|
||||
@@ -205,6 +205,7 @@ public class StorefrontsBuyController(
|
||||
/// persisted room key. The 2020.12 client sends roomKeyId and the
|
||||
/// price it displayed so we reject stale-price purchases.</summary>
|
||||
[HttpGet("api/storefronts/v1/buyRoomKey")]
|
||||
[HttpGet("api/storefronts/v1/PurchaseRoomKeyWithCurrency")]
|
||||
public async Task<IActionResult> BuyRoomKey(
|
||||
[FromQuery] long roomKeyId,
|
||||
[FromQuery] int requestedPrice)
|
||||
@@ -242,6 +243,95 @@ public class StorefrontsBuyController(
|
||||
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.Success, key, newBalance));
|
||||
}
|
||||
|
||||
[HttpPost("api/storefronts/v2/buyInvention")]
|
||||
[HttpGet("api/storefronts/v2/buyInvention")]
|
||||
public async Task<IActionResult> BuyInvention([FromQuery] long inventionId, [FromQuery] int currencyType = 2)
|
||||
{
|
||||
var pid = Me;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(i => i.Id == inventionId && !i.IsDeleted && i.IsPublished);
|
||||
if (inv is null) return NotFound();
|
||||
var price = Math.Max(0, inv.Price);
|
||||
var balance = await level.GetBalanceAsync(pid, currencyType);
|
||||
if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType));
|
||||
var newBalance = price == 0
|
||||
? balance
|
||||
: await level.GrantCurrencyAsync(pid, currencyType, -price, $"buyInvention:{inv.Id}");
|
||||
var key = $"invention_purchase:{inv.Id}";
|
||||
if (!await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == key))
|
||||
{
|
||||
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
Key = key,
|
||||
IsCompleted = true,
|
||||
ClearedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Ok(BalanceUpdateResponse(newBalance, currencyType, null, null));
|
||||
}
|
||||
|
||||
[HttpPost("api/storefronts/v1/buyForFreeGiftButton")]
|
||||
[HttpGet("api/storefronts/v1/buyForFreeGiftButton")]
|
||||
public async Task<IActionResult> BuyForFreeGiftButton([FromQuery] int currencyType = 2, [FromQuery] int price = 0)
|
||||
{
|
||||
var pid = Me;
|
||||
var balance = await level.GetBalanceAsync(pid, currencyType);
|
||||
if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType));
|
||||
var newBalance = price == 0
|
||||
? balance
|
||||
: await level.GrantCurrencyAsync(pid, currencyType, -price, "freeGiftButton");
|
||||
db.GiftPackages.Add(new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = pid,
|
||||
CurrencyType = 2,
|
||||
Currency = 25,
|
||||
Level = 1,
|
||||
PackageVariant = "FreeGiftButton",
|
||||
PackageMaterial = string.Empty,
|
||||
IsValid = true,
|
||||
SupportsCurrentPlatform = true,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(BalanceUpdateResponse(newBalance, currencyType));
|
||||
}
|
||||
|
||||
[HttpPost("api/storefronts/v1/buyProgressionEventXpBoost")]
|
||||
[HttpGet("api/storefronts/v1/buyProgressionEventXpBoost")]
|
||||
public async Task<IActionResult> BuyProgressionEventXpBoost([FromQuery] int currencyType = 2, [FromQuery] int price = 0)
|
||||
{
|
||||
var pid = Me;
|
||||
var balance = await level.GetBalanceAsync(pid, currencyType);
|
||||
if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType));
|
||||
var newBalance = price == 0
|
||||
? balance
|
||||
: await level.GrantCurrencyAsync(pid, currencyType, -price, "progressionEventXpBoost");
|
||||
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
Key = $"progressionEvent:xpBoost:{Guid.NewGuid():N}",
|
||||
IsCompleted = true,
|
||||
ClearedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(BalanceUpdateResponse(newBalance, currencyType));
|
||||
}
|
||||
|
||||
[HttpPost("api/storefronts/v1/buyPurchaseReminder")]
|
||||
[HttpGet("api/storefronts/v1/buyPurchaseReminder")]
|
||||
public async Task<IActionResult> BuyPurchaseReminder()
|
||||
{
|
||||
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
Key = $"purchaseReminder:{Guid.NewGuid():N}",
|
||||
IsCompleted = true,
|
||||
ClearedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private async Task GrantWardrobeAsync(long pid, string guid)
|
||||
|
||||
@@ -32,6 +32,7 @@ public class StorefrontsController(
|
||||
[HttpGet("api/storefronts/v1/all")]
|
||||
[HttpGet("api/storefronts/v2/all")]
|
||||
[HttpGet("api/storefronts/v3/all")]
|
||||
[HttpGet("api/storefronts/v1/toptoday")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> All()
|
||||
{
|
||||
@@ -39,6 +40,20 @@ public class StorefrontsController(
|
||||
return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/storefronts/v1/adcarouselitems</c> — the rotating
|
||||
/// promo banner at the top of the Shop, NOT the main catalog. The client
|
||||
/// (AdCarouselItemListModel) keys these by <c>AdCarouselItemId</c> in a
|
||||
/// Dictionary; our generic store DTOs don't carry that field, so every
|
||||
/// row deserialised to id 0 and the SECOND item threw
|
||||
/// "An item with the same key has already been added. Key: 0",
|
||||
/// which surfaced as an unobserved task exception that broke the Shop.
|
||||
/// We don't run promo carousels, so return an empty banner — the real
|
||||
/// catalog still comes from /all + /skus. (To add a real carousel later,
|
||||
/// emit one row per item with a UNIQUE AdCarouselItemId.)</summary>
|
||||
[HttpGet("api/storefronts/v1/adcarouselitems")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult AdCarouselItems() => Ok(Array.Empty<object>());
|
||||
|
||||
/// <summary>GET api/storefronts/v3/skus — alias for the full
|
||||
/// catalog. Some 2020 client paths call this instead of /all
|
||||
/// during the watch's "browse all SKUs" flow.</summary>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.SubscriptionSeasons;
|
||||
|
||||
[ApiController]
|
||||
public class SubscriptionSeasonsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/subscriptionseasons/v1/seasons/current")]
|
||||
public async Task<IActionResult> Current()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var start = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var end = start.AddMonths(1);
|
||||
var activeSubscribers = await db.Subscriptions.CountAsync();
|
||||
var rewards = await db.StoreItems
|
||||
.Where(i => i.IsActive)
|
||||
.OrderBy(i => i.Id)
|
||||
.Take(10)
|
||||
.Select(i => new
|
||||
{
|
||||
RewardId = i.Id,
|
||||
i.Slug,
|
||||
Name = i.DisplayName,
|
||||
i.Category,
|
||||
i.ImageName,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(new
|
||||
{
|
||||
SeasonId = $"{start:yyyyMM}",
|
||||
Name = $"Season {start:yyyy.MM}",
|
||||
StartAt = start,
|
||||
EndAt = end,
|
||||
ActiveSubscriberCount = activeSubscribers,
|
||||
Rewards = rewards,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.UgcPurchasables;
|
||||
|
||||
[ApiController]
|
||||
public class UgcPurchasablesController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/ugcPurchasables")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> List([FromQuery] long roomId = 0)
|
||||
{
|
||||
var rows = await db.UgcPurchasables
|
||||
.Where(i => !i.IsDeleted && (roomId <= 0 || i.RoomId == roomId))
|
||||
.OrderBy(i => i.SortOrder)
|
||||
.ThenBy(i => i.Name)
|
||||
.Take(200)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpPost("api/ugcPurchasables")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var roomId = ReadLong(fields, "roomId", "RoomId");
|
||||
if (roomId is not long rid || rid <= 0) return BadRequest("missing_room");
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == rid);
|
||||
if (room is null) return NotFound("room_not_found");
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
if (!await CanManageRoomAsync(room, me)) return Forbid();
|
||||
|
||||
var item = new UgcPurchasableEntity
|
||||
{
|
||||
RoomId = room.Id,
|
||||
CreatorPlayerId = me,
|
||||
Name = Trim(ReadString(fields, "name", "Name") ?? "Purchasable", 128),
|
||||
Description = Trim(ReadString(fields, "description", "Description"), 1024),
|
||||
ImageName = Trim(ReadString(fields, "imageName", "ImageName"), 256),
|
||||
Price = Math.Max(0, ReadInt(fields, "price", "Price") ?? 0),
|
||||
CurrencyType = Math.Max(0, ReadInt(fields, "currencyType", "CurrencyType") ?? 2),
|
||||
ItemType = Math.Max(0, ReadInt(fields, "itemType", "ItemType") ?? 0),
|
||||
SortOrder = ReadInt(fields, "sortOrder", "SortOrder") ?? 0,
|
||||
IsFeatured = ReadBool(fields, "isFeatured", "IsFeatured") ?? false,
|
||||
};
|
||||
db.UgcPurchasables.Add(item);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(item));
|
||||
}
|
||||
|
||||
[HttpPut("api/ugcPurchasables")]
|
||||
[HttpPost("api/ugcPurchasables/update")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Update()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var item = await FindItemAsync(fields);
|
||||
if (item is null) return NotFound();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == item.RoomId);
|
||||
if (room is null || !await CanManageRoomAsync(room, this.RequireCurrentPlayerId())) return Forbid();
|
||||
|
||||
if (ReadString(fields, "name", "Name") is { } name) item.Name = Trim(name, 128);
|
||||
if (ReadString(fields, "description", "Description") is { } description) item.Description = Trim(description, 1024);
|
||||
if (ReadString(fields, "imageName", "ImageName") is { } imageName) item.ImageName = Trim(imageName, 256);
|
||||
if (ReadInt(fields, "price", "Price") is int price) item.Price = Math.Max(0, price);
|
||||
if (ReadInt(fields, "currencyType", "CurrencyType") is int currencyType) item.CurrencyType = Math.Max(0, currencyType);
|
||||
if (ReadInt(fields, "itemType", "ItemType") is int itemType) item.ItemType = Math.Max(0, itemType);
|
||||
if (ReadInt(fields, "sortOrder", "SortOrder") is int sortOrder) item.SortOrder = sortOrder;
|
||||
if (ReadBool(fields, "isFeatured", "IsFeatured") is bool featured) item.IsFeatured = featured;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(item));
|
||||
}
|
||||
|
||||
[HttpDelete("api/ugcPurchasables")]
|
||||
[HttpPost("api/ugcPurchasables/delete")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Delete()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var item = await FindItemAsync(fields);
|
||||
if (item is null) return NotFound();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == item.RoomId);
|
||||
if (room is null || !await CanManageRoomAsync(room, this.RequireCurrentPlayerId())) return Forbid();
|
||||
|
||||
item.IsDeleted = true;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(item));
|
||||
}
|
||||
|
||||
[HttpGet("api/ugcPurchasables/v1/items/bulk")]
|
||||
[HttpPost("api/ugcPurchasables/v1/items/bulk")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Bulk()
|
||||
{
|
||||
// The 2023 client posts {"RoomId":N,"Ids":[{"itemType":1,
|
||||
// "itemId":"<guid>"}]} — Ids is an array of OBJECTS, not flat guid
|
||||
// strings. The generic field-flattener collapses each object to its
|
||||
// JSON text, so the old ReadGuidList parsed ZERO guids and this
|
||||
// endpoint always returned [] — the client's UGC resolver then
|
||||
// re-queried the same ids forever (the growing-list flood in the
|
||||
// server log), the referenced items never resolved, and the online
|
||||
// player/avatar spawn null-reffed on an unresolved custom item
|
||||
// (reportjoinresult FailedToSpawnPlayer → infinite dorm bounce).
|
||||
var requested = await ReadBulkIdsAsync();
|
||||
if (requested.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var ids = requested.Select(r => r.Id).ToList();
|
||||
var rows = await db.UgcPurchasables
|
||||
.Where(i => !i.IsDeleted && ids.Contains(i.PublicId))
|
||||
.ToListAsync();
|
||||
var byId = rows.ToDictionary(r => r.PublicId);
|
||||
|
||||
// Return an entry for EVERY requested id, in request order. Items not
|
||||
// in our DB (custom UGC the player owns from real Rec Room that we
|
||||
// never ingested) get a synthetic placeholder so the client treats
|
||||
// the reference as resolved instead of spinning.
|
||||
var result = requested
|
||||
.Select(r => byId.TryGetValue(r.Id, out var row) ? ToWire(row) : SyntheticWire(r.Id, r.ItemType))
|
||||
.ToList();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Parse the bulk request's item ids. Handles the 2023 client's
|
||||
/// JSON object-array shape (<c>Ids:[{itemType,itemId}]</c>) as well as
|
||||
/// flat guid lists in the body/form/query for older callers.</summary>
|
||||
private async Task<List<(Guid Id, int ItemType)>> ReadBulkIdsAsync()
|
||||
{
|
||||
var list = new List<(Guid, int)>();
|
||||
var seen = new HashSet<Guid>();
|
||||
void Add(Guid id, int itemType)
|
||||
{
|
||||
if (id != Guid.Empty && seen.Add(id)) list.Add((id, itemType));
|
||||
}
|
||||
|
||||
if ((Request.ContentLength ?? 0) > 0
|
||||
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object
|
||||
&& (TryGetPropertyCI(root, "Ids", out var idsEl)
|
||||
|| TryGetPropertyCI(root, "itemIds", out idsEl)
|
||||
|| TryGetPropertyCI(root, "ugcPurchasableIds", out idsEl))
|
||||
&& idsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var el in idsEl.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind == JsonValueKind.String && Guid.TryParse(el.GetString(), out var flat))
|
||||
{
|
||||
Add(flat, 0);
|
||||
}
|
||||
else if (el.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var itemType = TryGetPropertyCI(el, "itemType", out var it) && it.TryGetInt32(out var n) ? n : 0;
|
||||
if ((TryGetPropertyCI(el, "itemId", out var idEl)
|
||||
|| TryGetPropertyCI(el, "id", out idEl)
|
||||
|| TryGetPropertyCI(el, "ugcPurchasableId", out idEl))
|
||||
&& Guid.TryParse(idEl.GetString(), out var guid))
|
||||
{
|
||||
Add(guid, itemType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: flat guid lists in form/query (older callers).
|
||||
if (list.Count == 0)
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
foreach (var id in ReadGuidList(fields, "ids", "Ids", "itemIds", "ItemIds", "ugcPurchasableIds", "UgcPurchasableIds"))
|
||||
Add(id, 0);
|
||||
}
|
||||
|
||||
return list.Take(200).ToList();
|
||||
}
|
||||
|
||||
private static bool TryGetPropertyCI(JsonElement obj, string name, out JsonElement value)
|
||||
{
|
||||
foreach (var prop in obj.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = prop.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Minimal placeholder for a requested ugc item we don't have a
|
||||
/// row for. Same wire shape as <see cref="ToWire"/> so the client's
|
||||
/// deserializer resolves the reference; zero price / empty metadata.</summary>
|
||||
private static object SyntheticWire(Guid id, int itemType) => new
|
||||
{
|
||||
UgcPurchasableId = id,
|
||||
PurchasableItemId = id,
|
||||
Id = id,
|
||||
InternalId = 0L,
|
||||
RoomId = 0L,
|
||||
CreatorPlayerId = 0,
|
||||
Name = string.Empty,
|
||||
Description = string.Empty,
|
||||
ImageName = string.Empty,
|
||||
Price = 0,
|
||||
CurrencyType = 2,
|
||||
ItemType = itemType,
|
||||
IsFeatured = false,
|
||||
SortOrder = 0,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private async Task<UgcPurchasableEntity?> FindItemAsync(Dictionary<string, string> fields)
|
||||
{
|
||||
var publicId = ReadGuid(fields, "ugcPurchasableId", "UgcPurchasableId", "itemId", "ItemId", "id", "Id");
|
||||
if (publicId is Guid guid)
|
||||
return await db.UgcPurchasables.FirstOrDefaultAsync(i => i.PublicId == guid && !i.IsDeleted);
|
||||
var localId = ReadLong(fields, "internalId", "ugcPurchasableInternalId");
|
||||
if (localId is long id)
|
||||
return await db.UgcPurchasables.FirstOrDefaultAsync(i => i.Id == id && !i.IsDeleted);
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<bool> CanManageRoomAsync(RoomEntity room, long playerId)
|
||||
{
|
||||
if (room.CreatorPlayerId == playerId) return true;
|
||||
if (await db.RoomRoles.AnyAsync(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Role == 0 && r.Accepted))
|
||||
return true;
|
||||
return await db.Players.Where(p => p.Id == playerId).Select(p => p.IsAdmin).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> ReadFieldsAsync()
|
||||
{
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in Request.Query)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var pair in form)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
else if ((Request.ContentLength ?? 0) > 0
|
||||
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
fields[prop.Name] = prop.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())),
|
||||
JsonValueKind.String => prop.Value.GetString() ?? string.Empty,
|
||||
_ => prop.Value.GetRawText(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static string? ReadString(Dictionary<string, string> fields, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static long? ReadLong(Dictionary<string, string> fields, params string[] names) =>
|
||||
long.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||
|
||||
private static int? ReadInt(Dictionary<string, string> fields, params string[] names) =>
|
||||
int.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||
|
||||
private static bool? ReadBool(Dictionary<string, string> fields, params string[] names) =>
|
||||
bool.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||
|
||||
private static Guid? ReadGuid(Dictionary<string, string> fields, params string[] names) =>
|
||||
Guid.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||
|
||||
private static List<Guid> ReadGuidList(Dictionary<string, string> fields, params string[] names) =>
|
||||
names.SelectMany(name => fields.TryGetValue(name, out var value)
|
||||
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
: Array.Empty<string>())
|
||||
.Select(v => Guid.TryParse(v, out var id) ? id : Guid.Empty)
|
||||
.Where(id => id != Guid.Empty)
|
||||
.Distinct()
|
||||
.Take(200)
|
||||
.ToList();
|
||||
|
||||
private static string Trim(string? value, int max)
|
||||
{
|
||||
var s = (value ?? string.Empty).Trim();
|
||||
return s.Length <= max ? s : s[..max];
|
||||
}
|
||||
|
||||
private static object ToWire(UgcPurchasableEntity item) => new
|
||||
{
|
||||
UgcPurchasableId = item.PublicId,
|
||||
PurchasableItemId = item.PublicId,
|
||||
Id = item.PublicId,
|
||||
InternalId = item.Id,
|
||||
item.RoomId,
|
||||
CreatorPlayerId = (int)item.CreatorPlayerId,
|
||||
item.Name,
|
||||
item.Description,
|
||||
item.ImageName,
|
||||
item.Price,
|
||||
item.CurrencyType,
|
||||
item.ItemType,
|
||||
item.IsFeatured,
|
||||
item.SortOrder,
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt,
|
||||
};
|
||||
}
|
||||
@@ -73,9 +73,11 @@ public class AccountsController(
|
||||
private async Task<Data.Entities.PlayerEntity?> GetCurrentPlayerAsync()
|
||||
{
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
|
||||
? auth["Bearer ".Length..].Trim()
|
||||
: Request.Cookies[AuthService.AccessCookieName];
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return null;
|
||||
var token = auth["Bearer ".Length..].Trim();
|
||||
var id = authService.ValidateToken(token);
|
||||
if (id is not long playerId) return null;
|
||||
return await playerService.GetByIdAsync(playerId);
|
||||
@@ -170,6 +172,7 @@ public class AccountsController(
|
||||
// Accounts.SearchForAccounts — used by the watch's typeahead AND the
|
||||
// localhost public site's account search. Anonymous-safe (no JWT
|
||||
// required) so the front-facing site can call it directly.
|
||||
[HttpGet("/account/search")]
|
||||
[HttpGet("/account/v1/search")]
|
||||
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Search([FromQuery] string? query, [FromQuery] int take = 20)
|
||||
@@ -405,6 +408,26 @@ public class AccountsController(
|
||||
return age;
|
||||
}
|
||||
|
||||
private async Task SetPlayerSettingAsync(long playerId, string key, string value)
|
||||
{
|
||||
var row = await db.PlayerSettings.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key);
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Key = key,
|
||||
Value = value,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = value;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Everyone is a dev for now — exposes the in-game debug console + dev
|
||||
// tools. Per user request: easier debugging while iterating on the
|
||||
// private server. Flip back to false later if you want shipping behavior.
|
||||
@@ -509,6 +532,120 @@ public class AccountsController(
|
||||
[HttpGet("/account/v1/me/disallowiap")]
|
||||
public IActionResult DisallowIap() => Ok(new { Disallow = false });
|
||||
|
||||
[HttpGet("/account/isactivecreator/me")]
|
||||
public async Task<IActionResult> IsActiveCreator()
|
||||
{
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is not long me)
|
||||
return Content("false", "application/json");
|
||||
|
||||
var active = await db.Rooms.AnyAsync(r => r.CreatorPlayerId == me)
|
||||
|| await db.Inventions.AnyAsync(i => i.CreatorPlayerId == me && !i.IsDeleted)
|
||||
|| await db.CustomAvatarItems.AnyAsync(i => i.CreatorPlayerId == me);
|
||||
return Content(active ? "true" : "false", "application/json");
|
||||
}
|
||||
|
||||
[HttpPost("/account/me/createlogintoken")]
|
||||
[HttpGet("/account/me/createlogintoken")]
|
||||
public async Task<IActionResult> CreateLoginToken()
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
var expiresAt = DateTime.UtcNow.AddMinutes(10);
|
||||
await SetPlayerSettingAsync(player.Id, "remote_login_token", $"{token}|{expiresAt:O}");
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Error = string.Empty,
|
||||
Token = token,
|
||||
LoginToken = token,
|
||||
ExpiresAt = expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/account/me/remoteauth")]
|
||||
[HttpGet("/account/me/remoteauth")]
|
||||
public async Task<IActionResult> RemoteAuth()
|
||||
{
|
||||
var token = Request.Query["token"].FirstOrDefault()
|
||||
?? Request.Query["loginToken"].FirstOrDefault()
|
||||
?? Request.Query["code"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(token) && Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
token = form["token"].FirstOrDefault()
|
||||
?? form["loginToken"].FirstOrDefault()
|
||||
?? form["code"].FirstOrDefault();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
var pair = authService.GenerateTokenPair(player.Id);
|
||||
return Ok(new LoginResponse
|
||||
{
|
||||
AccessToken = pair.AccessToken,
|
||||
RefreshToken = pair.RefreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
var prefix = token.Trim() + "|";
|
||||
var row = await db.PlayerSettings
|
||||
.Where(s => s.Key == "remote_login_token" && s.Value.StartsWith(prefix))
|
||||
.OrderByDescending(s => s.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (row is null) return Unauthorized();
|
||||
|
||||
var rawExpiry = row.Value[prefix.Length..];
|
||||
if (!DateTime.TryParse(rawExpiry, out var expiresAt) || expiresAt < DateTime.UtcNow)
|
||||
return Unauthorized();
|
||||
|
||||
db.PlayerSettings.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
var (access, refresh) = authService.GenerateTokenPair(row.PlayerId);
|
||||
return Ok(new LoginResponse
|
||||
{
|
||||
AccessToken = access,
|
||||
RefreshToken = refresh,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/account/recoverpassword")]
|
||||
[HttpGet("/account/recoverpassword")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> RecoverPassword(
|
||||
[FromForm] string? username,
|
||||
[FromForm] string? email)
|
||||
{
|
||||
var nameOrEmail = username
|
||||
?? email
|
||||
?? Request.Query["username"].FirstOrDefault()
|
||||
?? Request.Query["email"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(nameOrEmail))
|
||||
return Ok(new RecNetResult { Success = false, Error = "missing_account" });
|
||||
|
||||
var needle = nameOrEmail.Trim().ToLowerInvariant();
|
||||
var player = await db.Players
|
||||
.FirstOrDefaultAsync(p => p.Username.ToLower() == needle
|
||||
|| (p.Email != null && p.Email.ToLower() == needle));
|
||||
if (player is null)
|
||||
return Ok(new RecNetResult { Success = false, Error = "unknown_account" });
|
||||
|
||||
var code = Random.Shared.Next(100000, 999999).ToString();
|
||||
var expiresAt = DateTime.UtcNow.AddMinutes(30);
|
||||
await SetPlayerSettingAsync(player.Id, "password_recovery_code", $"{code}|{expiresAt:O}");
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Error = string.Empty,
|
||||
Code = code,
|
||||
ExpiresAt = expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET /account/{id}/bio — public bio lookup. Watch URL
|
||||
/// from <c>Cpp2IL_ISIL/.../RecNet/Accounts.txt</c> line 5686:
|
||||
/// <c>"account/{0}/bio"</c> (no v1 prefix). Wire shape from
|
||||
@@ -530,6 +667,31 @@ public class AccountsController(
|
||||
[HttpGet("/account/v1/{accountId:long}/platformid")]
|
||||
public IActionResult GetPlatformId(long accountId) => Ok(new { PlatformId = 0L });
|
||||
|
||||
[HttpGet("/accountprivacysettings/{accountId:long}")]
|
||||
[HttpGet("/account/v1/privacysettings/{accountId:long}")]
|
||||
[HttpGet("/account/{accountId:long}/privacysettings")]
|
||||
public IActionResult GetAccountPrivacySettings(long accountId) => Ok(new
|
||||
{
|
||||
AccountId = (int)accountId,
|
||||
IsProfileVisible = true,
|
||||
IsOnlineStatusVisible = true,
|
||||
IsActivityVisible = true,
|
||||
ShowOnlineStatus = true,
|
||||
ShowActivity = true,
|
||||
ShowCurrentRoom = true,
|
||||
ReceiveFriendRequests = true,
|
||||
ReceiveInvites = true,
|
||||
ReceiveMessages = true,
|
||||
WhoCanSeeOnlineStatus = 0,
|
||||
WhoCanSeeActivity = 0,
|
||||
WhoCanSeeCurrentRoom = 0,
|
||||
WhoCanSendFriendRequests = 0,
|
||||
WhoCanInviteMe = 0,
|
||||
WhoCanMessageMe = 0,
|
||||
VoicePrivacy = 0,
|
||||
TextChatPrivacy = 0,
|
||||
});
|
||||
|
||||
// Previously had a /{*path} catch-all that returned [] / {}. Removed —
|
||||
// unknown accounts URLs now 404 so we notice them in logs and wire
|
||||
// them properly rather than silently masking missing endpoints.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
@@ -39,6 +40,7 @@ public class AdminController(
|
||||
SignupCodeService signupCodes,
|
||||
IObjectStorage storage,
|
||||
DomainConfig domain,
|
||||
IdentityServerSigningKeyProvider signingKeys,
|
||||
ILogger<AdminController> adminLogger) : ControllerBase
|
||||
{
|
||||
private long CurrentAdminId => this.RequireCurrentPlayerId();
|
||||
@@ -404,6 +406,7 @@ public class AdminController(
|
||||
r.IsAGRoom,
|
||||
r.IsDormRoom,
|
||||
r.CreatorPlayerId,
|
||||
r.State,
|
||||
BlobCount = db.RoomDataBlobs.Count(b => b.RoomId == r.Id),
|
||||
})
|
||||
.ToListAsync();
|
||||
@@ -448,6 +451,21 @@ public class AdminController(
|
||||
return Ok(new { room.Id, room.State });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/rooms/{id}/restore</c> — the inverse of
|
||||
/// the soft-archive above: clears State back to 0 (Active) so the room
|
||||
/// shows up in browse/search again. Idempotent; safe on a non-archived
|
||||
/// room (just re-asserts State=0).</summary>
|
||||
[HttpPost("rooms/{id:long}/restore")]
|
||||
public async Task<ActionResult> RestoreRoom(long id)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (room is null) return NotFound();
|
||||
room.State = 0; // Active
|
||||
await LogAsync("restore_room", "room", id, "");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { room.Id, room.State });
|
||||
}
|
||||
|
||||
// The bulk "purge every custom room" endpoint was removed: a single
|
||||
// wrong click could wipe every imported map in one shot. Per-room
|
||||
// purge (below) is the only hard-delete path, gated by two
|
||||
@@ -487,8 +505,11 @@ public class AdminController(
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (room is null) return NotFound();
|
||||
if (room.IsDormRoom)
|
||||
return BadRequest(new { error = "refuses_to_hard_delete_dorm", note = "Dorms are per-player state; never purge." });
|
||||
// Dorms are per-player state — purging one wipes that player's dorm
|
||||
// contents, but it's recoverable: EnsurePersonalDormAsync regenerates
|
||||
// a fresh dorm on their next join. An admin sometimes needs to nuke a
|
||||
// corrupted/test dorm, so we ALLOW it; the two typed-name
|
||||
// confirmations below are the guard against an accidental wipe.
|
||||
if (room.IsAGRoom && room.CreatorPlayerId == PlayerService.SystemAccountId)
|
||||
return BadRequest(new { error = "refuses_to_hard_delete_seeded_room", note = "This row is owned by the system seed account — soft-archive via DELETE /rooms/{id} instead." });
|
||||
|
||||
@@ -1460,6 +1481,53 @@ public class AdminController(
|
||||
|
||||
// ── Server settings (runtime toggles) ────────────────────────────────
|
||||
|
||||
[HttpGet("identityserver")]
|
||||
public ActionResult GetIdentityServerSettings()
|
||||
{
|
||||
var issuer = domain.AuthIssuer.TrimEnd('/');
|
||||
var client = RecRoomIdentityServerConfig.Clients[0];
|
||||
var certPath = signingKeys.CertificatePath;
|
||||
var fullCertPath = Path.GetFullPath(certPath);
|
||||
var certificateExists = signingKeys.LoadedFromBase64 || System.IO.File.Exists(fullCertPath);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Issuer = issuer,
|
||||
DiscoveryUrl = $"{issuer}/.well-known/openid-configuration",
|
||||
TokenEndpoint = $"{issuer}/connect/token",
|
||||
UserInfoEndpoint = $"{issuer}/connect/userinfo",
|
||||
Client = new
|
||||
{
|
||||
client.ClientId,
|
||||
client.ClientName,
|
||||
AllowedGrantTypes = client.AllowedGrantTypes.ToArray(),
|
||||
AllowedScopes = client.AllowedScopes.ToArray(),
|
||||
client.AllowOfflineAccess,
|
||||
client.AccessTokenLifetime,
|
||||
client.AbsoluteRefreshTokenLifetime,
|
||||
client.SlidingRefreshTokenLifetime,
|
||||
RefreshTokenUsage = client.RefreshTokenUsage.ToString(),
|
||||
RefreshTokenExpiration = client.RefreshTokenExpiration.ToString(),
|
||||
},
|
||||
Signing = new
|
||||
{
|
||||
signingKeys.CertificateSource,
|
||||
signingKeys.LoadedFromBase64,
|
||||
CertificatePath = fullCertPath,
|
||||
CertificateExists = certificateExists,
|
||||
signingKeys.Certificate.Thumbprint,
|
||||
NotBeforeUtc = signingKeys.Certificate.NotBefore.ToUniversalTime(),
|
||||
NotAfterUtc = signingKeys.Certificate.NotAfter.ToUniversalTime(),
|
||||
Algorithm = SecurityAlgorithms.RsaSha256,
|
||||
},
|
||||
PersistedGrants = new
|
||||
{
|
||||
Store = "in-memory",
|
||||
Warning = "Refresh tokens are process-local until an operational store is configured.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/settings</c> — current server-wide
|
||||
/// toggle state. Single row, single round trip.</summary>
|
||||
[HttpGet("settings")]
|
||||
@@ -1609,16 +1677,18 @@ public class AdminController(
|
||||
|
||||
public sealed record PlayMenuTagsRequest(
|
||||
List<string>? PinnedTags,
|
||||
List<string>? PopularTags);
|
||||
List<string>? PopularTags,
|
||||
List<string>? TrendingTags);
|
||||
|
||||
[HttpPost("settings/play-menu-tags")]
|
||||
public async Task<ActionResult> SetPlayMenuTags([FromBody] PlayMenuTagsRequest body)
|
||||
{
|
||||
var tags = await serverSettings.SetPlayMenuTagsAsync(
|
||||
body.PinnedTags ?? [],
|
||||
body.PopularTags ?? []);
|
||||
body.PopularTags ?? [],
|
||||
body.TrendingTags ?? []);
|
||||
await LogAsync("play_menu_tags_updated", "system", 0,
|
||||
$"pinned={string.Join(",", tags.PinnedTags)} popular={string.Join(",", tags.PopularTags)}");
|
||||
$"pinned={string.Join(",", tags.PinnedTags)} popular={string.Join(",", tags.PopularTags)} trending={string.Join(",", tags.TrendingTags)}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(tags);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ public class RoomZipImportController(
|
||||
ILogger<RoomZipImportController> logger) : ControllerBase
|
||||
{
|
||||
private long CurrentAdminId => this.RequireCurrentPlayerId();
|
||||
private const long MaxArchiveBytes = 25_000_000_000L;
|
||||
|
||||
// ── Room-blob normaliser toggle ─────────────────────────────────────
|
||||
// The normaliser does two things now (see RoomBlobNormalizerService):
|
||||
@@ -128,6 +129,7 @@ public class RoomZipImportController(
|
||||
".mp4" => "video/mp4",
|
||||
".htr" => "application/octet-stream",
|
||||
".room" => "application/octet-stream",
|
||||
".assetbundle" => "application/octet-stream",
|
||||
".meta" => "application/octet-stream",
|
||||
".inv" => "application/octet-stream",
|
||||
".dat" => "application/octet-stream",
|
||||
@@ -138,6 +140,14 @@ public class RoomZipImportController(
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
// RecNet serializes large snowflake-style IDs (SubRoomId, save IDs)
|
||||
// as JSON strings to avoid JS 2^53 precision loss, while smaller/older
|
||||
// IDs stay as raw numbers — and a single SubRooms[] array can mix both
|
||||
// forms (Showdown's export is numeric for entries 0-17, then string
|
||||
// from "SD_Archive_120822_Catalyst" on). Allow numeric properties to
|
||||
// read from either token so the import doesn't crash on the first
|
||||
// string-encoded ID.
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString,
|
||||
};
|
||||
|
||||
// ── RoomDetails.json DTOs ──────────────────────────────────────────
|
||||
@@ -220,6 +230,7 @@ public class RoomZipImportController(
|
||||
/// missing, the importer derives it from the SubRooms/<scene>
|
||||
/// folder name suffix (<c>SubRoomName__<subRoomId></c>).</summary>
|
||||
public long? SubRoomId { get; set; }
|
||||
public string? UnityAssetId { get; set; }
|
||||
public string? DataBlob { get; set; }
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
public string? Description { get; set; }
|
||||
@@ -268,7 +279,7 @@ public class RoomZipImportController(
|
||||
|
||||
[HttpPost("zip-bulk-import")]
|
||||
[DisableRequestSizeLimit]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 5_000_000_000)] // 5 GB
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = MaxArchiveBytes)]
|
||||
public async Task<IActionResult> ZipImport(
|
||||
[FromForm(Name = "archive")] IFormFile? archive,
|
||||
[FromForm(Name = "creatorPlayerId")] long? creatorPlayerId,
|
||||
@@ -320,8 +331,8 @@ public class RoomZipImportController(
|
||||
[HttpPost("zip-upload-init")]
|
||||
public ActionResult ChunkInit([FromBody] ChunkInitRequest body)
|
||||
{
|
||||
if (body.TotalBytes <= 0 || body.TotalBytes > 5_000_000_000)
|
||||
return BadRequest(new { error = "invalid_size", maxBytes = 5_000_000_000L, gotBytes = body.TotalBytes });
|
||||
if (body.TotalBytes <= 0 || body.TotalBytes > MaxArchiveBytes)
|
||||
return BadRequest(new { error = "invalid_size", maxBytes = MaxArchiveBytes, gotBytes = body.TotalBytes });
|
||||
|
||||
// Prune stale sessions before allocating a new one. Anything
|
||||
// older than 2 hours with no activity is abandoned.
|
||||
@@ -914,9 +925,11 @@ public class RoomZipImportController(
|
||||
// it reflects whether the diagnostic parse succeeded, not
|
||||
// whether we actually applied the normalised output.
|
||||
NormalizedOk: norm.Normalized,
|
||||
StudioUnityAssetId: null,
|
||||
HtrAssets: htrAssets,
|
||||
PvImages: pvImages,
|
||||
Polaroids: polaroids,
|
||||
AssetBundles: new List<(string Name, ZipArchiveEntry Entry)>(),
|
||||
History: history));
|
||||
logger.LogInformation(
|
||||
"[zip-import] room='{Room}' scene='{Scene}' blob={Blob} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol}",
|
||||
@@ -1044,9 +1057,11 @@ public class RoomZipImportController(
|
||||
var htrAssetCount = 0;
|
||||
var pvImageCount = 0;
|
||||
var polaroidCount = 0;
|
||||
var assetBundleCount = 0;
|
||||
var allHtrAssets = ordered.SelectMany(s => s.HtrAssets).ToList();
|
||||
var allPvImages = ordered.SelectMany(s => s.PvImages).ToList();
|
||||
var allPolaroids = ordered.SelectMany(s => s.Polaroids).ToList();
|
||||
var allAssetBundles = ordered.SelectMany(s => s.AssetBundles).ToList();
|
||||
|
||||
// De-dupe within the archive first (same .htr can appear under
|
||||
// multiple scenes), then de-dupe against the DB. Both layers
|
||||
@@ -1054,6 +1069,7 @@ public class RoomZipImportController(
|
||||
var uniqueHtrNames = allHtrAssets.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var uniquePvNames = allPvImages.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var uniquePolaroidNames = allPolaroids.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var uniqueAssetBundleNames = allAssetBundles.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
var existingHtrNames = new HashSet<string>(
|
||||
await db.RoomDataBlobs
|
||||
@@ -1070,9 +1086,15 @@ public class RoomZipImportController(
|
||||
.Where(b => uniquePolaroidNames.Contains(b.BlobName))
|
||||
.Select(b => b.BlobName).ToListAsync(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var existingAssetBundleNames = new HashSet<string>(
|
||||
await db.RoomDataBlobs
|
||||
.Where(b => uniqueAssetBundleNames.Contains(b.BlobName))
|
||||
.Select(b => b.BlobName).ToListAsync(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var htrAlreadyInDbAtStart = existingHtrNames.Count;
|
||||
var pvAlreadyInDbAtStart = existingPvNames.Count;
|
||||
var polaroidAlreadyInDbAtStart = existingPolaroidNames.Count;
|
||||
var assetBundleAlreadyInDbAtStart = existingAssetBundleNames.Count;
|
||||
|
||||
// History (SubRooms/<scene>/History/<DataBlob>.room) — pre-load
|
||||
// existing names so the de-dup HashSet covers prior imports + same
|
||||
@@ -1173,6 +1195,17 @@ public class RoomZipImportController(
|
||||
await WriteBlobAsync(0, polName, bytes, creator);
|
||||
polaroidCount++;
|
||||
}
|
||||
// Studio baked Unity asset bundles. Keep original filenames;
|
||||
// Studio save metadata points the 2023 client at
|
||||
// cdn.../room/<filename>.assetbundle.
|
||||
foreach (var (bundleName, bundleEntry) in allAssetBundles)
|
||||
{
|
||||
if (existingAssetBundleNames.Contains(bundleName)) continue;
|
||||
existingAssetBundleNames.Add(bundleName);
|
||||
var bytes = await ReadAllAsync(bundleEntry);
|
||||
await WriteBlobAsync(0, bundleName, bytes, creator);
|
||||
assetBundleCount++;
|
||||
}
|
||||
// Room-root opaque marker files (e.g. <hash>.meta — 6-byte
|
||||
// sentinel the exporter drops, purpose unclear but watching
|
||||
// for fetches just in case). Rename to room_<id>_<hash>.meta
|
||||
@@ -1234,6 +1267,9 @@ public class RoomZipImportController(
|
||||
Accessibility = details.Accessibility ?? 1,
|
||||
IsAGRoom = false,
|
||||
IsDormRoom = details.IsDorm ?? false,
|
||||
IsStudioRoom = entries.ContainsKey(studioDetailsKey),
|
||||
IsRoomLinkedToRecRoomStudio = entries.ContainsKey(studioDetailsKey),
|
||||
StudioSessionId = details.RoomId?.ToString() ?? TryParseStudioId(ZipFileName(roomFolder))?.ToString() ?? string.Empty,
|
||||
CloningAllowed = details.CloningAllowed ?? true,
|
||||
SupportsLevelVoting = details.SupportsLevelVoting ?? false,
|
||||
SupportsVRLow = details.SupportsVRLow ?? true,
|
||||
@@ -1246,7 +1282,7 @@ public class RoomZipImportController(
|
||||
CustomRoomWarning = details.CustomWarning ?? string.Empty,
|
||||
DisableMicAutoMute = details.DisableMicAutoMute ?? false,
|
||||
LocationReplicationId = entryLocation,
|
||||
TagsCsv = JoinTags(details.Tags),
|
||||
TagsCsv = EnsureStudioTags(JoinTags(details.Tags), entries.ContainsKey(studioDetailsKey)),
|
||||
CheerCount = details.Stats?.CheerCount ?? 0,
|
||||
FavoriteCount = details.Stats?.FavoriteCount ?? 0,
|
||||
VisitCount = details.Stats?.VisitCount ?? 0,
|
||||
@@ -1266,6 +1302,11 @@ public class RoomZipImportController(
|
||||
Name = s.SceneName,
|
||||
RoomSceneLocationId = s.Manifest.UnitySceneId ?? entryLocation,
|
||||
DataBlobName = MakeBlobName(s.SceneName),
|
||||
StudioSubRoomDataSaveId = s.Manifest.CurrentSave?.SubRoomDataSaveId,
|
||||
StudioUnityAssetId = s.StudioUnityAssetId ?? string.Empty,
|
||||
StudioAssetBundleNamesCsv = string.Join(',', s.AssetBundles
|
||||
.Select(a => a.Name)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)),
|
||||
MaxPlayers = s.Manifest.MaxPlayers ?? 8,
|
||||
IsSandbox = s.Manifest.IsSandbox ?? false,
|
||||
CanMatchmakeInto = true,
|
||||
@@ -1282,7 +1323,8 @@ public class RoomZipImportController(
|
||||
Reason = $"name={name} scenes={ordered.Count} entry={entry.SceneName} src={entrySource} " +
|
||||
$"htr={htrAssetCount}new+{htrAlreadyInDbAtStart}existed " +
|
||||
$"pv={pvImageCount}new+{pvAlreadyInDbAtStart}existed " +
|
||||
$"polaroids={polaroidCount}new+{polaroidAlreadyInDbAtStart}existed",
|
||||
$"polaroids={polaroidCount}new+{polaroidAlreadyInDbAtStart}existed " +
|
||||
$"bundles={assetBundleCount}new+{assetBundleAlreadyInDbAtStart}existed",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
@@ -1318,6 +1360,12 @@ public class RoomZipImportController(
|
||||
newlyImported = polaroidCount,
|
||||
alreadyInDb = polaroidAlreadyInDbAtStart,
|
||||
},
|
||||
assetBundles = new
|
||||
{
|
||||
referenced = uniqueAssetBundleNames.Count,
|
||||
newlyImported = assetBundleCount,
|
||||
alreadyInDb = assetBundleAlreadyInDbAtStart,
|
||||
},
|
||||
history = new
|
||||
{
|
||||
referenced = allHistoryNames.Count,
|
||||
@@ -1350,6 +1398,7 @@ public class RoomZipImportController(
|
||||
maxPlayers = s.Manifest.MaxPlayers,
|
||||
htrAssets = s.HtrAssets.Count,
|
||||
pvImages = s.PvImages.Count,
|
||||
assetBundles = s.AssetBundles.Count,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1430,9 +1479,19 @@ public class RoomZipImportController(
|
||||
var htrAssets = new List<(string Name, ZipArchiveEntry Entry)>();
|
||||
var pvImages = new List<(string Name, ZipArchiveEntry Entry)>();
|
||||
var polaroids = new List<(string Name, ZipArchiveEntry Entry)>();
|
||||
var assetBundles = new List<(string Name, ZipArchiveEntry Entry)>();
|
||||
|
||||
foreach (var save in saves)
|
||||
{
|
||||
var bundlePrefix = $"{savesPrefix}{save.SaveId}__bundle_";
|
||||
foreach (var (path, assetEntry) in entries.Where(kv =>
|
||||
kv.Key.StartsWith(bundlePrefix, StringComparison.OrdinalIgnoreCase)
|
||||
&& kv.Key.EndsWith(".assetbundle", StringComparison.OrdinalIgnoreCase)
|
||||
&& !kv.Key[bundlePrefix.Length..].Contains('/')))
|
||||
{
|
||||
assetBundles.Add((Path.GetFileName(path), assetEntry));
|
||||
}
|
||||
|
||||
var refPrefix = $"{savesPrefix}{save.SaveId}__ref_";
|
||||
foreach (var (path, assetEntry) in entries.Where(kv =>
|
||||
kv.Key.StartsWith(refPrefix, StringComparison.OrdinalIgnoreCase)
|
||||
@@ -1481,16 +1540,18 @@ public class RoomZipImportController(
|
||||
Bytes: persistedBytes,
|
||||
RawBytes: blobBytes.Length,
|
||||
NormalizedOk: norm.Normalized,
|
||||
StudioUnityAssetId: current.Sidecar.UnityAssetId,
|
||||
HtrAssets: htrAssets,
|
||||
PvImages: pvImages,
|
||||
Polaroids: polaroids,
|
||||
AssetBundles: assetBundles,
|
||||
History: history));
|
||||
|
||||
logger.LogInformation(
|
||||
"[zip-import] studio-dump room='{Room}' scene='{Scene}' save={SaveId} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol} history={History}",
|
||||
"[zip-import] studio-dump room='{Room}' scene='{Scene}' save={SaveId} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol} bundles={Bundles} history={History}",
|
||||
roomName, sceneName, current.SaveId, blobBytes.Length, norm.Bytes.Length,
|
||||
normalizeBlobs ? "normaliser" : "raw",
|
||||
htrAssets.Count, pvImages.Count, polaroids.Count, history.Count);
|
||||
htrAssets.Count, pvImages.Count, polaroids.Count, assetBundles.Count, history.Count);
|
||||
}
|
||||
|
||||
return (resolvedScenes, missingScenes);
|
||||
@@ -1791,15 +1852,32 @@ public class RoomZipImportController(
|
||||
return values.Count > 0 ? string.Join(',', values!) : "community";
|
||||
}
|
||||
|
||||
private static string EnsureStudioTags(string tagsCsv, bool isStudioDump)
|
||||
{
|
||||
if (!isStudioDump) return tagsCsv;
|
||||
var values = (tagsCsv ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToList();
|
||||
if (!values.Contains("community", StringComparer.OrdinalIgnoreCase))
|
||||
values.Add("community");
|
||||
if (!values.Contains("studio", StringComparer.OrdinalIgnoreCase))
|
||||
values.Add("studio");
|
||||
if (!values.Contains("developer", StringComparer.OrdinalIgnoreCase))
|
||||
values.Add("developer");
|
||||
return string.Join(',', values);
|
||||
}
|
||||
|
||||
private sealed record ResolvedSubroom(
|
||||
SubRoomDto Manifest,
|
||||
string SceneName,
|
||||
byte[] Bytes,
|
||||
int RawBytes,
|
||||
bool NormalizedOk,
|
||||
string? StudioUnityAssetId,
|
||||
List<(string Name, ZipArchiveEntry Entry)> HtrAssets,
|
||||
List<(string Name, ZipArchiveEntry Entry)> PvImages,
|
||||
List<(string Name, ZipArchiveEntry Entry)> Polaroids,
|
||||
List<(string Name, ZipArchiveEntry Entry)> AssetBundles,
|
||||
List<HistorySave> History);
|
||||
|
||||
/// <summary>One historical save under SubRooms/<scene>/History/.
|
||||
|
||||
@@ -23,7 +23,7 @@ public class AuthAccountController(DorkNetDbContext db) : ControllerBase
|
||||
/// <c>ExpectPrimitiveResponse<bool></c> so the body must be
|
||||
/// the literal JSON <c>true</c> or <c>false</c>.</summary>
|
||||
[HttpGet("/account/me/haspassword")]
|
||||
[HttpGet("/api/platformlogin/haspassword")]
|
||||
[HttpGet("/api/platformlogin/haspassword", Order = 10)]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> HasPassword()
|
||||
{
|
||||
@@ -40,8 +40,8 @@ public class AuthAccountController(DorkNetDbContext db) : ControllerBase
|
||||
/// one; first-time creation skips that check. Form-encoded body.</summary>
|
||||
[HttpPost("/account/me/changepassword")]
|
||||
[HttpPost("/account/me/createpassword")]
|
||||
[HttpPost("/api/platformlogin/password")]
|
||||
[HttpPut("/api/platformlogin/password")]
|
||||
[HttpPost("/api/platformlogin/password", Order = 10)]
|
||||
[HttpPut("/api/platformlogin/password", Order = 10)]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> ChangePassword(
|
||||
@@ -76,7 +76,7 @@ public class AuthAccountController(DorkNetDbContext db) : ControllerBase
|
||||
/// account from the admin tab and the user re-creates it via
|
||||
/// changepassword on next login. Anonymous calls 401.</summary>
|
||||
[HttpPost("/account/me/resetpassword")]
|
||||
[HttpPost("/api/platformlogin/resetpassword")]
|
||||
[HttpPost("/api/platformlogin/resetpassword", Order = 10)]
|
||||
[Authorize]
|
||||
[AdminOnly]
|
||||
public async Task<IActionResult> AdminResetPassword(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Auth;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
@@ -7,241 +6,18 @@ using DorkNet.Server.Services;
|
||||
namespace DorkNet.Server.Controllers.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// auth.rec.net — OAuth-style token endpoint used by 2019+ builds alongside PlatformLogin.
|
||||
/// auth.rec.net game-specific auth helpers. OAuth/OIDC endpoints are owned by IdentityServer.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class AuthController(
|
||||
PlayerService playerService,
|
||||
AuthService authService,
|
||||
LevelService level,
|
||||
OrphanAccountTracker orphans,
|
||||
ServerSettingsService settings,
|
||||
DorkNet.Server.Data.DorkNetDbContext db,
|
||||
DomainConfig domain,
|
||||
ILogger<AuthController> logger) : ControllerBase
|
||||
AuthService authService) : ControllerBase
|
||||
{
|
||||
/// <summary>Award the per-day first-login XP if the calling
|
||||
/// player hasn't been seen yet today (UTC).</summary>
|
||||
private async Task TryAwardLoginXpAsync(long playerId)
|
||||
{
|
||||
var lastSeen = await db.Players
|
||||
.Where(p => p.Id == playerId)
|
||||
.Select(p => p.LastSeenAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (lastSeen.Date < DateTime.UtcNow.Date)
|
||||
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
|
||||
}
|
||||
// OAuth token endpoint. The 2020 client uses three grant types here:
|
||||
// - grant_type=cached_login → user clicked a remembered account on the
|
||||
// account-selection screen. Body includes `account_id` (and platform
|
||||
// creds). MUST issue a token for THAT specific account; otherwise the
|
||||
// client receives a JWT for a different account and prompts the user
|
||||
// to create a new one (because the JWT identity doesn't match what
|
||||
// they picked).
|
||||
// - grant_type=refresh_token → re-issue access token from the existing
|
||||
// refresh JWT.
|
||||
// - grant_type=password → first-time sign-in, resolve via deviceId
|
||||
// (preferred) or fall back to a stable synthetic id derived from
|
||||
// username so legacy callers don't share accounts.
|
||||
[HttpPost("/connect/token")]
|
||||
[Consumes("application/x-www-form-urlencoded")]
|
||||
public async Task<IActionResult> Token(
|
||||
[FromForm] string? grant_type,
|
||||
[FromForm] string? username,
|
||||
[FromForm] string? password,
|
||||
[FromForm] string? refresh_token,
|
||||
[FromForm] string? deviceId,
|
||||
[FromForm(Name = "device_id")] string? device_id_snake,
|
||||
[FromForm] int platform = 0,
|
||||
[FromForm] string? platformId = null,
|
||||
[FromForm(Name = "platform_id")] string? platform_id_snake = null,
|
||||
[FromForm] long? account_id = null)
|
||||
{
|
||||
// The client mixes camelCase and snake_case parameter naming
|
||||
// depending on grant type — accept both and merge.
|
||||
var effDeviceId = !string.IsNullOrWhiteSpace(deviceId) ? deviceId : device_id_snake;
|
||||
var effPlatformId = !string.IsNullOrWhiteSpace(platformId) ? platformId : platform_id_snake;
|
||||
|
||||
if (string.Equals(grant_type, "cached_login", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (account_id is not long pickedId)
|
||||
return BadRequest(new { error = "invalid_request", error_description = "missing account_id" });
|
||||
var picked = await playerService.GetByIdAsync(pickedId);
|
||||
if (picked is null)
|
||||
return Unauthorized(new { error = "invalid_grant", error_description = "unknown account_id" });
|
||||
|
||||
// Orphan cleanup: if the watch just created a brand-new
|
||||
// account on account/create as part of its boot probe, but
|
||||
// the user actually picked a different cached account here,
|
||||
// the just-created row is unused. Delete it so the DB
|
||||
// doesn't accumulate one orphan per game launch. When the
|
||||
// user is genuinely creating a new account, the picked id
|
||||
// matches the just-created id and we leave it.
|
||||
var pendingId = orphans.PeekPending(effDeviceId, effPlatformId);
|
||||
if (pendingId is long orphanId && orphanId != pickedId)
|
||||
{
|
||||
await playerService.DeleteOrphanAsync(orphanId);
|
||||
logger.LogInformation(
|
||||
"[orphan-cleanup] cached_login picked {PickedId}; deleted just-created {OrphanId}",
|
||||
pickedId, orphanId);
|
||||
}
|
||||
orphans.Clear(effDeviceId, effPlatformId);
|
||||
|
||||
await TryAwardLoginXpAsync(picked.Id);
|
||||
await playerService.TagPlatformAsync(picked.Id, platform, effPlatformId);
|
||||
var (acc1, refr1) = authService.GenerateTokenPair(picked.Id);
|
||||
return Ok(new
|
||||
{
|
||||
access_token = acc1,
|
||||
token_type = "Bearer",
|
||||
expires_in = 43200,
|
||||
refresh_token = refr1,
|
||||
scope = "openid profile",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(refresh_token))
|
||||
return BadRequest(new { error = "invalid_request" });
|
||||
var resolved = authService.ValidateToken(refresh_token);
|
||||
if (resolved is not long refreshedId)
|
||||
return Unauthorized(new { error = "invalid_grant" });
|
||||
var existing = await playerService.GetByIdAsync(refreshedId);
|
||||
if (existing is null)
|
||||
return Unauthorized(new { error = "invalid_grant" });
|
||||
var (acc, refr) = authService.GenerateTokenPair(existing.Id);
|
||||
return Ok(new
|
||||
{
|
||||
access_token = acc,
|
||||
token_type = "Bearer",
|
||||
expires_in = 43200,
|
||||
refresh_token = refr,
|
||||
scope = "openid profile",
|
||||
});
|
||||
}
|
||||
|
||||
// password grant: two paths.
|
||||
// • Username + password supplied: real password verify against
|
||||
// PlayerEntity.PasswordHash. Wrong password → 401.
|
||||
// • No password: legacy device-id synth path for the 2020
|
||||
// client's first-launch flow where the player hasn't set a
|
||||
// password yet (HasPassword returns false in the watch UI
|
||||
// and prompts them to create one). Resolves the existing
|
||||
// account (or creates a fresh one) by deviceId.
|
||||
if (!string.IsNullOrEmpty(password) && !string.IsNullOrEmpty(username))
|
||||
{
|
||||
var byName = await playerService.GetByUsernameAsync(username);
|
||||
if (byName is null || byName.PasswordHash is null)
|
||||
return Unauthorized(new
|
||||
{
|
||||
error = "invalid_grant",
|
||||
error_description = "unknown_user_or_no_password",
|
||||
});
|
||||
if (!BCrypt.Net.BCrypt.Verify(password, byName.PasswordHash))
|
||||
return Unauthorized(new
|
||||
{
|
||||
error = "invalid_grant",
|
||||
error_description = "password_mismatch",
|
||||
});
|
||||
// Tag the platform so the next boot's cached-login lookup
|
||||
// finds this account — without this the watch's "remembered
|
||||
// accounts" prompt would stay empty after a manual sign-in.
|
||||
await playerService.TagPlatformAsync(byName.Id, platform, effPlatformId);
|
||||
await TryAwardLoginXpAsync(byName.Id);
|
||||
var pair = authService.GenerateTokenPair(byName.Id);
|
||||
return Ok(new
|
||||
{
|
||||
access_token = pair.AccessToken,
|
||||
token_type = "Bearer",
|
||||
expires_in = 43200,
|
||||
refresh_token = pair.RefreshToken,
|
||||
scope = "openid profile",
|
||||
});
|
||||
}
|
||||
|
||||
// Device-id fallback path. Prefer the real deviceId; fall back
|
||||
// to a stable legacy synthesis from username so older callers
|
||||
// don't accidentally share an account.
|
||||
var effectiveDeviceId = !string.IsNullOrWhiteSpace(effDeviceId)
|
||||
? effDeviceId
|
||||
: $"legacy-{(username ?? "anonymous").ToLowerInvariant()}";
|
||||
|
||||
// Honour the admin "signups disabled" toggle. If the device
|
||||
// hasn't logged in before, GetOrCreateByDeviceAsync would mint
|
||||
// a fresh account here — same outcome as hitting
|
||||
// /account/create directly, which the toggle blocks. Refuse
|
||||
// BEFORE creation so the flag isn't bypassable.
|
||||
if (await settings.AreSignupsDisabledAsync())
|
||||
{
|
||||
var existing = await playerService.GetByDeviceAsync(effectiveDeviceId);
|
||||
if (existing is null)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[auth] /connect/token device fallback refused — signups disabled (device={Device})",
|
||||
effectiveDeviceId);
|
||||
return Unauthorized(new
|
||||
{
|
||||
error = "signups_disabled",
|
||||
error_description = "Account creation is currently disabled by the server admin.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var player = await playerService.GetOrCreateByDeviceAsync(
|
||||
deviceId: effectiveDeviceId,
|
||||
platform: platform,
|
||||
platformId: effPlatformId,
|
||||
displayName: username);
|
||||
await TryAwardLoginXpAsync(player.Id);
|
||||
var (access, refresh) = authService.GenerateTokenPair(player.Id);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
access_token = access,
|
||||
token_type = "Bearer",
|
||||
expires_in = 43200,
|
||||
refresh_token = refresh,
|
||||
scope = "openid profile",
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/connect/userinfo")]
|
||||
public async Task<IActionResult> UserInfo()
|
||||
{
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return Unauthorized();
|
||||
var token = auth["Bearer ".Length..].Trim();
|
||||
var id = authService.ValidateToken(token);
|
||||
if (id is not long playerId) return Unauthorized();
|
||||
var player = await playerService.GetByIdAsync(playerId);
|
||||
if (player is null) return Unauthorized();
|
||||
return Ok(new { sub = player.Id.ToString(), name = player.Username });
|
||||
}
|
||||
|
||||
// Discovery document — issuer + endpoints derived from
|
||||
// DomainConfig so a single DORKNET_DOMAIN env var keeps the
|
||||
// OpenID document pointed at the live deployment.
|
||||
[HttpGet("/.well-known/openid-configuration")]
|
||||
public IActionResult OpenIdConfig()
|
||||
{
|
||||
var base_ = $"https://{domain.Sub("auth")}";
|
||||
return Ok(new
|
||||
{
|
||||
issuer = base_,
|
||||
token_endpoint = $"{base_}/connect/token",
|
||||
userinfo_endpoint = $"{base_}/connect/userinfo",
|
||||
});
|
||||
}
|
||||
|
||||
// 2020.12 added an EAC challenge step in the login flow. The client GETs
|
||||
// a challenge string and passes it to EACManager.GenerateChallengeResponse,
|
||||
// which calls System.Convert.FromBase64CharPtr on the string. So the
|
||||
// returned challenge MUST be valid base64 — sending arbitrary text like
|
||||
// "dorknet-eac-disabled" throws FormatException client-side and kicks
|
||||
// the login. 24 chars of base64 'A's decodes to 18 zero bytes; matches
|
||||
// the rough size of a real EAC handshake nonce.
|
||||
// returned challenge MUST be valid base64 — sending arbitrary text throws
|
||||
// FormatException client-side and kicks the login.
|
||||
[HttpGet("/eac/challenge")]
|
||||
[HttpPost("/eac/challenge")]
|
||||
public IActionResult EacChallenge()
|
||||
@@ -250,11 +26,6 @@ public class AuthController(
|
||||
// Cached-logins lookup — boot-sequence call from ScreenAccountSelectionManager.
|
||||
// URL: GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId}
|
||||
// Returns List<Login.CachedLogin>. Empty array = no remembered accounts.
|
||||
//
|
||||
// Real lookup: any account whose last platformlogin matched
|
||||
// (platform, platformId) shows up here, sorted most-recent-first. On a
|
||||
// single-Steam-emu setup that's one row; on a multi-account dev box
|
||||
// it's the full list the user has cycled through.
|
||||
[HttpGet("/cachedlogin/forplatformid/{platform:int}/{platformId}")]
|
||||
public async Task<IActionResult> CachedLoginsForPlatform(int platform, string platformId)
|
||||
{
|
||||
@@ -264,10 +35,8 @@ public class AuthController(
|
||||
|
||||
/// <summary>POST <c>cachedlogin/forplatformids</c> — bulk variant
|
||||
/// the watch uses when hydrating multiple platform-id mappings
|
||||
/// at once (e.g. populating a friend list from Steam ids). Body
|
||||
/// is form-urlencoded <c>platform=&platformIds=a,b,c</c> or
|
||||
/// JSON with the same shape. Returns the flattened
|
||||
/// <c>List<CachedLogin></c> across every matched id.</summary>
|
||||
/// at once. Body is form-urlencoded <c>platform=&platformIds=a,b,c</c>
|
||||
/// or JSON with the same shape.</summary>
|
||||
[HttpPost("/cachedlogin/forplatformids")]
|
||||
[HttpGet("/cachedlogin/forplatformids")]
|
||||
public async Task<IActionResult> CachedLoginsForPlatformIds()
|
||||
@@ -285,6 +54,7 @@ public class AuthController(
|
||||
}
|
||||
}
|
||||
catch { /* not form */ }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rawIds))
|
||||
{
|
||||
int.TryParse(Request.Query["platform"], out platform);
|
||||
@@ -307,13 +77,50 @@ public class AuthController(
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new()
|
||||
[HttpGet("/cachedlogin/current")]
|
||||
public async Task<IActionResult> CurrentCachedLogin()
|
||||
{
|
||||
var token = GetBearerToken();
|
||||
if (token is null) return Unauthorized();
|
||||
var id = authService.ValidateToken(token);
|
||||
if (id is not long playerId) return Unauthorized();
|
||||
var player = await playerService.GetByIdAsync(playerId);
|
||||
if (player is null) return Unauthorized();
|
||||
return Ok(ToCachedLogin(player, player.LastPlatform, player.LastPlatformId));
|
||||
}
|
||||
|
||||
[HttpPost("/cachedlogin/migrate")]
|
||||
public async Task<IActionResult> MigrateCachedLogin(
|
||||
[FromForm] int platform = 0,
|
||||
[FromForm] string? platformId = null)
|
||||
{
|
||||
var token = GetBearerToken();
|
||||
if (token is null) return Unauthorized();
|
||||
var id = authService.ValidateToken(token);
|
||||
if (id is not long playerId) return Unauthorized();
|
||||
await playerService.TagPlatformAsync(playerId, platform, platformId);
|
||||
var player = await playerService.GetByIdAsync(playerId);
|
||||
return player is null
|
||||
? Unauthorized()
|
||||
: Ok(ToCachedLogin(player, platform, platformId ?? string.Empty));
|
||||
}
|
||||
|
||||
private string? GetBearerToken()
|
||||
{
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return auth["Bearer ".Length..].Trim();
|
||||
return Request.Cookies.TryGetValue(AuthService.AccessCookieName, out var cookieToken)
|
||||
? cookieToken
|
||||
: null;
|
||||
}
|
||||
|
||||
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string? platformId) => new()
|
||||
{
|
||||
Platform = platform,
|
||||
PlatformId = platformId,
|
||||
PlatformId = platformId ?? string.Empty,
|
||||
AccountId = (int)p.Id,
|
||||
LastLoginTime = p.LastSeenAt,
|
||||
RequirePassword = false,
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Auth;
|
||||
|
||||
/// <summary>The boot-time client-version gate the 2020.12 watch hits
|
||||
/// <summary>The boot-time client-version gate the 2023.03.21 client hits
|
||||
/// at startup as <c>GET api/versioncheck/v4?v={build}&p={platform}</c>.
|
||||
///
|
||||
/// <para>Verified against the watch's <c>KMDHPCHFADM.VerifyGameVersion</c>
|
||||
@@ -20,10 +20,9 @@ namespace DorkNet.Server.Controllers.Auth;
|
||||
/// front door — the watch has dedicated UX for it, no custom client
|
||||
/// mod work needed.</para>
|
||||
///
|
||||
/// <para>The <c>v</c> query param is the raw build identifier the watch
|
||||
/// reads from <c>KMJJGELKNAP.MCFMPCNNEBH</c> — for the December build
|
||||
/// that's the literal string <c>"20201210"</c>. We map raw build IDs
|
||||
/// to our internal <see cref="ClientVersion.Key"/>s via the
|
||||
/// <para>The <c>v</c> query param is the raw build identifier supplied
|
||||
/// by the client. We map raw build IDs to our internal
|
||||
/// <see cref="ClientVersion.Key"/>s via the
|
||||
/// <c>DorkNet:BuildIdToVersionKey</c> config map; any build ID we
|
||||
/// don't recognise OR that maps to a key not in
|
||||
/// <see cref="VersionRegistry.SupportedVersionKeys"/> gets
|
||||
@@ -46,6 +45,13 @@ public class VersionCheckController(
|
||||
private const int VersionStatus_ValidForMenu = 1;
|
||||
private const int VersionStatus_UpdateRequired = 2;
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> BuiltInBuildIdToVersionKey =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["20230317"] = "march_2023_03_21",
|
||||
["20230321"] = "march_2023_03_21",
|
||||
};
|
||||
|
||||
[HttpGet("/api/versioncheck/v4")]
|
||||
[HttpGet("/api/versioncheck/v3")]
|
||||
[HttpGet("/api/versioncheck/v2")]
|
||||
@@ -60,15 +66,22 @@ public class VersionCheckController(
|
||||
// Map raw build ID → ClientVersion key via config. Format:
|
||||
// "DorkNet": {
|
||||
// "BuildIdToVersionKey": {
|
||||
// "20201210": "december_2020_12_18",
|
||||
// "20200310": "march_2020_03_10"
|
||||
// "20230321": "march_2023_03_21"
|
||||
// }
|
||||
// }
|
||||
// Mapping ABSENT → unknown build → UpdateRequired.
|
||||
var mapSection = config.GetSection("DorkNet:BuildIdToVersionKey");
|
||||
string? versionKey = !string.IsNullOrWhiteSpace(buildId)
|
||||
? mapSection[buildId]
|
||||
: null;
|
||||
string? versionKey = null;
|
||||
if (!string.IsNullOrWhiteSpace(buildId))
|
||||
{
|
||||
var normalizedBuildId = buildId.Trim();
|
||||
versionKey = mapSection[normalizedBuildId];
|
||||
if (string.IsNullOrWhiteSpace(versionKey)
|
||||
&& BuiltInBuildIdToVersionKey.TryGetValue(normalizedBuildId, out var builtInVersionKey))
|
||||
{
|
||||
versionKey = builtInVersionKey;
|
||||
}
|
||||
}
|
||||
|
||||
bool allowed = versionKey is not null && registry.IsSupported(versionKey);
|
||||
var status = allowed ? VersionStatus_ValidForPlay : VersionStatus_UpdateRequired;
|
||||
|
||||
@@ -60,6 +60,8 @@ public class CdnController(
|
||||
|
||||
private static readonly string ImageDir =
|
||||
Path.Combine(AppContext.BaseDirectory, "data", "images");
|
||||
private static readonly Lazy<Dictionary<string, string>> RoomImageAliases =
|
||||
new(LoadRoomImageAliases);
|
||||
|
||||
// Specific route prefixes only — NO global catch-all wildcard.
|
||||
// A previous version had [Route("/{*path:minlength(1)}")] which
|
||||
@@ -100,16 +102,22 @@ public class CdnController(
|
||||
// String.Concat("/invention/", filename). Same byte-fetch flow as
|
||||
// /room/, routed to S3 via BlobRouter on the filename.
|
||||
[Route("/invention/{*path:minlength(1)}")]
|
||||
// 2023 builds a CDN URL as `/sigs/<signature-set-id>` during boot
|
||||
// file-hash verification. The original Rec Room CDN no longer
|
||||
// serves the embedded 2023 id, so keep this on a dedicated path and
|
||||
// fail soft instead of letting the request 404.
|
||||
[Route("/sigs/{*path:minlength(1)}")]
|
||||
// ASP.NET route templates treat `[` / `]` as token markers
|
||||
// (e.g. `[controller]`). To use them as literals inside a regex
|
||||
// constraint they must be doubled — so `[^/]` becomes `[[^/]]`.
|
||||
//
|
||||
// Extension list: image formats + room/.htr/.inv blobs that the
|
||||
// watch fetches directly, plus video formats (mp4/webm/mov/m4v) so
|
||||
// watch fetches directly, Studio .assetbundle files, plus video
|
||||
// formats (mp4/webm/mov/m4v) so
|
||||
// community-board video blobs uploaded via /admin/v1/communityboard/
|
||||
// video/upload (which writes `cb_video_<hash>.mp4` etc.) resolve
|
||||
// through the same bare-filename catch-all.
|
||||
[Route("/{path:regex(^[[^/]]+\\.(png|jpg|jpeg|webp|gif|dat|bin|holotar|inv|room|htr|mp4|webm|mov|m4v)$)}")]
|
||||
[Route("/{path:regex(^[[^/]]+\\.(png|jpg|jpeg|webp|gif|dat|bin|holotar|inv|room|htr|assetbundle|mp4|webm|mov|m4v)$)}")]
|
||||
[AcceptVerbs("GET", "HEAD")]
|
||||
public async Task<IActionResult> Serve(string? path)
|
||||
{
|
||||
@@ -135,6 +143,113 @@ public class CdnController(
|
||||
return await ServeCdn(fullPath);
|
||||
}
|
||||
|
||||
// ── Studio baked Unity asset-bundle download ───────────────────────
|
||||
/// <summary>GET <c>unity_assets/{assetId}/{target}/{version}</c> — the
|
||||
/// 2023 client's asset-bundle download for baked Studio scenes. The
|
||||
/// client builds this URL itself in RecNet.Runtime:
|
||||
/// <c>String.Format("unity_assets/{0}/{1}/{2}",
|
||||
/// WebUtility.UrlEncode(unityAssetId), (byte)target, (int)version)</c>
|
||||
/// after reading <c>UnityAssetId</c> / <c>Target</c> / <c>Version</c>
|
||||
/// from <c>rooms/{id}/subrooms/{sid}/saves/{saveId}</c>. It does NOT
|
||||
/// fetch the bare <c>{filename}.assetbundle</c> the save metadata names
|
||||
/// — so the bundle bytes only resolve through this id-addressed route.
|
||||
///
|
||||
/// <para>Resolution: the importer stores each baked bundle in S3 under
|
||||
/// its original filename (<c>{saveId}__bundle_t{target}_v{version}.assetbundle</c>)
|
||||
/// and stamps the owning scene's <see cref="Data.Entities.RoomSceneEntity.StudioUnityAssetId"/>
|
||||
/// + <see cref="Data.Entities.RoomSceneEntity.StudioAssetBundleNamesCsv"/>.
|
||||
/// We look the scene up by <c>StudioUnityAssetId == assetId</c>, pick the
|
||||
/// bundle whose embedded target+version match, and stream its bytes.</para>
|
||||
///
|
||||
/// <para>A genuine miss 404s — handing back a default room blob here
|
||||
/// (as the room-blob path does) would feed the Unity AssetBundle loader
|
||||
/// garbage and fail the scene load far less legibly than an honest
|
||||
/// 404.</para></summary>
|
||||
[Route("/unity_assets/{assetId}/{target:int}/{version:int}")]
|
||||
[AcceptVerbs("GET", "HEAD")]
|
||||
public async Task<IActionResult> ServeUnityAsset(string assetId, int target, int version)
|
||||
{
|
||||
assetId = (assetId ?? string.Empty).Trim();
|
||||
if (string.IsNullOrEmpty(assetId)) return NotFound();
|
||||
|
||||
// The Studio UnityAssetId is the per-save asset id from the dump
|
||||
// sidecar, unique per baked save, so this maps to one scene in
|
||||
// practice; tolerate >1 by scanning each candidate's bundle list.
|
||||
var scenes = await db.RoomScenes
|
||||
.Where(s => s.StudioUnityAssetId == assetId)
|
||||
.ToListAsync();
|
||||
|
||||
var candidates = scenes
|
||||
.SelectMany(s => (s.StudioAssetBundleNamesCsv ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Select(ParseBundleName)
|
||||
.Where(b => b is not null)
|
||||
.Cast<(int Target, int Version, string Filename)>()
|
||||
.Where(b => b.Target == target)
|
||||
.ToList();
|
||||
|
||||
// Exact match first; otherwise serve the highest available version
|
||||
// for the requested target (a Studio re-bake bumps the version, but
|
||||
// a cached client manifest may still ask for a superseded one).
|
||||
var pick = candidates.FirstOrDefault(b => b.Version == version);
|
||||
if (pick.Filename is null && candidates.Count > 0)
|
||||
pick = candidates.OrderByDescending(b => b.Version).First();
|
||||
|
||||
if (pick.Filename is null)
|
||||
{
|
||||
logger.LogWarning("[unity_assets] no bundle assetId={AssetId} target={Target} version={Version} scenes={Scenes}",
|
||||
assetId, target, version, scenes.Count);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var (bucket, key) = BlobRouter.Route(pick.Filename);
|
||||
byte[]? bytes = null;
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
bytes = await storage.GetAsync(bucket, key, cts.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "[unity_assets] S3 GetAsync threw {Bucket}/{Key}", bucket, key);
|
||||
}
|
||||
|
||||
if (bytes is not { Length: > 0 })
|
||||
{
|
||||
logger.LogWarning("[unity_assets] MISS assetId={AssetId} file={File} bucket={Bucket} key={Key}",
|
||||
assetId, pick.Filename, bucket, key);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
logger.LogInformation("[unity_assets] hit assetId={AssetId} target={Target} version={Version} file={File} bytes={Bytes}",
|
||||
assetId, target, version, pick.Filename, bytes.Length);
|
||||
Response.Headers.CacheControl = "public, max-age=3600";
|
||||
signatures.AddContentSignature(Response, bytes);
|
||||
return new FileContentResult(bytes, "application/octet-stream");
|
||||
}
|
||||
|
||||
/// <summary>Parse a Studio baked-bundle filename
|
||||
/// (<c>{saveId}__bundle_t{target}_v{version}.assetbundle</c>, or older
|
||||
/// <c>{saveId}__bundle_t{target}.assetbundle</c>) into its
|
||||
/// target/version/filename. Mirrors the parser in RoomsController so the
|
||||
/// id-addressed CDN route and the save-metadata endpoint agree on the
|
||||
/// same naming contract.</summary>
|
||||
private static (int Target, int Version, string Filename)? ParseBundleName(string fileName)
|
||||
{
|
||||
var name = Path.GetFileName(fileName);
|
||||
var match = System.Text.RegularExpressions.Regex.Match(
|
||||
name,
|
||||
@"^(?<save>\d+)__bundle_t(?<target>\d+)(?:_v(?<version>\d+))?\.assetbundle$",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant);
|
||||
if (!match.Success) return null;
|
||||
if (!int.TryParse(match.Groups["target"].Value, out var target)) return null;
|
||||
var version = 0;
|
||||
if (match.Groups["version"].Success &&
|
||||
!int.TryParse(match.Groups["version"].Value, out version))
|
||||
return null;
|
||||
return (target, version, name);
|
||||
}
|
||||
|
||||
// ── Image pipeline (img.{apex}) ────────────────────────────────────
|
||||
private async Task<IActionResult> ServeImage(string? path)
|
||||
{
|
||||
@@ -227,8 +342,8 @@ public class CdnController(
|
||||
return RespondWithTransforms(s3Bytes, contentType);
|
||||
}
|
||||
|
||||
var full = Path.Combine(ImageDir, safe);
|
||||
if (System.IO.File.Exists(full))
|
||||
var full = ResolveLocalImagePath(safe);
|
||||
if (full is not null)
|
||||
{
|
||||
logger.LogInformation("[img] disk hit host={Host} path={Path} → {Full}",
|
||||
Request.Host.Host, path, full);
|
||||
@@ -266,6 +381,17 @@ public class CdnController(
|
||||
// ── CDN serve path (cdn.*, storage.*, data.*, apex) ────────────────
|
||||
private async Task<IActionResult> ServeCdn(string? path)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(path) &&
|
||||
path.StartsWith("sigs/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogWarning("[cdn] signature set MISS host={Host} path={Path} -> empty signature payload",
|
||||
Request.Host.Host, path);
|
||||
var payload = Array.Empty<byte>();
|
||||
Response.Headers.CacheControl = "public, max-age=60";
|
||||
signatures.AddContentSignature(Response, payload);
|
||||
return new FileContentResult(payload, "application/octet-stream");
|
||||
}
|
||||
|
||||
if (string.Equals(path, "config/LoadingScreenTipData", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Tips live in the LoadingScreenTips DB table; admins
|
||||
@@ -382,10 +508,21 @@ public class CdnController(
|
||||
|
||||
// S3 is the only canonical store — DB carries text-only
|
||||
// metadata (BlobName, owner, timestamps) and never holds
|
||||
// bytes. If S3 misses, the bytes don't exist on this server.
|
||||
|
||||
// bytes. RRO thumbnail images are the exception for local dev:
|
||||
// tools/fetch-room-images.py stores them under data/images/, and
|
||||
// the 2023 client requests them through cdn.* rather than img.*.
|
||||
if (IsImageName(fileName))
|
||||
{
|
||||
var imagePath = ResolveLocalImagePath(fileName);
|
||||
if (imagePath is not null)
|
||||
{
|
||||
var imageBytes = await System.IO.File.ReadAllBytesAsync(imagePath);
|
||||
logger.LogInformation("[cdn] disk image hit host={Host} file={File} -> {Path} bytes={Bytes}",
|
||||
Request.Host.Host, fileName, imagePath, imageBytes.Length);
|
||||
Response.Headers.CacheControl = "public, max-age=300";
|
||||
return RespondWithTransforms(imageBytes, MimeFromName(fileName));
|
||||
}
|
||||
|
||||
logger.LogWarning("[cdn] image MISS host={Host} file={File} query='{Query}' -> transparent png",
|
||||
Request.Host.Host, fileName, Request.QueryString);
|
||||
Response.Headers.CacheControl = "public, max-age=60";
|
||||
@@ -496,6 +633,7 @@ public class CdnController(
|
||||
".mp4" or ".m4v" => "video/mp4",
|
||||
".webm" => "video/webm",
|
||||
".mov" => "video/quicktime",
|
||||
".assetbundle" => "application/octet-stream",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
}
|
||||
@@ -505,4 +643,52 @@ public class CdnController(
|
||||
var ext = Path.GetExtension(name).ToLowerInvariant();
|
||||
return ext is ".png" or ".jpg" or ".jpeg" or ".gif" or ".webp";
|
||||
}
|
||||
|
||||
private static string? ResolveLocalImagePath(string fileName)
|
||||
{
|
||||
var direct = Path.Combine(ImageDir, fileName);
|
||||
if (System.IO.File.Exists(direct)) return direct;
|
||||
|
||||
var baseName = Path.GetFileNameWithoutExtension(fileName);
|
||||
if (!baseName.StartsWith("image_", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
var roomName = baseName["image_".Length..];
|
||||
if (!RoomImageAliases.Value.TryGetValue(roomName, out var mappedName))
|
||||
return null;
|
||||
|
||||
var mapped = Path.Combine(ImageDir, mappedName);
|
||||
return System.IO.File.Exists(mapped) ? mapped : null;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> LoadRoomImageAliases()
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "data", "room_images.json"),
|
||||
Path.Combine(Directory.GetCurrentDirectory(), "data", "room_images.json"),
|
||||
};
|
||||
|
||||
foreach (var path in candidates)
|
||||
{
|
||||
if (!System.IO.File.Exists(path)) continue;
|
||||
try
|
||||
{
|
||||
using var fs = System.IO.File.OpenRead(path);
|
||||
var map = JsonSerializer.Deserialize<Dictionary<string, string>>(fs);
|
||||
if (map is null) continue;
|
||||
foreach (var (roomName, imageName) in map)
|
||||
if (!string.IsNullOrWhiteSpace(roomName) && !string.IsNullOrWhiteSpace(imageName))
|
||||
result[roomName] = imageName;
|
||||
break;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall back to direct filename lookup.
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,23 @@ public class ClubsController(ClubService clubs) : ControllerBase
|
||||
return Ok(playerSubs + clubSubs);
|
||||
}
|
||||
|
||||
[HttpGet("/subscription/details/{accountId:long}")]
|
||||
public async Task<IActionResult> SubscriptionDetails(long accountId)
|
||||
{
|
||||
var viewerId = this.CurrentPlayerId();
|
||||
var subscriberCount = await clubs.PlayerSubscriberCountAsync(accountId);
|
||||
var subscribedCount = await clubs.PlayerSubscribedCountAsync(accountId);
|
||||
var isSubscribed = viewerId is long viewer
|
||||
&& await clubs.IsSubscribedToPlayerAsync(viewer, accountId);
|
||||
return Ok(new
|
||||
{
|
||||
accountId,
|
||||
subscriberCount,
|
||||
subscribedCount,
|
||||
isSubscribed,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST endpoint the watch hits when the player taps "mark read".
|
||||
/// Idempotent — the upsert in the service quietly returns if a
|
||||
@@ -668,11 +685,14 @@ public class ClubsController(ClubService clubs) : ControllerBase
|
||||
[HttpPost("/members/bulk")]
|
||||
public async Task<IActionResult> MembersBulk()
|
||||
{
|
||||
var form = Request.HasFormContentType ? Request.Form : null;
|
||||
if (!long.TryParse(Request.Query["clubId"].ToString(), out var clubId)
|
||||
&& !long.TryParse(Request.Form["clubId"].ToString(), out clubId))
|
||||
&& (form is null || !long.TryParse(form["clubId"].ToString(), out clubId)))
|
||||
return BadRequest(new { error = "missing_club_id" });
|
||||
|
||||
var idsRaw = Request.Query["playerIds"].Concat(Request.Form["playerIds"]);
|
||||
var idsRaw = form is null
|
||||
? Request.Query["playerIds"].AsEnumerable()
|
||||
: Request.Query["playerIds"].Concat(form["playerIds"]);
|
||||
var ids = idsRaw
|
||||
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Select(s => long.TryParse(s, out var n) ? n : 0)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Services;
|
||||
using DorkNet.Server.Controllers.API.Avatar.V4;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Commerce;
|
||||
|
||||
@@ -85,6 +86,31 @@ public class CommerceCatalogController(StoreService store, DomainConfig domain)
|
||||
{
|
||||
var available = i.IsActive && (i.AvailableUntil == null || i.AvailableUntil > DateTime.UtcNow);
|
||||
var imageUrl = string.IsNullOrEmpty(i.ImageName) ? "" : $"https://cdn.{apex}/{i.ImageName}";
|
||||
|
||||
// Avatar-item linkage — THE thing that makes wardrobe items appear in
|
||||
// the Shop's Clothing tab. The 2023 client does NOT categorize a SKU by
|
||||
// any field on the SKU itself; instead StoreItemListModel resolves the
|
||||
// SKU to the client's baked AvatarItem (by GUID) and reads that item's
|
||||
// OutfitType to place it in a tab. With no AvatarItemId the resolve
|
||||
// returns OutfitType = -1, the item matches no Clothing/Consumables
|
||||
// filter, and the whole Shop renders empty even though all 1531 SKUs
|
||||
// parsed fine. The GUID is the tail of the "wardrobe-{guid}" slug; ship
|
||||
// it (plus the OutfitType) under the keys the client reads
|
||||
// (AvatarItemId / AvatarItemType — verified vs 2023 ISIL store-item
|
||||
// context types EMBCEDNHFLB / CLIANCNHNIE). Coloured variants
|
||||
// (wardrobe-colored-*) resolve through a different descriptor path and
|
||||
// are left alone here.
|
||||
string? avatarItemId = null;
|
||||
int? avatarItemType = null;
|
||||
if (i.Slug.StartsWith("wardrobe-", StringComparison.Ordinal)
|
||||
&& !i.Slug.StartsWith("wardrobe-colored-", StringComparison.Ordinal))
|
||||
{
|
||||
var guid = i.Slug["wardrobe-".Length..];
|
||||
avatarItemId = guid;
|
||||
if (AvatarItemsController.TryGetOutfitType(guid, out var ot))
|
||||
avatarItemType = ot;
|
||||
}
|
||||
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
// Identity / naming — every alias the watch's strict
|
||||
@@ -116,6 +142,14 @@ public class CommerceCatalogController(StoreService store, DomainConfig domain)
|
||||
["Slug"] = i.Slug,
|
||||
["slug"] = i.Slug,
|
||||
|
||||
// Avatar-item join keys (see resolve block above). Emitted both at
|
||||
// top level and inside Data so whichever the client reads, the
|
||||
// SKU→AvatarItem resolve succeeds and the item lands in a tab.
|
||||
["AvatarItemId"] = avatarItemId,
|
||||
["avatarItemId"] = avatarItemId,
|
||||
["AvatarItemType"] = avatarItemType,
|
||||
["avatarItemType"] = avatarItemType,
|
||||
|
||||
["Name"] = i.DisplayName,
|
||||
["name"] = i.DisplayName,
|
||||
["DisplayName"] = i.DisplayName,
|
||||
@@ -205,13 +239,21 @@ public class CommerceCatalogController(StoreService store, DomainConfig domain)
|
||||
// string / null sub-purchase are valid.
|
||||
["Data"] = new Dictionary<string, object?>
|
||||
{
|
||||
["GiftDropIds"] = Array.Empty<int>(),
|
||||
["AvatarItemId"] = avatarItemId,
|
||||
["AvatarItemType"] = avatarItemType,
|
||||
// Link this SKU to its gift-drop store card (the EFFIEFEFHHB
|
||||
// record `storefronts/v3/giftdropstore` builds, GiftDropId =
|
||||
// this same id) so the client can resolve the card that carries
|
||||
// the AvatarItemId join key. Empty here = SKU points at no card.
|
||||
["GiftDropIds"] = new[] { (int)(i.Id & 0x7fffffff) },
|
||||
["Message"] = string.Empty,
|
||||
["SubscriptionPurchase"] = (object?)null,
|
||||
},
|
||||
["data"] = new Dictionary<string, object?>
|
||||
{
|
||||
["giftDropIds"] = Array.Empty<int>(),
|
||||
["avatarItemId"] = avatarItemId,
|
||||
["avatarItemType"] = avatarItemType,
|
||||
["giftDropIds"] = new[] { (int)(i.Id & 0x7fffffff) },
|
||||
["message"] = string.Empty,
|
||||
["subscriptionPurchase"] = (object?)null,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Commerce;
|
||||
|
||||
[ApiController]
|
||||
public class PurchaseCampaignController : ControllerBase
|
||||
{
|
||||
[HttpGet("/purchasecampaign/allcurrent/v2")]
|
||||
[HttpGet("/purchasecampaign/allcurrent")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult AllCurrent() => Ok(Array.Empty<object>());
|
||||
}
|
||||
@@ -71,6 +71,53 @@ public class EconController(DorkNetDbContext db, LevelService level) : Controlle
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpGet("/econ/customAvatarItems")]
|
||||
[HttpGet("/econ/customAvatarItems/v1/owned")]
|
||||
public async Task<IActionResult> GetOwnedCustomAvatarItems([FromQuery] int skip = 0, [FromQuery] int take = 100)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
|
||||
var query = db.CustomAvatarItemOwnership
|
||||
.Where(o => o.PlayerId == pid)
|
||||
.Join(db.CustomAvatarItems, o => o.CustomAvatarItemId, i => i.Id, (o, i) => i)
|
||||
.OrderByDescending(i => i.UpdatedAt);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var rows = await query
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Results = rows.Select(ToCustomAvatarItemWire),
|
||||
TotalResults = total,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/econ/customAvatarItems/v1/itemOwnershipLimit")]
|
||||
public IActionResult CustomAvatarItemOwnershipLimit() => Ok(new
|
||||
{
|
||||
Limit = 1000,
|
||||
ItemOwnershipLimit = 1000,
|
||||
});
|
||||
|
||||
private static object ToCustomAvatarItemWire(DorkNet.Server.Data.Entities.CustomAvatarItemEntity i) => new
|
||||
{
|
||||
CustomAvatarItemId = i.PublicId,
|
||||
Id = i.PublicId,
|
||||
CreatorPlayerId = (int)i.CreatorPlayerId,
|
||||
i.Name,
|
||||
i.Description,
|
||||
i.Price,
|
||||
i.ItemType,
|
||||
i.ImageName,
|
||||
i.AssetName,
|
||||
i.Color,
|
||||
};
|
||||
}
|
||||
|
||||
public class TokenBalance
|
||||
|
||||
@@ -215,19 +215,7 @@ public class GoToController(
|
||||
// (roomInstanceId differs → reset state machine) plus "new
|
||||
// photon match" (photonRoomId differs → leave/rejoin →
|
||||
// SceneLoaded events that ultimately dismiss the dialog).
|
||||
resp.RoomInstance.RoomInstanceId = resp.RoomInstance.RoomId * 100_000L + subId;
|
||||
resp.RoomInstance.PhotonRoomId = $"{resp.RoomInstance.PhotonRoomId}_sub{subId}";
|
||||
resp.RoomInstance.Name = subRoomName;
|
||||
resp.RoomInstance.SubRoomId = subId;
|
||||
if (subLocation is not null) resp.RoomInstance.Location = subLocation;
|
||||
// dataBlob on the matchmaking response tells the client which
|
||||
// PersistedRoomData blob to download for the entered scene.
|
||||
// RoomInstance.Deserialize at RVA 0x114DAB0 reads it via the
|
||||
// string-with-default reader (call 0x180EE6210), so empty is
|
||||
// technically legal — but if it's empty the watch falls back
|
||||
// to the parent room's RoomDataBlobName, which is the lobby's
|
||||
// blob, so the sub-scene visually never changes.
|
||||
if (!string.IsNullOrEmpty(subDataBlob)) resp.RoomInstance.DataBlob = subDataBlob;
|
||||
ApplySubRoomMutation(resp, subId, subRoomName, subLocation, subDataBlob);
|
||||
|
||||
// Private/new-instance mutation goes AFTER sub-room mutation so the
|
||||
// PhotonRoomId we register includes the _sub{N} suffix as its
|
||||
@@ -240,6 +228,142 @@ public class GoToController(
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
/// <summary>Mutate <paramref name="resp"/> in place so it represents
|
||||
/// the given sub-scene of its parent room. Both PhotonRoomId AND
|
||||
/// RoomInstanceId must differ from the lobby's or the watch's
|
||||
/// transition state machine never fires:
|
||||
/// • Same photon + same roomInstanceId → the new dataBlob downloads
|
||||
/// but the watch never transitions; "Going to X" hangs forever.
|
||||
/// • Different photon + same roomInstanceId → the watch sees the id
|
||||
/// unchanged, treats the response as a no-op presence update and
|
||||
/// skips the join flow entirely.
|
||||
/// Folding subId into RoomInstanceId AND forking PhotonRoomId gives
|
||||
/// the watch both signals it needs ("new instance" + "new photon
|
||||
/// match"). dataBlob tells the client which PersistedRoomData to
|
||||
/// download for the scene (RoomInstance.Deserialize, RVA 0x114DAB0,
|
||||
/// reads it with a string-with-default reader so empty is legal) —
|
||||
/// but an empty blob makes the watch fall back to the parent room's
|
||||
/// lobby blob, so the sub-scene never changes visually. Only override
|
||||
/// location/blob when we actually resolved one.</summary>
|
||||
private static void ApplySubRoomMutation(
|
||||
MatchmakingResponseDto resp, long subId, string subRoomName,
|
||||
string? subLocation, string? subDataBlob, string? subRoomUnityAssetId = null)
|
||||
{
|
||||
resp.RoomInstance.RoomInstanceId = resp.RoomInstance.RoomId * 100_000L + subId;
|
||||
resp.RoomInstance.PhotonRoomId = $"{resp.RoomInstance.PhotonRoomId}_sub{subId}";
|
||||
resp.RoomInstance.Name = subRoomName;
|
||||
resp.RoomInstance.SubRoomId = subId;
|
||||
if (subLocation is not null) resp.RoomInstance.Location = subLocation;
|
||||
if (!string.IsNullOrEmpty(subDataBlob)) resp.RoomInstance.DataBlob = subDataBlob;
|
||||
resp.RoomInstance.SubRoomUnityAssetId = subRoomUnityAssetId ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>POST <c>match.*/matchmake/room/{roomId}</c> — the 2023
|
||||
/// client's primary "join this public room" call
|
||||
/// (<c>RecNet.Matchmaking</c>, format string <c>"matchmake/room/{0}"</c>,
|
||||
/// same sender <c>HHJFFJEHLOB</c> as the working <c>matchmake/dorm</c>,
|
||||
/// so it's a POST returning <see cref="MatchmakingResponseDto"/>).
|
||||
/// It navigates by NUMERIC roomId straight from the room tile, NOT by
|
||||
/// name like the older <c>/goto/room/{name}</c> path. With no route
|
||||
/// here every public-room join 404s and the watch bounces the player
|
||||
/// back to their dorm ("Matchmaking request failed
|
||||
/// (matchmake/room/N): HTTP Error 404"). We resolve the id to its
|
||||
/// room and reuse <see cref="BuildResponseAsync"/> so dorm/private/
|
||||
/// dataBlob/JoinMode handling stays identical to <see cref="GoToRoom"/>.
|
||||
/// GET is accepted too as cheap insurance against a verb mismatch.</summary>
|
||||
[HttpPost("/matchmake/room/{roomId:long}")]
|
||||
[HttpGet("/matchmake/room/{roomId:long}")]
|
||||
public async Task<ActionResult<MatchmakingResponseDto>> MatchmakeRoom(
|
||||
long roomId,
|
||||
[FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false,
|
||||
[FromForm(Name = "JoinMode")] int joinMode = 0,
|
||||
[FromForm(Name = "AdditionalPlayerIds")] List<long>? additionalPlayerIds = null)
|
||||
{
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null)
|
||||
{
|
||||
logger.LogWarning("[matchmake-room] unknown roomId={RoomId} — returning RoomDoesNotExist", roomId);
|
||||
// 4 = RoomDoesNotExist (CreateModifyRoomStatus); watch shows
|
||||
// its standard "room not found" toast instead of softlocking.
|
||||
return Ok(new MatchmakingResponseDto { ErrorCode = 4, RoomInstance = null });
|
||||
}
|
||||
|
||||
var resp = await BuildResponseAsync(room.Name);
|
||||
await ApplyNewInstanceAsync(resp, subRoomId: 0, createPrivateInstance, joinMode);
|
||||
await RecordResponseAsync(resp);
|
||||
await NotifyAdditionalPlayersAsync(additionalPlayerIds, resp);
|
||||
logger.LogInformation(
|
||||
"[matchmake-room] player={Player} roomId={RoomId} name={Name} instance={Instance} photon={Photon}",
|
||||
this.CurrentPlayerId(), roomId, room.Name,
|
||||
resp.RoomInstance?.RoomInstanceId, resp.RoomInstance?.PhotonRoomId);
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
/// <summary>POST <c>match.*/matchmake/room/{roomId}/{subRoomId}</c> —
|
||||
/// sub-room form (<c>RecNet.Matchmaking</c> "matchmake/room/{0}/{1}").
|
||||
/// Unlike <see cref="GoToSubRoom"/> (which takes a sub-room NAME and
|
||||
/// fuzzy-matches it) the sub-room arrives here as a numeric
|
||||
/// <see cref="RoomSceneEntity.OrderIndex"/>, so we resolve the scene
|
||||
/// row by OrderIndex directly. subRoomId 0 is the lobby/entry scene —
|
||||
/// the base response already represents it, so we skip the
|
||||
/// mutation.</summary>
|
||||
[HttpPost("/matchmake/room/{roomId:long}/{subRoomId:long}")]
|
||||
[HttpGet("/matchmake/room/{roomId:long}/{subRoomId:long}")]
|
||||
public async Task<ActionResult<MatchmakingResponseDto>> MatchmakeSubRoom(
|
||||
long roomId, long subRoomId,
|
||||
[FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false,
|
||||
[FromForm(Name = "JoinMode")] int joinMode = 0,
|
||||
[FromForm(Name = "AdditionalPlayerIds")] List<long>? additionalPlayerIds = null)
|
||||
{
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"[matchmake-room] unknown roomId={RoomId} (sub={Sub}) — returning RoomDoesNotExist",
|
||||
roomId, subRoomId);
|
||||
return Ok(new MatchmakingResponseDto { ErrorCode = 4, RoomInstance = null });
|
||||
}
|
||||
|
||||
var resp = await BuildResponseAsync(room.Name);
|
||||
|
||||
if (subRoomId != 0)
|
||||
{
|
||||
var scene = await db.RoomScenes.AsNoTracking()
|
||||
.Where(s => s.RoomId == resp.RoomInstance.RoomId && s.OrderIndex == subRoomId)
|
||||
.Select(s => new { s.Name, s.RoomSceneLocationId, s.DataBlobName, s.StudioUnityAssetId })
|
||||
.FirstOrDefaultAsync();
|
||||
if (scene is not null)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[matchmake-subroom] {Room}/{Sub} → scene '{Name}' loc={Loc} blob={Blob} unityAsset={UnityAsset}",
|
||||
roomId, subRoomId, scene.Name, scene.RoomSceneLocationId, scene.DataBlobName, scene.StudioUnityAssetId);
|
||||
ApplySubRoomMutation(
|
||||
resp,
|
||||
subRoomId,
|
||||
scene.Name,
|
||||
scene.RoomSceneLocationId,
|
||||
scene.DataBlobName,
|
||||
scene.StudioUnityAssetId);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning(
|
||||
"[matchmake-subroom] {Room}/{Sub} — no RoomScenes row with that OrderIndex; " +
|
||||
"forking ids onto the lobby scene so the watch still transitions",
|
||||
roomId, subRoomId);
|
||||
// Reuse the lobby location/blob (sub-scene won't differ
|
||||
// visually) but still fold subId in so the watch leaves
|
||||
// the current match instead of softlocking.
|
||||
ApplySubRoomMutation(resp, subRoomId, resp.RoomInstance.Name, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
await ApplyNewInstanceAsync(resp, subRoomId: subRoomId, createPrivateInstance, joinMode);
|
||||
await RecordResponseAsync(resp);
|
||||
await NotifyAdditionalPlayersAsync(additionalPlayerIds, resp);
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
/// <summary>Per-sub-room scene location overrides for baked
|
||||
/// AG-Original rooms with multiple scenes. Each entry maps a
|
||||
/// <c>"{room}/{subRoom}"</c> key (lowercase) to the
|
||||
@@ -567,6 +691,7 @@ public class GoToController(
|
||||
IsPrivate = true,
|
||||
IsInProgress = true,
|
||||
DataBlob = inst.DataBlob,
|
||||
SubRoomUnityAssetId = await ResolveSubRoomUnityAssetIdAsync(inst.RoomId, inst.SubRoomId),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -701,6 +826,7 @@ public class GoToController(
|
||||
IsPrivate = room.IsPrivate,
|
||||
IsInProgress = room.IsInProgress,
|
||||
DataBlob = room.DataBlob,
|
||||
SubRoomUnityAssetId = room.SubRoomUnityAssetId,
|
||||
EventId = room.EventId,
|
||||
Name = room.Name,
|
||||
RoomInstanceType = room.RoomInstanceType,
|
||||
@@ -1083,6 +1209,7 @@ public class GoToController(
|
||||
? $"^dormroom_p{ownerForName}"
|
||||
: $"^{roomName.ToLowerInvariant()}_{roomId}";
|
||||
var dataBlob = await ResolveInitialDataBlobAsync(roomId, isDorm, dormOwnerId);
|
||||
var subRoomUnityAssetId = await ResolveSubRoomUnityAssetIdAsync(roomId, 0);
|
||||
|
||||
var resp = new MatchmakingResponseDto
|
||||
{
|
||||
@@ -1111,6 +1238,7 @@ public class GoToController(
|
||||
IsPrivate = false,
|
||||
IsInProgress = true,
|
||||
DataBlob = dataBlob,
|
||||
SubRoomUnityAssetId = subRoomUnityAssetId,
|
||||
},
|
||||
};
|
||||
logger.LogInformation(
|
||||
@@ -1172,10 +1300,17 @@ public class GoToController(
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
|
||||
|
||||
if (room.IsDormRoom) return string.Empty;
|
||||
if (RoomService.IsBakedOriginalRoom(room)) return string.Empty;
|
||||
|
||||
var customisable = room.CreatorPlayerId != 1;
|
||||
return customisable ? $"room_{room.Id}_v1.dat" : string.Empty;
|
||||
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
|
||||
}
|
||||
|
||||
private async Task<string> ResolveSubRoomUnityAssetIdAsync(long roomId, long subRoomId)
|
||||
{
|
||||
return await db.RoomScenes.AsNoTracking()
|
||||
.Where(s => s.RoomId == roomId && s.OrderIndex == subRoomId)
|
||||
.Select(s => s.StudioUnityAssetId)
|
||||
.FirstOrDefaultAsync() ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1225,6 +1360,7 @@ public class GoToController(
|
||||
"cyberjunkcity" or "lasertagcyberjunk" => "9d6456ce-6264-48b4-808d-2d96b3d91038",
|
||||
"recroyalesquads" => "253fa009-6e65-4c90-91a1-7137a56a267f",
|
||||
"recroyalesolos" => "85b43509-77f7-3884-3a6b-9a4e737d6d11",
|
||||
"recrally" => "56193568-9ae0-498c-8a77-4df79dec91f5",
|
||||
_ => "76d98498-60a1-430c-ab76-b54a29b7a163", // DormRoom
|
||||
};
|
||||
}
|
||||
@@ -1285,6 +1421,13 @@ public class RoomInstanceDto
|
||||
[JsonPropertyName("dataBlob")]
|
||||
public string DataBlob { get; set; } = string.Empty;
|
||||
|
||||
// 2023 RoomLoading.LoadRoomCommand carries this separately from
|
||||
// dataBlob. If it is empty, the loader can deserialize room view data
|
||||
// before the Studio asset bundle has registered its prefabs, producing
|
||||
// "Bad View Data detected. Expected a prefab, but found none".
|
||||
[JsonPropertyName("subRoomUnityAssetId")]
|
||||
public string SubRoomUnityAssetId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("eventId")]
|
||||
public long EventId { get; set; } = 0;
|
||||
|
||||
@@ -1358,6 +1501,7 @@ public class RoomInstanceDto
|
||||
IsPrivate = room.IsPrivate,
|
||||
IsInProgress = room.IsInProgress,
|
||||
DataBlob = string.Empty,
|
||||
SubRoomUnityAssetId = room.SubRoomUnityAssetId,
|
||||
EventId = room.EventId,
|
||||
Name = room.Name,
|
||||
};
|
||||
|
||||
@@ -17,10 +17,12 @@ namespace DorkNet.Server.Controllers.Match;
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class MatchController(
|
||||
IConfiguration config,
|
||||
GameSessionService sessionService,
|
||||
PlayerPresenceService presence,
|
||||
PrivateInstanceService privateInstances,
|
||||
JoinTimeoutService joinTimeouts,
|
||||
RoomService rooms,
|
||||
DorkNetDbContext db,
|
||||
ILogger<MatchController> logger) : ControllerBase
|
||||
{
|
||||
@@ -35,8 +37,8 @@ public class MatchController(
|
||||
/// we mostly use this for visibility into Photon-side issues —
|
||||
/// without it, all we see in the catch-all is an opaque "200, empty
|
||||
/// reply" and no clue why a player crashed back to dorm. Failure
|
||||
/// reports also clear the pending /goto room and push a ModerationKick
|
||||
/// so the watch leaves the "Joining room" overlay instead of softlocking.</summary>
|
||||
/// reports also clear the pending /goto room so the next matchmaking
|
||||
/// attempt starts from a clean presence state.</summary>
|
||||
[HttpPost("/roominstance/{instanceId:long}/reportjoinresult")]
|
||||
public async Task<IActionResult> ReportJoinResult(long instanceId, [FromForm(Name = "result")] int? result)
|
||||
{
|
||||
@@ -57,6 +59,7 @@ public class MatchController(
|
||||
{
|
||||
presence.SetRoom(pid, completed.Value.TargetRoom);
|
||||
presence.MarkActive(pid);
|
||||
presence.EndBootDormGrace(pid);
|
||||
logger.LogInformation(
|
||||
"[joinresult] committed presence player={Player} room={RoomId}/{RoomName} instance={Instance} deferred={Deferred}",
|
||||
pid,
|
||||
@@ -69,6 +72,7 @@ public class MatchController(
|
||||
{
|
||||
presence.MarkActive(pid);
|
||||
presence.TouchRoom(pid);
|
||||
presence.EndBootDormGrace(pid);
|
||||
}
|
||||
logger.LogInformation(
|
||||
"[joinresult] player={Player} instance={Instance} OK ({Label})",
|
||||
@@ -78,12 +82,220 @@ public class MatchController(
|
||||
{
|
||||
await joinTimeouts.MarkFailedAsync(pid, instanceId, label);
|
||||
logger.LogWarning(
|
||||
"[joinresult] player={Player} instance={Instance} FAILED code={Code} ({Label}) — Photon-side error, check Photon AppId/region/auth",
|
||||
"[joinresult] player={Player} instance={Instance} FAILED code={Code} ({Label})",
|
||||
pid, instanceId, code, label);
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost("/matchmake/none")]
|
||||
public async Task<IActionResult> MatchmakeNone()
|
||||
{
|
||||
var pid = this.CurrentPlayerId() ?? 0;
|
||||
if (pid == 0) return Unauthorized();
|
||||
|
||||
var pending = joinTimeouts.GetPending(pid);
|
||||
if (pending is { DeferPresenceCommit: true })
|
||||
{
|
||||
presence.MarkActive(pid);
|
||||
logger.LogInformation(
|
||||
"[matchmake-none] player={Player} has deferred pending room={Room} instance={Instance}; not committing presence",
|
||||
pid,
|
||||
pending.Value.TargetRoom.RoomId,
|
||||
pending.Value.TargetRoom.RoomInstanceId);
|
||||
return Ok(new MatchmakingResponseDto
|
||||
{
|
||||
ErrorCode = 0,
|
||||
RoomInstance = pending.Value.TargetRoom,
|
||||
});
|
||||
}
|
||||
|
||||
var current = presence.GetRoom(pid) ?? await BuildDormRoomInstanceAsync(pid);
|
||||
presence.SetRoom(pid, current);
|
||||
presence.MarkActive(pid);
|
||||
logger.LogInformation("[matchmake-none] player={Player}", pid);
|
||||
return Ok(new MatchmakingResponseDto
|
||||
{
|
||||
ErrorCode = 0,
|
||||
RoomInstance = current,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/photon_access_token")]
|
||||
public IActionResult PhotonAccessToken()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var appId = config["Photon:AppId"] ?? string.Empty;
|
||||
var voiceAppId = config["Photon:VoiceAppId"] ?? string.Empty;
|
||||
var region = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
|
||||
var currentRoom = presence.GetRoom(pid);
|
||||
var permissions = BuildPhotonPermissionSeed();
|
||||
logger.LogInformation(
|
||||
"[photon-token] player={PlayerId} roomInstance={RoomInstanceId} permissions={PermissionCount}",
|
||||
pid, currentRoom?.RoomInstanceId ?? 0L, permissions.Length);
|
||||
return Ok(new Dictionary<string, object>
|
||||
{
|
||||
// 2023 deserializes this endpoint as ALIIIAGAPND before room
|
||||
// permissions initialization. If Permissions is missing/empty,
|
||||
// OMJKHJLFOCO falls into the persisted-role migration path that
|
||||
// currently NREs during first spawn.
|
||||
["RoomInstanceId"] = currentRoom?.RoomInstanceId ?? 0L,
|
||||
["PhotonAccessToken"] = string.Empty,
|
||||
["Permissions"] = permissions,
|
||||
|
||||
// Existing custom-auth/app-id shape kept for Photon auth callers.
|
||||
["ResultCode"] = 1,
|
||||
["Message"] = string.Empty,
|
||||
["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
["PhotonAppId"] = appId,
|
||||
["PhotonVoiceAppId"] = voiceAppId,
|
||||
["AppId"] = appId,
|
||||
["VoiceAppId"] = voiceAppId,
|
||||
["CloudRegion"] = region,
|
||||
["Region"] = region,
|
||||
["Token"] = string.Empty,
|
||||
});
|
||||
}
|
||||
|
||||
private static object[] BuildPhotonPermissionSeed()
|
||||
=>
|
||||
[
|
||||
BuildPhotonPermission(13, 30), // Can Use Maker Pen
|
||||
BuildPhotonPermission(20, 30), // Can Spawn Inventions
|
||||
BuildPhotonPermission(21, 30), // Can Spawn Consumables
|
||||
BuildPhotonPermission(22, 30), // Can Use Room Reset Button
|
||||
BuildPhotonPermission(23, 30), // Can Use Play Gizmos Toggle
|
||||
BuildPhotonPermission(13, 255), // Creator alias
|
||||
BuildPhotonPermission(20, 255),
|
||||
BuildPhotonPermission(21, 255),
|
||||
BuildPhotonPermission(22, 255),
|
||||
BuildPhotonPermission(23, 255),
|
||||
];
|
||||
|
||||
private static object BuildPhotonPermission(int permission, int role) => new
|
||||
{
|
||||
Permission = permission.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
Role = role,
|
||||
Override = true,
|
||||
Type = 0,
|
||||
Value = "true",
|
||||
};
|
||||
|
||||
[HttpPost("/matchmake/dorm")]
|
||||
public async Task<IActionResult> MatchmakeDorm()
|
||||
{
|
||||
var pid = this.CurrentPlayerId() ?? 0;
|
||||
if (pid == 0) return Unauthorized();
|
||||
|
||||
var room = await BuildDormRoomInstanceAsync(pid);
|
||||
joinTimeouts.MarkPending(pid, room, deferPresenceCommit: true);
|
||||
presence.MarkActive(pid);
|
||||
await RecordVisitAsync(pid, room.RoomId);
|
||||
logger.LogInformation(
|
||||
"[matchmake-dorm] player={Player} room={Room} instance={Instance} photon={Photon} deferredPresence=True",
|
||||
pid, room.RoomId, room.RoomInstanceId, room.PhotonRoomId);
|
||||
return Ok(new MatchmakingResponseDto
|
||||
{
|
||||
ErrorCode = 0,
|
||||
RoomInstance = room,
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<RoomInstanceDto> BuildDormRoomInstanceAsync(long playerId)
|
||||
{
|
||||
var personal = await rooms.EnsurePersonalDormAsync(playerId);
|
||||
var dataBlob = await rooms.ResolveDormDataBlobNameAsync(playerId, personal.Id);
|
||||
if (string.IsNullOrWhiteSpace(dataBlob))
|
||||
dataBlob = personal.CurrentDataBlobName;
|
||||
|
||||
var photonRegion = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
|
||||
var photonRoomId = $"^dormroom_p{playerId}";
|
||||
var dormInstanceName = await BuildDormInstanceNameAsync(playerId);
|
||||
|
||||
var dorm = await privateInstances.EnsureForDormAsync(
|
||||
ownerPlayerId: playerId,
|
||||
roomId: personal.Id,
|
||||
subRoomId: 0,
|
||||
baseName: dormInstanceName,
|
||||
location: personal.LocationReplicationId,
|
||||
dataBlob: dataBlob ?? string.Empty,
|
||||
photonRegion: photonRegion,
|
||||
maxCapacity: personal.MaxCapacity,
|
||||
photonRoomId: photonRoomId);
|
||||
|
||||
return new RoomInstanceDto
|
||||
{
|
||||
RoomInstanceId = dorm.InstanceId,
|
||||
RoomId = personal.Id,
|
||||
SubRoomId = 0,
|
||||
Location = personal.LocationReplicationId,
|
||||
PhotonRegionId = photonRegion,
|
||||
PhotonRoomId = photonRoomId,
|
||||
Name = dormInstanceName,
|
||||
MaxCapacity = personal.MaxCapacity,
|
||||
IsFull = false,
|
||||
IsPrivate = true,
|
||||
IsInProgress = true,
|
||||
DataBlob = dataBlob ?? string.Empty,
|
||||
EventId = 0,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string> BuildDormInstanceNameAsync(long ownerPlayerId)
|
||||
{
|
||||
var player = await db.Players.AsNoTracking()
|
||||
.Where(p => p.Id == ownerPlayerId)
|
||||
.Select(p => new { p.DisplayName, p.Username })
|
||||
.FirstOrDefaultAsync();
|
||||
var name = !string.IsNullOrWhiteSpace(player?.DisplayName)
|
||||
? player!.DisplayName
|
||||
: !string.IsNullOrWhiteSpace(player?.Username)
|
||||
? player!.Username
|
||||
: $"Player{ownerPlayerId}";
|
||||
var cleaned = new string(name
|
||||
.Select(ch => char.IsLetterOrDigit(ch) || ch == '_' || ch == '-' ? ch : '_')
|
||||
.ToArray()).Trim('_');
|
||||
return string.IsNullOrWhiteSpace(cleaned) ? $"Player{ownerPlayerId}_dorm" : $"{cleaned}_dorm";
|
||||
}
|
||||
|
||||
private async Task RecordVisitAsync(long playerId, long roomId)
|
||||
{
|
||||
var visit = await db.RoomVisits
|
||||
.FirstOrDefaultAsync(v => v.RoomId == roomId && v.PlayerId == playerId);
|
||||
var isFirst = visit is null;
|
||||
if (isFirst)
|
||||
{
|
||||
visit = new RoomVisitEntity
|
||||
{
|
||||
RoomId = roomId,
|
||||
PlayerId = playerId,
|
||||
FirstVisitAt = DateTime.UtcNow,
|
||||
};
|
||||
db.RoomVisits.Add(visit);
|
||||
}
|
||||
visit!.LastVisitAt = DateTime.UtcNow;
|
||||
visit.VisitCount += 1;
|
||||
|
||||
if (isFirst)
|
||||
{
|
||||
await db.Rooms
|
||||
.Where(r => r.Id == roomId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(r => r.VisitCount, r => r.VisitCount + 1)
|
||||
.SetProperty(r => r.VisitorCount, r => r.VisitorCount + 1)
|
||||
.SetProperty(r => r.HotScore, r => r.HotScore + 2.0));
|
||||
}
|
||||
else
|
||||
{
|
||||
await db.Rooms
|
||||
.Where(r => r.Id == roomId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(r => r.VisitCount, r => r.VisitCount + 1)
|
||||
.SetProperty(r => r.HotScore, r => r.HotScore + 0.25));
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
|
||||
/// <summary>PUT <c>/roominstance/{id}/inprogress</c> — fired by
|
||||
/// <c>RecNet.Matchmaking.SetGameInProgress(bool inProgress)</c>
|
||||
|
||||
@@ -23,6 +23,7 @@ public class MatchPlayerController(
|
||||
NotificationService notifications,
|
||||
RoomService rooms,
|
||||
PrivateInstanceService privateInstances,
|
||||
JoinTimeoutService joinTimeouts,
|
||||
IConfiguration config,
|
||||
DorkNetDbContext db,
|
||||
ILogger<MatchPlayerController> log) : ControllerBase
|
||||
@@ -38,16 +39,37 @@ public class MatchPlayerController(
|
||||
[HttpPost("/player/login")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock)
|
||||
=> await RecordLoginLockAsync(loginLock, "player-login");
|
||||
|
||||
/// <summary>
|
||||
/// POST <c>/player/exclusivelogin</c> — newer clients use this instead
|
||||
/// of <c>/player/login</c> and include a
|
||||
/// <c>TakeOverExclusiveSession</c> form value. The lock semantics are
|
||||
/// otherwise the same: the latest LoginLock wins for this account.
|
||||
/// </summary>
|
||||
[HttpPost("/player/exclusivelogin")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> PlayerExclusiveLogin(
|
||||
[FromForm(Name = "LoginLock")] string? loginLock,
|
||||
[FromForm(Name = "TakeOverExclusiveSession")] bool? takeOverExclusiveSession)
|
||||
=> await RecordLoginLockAsync(loginLock, "player-exclusive-login", takeOverExclusiveSession);
|
||||
|
||||
private async Task<IActionResult> RecordLoginLockAsync(
|
||||
string? loginLock,
|
||||
string logScope,
|
||||
bool? takeOverExclusiveSession = null)
|
||||
{
|
||||
var playerId = TryGetCurrentPlayerId();
|
||||
if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock))
|
||||
{
|
||||
presence.Clear(playerId);
|
||||
presence.BeginBootDormGrace(playerId);
|
||||
var replaced = presence.SetLock(playerId, loginLock);
|
||||
if (replaced)
|
||||
{
|
||||
log.LogWarning(
|
||||
"[player-login] concurrent session for player={PlayerId}; kicking old session(s) via ModerationKick push",
|
||||
playerId);
|
||||
"[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; kicking old session(s) via ModerationKick push",
|
||||
logScope, playerId, takeOverExclusiveSession);
|
||||
// The new session hasn't opened its SignalR socket yet
|
||||
// (negotiate fires AFTER /player/login on the watch's
|
||||
// boot sequence — verified in the live trace), so the
|
||||
@@ -136,7 +158,7 @@ public class MatchPlayerController(
|
||||
return Ok(new PresenceDto
|
||||
{
|
||||
PlayerId = playerId,
|
||||
StatusVisibility = 1,
|
||||
StatusVisibility = 0, // Everyone (was 1 = FriendsOnly)
|
||||
DeviceClass = 0,
|
||||
VrMovementMode = 0,
|
||||
RoomInstance = room,
|
||||
@@ -152,7 +174,7 @@ public class MatchPlayerController(
|
||||
return Ok(new PresenceDto
|
||||
{
|
||||
PlayerId = playerId,
|
||||
StatusVisibility = value ?? 1,
|
||||
StatusVisibility = value ?? 0, // default Everyone; honor explicit client choice
|
||||
DeviceClass = 0,
|
||||
VrMovementMode = 0,
|
||||
RoomInstance = room,
|
||||
@@ -168,7 +190,7 @@ public class MatchPlayerController(
|
||||
return Ok(new PresenceDto
|
||||
{
|
||||
PlayerId = playerId,
|
||||
StatusVisibility = 1,
|
||||
StatusVisibility = 0, // Everyone (was 1 = FriendsOnly)
|
||||
DeviceClass = 0,
|
||||
VrMovementMode = value ?? 0,
|
||||
RoomInstance = room,
|
||||
@@ -223,9 +245,12 @@ public class MatchPlayerController(
|
||||
var playerId = TryGetCurrentPlayerId();
|
||||
if (playerId == 0) return Ok();
|
||||
|
||||
var form = Request.Form;
|
||||
var regions = form["region"].ToArray();
|
||||
var pings = form["ping"].ToArray();
|
||||
var regions = Request.HasFormContentType
|
||||
? Request.Form["region"].ToArray()
|
||||
: Request.Query["region"].ToArray();
|
||||
var pings = Request.HasFormContentType
|
||||
? Request.Form["ping"].ToArray()
|
||||
: Request.Query["ping"].ToArray();
|
||||
var pairs = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < Math.Min(regions.Length, pings.Length); i++)
|
||||
{
|
||||
@@ -254,8 +279,27 @@ public class MatchPlayerController(
|
||||
if (playerId == 0) return null;
|
||||
|
||||
var room = presence.GetRoom(playerId);
|
||||
var pending = joinTimeouts.GetPending(playerId);
|
||||
if (pending is { DeferPresenceCommit: true })
|
||||
{
|
||||
log.LogInformation(
|
||||
"[player-heartbeat] player={PlayerId} pending deferred room={RoomId} instance={InstanceId}; returning null until join result",
|
||||
playerId,
|
||||
pending.Value.TargetRoom.RoomId,
|
||||
pending.Value.TargetRoom.RoomInstanceId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (room is null)
|
||||
{
|
||||
if (presence.IsBootDormGraceActive(playerId))
|
||||
{
|
||||
log.LogInformation(
|
||||
"[player-heartbeat] player={PlayerId} in boot dorm grace; returning null roomInstance",
|
||||
playerId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Boot-time race: the watch hits /player/heartbeat before
|
||||
// /goto/room/DormRoom has finished recording presence. The
|
||||
// watch's OnPresenceHeartbeatResponse compares
|
||||
@@ -285,7 +329,12 @@ public class MatchPlayerController(
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolvedDataBlob = await ResolveDataBlobForPresenceAsync(room.RoomId, playerId, room.RoomInstanceId);
|
||||
var resolvedDataBlob = await ResolveDataBlobForPresenceAsync(
|
||||
room.RoomId,
|
||||
playerId,
|
||||
room.RoomInstanceId,
|
||||
room.SubRoomId,
|
||||
room.IsPrivate);
|
||||
if (!string.IsNullOrWhiteSpace(resolvedDataBlob)
|
||||
&& !string.Equals(room.DataBlob, resolvedDataBlob, StringComparison.Ordinal))
|
||||
{
|
||||
@@ -390,7 +439,9 @@ public class MatchPlayerController(
|
||||
private async Task<string> ResolveDataBlobForPresenceAsync(
|
||||
long roomId,
|
||||
int playerId,
|
||||
long? roomInstanceId = null)
|
||||
long? roomInstanceId = null,
|
||||
long? subRoomId = null,
|
||||
bool isPrivateInstance = false)
|
||||
{
|
||||
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return string.Empty;
|
||||
@@ -401,7 +452,7 @@ public class MatchPlayerController(
|
||||
if (!string.IsNullOrWhiteSpace(dormBlob)) return dormBlob;
|
||||
}
|
||||
|
||||
if (roomInstanceId is > 0)
|
||||
if (isPrivateInstance && roomInstanceId is > 0)
|
||||
{
|
||||
var instanceBlob = await db.PrivateInstances.AsNoTracking()
|
||||
.Where(instance => instance.Id == roomInstanceId && instance.RoomId == roomId)
|
||||
@@ -410,12 +461,20 @@ public class MatchPlayerController(
|
||||
if (!string.IsNullOrWhiteSpace(instanceBlob)) return instanceBlob;
|
||||
}
|
||||
|
||||
if (subRoomId is >= 0)
|
||||
{
|
||||
var sceneBlob = await db.RoomScenes.AsNoTracking()
|
||||
.Where(scene => scene.RoomId == roomId && scene.OrderIndex == subRoomId)
|
||||
.Select(scene => scene.DataBlobName)
|
||||
.FirstOrDefaultAsync();
|
||||
if (!string.IsNullOrWhiteSpace(sceneBlob)) return sceneBlob;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
|
||||
|
||||
if (room.IsDormRoom) return string.Empty;
|
||||
if (RoomService.IsBakedOriginalRoom(room)) return string.Empty;
|
||||
|
||||
var customisable = room.CreatorPlayerId != 1;
|
||||
return customisable ? $"room_{room.Id}_v1.dat" : string.Empty;
|
||||
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,5 +504,8 @@ public class PresenceDto
|
||||
public bool IsOnline { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("appVersion")]
|
||||
public int AppVersion { get; set; } = 20201210;
|
||||
// 2023.03.21 client build version ("20230317"); must match the joining
|
||||
// client's own version or it reports a version mismatch. Was a stale 2020
|
||||
// value (20201210) left over from the December branch this forked from.
|
||||
public string AppVersion { get; set; } = "20230317";
|
||||
}
|
||||
|
||||
@@ -98,12 +98,14 @@ public class PlayerPresenceController(
|
||||
return new PlayerPresenceDto
|
||||
{
|
||||
PlayerId = (int)id,
|
||||
// StatusVisibility is the player's *preference* (would
|
||||
// they like to appear online if they were online?). For
|
||||
// offline players we still report Visible so when they
|
||||
// come back the friend's cached preference is correct,
|
||||
// and IsOnline=false is what gates the "Online" badge.
|
||||
StatusVisibility = 1, // Visible
|
||||
// StatusVisibility is the player's *preference* for who may
|
||||
// see them online. The enum is Everyone=0 / FriendsOnly=1 /
|
||||
// Nobody=2 — so the previous value 1 (mislabeled "Visible")
|
||||
// actually meant FriendsOnly, and anyone who wasn't your
|
||||
// friend saw you as OFFLINE. On a private server everyone
|
||||
// should see everyone, so report Everyone (0). IsOnline=false
|
||||
// still gates the "Online" badge for offline players.
|
||||
StatusVisibility = 0, // Everyone
|
||||
DeviceClass = 0, // Desktop / unknown — not VR
|
||||
VrMovementMode = 0,
|
||||
// Full instance details are safe, and necessary, when the
|
||||
@@ -179,5 +181,8 @@ public class PlayerPresenceDto
|
||||
public bool IsOnline { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("appVersion")]
|
||||
public int AppVersion { get; set; } = 20201210;
|
||||
// 2023.03.21 client build version ("20230317"); must match the joining
|
||||
// client's own version or it reports a version mismatch. Stale 2020 value
|
||||
// (20201210) carried over from the December branch.
|
||||
public string AppVersion { get; set; } = "20230317";
|
||||
}
|
||||
|
||||
@@ -37,13 +37,13 @@ public class NsController : ControllerBase
|
||||
// code: locker)" in BootSequence.
|
||||
[HttpGet("/", Order = -1000)]
|
||||
public ActionResult<Dictionary<string, string>> Root() =>
|
||||
Ok(ConfigService.BuildServiceUrlMap(_domain.Apex));
|
||||
Ok(ConfigService.BuildServiceUrlMap(_domain));
|
||||
|
||||
// Some builds fetch /v1 or /v1/services directly
|
||||
[HttpGet("/v1")]
|
||||
[HttpGet("/v1/services")]
|
||||
public ActionResult<Dictionary<string, string>> Services() =>
|
||||
Ok(ConfigService.BuildServiceUrlMap(_domain.Apex));
|
||||
Ok(ConfigService.BuildServiceUrlMap(_domain));
|
||||
|
||||
// Ping / health used by the client keep-alive loop
|
||||
[HttpGet("/v1/ping")]
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.PlatformNotifications;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class PlatformNotificationsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpGet("/preferences")]
|
||||
[HttpGet("/platformnotifications/preferences")]
|
||||
public async Task<IActionResult> Preferences()
|
||||
{
|
||||
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == Me);
|
||||
return Ok(ToPreferencesWire(row));
|
||||
}
|
||||
|
||||
[HttpPut("/preferences")]
|
||||
[HttpPost("/preferences")]
|
||||
[HttpPut("/platformnotifications/preferences")]
|
||||
[HttpPost("/platformnotifications/preferences")]
|
||||
public async Task<IActionResult> SetPreferences([FromBody] PlatformNotificationPrefsRequest? body)
|
||||
{
|
||||
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == Me);
|
||||
if (row is null)
|
||||
{
|
||||
row = new NotificationPrefsEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
Platform = "platform",
|
||||
};
|
||||
db.NotificationPrefs.Add(row);
|
||||
}
|
||||
|
||||
if (body is not null)
|
||||
{
|
||||
row.AllowAll = body.AllowAll ?? body.Enabled ?? row.AllowAll;
|
||||
row.AllowMessage = body.AllowMessages ?? body.Messages ?? row.AllowMessage;
|
||||
row.AllowFriendRequest = body.AllowFriendRequests ?? body.FriendRequests ?? row.AllowFriendRequest;
|
||||
row.AllowEventInvite = body.AllowEventInvites ?? body.EventInvites ?? row.AllowEventInvite;
|
||||
row.AllowAnnouncements = body.AllowAnnouncements ?? body.Announcements ?? row.AllowAnnouncements;
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToPreferencesWire(row));
|
||||
}
|
||||
|
||||
[HttpGet("/config/categories")]
|
||||
[HttpGet("/platformnotifications/config/categories")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Categories()
|
||||
{
|
||||
var categories = new[]
|
||||
{
|
||||
Category(1, "messages", "Messages"),
|
||||
Category(2, "friendRequests", "Friend Requests"),
|
||||
Category(3, "eventInvites", "Event Invites"),
|
||||
Category(4, "announcements", "Announcements"),
|
||||
};
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Categories = categories,
|
||||
Results = categories,
|
||||
TotalResults = categories.Length,
|
||||
});
|
||||
}
|
||||
|
||||
private static object Category(int id, string key, string name) => new
|
||||
{
|
||||
Id = id,
|
||||
CategoryId = id,
|
||||
Key = key,
|
||||
Name = name,
|
||||
DisplayName = name,
|
||||
IsEnabled = true,
|
||||
Enabled = true,
|
||||
DefaultEnabled = true,
|
||||
};
|
||||
|
||||
private static object ToPreferencesWire(NotificationPrefsEntity? row) => new
|
||||
{
|
||||
AllowAll = row?.AllowAll ?? true,
|
||||
Enabled = row?.AllowAll ?? true,
|
||||
AllowMessages = row?.AllowMessage ?? true,
|
||||
Messages = row?.AllowMessage ?? true,
|
||||
AllowFriendRequests = row?.AllowFriendRequest ?? true,
|
||||
FriendRequests = row?.AllowFriendRequest ?? true,
|
||||
AllowEventInvites = row?.AllowEventInvite ?? true,
|
||||
EventInvites = row?.AllowEventInvite ?? true,
|
||||
AllowAnnouncements = row?.AllowAnnouncements ?? true,
|
||||
Announcements = row?.AllowAnnouncements ?? true,
|
||||
Categories = new[]
|
||||
{
|
||||
new { CategoryId = 1, Key = "messages", Enabled = row?.AllowMessage ?? true },
|
||||
new { CategoryId = 2, Key = "friendRequests", Enabled = row?.AllowFriendRequest ?? true },
|
||||
new { CategoryId = 3, Key = "eventInvites", Enabled = row?.AllowEventInvite ?? true },
|
||||
new { CategoryId = 4, Key = "announcements", Enabled = row?.AllowAnnouncements ?? true },
|
||||
},
|
||||
};
|
||||
|
||||
public sealed class PlatformNotificationPrefsRequest
|
||||
{
|
||||
public bool? AllowAll { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public bool? AllowMessages { get; set; }
|
||||
public bool? Messages { get; set; }
|
||||
public bool? AllowFriendRequests { get; set; }
|
||||
public bool? FriendRequests { get; set; }
|
||||
public bool? AllowEventInvites { get; set; }
|
||||
public bool? EventInvites { get; set; }
|
||||
public bool? AllowAnnouncements { get; set; }
|
||||
public bool? Announcements { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Server.Controllers.PlayerSettings;
|
||||
@@ -13,10 +14,35 @@ namespace DorkNet.Server.Controllers.PlayerSettings;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase
|
||||
public class PlayerSettingsController(DorkNetDbContext db, ILogger<PlayerSettingsController> logger) : ControllerBase
|
||||
{
|
||||
private long CurrentPlayerId => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpGet("/playersettings")]
|
||||
public async Task<ActionResult<List<SettingDto>>> GetMine()
|
||||
{
|
||||
var accountId = CurrentPlayerId;
|
||||
var settings = await db.PlayerSettings
|
||||
.Where(s => s.PlayerId == accountId)
|
||||
.Select(s => new SettingDto { Key = s.Key, Value = s.Value })
|
||||
.ToListAsync();
|
||||
return Ok(settings);
|
||||
}
|
||||
|
||||
[HttpPut("/playersettings")]
|
||||
public async Task<IActionResult> SetMine()
|
||||
{
|
||||
var (key, value) = await ReadSettingKeyValueAsync();
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
return BadRequest(new PlayerSettingsResult { Success = false, Error = "missing_key" });
|
||||
|
||||
key = key.Trim();
|
||||
value ??= string.Empty;
|
||||
|
||||
await UpsertSettingAsync(CurrentPlayerId, key, value);
|
||||
return Ok(new SettingDto { Key = key, Value = value });
|
||||
}
|
||||
|
||||
[HttpGet("/settings/v1/{accountId:long}")]
|
||||
public async Task<ActionResult<Dictionary<string, string>>> Get(long accountId)
|
||||
{
|
||||
@@ -36,16 +62,9 @@ public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase
|
||||
if (accountId != CurrentPlayerId) return Forbid();
|
||||
|
||||
foreach (var (key, value) in updates)
|
||||
{
|
||||
var existing = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == accountId && s.Key == key);
|
||||
if (existing is null)
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = accountId, Key = key, Value = value });
|
||||
else
|
||||
existing.Value = value;
|
||||
}
|
||||
await UpsertSettingAsync(accountId, key, value, save: false);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
await SaveSettingsChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -59,7 +78,106 @@ public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase
|
||||
if (setting is null) return NotFound();
|
||||
|
||||
db.PlayerSettings.Remove(setting);
|
||||
await db.SaveChangesAsync();
|
||||
await SaveSettingsChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private async Task UpsertSettingAsync(long accountId, string key, string value, bool save = true)
|
||||
{
|
||||
var existing = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == accountId && s.Key == key);
|
||||
if (existing is null)
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = accountId, Key = key, Value = value });
|
||||
else
|
||||
existing.Value = value;
|
||||
|
||||
if (save) await SaveSettingsChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SaveSettingsChangesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException ex) when (SqliteErrors.IsBusy(ex))
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
logger.LogWarning(ex, "[playersettings] dropping low-priority setting write because sqlite is busy");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(string? Key, string? Value)> ReadSettingKeyValueAsync()
|
||||
{
|
||||
string? key = FirstValue(Request.Query, "key", "Key");
|
||||
string? value = FirstValue(Request.Query, "value", "Value");
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
key ??= FirstValue(form, "key", "Key");
|
||||
value ??= FirstValue(form, "value", "Value");
|
||||
}
|
||||
|
||||
if (key is null && Request.ContentLength.GetValueOrDefault() > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
key = FirstJsonString(document.RootElement, "key", "Key");
|
||||
value = FirstJsonString(document.RootElement, "value", "Value");
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Non-JSON bodies are handled by form/query parsing above.
|
||||
}
|
||||
}
|
||||
|
||||
return (key, value);
|
||||
}
|
||||
|
||||
private static string? FirstValue(IQueryCollection values, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
if (values.TryGetValue(name, out var value))
|
||||
return value.ToString();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? FirstValue(IFormCollection values, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
if (values.TryGetValue(name, out var value))
|
||||
return value.ToString();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? FirstJsonString(JsonElement obj, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
if (obj.TryGetProperty(name, out var prop))
|
||||
return prop.ValueKind == JsonValueKind.String ? prop.GetString() : prop.ToString();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public class SettingDto
|
||||
{
|
||||
[JsonPropertyName("Key")]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Value")]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class PlayerSettingsResult
|
||||
{
|
||||
[JsonPropertyName("Success")]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[JsonPropertyName("Error")]
|
||||
public string Error { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -74,6 +74,14 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
|
||||
public DbSet<ClubSubscriptionEntity> ClubSubscriptions => Set<ClubSubscriptionEntity>();
|
||||
public DbSet<SignupCodeEntity> SignupCodes => Set<SignupCodeEntity>();
|
||||
public DbSet<PendingDeviceEntity> PendingDevices => Set<PendingDeviceEntity>();
|
||||
public DbSet<CustomAvatarItemEntity> CustomAvatarItems => Set<CustomAvatarItemEntity>();
|
||||
public DbSet<CustomAvatarItemOwnershipEntity> CustomAvatarItemOwnership => Set<CustomAvatarItemOwnershipEntity>();
|
||||
public DbSet<ItemWishlistEntity> ItemWishlists => Set<ItemWishlistEntity>();
|
||||
public DbSet<KeepsakeEntity> Keepsakes => Set<KeepsakeEntity>();
|
||||
public DbSet<RoomCurrencyEntity> RoomCurrencies => Set<RoomCurrencyEntity>();
|
||||
public DbSet<RoomCurrencyBalanceEntity> RoomCurrencyBalances => Set<RoomCurrencyBalanceEntity>();
|
||||
public DbSet<RoomCurrencyPurchaseOfferEntity> RoomCurrencyPurchaseOffers => Set<RoomCurrencyPurchaseOfferEntity>();
|
||||
public DbSet<UgcPurchasableEntity> UgcPurchasables => Set<UgcPurchasableEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -679,5 +687,64 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
|
||||
e.HasKey(c => new { c.RoomId, c.Channel });
|
||||
e.HasIndex(c => c.RoomId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CustomAvatarItemEntity>(e =>
|
||||
{
|
||||
e.HasKey(i => i.Id);
|
||||
e.HasIndex(i => i.PublicId).IsUnique();
|
||||
e.HasIndex(i => i.CreatorPlayerId);
|
||||
e.HasIndex(i => i.IsPublic);
|
||||
e.HasIndex(i => i.IsFeatured);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CustomAvatarItemOwnershipEntity>(e =>
|
||||
{
|
||||
e.HasKey(o => o.Id);
|
||||
e.HasIndex(o => new { o.PlayerId, o.CustomAvatarItemId }).IsUnique();
|
||||
e.HasIndex(o => o.CustomAvatarItemId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ItemWishlistEntity>(e =>
|
||||
{
|
||||
e.HasKey(w => w.Id);
|
||||
e.HasIndex(w => new { w.PlayerId, w.ItemKey, w.ItemType }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<KeepsakeEntity>(e =>
|
||||
{
|
||||
e.HasKey(k => k.Id);
|
||||
e.HasIndex(k => k.PlayerId);
|
||||
e.HasIndex(k => new { k.PlayerId, k.EventKey });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RoomCurrencyEntity>(e =>
|
||||
{
|
||||
e.HasKey(c => c.Id);
|
||||
e.HasIndex(c => c.PublicId).IsUnique();
|
||||
e.HasIndex(c => c.RoomId);
|
||||
e.HasIndex(c => c.CreatorPlayerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RoomCurrencyBalanceEntity>(e =>
|
||||
{
|
||||
e.HasKey(b => b.Id);
|
||||
e.HasIndex(b => new { b.PlayerId, b.RoomCurrencyId }).IsUnique();
|
||||
e.HasIndex(b => b.RoomCurrencyId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RoomCurrencyPurchaseOfferEntity>(e =>
|
||||
{
|
||||
e.HasKey(o => o.Id);
|
||||
e.HasIndex(o => o.PublicId).IsUnique();
|
||||
e.HasIndex(o => o.RoomCurrencyId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<UgcPurchasableEntity>(e =>
|
||||
{
|
||||
e.HasKey(p => p.Id);
|
||||
e.HasIndex(p => p.PublicId).IsUnique();
|
||||
e.HasIndex(p => p.RoomId);
|
||||
e.HasIndex(p => p.CreatorPlayerId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
public class CustomAvatarItemEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public Guid PublicId { get; set; } = Guid.NewGuid();
|
||||
|
||||
public long CreatorPlayerId { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public int Price { get; set; } = 100;
|
||||
|
||||
public int ItemType { get; set; } = 0;
|
||||
|
||||
public int BaseAvatarItemId { get; set; }
|
||||
|
||||
[MaxLength(64)]
|
||||
public string Color { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(256)]
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(256)]
|
||||
public string AssetName { get; set; } = string.Empty;
|
||||
|
||||
public bool IsPublic { get; set; }
|
||||
|
||||
public bool IsFeatured { get; set; }
|
||||
|
||||
public long CheerCount { get; set; }
|
||||
|
||||
public long PurchaseCount { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class CustomAvatarItemOwnershipEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
public long CustomAvatarItemId { get; set; }
|
||||
|
||||
public DateTime AcquiredAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class ItemWishlistEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string ItemKey { get; set; } = string.Empty;
|
||||
|
||||
public int ItemType { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class KeepsakeEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
[MaxLength(64)]
|
||||
public string Category { get; set; } = "general";
|
||||
|
||||
[MaxLength(128)]
|
||||
public string EventKey { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(128)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(256)]
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
|
||||
public DateTime EarnedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -85,6 +85,7 @@ public class InventionEntity
|
||||
|
||||
public int CheerCount { get; set; } = 0;
|
||||
public int SpawnCount { get; set; } = 0;
|
||||
public int Price { get; set; } = 0;
|
||||
|
||||
/// <summary>Soft-delete flag. When set, the row is hidden from
|
||||
/// browse / search / detail endpoints but stays in the table so
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
public class RoomCurrencyEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public Guid PublicId { get; set; } = Guid.NewGuid();
|
||||
public long RoomId { get; set; }
|
||||
public long CreatorPlayerId { get; set; }
|
||||
[MaxLength(64)] public string Name { get; set; } = string.Empty;
|
||||
[MaxLength(512)] public string Description { get; set; } = string.Empty;
|
||||
[MaxLength(256)] public string ImageName { get; set; } = string.Empty;
|
||||
public int DailyLimit { get; set; } = 0;
|
||||
public bool IsDeleted { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class RoomCurrencyBalanceEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
public long RoomCurrencyId { get; set; }
|
||||
public long Balance { get; set; }
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class RoomCurrencyPurchaseOfferEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public Guid PublicId { get; set; } = Guid.NewGuid();
|
||||
public long RoomCurrencyId { get; set; }
|
||||
[MaxLength(64)] public string Name { get; set; } = string.Empty;
|
||||
public int Amount { get; set; }
|
||||
public int Price { get; set; }
|
||||
public int CurrencyType { get; set; } = 2;
|
||||
public bool IsDeleted { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class UgcPurchasableEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public Guid PublicId { get; set; } = Guid.NewGuid();
|
||||
public long RoomId { get; set; }
|
||||
public long CreatorPlayerId { get; set; }
|
||||
[MaxLength(128)] public string Name { get; set; } = string.Empty;
|
||||
[MaxLength(1024)] public string Description { get; set; } = string.Empty;
|
||||
[MaxLength(256)] public string ImageName { get; set; } = string.Empty;
|
||||
public int Price { get; set; }
|
||||
public int CurrencyType { get; set; } = 2;
|
||||
public int ItemType { get; set; }
|
||||
public bool IsFeatured { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsDeleted { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -47,6 +47,12 @@ public class RoomEntity
|
||||
public bool IsAGRoom { get; set; } = true;
|
||||
|
||||
public bool IsDormRoom { get; set; } = false;
|
||||
public bool IsStudioRoom { get; set; } = false;
|
||||
public bool IsRoomLinkedToRecRoomStudio { get; set; } = false;
|
||||
|
||||
[MaxLength(128)]
|
||||
public string StudioSessionId { get; set; } = string.Empty;
|
||||
|
||||
public bool CloningAllowed { get; set; } = false;
|
||||
public bool SupportsVRLow { get; set; } = true;
|
||||
public bool SupportsMobile { get; set; } = false;
|
||||
@@ -54,6 +60,9 @@ public class RoomEntity
|
||||
public bool SupportsWalkVR { get; set; } = true;
|
||||
public bool SupportsTeleportVR { get; set; } = true;
|
||||
public bool AllowsJuniors { get; set; } = true;
|
||||
public bool AllowNewUsers { get; set; } = true;
|
||||
public int MinLevel { get; set; } = 0;
|
||||
public int MaxPlayerCalculationMode { get; set; } = 0;
|
||||
|
||||
/// <summary>Max players the room advertises per instance — flows into the
|
||||
/// matchmaking RoomInstance.MaxCapacity and the v4/details synthesized
|
||||
@@ -83,6 +92,15 @@ public class RoomEntity
|
||||
[MaxLength(1024)]
|
||||
public string TagsCsv { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(4096)]
|
||||
public string LoadScreensJson { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(4096)]
|
||||
public string PromoImagesJson { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(4096)]
|
||||
public string PromoExternalContentJson { get; set; } = string.Empty;
|
||||
|
||||
public int CheerCount { get; set; } = 0;
|
||||
public int FavoriteCount { get; set; } = 0;
|
||||
|
||||
|
||||
@@ -52,6 +52,18 @@ public class RoomSceneEntity
|
||||
[MaxLength(128)]
|
||||
public string DataBlobName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Original RecNet Studio save id for this sub-room, when
|
||||
/// the scene was imported from a Studio dump. Used by
|
||||
/// rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId} to return the
|
||||
/// baked Unity asset bundle filenames the 2023 client downloads.</summary>
|
||||
public long? StudioSubRoomDataSaveId { get; set; }
|
||||
|
||||
[MaxLength(64)]
|
||||
public string StudioUnityAssetId { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(4096)]
|
||||
public string StudioAssetBundleNamesCsv { get; set; } = string.Empty;
|
||||
|
||||
public int MaxPlayers { get; set; } = 8;
|
||||
public bool IsSandbox { get; set; } = false;
|
||||
public bool CanMatchmakeInto { get; set; } = true;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user