Compare commits

..

21 Commits

Author SHA1 Message Date
Alexa ecaf17b764 Merge pull request #2 from DorkSquadRR/modernize-march-startup
Update project links
2026-06-15 14:07:36 +03:00
Alexa 4985bb09d4 Update project links 2026-06-15 11:50:13 +03:00
Alexa a5b00980a5 Merge pull request #1 from DorkSquadRR/modernize-march-startup
Modernize March server startup
2026-06-15 11:44:06 +03:00
Alexa 32cd60792f Modernize March server startup 2026-06-15 11:36:36 +03:00
Alexa 955246c6e4 feat: Refactor admin UI pages to support embedded mode and improve navigation
- Updated PlayerLogs, Players, Reports, Rooms, Settings, SignupCodes, and other pages to accept an optional `embedded` prop for better integration in various contexts.
- Introduced a new Activity page consolidating audit logs and player request logs with tab navigation.
- Added Content page for community board and loading tips with tab navigation.
- Created Moderation page to unify player directory, bans, and reports under a single interface.
- Implemented SettingsHome page to manage server settings and signup codes with tab navigation.
- Enhanced the Join page to include password creation for new accounts.
- Added global friends feature in Settings to allow all accounts to be friends without database entries.
- Introduced RelationshipQueries service to manage effective friend resolution based on global friends toggle.
- Added WireDates for consistent date handling in serialized data.
- Created reusable Tabs component for consistent tab navigation across various pages.
2026-06-06 16:39:19 +03:00
Alexa 34506e8294 admin: change a player's username from the Players panel
Add POST players/{id}/username + a Username field in the player detail's
Profile tab. Changes only the unique @handle (not the display name) and
rejects a collision outright rather than auto-suffixing, so the admin
gets the exact handle they typed. Same 2-24 char letters/digits/_/-
rule as the account API; logged to the audit trail as old -> new.
2026-05-31 20:52:11 +03:00
Alexa 11377b46d5 signup codes: admin-issued invite codes + site /join redemption
Port the December signup-code feature to the 2020.03 branch.

While account creation is disabled, the only way in is now an
admin-issued single-use signup code, redeemed on the public site's /join
page. Because the 2020.03 watch login is device-id-bound with no code
field, a new device refused at the signups-disabled gate (PlatformLogin /
account create) is stashed in a PendingDevices table keyed by the real
client IP (CF-Connecting-IP aware). The /join page shows the player the
device their own client just reported; they enter code + username and the
account is minted bound to that device, so the next launch logs straight
in. No watch changes.

- SignupCodeEntity + PendingDeviceEntity; tables created via idempotent
  CREATE TABLE IF NOT EXISTS at boot (Program.cs Postgres patch block +
  SQLite path) since they post-date the migration chain.
- SignupCodeService: generate / list / revoke / redeem + pending-device
  capture (single-use, expiry, revoke validation).
- Admin: GET/POST signup-codes + revoke endpoints and a Signup codes
  admin-ui page (generate with descriptor/expiry, status table, revoke).
- Public: anonymous /api/site/v1/join pending-devices + redeem, and a
  /join site page (+ header link).
- README documents the flow.
2026-05-31 20:08:33 +03:00
Alexa a840355489 site: load images from own img.{apex}; add Bowling/Paintball art
The public site built room/profile image URLs against a hardcoded
img.rec.net fallback for any non-localhost host, so thumbnails loaded
from real Rec Room (Azure-fronted — hence the TiPMix / x-ms-routing-name
cookies the browser rejected) and DorkNet custom-room images could never
display. Derive the CDN apex from the live host instead (shared
imageApex helper in types.ts, used by profileImageUrl and Rooms.tsx) so
images come from this server's own img.{apex}.

Add the two RR-Original thumbnails as static CDN files in data/images so
img.{apex} serves them directly: image_BowlingAlley.png and
image_PaintballKeyArt.png (both real PNGs).
2026-05-31 15:47:12 +03:00
Alexa b26ae93014 inventions: resolve real creator ids for a room (creatorIds)
api/inventions/v1/creatorIds was a stub returning an empty list, so the
2020.03 watch's GetCreatorIdsOfInventionsInRoom never learned which
creators it must request invention permissions for before spawning a
room's invention-based geometry. Resolve the real set from
InventionEntity.CreationRoomId via a shared ResolveRoomInventionsAsync
helper: direct matches (created in-place) unioned with the imported
cluster (most-frequent non-local CreationRoomId among the room creator's
inventions, since zip-imported inventions keep their original Rec Room
room id). Returns List<int> of distinct creator account ids.
2026-05-31 15:32:16 +03:00
Alexa f6a4fdc5fb weekly challenges: admin-configurable slate + working skin rewards
Port the admin-editable weekly-challenge feature to the 2020.03 branch
and fix the skin-reward bug it had on December: rewards now actually
land in the player's inventory instead of only granting XP + tokens.

Server config: add WeeklyChallenges* columns to ServerSettings (Postgres
post-EnsureCreated patches + SQLite column patches), and port the
ServerSettingsService weekly methods/records. The reward record carries
the store item Slug — the reliable handle December lacked — so the grant
can resolve the real item.

ProgressionController: CurrentChallenge + updateProgress now read the
configured slate and reward instead of a hardcoded one, keeping the
2020.03 ChallengeMap/ChallengeGift wire shapes (verified against the
march dump: ChallengeGift TypeDefIndex 11455). The Gift now sends
AvatarItemType (Outfit when an AvatarItemDesc is set) so the watch's
gift-card render binds the avatar preview. On completing the week's
challenges the configured skin/consumable is granted via the new
StoreService.GrantItemFreeBySlugAsync (same inventory path as a purchase,
no charge), gated behind the once-only reward sentinel.

Admin: GET/POST settings/weekly-challenges + reward-options (avatar +
consumable only — 2020.03 StoreService has no equipment payload helper),
and the admin-ui Server settings page to edit the slate + reward. The
reward picker keys on item slug rather than December's fragile
descriptor-string match.

Docs: README no longer lists weekly challenges as December-only;
url-coverage notes the config-driven challenge endpoints + skin grant.
2026-05-31 14:47:12 +03:00
Alexa 1eba8d9345 site/admin-ui: relative asset base for single-origin path prefix
Use base './' so built index.html references ./assets/... instead of
/assets/..., letting the SPAs work under the Easy Launcher's
single-origin path prefix (https://<apex>/__dn/admin/) as well as the
subdomain layout. Root-absolute paths would skip the prefix and 404.
2026-05-31 12:33:16 +03:00
Alexa a9518b08ab clientmod: parameterize EnableTlsTrustBypass via template placeholder
Render the flag from {ENABLE_TLS_TRUST_BYPASS} instead of hardcoding
true, so the launcher's opt-in toggle controls it. Off by default
since Localtunnel/Tunnelto serve valid certs.
2026-05-31 12:33:16 +03:00
Alexa 82c6f72219 Remove BepInEx client patcher (DorkNet.ClientPatch)
MelonLoader (DorkNet.ClientMod) is the sole client patcher now:

- delete the DorkNet.ClientPatch project + tools/install-plugin.ps1
- drop the project from DorkNet.sln
- README / deploy docs: present DorkNet.ClientMod (install-melon.ps1)
  as the patcher instead of the BepInEx plugin
- drop the dead install-plugin.ps1 reference from Program.cs's JWT
  error and install-melon.ps1's DRM-fallback hint
- tidy stray 'BepInEx port' comments

Legacy-cleanup paths that REMOVE an old BepInEx install
(install-legacy-client.ps1, build-patcher-zip.ps1's old_plugin_paths)
are kept intentionally.
2026-05-30 02:12:00 +03:00
Alexa 80cc4ce0bd Eager Photon AppId override via OnUpdate poll
The harmony hook on PhotonNetwork.ConnectUsingSettings registers in
March's IL2CPP build but never actually fires — the watch's connect
path goes through a different (mangled) entry point. Log confirms
[patch-ok] PhotonNetwork.ConnectUsingSettings but no
[photon-appid] override line, ever.

Fix moves the override out of the prefix-only path:

- New PhotonPatches.TryApplyOverride(reason) does the
  PhotonServerSettings.AppID / VoiceAppID / region mutation and
  flips an OverrideApplied flag once it succeeds. Idempotent.
- OnUpdate polls TryApplyOverride every 6th frame (~10 Hz) until
  it succeeds, then stops. The "type not found yet" log is
  throttled to once on first miss + every 300th miss after, so
  the file doesn't drown if PhotonServerSettings stays unloaded.
- Mod.ResolveType made internal (was private) so PhotonPatches
  + SavePatches can use the broader assembly-scan fallback —
  the local FindGameType copies were too narrow and missed
  IL2CPP-mangled type names. Both FindGameType helpers collapse
  to one-line delegates to Mod.ResolveType.
- Prefix on ConnectUsingSettings kept as a belt-and-braces hook
  (passes reason="prefix"); harmless if it fires, fine if it
  doesn't.
2026-05-28 13:46:39 +03:00
Alexa cd9d10586c Park Photon CustomAuth injector
- Photon dashboard has CustomAuth disabled on the AppId now, so the
  watch never needs to attach userid/LoginLock to its Authenticate
  ops. Removed the harmony hook on NetworkingPeer.CallAuthenticate,
  the AuthValuesInjector_Prefix method, the GetCurrentLockToken
  helper, and the InjectAuthValues Cfg field + config load. Bumped
  the [config] log line to drop the InjectAuthValues field.
- Both dorknet-clientmod.json.template copies (source +
  local-releases) lose the "InjectAuthValues" key.
- Code moved verbatim to DorkNet.ClientMod/attic/AuthValuesInjector.cs.attic
  with a header explaining why it was parked and how to restore it.
  The .cs.attic suffix keeps the SDK's auto-globber from compiling it.
2026-05-28 13:21:34 +03:00
Alexa 46e2bb15e4 Single-origin URL rewriting; configurable SQLite path; default InjectAuthValues=false
- ClientMod: add SingleOriginBaseUrl config + URI rewrite path so the
  client can talk to one https://<apex>/__dn/<service>/... base
  instead of demanding wildcard subdomains. ServerHost rewrite still
  the fallback when SingleOriginBaseUrl is empty.
- ClientMod config: InjectAuthValues now defaults to false. The
  server's PhotonCustomAuthController already fail-opens
  (Photon:RequireCustomAuth=false), so anonymous bootstrap is the
  cleaner default and avoids Photon's CustomAuthFailed loop when a
  WAF returns 403 to Photon's probe.
- Server: SQLite path now reads Database:SqlitePath config
  (Database__SqlitePath env). Falls back to <bin>/data/dorknet.db
  so existing Docker / standalone deploys are unchanged. Lets the
  Windows launcher put the db under %APPDATA%\DorkNet.
- Server: DomainConfig + ConfigService updates for the single-origin
  apex.
- .gitignore: exclude local-releases.zip.
2026-05-28 01:39:54 +03:00
Alexa 676dcd09a7 Support local domains and bundled client patching 2026-05-27 18:27:09 +03:00
Alexa ecbf192e47 patcher: install mod to <root>/Mods, mirrors main f9a40ba
MelonLoader 0.6.x scans <install root>\Mods\ for plugin DLLs and
ignores MelonLoader\Mods\, so 'plugin_dest' = 'MelonLoader/Mods'
in the manifest meant DorkNet.ClientMod.dll was never loaded —
Player.log showed '0 Mods loaded.' and the watch booted unpatched.
2026-05-27 13:13:32 +03:00
Alexa bc8bcd99d0 patcher: bundle Goldberg steam_api64.dll stub + steam_appid
Stripping SteamStub DRM with Steamless is necessary but not
sufficient — Rec Room's Plugins/steam_api64.dll still tries to
talk to Steam at runtime and bails when Steam isn't loaded. The
working test installs swap in Goldberg Steam Emulator (~11 MB),
which fakes a logged-in user without needing Steam running.

- DorkNet.ClientMod/steam-stub/ holds the stub DLL + a 7-byte
  steam_appid.txt (Rec Room is Steam app 471710)
- build-patcher-zip.ps1 copies both into the zip and writes a new
  steam_stub block in manifest.json so the launcher knows where to
  install them
- Manifest schema is back-compat: launchers that don't know the
  steam_stub block just ignore it

Without this, the launcher's Steamless step produced a runnable
exe that exited within a second of launch because of the Steam
API failure. With it, the unwrapped exe starts up cleanly.
2026-05-27 13:03:43 +03:00
Alexa 1f407ef7b3 clientmod: build without a local Rec Room install
The mod resolves every game-side type at runtime via
AccessTools.TypeByName, so the IL2CPP interop assemblies
(Assembly-CSharp, Photon3Unity3D, UnityEngine.*) in the csproj were
dead references. Dropped them entirely — only MelonLoader's own
DLLs are actually needed at compile time.

tools/build-patcher-zip.ps1 now downloads MelonLoader BEFORE the
dotnet build and points MelonLoaderDir at the extracted contents,
so a fresh contributor checkout builds the patcher zip cold with
no Rec Room install required. (Previously the csproj defaulted
RecRoomDir to a hardcoded user path and failed for anyone else.)

Verified by deleting bin/obj and running the script from scratch
on a machine without RecRoomDir set — produces a 20 MB zip with
the same manifest/DLL/MelonLoader.zip layout as before.
2026-05-27 11:47:25 +03:00
Alexa d471b418b9 Import march-2020-03-10 from private repo (scrubbed 2026-05-27)
Targets Rec Room 2020.03.10 client (also serves 2020.03.06).

Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
  the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces dorknet-clientpatch-{key}.zip
  for v1-march-* Release uploads.
- .github/workflows/release.yml — on tag push publishes both
  dorknet-server-march_2020_03_10-win-x64.zip and
  dorknet-clientpatch-march_2020_03_10.zip as Release assets.

Same scrub rules as december; tools/build-patcher-zip.ps1 added to
the maintainer-side allow-list so it survives.
2026-05-27 01:06:12 +03:00
350 changed files with 38470 additions and 150651 deletions
@@ -1,78 +0,0 @@
# 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
+3 -6
View File
@@ -1,4 +1,4 @@
name: Release (december-2020-12-18)
name: Release (march-2020-03-10)
# Auto-publishes dorknet-server + dorknet-clientpatch zips to a
# GitHub Release on tag push. The launcher (on the public main
@@ -9,17 +9,14 @@ name: Release (december-2020-12-18)
on:
push:
tags:
- 'v1-december-*'
permissions:
contents: write # create the GitHub release + upload assets
- 'v1-march-*'
jobs:
release:
runs-on: windows-latest
env:
VERSION_KEY: december_2020_12_18
VERSION_KEY: march_2020_03_10
steps:
- uses: actions/checkout@v4
+2 -23
View File
@@ -8,6 +8,7 @@ build/
.vscode/
*.csproj.bak
dist/
local-releases/
## Data / secrets
# Local SQLite + uploaded blobs only — DON'T match the source-tree
# DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on
@@ -48,7 +49,6 @@ dist$ZIPNAME.zip
## time someone rebuilds. Keep the source committed, regenerate output.
DorkNet.Server/wwwroot/admin/index.html
DorkNet.Server/wwwroot/admin/assets/
DorkNet.Server/wwwroot/admin/brand/
DorkNet.Server/wwwroot/admin/*.map
DorkNet.Server/admin-ui/node_modules/
DorkNet.Server/admin-ui/dist/
@@ -59,30 +59,9 @@ DorkNet.Server/admin-ui/tsconfig.tsbuildinfo
## Same reasoning as admin-ui above: hashed output, regenerate per build.
DorkNet.Server/wwwroot/site/index.html
DorkNet.Server/wwwroot/site/assets/
DorkNet.Server/wwwroot/site/brand/
DorkNet.Server/wwwroot/site/*.map
DorkNet.Server/site/node_modules/
DorkNet.Server/site/dist/
DorkNet.Server/site/.vite/
DorkNet.Server/site/tsconfig.tsbuildinfo
steamless/
## Local recovery / cache-mining artifacts produced by tools/recover-inventions.ps1
## and friends. These can run hundreds of MB to multi-GB (HTTPCache snapshots,
## raw .inv bytes staged for re-import, ready-to-import zip bundles). Per-room,
## per-run, regeneratable from the source room zip + a live walkthrough — never
## belongs in source control.
*-cache-snapshot/
*-cache-snapshot-*/
*-recovered-inv/
*-recovered.zip
DorkSquad-recovered/
ProficioVII-recovered/
Recovered.zip
Recovered/
Recovered(*)/
local-releases/**
local-releases.zip
+4 -6
View File
@@ -1,14 +1,14 @@
# DorkNet.Server container image — Coolify deploy target.
# ────────────────────────────────────────────────────────────────────
# Multi-stage: SDK image builds + publishes, runtime image is the
# slimmer aspnet:10.0 with only the published output.
# slimmer aspnet:9.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=10.0
ARG DOTNET_VERSION=9.0
ARG NODE_VERSION=20
# ── admin SPA build stage ──────────────────────────────────────────
@@ -53,14 +53,12 @@ WORKDIR /src
# DorkNet.Models project ref, so its csproj needs to be present too.
COPY DorkNet.Server/DorkNet.Server.csproj DorkNet.Server/
COPY DorkNet.Models/DorkNet.Models.csproj DorkNet.Models/
COPY DorkNet.Contracts/DorkNet.Contracts.csproj DorkNet.Contracts/
RUN dotnet restore DorkNet.Server/DorkNet.Server.csproj
# Now copy the rest of the .NET source, then drop the prebuilt admin
# SPA into wwwroot/admin so the publish step ships it as static content.
COPY DorkNet.Server/ DorkNet.Server/
COPY DorkNet.Models/ DorkNet.Models/
COPY DorkNet.Contracts/ DorkNet.Contracts/
COPY --from=admin-ui /wwwroot/admin/ DorkNet.Server/wwwroot/admin/
COPY --from=site /wwwroot/site/ DorkNet.Server/wwwroot/site/
@@ -78,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:10.0 base image strips both by
# both have to be present. The aspnet:9.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:10.0 ships with the `app` UID 1654.
# Run as a non-root user — aspnet:9.0 ships with the `app` UID 1654.
# Coolify mounts persistent volumes with write permission for this UID.
USER app
-55
View File
@@ -1,55 +0,0 @@
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\""]
-39
View File
@@ -1,39 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Pages;
namespace DorkNet.AdminMobile;
public class App : Application
{
public App(LoginPage loginPage)
{
UserAppTheme = AppTheme.Dark;
MainPage = new NavigationPage(loginPage)
{
BarBackgroundColor = AppDesign.Canvas,
BarTextColor = AppDesign.Text,
};
}
public static void ShowAdmin()
{
Current!.MainPage = new NavigationPage(
AppServices.Services.GetRequiredService<AdminTabbedPage>())
{
BarBackgroundColor = AppDesign.Canvas,
BarTextColor = AppDesign.Text,
};
}
public static void ShowLogin()
{
Current!.MainPage = new NavigationPage(
AppServices.Services.GetRequiredService<LoginPage>())
{
BarBackgroundColor = AppDesign.Canvas,
BarTextColor = AppDesign.Text,
};
}
}
-120
View File
@@ -1,120 +0,0 @@
using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Shapes;
namespace DorkNet.AdminMobile.Design;
public static class AppDesign
{
public static readonly Color Canvas = Color.FromArgb("#0f1115");
public static readonly Color Surface = Color.FromArgb("#171a20");
public static readonly Color SurfaceLifted = Color.FromArgb("#20252d");
public static readonly Color RowSurface = Color.FromArgb("#14171d");
public static readonly Color Stroke = Color.FromArgb("#2a3039");
public static readonly Color Text = Color.FromArgb("#f4f6fa");
public static readonly Color Muted = Color.FromArgb("#aeb7c6");
public static readonly Color Subtle = Color.FromArgb("#778294");
public static readonly Color Accent = Color.FromArgb("#64b5ff");
public static readonly Color AndroidAccent = Color.FromArgb("#b7dd72");
public static readonly Color Danger = Color.FromArgb("#ff6f82");
public static Brush PageBackground => new SolidColorBrush(Canvas);
public static Color PlatformAccent =>
#if ANDROID
AndroidAccent;
#else
Accent;
#endif
public static Label Title(string text) => new()
{
Text = text,
TextColor = Text,
FontSize = 22,
FontAttributes = FontAttributes.Bold,
LineBreakMode = LineBreakMode.TailTruncation,
};
public static Label Section(string text) => new()
{
Text = text.ToUpperInvariant(),
TextColor = Subtle,
FontSize = 12,
FontAttributes = FontAttributes.Bold,
Margin = new Thickness(0, 12, 0, 0),
};
public static Border GlassCard(View content, Thickness? padding = null) => new()
{
Padding = padding ?? new Thickness(14),
Stroke = Stroke,
StrokeThickness = 1,
BackgroundColor = Surface,
StrokeShape = new RoundRectangle { CornerRadius = 10 },
Content = content,
};
public static Button PrimaryButton(string text) => new()
{
Text = text,
BackgroundColor = PlatformAccent,
TextColor = Colors.Black,
CornerRadius = 10,
HeightRequest = 44,
FontAttributes = FontAttributes.Bold,
Padding = new Thickness(14, 0),
};
public static Button SecondaryButton(string text) => new()
{
Text = text,
BackgroundColor = SurfaceLifted,
TextColor = Text,
CornerRadius = 10,
HeightRequest = 42,
Padding = new Thickness(14, 0),
};
public static Button DangerButton(string text) => new()
{
Text = text,
BackgroundColor = Color.FromArgb("#4c1d28"),
TextColor = Color.FromArgb("#ffd5dc"),
CornerRadius = 10,
HeightRequest = 42,
Padding = new Thickness(12, 0),
};
public static Entry Entry(string placeholder, Keyboard? keyboard = null, bool secret = false) => new()
{
Placeholder = placeholder,
IsPassword = secret,
TextColor = Text,
PlaceholderColor = Subtle,
BackgroundColor = Color.FromArgb("#19202b"),
ClearButtonVisibility = ClearButtonVisibility.WhileEditing,
Keyboard = keyboard ?? Keyboard.Text,
HeightRequest = 44,
};
public static Label Caption(string text) => new()
{
Text = text,
TextColor = Muted,
FontSize = 13,
LineBreakMode = LineBreakMode.WordWrap,
};
public static BoxView Divider() => new()
{
HeightRequest = 1,
Color = Stroke,
Opacity = 0.7,
};
public static Label StatusLabel() => new()
{
TextColor = Muted,
FontSize = 13,
};
}
@@ -1,32 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net9.0-android36.0;net9.0-ios26.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<RootNamespace>DorkNet.AdminMobile</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ApplicationTitle>DorkNet Admin</ApplicationTitle>
<ApplicationId>net.dorknet.adminmobile</ApplicationId>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>3</ApplicationVersion>
<AndroidTargetSdkVersion>36</AndroidTargetSdkVersion>
<RuntimeIdentifiers>android-arm;android-arm64;android-x86;android-x64</RuntimeIdentifiers>
<PublishTrimmed Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">false</PublishTrimmed>
<RunAOTCompilation Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">false</RunAOTCompilation>
<AndroidLinkMode Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">None</AndroidLinkMode>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">23.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">14.2</SupportedOSPlatformVersion>
</PropertyGroup>
<ItemGroup>
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#0B0F14" />
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#0B0F14" BaseSize="256,256" />
<MauiImage Include="Resources\Brand\dorknet.svg" BaseSize="256,256" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="9.0.120" />
</ItemGroup>
</Project>
-34
View File
@@ -1,34 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Maui.Controls.Hosting;
using Microsoft.Maui.Hosting;
using DorkNet.AdminMobile.Pages;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>();
builder.Services.AddSingleton<SecureAdminSettings>();
builder.Services.AddSingleton<AdminApiClient>();
builder.Services.AddTransient<LoginPage>();
builder.Services.AddTransient<AdminTabbedPage>();
builder.Services.AddTransient<DashboardPage>();
builder.Services.AddTransient<PlayersPage>();
builder.Services.AddTransient<RoomsPage>();
builder.Services.AddTransient<SettingsPage>();
var app = builder.Build();
AppServices.Services = app.Services;
return app;
}
}
public static class AppServices
{
public static IServiceProvider Services { get; set; } = default!;
}
-114
View File
@@ -1,114 +0,0 @@
using System.Text.Json.Serialization;
namespace DorkNet.AdminMobile.Models;
public sealed class AdminLoginResponse
{
[JsonPropertyName("access_token")]
public string AccessToken { get; set; } = string.Empty;
[JsonPropertyName("refresh_token")]
public string RefreshToken { get; set; } = string.Empty;
[JsonPropertyName("account_id")]
public long AccountId { get; set; }
[JsonPropertyName("username")]
public string Username { get; set; } = string.Empty;
[JsonPropertyName("display_name")]
public string DisplayName { get; set; } = string.Empty;
}
public sealed class PlayerSummary
{
public long Id { get; set; }
public string Username { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string? Email { get; set; }
public bool IsAdmin { get; set; }
public bool IsDeveloper { get; set; }
public bool IsVerified { get; set; }
public bool IsJunior { get; set; }
public bool Online { get; set; }
public int Level { get; set; }
public int XP { get; set; }
public DateTime? LastSeenAt { get; set; }
public DateTime? BannedUntil { get; set; }
}
public sealed class RoomSummary
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public long CreatorPlayerId { get; set; }
public int BlobCount { get; set; }
public bool IsAGRoom { get; set; }
public bool IsDormRoom { get; set; }
}
public sealed class InstanceSummary
{
public long RoomInstanceId { get; set; }
public long RoomId { get; set; }
public string RoomName { get; set; } = string.Empty;
public string PhotonRoomId { get; set; } = string.Empty;
public string PhotonRegionId { get; set; } = string.Empty;
public int MaxCapacity { get; set; }
public bool IsPrivate { get; set; }
public List<InstanceParticipant> Participants { get; set; } = [];
}
public sealed class InstanceParticipant
{
public long Id { get; set; }
public string Username { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public bool IsMaster { get; set; }
}
public sealed class ServerSettingsDto
{
public bool SignupsDisabled { get; set; }
public bool GlobalFriendsEnabled { get; set; }
public bool WeeklyChallengesCompletedRequired { get; set; }
public DateTime UpdatedAt { get; set; }
}
public sealed class AdminStats
{
public PlayerStats Players { get; set; } = new();
public RoomStats Rooms { get; set; } = new();
public int Inventions { get; set; }
public PhotoStats Photos { get; set; } = new();
public ModerationStats Moderation { get; set; } = new();
public DateTime ServerTime { get; set; }
}
public sealed class PlayerStats
{
public int Total { get; set; }
public int OnlineNow { get; set; }
public int BannedNow { get; set; }
public int NewToday { get; set; }
}
public sealed class RoomStats
{
public int Total { get; set; }
public long TotalVisits { get; set; }
public long TotalCheers { get; set; }
public int ActiveSessionCount { get; set; }
public int InGamePlayerCount { get; set; }
}
public sealed class PhotoStats
{
public int Today { get; set; }
}
public sealed class ModerationStats
{
public int OpenReports { get; set; }
public int ActiveIpBans { get; set; }
}
@@ -1,23 +0,0 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
namespace DorkNet.AdminMobile.Pages;
public sealed class AdminTabbedPage : TabbedPage
{
public AdminTabbedPage(
DashboardPage dashboard,
PlayersPage players,
RoomsPage rooms,
SettingsPage settings)
{
BarBackgroundColor = AppDesign.Canvas;
BarTextColor = AppDesign.Text;
SelectedTabColor = AppDesign.PlatformAccent;
UnselectedTabColor = AppDesign.Subtle;
Children.Add(dashboard);
Children.Add(players);
Children.Add(rooms);
Children.Add(settings);
}
}
@@ -1,91 +0,0 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Models;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile.Pages;
public sealed class DashboardPage : ContentPage
{
private readonly AdminApiClient api;
private readonly Label status = AppDesign.StatusLabel();
private readonly Grid metrics = new() { ColumnSpacing = 10, RowSpacing = 10 };
public DashboardPage(AdminApiClient api)
{
this.api = api;
Title = "Dashboard";
Background = AppDesign.PageBackground;
var refresh = AppDesign.SecondaryButton("Refresh");
refresh.Clicked += async (_, _) => await LoadAsync();
Content = new ScrollView
{
Content = new VerticalStackLayout
{
Padding = 16,
Spacing = 14,
Children =
{
AppDesign.Title("Operations"),
refresh,
metrics,
status,
},
},
};
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (metrics.Children.Count == 0) await LoadAsync();
}
private async Task LoadAsync()
{
try
{
status.Text = "Loading...";
var s = await api.GetStatsAsync();
DrawMetrics(s);
status.Text = $"Server time {s.ServerTime:g}";
}
catch (Exception ex)
{
status.Text = ex.Message;
}
}
private void DrawMetrics(AdminStats s)
{
metrics.Children.Clear();
metrics.RowDefinitions.Clear();
metrics.ColumnDefinitions.Clear();
metrics.ColumnDefinitions.Add(new ColumnDefinition());
metrics.ColumnDefinitions.Add(new ColumnDefinition());
AddMetric(0, 0, "Players", $"{s.Players.Total}", $"{s.Players.OnlineNow} online");
AddMetric(0, 1, "Rooms", $"{s.Rooms.Total}", $"{s.Rooms.ActiveSessionCount} active sessions");
AddMetric(1, 0, "Moderation", $"{s.Moderation.OpenReports}", "open reports");
AddMetric(1, 1, "Traffic", $"{s.Rooms.InGamePlayerCount}", $"{s.Rooms.TotalVisits:n0} visits");
AddMetric(2, 0, "New today", $"{s.Players.NewToday}", "players");
AddMetric(2, 1, "Photos", $"{s.Photos.Today}", "last 24h");
}
private void AddMetric(int row, int col, string label, string value, string caption)
{
while (metrics.RowDefinitions.Count <= row) metrics.RowDefinitions.Add(new RowDefinition());
var tile = AppDesign.GlassCard(new VerticalStackLayout
{
Spacing = 4,
Children =
{
new Label { Text = label, TextColor = AppDesign.Subtle, FontSize = 12 },
new Label { Text = value, TextColor = AppDesign.Text, FontSize = 28, FontAttributes = FontAttributes.Bold },
new Label { Text = caption, TextColor = AppDesign.Muted, FontSize = 12 },
},
}, new Thickness(14));
metrics.Add(tile, col, row);
}
}
-137
View File
@@ -1,137 +0,0 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile.Pages;
public sealed class LoginPage : ContentPage
{
private readonly AdminApiClient api;
private readonly SecureAdminSettings settings;
private readonly Entry baseUrl = AppDesign.Entry("https://admin.example.com", Keyboard.Url);
private readonly Entry cfClientId = AppDesign.Entry("Cloudflare Access Client ID");
private readonly Entry cfClientSecret = AppDesign.Entry("Cloudflare Access Client Secret", secret: true);
private readonly Entry cfJwt = AppDesign.Entry("Cloudflare Access JWT assertion", secret: true);
private readonly Entry username = AppDesign.Entry("Admin username");
private readonly Entry password = AppDesign.Entry("Password", secret: true);
private readonly Label status = AppDesign.StatusLabel();
private readonly ActivityIndicator busy = new() { IsVisible = false };
private bool loaded;
public LoginPage(AdminApiClient api, SecureAdminSettings settings)
{
this.api = api;
this.settings = settings;
Title = "DorkNet Admin";
Background = AppDesign.PageBackground;
var login = AppDesign.PrimaryButton("Sign in");
login.Clicked += OnLogin;
var continueSaved = AppDesign.SecondaryButton("Continue saved session");
continueSaved.Clicked += async (_, _) =>
{
if (await settings.GetJwtAsync() is { Length: > 0 }) App.ShowAdmin();
else status.Text = "No saved DorkNet admin session is stored.";
};
Content = new ScrollView
{
Content = new VerticalStackLayout
{
Padding = new Thickness(20, 28),
Spacing = 14,
Children =
{
new Image
{
Source = "dorknet.svg",
WidthRequest = 96,
HeightRequest = 96,
HorizontalOptions = LayoutOptions.Start,
},
AppDesign.Title("Admin mobile"),
new Label
{
Text = "Connect to a DorkNet admin host. Cloudflare Access service tokens are sent with every request when configured.",
TextColor = AppDesign.Muted,
FontSize = 14,
},
AppDesign.GlassCard(new VerticalStackLayout
{
Spacing = 10,
Children =
{
AppDesign.Section("Connection"),
baseUrl,
cfClientId,
cfClientSecret,
cfJwt,
},
}),
AppDesign.GlassCard(new VerticalStackLayout
{
Spacing = 10,
Children =
{
AppDesign.Section("DorkNet admin login"),
username,
password,
login,
continueSaved,
},
}),
busy,
status,
},
},
};
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (loaded) return;
loaded = true;
try
{
var saved = await settings.LoadConnectionAsync();
baseUrl.Text = saved.BaseUrl;
cfClientId.Text = saved.CloudflareAccessClientId;
cfClientSecret.Text = saved.CloudflareAccessClientSecret;
cfJwt.Text = saved.CloudflareAccessJwt;
}
catch (Exception ex)
{
baseUrl.Text = "https://admin.rec.net";
status.Text = $"Secure storage was reset: {ex.Message}";
}
}
private async void OnLogin(object? sender, EventArgs e)
{
busy.IsVisible = true;
busy.IsRunning = true;
status.Text = string.Empty;
try
{
await settings.SaveConnectionAsync(new AdminConnectionSettings(
baseUrl.Text ?? string.Empty,
cfClientId.Text ?? string.Empty,
cfClientSecret.Text ?? string.Empty,
cfJwt.Text ?? string.Empty));
await api.LoginAsync(username.Text ?? string.Empty, password.Text ?? string.Empty);
App.ShowAdmin();
}
catch (Exception ex)
{
status.Text = ex.Message;
}
finally
{
busy.IsRunning = false;
busy.IsVisible = false;
}
}
}
-222
View File
@@ -1,222 +0,0 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Models;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile.Pages;
public sealed class PlayersPage : ContentPage
{
private readonly AdminApiClient api;
private readonly Entry search = AppDesign.Entry("Search players");
private readonly CollectionView list = new();
private readonly Label status = AppDesign.StatusLabel();
private readonly Button refresh = AppDesign.SecondaryButton("Refresh");
private List<PlayerSummary> players = [];
public PlayersPage(AdminApiClient api)
{
this.api = api;
Title = "Players";
Background = AppDesign.PageBackground;
refresh.Clicked += async (_, _) => await LoadAsync();
search.Completed += async (_, _) => await LoadAsync();
list.SelectionMode = SelectionMode.Single;
list.ItemSizingStrategy = ItemSizingStrategy.MeasureFirstItem;
list.SelectionChanged += async (_, e) =>
{
if (e.CurrentSelection.FirstOrDefault() is PlayerSummary player)
{
list.SelectedItem = null;
await PlayerActionsAsync(player);
}
};
list.EmptyView = new Label
{
Text = "No players loaded",
TextColor = AppDesign.Subtle,
HorizontalTextAlignment = TextAlignment.Center,
VerticalTextAlignment = TextAlignment.Center,
};
list.ItemTemplate = new DataTemplate(() =>
{
var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 16 };
name.SetBinding(Label.TextProperty, nameof(PlayerSummary.DisplayName));
var handle = new Label { TextColor = AppDesign.Muted, FontSize = 13 };
handle.SetBinding(Label.TextProperty, new Binding(nameof(PlayerSummary.Username), stringFormat: "@{0}"));
var detail = new Label { TextColor = AppDesign.Subtle, FontSize = 12 };
detail.SetBinding(Label.TextProperty, new Binding(nameof(PlayerSummary.Level), stringFormat: "Level {0}"));
var online = new Label { TextColor = AppDesign.AndroidAccent, FontSize = 12, FontAttributes = FontAttributes.Bold };
online.SetBinding(Label.IsVisibleProperty, nameof(PlayerSummary.Online));
online.Text = "ONLINE";
var chevron = new Label
{
Text = ">",
TextColor = AppDesign.Subtle,
FontSize = 18,
VerticalTextAlignment = TextAlignment.Center,
};
var row = new Grid
{
RowDefinitions =
{
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Auto },
},
ColumnDefinitions =
{
new ColumnDefinition(),
new ColumnDefinition { Width = GridLength.Auto },
new ColumnDefinition { Width = GridLength.Auto },
},
Padding = new Thickness(12, 10),
RowSpacing = 2,
ColumnSpacing = 10,
BackgroundColor = AppDesign.RowSurface,
};
row.Add(name, 0, 0);
row.Add(online, 1, 0);
row.Add(handle, 0, 1);
row.Add(detail, 0, 2);
row.Add(chevron, 2, 0);
Grid.SetRowSpan(chevron, 3);
return new VerticalStackLayout
{
Spacing = 0,
Children = { row, AppDesign.Divider() },
};
});
var searchRow = new Grid
{
ColumnSpacing = 8,
ColumnDefinitions =
{
new ColumnDefinition(),
new ColumnDefinition { Width = GridLength.Auto },
},
};
searchRow.Add(search, 0, 0);
searchRow.Add(refresh, 1, 0);
var grid = new Grid
{
Padding = 16,
RowSpacing = 12,
RowDefinitions =
{
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Auto },
new RowDefinition(),
new RowDefinition { Height = GridLength.Auto },
},
};
grid.Add(AppDesign.Title("Players"), 0, 0);
grid.Add(searchRow, 0, 1);
grid.Add(list, 0, 2);
grid.Add(status, 0, 3);
Content = grid;
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (players.Count == 0) await LoadAsync();
}
private async Task LoadAsync()
{
try
{
refresh.IsEnabled = false;
status.Text = "Loading...";
players = await api.GetPlayersAsync(search.Text);
list.ItemsSource = players;
status.Text = $"{players.Count} players loaded";
}
catch (Exception ex)
{
status.Text = ex.Message;
}
finally
{
refresh.IsEnabled = true;
}
}
private async Task PlayerActionsAsync(PlayerSummary player)
{
var action = await DisplayActionSheet(
$"@{player.Username}",
"Cancel",
"Remove account",
"Kick",
"Reset avatar");
switch (action)
{
case "Kick":
await ConfirmAction($"Kick @{player.Username}?", () => api.KickPlayerAsync(player.Id, "Kicked from mobile admin."));
break;
case "Reset avatar":
await ConfirmAction($"Reset @{player.Username}'s avatar?", () => api.ResetAvatarAsync(player.Id));
break;
case "Remove account":
await RemoveAccountAsync(player);
break;
}
}
private async Task ConfirmAction(string message, Func<Task> action)
{
if (!await DisplayAlert("Confirm", message, "Run", "Cancel")) return;
try
{
await action();
await LoadAsync();
}
catch (Exception ex)
{
await DisplayAlert("Request failed", ex.Message, "OK");
}
}
private async Task RemoveAccountAsync(PlayerSummary player)
{
var username = await DisplayPromptAsync(
"Verify username",
$"Type {player.Username} exactly to remove @{player.Username}.",
"Next",
"Cancel",
maxLength: Math.Max(32, player.Username.Length),
keyboard: Keyboard.Text);
if (!string.Equals(username?.Trim(), player.Username, StringComparison.Ordinal)) return;
var phrase = $"DELETE {player.Id}";
var typedPhrase = await DisplayPromptAsync(
"Verify removal",
$"Type {phrase} exactly. This cannot be undone.",
"Remove",
"Cancel",
maxLength: phrase.Length,
keyboard: Keyboard.Text);
if (!string.Equals(typedPhrase?.Trim(), phrase, StringComparison.Ordinal)) return;
if (!await DisplayAlert("Remove account", $"Permanently remove @{player.Username}?", "Remove", "Cancel")) return;
try
{
status.Text = $"Removing @{player.Username}...";
await api.DeletePlayerAsync(player.Id, player.Username, phrase, "Removed from mobile admin.");
await LoadAsync();
}
catch (Exception ex)
{
await DisplayAlert("Removal failed", ex.Message, "OK");
}
}
}
-129
View File
@@ -1,129 +0,0 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Models;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile.Pages;
public sealed class RoomsPage : ContentPage
{
private readonly AdminApiClient api;
private readonly CollectionView rooms = new();
private readonly CollectionView instances = new();
private readonly Label status = AppDesign.StatusLabel();
private readonly Button refresh = AppDesign.SecondaryButton("Refresh");
private bool loaded;
public RoomsPage(AdminApiClient api)
{
this.api = api;
Title = "Rooms";
Background = AppDesign.PageBackground;
rooms.SelectionMode = SelectionMode.None;
rooms.ItemSizingStrategy = ItemSizingStrategy.MeasureFirstItem;
rooms.EmptyView = EmptyList("No recent rooms");
rooms.ItemTemplate = new DataTemplate(() =>
{
var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 15 };
name.SetBinding(Label.TextProperty, nameof(RoomSummary.Name));
var meta = new Label { TextColor = AppDesign.Muted, FontSize = 12 };
meta.SetBinding(Label.TextProperty, new Binding(nameof(RoomSummary.Id), stringFormat: "Room #{0}"));
var row = new VerticalStackLayout
{
Padding = new Thickness(12, 9),
Spacing = 2,
BackgroundColor = AppDesign.RowSurface,
Children = { name, meta },
};
return new VerticalStackLayout { Spacing = 0, Children = { row, AppDesign.Divider() } };
});
instances.SelectionMode = SelectionMode.None;
instances.ItemSizingStrategy = ItemSizingStrategy.MeasureFirstItem;
instances.EmptyView = EmptyList("No active instances");
instances.ItemTemplate = new DataTemplate(() =>
{
var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 15 };
name.SetBinding(Label.TextProperty, nameof(InstanceSummary.RoomName));
var meta = new Label { TextColor = AppDesign.Muted, FontSize = 12 };
meta.SetBinding(Label.TextProperty, new Binding(nameof(InstanceSummary.PhotonRegionId), stringFormat: "Region {0}"));
var count = new Label { TextColor = AppDesign.Subtle, FontSize = 12 };
count.SetBinding(Label.TextProperty, new Binding("Participants.Count", stringFormat: "{0} participants"));
var row = new VerticalStackLayout
{
Padding = new Thickness(12, 9),
Spacing = 2,
BackgroundColor = AppDesign.RowSurface,
Children = { name, meta, count },
};
return new VerticalStackLayout { Spacing = 0, Children = { row, AppDesign.Divider() } };
});
refresh.Clicked += async (_, _) => await LoadAsync();
var grid = new Grid
{
Padding = 16,
RowSpacing = 10,
RowDefinitions =
{
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = new GridLength(1, GridUnitType.Star) },
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = new GridLength(2, GridUnitType.Star) },
new RowDefinition { Height = GridLength.Auto },
},
};
grid.Add(AppDesign.Title("Rooms"), 0, 0);
grid.Add(refresh, 0, 1);
grid.Add(new Label { Text = "Live instances", TextColor = AppDesign.Muted, FontAttributes = FontAttributes.Bold }, 0, 2);
grid.Add(instances, 0, 3);
grid.Add(new Label { Text = "Recent rooms", TextColor = AppDesign.Muted, FontAttributes = FontAttributes.Bold }, 0, 4);
grid.Add(rooms, 0, 5);
grid.Add(status, 0, 6);
Content = grid;
}
private static Label EmptyList(string text) => new()
{
Text = text,
TextColor = AppDesign.Subtle,
HorizontalTextAlignment = TextAlignment.Center,
VerticalTextAlignment = TextAlignment.Center,
};
protected override async void OnAppearing()
{
base.OnAppearing();
if (!loaded)
{
loaded = true;
await LoadAsync();
}
}
private async Task LoadAsync()
{
try
{
refresh.IsEnabled = false;
status.Text = "Loading...";
var instanceRows = await api.GetInstancesAsync();
var roomRows = await api.GetRoomsAsync();
instances.ItemsSource = instanceRows;
rooms.ItemsSource = roomRows;
status.Text = $"{instanceRows.Count} active instances, {roomRows.Count} rooms loaded";
}
catch (Exception ex)
{
status.Text = ex.Message;
}
finally
{
refresh.IsEnabled = true;
}
}
}
-119
View File
@@ -1,119 +0,0 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile.Pages;
public sealed class SettingsPage : ContentPage
{
private readonly AdminApiClient api;
private readonly SecureAdminSettings settings;
private readonly Switch signupsDisabled = new();
private readonly Switch globalFriends = new();
private readonly Label status = AppDesign.StatusLabel();
private bool loading;
public SettingsPage(AdminApiClient api, SecureAdminSettings settings)
{
this.api = api;
this.settings = settings;
Title = "Settings";
Background = AppDesign.PageBackground;
signupsDisabled.Toggled += async (_, e) =>
{
if (!loading) await SaveToggleAsync(() => api.SetSignupsDisabledAsync(e.Value));
};
globalFriends.Toggled += async (_, e) =>
{
if (!loading) await SaveToggleAsync(() => api.SetGlobalFriendsAsync(e.Value));
};
var refresh = AppDesign.SecondaryButton("Refresh");
refresh.Clicked += async (_, _) => await LoadAsync();
var logout = AppDesign.DangerButton("Clear saved session");
logout.Clicked += (_, _) =>
{
settings.ClearSession();
App.ShowLogin();
};
Content = new VerticalStackLayout
{
Padding = 16,
Spacing = 14,
Children =
{
AppDesign.Title("Settings"),
refresh,
Row("Disable new account creation", signupsDisabled),
Row("Global friends mode", globalFriends),
logout,
status,
},
};
}
protected override async void OnAppearing()
{
base.OnAppearing();
await LoadAsync();
}
private async Task LoadAsync()
{
try
{
loading = true;
status.Text = "Loading...";
var s = await api.GetSettingsAsync();
signupsDisabled.IsToggled = s.SignupsDisabled;
globalFriends.IsToggled = s.GlobalFriendsEnabled;
status.Text = $"Updated {s.UpdatedAt:g}";
}
catch (Exception ex)
{
status.Text = ex.Message;
}
finally
{
loading = false;
}
}
private async Task SaveToggleAsync(Func<Task> save)
{
try
{
status.Text = "Saving...";
await save();
status.Text = "Saved";
}
catch (Exception ex)
{
status.Text = ex.Message;
await LoadAsync();
}
}
private static View Row(string label, View control)
{
var grid = new Grid
{
ColumnDefinitions =
{
new ColumnDefinition(),
new ColumnDefinition { Width = GridLength.Auto },
},
Padding = new Thickness(12, 10),
};
grid.Add(new Label
{
Text = label,
TextColor = AppDesign.Text,
VerticalTextAlignment = TextAlignment.Center,
}, 0, 0);
grid.Add(control, 1, 0);
return AppDesign.GlassCard(grid, new Thickness(0));
}
}
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="false"
android:icon="@mipmap/appicon"
android:roundIcon="@mipmap/appicon_round"
android:label="DorkNet Admin"
android:supportsRtl="true" />
</manifest>
@@ -1,23 +0,0 @@
using Android.App;
using Android.Content.PM;
using Microsoft.Maui;
namespace DorkNet.AdminMobile;
[Activity(
Label = "DorkNet Admin",
Icon = "@mipmap/appicon",
RoundIcon = "@mipmap/appicon_round",
MainLauncher = true,
Exported = true,
Theme = "@style/Maui.SplashTheme",
ConfigurationChanges =
ConfigChanges.ScreenSize |
ConfigChanges.Orientation |
ConfigChanges.UiMode |
ConfigChanges.ScreenLayout |
ConfigChanges.SmallestScreenSize |
ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
{
}
@@ -1,13 +0,0 @@
using Android.App;
using Android.Runtime;
using Microsoft.Maui;
using Microsoft.Maui.Hosting;
namespace DorkNet.AdminMobile;
[Application]
public class MainApplication(nint handle, JniHandleOwnership ownership)
: MauiApplication(handle, ownership)
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
@@ -1,11 +0,0 @@
using Foundation;
using Microsoft.Maui;
using Microsoft.Maui.Hosting;
namespace DorkNet.AdminMobile;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>DorkNet Admin</string>
<key>CFBundleIdentifier</key>
<string>net.dorknet.adminmobile</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>MinimumOSVersion</key>
<string>14.2</string>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
<integer>2</integer>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -1,11 +0,0 @@
using UIKit;
namespace DorkNet.AdminMobile;
public static class Program
{
public static void Main(string[] args)
{
UIApplication.Main(args, null, typeof(AppDelegate));
}
}
-45
View File
@@ -1,45 +0,0 @@
# DorkNet Admin Mobile
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:
- DorkNet admin JWT from `POST /api/admin/v1/login`
- Cloudflare Access service-token client id
- Cloudflare Access service-token client secret
- Optional Cloudflare Access JWT assertion
Every API request includes:
```http
Authorization: Bearer <dorknet-admin-jwt>
CF-Access-Client-Id: <service-token-client-id>
CF-Access-Client-Secret: <service-token-client-secret>
CF-Access-Jwt-Assertion: <optional-jwt>
```
The Cloudflare headers are optional, but required when the admin host is behind Cloudflare Access.
## Build
Install the .NET MAUI workloads, then build per platform:
```powershell
dotnet workload install maui
dotnet build DorkNet.AdminMobile\DorkNet.AdminMobile.csproj -f net9.0-android36.0
dotnet build DorkNet.AdminMobile\DorkNet.AdminMobile.csproj -f net9.0-ios26.0
```
The project targets Android 16 / API 36 and iOS 26 SDK. iOS device/archive builds still require Xcode 26 on macOS.
@@ -1,71 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
<clipPath id="_clip2">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip2)">
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
<clipPath id="_clip4">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip4)">
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
</g>
</g>
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
</g>
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,71 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
<clipPath id="_clip2">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip2)">
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
<clipPath id="_clip4">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip4)">
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
</g>
</g>
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
</g>
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,71 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
<clipPath id="_clip2">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip2)">
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
<clipPath id="_clip4">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip4)">
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
</g>
</g>
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
</g>
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,71 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
<clipPath id="_clip2">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip2)">
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
<clipPath id="_clip4">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip4)">
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
</g>
</g>
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
</g>
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,147 +0,0 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using DorkNet.AdminMobile.Models;
namespace DorkNet.AdminMobile.Services;
public sealed class AdminApiClient(SecureAdminSettings settings)
{
private static readonly JsonSerializerOptions Json = new()
{
PropertyNameCaseInsensitive = true,
};
private readonly HttpClient http = new()
{
Timeout = TimeSpan.FromSeconds(20),
};
public async Task<AdminLoginResponse> LoginAsync(string username, string password)
{
var result = await SendAsync<AdminLoginResponse>(
HttpMethod.Post,
"login",
new { Username = username, Password = password },
includeJwt: false);
await settings.SaveSessionAsync(result.AccessToken, result.RefreshToken);
return result;
}
public Task<AdminStats> GetStatsAsync() =>
SendAsync<AdminStats>(HttpMethod.Get, "stats");
public Task<List<PlayerSummary>> GetPlayersAsync(string? query = null)
{
var suffix = string.IsNullOrWhiteSpace(query)
? "players?take=100"
: $"players?take=100&query={Uri.EscapeDataString(query)}";
return SendAsync<List<PlayerSummary>>(HttpMethod.Get, suffix);
}
public Task<List<RoomSummary>> GetRoomsAsync() =>
SendAsync<List<RoomSummary>>(HttpMethod.Get, "rooms?take=100");
public Task<List<InstanceSummary>> GetInstancesAsync() =>
SendAsync<List<InstanceSummary>>(HttpMethod.Get, "instances");
public Task<ServerSettingsDto> GetSettingsAsync() =>
SendAsync<ServerSettingsDto>(HttpMethod.Get, "settings");
public Task<ServerSettingsDto> SetSignupsDisabledAsync(bool disabled) =>
SendAsync<ServerSettingsDto>(HttpMethod.Post, "settings/signups", new { Disabled = disabled });
public Task<ServerSettingsDto> SetGlobalFriendsAsync(bool enabled) =>
SendAsync<ServerSettingsDto>(HttpMethod.Post, "settings/global-friends", new { Enabled = enabled });
public Task KickPlayerAsync(long playerId, string reason) =>
SendAsync<object>(HttpMethod.Post, $"players/{playerId}/kick", new { Reason = reason });
public Task ResetAvatarAsync(long playerId) =>
SendAsync<object>(HttpMethod.Post, $"players/{playerId}/avatar/reset");
public Task DeletePlayerAsync(long playerId, string username, string phrase, string? reason) =>
SendAsync<object>(
HttpMethod.Delete,
$"players/{playerId}",
new { ConfirmUsername = username, ConfirmPhrase = phrase, Reason = reason });
private async Task<T> SendAsync<T>(
HttpMethod method,
string path,
object? body = null,
bool includeJwt = true)
{
var connection = await settings.LoadConnectionAsync();
var url = $"{connection.BaseUrl.TrimEnd('/')}/api/admin/v1/{path.TrimStart('/')}";
using var request = new HttpRequestMessage(method, url);
AddCloudflareAccessHeaders(request, connection);
if (includeJwt)
{
var jwt = await settings.GetJwtAsync();
if (!string.IsNullOrWhiteSpace(jwt))
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
}
if (body is not null)
{
var json = JsonSerializer.Serialize(body, Json);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
}
using var response = await http.SendAsync(request);
var text = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
throw new AdminApiException((int)response.StatusCode, ExtractError(text, response.ReasonPhrase));
if (typeof(T) == typeof(object) || string.IsNullOrWhiteSpace(text))
return default!;
return JsonSerializer.Deserialize<T>(text, Json)
?? throw new AdminApiException(0, "Server returned an empty response.");
}
private static void AddCloudflareAccessHeaders(HttpRequestMessage request, AdminConnectionSettings connection)
{
if (!string.IsNullOrWhiteSpace(connection.CloudflareAccessClientId) &&
!string.IsNullOrWhiteSpace(connection.CloudflareAccessClientSecret))
{
request.Headers.SetOrReplace("CF-Access-Client-Id", connection.CloudflareAccessClientId);
request.Headers.SetOrReplace("CF-Access-Client-Secret", connection.CloudflareAccessClientSecret);
}
if (!string.IsNullOrWhiteSpace(connection.CloudflareAccessJwt))
request.Headers.SetOrReplace("CF-Access-Jwt-Assertion", connection.CloudflareAccessJwt);
}
private static string ExtractError(string text, string? fallback)
{
if (string.IsNullOrWhiteSpace(text)) return fallback ?? "Request failed.";
try
{
using var doc = JsonDocument.Parse(text);
if (doc.RootElement.TryGetProperty("error", out var error))
return error.GetString() ?? fallback ?? "Request failed.";
if (doc.RootElement.TryGetProperty("message", out var message))
return message.GetString() ?? fallback ?? "Request failed.";
}
catch { }
return text.Length > 300 ? text[..300] : text;
}
}
public sealed class AdminApiException(int statusCode, string message) : Exception(message)
{
public int StatusCode { get; } = statusCode;
}
internal static class HeaderExtensions
{
public static void SetOrReplace(this HttpRequestHeaders headers, string name, string value)
{
headers.Remove(name);
headers.Add(name, value);
}
}
@@ -1,61 +0,0 @@
using Microsoft.Maui.Storage;
namespace DorkNet.AdminMobile.Services;
public sealed record AdminConnectionSettings(
string BaseUrl,
string CloudflareAccessClientId,
string CloudflareAccessClientSecret,
string CloudflareAccessJwt);
public sealed class SecureAdminSettings
{
private const string BaseUrlKey = "admin.baseUrl";
private const string JwtKey = "admin.jwt";
private const string RefreshKey = "admin.refresh";
private const string CfClientIdKey = "cf.access.clientId";
private const string CfClientSecretKey = "cf.access.clientSecret";
private const string CfJwtKey = "cf.access.jwt";
public async Task<AdminConnectionSettings> LoadConnectionAsync()
{
try
{
var baseUrl = await SecureStorage.GetAsync(BaseUrlKey) ?? "https://admin.rec.net";
var cfId = await SecureStorage.GetAsync(CfClientIdKey) ?? string.Empty;
var cfSecret = await SecureStorage.GetAsync(CfClientSecretKey) ?? string.Empty;
var cfJwt = await SecureStorage.GetAsync(CfJwtKey) ?? string.Empty;
return new AdminConnectionSettings(baseUrl, cfId, cfSecret, cfJwt);
}
catch
{
SecureStorage.Remove(BaseUrlKey);
SecureStorage.Remove(CfClientIdKey);
SecureStorage.Remove(CfClientSecretKey);
SecureStorage.Remove(CfJwtKey);
return new AdminConnectionSettings("https://admin.rec.net", string.Empty, string.Empty, string.Empty);
}
}
public async Task SaveConnectionAsync(AdminConnectionSettings settings)
{
await SecureStorage.SetAsync(BaseUrlKey, settings.BaseUrl.TrimEnd('/'));
await SecureStorage.SetAsync(CfClientIdKey, settings.CloudflareAccessClientId.Trim());
await SecureStorage.SetAsync(CfClientSecretKey, settings.CloudflareAccessClientSecret.Trim());
await SecureStorage.SetAsync(CfJwtKey, settings.CloudflareAccessJwt.Trim());
}
public Task<string?> GetJwtAsync() => SecureStorage.GetAsync(JwtKey);
public async Task SaveSessionAsync(string accessToken, string refreshToken)
{
await SecureStorage.SetAsync(JwtKey, accessToken);
await SecureStorage.SetAsync(RefreshKey, refreshToken);
}
public void ClearSession()
{
SecureStorage.Remove(JwtKey);
SecureStorage.Remove(RefreshKey);
}
}
+11 -19
View File
@@ -1,13 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework Condition="'$(TargetFramework)' == '' and '$(TargetFrameworks)' == ''">net6.0</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>DorkNet.ClientMod</RootNamespace>
<AssemblyName>DorkNet.ClientMod</AssemblyName>
<AppendTargetFrameworkToOutputPath Condition="'$(TargetFrameworks)' == ''">false</AppendTargetFrameworkToOutputPath>
<AppendTargetFrameworkToOutputPath Condition="'$(TargetFrameworks)' != ''">true</AppendTargetFrameworkToOutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
</PropertyGroup>
@@ -20,28 +19,21 @@
they would be derivative works of the game anyway, so we never
reference them. Only MelonLoader's own DLLs are needed.
By default the project downloads the pinned desktop MelonLoader release
into DorkNet.ClientMod/build/ and references its net6 DLLs. For Quest /
LemonLoader builds, pass:
-p:TargetFrameworks=net8.0
-p:MelonLoaderDir=<path-to-LemonLoader-melon_data/MelonLoader>
The reference folder follows the target framework unless explicitly
overridden with -p:MelonLoaderReferenceTfm=...
By default the project downloads the pinned MelonLoader release
into DorkNet.ClientMod/build/ and references those DLLs. Pass
-p:MelonLoaderDir=<path-to-extracted-MelonLoader> only when you
deliberately want to use a local override.
-->
<PropertyGroup>
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)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')">
Condition="!Exists('$(MelonLoaderDir)\net6\MelonLoader.dll')">
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
<DownloadFile
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
@@ -55,19 +47,19 @@
<ItemGroup>
<Reference Include="MelonLoader">
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll</HintPath>
<HintPath>$(MelonLoaderDir)\net6\MelonLoader.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="0Harmony">
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\0Harmony.dll</HintPath>
<HintPath>$(MelonLoaderDir)\net6\0Harmony.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Il2CppInterop.Runtime">
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Runtime.dll</HintPath>
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Runtime.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Il2CppInterop.Common">
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Common.dll</HintPath>
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Common.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
+176 -1474
View File
@@ -43,11 +43,10 @@ public class Mod : MelonMod
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
private readonly HashSet<string> _diagnosticPatchLabels = new();
private bool _networkPatchesRegistered;
private bool _joinTracePatchesRegistered;
private bool _diagnosticCoreRegistered;
private bool _diagnosticGameComplete;
private int _diagnosticRetryFrame;
private bool _firstUpdateLogged;
private int _photonOverridePollFrame;
// Config backed by a plain JSON file under MelonLoader/UserData.
// The defaults below match the recommended install, so dropping this
@@ -55,6 +54,7 @@ public class Mod : MelonMod
public static class Cfg
{
public static string ServerHost = "localhost";
public static string SingleOriginBaseUrl = "";
public static string PhotonAppId = "";
public static string PhotonVoiceAppId = "";
// Forced PhotonServerSettings.PreferredRegion (with HostType
@@ -67,19 +67,9 @@ public class Mod : MelonMod
// 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
// registration-prompt flow + the confirm-dialog buttons and writes
// what fires to MelonLoader/UserData/dorknet-diagnostics.log, so a
// brand-new account that reproduces the freeze tells us exactly
// where it breaks (does the click reach Button_Affirmative? does
// the prompt even run?). No behaviour change — purely diagnostic.
// See RegistrationPatches and the registration site in
// OnLateInitializeMelon.
public static bool TraceRegistrationDialog = true;
// Photon Custom Auth injector was parked 2026-05-28; see
// attic/AuthValuesInjector.cs.attic for the code + restore notes.
// Force-enable the client's built-in RecRoom.Debugging.DebugConsole
// (normally dev-gated) and silence CheatManager's tamper detectors
// so movement/time commands (SetTimeScale, Fly, Teleport, …) don't
@@ -87,68 +77,6 @@ public class Mod : MelonMod
// is a UnityEngine.KeyCode name; BackQuote is the `~` key.
public static bool EnableDebugConsole = false;
public static string DebugConsoleToggleKey = "BackQuote";
// ── Desktop Screen Sharing gadget FPS ──
// RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts
// the shared desktop image. Its cadence is a baked [SerializeField]
// float screenShareImageRefreshFrequency — the broadcast tick gates
// on (Time.time - lastSend >= 1f / frequency), verified at
// DesktopScreenSharingDisplay.txt:697-703, so the field IS the FPS
// and its editor-only [Range] is NOT enforced at runtime. Set a
// target here to override it (default prefab value is low, ~5).
// 0 = leave the prefab value alone (patch not even registered).
public static float DesktopScreenShareFps = 0f;
// The captured frame ships via the gadget's IPunObservable
// OnPhotonSerializeView, which only fires at
// PhotonNetwork.SerializationRate (default 10 Hz) — so 30 fps capture
// still delivers ~10 unless we raise it. This lifts SendRate +
// SerializationRate to >= the FPS target. GLOBAL: affects every
// PhotonView's sync traffic, so turn off if it lags a busy room.
public static bool DesktopScreenShareRaisePhotonRate = true;
// Optional per-frame size knobs (0 = leave the prefab value). Higher
// FPS needs smaller frames to fit the Photon serialization budget;
// lower these if 30 fps saturates bandwidth (chunked frames stall).
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
// quest scoreboard + party HUD build rows dynamically, so they
// auto-scale once the team cap is raised. 0 = off (leave at 4).
// Only single-team, quest-named configs are touched, so PvP team
// sizes (paintball/laser tag) are never changed. See QuestTeamSize.
public static int QuestMaxTeamSize = 0;
// Only these RRO quest GameConfigurationAssets get their team size
// bumped (matched loosely: case/space-insensitive, filler words like
// "the/of/for/quest" ignored, so "IsleOfLostSkulls" matches the
// in-game "Isle of the Lost Skulls"). Empty = bump nothing. The
// generic "Quest" config is intentionally NOT in the default list.
public static string[] QuestMaxTeamSizeRooms =
{ "CrimsonCauldron", "Crescendo", "GoldenTrophy", "IsleOfLostSkulls", "TheRiseofJumbotron" };
}
public override void OnInitializeMelon()
@@ -167,53 +95,14 @@ public class Mod : MelonMod
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));
// 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
// does its first handshake, and MonoMod's CompileMethodHook
// fatal-CLRs (0x80131506) installing trampolines into these
// third-party interface implementations. Only register the
// detours if the bypass is actually wanted; on a setup with
// valid certs (the default) skip the patch entirely.
if (Cfg.EnableTlsTrustBypass)
{
TryPatchByName("BestHTTP.SecureProtocol.Org.BouncyCastle.Crypto.Tls.ServerOnlyTlsAuthentication",
"NotifyServerCertificate",
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
TryPatchByName("Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication",
"NotifyServerCertificate",
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
}
// NetworkingPeer.CallAuthenticate hook (Photon Custom Auth
// injector) parked — see attic/AuthValuesInjector.cs.attic.
TryPatchByName("BestHTTP.SecureProtocol.Org.BouncyCastle.Crypto.Tls.ServerOnlyTlsAuthentication",
"NotifyServerCertificate",
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
TryPatchByName("Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication",
"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
@@ -301,236 +190,21 @@ public class Mod : MelonMod
"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)
// Debug console: skip CheatManager's tamper detectors so the
// console's movement/time commands don't trip a dorm-drop. The
// console UI toggle itself is driven from OnUpdate (hotkey) and
// resolved lazily — only the anti-cheat skip needs a patch.
if (Cfg.EnableDebugConsole)
{
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();
// 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
// serialization rate so the higher capture rate actually transmits).
if (Cfg.DesktopScreenShareFps > 0f)
{
TryPatchByName("RecRoom.Tools.Productivity.DesktopScreenSharingDisplay",
"Awake", args: Type.EmptyTypes,
postfix: nameof(ScreenSharePatches.DisplayAwake_Postfix));
Log.Msg($"[screenshare] override armed: target {Cfg.DesktopScreenShareFps} fps " +
$"(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
// team manager / pre-game roster reads it. Only when a target is set.
if (Cfg.QuestMaxTeamSize > 0)
{
// The runtime team cap is built from the baked GameConfigurationAsset
// (a readable ScriptableObject) by GameConfigurationAsset.GDECPANDBHJ()
// — it reads asset.TeamConfigurations[i].MaxTeamSize (a typed int
// struct-array) into the protobuf TeamSize. Prefix that builder and
// bump the source array IN PLACE before it converts, scoped by
// asset.Name. This avoids the obfuscated protobuf entirely (its
// generic RepeatedField getter isn't emitted by Il2CppInterop).
// Host-authoritative: the quest host must run the mod. See QuestTeamSize.
TryPatchByName("RecRoom.Core.GameManagement.GameConfigurationAsset", "GDECPANDBHJ",
prefix: nameof(QuestTeamSize.Build_Prefix));
QuestTeamSize.TryPatchSpawnIndexNormalizers(HarmonyInstance);
TryPatchByName("GameSpawnManager", "FKEICBPCMPJ",
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
Log.Msg($"[questsize] armed: target {Cfg.QuestMaxTeamSize} for [{string.Join(", ", Cfg.QuestMaxTeamSizeRooms)}]");
foreach (var m in new[] { "OnTimeCheatDetected", "OnObscuredTypeCheatDetected", "OnHeightCheatDetected", "OnAdvancedMovementCheatDetected" })
TryPatchByName("CheatManager", m, prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix));
Log.Msg($"[debugconsole] enabled — CheatManager detectors silenced; press '{Cfg.DebugConsoleToggleKey}' to toggle the console.");
}
RegisterDiagnostics();
Log.Msg("=== Client patches 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)
// is debuggable from the log alone. The chain, verified against the
// 2020.12.18 IL2CPP dump:
//
// AGUI.StackedUI.HomeScreenFlow.Button_DormRoom() (button)
// → SessionManager.RunJoinRoom("DormRoom", …) (join entry)
// → OJMCBOKJFOF.NHBPIIGDAJP(name) → GET /goto/room/{name}
// rejects with "No such room" (OJMCBOKJFOF.txt:958-1019)
// → .Then(SessionManager.RunJoinRoom(RoomInstance, …))
// → on reject: SessionManager+<>c.<RunJoinRoom>b__165_1(error)
// → NotificationManager.Play("An error occured", "…happyfox…")
// (the "contact recroom.happyfox.com" toast)
//
// We can't express the obfuscated RunJoinRoom param types as a Type[]
// for AccessTools, so every RunJoinRoom overload + every /goto overload
// is patched by iterating declared methods by name, and the prefixes
// read arguments generically via Harmony's `object[] __args`.
private void RegisterJoinTracePatches()
{
if (_joinTracePatchesRegistered) return;
Log.Msg("=== Registering room-join trace ===");
// Watch "Dorm Room" button. Void, no params, unique name —
// HomeScreenFlow.txt:4791, calls RunJoinRoom("DormRoom",…) at :4848.
TryPatchByName("AGUI.StackedUI.HomeScreenFlow", "Button_DormRoom",
args: Type.EmptyTypes,
prefix: nameof(JoinPatches.ButtonDormRoom_Prefix));
// Every SessionManager.RunJoinRoom overload (string-name entry +
// the RoomInstance inner legs). Logs the method + its arguments.
PatchAllOverloads("SessionManager", "RunJoinRoom",
nameof(JoinPatches.RunJoinRoom_Prefix));
// The /goto/room/{name|id} promise builders — both the string
// (room-name) and Int64 (room-id) overloads of NHBPIIGDAJP.
PatchAllOverloads("OJMCBOKJFOF", "NHBPIIGDAJP",
nameof(JoinPatches.Goto_Prefix));
// The RunJoinRoom promise-chain callbacks compiled onto the
// SessionManager <>c display class — most importantly the reject
// handler b__165_1(string error) that raises the happyfox toast.
// Match by the (de-mangled) substring "RunJoinRoom" + the lambda
// marker "b__" so we hit the callbacks but not the RunJoinRoom
// state-machine MoveNext.
PatchNestedLambdas("SessionManager", "RunJoinRoom",
nameof(JoinPatches.JoinCallback_Prefix));
// Additionally capture the REJECT REASON string on b__165_1 (the
// happyfox-toast handler). Matches only that one lambda; it ends
// up with two prefixes (flow + error string), which is harmless.
PatchNestedLambdas("SessionManager", "RunJoinRoom_b__165_1",
nameof(JoinPatches.JoinError_Prefix));
_joinTracePatchesRegistered = true;
Log.Msg("=== Room-join trace registered ===");
}
// Patch every declared method on `typeName` named `methodName`,
// regardless of signature, with a single prefix. Used for obfuscated
// overload sets whose param types we can't name in a Type[].
private bool PatchAllOverloads(string typeName, string methodName, string prefix, bool logMiss = true)
{
var type = ResolveType(typeName);
if (type is null)
{
if (logMiss) Log.Warning($"[patch-miss] {typeName}.{methodName}(*): type not found");
return false;
}
var prefixMethod = GetPatchMethod(prefix);
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: new HarmonyMethod(prefixMethod));
patched++;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] {typeName}.{methodName} overload: {ex.Message}");
}
}
if (patched == 0)
{
if (logMiss) Log.Warning($"[patch-miss] {typeName}.{methodName}(*): no overloads matched");
return false;
}
Log.Msg($"[patch-ok] {typeName}.{methodName}(*) x{patched}");
return true;
}
// Patch compiler-generated lambda methods (names containing "b__")
// on the nested display classes of `typeName` whose (de-mangled) name
// also contains `nameContains`. Il2CppInterop mangles the angle
// brackets of `<RunJoinRoom>b__165_1` to `_003C…_003E` but leaves the
// method name itself intact, so a plain substring match still works.
private bool PatchNestedLambdas(string typeName, string nameContains, string prefix, bool logMiss = true)
{
var type = ResolveType(typeName);
if (type is null)
{
if (logMiss) Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): type not found");
return false;
}
var prefixMethod = GetPatchMethod(prefix);
var patched = 0;
foreach (var nested in type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic))
{
foreach (var method in AccessTools.GetDeclaredMethods(nested))
{
if (method.IsAbstract || method.ContainsGenericParameters) continue;
if (method.Name.IndexOf("b__", StringComparison.Ordinal) < 0) continue;
if (method.Name.IndexOf(nameContains, StringComparison.Ordinal) < 0) continue;
try
{
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
patched++;
Log.Msg($"[patch-ok] {typeName}+{nested.Name}.{method.Name}");
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] {typeName}+{nested.Name}.{method.Name}: {ex.Message}");
}
}
}
if (patched == 0 && logMiss)
Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): none matched");
return patched > 0;
}
private void RegisterNetworkPatches()
{
if (_networkPatchesRegistered) return;
@@ -584,11 +258,19 @@ public class Mod : MelonMod
public override void OnUpdate()
{
if (!_firstUpdateLogged)
{
_firstUpdateLogged = true;
Log.Msg("[lifecycle] first OnUpdate tick (Unity frame loop is live)");
}
// Photon AppId override poller. The harmony hook on
// PhotonNetwork.ConnectUsingSettings registers in IL2CPP but
// doesn't actually fire in this build (verified in the
// MelonLoader log — [patch-ok] appears but [photon-appid]
// never does), so we poll until PhotonServerSettings is loaded
// and write the AppId then. Stops calling once the override
// succeeds. Throttled to every 6th frame (~10 Hz at 60 fps) so
// the failure log doesn't drown the file when PhotonServerSettings
// isn't loaded yet.
if (!PhotonPatches.OverrideApplied && (_photonOverridePollFrame++ % 6) == 0)
PhotonPatches.TryApplyOverride(reason: "update-poll");
if (Cfg.EnableDebugConsole) DebugConsolePatches.PollToggleKey();
if (_diagnosticGameComplete) return;
if (_diagnosticRetryFrame++ > 3600) return;
@@ -610,7 +292,17 @@ public class Mod : MelonMod
{
try
{
var userData = ResolveUserDataDirectory();
// 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");
Directory.CreateDirectory(userData);
DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log");
var path = Path.Combine(userData, "dorknet-clientmod.json");
@@ -621,42 +313,17 @@ public class Mod : MelonMod
}
var doc = JsonDocument.Parse(File.ReadAllText(path));
var r = doc.RootElement;
if (TryGetConfigValue(r, "ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost;
if (TryGetConfigValue(r, "PhotonAppId", out v)) Cfg.PhotonAppId = v.GetString() ?? Cfg.PhotonAppId;
if (TryGetConfigValue(r, "PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId;
if (TryGetConfigValue(r, "PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion;
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, "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, "DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
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())
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
rooms.Add(e.GetString()!);
Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none)
}
if (TryGetConfigValue(r, "EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean();
if (TryGetConfigValue(r, "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();
}
if (r.TryGetProperty("ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost;
if (r.TryGetProperty("SingleOriginBaseUrl", out v)) Cfg.SingleOriginBaseUrl = (v.GetString() ?? "").TrimEnd('/');
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("EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
if (r.TryGetProperty("DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
// "InjectAuthValues" key in the template is now ignored —
// see attic/AuthValuesInjector.cs.attic.
Log.Msg($"[config] loaded from {path}: ServerHost={Cfg.ServerHost}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "<unset>" : "<set>")}, " +
Log.Msg($"[config] loaded: ServerHost={Cfg.ServerHost}, SingleOrigin={(string.IsNullOrEmpty(Cfg.SingleOriginBaseUrl) ? "<off>" : Cfg.SingleOriginBaseUrl)}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "<unset>" : "<set>")}, " +
$"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}");
}
catch (Exception ex)
@@ -665,48 +332,9 @@ 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,
bool logMiss = true)
Type[]? args, string? prefix = null, string? postfix = null)
{
try
{
@@ -716,12 +344,7 @@ public class Mod : MelonMod
: AccessTools.Method(target, methodName, args));
if (method is null)
{
// `logMiss` is throttled by the retry-loop caller — once per
// 5 seconds in OnUpdate, every time in the one-shot initial
// RegisterDiagnostics. Without the gate the per-second retry
// loop would spam `[patch-miss]` lines forever for any method
// whose name drifted in a newer game build.
if (logMiss) Log.Warning($"[patch-miss] {label}");
Log.Warning($"[patch-miss] {label}");
return false;
}
HarmonyInstance.Patch(method,
@@ -754,59 +377,7 @@ public class Mod : MelonMod
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
return false;
}
return TryPatch(label, type, methodName, args, prefix, postfix, logMiss);
}
private bool TryPatchDeclaredMethodByReturnType(string typeName, string returnTypeName,
string fallbackMethodName,
string? prefix = null, string? postfix = null,
bool logMiss = true)
{
var type = ResolveType(typeName);
var label = $"{typeName}.<returns {returnTypeName}>";
if (type is null)
{
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
return TryPatchByName(typeName, fallbackMethodName, args: null, prefix, postfix, logMiss);
}
var matches = new List<MethodInfo>();
foreach (var method in AccessTools.GetDeclaredMethods(type))
{
if (method.IsAbstract || method.ContainsGenericParameters || method.IsSpecialName) continue;
if (ReturnTypeMatches(method.ReturnType, returnTypeName)) matches.Add(method);
}
if (matches.Count == 1)
{
var method = matches[0];
try
{
HarmonyInstance.Patch(method,
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
Log.Msg($"[patch-ok] {typeName}.{method.Name} (returns {returnTypeName})");
return true;
}
catch (Exception ex)
{
Log.Error($"[patch-fail] {typeName}.{method.Name} (returns {returnTypeName}): {ex.Message}");
return false;
}
}
if (matches.Count > 1)
{
var names = new List<string>();
foreach (var method in matches) names.Add(method.Name);
Log.Warning($"[patch-ambiguous] {label}: {string.Join(", ", names)}; falling back to {fallbackMethodName}");
}
else if (logMiss)
{
Log.Warning($"[patch-miss] {label}; falling back to {fallbackMethodName}");
}
return TryPatchByName(typeName, fallbackMethodName, args: null, prefix, postfix, logMiss);
return TryPatch(label, type, methodName, args, prefix, postfix);
}
internal static Type? ResolveType(string name)
@@ -881,15 +452,6 @@ public class Mod : MelonMod
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;
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;
}
private static string FormatPatchLabel(string typeName, string methodName, Type[]? args)
{
if (args is null) return $"{typeName}.{methodName}";
@@ -903,7 +465,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(PhotonPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(DebugConsolePatches) })
{
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
if (m is not null) return m;
@@ -930,24 +492,21 @@ public class Mod : MelonMod
private bool RegisterGameDiagnostics(bool logMisses)
{
var complete = true;
complete &= TryPatchDiagnosticByName("BootSequence", "RegisterError", new[] { typeof(string) },
prefix: nameof(DiagnosticPatches.BootSequenceRegisterError_Prefix),
logMiss: logMisses);
// 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.
// Names verified in CheatManager.txt for 2020.12.18; the older
// build's "AnalyticsHelper.<X>Cheat" names don't exist here, and
// there's no DeveloperFlag/StreamingAsset callback nor a
// BootSequence.RegisterError sink in this build — those legacy
// hooks were removed (they only ever logged [patch-miss] forever).
foreach (var method in new[]
{
"OnTimeCheatDetected",
"OnObscuredTypeCheatDetected",
"OnHeightCheatDetected",
"OnAdvancedMovementCheatDetected",
"UnityTimeCheat",
"ObscuredTypeCheat",
"DeveloperFlagCheat",
"HeightChangeCheat",
"AdvancedMotionCheat",
"StreamingAssetCheat"
})
{
complete &= TryPatchDiagnosticByName("CheatManager", method,
complete &= TryPatchDiagnosticByName("AnalyticsHelper", method,
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
logMiss: logMisses);
}
@@ -955,14 +514,15 @@ public class Mod : MelonMod
complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit",
prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix),
logMiss: 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
// methods) MonoMod's lazy CompileMethodHook on the trampoline
// crashes the CLR with 0x80131506 the instant Awake JITs. The
// 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.
complete &= TryPatchDiagnosticByName("SteamManager", "Awake",
prefix: nameof(DiagnosticPatches.NamedMethod_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("SteamPlatformManager", "PreLoginInitialize",
prefix: nameof(DiagnosticPatches.NamedMethod_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("SteamPlatformManager", "PostLoginInitialize",
prefix: nameof(DiagnosticPatches.NamedMethod_Prefix),
logMiss: logMisses);
return complete;
}
@@ -1183,543 +743,6 @@ internal static class DebugConsolePatches
}
}
// ── Dev-menu runtime probe (one-shot diagnostic) ────────────────────────
// Answers stripped-vs-gated for the watch developer menu. With a real dev
// account + forced flag the menu still doesn't appear, so the gate isn't the
// account flag — either the native dev-menu prefab content is stripped from
// this retail build, or a build-level flag skips its init. This logs the
// actual runtime state once ~5s after load (no behaviour change) so we know
// whether to un-gate the game's own dev menu or build our own watch entry.
// Called from OnUpdate when Cfg.DiagnoseDevMenu is set; self-gates to run
// once. Not a Harmony holder — invoked directly, so it's not registered in
// GetPatchMethod.
internal static class DevMenuProbe
{
private static int _frame;
private static bool _done;
public static void Tick()
{
if (_done) return;
_frame++;
if (_frame < 120) return; // ~2s — let MelonLoader/game boot
if ((_frame % 60) != 0) return; // then check ~once per second
// Only fire once a REAL, in-scene HomeScreenFlow exists with its
// UIBase wired (it gets assigned when the watch StackedUI initialises
// in the dorm). The earlier version fired during boot on the loaded
// PREFAB (Resources.FindObjectsOfTypeAll returns prefabs/inactive,
// whose runtime UIBase is null) and, being one-shot, captured nothing
// and never retried. NO timeout now — we wait until the watch is up.
var homeType = Mod.ResolveType("AGUI.StackedUI.HomeScreenFlow") ?? Mod.ResolveType("HomeScreenFlow");
var home = homeType != null ? FindLiveHome(homeType) : null;
if (home == null)
{
if ((_frame % 600) == 0)
Mod.Log.Msg("[devmenu-probe] waiting for an in-scene HomeScreenFlow — enter your dorm / open the watch…");
return;
}
_done = true;
try { Run(homeType!, home); }
catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] failed: {ex}"); }
}
// The live HomeScreenFlow (UIBase wired), skipping the prefab/inactive
// copies FindObjectsOfTypeAll also returns.
private static object? FindLiveHome(Type homeType)
{
try
{
var il2 = ToIl2CppType(homeType);
if (il2 is null) return null;
var resources = Mod.ResolveType("UnityEngine.Resources");
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2)?.Invoke(null, new[] { il2 });
int n = ArrLen(all);
for (int i = 0; i < n; i++)
{
var o = ArrItem(all, i);
if (o is null) continue;
if (GetMemberValue(homeType, o, "UIBase", "get_UIBase") != null) return o;
}
}
catch { }
return null;
}
private static void Run(Type homeType, object homeInst)
{
Mod.Log.Msg("=== [devmenu-probe] runtime dev-UI state ===");
ProbeType("RecRoom.Core.WatchUI", "WatchUI", DumpWatchUi);
Mod.Log.Msg($"[devmenu-probe] HomeScreenFlow: type={homeType.FullName}, liveInstance=FOUND (UIBase wired)");
try { DumpHomeScreen(homeType, homeInst); }
catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] HomeScreenFlow dump failed: {ex.Message}"); }
ProbeType("RecRoom.Debugging.DebugConsole", "DebugConsole", DumpDebugConsole);
Mod.Log.Msg("=== [devmenu-probe] done ===");
}
private static void ProbeType(string fullName, string shortName, Action<Type, object?> dump)
{
var t = Mod.ResolveType(fullName) ?? Mod.ResolveType(shortName);
if (t is null) { Mod.Log.Msg($"[devmenu-probe] {shortName}: TYPE NOT FOUND"); return; }
var inst = FindOne(t);
Mod.Log.Msg($"[devmenu-probe] {shortName}: type={t.FullName}, liveInstance={(inst is null ? "null" : "FOUND")}");
try { dump(t, inst); } catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] {shortName} dump failed: {ex.Message}"); }
}
private static void DumpHomeScreen(Type t, object? inst)
{
if (inst is null) { Mod.Log.Msg(" (no HomeScreenFlow instance live yet — open the watch, then re-probe)"); return; }
// Il2CppInterop exposes IL2CPP instance fields as PROPERTIES on the
// managed proxy, so a plain AccessTools.Field misses them (the earlier
// "field not found" was a false negative). Try property first, then
// field. NULL => content stripped from the prefab (can't un-gate);
// present => only gated.
foreach (var name in new[] { "devMenuPanel", "devButtonPrefab", "reportBugButtonSpacer" })
{
var (found, mt, v) = GetMember(t, inst, name);
if (!found) { Mod.Log.Msg($" {name}: <not found as property or field>"); continue; }
var extra = "";
if (v != null && name == "devMenuPanel")
{
try { var cc = v.GetType().GetProperty("childCount")?.GetValue(v); if (cc != null) extra = $" childCount={cc}"; } catch { }
}
Mod.Log.Msg($" {name} ({mt.Name}): {(v is null ? "NULL <-- stripped/unset" : "present")}{extra}");
}
// Calibration: are ordinary serialized field names exposed at all on
// the runtime proxy? If 'profileButton' (a non-dev [SerializeField])
// IS found but devMenuPanel isn't, the dev fields are genuinely absent
// in this build. If profileButton is ALSO not found, Il2CppInterop
// isn't exposing these fields by name (need a different access path).
foreach (var cal in new[] { "profileButton", "settingsButton" })
{
var (cf, cmt, cv) = GetMember(t, inst, cal);
Mod.Log.Msg($" [calib] {cal}: {(cf ? $"FOUND ({cmt.Name}, {(cv is null ? "null" : "present")})" : "not found")}");
}
// Named lookups miss → dump the proxy's actual member names to see
// Il2CppInterop's scheme, then enumerate Button3D children directly
// (no field names needed) — those are our clone sources for injection.
var fs = t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
var ps = t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
var fn = new List<string>(); foreach (var f in fs) fn.Add(f.Name);
var pn = new List<string>(); foreach (var p in ps) pn.Add(p.Name);
Mod.Log.Msg($" [members] fields={fn.Count}, props={pn.Count}");
if (fn.Count > 0) Mod.Log.Msg($" [fieldnames] {string.Join(", ", fn.GetRange(0, Math.Min(40, fn.Count)))}");
if (pn.Count > 0) Mod.Log.Msg($" [propnames] {string.Join(", ", pn.GetRange(0, Math.Min(40, pn.Count)))}");
DumpWatchButtons(t, inst);
DumpCollections(t, inst, "HomeScreenFlow");
DumpStackedUi(t, inst);
}
// Walk HomeScreenFlow -> UIBase (StackedUIBase) and dump the data we need
// to build a CUSTOM watch page by cloning a real flow:
// * flowResources catalog — the TypeName of every PUSHABLE flow; these
// are our clone-target candidates (pick a simple self-contained one).
// * the Type->flow cache — what's already instantiated (do NOT mutate
// these cached singletons; clone an independent copy instead).
// * ScreenHeader presence — proves a cloned flow brings its own title
// bar + back button for free.
// Names are obfuscated, so we match by SHAPE (array-of-prefab-ref,
// Dictionary<Type,flow>) rather than by field name.
private static void DumpStackedUi(Type homeType, object homeInst)
{
var ui = GetMemberValue(homeType, homeInst, "UIBase", "get_UIBase");
if (ui is null) { Mod.Log.Msg(" [stackedui] UIBase: null (could not reach StackedUIBase)"); return; }
var uiType = ui.GetType();
Mod.Log.Msg($" [stackedui] UIBase type={uiType.FullName}");
var hdr = GetMemberValue(homeType, homeInst, "ScreenHeader", "get_ScreenHeader");
if (hdr != null)
{
var ht = hdr.GetType();
var hp = new List<string>(); foreach (var p in ht.GetProperties()) hp.Add(p.Name);
Mod.Log.Msg($" [stackedui] home ScreenHeader={ht.Name}; props={string.Join(",", hp.GetRange(0, Math.Min(30, hp.Count)))}");
}
else Mod.Log.Msg(" [stackedui] home ScreenHeader: null");
foreach (var f in uiType.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
object? v; try { v = f.GetValue(ui); } catch { continue; }
if (v is null) continue;
int n = ArrLen(v);
// Flow-prefab catalog: a non-empty array whose elements expose a
// TypeName string (FlowPrefabResourceReference).
if (n > 0 && n <= 300 && (v is Array || v.GetType().Name.IndexOf("Array", StringComparison.OrdinalIgnoreCase) >= 0))
{
var first = ArrItem(v, 0);
var et = first?.GetType().Name ?? "?";
if (et.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) < 0 &&
et.IndexOf("Prefab", StringComparison.OrdinalIgnoreCase) < 0 &&
et.IndexOf("Resource", StringComparison.OrdinalIgnoreCase) < 0 &&
et.IndexOf("Reference", StringComparison.OrdinalIgnoreCase) < 0) continue;
Mod.Log.Msg($" [stackedui] flow catalog '{f.Name}': {et}[{n}]");
for (int i = 0; i < n; i++)
{
var e = ArrItem(v, i); if (e is null) continue;
Mod.Log.Msg($" [{i}] {ReadStringMember(e)}");
}
continue;
}
// Flow cache: Dictionary<Type, StackedUIFlowBase>.
var vt = v.GetType();
if (vt.IsGenericType && vt.Name.StartsWith("Dictionary"))
{
var ga = vt.GetGenericArguments();
if (ga.Length == 2 && ga[1].Name.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) >= 0)
{
var cp = vt.GetProperty("Count");
Mod.Log.Msg($" [stackedui] flow cache '{f.Name}': Dictionary<{ga[0].Name},{ga[1].Name}> count={(cp?.GetValue(v) as int? ?? -1)}");
}
}
}
}
// Property by name, else parameterless getter method, else null.
private static object? GetMemberValue(Type t, object inst, string prop, string getter)
{
var (found, _, v) = GetMember(t, inst, prop);
if (found && v != null) return v;
var m = t.GetMethod(getter, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
null, Type.EmptyTypes, null);
if (m != null) { try { return m.Invoke(inst, null); } catch { } }
return v;
}
// First non-empty string field/property on an object — for obfuscated
// wrappers, that's the TypeName of a FlowPrefabResourceReference.
private static string ReadStringMember(object o)
{
var t = o.GetType();
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
if (p.PropertyType == typeof(string) && p.GetIndexParameters().Length == 0)
{ try { if (p.GetValue(o) is string s && s.Length > 0) return s; } catch { } }
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
if (f.FieldType == typeof(string))
{ try { if (f.GetValue(o) is string s && s.Length > 0) return s; } catch { } }
return "(no string member)";
}
// Enumerate Button3D components under the HomeScreenFlow via Unity's
// GetComponentsInChildren(Type, includeInactive) — needs no field names.
// Confirms we can grab native buttons to clone for the dev entry.
private static void DumpWatchButtons(Type homeType, object homeInst)
{
var btnType = Mod.ResolveType("AGUI.StackedUI.Button3D");
if (btnType is null) { Mod.Log.Msg(" [buttons] Button3D type NOT FOUND"); return; }
var il2 = ToIl2CppType(btnType);
if (il2 is null) { Mod.Log.Msg(" [buttons] Button3D il2cppType null"); return; }
MethodInfo? m = null;
foreach (var mi in homeType.GetMethods(BindingFlags.Public | BindingFlags.Instance))
{
if (mi.Name != "GetComponentsInChildren") continue;
var mp = mi.GetParameters();
if (mp.Length == 2 && mp[0].ParameterType.IsInstanceOfType(il2) && mp[1].ParameterType == typeof(bool)) { m = mi; break; }
}
if (m is null) { Mod.Log.Msg(" [buttons] GetComponentsInChildren(Type,bool) not found"); return; }
object? arr;
try { arr = m.Invoke(homeInst, new object[] { il2, true }); }
catch (Exception ex) { Mod.Log.Msg($" [buttons] GetComponentsInChildren threw: {(ex.InnerException ?? ex).Message}"); return; }
int n = ArrLen(arr);
Mod.Log.Msg($" [buttons] Button3D children: {n}");
for (int i = 0; i < n && i < 20; i++)
{
var b = ArrItem(arr, i);
var nm = b?.GetType().GetProperty("name")?.GetValue(b)?.ToString() ?? "?";
Mod.Log.Msg($" [{i}] {nm}");
}
}
private static int ArrLen(object? arr)
{
if (arr is null) return 0;
if (arr is Array a) return a.Length;
var lp = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count");
return (lp?.GetValue(arr) as int?) ?? 0;
}
private static object? ArrItem(object? arr, int i)
{
if (arr is null) return null;
if (arr is Array a) return a.GetValue(i);
var gi = arr.GetType().GetMethod("get_Item", new[] { typeof(int) });
return gi?.Invoke(arr, new object[] { i });
}
private static (bool found, Type type, object? val) GetMember(Type t, object inst, string name)
{
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (p != null && p.CanRead && p.GetIndexParameters().Length == 0)
{ try { return (true, p.PropertyType, p.GetValue(inst)); } catch { } }
var f = AccessTools.Field(t, name);
if (f != null) { try { return (true, f.FieldType, f.GetValue(inst)); } catch { } }
return (false, typeof(object), null);
}
private static void DumpWatchUi(Type t, object? inst)
{
if (inst is null) { Mod.Log.Msg(" (no WatchUI instance live yet)"); return; }
DumpCollections(t, inst, "WatchUI");
}
private static void DumpDebugConsole(Type t, object? inst)
{
foreach (var name in new[] { "GJIKMPIMDHH", "IEGCNLHPKFH", "ShowButton", "ShowErrorsInHeadset", "ShowUnityDevelopmentConsole" })
{
try
{
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
if (p != null && p.CanRead) { Mod.Log.Msg($" static {name} = {p.GetValue(null)}"); continue; }
var f = t.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
if (f != null) Mod.Log.Msg($" static {name} = {f.GetValue(null)}");
}
catch (Exception ex) { Mod.Log.Msg($" {name}: read failed ({ex.Message})"); }
}
// Does the static command path work without the console UI in-scene?
var exec = AccessTools.Method(t, "Execute", new[] { typeof(string) });
if (exec is null) { Mod.Log.Msg(" Execute(string): <not found>"); return; }
try { exec.Invoke(null, new object[] { "help" }); Mod.Log.Msg(" Execute(\"help\"): invoked OK — static command path is usable"); }
catch (Exception ex) { var e = ex.InnerException ?? ex; Mod.Log.Msg($" Execute(\"help\"): threw {e.GetType().Name} ({e.Message})"); }
}
// Log every array / List<> field whose element type mentions Flow/Watch,
// with its count — surfaces the dev-flow list (devOnlyFlows-equivalent)
// regardless of its obfuscated name, so we can see if dev flows exist.
private static void DumpCollections(Type t, object inst, string label)
{
var members = new List<(string name, object? val)>();
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{ try { members.Add((f.Name, f.GetValue(inst))); } catch { } }
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{ if (p.CanRead && p.GetIndexParameters().Length == 0) { try { members.Add((p.Name, p.GetValue(inst))); } catch { } } }
foreach (var (name, v) in members)
{
if (v is null) continue;
int? count = null; string elem = "";
if (v is Array arr) { count = arr.Length; elem = v.GetType().GetElementType()?.Name ?? "?"; }
else
{
var ft = v.GetType();
if (ft.IsGenericType && typeof(System.Collections.IEnumerable).IsAssignableFrom(ft))
{
var cp = ft.GetProperty("Count");
if (cp != null && cp.PropertyType == typeof(int))
{
count = (int?)cp.GetValue(v);
var ga = ft.GetGenericArguments();
elem = ga.Length > 0 ? ga[0].Name : "?";
}
}
}
if (count.HasValue &&
(elem.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) >= 0 ||
elem.IndexOf("Watch", StringComparison.OrdinalIgnoreCase) >= 0))
Mod.Log.Msg($" {label}.{name}: {elem}[{count}]");
}
}
// Under Il2CppInterop, UnityEngine.Object.FindObjectOfType takes an
// Il2CppSystem.Type (NOT System.Type), so AccessTools.Method(...,
// typeof(Type)) misses — that's the "FindObjectOfType(Type) not found"
// in the log. Convert the managed Type to an Il2CppSystem.Type via
// Il2CppType.From, then use Resources.FindObjectsOfTypeAll (which also
// returns INACTIVE objects + loaded prefab instances — important, the
// watch UI is usually inactive until opened). Falls back to the
// active-only FindObjectOfType.
private static object? FindOne(Type t)
{
try
{
var il2cppType = ToIl2CppType(t);
if (il2cppType is null) return null;
var resources = Mod.ResolveType("UnityEngine.Resources");
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType);
var first = FirstOf(all?.Invoke(null, new[] { il2cppType }));
if (first != null) return first;
var objType = Mod.ResolveType("UnityEngine.Object");
var findOne = OneArgMethod(objType, "FindObjectOfType", il2cppType);
return findOne?.Invoke(null, new[] { il2cppType });
}
catch { return null; }
}
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; }
}
// Find a public static method `name` whose single parameter accepts `arg`.
private static MethodInfo? OneArgMethod(Type? t, string name, object arg)
{
if (t is null) return null;
foreach (var m in t.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (m.Name != name) continue;
var ps = m.GetParameters();
if (ps.Length == 1 && ps[0].ParameterType.IsInstanceOfType(arg)) return m;
}
return null;
}
// First element of an Il2CppReferenceArray / Array / IList, or null.
private static object? FirstOf(object? arr)
{
if (arr is null) return null;
if (arr is Array a) return a.Length > 0 ? a.GetValue(0) : null;
var at = arr.GetType();
var lenProp = at.GetProperty("Length") ?? at.GetProperty("Count");
if (lenProp?.GetValue(arr) is int n && n > 0)
{
var getItem = at.GetMethod("get_Item", new[] { typeof(int) });
if (getItem != null) return getItem.Invoke(arr, new object[] { 0 });
}
return null;
}
}
// ── Desktop Screen Sharing FPS override ─────────────────────────────────
// RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts the
// shared desktop image. Capture cadence is the baked [SerializeField]
// float screenShareImageRefreshFrequency; the broadcast tick gates on
// if (Time.time - lastSend >= 1f / screenShareImageRefreshFrequency)
// (DesktopScreenSharingDisplay.txt:697-703) — so the field is literally the
// FPS, with no runtime clamp (the [Range] is editor-only). We rewrite it in
// an Awake postfix. The captured frame is transmitted via the component's
// IPunObservable OnPhotonSerializeView, which only fires at
// PhotonNetwork.SerializationRate (default 10 Hz), so we also lift the
// Photon send/serialization rate to cover the target — otherwise a 30 fps
// capture still only delivers ~10. Field names are un-obfuscated on both
// builds (confirmed in the 2020.12.18 dump), so a plain AccessTools.Field
// lookup works.
internal static class ScreenSharePatches
{
private static bool _photonRateApplied;
public static void DisplayAwake_Postfix(object __instance)
{
try
{
var fps = Mod.Cfg.DesktopScreenShareFps;
if (fps <= 0f || __instance is null) return;
var t = __instance.GetType();
SetField(t, __instance, "screenShareImageRefreshFrequency", fps);
if (Mod.Cfg.DesktopScreenShareResolution > 0)
SetField(t, __instance, "screenShareImageHorizontalResolution", Mod.Cfg.DesktopScreenShareResolution);
if (Mod.Cfg.DesktopScreenShareQuality > 0)
SetField(t, __instance, "screenShareImageCompressionQuality", Mod.Cfg.DesktopScreenShareQuality);
Mod.Log.Msg($"[screenshare] {t.Name}: refreshFrequency→{fps}fps" +
(Mod.Cfg.DesktopScreenShareResolution > 0 ? $" res→{Mod.Cfg.DesktopScreenShareResolution}px" : "") +
(Mod.Cfg.DesktopScreenShareQuality > 0 ? $" quality→{Mod.Cfg.DesktopScreenShareQuality}" : ""));
if (Mod.Cfg.DesktopScreenShareRaisePhotonRate && !_photonRateApplied)
RaisePhotonRates((int)Math.Ceiling(fps));
}
catch (Exception ex) { Mod.Log.Warning($"[screenshare] Awake postfix failed: {ex.Message}"); }
}
private static void SetField(Type t, object inst, string name, object value)
{
var f = AccessTools.Field(t, name);
if (f is null) { Mod.Log.Warning($"[screenshare] field '{name}' not found on {t.Name}"); return; }
f.SetValue(inst, value);
}
// Lift PhotonNetwork.SendRate + SerializationRate to >= hz. PUN requires
// SerializationRate <= SendRate, so SendRate is kept at the max of the
// two. Both are public static int members (property on some PUN builds,
// field on others) — handle either. GLOBAL: every PhotonView's
// OnPhotonSerializeView now fires at this rate.
private static void RaisePhotonRates(int hz)
{
var pn = Mod.ResolveType("PhotonNetwork");
if (pn is null) { Mod.Log.Warning("[screenshare] PhotonNetwork not found; can't raise serialization rate (delivered FPS will stay ~10)"); return; }
var send = ReadInt(pn, "SendRate");
var ser = ReadInt(pn, "SerializationRate");
var target = Math.Max(hz, 1);
var newSend = Math.Max(target, Math.Max(send, ser));
WriteInt(pn, "SendRate", newSend);
WriteInt(pn, "SerializationRate", target);
_photonRateApplied = true;
Mod.Log.Msg($"[screenshare] Photon rates raised (GLOBAL): SendRate {send}→{newSend}, SerializationRate {ser}→{target}");
}
private static int ReadInt(Type t, string name)
{
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.Static);
if (p is not null && p.CanRead && p.GetValue(null) is int pv) return pv;
var f = t.GetField(name, BindingFlags.Public | BindingFlags.Static);
if (f is not null && f.GetValue(null) is int fv) return fv;
return 0;
}
private static void WriteInt(Type t, string name, int value)
{
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.Static);
if (p is not null && p.CanWrite) { p.SetValue(null, value); return; }
var f = t.GetField(name, BindingFlags.Public | BindingFlags.Static);
if (f is not null) f.SetValue(null, value);
}
}
internal static class MakerPenGiftPreviewPatches
{
private static bool _loggedMissingPointer;
private static bool _loggedMissingRestrictionFlag;
public static bool LaserPointerEnabled_Prefix(object __instance, bool ONGBFDACHHG)
{
try
{
if (__instance is null) return true;
var t = __instance.GetType();
if (ReadField(__instance, t, "pointerLineRenderer") is null)
{
if (!_loggedMissingPointer)
{
_loggedMissingPointer = true;
Mod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled on MakerPenVisuals with no pointerLineRenderer");
}
return false;
}
if (ONGBFDACHHG && ReadField(__instance, t, "EANJBIDHAMN") is null)
{
if (!_loggedMissingRestrictionFlag)
{
_loggedMissingRestrictionFlag = true;
Mod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled=true on MakerPenVisuals with no laser restriction flag");
}
return false;
}
}
catch (Exception ex)
{
Mod.Log.Warning($"[makerpen-preview] guard failed, letting original run: {ex.Message}");
}
return true;
}
private static object? ReadField(object instance, Type type, string name)
{
var field = AccessTools.Field(type, name) ?? AccessTools.Field(type.BaseType, name);
return field?.GetValue(instance);
}
}
internal static class UriPatches
{
public static void HttpManagerSendRequestString_Prefix(ref string url)
@@ -1764,9 +787,22 @@ internal static class UriPatches
private static void RewriteString(ref string uriString, string label)
{
if (string.IsNullOrEmpty(uriString)) return;
if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return;
if (!string.IsNullOrEmpty(Mod.Cfg.SingleOriginBaseUrl) &&
Uri.TryCreate(uriString, UriKind.Absolute, out var uri))
{
var rewrittenUri = RewriteUri(uri);
if (rewrittenUri is null) return;
var rewrittenText = rewrittenUri.ToString();
if (rewrittenText == uriString) return;
Mod.Log.Msg($"[{label}] {uriString} → {rewrittenText}");
uriString = rewrittenText;
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 (rewritten == uriString) return;
Mod.Log.Msg($"[{label}] {uriString} → {rewritten}");
@@ -1782,30 +818,88 @@ internal static class UriPatches
var service = uri.Host[..^".rec.net".Length];
if (string.IsNullOrWhiteSpace(service)) return null;
if (!string.IsNullOrEmpty(Mod.Cfg.SingleOriginBaseUrl) &&
Uri.TryCreate(Mod.Cfg.SingleOriginBaseUrl, UriKind.Absolute, out var baseUri))
{
var path = CombinePath(baseUri.AbsolutePath, "__dn/" + service, uri.AbsolutePath);
return new UriBuilder(baseUri)
{
Path = path,
Query = uri.Query.TrimStart('?'),
}.Uri;
}
return new UriBuilder(uri)
{
Host = service + "." + host,
}.Uri;
}
private static string CombinePath(params string[] parts)
{
var cleaned = new List<string>();
foreach (var part in parts)
{
var p = (part ?? "").Trim('/');
if (p.Length > 0) cleaned.Add(p);
}
return "/" + string.Join("/", cleaned);
}
}
internal static class PhotonPatches
{
/// <summary>True once <see cref="TryApplyOverride"/> has successfully
/// written the user's AppId into <c>PhotonServerSettings</c>. The
/// per-frame poller in <see cref="Mod.OnUpdate"/> stops calling once
/// this flips true.</summary>
public static bool OverrideApplied;
public static void PhotonAppIdOverride_Prefix()
{
if (string.IsNullOrEmpty(Mod.Cfg.PhotonAppId)) return;
// Kept as a belt-and-braces hook in case the watch's actual
// connect path does happen to flow through ConnectUsingSettings
// (it doesn't, in March's IL2CPP build — verified via log). The
// poller in OnUpdate is what actually gets the override in
// before the wire connect.
TryApplyOverride(reason: "prefix");
}
/// <summary>Tracks how many polling attempts we've logged so the
/// "type not found yet" line doesn't drown the log file. Cleared
/// once the override succeeds.</summary>
private static int _missLogCount;
/// <summary>Mutates <see cref="PhotonServerSettings.AppID"/> +
/// <c>VoiceAppID</c> + region with the values from
/// <see cref="Mod.Cfg"/>. Returns true once the override has been
/// written successfully; subsequent calls become no-ops. Returns
/// false (and logs nothing on most retries) when PhotonServerSettings
/// hasn't been loaded by the watch yet — the poller retries every
/// frame until the ScriptableObject shows up.</summary>
public static bool TryApplyOverride(string reason)
{
if (OverrideApplied) return true;
if (string.IsNullOrEmpty(Mod.Cfg.PhotonAppId)) return false;
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;
// Use Mod.ResolveType (the same resolver TryPatchByName uses)
// — it falls back to a full assembly scan after the prefix
// attempts, which is how it finds the IL2CPP-mangled types
// that the local FindGameType helper misses.
var photonNetwork = Mod.ResolveType("PhotonNetwork");
if (photonNetwork is null)
{
// Log the miss only on the first call + once every ~10s
// after that, to avoid the log spam previous build hit.
if (_missLogCount == 0 || _missLogCount % 300 == 0)
Mod.Log.Msg($"[photon-appid] PhotonNetwork type not found yet (#{_missLogCount}, {reason})");
_missLogCount++;
return false;
}
var settingsProp = photonNetwork.GetProperty("PhotonServerSettings", BindingFlags.Public | BindingFlags.Static);
var settings = settingsProp?.GetValue(null);
if (settings is null) return;
if (settings is null) return false; // ScriptableObject not loaded yet; quietly retry next frame.
var settingsType = settings.GetType();
var appIdProp = settingsType.GetProperty("AppID") ?? settingsType.GetProperty("AppId");
@@ -1814,19 +908,9 @@ internal static class PhotonPatches
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}");
Mod.Log.Msg($"[photon-appid] override AppID/VoiceAppID → {newId} / {newVoice} ({reason})");
// Force a deterministic Photon region so two watches running
// this mod end up on the same Photon master and "JoinByName"
@@ -1848,8 +932,14 @@ internal static class PhotonPatches
// any) over the config default — that way a server-side
// region change propagates without redeploying the mod.
ApplyForcedRegion(settings, settingsType);
OverrideApplied = true;
return true;
}
catch (Exception ex)
{
Mod.Log.Warning($"[photon-appid] override failed ({reason}): {ex.Message}");
return false;
}
catch (Exception ex) { Mod.Log.Warning($"[photon-appid] override failed: {ex.Message}"); }
}
/// <summary>Forces <c>PhotonServerSettings.HostType = PhotonCloud</c>
@@ -1928,190 +1018,11 @@ internal static class PhotonPatches
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.
// AuthValuesInjector_Prefix + GetCurrentLockToken parked 2026-05-28
// — see attic/AuthValuesInjector.cs.attic. Photon Custom Auth is
// now disabled at the AppId level in the Photon dashboard, so the
// watch no longer needs to attach userid+LoginLock to Photon
// Authenticate ops.
private static Type? FindGameType(string name) => Mod.ResolveType(name);
}
@@ -2403,6 +1314,12 @@ internal static class DiagnosticPatches
WriteStack();
}
public static void BootSequenceRegisterError_Prefix(string error)
{
Write($"[boot-error] BootSequence.RegisterError: {error}");
WriteStack();
}
public static void AnalyticsCheat_Prefix(MethodBase __originalMethod)
{
Write($"[cheat-analytics] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
@@ -2477,218 +1394,3 @@ internal static class ChatPatches
Mod.Log.Msg($"[chat-trace] RpcChatEmote (RPC RECEIVED) msg=\"{message}\"");
}
}
// Log-only diagnostics for the stuck first-dorm "Change Username?" dialog.
// See the registration site in OnLateInitializeMelon and
// Cfg.TraceRegistrationDialog. Nothing here changes game behaviour — every
// hook only writes to the diagnostics log.
internal static class RegistrationPatches
{
private static bool s_loggedFullyReg;
// RRUI.Data.NUX.RegistrationModel.IsFullyRegistered() — postfix reads
// (does NOT modify) the return value. False ⇒ the dorm raises the
// "Change Username?" prompt; true ⇒ no prompt. Logged once so the file
// stays readable (it's polled from several call sites every boot).
public static void IsFullyRegistered_Postfix(bool __result)
{
if (!s_loggedFullyReg)
{
s_loggedFullyReg = true;
DiagnosticPatches.Write($"[reg-trace] RegistrationModel.IsFullyRegistered() = {__result} ({(__result ? "no prompt — fully registered" : "NOT registered → dorm will raise the Change Username? prompt")})");
}
}
// DormroomSceneManager.<PromptForRegistration>b__6_* lambdas. b__6_1 is
// the dialog-response handler (a dialog was shown). Names reach the
// prefix via __originalMethod so we can tell which branch ran.
public static void PromptLambda_Prefix(MethodBase __originalMethod)
{
DiagnosticPatches.Write($"[reg-trace] DormroomSceneManager prompt lambda fired: {__originalMethod.Name}");
}
// ConfirmUIDialog button handlers. If one of these fires when the user
// taps a dialog button but the modal stays open, the break is in the
// resolve/hide completer (UIDialog`1.Respond's close path) — not input
// routing. "Okay" on the screenshot is Button_Affirmative.
public static bool ButtonAffirmative_Prefix(object __instance)
{
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Affirmative pressed (this is 'Okay')");
if (!IsRegistrationWarningDialog(__instance))
{
return true;
}
if (TryResolveAffirmativeWithoutClose(__instance))
{
DiagnosticPatches.Write("[registration-fix] resolved first-dorm registration warning without the broken close-before-resolve path");
return false;
}
DiagnosticPatches.Write("[registration-fix] registration warning matched, but reflection resolve failed; falling back to original button handler");
return true;
}
public static void ButtonNegative_Prefix() =>
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Negative pressed");
public static void ButtonNotNow_Prefix() =>
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_NotNow pressed");
public static void ButtonCancel_Prefix() =>
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Cancel pressed");
private static bool IsRegistrationWarningDialog(object instance)
{
var title = ReadTextField(instance, "titleText");
var body = ReadTextField(instance, "bodyText");
return Contains(title, "Change Username")
&& Contains(body, "permanently lost")
&& Contains(body, "password");
}
private static bool TryResolveAffirmativeWithoutClose(object instance)
{
try
{
var respond = AccessTools.Method(instance.GetType(), "Respond");
if (respond is null)
{
respond = AccessTools.Method(instance.GetType().BaseType, "Respond");
}
if (respond is null)
{
return false;
}
var parameters = respond.GetParameters();
if (parameters.Length != 2)
{
return false;
}
var response = CreateAffirmativeResponse(parameters[0].ParameterType);
respond.Invoke(instance, new[] { response, (object)false });
return true;
}
catch (Exception ex)
{
DiagnosticPatches.Write($"[registration-fix] resolve failed: {ex.GetType().Name}: {ex.Message}");
return false;
}
}
private static object CreateAffirmativeResponse(Type responseType)
{
if (responseType.IsEnum)
{
return Enum.ToObject(responseType, 1);
}
return Convert.ChangeType(1, responseType);
}
private static string? ReadTextField(object instance, string fieldName)
{
try
{
var field = AccessTools.Field(instance.GetType(), fieldName);
var textObject = field?.GetValue(instance);
if (textObject is null)
{
return null;
}
var property = AccessTools.Property(textObject.GetType(), "text");
if (property?.GetValue(textObject) is string text)
{
return text;
}
var getter = AccessTools.Method(textObject.GetType(), "get_text");
return getter?.Invoke(textObject, Array.Empty<object>()) as string;
}
catch (Exception ex)
{
DiagnosticPatches.Write($"[registration-fix] could not read {fieldName}: {ex.GetType().Name}: {ex.Message}");
return null;
}
}
private static bool Contains(string? value, string fragment)
{
return value?.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0;
}
}
// Trace hooks for the watch's room-join flow. Registered by
// Mod.RegisterJoinTracePatches — see that method for the verified call
// chain. Everything here is log-only; no prefix returns false, so the
// game's own join logic runs unchanged.
internal static class JoinPatches
{
// AGUI.StackedUI.HomeScreenFlow.Button_DormRoom() — the watch's
// "Dorm Room" button. If this never fires, the button's UnityEvent
// binding is the problem, not the join chain.
public static void ButtonDormRoom_Prefix()
{
DiagnosticPatches.Write("[join-trace] >>> watch DORM ROOM button pressed (HomeScreenFlow.Button_DormRoom)");
}
// Any SessionManager.RunJoinRoom overload. We DON'T read argument
// values here: Harmony's `object[] __args` forces a trampoline that
// boxes the obfuscated IL2CPP reference-type params (KMKPEOGJDFK,
// RoomInstance, …), and MonoMod fatal-CLRs (0x80131506) compiling it
// when the boot-time dorm auto-join first calls this method. Logging
// the overload's parameter *types* (pure reflection metadata, no value
// boxing) is enough to tell which overload fired — the entry overload
// (String,…) is the "DormRoom" press; the others are inner legs.
public static void RunJoinRoom_Prefix(MethodBase __originalMethod)
{
DiagnosticPatches.Write($"[join-trace] SessionManager.RunJoinRoom [{Sig(__originalMethod)}]");
}
// OJMCBOKJFOF.NHBPIIGDAJP(name|id) — the get-room-by-name / by-id
// resolver that feeds the join. __0 is the single first param: a room
// NAME string (String overload) or room ID (Int64 overload). Reading
// it via `object __0` is safe — it's a string/long, not one of the
// obfuscated ref-types that crashed `object[] __args`. This is the
// value we need to compare boot (works) vs button (fails).
public static void Goto_Prefix(MethodBase __originalMethod, object __0)
{
var arg = __0?.ToString() ?? "<null>";
DiagnosticPatches.Write($"[join-trace] /goto resolve {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}(\"{arg}\") [{Sig(__originalMethod)}]");
}
// RunJoinRoom promise-chain callbacks (SessionManager <>c lambdas).
// b__165_1 is the reject handler that raises the "An error occured /
// contact recroom.happyfox.com" toast — so if THIS name appears, the
// join failed; the success legs (other b__*) firing means it advanced.
// (Same __args/0x80131506 constraint as above — name only.)
public static void JoinCallback_Prefix(MethodBase __originalMethod)
{
DiagnosticPatches.Write($"[join-trace] RunJoinRoom callback {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}");
}
// Dedicated capture for the reject handler b__165_1(string error) —
// the answer to "why did the join fail". Single string param, so
// `object __0` is safe (same as OnCustomAuthenticationFailed). This
// is the string that becomes the happyfox toast.
public static void JoinError_Prefix(object __0)
{
var err = __0?.ToString() ?? "<null>";
DiagnosticPatches.Write($"[join-trace] !!! RunJoinRoom REJECTED — error=\"{err}\" (raises the happyfox toast)");
}
// Parameter-type list from reflection metadata only — never reads
// instance values, so it can't trigger the MonoMod trampoline crash.
private static string Sig(MethodBase m)
{
var ps = m.GetParameters();
var names = new List<string>(ps.Length);
foreach (var p in ps) names.Add(p.ParameterType.Name);
return $"{ps.Length} args: {string.Join(",", names)}";
}
}
-464
View File
@@ -1,464 +0,0 @@
// Raise RRO quest party size past the baked 4 — precisely, only for the
// allowlisted quests (Cfg.QuestMaxTeamSizeRooms).
//
// HOW THE CAP IS REALLY BUILT (cracked from the December dump/ISIL):
// GameConfigurationTool.Awake()
// → reads its baked `defaultGameConfiguration` (GameConfigurationAsset, the
// Unity ScriptableObject @0x58)
// → calls GameConfigurationAsset.GDECPANDBHJ() ← builds the runtime
// protobuf GameConfigurationData (PJPAPHKPKGM) FROM the asset
// → stores it @0x88 and applies it (OAHJOLJOKGI) to the team manager.
// Inside GDECPANDBHJ the build loop reads asset.TeamConfigurations[i]
// .MaxTeamSize (a typed int struct-array: [arr+0x20 + i*4]) and writes each
// straight into a new GameTeamConfigurationData.TeamSize (HFBCEJLLDLI @0x10).
//
// So the runtime cap originates from the asset's `TeamConfiguration[]` — a
// READABLE, typed struct array — NOT from anything that needs the obfuscated
// protobuf. Earlier attempts failed because:
// • bumping the asset on a 2s poll happened AFTER GDECPANDBHJ already built &
// cached the protobuf (timing, not the wrong field);
// • patching the protobuf getters (get_FNJLAGHPMFL / get_FBOCAPLIIDH) is
// impossible — Il2CppInterop doesn't emit the generic RepeatedField<T>
// getter, and the simple int getter wasn't emitted either.
//
// THE FIX: Harmony-PREFIX GameConfigurationAsset.GDECPANDBHJ() and bump the
// asset's TeamConfigurations[i].MaxTeamSize to the target IN PLACE, right
// before it builds the protobuf. The built config then carries the new cap and
// the team manager / pre-game roster / open-slots all honour it. Scoped by
// asset.Name to the allowlist (PvP/sandbox configs are left alone).
//
// HOST-AUTHORITATIVE: the quest host builds the config it networks to joiners
// (RpcDeserializeConfig), so the host must run this mod for everyone to see the
// raised cap. (A joiner can't override — that would need the unreachable
// protobuf path.)
using System;
using System.Collections.Generic;
using System.Reflection;
using HarmonyLib;
namespace DorkNet.ClientMod;
internal static class QuestTeamSize
{
private const BindingFlags BF = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
private static readonly HashSet<string> _seen = new(StringComparer.Ordinal);
private static bool _loggedBump;
private static bool _activeAllowedQuestConfig;
private static int _teamIndexNormalizeLogCount;
private static int _spawnModeDataNormalizeLogCount;
private static int _requiredSpawnIndexNormalizeLogCount;
private static int _spawnRelaxLogCount;
public static bool TryPatchSpawnIndexNormalizers(HarmonyLib.Harmony harmony)
{
var ok = true;
ok &= TryPatchTeamIndexNormalizer(harmony);
ok &= TryPatchSpawnModeDataNormalizer(harmony);
ok &= TryPatchRequiredSpawnIndexNormalizer(harmony);
return ok;
}
private static bool TryPatchTeamIndexNormalizer(HarmonyLib.Harmony harmony)
{
try
{
var managerType = Mod.ResolveType("GameTeamManager");
var indexType = Mod.ResolveType("CCMLKDOHKJE");
if (managerType is null || indexType is null)
{
Mod.Log.Warning("[patch-miss] GameTeamManager.GetTeamPlayerIndex: type not found");
return false;
}
var target = AccessTools.Method(managerType, "GetTeamPlayerIndex");
if (target is null)
{
Mod.Log.Warning("[patch-miss] GameTeamManager.GetTeamPlayerIndex");
return false;
}
var patch = typeof(QuestTeamSize)
.GetMethod(nameof(NormalizeTeamPlayerIndex_Postfix), BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(indexType);
harmony.Patch(target, postfix: new HarmonyMethod(patch));
Mod.Log.Msg("[patch-ok] GameTeamManager.GetTeamPlayerIndex");
return true;
}
catch (Exception ex)
{
Mod.Log.Error($"[patch-fail] GameTeamManager.GetTeamPlayerIndex: {ex.Message}");
return false;
}
}
private static bool TryPatchSpawnModeDataNormalizer(HarmonyLib.Harmony harmony)
{
try
{
var spawnType = Mod.ResolveType("GameSpawnManager");
var indexType = Mod.ResolveType("CCMLKDOHKJE");
if (spawnType is null || indexType is null)
{
Mod.Log.Warning("[patch-miss] GameSpawnManager.ALCADGJKKHJ: type not found");
return false;
}
var target = AccessTools.Method(spawnType, "ALCADGJKKHJ");
if (target is null)
{
Mod.Log.Warning("[patch-miss] GameSpawnManager.ALCADGJKKHJ");
return false;
}
var patch = typeof(QuestTeamSize)
.GetMethod(nameof(NormalizeSpawnModeData_Postfix), BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(indexType);
harmony.Patch(target, postfix: new HarmonyMethod(patch));
Mod.Log.Msg("[patch-ok] GameSpawnManager.ALCADGJKKHJ");
return true;
}
catch (Exception ex)
{
Mod.Log.Error($"[patch-fail] GameSpawnManager.ALCADGJKKHJ: {ex.Message}");
return false;
}
}
private static bool TryPatchRequiredSpawnIndexNormalizer(HarmonyLib.Harmony harmony)
{
try
{
var spawnType = Mod.ResolveType("GameSpawnManager");
var indexType = Mod.ResolveType("CCMLKDOHKJE");
if (spawnType is null || indexType is null)
{
Mod.Log.Warning("[patch-miss] GameSpawnManager.FKEICBPCMPJ index normalizer: type not found");
return false;
}
var target = AccessTools.Method(spawnType, "FKEICBPCMPJ");
if (target is null)
{
Mod.Log.Warning("[patch-miss] GameSpawnManager.FKEICBPCMPJ index normalizer");
return false;
}
var patch = typeof(QuestTeamSize)
.GetMethod(nameof(NormalizeRequiredSpawnIndex_Prefix), BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(indexType);
harmony.Patch(target, prefix: new HarmonyMethod(patch));
Mod.Log.Msg("[patch-ok] GameSpawnManager.FKEICBPCMPJ index normalizer");
return true;
}
catch (Exception ex)
{
Mod.Log.Error($"[patch-fail] GameSpawnManager.FKEICBPCMPJ index normalizer: {ex.Message}");
return false;
}
}
// POSTFIX on GameTeamManager.GetTeamPlayerIndex(Player). The RRO quest
// scenes only have spawn points for indexes 0-3. After raising the quest
// team cap, players 5+ legitimately receive INDEX_4+, but those values
// cannot match any baked quest spawn point. Normalize just the enum result
// that spawn code asks for; the actual team roster/cap remains raised.
public static void NormalizeTeamPlayerIndex_Postfix<T>(ref T __result) where T : struct
{
try
{
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
var value = Convert.ToInt32(__result);
if (!TryNormalizeIndex(value, out var normalized)) return;
__result = (T)Enum.ToObject(typeof(T), normalized);
if (_teamIndexNormalizeLogCount < 12)
{
_teamIndexNormalizeLogCount++;
Mod.Log.Msg($"[questsize] normalized team-player index {value} -> {normalized} for quest spawn lookup " +
$"activeQuest={_activeAllowedQuestConfig}");
}
}
catch (Exception ex)
{
Mod.Log.Warning($"[questsize] team-player index normalize failed: {ex.Message}");
}
}
// POSTFIX on GameSpawnManager.ALCADGJKKHJ(..., out CCMLKDOHKJE index, ...).
// FPBFJMMKPPJ calls this private helper immediately before FKEICBPCMPJ, so
// this is the exact spawn-path value seen in the "TeamIndex INDEX_4" log.
public static void NormalizeSpawnModeData_Postfix<T>(ref T __3) where T : struct
{
try
{
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
var value = Convert.ToInt32(__3);
if (!TryNormalizeIndex(value, out var normalized)) return;
__3 = (T)Enum.ToObject(typeof(T), normalized);
if (_spawnModeDataNormalizeLogCount < 12)
{
_spawnModeDataNormalizeLogCount++;
Mod.Log.Msg($"[questsize] normalized spawn-mode team-player index {value} -> {normalized} " +
$"activeQuest={_activeAllowedQuestConfig}");
}
}
catch (Exception ex)
{
Mod.Log.Warning($"[questsize] spawn-mode index normalize failed: {ex.Message}");
}
}
// PREFIX on GameSpawnManager.FKEICBPCMPJ(..., CCMLKDOHKJE requiredIndex, ...).
// This is the last guard before the spawn point field compare at +0x88.
public static void NormalizeRequiredSpawnIndex_Prefix<T>(ref T __2) where T : struct
{
try
{
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
var value = Convert.ToInt32(__2);
if (!TryNormalizeIndex(value, out var normalized)) return;
__2 = (T)Enum.ToObject(typeof(T), normalized);
if (_requiredSpawnIndexNormalizeLogCount < 12)
{
_requiredSpawnIndexNormalizeLogCount++;
Mod.Log.Msg($"[questsize] normalized required spawn index {value} -> {normalized} " +
$"activeQuest={_activeAllowedQuestConfig}");
}
}
catch (Exception ex)
{
Mod.Log.Warning($"[questsize] required spawn index normalize failed: {ex.Message}");
}
}
private static bool TryNormalizeIndex(int value, out int normalized)
{
normalized = value % 4;
return value >= 4;
}
// PREFIX on GameConfigurationAsset.GDECPANDBHJ() (asset → protobuf builder).
public static void Build_Prefix(object __instance)
{
try
{
var target = Mod.Cfg.QuestMaxTeamSize;
if (target <= 0 || __instance is null) return;
var name = GetMember(__instance, "Name") as string ?? "";
var allowed = IsAllowed(name);
_activeAllowedQuestConfig = allowed;
if (_seen.Add(name))
Mod.Log.Msg($"[questsize] config build '{name}' allowed={allowed}");
if (!allowed) return;
var arr = GetMember(__instance, "TeamConfigurations");
int len = ArrLen(arr);
if (len <= 0) return;
int bumped = 0;
for (int i = 0; i < len; i++)
{
var elem = ArrItem(arr, i); // boxed TeamConfiguration struct (copy)
if (elem is null) continue;
int cur = ReadInt(elem, "MaxTeamSize");
if (cur > 0 && cur < target &&
WriteInt(elem, "MaxTeamSize", target) &&
SetArrItem(arr!, i, elem)) // write the mutated struct back
bumped++;
}
if (bumped > 0 && !_loggedBump)
{
_loggedBump = true;
Mod.Log.Msg($"[questsize] '{name}': bumped {bumped} team slot(s) → {target}");
}
}
catch (Exception ex) { Mod.Log.Warning($"[questsize] build prefix failed: {ex.Message}"); }
}
// POSTFIX on GameSpawnManager.FKEICBPCMPJ(..., outputList). That method
// filters spawn points by state/game-mode/spectator/team/team-player-index/
// custom-tag before the spawn selector runs. The RRO quest prefabs only
// have indexed start points for players 0-3; once we raise the team size,
// player index 4+ can filter every point out before the built-in overlap
// fallback has a chance to help.
//
// December decompile: FPBFJMMKPPJ calls this filter, then if output.Count
// is still 0 it logs "due to spawn point requirement tests" and falls back
// to the unfiltered source list as an arbitrary spawn set. Filling the
// output list here keeps the normal overlap pass and spawn-selection mode
// alive instead of taking that bad arbitrary fallback.
//
// Harmony binds __0 and __7 by argument position: source list and output
// list. The obfuscated parameter names are not reliable in MelonLoader's
// generated IL2CPP metadata, which is why this intentionally avoids them.
// If the filtered output is empty, copy the already-selected candidate
// scene spawn list back into the output so extra players reuse baked quest
// spawn points instead of being left out in the void.
public static void RelaxEmptySpawnFilter_Postfix(object __0, object __7)
{
try
{
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
if (Count(__7) > 0) return;
var sourceCount = Count(__0);
if (sourceCount <= 0) return;
var copied = CopyListItems(__0, __7);
if (_spawnRelaxLogCount < 12)
{
_spawnRelaxLogCount++;
Mod.Log.Msg($"[questsize] relaxed empty quest spawn filter; activeQuest={_activeAllowedQuestConfig} " +
$"source={sourceCount} copied={copied} " +
$"sourceType={TypeName(__0)} outputType={TypeName(__7)}");
}
}
catch (Exception ex)
{
Mod.Log.Warning($"[questsize] spawn filter relax failed: {ex.Message}");
}
}
// ── allowlist matching (normalize: lowercase-alnum, strip filler) ──────
private static List<string>? _wanted;
private static bool IsAllowed(string configName)
{
_wanted ??= BuildWanted();
if (_wanted.Count == 0) return false;
var have = Norm(configName);
foreach (var w in _wanted)
if (have.Contains(w)) return true;
return false;
}
private static List<string> BuildWanted()
{
var list = new List<string>();
foreach (var entry in Mod.Cfg.QuestMaxTeamSizeRooms)
{
var nrm = Norm(entry);
if (nrm.Length > 0) list.Add(nrm);
}
return list;
}
private static string Norm(string s)
{
if (string.IsNullOrEmpty(s)) return "";
var sb = new System.Text.StringBuilder(s.Length);
foreach (var c in s)
if (char.IsLetterOrDigit(c)) sb.Append(char.ToLowerInvariant(c));
return sb.ToString().Replace("the", "").Replace("for", "").Replace("of", "");
}
// ── reflection helpers (Il2CppInterop exposes il2cpp members as either a
// property or a field depending on version — try both) ────────────────
private static object? GetMember(object o, string name)
{
var t = o.GetType();
var p = t.GetProperty(name, BF);
if (p != null && p.CanRead && p.GetIndexParameters().Length == 0)
{ try { return p.GetValue(o); } catch { } }
var f = t.GetField(name, BF);
if (f != null) { try { return f.GetValue(o); } catch { } }
return null;
}
private static int ReadInt(object? o, string name)
{
if (o is null) return 0;
var v = GetMember(o, name);
if (v is int n) return n;
return v != null && int.TryParse(v.ToString(), out var p) ? p : 0;
}
private static bool WriteInt(object o, string name, int val)
{
var t = o.GetType();
var p = t.GetProperty(name, BF);
if (p != null && p.CanWrite) { try { p.SetValue(o, val); return true; } catch { } }
var f = t.GetField(name, BF);
if (f != null) { try { f.SetValue(o, val); return true; } catch { } }
return false;
}
private static int ArrLen(object? arr)
{
if (arr is null) return 0;
if (arr is Array a) return a.Length;
var lp = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count");
return (lp?.GetValue(arr) as int?) ?? 0;
}
private static int Count(object? collection) => ArrLen(collection);
private static object? ArrItem(object? arr, int i)
{
if (arr is null) return null;
if (arr is Array a) return a.GetValue(i);
var gi = arr.GetType().GetMethod("get_Item", new[] { typeof(int) });
return gi?.Invoke(arr, new object[] { i });
}
// Write a (boxed) struct element back into an Il2CppStructArray via its
// set_Item(int, T) indexer. Reflection unboxes to T on invoke.
private static bool SetArrItem(object arr, int i, object val)
{
if (arr is Array a) { try { a.SetValue(val, i); return true; } catch { return false; } }
foreach (var m in arr.GetType().GetMethods())
{
if (m.Name != "set_Item") continue;
var ps = m.GetParameters();
if (ps.Length == 2 && ps[0].ParameterType == typeof(int))
{
try { m.Invoke(arr, new[] { i, val }); return true; } catch { }
}
}
return false;
}
private static int CopyListItems(object? src, object? dst)
{
if (src is null || dst is null) return 0;
MethodInfo? add = null;
foreach (var method in dst.GetType().GetMethods())
{
if (method.Name != "Add") continue;
if (method.GetParameters().Length != 1) continue;
add = method;
break;
}
if (add is null) return 0;
var n = ArrLen(src);
var copied = 0;
for (var i = 0; i < n; i++)
{
var item = ArrItem(src, i);
if (item is null) continue;
try
{
add.Invoke(dst, new[] { item });
copied++;
}
catch { }
}
return copied;
}
private static string TypeName(object? value) =>
value?.GetType().FullName ?? "<null>";
}
@@ -1,5 +1,5 @@
// ─────────────────────────────────────────────────────────────────────
// AuthValuesInjector — PARKED 2026-05-28 (december-2020-12-18)
// AuthValuesInjector — PARKED 2026-05-28
// ─────────────────────────────────────────────────────────────────────
//
// Filename suffix is .cs.attic on purpose so the .NET SDK's globber
@@ -8,41 +8,48 @@
//
// 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.
// Prefix-patched NetworkingPeer.CallAuthenticate so the 2020 watch
// would attach a Photon Custom-Auth payload (`userid` +
// `LoginLock`) to every Photon Authenticate op. The server's
// /photon/customauth controller (PhotonCustomAuthController.cs) read
// those parameters and validated them 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.
// Why it's parked
// ---------------
// Photon Cloud's Custom Auth probe was getting 403'd at the WAF in
// front of `auth.<apex>/photon/customauth` for hosts behind
// Cloudflare or Tunnelto's proxy. The Photon NameServer then returned
// `CustomAuthenticationFailed (ReturnCode 32755)` and the watch
// bounced back to the dorm before a session could start.
//
// 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.
// The cleaner fix turned out to be:
// 1. Turn off Custom Authentication on the Photon AppId in the
// dashboard (or flip "Anonymous connect" to Allow), so Photon
// never calls our URL.
// 2. Stop sending custom-auth params from the client — that's the
// InjectAuthValues=false flag we shipped first. With the
// dashboard side off too, the injector code below has nothing
// to gate on, so it's removed from the live patch.
//
// 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.
// 1. Rename this file to AuthValuesInjector.cs (drop the .attic
// suffix). It compiles as part of DorkNet.ClientMod.
// 2. Reinstate the `InjectAuthValues` field on Mod.Cfg + the
// config-load line in LoadConfig.
// 3. Re-add the harmony patch hook in OnLateInitializeMelon:
// TryPatchByName("NetworkingPeer", "CallAuthenticate",
// prefix: nameof(PhotonPatches.AuthValuesInjector_Prefix));
// 4. Flip `"InjectAuthValues": true` in
// DorkNet.ClientMod/dorknet-clientmod.json.template (and the
// copy under local-releases/).
// 5. If you also want the server to enforce the lock, set
// Photon__RequireCustomAuth=true on the server.
// Photon:RequireCustomAuth=true on the server (env
// Photon__RequireCustomAuth=true).
//
// The body below is verbatim what lived in Mod.cs's PhotonPatches
// class. `FindGameType` is the same helper still in Mod.cs.
using System.Reflection;
@@ -85,6 +92,9 @@ internal static class AuthValuesInjector
var token = GetCurrentLockToken();
// Build a new AuthenticationValues. The type lives in the
// ExitGames.Client.Photon.LoadBalancing namespace (game-side).
// CustomAuthenticationType.Custom = 0 in this build.
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);
@@ -125,8 +135,30 @@ internal static class AuthValuesInjector
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;
// FindGameType is already defined inside Mod.cs's PhotonPatches
// class (private static) — when re-instating, either drop this
// private copy and inline the lookup, or move the helper to a
// shared internal class. The current shape preserves the original
// behaviour for an isolated paste-back.
private static Type? FindGameType(string name)
{
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
try
{
var t = asm.GetType(name, throwOnError: false);
if (t is not null) return t;
var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false);
if (prefixed is not null) return prefixed;
var dot = name.IndexOf('.');
if (dot > 0)
{
var nsPrefixed = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), throwOnError: false);
if (nsPrefixed is not null) return nsPrefixed;
}
}
catch { }
}
return null;
}
}
@@ -1,15 +1,8 @@
{
"ServerHost": "{HOST}",
"SingleOriginBaseUrl": "{SINGLE_ORIGIN_BASE_URL}",
"PhotonAppId": "{PHOTON_APPID}",
"PhotonVoiceAppId": "{PHOTON_VOICE_APPID}",
"PhotonCloudRegion": "{PHOTON_REGION}",
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
"QuestMaxTeamSize": 0,
"QuestMaxTeamSizeRooms": [
"CrimsonCauldron",
"Crescendo",
"GoldenTrophy",
"IsleOfLostSkulls",
"TheRiseofJumbotron"
]
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS}
}
@@ -1,9 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
-307
View File
@@ -1,307 +0,0 @@
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 IReadOnlyList<string> PublicSubdomains =>
ServiceRouteGroups
.SelectMany(group => group.HostSubdomains)
.Append(AdminHost)
.Append("feed")
.Distinct(StringComparer.OrdinalIgnoreCase)
.Order(StringComparer.OrdinalIgnoreCase)
.ToArray();
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);
@@ -1,38 +0,0 @@
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);
-19
View File
@@ -1,19 +0,0 @@
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);
-16
View File
@@ -1,16 +0,0 @@
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";
}
-290
View File
@@ -1,290 +0,0 @@
using HarmonyLib;
using Il2CppRecRoom.Debugging;
using MelonLoader;
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text.Json;
using UnityEngine;
using UObject = UnityEngine.Object;
[assembly: MelonInfo(typeof(DorkNet.DevMenu.DevMenuMod), "DorkNet DevMenu", "1.0.0", "Dork Squad")]
[assembly: MelonGame(null, null)]
namespace DorkNet.DevMenu;
public class DevMenuMod : MelonMod
{
internal static MelonLogger.Instance Log => Instance!.LoggerInstance;
internal static DevMenuMod? Instance;
internal HarmonyLib.Harmony PatchHarmony => HarmonyInstance;
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
public static class Cfg
{
public static bool EnableDebugConsole = false;
public static string DebugConsoleToggleKey = "BackQuote";
public static bool DiagnoseDevMenu = false;
public static bool EnableDevWatchButton = false;
public static string[] DevCommands = { "Help=help", "Fly=fly", "NoClip=noclip" };
}
public override void OnInitializeMelon()
{
Instance = this;
Log.Msg("=== DorkNet DevMenu loading ===");
LoadConfig();
if (Cfg.EnableDebugConsole)
{
foreach (var m in new[] { "OnTimeCheatDetected", "OnObscuredTypeCheatDetected", "OnHeightCheatDetected", "OnAdvancedMovementCheatDetected" })
TryPatchByName("CheatManager", m, prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix));
Log.Msg($"[debugconsole] enabled; press '{Cfg.DebugConsoleToggleKey}' to toggle the console.");
}
}
public override void OnUpdate()
{
if (Cfg.EnableDebugConsole) DebugConsolePatches.PollToggleKey();
if (Cfg.DiagnoseDevMenu) DevMenuProbe.Tick();
if (Cfg.EnableDevWatchButton) DevWatchButton.Tick();
}
private static void LoadConfig()
{
try
{
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");
Directory.CreateDirectory(userData);
var devPath = Path.Combine(userData, "dorknet-devmenu.json");
var legacyPath = Path.Combine(userData, "dorknet-clientmod.json");
var path = File.Exists(devPath) ? devPath : legacyPath;
if (!File.Exists(path))
{
Log.Msg($"[config] no dev config found; all dev menu features disabled");
return;
}
var doc = JsonDocument.Parse(File.ReadAllText(path));
var r = doc.RootElement;
if (r.TryGetProperty("EnableDebugConsole", out var v)) Cfg.EnableDebugConsole = v.GetBoolean();
if (r.TryGetProperty("DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
if (r.TryGetProperty("DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
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();
}
Log.Msg($"[config] loaded {Path.GetFileName(path)}: debugConsole={Cfg.EnableDebugConsole}, devWatchButton={Cfg.EnableDevWatchButton}, diagnose={Cfg.DiagnoseDevMenu}");
}
catch (Exception ex)
{
Log.Warning($"[config] load failed, using disabled defaults: {ex.Message}");
}
}
private bool TryPatchByName(string typeName, string methodName, Type[]? args = null, string? prefix = null, string? postfix = null)
{
try
{
var type = ResolveType(typeName);
if (type is null)
{
Log.Warning($"[patch-miss] {typeName}.{methodName}: type not found");
return false;
}
var method = args is null
? AccessTools.Method(type, methodName)
: AccessTools.Method(type, methodName, args);
if (method is null)
{
Log.Warning($"[patch-miss] {typeName}.{methodName}: method not found");
return false;
}
HarmonyMethod? pre = prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix));
HarmonyMethod? post = postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix));
Instance!.PatchHarmony.Patch(method, pre, post);
Log.Msg($"[patch-ok] {typeName}.{methodName}");
return true;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] {typeName}.{methodName}: {ex.GetType().Name}: {ex.Message}");
return false;
}
}
private static MethodInfo GetPatchMethod(string name)
{
foreach (var holder in new[] { typeof(DebugConsolePatches) })
{
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
if (m is not null) return m;
}
throw new MissingMethodException($"patch method '{name}' not found");
}
internal static Type? ResolveType(string requested)
{
if (ResolvedTypeCache.TryGetValue(requested, out var cached)) return cached;
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
Type? found = null;
try
{
found = asm.GetType(requested, throwOnError: false)
?? asm.GetType("Il2Cpp" + requested, throwOnError: false);
if (found is null)
{
foreach (var t in asm.GetTypes())
{
if (t.FullName == requested ||
t.Name == requested ||
t.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
t.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true)
{
found = t;
break;
}
}
}
}
catch { }
if (found is null) continue;
ResolvedTypeCache[requested] = found;
return found;
}
return null;
}
}
internal static class DebugConsolePatches
{
private static bool _consoleVisible;
private static bool _warned;
private static KeyCode _toggleKey = KeyCode.BackQuote;
private static bool _keyParsed;
private static MethodInfo? _toggleMethod;
public static bool SuppressCheatDetected_Prefix() => false;
public static void PollToggleKey()
{
try
{
if (!_keyParsed)
{
_keyParsed = true;
if (!Enum.TryParse(DevMenuMod.Cfg.DebugConsoleToggleKey, ignoreCase: true, out _toggleKey))
{
_toggleKey = KeyCode.BackQuote;
DevMenuMod.Log.Warning($"[debugconsole] invalid key '{DevMenuMod.Cfg.DebugConsoleToggleKey}', defaulting to BackQuote");
}
}
if (Input.GetKeyDown(_toggleKey))
{
Toggle();
}
}
catch (Exception ex)
{
if (!_warned)
{
_warned = true;
DevMenuMod.Log.Warning($"[debugconsole] poll failed: {ex.Message}");
}
}
}
private static void Toggle()
{
try
{
var console = UObject.FindObjectOfType<DebugConsole>();
if (console == null)
{
DevMenuMod.Log.Warning("[debugconsole] no live DebugConsole in scene");
return;
}
_toggleMethod ??= AccessTools.Method(typeof(DebugConsole), "ShowInputField", new[] { typeof(bool) })
?? AccessTools.Method(typeof(DebugConsole), "MCNBJFECHLJ", new[] { typeof(bool) })
?? FindToggleByShape(typeof(DebugConsole));
if (_toggleMethod is null)
{
DevMenuMod.Log.Warning("[debugconsole] show/hide method not found");
return;
}
_consoleVisible = !_consoleVisible;
_toggleMethod.Invoke(console, new object[] { _consoleVisible });
DevMenuMod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
}
catch (Exception ex)
{
DevMenuMod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}");
}
}
private static MethodInfo? FindToggleByShape(Type t)
{
MethodInfo? found = null;
foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
{
if (m.ReturnType != typeof(void)) continue;
var ps = m.GetParameters();
if (ps.Length != 1 || ps[0].ParameterType != typeof(bool)) continue;
if (m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
if (m.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) continue;
if (found is not null) return null;
found = m;
}
return found;
}
}
internal static class DevMenuProbe
{
private static int _frame;
private static bool _done;
public static void Tick()
{
if (_done) return;
_frame++;
if (_frame < 180) return;
_done = true;
var homeType = DevMenuMod.ResolveType("AGUI.StackedUI.HomeScreenFlow") ?? DevMenuMod.ResolveType("HomeScreenFlow");
var consoleType = DevMenuMod.ResolveType("RecRoom.Debugging.DebugConsole") ?? typeof(DebugConsole);
DevMenuMod.Log.Msg("=== [devmenu-probe] runtime dev-menu state ===");
DevMenuMod.Log.Msg($"[devmenu-probe] HomeScreenFlow type: {(homeType is null ? "NOT FOUND" : homeType.FullName)}");
DevMenuMod.Log.Msg($"[devmenu-probe] DebugConsole type: {(consoleType is null ? "NOT FOUND" : consoleType.FullName)}");
try
{
var console = UObject.FindObjectOfType<DebugConsole>();
DevMenuMod.Log.Msg($"[devmenu-probe] DebugConsole live instance: {(console == null ? "null" : "FOUND")}");
}
catch (Exception ex)
{
DevMenuMod.Log.Warning($"[devmenu-probe] console instance probe failed: {ex.Message}");
}
DevMenuMod.Log.Msg("=== [devmenu-probe] done ===");
}
}
-124
View File
@@ -1,124 +0,0 @@
// Native watch "Dev Console" injection — typed against the per-install Il2Cpp
// interop assemblies (referenced in the csproj, dev-tools only, never shipped).
// Pure reflection couldn't drive this build's watch UI (FindObjectOfType /
// property-get / GetComponentsInChildren all failed under Il2CppInterop), so
// we use real types here: clone an existing watch Button3D, relabel it, and
// wire its OnClick to run console commands via DebugConsole.Execute (which
// works headless even though the console UI isn't in the scene). It's the
// game's own Button3D, so it's interactable with the VR laser AND the
// Screen-Mode mouse. Gated behind Cfg.EnableDevWatchButton.
using System;
using Il2CppInterop.Runtime;
using Il2CppAGUI.StackedUI;
using Il2CppRecRoom.Debugging;
using Il2CppTMPro;
using UnityEngine.Events;
using UObject = UnityEngine.Object;
namespace DorkNet.DevMenu;
internal static class DevWatchButton
{
private static int _frame;
private static bool _done;
// Polled from Mod.OnUpdate while Cfg.EnableDevWatchButton is set. Waits for
// the watch's HomeScreenFlow to exist, then injects once.
public static void Tick()
{
if (_done) return;
_frame++;
if (_frame < 120 || (_frame % 30) != 0) return; // settle, then ~2/s
var home = UObject.FindObjectOfType<HomeScreenFlow>();
if (home == null && _frame < 3600) return; // wait for the watch
_done = true;
if (home == null) { DevMenuMod.Log.Warning("[devbtn] HomeScreenFlow never appeared"); return; }
try { Inject(home); }
catch (Exception ex) { DevMenuMod.Log.Warning($"[devbtn] inject failed: {ex}"); }
}
private static void Inject(HomeScreenFlow home)
{
var buttons = home.GetComponentsInChildren<Button3D>(true);
if (buttons == null || buttons.Length == 0)
{
DevMenuMod.Log.Warning("[devbtn] no Button3D found under HomeScreenFlow");
return;
}
// Prefer a source button that HAS a TMP label, so our clones can be
// relabeled (HomeButton etc. are icon-only — no text child).
Button3D? src = null;
for (int i = 0; i < buttons.Length; i++)
if (buttons[i].GetComponentInChildren<TextMeshProUGUI>(true) != null) { src = buttons[i]; break; }
bool labeled = src != null;
if (src == null) src = buttons[0]; // fall back: inject anyway, just no text
DevMenuMod.Log.Msg($"[devbtn] {buttons.Length} watch buttons; clone source='{src.name}' labeled={labeled}");
int n = 0;
foreach (var (label, cmd) in ParsePresets())
{
var clone = UObject.Instantiate(src, src.transform.parent);
clone.name = $"DorkNetDev_{label}";
clone.gameObject.SetActive(true);
clone.transform.localScale = src.transform.localScale;
var tmp = clone.GetComponentInChildren<TextMeshProUGUI>(true);
if (tmp != null) tmp.text = label;
var command = cmd; // capture per-button
clone.OnClick.RemoveAllListeners();
clone.OnClick.AddListener(DelegateSupport.ConvertDelegate<UnityAction>((Action)(() => Run(label, command))));
_clones.Add(clone);
n++;
}
DevMenuMod.Log.Msg($"[devbtn] injected {n} dev command button(s) into the watch bar — open the watch to use them");
DumpCommands();
}
// Log the real console command names from DebugConsoleCommandConfig.Metas
// so we can set DevCommands correctly (the asset loads even though the
// console component isn't in the scene).
private static void DumpCommands()
{
try
{
var cfgs = UObject.FindObjectsOfTypeAll(Il2CppType.Of<DebugConsoleCommandConfig>());
DevMenuMod.Log.Msg($"[devcmds] {cfgs.Length} DebugConsoleCommandConfig asset(s) loaded");
for (int c = 0; c < cfgs.Length; c++)
{
var cfg = cfgs[c].TryCast<DebugConsoleCommandConfig>();
var metas = cfg?.Metas;
if (metas == null) continue;
DevMenuMod.Log.Msg($"[devcmds] {metas.Count} command(s):");
for (int i = 0; i < metas.Count; i++)
{
var m = metas[i];
DevMenuMod.Log.Msg($" {m.MethodName} ({m.DeclaringType})");
}
}
}
catch (Exception ex) { DevMenuMod.Log.Warning($"[devcmds] failed: {ex.Message}"); }
}
private static void Run(string label, string command)
{
DevMenuMod.Log.Msg($"[devbtn] '{label}' -> DebugConsole.Execute(\"{command}\")");
try { DebugConsole.Execute(command); }
catch (Exception ex) { DevMenuMod.Log.Warning($"[devbtn] Execute('{command}') failed: {ex.Message}"); }
}
// Presets come from Cfg.DevCommands as "Label=command" strings.
private static System.Collections.Generic.IEnumerable<(string label, string cmd)> ParsePresets()
{
foreach (var entry in DevMenuMod.Cfg.DevCommands)
{
if (string.IsNullOrWhiteSpace(entry)) continue;
var eq = entry.IndexOf('=');
if (eq > 0) yield return (entry.Substring(0, eq).Trim(), entry.Substring(eq + 1).Trim());
else yield return (entry.Trim(), entry.Trim());
}
}
private static readonly System.Collections.Generic.List<Button3D> _clones = new();
}
-68
View File
@@ -1,68 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>DorkNet.DevMenu</RootNamespace>
<AssemblyName>DorkNet.DevMenu</AssemblyName>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
</PropertyGroup>
<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>
</PropertyGroup>
<Target Name="EnsureMelonLoaderReferences"
BeforeTargets="ResolveAssemblyReferences"
Condition="!Exists('$(MelonLoaderDir)\net6\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)\net6\MelonLoader.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="0Harmony">
<HintPath>$(MelonLoaderDir)\net6\0Harmony.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Il2CppInterop.Runtime">
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Runtime.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Il2CppInterop.Common">
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Common.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<PropertyGroup>
<Il2CppAsmDir Condition="'$(Il2CppAsmDir)' == ''">C:\Users\Alexa\Downloads\DorkRoom-2020.12\MelonLoader\Il2CppAssemblies</Il2CppAsmDir>
</PropertyGroup>
<ItemGroup Condition="Exists('$(Il2CppAsmDir)')">
<Reference Include="Assembly-CSharp"><HintPath>$(Il2CppAsmDir)\Assembly-CSharp.dll</HintPath><Private>false</Private></Reference>
<Reference Include="Il2Cppmscorlib"><HintPath>$(Il2CppAsmDir)\Il2Cppmscorlib.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.CoreModule"><HintPath>$(Il2CppAsmDir)\UnityEngine.CoreModule.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.UI"><HintPath>$(Il2CppAsmDir)\UnityEngine.UI.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.InputLegacyModule"><HintPath>$(Il2CppAsmDir)\UnityEngine.InputLegacyModule.dll</HintPath><Private>false</Private></Reference>
<Reference Include="Unity.TextMeshPro"><HintPath>$(Il2CppAsmDir)\Unity.TextMeshPro.dll</HintPath><Private>false</Private></Reference>
<Reference Include="Il2CppRecRoom.Promises.Runtime"><HintPath>$(Il2CppAsmDir)\Il2CppRecRoom.Promises.Runtime.dll</HintPath><Private>false</Private></Reference>
<Reference Include="Il2CppRecRoom.Datastructures.Runtime"><HintPath>$(Il2CppAsmDir)\Il2CppRecRoom.Datastructures.Runtime.dll</HintPath><Private>false</Private></Reference>
</ItemGroup>
</Project>
@@ -1,11 +0,0 @@
{
"EnableDebugConsole": false,
"DebugConsoleToggleKey": "BackQuote",
"DiagnoseDevMenu": false,
"EnableDevWatchButton": false,
"DevCommands": [
"Help=help",
"Fly=fly",
"NoClip=noclip"
]
}
-18
View File
@@ -1,18 +0,0 @@
<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>
-392
View File
@@ -1,392 +0,0 @@
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();
var publicHosts = BuildPublicHosts(apexDomain);
app.Use(async (ctx, next) =>
{
if (DorkNetRouteOwnership.IsInfrastructurePath(ctx.Request.Path.Value ?? "/")
|| IsAllowedPublicHost(ctx.Request.Host.Host, publicHosts))
{
await next();
return;
}
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
await ctx.Response.WriteAsync("Unknown DorkNet host.");
});
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 HashSet<string> BuildPublicHosts(string apexDomain)
{
var hosts = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
apexDomain,
"localhost",
"127.0.0.1",
};
foreach (var subdomain in DorkNetRouteOwnership.PublicSubdomains)
{
hosts.Add(SubdomainHost(subdomain, apexDomain));
}
return hosts;
}
static bool IsAllowedPublicHost(string? host, HashSet<string> publicHosts)
{
if (string.IsNullOrWhiteSpace(host))
{
return true;
}
return publicHosts.Contains(host.Split(':', 2)[0].Trim());
}
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 },
},
});
}
-23
View File
@@ -1,23 +0,0 @@
{
"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"
}
}
}
-7
View File
@@ -81,13 +81,6 @@ public class RecNetAccount
// Likewise.
[JsonPropertyName("hasBirthday")]
public bool HasBirthday { get; set; } = true;
// 2020.12 Account.Deserialize reads this key (verified via Cpp2IL ISIL
// dump of CCEOLAOLEKJ.PPGFHEDFBEA — the obfuscated Account class). The
// 2020.03 client doesn't read it, but emitting it is harmless there.
// Missing → LitJson throws and 2020.12 surfaces "Malformed Response".
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>
+2 -2
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -1,26 +0,0 @@
<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>
@@ -1,75 +0,0 @@
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"] = "december_2020_12_18",
["DorkNet:SupportedVersions:0"] = "december_2020_12_18",
});
});
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.
}
}
}
@@ -1,222 +0,0 @@
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);
}
}
@@ -1,411 +0,0 @@
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/2020.12.18");
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
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";
}
-67
View File
@@ -1,67 +0,0 @@
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/2020.12.18");
tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
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/2020.12.18");
userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
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);
}
}
@@ -1,56 +0,0 @@
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 December_2020_build_is_valid_with_minimal_service_config()
{
var controller = CreateController();
var result = Assert.IsType<OkObjectResult>(controller.Check("20201210", "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)
{
"december_2020_12_18",
},
"december_2020_12_18");
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();
}
}
@@ -1,590 +0,0 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using Open.IdentityServer;
using Open.IdentityServer.Models;
using Open.IdentityServer.Services;
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>();
}
}
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)
{
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 next(context);
}
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 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;
}
}
-28
View File
@@ -1,28 +0,0 @@
namespace DorkNet.Server.Compat;
/// <summary>Identifies a specific Rec Room client build.
/// <para>
/// <see cref="Key"/> is the wire-format identifier the client mod
/// stamps on every request via the <c>X-DorkNet-Version</c> header.
/// Format is <c>{branch}_{semver}</c>: <c>march_2020_03_10</c>,
/// <c>december_2020_12_18</c>. Keep it lowercase, underscore-separated;
/// it shows up in admin logs and error responses.
/// </para>
/// <para>
/// <see cref="Generation"/> is the wire-format-compatibility group:
/// every version inside the same generation shares the same wire
/// shapes (JSON DTO casing, route names, etc.) and therefore the same
/// strategy implementations. Adding a new build that's protocol-compatible
/// with an existing generation = a one-line registration; only a new
/// generation entails new strategy code.
/// </para>
/// </summary>
public sealed record ClientVersion(string Key, string Generation)
{
/// <summary>Sentinel returned when a request has no
/// <c>X-DorkNet-Version</c> header AND no
/// <c>DorkNet:DefaultClientVersion</c> is configured. Controllers
/// can branch on this to either fall through to a sensible default
/// or 426 the request.</summary>
public static readonly ClientVersion Unknown = new("unknown", "unknown");
}
@@ -1,29 +0,0 @@
using Microsoft.AspNetCore.Http;
namespace DorkNet.Server.Compat;
/// <summary>Helpers to read the active <see cref="ClientVersion"/>
/// off the current request. The version is set by
/// <c>VersionDetectionMiddleware</c> early in the pipeline so any
/// downstream consumer (controllers, services, filters) can resolve
/// it without re-parsing the header.</summary>
public static class HttpContextExtensions
{
private const string ClientVersionKey = "DorkNet.ClientVersion";
/// <summary>Stamp the resolved version on the request. Called by
/// <c>VersionDetectionMiddleware</c>; not for general use.</summary>
internal static void SetClientVersion(this HttpContext ctx, ClientVersion v)
=> ctx.Items[ClientVersionKey] = v;
/// <summary>The client version for this request. Returns
/// <see cref="ClientVersion.Unknown"/> if the middleware hasn't
/// run (e.g. a unit test that calls a controller method directly)
/// rather than null, so callers can safely branch on
/// <c>Generation</c> without null-checks.</summary>
public static ClientVersion ClientVersion(this HttpContext ctx)
=> ctx.Items.TryGetValue(ClientVersionKey, out var v)
&& v is ClientVersion cv
? cv
: Compat.ClientVersion.Unknown;
}
-39
View File
@@ -1,39 +0,0 @@
namespace DorkNet.Server.Compat;
/// <summary>One client-version-family plugin. Each implementation
/// declares which exact version <see cref="VersionKeys"/> it handles
/// and which protocol-compatibility <see cref="Generation"/> they map
/// to. The plugin's job at registration time is to also wire any
/// generation-specific strategy services into DI via
/// <see cref="RegisterStrategies"/> so controllers / services
/// downstream can resolve the right <c>IWireFormat</c>,
/// <c>IRouteOverride</c>, etc. for the active request's generation.
///
/// <para>Adding a new version with the SAME wire shapes as an existing
/// generation = just listing its key in that generation's plugin
/// <see cref="VersionKeys"/>. Only a new generation needs new
/// strategy classes.</para>
/// </summary>
public interface IVersionPlugin
{
/// <summary>All client-build keys this plugin handles. Multiple
/// keys can share one plugin when several Rec Room builds shipped
/// the same wire format. Convention: lowercase, underscore-separated,
/// matching the literal the client mod sends in
/// <c>X-DorkNet-Version</c>.</summary>
IReadOnlyCollection<string> VersionKeys { get; }
/// <summary>Wire-format-compatibility group this plugin implements.
/// Multiple plugins can share a generation (rare, but allowed when
/// e.g. a Quest build and a PC build of the same era are
/// protocol-identical and we just want separate
/// <see cref="VersionKeys"/> bookkeeping).</summary>
string Generation { get; }
/// <summary>Register any generation-specific strategy services
/// into DI. Called once at startup by the bootstrap code in
/// <c>Program.cs</c>. Use keyed registrations (keyed by
/// <see cref="Generation"/>) so requests for other generations
/// don't collide.</summary>
void RegisterStrategies(IServiceCollection services);
}
@@ -1,144 +0,0 @@
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using DorkNet.Server.Services;
namespace DorkNet.Server.Compat;
/// <summary>Extracts the client version from <c>X-DorkNet-Version</c>
/// on every request, validates it against this deployment's allow-list,
/// and either:
/// <list type="bullet">
/// <item>Stamps <see cref="HttpContextExtensions.ClientVersion"/> with
/// the resolved <see cref="ClientVersion"/> and forwards the request, OR</item>
/// <item>Returns <c>HTTP 426 Upgrade Required</c> with a JSON body
/// describing the mismatch when the client's version isn't in
/// <see cref="VersionRegistry.SupportedVersionKeys"/>.</item>
/// </list>
///
/// <para>Headerless requests fall back to
/// <see cref="VersionRegistry.DefaultVersionKey"/> when set. That's
/// the transition pathway — once every shipped client mod stamps the
/// header, set <c>DorkNet:DefaultClientVersion</c> to empty and
/// headerless requests get rejected too.</para>
///
/// <para>Some routes intentionally don't carry a client identity
/// (the public-facing site at the apex, the admin SPA at
/// <c>admin.{apex}</c>, health checks). Those are excluded by host
/// + path prefix below — they always skip the version gate and
/// receive <see cref="ClientVersion.Unknown"/>. The apex is whatever
/// <see cref="DomainConfig.Apex"/> resolved to at startup, so forks
/// running on a different domain get the same exemption automatically.</para>
/// </summary>
public sealed class VersionDetectionMiddleware(
RequestDelegate next,
VersionRegistry registry,
DomainConfig domain,
ILogger<VersionDetectionMiddleware> logger)
{
private const string HeaderName = "X-DorkNet-Version";
public async Task InvokeAsync(HttpContext ctx)
{
if (IsExempt(ctx))
{
ctx.SetClientVersion(ClientVersion.Unknown);
await next(ctx);
return;
}
var headerVal = ctx.Request.Headers[HeaderName].ToString();
var versionKey = string.IsNullOrWhiteSpace(headerVal)
? registry.DefaultVersionKey
: headerVal.Trim();
// No header AND no default configured — reject. Once every
// client mod sends the header this is the path that catches
// probes / misconfigured clients.
if (string.IsNullOrWhiteSpace(versionKey))
{
await WriteMismatch(ctx, "client_version_missing", got: null);
return;
}
var resolved = registry.Resolve(versionKey);
if (resolved == ClientVersion.Unknown)
{
// The client claims a version but no plugin knows it. That
// means either a typo in the header or a build of Rec Room
// we haven't onboarded yet. Either way, reject — server
// can't reliably serve a version it doesn't have a
// strategy bundle for.
await WriteMismatch(ctx, "client_version_unknown", got: versionKey);
return;
}
if (!registry.IsSupported(versionKey))
{
// Known version, but this specific deployment is
// configured to accept a different set. e.g. a March
// client hitting a December-only server.
await WriteMismatch(ctx, "client_version_not_accepted", got: versionKey);
return;
}
ctx.SetClientVersion(resolved);
await next(ctx);
}
private bool IsExempt(HttpContext ctx)
{
// Hosts that don't serve the watch — these are humans on a
// browser or admin tooling, not the game client.
var host = ctx.Request.Host.Host.ToLowerInvariant();
var apex = domain.Apex.ToLowerInvariant();
if (host == apex || host == "www." + apex) return true;
if (host.StartsWith("admin.")) return true;
if (host.StartsWith("site.")) return true;
if (host.StartsWith("feed.")) return true;
// Server health / readiness probes.
var path = ctx.Request.Path.Value;
if (path is null) return false;
if (path.StartsWith("/health", StringComparison.OrdinalIgnoreCase)) return true;
if (path.StartsWith("/api/admin/", StringComparison.OrdinalIgnoreCase)) return true;
if (path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase)) return true;
// The boot-time version gate itself — the whole POINT of this
// endpoint is to receive a mismatched-version client and reply
// with the watch's native "Update Required" payload. Gating it
// here would 426 the request before VersionCheckController
// could respond.
if (path.StartsWith("/api/versioncheck/", StringComparison.OrdinalIgnoreCase)) return true;
return false;
}
private async Task WriteMismatch(HttpContext ctx, string error, string? got)
{
logger.LogWarning(
"[version-gate] rejecting request path={Path} host={Host} reason={Reason} got={Got} supported=[{Supported}]",
ctx.Request.Path, ctx.Request.Host.Host, error, got ?? "(none)",
string.Join(",", registry.SupportedVersionKeys));
ctx.Response.StatusCode = StatusCodes.Status426UpgradeRequired;
ctx.Response.ContentType = "application/json; charset=utf-8";
var body = new
{
error,
got,
supported = registry.SupportedVersionKeys.ToArray(),
message = error switch
{
"client_version_missing" =>
"Client did not send an X-DorkNet-Version header. Update your DorkNet client mod.",
"client_version_unknown" =>
$"Client claims version '{got}', which no version plugin in this server recognises.",
"client_version_not_accepted" =>
$"Client version '{got}' is known but this deployment is configured to accept only: " +
string.Join(", ", registry.SupportedVersionKeys) + ".",
_ => error,
},
};
await JsonSerializer.SerializeAsync(ctx.Response.Body, body);
}
}
-69
View File
@@ -1,69 +0,0 @@
namespace DorkNet.Server.Compat;
/// <summary>Resolves a client-build key (e.g. <c>december_2020_12_18</c>)
/// to its <see cref="ClientVersion"/>. Built once at startup from all
/// registered <see cref="IVersionPlugin"/> instances.
///
/// <para>Singleton — the version-key → generation mapping is fixed at
/// boot time, and the registry is read on every request by the
/// version-detection middleware.</para>
/// </summary>
public sealed class VersionRegistry
{
private readonly Dictionary<string, ClientVersion> _byKey;
/// <summary>The set of version keys this deployment is willing to
/// serve. Comes from <c>DorkNet:SupportedVersions</c> in config.
/// Anything else gets a 426 from the middleware.</summary>
public IReadOnlySet<string> SupportedVersionKeys { get; }
/// <summary>Falls back to this when a request arrives without an
/// <c>X-DorkNet-Version</c> header — set via
/// <c>DorkNet:DefaultClientVersion</c> in config. Empty/null means
/// "no fallback; reject headerless requests". Set during the
/// transition window where the old client mod doesn't yet stamp
/// the header.</summary>
public string? DefaultVersionKey { get; }
public VersionRegistry(
IEnumerable<IVersionPlugin> plugins,
IReadOnlySet<string> supportedVersionKeys,
string? defaultVersionKey)
{
_byKey = new Dictionary<string, ClientVersion>(StringComparer.OrdinalIgnoreCase);
foreach (var plugin in plugins)
{
foreach (var key in plugin.VersionKeys)
{
if (_byKey.ContainsKey(key))
throw new InvalidOperationException(
$"VersionPlugin collision: '{key}' is claimed by more than one IVersionPlugin. " +
$"Each version key maps to exactly one plugin.");
_byKey[key] = new ClientVersion(key, plugin.Generation);
}
}
SupportedVersionKeys = supportedVersionKeys;
DefaultVersionKey = defaultVersionKey;
}
/// <summary>Resolve a version key (case-insensitive) to its
/// <see cref="ClientVersion"/>, or <see cref="ClientVersion.Unknown"/>
/// if no plugin claims it. The middleware decides whether unknown
/// → reject or → fall back to <see cref="DefaultVersionKey"/>.</summary>
public ClientVersion Resolve(string? key)
{
if (string.IsNullOrWhiteSpace(key)) return ClientVersion.Unknown;
return _byKey.TryGetValue(key, out var v) ? v : ClientVersion.Unknown;
}
/// <summary>True when the given version key is in
/// <see cref="SupportedVersionKeys"/>. Used by the middleware to
/// 426 cross-version connection attempts (e.g. a March client
/// hitting a server configured for December only).</summary>
public bool IsSupported(string key)
=> SupportedVersionKeys.Contains(key);
/// <summary>Every known plugin-registered version key, for
/// diagnostic responses and admin tooling.</summary>
public IEnumerable<ClientVersion> AllKnown => _byKey.Values;
}
@@ -128,8 +128,6 @@ public class AccountApiController(
var name = (displayName ?? value ?? string.Empty).Trim();
if (name.Length == 0) return BadRequest(new RecNetResult { Success = false, Error = "empty" });
await playerService.UpdateDisplayNameAsync(p.Id, name);
var updated = await playerService.GetByIdAsync(p.Id);
if (updated is not null) await notifications.AccountChanged(updated);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
@@ -155,7 +153,6 @@ public class AccountApiController(
if (taken) return Ok(new RecNetResult { Success = false, Error = "username_taken" });
p.Username = newName;
await db.SaveChangesAsync();
await notifications.AccountChanged(p);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
@@ -201,12 +198,12 @@ public class AccountApiController(
[HttpPost("api/account/v1/profileimage")]
[HttpPost("api/account/me/profileimage")]
[HttpPost("account/me/profileimage", Order = 10)]
[HttpPost("profileimage", Order = 10)]
[HttpPost("account/me/profileimage")]
[HttpPost("profileimage")]
[HttpPut("api/account/v1/profileimage")]
[HttpPut("api/account/me/profileimage")]
[HttpPut("account/me/profileimage", Order = 10)]
[HttpPut("profileimage", Order = 10)]
[HttpPut("account/me/profileimage")]
[HttpPut("profileimage")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetProfileImage(
[FromForm(Name = "imageName")] string? imageName,
@@ -225,10 +222,44 @@ public class AccountApiController(
"[api-account] profile image set: player={PlayerId} -> {Name}",
player.Id,
player.ProfileImageName ?? "<cleared>");
await notifications.AccountChanged(player);
await NotifyAccountImageChangedAsync(player);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
private Task NotifyAccountImageChangedAsync(PlayerEntity player)
{
var account = new RecNetAccount
{
AccountId = (int)player.Id,
RawUsername = player.Username,
Username = player.Username,
DisplayName = player.DisplayName ?? player.Username,
ProfileImage = player.ProfileImageName ?? string.Empty,
TreatAsJunior = player.IsJunior,
HasBirthday = true,
Platforms = 1,
};
var selfAccount = new RecNetSelfAccount
{
AccountId = account.AccountId,
RawUsername = account.RawUsername,
Username = account.Username,
DisplayName = account.DisplayName,
ProfileImage = account.ProfileImage,
TreatAsJunior = account.TreatAsJunior,
HasBirthday = account.HasBirthday,
Platforms = account.Platforms,
Email = player.Email ?? string.Empty,
Phone = player.Phone ?? string.Empty,
Birthday = player.Birthday,
JuniorState = player.IsJunior ? 1 : 0,
ParentAccountId = null,
};
return Task.WhenAll(
notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account),
notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
}
[HttpGet("api/account/v1/namegen")]
[HttpGet("api/account/v1/namegeneration")]
public IActionResult NameGen() => Ok(new
@@ -198,32 +198,6 @@ public class AvatarGiftsController(
}
}
if (!string.IsNullOrWhiteSpace(gift.EquipmentPrefabName) ||
!string.IsNullOrWhiteSpace(gift.EquipmentModificationGuid))
{
var itemSlug = !string.IsNullOrWhiteSpace(gift.EquipmentModificationGuid)
? StoreService.EquipmentSkinSlug(gift.EquipmentPrefabName, gift.EquipmentModificationGuid)
: gift.EquipmentPrefabName;
if (!string.IsNullOrWhiteSpace(itemSlug))
{
var row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == itemSlug);
if (row is null)
{
db.PlayerInventory.Add(new PlayerInventoryEntity
{
PlayerId = pid,
ItemSlug = itemSlug,
Quantity = 1,
});
}
else
{
row.Quantity = Math.Max(row.Quantity, 1);
}
}
}
// Apply currency / XP / level rewards bundled with the gift.
// The previous implementation only handled the avatar item path,
// so token gifts from the admin SPA marked themselves consumed
@@ -278,17 +252,7 @@ public class AvatarGiftsController(
_ => 50, // Legendary
};
// Wire shape verified against the 2020.12 watch's
// NLEKGNENMCO+LOCNECLOHCA gift DTO deserializer (Cpp2IL ISIL
// NLEKGNENMCO_NestedType_LOCNECLOHCA.txt:792-946). All keys read
// via Util.GetKey<T> (mandatory): Id, Message, FromPlayerId,
// AvatarItemType, AvatarItemDesc, ConsumableItemDesc,
// EquipmentPrefabName, EquipmentModificationGuid, Platform,
// PlatformsToSpawnOn, BalanceType, CurrencyType, Currency, Xp,
// GiftRarity, GiftContext. Missing any one → KeyNotFoundException
// surfaces as "Failed to download gifts: Malformed Response" from
// BootSequence and BLOCKS LOGIN.
public static object ToWire(GiftPackageEntity g) => new
private static object ToWire(GiftPackageEntity g) => new
{
g.Id,
g.FromPlayerId,
@@ -306,23 +270,6 @@ public class AvatarGiftsController(
g.GiftRarity,
g.Message,
g.Platform,
// PlatformsToSpawnOn — required by the watch's gift DTO
// deserializer. This is a SINGLE int (4-byte field at offset
// 0x6C in LOCNECLOHCA, separate enum from Platform itself).
// The watch reads it with strict Util.GetKey<int>, so sending
// an empty array crashes Convert.ChangeType([], typeof(int))
// with "Object must implement IConvertible". -1 mirrors the
// sibling Platform field's "every platform" sentinel — no
// platform-gated gift restrictions on a private server.
PlatformsToSpawnOn = -1,
// BalanceType — required mandatory key on the gift DTO. The
// enum identifies whether the gift's payload is a Currency
// balance update (0) vs Inventory item (1) vs XP (2). For
// currency-bearing gifts we'd send 0; the safe default for
// our seed gifts (which are item-only) is 0 too since the
// watch only reads it to dispatch the balance-update side
// and we never carry one. Wrong type doesn't crash.
BalanceType = 0,
g.PackageMaterial,
g.PackageVariant,
g.Consumed,
@@ -85,16 +85,8 @@ 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);
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 30), 1, 120);
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 4), 1, 20);
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>());
@@ -104,7 +96,7 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
.Select(a => new { a.InventoryJson, a.OutfitSelections })
.FirstOrDefaultAsync();
var owned = ParseInventoryEntries(avatar?.InventoryJson);
var owned = ParseInventory(avatar?.InventoryJson);
var ownedHairDyeSlugs = await db.PlayerInventory
.Where(i => i.PlayerId == me && i.Quantity > 0)
.Select(i => i.ItemSlug)
@@ -117,54 +109,28 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
var catalog = _catalog.Value;
var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections);
// Recency tiebreaker: GrantWardrobeAsync / gift-consume APPEND new
// GUIDs to the end of InventoryJson, so the original index is also
// the purchase order. When per-slot truncation has to drop entries,
// we want it to drop the OLDEST (starter seed) items rather than the
// one the user just bought.
var ordered = owned
.Select((entry, idx) => (Entry: entry, OriginalIndex: idx))
.OrderByDescending(x => equipped.Contains(x.Entry.BaseGuid))
.ThenBy(x => catalog.SlotByGuid.TryGetValue(x.Entry.BaseGuid, out var slot) ? slot : int.MaxValue)
.ThenByDescending(x => x.OriginalIndex)
.Select(x => x.Entry);
.OrderByDescending(guid => equipped.Contains(guid))
.ThenBy(guid => catalog.SlotByGuid.TryGetValue(guid, out var slot) ? slot : int.MaxValue)
.ThenBy(guid => guid, StringComparer.OrdinalIgnoreCase);
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count);
var perSlot = new Dictionary<int, int>();
var result = new List<UnlockedAvatarItemDto>(Math.Min(owned.Count, maxItems));
foreach (var entry in ordered)
foreach (var guid in ordered)
{
if (result.Count >= maxOutfitItems) break;
// Intersect against the safelist. Items not in the bundled
// lookup would crash the watch's wardrobe parser, so they're
// silently dropped here. The catalog is keyed by the BARE GUID,
// so colored variants (stored as "{guid},{swatch},{mask},") must
// look up by BaseGuid — keying by the full comma string would
// miss and silently drop every owned colored item (the contest
// hat "I bought it but it never shows in my wardrobe" bug).
if (!catalog.Items.TryGetValue(entry.BaseGuid, out var item)) continue;
// silently dropped here.
if (!catalog.Items.TryGetValue(guid, out var item)) continue;
var slot = catalog.SlotByGuid.TryGetValue(entry.BaseGuid, out var value) ? value : -1;
var slot = catalog.SlotByGuid.TryGetValue(guid, out var value) ? value : -1;
perSlot.TryGetValue(slot, out var count);
if (count >= maxPerSlot && !equipped.Contains(entry.BaseGuid)) continue;
if (count >= maxPerSlot && !equipped.Contains(guid)) continue;
perSlot[slot] = count + 1;
// Preserve the bought color: a bare entry (FullDesc == BaseGuid)
// emits the catalog DTO as-is ("{guid},,,"); a colored entry
// overrides AvatarItemDesc with the stored swatch/mask desc so
// the watch renders the variant the player paid for instead of
// falling back to the default swatch.
result.Add(string.Equals(entry.FullDesc, entry.BaseGuid, StringComparison.OrdinalIgnoreCase)
? item
: new UnlockedAvatarItemDto
{
AvatarItemType = item.AvatarItemType,
AvatarItemDesc = ToWatchDesc(entry.FullDesc),
PlatformMask = item.PlatformMask,
FriendlyName = item.FriendlyName,
Tooltip = item.Tooltip,
Rarity = item.Rarity,
});
result.Add(item);
if (result.Count >= maxItems) break;
}
@@ -200,49 +166,6 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
public static IReadOnlyDictionary<string, UnlockedAvatarItemDto> Catalog
=> _catalog.Value.Items;
/// <summary>GET <c>api/avatar/v1/lockeditems</c> — metadata for
/// specific avatar item descriptions the caller does not own. The
/// store UI calls this with item descs from purchasable rows and
/// expects the same item-detail shape used by unlocked items,
/// minus ownership. We resolve against the safe runtime catalog so
/// unknown GUIDs never reach the client parser.</summary>
[HttpGet("api/avatar/v1/lockeditems")]
[AllowAnonymous]
public async Task<IActionResult> LockedItems()
{
var requested = RequestedAvatarItemGuids().ToList();
if (requested.Count == 0) return Ok(Array.Empty<object>());
var owned = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var pid = this.CurrentPlayerId();
if (pid is long me)
{
var invJson = await db.Avatars
.Where(a => a.PlayerId == me)
.Select(a => a.InventoryJson)
.FirstOrDefaultAsync();
foreach (var guid in ParseInventory(invJson))
owned.Add(guid);
}
var catalog = _catalog.Value.Items;
var rows = requested
.Where(guid => !owned.Contains(guid))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(guid => catalog.TryGetValue(guid, out var item) ? item : null)
.Where(item => item is not null)
.Select(item => new
{
item!.AvatarItemType,
item.AvatarItemDesc,
item.FriendlyName,
item.Tooltip,
item.Rarity,
})
.ToArray();
return Ok(rows);
}
/// <summary>Per-OutfitType slot index → list of GUIDs in that slot.
/// Used by <see cref="DorkNet.Server.Services.PlayerService"/> to
/// build a starter loadout (one per slot) and a starter inventory
@@ -253,103 +176,47 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
public static bool TryGetOutfitType(string guid, out int outfitType) =>
_catalog.Value.SlotByGuid.TryGetValue(guid, out outfitType);
private IEnumerable<string> RequestedAvatarItemGuids()
private static List<string> ParseInventory(string? json)
{
foreach (var pair in Request.Query)
foreach (var raw in pair.Value)
foreach (var part in (raw ?? string.Empty).Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var value = part.Trim();
if (string.IsNullOrWhiteSpace(value)) continue;
var comma = value.IndexOf(',');
if (comma >= 0) value = value[..comma];
if (Guid.TryParse(value, out _)) yield return value;
}
}
/// <summary>An owned wardrobe entry: the bare AvatarItem GUID used as
/// the catalog / slot / equipped key, plus the full RecNet desc exactly
/// as stored in InventoryJson. For a plain item the two are identical;
/// for a colored variant the desc is "{guid},{swatch},{mask}," so the
/// swatch the player paid for survives the round-trip to the wardrobe.</summary>
private readonly record struct OwnedEntry(string BaseGuid, string FullDesc);
/// <summary>Parse InventoryJson into normalized owned entries. Accepts
/// both the plain JSON string array of descs (the shape
/// GrantWardrobeAsync / PurchaseAsync write) and the legacy
/// [{itemId,...}] object shape. Strips a leading "wardrobe-" slug
/// prefix, derives the bare GUID (substring before the first comma) for
/// catalog lookups, and keeps the full desc so a colored variant keeps
/// its swatch. Dedupes by full desc, so multiple colors of the same
/// base item remain distinct wardrobe tiles while exact duplicates
/// collapse.</summary>
private static List<OwnedEntry> ParseInventoryEntries(string? json)
{
if (string.IsNullOrWhiteSpace(json) || json == "[]") return new();
var raw = new List<string>();
if (string.IsNullOrWhiteSpace(json)) return new();
try
{
var strings = JsonSerializer.Deserialize<List<string>>(json);
if (strings is not null) raw.AddRange(strings);
if (strings is not null) return strings;
}
catch { }
if (raw.Count == 0)
try
{
try
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new();
var result = new List<string>();
foreach (var element in doc.RootElement.EnumerateArray())
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new();
foreach (var element in doc.RootElement.EnumerateArray())
{
if (element.ValueKind == JsonValueKind.String)
raw.Add(element.GetString() ?? string.Empty);
else if (element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty("itemId", out var itemId) &&
itemId.ValueKind == JsonValueKind.String)
raw.Add(itemId.GetString() ?? string.Empty);
}
string? value = null;
if (element.ValueKind == JsonValueKind.String)
value = element.GetString();
else if (element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty("itemId", out var itemId) &&
itemId.ValueKind == JsonValueKind.String)
value = itemId.GetString();
if (string.IsNullOrWhiteSpace(value)) continue;
const string wardrobePrefix = "wardrobe-";
if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal))
value = value[wardrobePrefix.Length..];
var comma = value.IndexOf(',');
if (comma >= 0)
value = value[..comma];
result.Add(value);
}
catch { return new(); }
return result
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
const string wardrobePrefix = "wardrobe-";
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var result = new List<OwnedEntry>();
foreach (var entry in raw)
{
var value = entry;
if (string.IsNullOrWhiteSpace(value)) continue;
if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal))
value = value[wardrobePrefix.Length..];
var comma = value.IndexOf(',');
var baseGuid = comma >= 0 ? value[..comma] : value;
if (string.IsNullOrWhiteSpace(baseGuid)) continue;
if (!seen.Add(value)) continue;
result.Add(new OwnedEntry(baseGuid, value));
}
return result;
}
/// <summary>Bare-GUID view of the owned set, for callers that only need
/// ownership membership (e.g. the locked-items diff), not the swatch.</summary>
private static List<string> ParseInventory(string? json) =>
ParseInventoryEntries(json)
.Select(e => e.BaseGuid)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
/// <summary>Ensure a stored desc carries the ≥4 comma-separated parts
/// (guid + 3 customizations) that the watch's
/// <c>AvatarItem.FromRecNetString</c> requires — anything shorter throws
/// inside the wardrobe parser. Stored colored descs are already
/// "{guid},{swatch},{mask}," (3 commas); this pads any shorter legacy
/// value.</summary>
private static string ToWatchDesc(string storedDesc)
{
var commas = storedDesc.Count(c => c == ',');
return commas >= 3 ? storedDesc : storedDesc + new string(',', 3 - commas);
catch { return new(); }
}
private static HashSet<string> ParseEquippedItemGuids(string? outfitSelections)
@@ -415,13 +282,10 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
{
if (string.IsNullOrEmpty(row.Guid)) continue;
safe.Add(row.Guid);
// Prefer the baked display_name from the JSON if present
// (tools/add-display-names.py writes it once per refresh).
// Falls back to the raw-name cleanup if the field is missing
// — e.g. partial dumps or older fixture files.
var pretty = !string.IsNullOrEmpty(row.DisplayName)
? row.DisplayName
: PrettyName(row.Name);
// Rec Room's bundled item names are wrapped in parens like
// "(Hat_Wizard)" — strip those and the underscores for a
// friendlier UI label.
var pretty = PrettyName(row.Name);
items[row.Guid] = new UnlockedAvatarItemDto
{
// AvatarItemType is the wire enum the watch parses
@@ -485,22 +349,11 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
{
[JsonPropertyName("guid")] public string Guid { get; set; } = string.Empty;
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
// Algorithmically derived from `name` by
// tools/add-display-names.py — Rec Room's official UI strings
// live in Unity asset bundles we can't read from a code dump,
// so this is a best-effort cleanup. Optional — server falls
// back to PrettyName() if missing.
[JsonPropertyName("display_name")] public string? DisplayName { get; set; }
[JsonPropertyName("outfit_type")] public int OutfitType { get; set; }
[JsonPropertyName("outfit_set")] public int OutfitSet { get; set; }
[JsonPropertyName("drawer_index")] public int DrawerIndex { get; set; }
[JsonPropertyName("hides_hair")] public bool HidesHair { get; set; }
[JsonPropertyName("hides_hand")] public bool HidesHand { get; set; }
// hides_left_ear / hides_right_ear were added to AvatarItemData
// in the 2020.12.18 client build. Optional in the JSON so older
// dumps still deserialize.
[JsonPropertyName("hides_left_ear")] public bool HidesLeftEar { get; set; }
[JsonPropertyName("hides_right_ear")] public bool HidesRightEar { get; set; }
[JsonPropertyName("uses_hair_color")] public bool UsesHairColor { get; set; }
[JsonPropertyName("uses_team_color")] public bool UsesTeamColor { get; set; }
[JsonPropertyName("is_team_item")] public bool IsTeamItem { get; set; }
@@ -67,19 +67,9 @@ public class CommunityBoardController(
.Where(r => ids.Contains(r.Id))
.Select(r => new { r.Id, r.Name, r.ImageName })
.ToDictionaryAsync(r => r.Id);
// 2020.12 FeaturedRoomGroup deserializer (NMPFCIJPODA.PPGFHEDFBEA)
// reads required keys: FeaturedRoomGroupId, Name, Rooms (NOT
// FeaturedRooms). Missing/renamed → KeyNotFoundException →
// breaks the boot promise chain on the community-board fetch.
featuredRoomGroup = new
{
FeaturedRoomGroupId = 0L,
Name = group.Name ?? string.Empty,
Rooms = ids.Select(id => rooms.TryGetValue(id, out var r)
? new { RoomName = r.Name, RoomId = r.Id, ImageName = r.ImageName ?? string.Empty }
: new { RoomName = string.Empty, RoomId = id, ImageName = string.Empty })
.ToArray(),
// Keep the old key around for any 2020.03 clients hitting prod.
FeaturedRooms = ids.Select(id => rooms.TryGetValue(id, out var r)
? new { RoomName = r.Name, RoomId = r.Id, ImageName = r.ImageName ?? string.Empty }
: new { RoomName = string.Empty, RoomId = id, ImageName = string.Empty })
@@ -1,157 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Compatibility;
[ApiController]
public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBase
{
[HttpPost("api/banappeal/generateCode")]
[Authorize]
public async Task<IActionResult> GenerateBanAppealCode()
{
var pid = this.RequireCurrentPlayerId();
var code = $"BA-{RandomNumberGenerator.GetInt32(100000, 999999)}";
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = pid,
Key = $"banappeal:{DateTime.UtcNow:yyyyMMddHHmmss}",
Value = code,
});
await db.SaveChangesAsync();
return Ok(new { value = code });
}
[HttpPost("api/CampusCard/v1/UpdateAndGetSubscription")]
[Authorize]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> UpdateAndGetCampusCardSubscription()
{
var pid = this.RequireCurrentPlayerId();
var req = await ReadCampusCardRequestAsync();
var targetId = req.PlatformAccountSubscribedPlayerId > 0
? req.PlatformAccountSubscribedPlayerId
: pid;
if (targetId != pid && req.Subscription)
{
var exists = await db.Subscriptions.AnyAsync(s =>
s.SubscriberPlayerId == pid && s.TargetPlayerId == targetId);
if (!exists)
{
db.Subscriptions.Add(new SubscriptionEntity
{
SubscriberPlayerId = pid,
TargetPlayerId = targetId,
});
await db.SaveChangesAsync();
}
}
else if (targetId != pid && !req.Subscription)
{
await db.Subscriptions
.Where(s => s.SubscriberPlayerId == pid && s.TargetPlayerId == targetId)
.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
{
subscription = (object?)null,
platformAccountSubscribedPlayerId = targetId,
});
}
[HttpPost("api/clubreporting/v1/report")]
[Authorize]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> ClubReport()
{
var pid = this.RequireCurrentPlayerId();
var req = await ReadClubReportAsync();
var club = req.ClubId > 0
? await db.Clubs.FirstOrDefaultAsync(c => c.Id == req.ClubId)
: null;
if (club is null) return Ok(new { Success = false, Message = "club_not_found" });
var message = $"Club report: {req.Message ?? string.Empty}".Trim();
if (message.Length > 1000) message = message[..1000];
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = pid,
TargetPlayerId = club.CreatorPlayerId,
Category = req.ReportCategory,
Message = message,
});
await db.SaveChangesAsync();
return Ok(new { Success = true, Message = string.Empty });
}
private async Task<CampusCardRequest> ReadCampusCardRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new CampusCardRequest
{
Subscription = bool.TryParse(form["subscription"].FirstOrDefault(), out var s) && s,
PlatformAccountSubscribedPlayerId = long.TryParse(
form["platformAccountSubscribedPlayerId"].FirstOrDefault(), out var id) ? id : 0,
};
}
try
{
return await JsonSerializer.DeserializeAsync<CampusCardRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new CampusCardRequest();
}
catch (JsonException) { return new CampusCardRequest(); }
}
private async Task<ClubReportRequest> ReadClubReportAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new ClubReportRequest
{
ClubId = long.TryParse(form["clubId"].FirstOrDefault()
?? form["ClubId"].FirstOrDefault(), out var clubId) ? clubId : 0,
ReportCategory = int.TryParse(form["reportCategory"].FirstOrDefault()
?? form["ReportCategory"].FirstOrDefault(), out var cat) ? cat : 5,
Message = form["message"].FirstOrDefault() ?? form["Message"].FirstOrDefault(),
};
}
try
{
return await JsonSerializer.DeserializeAsync<ClubReportRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new ClubReportRequest();
}
catch (JsonException) { return new ClubReportRequest(); }
}
private sealed class CampusCardRequest
{
public bool Subscription { get; set; }
public long PlatformAccountSubscribedPlayerId { get; set; }
}
private sealed class ClubReportRequest
{
public long ClubId { get; set; }
public int ReportCategory { get; set; } = 5;
public string? Message { get; set; }
}
}
@@ -21,13 +21,6 @@ public class ConfigController(ConfigService configService) : ControllerBase
[HttpGet("amplitude")]
public ActionResult<object> GetAmplitude() => Ok(new { AmplitudeKey = string.Empty });
/// <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
/// button is hidden.</summary>
[HttpGet("freegiftbutton")]
public IActionResult FreeGiftButton() => Content("false", "application/json");
/// <summary>
/// `Config.GetCohortNUXButtonConfigs(cohortId)` — returns the four
/// "What would you like to do first?" buttons shown in the OOBE
@@ -1,10 +1,9 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.GameConfigurations;
[ApiController]
public class GameConfigurationsController(ServerSettingsService serverSettings) : ControllerBase
public class GameConfigurationsController : ControllerBase
{
// Actual path used by the 2020 build
[HttpGet("api/gameconfigs/v1/all")]
@@ -12,6 +11,5 @@ public class GameConfigurationsController(ServerSettingsService serverSettings)
// Legacy path we originally assumed
[HttpGet("api/gameconfigurations/v1/all")]
[HttpGet("api/gameconfigurations/v1")]
public async Task<ActionResult<IReadOnlyList<GameConfigurationSetting>>> GetAll()
=> Ok(await serverSettings.GetGameConfigurationsAsync());
public ActionResult<List<object>> GetAll() => Ok(new List<object>());
}
@@ -1,151 +0,0 @@
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.GameRewards;
[ApiController]
[Authorize]
public class GameRewardsController(DorkNetDbContext db, LevelService level) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("api/gamerewards/v1/pending")]
public async Task<IActionResult> Pending()
{
var pid = Me;
var rows = await db.GameRewardSelections
.Where(r => r.PlayerId == pid && r.SelectedAt == null)
.OrderBy(r => r.CreatedAt)
.Take(20)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpPost("api/gamerewards/v1/request")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> RequestReward()
{
var req = await ReadRewardRequestAsync();
var row = new GameRewardSelectionEntity
{
PlayerId = Me,
RewardType = req.RewardType,
GiftContext = req.GiftContext,
Message = string.IsNullOrWhiteSpace(req.Message) ? "Choose a reward" : req.Message[..Math.Min(req.Message.Length, 256)],
};
db.GameRewardSelections.Add(row);
await db.SaveChangesAsync();
return Ok(new { Success = true, Error = string.Empty });
}
[HttpPost("api/gamerewards/v1/select")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> SelectReward()
{
var req = await ReadRewardSelectAsync();
var pid = Me;
var row = await db.GameRewardSelections
.FirstOrDefaultAsync(r => r.Id == req.RewardSelectionId && r.PlayerId == pid && r.SelectedAt == null);
if (row is null) return Ok(new { Success = false, Error = "reward_not_found" });
row.SelectedAt = DateTime.UtcNow;
row.SelectedGiftDropId = req.GiftDropId;
await level.GrantCurrencyAsync(pid, 2, 25, $"gameReward:{row.Id}");
await level.AwardXpAsync(pid, 25, $"gameReward:{row.Id}");
await db.SaveChangesAsync();
return Ok(new { Success = true, Error = string.Empty });
}
private static object ToWire(GameRewardSelectionEntity row) => new
{
RewardSelectionId = row.Id,
row.Message,
row.GiftContext,
row.RewardType,
GiftDrop1 = GiftDrop(row.Id * 10 + 1, "Tokens", currency: 25),
GiftDrop2 = GiftDrop(row.Id * 10 + 2, "XP", currency: 0),
GiftDrop3 = GiftDrop(row.Id * 10 + 3, "Bonus Tokens", currency: 25),
row.CreatedAt,
};
private static object GiftDrop(long id, string name, int currency) => new
{
GiftDropId = id,
FriendlyName = name,
Tooltip = string.Empty,
ConsumableItemDesc = string.Empty,
AvatarItemDesc = string.Empty,
AvatarItemType = 0,
EquipmentPrefabName = string.Empty,
EquipmentModificationGuid = string.Empty,
IsQuery = false,
Unique = false,
SubscribersOnly = false,
Rarity = 0,
CurrencyType = 2,
Currency = currency,
Context = 0,
ItemSetId = 0,
ItemSetFriendlyName = string.Empty,
};
private async Task<RewardRequest> ReadRewardRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new RewardRequest
{
RewardType = int.TryParse(form["rewardType"].FirstOrDefault(), out var rt) ? rt : 0,
GiftContext = int.TryParse(form["giftContext"].FirstOrDefault(), out var gc) ? gc : 0,
Message = form["Message"].FirstOrDefault() ?? form["message"].FirstOrDefault(),
};
}
try
{
return await JsonSerializer.DeserializeAsync<RewardRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new RewardRequest();
}
catch (JsonException) { return new RewardRequest(); }
}
private async Task<RewardSelect> ReadRewardSelectAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new RewardSelect
{
RewardSelectionId = long.TryParse(form["rewardSelectionId"].FirstOrDefault(), out var id) ? id : 0,
GiftDropId = int.TryParse(form["giftDropId"].FirstOrDefault(), out var giftDropId) ? giftDropId : 0,
};
}
try
{
return await JsonSerializer.DeserializeAsync<RewardSelect>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new RewardSelect();
}
catch (JsonException) { return new RewardSelect(); }
}
private sealed class RewardRequest
{
public int RewardType { get; set; }
public int GiftContext { get; set; }
public string? Message { get; set; }
}
private sealed class RewardSelect
{
public long RewardSelectionId { get; set; }
public int GiftDropId { get; set; }
}
}
@@ -29,10 +29,10 @@ namespace DorkNet.Server.Controllers.API.Images;
/// Returning [] (the previous catch-all) made the watch silently
/// drop the photo.
///
/// Saved photos are stored as <see cref="PhotoEntity"/> rows. The 2020
/// client sends camera share state inside an imgMeta JSON form field
/// (accessibility=1 for public ShareCamera uploads), while newer callers
/// can still send top-level IsPublic.
/// Saved photos are stored as <see cref="PhotoEntity"/> rows with
/// IsPublic=false by default. A separate Share button (POST
/// api/images/v4/share/{id}) flips IsPublic=true and surfaces them on
/// feed.rec.net.
/// </summary>
[ApiController]
public class ImagesController(
@@ -82,17 +82,12 @@ public class ImagesController(
// Pull optional metadata from the form (the 2020 watch sometimes
// includes a caption / room context, sometimes posts bytes only).
var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null;
var imageMeta = ParseImageMeta(form);
var caption = form?["Caption"].ToString() ?? form?["Description"].ToString() ?? string.Empty;
var taggedIds = form?["TaggedPlayerIds"].ToString() ?? string.Empty;
if (string.IsNullOrWhiteSpace(taggedIds) && imageMeta?.PlayerIds.Count > 0)
taggedIds = string.Join(",", imageMeta.PlayerIds);
long roomId = 0;
if (form is not null && long.TryParse(form["RoomId"].ToString(), out var rid)) roomId = rid;
if (roomId == 0 && imageMeta?.RoomId is > 0) roomId = imageMeta.RoomId.Value;
if (roomId == 0) roomId = presence.GetRoom(pid)?.RoomId ?? 0;
var explicitPublic = form is null ? null : TryReadBool(form["IsPublic"].ToString());
var isPublic = explicitPublic ?? (imageMeta?.Accessibility == 1 || imageMeta?.SavedImageType == 1);
var isPublic = string.Equals(form?["IsPublic"].ToString(), "true", StringComparison.OrdinalIgnoreCase);
var isProfileThumbnail = IsProfileThumbnail(form);
LogUploadForm("uploadsaved", pid, form, isProfileThumbnail);
@@ -285,23 +280,16 @@ public class ImagesController(
/// <summary>GET <c>api/images/v1/slideshow</c> — the watch's lobby
/// slideshow info. Response shape per <c>SlideshowInfoDTO.Deserialize</c>:
/// <c>{ValidTill (DateTime, required), Images (list, required)}</c>.
/// A short <c>ValidTill</c> lets the Rec Center board pick up newly
/// shared camera photos without needing a server restart.</summary>
/// Far-future <c>ValidTill</c> stops the watch from re-polling.</summary>
[HttpGet("api/images/v1/slideshow")]
public async Task<IActionResult> SlideshowInfo()
public IActionResult SlideshowInfo() => Ok(new
{
var rows = await db.Photos
.Where(p => p.IsPublic && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Take(50)
.ToListAsync();
return Ok(new
{
ValidTill = DateTime.UtcNow.AddMinutes(5),
Images = rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)),
});
}
// WireDates.FarFuture (2099), NOT year 9999 — a 9999 ValidTill
// overflows the watch's local-timezone date parse in some DST
// timezones → "malformed RecNet response". See WireDates.
ValidTill = WireDates.FarFuture,
Images = Array.Empty<object>(),
});
/// <summary>Binary image fallback for clients that request image bytes
/// through api/images/{name} instead of img.rec.net/{name}. Specific JSON
@@ -417,36 +405,6 @@ public class ImagesController(
public long PhotoId { get; set; }
}
public sealed class BulkCheeredRequest
{
public List<long>? SavedImageIds { get; set; }
public List<long>? ImageIds { get; set; }
}
[HttpPost("api/images/v4/cheered/bulk")]
[Authorize]
public async Task<IActionResult> CheeredBulk([FromBody] BulkCheeredRequest? body)
{
var pid = this.RequireCurrentPlayerId();
var ids = (body?.SavedImageIds ?? body?.ImageIds ?? new())
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var cheered = await db.Cheers
.Where(c => c.FromPlayerId == pid && ids.Contains(c.TargetPhotoId))
.Select(c => c.TargetPhotoId)
.ToListAsync();
var set = cheered.ToHashSet();
return Ok(ids.Select(id => new
{
SavedImageId = id,
IsCheered = set.Contains(id),
}));
}
/// <summary>POST <c>api/images/v1/cheer</c> — cheer a photo.
/// Was previously Ack-only; now persists a <c>CheerEntity</c> row
/// keyed on <c>TargetPhotoId</c> + bumps <c>Photos.CheerCount</c>.
@@ -808,104 +766,6 @@ public class ImagesController(
return false;
}
private sealed class ParsedImageMeta
{
public int? SavedImageType { get; init; }
public int? Accessibility { get; init; }
public long? RoomId { get; init; }
public IReadOnlyList<long> PlayerIds { get; init; } = Array.Empty<long>();
}
private static ParsedImageMeta? ParseImageMeta(IFormCollection? form)
{
if (form is null) return null;
var meta = form["imgMeta"].ToString();
if (string.IsNullOrWhiteSpace(meta))
meta = form["imageMeta"].ToString();
if (string.IsNullOrWhiteSpace(meta))
return null;
try
{
using var doc = JsonDocument.Parse(meta);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
return null;
return new ParsedImageMeta
{
SavedImageType = TryGetInt(doc.RootElement, "savedImageType", "SavedImageType", "imageType", "ImageType"),
Accessibility = TryGetInt(doc.RootElement, "accessibility", "Accessibility"),
RoomId = TryGetLong(doc.RootElement, "roomId", "RoomId"),
PlayerIds = TryGetLongArray(doc.RootElement, "playerIds", "PlayerIds"),
};
}
catch (JsonException)
{
return null;
}
}
private static bool? TryReadBool(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
if (bool.TryParse(value, out var parsed)) return parsed;
if (value == "1") return true;
if (value == "0") return false;
return null;
}
private static int? TryGetInt(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (!root.TryGetProperty(name, out var value))
continue;
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var n))
return n;
if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out n))
return n;
}
return null;
}
private static long? TryGetLong(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (!root.TryGetProperty(name, out var value))
continue;
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var n))
return n;
if (value.ValueKind == JsonValueKind.String && long.TryParse(value.GetString(), out n))
return n;
}
return null;
}
private static IReadOnlyList<long> TryGetLongArray(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (!root.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Array)
continue;
return value.EnumerateArray()
.Select(x => x.ValueKind == JsonValueKind.Number && x.TryGetInt64(out var n)
? n
: x.ValueKind == JsonValueKind.String && long.TryParse(x.GetString(), out n)
? n
: 0L)
.Where(x => x > 0)
.Distinct()
.ToArray();
}
return Array.Empty<long>();
}
/// <summary>Build the response shape every images/v* endpoint
/// returns. PascalCase keys + redundant aliases so whichever JSON
/// parser the 2020 watch uses, it finds the field it wants. The
@@ -917,49 +777,24 @@ public class ImagesController(
private object BuildImageInfo(PhotoEntity p, long accountId)
{
var cdnHost = domain.Sub("cdn");
// Accessibility maps from IsPublic so the 2020.12 watch's IBANLCLBGLM
// deserialiser reads a non-default value (0=Private, 1=Public).
var accessibility = p.IsPublic ? 1 : 0;
return new
{
// 2020.12 watch's IBANLCLBGLM strict keys (per
// docs/recroom-2020-client-response-contracts.md:1048).
// Missing any of these throws KeyNotFoundException in LitJson
// and the gallery empties out.
Id = p.Id,
// Primary keys the 2020 watch's ImageInfo deserialiser reads.
ImageName = p.BlobName,
AccountId = (int)accountId,
// Server-side mirror of AccountId as PlayerId — some
// call-sites read PlayerId (lowercase id) instead of
// AccountId. Including both is harmless on the watch.
PlayerId = (int)accountId,
RoomId = p.RoomId,
// PlayerEventId is always 0 here (photo-event linkage isn't
// tracked yet); the field still has to be present so the
// strict deserialiser doesn't bail.
PlayerEventId = 0L,
Accessibility = accessibility,
// AccessibilityLocked = whether the watch can flip the
// privacy toggle. We don't gate on this server-side so the
// value is always false (UI is free to toggle).
AccessibilityLocked = false,
// ImageType: 0=ScreenShot, 1=ProfileImage, 2=ImageOfMe, …
// we don't differentiate yet — surface 0 unconditionally.
Type = 0,
ImageType = 0,
CreatedAt = p.CreatedAt,
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
CheerCount = p.CheerCount,
// We don't track per-photo comments yet — surface 0 for
// the strict-key requirement.
CommentCount = 0,
// Bonus fields — useful for other clients (and our own feed UI).
Id = p.Id,
Filename = p.BlobName,
Url = $"https://{cdnHost}/{p.BlobName}",
Caption = p.Caption,
Description = p.Caption,
RoomId = p.RoomId,
IsPublic = p.IsPublic,
CheerCount = p.CheerCount,
ViewCount = p.ViewCount,
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
};
}
@@ -19,15 +19,13 @@ namespace DorkNet.Server.Controllers.API.Inventions;
/// are POST. <c>delete</c>, <c>publish</c>, <c>unpublish</c>,
/// <c>download</c> are also GET.
///
/// Wire DTO matches the watch's <c>OBBBPCBIMME.PPGFHEDFBEA</c>
/// deserializer (Cpp2IL ISIL <c>OBBBPCBIMME.txt:588-704</c>): keys
/// Wire DTO matches <c>RecNet.Invention</c>
/// (<c>Cpp2IL_CS/.../RecNet/Invention.cs</c>) byte-for-byte: keys
/// <c>InventionId, ReplicationId, CreatorPlayerId, Name, Description,
/// ImageName, CurrentVersionNumber, IsPublished, AllowTrial,
/// ModifiedAt, CreatedAt, FirstPublishedAt, CreationRoomId,
/// ImageName, CurrentVersionNumber, IsPublished, ModifiedAt,
/// CreatedAt, FirstPublishedAt, CreationRoomId,
/// NumPlayersHaveUsedInRoom, NumDownloads, CheerCount,
/// CreatorPermission, GeneralPermission, IsAGInvention, Price,
/// HideFromPlayer</c>. Casing is significant — note IsAGInvention
/// has a capital G.
/// CreatorPermission, GeneralPermission, IsAgInvention</c>.
/// </summary>
[ApiController]
public class InventionsController(
@@ -73,7 +71,6 @@ public class InventionsController(
/// <summary><c>GetLocalPlayerInventions</c> — same as Saved but
/// at v1 path. Watch hits both depending on tab.</summary>
[HttpGet("api/inventions/v1/mine")]
[HttpGet("api/inventions/v2/mine")]
[Authorize]
public Task<ActionResult> Mine() => Saved(100);
@@ -170,11 +167,11 @@ public class InventionsController(
/// <summary>GET <c>api/inventions/v1/creatorIds</c> — the distinct
/// creator account ids of every invention placed in a room
/// (<c>GetCreatorIdsOfInventionsInRoom</c>). The watch uses this to
/// resolve which creators it must request invention permissions for
/// before spawning a room's invention-based geometry. Returns
/// <c>List&lt;int&gt;</c> — empty when the room has no tracked
/// inventions.</summary>
/// (<c>GetCreatorIdsOfInventionsInRoom</c>, called by the 2020.03
/// watch on room load). It uses these to resolve which creators it
/// must request invention permissions for before spawning a room's
/// invention-based geometry. Returns <c>List&lt;int&gt;</c> — empty
/// when the room has no tracked inventions.</summary>
[HttpGet("api/inventions/v1/creatorIds")]
public async Task<IActionResult> CreatorIdsInRoom([FromQuery] long roomId)
{
@@ -222,59 +219,8 @@ public class InventionsController(
.ToListAsync();
}
/// <summary>GET <c>api/inventions/v1/fulllineageowner</c> —
/// raw Boolean. The client uses this to decide whether a set of
/// referenced inventions can be saved together under one owner.
/// DorkNet tracks current creator ownership, so the concrete rule
/// here is: every requested invention id must exist and share the
/// same CreatorPlayerId.</summary>
[HttpGet("api/inventions/v1/fulllineageowner")]
public async Task<IActionResult> FullLineageOwner()
{
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();
if (ids.Count == 0) return Content("true", "application/json");
var owners = await db.Inventions
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
.Select(i => new { i.Id, i.CreatorPlayerId })
.ToListAsync();
var ok = owners.Count == ids.Count
&& owners.Select(o => o.CreatorPlayerId).Distinct().Count() <= 1;
return Content(ok ? "true" : "false", "application/json");
}
// ── Single fetch ─────────────────────────────────────────────────────
/// <summary>GET <c>/api/inventions/v1/room?id={roomId}</c> — every
/// invention used by the room. The 2020.12 watch fires this on
/// EVERY room load right after fetching the room data blob; it uses
/// the response to pre-resolve invention references the blob will
/// spawn. Without this, imported rooms with invention-based
/// geometry (floors, walls, custom shapes) render with everything
/// missing — the watch silently skips invention spawns when it
/// has no list, and you fall through the void.
///
/// We match on <see cref="InventionEntity.CreationRoomId"/> which
/// the zip importer populates from each Invention.json's
/// <c>CreationRoomId</c> field. Returns an array of
/// <see cref="ToWire"/>-shaped Invention objects (same wire shape
/// the single-fetch endpoint returns) so the watch's existing
/// deserializer works.</summary>
[HttpGet("api/inventions/v1/room")]
public async Task<ActionResult> ByRoom([FromQuery] long id)
{
var inventions = await ResolveRoomInventionsAsync(id);
return Ok(inventions.Select(ToWire).ToList());
}
[HttpGet("api/inventions/v1")]
public async Task<ActionResult> SingleByQuery([FromQuery] long inventionId)
{
@@ -338,87 +284,53 @@ public class InventionsController(
// ── Create / version ─────────────────────────────────────────────────
public sealed class SaveInventionV4Request
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public int InstantiationCost { get; set; }
public int LightsCost { get; set; }
public int AiCost { get; set; }
public long? CreationRoomId { get; set; }
public string? InventionDataFilename { get; set; }
public List<long>? ReferencedInventions { get; set; }
public int CreatorAccountRole { get; set; }
}
public sealed record SaveInventionRequest(
string Name, string? Description, string? ImageName,
int? Permission, string? Tags, string BlobName,
long? CreationRoomId, int? InstantiationCost, int? LightsCost);
/// <summary>POST <c>api/inventions/v4/save</c> — 2020.12 maker-pen
/// 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/v3/save")]
[Authorize]
public async Task<ActionResult> SaveV4([FromBody] SaveInventionV4Request req)
public async Task<ActionResult> Save([FromBody] SaveInventionRequest req)
{
var pid = CurrentPlayerId;
var name = (req.Name ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(name)) return BadRequest(new { Status = 1, Error = "missing name" });
var blobName = (req.InventionDataFilename ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(blobName)) return BadRequest(new { Status = 1, Error = "missing inventionDataFilename" });
if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest("missing name");
if (string.IsNullOrWhiteSpace(req.BlobName)) return BadRequest("missing BlobName");
var inv = new InventionEntity
{
CreatorPlayerId = pid,
ReplicationId = Guid.NewGuid().ToString("D"),
Name = name,
Name = req.Name.Trim(),
Description = req.Description ?? string.Empty,
ImageName = req.ImageName ?? string.Empty,
Permission = 0,
CurrentBlobName = blobName,
TagsCsv = string.Empty,
Permission = Math.Clamp(req.Permission ?? 0, 0, 2),
CurrentBlobName = req.BlobName,
TagsCsv = req.Tags ?? string.Empty,
CreationRoomId = req.CreationRoomId,
CreatorPermission = 100,
GeneralPermission = 0,
CreatorPermission = 100, // Unlimited for the creator
GeneralPermission = 0, // Unassigned (private) until publish
IsPublished = false,
CurrentVersionNumber = 1,
};
db.Inventions.Add(inv);
await db.SaveChangesAsync();
// Snapshot the v1 version row.
var v1 = new InventionVersionEntity
{
InventionId = inv.Id,
ReplicationId = Guid.NewGuid().ToString("D"),
VersionNumber = 1,
BlobName = blobName,
InstantiationCost = Math.Max(0, req.InstantiationCost),
LightsCost = Math.Max(0, req.LightsCost),
BlobName = req.BlobName,
InstantiationCost = req.InstantiationCost ?? 0,
LightsCost = req.LightsCost ?? 0,
};
db.InventionVersions.Add(v1);
if (req.ReferencedInventions is { Count: > 0 })
{
foreach (var referencedId in req.ReferencedInventions.Distinct().Take(200))
{
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = pid,
Key = $"invention:{inv.Id}:references:{referencedId}",
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
}
}
await db.SaveChangesAsync();
await level.AwardXpAsync(pid, LevelService.InventionSavedXp, $"invention_save:{inv.Id}");
return Ok(new
{
Status = 0,
Invention = ToWireV4(inv),
InventionVersion = ToVersionWire(v1),
});
await level.AwardXpAsync(pid, LevelService.InventionSavedXp, $"invention_save:{inv.Id}");
return Ok(ToWire(inv));
}
public sealed record AddVersionRequest(
@@ -507,7 +419,7 @@ public class InventionsController(
return Ok(ToWire(inv));
}
[HttpGet("api/inventions/v3/publish")]
[HttpGet("api/inventions/v2/publish")]
[Authorize]
public async Task<ActionResult> Publish(
[FromQuery] long inventionId, [FromQuery] int permissionLevel)
@@ -645,23 +557,6 @@ public class InventionsController(
_ => 0,
};
// Wire shape verified against the 2020.12 watch's
// OBBBPCBIMME.PPGFHEDFBEA deserializer (Cpp2IL ISIL OBBBPCBIMME.txt
// lines 588-704). All keys read via Util.GetKey<T> are required —
// a missing key throws KeyNotFoundException → the watch logs
// "Received malformed RecNet response" and aborts the join/fetch.
//
// Required (in disasm order):
// InventionId, ReplicationId, CreatorPlayerId, Name, Description,
// ImageName, CurrentVersionNumber, IsPublished, AllowTrial,
// ModifiedAt, CreatedAt, NumPlayersHaveUsedInRoom, NumDownloads,
// CheerCount, CreatorPermission, GeneralPermission, IsAGInvention,
// Price, HideFromPlayer.
// Optional (Util.GetKeyOrDefault): FirstPublishedAt, CreationRoomId.
//
// Casing is significant — LitJson + Util.GetKey are case-sensitive.
// In particular IsAGInvention has a capital G (matches the asset's
// "IsAGInvention" property, not the entity column IsAgInvention).
private static object ToWire(InventionEntity i) => new
{
InventionId = i.Id,
@@ -674,7 +569,6 @@ public class InventionsController(
i.ImageName,
i.CurrentVersionNumber,
i.IsPublished,
AllowTrial = true,
ModifiedAt = i.UpdatedAt,
i.CreatedAt,
i.FirstPublishedAt,
@@ -684,13 +578,9 @@ public class InventionsController(
i.CheerCount,
i.CreatorPermission,
i.GeneralPermission,
IsAGInvention = i.IsAgInvention,
Price = 0,
HideFromPlayer = false,
i.IsAgInvention,
};
private static object ToWireV4(InventionEntity i) => ToWire(i);
private static object ToVersionWire(InventionVersionEntity v) => new
{
v.InventionId,
@@ -75,27 +75,15 @@ public class InventoryController(
public async Task<IActionResult> UpdateEquipment([FromBody] EquipmentUpdateRequest req)
{
var pid = Me;
// Catalog-backed skins use a compound slug so we can round-trip
// the client's separate PrefabName + ModificationGuid fields.
// The slug for equipment is the modification GUID (or prefab
// name fallback when no mod is applied).
var slug = !string.IsNullOrWhiteSpace(req.ModificationGuid)
? StoreService.EquipmentSkinSlug(req.PrefabName, req.ModificationGuid)
? req.ModificationGuid
: req.PrefabName;
if (string.IsNullOrWhiteSpace(slug)) return BadRequest("missing prefab/mod identifier");
var row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug);
if (row is null && !string.IsNullOrWhiteSpace(req.ModificationGuid))
{
// Legacy rows created before catalog-backed equipment skins
// used the raw modification GUID as ItemSlug.
row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == req.ModificationGuid);
}
if (row is null && !string.IsNullOrWhiteSpace(req.PrefabName))
{
row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == req.PrefabName);
}
if (row is null)
{
// Player doesn't own this; reject rather than silently
@@ -110,15 +98,8 @@ public class InventoryController(
// ── Consumables ──────────────────────────────────────────────────────
// 2020.12 client bumped the URL to v2 AND changed the response shape from
// per-item to BulkConsumable: each entry groups N instances of the same
// ConsumableItemDesc with parallel Ids[] / CreatedAts[] arrays. Deserializer
// is PLHFAEBLDAE.PPGFHEDFBEA reading PASCALCASE keys: Ids, CreatedAts,
// ConsumableItemDesc, Count, InitialCount, IsActive, ActiveDurationMinutes,
// IsTransferable. Returning the v1 per-item shape on v2 throws "Malformed
// Response" and the chain calls BootSequence.OINIBKFNFAJ — fatal kick.
[HttpGet("api/consumables/v1/getUnlocked")]
public async Task<IActionResult> GetUnlockedConsumablesV1()
public async Task<IActionResult> GetUnlockedConsumables()
{
var pid = Me;
var rows = await (from inv in db.PlayerInventory
@@ -129,33 +110,6 @@ public class InventoryController(
return Ok(rows.Select(r => ToConsumableWire(r.inv, r.item)));
}
[HttpGet("api/consumables/v2/getUnlocked")]
public async Task<IActionResult> GetUnlockedConsumablesV2()
{
var pid = Me;
var rows = await (from inv in db.PlayerInventory
join item in db.StoreItems on inv.ItemSlug equals item.Slug
where inv.PlayerId == pid
&& ConsumableCategories.Contains(item.Category)
select new { inv, item }).ToListAsync();
// Group by ConsumableItemDesc — one BulkConsumable per consumable type.
var bulk = rows
.GroupBy(r => StoreService.TryGetConsumableItemDesc(r.item.Slug, out var d) ? d : r.item.Slug)
.Select(g => new
{
Ids = g.Select(r => r.inv.Id).ToArray(),
CreatedAts = g.Select(r => r.inv.AcquiredAt).ToArray(),
ConsumableItemDesc = g.Key,
Count = g.Sum(r => r.inv.Quantity),
InitialCount = g.Sum(r => r.inv.Quantity),
IsActive = false,
ActiveDurationMinutes = 0,
IsTransferable = false,
});
return Ok(bulk);
}
public sealed class ConsumableItemRequest
{
public long Id { get; set; }
@@ -202,30 +156,17 @@ public class InventoryController(
// ── Wire serializers ─────────────────────────────────────────────────
private static object ToEquipmentWire(PlayerInventoryEntity inv, StoreItemEntity item)
private static object ToEquipmentWire(PlayerInventoryEntity inv, StoreItemEntity item) => new
{
var prefabName = item.Slug;
var modificationGuid = inv.ItemSlug;
var rarity = 0;
if (StoreService.TryGetEquipmentPayload(item.Slug, out var prefab, out var mod, out var skin))
{
prefabName = prefab;
modificationGuid = mod;
rarity = skin?.rarity ?? 0;
}
return new
{
PrefabName = prefabName,
ModificationGuid = modificationGuid,
PlatformMask = -1, // All
IsPlatformLocked = false,
FriendlyName = item.DisplayName,
Tooltip = item.Description,
Rarity = rarity,
Favorited = inv.IsActive,
};
}
PrefabName = item.Slug, // for store-derived items the slug doubles as prefab name
ModificationGuid = inv.ItemSlug,
PlatformMask = -1, // All
IsPlatformLocked = false,
FriendlyName = item.DisplayName,
Tooltip = item.Description,
Rarity = 0, // Common (no rarity column on StoreItem yet)
Favorited = inv.IsActive,
};
private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item)
{
@@ -78,15 +78,13 @@ public class LeaderboardController(
public async Task<IActionResult> SetStat([FromBody] SetStatRequest req)
{
var pid = this.RequireCurrentPlayerId();
var roomId = ResolveRoomId(req.RoomId, pid);
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.RoomId == roomId && s.PlayerId == pid && s.StatChannel == req.StatChannel);
s.PlayerId == pid && s.StatChannel == req.StatChannel);
if (row is null)
{
row = new LeaderboardStatEntity
{
PlayerId = pid,
RoomId = roomId,
StatChannel = req.StatChannel,
Value = req.StatValue,
};
@@ -105,10 +103,11 @@ public class LeaderboardController(
// when it's zero (rare — direct stat report outside a room
// context) fall back to the player's current presence so a
// stray score still gets bucketed somewhere visible.
var roomId = req.RoomId > 0 ? req.RoomId : (playerPresence.GetRoom(pid)?.RoomId ?? 0);
if (roomId > 0)
{
var meta = await db.LeaderboardChannelMeta
.FirstOrDefaultAsync(c => c.RoomId == roomId && c.Channel == req.StatChannel);
.FirstOrDefaultAsync(c => c.Channel == req.StatChannel);
if (meta is null)
{
// First time this channel reports. Stamp a meta row so
@@ -125,6 +124,17 @@ public class LeaderboardController(
ValueFormat = "count",
});
}
else if (meta.RoomId == 0)
{
// Channel registered globally (e.g. from the hardcoded
// KnownStatChannels fallback or an unscoped admin
// entry). Bind it to this room now that we know which
// room it came from. Don't re-bind if it already
// points at a different room — the admin must be the
// one to reassign once a channel has a home.
meta.RoomId = roomId;
meta.UpdatedAt = DateTime.UtcNow;
}
}
await db.SaveChangesAsync();
@@ -151,22 +161,18 @@ public class LeaderboardController(
if (pid == 0)
return Ok(new { playerId = 0, score = 0L, rank = 0 });
var roomId = ResolveRoomId(req.RoomId, pid);
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.RoomId == roomId && s.PlayerId == pid && s.StatChannel == req.StatChannel);
s.PlayerId == pid && s.StatChannel == req.StatChannel);
if (row is null)
return Ok(new { playerId = (int)pid, score = 0L, rank = 0 });
var lowerIsBetter = await IsLowerIsBetterAsync(roomId, req.StatChannel);
var betterCount = await db.LeaderboardStats
.CountAsync(s => s.RoomId == roomId
&& s.StatChannel == req.StatChannel
&& (lowerIsBetter ? s.Value < row.Value : s.Value > row.Value));
var higherCount = await db.LeaderboardStats
.CountAsync(s => s.StatChannel == req.StatChannel && s.Value > row.Value);
return Ok(new
{
playerId = (int)pid,
score = row.Value,
rank = betterCount + 1,
rank = higherCount + 1,
});
}
@@ -188,13 +194,12 @@ public class LeaderboardController(
{
var pid = req.PlayerId > 0 ? (long)req.PlayerId : this.CurrentPlayerId() ?? 0;
var window = Math.Clamp(req.WindowSize, 1, 50);
var roomId = ResolveRoomId(req.RoomId, pid);
var lowerIsBetter = await IsLowerIsBetterAsync(roomId, req.StatChannel);
var query = db.LeaderboardStats
.Where(s => s.RoomId == roomId && s.StatChannel == req.StatChannel);
query = lowerIsBetter ? query.OrderBy(s => s.Value) : query.OrderByDescending(s => s.Value);
var all = await query.Select(s => new { s.PlayerId, s.Value }).ToListAsync();
var all = await db.LeaderboardStats
.Where(s => s.StatChannel == req.StatChannel)
.OrderByDescending(s => s.Value)
.Select(s => new { s.PlayerId, s.Value })
.ToListAsync();
if (all.Count == 0)
{
// Empty case must still come back as a SingleLeaderboard
@@ -246,14 +251,12 @@ public class LeaderboardController(
var end = Math.Max(start, req.RankEnd);
var skip = start - 1;
var take = Math.Clamp(end - skip, 1, 200);
var pid = req.PlayerId > 0 ? (long)req.PlayerId : this.CurrentPlayerId() ?? 0;
var roomId = ResolveRoomId(req.RoomId, pid);
var lowerIsBetter = await IsLowerIsBetterAsync(roomId, req.StatChannel);
var query = db.LeaderboardStats
.Where(s => s.RoomId == roomId && s.StatChannel == req.StatChannel);
query = lowerIsBetter ? query.OrderBy(s => s.Value) : query.OrderByDescending(s => s.Value);
var rows = await query.Skip(skip).Take(take).ToListAsync();
var rows = await db.LeaderboardStats
.Where(s => s.StatChannel == req.StatChannel)
.OrderByDescending(s => s.Value)
.Skip(skip).Take(take)
.ToListAsync();
var entries = rows.Select((s, i) => new
{
playerId = (int)s.PlayerId,
@@ -268,15 +271,14 @@ public class LeaderboardController(
/// by the watch's full-leaderboard scoreboard view.</summary>
[HttpGet("/leaderboard/Top")]
[HttpGet("/api/leaderboard/Top")]
public async Task<IActionResult> Top([FromQuery] int channel, [FromQuery] long roomId = 0, [FromQuery] int take = 50)
public async Task<IActionResult> Top([FromQuery] int channel, [FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 200);
var resolvedRoomId = ResolveRoomId(roomId, this.CurrentPlayerId() ?? 0);
var lowerIsBetter = await IsLowerIsBetterAsync(resolvedRoomId, channel);
var query = db.LeaderboardStats
.Where(s => s.RoomId == resolvedRoomId && s.StatChannel == channel);
query = lowerIsBetter ? query.OrderBy(s => s.Value) : query.OrderByDescending(s => s.Value);
var rows = await query.Take(take).ToListAsync();
var rows = await db.LeaderboardStats
.Where(s => s.StatChannel == channel)
.OrderByDescending(s => s.Value)
.Take(take)
.ToListAsync();
// FullLeaderboard wire shape: GlobalOverall / GlobalPeriodic /
// FriendsOverall / FriendsPeriodic + NextResetUTC. Use the
// same Entry list for all four buckets — friends-vs-global
@@ -296,20 +298,4 @@ public class LeaderboardController(
NextResetUTC = DateTime.UtcNow.AddDays(7),
});
}
private long ResolveRoomId(long requestedRoomId, long playerId)
{
if (requestedRoomId > 0) return requestedRoomId;
if (playerId > 0) return playerPresence.GetRoom(playerId)?.RoomId ?? 0;
return 0;
}
private async Task<bool> IsLowerIsBetterAsync(long roomId, int channel)
{
var live = await db.LeaderboardChannelMeta
.Where(c => c.RoomId == roomId && c.Channel == channel)
.Select(c => (bool?)c.LowerIsBetter)
.FirstOrDefaultAsync();
return live ?? false;
}
}
@@ -22,7 +22,7 @@ public class MessagesController(
DorkNetDbContext db,
NotificationService notifications,
PrivateInstanceService privateInstances,
PlayerPresenceService presence,
OnlinePresenceService onlinePresence,
ServerSettingsService serverSettings,
ILogger<MessagesController> logger) : ControllerBase
{
@@ -36,12 +36,12 @@ public class MessagesController(
[HttpGet("get")]
[HttpGet("/api/messages/v1/get")]
[HttpGet("/api/messages/v3/get")]
[HttpGet("/api/messages/v1/onlineStatus")]
public async Task<ActionResult<List<MessageDto>>> GetMessages([FromQuery] int take = 100)
{
take = Math.Clamp(take, 1, 200);
var rows = await db.Messages
.Where(m => m.RecipientPlayerId == Me || m.SenderPlayerId == Me)
.Where(m => m.Type >= 0)
// Defensive filter for the empty-body ghost rows that
// accumulated before SendFormMessage rejected them. The
// watch's MessagesScreenItem renders TextMessage (type 30)
@@ -65,7 +65,7 @@ public class MessagesController(
public async Task<ActionResult<List<MessageDto>>> Unread()
{
var rows = await db.Messages
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null && m.Type >= 0)
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null)
// Same ghost-row filter as GetMessages above — keep the
// unread badge count honest by hiding empty TextMessages.
.Where(m => m.Type != 30 || (m.Body != null && m.Body != ""))
@@ -82,7 +82,7 @@ public class MessagesController(
public List<long>? MessageIds { get; set; }
}
/// <summary>POST <c>api/messages/v3/delete</c> — delete one or
/// <summary>POST <c>api/messages/v3/delete</c> — hard-delete one or
/// more messages addressed to the caller. The 2020 watch's inbox
/// "delete" action posts JSON <c>{"MessageIds":[8]}</c> (plural
/// array, verified from the live trace where the body was
@@ -90,9 +90,7 @@ public class MessagesController(
/// some older / SPA call sites still post <c>id</c> or
/// <c>messageId</c> as a form/query. Accept all three shapes and
/// scope every delete to <c>RecipientPlayerId == Me</c> so the
/// sender can't unsend. Game invites are tombstoned instead of
/// hard-deleted because the watch accepts an invite by firing this
/// delete request and <c>/goto/invite/{id}</c> in parallel.</summary>
/// sender can't unsend.</summary>
[HttpPost("/api/messages/v3/delete")]
[HttpPost("/api/messages/v1/delete")]
[Consumes("application/json")]
@@ -130,27 +128,10 @@ public class MessagesController(
{
ids = ids.Distinct().ToList();
if (ids.Count == 0) return BadRequest(new { error = "missing_id" });
var inviteRows = await db.Messages
.Where(m => ids.Contains(m.Id)
&& m.RecipientPlayerId == Me
&& (m.Type == 6 || m.Type == 7))
.ToListAsync();
var now = DateTime.UtcNow;
foreach (var invite in inviteRows)
{
invite.Type = -invite.Type;
invite.ReadAt ??= now;
}
var hardDeleted = await db.Messages
var deleted = await db.Messages
.Where(m => ids.Contains(m.Id) && m.RecipientPlayerId == Me)
.Where(m => m.Type != 6 && m.Type != 7 && m.Type != -6 && m.Type != -7)
.ExecuteDeleteAsync();
if (inviteRows.Count > 0)
await db.SaveChangesAsync();
return Ok(new { success = true, error = "", deleted = hardDeleted + inviteRows.Count });
return Ok(new { success = true, error = "", deleted });
}
/// <summary>POST <c>api/messages/v3/markRead</c> — bulk
@@ -297,71 +278,14 @@ public class MessagesController(
if (playerId <= 0 || playerId == Me)
return BadRequest(new { error = "invalid_recipient" });
// Resolve which room the invite is for. Two code paths:
//
// PRIVATE instance: the sender created an invite-only match
// (via /goto/room/X?CreatePrivateInstance=1). The
// roomInstanceId the watch sends maps to a registered
// PrivateInstance and the owner or someone already inside
// that exact live instance can invite. That keeps access
// control intact while still letting a guest in someone
// else's dorm invite a friend from the in-game watch.
//
// PUBLIC room: the sender is in a public room / a dorm /
// any non-private room. RoomInstanceId is just
// roomId*100_000 — synthetic, never registered. We have to
// resolve which Room they're in via PlayerPresenceService
// (heartbeats keep this up to date) so we can stamp RoomId
// on the message and the recipient's /goto/invite/{N} path
// knows where to send them. No ownership check applies for
// public rooms.
long resolvedRoomId;
string roomName;
// Only the OWNER of the private instance can invite into it.
// Otherwise an attacker who somehow learned a roomInstanceId
// could invite themselves (or anyone) freely.
var inst = await privateInstances.GetAsync(roomInstanceId);
if (inst is not null)
{
var senderPresence = presence.GetRoom(Me);
var senderIsInInstance = senderPresence is not null
&& senderPresence.RoomInstanceId == roomInstanceId;
if (inst.OwnerPlayerId != Me && !senderIsInInstance)
{
logger.LogWarning(
"[invite] from={From} tried to invite to private instance={Instance} ownedBy={Owner} while presenceInstance={PresenceInstance}; rejecting",
Me, roomInstanceId, inst.OwnerPlayerId, senderPresence?.RoomInstanceId);
return Forbid();
}
resolvedRoomId = inst.RoomId;
roomName = !string.IsNullOrWhiteSpace(inst.Name)
? inst.Name
: await db.Rooms
.Where(r => r.Id == inst.RoomId)
.Select(r => r.Name)
.FirstOrDefaultAsync() ?? "Private match";
}
else
{
// Public-room path. Trust the sender's current presence —
// the watch only fires SendInvite from the social menu
// while you're in a room, and heartbeats refresh presence
// every few seconds. We DON'T trust the raw roomInstanceId
// from the form because it's user-controlled and a
// malicious mod could send a fabricated one.
var senderPresence = presence.GetRoom(Me);
if (senderPresence is null || senderPresence.RoomId <= 0)
{
logger.LogWarning(
"[invite] from={From} to={To} instance={Instance}: no private instance AND no presence room — rejecting",
Me, playerId, roomInstanceId);
return BadRequest(new { error = "sender_not_in_a_room", roomInstanceId });
}
resolvedRoomId = senderPresence.RoomId;
roomName = !string.IsNullOrWhiteSpace(senderPresence.Name)
? senderPresence.Name
: await db.Rooms
.Where(r => r.Id == senderPresence.RoomId)
.Select(r => r.Name)
.FirstOrDefaultAsync() ?? "Room";
}
if (inst is null)
return BadRequest(new { error = "instance_not_found", roomInstanceId });
if (inst.OwnerPlayerId != Me)
return Forbid();
// Persist a real RecNet.Message.MessageType.GameInviteV2 (=6)
// so the watch's GameInviteController calls
@@ -374,6 +298,10 @@ public class MessagesController(
// second backfills the JSON Body once we know it. We can't
// pre-allocate the Id because EF Core's identity columns only
// populate on SaveChanges.
var roomName = await db.Rooms
.Where(r => r.Id == inst.RoomId)
.Select(r => r.Name)
.FirstOrDefaultAsync() ?? "Private match";
var entry = new MessageEntity
{
@@ -383,19 +311,18 @@ public class MessagesController(
// until we overwrite it post-save with the real JSON.
Body = string.Empty,
Type = 6,
RoomId = resolvedRoomId,
RoomId = inst.RoomId,
};
db.Messages.Add(entry);
await db.SaveChangesAsync();
// Only pin the invitee row for PRIVATE instances — public-room
// invites have no registered instance to track invitees against.
// GoToInvite handles the missing-mapping case by falling through
// to the message's RoomId + BuildResponseAsync(roomName).
if (inst is not null)
{
await privateInstances.InviteAsync(roomInstanceId, playerId, messageId: entry.Id);
}
// InviteAsync AFTER the message save so we can pin the freshly-
// allocated Message.Id onto the invitee row's
// LatestInviteMessageId. This is what /goto/invite/{N} falls
// back to when the watch's accept flow has already raced
// through DELETE /api/messages/v3/delete — the message row is
// gone but the (instance, player) → messageId mapping survives.
await privateInstances.InviteAsync(roomInstanceId, playerId, messageId: entry.Id);
// Body shape MUST be the JSON the watch's Message.Deserialize
// builds RoomInviteDetails from (verified at
@@ -492,20 +419,6 @@ public class MessagesController(
return Ok(Array.Empty<object>());
}
// NOTE: There is intentionally NO separate handler for
// `/api/messages/v2/send`. The controller route is
// `api/[controller]/v2`, so SendFormMessage's relative
// `[HttpPost("send")]` already binds `api/messages/v2/send` for
// form posts (and SendMessage binds it for JSON, disambiguated by
// [Consumes]). A previous standalone `SendSingle` action with an
// explicit `[HttpPost("/api/messages/v2/send")]` duplicated that
// binding, which made every form post to v2/send match two
// endpoints → AmbiguousMatchException (500). That broke the very
// thing it was meant to add: the watch's PartyUpRequest /
// "send join request" (MessageType 120), which posts the same
// ToPlayerId/Type/Data form shape SendFormMessage already accepts.
// Do not re-add a v2/send action; extend SendFormMessage instead.
/// <summary>POST <c>api/offlineinvite/v1/send</c> — send an invite
/// message to a player who's offline; surfaces in their inbox on
/// next login. Wire shape: form-urlencoded <c>RecipientId</c> +
@@ -555,23 +468,17 @@ public class MessagesController(
/// <summary>The boot sequence asks for online friends. We
/// derive that from the SignalR hub's connected-player set —
/// the response shape is <c>[ {AccountId, Online}, ... ]</c>.
///
/// Both <c>favoriteFriendOnlineStatus</c> and the more general
/// <c>onlineStatus</c> URLs alias to this handler since they
/// share the wire shape; pre-2026-05-25 the onlineStatus route
/// was wrongly aliased to GetMessages and returned the inbox
/// list instead, breaking the friends-online HUD.</summary>
/// the response shape is <c>[ {AccountId, Online}, ... ]</c>.</summary>
[HttpGet("/api/messages/v1/favoriteFriendOnlineStatus")]
[HttpGet("/api/messages/v1/onlineStatus")]
public async Task<ActionResult> FavoriteFriendOnlineStatus()
{
// Honors the global-friends toggle (everyone is a friend when on).
var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, Me);
var online = onlinePresence.OnlinePlayerIds().ToHashSet();
return Ok(friendIds.Select(id => new
{
AccountId = id,
Online = presence.IsRecentlyActive(id),
Online = online.Contains(id),
}));
}
@@ -136,9 +136,12 @@ public class InGameModerationController(
foreach (var target in ids)
{
logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target);
// Push the ModerationKick shape the 2020.12 client expects:
// Msg.Reason must be a ModerationBlockDetail object.
await notifications.KickPlayerAsync(target, "Instant kick by moderator.");
// Push a SignalR ModerationKick notification — the watch's
// PlayerReporting.OnModerationKick handler boots the player
// back to their dorm.
await notifications.NotifyAsync(
target,
Models.Notification.PushNotificationId.ModerationKick);
}
return Ok(new { success = true, error = "" });
}
@@ -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.Url("notify", "/hub/v1")} instead",
hint = $"connect to {domain.Sub("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 = domain.Url("notify", "/hub/v1"),
Url = $"https://{domain.Sub("notify")}/hub/v1",
Protocol = "signalr",
Version = 1,
});
@@ -1,57 +0,0 @@
using DorkNet.Server.Auth;
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.Pageview;
/// <summary>
/// Endpoints behind the watch's <c>COFHGNFJMOG</c> page-view + link
/// system. The 2020.12 client fires <c>POST pageview/consume</c> on
/// every screen change in the boot/menu flow expecting a response
/// that tells it (a) whether there's an in-app deep-link URL to
/// resolve and (b) how long the answer is fresh for. Returning a 404
/// here surfaces as red toast spam in the menus and (per
/// <c>BootSequence.OINIBKFNFAJ</c>) can short-circuit the avatar /
/// inventory bootstrap.
///
/// <para>Response shape verified at
/// <c>Cpp2IL_ISIL/.../MPHABHIMOOO.txt:45,50</c> — the watch's
/// <c>MPHABHIMOOO.PPGFHEDFBEA(Dictionary)</c> reads two camelCase
/// keys:
/// <list type="bullet">
/// <item><c>url</c> — string. Empty means "no deep-link to load".</item>
/// <item><c>freshnessSeconds</c> — int. How long the client can
/// cache this response before re-polling.</item>
/// </list>
/// Returning a long freshness (1 hour) keeps a private server's
/// chattiness low when there's no real linking layer.</para>
///
/// <para>Sibling endpoints from the same watch class
/// (<c>actionlink/*</c>, <c>datalink/*</c>, <c>referral</c>) are out
/// of scope for now — they fire only on explicit user share/click,
/// not in the boot path. Add them as separate routes when they show
/// up in the production log.</para>
/// </summary>
[ApiController]
public class PageviewController(ILogger<PageviewController> logger) : ControllerBase
{
/// <summary>POST <c>pageview/consume</c> — record a screen
/// transition. The body is empty in 2020.12.
/// <c>Promise&lt;MPHABHIMOOO&gt;</c> result; the watch only uses
/// the <c>url</c> / <c>freshnessSeconds</c> fields. We
/// 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", Order = 10)]
[HttpPost("/pageview/consume")]
[HttpGet("pageview/consume", Order = 10)]
[HttpGet("/pageview/consume")]
public IActionResult Consume()
{
logger.LogDebug("[pageview] consume callerId={Caller}", this.CurrentPlayerId() ?? 0);
return Ok(new
{
url = string.Empty,
freshnessSeconds = 3600,
});
}
}
@@ -62,35 +62,17 @@ public class PlayerCheerController(
/// <summary>POST <c>api/PlayerCheer/v1/SetSelectedCheer</c> — pin
/// a cheer category as the caller's selected badge. Stored on the
/// PlayerSetting key <c>SelectedCheer</c> (string-form int).
///
/// Form field is <c>CheerCategory</c> — NOT <c>Cheer</c>. The 2020.12
/// watch's AIGGBBFKAKL.SetSelectedCheer (at
/// <c>AIGGBBFKAKL.txt:1083-1084</c>) does
/// <c>AddField("CheerCategory", value)</c> when POSTing to
/// <c>api/PlayerCheer/v1/SetSelectedCheer</c>. A mismatched binding
/// silently parses to 0, which the watch reads back as "no badge",
/// so the player's pinned cheer reverts every time.
///
/// The posted value is the watch's PIDFCOGLFHN category enum (e.g.
/// <c>9000</c> for a specific category) — we store it verbatim and
/// round-trip through <see cref="Reputation.SelectedCheer"/>; the
/// watch maps it back to the visual badge.</summary>
/// PlayerSetting key <c>SelectedCheer</c> (string-form int).</summary>
[HttpPost("api/PlayerCheer/v1/SetSelectedCheer")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetSelectedCheer(
[FromForm(Name = "CheerCategory")] int? cheerCategory = null,
[FromForm(Name = "Cheer")] int? legacyCheer = null)
public async Task<IActionResult> SetSelectedCheer([FromForm(Name = "Cheer")] int cheer = 0)
{
// Accept either field name — `CheerCategory` for 2020.12, `Cheer`
// as a defensive fallback for older / re-skinned clients.
var value = cheerCategory ?? legacyCheer ?? 0;
var existing = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == Me && s.Key == "SelectedCheer");
if (existing is null)
db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = Me, Key = "SelectedCheer", Value = value.ToString() });
db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = Me, Key = "SelectedCheer", Value = cheer.ToString() });
else
existing.Value = value.ToString();
existing.Value = cheer.ToString();
await db.SaveChangesAsync();
return Ok(new { success = true, error = "" });
}
@@ -184,19 +184,13 @@ public class PlayerEventsController(
/// <summary>POST <c>api/playerevents/v1/respond</c> — set the
/// caller's RSVP state for an event. <c>Response</c> is the
/// <c>PlayerEventResponseType</c> enum (0=Going, 1=Maybe, 2=Pass).
/// Idempotent — re-responding overwrites.
///
/// Wire return is a BARE int (<c>DGOPHENCPOC</c> enum value:
/// 0=Success, plus various error codes). Returning an object
/// here throws InvalidCastException on the 2020.12 deserialiser
/// — verified at <c>recroom-2020-client-response-contracts.md</c>
/// under the playerevents/v1/respond section.</summary>
/// Idempotent — re-responding overwrites.</summary>
[HttpPost("api/playerevents/v1/respond")]
public async Task<IActionResult> RespondForm(
[FromForm(Name = "EventId")] long? eventId,
[FromForm(Name = "Response")] int response = 0)
{
if (eventId is not long evt || evt <= 0) return Ok(2 /*NoSuchEvent*/);
if (eventId is not long evt || evt <= 0) return Ok(new { success = false });
var existing = await db.PlayerEventResponses
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
if (existing is null)
@@ -204,20 +198,19 @@ public class PlayerEventsController(
else
existing.Response = response;
await db.SaveChangesAsync();
return Ok(0 /*Success*/);
return Ok(new { success = true, error = "" });
}
/// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop
/// the caller's RSVP row. Same bare-int wire shape as
/// <see cref="RespondForm"/>.</summary>
/// the caller's RSVP row.</summary>
[HttpPost("api/playerevents/v1/deleteResponse")]
public async Task<IActionResult> DeleteResponseForm([FromForm(Name = "EventId")] long? eventId)
{
if (eventId is not long evt) return Ok(2 /*NoSuchEvent*/);
if (eventId is not long evt) return Ok(new { success = true });
await db.PlayerEventResponses
.Where(r => r.PlayerId == Me && r.EventId == evt)
.ExecuteDeleteAsync();
return Ok(0 /*Success*/);
return Ok(new { success = true, error = "" });
}
/// <summary>DELETE <c>api/playerevents/v2/delete/{id}</c> — hard
@@ -306,15 +299,11 @@ public class PlayerEventsController(
public string? Details { get; set; }
}
/// <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
/// <c>{Reported:true}</c> throws on the strict deserialiser.</summary>
[HttpPost("api/playerevents/v1/report")]
public async Task<IActionResult> Report([FromBody] PlayerEventReportRequest req)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
if (ev is null) return Ok(new { Success = false, Message = "no_such_event" });
if (ev is null) return NotFound();
var msg = (req.Details ?? string.Empty).Trim();
db.Reports.Add(new ReportEntity
{
@@ -325,6 +314,6 @@ public class PlayerEventsController(
Message = msg[..Math.Min(1000, msg.Length)],
});
await db.SaveChangesAsync();
return Ok(new { Success = true, Message = string.Empty });
return Ok(new { Reported = true });
}
}
@@ -25,17 +25,6 @@ namespace DorkNet.Server.Controllers.API.PlayerReporting;
[Authorize]
public class ReportsController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/PlayerReporting/v1/voteToKickReasons")]
[AllowAnonymous]
public IActionResult VoteToKickReasons() => Ok(new[]
{
new { ReportCategory = 1, Reason = "Harassment" },
new { ReportCategory = 2, Reason = "Inappropriate behavior" },
new { ReportCategory = 3, Reason = "Cheating" },
new { ReportCategory = 4, Reason = "Spam" },
new { ReportCategory = 5, Reason = "Other" },
});
[HttpPost("api/PlayerReporting/v1/report")]
[HttpPost("api/PlayerReporting/v1/playerreport")]
[HttpPost("api/PlayerReporting/v2/report")]
@@ -47,7 +47,7 @@ public class PlayersController(PlayerService playerService, NotificationService
if (!updated) return NotFound();
var player = await playerService.GetByIdAsync(CurrentPlayerId);
await notificationService.AccountChanged(player!);
await notificationService.NotifyAsync(CurrentPlayerId, Models.Notification.PushNotificationId.SubscriptionUpdateProfile, PlayerService.ToProfile(player!));
return Ok(PlayerService.ToProfile(player!));
}
@@ -63,7 +63,6 @@ public class PlayersController(PlayerService playerService, NotificationService
await playerService.UpdateBioAsync(id, req.Bio);
var player = await playerService.GetByIdAsync(id);
await notificationService.AccountChanged(player!);
return Ok(PlayerService.ToProfile(player!));
}
}
@@ -1,304 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Controllers.API.Rooms.V2;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Playlists;
/// <summary>
/// 2020.12 playlist CRUD surface — every <c>playlists/*</c> route the
/// watch hits beyond the basic listing/detail reads in
/// <see cref="RoomsController"/>. URL templates are bare (no
/// <c>/roomserver/</c> prefix) because the 2020.12 client emits them
/// that way on the Rooms host; the same routes are NOT mirrored under
/// <c>/roomserver/</c> here to avoid duplicating definitions that already
/// exist on <c>RoomsController</c>.
///
/// Wire shape conventions:
/// • Single mutation responses use <c>BMFAGMFKODA</c> — the union
/// <c>KMKPEOGJDFK</c> entry + <c>Rooms</c> + <c>Tags</c> arrays
/// (same shape that <c>PlaylistDetails</c> in <see cref="RoomsController"/>
/// returns). See <see cref="BuildDetailsResponseAsync"/>.
/// • List responses (cheeredby/me, createdby/me, etc.) use bare
/// <c>List&lt;KMKPEOGJDFK&gt;</c> — the union entry alone.
/// • Bookmark / cheer toggles return <c>{success: true}</c>; the
/// watch's reads come back through <c>interactionby/me</c>.
/// </summary>
[ApiController]
public class PlaylistsController(
PlaylistService playlists,
DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
// ── Create / delete ──────────────────────────────────────────────
public sealed class CreatePlaylistRequest
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public string? Tags { get; set; }
}
[HttpPost("/playlists")]
[Authorize]
public async Task<IActionResult> Create([FromBody] CreatePlaylistRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest(new { error = "missing_name" });
var created = await playlists.CreateAsync(Me, req.Name, req.Description, req.ImageName, req.Tags);
return await BuildDetailsResponseAsync(created.Id);
}
[HttpDelete("/playlists/{playlistId:long}")]
[Authorize]
public async Task<IActionResult> Delete(long playlistId)
{
try
{
var ok = await playlists.DeleteAsync(playlistId, Me);
return ok ? Ok() : NotFound();
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
// ── Per-field mutations (each returns BMFAGMFKODA) ───────────────
public sealed class StringFieldRequest { public string? Value { get; set; } }
public sealed class IntFieldRequest { public int? Value { get; set; } }
public sealed class BoolFieldRequest { public bool? Value { get; set; } }
public sealed class TagsRequest { public string? Tags { get; set; } public List<string>? TagsList { get; set; } }
[HttpPost("/playlists/{playlistId:long}/name")]
[HttpPut("/playlists/{playlistId:long}/name")]
[Authorize]
public Task<IActionResult> PlaylistName(long playlistId, [FromBody] StringFieldRequest req,
[FromForm(Name = "Name")] string? formValue) =>
ApplyMutation(playlistId, p => p.Name = (req?.Value ?? formValue ?? p.Name).Trim());
[HttpPost("/playlists/{playlistId:long}/description")]
[HttpPut("/playlists/{playlistId:long}/description")]
[Authorize]
public Task<IActionResult> PlaylistDescription(long playlistId, [FromBody] StringFieldRequest req,
[FromForm(Name = "Description")] string? formValue) =>
ApplyMutation(playlistId, p => p.Description = req?.Value ?? formValue ?? p.Description);
[HttpPost("/playlists/{playlistId:long}/image")]
[HttpPut("/playlists/{playlistId:long}/image")]
[Authorize]
public Task<IActionResult> PlaylistImage(long playlistId, [FromBody] StringFieldRequest req,
[FromForm(Name = "ImageName")] string? formValue) =>
ApplyMutation(playlistId, p => p.ImageName = req?.Value ?? formValue ?? p.ImageName);
[HttpPost("/playlists/{playlistId:long}/tags")]
[HttpPut("/playlists/{playlistId:long}/tags")]
[Authorize]
public Task<IActionResult> PlaylistTags(long playlistId, [FromBody] TagsRequest? req,
[FromForm(Name = "Tags")] string? formValue) =>
ApplyMutation(playlistId, p =>
{
var csv = req?.Tags
?? (req?.TagsList is { Count: > 0 } list ? string.Join(',', list) : null)
?? formValue
?? p.TagsCsv;
p.TagsCsv = csv;
});
/// <summary>Accessibility / visibility / level-voting / restrictions /
/// warning are persisted on the playlist row even though the 2020
/// PlaylistEntity doesn't have dedicated columns for each — the wire
/// shape always reports the union-entry defaults, so for now the
/// mutation is a no-op acknowledgement that returns the unchanged
/// playlist. Real per-field columns can be added when we have a
/// playlist-settings UI to drive them.</summary>
[HttpPost("/playlists/{playlistId:long}/accessibility")]
[HttpPost("/playlists/{playlistId:long}/visibility")]
[HttpPost("/playlists/{playlistId:long}/levelvoting")]
[HttpPost("/playlists/{playlistId:long}/restrictions")]
[HttpPost("/playlists/{playlistId:long}/warning")]
[Authorize]
public Task<IActionResult> PlaylistAck(long playlistId) =>
ApplyMutation(playlistId, _ => { /* no-op: field not persisted yet */ });
private async Task<IActionResult> ApplyMutation(long playlistId, Action<PlaylistEntity> mutator)
{
try
{
var updated = await playlists.ModifyAsync(playlistId, Me, mutator);
if (updated is null) return NotFound();
return await BuildDetailsResponseAsync(playlistId);
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
// ── Member-room mutations ────────────────────────────────────────
[HttpPut("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[HttpPost("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[Authorize]
public async Task<IActionResult> AddRoom(long playlistId, long roomId)
{
try
{
if (!await playlists.AddRoomAsync(playlistId, roomId, Me)) return NotFound();
return await BuildDetailsResponseAsync(playlistId);
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
[HttpDelete("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[Authorize]
public async Task<IActionResult> RemoveRoom(long playlistId, long roomId)
{
try
{
if (!await playlists.RemoveRoomAsync(playlistId, roomId, Me)) return NotFound();
return await BuildDetailsResponseAsync(playlistId);
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
// ── Cheer / favorite toggles ─────────────────────────────────────
[HttpPost("/playlists/{playlistId:long}/interactionby/me/cheer")]
[HttpPut("/playlists/{playlistId:long}/interactionby/me/cheer")]
[Authorize]
public async Task<IActionResult> Cheer(long playlistId)
{
await playlists.SetInteractionAsync(playlistId, Me, cheered: true);
return Ok(new { success = true });
}
[HttpDelete("/playlists/{playlistId:long}/interactionby/me/cheer")]
[Authorize]
public async Task<IActionResult> Uncheer(long playlistId)
{
await playlists.SetInteractionAsync(playlistId, Me, cheered: false);
return Ok(new { success = true });
}
[HttpPost("/playlists/{playlistId:long}/interactionby/me/favorite")]
[HttpPut("/playlists/{playlistId:long}/interactionby/me/favorite")]
[Authorize]
public async Task<IActionResult> Favorite(long playlistId)
{
await playlists.SetInteractionAsync(playlistId, Me, favorited: true);
return Ok(new { success = true });
}
[HttpDelete("/playlists/{playlistId:long}/interactionby/me/favorite")]
[Authorize]
public async Task<IActionResult> Unfavorite(long playlistId)
{
await playlists.SetInteractionAsync(playlistId, Me, favorited: false);
return Ok(new { success = true });
}
// ── My-playlists tabs (bare-list KMKPEOGJDFK) ───────────────────
[HttpGet("/playlists/createdby/me")]
[Authorize]
public async Task<IActionResult> CreatedByMe()
{
var rows = await playlists.CreatedByAsync(Me);
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
}
[HttpGet("/playlists/cheeredby/me")]
[Authorize]
public async Task<IActionResult> CheeredByMe()
{
var rows = await playlists.CheeredByAsync(Me);
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
}
[HttpGet("/playlists/favoritedby/me")]
[Authorize]
public async Task<IActionResult> FavoritedByMe()
{
var rows = await playlists.FavoritedByAsync(Me);
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
}
/// <summary>GET <c>/playlists/visitedby/me</c> — we don't track
/// per-player playlist visits yet (visits are tracked at the Room
/// level via RoomVisitEntity). Return empty so the watch's
/// playlists tab renders cleanly; populated once we add a
/// PlaylistVisitEntity or derive from member-room visits.</summary>
[HttpGet("/playlists/visitedby/me")]
[Authorize]
public IActionResult VisitedByMe() => Ok(Array.Empty<object>());
// ── Bulk lookup ──────────────────────────────────────────────────
/// <summary>GET <c>/playlists/bulk?id=A&amp;id=B</c> — bulk-by-id
/// lookup for the watch's playlist cache warmup. Bare list of
/// KMKPEOGJDFK entries, one per resolved id (missing ids
/// silently dropped).</summary>
[HttpGet("/playlists/bulk")]
public async Task<IActionResult> Bulk()
{
var ids = Request.Query["id"].Concat(Request.Query["ids"])
.SelectMany(v => (v ?? string.Empty).Split(',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(s => long.TryParse(s, out var n) ? n : 0)
.Where(n => n != 0)
.ToList();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await playlists.BulkAsync(ids);
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
}
// ── Detail-response builder (BMFAGMFKODA shape) ─────────────────
/// <summary>
/// BMFAGMFKODA wire shape: KMKPEOGJDFK union entry + Rooms list
/// (List&lt;KLCOGEIGEBJ&gt;) + Tags list (List&lt;DPHPFLGAICI&gt;).
/// Mirrors the inline logic in <see cref="RoomsController.PlaylistDetails"/>
/// so the BARE-PATH mutation routes here return the same shape the
/// watch's <c>PlaylistDetails.Deserialize</c> reads.
/// </summary>
private async Task<IActionResult> BuildDetailsResponseAsync(long playlistId)
{
var p = await playlists.GetByIdAsync(playlistId);
if (p is null) return NotFound();
var roomIds = await playlists.RoomIdsAsync(playlistId);
List<RoomEntity> memberRooms = new();
Dictionary<long, IReadOnlyList<RoomSceneEntity>> scenesByRoom = new();
if (roomIds.Count > 0)
{
var roomRows = await db.Rooms.Where(r => roomIds.Contains(r.Id)).ToListAsync();
var byId = roomRows.ToDictionary(r => r.Id);
memberRooms = roomIds
.Select(rid => byId.TryGetValue(rid, out var r) ? r : null)
.Where(r => r is not null).Select(r => r!).ToList();
var sceneRows = await db.RoomScenes
.Where(s => roomIds.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex).ToListAsync();
scenesByRoom = sceneRows.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
}
var roomsWire = memberRooms
.Select(r => RoomsController.BuildRoomServerDetails(r, scenesByRoom.GetValueOrDefault(r.Id)))
.ToList();
var tagsWire = string.IsNullOrWhiteSpace(p.TagsCsv)
? new List<object>()
: p.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new { Type = 0, Tag = t })
.ToList();
var result = RoomsController.BuildPlaylistUnionEntry(p);
result["Rooms"] = roomsWire;
result["Tags"] = tagsWire;
return Ok(result);
}
}
@@ -21,7 +21,8 @@ namespace DorkNet.Server.Controllers.API.ProgressionApi;
public class ProgressionController(
DorkNetDbContext db,
LevelService level,
ServerSettingsService serverSettings) : ControllerBase
ServerSettingsService serverSettings,
StoreService store) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
private long? MeOrNull => this.CurrentPlayerId();
@@ -60,16 +61,18 @@ public class ProgressionController(
var mapId = CurrentChallengeMapId();
var weekly = await serverSettings.GetWeeklyChallengesAsync();
var challengeDefs = weekly.Challenges;
var keys = challengeDefs.Select(c => ChallengeProgressKey(mapId, c.Index)).ToArray();
var rows = pid is long me
var defs = weekly.Challenges;
var keys = defs.Select(c => ChallengeProgressKey(mapId, c.Index)).ToArray();
var rows = pid is long me && keys.Length > 0
? await db.ObjectiveProgress
.Where(o => o.PlayerId == me && keys.Contains(o.Key))
.ToDictionaryAsync(o => o.Key, o => o.IsCompleted)
: new Dictionary<string, bool>();
bool Done(int idx) => rows.TryGetValue(keys[idx], out var v) && v;
bool Done(int idx) => rows.TryGetValue(ChallengeProgressKey(mapId, idx), out var v) && v;
var reward = weekly.Reward;
return Ok(new
{
ChallengeMapId = mapId,
@@ -77,13 +80,8 @@ public class ProgressionController(
StartAt = start,
EndAt = start.AddDays(7),
ServerTime = now,
// 2020.12 ChallengeMap.Deserialize (FNEEJMCEPOL.PPGFHEDFBEA) reads
// FallbackGiftName as a required key. Missing → KeyNotFoundException
// → "Received malformed RecNet response" → blocks the weekly-challenge
// promise chain at boot.
FallbackGiftName = string.Empty,
ChallengeThemeString = "Weekly",
Challenges = challengeDefs.Select(c => new
Challenges = defs.Select(c => new
{
ChallengeId = ChallengeId(mapId, c.Index),
c.Name,
@@ -92,32 +90,65 @@ public class ProgressionController(
c.Tooltip,
Complete = Done(c.Index),
}),
Gift = new
{
GiftDropId = weekly.Reward.GiftDropId != 0 ? weekly.Reward.GiftDropId : mapId,
weekly.Reward.AvatarItemDesc,
weekly.Reward.ConsumableItemDesc,
weekly.Reward.EquipmentPrefabName,
weekly.Reward.EquipmentModificationGuid,
weekly.Reward.Xp,
weekly.Reward.Level,
weekly.Reward.GiftContext,
weekly.Reward.GiftRarity,
},
Gift = BuildGiftDto(mapId, reward),
});
}
/// <summary>Project a <see cref="WeeklyChallengeReward"/> onto the
/// 2020.03 <c>ChallengeGift</c> wire shape (dump.cs ChallengeGift,
/// TypeDefIndex 11455). <c>AvatarItemType</c> is sent as
/// <c>Outfit</c> (0) whenever an <c>AvatarItemDesc</c> is present and
/// <c>null</c> otherwise — the client's gift-card render binds the
/// avatar preview through it, and an <c>AvatarItemDesc</c> with no
/// <c>AvatarItemType</c> leaves that binding half-populated (same
/// failure mode StoreService documents for the shop grid).</summary>
private static object BuildGiftDto(int mapId, WeeklyChallengeReward reward) => new
{
GiftDropId = reward.GiftDropId != 0 ? reward.GiftDropId : mapId,
reward.AvatarItemDesc,
AvatarItemType = string.IsNullOrEmpty(reward.AvatarItemDesc)
? (int?)null
: StoreAvatarItemTypeOutfit,
reward.ConsumableItemDesc,
reward.EquipmentPrefabName,
reward.EquipmentModificationGuid,
Xp = reward.Xp,
reward.Level,
GiftContext = reward.GiftContext,
GiftRarity = reward.GiftRarity,
};
/// <summary>AvatarItemType.Outfit — kept in sync with
/// <c>StoreService</c>'s constant of the same value.</summary>
private const int StoreAvatarItemTypeOutfit = 0;
// ObjectiveProgress row key for a weekly challenge slot.
private static string ChallengeProgressKey(int mapId, int index) => $"challenge:{mapId}:{index}";
// Stable per-map ChallengeId the watch round-trips: mapId*10 + slot
// index. Caps the slate at 10 challenges (NormalizeWeeklyChallenges
// enforces the same Take(10)).
private static int ChallengeId(int mapId, int index) => mapId * 10 + index;
// Recover the slot index from whatever the watch echoes back. Prefer
// the mapId*10 + index encoding; fall back to the low digit for
// clients that send a bare id. Clamp into the configured range.
private static int ChallengeIndex(int mapId, int challengeId, int challengeCount)
{
if (challengeId >= mapId * 10 && challengeId < mapId * 10 + 10)
return Math.Clamp(challengeId - mapId * 10, 0, challengeCount - 1);
return Math.Clamp(Math.Abs(challengeId % 10), 0, challengeCount - 1);
}
public sealed class UpdateChallengeRequest
{
public int Id { get; set; }
public int ChallengeMapId { get; set; }
public int ChallengeId { get; set; }
public int Progress { get; set; }
public string? Config { get; set; }
public bool? Complete { get; set; }
}
[HttpPost("api/challenge/v1/updateProgress")]
[HttpPost("api/challenge/v2/updateProgress")]
[Authorize]
public async Task<IActionResult> UpdateChallengeProgress([FromForm] UpdateChallengeRequest formReq)
@@ -126,6 +157,7 @@ public class ProgressionController(
var req = await ReadChallengeRequestAsync(formReq);
var mapId = req.ChallengeMapId != 0 ? req.ChallengeMapId : CurrentChallengeMapId();
var challengeId = req.ChallengeId != 0 ? req.ChallengeId : req.Id;
var weekly = await serverSettings.GetWeeklyChallengesAsync();
var challengeCount = Math.Max(1, weekly.Challenges.Count);
var index = ChallengeIndex(mapId, challengeId, challengeCount);
@@ -137,8 +169,7 @@ public class ProgressionController(
row = new ObjectiveProgressEntity { PlayerId = pid, Key = key };
db.ObjectiveProgress.Add(row);
}
var shouldComplete = req.Complete ?? true;
if (shouldComplete && !row.IsCompleted)
if (!row.IsCompleted)
{
row.IsCompleted = true;
row.ClearedAt = DateTime.UtcNow;
@@ -147,29 +178,36 @@ public class ProgressionController(
var weeklyRewarded = false;
var allKeys = weekly.Challenges
.Select(c => c.Index)
.Select(i => ChallengeProgressKey(mapId, i))
.Select(c => ChallengeProgressKey(mapId, c.Index))
.ToArray();
var completeCount = await db.ObjectiveProgress
.CountAsync(o => o.PlayerId == pid && allKeys.Contains(o.Key) && o.IsCompleted);
if (completeCount >= allKeys.Length)
{
var reward = weekly.Reward;
weeklyRewarded = await GrantRewardOnceAsync(
pid,
$"weekly-challenge:{mapId}",
xp: weekly.Reward.Xp,
tokens: weekly.Reward.Tokens,
xp: reward.Xp,
tokens: reward.Tokens,
reason: $"weekly_challenge:{mapId}");
// Land the actual skin/consumable, not just XP + tokens — the
// December build only granted currency, so configured item
// rewards showed on the watch but never reached the player's
// inventory. Gated behind the same once-only sentinel so a
// re-submit doesn't re-grant: only grant the item on the boot
// transition where currency was also granted.
if (weeklyRewarded && !string.IsNullOrWhiteSpace(reward.Slug))
await store.GrantItemFreeBySlugAsync(pid, reward.Slug);
}
return Ok(new
{
ChallengeMapId = mapId,
ChallengeId = challengeId,
Config = req.Config ?? string.Empty,
Complete = row.IsCompleted,
Progress = Math.Max(req.Progress, row.IsCompleted ? 10 : 0),
IsCompleted = row.IsCompleted,
Progress = Math.Max(req.Progress, 10),
IsCompleted = true,
ClearedAt = row.ClearedAt,
Rewarded = weeklyRewarded,
});
@@ -282,13 +320,13 @@ public class ProgressionController(
var isRewarded = await IsRewardedAsync(pid, rewardKey);
return Ok(new
{
// 2020.12 NMEKJOABIFC.PPGFHEDFBEA: HasClaimedReward (not IsRewarded).
req.Index,
req.Group,
req.Index,
Progress = row.IsCompleted ? 1f : req.Progress,
VisualProgress = row.IsCompleted ? 1f : req.Progress,
IsCompleted = row.IsCompleted,
HasClaimedReward = isRewarded,
IsRewarded = isRewarded,
IsDirty = false,
});
}
@@ -349,10 +387,6 @@ public class ProgressionController(
.Select(o =>
{
var (grp, idx) = ParseObjectiveKey(o.Key);
// 2020.12 NMEKJOABIFC.PPGFHEDFBEA reads Index, Group, Progress,
// VisualProgress, IsCompleted, HasClaimedReward (NOT IsRewarded).
// Missing HasClaimedReward → KeyNotFoundException → "Couldn't
// download progress data" → BootSequence kick to LogoutScene.
return new
{
Index = idx,
@@ -360,7 +394,8 @@ public class ProgressionController(
Progress = o.IsCompleted ? 1f : 0f,
VisualProgress = o.IsCompleted ? 1f : 0f,
IsCompleted = o.IsCompleted,
HasClaimedReward = rewardKeys.Contains(o.Key),
IsRewarded = rewardKeys.Contains(o.Key),
IsDirty = false,
};
});
@@ -415,18 +450,10 @@ public class ProgressionController(
});
}
/// <summary>POST <c>api/players/v1/objectives</c> +
/// <c>api/players/v2/objectives</c> — batched ProgressionManager
/// completion records (the watch's
/// <c>ACJLCBNBJDK.EIFPPIPOIHB(List&lt;PIHHFPDKFAG&gt;)</c>; body is a
/// JSON array of <c>{objectiveType: int, additionalXp: int,
/// inParty: bool}</c>). Grants XP + RecCenter tokens via
/// <see cref="RewardForObjectiveType"/> and dedupes per UTC day
/// through <see cref="GrantRewardOnceAsync"/>, so a re-submission of
/// the same objective on the same day is a no-op. Returns the
/// granted XP / currency + post-grant balance so the watch UI can
/// refresh without a follow-up GET.</summary>
[HttpPost("api/players/v1/objectives")]
/// <summary>POST <c>api/players/v2/objectives</c> — batched
/// ProgressionManager completion records. These are separate from
/// ObjectiveProgress rows: they grant XP/currency for daily
/// objective events and must be idempotent per UTC day.</summary>
[HttpPost("api/players/v2/objectives")]
[Authorize]
public async Task<IActionResult> CompletePlayerObjectives([FromBody] List<PlayerObjectiveCompletionRecord>? records)
@@ -478,18 +505,6 @@ public class ProgressionController(
private static int CurrentChallengeMapId() =>
(int)((DateTime.UtcNow - new DateTime(2020, 1, 1)).Days / 7 % 52 + 1);
private static string ChallengeProgressKey(int mapId, int index) => $"challenge:{mapId}:{index}";
private static int ChallengeId(int mapId, int index) => mapId * 10 + index;
private static int ChallengeIndex(int mapId, int challengeId, int challengeCount)
{
if (challengeId >= mapId * 10 && challengeId < mapId * 10 + 10)
return Math.Clamp(challengeId - mapId * 10, 0, challengeCount - 1);
return Math.Clamp(Math.Abs(challengeId % 10), 0, challengeCount - 1);
}
private async Task<UpdateChallengeRequest> ReadChallengeRequestAsync(UpdateChallengeRequest formReq)
{
if (Request.HasFormContentType || formReq.Id != 0 || formReq.ChallengeId != 0 || formReq.ChallengeMapId != 0)
@@ -340,41 +340,27 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
// Watch URLs: "api/relationships/v1/favorite?id={N}" and
// "api/relationships/v1/unfavorite?id={N}". These return a
// boolean-ish RecNetResult per the FavoritePlayer/UnfavoritePlayer
// disassembly, not a Relationship. The flag is persisted on the caller's
// directional row and surfaced back through v2/get + personaldetails
// (see BuildMerged).
//
// VERB: the 2020 watch sends these as GET (RecNet.Relationships
// FavoritePlayer/UnfavoritePlayer/etc. call Core.Get — verified in the
// readable March dump at Relationships.txt:1675/1834, and the December
// build hits the same obfuscated Core-GET wrapper). The endpoints were
// POST-only, so the watch's GET returned HTTP 405 ("Error favoriting
// player: HTTP Error 405"). Accept GET (real verb) + POST (cross-build
// safety), mirroring the friend endpoints above.
[HttpGet("api/relationships/v1/favorite")]
// disassembly (Core.Post … ExpectHttpStatusSuccess), not a
// Relationship. The flag is persisted on the caller's directional row
// and surfaced back through v2/get + personaldetails (see BuildMerged).
[HttpPost("api/relationships/v1/favorite")]
public Task<IActionResult> Favorite([FromQuery] long id) => SetPreference(id, favorited: true);
[HttpGet("api/relationships/v1/unfavorite")]
[HttpPost("api/relationships/v1/unfavorite")]
public Task<IActionResult> Unfavorite([FromQuery] long id) => SetPreference(id, favorited: false);
[HttpGet("api/relationships/v1/mute")]
[HttpPost("api/relationships/v1/mute")]
public Task<IActionResult> Mute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, muted: true);
[HttpGet("api/relationships/v1/unmute")]
[HttpPost("api/relationships/v1/unmute")]
public Task<IActionResult> Unmute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, muted: false);
[HttpGet("api/relationships/v1/ignore")]
[HttpPost("api/relationships/v1/ignore")]
public Task<IActionResult> Ignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, ignored: true);
[HttpGet("api/relationships/v1/unignore")]
[HttpPost("api/relationships/v1/unignore")]
public Task<IActionResult> Unignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, ignored: false);
@@ -1,280 +0,0 @@
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;
namespace DorkNet.Server.Controllers.API.RoomKeys;
[ApiController]
[Authorize]
public class RoomKeysController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
// Boot-sequence call — the 2020.12 client hits this with roomId=-1
// before the user has authenticated. Class-level [Authorize] would
// 401 and the client treats that as fatal, kicking back to LogoutScene.
[AllowAnonymous]
[HttpGet("api/roomkeys/v1/room")]
public async Task<IActionResult> ForRoom([FromQuery] long roomId)
{
if (roomId <= 0) return Ok(Array.Empty<object>());
var rows = await db.RoomKeys
.Where(k => k.RoomId == roomId && !k.IsDeleted)
.OrderBy(k => k.Price)
.ThenBy(k => k.Name)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("api/roomkeys/v1/mine")]
public async Task<IActionResult> Mine()
{
var pid = Me;
var rows = await db.RoomKeys
.Where(k => k.CreatorPlayerId == pid && !k.IsDeleted)
.OrderByDescending(k => k.UpdatedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpPost("api/roomkeys/v1/create")]
public async Task<IActionResult> Create()
{
var req = await ReadBodyAsync<NewRoomKeyRequest>();
var status = ValidateText(req.Name, req.Description, req.Price);
if (status != RoomKeyStatus.Success)
return Ok(RoomKeyResponse(status, null));
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId);
if (room is null) return Ok(RoomKeyResponse(RoomKeyStatus.RoomDoesNotExist, null));
if (!await CanManageRoomKeysAsync(room, Me))
return Ok(RoomKeyResponse(RoomKeyStatus.PermissionDenied, null));
var activeCount = await db.RoomKeys.CountAsync(k => k.RoomId == room.Id && !k.IsDeleted);
if (activeCount >= 100)
return Ok(RoomKeyResponse(RoomKeyStatus.RoomKeyLimitReached, null));
var duplicate = await db.RoomKeys.AnyAsync(k =>
k.RoomId == room.Id && !k.IsDeleted && k.Name == req.Name!.Trim());
if (duplicate) return Ok(RoomKeyResponse(RoomKeyStatus.DuplicateName, null));
var key = new RoomKeyEntity
{
RoomId = room.Id,
CreatorPlayerId = Me,
Name = req.Name!.Trim(),
Description = req.Description!.Trim(),
Price = req.Price,
ReplicationId = Guid.NewGuid().ToString("D"),
};
db.RoomKeys.Add(key);
await db.SaveChangesAsync();
return Ok(RoomKeyResponse(RoomKeyStatus.Success, key));
}
[HttpPost("api/roomkeys/v1/update")]
public async Task<IActionResult> Update()
{
var req = await ReadBodyAsync<UpdateRoomKeyRequest>();
var key = await db.RoomKeys.FirstOrDefaultAsync(k => k.Id == req.RoomKeyId && !k.IsDeleted);
if (key is null) return Ok(RoomKeyResponse(RoomKeyStatus.DoesNotExist, null));
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == key.RoomId);
if (room is null) return Ok(RoomKeyResponse(RoomKeyStatus.RoomDoesNotExist, null));
if (!await CanManageRoomKeysAsync(room, Me))
return Ok(RoomKeyResponse(RoomKeyStatus.PermissionDenied, key));
var nextName = req.Name?.Trim() ?? key.Name;
var nextDescription = req.Description?.Trim() ?? key.Description;
var nextPrice = req.Price ?? key.Price;
var status = ValidateText(nextName, nextDescription, nextPrice);
if (status != RoomKeyStatus.Success)
return Ok(RoomKeyResponse(status, key));
var duplicate = await db.RoomKeys.AnyAsync(k =>
k.Id != key.Id && k.RoomId == key.RoomId && !k.IsDeleted && k.Name == nextName);
if (duplicate) return Ok(RoomKeyResponse(RoomKeyStatus.DuplicateName, key));
key.Name = nextName;
key.Description = nextDescription;
key.Price = nextPrice;
key.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(RoomKeyResponse(RoomKeyStatus.Success, key));
}
[HttpDelete("api/roomkeys/v1/delete/{roomKeyId:long}")]
[HttpPost("api/roomkeys/v1/delete/{roomKeyId:long}")]
public async Task<IActionResult> Delete(long roomKeyId)
{
var key = await db.RoomKeys.FirstOrDefaultAsync(k => k.Id == roomKeyId && !k.IsDeleted);
if (key is null) return Content(((int)RoomKeyStatus.DoesNotExist).ToString(), "application/json");
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == key.RoomId);
if (room is null) return Content(((int)RoomKeyStatus.RoomDoesNotExist).ToString(), "application/json");
if (!await CanManageRoomKeysAsync(room, Me))
return Content(((int)RoomKeyStatus.PermissionDenied).ToString(), "application/json");
key.IsDeleted = true;
key.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Content(((int)RoomKeyStatus.Success).ToString(), "application/json");
}
[HttpGet("api/roomkeys/v1/purchased/{roomKeyId:long}")]
public async Task<IActionResult> Purchased(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");
}
public static object RoomKeyResponse(RoomKeyStatus status, RoomKeyEntity? key) => new
{
Status = (int)status,
RoomKey = key is null ? null : ToWire(key),
};
public static object ToWire(RoomKeyEntity key) => new
{
RoomKeyId = key.Id,
ReplicationId = Guid.TryParse(key.ReplicationId, out var guid)
? guid
: Guid.Empty,
key.RoomId,
key.Name,
key.Description,
key.Price,
};
private static RoomKeyStatus ValidateText(string? name, string? description, int price)
{
if (string.IsNullOrWhiteSpace(name) || name.Trim().Length < 3) return RoomKeyStatus.NameTooShort;
if (name.Trim().Length > 40) return RoomKeyStatus.NameTooLong;
if (string.IsNullOrWhiteSpace(description) || description.Trim().Length < 10) return RoomKeyStatus.DescriptionTooShort;
if (description.Trim().Length > 174) return RoomKeyStatus.DescriptionTooLong;
if (price < 10) return RoomKeyStatus.PriceTooLow;
if (price > 10000) return RoomKeyStatus.PriceTooHigh;
return RoomKeyStatus.Success;
}
private async Task<bool> CanManageRoomKeysAsync(RoomEntity room, long playerId)
{
if (room.CreatorPlayerId == playerId) return true;
var isCoOwner = await db.RoomRoles.AnyAsync(r =>
r.RoomId == room.Id && r.PlayerId == playerId && r.Role == 0 && r.Accepted);
if (isCoOwner) return true;
return await db.Players
.Where(p => p.Id == playerId)
.Select(p => p.IsAdmin)
.FirstOrDefaultAsync();
}
private async Task<T> ReadBodyAsync<T>() where T : new()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
if (typeof(T) == typeof(NewRoomKeyRequest))
{
object req = new NewRoomKeyRequest
{
RoomId = long.TryParse(form["roomId"].FirstOrDefault()
?? form["RoomId"].FirstOrDefault(), out var roomId)
? roomId
: 0,
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
: 0,
};
return (T)req;
}
if (typeof(T) == typeof(UpdateRoomKeyRequest))
{
object req = new UpdateRoomKeyRequest
{
RoomKeyId = long.TryParse(form["roomKeyId"].FirstOrDefault()
?? form["RoomKeyId"].FirstOrDefault(), out var roomKeyId)
? roomKeyId
: 0,
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,
};
return (T)req;
}
return new T();
}
try
{
return await JsonSerializer.DeserializeAsync<T>(Request.Body, JsonOptions) ?? new T();
}
catch (JsonException)
{
return new T();
}
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public sealed class NewRoomKeyRequest
{
[JsonPropertyName("roomId")] public long RoomId { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
[JsonPropertyName("price")] public int Price { get; set; }
}
public sealed class UpdateRoomKeyRequest
{
[JsonPropertyName("roomKeyId")] public long RoomKeyId { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
[JsonPropertyName("price")] public int? Price { get; set; }
}
}
public enum RoomKeyStatus
{
Success = 0,
InvalidParameters = 1,
DoesNotExist = 2,
NameTooShort = 3,
NameTooLong = 4,
DuplicateName = 5,
InappropriateName = 6,
DescriptionTooShort = 7,
DescriptionTooLong = 8,
InappropriateDescription = 9,
PriceTooLow = 10,
PriceTooHigh = 11,
PermissionDenied = 12,
PlayerHasRoomUnderModerationReview = 13,
JuniorStatusFail = 14,
PlayerIsNotCoOwner = 15,
RoomKeyLimitReached = 16,
PlayerAlreadyOwns = 17,
RoomUnderModerationReview = 18,
PurchaseFailed = 19,
RoomDoesNotExist = 20,
PaidKeyPurchasingDisabled = 21,
CreateOrModifyKeysDisabled = 22,
RoomKeyUnderModerationReview = 23,
PlayerRestrictedFromP2PSelling = 24,
PlayerNotRecRoomPlusMember = 25,
}
@@ -1,7 +1,6 @@
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.Controllers.Match;
@@ -19,7 +18,7 @@ namespace DorkNet.Server.Controllers.API.Rooms.V2;
/// v1/hot, v1/filters, v1/tags, v1/search, v2/baserooms, v2/myrooms,
/// v2/mybookmarks, v2/mymoderated, v2/mysubscribed, v2/myrecent,
/// v2/name/{name}, v2/{id}, v3/{id}, v3/details/{id}, v4/details/{id},
/// roomserver/rooms/{id}, v1/personaldetails/{id}, v2/personaldetails/{id}.
/// v1/personaldetails/{id}, v2/personaldetails/{id}.
///
/// All endpoints serialize Rooms via RoomService.ToWireRoom which matches
/// Room.Deserialize at RVA 0x114E430 (PascalCase keys, all 16 required
@@ -28,13 +27,11 @@ namespace DorkNet.Server.Controllers.API.Rooms.V2;
[ApiController]
public class RoomsController(
RoomService rooms,
PlaylistService playlists,
DorkNetDbContext db,
PlayerPresenceService presence,
OnlinePresenceService onlinePresence,
DomainConfig domain,
NotificationService notifications,
ServerSettingsService serverSettings,
ILogger<RoomsController> logger) : ControllerBase
{
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
@@ -48,71 +45,16 @@ public class RoomsController(
/// TagsCsv column (e.g. `#community`, `#recroomoriginal`).
/// </summary>
[HttpGet("api/rooms/v1/hot")]
[HttpGet("rooms/hot")]
public async Task<IActionResult> Hot(
[FromQuery] string? roomScoreType,
[FromQuery] string? tags)
=> Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList());
/// <summary>
/// GET <c>/roomserver/rooms/recommendations</c> — the home-tab
/// "Recommended" carousel. <b>Wire shape is NOT a flat
/// <c>List&lt;Room&gt;</c></b>; the watch's
/// <c>EJDCNGBEICB.JDKLBNBIEAH</c> at <c>EJDCNGBEICB.txt:1349,1430</c>
/// returns <c>IPromise&lt;List&lt;ANNKHNFLMNP&gt;&gt;</c> and
/// <see cref="ANNKHNFLMNP.PPGFHEDFBEA"/> at
/// <c>ANNKHNFLMNP.txt:43-97</c> reads <c>SeedRoom</c> (sub-object,
/// <c>KLCOGEIGEBJ</c> i.e. full Room) and <c>Rooms</c> (list of
/// Rooms) as required keys. Returning <c>[{room}, …]</c> raised
/// <c>KeyNotFoundException: Failed to find key 'Rooms' when
/// deserializing object of type KLCOGEIGEBJ</c>.
///
/// We group the hot list into one "card" per room — that gives the
/// watch one row per featured room with that room as its own
/// SeedRoom and a single-element Rooms list. Cheap, semantically
/// correct, and avoids the deserializer-crashing empty-Rooms
/// edge case the watch hits when a group has no rooms.
/// </summary>
[HttpGet("roomserver/rooms/recommendations")]
[HttpGet("rooms/recommendations")]
public async Task<IActionResult> Recommendations(
[FromQuery(Name = "splitTestId")] int? splitTestId,
[FromQuery(Name = "splitTestValue")] int? splitTestValue)
{
var hot = await rooms.HotAsync(null, take: 12);
if (hot.Count == 0) return Ok(Array.Empty<object>());
var roomIds = hot.Select(r => r.Id).ToList();
var sceneRows = await db.RoomScenes
.Where(s => roomIds.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 wireRooms = hot
.Select(r => BuildRoomServerDetails(r, scenesByRoom.GetValueOrDefault(r.Id)))
.ToList();
// One ANNKHNFLMNP entry per room: SeedRoom = the room itself,
// Rooms = [that room]. The watch enumerates the outer list to
// build the row list; each row shows its SeedRoom as headline.
var result = wireRooms.Select(room => new Dictionary<string, object>(StringComparer.Ordinal)
{
["SeedRoom"] = room,
["Rooms"] = new List<object> { room },
}).ToList();
return Ok(result);
}
/// <summary>
/// `Rooms.SearchForRooms(query)` — free-text room search.
/// </summary>
[HttpGet("api/rooms/v1/search")]
[HttpGet("api/rooms/v2/search")]
[HttpGet("rooms/search")]
public async Task<IActionResult> Search(
[FromQuery(Name = "query")] string? query,
[FromQuery(Name = "value")] string? value)
@@ -133,7 +75,6 @@ public class RoomsController(
/// </summary>
[HttpGet("api/rooms/v2/baserooms")]
[HttpGet("api/rooms/v3/baserooms")]
[HttpGet("rooms/base")]
public async Task<IActionResult> BaseRooms()
=> Ok((await rooms.HotAsync("#recroomoriginal")).Select(RoomService.ToWireRoom).ToList());
@@ -145,30 +86,23 @@ public class RoomsController(
// '#' at render time, so sending "#community" produces "##community" in
// the UI. Names stored in the DB also drop the prefix.
[HttpGet("api/rooms/v1/filters")]
public async Task<IActionResult> Filters()
public IActionResult Filters() => Ok(new
{
var tags = await serverSettings.GetPlayMenuTagsAsync();
return Ok(new
{
PinnedFilters = tags.PinnedTags,
PopularFilters = tags.PopularTags,
});
}
PinnedFilters = new[] { "community", "recroomoriginal", "featured", "quest" },
PopularFilters = new[] { "paintball", "dodgeball", "soccer", "lasertag", "discgolf" },
});
[HttpGet("api/rooms/v1/tags")]
public async Task<IActionResult> Tags()
{
var tags = await serverSettings.GetPlayMenuTagsAsync();
return Ok(tags.PinnedTags.Concat(tags.PopularTags).Distinct(StringComparer.OrdinalIgnoreCase).ToArray());
}
public IActionResult Tags() => Ok(new[] {
"community", "recroomoriginal", "featured", "quest",
"paintball", "dodgeball", "soccer", "lasertag", "discgolf",
});
[HttpGet("api/rooms/v1/pinnedtags")]
public async Task<IActionResult> PinnedTags()
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).PinnedTags);
public IActionResult PinnedTags() => Ok(new[] { "community", "recroomoriginal", "featured" });
[HttpGet("api/rooms/v1/populartags")]
public async Task<IActionResult> PopularTags()
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).PopularTags);
public IActionResult PopularTags() => Ok(new[] { "paintball", "dodgeball", "soccer" });
// ── Single room lookups ──────────────────────────────────────────────
@@ -191,7 +125,6 @@ public class RoomsController(
/// </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);
@@ -219,144 +152,50 @@ public class RoomsController(
// Rooms.LocalRoom is populated and the coroutine just needs a
// populated Scenes[0] to get a non-null LocalRoomScene.
/// <summary>GET <c>roomserver/rooms/{roomId}/subrooms/{subRoomId}/datahistory</c>
/// — drives the watch's "Restore to old version" UI in the room-
/// settings tab. Per-subroom (not per-room) — the watch builds the
/// URL from the template
/// <c>String.Format("rooms/{0}/subrooms/{1}/datahistory", roomId, subRoomId)</c>
/// (verified at <c>EJDCNGBEICB.txt:1190</c>); the <c>roomserver/</c>
/// prefix is added by RecNet's URL builder for everything on the
/// rooms host (api.localhost proxies it through the same prefix as the
/// rest of the rooms API — see all the other <c>roomserver/rooms/...</c>
/// routes in this controller).
/// <summary>GET api/rooms/v1/datahistory/{roomId} — returns the
/// version history for a room as a list of saves. Drives the
/// watch's "Restore to old version" UI in the room-settings tab.
///
/// <para><b>SubRoomId semantics:</b> the watch's 2020.12 build treats
/// "SubRoomId" as the <see cref="RoomSceneEntity.OrderIndex"/>, NOT
/// rec.net's long random Guid-like SubRoomId. The heartbeat reports
/// <c>subRoom=11</c> for the 12th scene in <c>RoomDetails.SubRooms[]</c>;
/// this endpoint must filter by OrderIndex to match. The zip-importer
/// stamps each <c>SubRooms/&lt;scene&gt;/History/</c> blob's
/// <see cref="RoomDataBlobEntity.SubRoomId"/> with the scene's
/// OrderIndex at insert time — the rec.net SubRoomId is captured in
/// the sidecar JSON for provenance but isn't what gets indexed.</para>
/// The watch sometimes calls this with <c>roomId=0</c>, which we
/// interpret as "the caller's currently-presence-tracked room"
/// (typically their dorm). Anything else is a literal room id.
///
/// <para>Wire shape: <c>List&lt;RoomDataHistoryDTO&gt;</c>. The
/// watch's <c>DHOCPFIOKHD.PPGFHEDFBEA</c> deserializer reads EXACTLY
/// four PascalCase keys (<c>DHOCPFIOKHD.txt:136-150</c>):
/// <c>SubRoomId</c> (long, strict GetKey), <c>DataBlob</c> (string),
/// <c>SavedByAccountId</c> (long, strict GetKey), <c>CreatedAt</c>
/// (DateTime, GetKeyOrDefault). Missing strict keys throw
/// <c>KeyNotFoundException</c> in LitJson and the watch shows the
/// "Could not retrieve save history from server :-(" toast from
/// <c>UIRestoreRoomDialog.txt:1970</c>. Extra keys are ignored.</para>
///
/// <para>Pre-2026-05 rows (and any rows the live save flow inserts,
/// which doesn't populate SubRoomId yet) have a null SubRoomId and
/// surface under every sub-room's history list — back-compat.</para></summary>
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")]
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")]
public async Task<IActionResult> SubRoomDataHistory(long roomId, long subRoomId)
/// Wire shape: <c>List&lt;RoomDataHistoryDTO&gt;</c>. Deserializer
/// reads EXACTLY three PascalCase keys (verified at
/// <c>Cpp2IL_ISIL/.../RecNet/Rooms_NestedType_RoomDataHistoryDTO.txt</c>):
/// <c>RoomDataHistoryId</c> (long), <c>DataBlobName</c> (string),
/// <c>CreatedAt</c> (DateTime). Extra/wrong keys aren't ignored —
/// missing required keys throw <c>KeyNotFoundException</c> in
/// LitJson and the watch shows an empty history list. Don't add
/// helper fields here; pipe them through a separate admin endpoint
/// if needed.</summary>
[HttpGet("api/rooms/v1/datahistory/{roomId:long}")]
[HttpGet("api/rooms/v2/datahistory/{roomId:long}")]
public async Task<IActionResult> DataHistory(long roomId)
{
// 0 is a sentinel — fall back to the caller's current room
// from PlayerPresenceService.
if (roomId == 0 && CurrentPlayerId is long pid &&
presence.GetRoom(pid)?.RoomId is long currentId)
{
roomId = currentId;
}
if (roomId == 0) return Ok(Array.Empty<object>());
var blobs = await db.RoomDataBlobs
.Where(b => b.RoomId == roomId
&& (b.SubRoomId == subRoomId || b.SubRoomId == null))
// Skip the canonical per-scene blob from the import — the watch
// is asking for HISTORY, and serving the current `room_<id>_<slug>_<scene>.room`
// back to it as an option would make `restore to current` a
// confusing no-op entry in the list.
.Where(b => !b.BlobName.StartsWith($"room_{roomId}_"))
.Where(b => b.RoomId == roomId)
.OrderByDescending(b => b.UploadedAt)
.Take(50)
.Select(b => new
{
SubRoomId = subRoomId,
DataBlob = b.BlobName,
SavedByAccountId = b.UploadedByPlayerId,
RoomDataHistoryId = b.Id,
DataBlobName = b.BlobName,
CreatedAt = b.UploadedAt,
})
.ToListAsync();
return Ok(blobs);
}
/// <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
/// historical blob name from the datahistory list. <paramref name="subRoomId"/>
/// is RoomSceneEntity.OrderIndex (same convention as the GET above).
///
/// Effect: stamp the chosen scene's DataBlobName to the historical
/// blob and, when restoring the room's current entry scene, also
/// update the room-level CurrentDataBlobName so a fresh /v4/details
/// returns the restored bytes too. Then push SubscriptionUpdateRoom
/// so any clients already cached on the room reload the scene.
///
/// Returns CreateModifyRoomSceneResponse — same shape as
/// /api/rooms/v1/datahistory/restore — so the watch's
/// ProcessCreateModifyRoomSceneResponse chain can deserialise and
/// flip the Restore dialog out of its "applying" state.</summary>
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/restoredata")]
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/restoredata")]
[Authorize]
public async Task<IActionResult> SubRoomRestoreData(
long roomId, long subRoomId, [FromForm] string? filename)
{
if (string.IsNullOrWhiteSpace(filename))
return BadRequest(new { error = "missing_filename" });
var pid = this.RequireCurrentPlayerId();
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found" });
if (room.CreatorPlayerId != pid) return Forbid();
var blob = await db.RoomDataBlobs
.FirstOrDefaultAsync(b => b.BlobName == filename && b.RoomId == roomId);
if (blob is null) return NotFound(new { error = "blob_not_found", filename });
var sceneRows = await db.RoomScenes
.Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var sceneRow = sceneRows.FirstOrDefault(s => s.OrderIndex == (int)subRoomId);
if (sceneRow is null) return NotFound(new { error = "subroom_not_found", subRoomId });
var restoredAt = DateTime.UtcNow;
sceneRow.DataBlobName = filename;
sceneRow.DataModifiedAt = restoredAt;
// Only stamp the room-level CurrentDataBlobName when restoring the
// entry scene (OrderIndex 0). Restoring a non-entry sub-room
// shouldn't rewrite what the room loads at the front door.
if (sceneRow.OrderIndex == 0)
{
await db.Rooms
.Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, filename)
.SetProperty(r => r.UpdatedAt, restoredAt));
room.CurrentDataBlobName = filename;
room.UpdatedAt = restoredAt;
}
await db.SaveChangesAsync();
var pushRoom = room.IsDormRoom
? CloneWithCreator(room, pid, room.CurrentDataBlobName)
: CloneWithCreator(room, room.CreatorPlayerId, room.CurrentDataBlobName);
var roomDetailsPayload = BuildRoomDetails(pushRoom, sceneRows, sceneRow.OrderIndex, filename);
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, roomDetailsPayload);
logger.LogInformation(
"[rooms-restore] player={PlayerId} room={RoomId} subRoom={SubRoomId} scene={Scene} blob={Blob}",
pid, roomId, subRoomId, sceneRow.Name, filename);
// Watch's response deserializer is RecNetResult<PPENFJMFPNE> —
// the same legacy success/value/error envelope used by
// roomserver/rooms/{id}/subrooms/{id}/data. `value` must be the
// flattened /roomserver/rooms/{id} DTO, not the modern
// { Room, Scenes } details payload pushed over SubscriptionUpdateRoom.
var roomServerDetails = BuildRoomServerDetails(pushRoom, sceneRows, filename);
return Ok(new { success = true, value = roomServerDetails, error = string.Empty });
}
/// <summary>POST api/rooms/v4/saveData — the watch's
/// <c>RecNet.Rooms.UploadLocalRoomSceneData</c> save flow.
/// Body shape mirrors <c>RecNet.Rooms+SaveRoomSceneRequest</c>
@@ -374,12 +213,6 @@ public class RoomsController(
[HttpPost("api/rooms/v4/saveData")]
[Authorize]
public async Task<IActionResult> SaveData([FromBody] SaveRoomSceneRequest body)
=> await SaveDataCore(body);
private async Task<IActionResult> SaveDataCore(
SaveRoomSceneRequest body,
long? routeRoomId = null,
bool wrapCreateModifyResponse = false)
{
var pid = this.RequireCurrentPlayerId();
@@ -390,13 +223,13 @@ public class RoomsController(
// on every /goto). This is the only signal we have without a
// RoomId on the request body.
var current = presence.GetRoom(pid);
if (current is null && routeRoomId is null)
if (current is null)
{
// No active room — without it we can't pick a target row.
// Return a degenerate RoomScene so the deserializer doesn't
// throw, but flag CanMatchmakeInto=false so the watch
// doesn't think the save succeeded.
var missingPresenceScene = new
return Ok(new
{
RoomSceneId = body.RoomSceneId,
RoomId = 0L,
@@ -407,25 +240,14 @@ public class RoomsController(
MaxPlayers = 8,
CanMatchmakeInto = false,
DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
};
return Ok(wrapCreateModifyResponse
? new { success = true, value = missingPresenceScene, error = string.Empty }
: missingPresenceScene);
});
}
var roomId = routeRoomId ?? current!.RoomId;
var roomId = current.RoomId;
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found" });
var newBlob = body.EffectiveRoomDataFilename.Trim();
if (string.IsNullOrWhiteSpace(newBlob))
{
logger.LogWarning(
"[rooms-save] missing blob filename player={PlayerId} room={RoomId} requestedScene={RequestedSceneId} routeRoom={RouteRoomId}",
pid, roomId, body.RoomSceneId, routeRoomId);
return BadRequest(new { error = "missing_room_data_filename" });
}
var savedAt = DateTime.UtcNow;
var newBlob = body.RoomDataFilename?.Trim() ?? string.Empty;
// Multi-scene rooms have a RoomScenes row per scene. The 2020
// client usually sends the scene's OrderIndex, but some flows
@@ -455,33 +277,27 @@ public class RoomsController(
db.DormStates.Add(dormState);
}
dormState.CurrentDataBlobName = newBlob;
dormState.UpdatedAt = savedAt;
dormState.UpdatedAt = DateTime.UtcNow;
}
else
{
var canSave = room.CreatorPlayerId == pid
|| await db.Players.AnyAsync(p => p.Id == pid && p.IsAdmin)
|| await db.RoomRoles.AnyAsync(r =>
r.RoomId == room.Id &&
r.PlayerId == pid &&
r.Accepted &&
r.Role == 0);
if (!canSave) return Forbid();
// Owned rooms: only the creator can save. Admin override
// intentionally not included — admins should use /restore
// when they need to roll a room back.
if (room.CreatorPlayerId != pid) return Forbid();
}
if (sceneRow is not null)
{
sceneRow.DataBlobName = newBlob;
sceneRow.DataModifiedAt = savedAt;
sceneRow.DataModifiedAt = DateTime.UtcNow;
}
await db.Rooms
.Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, newBlob)
.SetProperty(r => r.UpdatedAt, savedAt));
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
await db.SaveChangesAsync();
room.CurrentDataBlobName = newBlob;
room.UpdatedAt = savedAt;
// The watch's MasterUploadRoomDataBlobCoroutine waits for BOTH
// the /upload + /saveData responses AND a SubscriptionUpdateRoom
@@ -505,7 +321,7 @@ public class RoomsController(
if (sceneRow is not null && scene.Id == sceneRow.Id)
{
scene.DataBlobName = newBlob;
scene.DataModifiedAt = savedAt;
scene.DataModifiedAt = DateTime.UtcNow;
}
}
var savedSceneId = sceneRow?.OrderIndex ?? body.RoomSceneId;
@@ -513,48 +329,30 @@ public class RoomsController(
logger.LogInformation(
"[rooms-save] saveData player={PlayerId} room={RoomId} requestedScene={RequestedSceneId} savedScene={SavedSceneId} blob={BlobName} sceneRow={SceneRowFound} scenes={SceneCount}",
pid, roomId, body.RoomSceneId, savedSceneId, newBlob, sceneRow is not null, sceneRowsForRoom.Count);
var activeInstanceId = current is not null && current.RoomId == roomId
? current.RoomInstanceId
: 0L;
var playersInInstance = activeInstanceId == 0
? new[] { pid }
: onlinePresence.OnlinePlayerIds()
.Where(playerId =>
{
var playerRoom = presence.GetRoom(playerId);
return playerRoom is not null
&& playerRoom.RoomId == roomId
&& playerRoom.RoomInstanceId == activeInstanceId;
})
.Append(pid)
.Distinct()
.ToArray();
var playersInInstance = onlinePresence.OnlinePlayerIds()
.Where(playerId =>
{
var playerRoom = presence.GetRoom(playerId);
return playerRoom is not null
&& playerRoom.RoomId == roomId
&& playerRoom.RoomInstanceId == current.RoomInstanceId;
})
.Append(pid)
.Distinct()
.ToArray();
logger.LogInformation(
"[rooms-save] fanout room update player={PlayerId} room={RoomId} instance={InstanceId} blob={BlobName} recipients={RecipientCount}",
pid, roomId, activeInstanceId, newBlob, playersInInstance.Length);
if (activeInstanceId != 0)
{
var updatedPrivateInstances = await db.PrivateInstances
.Where(instance => instance.Id == activeInstanceId && instance.RoomId == roomId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(instance => instance.DataBlob, newBlob));
if (updatedPrivateInstances > 0)
{
logger.LogInformation(
"[rooms-save] updated private instance blob room={RoomId} instance={InstanceId} blob={BlobName}",
roomId, activeInstanceId, newBlob);
}
}
pid, roomId, current.RoomInstanceId, newBlob, playersInInstance.Length);
var updatedRoomInstances = new Dictionary<long, RoomInstanceDto>();
foreach (var playerId in playersInInstance)
{
var currentPresence = presence.GetRoom(playerId);
if (currentPresence is null && playerId == pid && current is not null)
if (currentPresence is null && playerId == pid)
{
currentPresence = current;
}
if (currentPresence is null || currentPresence.RoomId != roomId) continue;
if (currentPresence is null) continue;
currentPresence.DataBlob = newBlob;
presence.SetRoom(playerId, currentPresence);
@@ -596,31 +394,25 @@ public class RoomsController(
vrMovementMode = 0,
roomInstance = new
{
roomInstanceId = currentPresence.RoomInstanceId,
roomId = currentPresence.RoomId,
subRoomId = currentPresence.SubRoomId,
roomInstanceType = currentPresence.RoomInstanceType,
location = currentPresence.Location,
dataBlob = currentPresence.DataBlob,
eventId = currentPresence.EventId,
clubId = currentPresence.ClubId,
roomCode = currentPresence.RoomCode,
photonRegionId = currentPresence.PhotonRegionId,
photonRoomId = currentPresence.PhotonRoomId,
name = currentPresence.NameWire,
maxCapacity = currentPresence.MaxCapacity,
isFull = currentPresence.IsFull,
isPrivate = currentPresence.IsPrivate,
isInProgress = currentPresence.IsInProgress,
EncryptVoiceChat = currentPresence.EncryptVoiceChat,
roomInstanceId = currentPresence.RoomInstanceId,
roomId = currentPresence.RoomId,
subRoomId = currentPresence.SubRoomId,
location = currentPresence.Location,
photonRegionId = currentPresence.PhotonRegionId,
photonRoomId = currentPresence.PhotonRoomId,
maxCapacity = currentPresence.MaxCapacity,
isFull = currentPresence.IsFull,
isPrivate = currentPresence.IsPrivate,
isInProgress = currentPresence.IsInProgress,
dataBlob = currentPresence.DataBlob,
eventId = currentPresence.EventId,
name = currentPresence.Name,
},
isOnline = true,
appVersion = 20201210,
};
await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdatePresence, presencePayload);
}
var savedScene = new
return Ok(new
{
RoomSceneId = savedSceneId,
RoomId = roomId,
@@ -630,31 +422,17 @@ public class RoomsController(
DataBlobName = newBlob,
MaxPlayers = sceneRow?.MaxPlayers ?? 8,
CanMatchmakeInto = sceneRow?.CanMatchmakeInto ?? true,
DataModifiedAt = savedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"),
};
if (wrapCreateModifyResponse)
{
var roomServerDetails = BuildRoomServerDetails(pushRoom, sceneRowsForRoom, newBlob);
return Ok(new { success = true, value = roomServerDetails, error = string.Empty });
}
return Ok(savedScene);
DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
});
}
public class SaveRoomSceneRequest
{
public long RoomSceneId { get; set; }
public string? RoomDataFilename { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("filename")]
public string? Filename { get; set; }
public List<long>? InventionUsages { get; set; }
public string? InventionUsageBase64 { get; set; }
public CreatorActionContextDto? CreatorActionContext { get; set; }
public long RequestPlayerId { get; set; }
public int SaveRequestPlayerId { get; set; }
public string EffectiveRoomDataFilename =>
RoomDataFilename ?? Filename ?? string.Empty;
}
public class CreatorActionContextDto
@@ -838,53 +616,8 @@ public class RoomsController(
return Ok(new { IsCheering = cheering, IsBookmarked = bookmarked });
}
/// <summary>
/// GET <c>/roomserver/rooms/{id}/interactionby/me</c> — sibling of
/// personaldetails but DIFFERENT wire shape. Per
/// <c>EJDCNGBEICB.txt:8219,8266</c> the deserialiser is
/// <c>CJODCLDGFCF</c>, which reads <c>Cheered</c> + <c>Favorited</c>
/// (PascalCase, both bool, both required — verified at
/// <c>CJODCLDGFCF.txt:118,123</c>). Sharing the
/// <c>{IsCheering, IsBookmarked}</c> payload with personaldetails
/// throws <c>KeyNotFoundException</c> on the watch and stalls the
/// room-tile UI.
/// </summary>
[HttpGet("roomserver/rooms/{roomId:long}/interactionby/me")]
[HttpGet("rooms/{roomId:long}/interactionby/me")]
public async Task<IActionResult> RoomInteractionByMe(long roomId)
{
var pid = CurrentPlayerId ?? 0;
bool cheered = false, favorited = false;
if (pid > 0)
{
cheered = await db.Cheers.AnyAsync(c => c.TargetRoomId == roomId && c.FromPlayerId == pid);
favorited = await db.RoomBookmarks.AnyAsync(b => b.RoomId == roomId && b.PlayerId == pid);
}
return Ok(new { Cheered = cheered, Favorited = favorited });
}
/// <summary>
/// GET <c>/roomserver/playlists/{id}/interactionby/me</c> — same
/// <c>CJODCLDGFCF</c> wire shape as the room variant. Per
/// <c>EJDCNGBEICB.txt:8600</c> the URL is
/// <c>"playlists/{0}/interactionby/me"</c>. Reads from
/// <see cref="PlaylistInteractionEntity"/>; an anonymous caller
/// (no JWT) reads as (false, false) so the watch's pre-login
/// browse renders cleanly.
/// </summary>
[HttpGet("roomserver/playlists/{playlistId:long}/interactionby/me")]
[HttpGet("playlists/{playlistId:long}/interactionby/me")]
public async Task<IActionResult> PlaylistInteractionByMe(long playlistId)
{
var pid = CurrentPlayerId ?? 0;
if (pid <= 0) return Ok(new { Cheered = false, Favorited = false });
var (cheered, favorited) = await playlists.InteractionForAsync(playlistId, pid);
return Ok(new { Cheered = cheered, Favorited = favorited });
}
[HttpGet("api/rooms/v4/details/{roomId:long}")]
[HttpGet("api/rooms/v3/details/{roomId:long}")]
[HttpGet("roomserver/rooms/{roomId:long}")]
public async Task<IActionResult> Details(long roomId)
{
var r = await rooms.GetByIdAsync(roomId);
@@ -897,7 +630,12 @@ public class RoomsController(
// owner's DormStateEntity blob so they load the same room state.
if (room.IsDormRoom)
{
room = await ShapeDormRoomAsync(room);
var dormBlobName = await db.DormStates
.Where(d => d.PlayerId == room.CreatorPlayerId)
.Select(d => d.CurrentDataBlobName)
.FirstOrDefaultAsync()
?? string.Empty;
room = CloneWithCreator(room, room.CreatorPlayerId, dormBlobName);
}
var scenes = await db.RoomScenes
@@ -907,125 +645,9 @@ public class RoomsController(
var roomRoles = await db.RoomRoles
.Where(r => r.RoomId == room.Id)
.ToListAsync();
if (Request.Path.StartsWithSegments("/roomserver", StringComparison.OrdinalIgnoreCase))
return Ok(BuildRoomServerDetails(room, scenes, roles: roomRoles));
return Ok(BuildRoomDetails(room, scenes, roles: roomRoles));
}
[HttpGet("roomserver/rooms")]
[HttpGet("rooms")]
public async Task<IActionResult> RoomServerDetailsByName([FromQuery(Name = "name")] string? roomName)
{
if (string.IsNullOrWhiteSpace(roomName))
return Ok(Array.Empty<object>());
var pid = CurrentPlayerId ?? 0;
RoomEntity? room;
if (roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase) && pid > 0)
{
room = await rooms.EnsurePersonalDormAsync(pid);
}
else
{
room = await rooms.GetByNameAsync(roomName);
}
room ??= Synthetic(roomName);
if (room.IsDormRoom)
{
// Stamp the wire Name with the requested name so the watch's
// name-keyed room cache (OJMCBOKJFOF.NHBPIIGDAJP) can find it.
room = await ShapeDormRoomAsync(room, DormNameOverride(roomName));
}
var scenes = await db.RoomScenes
.Where(s => s.RoomId == room.Id)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var roomRoles = await db.RoomRoles
.Where(r => r.RoomId == room.Id)
.ToListAsync();
return Ok(BuildRoomServerDetails(room, scenes, roles: roomRoles));
}
/// <summary>GET <c>/roomserver/rooms/bulk?name=X&amp;name=Y</c> or
/// <c>?id=1&amp;id=2</c> — bulk room cache lookup. Decomp-verified
/// in EJDCNGBEICB: the Int64 overload writes query key <c>id</c>
/// and the string overload writes <c>name</c>. Returns the same
/// per-room object that <c>/roomserver/rooms/{id}</c> emits;
/// unresolved entries are silently skipped (watch handles a shorter
/// list fine, but a 404 wedges the room browser).</summary>
[HttpGet("roomserver/rooms/bulk")]
[HttpGet("rooms/bulk")]
public async Task<IActionResult> RoomServerBulkByName()
{
var names = Request.Query["name"].Where(n => !string.IsNullOrWhiteSpace(n)).ToArray();
var ids = Request.Query["id"]
.Where(v => !string.IsNullOrWhiteSpace(v))
.SelectMany(v => v!.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(v => long.TryParse(v, out var id) ? id : 0)
.Where(id => id > 0)
.Distinct()
.ToArray();
if (names.Length == 0 && ids.Length == 0) return Ok(Array.Empty<object>());
var pid = CurrentPlayerId ?? 0;
var results = new List<object>(names.Length + ids.Length);
var emitted = new HashSet<long>();
foreach (var raw in names)
{
var name = raw!.Trim();
RoomEntity? room;
if (name.Equals("DormRoom", StringComparison.OrdinalIgnoreCase) && pid > 0)
room = await rooms.EnsurePersonalDormAsync(pid);
else
room = await rooms.GetByNameAsync(name);
room ??= Synthetic(name);
if (room.IsDormRoom)
{
// Stamp the wire Name with the requested name so the watch's
// name-keyed room cache (OJMCBOKJFOF.NHBPIIGDAJP) finds it.
room = await ShapeDormRoomAsync(room, DormNameOverride(name));
}
var scenes = await db.RoomScenes
.Where(s => s.RoomId == room.Id)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var roomRoles = await db.RoomRoles
.Where(r => r.RoomId == room.Id)
.ToListAsync();
results.Add(BuildRoomServerDetails(room, scenes, roles: roomRoles));
emitted.Add(room.Id);
}
foreach (var id in ids)
{
if (!emitted.Add(id)) continue;
var room = await rooms.GetByIdAsync(id);
if (room is null) continue;
if (room.IsDormRoom)
{
room = await ShapeDormRoomAsync(room);
}
var scenes = await db.RoomScenes
.Where(s => s.RoomId == room.Id)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var roomRoles = await db.RoomRoles
.Where(r => r.RoomId == room.Id)
.ToListAsync();
results.Add(BuildRoomServerDetails(room, scenes, roles: roomRoles));
}
return Ok(results);
}
/// <summary>
/// Shallow clone of a RoomEntity with a different CreatorPlayerId
/// and (optionally) a different CurrentDataBlobName. Used to give
@@ -1033,40 +655,11 @@ public class RoomsController(
/// caller and pointing at the caller's own most recent save.
/// Doesn't touch the DB — purely a response-shaping utility.
/// </summary>
/// <summary>When a dorm was resolved via the magic name "DormRoom",
/// returns that requested name (verbatim, so casing matches what the
/// watch will look the cache up by) to stamp onto the wire room.
/// Returns null for any other request name so the dorm keeps its real
/// "Dorm_{playerId}" name in by-id / direct-name responses.</summary>
private static string? DormNameOverride(string requestedName) =>
requestedName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase)
? requestedName
: null;
private async Task<RoomEntity> ShapeDormRoomAsync(RoomEntity room, string? overrideName = null)
{
var dormBlobName = await rooms.ResolveDormDataBlobNameAsync(room.CreatorPlayerId, room.Id);
var dormName = overrideName ?? await rooms.BuildPersonalDormDisplayNameAsync(room.CreatorPlayerId);
return CloneWithCreator(room, room.CreatorPlayerId, dormBlobName, dormName);
}
public static RoomEntity CloneWithCreator(
RoomEntity src, long creatorId, string? overrideBlobName = null,
string? overrideName = null) => new()
RoomEntity src, long creatorId, string? overrideBlobName = null) => new()
{
Id = src.Id,
// overrideName lets the by-name resolvers stamp the wire Name with
// the name the client actually requested. The personal-dorm entity
// is named "Dorm_{playerId}", but the watch resolves it via the
// magic name "DormRoom" (HomeScreenFlow.Button_DormRoom →
// RunJoinRoom("DormRoom") → OJMCBOKJFOF.NHBPIIGDAJP("DormRoom")).
// That call caches the response in a Dictionary<string,Room> and
// looks it back up by the REQUESTED string ("DormRoom"); if the
// wire Name is "Dorm_{id}" the lookup misses → the promise rejects
// with "No such room" → the "contact recroom.happyfox.com" toast.
// Stamping Name="DormRoom" here makes the lookup hit, the same way
// RecCenter (whose entity Name IS "RecCenter") already works.
Name = overrideName ?? src.Name,
Name = src.Name,
Description = src.Description,
CreatorPlayerId = creatorId,
ImageName = src.ImageName,
@@ -1150,13 +743,11 @@ public class RoomsController(
// no DeserializeRoomDataBlobCoroutine, the master init
// proceeds with default-zero permissions.
//
// • Saved dorm / customisable rooms: DataBlobName is the
// • Dorm / customisable rooms: DataBlobName is either 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.
// written by StorageController on /upload) or, before any
// save, "room_<id>_v1.dat" — which the catch-all serves
// from RoomDataBlobService (the all-perms default blob).
// • AG-Original rooms: DataBlobName="" → completed-promise
// short-circuit → no download → no leaked stale-blob
// behaviour → master flow proceeds → spawn fires. Maker
@@ -1169,12 +760,9 @@ public class RoomsController(
// 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.
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
var editableRro = room.IsAGRoom && roleList.Any(r => r.Accepted);
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro;
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1;
var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName)
? room.CurrentDataBlobName
: room.IsDormRoom ? ""
: customisable ? $"room_{room.Id}_v1.dat" : "";
// Multi-scene rooms (imported via tools/import-room.ps1) carry one
@@ -1197,7 +785,6 @@ public class RoomsController(
DataBlobName = !string.IsNullOrWhiteSpace(overrideDataBlobName) &&
(overrideSceneId == s.OrderIndex || sceneRows.Count == 1)
? overrideDataBlobName
: room.IsDormRoom ? dataBlobName
: s.DataBlobName,
MaxPlayers = s.MaxPlayers,
CanMatchmakeInto = s.CanMatchmakeInto,
@@ -1218,7 +805,7 @@ public class RoomsController(
Name = "Home",
IsSandbox = false,
DataBlobName = dataBlobName,
MaxPlayers = room.MaxCapacity,
MaxPlayers = 8,
CanMatchmakeInto = true,
// Use the room's actual UpdatedAt so the watch's
// "is my local cache stale?" check sees an accurate
@@ -1247,6 +834,7 @@ public class RoomsController(
// Additional grants come from RoomRoleEntity rows when supplied;
// callers without role data (clone/myrooms/etc.) ship just the
// implicit creator.
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
int[] PlayersIn(int role, bool accepted) => roleList
.Where(r => r.Role == role && r.Accepted == accepted)
.Select(r => (int)r.PlayerId)
@@ -1276,132 +864,6 @@ public class RoomsController(
};
}
/// <summary>
/// 2020.12 <c>/roomserver/rooms/{id}?include=301</c> shape. The client
/// deserializes this directly as PPENFJMFPNE, not as the older
/// { Room, Scenes, ... } wrapper.
/// </summary>
public static object BuildRoomServerDetails(
RoomEntity room,
IReadOnlyList<RoomSceneEntity>? sceneRows = null,
string? overrideDataBlobName = null,
IReadOnlyList<RoomRoleEntity>? roles = 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" : "";
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
{
SubRoomId = (long)s.OrderIndex,
RoomId = room.Id,
Name = s.Name,
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,
}).ToArray()
: new object[]
{
new
{
SubRoomId = 0L,
RoomId = room.Id,
Name = "Home",
DataBlob = dataBlobName,
DataSavedAt = updatedAt,
IsSandbox = false,
MaxPlayers = 8,
Accessibility = room.Accessibility,
UnitySceneId = room.LocationReplicationId,
},
};
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 },
};
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,
};
}));
var tags = string.IsNullOrEmpty(room.TagsCsv)
? Array.Empty<object>()
: room.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new { Type = 0, Tag = t })
.ToArray();
return new
{
RoomId = room.Id,
Name = room.Name,
Description = room.Description,
ImageName = room.ImageName,
WarningMask = room.RoomWarningMask,
CustomWarning = room.CustomRoomWarning,
CreatorAccountId = room.CreatorPlayerId,
State = room.State,
Accessibility = room.Accessibility,
SupportsLevelVoting = room.SupportsLevelVoting,
IsRRO = room.IsAGRoom,
SupportsScreens = room.SupportsScreens,
SupportsWalkVR = room.SupportsWalkVR,
SupportsTeleportVR = room.SupportsTeleportVR,
SupportsVRLow = room.SupportsVRLow,
SupportsQuest2 = true,
SupportsMobile = room.SupportsMobile,
SupportsJuniors = room.AllowsJuniors,
CreatedAt = (room.CreatedAt == default ? DateTime.UtcNow : room.CreatedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
Stats = new
{
CheerCount = room.CheerCount,
FavoriteCount = room.FavoriteCount,
VisitorCount = room.VisitorCount,
VisitCount = room.VisitCount,
},
IsDorm = room.IsDormRoom,
CloningAllowed = room.CloningAllowed,
DisableMicAutoMute = room.DisableMicAutoMute,
DisableRoomComments = false,
EncryptVoiceChat = false,
SubRooms = subRooms,
Roles = wireRoles,
LoadScreens = Array.Empty<object>(),
PromoImages = Array.Empty<string>(),
PromoExternalContent = Array.Empty<object>(),
Tags = tags,
};
}
// ── Clone / create ───────────────────────────────────────────────────
@@ -1441,19 +903,7 @@ public class RoomsController(
// ── My-Rooms tabs ────────────────────────────────────────────────────
// 2020.12 OJMCBOKJFOF populates its local rooms cache from these
// URLs at boot (verified via metadata strings: "rooms/createdby/me",
// "rooms/moderatedby/me", "rooms/visitedby/me"). The 2020.03 client
// used /api/rooms/v2/myrooms, while 2020.12 moved the calls under
// /roomserver/rooms/*. Keep both route families on the same handlers.
[HttpGet("api/rooms/v2/myrooms")]
[HttpGet("api/rooms/v1/createdby/me")]
[HttpGet("api/rooms/v2/createdby/me")]
[HttpGet("api/rooms/v3/createdby/me")]
[HttpGet("roomserver/rooms/createdby/me")]
[HttpGet("roomserver/rooms/ownedby/me")]
[HttpGet("rooms/createdby/me")]
[HttpGet("rooms/ownedby/me")]
[Authorize]
public async Task<IActionResult> MyRooms()
{
@@ -1462,46 +912,6 @@ public class RoomsController(
return Ok((await rooms.CreatedByAsync(pid.Value)).Select(RoomService.ToWireRoom).ToList());
}
[HttpGet("api/rooms/v1/createdby/{otherPlayerId:long}")]
[HttpGet("api/rooms/v2/createdby/{otherPlayerId:long}")]
[HttpGet("api/rooms/v3/createdby/{otherPlayerId:long}")]
[HttpGet("roomserver/rooms/createdby/{otherPlayerId:long}")]
[HttpGet("rooms/createdby/{otherPlayerId:long}")]
public async Task<IActionResult> CreatedByOther(long otherPlayerId)
=> Ok((await rooms.CreatedByAsync(otherPlayerId)).Select(RoomService.ToWireRoom).ToList());
[HttpGet("api/rooms/v1/visitedby/me")]
[HttpGet("api/rooms/v2/visitedby/me")]
[HttpGet("api/rooms/v3/visitedby/me")]
[HttpGet("roomserver/rooms/visitedby/me")]
[HttpGet("rooms/visitedby/me")]
[Authorize]
public IActionResult VisitedByMe() => Ok(Array.Empty<object>());
[HttpGet("api/rooms/v1/moderatedby/me")]
[HttpGet("api/rooms/v2/moderatedby/me")]
[HttpGet("api/rooms/v3/moderatedby/me")]
[HttpGet("roomserver/rooms/moderatedby/me")]
[HttpGet("rooms/moderatedby/me")]
[Authorize]
public IActionResult ModeratedByMe() => Ok(Array.Empty<object>());
[HttpGet("api/rooms/v1/favoritedby/me")]
[HttpGet("api/rooms/v2/favoritedby/me")]
[HttpGet("api/rooms/v3/favoritedby/me")]
[HttpGet("roomserver/rooms/favoritedby/me")]
[HttpGet("rooms/favoritedby/me")]
[Authorize]
public IActionResult FavoritedByMe() => Ok(Array.Empty<object>());
[HttpGet("api/rooms/v1/cheeredby/me")]
[HttpGet("api/rooms/v2/cheeredby/me")]
[HttpGet("api/rooms/v3/cheeredby/me")]
[HttpGet("roomserver/rooms/cheeredby/me")]
[HttpGet("rooms/cheeredby/me")]
[Authorize]
public IActionResult CheeredByMe() => Ok(Array.Empty<object>());
[HttpGet("api/rooms/v2/mybookmarks")]
[HttpGet("api/rooms/v2/mybookmarkedrooms")]
[Authorize]
@@ -1548,7 +958,6 @@ public class RoomsController(
/// seeded AG / RR-Original room id. Used by the watch to bias the
/// room browser toward official rooms first.</summary>
[HttpGet("api/rooms/v1/agRoomIds")]
[HttpGet("rooms/rro_ids")]
public async Task<IActionResult> AgRoomIds() =>
Ok(await rooms.AgRoomIdsAsync());
@@ -1563,28 +972,6 @@ public class RoomsController(
FeaturedRooms = await rooms.FeaturedAgRoomIdsAsync(12),
});
/// <summary>GET <c>featuredrooms/current</c> — the watch's pointer
/// to the current "Featured" room group. Caller:
/// <c>EJDCNGBEICB.CFKDADKHAGB</c>. Wire shape is <c>NMPFCIJPODA</c>
/// with required keys <c>FeaturedRoomGroupId</c> (long) and
/// <c>Name</c> (string) — a pointer record, NOT the room list
/// itself (the watch fetches the list separately via
/// <c>api/rooms/v3/featured</c>). Returning the
/// <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
{
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),
});
/// <summary>POST <c>api/rooms/v1/bookmark</c> — toggle bookmark
/// state for a room. Body: <c>RoomId</c> + optional <c>Bookmark</c>
/// flag (default = add).</summary>
@@ -1601,148 +988,6 @@ public class RoomsController(
return Ok(new { success = true, error = "" });
}
/// <summary>POST <c>rooms/{roomId}/name</c> — rename a room.
/// Watch path: <c>EJDCNGBEICB.DNOGGKKHMHI(long roomId, string newName)</c>
/// (analytics tag "change room name"). Body shape: form-urlencoded
/// <c>Name=...</c> or a bare string in the body. Returns the
/// updated Room DTO (<c>PPENFJMFPNE : KLCOGEIGEBJ</c>).
///
/// <para>Caller must be the room creator OR a co-owner. Other
/// validations: name length 1..40, no leading/trailing whitespace,
/// uniqueness against other Rooms.Name. Failures return
/// the watch's <c>CreateModifyRoomStatus</c>-style payload with
/// a non-zero Result so the rename toast surfaces a real reason.</para>
///
/// <para>We accept both POST and PUT because the watch's ISIL
/// doesn't expose the explicit HTTPMethod literal at this call
/// site — covering both is cheap and means we can't get bitten
/// by the wrong one.</para></summary>
public sealed class RenameBody { public string? Name { get; set; } }
[HttpPost("rooms/{roomId:long}/name")]
[HttpPut("rooms/{roomId:long}/name")]
[Authorize]
public async Task<IActionResult> RenameRoom(
long roomId,
[FromForm(Name = "Name")] string? nameForm,
[FromForm(Name = "name")] string? nameFormLower,
[FromBody] RenameBody? body)
{
var newName = nameForm ?? nameFormLower ?? body?.Name;
if (string.IsNullOrWhiteSpace(newName))
return BadRequest(new { Result = 1, error = "empty_name" });
newName = newName.Trim();
if (newName.Length is < 1 or > 40)
return BadRequest(new { Result = 1, error = "invalid_length", length = newName.Length });
var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(new { Result = 4, error = "room_not_found" });
if (room.CreatorPlayerId != me)
{
// RoomRoles co-owner check — same authorisation as the
// existing modify endpoint, lets co-owners rename too.
var coOwner = await db.RoomRoles.AnyAsync(rr =>
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1));
if (!coOwner) return Forbid();
}
// Uniqueness — Rooms.Name has a unique index (DorkNetDbContext.cs:118).
var collision = await db.Rooms.AnyAsync(r => r.Id != roomId && r.Name == newName);
if (collision) return Conflict(new { Result = 5, error = "name_taken" });
room.Name = newName;
room.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
logger.LogInformation("[room-rename] room={Room} by={By} → '{Name}'", roomId, me, newName);
// Return the full updated Room DTO. The watch's
// <c>PPENFJMFPNE</c> deserialiser inherits from
// <c>KLCOGEIGEBJ</c> (the standard Room shape) so we can
// forward to the same v3-details payload builder by re-reading.
// BuildRoomDetailsV3Async is too heavy here — just return the
// minimal mutated subset; the watch UI re-fetches details
// shortly after for the room-info screen.
return Ok(new
{
Result = 0,
RoomId = room.Id,
Name = room.Name,
Description = room.Description,
ImageName = room.ImageName,
CreatorPlayerId = room.CreatorPlayerId,
CreatorAccountId = room.CreatorPlayerId,
State = room.State,
Accessibility = room.Accessibility,
IsAGRoom = room.IsAGRoom,
IsRRO = false,
IsDormRoom = room.IsDormRoom,
});
}
/// <summary>POST <c>rooms/{roomId}/subrooms/{subRoomId}/name</c> —
/// rename a scene/sub-room within a multi-scene room. Watch path:
/// <c>EJDCNGBEICB.PAENPJCDLNJ(long roomId, long subRoomId, string newName)</c>.
/// Updates the matching <see cref="RoomSceneEntity.Name"/> after
/// verifying ownership and intra-room uniqueness.</summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[Authorize]
public async Task<IActionResult> RenameSubroom(
long roomId,
long subRoomId,
[FromForm(Name = "Name")] string? nameForm,
[FromForm(Name = "name")] string? nameFormLower,
[FromBody] RenameBody? body)
{
var newName = nameForm ?? nameFormLower ?? body?.Name;
if (string.IsNullOrWhiteSpace(newName))
return BadRequest(new { Result = 1, error = "empty_name" });
newName = newName.Trim();
if (newName.Length is < 1 or > 40)
return BadRequest(new { Result = 1, error = "invalid_length", length = newName.Length });
var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(new { Result = 4, error = "room_not_found" });
if (room.CreatorPlayerId != me)
{
var coOwner = await db.RoomRoles.AnyAsync(rr =>
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1));
if (!coOwner) return Forbid();
}
var scene = await db.RoomScenes.FirstOrDefaultAsync(s =>
s.RoomId == roomId && s.OrderIndex == subRoomId);
if (scene is null) return NotFound(new { Result = 4, error = "subroom_not_found", subRoomId });
// Uniqueness within this room — same scene name twice would
// make /goto/room/X/Y ambiguous against the RoomScenes index
// on (RoomId, Name) at DorkNetDbContext.cs:153.
var collision = await db.RoomScenes.AnyAsync(s =>
s.RoomId == roomId && s.OrderIndex != subRoomId && s.Name == newName);
if (collision) return Conflict(new { Result = 5, error = "subroom_name_taken" });
scene.Name = newName;
scene.DataModifiedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
logger.LogInformation("[subroom-rename] room={Room} sub={Sub} by={By} → '{Name}'", roomId, subRoomId, me, newName);
return Ok(new
{
Result = 0,
RoomId = roomId,
RoomSceneId = scene.OrderIndex,
SubRoomId = scene.OrderIndex,
Name = scene.Name,
scene.DataBlobName,
RoomSceneLocationId = scene.RoomSceneLocationId,
scene.IsSandbox,
scene.MaxPlayers,
scene.CanMatchmakeInto,
});
}
/// <summary>POST <c>api/rooms/v1/cheer</c> — cheer a room (body
/// form). Idempotent per (caller, room) — duplicate taps don't
/// inflate CheerCount.</summary>
@@ -1786,965 +1031,6 @@ public class RoomsController(
return Ok(new { RoomId = roomId, CheerCount = count, ICheered = iCheered });
}
// ── Bare-path interactionby/me toggles ─────────────────────────────
[HttpPost("rooms/{roomId:long}/interactionby/me/cheer")]
[HttpPut("rooms/{roomId:long}/interactionby/me/cheer")]
[Authorize]
public async Task<IActionResult> BareCheer(long roomId)
{
var me = this.RequireCurrentPlayerId();
await rooms.CheerRoomAsync(me, roomId, 0);
return Ok(new { success = true });
}
[HttpDelete("rooms/{roomId:long}/interactionby/me/cheer")]
[Authorize]
public async Task<IActionResult> BareUncheer(long roomId)
{
var me = this.RequireCurrentPlayerId();
await rooms.UncheerRoomAsync(me, roomId, 0);
return Ok(new { success = true });
}
[HttpPost("rooms/{roomId:long}/interactionby/me/favorite")]
[HttpPut("rooms/{roomId:long}/interactionby/me/favorite")]
[Authorize]
public async Task<IActionResult> BareFavorite(long roomId)
{
var me = this.RequireCurrentPlayerId();
await rooms.BookmarkAsync(me, roomId);
return Ok(new { success = true });
}
[HttpDelete("rooms/{roomId:long}/interactionby/me/favorite")]
[Authorize]
public async Task<IActionResult> BareUnfavorite(long roomId)
{
var me = this.RequireCurrentPlayerId();
await rooms.UnbookmarkAsync(me, roomId);
return Ok(new { success = true });
}
// ── Rooms-and-Playlists union (2020.12 watch) ────────────────────────
/// <summary>
/// GET <c>/roomserver/roomsandplaylists/hot</c> — the 2020.12 watch's
/// merged "Trending" / "New" tab. Two URL variants exist with
/// DIFFERENT wire shapes:
///
/// 1. <c>/roomserver/roomsandplaylists/hot</c> (this endpoint)
/// Watch caller: <c>EJDCNGBEICB.EBBHGHEGBKG</c>
/// Signature: <c>IPromise&lt;HJKAOMOICJG&gt;</c>
/// Wire shape: <c>{ TotalResults, Results: [union] }</c>
/// (verified at <c>EJDCNGBEICB.txt:2571,2645</c> +
/// <c>HJKAOMOICJG.txt:39-50</c>)
/// 2. <c>/roomserver/hot_roomsandplaylists/{tags-joined}</c>
/// Watch caller: <c>OJMCBOKJFOF.EBBHGHEGBKG</c>
/// Signature: <c>IPromise&lt;List&lt;MKAMHOIHOJK&gt;&gt;</c>
/// Wire shape: bare list, see <see cref="HotRoomsAndPlaylistsPath"/>
/// (verified at <c>OJMCBOKJFOF.txt:4607,4743</c>)
///
/// Returning a bare list here throws
/// <c>InvalidCastException: Unable to cast object of type 'List`1'
/// to type 'Dictionary`2'</c> on the watch — the wrapped
/// HJKAOMOICJG deserializer reads <c>TotalResults</c> as a top-level
/// dict key, and a JSON array body fails that cast.
///
/// Inside <c>Results</c>: union entries discriminated by lowercase
/// <c>roomId</c> key per the <c>MKAMHOIHOJK</c> factory at
/// <c>MKAMHOIHOJK.txt:621</c> — see <see cref="BuildRoomUnionEntry"/>
/// + <see cref="BuildPlaylistUnionEntry"/>.
///
/// <para><b>Why rooms-only despite the "and playlists" name:</b> the
/// Hot tab of the watch's Play page mounts this endpoint and used to
/// interleave playlist tiles next to room tiles, but design intent is
/// that playlists appear only under the Moods/Playlists section (their
/// own endpoints — <c>/api/curatedroomplaylists</c> +
/// <c>/roomserver/playlists/{id}</c>). The watch's union deserializer
/// happily accepts a Results list that's 100% rooms, so we keep the
/// wrapper shape but drop the playlist half.</para>
/// </summary>
[HttpGet("roomserver/roomsandplaylists/hot")]
[HttpGet("roomsandplaylists/hot")]
public async Task<IActionResult> RoomsAndPlaylistsHot([FromQuery] string? tag)
{
var (entries, total) = await BuildHotRoomsAsync(tag);
return Ok(new Dictionary<string, object>(StringComparer.Ordinal)
{
["TotalResults"] = (long)total,
["Results"] = entries,
});
}
/// <summary>
/// GET <c>/roomserver/hot_roomsandplaylists/{tagsJoined}</c> —
/// path-style sibling of <see cref="RoomsAndPlaylistsHot"/> with a
/// BARE <c>List&lt;MKAMHOIHOJK&gt;</c> shape (no TotalResults
/// wrapper). Tags are comma-joined into the URL path per
/// <c>OJMCBOKJFOF.txt:4739,4743</c> (<c>String.Join(",", tags)</c>
/// followed by <c>String.Concat("hot_roomsandplaylists/", joined)</c>).
/// Empty path is legal — equivalent to "no tag filter".
/// </summary>
[HttpGet("roomserver/hot_roomsandplaylists/{tagsJoined?}")]
[HttpGet("hot_roomsandplaylists/{tagsJoined?}")]
public async Task<IActionResult> HotRoomsAndPlaylistsPath(string? tagsJoined)
{
// Watch joins multiple tags with "," — use the first one as a
// substring filter since RoomService.HotAsync takes a single
// tag arg. Empty / null = unfiltered.
//
// Returns rooms-only for the same reason as the wrapped
// RoomsAndPlaylistsHot endpoint above: playlists belong in
// Moods, not in the Hot tab. See that endpoint's doc-comment.
var firstTag = string.IsNullOrWhiteSpace(tagsJoined)
? null
: tagsJoined.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault();
var (entries, _) = await BuildHotRoomsAsync(firstTag);
return Ok(entries);
}
/// <summary>GET <c>hot_rooms/{tagsJoined?}</c> — rooms-only variant
/// of <see cref="HotRoomsAndPlaylistsPath"/>. Caller:
/// <c>OJMCBOKJFOF.DLPLPKCNLNA</c>. Same <c>MKAMHOIHOJK</c> union
/// entry shape as <c>hot_roomsandplaylists</c>, just skips playlist
/// rows so the watch's rooms-only browse tab doesn't render
/// playlist tiles that have nowhere to go.</summary>
[HttpGet("hot_rooms/{tagsJoined?}")]
public async Task<IActionResult> HotRoomsOnlyPath(string? tagsJoined)
{
var firstTag = string.IsNullOrWhiteSpace(tagsJoined)
? null
: tagsJoined.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault();
var roomList = await rooms.HotAsync(firstTag, take: 12);
var roomIds = roomList.Select(r => r.Id).ToList();
var sceneRows = roomIds.Count == 0
? new List<RoomSceneEntity>()
: await db.RoomScenes.Where(s => roomIds.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex).ToListAsync();
var scenesByRoom = sceneRows.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
return Ok(roomList
.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id)))
.ToList());
}
/// <summary>
/// Shared body for the two hot variants — pulls hot rooms only,
/// fans out sub-room rows in one query to avoid N+1, and returns
/// the union-shape entries the watch's factory expects (each entry
/// happens to be a room dict; the factory accepts that). Returns
/// (entries, totalCount) so the wrapped variant can fill in
/// TotalResults.
///
/// <para>Take limit raised to 100 (was 12) — the Hot tab used to
/// surface only the top dozen rooms which is far less than the
/// catalog the watch's browse UI can actually render. The watch
/// paginates client-side if it needs to chunk further.</para>
/// </summary>
private async Task<(List<IDictionary<string, object>> Entries, int Total)> BuildHotRoomsAsync(string? tag)
{
var roomList = await rooms.HotAsync(tag, take: 100);
var roomIds = roomList.Select(r => r.Id).ToList();
var sceneRows = roomIds.Count == 0
? new List<RoomSceneEntity>()
: await db.RoomScenes
.Where(s => roomIds.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 entries = roomList
.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id)))
.ToList();
return (entries, entries.Count);
}
/// <summary>
/// GET <c>/api/curatedroomplaylists</c> — the editorial "Curated"
/// row on the watch's playlists tab.
///
/// Wire shape: <c>List&lt;Int64&gt;</c> — JUST the playlist IDs, not
/// the full playlist objects. Verified against
/// <c>FPNPBJBCMKB.EPPPMHMMGME</c> (signature
/// <c>IPromise&lt;List&lt;long&gt;&gt;</c>) and the call site at
/// <c>BrowseRoomsScreen.txt:2806</c> which constructs an
/// <c>Action&lt;List&lt;Int64&gt;&gt;</c> callback. Returning rich
/// objects here throws
/// <c>InvalidCastException: Unable to cast object of type 'List`1'
/// to type 'Dictionary`2'</c> on the watch ("Failed to get curated
/// room playlists: Malformed Response"). The watch fetches each
/// playlist's details separately via the playlist-detail endpoints.
/// </summary>
[HttpGet("api/curatedroomplaylists")]
public async Task<IActionResult> CuratedRoomPlaylists()
{
var curated = await playlists.CuratedAsync();
return Ok(curated.Select(p => p.Id).ToList());
}
/// <summary>
/// GET <c>/roomserver/playlists/{id}</c> — single-playlist details
/// with members. The watch deserializes this as <b>BMFAGMFKODA</b>
/// (verified at <c>BMFAGMFKODA.txt:182-191</c>) which:
/// 1. Calls <c>KMKPEOGJDFK.PPGFHEDFBEA</c> to read the base
/// Playlist union fields (<c>PlaylistId</c> + all
/// <c>MKAMHOIHOJK</c> base keys).
/// 2. Reads required key <c>Rooms</c> — <c>List&lt;KLCOGEIGEBJ&gt;</c>
/// (the playlist's member rooms, full Room dicts).
/// 3. Reads required key <c>Tags</c> — <c>List&lt;DPHPFLGAICI&gt;</c>
/// (tag rows with <c>Type</c> + <c>Tag</c>).
/// Returning just the <c>KMKPEOGJDFK</c> shape (no Rooms/Tags) throws
/// <c>KeyNotFoundException: Failed to find key 'Rooms' when
/// deserializing object of type KLCOGEIGEBJ</c> on the watch.
/// </summary>
[HttpGet("roomserver/playlists/{id:long}")]
[HttpGet("playlists/{id:long}")]
public async Task<IActionResult> PlaylistDetails(long id)
{
var p = await db.Playlists.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
if (p is null) return NotFound();
// Pull member rooms in playlist order, then fan out room +
// scene rows so each entry can render as a full KLCOGEIGEBJ.
var roomIds = await db.PlaylistRooms
.Where(pr => pr.PlaylistId == id)
.OrderBy(pr => pr.OrderIndex)
.Select(pr => pr.RoomId)
.ToListAsync();
List<RoomEntity> memberRooms;
Dictionary<long, IReadOnlyList<RoomSceneEntity>> scenesByRoom;
if (roomIds.Count == 0)
{
memberRooms = new List<RoomEntity>();
scenesByRoom = new Dictionary<long, IReadOnlyList<RoomSceneEntity>>();
}
else
{
var roomRows = await db.Rooms
.Where(r => roomIds.Contains(r.Id))
.ToListAsync();
// Preserve playlist order — db.Rooms.Where doesn't guarantee
// it matches the OrderBy on PlaylistRooms.OrderIndex.
var roomById = roomRows.ToDictionary(r => r.Id);
memberRooms = roomIds
.Select(rid => roomById.TryGetValue(rid, out var r) ? r : null)
.Where(r => r is not null)
.Select(r => r!)
.ToList();
var sceneRows = await db.RoomScenes
.Where(s => roomIds.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex)
.ToListAsync();
scenesByRoom = sceneRows
.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
}
var roomsWire = memberRooms
.Select(r => BuildRoomServerDetails(r, scenesByRoom.GetValueOrDefault(r.Id)))
.ToList();
// Tags wire shape: [{Type:int, Tag:string}] — same shape used in
// BuildRoomServerDetails for Room.Tags. Split TagsCsv if present.
var tagsWire = string.IsNullOrWhiteSpace(p.TagsCsv)
? new List<object>()
: p.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new { Type = 0, Tag = t })
.ToList();
// Start from BuildPlaylistUnionEntry for base + PlaylistId, then
// splice in the Rooms + Tags lists. Dictionary lets us add the
// extra keys after the base build.
var result = BuildPlaylistUnionEntry(p);
result["Rooms"] = roomsWire;
result["Tags"] = tagsWire;
return Ok(result);
}
/// <summary>
/// GET <c>/roomserver/roomsandplaylists/search?query=X</c> — search
/// surface used by the watch's friend-mention / room-search affordance.
/// Verified at <c>EJDCNGBEICB.txt:2486,2560</c> — signature
/// <c>IPromise&lt;HJKAOMOICJG&gt;</c>. Per
/// <see cref="HJKAOMOICJG.PPGFHEDFBEA"/> at <c>HJKAOMOICJG.txt:39-50</c>
/// the response object reads <c>TotalResults</c> (Int64) and
/// <c>Results</c> (List of <c>MKAMHOIHOJK</c> union entries — same
/// Room/Playlist factory used by <c>/hot</c>).
///
/// Distinct from <c>/roomserver/search_roomsandplaylists/{query}</c>
/// (path-param shape from <c>OJMCBOKJFOF.txt:4567</c>); both URLs
/// reach the same data but differ in shape — the path-param sibling
/// returns a bare <c>List&lt;MKAMHOIHOJK&gt;</c> without the
/// TotalResults wrapper.
/// </summary>
[HttpGet("roomserver/roomsandplaylists/search")]
[HttpGet("roomsandplaylists/search")]
public async Task<IActionResult> RoomsAndPlaylistsSearch([FromQuery] string? query)
{
var (merged, total) = await BuildSearchUnionAsync(query);
return Ok(new Dictionary<string, object>(StringComparer.Ordinal)
{
["TotalResults"] = (long)total,
["Results"] = merged,
});
}
/// <summary>
/// GET <c>/roomserver/search_roomsandplaylists/{query}</c> — free-text
/// search across both rooms and playlists, BARE-LIST variant. Per
/// <c>OJMCBOKJFOF.txt:4439,4567</c> the signature is
/// <c>IPromise&lt;List&lt;MKAMHOIHOJK&gt;&gt;</c> — no TotalResults
/// wrapper. URL is <c>search_roomsandplaylists/{query}</c>
/// (query in path, not query-string).
/// </summary>
[HttpGet("roomserver/search_roomsandplaylists/{query}")]
[HttpGet("search_roomsandplaylists/{query}")]
public async Task<IActionResult> SearchRoomsAndPlaylists(string query)
{
var (merged, _) = await BuildSearchUnionAsync(query);
return Ok(merged);
}
/// <summary>GET <c>search_rooms/{query}</c> — rooms-only search
/// variant. Same <c>MKAMHOIHOJK</c> union entry shape as
/// <c>search_roomsandplaylists/{query}</c>, just skips playlist
/// rows.</summary>
[HttpGet("search_rooms/{query}")]
public async Task<IActionResult> SearchRoomsOnly(string query)
{
if (string.IsNullOrWhiteSpace(query))
return Ok(Array.Empty<object>());
var roomList = await rooms.SearchAsync(query);
var roomIds = roomList.Select(r => r.Id).ToList();
var sceneRows = roomIds.Count == 0
? new List<RoomSceneEntity>()
: await db.RoomScenes.Where(s => roomIds.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex).ToListAsync();
var scenesByRoom = sceneRows.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
return Ok(roomList
.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id)))
.ToList());
}
/// <summary>
/// Shared body for the two search variants. Empty/whitespace query
/// returns an empty result without round-tripping the DB.
/// </summary>
private async Task<(List<IDictionary<string, object>> Entries, int Total)> BuildSearchUnionAsync(string? query)
{
if (string.IsNullOrWhiteSpace(query))
return (new List<IDictionary<string, object>>(), 0);
// Sequential — shared scoped DbContext isn't thread-safe.
var roomList = await rooms.SearchAsync(query);
var playlistList = await playlists.SearchAsync(query);
var roomIds = roomList.Select(r => r.Id).ToList();
var sceneRows = await db.RoomScenes
.Where(s => roomIds.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 merged = InterleaveUnion(
roomList.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id))),
playlistList.Select(BuildPlaylistUnionEntry));
return (merged, roomList.Count + playlistList.Count);
}
/// <summary>Round-robin merge of two enumerables so the watch's
/// hot row alternates between rooms and playlists rather than
/// stacking one type on top. Once one side runs out the remainder
/// of the other side spills out in order.</summary>
private static List<IDictionary<string, object>> InterleaveUnion(
IEnumerable<IDictionary<string, object>> a,
IEnumerable<IDictionary<string, object>> b)
{
var listA = a.ToList();
var listB = b.ToList();
var output = new List<IDictionary<string, object>>(listA.Count + listB.Count);
int i = 0, j = 0;
while (i < listA.Count || j < listB.Count)
{
if (i < listA.Count) output.Add(listA[i++]);
if (j < listB.Count) output.Add(listB[j++]);
}
return output;
}
/// <summary>
/// Build a union-shape Room entry — same payload as
/// <see cref="BuildRoomServerDetails"/> but with the additional
/// lowercase <c>roomId</c> key that the 2020.12
/// <c>MKAMHOIHOJK</c> factory uses as its Room-vs-Playlist
/// discriminator (factory at <c>MKAMHOIHOJK.txt:621</c> calls
/// <c>Dictionary.ContainsKey("roomId")</c>; the actual Room
/// deserializer at <c>KLCOGEIGEBJ.txt:208-234</c> reads
/// PascalCase <c>RoomId</c>). Must include BOTH keys.
/// </summary>
private static IDictionary<string, object> BuildRoomUnionEntry(
RoomEntity room,
IReadOnlyList<RoomSceneEntity>? sceneRows)
{
// Start from the canonical RoomServerDetails shape so the union
// entry carries every field the 2020.12 client expects on a
// Room subclass (SubRooms, Roles, Stats, etc.). Then add the
// lowercase 'roomId' discriminator key on top.
var details = BuildRoomServerDetails(room, sceneRows);
// Convert anonymous record into a mutable dictionary so we can
// add the lowercase 'roomId' key that anonymous types can't
// express (you can't have two properties differing only in
// letter casing on a single anonymous type).
var dict = new Dictionary<string, object>(StringComparer.Ordinal);
foreach (var prop in details.GetType().GetProperties())
{
var value = prop.GetValue(details);
if (value is not null) dict[prop.Name] = value;
}
// Discriminator key — lowercase 'roomId'. Without this the
// MKAMHOIHOJK factory falls through to the Playlist branch
// and PlaylistId.PPGFHEDFBEA throws on the missing key.
dict["roomId"] = room.Id;
return dict;
}
/// <summary>
/// Build a union-shape Playlist entry — the
/// <see cref="KMKPEOGJDFK"/> branch of MKAMHOIHOJK. Keys read at
/// <c>MKAMHOIHOJK.txt:516-612</c> (PascalCase base keys) and
/// <c>KMKPEOGJDFK.txt:68</c> (PascalCase <c>PlaylistId</c>).
/// Omits the lowercase <c>roomId</c> key so the factory's
/// Room-vs-Playlist branch falls through to the Playlist
/// constructor.
/// </summary>
public static IDictionary<string, object> BuildPlaylistUnionEntry(PlaylistEntity p)
{
return new Dictionary<string, object>(StringComparer.Ordinal)
{
["PlaylistId"] = p.Id,
["Name"] = p.Name,
["Description"] = p.Description,
["ImageName"] = p.ImageName,
["CreatorAccountId"] = p.CreatorPlayerId,
["State"] = 0,
["Accessibility"] = 1,
["WarningMask"] = 0,
["CustomWarning"] = string.Empty,
["SupportsLevelVoting"] = false,
// Playlists are watchable from every device the
// member rooms support; advertise the broadest set so
// the watch never filters them out by capability.
["IsRRO"] = p.IsCurated,
["SupportsScreens"] = true,
["SupportsWalkVR"] = true,
["SupportsTeleportVR"] = true,
["SupportsVRLow"] = true,
["SupportsQuest2"] = true,
["SupportsMobile"] = true,
["SupportsJuniors"] = true,
["CreatedAt"] = (p.CreatedAt == default ? DateTime.UtcNow : p.CreatedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
["Stats"] = new
{
CheerCount = p.CheerCount,
FavoriteCount = p.FavoriteCount,
VisitorCount = p.VisitorCount,
VisitCount = p.VisitCount,
},
};
}
// ── Subroom mutations (bare path) ──────────────────────────────────
public sealed class CreateSubRoomRequest
{
public string? Name { get; set; }
public string? RoomSceneLocationId { get; set; }
public int? MaxPlayers { get; set; }
public bool? IsSandbox { get; set; }
}
/// <summary>POST <c>rooms/{id}/subrooms</c> — append a new scene.
/// Owner-gated. OrderIndex = max+1, Name unique within room.</summary>
[HttpPost("rooms/{roomId:long}/subrooms")]
[Authorize]
public async Task<IActionResult> AddSubRoom(long roomId, [FromBody] CreateSubRoomRequest req)
{
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 name = (req.Name ?? string.Empty).Trim();
if (name.Length == 0) name = "NewScene";
var collision = await db.RoomScenes.AnyAsync(s => s.RoomId == roomId && s.Name == name);
if (collision) return Conflict(new { error = "name_taken" });
var nextOrder = (await db.RoomScenes
.Where(s => s.RoomId == roomId)
.Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1;
var scene = new RoomSceneEntity
{
RoomId = roomId,
OrderIndex = nextOrder,
Name = name,
RoomSceneLocationId = req.RoomSceneLocationId
?? "a75f7547-79eb-47c6-8986-6767abcb4f92",
MaxPlayers = req.MaxPlayers ?? 8,
IsSandbox = req.IsSandbox ?? false,
};
db.RoomScenes.Add(scene);
await db.SaveChangesAsync();
return Ok(SceneWire(scene));
}
[HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[Authorize]
public async Task<IActionResult> DeleteSubRoom(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 db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (scene is null) return NotFound();
if (scene.OrderIndex == 0) return BadRequest(new { error = "cannot_delete_entry_scene" });
db.RoomScenes.Remove(scene);
await db.SaveChangesAsync();
return Ok(new { Result = 0 });
}
public sealed class SubRoomIntRequest { public int? Value { get; set; } }
public sealed class SubRoomBoolRequest { public bool? Value { get; set; } }
public sealed class SubRoomModifyRequest
{
public string? Name { get; set; }
public int? MaxPlayers { get; set; }
public bool? IsSandbox { get; set; }
public bool? CanMatchmakeInto { get; set; }
public string? RoomSceneLocationId { get; set; }
}
public sealed class SubRoomMoveRequest { public int? NewIndex { get; set; } }
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[HttpPut("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")]
[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; });
/// <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>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
[Authorize]
public async Task<IActionResult> CloneSubRoom(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 src = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (src is null) return NotFound();
var baseName = $"{src.Name} Copy";
var newName = baseName;
int suffix = 1;
while (await db.RoomScenes.AnyAsync(s => s.RoomId == roomId && s.Name == newName))
{
suffix += 1;
newName = $"{baseName} {suffix}";
}
var nextOrder = (await db.RoomScenes.Where(s => s.RoomId == roomId)
.Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1;
var clone = new RoomSceneEntity
{
RoomId = roomId,
OrderIndex = nextOrder,
Name = newName,
RoomSceneLocationId = src.RoomSceneLocationId,
DataBlobName = src.DataBlobName,
MaxPlayers = src.MaxPlayers,
IsSandbox = src.IsSandbox,
CanMatchmakeInto = src.CanMatchmakeInto,
};
db.RoomScenes.Add(clone);
await db.SaveChangesAsync();
return Ok(SceneWire(clone));
}
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[Authorize]
public Task<IActionResult> ModifySubRoom(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest req) =>
MutateScene(roomId, subRoomId, s =>
{
if (!string.IsNullOrWhiteSpace(req.Name)) s.Name = req.Name.Trim();
if (req.MaxPlayers is int mp) s.MaxPlayers = Math.Max(1, mp);
if (req.IsSandbox is bool sb) s.IsSandbox = sb;
if (req.CanMatchmakeInto is bool cmi) s.CanMatchmakeInto = cmi;
if (req.RoomSceneLocationId is not null) s.RoomSceneLocationId = req.RoomSceneLocationId;
});
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/move</c> — reorder a
/// 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")]
[Authorize]
public async Task<IActionResult> MoveSubRoom(long roomId, long subRoomId, [FromBody] SubRoomMoveRequest req)
{
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 scenes = await db.RoomScenes
.Where(s => s.RoomId == roomId).OrderBy(s => s.OrderIndex).ToListAsync();
var moving = scenes.FirstOrDefault(s => s.OrderIndex == subRoomId);
if (moving is null) return NotFound();
var newIndex = Math.Clamp(req.NewIndex ?? 0, 0, scenes.Count - 1);
scenes.Remove(moving);
scenes.Insert(newIndex, moving);
// Recompute OrderIndex on the entire row set. The unique
// (RoomId, OrderIndex) index would clash if we wrote rows in
// place — flush a sentinel offset first, then settle to
// final indices.
const int sentinelOffset = 100_000;
for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i + sentinelOffset;
await db.SaveChangesAsync();
for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i;
await db.SaveChangesAsync();
return Ok(SceneWire(moving));
}
/// <summary>POST <c>roomserver/rooms/{id}/subrooms/{sub}/data</c> —
/// commit the blob returned by <c>storage/upload</c> as the current
/// scene save. The 2020 watch posts this as form-urlencoded
/// (<c>filename</c>, <c>inventionUsage</c>, <c>savedByAccountId</c>),
/// while some tooling uses JSON. Both variants delegate to the
/// existing saveData path so room, scene, dorm-state, presence, and
/// subscription fanout stay consistent.</summary>
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
[Authorize]
public async Task<IActionResult> SubRoomData(long roomId, long subRoomId)
{
var body = await ReadSaveRoomSceneRequestAsync();
body.RoomSceneId = subRoomId;
return await SaveDataCore(body, roomId, wrapCreateModifyResponse: true);
}
private async Task<SaveRoomSceneRequest> ReadSaveRoomSceneRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
var request = new SaveRoomSceneRequest
{
Filename = FirstFormValue(form, "filename", "Filename"),
RoomDataFilename = FirstFormValue(form, "roomDataFilename", "RoomDataFilename", "roomDataFileName", "RoomDataFileName"),
InventionUsageBase64 = FirstFormValue(form, "inventionUsage", "InventionUsage", "inventionUsageBase64", "InventionUsageBase64"),
};
if (long.TryParse(FirstFormValue(form, "requestPlayerId", "RequestPlayerId", "savedByAccountId", "SavedByAccountId"), out var requestPlayerId))
request.RequestPlayerId = requestPlayerId;
if (int.TryParse(FirstFormValue(form, "saveRequestPlayerId", "SaveRequestPlayerId"), out var saveRequestPlayerId))
request.SaveRequestPlayerId = saveRequestPlayerId;
return request;
}
if (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
return await JsonSerializer.DeserializeAsync<SaveRoomSceneRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new SaveRoomSceneRequest();
}
catch (JsonException)
{
return new SaveRoomSceneRequest();
}
}
return new SaveRoomSceneRequest();
}
private static string? FirstFormValue(IFormCollection form, params string[] keys)
{
foreach (var key in keys)
{
var value = form[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) return value;
}
return null;
}
private async Task<IActionResult> MutateScene(long roomId, long subRoomId, Action<RoomSceneEntity> mutator)
{
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 db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (scene is null) return NotFound();
mutator(scene);
scene.DataModifiedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(SceneWire(scene));
}
private static object SceneWire(RoomSceneEntity s) => new
{
Result = 0,
RoomId = s.RoomId,
RoomSceneId = s.OrderIndex,
SubRoomId = s.OrderIndex,
Name = s.Name,
s.DataBlobName,
RoomSceneLocationId = s.RoomSceneLocationId,
s.IsSandbox,
s.MaxPlayers,
s.CanMatchmakeInto,
};
// ── 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>
[HttpGet("rooms/{roomId:long}/roles")]
public async Task<IActionResult> RolesList(long roomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
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 },
};
list.AddRange(rows.Select(r => (object)new
{
RoomId = roomId,
PlayerId = (int)r.PlayerId,
Role = r.Role,
Accepted = r.Accepted,
}));
return Ok(list);
}
[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 });
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 });
}
public sealed class GrantRoleRequest { public int? Role { get; set; } }
/// <summary>POST <c>rooms/{id}/roles/{playerId}</c> — owner grants
/// a role to a player. Auto-accepted (no invite step).</summary>
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
[Authorize]
public async Task<IActionResult> GrantRole(long roomId, long playerId)
{
var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
var role = await ReadClientRoomRoleAsync();
if (role is null) return BadRequest(new { error = "missing_role" });
if (role.Value == 0)
return await ClearRoomAccountRoleAsync(room, playerId);
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
return BadRequest(new { error = "invalid_role", role });
var existing = await db.RoomRoles
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
if (existing is null)
{
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = playerId, Role = roomRole,
Accepted = true, GrantedByPlayerId = me,
});
}
else
{
existing.Accepted = true;
}
await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
/// <summary>POST <c>rooms/{id}/roles/{playerId}/invite</c> — same
/// as grant but Accepted=false; the target's accept-invite flow
/// flips the flag (separate endpoint, not yet exposed). For now
/// invited rows surface in RoomDetails.InvitedCoOwners etc.</summary>
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[Authorize]
public async Task<IActionResult> InviteRole(long roomId, long playerId)
{
var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
var role = await ReadClientRoomRoleAsync();
if (role is null) return BadRequest(new { error = "missing_role" });
if (role.Value == 0)
return await ClearRoomAccountRoleAsync(room, playerId);
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
return BadRequest(new { error = "invalid_role", role });
var existing = await db.RoomRoles
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
if (existing is null)
{
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = playerId, Role = roomRole,
Accepted = false, GrantedByPlayerId = me,
});
}
else
{
existing.Accepted = false;
}
await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
private async Task<IActionResult> ClearRoomAccountRoleAsync(RoomEntity room, long playerId)
{
if (room.CreatorPlayerId == playerId)
return BadRequest(new { error = "cannot_change_creator_role" });
var rows = await db.RoomRoles
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
.ToListAsync();
if (rows.Count > 0)
db.RoomRoles.RemoveRange(rows);
await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(room.Id));
}
private async Task<bool> CanManageRoomRolesAsync(RoomEntity room, long playerId)
{
if (room.CreatorPlayerId == playerId) return true;
return await db.RoomRoles.AnyAsync(r =>
r.RoomId == room.Id
&& r.PlayerId == playerId
&& r.Accepted
&& r.Role == 0);
}
private async Task<int?> ReadClientRoomRoleAsync()
{
int parsed;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
if (int.TryParse(form["role"].FirstOrDefault(), out parsed)) return parsed;
if (int.TryParse(form["Role"].FirstOrDefault(), out parsed)) return parsed;
}
if (int.TryParse(Request.Query["role"].FirstOrDefault(), out parsed)) return parsed;
if (int.TryParse(Request.Query["Role"].FirstOrDefault(), out parsed)) return parsed;
if (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
var body = await JsonSerializer.DeserializeAsync<GrantRoleRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (body?.Role is int bodyRole) return bodyRole;
}
catch (JsonException)
{
return null;
}
}
return null;
}
private async Task RemoveOtherRoomRolesAsync(long roomId, long playerId, int keepRole)
{
var rows = await db.RoomRoles
.Where(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role != keepRole)
.ToListAsync();
if (rows.Count > 0)
db.RoomRoles.RemoveRange(rows);
}
private static bool TryMapClientRoleToRoomRole(int clientRole, out int roomRole)
{
roomRole = clientRole switch
{
30 => 0, // CoOwner
20 => 1, // Moderator
10 => 2, // Host
_ => -1,
};
return roomRole >= 0;
}
private async Task<object> BuildRoomServerDetailsWithRolesAsync(long roomId)
{
var room = await db.Rooms.FirstAsync(r => r.Id == roomId);
var scenes = await db.RoomScenes
.Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var roles = await db.RoomRoles
.Where(r => r.RoomId == roomId)
.ToListAsync();
return BuildRoomServerDetails(room, scenes, roles: roles);
}
/// <summary>
/// Synthesise a placeholder Room when the client asks for one we don't
/// have in the DB. Keeps the deserializer happy — every required key
@@ -1,7 +1,6 @@
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;
@@ -23,20 +22,13 @@ namespace DorkNet.Server.Controllers.API.Rooms.V2;
[Authorize]
public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
{
private const int PublicAccessibility = 1;
private const long CanUseShareCamPermission = 1L << 18;
private long Me => this.RequireCurrentPlayerId();
private async Task<RoomEntity?> RequireOwnedRoomAsync(long roomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return null;
if (room.CreatorPlayerId == Me) return room;
var coOwner = await db.RoomRoles.AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == Me && r.Accepted && r.Role == 0);
return coOwner ? room : null;
return room.CreatorPlayerId == Me ? room : null;
}
// ── /modify (general fields) ─────────────────────────────────────────
@@ -306,29 +298,14 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
return Ok(new { success = true, error = "" });
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
var acceptedRoles = await db.RoomRoles
.Where(r => r.RoomId == roomId && r.PlayerId == playerId && r.Accepted)
.Select(r => r.Role)
.ToListAsync();
int role = (room?.CreatorPlayerId == playerId)
? 30
: acceptedRoles.Select(RoomRoleRank).DefaultIfEmpty(0).Max();
int role = (room?.CreatorPlayerId == playerId) ? 30 : 0;
long permissions = role switch
{
30 => -1L,
20 => 0x0FFFFFFFL,
10 => 0x0FFFFFFFL,
5 => 0x000000FFL,
_ => 0x0000000FL,
};
// RoomRole.BJICCBAKLAF.CAN_USE_SHARE_CAM = 18 in the
// 2020.12 decompile. Public rooms should allow desktop/share
// screens without requiring host/mod/co-owner, while private
// rooms keep their explicit role gate.
if (room?.Accessibility == PublicAccessibility)
{
permissions |= CanUseShareCamPermission;
}
return Ok(new
{
success = true,
@@ -339,366 +316,4 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
Permissions = permissions,
});
}
/// <summary>POST <c>api/rooms/v1/verifyRole</c> — status-only
/// gate used by 2020.12 before privileged room actions. Body is
/// form or JSON with roomId, role, and optional context.</summary>
[HttpPost("api/rooms/v1/verifyRole")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> VerifyRole()
{
var req = await ReadVerifyRoleRequestAsync();
if (req.RoomId <= 0) return BadRequest();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId);
if (room is null) return NotFound();
var me = Me;
var required = RoleRank(req.Role);
if (required <= 0) return Ok();
var isAdmin = await db.Players
.Where(p => p.Id == me)
.Select(p => p.IsAdmin)
.FirstOrDefaultAsync();
if (isAdmin || room.CreatorPlayerId == me) return Ok();
var acceptedRoles = await db.RoomRoles
.Where(r => r.RoomId == req.RoomId && r.PlayerId == me && r.Accepted)
.Select(r => r.Role)
.ToListAsync();
var actual = acceptedRoles.Select(RoomRoleRank).DefaultIfEmpty(0).Max();
return actual >= required ? Ok() : Forbid();
}
private sealed class VerifyRoleRequest
{
public long RoomId { get; set; }
public string Role { get; set; } = string.Empty;
public string Context { get; set; } = string.Empty;
}
private async Task<VerifyRoleRequest> ReadVerifyRoleRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new VerifyRoleRequest
{
RoomId = long.TryParse(form["roomId"].FirstOrDefault()
?? form["RoomId"].FirstOrDefault(), out var roomId)
? roomId
: 0,
Role = form["role"].FirstOrDefault()
?? form["Role"].FirstOrDefault()
?? string.Empty,
Context = form["context"].FirstOrDefault()
?? form["Context"].FirstOrDefault()
?? string.Empty,
};
}
try
{
var req = await JsonSerializer.DeserializeAsync<VerifyRoleRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return req ?? new VerifyRoleRequest();
}
catch (JsonException)
{
return new VerifyRoleRequest();
}
}
private static int RoomRoleRank(int role) => role switch
{
0 => 30, // CoOwner
1 => 10, // Moderator
2 => 20, // Host
_ => 0,
};
private static int RoleRank(string role)
{
if (int.TryParse(role, out var n)) return n;
return role.Trim().ToLowerInvariant() switch
{
"owner" or "creator" or "coowner" or "co-owner" => 30,
"host" => 20,
"moderator" or "mod" => 10,
"member" => 5,
"visitor" or "" => 0,
_ => 0,
};
}
// ── Bare-path per-field mutations (2020.12 watch URLs) ─────────────
//
// The 2020.12 client emits per-field mutation URLs like
// POST rooms/{id}/description rather than POST api/rooms/v2/modify
// with one field set. Each handler below shares the same
// RequireOwnedRoomAsync gate as /modify and returns the same
// ToWireRoom shape so the watch's CreateModifyRoomResponse
// deserializer reads cleanly.
public sealed class BareDescriptionRequest { public string? Description { get; set; } }
public sealed class BareImageRequest { public string? ImageName { get; set; } }
public sealed class BareTagsRequest { public string? Tags { get; set; } public List<string>? TagsList { get; set; } }
public sealed class BareIntRequest { public int? Value { get; set; } }
public sealed class BareBoolRequest { public bool? Value { get; set; } }
public sealed class BareWarningRequest { public int? RoomWarningMask { get; set; } public string? CustomRoomWarning { get; set; } }
// Each per-field route registers BOTH POST and PUT — the 2020.12
// watch's request-builder wraps the HTTP method inside opaque
// BPHGKAEDBPE helpers; the existing /name handler at
// RoomsController.cs:1455-1456 set the precedent of "register both
// because the ISIL is opaque". Same pattern here so the bind never
// 405s.
[HttpPost("rooms/{roomId:long}/description")]
[HttpPut("rooms/{roomId:long}/description")]
public async Task<IActionResult> BareDescription(long roomId,
[FromBody] BareDescriptionRequest? body,
[FromForm(Name = "Description")] string? form) =>
await ApplyAndReturn(roomId, r => r.Description = body?.Description ?? form ?? r.Description);
[HttpPost("rooms/{roomId:long}/image")]
[HttpPut("rooms/{roomId:long}/image")]
public async Task<IActionResult> BareImage(long roomId,
[FromBody] BareImageRequest? body,
[FromForm(Name = "ImageName")] string? form) =>
await ApplyAndReturn(roomId, r => r.ImageName = body?.ImageName ?? form ?? r.ImageName);
[HttpPost("rooms/{roomId:long}/tags")]
[HttpPut("rooms/{roomId:long}/tags")]
public async Task<IActionResult> BareTags(long roomId,
[FromBody] BareTagsRequest? body,
[FromForm(Name = "Tags")] string? form)
{
var csv = body?.Tags
?? (body?.TagsList is { Count: > 0 } list ? string.Join(',', list) : null)
?? form;
return await ApplyAndReturn(roomId, r => r.TagsCsv = csv ?? r.TagsCsv);
}
[HttpPost("rooms/{roomId:long}/accessibility")]
[HttpPut("rooms/{roomId:long}/accessibility")]
[HttpPost("roomserver/rooms/{roomId:long}/accessibility")]
[HttpPut("roomserver/rooms/{roomId:long}/accessibility")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareAccessibility(long roomId)
{
var value = await ReadBareIntAsync("accessibility", "Accessibility", "value", "Value");
return await ApplyAndReturn(roomId, r =>
{
if (value is int vv) r.Accessibility = Math.Clamp(vv, 0, 2);
});
}
private async Task<int?> ReadBareIntAsync(params string[] keys)
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in keys)
if (int.TryParse(form[key].FirstOrDefault(), out var value)) return value;
return null;
}
foreach (var key in keys)
if (int.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.TryGetInt32(out var n)) return n;
if (prop.ValueKind == JsonValueKind.String && int.TryParse(prop.GetString(), out n)) return n;
}
}
catch (JsonException)
{
return null;
}
return null;
}
[HttpPost("rooms/{roomId:long}/cloning")]
[HttpPut("rooms/{roomId:long}/cloning")]
public async Task<IActionResult> BareCloning(long roomId, [FromBody] BareBoolRequest? body,
[FromForm(Name = "CloningAllowed")] bool? form) =>
await ApplyAndReturn(roomId, r =>
{
var v = body?.Value ?? form;
if (v is bool vv) r.CloningAllowed = vv;
});
[HttpPost("rooms/{roomId:long}/automute")]
[HttpPut("rooms/{roomId:long}/automute")]
public async Task<IActionResult> BareAutomute(long roomId, [FromBody] BareBoolRequest? body,
[FromForm(Name = "DisableMicAutoMute")] bool? form) =>
await ApplyAndReturn(roomId, r =>
{
var v = body?.Value ?? form;
if (v is bool vv) r.DisableMicAutoMute = vv;
});
/// <summary><c>rooms/{id}/restrictions</c> — junior / age-related
/// restriction toggle. Body bool maps to AllowsJuniors (true = no
/// restriction, false = juniors restricted).</summary>
[HttpPost("rooms/{roomId:long}/restrictions")]
[HttpPut("rooms/{roomId:long}/restrictions")]
public async Task<IActionResult> BareRestrictions(long roomId, [FromBody] BareBoolRequest? body,
[FromForm(Name = "AllowsJuniors")] bool? form) =>
await ApplyAndReturn(roomId, r =>
{
var v = body?.Value ?? form;
if (v is bool vv) r.AllowsJuniors = vv;
});
[HttpPost("rooms/{roomId:long}/warning")]
[HttpPut("rooms/{roomId:long}/warning")]
public async Task<IActionResult> BareWarning(long roomId, [FromBody] BareWarningRequest? body) =>
await ApplyAndReturn(roomId, r =>
{
if (body?.RoomWarningMask is int m) r.RoomWarningMask = m;
if (body?.CustomRoomWarning is not null) r.CustomRoomWarning = body.CustomRoomWarning;
});
/// <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
/// a "save failed" error; populated when the columns land.</summary>
[HttpPost("rooms/{roomId:long}/comments")]
[HttpPut("rooms/{roomId:long}/comments")]
[HttpPost("rooms/{roomId:long}/voice_chat_encryption")]
[HttpPut("rooms/{roomId:long}/voice_chat_encryption")]
public async Task<IActionResult> BareAck(long roomId) =>
await ApplyAndReturn(roomId, _ => { /* no-op: not modelled yet */ });
/// <summary><c>rooms/{id}/modify</c> — bare-path equivalent
/// of <c>api/rooms/v2/modify</c>. Reuses <see cref="Modify"/> by
/// forwarding the body with RoomId pre-populated from the URL.</summary>
[HttpPost("rooms/{roomId:long}/modify")]
[HttpPut("rooms/{roomId:long}/modify")]
public async Task<IActionResult> BareModify(long roomId, [FromBody] ModifyRoomRequest req)
{
req.RoomId = roomId;
return await Modify(req);
}
/// <summary>POST <c>rooms/{id}/clone</c> — bare-path alias of
/// <c>api/rooms/v1/clone</c>. The actual clone logic lives on
/// <see cref="DorkNet.Server.Controllers.API.Rooms.V2.RoomsController"/>;
/// this handler proxies via the service so we don't duplicate the
/// substantial clone-rooms logic.</summary>
[HttpPost("rooms/{roomId:long}/clone")]
public async Task<IActionResult> BareClone(long roomId,
[FromBody] CloneBareRequest? body,
[FromForm(Name = "Name")] string? formName)
{
var newName = body?.Name ?? formName ?? string.Empty;
var source = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (source is null) return NotFound();
if (!source.CloningAllowed && source.CreatorPlayerId != Me)
return Forbid();
if (string.IsNullOrWhiteSpace(newName))
newName = $"{source.Name}Copy";
else
newName = newName.Trim();
// Uniqueness — append a numeric suffix until free.
var baseName = newName;
int suffix = 1;
while (await db.Rooms.AnyAsync(r => r.Name == newName))
{
suffix += 1;
newName = $"{baseName}{suffix}";
}
var clone = new RoomEntity
{
Name = newName,
Description = source.Description,
CreatorPlayerId = Me,
ImageName = source.ImageName,
State = 0,
Accessibility = source.Accessibility,
SupportsLevelVoting = source.SupportsLevelVoting,
IsAGRoom = false,
IsDormRoom = false,
CloningAllowed = source.CloningAllowed,
SupportsVRLow = source.SupportsVRLow,
SupportsMobile = source.SupportsMobile,
SupportsScreens = source.SupportsScreens,
SupportsWalkVR = source.SupportsWalkVR,
SupportsTeleportVR = source.SupportsTeleportVR,
AllowsJuniors = source.AllowsJuniors,
RoomWarningMask = source.RoomWarningMask,
CustomRoomWarning = source.CustomRoomWarning,
DisableMicAutoMute = source.DisableMicAutoMute,
LocationReplicationId = source.LocationReplicationId,
TagsCsv = source.TagsCsv,
CurrentDataBlobName = source.CurrentDataBlobName,
};
db.Rooms.Add(clone);
await db.SaveChangesAsync();
return Ok(Services.RoomService.ToWireRoom(clone));
}
public sealed class CloneBareRequest { public string? Name { get; set; } }
// ── Bare-path bans aliases ─────────────────────────────────────────
public sealed class BareBanRequest { public long PlayerId { get; set; } public int BanType { get; set; } public DateTime? Until { get; set; } public string? Reason { get; set; } }
[HttpGet("rooms/{roomId:long}/bans")]
public Task<IActionResult> BareBansList(long roomId) => RoomBansList(roomId);
[HttpPost("rooms/{roomId:long}/bans")]
public Task<IActionResult> BareBan(long roomId, [FromBody] BareBanRequest req) =>
BanFromRoom(new BanFromRoomRequest
{
RoomId = roomId,
PlayerId = req.PlayerId,
BanType = req.BanType,
Until = req.Until,
Reason = req.Reason,
});
[HttpDelete("rooms/{roomId:long}/bans/{playerId:long}")]
public async Task<IActionResult> BareUnban(long roomId, long playerId)
{
var room = await RequireOwnedRoomAsync(roomId);
if (room is null) return NotFound();
var rows = await db.RoomBans
.Where(b => b.RoomId == roomId && b.BannedPlayerId == playerId)
.ToListAsync();
if (rows.Count == 0) return Ok(new { Removed = 0 });
db.RoomBans.RemoveRange(rows);
await db.SaveChangesAsync();
return Ok(new { Removed = rows.Count });
}
[HttpPost("rooms/{roomId:long}/bans/import")]
public Task<IActionResult> BareBansImport(long roomId, [FromBody] ImportRoomBansRequest req)
{
req.RoomId = roomId;
return ImportRoomBans(req);
}
private async Task<IActionResult> ApplyAndReturn(long roomId, Action<RoomEntity> mutator)
{
var room = await RequireOwnedRoomAsync(roomId);
if (room is null) return NotFound();
mutator(room);
room.UpdatedAt = DateTime.UtcNow;
try { await db.SaveChangesAsync(); }
catch (DbUpdateException) { return Conflict(new { Result = 2 }); }
return Ok(Services.RoomService.ToWireRoom(room));
}
}
@@ -115,18 +115,4 @@ public class SanitizeController(ILogger<SanitizeController> logger) : Controller
?? textForm ?? textQuery ?? string.Empty;
return Ok(ProfanityFilter.IsClean(input));
}
/// <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>
[HttpPost("api/sanitize/isPure")]
public IActionResult IsPure(
[FromBody] SanitizeBody? body,
[FromForm(Name = "Text")] string? textForm,
[FromQuery(Name = "text")] string? textQuery)
{
var input = body?.Text ?? body?.Input ?? body?.Value
?? textForm ?? textQuery ?? string.Empty;
return Ok(new { IsPure = ProfanityFilter.IsClean(input) });
}
}
@@ -113,66 +113,23 @@ public class PurchaseController(StoreService store, ILogger<PurchaseController>
public IActionResult CleanupPending() =>
Ok(new { success = true, error = "" });
/// <summary>POST <c>subscription/v1/*</c> — Rec Room+. The private
/// server has no real billing integration, so buying RR+ is a yes/ack
/// operation and cancel is also acknowledged. Returning an active
/// subscription object keeps the client out of platform IAP UI.</summary>
/// <summary>POST <c>subscription/v1/cancel</c> — Rec Room+
/// subscription cancel button. Verified URL at
/// Commerce.txt:2188. The private server has no real billing
/// integration (no Stripe, no platform IAP receipts) so the
/// subscription is purely cosmetic — we ack the cancel so the
/// watch flips its UI to "canceled" and stops the recurring
/// renewal nag. If a real subscription entity ever lands, this
/// is where to expire its CurrentPeriodEnd.</summary>
[HttpPost("/subscription/v1")]
[HttpPost("/subscription/v1/purchase")]
[HttpPost("/subscription/v1/buy")]
[HttpPost("/subscription/v1/cancel")]
[HttpPost("/api/subscription/v1")]
[HttpPost("/api/subscription/v1/purchase")]
[HttpPost("/api/subscription/v1/buy")]
[HttpPost("/api/subscription/v1/cancel")]
public IActionResult SubscriptionAck()
public IActionResult CancelSubscription()
{
var pid = this.CurrentPlayerId() ?? 0;
logger.LogInformation("[subscription] ack requested by player {Pid}", pid);
return Ok(SubscriptionResponse(pid));
}
[HttpGet("/subscription/v1")]
[HttpGet("/subscription/v1/current")]
[HttpGet("/subscription/v1/mine")]
[HttpGet("/api/subscription/v1")]
[HttpGet("/api/subscription/v1/current")]
[HttpGet("/api/subscription/v1/mine")]
public IActionResult SubscriptionStatus() =>
Ok(SubscriptionResponse(this.CurrentPlayerId() ?? 0));
private static Dictionary<string, object?> SubscriptionResponse(long playerId)
{
var now = DateTime.UtcNow;
var pascal = new Dictionary<string, object?>
{
["SubscriptionId"] = $"dorknet-rrplus-{playerId}",
["RecNetPlayerId"] = playerId,
["IsActive"] = true,
["StartedAt"] = now,
["CurrentPeriodEnd"] = now.AddYears(10),
["RenewalDate"] = now.AddYears(10),
};
var camel = new Dictionary<string, object?>
{
["subscriptionId"] = $"dorknet-rrplus-{playerId}",
["recNetPlayerId"] = playerId,
["isActive"] = true,
["startedAt"] = now,
["currentPeriodEnd"] = now.AddYears(10),
["renewalDate"] = now.AddYears(10),
};
return new Dictionary<string, object?>
{
["success"] = true,
["error"] = "",
["IsSubscribed"] = true,
["isSubscribed"] = true,
["Active"] = true,
["active"] = true,
["Subscription"] = pascal,
["subscription"] = camel,
};
logger.LogInformation("[subscription] cancel requested by player {Pid}",
this.CurrentPlayerId() ?? 0);
return Ok(new { success = true, error = "" });
}
private async Task<Data.Entities.StoreItemEntity?> ResolveItemAsync(PurchaseRequest body)
@@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Controllers.API.RoomKeys;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
@@ -103,7 +102,6 @@ public class StorefrontsBuyController(
StoreService.TryGetAvatarItemPayload(item.Slug, out var avatarItemType, out avatarItemDesc);
var inventoryAvatarDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc);
StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableItemDesc);
StoreService.TryGetEquipmentPayload(item.Slug, out var equipmentPrefabName, out var equipmentModificationGuid);
if (StoreService.TryGetHairDyePayload(item.Slug, out var hairDyeConsumableDesc, out var hairDyeColorGuid))
{
avatarItemType = 1;
@@ -117,8 +115,6 @@ public class StorefrontsBuyController(
AvatarItemType = string.IsNullOrEmpty(inventoryAvatarDesc) ? null : avatarItemType,
AvatarItemDescOrHairDyeDesc = inventoryAvatarDesc,
ConsumableItemDesc = consumableItemDesc,
EquipmentPrefabName = equipmentPrefabName,
EquipmentModificationGuid = equipmentModificationGuid,
CurrencyType = req.CurrencyType,
Currency = 0,
Xp = 0,
@@ -161,8 +157,6 @@ public class StorefrontsBuyController(
AvatarItemType = gift.AvatarItemType,
AvatarItemDesc = avatarItemDesc,
ConsumableItemDesc = gift.ConsumableItemDesc ?? string.Empty,
EquipmentPrefabName = gift.EquipmentPrefabName,
EquipmentModificationGuid = gift.EquipmentModificationGuid,
Message = gift.Message ?? string.Empty,
});
}
@@ -201,47 +195,6 @@ public class StorefrontsBuyController(
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
}
/// <summary>GET <c>api/storefronts/v1/buyRoomKey</c> — purchase a
/// 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")]
public async Task<IActionResult> BuyRoomKey(
[FromQuery] long roomKeyId,
[FromQuery] int requestedPrice)
{
var pid = Me;
var key = await db.RoomKeys.FirstOrDefaultAsync(k => k.Id == roomKeyId && !k.IsDeleted);
if (key is null)
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.DoesNotExist, null, await level.GetBalanceAsync(pid, 2)));
if (key.CreatorPlayerId == pid)
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.PlayerAlreadyOwns, key, await level.GetBalanceAsync(pid, 2)));
if (requestedPrice != key.Price)
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.InvalidParameters, key, await level.GetBalanceAsync(pid, 2)));
var existing = await db.RoomKeyPurchases
.FirstOrDefaultAsync(p => p.RoomKeyId == key.Id && p.PlayerId == pid);
if (existing is not null)
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.PlayerAlreadyOwns, key, await level.GetBalanceAsync(pid, 2)));
var balance = await level.GetBalanceAsync(pid, 2);
if (balance < key.Price)
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.PurchaseFailed, key, balance));
var newBalance = await level.GrantCurrencyAsync(pid, 2, -key.Price, $"buyRoomKey:{key.Id}");
db.RoomKeyPurchases.Add(new RoomKeyPurchaseEntity
{
RoomKeyId = key.Id,
PlayerId = pid,
PaidPrice = key.Price,
});
await db.SaveChangesAsync();
if (key.CreatorPlayerId != pid && key.Price > 0)
await level.GrantCurrencyAsync(key.CreatorPlayerId, 2, key.Price, $"sellRoomKey:{key.Id}");
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.Success, key, newBalance));
}
// ── Helpers ──────────────────────────────────────────────────────────
private async Task GrantWardrobeAsync(long pid, string guid)
@@ -346,13 +299,10 @@ public class StorefrontsBuyController(
var avatarItemType = 0;
var avatarItemDesc = string.Empty;
var consumableItemDesc = string.Empty;
var equipmentPrefabName = string.Empty;
var equipmentModificationGuid = string.Empty;
if (grantedSlug is not null)
{
StoreService.TryGetAvatarItemPayload(grantedSlug, out avatarItemType, out avatarItemDesc);
StoreService.TryGetConsumableItemDesc(grantedSlug, out consumableItemDesc);
StoreService.TryGetEquipmentPayload(grantedSlug, out equipmentPrefabName, out equipmentModificationGuid);
if (StoreService.TryGetHairDyePayload(grantedSlug, out var hairDyeConsumableDesc, out var hairDyeColorGuid))
{
avatarItemType = 1;
@@ -364,18 +314,8 @@ public class StorefrontsBuyController(
var giftPackage = new
{
// REQUIRED via Util.GetKey — KeyNotFoundException on miss.
// Verified keys at NLEKGNENMCO_NestedType_LOCNECLOHCA.txt:792-946:
// Id, Message, FromPlayerId, AvatarItemType, AvatarItemDesc,
// ConsumableItemDesc, EquipmentPrefabName,
// EquipmentModificationGuid, Platform, PlatformsToSpawnOn,
// BalanceType, CurrencyType, Currency, Xp, GiftRarity,
// GiftContext. Missing any → "Failed to download gifts:
// Malformed Response" (and on a purchase path, the buy
// times out because the response never deserializes).
Id = giftId,
Platform = 0,
PlatformsToSpawnOn = -1,
BalanceType = 0,
Xp = 0,
Level = 1,
GiftRarity = 0,
@@ -386,8 +326,8 @@ public class StorefrontsBuyController(
AvatarItemDesc = avatarItemDesc,
AvatarItemDescOrHairDyeDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc),
ConsumableItemDesc = consumableItemDesc,
EquipmentPrefabName = equipmentPrefabName,
EquipmentModificationGuid = equipmentModificationGuid,
EquipmentPrefabName = string.Empty,
EquipmentModificationGuid = string.Empty,
CurrencyType = currencyType,
Currency = 0,
Message = string.Empty,
@@ -415,16 +355,4 @@ public class StorefrontsBuyController(
},
};
}
private static object RoomKeyPurchaseResponse(RoomKeyStatus status, RoomKeyEntity? key, long balance) => new
{
RoomKeyResponse = RoomKeysController.RoomKeyResponse(status, key),
BalanceUpdateResponse = new
{
Balance = balance,
CurrencyType = 2,
BalanceType = 0,
BalanceUpdates = Array.Empty<object>(),
},
};
}
@@ -50,23 +50,6 @@ public class StorefrontsController(
return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray());
}
/// <summary>GET <c>api/storefronts/v1/p2p/betaEnabled</c> —
/// raw Boolean gate for player-to-player sales. Returning false
/// disables the beta affordance without lying about catalog state.</summary>
[HttpGet("api/storefronts/v1/p2p/betaEnabled")]
[AllowAnonymous]
public IActionResult P2PBetaEnabled() => Content("false", "application/json");
/// <summary>GET <c>api/storefronts/v1/trialInvention/duration</c>.
/// The 2020.12 client's public promise projects the Duration
/// property from this object, so the wire body is not a bare int.</summary>
[HttpGet("api/storefronts/v1/trialInvention/duration")]
[AllowAnonymous]
public IActionResult TrialInventionDuration() => Ok(new
{
Duration = Math.Max(0, config.GetValue("Store:TrialInventionDurationSeconds", 300)),
});
/// <summary>GET api/storefronts/v3/season — items currently in the
/// season-pass storefront. Empty for now since we don't run
/// seasons; returning an empty list rather than the catch-all so
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.Version;
[ApiController]
public class VersionController : ControllerBase
{
// VersionStatus enum: ValidForPlay=0, ValidForMenu=1, UpdateRequired=2
private static readonly object ValidForPlay = new { VersionStatus = 0 };
[HttpGet("api/version/v1")]
[HttpGet("api/version")]
public ActionResult<object> GetV1() => Ok(ValidForPlay);
// Actual path the 2020 build uses
[HttpGet("api/versioncheck/v4")]
[HttpGet("api/versioncheck/v3")]
[HttpGet("api/versioncheck/v2")]
[HttpGet("api/versioncheck/v1")]
[HttpGet("api/versioncheck")]
public ActionResult<object> GetCheck() => Ok(ValidForPlay);
}
@@ -393,10 +393,19 @@ public class AccountsController(
logger.LogInformation(
"[accounts] profile image set: player={PlayerId} → {Name}",
player.Id, player.ProfileImageName ?? "<cleared>");
await notifications.AccountChanged(player);
await NotifyAccountImageChangedAsync(player);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
private Task NotifyAccountImageChangedAsync(PlayerEntity player)
{
var account = BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName);
var selfAccount = BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName);
return Task.WhenAll(
notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account),
notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
}
private static int AgeYears(DateTime birthday)
{
var today = DateTime.UtcNow.Date;
@@ -431,7 +440,6 @@ public class AccountsController(
if (player is null) return Unauthorized();
await playerService.UpdateUsernameAsync(player.Id, username.Trim());
var updated = await playerService.GetByIdAsync(player.Id);
if (updated is not null) await notifications.AccountChanged(updated);
return Ok(new
{
success = true,
@@ -460,8 +468,6 @@ public class AccountsController(
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
await playerService.UpdateDisplayNameAsync(player.Id, displayName.Trim());
var updated = await playerService.GetByIdAsync(player.Id);
if (updated is not null) await notifications.AccountChanged(updated);
return Ok(new { success = true, error = "" });
}
@@ -1,7 +1,6 @@
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;
@@ -40,7 +39,6 @@ public class AdminController(
SignupCodeService signupCodes,
IObjectStorage storage,
DomainConfig domain,
IdentityServerSigningKeyProvider signingKeys,
ILogger<AdminController> adminLogger) : ControllerBase
{
private long CurrentAdminId => this.RequireCurrentPlayerId();
@@ -77,6 +75,7 @@ public class AdminController(
q = q.Where(p => p.Username.Contains(query) || p.DisplayName.Contains(query));
var page = await q.Skip(skip).Take(take).ToListAsync();
var onlineSet = onlinePresence.OnlinePlayerIds().ToHashSet();
return Ok(page.Select(p => new
{
p.Id,
@@ -95,7 +94,7 @@ public class AdminController(
p.Level,
p.XP,
p.ProfileImageName,
Online = playerPresence.IsRecentlyActive(p.Id),
Online = onlineSet.Contains(p.Id),
}));
}
@@ -134,7 +133,7 @@ public class AdminController(
p.Level,
p.XP,
p.ProfileImageName,
Online = playerPresence.IsRecentlyActive(p.Id),
Online = onlinePresence.OnlinePlayerIds().Contains(p.Id),
Balances = balances,
Avatar = avatar,
});
@@ -160,8 +159,8 @@ public class AdminController(
await LogAsync("ban_player", "player", id, body.Reason ?? "");
await db.SaveChangesAsync();
await notifications.KickPlayerAsync(id, body.Reason ?? "You have been banned.");
playerPresence.Clear(id);
await notifications.NotifyAsync(id, PushNotificationId.ModerationKick,
new { Reason = body.Reason ?? "", Until = player.BannedUntil });
return Ok(new { player.Id, player.BannedUntil });
}
@@ -205,56 +204,6 @@ public class AdminController(
return Ok(new { player.Id, player.IsAdmin });
}
public sealed record DeletePlayerRequest(string ConfirmUsername, string ConfirmPhrase, string? Reason);
/// <summary>Hard-remove a non-admin player account. The destructive
/// endpoint requires two exact confirmations: the current username and
/// the phrase <c>DELETE {id}</c>. Personal rows are deleted, authored
/// durable content is reassigned to the system account, and live sessions
/// are kicked so stale bearer tokens stop being useful immediately.</summary>
[HttpDelete("players/{id:long}")]
public async Task<ActionResult> DeletePlayer(long id, [FromBody] DeletePlayerRequest body)
{
if (id == 1)
return BadRequest(new { Error = "Cannot delete the system account." });
if (id == CurrentAdminId)
return BadRequest(new { Error = "Cannot delete your own admin account." });
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
if (player.IsAdmin)
return BadRequest(new { Error = "Cannot delete an admin account. Demote it first." });
var expectedPhrase = $"DELETE {player.Id}";
if (!string.Equals(body.ConfirmUsername?.Trim(), player.Username, StringComparison.Ordinal)
|| !string.Equals(body.ConfirmPhrase?.Trim(), expectedPhrase, StringComparison.Ordinal))
return BadRequest(new { Error = $"Confirmation failed. Type @{player.Username} and {expectedPhrase} exactly." });
var reason = string.IsNullOrWhiteSpace(body.Reason)
? $"removed @{player.Username}"
: body.Reason.Trim();
var systemPlayerId = await db.Players.AnyAsync(p => p.Id == 1) ? 1L : CurrentAdminId;
await using var tx = await db.Database.BeginTransactionAsync();
var summary = await DeletePlayerDataAsync(id, systemPlayerId);
db.Players.Remove(player);
await LogAsync("delete_player", "player", id, $"{reason}; {summary}");
await db.SaveChangesAsync();
await tx.CommitAsync();
await notifications.KickPlayerAsync(id, "Your account has been removed by an admin.");
playerPresence.Clear(id);
return Ok(new
{
deleted = id,
username = player.Username,
reassignedToPlayerId = systemPlayerId,
summary,
});
}
// ── Rooms ────────────────────────────────────────────────────────────
public sealed record DeleteRoomRequest(string? Reason);
@@ -402,7 +351,6 @@ public class AdminController(
{
r.Id,
r.Name,
r.ImageName,
r.IsAGRoom,
r.IsDormRoom,
r.CreatorPlayerId,
@@ -533,191 +481,6 @@ public class AdminController(
});
}
public sealed record WipeImportTargetsRequest(
long[]? InventionIds,
string[]? RoomNames);
/// <summary>POST <c>api/admin/v1/import/wipe-targets</c> — clear the
/// DB rows that would conflict with a zip re-import. Built for the
/// zip importer's "Wipe duplicates first" affordance — when the
/// admin uploads a refreshed zip after a recovery pass added more
/// .inv bytes, the second import would otherwise: (a) reject every
/// room with <c>duplicate_name</c>, and (b) silently auto-id every
/// invention whose original id is already in the DB, leaving the
/// room blob's placement refs pointing at orphans (= empty/missing
/// inventions in-game).
///
/// Wipes:
/// * <see cref="InventionEntity"/> rows for the listed ids + their
/// associated <see cref="RoomDataBlobEntity"/> rows (.inv bytes
/// + image thumbnails)
/// * <see cref="RoomEntity"/> rows matching any of the listed
/// names exactly + their <see cref="RoomSceneEntity"/> rows +
/// <see cref="RoomDataBlobEntity"/> rows (scene blobs + room
/// image)
///
/// Skips dorms and seed-owned RR-Originals (same safety net as
/// <see cref="HardDeleteRoom"/>) — only user-content survives the
/// wipe path.
///
/// Returns a per-room and per-invention rollup so the UI can show
/// what landed before kicking off the actual upload.</summary>
[HttpPost("import/wipe-targets")]
public async Task<ActionResult> WipeImportTargets([FromBody] WipeImportTargetsRequest body)
{
var inventionIds = body?.InventionIds ?? Array.Empty<long>();
var roomNames = body?.RoomNames ?? Array.Empty<string>();
if (inventionIds.Length == 0 && roomNames.Length == 0)
return BadRequest(new { error = "nothing_to_wipe" });
// ── Inventions ────────────────────────────────────────────────
var inventions = inventionIds.Length == 0
? new List<InventionEntity>()
: await db.Inventions.Where(i => inventionIds.Contains(i.Id)).ToListAsync();
var invBlobNames = inventions
.SelectMany(i => new[] { i.CurrentBlobName, i.ImageName })
.Where(n => !string.IsNullOrEmpty(n))
.Cast<string>()
.Distinct()
.ToList();
var invBlobs = invBlobNames.Count == 0
? new List<RoomDataBlobEntity>()
: await db.RoomDataBlobs
.Where(b => b.RoomId == 0 && invBlobNames.Contains(b.BlobName))
.ToListAsync();
// ── Rooms ─────────────────────────────────────────────────────
// Exact-name match — same key the importer uses for its
// duplicate-name rejection. Skip dorms + seed-owned RR-Originals
// so a fat-finger on the wipe list can't nuke canonical content.
var roomMatches = roomNames.Length == 0
? new List<RoomEntity>()
: await db.Rooms.Where(r => roomNames.Contains(r.Name)).ToListAsync();
var roomsToDelete = roomMatches
.Where(r => !r.IsDormRoom
&& !(r.IsAGRoom && r.CreatorPlayerId == PlayerService.SystemAccountId))
.ToList();
var roomIds = roomsToDelete.Select(r => r.Id).ToList();
var roomScenes = roomIds.Count == 0
? new List<RoomSceneEntity>()
: await db.RoomScenes.Where(s => roomIds.Contains(s.RoomId)).ToListAsync();
var roomBlobs = roomIds.Count == 0
? new List<RoomDataBlobEntity>()
: await db.RoomDataBlobs.Where(b => roomIds.Contains(b.RoomId)).ToListAsync();
var roomImageNames = roomsToDelete
.Select(r => r.ImageName)
.Where(n => !string.IsNullOrEmpty(n))
.Cast<string>()
.Distinct()
.ToList();
var roomImages = roomImageNames.Count == 0
? new List<RoomDataBlobEntity>()
: await db.RoomDataBlobs
.Where(b => b.RoomId == 0 && roomImageNames.Contains(b.BlobName))
.ToListAsync();
db.RoomDataBlobs.RemoveRange(invBlobs);
db.Inventions.RemoveRange(inventions);
db.RoomScenes.RemoveRange(roomScenes);
db.RoomDataBlobs.RemoveRange(roomBlobs);
db.RoomDataBlobs.RemoveRange(roomImages);
db.Rooms.RemoveRange(roomsToDelete);
await LogAsync("wipe_import_targets", "import", 0,
$"inventions={inventions.Count}/{inventionIds.Length} " +
$"rooms={roomsToDelete.Count}/{roomNames.Length} " +
$"roomBlobs={roomBlobs.Count} roomScenes={roomScenes.Count} " +
$"invBlobs={invBlobs.Count} roomImages={roomImages.Count}");
await db.SaveChangesAsync();
var skippedRooms = roomMatches
.Where(r => r.IsDormRoom
|| (r.IsAGRoom && r.CreatorPlayerId == PlayerService.SystemAccountId))
.Select(r => new { id = r.Id, name = r.Name, reason = r.IsDormRoom ? "dorm" : "seeded_rr_original" })
.ToList();
return Ok(new
{
inventionsDeleted = inventions.Count,
inventionsRequested = inventionIds.Length,
inventionsNotFound = inventionIds.Length - inventions.Count,
inventionBlobsRemoved = invBlobs.Count,
roomsDeleted = roomsToDelete.Count,
roomsRequested = roomNames.Length,
roomsNotFound = roomNames.Length - roomMatches.Count,
roomScenesRemoved = roomScenes.Count,
roomBlobsRemoved = roomBlobs.Count + roomImages.Count,
skippedRooms,
});
}
public sealed record PurgeInventionsRequest(
long[]? InventionIds,
// The caller must echo back the count of ids it expects to delete.
// Mismatch = client and server disagree about scope → refuse.
// Same pattern as `git rm` requiring -f when files would be lost.
int? ExpectedCount);
/// <summary>POST <c>api/admin/v1/inventions/purge</c> — hard-delete a
/// batch of inventions by id. Used by the zip-recovery workflow to
/// wipe a room's referenced inventions before re-importing a refreshed
/// zip — avoids the duplicate-id problem where the zip importer
/// auto-generates new ids when the original is already in the DB,
/// leaving the room blob's invention references pointing at orphans.
///
/// Deletes:
/// * <see cref="InventionEntity"/> rows for the given ids
/// * Associated <see cref="RoomDataBlobEntity"/> rows for both the
/// invention's <c>CurrentBlobName</c> (the .inv bytes) and
/// <c>ImageName</c> (the thumbnail). RoomId on those rows is 0
/// for invention-owned blobs.
///
/// Does NOT touch S3 — the importer will overwrite on re-import, and
/// orphan S3 keys are harmless (read path lookups them by BlobName
/// from the DB).
///
/// Logs each delete to AdminActions for audit replay.
/// </summary>
[HttpPost("inventions/purge")]
public async Task<ActionResult> PurgeInventions([FromBody] PurgeInventionsRequest body)
{
var ids = body?.InventionIds;
if (ids is null || ids.Length == 0)
return BadRequest(new { error = "missing_ids" });
if (body!.ExpectedCount is not int ec || ec != ids.Length)
return BadRequest(new { error = "expected_count_mismatch", expected = body.ExpectedCount, actual = ids.Length });
var inventions = await db.Inventions
.Where(i => ids.Contains(i.Id))
.ToListAsync();
var blobNames = inventions
.SelectMany(i => new[] { i.CurrentBlobName, i.ImageName })
.Where(n => !string.IsNullOrEmpty(n))
.Cast<string>()
.Distinct()
.ToList();
var blobs = await db.RoomDataBlobs
.Where(b => b.RoomId == 0 && blobNames.Contains(b.BlobName))
.ToListAsync();
db.RoomDataBlobs.RemoveRange(blobs);
db.Inventions.RemoveRange(inventions);
await LogAsync("purge_inventions", "invention", 0,
$"count={inventions.Count} blobs={blobs.Count} ids=[{string.Join(",", inventions.Select(i => i.Id).Take(20))}{(inventions.Count > 20 ? ",..." : "")}]");
await db.SaveChangesAsync();
return Ok(new
{
deleted = inventions.Count,
requested = ids.Length,
notFound = ids.Length - inventions.Count,
blobsRemoved = blobs.Count,
});
}
// ── Leaderboards ─────────────────────────────────────────────────────
/// <summary>Verified stat-channel metadata. The 2020 watch's
@@ -751,31 +514,28 @@ public class AdminController(
public async Task<ActionResult> ListChannels()
{
var rows = await db.LeaderboardStats
.GroupBy(s => new { s.RoomId, s.StatChannel })
.GroupBy(s => s.StatChannel)
.Select(g => new
{
g.Key.RoomId,
Channel = g.Key.StatChannel,
Channel = g.Key,
EntryCount = g.Count(),
MinValue = g.Min(x => x.Value),
MaxValue = g.Max(x => x.Value),
})
.OrderBy(r => r.RoomId)
.ThenBy(r => r.Channel)
.OrderBy(r => r.Channel)
.ToListAsync();
// DB-defined meta beats hardcoded KnownStatChannels so admins
// can rename / reassign channels live without a code change.
var dbMeta = await db.LeaderboardChannelMeta
.ToDictionaryAsync(c => new { c.RoomId, c.Channel });
var dbMeta = await db.LeaderboardChannelMeta.ToDictionaryAsync(c => c.Channel);
var labelled = rows.Select(r =>
{
dbMeta.TryGetValue(new { r.RoomId, r.Channel }, out var live);
dbMeta.TryGetValue(r.Channel, out var live);
KnownStatChannels.TryGetValue(r.Channel, out var fallback);
return new
{
r.Channel,
Name = live?.Name is { Length: > 0 } n ? n : (fallback?.Name ?? $"Channel {r.Channel}"),
RoomId = r.RoomId,
RoomId = live?.RoomId ?? 0,
LowerIsBetter = live?.LowerIsBetter ?? fallback?.LowerIsBetter ?? false,
ValueFormat = live?.ValueFormat ?? fallback?.ValueFormat ?? "count",
Verified = live is not null || fallback is not null,
@@ -805,16 +565,16 @@ public class AdminController(
/// the channel's natural direction (lower-is-better channels
/// sorted ascending, everything else descending).</summary>
[HttpGet("leaderboards/{channel:int}")]
public async Task<ActionResult> ChannelDetail(int channel, [FromQuery] long roomId = 0, [FromQuery] int take = 100)
public async Task<ActionResult> ChannelDetail(int channel, [FromQuery] int take = 100)
{
take = Math.Clamp(take, 1, 1000);
var liveMeta = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.RoomId == roomId && c.Channel == channel);
var liveMeta = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == channel);
KnownStatChannels.TryGetValue(channel, out var fallback);
var name = liveMeta?.Name is { Length: > 0 } ln ? ln : (fallback?.Name ?? $"Channel {channel}");
var lowerIsBetter = liveMeta?.LowerIsBetter ?? fallback?.LowerIsBetter ?? false;
var valueFormat = liveMeta?.ValueFormat ?? fallback?.ValueFormat ?? "count";
var query = db.LeaderboardStats
.Where(s => s.RoomId == roomId && s.StatChannel == channel);
.Where(s => s.StatChannel == channel);
query = lowerIsBetter
? query.OrderBy(s => s.Value)
: query.OrderByDescending(s => s.Value);
@@ -835,7 +595,7 @@ public class AdminController(
{
Channel = channel,
Name = name,
RoomId = roomId,
RoomId = liveMeta?.RoomId ?? 0,
LowerIsBetter = lowerIsBetter,
ValueFormat = valueFormat,
Verified = liveMeta is not null || fallback is not null,
@@ -852,7 +612,7 @@ public class AdminController(
});
}
public sealed record SetLeaderboardScoreRequest(long PlayerId, int Value, long RoomId = 0);
public sealed record SetLeaderboardScoreRequest(long PlayerId, int Value);
/// <summary>POST <c>api/admin/v1/leaderboards/{channel}/score</c>
/// — upsert a single player's score in a channel.</summary>
@@ -860,13 +620,12 @@ public class AdminController(
public async Task<ActionResult> SetScore(int channel, [FromBody] SetLeaderboardScoreRequest body)
{
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.RoomId == body.RoomId && s.PlayerId == body.PlayerId && s.StatChannel == channel);
s.PlayerId == body.PlayerId && s.StatChannel == channel);
if (row is null)
{
row = new LeaderboardStatEntity
{
PlayerId = body.PlayerId,
RoomId = body.RoomId,
StatChannel = channel,
Value = body.Value,
};
@@ -877,37 +636,35 @@ public class AdminController(
row.Value = body.Value;
row.UpdatedAt = DateTime.UtcNow;
}
await LogAsync("set_leaderboard_score", "player", body.PlayerId, $"room={body.RoomId} channel={channel} value={body.Value}");
await LogAsync("set_leaderboard_score", "player", body.PlayerId, $"channel={channel} value={body.Value}");
await db.SaveChangesAsync();
return Ok(new { row.PlayerId, row.RoomId, row.StatChannel, row.Value });
return Ok(new { row.PlayerId, row.StatChannel, row.Value });
}
[HttpDelete("leaderboards/{channel:int}/score/{playerId:long}")]
public async Task<ActionResult> DeleteScore(int channel, long playerId, [FromQuery] long roomId = 0)
public async Task<ActionResult> DeleteScore(int channel, long playerId)
{
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.RoomId == roomId && s.PlayerId == playerId && s.StatChannel == channel);
s.PlayerId == playerId && s.StatChannel == channel);
if (row is null) return NotFound();
db.LeaderboardStats.Remove(row);
await LogAsync("delete_leaderboard_score", "player", playerId, $"room={roomId} channel={channel}");
await LogAsync("delete_leaderboard_score", "player", playerId, $"channel={channel}");
await db.SaveChangesAsync();
return Ok(new { deleted = playerId, roomId, channel });
return Ok(new { deleted = playerId, channel });
}
/// <summary>DELETE <c>api/admin/v1/leaderboards/{channel}</c> —
/// wipe every entry in a stat channel. Used for season resets or
/// to clear test data.</summary>
[HttpDelete("leaderboards/{channel:int}")]
public async Task<ActionResult> WipeChannel(int channel, [FromQuery] long roomId = 0)
public async Task<ActionResult> WipeChannel(int channel)
{
var rows = await db.LeaderboardStats
.Where(s => s.RoomId == roomId && s.StatChannel == channel)
.ToListAsync();
if (rows.Count == 0) return Ok(new { wiped = 0, roomId, channel });
var rows = await db.LeaderboardStats.Where(s => s.StatChannel == channel).ToListAsync();
if (rows.Count == 0) return Ok(new { wiped = 0, channel });
db.LeaderboardStats.RemoveRange(rows);
await LogAsync("wipe_leaderboard_channel", "system", channel, $"room={roomId} entries={rows.Count}");
await LogAsync("wipe_leaderboard_channel", "system", channel, $"entries={rows.Count}");
await db.SaveChangesAsync();
return Ok(new { wiped = rows.Count, roomId, channel });
return Ok(new { wiped = rows.Count, channel });
}
// ── Rec Room Originals (RROs) ────────────────────────────────────────
@@ -955,8 +712,7 @@ public class AdminController(
string? TagsCsv,
double? HotScore,
int? State,
string? ImageName,
int? MaxCapacity);
string? ImageName);
/// <summary>POST <c>api/admin/v1/rooms/{id}/props</c> — partial
/// update of room properties. Only the non-null fields in the
@@ -979,13 +735,6 @@ public class AdminController(
if (body.HotScore is double h) { room.HotScore = h; changes.Add($"hot={h}"); }
if (body.State is int st) { room.State = st; changes.Add($"state={st}"); }
if (body.ImageName is string img) { room.ImageName = img; changes.Add($"image={img}"); }
if (body.MaxCapacity is int cap)
{
// Clamp to a sane range. Note: advertised cap only on the 2020.12
// client today (no Photon hard-enforce) — see RoomEntity.MaxCapacity.
room.MaxCapacity = Math.Clamp(cap, 1, 80);
changes.Add($"maxCapacity={room.MaxCapacity}");
}
if (changes.Count == 0) return Ok(new { room.Id, unchanged = true });
@@ -1049,7 +798,6 @@ public class AdminController(
room.SupportsWalkVR,
room.SupportsTeleportVR,
room.AllowsJuniors,
room.MaxCapacity,
room.DisableMicAutoMute,
room.RoomWarningMask,
room.CustomRoomWarning,
@@ -1094,12 +842,6 @@ public class AdminController(
if (body.PlayerId <= 0) return BadRequest("missing player");
if (body.Role is < 0 or > 2) return BadRequest("invalid role");
var oldRoles = await db.RoomRoles
.Where(r => r.RoomId == id && r.PlayerId == body.PlayerId && r.Role != body.Role)
.ToListAsync();
if (oldRoles.Count > 0)
db.RoomRoles.RemoveRange(oldRoles);
var existing = await db.RoomRoles.FirstOrDefaultAsync(r =>
r.RoomId == id && r.PlayerId == body.PlayerId && r.Role == body.Role);
if (existing is null)
@@ -1119,7 +861,6 @@ public class AdminController(
}
await LogAsync("room_role_add", "room", id, $"player={body.PlayerId} role={body.Role}");
await db.SaveChangesAsync();
await NotifyRoomRolesChangedAsync(id, body.PlayerId, "AdminRoleGrant");
return Ok(new { ok = true });
}
@@ -1134,7 +875,6 @@ public class AdminController(
db.RoomRoles.Remove(row);
await LogAsync("room_role_remove", "room", id, $"player={playerId} role={role}");
await db.SaveChangesAsync();
await NotifyRoomRolesChangedAsync(id, playerId, "AdminRoleRevoke");
return Ok(new { ok = true, deleted = 1 });
}
@@ -1174,44 +914,9 @@ public class AdminController(
await LogAsync("room_transfer_owner", "room", id, $"from={previousOwner} to={body.NewCreatorPlayerId}");
await db.SaveChangesAsync();
await NotifyRoomRolesChangedAsync(id, previousOwner, "AdminOwnerTransfer");
await NotifyRoomRolesChangedAsync(id, body.NewCreatorPlayerId, "AdminOwnerTransfer");
return Ok(new { ok = true });
}
/// <summary>Push fresh RoomDetails after role/owner mutations. The
/// 2020 watch caches RoomDetails, including CoOwners/Moderators/Hosts,
/// and role-gated UI reads that cache until Rooms.OnSubscriptionUpdateRoom
/// stores a replacement.</summary>
private async Task NotifyRoomRolesChangedAsync(long roomId, long changedPlayerId, string reason)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return;
var scenes = await db.RoomScenes
.Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var roles = await db.RoomRoles
.Where(r => r.RoomId == roomId)
.ToListAsync();
var payload = DorkNet.Server.Controllers.API.Rooms.V2.RoomsController
.BuildRoomDetails(room, scenes, roles: roles);
var recipients = onlinePresence.OnlinePlayerIds()
.Where(pid => playerPresence.GetRoom(pid)?.RoomId == roomId)
.Append(changedPlayerId)
.Distinct()
.ToList();
adminLogger.LogInformation(
"[admin-room-role] pushing room details room={RoomId} reason={Reason} changedPlayer={PlayerId} recipients={RecipientCount}",
roomId, reason, changedPlayerId, recipients.Count);
foreach (var pid in recipients)
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, payload);
}
/// <summary>GET <c>api/admin/v1/rooms/{id}/instances</c> — active
/// Photon instances for one specific room. Subset of the global
/// <c>/instances</c> endpoint pre-filtered to one room; the admin
@@ -1236,7 +941,7 @@ public class AdminController(
.Select(r => new { r.Id, r.CreatorPlayerId, r.IsDormRoom })
.FirstOrDefaultAsync();
var onlinePids = playerPresence.RecentlyActivePlayerIds().ToList();
var onlinePids = onlinePresence.OnlinePlayerIds().ToList();
var byInstance = new Dictionary<long, (long roomInstanceId, long roomId, long subRoomId, string roomName, string photonRoomId, string photonRegionId, string location, int maxCapacity, bool isPrivate, List<long> pids)>();
foreach (var pid in onlinePids)
{
@@ -1302,7 +1007,7 @@ public class AdminController(
// route the pulled player into. If nobody's online in the
// instance anymore the instance has effectively died; reject.
DorkNet.Server.Controllers.Match.RoomInstanceDto? sample = null;
foreach (var pid in playerPresence.RecentlyActivePlayerIds())
foreach (var pid in onlinePresence.OnlinePlayerIds())
{
var r = playerPresence.GetRoom(pid);
if (r is not null && r.RoomId == id && r.RoomInstanceId == instanceId)
@@ -1338,11 +1043,11 @@ public class AdminController(
{
var reason = body?.Reason ?? "Instance closed by admin";
var kicked = new List<long>();
foreach (var pid in playerPresence.RecentlyActivePlayerIds().ToList())
foreach (var pid in onlinePresence.OnlinePlayerIds().ToList())
{
var r = playerPresence.GetRoom(pid);
if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue;
await notifications.KickPlayerAsync(pid, reason);
await notifications.NotifyAsync(pid, PushNotificationId.ModerationKick, new { Reason = reason });
playerPresence.Clear(pid);
kicked.Add(pid);
}
@@ -1371,7 +1076,7 @@ public class AdminController(
var channels = metas.Select(m => m.Channel).ToList();
var counts = await db.LeaderboardStats
.Where(s => s.RoomId == id && channels.Contains(s.StatChannel))
.Where(s => channels.Contains(s.StatChannel))
.GroupBy(s => s.StatChannel)
.Select(g => new { Channel = g.Key, Count = g.Count(), Best = g.Max(x => x.Value), Worst = g.Min(x => x.Value) })
.ToListAsync();
@@ -1402,11 +1107,10 @@ public class AdminController(
public async Task<ActionResult> UpsertChannelMeta([FromBody] UpsertChannelMetaRequest body)
{
if (body is null) return BadRequest("missing body");
var row = await db.LeaderboardChannelMeta
.FirstOrDefaultAsync(c => c.RoomId == body.RoomId && c.Channel == body.Channel);
var row = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == body.Channel);
if (row is null)
{
row = new LeaderboardChannelMetaEntity { RoomId = body.RoomId, Channel = body.Channel };
row = new LeaderboardChannelMetaEntity { Channel = body.Channel };
db.LeaderboardChannelMeta.Add(row);
}
row.RoomId = body.RoomId;
@@ -1423,13 +1127,12 @@ public class AdminController(
/// remove the metadata row. The channel goes back to showing as
/// "Channel N" / unscoped. Existing score rows are kept.</summary>
[HttpDelete("leaderboards/meta/{channel:int}")]
public async Task<ActionResult> DeleteChannelMeta(int channel, [FromQuery] long roomId = 0)
public async Task<ActionResult> DeleteChannelMeta(int channel)
{
var row = await db.LeaderboardChannelMeta
.FirstOrDefaultAsync(c => c.RoomId == roomId && c.Channel == channel);
var row = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == channel);
if (row is null) return Ok(new { ok = true, deleted = 0 });
db.LeaderboardChannelMeta.Remove(row);
await LogAsync("leaderboard_meta_remove", "channel", channel, $"room={roomId}");
await LogAsync("leaderboard_meta_remove", "channel", channel, "");
await db.SaveChangesAsync();
return Ok(new { ok = true, deleted = 1 });
}
@@ -1442,72 +1145,20 @@ public class AdminController(
/// actually use: load a course, drive it, then orphans surfaces
/// the channel id the game just reported.</summary>
[HttpGet("leaderboards/orphans")]
public async Task<ActionResult> LeaderboardOrphans([FromQuery] long? roomId = null)
public async Task<ActionResult> LeaderboardOrphans()
{
var mapped = await db.LeaderboardChannelMeta
.Select(c => new { c.RoomId, c.Channel })
.ToListAsync();
var query = db.LeaderboardStats.AsQueryable();
if (roomId is long rid) query = query.Where(s => s.RoomId == rid);
var rows = await query
.GroupBy(s => new { s.RoomId, s.StatChannel })
.Select(g => new { g.Key.RoomId, Channel = g.Key.StatChannel, EntryCount = g.Count(), LastSeen = g.Max(s => s.UpdatedAt) })
var mapped = await db.LeaderboardChannelMeta.Select(c => c.Channel).ToListAsync();
var rows = await db.LeaderboardStats
.Where(s => !mapped.Contains(s.StatChannel))
.GroupBy(s => s.StatChannel)
.Select(g => new { Channel = g.Key, EntryCount = g.Count(), LastSeen = g.Max(s => s.UpdatedAt) })
.OrderByDescending(r => r.LastSeen)
.ToListAsync();
rows = rows
.Where(r => !mapped.Any(m => m.RoomId == r.RoomId && m.Channel == r.Channel))
.ToList();
return Ok(rows);
}
// ── 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")]
@@ -1574,6 +1225,11 @@ public class AdminController(
});
}
// ── Weekly challenges ────────────────────────────────────────────────
/// <summary>GET <c>api/admin/v1/settings/weekly-challenges</c> — the
/// current weekly slate + reward the watch's challenge map is built
/// from (<see cref="ProgressionApi.ProgressionController"/>).</summary>
[HttpGet("settings/weekly-challenges")]
public async Task<ActionResult> GetWeeklyChallenges()
{
@@ -1581,6 +1237,16 @@ public class AdminController(
return Ok(weekly);
}
/// <summary>GET
/// <c>api/admin/v1/settings/weekly-challenges/reward-options</c> —
/// the store items that can be assigned as the weekly gift. Only
/// avatar outfits and consumables are offered: the 2020.03
/// <see cref="StoreService"/> exposes
/// <c>TryGetAvatarItemPayload</c> / <c>TryGetConsumableItemDesc</c>
/// but has no equipment payload helper, so equipment gifts aren't
/// assignable on this build. Each option carries the descriptors the
/// watch's <c>ChallengeGift</c> render needs plus the
/// <c>Slug</c> the server grants on completion.</summary>
[HttpGet("settings/weekly-challenges/reward-options")]
public async Task<ActionResult> GetWeeklyChallengeRewardOptions()
{
@@ -1593,17 +1259,14 @@ public class AdminController(
string kind,
StoreItemEntity item,
string avatarItemDesc = "",
string consumableItemDesc = "",
string equipmentPrefabName = "",
string equipmentModificationGuid = "") => new
string consumableItemDesc = "") => new
{
Kind = kind,
Label = $"{item.DisplayName} ({item.Slug})",
Slug = item.Slug,
GiftDropId = (int)(item.Id & 0x7fffffff),
AvatarItemDesc = avatarItemDesc,
ConsumableItemDesc = consumableItemDesc,
EquipmentPrefabName = equipmentPrefabName,
EquipmentModificationGuid = equipmentModificationGuid,
};
var avatarItems = rows
@@ -1620,18 +1283,10 @@ public class AdminController(
.Where(o => o is not null)
.ToArray();
var equipment = rows
.Select(i => StoreService.TryGetEquipmentPayload(i.Slug, out var prefab, out var mod)
? Option("equipment", i, equipmentPrefabName: prefab, equipmentModificationGuid: mod)
: null)
.Where(o => o is not null)
.ToArray();
return Ok(new
{
AvatarItems = avatarItems,
Consumables = consumables,
Equipment = equipment,
});
}
@@ -1640,6 +1295,10 @@ public class AdminController(
List<WeeklyChallengeTemplate>? Challenges,
WeeklyChallengeReward? Reward);
/// <summary>POST <c>api/admin/v1/settings/weekly-challenges</c> —
/// replace the weekly slate + reward. Normalisation (indexing,
/// trimming, the Take(10) cap) happens in
/// <see cref="ServerSettingsService.SetWeeklyChallengesAsync"/>.</summary>
[HttpPost("settings/weekly-challenges")]
public async Task<ActionResult> SetWeeklyChallenges([FromBody] WeeklyChallengeSettingsRequest body)
{
@@ -1651,56 +1310,6 @@ public class AdminController(
return Ok(weekly);
}
[HttpGet("settings/play-menu-tags")]
public async Task<ActionResult> GetPlayMenuTags()
=> Ok(await serverSettings.GetPlayMenuTagsAsync());
public sealed record PlayMenuTagsRequest(
List<string>? PinnedTags,
List<string>? PopularTags);
[HttpPost("settings/play-menu-tags")]
public async Task<ActionResult> SetPlayMenuTags([FromBody] PlayMenuTagsRequest body)
{
var tags = await serverSettings.SetPlayMenuTagsAsync(
body.PinnedTags ?? [],
body.PopularTags ?? []);
await LogAsync("play_menu_tags_updated", "system", 0,
$"pinned={string.Join(",", tags.PinnedTags)} popular={string.Join(",", tags.PopularTags)}");
await db.SaveChangesAsync();
return Ok(tags);
}
[HttpGet("settings/rec-center-doors")]
public async Task<ActionResult> GetRecCenterDoors()
=> Ok(await serverSettings.GetRecCenterDoorsAsync());
public sealed record RecCenterDoorsRequest(
List<RecCenterDoorConfig>? Doors);
[HttpPost("settings/rec-center-doors")]
public async Task<ActionResult> SetRecCenterDoors([FromBody] RecCenterDoorsRequest body)
{
var doors = await serverSettings.SetRecCenterDoorsAsync(body.Doors ?? []);
await LogAsync("rec_center_doors_updated", "system", 0,
string.Join(";", doors.Doors.Select(d => $"{d.Key}:{d.Query}")));
await db.SaveChangesAsync();
return Ok(doors);
}
[HttpGet("settings/gameconfigs")]
public async Task<ActionResult> GetDiscoveredGameConfigs()
=> Ok(await serverSettings.GetDiscoveredGameConfigsAsync());
[HttpPost("settings/gameconfigs")]
public async Task<ActionResult> SetDiscoveredGameConfigs([FromBody] DiscoveredGameConfigSettings body)
{
var settings = await serverSettings.SetDiscoveredGameConfigsAsync(body);
await LogAsync("gameconfigs_updated", "system", 0, "discovered");
await db.SaveChangesAsync();
return Ok(settings);
}
// ── Signup codes ─────────────────────────────────────────────────────
/// <summary>GET <c>api/admin/v1/signup-codes</c> — every issued code
@@ -1823,11 +1432,8 @@ public class AdminController(
[HttpPost("players/{id:long}/kick")]
public async Task<ActionResult> KickPlayer(long id, [FromBody] KickRequest? body)
{
var reason = string.IsNullOrWhiteSpace(body?.Reason)
? "Kicked by an admin."
: body!.Reason!.Trim();
await notifications.KickPlayerAsync(id, reason);
playerPresence.Clear(id);
await notifications.NotifyAsync(id, PushNotificationId.ModerationKick,
new { Reason = body?.Reason ?? "" });
await LogAsync("kick_player", "player", id, body?.Reason ?? "");
await db.SaveChangesAsync();
return Ok(new { kicked = id });
@@ -1890,83 +1496,16 @@ public class AdminController(
[HttpGet("stats")]
public async Task<ActionResult> Stats()
{
var now = DateTime.UtcNow;
var totalPlayers = await db.Players.CountAsync();
var totalRooms = await db.Rooms.CountAsync();
var totalInventions = await db.Inventions.CountAsync();
var openReports = await db.Reports.CountAsync(r => r.ResolvedAt == null);
var bannedNow = await db.Players.CountAsync(p =>
p.BannedUntil != null && p.BannedUntil > now);
p.BannedUntil != null && p.BannedUntil > DateTime.UtcNow);
var activeIpBans = await db.IpBans.CountAsync(b =>
b.Until == null || b.Until > now);
b.Until == null || b.Until > DateTime.UtcNow);
var online = playerPresence.RecentlyActivePlayerIds().ToArray();
var onlineSet = online.ToHashSet();
var currentRoomsByPlayer = online
.Select(id => new { PlayerId = id, Room = playerPresence.GetRoom(id) })
.Where(x => x.Room is not null)
.ToDictionary(x => x.PlayerId, x => x.Room!);
var roomIds = currentRoomsByPlayer.Values.Select(r => r.RoomId).Distinct().ToList();
var roomNames = roomIds.Count == 0
? new Dictionary<long, string>()
: await db.Rooms
.Where(r => roomIds.Contains(r.Id))
.Select(r => new { r.Id, r.Name })
.ToDictionaryAsync(r => r.Id, r => r.Name);
var onlinePlayers = await db.Players
.Where(p => onlineSet.Contains(p.Id))
.OrderBy(p => p.DisplayName)
.ThenBy(p => p.Username)
.Select(p => new
{
p.Id,
p.Username,
p.DisplayName,
p.Level,
p.ProfileImageName,
p.IsAdmin,
p.IsDeveloper,
p.IsCommunityTeam,
p.IsJunior,
p.IsVerified,
p.BannedUntil,
p.LastSeenAt,
})
.ToListAsync();
var onlinePlayerCards = onlinePlayers.Select(p =>
{
currentRoomsByPlayer.TryGetValue(p.Id, out var room);
roomNames.TryGetValue(room?.RoomId ?? 0, out var roomName);
return new
{
p.Id,
p.Username,
p.DisplayName,
p.Level,
p.ProfileImageName,
p.IsAdmin,
p.IsDeveloper,
p.IsCommunityTeam,
p.IsJunior,
p.IsVerified,
p.BannedUntil,
p.LastSeenAt,
CurrentRoom = room is null ? null : new
{
room.RoomId,
Name = roomName ?? room.Name,
room.SubRoomId,
room.RoomInstanceId,
room.PhotonRoomId,
room.PhotonRegionId,
room.IsPrivate,
room.MaxCapacity,
},
};
}).ToList();
var online = onlinePresence.OnlinePlayerIds().ToArray();
var topRooms = await db.Rooms
.OrderByDescending(r => r.VisitCount)
@@ -1974,31 +1513,6 @@ public class AdminController(
.Select(r => new { r.Id, r.Name, r.VisitCount, r.VisitorCount, r.CheerCount })
.ToListAsync();
var activeSessions = await db.GameSessions
.Where(s => s.PlayerCount > 0)
.OrderByDescending(s => s.PlayerCount)
.ThenByDescending(s => s.CreatedAt)
.Take(8)
.Select(s => new
{
s.Id,
s.RoomId,
s.ActivityLevelId,
s.Region,
s.PhotonRoomName,
s.PlayerCount,
s.MaxCapacity,
s.CreatedAt,
})
.ToListAsync();
var inGamePlayerCount = await db.GameSessions.SumAsync(s => (int?)s.PlayerCount) ?? 0;
var activeSessionCount = await db.GameSessions.CountAsync(s => s.PlayerCount > 0);
var photosToday = await db.Photos.CountAsync(p =>
p.CreatedAt >= now.AddDays(-1) && p.DeletedAt == null);
var newPlayersToday = await db.Players.CountAsync(p => p.CreatedAt >= now.AddDays(-1));
var totalVisitCount = await db.Rooms.SumAsync(r => (long)r.VisitCount);
var totalCheerCount = await db.Rooms.SumAsync(r => (long)r.CheerCount);
var recentJoins = await db.Players
.OrderByDescending(p => p.CreatedAt)
.Take(10)
@@ -2012,28 +1526,16 @@ public class AdminController(
Total = totalPlayers,
OnlineNow = online.Length,
BannedNow = bannedNow,
NewToday = newPlayersToday,
Online = onlinePlayerCards,
},
Rooms = new
{
Total = totalRooms,
TotalVisits = totalVisitCount,
TotalCheers = totalCheerCount,
TopByVisits = topRooms,
ActiveSessions = activeSessions,
ActiveSessionCount = activeSessionCount,
InGamePlayerCount = inGamePlayerCount,
},
Rooms = new { Total = totalRooms, TopByVisits = topRooms },
Inventions = totalInventions,
Photos = new { Today = photosToday },
Moderation = new
{
OpenReports = openReports,
ActiveIpBans = activeIpBans,
},
RecentJoins = recentJoins,
ServerTime = now,
ServerTime = DateTime.UtcNow,
});
}
@@ -2174,106 +1676,6 @@ public class AdminController(
return Ok(new { granted = body.ItemId, quantity = qty });
}
/// <summary>POST <c>api/admin/v1/players/{id}/inventory/unlock-all</c>
/// — grant the player the ENTIRE catalog: every avatar cosmetic GUID
/// (merged into <see cref="AvatarEntity.InventoryJson"/>), every
/// permanent hair dye, and every store equipment/consumable slug (as
/// <see cref="PlayerInventoryEntity"/> rows). Idempotent — only adds
/// what's missing. Pushes SubscriptionUpdateProfile so the watch
/// re-fetches without a relog.
///
/// Cosmetics use the canonical InventoryJson shape — a plain JSON array
/// of GUID strings (what the buy/gift flow writes and
/// AvatarItemsController.ParseInventoryEntries reads). Only BARE GUIDs
/// from the authoritative safe-list catalog are added, so every
/// unlocked item is one this build actually ships (no missing-bundle
/// crashes). NOTE: the 2020 watch's wardrobe only renders up to
/// Avatar:MaxUnlockedItems (hard cap 120) owned cosmetics at once, so
/// the player won't see thousands of tiles even though all are granted.</summary>
[HttpPost("players/{id:long}/inventory/unlock-all")]
public async Task<ActionResult> UnlockAllItems(long id)
{
var player = await db.Players.FirstOrDefaultAsync(x => x.Id == id);
if (player is null) return NotFound();
// 1) Avatar cosmetics → InventoryJson (JSON array of GUID strings).
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id);
if (avatar is null) { avatar = new AvatarEntity { PlayerId = id }; db.Avatars.Add(avatar); }
var owned = ParseOwnedDescs(avatar.InventoryJson);
var ownedSet = new HashSet<string>(owned, StringComparer.OrdinalIgnoreCase);
var catalog = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.Catalog;
var addedCosmetics = 0;
foreach (var guid in catalog.Keys)
if (ownedSet.Add(guid)) { owned.Add(guid); addedCosmetics++; }
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(owned);
avatar.UpdatedAt = DateTime.UtcNow;
// 2) Hair dyes + store equipment/consumables → PlayerInventory (by slug).
var existingSlugs = await db.PlayerInventory
.Where(pi => pi.PlayerId == id)
.Select(pi => pi.ItemSlug)
.ToListAsync();
var slugSet = new HashSet<string>(existingSlugs, StringComparer.OrdinalIgnoreCase);
var allSlugs = StoreService.AllHairDyeSlugs
.Concat(await db.StoreItems.Select(s => s.Slug).ToListAsync());
var addedSlugs = 0;
foreach (var slug in allSlugs)
{
if (string.IsNullOrWhiteSpace(slug) || !slugSet.Add(slug)) continue;
db.PlayerInventory.Add(new PlayerInventoryEntity { PlayerId = id, ItemSlug = slug, Quantity = 1 });
addedSlugs++;
}
await LogAsync("unlock_all_items", "player", id,
$"cosmetics+{addedCosmetics} (catalog={catalog.Count}) slugs+{addedSlugs}");
await db.SaveChangesAsync();
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "AdminUnlockAll" });
return Ok(new
{
id,
addedCosmetics,
totalCosmetics = catalog.Count,
addedSlugs,
ownedCosmetics = owned.Count,
});
}
// Tolerant parse of InventoryJson into a flat list of owned item descs.
// Canonical shape is a JSON string array; also accepts the legacy
// [{itemId,...}] object shape so existing data is never clobbered.
private static List<string> ParseOwnedDescs(string? json)
{
if (string.IsNullOrWhiteSpace(json) || json == "[]") return new();
try
{
var strings = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
if (strings is not null && strings.Count > 0) return strings;
}
catch { }
var result = new List<string>();
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array)
foreach (var el in doc.RootElement.EnumerateArray())
{
if (el.ValueKind == System.Text.Json.JsonValueKind.String)
result.Add(el.GetString() ?? string.Empty);
else if (el.ValueKind == System.Text.Json.JsonValueKind.Object &&
el.TryGetProperty("itemId", out var iid) &&
iid.ValueKind == System.Text.Json.JsonValueKind.String)
result.Add(iid.GetString() ?? string.Empty);
}
}
catch { }
return result;
}
public sealed record CurrencyRequest(int CurrencyType, long Amount, string? Reason);
/// <summary>Adjust a player's currency wallet. Positive amounts
@@ -2305,83 +1707,6 @@ public class AdminController(
return Ok(new { id, level = lvl, xp });
}
public sealed record SetLevelRequest(int Level);
/// <summary>Set a player's level directly from the admin console.
/// XP is moved to the minimum value for that level so the next normal
/// XP grant does not snap the row back to the curve-derived level.</summary>
[HttpPost("players/{id:long}/level")]
public async Task<ActionResult> SetLevel(long id, [FromBody] SetLevelRequest body)
{
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id);
if (p is null) return NotFound();
var newLevel = Math.Clamp(body.Level, 1, 100);
var previous = p.Level;
p.Level = newLevel;
p.XP = MinimumXpForLevel(newLevel);
await LogAsync("set_level", "player", id, $"{previous} -> {newLevel}");
await db.SaveChangesAsync();
await notifications.AccountChanged(p);
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "AdminLevelSet", Level = p.Level, XP = p.XP, PreviousLevel = previous });
return Ok(new { p.Id, p.Level, p.XP });
}
/// <summary>Insert one <see cref="CheerEntity"/> row per cheer Type
/// (0=General, 1=Helpful, 2=GreatHost, 3=Sportsman, 4=Creative,
/// 5=Credit) so the player has at least one received cheer in every
/// category. The watch's pinned-cheer picker (see
/// <see cref="DorkNet.Server.Controllers.API.PlayerCheer.PlayerCheerController.SetSelectedCheer"/>)
/// only lets a player pin a category they've actually received — this
/// unlocks every badge for them without having to wait on real
/// peer cheers.
///
/// Inserts come from system account id=1 (the seed admin). Idempotent
/// on (FromPlayerId, TargetPlayerId, Type) — re-running just fills in
/// whichever categories aren't there yet. Does NOT award XP (these
/// are admin-injected, not real peer interactions), so the player's
/// level isn't inflated.</summary>
[HttpPost("players/{id:long}/cheers/unlock")]
public async Task<ActionResult> UnlockCheers(long id)
{
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id);
if (p is null) return NotFound();
// RoomieCheerType enum mirror — see CheerEntity.Type docblock.
var allTypes = new[] { 0, 1, 2, 3, 4, 5 };
var existingTypes = await db.Cheers
.Where(c => c.TargetPlayerId == id && c.TargetRoomId == 0
&& c.TargetPhotoId == 0 && c.TargetInventionId == 0)
.Select(c => c.Type)
.Distinct()
.ToListAsync();
var missing = allTypes.Except(existingTypes).ToList();
foreach (var t in missing)
{
db.Cheers.Add(new CheerEntity
{
FromPlayerId = 1, // system / seed-admin account
TargetPlayerId = id,
Type = t,
});
}
await LogAsync("unlock_cheers", "player", id,
$"inserted={missing.Count} types=[{string.Join(",", missing)}]");
await db.SaveChangesAsync();
// Nudge the watch to re-fetch its cheer list so the pinned-cheer
// picker shows the newly-unlocked badges without a relog.
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "AdminCheerUnlock", Inserted = missing.Count });
return Ok(new { id, inserted = missing.Count, types = missing });
}
public sealed record ProfileFlagsRequest(
bool? IsVerified,
bool? IsDeveloper,
@@ -2427,7 +1752,6 @@ public class AdminController(
p.DisplayName = body.DisplayName.Trim();
await LogAsync("set_display_name", "player", id, p.DisplayName);
await db.SaveChangesAsync();
await notifications.AccountChanged(p);
return Ok(new { p.Id, p.DisplayName });
}
@@ -2457,16 +1781,9 @@ public class AdminController(
p.Username = name;
await LogAsync("set_username", "player", id, $"{previous} -> {name}");
await db.SaveChangesAsync();
await notifications.AccountChanged(p);
return Ok(new { p.Id, p.Username });
}
private static int MinimumXpForLevel(int level)
{
var normalized = Math.Max(1, level);
return 100 * (normalized - 1) * (normalized - 1);
}
private sealed class InventoryEntry
{
[System.Text.Json.Serialization.JsonPropertyName("itemId")]
@@ -2655,7 +1972,31 @@ public class AdminController(
// one that ends up invoking the GiftManager popup path.
await notifications.NotifyAsync(id,
PushNotificationId.GiftPackageReceivedImmediate,
DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(gift));
new
{
gift.Id,
gift.FromPlayerId,
gift.AvatarItemType,
AvatarItemDesc = gift.AvatarItemDescOrHairDyeDesc,
gift.AvatarItemDescOrHairDyeDesc,
gift.EquipmentPrefabName,
gift.EquipmentModificationGuid,
gift.CurrencyType,
gift.Currency,
gift.Xp,
gift.Level,
gift.GiftContext,
gift.GiftRarity,
gift.Message,
gift.Platform,
gift.PackageMaterial,
gift.PackageVariant,
gift.Consumed,
gift.IsValid,
gift.ErrorMessage,
gift.SupportsCurrentPlatform,
gift.IsGifted,
});
return Ok(new { gift.Id, gift.RecipientPlayerId });
}
@@ -2782,7 +2123,7 @@ public class AdminController(
[HttpGet("instances")]
public async Task<ActionResult> ListInstances()
{
var onlineIds = playerPresence.RecentlyActivePlayerIds().ToHashSet();
var onlineIds = onlinePresence.OnlinePlayerIds().ToHashSet();
if (onlineIds.Count == 0) return Ok(Array.Empty<object>());
// Bulk-pull names so we can render usernames per participant.
@@ -2999,13 +2340,7 @@ public class AdminController(
[FromQuery] string? storefront, [FromQuery] string? category,
[FromQuery] int take = 200, [FromQuery] int skip = 0)
{
// Ceiling raised from 500: the wardrobe + color-variant seed pushes
// the live catalog past 800 rows, all under storefront "main". A
// 500-row window (ordered Storefront→Category→Price) silently hid
// every item that sorted past it — e.g. torso SKUs like the Sea
// Captain Contest shirt — so the admin UI couldn't find them even
// by search (it filters the fetched page client-side).
take = Math.Clamp(take, 1, 5000);
take = Math.Clamp(take, 1, 500);
skip = Math.Max(0, skip);
IQueryable<StoreItemEntity> q = db.StoreItems;
if (!string.IsNullOrWhiteSpace(storefront)) q = q.Where(i => i.Storefront == storefront);
@@ -3021,59 +2356,6 @@ public class AdminController(
long? Price, bool? IsActive, bool? IsLimitedTime,
DateTime? AvailableUntil, string? Storefront);
/// <summary>POST <c>api/admin/v1/storeitems/image/upload</c> —
/// attachable catalog artwork. Stored as a global image blob so the
/// admin UI and the watch can resolve the same ImageName through
/// img.* / cdn.* without admins hand-copying filenames.</summary>
[HttpPost("storeitems/image/upload")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 25_000_000)]
public async Task<ActionResult> UploadStoreItemImage([FromForm(Name = "file")] IFormFile? file)
{
if (file is null || file.Length == 0)
return BadRequest(new { error = "missing_file" });
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (ext is not (".jpg" or ".jpeg" or ".png" or ".webp"))
return BadRequest(new { error = "unsupported_extension", allowed = new[] { ".jpg", ".jpeg", ".png", ".webp" } });
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var bytes = ms.ToArray();
using var sha = System.Security.Cryptography.SHA1.Create();
var hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
var imageName = $"store_{hash[..24]}{ext}";
var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == imageName);
if (existing is null)
{
var (bucket, key) = BlobRouter.Route(imageName);
var contentType = ext switch
{
".png" => "image/png",
".webp" => "image/webp",
_ => "image/jpeg",
};
await storage.PutAsync(bucket, key, bytes, contentType);
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = imageName,
UploadedByPlayerId = CurrentAdminId,
UploadedAt = DateTime.UtcNow,
});
await LogAsync("store_item_image_upload", "storeitem", 0, $"image={imageName} bytes={bytes.Length}");
await db.SaveChangesAsync();
}
return Ok(new
{
imageName,
imageUrl = $"https://{domain.Sub("img")}/{imageName}",
bytes = bytes.Length,
reused = existing is not null,
});
}
/// <summary>Create a new catalog item.</summary>
[HttpPost("storeitems")]
public async Task<ActionResult> CreateStoreItem([FromBody] StoreItemUpsertRequest body)
@@ -3142,133 +2424,6 @@ public class AdminController(
// ── Helpers ──────────────────────────────────────────────────────────
private async Task<string> DeletePlayerDataAsync(long playerId, long systemPlayerId)
{
var now = DateTime.UtcNow;
var deleted = 0;
var reassigned = 0;
deleted += await db.Avatars.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerSettings.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Relationships.Where(x => x.RequesterId == playerId || x.TargetId == playerId).ExecuteDeleteAsync();
deleted += await db.CurrencyBalances.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerInventory.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerDevices.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.NotificationPrefs.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.CohortAssignments.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PushTokens.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlatformIgnores.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Cards.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ObjectiveProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.GameRewardSelections.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.CouponRedemptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.LeaderboardStats.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerElo.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoyalePlayerProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoyaleMatchPlayers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Messages.Where(x => x.SenderPlayerId == playerId || x.RecipientPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ChatMessages.Where(x => x.SenderPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ChatThreadMembers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.ChatThreads
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
deleted += await db.Cheers.Where(x => x.FromPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Reports.Where(x => x.ReporterPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.BugReports.Where(x => x.ReporterPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Subscriptions.Where(x => x.SubscriberPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ClubMemberships.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ClubSubscriptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ClubAnnouncementReads.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.Clubs
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
.SetProperty(x => x.UpdatedAt, now));
reassigned += await db.ClubAnnouncements
.Where(x => x.AuthorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.AuthorPlayerId, systemPlayerId));
deleted += await db.RoomBookmarks.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoomVisits.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoomRoles.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoomBans.Where(x => x.BannedPlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.RoomBans
.Where(x => x.BannedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.BannedByPlayerId, systemPlayerId));
reassigned += await db.RoomRoles
.Where(x => x.GrantedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.GrantedByPlayerId, systemPlayerId));
reassigned += await db.Rooms
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
.SetProperty(x => x.UpdatedAt, now));
reassigned += await db.RoomDataBlobs
.Where(x => x.UploadedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.UploadedByPlayerId, systemPlayerId));
reassigned += await db.RoomKeys
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
deleted += await db.RoomKeyPurchases.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlaylistInteractions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.Playlists
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
deleted += await db.PlayerEventResponses
.Where(x => x.PlayerId == playerId
|| db.PlayerEvents.Where(e => e.CreatorPlayerId == playerId).Select(e => e.Id).Contains(x.EventId))
.ExecuteDeleteAsync();
deleted += await db.PlayerEvents.Where(x => x.CreatorPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PrivateInstanceInvitees.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
var privateInstanceIds = await db.PrivateInstances
.Where(x => x.OwnerPlayerId == playerId)
.Select(x => x.Id)
.ToListAsync();
if (privateInstanceIds.Count > 0)
{
deleted += await db.PrivateInstanceInvitees
.Where(x => privateInstanceIds.Contains(x.PrivateInstanceId))
.ExecuteDeleteAsync();
deleted += await db.PrivateInstances
.Where(x => privateInstanceIds.Contains(x.Id))
.ExecuteDeleteAsync();
}
deleted += await db.GiftPackages
.Where(x => x.RecipientPlayerId == playerId)
.ExecuteDeleteAsync();
if (playerId <= int.MaxValue)
{
reassigned += await db.GiftPackages
.Where(x => x.FromPlayerId == (int)playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.FromPlayerId, (int?)null));
}
reassigned += await db.Inventions
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
.SetProperty(x => x.UpdatedAt, now));
reassigned += await db.Photos
.Where(x => x.UploaderPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.UploaderPlayerId, systemPlayerId)
.SetProperty(x => x.DeletedAt, now)
.SetProperty(x => x.IsPublic, false));
reassigned += await db.SignupCodes
.Where(x => x.RedeemedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.RedeemedByPlayerId, (long?)null));
return $"deleted_rows={deleted} reassigned_rows={reassigned}";
}
private Task LogAsync(string action, string targetType, long targetId, string reason)
{
db.AdminActions.Add(new AdminActionEntity
@@ -55,28 +55,23 @@ public class RoomZipImportController(
DorkNetDbContext db,
RoomBlobNormalizerService roomBlobNormalizer,
IObjectStorage storage,
IServiceScopeFactory scopeFactory,
ILogger<RoomZipImportController> logger) : ControllerBase
{
private long CurrentAdminId => this.RequireCurrentPlayerId();
// ── Room-blob normaliser toggle ─────────────────────────────────────
// The normaliser does two things now (see RoomBlobNormalizerService):
// 1. Project modern Rec Room's TransformData.quaternion_rotation
// (field 6) onto the legacy Euler rotation Vector3 (field 2)
// the 2020.12 watch reads. Without this, every shape in a
// modern-authored room renders at rotation=(0,0,0) — the
// "Studio-imported shapes are misplaced" bug.
// 2. Re-encode through the 2020 PersistedRoomData schema to drop
// any non-canonical wire encodings the watch's stricter parser
// doesn't accept.
// The normaliser re-encodes .room bytes through the 2020 schema to
// strip non-canonical encodings. It's been tripping the watch into
// "Error attempting to parse room data stream" — the generated 2020
// protobuf bindings appear to be missing fields or to emit them in
// a wire-order the watch's parser disagrees with.
//
// Per-import flag (`normalizeBlobs` in the form / finalize body)
// defaults ON now that the projection pass exists. The SPA still
// surfaces it as a checkbox so an admin can opt out for an
// already-Euler-encoded blob if a particular import misbehaves.
// The diagnostic parse always runs (its parse-OK / parse-FAIL log
// line is useful telemetry).
// defaults OFF. The SPA's importer surfaces it as a checkbox so an
// admin can opt back in once we're confident a particular zip's
// contents survive the re-encode. The diagnostic parse still runs
// either way (its parse-OK / parse-FAIL log line is useful), but
// when the flag is false the original bytes are what reach S3.
/// <summary>Write a blob to S3 at its canonical (bucket, key) AND
/// record the metadata row in <see cref="RoomDataBlobEntity"/>.
@@ -90,8 +85,7 @@ public class RoomZipImportController(
/// S3 the same way.</summary>
private async Task<RoomDataBlobEntity> WriteBlobAsync(
long roomId, string blobName, byte[] bytes, long uploaderPlayerId,
string? referencesCsv = null, string? contentTypeOverride = null,
DateTime? uploadedAt = null, long? subRoomId = null)
string? referencesCsv = null, string? contentTypeOverride = null)
{
var (bucket, key) = BlobRouter.Route(blobName);
var contentType = contentTypeOverride ?? MimeFromName(blobName);
@@ -111,9 +105,8 @@ public class RoomZipImportController(
RoomId = roomId,
BlobName = blobName,
UploadedByPlayerId = uploaderPlayerId,
UploadedAt = uploadedAt ?? DateTime.UtcNow,
UploadedAt = DateTime.UtcNow,
ReferencedFilenamesCsv = referencesCsv ?? string.Empty,
SubRoomId = subRoomId,
};
db.RoomDataBlobs.Add(entity);
return entity;
@@ -206,26 +199,6 @@ public class RoomZipImportController(
public string? DataBlob { get; set; }
}
/// <summary>Sidecar JSON next to a History/&lt;blob&gt;.room entry. All
/// fields optional — the importer reads them to populate
/// RoomDataBlobEntity.UploadedAt + audit trail when present, and falls
/// back to <see cref="DateTime.UtcNow"/> for the timestamp otherwise.</summary>
public sealed class HistorySidecarDto
{
public long? SubRoomDataSaveId { get; set; }
/// <summary>Parent sub-room id. The Restore UI fetches
/// <c>rooms/{roomId}/subrooms/{subRoomId}/datahistory</c>; without
/// a SubRoomId on each row we'd have to return the whole room's
/// blob mix under every sub-room's history list. Optional: when
/// missing, the importer derives it from the SubRooms/&lt;scene&gt;
/// folder name suffix (<c>SubRoomName__&lt;subRoomId&gt;</c>).</summary>
public long? SubRoomId { get; set; }
public string? DataBlob { get; set; }
public DateTime? CreatedAt { get; set; }
public string? Description { get; set; }
public long? SavedByAccountId { get; set; }
}
// ── Invention DTOs ────────────────────────────────────────────────
public sealed class InventionJsonDto
@@ -268,7 +241,7 @@ public class RoomZipImportController(
[HttpPost("zip-bulk-import")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 5_000_000_000)] // 5 GB
[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)] // 2 GB
public async Task<IActionResult> ZipImport(
[FromForm(Name = "archive")] IFormFile? archive,
[FromForm(Name = "creatorPlayerId")] long? creatorPlayerId,
@@ -280,7 +253,7 @@ public class RoomZipImportController(
await using var stream = archive.OpenReadStream();
return await RunImportAsync(
stream, archive.Length, creatorPlayerId,
normalizeBlobs: normalizeBlobs ?? true);
normalizeBlobs: normalizeBlobs ?? false);
}
// ── Chunked upload (Cloudflare 100MB workaround) ─────────────────
@@ -312,16 +285,13 @@ public class RoomZipImportController(
/// <summary>POST <c>zip-upload-init</c> — allocates a temp file and
/// returns an <c>uploadId</c> the SPA threads through subsequent
/// chunk POSTs. Capped at 5 GB total; opens a sparse temp file so
/// disk usage tracks actual bytes received. The cap is generous
/// because the live RecNet exports of large story-mode rooms
/// (large multi-scene Studio quests) easily
/// run 2-3 GB before any of the per-room recovery zip bundling.</summary>
/// chunk POSTs. Capped at 2 GB total; opens a sparse temp file so
/// disk usage tracks actual bytes received.</summary>
[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 > 2_000_000_000)
return BadRequest(new { error = "invalid_size" });
// Prune stale sessions before allocating a new one. Anything
// older than 2 hours with no activity is abandoned.
@@ -402,143 +372,32 @@ public class RoomZipImportController(
List<string>? SelectedInventionFolders,
bool? NormalizeBlobs);
/// <summary>In-memory background job state for an async import.
/// The HTTP request that kicked the import off only stays alive
/// for milliseconds (it returns 202 immediately); the work runs
/// in <c>Task.Run</c> against a freshly-created DI scope so it
/// outlives the request. The SPA polls
/// <c>GET zip-import-status/{jobId}</c> for progress + result.
/// Jobs auto-expire 1 hour after finish (so the dictionary doesn't
/// grow forever).</summary>
public sealed class ImportJob
{
public Guid Id { get; init; }
public long AdminPlayerId { get; init; }
public DateTime StartedAt { get; init; } = DateTime.UtcNow;
public DateTime? FinishedAt { get; set; }
/// <summary>"running" | "done" | "failed".</summary>
public string State { get; set; } = "running";
/// <summary>Short progress string updated by the import core
/// after each room/invention completes ("room 5/12: …").</summary>
public string? Phase { get; set; }
/// <summary>On success: the same anonymous payload the sync
/// endpoint would have returned. On failure (validation
/// error): the <c>{ error = ..., ... }</c> object the sync
/// endpoint would have wrapped in BadRequest.</summary>
public object? Result { get; set; }
/// <summary>Exception message when <see cref="State"/> is
/// "failed". <see cref="Result"/> stays null in that case.</summary>
public string? Error { get; set; }
}
private static readonly System.Collections.Concurrent.ConcurrentDictionary<Guid, ImportJob> Jobs = new();
/// <summary>POST <c>zip-upload-finalize/{id}</c> — kicks off the
/// import on the assembled temp file in a background task and
/// returns 202 immediately with a <c>jobId</c>. The synchronous
/// path got 524'd by Cloudflare on multi-GB archives (edge
/// disconnects clients at 100s but the server kept processing
/// successfully). Now: the upload session is consumed here, the
/// finalize request returns before any timeout, and the SPA polls
/// the status endpoint for completion.</summary>
/// <summary>POST <c>zip-upload-finalize/{id}</c> — runs the existing
/// zip import on the assembled temp file then deletes it. Same
/// response shape as <c>zip-bulk-import</c>.</summary>
[HttpPost("zip-upload-finalize/{id:guid}")]
public ActionResult ChunkFinalize(Guid id, [FromBody] ChunkFinalizeRequest? body)
public async Task<IActionResult> ChunkFinalize(Guid id, [FromBody] ChunkFinalizeRequest? body)
{
if (!Sessions.TryGetValue(id, out var session)) return NotFound(new { error = "unknown_session" });
var adminId = CurrentAdminId;
if (session.AdminPlayerId != adminId) return Forbid();
if (session.AdminPlayerId != CurrentAdminId) return Forbid();
// Capture before we leave the request context.
var tempPath = session.TempPath;
var totalBytes = session.TotalBytes;
var creator = body?.CreatorPlayerId ?? adminId;
var selectedRoomFolders = body?.SelectedRoomFolders;
var selectedInventionFolders = body?.SelectedInventionFolders;
var normalizeBlobs = body?.NormalizeBlobs ?? true;
var jobId = Guid.NewGuid();
var job = new ImportJob { Id = jobId, AdminPlayerId = adminId };
Jobs[jobId] = job;
Sessions.TryRemove(id, out _);
// Prune jobs older than 1 hour past completion to keep the
// dictionary bounded. Admin only runs the importer a few times
// a day, so this stays tiny.
foreach (var (jid, j) in Jobs)
try
{
if (j.FinishedAt is { } fin && DateTime.UtcNow - fin > TimeSpan.FromHours(1))
Jobs.TryRemove(jid, out _);
await using var stream = System.IO.File.OpenRead(session.TempPath);
return await RunImportAsync(
stream,
session.TotalBytes,
body?.CreatorPlayerId,
body?.SelectedRoomFolders,
body?.SelectedInventionFolders,
normalizeBlobs: body?.NormalizeBlobs ?? false);
}
_ = Task.Run(async () =>
finally
{
using var scope = scopeFactory.CreateScope();
var sp = scope.ServiceProvider;
// Build a fresh background controller instance bound to
// the new scope's services. The original `this` controller
// is tied to the request scope and will be disposed the
// moment we return below — we cannot reference its `db`,
// `storage`, etc. from the bg task.
var bgDb = sp.GetRequiredService<DorkNetDbContext>();
var bgNorm = sp.GetRequiredService<RoomBlobNormalizerService>();
var bgStorage = sp.GetRequiredService<IObjectStorage>();
var bgLogger = sp.GetRequiredService<ILogger<RoomZipImportController>>();
var bgController = new RoomZipImportController(bgDb, bgNorm, bgStorage, scopeFactory, bgLogger);
try
{
await using var stream = System.IO.File.OpenRead(tempPath);
var outcome = await bgController.RunImportCoreAsync(
stream, totalBytes, creator, adminId,
selectedRoomFolders, selectedInventionFolders, normalizeBlobs,
jobPhase: p => job.Phase = p);
job.Result = outcome.Payload;
job.State = outcome.Success ? "done" : "failed";
}
catch (Exception ex)
{
bgLogger.LogError(ex, "[zip-import job {Job}] failed", jobId);
job.Error = ex.Message;
job.State = "failed";
}
finally
{
job.FinishedAt = DateTime.UtcNow;
try { if (System.IO.File.Exists(tempPath)) System.IO.File.Delete(tempPath); }
catch (Exception ex) { bgLogger.LogWarning(ex, "[zip-import] failed to clean up {Path}", tempPath); }
}
});
return Accepted(new
{
jobId,
state = job.State,
statusUrl = $"/api/admin/v1/rooms/zip-import-status/{jobId}",
});
}
/// <summary>GET <c>zip-import-status/{jobId}</c> — SPA polls this
/// every few seconds while the import is running. Returns the
/// final result payload once <c>state == "done"</c> or
/// <c>"failed"</c>; on "failed" the payload either includes a
/// validation error (creator_not_found etc.) or just an
/// <c>error</c> string from the caught exception.</summary>
[HttpGet("zip-import-status/{jobId:guid}")]
public ActionResult GetImportStatus(Guid jobId)
{
if (!Jobs.TryGetValue(jobId, out var job)) return NotFound(new { error = "unknown_job" });
if (job.AdminPlayerId != CurrentAdminId) return Forbid();
return Ok(new
{
jobId = job.Id,
state = job.State,
phase = job.Phase,
startedAt = job.StartedAt,
finishedAt = job.FinishedAt,
elapsedSeconds = ((job.FinishedAt ?? DateTime.UtcNow) - job.StartedAt).TotalSeconds,
result = job.Result,
error = job.Error,
});
Sessions.TryRemove(id, out _);
try { if (System.IO.File.Exists(session.TempPath)) System.IO.File.Delete(session.TempPath); }
catch (Exception ex) { logger.LogWarning(ex, "[zip-import] failed to clean up {Path}", session.TempPath); }
}
}
/// <summary>DELETE <c>zip-upload-abort/{id}</c> — drop an in-flight
@@ -557,23 +416,6 @@ public class RoomZipImportController(
// ── Shared import core (called by both single-shot and chunked) ──
/// <summary>Outcome of a single zip import. Wraps both success
/// payloads and validation errors into one object so the same
/// import code can serve the sync <c>zip-bulk-import</c> endpoint
/// AND the async <c>zip-upload-finalize</c> background job (which
/// has no <see cref="IActionResult"/> to return — it stores the
/// outcome in an <see cref="ImportJob"/> for the SPA to poll).</summary>
public sealed class ImportOutcome
{
/// <summary>true → payload is the success response;
/// false → payload includes an "error" property and the
/// sync endpoint should return 400.</summary>
public bool Success { get; init; }
/// <summary>The anonymous payload that would have been wrapped
/// in Ok()/BadRequest() in the sync path.</summary>
public object Payload { get; init; } = new { };
}
private async Task<IActionResult> RunImportAsync(
Stream archiveStream,
long archiveBytes,
@@ -582,37 +424,9 @@ public class RoomZipImportController(
List<string>? selectedInventionFolders = null,
bool normalizeBlobs = false)
{
var adminId = CurrentAdminId;
var outcome = await RunImportCoreAsync(
archiveStream, archiveBytes,
creatorPlayerId ?? adminId,
adminId,
selectedRoomFolders, selectedInventionFolders, normalizeBlobs,
jobPhase: null);
return outcome.Success
? Ok(outcome.Payload)
: BadRequest(outcome.Payload);
}
/// <summary>Pure import worker: no HttpContext access (caller passes
/// the resolved <paramref name="creator"/>), no IActionResult
/// wrapping. Background jobs call this via a freshly-resolved
/// scope so they can outlive the request that kicked them off.
/// <paramref name="jobPhase"/>, when non-null, gets updated with a
/// short progress string after each room/invention is processed so
/// the status endpoint can report meaningful progress.</summary>
private async Task<ImportOutcome> RunImportCoreAsync(
Stream archiveStream,
long archiveBytes,
long creator,
long adminId,
List<string>? selectedRoomFolders,
List<string>? selectedInventionFolders,
bool normalizeBlobs,
Action<string>? jobPhase)
{
var creator = creatorPlayerId ?? CurrentAdminId;
if (!await db.Players.AnyAsync(p => p.Id == creator))
return new ImportOutcome { Success = false, Payload = new { error = "creator_not_found", creator } };
return BadRequest(new { error = "creator_not_found", creator });
using var zip = new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: false);
@@ -636,14 +450,6 @@ public class RoomZipImportController(
.Select(k => k.Substring(0, k.Length - "/RoomDetails.json".Length))
.ToList();
var studioRoomFolders = entryByPath.Keys
.Where(k => k.EndsWith("/room.json", StringComparison.OrdinalIgnoreCase))
.Select(k => k[..^"/room.json".Length])
.Where(f => HasStudioSaveEntries(f, entryByPath.Keys))
.Where(f => !roomFolders.Contains(f, StringComparer.OrdinalIgnoreCase))
.ToList();
roomFolders.AddRange(studioRoomFolders);
// Inventions can live either at the zip root (Inventions/Invention_<id>_<name>_<ts>/)
// OR inside a room folder (Rooms/Room_<id>_<name>_<ts>/Inventions/Invention_…).
// Both layouts show up in the wild — handle either.
@@ -655,7 +461,7 @@ public class RoomZipImportController(
.ToList();
if (roomFolders.Count == 0 && inventionFolders.Count == 0)
return new ImportOutcome { Success = false, Payload = new { error = "empty_archive_no_manifests" } };
return BadRequest(new { error = "empty_archive_no_manifests" });
// If the admin supplied an explicit selection (non-null,
// non-empty), filter to just those folders. Comparison is
@@ -684,14 +490,11 @@ public class RoomZipImportController(
.Select(r => (long?)r.Id)
.MaxAsync() ?? 999) + 1;
var roomIdx = 0;
foreach (var folder in roomFolders)
{
roomIdx++;
jobPhase?.Invoke($"room {roomIdx}/{roomFolders.Count}: {folder}");
try
{
var result = await ImportRoomAsync(folder, nextRoomId, creator, adminId, entryByPath, normalizeBlobs);
var result = await ImportRoomAsync(folder, nextRoomId, creator, entryByPath, normalizeBlobs);
roomReports.Add(result);
if (result.GetType().GetProperty("ok")!.GetValue(result) as bool? == true)
nextRoomId++;
@@ -704,12 +507,9 @@ public class RoomZipImportController(
}
var inventionReports = new List<object>();
var invIdx = 0;
foreach (var folder in inventionFolders)
{
invIdx++;
jobPhase?.Invoke($"invention {invIdx}/{inventionFolders.Count}: {folder}");
try { inventionReports.Add(await ImportInventionAsync(folder, creator, adminId, entryByPath)); }
try { inventionReports.Add(await ImportInventionAsync(folder, creator, entryByPath)); }
catch (Exception ex)
{
logger.LogError(ex, "[zip-import] invention folder='{Folder}' failed", folder);
@@ -717,19 +517,14 @@ public class RoomZipImportController(
}
}
jobPhase?.Invoke($"done: {roomFolders.Count} rooms, {inventionFolders.Count} inventions");
return new ImportOutcome
return Ok(new
{
Success = true,
Payload = new
{
archiveBytes,
roomCount = roomFolders.Count,
inventionCount = inventionFolders.Count,
rooms = roomReports,
inventions = inventionReports,
},
};
archiveBytes,
roomCount = roomFolders.Count,
inventionCount = inventionFolders.Count,
rooms = roomReports,
inventions = inventionReports,
});
}
// ── Per-room import ───────────────────────────────────────────────
@@ -738,25 +533,13 @@ public class RoomZipImportController(
string roomFolder,
long roomId,
long creator,
long adminId,
Dictionary<string, ZipArchiveEntry> entries,
bool normalizeBlobs)
{
// RoomDetails.json. Studio single-room dumps use room.json at
// the room root; it is the same rooms.rec.net detail payload and
// can be read through RoomDetailsDto directly.
// RoomDetails.json
var detailsKey = $"{roomFolder}/RoomDetails.json";
var studioDetailsKey = $"{roomFolder}/room.json";
ZipArchiveEntry detailsEntry;
if (entries.TryGetValue(detailsKey, out var recNetDetailsEntry))
detailsEntry = recNetDetailsEntry;
else if (entries.TryGetValue(studioDetailsKey, out var studioDetailsEntry))
detailsEntry = studioDetailsEntry;
else
throw new InvalidOperationException($"Missing RoomDetails.json or room.json in {roomFolder}");
var details = await ReadJsonAsync<RoomDetailsDto>(detailsEntry) ?? new RoomDetailsDto();
var name = !string.IsNullOrWhiteSpace(details.Name) ? details.Name!.Trim() : StripStudioSuffix(ZipFileName(roomFolder));
var details = await ReadJsonAsync<RoomDetailsDto>(entries[detailsKey]) ?? new RoomDetailsDto();
var name = !string.IsNullOrWhiteSpace(details.Name) ? details.Name!.Trim() : Path.GetFileName(roomFolder);
if (await db.Rooms.AnyAsync(r => r.Name == name))
return new { name, ok = false, error = "duplicate_name", folder = roomFolder };
@@ -778,12 +561,6 @@ public class RoomZipImportController(
var resolvedScenes = new List<ResolvedSubroom>();
var missingScenes = new List<string>();
if (scenes.Count == 0 && entries.ContainsKey(studioDetailsKey))
{
(resolvedScenes, missingScenes) = await ResolveStudioDumpSubroomsAsync(
roomFolder, details, name, entries, normalizeBlobs);
}
foreach (var sceneName in scenes.Keys)
{
var sceneFolder = $"{subroomRoot}{sceneName}";
@@ -864,47 +641,6 @@ public class RoomZipImportController(
polaroids.Add((Path.GetFileName(k), e));
}
// History/ folder — every non-canonical save dropped by the
// studio-dump-to-import-zip converter (with --every-save). Each
// `<DataBlob>.room` becomes a separate RoomDataBlobEntity row so
// it shows up in `GET api/rooms/v1/datahistory/{roomId}`. The
// optional `<DataBlob>.json` sidecar carries CreatedAt; missing
// sidecars fall back to "now".
var history = new List<HistorySave>();
var historyPrefix = $"{sceneFolder}/History/";
// Derive SubRoomId from the folder name (SubRooms/<scene>) when
// the sidecar omits it. The converter names scene folders as
// either the rec.net Name (no id), or — when running our own
// dumper — the raw `<scene>__<subRoomId>`. Falling back to
// manifest.SubRoomId catches the no-suffix case.
long? subRoomIdFromFolder = null;
var lastUnderscores = sceneName.LastIndexOf("__", StringComparison.Ordinal);
if (lastUnderscores >= 0
&& long.TryParse(sceneName[(lastUnderscores + 2)..], out var parsedSub))
{
subRoomIdFromFolder = parsedSub;
}
var manifestSub = manifest.SubRoomId;
foreach (var (k, hEntry) in entries.Where(kv =>
kv.Key.StartsWith(historyPrefix, StringComparison.OrdinalIgnoreCase)
&& kv.Key.EndsWith(".room", StringComparison.OrdinalIgnoreCase)
&& !kv.Key[historyPrefix.Length..].Contains('/')))
{
var blobName = Path.GetFileName(k);
var sidecarKey = $"{k}.json";
HistorySidecarDto? sidecar = null;
if (entries.TryGetValue(sidecarKey, out var sidecarEntry))
sidecar = await ReadJsonAsync<HistorySidecarDto>(sidecarEntry);
history.Add(new HistorySave(
DataBlob: blobName,
RoomEntry: hEntry,
SubRoomId: sidecar?.SubRoomId ?? subRoomIdFromFolder ?? manifestSub,
SubRoomDataSaveId: sidecar?.SubRoomDataSaveId,
CreatedAt: sidecar?.CreatedAt,
Description: sidecar?.Description,
SavedByAccountId: sidecar?.SavedByAccountId));
}
resolvedScenes.Add(new ResolvedSubroom(
Manifest: manifest,
SceneName: sceneName,
@@ -916,8 +652,7 @@ public class RoomZipImportController(
NormalizedOk: norm.Normalized,
HtrAssets: htrAssets,
PvImages: pvImages,
Polaroids: polaroids,
History: history));
Polaroids: polaroids));
logger.LogInformation(
"[zip-import] room='{Room}' scene='{Scene}' blob={Blob} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol}",
name, sceneName, blobFile, blobBytes.Length, norm.Bytes.Length,
@@ -1010,7 +745,6 @@ public class RoomZipImportController(
imageEntry = entries.FirstOrDefault(kv =>
kv.Key.StartsWith($"{roomFolder}/RoomImage.", StringComparison.OrdinalIgnoreCase)).Value;
}
imageEntry ??= FindStudioPhotoEntry(roomFolder, details.ImageName, entries);
// Reorder so entry is first. Manifest order preserved for the rest.
var ordered = new List<ResolvedSubroom> { entry };
@@ -1074,78 +808,15 @@ public class RoomZipImportController(
var pvAlreadyInDbAtStart = existingPvNames.Count;
var polaroidAlreadyInDbAtStart = existingPolaroidNames.Count;
// History (SubRooms/<scene>/History/<DataBlob>.room) — pre-load
// existing names so the de-dup HashSet covers prior imports + same
// hash referenced under multiple scenes.
var allHistoryNames = ordered
.SelectMany(s => s.History.Select(h => h.DataBlob))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var existingHistoryNames = new HashSet<string>(
await db.RoomDataBlobs
.Where(b => allHistoryNames.Contains(b.BlobName))
.Select(b => b.BlobName).ToListAsync(),
StringComparer.OrdinalIgnoreCase);
var historyAlreadyInDbAtStart = existingHistoryNames.Count;
var historyImported = 0;
var historySkipped = 0;
var imageBlobName = string.Empty;
await using (var tx = await db.Database.BeginTransactionAsync())
{
// Subroom blobs — canonical save per scene + every History/ entry
// restoring the rec.net DataBlob filename verbatim so the in-game
// `Restore` UI's `datahistory` list shows up as expected.
//
// De-dup the History writes the same way HTR / PV / Polaroid
// sections do: pre-load existing BlobNames from the DB, then
// append in-memory as we queue each new row. Without this the
// unique index `IX_RoomDataBlobs_BlobName` fires on the
// SaveChanges when two scenes share a history hash (common: a
// baseline blob that didn't change between subroom edits).
var seenHistoryNames = new HashSet<string>(
existingHistoryNames, StringComparer.OrdinalIgnoreCase);
// Watch's URL uses RoomSceneEntity.OrderIndex (e.g. /datahistory
// for `subRoom=11` is asking for the scene at OrderIndex 11),
// NOT the rec.net SubRoomId from the manifest (e.g. 9717143).
// RoomDataBlobs.SubRoomId must store the OrderIndex so the
// datahistory query (`WHERE SubRoomId = subRoomId`) matches.
// Use the same idx that the RoomSceneEntity insert loop below
// assigns — both iterate `ordered` in lockstep.
int historyOrderIdx = 0;
// Subroom blobs
foreach (var s in ordered)
{
await WriteBlobAsync(roomId, MakeBlobName(s.SceneName), s.Bytes, creator);
foreach (var h in s.History)
{
if (!seenHistoryNames.Add(h.DataBlob))
{
historySkipped++;
continue;
}
var hBytes = await ReadAllAsync(h.RoomEntry);
// Don't normalise historical blobs — they're a snapshot of
// exactly what rec.net served at the time, and replaying
// them through the normaliser would mutate the bytes
// in subtle ways that obscure provenance. The watch reads
// them via CDN just like a normal save, so the same
// protobuf parser applies anyway.
await WriteBlobAsync(
roomId: roomId,
blobName: h.DataBlob,
bytes: hBytes,
uploaderPlayerId: h.SavedByAccountId ?? creator,
uploadedAt: h.CreatedAt,
subRoomId: historyOrderIdx);
historyImported++;
}
historyOrderIdx++;
}
logger.LogInformation(
"[zip-import] room='{Room}' history imports={Imported} (skipped={Skipped})",
name, historyImported, historySkipped);
// HTR assets (shared content — keyed by hash, RoomId=0)
foreach (var (hname, hentry) in allHtrAssets)
{
@@ -1212,16 +883,6 @@ public class RoomZipImportController(
var bytes = await ReadAllAsync(pentry);
await WriteBlobAsync(0, fname, bytes, creator);
}
var studioPromoPrefix = $"{roomFolder}/photos/";
foreach (var (k, pentry) in entries.Where(kv =>
kv.Key.StartsWith(studioPromoPrefix, StringComparison.OrdinalIgnoreCase)
&& Path.GetFileNameWithoutExtension(kv.Key).StartsWith("promo", StringComparison.OrdinalIgnoreCase)))
{
var fname = $"PromoImage_{Path.GetFileName(k)}";
if (await db.RoomDataBlobs.AnyAsync(b => b.BlobName == fname)) continue;
var bytes = await ReadAllAsync(pentry);
await WriteBlobAsync(0, fname, bytes, creator);
}
db.Rooms.Add(new RoomEntity
{
@@ -1275,7 +936,7 @@ public class RoomZipImportController(
db.AdminActions.Add(new AdminActionEntity
{
AdminPlayerId = adminId,
AdminPlayerId = CurrentAdminId,
Action = "zip_import_room",
TargetType = "room",
TargetId = roomId,
@@ -1318,13 +979,6 @@ public class RoomZipImportController(
newlyImported = polaroidCount,
alreadyInDb = polaroidAlreadyInDbAtStart,
},
history = new
{
referenced = allHistoryNames.Count,
newlyImported = historyImported,
alreadyInDb = historyAlreadyInDbAtStart,
skipped = historySkipped,
},
// Legacy fields kept for back-compat with anything that
// already reads them; new code should prefer htrAssets /
// pvImages / polaroids above.
@@ -1354,154 +1008,11 @@ public class RoomZipImportController(
};
}
private async Task<(List<ResolvedSubroom> Resolved, List<string> Missing)> ResolveStudioDumpSubroomsAsync(
string roomFolder,
RoomDetailsDto details,
string roomName,
Dictionary<string, ZipArchiveEntry> entries,
bool normalizeBlobs)
{
var resolvedScenes = new List<ResolvedSubroom>();
var missingScenes = new List<string>();
var rootPrefix = $"{roomFolder}/";
var sceneFolders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var path in entries.Keys.Where(k => k.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase)))
{
var rel = path[rootPrefix.Length..];
var slash = rel.IndexOf('/');
if (slash <= 0) continue;
var sceneFolder = rel[..slash];
if (rel[(slash + 1)..].StartsWith("saves/", StringComparison.OrdinalIgnoreCase))
sceneFolders.Add(sceneFolder);
}
foreach (var sceneFolderName in sceneFolders.OrderBy(StripStudioSuffix, StringComparer.OrdinalIgnoreCase))
{
var sceneName = StripStudioSuffix(sceneFolderName);
var savesPrefix = $"{rootPrefix}{sceneFolderName}/saves/";
var saves = new List<(long SaveId, HistorySidecarDto Sidecar, ZipArchiveEntry DataEntry)>();
foreach (var (path, jsonEntry) in entries.Where(kv =>
kv.Key.StartsWith(savesPrefix, StringComparison.OrdinalIgnoreCase)
&& kv.Key.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
&& !kv.Key[savesPrefix.Length..].Contains('/')))
{
var stem = Path.GetFileNameWithoutExtension(path);
if (!long.TryParse(stem, out var saveId)) continue;
if (!entries.TryGetValue($"{savesPrefix}{saveId}__data.room", out var dataEntry))
{
missingScenes.Add($"{sceneName} save {saveId} (missing {saveId}__data.room)");
continue;
}
var sidecar = await ReadJsonAsync<HistorySidecarDto>(jsonEntry) ?? new HistorySidecarDto();
saves.Add((sidecar.SubRoomDataSaveId ?? saveId, sidecar, dataEntry));
}
if (saves.Count == 0)
{
missingScenes.Add($"{sceneName} (no usable saves)");
continue;
}
var current = saves.OrderBy(s => s.SaveId).Last();
var subroomDetails = details.SubRooms?.FirstOrDefault(s =>
(s.SubRoomId.HasValue && s.SubRoomId == (current.Sidecar.SubRoomId ?? TryParseStudioId(sceneFolderName)))
|| string.Equals(s.Name, sceneName, StringComparison.OrdinalIgnoreCase));
var manifest = new SubRoomDto
{
SubRoomId = current.Sidecar.SubRoomId ?? subroomDetails?.SubRoomId ?? TryParseStudioId(sceneFolderName),
UnitySceneId = subroomDetails?.UnitySceneId,
Name = subroomDetails?.Name ?? sceneName,
IsSandbox = subroomDetails?.IsSandbox ?? true,
MaxPlayers = subroomDetails?.MaxPlayers ?? details.MaxPlayers ?? 8,
Accessibility = subroomDetails?.Accessibility ?? details.Accessibility ?? 1,
CurrentSave = new CurrentSaveDto
{
SubRoomDataSaveId = current.SaveId,
DataBlob = current.Sidecar.DataBlob ?? ZipFileName(current.DataEntry.FullName),
},
};
var blobBytes = await ReadAllAsync(current.DataEntry);
var norm = roomBlobNormalizer.Normalize(blobBytes);
var persistedBytes = normalizeBlobs ? norm.Bytes : blobBytes;
var htrAssets = new List<(string Name, ZipArchiveEntry Entry)>();
var pvImages = new List<(string Name, ZipArchiveEntry Entry)>();
var polaroids = new List<(string Name, ZipArchiveEntry Entry)>();
foreach (var save in saves)
{
var refPrefix = $"{savesPrefix}{save.SaveId}__ref_";
foreach (var (path, assetEntry) in entries.Where(kv =>
kv.Key.StartsWith(refPrefix, StringComparison.OrdinalIgnoreCase)
&& !kv.Key[refPrefix.Length..].Contains('/')))
{
var assetName = path[refPrefix.Length..];
var ext = Path.GetExtension(assetName).ToLowerInvariant();
if (ext == ".htr")
{
htrAssets.Add((assetName, assetEntry));
}
else if (IsImageExt(ext))
{
if (assetName.StartsWith("PVImage_", StringComparison.OrdinalIgnoreCase))
{
pvImages.Add((assetName, assetEntry));
polaroids.Add((assetName["PVImage_".Length..], assetEntry));
}
else
{
pvImages.Add(($"PVImage_{assetName}", assetEntry));
polaroids.Add((assetName, assetEntry));
}
}
}
}
var history = new List<HistorySave>();
foreach (var save in saves.Where(s => s.SaveId != current.SaveId))
{
history.Add(new HistorySave(
DataBlob: !string.IsNullOrWhiteSpace(save.Sidecar.DataBlob)
? save.Sidecar.DataBlob!
: ZipFileName(save.DataEntry.FullName),
RoomEntry: save.DataEntry,
SubRoomId: save.Sidecar.SubRoomId ?? manifest.SubRoomId,
SubRoomDataSaveId: save.Sidecar.SubRoomDataSaveId ?? save.SaveId,
CreatedAt: save.Sidecar.CreatedAt,
Description: save.Sidecar.Description,
SavedByAccountId: save.Sidecar.SavedByAccountId));
}
resolvedScenes.Add(new ResolvedSubroom(
Manifest: manifest,
SceneName: sceneName,
Bytes: persistedBytes,
RawBytes: blobBytes.Length,
NormalizedOk: norm.Normalized,
HtrAssets: htrAssets,
PvImages: pvImages,
Polaroids: polaroids,
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}",
roomName, sceneName, current.SaveId, blobBytes.Length, norm.Bytes.Length,
normalizeBlobs ? "normaliser" : "raw",
htrAssets.Count, pvImages.Count, polaroids.Count, history.Count);
}
return (resolvedScenes, missingScenes);
}
// ── Per-invention import ─────────────────────────────────────────
private async Task<object> ImportInventionAsync(
string folder,
long creator,
long adminId,
Dictionary<string, ZipArchiveEntry> entries)
{
// Invention.json
@@ -1548,58 +1059,6 @@ public class RoomZipImportController(
var blobBytes = await ReadAllAsync(blobEntry);
// Preserve the ORIGINAL Rec Room InventionId so the .room blob's
// embedded invention references resolve. The watch's blob format
// stores invention refs as `InventionId` longs (verified by
// varint-scanning a known room blob); if we let EF auto-generate
// a new Id, every invention-spawn reference inside the blob
// points at a non-existent id → invention shape doesn't spawn →
// big visible holes (floor missing, custom geometry absent, etc.).
//
// Skip the explicit-Id path when the json doesn't carry an
// InventionId (corrupt exports) or when our local DB already has
// a row with that id (cross-room collision: rare, but if a single
// zip imports two rooms that both reference the same shared
// invention with the same id, the second import would conflict —
// fall back to auto-id and accept the lookup loss for that one).
long? preservedId = null;
if (meta.InventionId is long origId && origId > 0)
{
var idTaken = await db.Inventions.AsNoTracking().AnyAsync(x => x.Id == origId);
if (!idTaken) preservedId = origId;
else logger.LogWarning("[zip-import] invention id {Id} already exists in DB; using auto-id (refs from .room blob may not resolve)", origId);
}
// Map CreationRoomId from the ORIGINAL Rec Room room id (carried
// in the json) to our LOCAL room id when we're importing the
// invention alongside a room in the same archive. Without this
// remap, /api/inventions/v1/room?id={localRoomId} returns nothing
// (watch asks with our local id; the row carries the original
// remote id). Fall back to the original id when we can't resolve
// the local room (e.g. invention imported standalone).
long? remappedRoomId = meta.CreationRoomId;
if (meta.CreationRoomId is long origRoomId)
{
// Walk recent zip_import_room admin actions for the same
// session to find which local room id maps to the original.
// The Reason text encodes "name={Name}" but not the original
// id; the room's Description sometimes hints. Simpler: match
// by name when the room json's CreationRoomId equals
// RoomDetails.RoomId we read earlier. We pass the local
// room id in via the caller chain when available.
//
// For zips that import the room AND its inventions together,
// ImportRoomAsync runs first and adds the room with the new
// local id, so by the time ImportInventionAsync runs we can
// look up the room by RoomDetails.RoomId == meta.CreationRoomId
// (the original) — but we don't store that field. To avoid
// an entity schema change here, attempt a lookup by the
// local id matching the original (handles single-room imports
// where the id happens to match) and otherwise leave as-is.
// The watch endpoint will need to handle both id spaces.
remappedRoomId = origRoomId;
}
var entity = new InventionEntity
{
CreatorPlayerId = creator,
@@ -1617,13 +1076,12 @@ public class RoomZipImportController(
CurrentVersionNumber = meta.CurrentVersionNumber ?? version.VersionNumber ?? 1,
NumPlayersHaveUsedInRoom = meta.NumPlayersHaveUsedInRoom ?? 0,
CheerCount = meta.CheerCount ?? 0,
CreationRoomId = remappedRoomId,
CreationRoomId = meta.CreationRoomId,
TagsCsv = JoinTags(extras.Tags),
CreatedAt = meta.CreatedAt ?? DateTime.UtcNow,
UpdatedAt = meta.ModifiedAt ?? DateTime.UtcNow,
FirstPublishedAt = meta.FirstPublishedAt,
};
if (preservedId is long pid) entity.Id = pid;
db.Inventions.Add(entity);
await db.SaveChangesAsync();
@@ -1642,34 +1100,9 @@ public class RoomZipImportController(
await WriteBlobAsync(0, imageBlobName, bytes, creator);
}
// Insert the per-version row for the watch's
// /api/inventions/v1/version?inventionId=X&version=N lookup.
// Without this the watch's room-load chain can't resolve a
// BlobName to download (returns 404 → invention spawns as empty
// placeholder), even though the bytes are on disk and the
// InventionEntity has CurrentBlobName set. The /v3/save path
// (api/inventions) writes this row at create time — the zip
// importer was missing the same step.
var exists = await db.InventionVersions.AnyAsync(
v => v.InventionId == entity.Id && v.VersionNumber == entity.CurrentVersionNumber);
if (!exists)
{
db.InventionVersions.Add(new InventionVersionEntity
{
InventionId = entity.Id,
ReplicationId = entity.ReplicationId ?? Guid.NewGuid().ToString("D"),
VersionNumber = entity.CurrentVersionNumber,
BlobName = blobName,
// Costs aren't in our minimal InventionVersionDto today;
// safe defaults until we extend the DTO to read them.
InstantiationCost = 0,
LightsCost = 0,
});
}
db.AdminActions.Add(new AdminActionEntity
{
AdminPlayerId = adminId,
AdminPlayerId = CurrentAdminId,
Action = "zip_import_invention",
TargetType = "invention",
TargetId = entity.Id,
@@ -1698,74 +1131,6 @@ public class RoomZipImportController(
private static string Normalize(string path) => path.Replace('\\', '/').TrimStart('/');
private static string ZipFileName(string path)
{
var normalized = Normalize(path).TrimEnd('/');
var slash = normalized.LastIndexOf('/');
return slash >= 0 ? normalized[(slash + 1)..] : normalized;
}
private static string StripStudioSuffix(string name)
{
var marker = name.LastIndexOf("__", StringComparison.Ordinal);
return marker > 0 && long.TryParse(name[(marker + 2)..], out _)
? name[..marker]
: name;
}
private static long? TryParseStudioId(string name)
{
var marker = name.LastIndexOf("__", StringComparison.Ordinal);
return marker > 0 && long.TryParse(name[(marker + 2)..], out var id) ? id : null;
}
private static bool IsImageExt(string ext) => ext.ToLowerInvariant() switch
{
".jpg" or ".jpeg" or ".png" or ".webp" or ".gif" => true,
_ => false,
};
private static bool HasStudioSaveEntries(string roomFolder, IEnumerable<string> paths)
{
var prefix = $"{roomFolder.TrimEnd('/')}/";
return paths.Any(k =>
{
if (!k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|| !k.EndsWith("__data.room", StringComparison.OrdinalIgnoreCase))
return false;
var rel = k[prefix.Length..];
var parts = rel.Split('/');
return parts.Length == 3
&& parts[0].Contains("__", StringComparison.Ordinal)
&& string.Equals(parts[1], "saves", StringComparison.OrdinalIgnoreCase);
});
}
private static ZipArchiveEntry? FindStudioPhotoEntry(
string roomFolder,
string? imageName,
Dictionary<string, ZipArchiveEntry> entries)
{
var photosPrefix = $"{roomFolder}/photos/";
if (!string.IsNullOrWhiteSpace(imageName))
{
var byImageName = entries.FirstOrDefault(kv =>
kv.Key.StartsWith(photosPrefix, StringComparison.OrdinalIgnoreCase)
&& string.Equals(Path.GetFileName(kv.Key), imageName, StringComparison.OrdinalIgnoreCase)).Value;
if (byImageName is not null) return byImageName;
var imageStem = Path.GetFileNameWithoutExtension(imageName);
byImageName = entries.FirstOrDefault(kv =>
kv.Key.StartsWith(photosPrefix, StringComparison.OrdinalIgnoreCase)
&& string.Equals(Path.GetFileNameWithoutExtension(kv.Key), imageStem, StringComparison.OrdinalIgnoreCase)).Value;
if (byImageName is not null) return byImageName;
}
return entries.FirstOrDefault(kv =>
kv.Key.StartsWith(photosPrefix, StringComparison.OrdinalIgnoreCase)
&& string.Equals(Path.GetFileNameWithoutExtension(kv.Key), "main", StringComparison.OrdinalIgnoreCase)).Value;
}
private static async Task<byte[]> ReadAllAsync(ZipArchiveEntry entry)
{
await using var s = entry.Open();
@@ -1799,24 +1164,7 @@ public class RoomZipImportController(
bool NormalizedOk,
List<(string Name, ZipArchiveEntry Entry)> HtrAssets,
List<(string Name, ZipArchiveEntry Entry)> PvImages,
List<(string Name, ZipArchiveEntry Entry)> Polaroids,
List<HistorySave> History);
/// <summary>One historical save under SubRooms/&lt;scene&gt;/History/.
/// The .room blob bytes go into S3 under the original
/// <see cref="DataBlob"/> filename so the watch's `Restore` UI can
/// fetch them by name. The optional .json sidecar carries the
/// original <see cref="CreatedAt"/> + provenance, which we mirror
/// into <see cref="RoomDataBlobEntity.UploadedAt"/> so the in-game
/// history list sorts in real chronological order.</summary>
private sealed record HistorySave(
string DataBlob,
ZipArchiveEntry RoomEntry,
long? SubRoomId,
long? SubRoomDataSaveId,
DateTime? CreatedAt,
string? Description,
long? SavedByAccountId);
List<(string Name, ZipArchiveEntry Entry)> Polaroids);
/// <summary>Decode RecNet's room-snapshot manifest <c>.meta</c> and
/// return the entry-subroom index it encodes, or null if the file
@@ -0,0 +1,230 @@
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.Admin;
/// <summary>
/// admin.*/api/admin/v1/storage/backfill — one-shot migrator that
/// moves the legacy <c>RoomDataBlobs.Bytes</c> column out of the
/// database and into S3 at the canonical key from
/// <see cref="BlobRouter.Route"/>. After every row's bytes have been
/// uploaded and verified, a follow-up EF migration drops the
/// <c>Bytes</c> column entirely.
///
/// Why this exists: every controller that writes new content already
/// targets S3 directly (the importer, photo upload, profile image,
/// dorm save, room save, …). The <c>Bytes</c> column only holds
/// pre-cutover data — rooms imported before the S3-only refactor
/// landed. This endpoint drains that legacy column into S3 once and
/// then we never look at it again.
///
/// Runs as a background task so the HTTP request returns immediately
/// even for a multi-GB drain. Progress streams to the server log and
/// to a small in-memory <see cref="BackfillStatus"/> snapshot the UI
/// can poll. Idempotent — each row is checked against
/// <see cref="IObjectStorage.ExistsAsync"/> first, and rows whose
/// bytes are already in S3 get their DB <c>Bytes</c> cleared without
/// re-upload.
/// </summary>
[ApiController]
[Route("api/admin/v1/storage")]
[Authorize]
[AdminOnly]
public class StorageBackfillController(
ILogger<StorageBackfillController> log,
IServiceScopeFactory scopeFactory) : ControllerBase
{
private static readonly object _gate = new();
private static BackfillStatus _status = BackfillStatus.Idle;
/// <summary>POST <c>/storage/backfill?dryRun=true</c> — start a
/// drain pass. Returns 200 immediately; the actual work happens
/// in a background task. Re-POSTing while running returns 409.
/// Set <c>?dryRun=true</c> on the first pass to log every
/// (row → bucket/key) decision without writing to S3 or clearing
/// the DB <c>Bytes</c> column.</summary>
[HttpPost("backfill")]
public IActionResult Start([FromQuery] bool dryRun = false)
{
lock (_gate)
{
if (_status.Running)
return Conflict(new { error = "already_running", since = _status.StartedAt });
_status = BackfillStatus.Starting(dryRun);
}
_ = Task.Run(() => RunAsync(dryRun));
return Ok(new { started = true, dryRun });
}
/// <summary>GET <c>/storage/backfill/status</c> — current
/// progress. Updated row-by-row during a run.</summary>
[HttpGet("backfill/status")]
public IActionResult Status() => Ok(_status);
private async Task RunAsync(bool dryRun)
{
var startedAt = DateTime.UtcNow;
try
{
// Phase 1: pull the list of row ids that still have bytes.
// Materialise just the ids (not the bytes) up-front so we
// don't hold a reader open across per-row S3 PUTs — that
// would conflict with the per-row ExecuteUpdateAsync on
// Npgsql, which doesn't allow a second command while a
// reader is mid-iteration. Bytes get fetched one row at a
// time below so RAM only holds one blob at a time.
// Filter on Bytes != null only — Npgsql can't translate
// .Length on a byte[] property (it sees it as Enumerable.Any
// and bails). Empty-byte-array rows don't exist in our
// schema; rows either hold real bytes or NULL after backfill.
// We still re-check Length on the materialised row before
// uploading, defensive against any future client-evaluated
// edge case.
List<long> rowIds;
long total;
await using (var scope = scopeFactory.CreateAsyncScope())
{
var dbScoped = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
rowIds = await dbScoped.RoomDataBlobs
.AsNoTracking()
.Where(b => b.Bytes != null)
.OrderBy(b => b.Id)
.Select(b => b.Id)
.ToListAsync();
total = rowIds.Count;
}
log.LogInformation(
"[backfill] starting — rowsWithBytes={Total} dryRun={DryRun}",
total, dryRun);
lock (_gate) _status = _status with { Total = total };
// Phase 2: per-row upload + clear. Each row gets its own
// short-lived DbContext so the reader/writer conflict is
// structurally impossible. Memory-bound at one row's
// bytes plus query overhead.
foreach (var rowId in rowIds)
{
await using var rowScope = scopeFactory.CreateAsyncScope();
var rowDb = rowScope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var storageScoped = rowScope.ServiceProvider.GetRequiredService<IObjectStorage>();
var row = await rowDb.RoomDataBlobs
.AsNoTracking()
.Where(b => b.Id == rowId)
.Select(b => new { b.Id, b.BlobName, b.Bytes })
.FirstOrDefaultAsync();
if (row is null || row.Bytes is null || row.Bytes.Length == 0)
{
Bump(s => s with { Skipped = s.Skipped + 1 });
continue;
}
var (bucket, key) = BlobRouter.Route(row.BlobName);
try
{
if (dryRun)
{
log.LogInformation(
"[backfill:dry] {Blob} → {Bucket}/{Key} ({Bytes} B)",
row.BlobName, bucket, key, row.Bytes.Length);
Bump(s => s with { Uploaded = s.Uploaded + 1, LastBlobName = row.BlobName });
continue;
}
// Upload unconditionally (S3 PUT is idempotent /
// overwriting). Verifying with ExistsAsync first
// would let us skip already-mirrored rows, but a
// mismatch between S3 and DB bytes (corrupted
// earlier upload, partial backfill, etc.) would
// leave the divergence in place. Re-uploading
// guarantees S3 == DB before we clear DB.
var contentType = MimeFromName(row.BlobName);
// Per-row timeout: 4 minutes generous enough for a
// 100 MB blob over a 5 Mbps origin link, well
// inside the 5-minute S3 SDK ceiling. Without a
// CT, a hung S3 connection could freeze the whole
// drain indefinitely.
using var putCt = new CancellationTokenSource(TimeSpan.FromMinutes(4));
await storageScoped.PutAsync(bucket, key, row.Bytes, contentType, putCt.Token);
// SAFETY GATE: confirm S3 actually persisted the
// object before dropping the DB copy. PutAsync
// returning success isn't enough — some S3 impls
// can ack a write that disappears (eventual
// consistency, replication lag, write buffer
// dropped on shutdown). HEAD costs nothing.
using var headCt = new CancellationTokenSource(TimeSpan.FromSeconds(15));
if (!await storageScoped.ExistsAsync(bucket, key, headCt.Token))
throw new InvalidOperationException(
$"S3 PUT {bucket}/{key} returned success but ExistsAsync says no — refusing to clear DB bytes");
await rowDb.RoomDataBlobs
.Where(b => b.Id == row.Id)
.ExecuteUpdateAsync(s => s.SetProperty(b => b.Bytes, (byte[]?)null));
Bump(s => s with { Uploaded = s.Uploaded + 1, LastBlobName = row.BlobName });
}
catch (Exception ex)
{
log.LogWarning(ex, "[backfill] failed {Blob} → {Bucket}/{Key} — DB bytes left intact",
row.BlobName, bucket, key);
Bump(s => s with { Failed = s.Failed + 1, LastBlobName = row.BlobName });
}
}
var elapsed = (int)(DateTime.UtcNow - startedAt).TotalMilliseconds;
lock (_gate) _status = _status with
{
Running = false,
FinishedAt = DateTime.UtcNow,
ElapsedMs = elapsed,
};
log.LogInformation(
"[backfill] finished — total={Total} uploaded={Up} alreadyMirrored={Already} skipped={Skip} failed={Fail} dryRun={Dry} elapsed={Ms}ms",
_status.Total, _status.Uploaded, _status.AlreadyMirrored, _status.Skipped, _status.Failed, dryRun, elapsed);
}
catch (Exception ex)
{
log.LogError(ex, "[backfill] crashed");
lock (_gate) _status = _status with { Running = false, FinishedAt = DateTime.UtcNow, Error = ex.Message };
}
}
private static void Bump(Func<BackfillStatus, BackfillStatus> mutate)
{
lock (_gate) _status = mutate(_status);
}
private static string MimeFromName(string name) => Path.GetExtension(name).ToLowerInvariant() switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
".mp4" => "video/mp4",
_ => "application/octet-stream",
};
public sealed record BackfillStatus(
bool Running,
bool DryRun,
DateTime? StartedAt,
DateTime? FinishedAt,
long Total,
long Uploaded,
long AlreadyMirrored,
long Skipped,
long Failed,
string? LastBlobName,
int? ElapsedMs,
string? Error)
{
public static BackfillStatus Idle => new(false, false, null, null, 0, 0, 0, 0, 0, null, null, null);
public static BackfillStatus Starting(bool dryRun) =>
new(true, dryRun, DateTime.UtcNow, null, 0, 0, 0, 0, 0, null, null, null);
}
}
@@ -23,7 +23,7 @@ public class AuthAccountController(DorkNetDbContext db) : ControllerBase
/// <c>ExpectPrimitiveResponse&lt;bool&gt;</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", Order = 10)]
[HttpGet("/api/platformlogin/haspassword")]
[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", Order = 10)]
[HttpPut("/api/platformlogin/password", Order = 10)]
[HttpPost("/api/platformlogin/password")]
[HttpPut("/api/platformlogin/password")]
[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", Order = 10)]
[HttpPost("/api/platformlogin/resetpassword")]
[Authorize]
[AdminOnly]
public async Task<IActionResult> AdminResetPassword(
+235 -54
View File
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
@@ -6,26 +7,242 @@ using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Auth;
/// <summary>
/// auth.rec.net game-specific auth helpers. OAuth/OIDC endpoints are owned by IdentityServer.
/// auth.rec.net — OAuth-style token endpoint used by 2019+ builds alongside PlatformLogin.
/// </summary>
[ApiController]
public class AuthController(
PlayerService playerService,
AuthService authService) : ControllerBase
AuthService authService,
LevelService level,
OrphanAccountTracker orphans,
ServerSettingsService settings,
DorkNet.Server.Data.DorkNetDbContext db,
DomainConfig domain,
ILogger<AuthController> logger) : ControllerBase
{
// 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 throws
// FormatException client-side and kicks the login.
[HttpGet("/eac/challenge")]
[HttpPost("/eac/challenge")]
public IActionResult EacChallenge()
=> Content("\"AAAAAAAAAAAAAAAAAAAAAAAA\"", "application/json");
/// <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",
});
}
// 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)
{
@@ -35,8 +252,10 @@ public class AuthController(
/// <summary>POST <c>cachedlogin/forplatformids</c> — bulk variant
/// the watch uses when hydrating multiple platform-id mappings
/// at once. Body is form-urlencoded <c>platform=&amp;platformIds=a,b,c</c>
/// or JSON with the same shape.</summary>
/// at once (e.g. populating a friend list from Steam ids). Body
/// is form-urlencoded <c>platform=&amp;platformIds=a,b,c</c> or
/// JSON with the same shape. Returns the flattened
/// <c>List&lt;CachedLogin&gt;</c> across every matched id.</summary>
[HttpPost("/cachedlogin/forplatformids")]
[HttpGet("/cachedlogin/forplatformids")]
public async Task<IActionResult> CachedLoginsForPlatformIds()
@@ -54,7 +273,6 @@ public class AuthController(
}
}
catch { /* not form */ }
if (string.IsNullOrWhiteSpace(rawIds))
{
int.TryParse(Request.Query["platform"], out platform);
@@ -77,50 +295,13 @@ public class AuthController(
return Ok(results);
}
[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()
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new()
{
Platform = platform,
PlatformId = platformId ?? string.Empty,
PlatformId = platformId,
AccountId = (int)p.Id,
LastLoginTime = p.LastSeenAt,
RequirePassword = false,
};
}
@@ -1,99 +0,0 @@
using DorkNet.Server.Compat;
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.Auth;
/// <summary>The boot-time client-version gate the 2020.12 watch hits
/// at startup as <c>GET api/versioncheck/v4?v={build}&amp;p={platform}</c>.
///
/// <para>Verified against the watch's <c>KMDHPCHFADM.VerifyGameVersion</c>
/// (decompiled): the response is parsed into <c>JFKEIPDCCHH</c> which
/// exposes a single <c>VersionStatus</c> property of enum type
/// <c>PCBCGDHBAEL</c>:
/// <list type="bullet">
/// <item>0 = <c>ValidForPlay</c> — proceed normally</item>
/// <item>1 = <c>ValidForMenu</c> — let user reach menus but block matchmaking</item>
/// <item>2 = <c>UpdateRequired</c> — watch shows its built-in
/// "Update Required" dialog and refuses to advance past the boot screen</item>
/// </list>
/// This is the CLEAN way to reject a wrong-build client at the
/// 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
/// <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
/// <c>VersionStatus = 2</c> and the watch refuses to proceed.</para>
///
/// <para>This endpoint is EXEMPT from
/// <c>VersionDetectionMiddleware</c>'s gate (otherwise the gate would
/// 426 the rejection request itself before this endpoint could reply
/// with the polite UpdateRequired payload). The exemption is set in
/// <c>VersionDetectionMiddleware.IsExempt</c>.</para>
/// </summary>
[ApiController]
public class VersionCheckController(
IConfiguration config,
VersionRegistry registry,
ILogger<VersionCheckController> logger) : ControllerBase
{
// Mirrors the PCBCGDHBAEL enum the watch decodes the response into.
private const int VersionStatus_ValidForPlay = 0;
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)
{
["20201210"] = "december_2020_12_18",
};
[HttpGet("/api/versioncheck/v4")]
[HttpGet("/api/versioncheck/v3")]
[HttpGet("/api/versioncheck/v2")]
[HttpGet("/api/versioncheck/v1")]
[HttpGet("/api/versioncheck")]
[HttpGet("/api/version/v1")]
[HttpGet("/api/version")]
public IActionResult Check(
[FromQuery(Name = "v")] string? buildId,
[FromQuery(Name = "p")] string? platform)
{
// Map raw build ID → ClientVersion key via config. Format:
// "DorkNet": {
// "BuildIdToVersionKey": {
// "20201210": "december_2020_12_18",
// "20200310": "march_2020_03_10"
// }
// }
// Mapping ABSENT → unknown build → UpdateRequired.
var mapSection = config.GetSection("DorkNet:BuildIdToVersionKey");
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;
logger.LogInformation(
"[versioncheck] buildId={Build} platform={Platform} → versionKey={Key} status={Status} ({Label})",
buildId, platform, versionKey ?? "(unmapped)", status,
status == VersionStatus_ValidForPlay ? "ValidForPlay" : "UpdateRequired");
// Wire shape verified against KMDHPCHFADM_NestedType_JFKEIPDCCHH.txt:
// single key "VersionStatus" (PascalCase, case-sensitive — LitJson).
return Ok(new { VersionStatus = status });
}
}
@@ -94,12 +94,6 @@ public class CdnController(
// explicit route. Without this, the catch-all regex below only
// matches single-segment names and `/video/cb_video_*.mp4` 404s.
[Route("/video/{*path:minlength(1)}")]
// `cdn.{apex}/invention/<BlobName>` — the watch's invention
// download fetcher (BBHENFCNLAB.GetInventionData at
// BBHENFCNLAB_NestedType_NBDMFFEGNGJ.txt:207) builds the URL via
// String.Concat("/invention/", filename). Same byte-fetch flow as
// /room/, routed to S3 via BlobRouter on the filename.
[Route("/invention/{*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 `[[^/]]`.
@@ -351,31 +345,12 @@ public class CdnController(
}
if (s3Bytes is { Length: > 0 })
{
var overlaidRoomRoleData = false;
if (await ShouldOverlayRroRoomRoleDataAsync(fileName))
{
try
{
s3Bytes = roomDataBlob.OverlayRroEditableRoleData(s3Bytes);
overlaidRoomRoleData = true;
logger.LogInformation("[cdn] overlaid RRO room-role permissions host={Host} file={File} bytes={Bytes}",
Request.Host.Host, fileName, s3Bytes.Length);
}
catch (Exception ex)
{
logger.LogWarning(ex, "[cdn] failed to overlay RRO room-role permissions for {File}; serving original blob",
fileName);
}
}
logger.LogInformation("[cdn] s3 hit host={Host} file={File} bytes={Bytes}",
Request.Host.Host, fileName, s3Bytes.Length);
// Allow CF edge to cache for an hour. RoomDataBlobs are
// content-addressed by hash so the same BlobName never
// serves different bytes — safe to cache aggressively.
Response.Headers.CacheControl = overlaidRoomRoleData
? "public, max-age=60"
: "public, max-age=3600";
Response.Headers.CacheControl = "public, max-age=3600";
signatures.AddContentSignature(Response, s3Bytes);
return new FileContentResult(s3Bytes, MimeFromName(fileName));
}
@@ -393,16 +368,6 @@ public class CdnController(
return new FileContentResult(TransparentPng, "image/png");
}
if (await ShouldOverlayRroRoomRoleDataAsync(fileName))
{
logger.LogInformation("[cdn] MISS host={Host} file={File} -> RRO editable role blob",
Request.Host.Host, fileName);
var rroBlob = roomDataBlob.GetRroEditableBlob();
Response.Headers.CacheControl = "public, max-age=60";
signatures.AddContentSignature(Response, rroBlob);
return new FileContentResult(rroBlob, "application/octet-stream");
}
logger.LogInformation("[cdn] MISS host={Host} file={File} -> default blob",
Request.Host.Host, fileName);
var fallbackBlob = roomDataBlob.GetDefaultBlob();
@@ -410,33 +375,6 @@ public class CdnController(
return new FileContentResult(fallbackBlob, "application/octet-stream");
}
private async Task<bool> ShouldOverlayRroRoomRoleDataAsync(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName)) return false;
if (await db.Rooms.AsNoTracking().AnyAsync(r =>
r.IsAGRoom &&
r.CurrentDataBlobName == fileName &&
db.RoomRoles.Any(rr => rr.RoomId == r.Id && rr.Accepted)))
return true;
if (fileName.StartsWith("room_", StringComparison.OrdinalIgnoreCase) &&
fileName.EndsWith("_v1.dat", StringComparison.OrdinalIgnoreCase) &&
long.TryParse(fileName[5..^7], out var roomId) &&
await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId && r.IsAGRoom) &&
await db.RoomRoles.AsNoTracking().AnyAsync(r => r.RoomId == roomId && r.Accepted))
return true;
return await (
from scene in db.RoomScenes.AsNoTracking()
join room in db.Rooms.AsNoTracking() on scene.RoomId equals room.Id
where room.IsAGRoom &&
scene.DataBlobName == fileName &&
db.RoomRoles.Any(rr => rr.RoomId == room.Id && rr.Accepted)
select scene.Id
).AnyAsync();
}
/// <summary>
/// Apply the watch's <c>?cropSquare=1</c> + <c>?width=N</c> transforms
/// per-request on the way out and sign the *resulting* bytes — the
@@ -1,939 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Clubs;
/// <summary>
/// clubs.localhost — the 2020.12 watch's club home tab and announcement
/// inbox. Path-based routing per the host-filter strategy in
/// Program.cs (HostFilteringMiddleware gates which subdomains reach
/// us, individual controllers don't need [Host] filters).
///
/// URL surface (verb + template) verified against
/// <c>Cpp2IL_ISIL/.../JDJGIBLMFKK.txt</c>:
/// GET /club/home/me
/// GET /club/mine/member
/// GET /club/mine/created
/// GET /club/categoryTags
/// GET /announcements/v2/mine/unread
/// GET /announcements/v2/subscription/mine/unread
/// POST /announcements/club/{clubId}/{announcementId}/read
///
/// Wire types (deserialiser JSON keys, per ISIL):
/// Club (<c>PLILLKHMNDA</c>): ClubId, Name, Description,
/// MainImageName, State, CreatorAccountId, Category,
/// Visibility, Joinability, AllowJuniors, MemberCount,
/// IsRRO, ClubhouseRoomId, ClubType
/// Announcement (<c>NFEMLMAFFIP</c>): AnnouncementId,
/// CreatorAccountId, ClubId, Title, Body, ImageName,
/// CreatedAt, Meta
/// UnreadResponse (<c>HPACLJHLHBG</c>) — wraps per-club rows:
/// { clubId, LastReadAnnouncementId, announcements: [Announcement] }
/// CategoryTags plain <c>List&lt;String&gt;</c> (per
/// <c>JDJGIBLMFKK.GetPrimaryTags</c> callback shape).
/// </summary>
[ApiController]
public class ClubsController(ClubService clubs) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("/club/home/me")]
[Authorize]
public async Task<IActionResult> HomeMe()
{
var pid = Me;
var home = await clubs.HomeClubAsync(pid);
if (home is null)
{
// Watch tolerates an empty/default Club object on the home
// tab — it shows the "no home club" empty state. Returning
// 204 No Content would short-circuit the deserialiser
// (<c>Action`1&lt;PLILLKHMNDA&gt;</c>) so we send a minimal
// sentinel row with ClubId=0 instead; the watch's null-check
// path keys off ClubId.
return Ok(ToWireClub(EmptyClub(), 0));
}
var memberCount = await clubs.MemberCountAsync(home.Id);
return Ok(ToWireClub(home, memberCount));
}
[HttpGet("/club/mine/member")]
[Authorize]
public async Task<IActionResult> MineMember()
{
var pid = Me;
var rows = await clubs.MyClubsAsync(pid);
var counts = await clubs.MemberCountsAsync(rows.Select(r => r.Id));
return Ok(rows.Select(c => ToWireClub(c, counts.GetValueOrDefault(c.Id))));
}
[HttpGet("/club/mine/created")]
[Authorize]
public async Task<IActionResult> MineCreated()
{
var pid = Me;
var rows = await clubs.CreatedByAsync(pid);
var counts = await clubs.MemberCountsAsync(rows.Select(r => r.Id));
return Ok(rows.Select(c => ToWireClub(c, counts.GetValueOrDefault(c.Id))));
}
/// <summary>GET <c>/club/search</c> — browse/search clubs by
/// optional category and text query. The watch issues this while
/// building the Clubs discover tab, e.g.
/// <c>?sort=0&amp;category=Creative&amp;count=30</c>, and expects
/// a <c>CAJHIEENJJD</c> envelope: <c>Clubs</c>,
/// <c>TotalClubs</c>, and <c>ContinuationToken</c>.</summary>
[HttpGet("/club/search")]
public async Task<IActionResult> ClubSearch(
[FromQuery] string? query,
[FromQuery] string? search,
[FromQuery] string? category,
[FromQuery] int? sort,
[FromQuery] int? count)
{
var result = await clubs.SearchAsync(query ?? search, category, sort ?? 0, count ?? 30);
var counts = await clubs.MemberCountsAsync(result.Clubs.Select(r => r.Id));
return Ok(new
{
Clubs = result.Clubs.Select(c => ToWireClub(c, counts.GetValueOrDefault(c.Id))),
TotalClubs = result.TotalClubs,
ContinuationToken = string.Empty,
});
}
/// <summary>GET <c>/club?name=Foo</c> — direct name lookup used by
/// the client when resolving a club by display name. Returns the
/// single <c>PLILLKHMNDA</c> Club wire shape.</summary>
[HttpGet("/club")]
public async Task<IActionResult> ClubByName([FromQuery] string? name)
{
if (string.IsNullOrWhiteSpace(name)) return BadRequest(new { error = "missing_name" });
var club = await clubs.GetByNameAsync(name);
if (club is null) return NotFound();
var members = await clubs.MemberCountAsync(club.Id);
return Ok(ToWireClub(club, members));
}
/// <summary>The 2020.12 watch deserialises this as
/// <c>List&lt;String&gt;</c> (per <c>JDJGIBLMFKK.GetPrimaryTags</c>
/// callback signature), so we project just the tag Name column.</summary>
[HttpGet("/club/categoryTags")]
public async Task<IActionResult> CategoryTags()
{
var rows = await clubs.CategoryTagsAsync();
return Ok(rows.Select(t => t.Name));
}
/// <summary>
/// Unread direct-feed announcements grouped by club into
/// <c>HPACLJHLHBG</c> wire rows ({ clubId, LastReadAnnouncementId,
/// announcements }). One row per club that has at least one unread
/// item — clubs with nothing new are omitted so the watch's bell
/// badge counts correctly.
/// </summary>
[HttpGet("/announcements/v2/mine/unread")]
[Authorize]
public async Task<IActionResult> UnreadMine()
{
var pid = Me;
var rows = await clubs.UnreadDirectAsync(pid);
return Ok(GroupByClub(rows));
}
[HttpGet("/announcements/v2/subscription/mine/unread")]
[Authorize]
public async Task<IActionResult> UnreadSubscription()
{
var pid = Me;
var rows = await clubs.UnreadSubscriptionAsync(pid);
return Ok(GroupByClub(rows));
}
/// <summary>GET <c>/subscription/mine/member</c> — list of clubs the
/// caller is subscribed to. Watch deserializes as
/// <c>List&lt;LNIKPLKOBDK&gt;</c>; per the dump at
/// <c>LNIKPLKOBDK.txt:110-124</c> the keys are lowercase
/// <c>accountId</c>, <c>clubId</c>, <c>subscriberCount</c>. One row
/// per subscription. Empty array is legal — the watch just hides the
/// "Subscribed Clubs" section when the list is empty.</summary>
[HttpGet("/subscription/mine/member")]
[Authorize]
public async Task<IActionResult> MySubscriptions()
{
var pid = Me;
var rows = await clubs.MySubscriptionsAsync(pid);
return Ok(rows);
}
/// <summary>
/// GET <c>/subscription/subscriberCount/{accountId}</c> — bare int
/// of how many subscribers an account has. Watch caller is
/// <c>JDJGIBLMFKK.PNFHJBIAPBM(int)</c> at
/// <c>JDJGIBLMFKK.txt:5533,5774</c> with signature
/// <c>IPromise&lt;Int32&gt;</c>. Each Rec Room account has an
/// implicit personal "creator club" so we union both possible
/// counts:
/// * player→player subscriptions where TargetPlayerId == id, and
/// * club subscriptions where ClubId == id (if a club happens to
/// share the id).
/// Anonymous-safe — used by profile cards the watch shows before
/// the viewer is logged in to anyone they're not friends with.
/// </summary>
[HttpGet("/subscription/subscriberCount/{accountId:long}")]
public async Task<IActionResult> SubscriberCount(long accountId)
{
var playerSubs = await clubs.PlayerSubscriberCountAsync(accountId);
var clubSubs = await clubs.ClubSubscriberCountAsync(accountId);
return Ok(playerSubs + clubSubs);
}
/// <summary>
/// POST endpoint the watch hits when the player taps "mark read".
/// Idempotent — the upsert in the service quietly returns if a
/// read-marker already exists.
/// </summary>
[HttpPost("/announcements/club/{clubId:long}/{announcementId:long}/read")]
[Authorize]
public async Task<IActionResult> MarkRead(long clubId, long announcementId)
{
var pid = Me;
await clubs.MarkAnnouncementReadAsync(pid, announcementId);
return Ok(new { Read = true });
}
// ── Single-club reads ─────────────────────────────────────────────
/// <summary>GET <c>/club/{id}</c> — single Club lookup. Wire shape
/// is <c>PLILLKHMNDA</c> (same as the lists). Used by the watch's
/// link-resolver and the "club tile" widget on profile cards.</summary>
[HttpGet("/club/{clubId:long}")]
public async Task<IActionResult> ClubById(long clubId)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
var members = await clubs.MemberCountAsync(clubId);
return Ok(ToWireClub(club, members));
}
/// <summary>GET <c>/club/{id}/details</c> — the central club page
/// payload. Wire shape is <c>PIHMJGCGNLP</c> with required keys
/// <c>Club</c>, <c>CoownerPermissions</c>, <c>ModeratorPermissions</c>,
/// <c>MemberPermissions</c>, <c>MyMembershipType</c> per
/// <c>PIHMJGCGNLP.cs</c>. Returning just the Club shape throws
/// KeyNotFoundException on the deserializer.</summary>
[HttpGet("/club/{clubId:long}/details")]
public Task<IActionResult> ClubDetails(long clubId) => BuildDetailsResponseAsync(clubId);
/// <summary><c>/club/{id}/clubhouse</c> — returns the same
/// <c>PIHMJGCGNLP</c> shape as /details. The watch's clubhouse
/// setter passes an optional roomId; clearing the clubhouse uses
/// DELETE. We accept GET / PUT / DELETE so whichever verb the
/// request-builder chose lands on the handler, then route the
/// roomId into ClubhouseRoomId when present (set/clear) and reply
/// with the refreshed details envelope.</summary>
[HttpGet("/club/{clubId:long}/clubhouse")]
[HttpPut("/club/{clubId:long}/clubhouse")]
[HttpDelete("/club/{clubId:long}/clubhouse")]
public async Task<IActionResult> ClubClubhouse(long clubId, [FromQuery(Name = "roomId")] long? roomId)
{
if (HttpMethods.IsPut(Request.Method) || HttpMethods.IsDelete(Request.Method))
{
try
{
var updated = await clubs.ModifyAsync(clubId, Me, c =>
c.ClubhouseRoomId = HttpMethods.IsDelete(Request.Method) ? null : roomId);
if (updated is null) return NotFound();
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
return await BuildDetailsResponseAsync(clubId);
}
/// <summary>GET <c>/club/{id}/permissions/{role}</c> — bare int of
/// the permission bitmask for a role on this club. Used by the
/// watch's role-permissions screen. Returns 0 for unknown roles so
/// the screen renders an empty checklist instead of erroring.</summary>
[HttpGet("/club/{clubId:long}/permissions/{role:int}")]
public async Task<IActionResult> ClubPermissions(long clubId, int role)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
return Ok(PermissionsForRole(role));
}
// ── Account / created lists ──────────────────────────────────────
/// <summary>GET <c>/account/{playerId}/clubs</c> — list of Clubs
/// the player is a member of. Wire shape is
/// <c>List&lt;PLILLKHMNDA&gt;</c>. Anonymous-safe — used on profile
/// cards.</summary>
[HttpGet("/account/{playerId:long}/clubs")]
public async Task<IActionResult> AccountClubs(long playerId)
{
var rows = await clubs.ClubsForPlayerAsync(playerId);
var counts = await clubs.MemberCountsAsync(rows.Select(r => r.Id));
return Ok(rows.Select(c => ToWireClub(c, counts.GetValueOrDefault(c.Id))));
}
/// <summary>GET <c>/club/account/{playerId}/created</c> — list of
/// Clubs the player created. Wire shape is
/// <c>List&lt;PLILLKHMNDA&gt;</c>. Anonymous-safe.</summary>
[HttpGet("/club/account/{playerId:long}/created")]
public async Task<IActionResult> ClubAccountCreated(long playerId)
{
var rows = await clubs.CreatedByAsync(playerId);
var counts = await clubs.MemberCountsAsync(rows.Select(r => r.Id));
return Ok(rows.Select(c => ToWireClub(c, counts.GetValueOrDefault(c.Id))));
}
// ── Club create / modify ─────────────────────────────────────────
public sealed class CreateClubRequest
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? MainImageName { get; set; }
public string? Category { get; set; }
}
/// <summary>POST <c>/club/create</c> — create a new club + the
/// owner's membership row. Returns the <c>PIHMJGCGNLP</c> envelope
/// (same shape as <c>/club/{id}/details</c>) so the watch's
/// post-create navigation can render the club page without a
/// separate fetch.</summary>
[HttpPost("/club/create")]
[Authorize]
public async Task<IActionResult> ClubCreate()
{
var req = await ReadCreateClubRequestAsync();
var club = await clubs.CreateAsync(Me, req.Name ?? string.Empty,
req.Description, req.MainImageName, req.Category);
if (club is null) return Conflict(new { error = "club_name_taken_or_empty" });
return await BuildDetailsResponseAsync(club.Id);
}
public sealed class ModifyClubRequest
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? Category { get; set; }
public int? Visibility { get; set; }
public int? Joinability { get; set; }
public bool? AllowJuniors { get; set; }
public long? ClubhouseRoomId { get; set; }
}
/// <summary>POST <c>/club/{id}/modify</c> + <c>/modifydetails</c> —
/// the watch sends two different request DTOs to two URLs, but
/// both mutate the same Club row and both expect the
/// <c>PIHMJGCGNLP</c> response. Single handler covers both
/// because the response shape is identical and we only persist
/// fields the request includes.</summary>
[HttpPost("/club/{clubId:long}/modify")]
[HttpPut("/club/{clubId:long}/modify")]
[HttpPost("/club/{clubId:long}/modifydetails")]
[HttpPut("/club/{clubId:long}/modifydetails")]
[Authorize]
public async Task<IActionResult> ClubModify(long clubId, [FromBody] ModifyClubRequest req)
{
try
{
var updated = await clubs.ModifyAsync(clubId, Me, c =>
{
if (!string.IsNullOrWhiteSpace(req.Name)) c.Name = req.Name.Trim();
if (req.Description is not null) c.Description = req.Description;
if (req.Category is not null) c.Category = req.Category;
if (req.Visibility is int v) c.Visibility = v;
if (req.Joinability is int j) c.Joinability = j;
if (req.AllowJuniors is bool aj) c.AllowJuniors = aj;
if (req.ClubhouseRoomId is long ch) c.ClubhouseRoomId = ch;
});
if (updated is null) return NotFound();
return await BuildDetailsResponseAsync(updated.Id);
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
public sealed class MainImageRequest { public string? ImageName { get; set; } }
/// <summary>POST <c>/club/{id}/mainimage</c> — replace the club's
/// MainImageName. Same <c>PIHMJGCGNLP</c> response so the watch can
/// refresh the club page in one round-trip.</summary>
[HttpPost("/club/{clubId:long}/mainimage")]
[HttpPut("/club/{clubId:long}/mainimage")]
[Authorize]
public async Task<IActionResult> ClubMainImage(long clubId, [FromBody] MainImageRequest req)
{
try
{
var updated = await clubs.ModifyAsync(clubId, Me,
c => c.ImageName = req.ImageName ?? string.Empty);
if (updated is null) return NotFound();
return await BuildDetailsResponseAsync(updated.Id);
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
/// <summary>POST <c>/club/{id}/additionalimage/{slot}</c> — the
/// watch's gallery-image slot setter. The 2020 ClubEntity doesn't
/// have additional image slots persisted yet, so we just return
/// the current details unchanged (the gallery renders blank).
/// Hooked up so the request doesn't 404 — populated once a future
/// schema patch lands.</summary>
[HttpPost("/club/{clubId:long}/additionalimage/{slot:int}")]
[HttpPut("/club/{clubId:long}/additionalimage/{slot:int}")]
[Authorize]
public async Task<IActionResult> ClubAdditionalImage(long clubId, int slot, [FromBody] MainImageRequest req)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid();
return await BuildDetailsResponseAsync(clubId);
}
// ── Single-member read ───────────────────────────────────────────
/// <summary>GET <c>/club/{id}/members/{playerId}</c> — single
/// membership row. Wire shape is <c>MFOAODGNGKB</c>: AccountId,
/// ClubId, MembershipType, CreatedAt.</summary>
[HttpGet("/club/{clubId:long}/members/{playerId:long}")]
public async Task<IActionResult> ClubMember(long clubId, long playerId)
{
var row = await clubs.MembershipForAsync(clubId, playerId);
if (row is null) return NotFound();
return Ok(ToWireMembership(row));
}
// ── Member mutations (void / IPromise) ───────────────────────────
/// <summary>POST <c>/club/{id}/members/requesttojoin</c> — caller
/// asks to join. For Open clubs we promote straight to Member; for
/// RequestToJoin we mark Pending; InviteOnly clubs reject.</summary>
[HttpPost("/club/{clubId:long}/members/requesttojoin")]
[HttpPut("/club/{clubId:long}/members/requesttojoin")]
[Authorize]
public async Task<IActionResult> MemberRequestToJoin(long clubId)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
var perms = club.Joinability switch
{
0 => 0, // Open: instant member
1 => 128, // RequestToJoin: pending
_ => -1, // InviteOnly: rejected
};
if (perms == -1) return Forbid();
await clubs.UpsertMembershipAsync(clubId, Me, perms);
return Ok();
}
/// <summary>POST <c>/club/{id}/members/directJoin</c> — same as
/// requesttojoin for an Open club but bypasses the pending flow
/// even on RequestToJoin clubs (used by the "accept invite" flow
/// after a successful invite acceptance).</summary>
[HttpPost("/club/{clubId:long}/members/directJoin")]
[Authorize]
public async Task<IActionResult> MemberDirectJoin(long clubId)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
await clubs.UpsertMembershipAsync(clubId, Me, 0);
return Ok();
}
/// <summary>POST <c>/club/{id}/members/leave</c> — caller leaves
/// the club. Owner cannot leave without first transferring
/// ownership (mirrors the GroupsController.Leave guard).</summary>
[HttpPost("/club/{clubId:long}/members/leave")]
[Authorize]
public async Task<IActionResult> MemberLeave(long clubId)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
if (club.CreatorPlayerId == Me)
return BadRequest(new { error = "owner_must_transfer_first" });
await clubs.RemoveMembershipAsync(clubId, Me);
return Ok();
}
/// <summary>POST <c>/club/{id}/members/invite</c> — caller invites
/// a target player. Pending=128 marker; the invited player's
/// <c>acceptinvite</c> flips it to Member.</summary>
public sealed class MemberInviteRequest
{
public int? PlayerId { get; set; }
public int? AccountId { get; set; }
public int? MembershipType { get; set; }
}
[HttpPost("/club/{clubId:long}/members/invite")]
[HttpPut("/club/{clubId:long}/members/invite")]
[Authorize]
public async Task<IActionResult> MemberInvite(long clubId, [FromBody] MemberInviteRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var target = req.PlayerId ?? req.AccountId ?? 0;
if (target == 0) return BadRequest(new { error = "missing_player_id" });
await clubs.UpsertMembershipAsync(clubId, target, 128); // Pending
return Ok();
}
/// <summary>POST <c>/club/{id}/members/invitemembers</c> — bulk
/// invite. Body is <c>{playerIds: [...]}</c> + optional
/// membership-type override.</summary>
public sealed class InviteMembersRequest
{
public List<int>? PlayerIds { get; set; }
public List<int>? AccountIds { get; set; }
public int? MembershipType { get; set; }
}
[HttpPost("/club/{clubId:long}/members/invitemembers")]
[Authorize]
public async Task<IActionResult> MemberInviteMembers(long clubId, [FromBody] InviteMembersRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
foreach (var pid in ids)
await clubs.UpsertMembershipAsync(clubId, pid, 128);
return Ok();
}
/// <summary>POST <c>/club/{id}/members/acceptinvite</c> — caller
/// accepts an outstanding invite (their own pending row).</summary>
[HttpPost("/club/{clubId:long}/members/acceptinvite")]
[Authorize]
public async Task<IActionResult> MemberAcceptInvite(long clubId)
{
var row = await clubs.MembershipForAsync(clubId, Me);
if (row is null) return NotFound();
if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_invite" });
await clubs.UpsertMembershipAsync(clubId, Me, 0); // Member
return Ok();
}
/// <summary>POST <c>/club/{id}/members/declineinvite</c> — caller
/// rejects an outstanding invite (removes their pending row).</summary>
[HttpPost("/club/{clubId:long}/members/declineinvite")]
[Authorize]
public async Task<IActionResult> MemberDeclineInvite(long clubId)
{
await clubs.RemoveMembershipAsync(clubId, Me);
return Ok();
}
public sealed class MemberTargetRequest
{
public int? PlayerId { get; set; }
public int? AccountId { get; set; }
public int? MembershipType { get; set; }
}
/// <summary>POST <c>/club/{id}/members/acceptrequest</c> — owner /
/// moderator approves a single pending join request, promoting the
/// target's row from Pending to Member.</summary>
[HttpPost("/club/{clubId:long}/members/acceptrequest")]
[Authorize]
public async Task<IActionResult> MemberAcceptRequest(long clubId, [FromBody] MemberTargetRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var target = req.PlayerId ?? req.AccountId ?? 0;
if (target == 0) return BadRequest(new { error = "missing_player_id" });
var row = await clubs.MembershipForAsync(clubId, target);
if (row is null) return NotFound();
if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_request" });
await clubs.UpsertMembershipAsync(clubId, target, 0);
return Ok();
}
public sealed class BulkTargetRequest
{
public List<int>? PlayerIds { get; set; }
public List<int>? AccountIds { get; set; }
}
[HttpPost("/club/{clubId:long}/members/acceptrequests")]
[Authorize]
public async Task<IActionResult> MemberAcceptRequests(long clubId, [FromBody] BulkTargetRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
foreach (var pid in ids)
{
var row = await clubs.MembershipForAsync(clubId, pid);
if (row is null || (row.Permissions & 128) == 0) continue;
await clubs.UpsertMembershipAsync(clubId, pid, 0);
}
return Ok();
}
[HttpPost("/club/{clubId:long}/members/denyrequest")]
[Authorize]
public async Task<IActionResult> MemberDenyRequest(long clubId, [FromBody] MemberTargetRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var target = req.PlayerId ?? req.AccountId ?? 0;
if (target == 0) return BadRequest(new { error = "missing_player_id" });
await clubs.RemoveMembershipAsync(clubId, target);
return Ok();
}
[HttpPost("/club/{clubId:long}/members/denyrequests")]
[Authorize]
public async Task<IActionResult> MemberDenyRequests(long clubId, [FromBody] BulkTargetRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
foreach (var pid in ids)
await clubs.RemoveMembershipAsync(clubId, pid);
return Ok();
}
[HttpPost("/club/{clubId:long}/members/remove")]
[Authorize]
public async Task<IActionResult> MemberRemove(long clubId, [FromBody] MemberTargetRequest req)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid();
var target = req.PlayerId ?? req.AccountId ?? 0;
if (target == 0) return BadRequest(new { error = "missing_player_id" });
if (target == club.CreatorPlayerId)
return BadRequest(new { error = "cannot_remove_owner" });
await clubs.RemoveMembershipAsync(clubId, target);
return Ok();
}
/// <summary>POST <c>/club/{id}/members/ban</c> — kick + prevent
/// re-join. We persist with perms=256 as a ban marker bit so the
/// request-to-join flow can refuse them later.</summary>
[HttpPost("/club/{clubId:long}/members/ban")]
[Authorize]
public async Task<IActionResult> MemberBan(long clubId, [FromBody] MemberTargetRequest req)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid();
var target = req.PlayerId ?? req.AccountId ?? 0;
if (target == 0) return BadRequest(new { error = "missing_player_id" });
if (target == club.CreatorPlayerId)
return BadRequest(new { error = "cannot_ban_owner" });
await clubs.UpsertMembershipAsync(clubId, target, 256); // Ban marker
return Ok();
}
[HttpPost("/club/{clubId:long}/members/unban")]
[Authorize]
public async Task<IActionResult> MemberUnban(long clubId, [FromBody] MemberTargetRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var target = req.PlayerId ?? req.AccountId ?? 0;
if (target == 0) return BadRequest(new { error = "missing_player_id" });
await clubs.RemoveMembershipAsync(clubId, target);
return Ok();
}
/// <summary>POST <c>/club/{id}/members/changetype</c> — promote /
/// demote a member's role. Body: PlayerId + MembershipType (the
/// wire enum, NOT the perms int — we translate via
/// <see cref="ClubService.PermsFromMembershipType"/>).</summary>
[HttpPost("/club/{clubId:long}/members/changetype")]
[HttpPut("/club/{clubId:long}/members/changetype")]
[Authorize]
public async Task<IActionResult> MemberChangeType(long clubId, [FromBody] MemberTargetRequest req)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
if (club.CreatorPlayerId != Me) return Forbid(); // Owner-only — perms transitions are sensitive
var target = req.PlayerId ?? req.AccountId ?? 0;
var newType = req.MembershipType ?? ClubService.MembershipTypeMember;
if (target == 0) return BadRequest(new { error = "missing_player_id" });
if (target == club.CreatorPlayerId)
return BadRequest(new { error = "cannot_change_owner_role" });
await clubs.UpsertMembershipAsync(clubId, target, ClubService.PermsFromMembershipType(newType));
return Ok();
}
// ── members/bulk + clubreporting ─────────────────────────────────
/// <summary>GET <c>/members/bulk?clubId=N&amp;playerIds=A&amp;playerIds=B</c>
/// — bulk membership lookup. Wire shape is
/// <c>List&lt;JHMMGLNJHIB&gt;</c> with each entry carrying
/// <c>AccountId</c> + <c>MembershipType</c>.</summary>
[HttpGet("/members/bulk")]
[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)
&& (form is null || !long.TryParse(form["clubId"].ToString(), out clubId)))
return BadRequest(new { error = "missing_club_id" });
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)
.Where(n => n != 0)
.ToList();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await clubs.MembershipsBulkAsync(clubId, ids);
return Ok(rows.Select(m => new
{
AccountId = (int)m.PlayerId,
MembershipType = ClubService.MembershipTypeFromPerms(m.Permissions),
}));
}
public sealed class ClubReportRequest
{
public long? ClubId { get; set; }
public int? Category { get; set; }
public string? Details { get; set; }
}
/// <summary>POST <c>/api/clubreporting/v1/report</c> — file a
/// moderation report against a club. Persists to the same
/// <see cref="DorkNet.Server.Data.Entities.ReportEntity"/> table
/// the admin queue reads from; the controller prefixes the message
/// with "[club {id}]" so admins can tell club reports apart from
/// player reports without a schema change.</summary>
[HttpPost("/api/clubreporting/v1/report")]
[Authorize]
public async Task<IActionResult> ClubReport([FromBody] ClubReportRequest req)
{
if (req.ClubId is not long cid) return BadRequest(new { error = "missing_club_id" });
await clubs.AddClubReportAsync(cid, Me, req.Category ?? 0, req.Details ?? string.Empty);
return Ok();
}
// ── Announcements (per-club feed + per-announcement) ─────────────
/// <summary>GET <c>/announcements/club/{clubId}</c> — full
/// announcement feed for the club. Wire shape mirrors the unread
/// rollup (<c>HPACLJHLHBG</c> envelope) but holds every visible
/// announcement, newest first.</summary>
[HttpGet("/announcements/club/{clubId:long}")]
public async Task<IActionResult> AnnouncementsForClub(long clubId)
{
var rows = await clubs.AnnouncementsForClubAsync(clubId);
return Ok(GroupByClub(rows));
}
/// <summary>GET <c>/announcements/club/{clubId}/{aid}</c> — single
/// announcement read. Returns the inner Announcement wire object
/// (<c>NFEMLMAFFIP</c>) without the per-club envelope.</summary>
[HttpGet("/announcements/club/{clubId:long}/{announcementId:long}")]
public async Task<IActionResult> AnnouncementSingle(long clubId, long announcementId)
{
var row = await clubs.AnnouncementAsync(clubId, announcementId);
if (row is null) return NotFound();
return Ok(ToWireAnnouncement(row));
}
/// <summary><c>/announcements/club/{clubId}/{aid}</c> delete —
/// register DELETE, PUT, and POST so the watch's mutation verb
/// reaches the same handler regardless of how its request-builder
/// shaped the call. The service is idempotent on missing rows so
/// double-fires are safe.</summary>
[HttpDelete("/announcements/club/{clubId:long}/{announcementId:long}")]
[HttpPost("/announcements/club/{clubId:long}/{announcementId:long}")]
[HttpPut("/announcements/club/{clubId:long}/{announcementId:long}")]
[Authorize]
public async Task<IActionResult> AnnouncementDelete(long clubId, long announcementId)
{
if (!await clubs.DeleteAnnouncementAsync(clubId, announcementId, Me)) return Forbid();
return Ok();
}
// ── Wire-shape mappers ──────────────────────────────────────────
/// <summary>
/// Build the PIHMJGCGNLP envelope around a single club row. Used
/// by /club/{id}/details, /clubhouse, and every modify* handler.
/// MyMembershipType is derived from the caller's row (or
/// NotAMember sentinel when the caller has no row).
/// </summary>
private async Task<IActionResult> BuildDetailsResponseAsync(long clubId)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
var memberCount = await clubs.MemberCountAsync(clubId);
var pid = ControllerBaseExtensions.CurrentPlayerId(User);
var myType = ClubService.MembershipTypeNotMember;
if (pid is long me)
{
var membership = await clubs.MembershipForAsync(clubId, me);
if (membership is not null)
myType = ClubService.MembershipTypeFromPerms(membership.Permissions);
// Owner shortcut — the creator row may not exist (legacy data) but the
// creator should always read as Owner.
if (club.CreatorPlayerId == me) myType = ClubService.MembershipTypeOwner;
}
return Ok(new
{
// PIHMJGCGNLP deserializer at PIHMJGCGNLP.txt:143-194 reads
// 7 fields off the response dict, not the 5 the parser-keys
// doc lists. Missing any throws KeyNotFoundException —
// CustomTags (List<String>) and AdditionalImages
// (List<FKFAKOKIEGN>) need to be present even if empty.
Club = ToWireClub(club, memberCount),
CustomTags = Array.Empty<string>(),
AdditionalImages = Array.Empty<object>(),
CoownerPermissions = PermissionsForRole(ClubService.MembershipTypeCoOwner),
ModeratorPermissions = PermissionsForRole(ClubService.MembershipTypeModerator),
MemberPermissions = PermissionsForRole(ClubService.MembershipTypeMember),
MyMembershipType = myType,
});
}
/// <summary>
/// Bitmask of capabilities a given role has on a club. The bits
/// are arbitrary (the wire enum <c>JHEEFBMODPG</c> is opaque in
/// the readable dump) but stay consistent so the watch's
/// role-permissions screen renders the same checklist on every
/// refresh. Owner / Coowner can manage all; Moderator can manage
/// content; Member can read.
/// </summary>
private static int PermissionsForRole(int membershipType) => membershipType switch
{
ClubService.MembershipTypeOwner => 0x7FFF, // everything
ClubService.MembershipTypeCoOwner => 0x7FFE, // all except disband
ClubService.MembershipTypeModerator => 0x00FF, // moderation only
ClubService.MembershipTypeMember => 0x0007, // post + read + react
_ => 0x0001, // read-only
};
private static object ToWireMembership(ClubMembershipEntity row) => new
{
AccountId = (int)row.PlayerId,
ClubId = row.ClubId,
MembershipType = ClubService.MembershipTypeFromPerms(row.Permissions),
CreatedAt = row.JoinedAt,
};
private static ClubEntity EmptyClub() => new()
{
Id = 0,
Name = string.Empty,
Description = string.Empty,
CreatorPlayerId = 0,
ImageName = string.Empty,
};
/// <summary>
/// Project a <see cref="ClubEntity"/> to the 2020.12 Club wire
/// shape. JSON keys + types match the
/// <c>PLILLKHMNDA.Deserialize</c> body in ISIL exactly:
/// ClubId/State/CreatorAccountId/Visibility/Joinability/MemberCount
/// are ints, Name/Description/MainImageName/Category are strings,
/// AllowJuniors/IsRRO are bools, ClubhouseRoomId is nullable long.
/// </summary>
private static object ToWireClub(ClubEntity c, int memberCount) => new
{
ClubId = c.Id,
c.Name,
c.Description,
MainImageName = c.ImageName,
State = c.State,
CreatorAccountId = (int)c.CreatorPlayerId,
Category = c.Category,
Visibility = c.Visibility,
Joinability = c.Joinability,
AllowJuniors = c.AllowJuniors,
MemberCount = memberCount,
IsRRO = c.IsRRO,
ClubhouseRoomId = c.ClubhouseRoomId,
ClubType = c.ClubType,
};
/// <summary>
/// Project a <see cref="ClubAnnouncementEntity"/> to the 2020.12
/// Announcement wire shape. Keys + types per the
/// <c>NFEMLMAFFIP.Deserialize</c> body in ISIL.
/// </summary>
private static object ToWireAnnouncement(ClubAnnouncementEntity a) => new
{
AnnouncementId = a.Id,
CreatorAccountId = (int)a.AuthorPlayerId,
ClubId = a.ClubId,
a.Title,
a.Body,
a.ImageName,
a.CreatedAt,
Meta = string.Empty,
};
private async Task<CreateClubRequest> ReadCreateClubRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new CreateClubRequest
{
Name = FirstFormValue(form, "name", "Name"),
Description = FirstFormValue(form, "description", "Description"),
MainImageName = FirstFormValue(form,
"mainImageName", "MainImageName", "imageName", "ImageName"),
Category = FirstFormValue(form, "category", "Category"),
};
}
try
{
return await Request.ReadFromJsonAsync<CreateClubRequest>(JsonOptions)
?? new CreateClubRequest();
}
catch (JsonException)
{
return new CreateClubRequest();
}
}
private static string? FirstFormValue(IFormCollection form, params string[] names)
{
foreach (var name in names)
{
var value = form[name].ToString();
if (!string.IsNullOrWhiteSpace(value)) return value;
}
return null;
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
/// <summary>
/// Group a flat list of announcements into the per-club
/// <c>HPACLJHLHBG</c> envelope shape. Uses
/// <see cref="Dictionary{TKey, TValue}"/> for the outer object
/// because the wire mixes lowercase and PascalCase keys for the
/// same record (<c>clubId</c> + <c>LastReadAnnouncementId</c> +
/// <c>announcements</c>) and anonymous types can't model that.
/// </summary>
private static List<Dictionary<string, object?>> GroupByClub(
List<ClubAnnouncementEntity> rows)
{
return rows
.GroupBy(a => a.ClubId)
.Select(g =>
{
var ordered = g.OrderByDescending(a => a.CreatedAt).ToList();
return new Dictionary<string, object?>
{
["clubId"] = g.Key,
["LastReadAnnouncementId"] = (long?)null,
["announcements"] = ordered.Select(ToWireAnnouncement).ToList(),
};
})
.ToList();
}
}

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