Compare commits

..

62 Commits

Author SHA1 Message Date
Alexa 94781fd11a fix(auth): don't read the provider's userinfo endpoint by default
Cloudflare Access answers userinfo without a Content-Type, which the
OpenID Connect handler rejects ("Unknown response type"), so every
sign-in failed after the code exchange. The ID token already carries
sub and email; DorkNet:SignIn:UseUserInfoEndpoint turns the call back
on for providers that need it.
2026-10-25 17:24:25 +02:00
Alexa 99f8c2e016 Merge pull request #73 from DorkSquadRR/feat/external-sign-in
feat(auth): external sign-in for the website and admin panel
2026-10-25 17:00:04 +02:00
Alexa d4ce92f1b4 test(auth): give sign-in test players sequential ids 2026-10-25 16:55:57 +02:00
Alexa c7a087eea1 fix(site): parse return paths as URLs; refresh account state after changes
- localPath resolves the path against this origin, so control characters
  or backslashes can't turn it into another site's address.
- link, sign-out and unlink drop the cached account answer.
2026-10-25 15:56:45 +02:00
Alexa 5bfa463528 fix(auth): show only known sign-in error codes
Sign-in errors arrive in the query string, so unknown codes get a fixed
message instead of being echoed into the banner.
2026-10-25 15:54:51 +02:00
Alexa 891ec89899 fix(auth): encrypt stored cookie keys; tighten the link and sign-out flows
- Data-protection keys are encrypted (AES-256-GCM, key derived from the
  server secret) before they reach the DataProtectionKeys table.
- /link only follows local return paths and keeps the form when a
  signed-in visitor has an identity waiting to be linked.
- A failed sign-out says so instead of reloading as if it worked.
2026-10-25 15:52:29 +02:00
Alexa f0e4679887 feat(auth): external sign-in for the website and admin panel
Players sign in to the website, and admins to the admin panel, with an
OpenID Connect provider (DorkNet:SignIn:*). The first sign-in links the
provider identity (issuer + subject) to a DorkNet account with that
account's password; after that the provider sign-in is enough.

- Cookie sessions per host, keys persisted in the database so redeploys
  keep people signed in; unlink, deletion or a ban ends the session.
- Link attempts limited to 5 failures per 15 minutes per identity and
  per username.
- Admin panel: "Sign in with ..." exchanges the session for the usual
  admin tokens via GET /api/admin/v1/sso (IsAdmin still required).
- Downloads: optional DorkNet:Downloads:RequireSignIn serves the
  manifest and files only to signed-in players; the Downloads and Quest
  pages offer sign-in instead.
- Website: sign-in in the header, /link and /account pages.

The game client's login is unchanged.
2026-10-25 15:47:56 +02:00
Alexa 260f6605fc fix(admin-ui): derive the apex and subdomain style from the admin host
The admin SPA special-cased one deployment's hostnames to choose between
dot (img.<apex>) and hyphen (img-<apex>) subdomains. The server always
mounts the SPA on its own admin subdomain, so read both from that:
admin.<apex> means dot style, admin-<apex> hyphen style.
2026-10-25 14:42:10 +02:00
Alexa 12da61407d Merge pull request #72 from DorkSquadRR/ci/client-only-push-builds
ci: build on push only for client-facing changes
2026-10-25 14:32:39 +02:00
Alexa 5b93af4ba9 Merge pull request #71 from DorkSquadRR/chore/dotnet-10
chore: move the server, services and tools to .NET 10
2026-10-25 14:32:17 +02:00
Alexa 51104464f0 Merge pull request #70 from DorkSquadRR/fix/2023-room-instances-publish-permissions
fix: 2023 room instances, publish, role permissions, co-owner invites and sub-rooms
2026-10-25 14:21:44 +02:00
Alexa 35768c05ed Merge pull request #69 from DorkSquadRR/fix/postgres-bootstrap
fix: Postgres bootstrap on a fresh database and with replicas booting together
2026-10-25 14:21:21 +02:00
Alexa 0a5c3a9a64 Merge remote-tracking branch 'origin/fix/postgres-bootstrap' into chore/dotnet-10
# Conflicts:
#	DorkNet.Server/Startup/DatabaseBootstrap.cs
2026-10-25 14:07:30 +02:00
Alexa fd93b4b028 Merge remote-tracking branch 'origin/fix/postgres-bootstrap' into fix/2023-room-instances-publish-permissions
# Conflicts:
#	DorkNet.Server/Startup/DatabaseBootstrap.cs
2026-10-25 14:03:23 +02:00
Alexa 8be0f90dbc fix: sub-room id sequence, restore sync, presence index TTL, permission races
From the local CodeRabbit review:
- Scene ids: the repair and room clone assigned max + 1, which never
  advanced the Postgres identity sequence (live: sequence 3412, max id 3466),
  so the next add / clone sub-room would collide on the primary key. The
  repair now moves the sequence past the highest id at startup, and both
  let the database assign ids.
- Room-level restores (datahistory/restore and the admin restore) now also
  restore the entry sub-room, whose blob room details serve first.
- TouchRoom refreshes the room's presence index too, so players kept alive
  only by heartbeats no longer drop out of the instance browser after 20 min.
- Sub-room permission saves match cells exactly (as the unique index does)
  and retry when a concurrent editor inserted the same new cell.
2026-10-25 13:59:30 +02:00
Alexa d0d26c9821 fix: wait for the bootstrap lock without a command timeout; safe unlock
From the local CodeRabbit review:
- A replica waiting for the lock used the default 30 s command timeout and
  failed to boot while another replica seeded a fresh database. Wait with
  no timeout, logging that it is waiting.
- An unlock failure in the finally (e.g. the connection already broken)
  replaced the real bootstrap error and skipped closing the connection.
  Log it instead, and always close.
2026-10-25 13:52:20 +02:00
Alexa 503d838fd1 ci: treat .markdown like .md in push builds
From the local CodeRabbit review: a Markdown-only push using the .markdown
extension still rebuilt Quest. Exclude both extensions in the push planner
and in the documented job path filter.
2026-10-25 13:51:26 +02:00
Alexa bb22675a91 fix: hold the Postgres bootstrap lock across schema creation and seeding
Booting several replicas against one fresh Postgres database left all but
one dead on startup: the advisory lock only covered EnsureCreated, so the
seeders (system account, rooms, playlists, store, ...) raced and hit
duplicate keys. Take a session-level advisory lock for the whole bootstrap
and release it explicitly (pooled connections would otherwise keep it).
The upgrades and seeds both providers run move into
RunUpgradesAndSeedsAsync.
2026-10-25 13:01:43 +02:00
Alexa c1000c82aa fix: let EF own the Postgres bootstrap transaction (EF Core 10)
EF Core 10 runs EnsureCreated through the migration executor, which begins
its own transaction unless EF already has one. The bootstrap held the
advisory lock in a transaction begun on the raw connection, invisible to
EF, so a fresh Postgres database failed at startup with "A transaction is
already in progress". Begin it through db.Database and take the lock with
ExecuteSqlRaw so EnsureCreated runs inside it.
2026-10-25 13:00:16 +02:00
Alexa 9076fb9fae fix: let EF own the Postgres bootstrap transaction (EF Core 10)
EF Core 10 runs EnsureCreated through the migration executor, which begins
its own transaction unless EF already has one. The bootstrap held the
advisory lock in a transaction begun on the raw connection, invisible to
EF, so a fresh Postgres database failed at startup with "A transaction is
already in progress". Begin it through db.Database and take the lock with
ExecuteSqlRaw so EnsureCreated runs inside it.
2026-10-25 12:56:13 +02:00
Alexa cf7a021898 fix: repair a missing entry sub-room under the bootstrap lock
From the local CodeRabbit review:
- Repair rooms that lack an OrderIndex 0 (entry) sub-room even when they
  have other sub-rooms, not only rooms with none.
- Every service runs the database bootstrap at startup, so on Postgres
  replicas booting together could hand out the same scene ids (max + 1)
  and one would fail startup on the primary key. Run the repair in a
  transaction holding the bootstrap's advisory lock.
2026-10-25 12:52:53 +02:00
Alexa e29707deeb ci: keep the checkout so the path filter can skip non-client pushes
The job's path filter needs the last build's checkout to compare against.
The pipeline deleted the whole workspace after each run, so Jenkins polled
with "Working Directory does not exist", assumed changes, and started a
run for every push (builds #6 and #7: zero changed files). Delete only
out/ and quest-work/ instead.
2026-10-25 12:49:09 +02:00
Alexa 3b332a6476 fix: let EF own the Postgres bootstrap transaction (EF Core 10)
EF Core 10 runs EnsureCreated through the migration executor, which begins
its own transaction unless EF already has one. The bootstrap held the
advisory lock in a transaction begun on the raw connection, invisible to
EF, so a fresh Postgres database failed at startup with "A transaction is
already in progress". Begin it through db.Database and take the lock with
ExecuteSqlRaw so EnsureCreated runs inside it.
2026-10-25 12:32:09 +02:00
Alexa d295fd5c37 chore: Swashbuckle 10.3.1 for ASP.NET Core 10
Swashbuckle 7.3.1 is built against Microsoft.OpenApi 1.x; ASP.NET Core 10
brings Microsoft.OpenApi 2.x, and SwaggerGenerator.GetSwagger then fails to
load (TypeLoadException), which broke 105 of the server tests.
2026-10-25 12:22:38 +02:00
Alexa 8e12db660b chore: move the server, services and tools to .NET 10
.NET 8 and 9 reach end of support on 2026-11-10; .NET 10 is the LTS release
(supported until November 2028).

- net9.0 -> net10.0 for every server, service, gateway, test and tool project
- ASP.NET Core / EF Core / SignalR Redis 10.0.13, Npgsql EF 10.0.3,
  Serilog.AspNetCore 10.0.0
- Dockerfile / Dockerfile.service: sdk and aspnet 10.0 images
- release workflow: setup-dotnet 10.0.x
- Jenkins build agent: dotnet-sdk-10.0 (was 8.0); the ClientMod still
  targets net6.0, which MelonLoader loads
- README / architecture docs, .gitignore data paths

DorkNet.AdminMobile (MAUI) stays on net9.0 for now: building it needs the
Android/iOS workloads.
2026-10-25 12:12:57 +02:00
Alexa bafb380fc8 ci: build on push only for client-facing changes
Push builds used to rebuild both platforms for any Jenkinsfile or ci/jenkins/
change, and every build publishes to the downloads page. Now only
DorkNet.ClientMod/ and tools/build_pc.py (PC) or DorkNet.Quest/ (Quest)
changes rebuild, and Markdown never counts; CI-only changes take a manual
run. The README gives the matching Git path filter for the job, so other
pushes don't start a run at all.
2026-10-25 12:11:08 +02:00
Alexa 78c64d117a Merge remote-tracking branch 'origin/march-2023-03-21' into fix/2023-room-instances-publish-permissions 2026-10-25 11:59:05 +02:00
Alexa e7c0b076f2 Merge pull request #68 from DorkSquadRR/ci/jenkins-jdk25 2026-10-25 11:58:51 +02:00
Alexa 0a3bad7c6c Merge pull request #67 from DorkSquadRR/feat/quest-install-latest-build 2026-10-25 11:57:05 +02:00
Alexa 1718912a07 Merge pull request #66 from DorkSquadRR/ci/downloads-page 2026-10-25 11:56:51 +02:00
Alexa 5acad4c9e2 Merge pull request #65 from DorkSquadRR/ci/jenkins-jdk25 2026-10-25 11:56:38 +02:00
Alexa ec88a39750 ci: leave the downloads page to the DorkNet site
The server now offers the published builds on its own site (Downloads page,
Latest build on the Quest page), reading the bucket on the internal network.
Drop the standalone index.html and DORKNET_SITE_URL; the bucket needs no
public route or access app of its own.
2026-10-25 11:53:23 +02:00
Alexa 6e5b011059 ci: publish client builds to a downloads page under player-facing names
Each run now stages what it built as DorkNet-<release>-PC-Full.zip,
-PC-Patch.zip, -Quest.xapk and -Quest-Installer.zip, writes their sizes and
SHA-256 to a manifest, and uploads them with a static page to an S3 bucket
served read-only (ci/downloads). The build scripts keep their own names,
which the launcher depends on; only the published copies are renamed.

PUBLISH_DOWNLOADS (on by default) turns the upload off per run. Jenkins
archives the same renamed files instead of the raw build output.
2026-10-25 11:53:23 +02:00
Alexa 2e09042344 ci: run the agent with an init process; document the controller upgrade
- init: true on the agent so processes left behind by build tools are reaped,
  as the inbound-agent image asks for.
- Redeploying an older controller from docker-compose.yml upgrades Jenkins
  and moves it from Java 17 to 25: say to back up jenkins_home first, update
  plugins that fail to load, and run the job once before relying on it.
2026-10-25 11:53:21 +02:00
Alexa 9c5b574d6e ci: keep docker-compose.yml as the controller; move the agent to its own file
Turning ci/jenkins/docker-compose.yml from the controller into the agent meant
that redeploying a Jenkins set up from the old file would replace the
controller with an agent. Keep that file as the controller instead: the same
jenkins service and jenkins_home volume, now on the stock Java 25 image (its
old build context, ci/jenkins/Dockerfile, is the agent image now). The agent
moves to docker-compose.agent.yml. This replaces the previous commit's warning.
2026-10-25 11:50:09 +02:00
Alexa 29aca0e88c docs(ci): warn against redeploying an old controller app from the agent compose
ci/jenkins/docker-compose.yml used to define the Jenkins controller and now
defines only the agent. Redeploying an application that was set up from the
old file would replace the controller with an agent. Say so in the file, and
add an upgrade path to the README: move the controller to its own service on
the same data volume first, then deploy the agent as a new application.
2026-10-25 11:47:49 +02:00
Alexa eb73f3c484 fix: serve downloads only on the site host and select the clicked Quest build
Review follow-ups:
- The downloads routes now answer only on the apex (the site's host) and 404
  on every other accepted host, so the game's open API/CDN subdomains can't
  serve the builds.
- Install on Quest passes its own file to the Quest page instead of always
  picking the newest release.
- Waiting for the bucket's headers is limited to 30 s; the body still streams
  without a limit.
- Dispose the upstream request message; use LINQ in FindFile.
2026-10-25 11:06:43 +02:00
Alexa cc7f77b7de feat(site): Downloads page and Quest install from the latest build
When the server is pointed at the bucket CI publishes builds to
(DorkNet:Downloads:BaseUrl), the site lists them on a new Downloads page and
the Quest page offers "Latest build" next to "Choose file".

The server reads manifest.json and the files over HTTP on the internal network
and streams them through with Range support (DownloadsController); only files
the manifest lists are served. The xapk reader now reads from a byte source,
either the picked File or the server URL via Range requests, so the browser
installer streams the APK and OBB to the headset without downloading the
package first. Unset, both features stay hidden.
2026-10-25 10:55:43 +02:00
Alexa 19402459a9 ci: deploy the agent with base directory ci/jenkins
The base directory is the compose build context; from the repo root the
build used the server's Dockerfile instead of ci/jenkins/Dockerfile.
2026-10-25 01:21:07 +03:00
Alexa 3266ce47f3 ci: keep the agent's Jenkins URL and node secret runtime-only
Coolify interpolates the compose file during the image build with only
build-time variables. Marking JENKINS_URL and JENKINS_SECRET required
(${VAR:?}) made the build fail when they are runtime-only, and making them
build-time would pass the node secret as a build argument.
2026-10-25 01:18:35 +03:00
Alexa cf3e6ea025 ci: run client builds on a Java 25 build agent instead of the controller
The toolchain image becomes a Jenkins inbound agent (jenkins/inbound-agent,
Java 25) labelled dorknet-builder. Jenkins itself stays on the stock image
and runs no builds, which also clears the built-in-node warning.

- Dockerfile: inbound-agent base; restic cache on the agent's own volume
- docker-compose.yml: the agent as a Coolify Docker Compose application,
  reaching Jenkins over WebSocket on the predefined Coolify network
- Jenkinsfile: agent { label 'dorknet-builder' }
- README: Java 25 for both sides (Java 21 support ends Sep 2027, Java 17
  is unsupported), restart policy, node setup and Coolify deployment
2026-10-25 01:14:17 +03:00
Alexa bf0f09ce84 fix: answer 2023 room and playlist mutations with the success/value/error envelope
Every rooms-client mutation (roles, invite, accessibility/publish, room
fields, sub-room ops, permissions, playlists) goes through
NLDBPDCNNCF.BDBHCEGKGDC<TResult>, the same helper and instantiation as
"clone room", which reads {success, value, error}. A bare room-details body
has no success, so the client rejected it with the message-less
"Exception of type 'CEMNLBKJABA' was thrown" (seen on co-owner invite)
although the server had applied the change.

RecNetEnvelopeAttribute wraps those handlers: 2xx becomes
{success:true, value, error:""}, refusals become 200
{success:false, value:null, error:<message>}.
2026-10-24 23:48:00 +03:00
Alexa fd2153d82b fix: 2023 room instances, publish, role permissions and co-owner invites
- Room roles: list the creator as Creator (255), not CoOwner (30). The
  client takes the local role from the Roles list and only lets a player
  edit roles below their own (Creator excepted), so the creator could not
  edit Co-Owner and Creator permissions. Enforce the role ladder on the
  server (RoomRoleRules), let invitees accept (PUT roles/{me} role=30) and
  reject (PUT roles/{me}/invite role=0), send message Types 62/60/61, and
  push RoomUpdate after every role change.
- Permissions: persist PUT subrooms/{sub}/permissions cells in a new
  RoomRolePermissions table, serve them from photon_access_token (plus a
  roomserver/ twin) and push PhotonAccessToken to the sub-room.
- Publish: player-owned rooms are never IsRRO on the wire; the client
  hides Publish and every room setting for an RRO.
- Sub-rooms: wire SubRoomId = OrderIndex + 1 (the client will not load a
  sub-room with id 0), new rooms always get a named Home sub-room, and a
  startup pass repairs rooms without one.
- Instance browser: per-room Redis presence index, live players only,
  private instances for the people allowed in, real IsFull.
- Room/sub-room settings: creator or accepted co-owner everywhere.
2026-10-24 23:22:53 +03:00
Alexa 0839d932ce Merge pull request #64 from DorkSquadRR/fix/login-sign-in-instead-of-auto-create
fix: open the sign-in page instead of creating a new account on a fresh client
2026-10-24 23:21:12 +03:00
Alexa 9523525cb8 fix: register the title-screen login patch so it actually applies on PC
GetPatchMethod only resolves patch methods from a fixed list of holder
classes; TitleScreenLoginPatches was missing, so the prefix failed with
[patch-fail] and a fresh PC client still looped on account creation.
Also log the real exception behind a failed sign-in redirect instead of
the TargetInvocationException wrapper.
2026-10-24 22:50:54 +03:00
Alexa 3880745efd fix: open the sign-in page instead of creating a new account on a fresh client
With no remembered account, the 2023 title screen creates a brand-new
account (grant_type create_account). On a server with sign-ups off that
fails and the title screen retries forever. PC (ClientMod) and Quest
now open the sign-in page instead, so players log into their existing
DorkNet account. AutoCreateAccount = true keeps Rec Room's behaviour.

Also replaces a literal NUL byte in the Quest config loader with '\0'.
2026-10-24 22:45:57 +03:00
Alexa 31d5a022c8 Merge pull request #62 from DorkSquadRR/ci/jenkins-client-builds
Build the PC and Quest clients on Jenkins
2026-10-24 20:37:05 +03:00
Alexa 83b896ffaf fix: drop an unused import and make the build scripts' exits explicit 2026-10-24 20:30:40 +03:00
Alexa fcc734726a Merge pull request #61 from DorkSquadRR/fix/notify-2023-contract
Notification pushes now match what the 2023 client reads
2026-10-24 20:28:51 +03:00
Alexa b1c1239165 ci: build the PC and Quest clients on Jenkins
A Jenkinsfile builds, on push (only what changed) or on demand:
- the PC patcher zip (MelonLoader, the mod, config template, Steam stub),
- the full PC client (stock build + patcher + config),
- the Quest APK and .xapk.

Stock builds come out of the restic build archive on the FTP: restic
through its rclone backend, picked by snapshot tag per run and cached.
The FTP login and restic password are Jenkins credentials, and the
repository folders are environment settings, so nothing about the
archive is fixed in the repo.

ci/jenkins/Dockerfile is the Jenkins image with the toolchain added
(.NET SDK, Android NDK and build-tools, apktool, Python with LIEF,
restic, rclone), for the existing Jenkins service.

The build scripts move from PowerShell to Python so they run the same
on Windows, Linux and in CI:
- DorkNet.Quest/scripts/build.py replaces build.ps1 + package.ps1; it
  can use a committed hook table (checked against the APK's
  libil2cpp.so) instead of dump.cs, and takes a persistent keystore.
- tools/build_pc.py replaces build-patcher-zip.ps1 (now with the
  2023 install layout) and adds the full-client build.

Setup steps are in ci/jenkins/README.md.
2026-10-24 20:26:42 +03:00
Alexa afad9bbc5a fix: address review on the notification pushes
- Force-join's RoomUpdate carries the room's role grants, so it no
  longer wipes them from the client's cached room.
- The consumable push reports IsTransferable the same as the inventory
  list does.
- A rejected private-instance join sends the owner at most one "wants
  to join" message per requester and instance every 5 minutes.
- SubscribeToPlayers ignores sets over 5,000 ids and keeps the current
  subscriptions.
- The subscriber exclusion list looks up online friends' connections in
  one pipelined Redis batch instead of one call per friend.
2026-10-24 20:25:51 +03:00
Alexa e33145307f fix: notification pushes now match what the 2023 client reads
Most pushes were still shaped for the 2020 watch, so the 2023 client
parsed them as empty or dropped them without any error.

- RelationshipChanged carries the relationship row, to both players, on
  friend request / accept / remove / add and on favorite / mute / ignore.
  Friend requests and accepts also land in the notifications inbox.
- ModerationKick uses the client's flat moderation block, with the
  reason in VoteKickReason (the only field the dialog shows).
- Bans, account deletion, password resets and second-session takeover
  are a Logout with a numeric reason instead of a kick to the dorm; the
  old string reason failed the client's parse.
- SubscribeToPlayers is honoured: each connection joins a presence group
  per subscribed player, and presence (only presence) reaches subscribers
  as well as friends. The hub allows 180 s of client silence, since the
  client reconnects at most 3 times per login.
- Admin broadcast sends its text as a coach message and can start the
  client's own maintenance countdown; ServerMaintenance only reads
  StartsInMinutes.
- ConsumableMapping, StorefrontBalance, gift, player event, level-up,
  RoomUpdate, profile-image and PlayerLeftChat pushes carry the client's
  DTOs. "Join rejected" is the client's RequestGameInvite message.

Contract in docs/recroom-2023-notifications.md; push frames are checked
by NotificationContractTests through a capturing hub context.
2026-10-24 19:50:05 +03:00
Alexa 38335c06a9 Merge pull request #60 from DorkSquadRR/fix/room-clone-envelope
Creating a room no longer fails with a blank "Failed to copy room"
2026-10-17 20:21:43 +03:00
Alexa 5ab28b1d4f fix: creating a room failed with a blank "Failed to copy room"
The 2023 client sends rooms/{id}/clone through the same RecNet rooms
helper as "save room" and "restore subroom save", which reads the
{success, value, error} envelope. The clone handler answered with the
bare room details, so the client found no success flag and rejected the
reply with an empty error, even though the room had been created.

Clone now returns the envelope with the full room details in value, and
refusals (unknown source, cloning not allowed) are a 200 with
success=false and a message instead of a bodyless 404/403.
2026-10-17 20:21:54 +03:00
Alexa d60b0642a8 Merge pull request #59 from DorkSquadRR/feat/quest-web-installer
Install the Quest build from the website
2026-10-16 02:19:31 +03:00
Alexa a7879d7ede feat: install the Quest build from the website
A Quest page on the public site (/quest) installs a DorkNet .xapk from
Chrome or Edge over WebUSB, with no adb, SideQuest or scripts: pick the
.xapk, connect the headset, install. It installs the APK, copies the OBB
to its install path (skipped when an identical one is already there) and
launches the app, with progress bars and plain-language errors for an
unsupported browser, a headset held by adb/SideQuest, a disconnect and a
copy signed with a different key.

The .xapk is read from the player's own disk: the APK and OBB are stored
uncompressed, so they stream straight out of the file without loading
1.4 GB into memory, and the server never hosts game files.

Uses Tango (@yume-chan/adb 2.6.4 and friends, MIT), pinned and bundled;
the page is lazy-loaded so the rest of the site doesn't grow. Credited in
NOTICE; README, DorkNet.Quest/README.md, docs/quest.md and the packaged
README.txt point players at the page.
2026-10-15 22:34:25 +03:00
Alexa 6272844445 Merge pull request #58 from DorkSquadRR/fix/base-avatar-items
Owned items showed a price: serve only the real base avatar items
2026-10-15 22:09:23 +03:00
Alexa 2dbd03b9ae fix: owned items showed a price because they were served as base avatar items
defaultbaseavataritems returned the first 100 catalog items. The 2023
client flags every row of that list as a custom-clothing template
(AvatarItemData.IsBaseAvatarItem, keyed by AvatarItemId) and skips
templates when it rebuilds the owned outfit-menu entries, so those 100
ordinary items (80sHipHopJacket, Beard Close, ...) showed a price even
when owned and equipped, while the cart (which checks the owned set)
said "owned".

- defaultbaseavataritems returns only the UGC templates, UGCTee_Shirt
  (AvatarItemId 2184) and UGCPulloverHoodie_Shirt (2918), as the real
  service served them
- those two keep the same ids in every list and carry IsBaseAvatarItem
- they're no longer sold (existing shop rows are removed on seed) or
  listed as owned clothing
2026-10-15 22:08:59 +03:00
Alexa f4b4836375 Merge pull request #57 from DorkSquadRR/fix/owned-item-metadata
Owned avatar items use their shop tile's name, tooltip and rarity
2026-10-15 21:45:40 +03:00
Alexa ee7d614262 fix: owned avatar items use their shop tile's name, tooltip and rarity
The owned list (v4/items, defaultunlocked, lockeditems) took names from
the catalog dump, so colour variants read as asset names like
"(Shirt_Winter)", every rarity was 0 and every tooltip empty. 1,864 of
the 2,719 items in both lists disagreed with their shop tile.

Each owned row now takes FriendlyName, Tooltip and Rarity from the store
row with the same AvatarItemDesc (exact match; colour-variant combination
ids are case-sensitive). Rows with no shop tile keep the catalog name.
Rarity uses the same GiftRarity enum on both DTOs.
2026-10-15 21:44:49 +03:00
Alexa 93b4e43316 Merge pull request #56 from DorkSquadRR/feat/cv2-network-limits
Free default avatar items + DorkNet.Quest client patcher
2026-10-15 20:33:18 +03:00
Alexa 8373e592f4 Merge pull request #55 from DorkSquadRR/feat/cv2-network-limits
2023 client: presence, profile, outfits, RecNetResult writes, CircuitsV2 limits
2026-10-15 14:10:25 +03:00
132 changed files with 16517 additions and 1539 deletions
@@ -25,7 +25,7 @@ jobs:
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
dotnet-version: 10.0.x
- name: Install protobuf compiler
run: |
@@ -40,7 +40,7 @@ jobs:
- name: Start server
run: |
dotnet DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
dotnet DorkNet.Server/bin/Debug/net10.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
echo "$!" > "$RUNNER_TEMP/dorknet-server.pid"
- name: Wait for server readiness
@@ -57,7 +57,7 @@ jobs:
- 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 \
--server-assembly DorkNet.Server/bin/Debug/net10.0/DorkNet.Server.dll \
--base-url http://127.0.0.1:18080 \
--report "$RUNNER_TEMP/endpoint-contract-report.json"
+3 -3
View File
@@ -26,7 +26,7 @@ jobs:
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
dotnet-version: '10.0.x'
# ── Server self-contained publish ────────────────────────────────
- name: Publish DorkNet.Server (win-x64, self-contained)
@@ -43,10 +43,10 @@ jobs:
Compress-Archive -Path publish/server/* `
-DestinationPath dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip
# ── Client patcher zip (built via tools/build-patcher-zip.ps1) ───
# ── Client patcher zip (built via tools/build_pc.py) ─────────────
- name: Build client patcher zip
run: |
.\tools\build-patcher-zip.ps1 -VersionKey ${{ env.VERSION_KEY }}
python tools/build_pc.py patcher --version-key ${{ env.VERSION_KEY }}
# ── Publish both as Release assets ───────────────────────────────
- name: Upload Release assets
+2 -2
View File
@@ -12,8 +12,8 @@ dist/
# Local SQLite + uploaded blobs only — DON'T match the source-tree
# DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on
# case-insensitive filesystems. Use the precise runtime data dirs.
DorkNet.Server/bin/Debug/net9.0/data/
DorkNet.Server/bin/Release/net9.0/data/
DorkNet.Server/bin/Debug/net10.0/data/
DorkNet.Server/bin/Release/net10.0/data/
# appsettings.Production.json is intentionally versioned — contains
# only the Postgres provider switch + Kestrel bind address. All real
# secrets come from Coolify env vars at runtime.
+4 -4
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:9.0 with only the published output.
# slimmer aspnet:10.0 with only the published output.
#
# Build context: repo root (so Tools/ and DorkNet.Models/ are visible
# to ProjectReference + dotnet restore). The .dockerignore at the
# repo root keeps the bin/, obj/, and other dev junk out of the
# context.
ARG DOTNET_VERSION=9.0
ARG DOTNET_VERSION=10.0
ARG NODE_VERSION=22
# ── admin SPA build stage ──────────────────────────────────────────
@@ -76,13 +76,13 @@ WORKDIR /app
# curl + wget for healthcheck probes — Coolify v4 defaults to wget for
# its own probe regardless of what the image's HEALTHCHECK uses, so
# both have to be present. The aspnet:9.0 base image strips both by
# both have to be present. The aspnet:10.0 base image strips both by
# default to keep the surface area small.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl wget \
&& rm -rf /var/lib/apt/lists/*
# Run as a non-root user — aspnet:9.0 ships with the `app` UID 1654.
# Run as a non-root user — aspnet:10.0 ships with the `app` UID 1654.
# Coolify mounts persistent volumes with write permission for this UID.
USER app
+1 -1
View File
@@ -1,4 +1,4 @@
ARG DOTNET_VERSION=9.0
ARG DOTNET_VERSION=10.0
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-alpine AS admin-ui
+64 -1
View File
@@ -156,6 +156,14 @@ public class Mod : MelonMod
// "N"). Empty = no hotkey. A hotkey also arms the feature when
// RecRoyaleNightMode is false, so it can be switched on in-game.
public static string RecRoyaleNightToggleKey = "";
// A client with no remembered account for this machine. false: the
// title screen opens the sign-in page so the player logs into their
// existing account. true: Rec Room's own behaviour, which creates a
// new account automatically -- only useful when the server allows
// sign-ups (otherwise the title screen keeps retrying and failing).
// See TitleScreenLoginPatches.
public static bool AutoCreateAccount = false;
}
public override void OnInitializeMelon()
{
@@ -225,6 +233,14 @@ public class Mod : MelonMod
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
}
// Title-screen auto-login: with no remembered account, open the
// sign-in page instead of creating a new account.
if (!Cfg.AutoCreateAccount)
{
TryPatchByName("RRUI.Data.TitleScreenFlowModel", "IAKHIJIHAPJ",
prefix: nameof(TitleScreenLoginPatches.AutoLogin_Prefix));
}
// Rec Royale night mode: armed when enabled OR when a hotkey is set
// (so it can be switched on in-game). Scene detection + mood apply
// run from OnSceneWasLoaded / OnUpdate below.
@@ -457,6 +473,7 @@ public class Mod : MelonMod
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, "AutoCreateAccount", out v)) Cfg.AutoCreateAccount = v.GetBoolean();
if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
{
@@ -662,7 +679,7 @@ public class Mod : MelonMod
private MethodInfo GetPatchMethod(string name)
{
// Look in the core + feature patch holder classes.
foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize), typeof(RecRoyaleNight) })
foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize), typeof(RecRoyaleNight), typeof(TitleScreenLoginPatches) })
{
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
if (m is not null) return m;
@@ -903,6 +920,52 @@ internal static class FileHashCheckerPatches
return false;
}
}
internal static class TitleScreenLoginPatches
{
// TitleScreenFlowModel.IAKHIJIHAPJ(cachedLogin) is the boot-time
// auto-login: the title screen downloads this machine's remembered
// accounts (CFILOMKCAPL), picks one (BFHOLLPGBPB) and logs into it here.
// With none (null) it creates a brand-new account instead (PJMGFADOEEM ->
// grant_type create_account). A server with sign-ups off refuses that, the
// error handler (IBDFENJNMAA) shows the connection-error page and the flow
// tries again, forever. Open the sign-in page instead (GoToLogin, what the
// "Log in" button does). ManuallyLogin carries the player into the game
// from there on its own, so the boot step gets a promise that never
// settles (MAMJDBJDFAF, the game's non-generic promise).
private static Type? _promiseType;
public static bool AutoLogin_Prefix(object __instance, object? __0, MethodBase __originalMethod, ref object? __result)
{
if (__0 is not null || Mod.Cfg.AutoCreateAccount) return true;
try
{
_promiseType ??= Mod.ResolveType("MAMJDBJDFAF");
var goToLogin = __instance.GetType().GetMethod("GoToLogin", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null);
if (_promiseType is null || goToLogin is null)
{
Mod.Log.Warning("[title] no remembered account, but the sign-in page could not be opened; creating one as Rec Room does");
return true;
}
// Il2CppInterop wraps the interface return type (LDGADANDBIO) as
// its own class, so hand back the new promise's native object
// wrapped in that type.
var pending = (Il2CppObjectBase)Activator.CreateInstance(_promiseType)!;
var returnType = ((MethodInfo)__originalMethod).ReturnType;
var result = Activator.CreateInstance(returnType, pending.Pointer);
goToLogin.Invoke(__instance, null);
__result = result;
Mod.Log.Msg("[title] no remembered account; opened the sign-in page instead of creating a new account");
return false;
}
catch (Exception ex)
{
// goToLogin.Invoke wraps the real failure in TargetInvocationException.
var cause = ex is TargetInvocationException { InnerException: not null } tie ? tie.InnerException! : ex;
Mod.Log.Warning($"[title] sign-in redirect failed, creating an account as Rec Room does: {cause.GetType().Name}: {cause.Message}");
return true;
}
}
}
internal static class ToxModPatches
{
public static bool CanInitializeToxMod_Prefix(ref bool __result)
@@ -1,6 +1,7 @@
{
"ServerHost": "{HOST}",
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
"AutoCreateAccount": false,
"EnableDebugConsole": false,
"DebugConsoleToggleKey": "BackQuote",
"DevCommands": [],
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
+40 -18
View File
@@ -30,7 +30,8 @@ Photon shards with PC DorkNet players with no server-side version change.
| image RSA/SHA1 verify | accept Content-Signatures the server signs with its own key | PC ImageSigning |
| hile-warning / unknown-DLL / file-hash / ToxMod callbacks | neutralised so a re-signed, hook-loaded client is not warned/quit | `ClientMod` AntiTamper/ToxMod/FileHash |
| integrity report + quit funnels | **log only** — names any new rejection in logcat | — |
| Oculus `ovr_*` platform calls | (no-Meta build only) steer the entitlement/login path to the client's own credential screen | — |
| Oculus `ovr_*` platform calls | (no-Meta build only) pass the entitlement / user-proof / logged-in-user requests Meta refuses for the renamed app (including the SDK's second check on the inner native message), answering the logged-in user with the placeholder user id `1` | — |
| title-screen auto-login (`TitleScreenFlowModel`) | a headset with no remembered account opens the **sign-in page** instead of auto-creating an account (`AutoCreateAccount = true` restores Rec Room's behaviour) | — |
### Two builds, selected by `OculusAppId`
@@ -60,7 +61,7 @@ Photon shards with PC DorkNet players with no server-side version change.
and its matching **OBB**.
> On Windows with Controlled Folder Access on, the NDK linker and apksigner
> can't write under `Documents`. `build.ps1` builds in `%TEMP%` by default;
> can't write under `Documents`. `build.py` builds in `%TEMP%` by default;
> keep the work dir outside protected folders.
## Build
@@ -76,20 +77,28 @@ dotnet Il2CppDumper.dll `
copy DorkNet.Quest\dorknet-quest.cfg.example DorkNet.Quest\dorknet-quest.cfg
# edit ServerHost, Photon app ids, (optional) OculusAppId, PackageName
# 3. Build the signed APK:
# 3. Build the signed APK (and, with --obb, the release package):
$env:ANDROID_SDK_ROOT = 'C:\path\to\android-sdk'
$env:ANDROID_NDK = 'C:\path\to\android-sdk\ndk\27.2.12479018'
$env:APKTOOL_JAR = 'C:\path\to\apktool.jar'
DorkNet.Quest\scripts\build.ps1 `
-Apk C:\path\to\RecRoom-store-2128210.apk `
-DumpCs <scratch>\dump\dump.cs `
-Obb C:\path o\main.2128210.com.AgainstGravity.RecRoom.obb
py -3 DorkNet.Quest\scripts\build.py build `
--apk C:\path\to\RecRoom-store-2128210.apk `
--dump-cs <scratch>\dump\dump.cs `
--obb C:\path\to\main.2128210.com.AgainstGravity.RecRoom.obb `
--keystore C:\path\to\dorknet-debug.keystore
# -> %TEMP%\dorknet-quest-build\dorknet-recroom-20230317.apk
# -> %TEMP%\dorknet-quest-build\DorkNet-Quest-20230317\ (with -Obb)
# -> %TEMP%\dorknet-quest-build\DorkNet-Quest-20230317\ (with --obb)
```
With `-Obb`, the build also runs `scripts/package.ps1`, which writes a release
folder ready to hand to players:
The same command works on Linux and macOS (`python3 DorkNet.Quest/scripts/build.py build …`).
For a build whose target table is already committed under `targets/`, pass
`--targets-json DorkNet.Quest/targets/20230317-2128210.json` instead of
`--dump-cs`. No Il2CppDumper is needed then: the build checks the table
against the APK's `libil2cpp.so` and stops on a mismatch. This is what the
Jenkins job does (`ci/jenkins/README.md`).
With `--obb`, the build also writes a release folder ready to hand to players:
| File | Purpose |
| --- | --- |
@@ -98,15 +107,16 @@ folder ready to hand to players:
| `install.sh` | one-command install on macOS / Linux |
| `README.txt` | player instructions |
To package an APK you already built, run `scripts/package.ps1 -Apk <apk> -Obb <obb>`.
To package an APK you already built, run `build.py package --apk <apk> --obb <obb>`.
The package contains game binaries from your store copy, so only share it with
people who own the game.
`build.ps1` chains: resolve targets → generate headers → compile
`build.py build` chains: resolve targets → generate headers → compile
`libdorknet.so` → apktool decode → package rename + LAUNCHER + manifest →
app name (`AppName`) → drop the library in + `DT_NEEDED` it onto `libmain.so` → apktool build →
zipalign → apksigner (v2+v3). It generates a fresh debug keystore in the work
dir on first run. **Keep that keystore** — Meta ties tokens to app id + package
zipalign → apksigner (v2+v3). Without `--keystore` it generates a fresh debug
keystore in the work dir on first run. **Keep that keystore** and pass it with
`--keystore` from then on — Meta ties tokens to app id + package
+ signing cert, so re-signing with a different key breaks any Meta registration.
It is never committed.
@@ -129,7 +139,18 @@ the running `libil2cpp.so`, so a table can never be applied to the wrong build.
## Install
From a release folder, one command does everything (finds adb and the headset,
**From the browser (easiest).** Open the **Quest** page of your DorkNet site
(`https://<apex>/quest`) in Chrome or Edge on a computer, plug the headset in
over USB, pick the `.xapk`, click **Connect**, accept "Allow USB debugging" in
the headset, then **Install**. The page talks to the headset over WebUSB
(Tango, `@yume-chan/adb`): it installs the APK, copies the OBB to its install
path (skipped if an identical one is already there) and launches the app. The
`.xapk` never leaves the player's computer; the server hosts no game files.
Close SideQuest / Meta Quest Developer Hub, or run `adb kill-server`, first:
only one program can hold the headset's USB connection. Firefox, Safari and
phones don't support WebUSB.
**From a release folder**, one command does everything (finds adb and the headset,
installs the APK, copies the OBB to the right path, skips the OBB if it is
already there, and launches the app):
@@ -178,12 +199,14 @@ every `armed <target>`, then `intercepted https://ns.rec.net/ -> https://ns.<hos
| Symptom | Likely cause |
| --- | --- |
| `REFUSING <target>: ... fingerprint mismatch` | wrong APK for this target table — rebuild from the matching store APK, or rebase |
| install fails `-124` / `NO_CERTIFICATES` | signing/align step — rebuild with `build.ps1`, don't hand-zip |
| install fails `-124` / `NO_CERTIFICATES` | signing/align step — rebuild with `build.py`, don't hand-zip |
| boots to an empty world | OBB missing or named for the wrong versionCode |
| `[integrity] report reason=... NetworkCertificateMismatch` then a quit | TLS bypass off, or a pin hook didn't arm |
| `[http<-]` shows a Cloudflare challenge page | edge WAF blocked the request before your origin |
| multiplayer never connects | Photon app ids not set in the cfg |
| Quest and PC players never see each other's rooms | `PhotonRegion` differs from the PC ClientMod `PhotonCloudRegion` |
| "Unable to load Oculus user account" | old build (before the GetLoggedInUser bypass), or a Meta build whose app id the account isn't entitled to |
| game restarts while "creating a new account" | `AutoCreateAccount = true` on a server with sign-ups off — set it to `false` so the sign-in page opens |
## Layout
@@ -198,8 +221,7 @@ every `armed <target>`, then `intercepted https://ns.rec.net/ -> https://ns.<hos
| `scripts/gen_targets_header.py` | target table → `targets_generated.h` |
| `scripts/gen_config_header.py` | `dorknet-quest.cfg` → `config_generated.h` |
| `scripts/inject_needed.py` | LIEF `DT_NEEDED` injection |
| `scripts/build.ps1` | one-shot: store APK → signed patched APK (+ release package with `-Obb`) |
| `scripts/package.ps1` | patched APK + OBB → `.xapk` + installers |
| `scripts/build.py` | `build`: store APK → signed patched APK (+ release package with `--obb`); `package`: patched APK + OBB → `.xapk` + installers |
| `scripts/installer/` | `install.ps1`, `install.cmd`, `install.sh` copied into each package |
| `dorknet-quest.cfg.example` | config template; copy to `dorknet-quest.cfg` (git-ignored) |
+6
View File
@@ -65,6 +65,12 @@ EnableTlsTrustBypass = true
# Accept image Content-Signatures the server signs with its own key.
AcceptUnsignedImages = true
# A headset with no remembered account. false: open the sign-in page so the
# player logs into their existing DorkNet account. true: Rec Room's own
# behaviour, which creates a new account automatically -- only useful when the
# server allows sign-ups (otherwise the game keeps retrying and failing).
AutoCreateAccount = false
# ---- diagnostics (adb logcat -s DORKNET) ----
LogHttp = true
-248
View File
@@ -1,248 +0,0 @@
<#
.SYNOPSIS
Build a DorkNet-patched Rec Room Quest APK from a pristine store APK.
.DESCRIPTION
End to end:
1. resolve_targets.py -> per-build target table (RVA + fingerprint)
2. gen_targets_header.py -> targets_generated.h
3. gen_config_header.py -> config_generated.h (from your cfg)
4. NDK clang++ -> libdorknet.so (arm64)
5. apktool decode -> working project
6. package rename + manifest/version edits
7. drop libdorknet.so in, DT_NEEDED it into libmain.so (LIEF)
8. apktool build -> zipalign -> apksigner -> the signed output APK
Nothing game-derived is written into the repo. The work directory defaults to
a temp path because Windows Controlled Folder Access blocks the NDK linker and
apksigner from writing under Documents; override with -WorkDir if needed.
You supply the store APK and (for install) the matching OBB. No game binaries
ship with this tool.
.PARAMETER Apk
Path to the pristine store APK (com.AgainstGravity.RecRoom, versionCode
2128210 / version 20230317).
.PARAMETER Config
dorknet-quest.cfg with your ServerHost / Photon ids / package name. Defaults
to dorknet-quest.cfg next to the repo folder; falls back to the .example.
.PARAMETER WorkDir
Scratch build directory (must be outside Controlled-Folder-Access-protected
locations). Defaults to $env:TEMP\dorknet-quest-build.
.PARAMETER OutApk
Where to write the finished APK. Default: <WorkDir>\dorknet-recroom-<ver>.apk.
.PARAMETER NdkRoot
Android NDK root. Default: $env:ANDROID_NDK, else the NDK under $env:ANDROID_SDK_ROOT\ndk.
.PARAMETER BuildToolsDir
Android SDK build-tools dir holding zipalign + apksigner. Default: resolved
from $env:ANDROID_SDK_ROOT.
.PARAMETER Obb
The store OBB matching the APK. When given, package.ps1 also bundles the
result into an installable .xapk with one-command installers.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Apk,
[string]$Config,
[string]$WorkDir = (Join-Path $env:TEMP 'dorknet-quest-build'),
[string]$OutApk,
[string]$NdkRoot,
[string]$BuildToolsDir,
[string]$ApktoolJar,
[string]$DumpCs,
[string]$ApiLevel = '24',
[string]$Obb
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$RepoDir = Split-Path -Parent $PSScriptRoot
$Scripts = $PSScriptRoot
$OldPkg = 'com.AgainstGravity.RecRoom'
$SoName = 'libdorknet.so'
function Step($m) { Write-Host "==> $m" -ForegroundColor Cyan }
function Need($path, $what) { if (-not (Test-Path -LiteralPath $path)) { throw "$what not found: $path" } }
# ---- resolve tools ------------------------------------------------------
if (-not $NdkRoot) {
$NdkRoot = $env:ANDROID_NDK
if (-not $NdkRoot -and $env:ANDROID_SDK_ROOT) {
$cand = Join-Path $env:ANDROID_SDK_ROOT 'ndk'
if (Test-Path $cand) { $NdkRoot = (Get-ChildItem $cand -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName }
}
}
if (-not $NdkRoot) { throw "Android NDK not found. Pass -NdkRoot or set ANDROID_NDK / ANDROID_SDK_ROOT." }
$Cxx = Join-Path $NdkRoot "toolchains\llvm\prebuilt\windows-x86_64\bin\aarch64-linux-android$ApiLevel-clang++.cmd"
$ObjDump = Join-Path $NdkRoot 'toolchains\llvm\prebuilt\windows-x86_64\bin\llvm-objdump.exe'
Need $Cxx 'NDK aarch64 clang++'
if (-not $BuildToolsDir) {
if (-not $env:ANDROID_SDK_ROOT) { throw "Pass -BuildToolsDir or set ANDROID_SDK_ROOT." }
$bt = Join-Path $env:ANDROID_SDK_ROOT 'build-tools'
$BuildToolsDir = (Get-ChildItem $bt -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName
}
$ZipAlign = Join-Path $BuildToolsDir 'zipalign.exe'
$ApkSigner = Join-Path $BuildToolsDir 'apksigner.bat'
Need $ZipAlign 'zipalign'
Need $ApkSigner 'apksigner'
if (-not $ApktoolJar) {
$ApktoolJar = if ($env:APKTOOL_JAR) { $env:APKTOOL_JAR } elseif ($env:ANDROID_SDK_ROOT) { Join-Path $env:ANDROID_SDK_ROOT 'apktool.jar' } else { '' }
}
Need $ApktoolJar 'apktool.jar'
Need $Apk 'store APK'
if (-not $Config) {
$c = Join-Path $RepoDir 'dorknet-quest.cfg'
$Config = if (Test-Path $c) { $c } else { Join-Path $RepoDir 'dorknet-quest.cfg.example' }
}
Need $Config 'config'
Write-Host "Using config: $Config"
$PkgName = ((Get-Content $Config) | Where-Object { $_ -match '^\s*PackageName\s*=' } | Select-Object -First 1)
$PkgName = if ($PkgName) { ($PkgName -split '=', 2)[1].Trim() } else { 'net.dorknet.RecRoom' }
$AppName = ((Get-Content $Config) | Where-Object { $_ -match '^\s*AppName\s*=' } | Select-Object -First 1)
$AppName = if ($AppName) { ($AppName -split '=', 2)[1].Split('#')[0].Trim() } else { '' }
if (-not $AppName) { $AppName = 'DorkNet' }
# Resolve the launcher to its FULL exe path -- "& py ..." would otherwise match
# the (case-insensitive) function name and recurse. Prefer py -3.14, else python.
$PyCmd = (Get-Command py.exe -ErrorAction SilentlyContinue)
if ($PyCmd) { $PyExe = $PyCmd.Source; $PyPre = @('-3.14', '-I') }
else { $PyExe = (Get-Command python.exe -ErrorAction Stop).Source; $PyPre = @('-I') }
function Invoke-Py { param([Parameter(ValueFromRemainingArguments = $true)][string[]]$a)
$pa = $PyPre + $a
& $PyExe @pa
if ($LASTEXITCODE) { throw "python failed: $($a -join ' ')" } }
# ---- work dirs ----------------------------------------------------------
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
$Gen = Join-Path $WorkDir 'gen'
$Project = Join-Path $WorkDir 'project'
$Extract = Join-Path $WorkDir 'extract'
New-Item -ItemType Directory -Force -Path $Gen, $Extract | Out-Null
# ---- 1. extract + dump + resolve targets --------------------------------
Step 'extracting libil2cpp.so + global-metadata.dat'
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $Apk))
try {
foreach ($e in @('lib/arm64-v8a/libil2cpp.so', 'assets/bin/Data/Managed/Metadata/global-metadata.dat', 'lib/arm64-v8a/libmain.so')) {
$entry = $zip.GetEntry($e); if (-not $entry) { throw "APK missing $e" }
$dest = Join-Path $Extract (Split-Path $e -Leaf)
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true)
}
} finally { $zip.Dispose() }
$Il2Cpp = Join-Path $Extract 'libil2cpp.so'
$LibMain = Join-Path $Extract 'libmain.so'
if (-not $DumpCs) {
# Prefer a dump.cs the operator points at; otherwise require one. Dumping
# is left to Il2CppDumper (see README) to keep this script toolchain-light.
throw "Pass -DumpCs <dump.cs> from Il2CppDumper for this APK (see DorkNet.Quest/README.md)."
}
Need $DumpCs 'dump.cs'
Step 'resolving hook targets'
$verName = '20230317'; $verCode = '2128210'
# Write the resolved table into the work dir (Controlled Folder Access can
# block writes under Documents). The repo keeps a committed copy under targets/.
$table = Join-Path $WorkDir "$verName-$verCode.json"
Invoke-Py @((Join-Path $Scripts 'resolve_targets.py'), '--dump', $DumpCs, '--il2cpp', $Il2Cpp,
'--version-name', $verName, '--version-code', $verCode, '--objdump', $ObjDump, '--out', $table)
Step 'generating headers'
Invoke-Py @((Join-Path $Scripts 'gen_targets_header.py'), $table, (Join-Path $Gen 'targets_generated.h'))
Invoke-Py @((Join-Path $Scripts 'gen_config_header.py'), $Config, $PkgName, (Join-Path $Gen 'config_generated.h'))
# ---- 4. compile libdorknet.so -------------------------------------------
Step 'compiling libdorknet.so (arm64)'
$src = @(
(Join-Path $RepoDir 'src\dorknet_quest.cpp'),
(Join-Path $RepoDir 'src\config.cpp'),
(Join-Path $RepoDir 'src\third_party\And64InlineHook\And64InlineHook.cpp')
)
$So = Join-Path $WorkDir $SoName
& $Cxx '-std=c++17' '-fPIC' '-shared' '-O2' '-fvisibility=hidden' '-static-libstdc++' `
'-ffunction-sections' '-fdata-sections' '-Wl,--gc-sections' `
"-I$Gen" "-I$(Join-Path $RepoDir 'src')" $src '-llog' '-o' $So
if ($LASTEXITCODE) { throw 'compile failed' }
Need $So 'libdorknet.so'
# ---- 5. apktool decode --------------------------------------------------
Step 'apktool decode'
if (Test-Path $Project) { Remove-Item -Recurse -Force $Project }
& java -jar $ApktoolJar decode -f -o $Project (Resolve-Path $Apk)
if ($LASTEXITCODE) { throw 'apktool decode failed' }
# ---- 6. package rename + manifest -------------------------------------
Step "renaming package $OldPkg -> $PkgName"
$manifest = Join-Path $Project 'AndroidManifest.xml'
$m = Get-Content -Raw $manifest
$m = $m.Replace($OldPkg, $PkgName)
if ($m -notmatch 'android.intent.category.LAUNCHER') {
$m = $m.Replace('<category android:name="android.intent.category.INFO"/>',
'<category android:name="android.intent.category.INFO"/><category android:name="android.intent.category.LAUNCHER"/>')
}
Set-Content -NoNewline -Path $manifest -Value $m
# The launcher label comes from @string/app_name; set it in every locale.
Step "naming the app '$AppName'"
$labelXml = [System.Security.SecurityElement]::Escape($AppName).Replace("'", "\'")
foreach ($sx in Get-ChildItem (Join-Path $Project 'res') -Filter strings.xml -Recurse) {
$s = Get-Content -Raw -Encoding utf8 $sx.FullName
$n = [regex]::Replace($s, '<string name="app_name">[^<]*</string>', "<string name=`"app_name`">$labelXml</string>")
if ($n -ne $s) { Set-Content -NoNewline -Encoding utf8 -Path $sx.FullName -Value $n }
}
# ---- 7. inject the hook library ----------------------------------------
Step 'injecting libdorknet.so'
Copy-Item $So (Join-Path $Project 'lib\arm64-v8a' $SoName) -Force
$projMain = Join-Path $Project 'lib\arm64-v8a\libmain.so'
Invoke-Py @((Join-Path $Scripts 'inject_needed.py'), $projMain, $SoName)
# ---- 8. rebuild + align + sign -----------------------------------------
Step 'apktool build'
$unsigned = Join-Path $WorkDir 'unsigned.apk'
& java -jar $ApktoolJar build -f -o $unsigned $Project
if ($LASTEXITCODE) { throw 'apktool build failed' }
$Keystore = Join-Path $WorkDir 'dorknet-debug.keystore'
if (-not (Test-Path $Keystore)) {
Step 'generating a fresh debug keystore (kept in the work dir, never committed)'
& keytool -genkeypair -v -keystore $Keystore -alias dorknet -keyalg RSA -keysize 2048 `
-validity 10000 -storepass dorknet -keypass dorknet -dname 'CN=DorkNet Quest, O=DorkNet'
if ($LASTEXITCODE) { throw 'keytool failed' }
}
Step 'zipalign'
$aligned = Join-Path $WorkDir 'aligned.apk'
& $ZipAlign -f -p 4 $unsigned $aligned
if ($LASTEXITCODE) { throw 'zipalign failed' }
if (-not $OutApk) { $OutApk = Join-Path $WorkDir "dorknet-recroom-$verName.apk" }
Step "apksigner -> $OutApk"
& $ApkSigner sign --ks $Keystore --ks-key-alias dorknet --ks-pass pass:dorknet --key-pass pass:dorknet `
--v2-signing-enabled true --v3-signing-enabled true --out $OutApk $aligned
if ($LASTEXITCODE) { throw 'apksigner failed' }
& $ApkSigner verify --print-certs $OutApk | Out-Null
Write-Host ""
Write-Host "DONE. Patched APK: $OutApk" -ForegroundColor Green
Write-Host "Package: $PkgName versionCode: $verCode" -ForegroundColor Green
if ($Obb) {
Write-Host ""
& (Join-Path $Scripts 'package.ps1') -Apk $OutApk -Obb $Obb -BuildToolsDir $BuildToolsDir
} else {
Write-Host "Install it, then push the matching OBB to:" -ForegroundColor Green
Write-Host " /sdcard/Android/obb/$PkgName/main.$verCode.$PkgName.obb" -ForegroundColor Green
Write-Host "(or pass -Obb to bundle both into an installable .xapk)" -ForegroundColor Green
}
+472
View File
@@ -0,0 +1,472 @@
#!/usr/bin/env python3
"""Build a DorkNet-patched Rec Room Quest APK (and optionally an .xapk release).
From a pristine store APK, end to end:
1. hook targets resolve them from an Il2CppDumper dump.cs
(resolve_targets.py), or use a committed table checked
against the APK's libil2cpp.so
2. headers targets_generated.h + config_generated.h
3. compile libdorknet.so (arm64) with the Android NDK
4. apktool decode
5. rename package rename, launcher category, app label
6. inject libdorknet.so in, DT_NEEDED into libmain.so (LIEF)
7. sign apktool build -> zipalign -> apksigner
8. package with --obb: .xapk + one-command installers (or run
`package` on an APK you already built)
Nothing game-derived is written into the repo. You supply the store APK and,
for a release package, the matching OBB. The output contains game binaries
from your own store copy: share it only with people who own the game.
Runs on Windows, Linux and macOS. Needs Python 3.9+ with LIEF, a JDK (java,
keytool), the Android NDK, Android build-tools and apktool.
py -3 build.py build --apk recroom.apk --obb main.2128210.com.AgainstGravity.RecRoom.obb \\
--targets-json ../targets/20230317-2128210.json --keystore dorknet.keystore
py -3 build.py package --apk dorknet-recroom-20230317.apk --obb main.2128210....obb
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
from typing import NoReturn
from xml.sax.saxutils import escape as xml_escape
SCRIPTS = Path(__file__).resolve().parent
REPO = SCRIPTS.parent # DorkNet.Quest/
OLD_PKG = "com.AgainstGravity.RecRoom"
SO_NAME = "libdorknet.so"
ON_WINDOWS = os.name == "nt"
def step(msg: str) -> None:
print(f"==> {msg}", flush=True)
def fail(msg: str) -> NoReturn:
raise SystemExit(f"error: {msg}")
def need(path: Path | str, what: str) -> Path:
p = Path(path)
if not p.exists():
fail(f"{what} not found: {p}")
return p
def run(*cmd: object, cwd: Path | None = None) -> None:
args = [str(c) for c in cmd]
r = subprocess.run(args, cwd=cwd)
if r.returncode:
fail(f"{Path(args[0]).name} failed (exit {r.returncode})")
def capture(*cmd: object) -> str:
args = [str(c) for c in cmd]
r = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", errors="replace")
if r.returncode:
sys.stderr.write(r.stderr)
fail(f"{Path(args[0]).name} failed (exit {r.returncode})")
return r.stdout
def run_py(script: str, *args: object) -> None:
"""Run a sibling helper script with this interpreter (isolated mode)."""
run(sys.executable, "-I", SCRIPTS / script, *args)
def exe(name: str) -> str:
return name + (".exe" if ON_WINDOWS else "")
# ---- tool resolution ------------------------------------------------------
def sdk_root() -> Path | None:
v = os.environ.get("ANDROID_SDK_ROOT") or os.environ.get("ANDROID_HOME")
return Path(v) if v else None
def newest_subdir(parent: Path) -> Path | None:
if not parent.is_dir():
return None
dirs = sorted((d for d in parent.iterdir() if d.is_dir()), key=lambda d: d.name, reverse=True)
return dirs[0] if dirs else None
def resolve_build_tools(arg: str | None) -> Path:
if arg:
return need(arg, "build-tools dir")
root = sdk_root()
found = newest_subdir(root / "build-tools") if root else None
if not found:
fail("Android build-tools not found. Pass --build-tools-dir or set ANDROID_SDK_ROOT.")
return found
def resolve_ndk(arg: str | None) -> Path:
if arg:
return need(arg, "NDK")
if os.environ.get("ANDROID_NDK"):
return need(os.environ["ANDROID_NDK"], "NDK (ANDROID_NDK)")
root = sdk_root()
found = newest_subdir(root / "ndk") if root else None
if not found:
fail("Android NDK not found. Pass --ndk-root or set ANDROID_NDK / ANDROID_SDK_ROOT.")
return found
def ndk_bin(ndk: Path) -> Path:
if ON_WINDOWS:
tag = "windows-x86_64"
elif sys.platform == "darwin":
tag = "darwin-x86_64"
else:
tag = "linux-x86_64"
return ndk / "toolchains" / "llvm" / "prebuilt" / tag / "bin"
def resolve_apktool(arg: str | None) -> Path:
if arg:
return need(arg, "apktool.jar")
if os.environ.get("APKTOOL_JAR"):
return need(os.environ["APKTOOL_JAR"], "apktool.jar (APKTOOL_JAR)")
root = sdk_root()
if root and (root / "apktool.jar").exists():
return root / "apktool.jar"
raise SystemExit("error: apktool.jar not found. Pass --apktool-jar or set APKTOOL_JAR.")
def cfg_value(config: Path, key: str) -> str | None:
for line in config.read_text(encoding="utf-8").splitlines():
m = re.match(rf"^\s*{re.escape(key)}\s*=(.*)$", line)
if m:
return m.group(1).split("#", 1)[0].strip()
return None
# ---- build ----------------------------------------------------------------
def cmd_build(a: argparse.Namespace) -> None:
ndk = resolve_ndk(a.ndk_root)
bin_dir = ndk_bin(ndk)
cxx = need(bin_dir / (f"aarch64-linux-android{a.api_level}-clang++" + (".cmd" if ON_WINDOWS else "")),
"NDK aarch64 clang++")
objdump = bin_dir / exe("llvm-objdump")
build_tools = resolve_build_tools(a.build_tools_dir)
zipalign = need(build_tools / exe("zipalign"), "zipalign")
apksigner = need(build_tools / ("apksigner.bat" if ON_WINDOWS else "apksigner"), "apksigner")
apktool = resolve_apktool(a.apktool_jar)
apk = need(a.apk, "store APK").resolve()
config = Path(a.config) if a.config else (
REPO / "dorknet-quest.cfg" if (REPO / "dorknet-quest.cfg").exists() else REPO / "dorknet-quest.cfg.example")
need(config, "config")
print(f"Using config: {config}")
pkg_name = cfg_value(config, "PackageName") or "net.dorknet.RecRoom"
app_name = cfg_value(config, "AppName") or "DorkNet"
work = Path(a.work_dir)
gen, project, extract = work / "gen", work / "project", work / "extract"
for d in (work, gen, extract):
d.mkdir(parents=True, exist_ok=True)
# 1. extract + targets
step("extracting libil2cpp.so + global-metadata.dat")
with zipfile.ZipFile(apk) as z:
for entry in ("lib/arm64-v8a/libil2cpp.so",
"assets/bin/Data/Managed/Metadata/global-metadata.dat",
"lib/arm64-v8a/libmain.so"):
try:
data = z.read(entry)
except KeyError:
fail(f"APK missing {entry}")
(extract / Path(entry).name).write_bytes(data)
il2cpp = extract / "libil2cpp.so"
ver_name, ver_code = "20230317", "2128210"
if a.targets_json:
# A committed, already-resolved table. It is only valid for the exact
# libil2cpp.so it was resolved against, so check that first.
table = need(a.targets_json, "target table")
step(f"using target table {table}")
data = json.loads(table.read_text(encoding="utf-8"))
sha = hashlib.sha256(il2cpp.read_bytes()).hexdigest()
want = str(data.get("il2cppSha256") or "").lower()
if want and sha != want:
fail(f"libil2cpp.so in this APK ({sha}) is not the build the target table was resolved "
f"for ({want}). Resolve a table for it with --dump-cs.")
ver_name, ver_code = str(data["versionName"]), str(data["versionCode"])
else:
if not a.dump_cs:
# Dumping is left to Il2CppDumper (see README) to keep this
# script toolchain-light.
fail("Pass --dump-cs <dump.cs> from Il2CppDumper for this APK, or --targets-json with a "
"resolved table (see DorkNet.Quest/README.md).")
need(a.dump_cs, "dump.cs")
step("resolving hook targets")
# Written into the work dir (Controlled Folder Access can block writes
# under Documents). The repo keeps a committed copy under targets/.
table = work / f"{ver_name}-{ver_code}.json"
run_py("resolve_targets.py", "--dump", a.dump_cs, "--il2cpp", il2cpp,
"--version-name", ver_name, "--version-code", ver_code,
"--objdump", objdump, "--out", table)
# 2. headers
step("generating headers")
run_py("gen_targets_header.py", table, gen / "targets_generated.h")
run_py("gen_config_header.py", config, pkg_name, gen / "config_generated.h")
# 3. compile
step("compiling libdorknet.so (arm64)")
src = REPO / "src"
so = work / SO_NAME
run(cxx, "-std=c++17", "-fPIC", "-shared", "-O2", "-fvisibility=hidden", "-static-libstdc++",
"-ffunction-sections", "-fdata-sections", "-Wl,--gc-sections",
f"-I{gen}", f"-I{src}",
src / "dorknet_quest.cpp", src / "config.cpp",
src / "third_party" / "And64InlineHook" / "And64InlineHook.cpp",
"-llog", "-o", so)
need(so, "libdorknet.so")
# 4. decode
step("apktool decode")
if project.exists():
shutil.rmtree(project)
run("java", "-jar", apktool, "decode", "-f", "-o", project, apk)
# 5. package rename + manifest + label
step(f"renaming package {OLD_PKG} -> {pkg_name}")
manifest = project / "AndroidManifest.xml"
m = manifest.read_text(encoding="utf-8").replace(OLD_PKG, pkg_name)
if "android.intent.category.LAUNCHER" not in m:
m = m.replace('<category android:name="android.intent.category.INFO"/>',
'<category android:name="android.intent.category.INFO"/>'
'<category android:name="android.intent.category.LAUNCHER"/>')
manifest.write_text(m, encoding="utf-8", newline="")
# The launcher label comes from @string/app_name; set it in every locale.
step(f"naming the app '{app_name}'")
# XML entities for & < > " ' (the PowerShell version used
# SecurityElement.Escape), so no raw quote reaches Android's string parser.
label = xml_escape(app_name, {'"': "&quot;", "'": "&apos;"})
for sx in (project / "res").rglob("strings.xml"):
s = sx.read_text(encoding="utf-8")
n = re.sub(r'<string name="app_name">[^<]*</string>', lambda _: f'<string name="app_name">{label}</string>', s)
if n != s:
sx.write_text(n, encoding="utf-8", newline="")
# 6. inject
step(f"injecting {SO_NAME}")
shutil.copy2(so, project / "lib" / "arm64-v8a" / SO_NAME)
run_py("inject_needed.py", project / "lib" / "arm64-v8a" / "libmain.so", SO_NAME)
# 7. build + align + sign
step("apktool build")
unsigned = work / "unsigned.apk"
run("java", "-jar", apktool, "build", "-f", "-o", unsigned, project)
if a.keystore:
keystore = need(a.keystore, "keystore")
else:
keystore = work / "dorknet-debug.keystore"
if not keystore.exists():
step("generating a fresh debug keystore (kept in the work dir, never committed)")
run("keytool", "-genkeypair", "-v", "-keystore", keystore, "-alias", a.key_alias,
"-keyalg", "RSA", "-keysize", "2048", "-validity", "10000",
"-storepass", a.keystore_pass, "-keypass", a.keystore_pass,
"-dname", "CN=DorkNet Quest, O=DorkNet")
step("zipalign")
aligned = work / "aligned.apk"
run(zipalign, "-f", "-p", "4", unsigned, aligned)
out_apk = Path(a.out_apk) if a.out_apk else work / f"dorknet-recroom-{ver_name}.apk"
out_apk.parent.mkdir(parents=True, exist_ok=True)
step(f"apksigner -> {out_apk}")
run(apksigner, "sign", "--ks", keystore, "--ks-key-alias", a.key_alias,
"--ks-pass", f"pass:{a.keystore_pass}", "--key-pass", f"pass:{a.keystore_pass}",
"--v2-signing-enabled", "true", "--v3-signing-enabled", "true",
"--out", out_apk, aligned)
capture(apksigner, "verify", "--print-certs", out_apk)
print()
print(f"DONE. Patched APK: {out_apk}")
print(f"Package: {pkg_name} versionCode: {ver_code}")
if a.obb:
print()
package(out_apk, Path(a.obb), Path(a.package_dir) if a.package_dir else None, build_tools)
else:
print("Install it, then push the matching OBB to:")
print(f" /sdcard/Android/obb/{pkg_name}/main.{ver_code}.{pkg_name}.obb")
print("(or pass --obb to bundle both into an installable .xapk)")
# ---- package --------------------------------------------------------------
README_TXT = """{name} {ver} - Rec Room for DorkNet (Meta Quest)
============================================================
1. Turn on developer mode for your headset (Meta Horizon phone app), plug it
in over USB and accept "Allow USB debugging" inside the headset.
2. Install. Easiest: open the Quest page of your DorkNet server's website
(https://<server>/quest) in Chrome or Edge, pick this .xapk and follow the
steps. Or use the scripts here:
Windows: double-click install.cmd
macOS / Linux: ./install.sh
The installer needs adb (Android platform-tools) on PATH or in
ANDROID_SDK_ROOT. It installs the app, copies the game data (~1.4 GB) and
offers to launch it.
3. In the headset: Library > Unknown Sources > {name}.
The app installs as {pkg} alongside any store copy of Rec Room.
SideQuest users can also drop the .xapk onto SideQuest instead.
"""
def package(apk: Path, obb: Path, out_dir: Path | None, build_tools: Path) -> Path:
"""Bundle a patched APK and its OBB into an .xapk plus installers.
The XAPK is the usual APKPure layout (manifest.json + APK + OBB + icon), so
XAPK-aware installers (SideQuest's file drop, SAI, APKPure) can open it too.
The APK and OBB are stored uncompressed: they are already compressed, and
storing them keeps packaging and install fast."""
apk, obb = need(apk, "patched APK").resolve(), need(obb, "OBB").resolve()
aapt2 = need(build_tools / exe("aapt2"), "aapt2")
step("reading APK metadata")
badging = capture(aapt2, "dump", "badging", apk).splitlines()
def badge(pattern: str) -> str | None:
for line in badging:
mm = re.search(pattern, line)
if mm:
return mm.group(1)
return None
pkg = badge(r"^package: name='([^']+)'")
ver_code = badge(r"versionCode='([^']+)'")
ver_name = badge(r"versionName='([^']+)'")
min_sdk = badge(r"^(?:minSdkVersion|sdkVersion):'([^']+)'")
tgt_sdk = badge(r"^targetSdkVersion:'([^']+)'")
label = badge(r"^application-label:'([^']*)'")
if not pkg or not ver_code:
fail("could not read package name / versionCode from the APK")
perms = [mm.group(1) for line in badging if (mm := re.match(r"^uses-permission: name='([^']+)'", line))]
icons = sorted(((int(mm.group(1)), mm.group(2)) for line in badging
if (mm := re.match(r"^application-icon-(\d+):'([^']+)'", line))), reverse=True)
# The launcher icon may be an adaptive-icon XML; XAPK installers want a
# PNG, so fall back to the same-named PNG from the densest mipmap/drawable.
icon_path = None
with zipfile.ZipFile(apk) as z:
names = set(z.namelist())
if icons:
ref = icons[0][1]
if ref.endswith(".png") and ref in names:
icon_path = ref
else:
base = Path(ref).stem
for d in ("xxxhdpi", "xxhdpi", "xhdpi", "hdpi", "mdpi", "ldpi"):
for kind in ("mipmap", "drawable"):
cand = f"res/{kind}-{d}/{base}.png"
if icon_path is None and cand in names:
icon_path = cand
icon_bytes = z.read(icon_path) if icon_path else None
print(f" {pkg} version {ver_name} ({ver_code})")
obb_entry = f"Android/obb/{pkg}/main.{ver_code}.{pkg}.obb"
out_dir = out_dir or apk.parent / f"DorkNet-Quest-{ver_name}"
out_dir.mkdir(parents=True, exist_ok=True)
xapk = out_dir / f"DorkNet-{ver_name}.xapk"
xapk.unlink(missing_ok=True)
manifest = {
"xapk_version": 2,
"package_name": pkg,
"name": label or "DorkNet",
"version_code": ver_code,
"version_name": ver_name,
"min_sdk_version": min_sdk,
"target_sdk_version": tgt_sdk,
"permissions": perms,
"total_size": apk.stat().st_size + obb.stat().st_size,
"expansions": [{"file": obb_entry, "install_location": "EXTERNAL_STORAGE", "install_path": obb_entry}],
}
if icon_bytes:
manifest["icon"] = "icon.png"
step(f"writing {xapk} (the OBB is large; this takes a minute)")
with zipfile.ZipFile(xapk, "w", allowZip64=True) as z:
z.writestr(zipfile.ZipInfo("manifest.json"), json.dumps(manifest, indent=2), compress_type=zipfile.ZIP_DEFLATED)
if icon_bytes:
z.writestr("icon.png", icon_bytes, compress_type=zipfile.ZIP_STORED)
z.write(apk, f"{pkg}.apk", compress_type=zipfile.ZIP_STORED)
z.write(obb, obb_entry, compress_type=zipfile.ZIP_STORED)
step("adding installers")
for f in ("install.ps1", "install.cmd", "install.sh"):
shutil.copy2(SCRIPTS / "installer" / f, out_dir / f)
(out_dir / "install.sh").chmod(0o755)
(out_dir / "README.txt").write_text(
README_TXT.format(name=manifest["name"], ver=ver_name, pkg=pkg), encoding="utf-8")
print()
print(f"DONE. Package folder: {out_dir}")
print(f" {xapk.name} ({xapk.stat().st_size / 2**30:.2f} GB)")
print(f"Install with: {out_dir / 'install.cmd'}")
return out_dir
def cmd_package(a: argparse.Namespace) -> None:
package(Path(a.apk), Path(a.obb), Path(a.out_dir) if a.out_dir else None,
resolve_build_tools(a.build_tools_dir))
# ---- cli ------------------------------------------------------------------
def main() -> None:
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
sub = p.add_subparsers(dest="cmd", required=True)
b = sub.add_parser("build", help="store APK -> signed patched APK (+ .xapk with --obb)")
b.add_argument("--apk", required=True, help="pristine store APK (com.AgainstGravity.RecRoom)")
b.add_argument("--obb", help="matching store OBB; also writes the .xapk release package")
b.add_argument("--config", help="dorknet-quest.cfg (default: DorkNet.Quest/dorknet-quest.cfg, else the .example)")
b.add_argument("--dump-cs", help="Il2CppDumper dump.cs for this APK, to resolve the hook targets")
b.add_argument("--targets-json", help="a resolved target table to use instead of --dump-cs; must match the APK's libil2cpp.so")
b.add_argument("--keystore", help="signing keystore; reuse the same one for every build so updates install in place")
b.add_argument("--keystore-pass", default="dorknet", help="store and key password (default: dorknet)")
b.add_argument("--key-alias", default="dorknet", help="key alias (default: dorknet)")
b.add_argument("--work-dir", default=str(Path(tempfile.gettempdir()) / "dorknet-quest-build"),
help="scratch directory (on Windows keep it outside Controlled Folder Access, e.g. %%TEMP%%)")
b.add_argument("--out-apk", help="where to write the APK (default: <work-dir>/dorknet-recroom-<ver>.apk)")
b.add_argument("--package-dir", help="release folder for the .xapk (default: <apk folder>/DorkNet-Quest-<ver>)")
b.add_argument("--ndk-root", help="Android NDK root (default: ANDROID_NDK, else newest under ANDROID_SDK_ROOT/ndk)")
b.add_argument("--build-tools-dir", help="Android build-tools dir (default: newest under ANDROID_SDK_ROOT/build-tools)")
b.add_argument("--apktool-jar", help="apktool.jar (default: APKTOOL_JAR, else ANDROID_SDK_ROOT/apktool.jar)")
b.add_argument("--api-level", default="24", help="NDK API level (default: 24)")
b.set_defaults(func=cmd_build)
k = sub.add_parser("package", help="patched APK + OBB -> .xapk + installers")
k.add_argument("--apk", required=True)
k.add_argument("--obb", required=True)
k.add_argument("--out-dir", help="release folder (default: <apk folder>/DorkNet-Quest-<ver>)")
k.add_argument("--build-tools-dir")
k.set_defaults(func=cmd_package)
a = p.parse_args()
a.func(a)
if __name__ == "__main__":
main()
@@ -23,6 +23,7 @@ KEYS = {
"PhotonRegion": ("DN_PHOTON_REGION", "str"),
"EnableTlsTrustBypass": ("DN_TLS_TRUST_BYPASS", "bool"),
"AcceptUnsignedImages": ("DN_ACCEPT_UNSIGNED_IMAGES", "bool"),
"AutoCreateAccount": ("DN_AUTO_CREATE_ACCOUNT", "bool"),
"LogHttp": ("DN_LOG_HTTP", "bool"),
"Debug": ("DN_DEBUG_HTTP", "bool"),
"LogHttpBodyMax": ("DN_LOG_BODY_MAX", "int"),
-170
View File
@@ -1,170 +0,0 @@
<#
.SYNOPSIS
Bundle a patched Quest APK and its OBB into one installable package.
.DESCRIPTION
Writes a release folder holding:
DorkNet-<ver>.xapk standard XAPK (manifest.json + APK + OBB + icon)
install.cmd / install.ps1 one-command installer for Windows
install.sh one-command installer for macOS / Linux
README.txt what to do with it
The XAPK is the usual APKPure layout, so XAPK-aware installers (SideQuest's
file drop, SAI, APKPure) can also open it. The OBB is stored uncompressed:
it is already compressed, and storing it keeps packaging and install fast.
The output contains game binaries from your own store copy. Share it only
with people who own the game.
.PARAMETER Apk
The signed, patched APK produced by build.ps1.
.PARAMETER Obb
The store OBB matching the APK's versionCode (main.<code>.com.AgainstGravity.RecRoom.obb).
.PARAMETER OutDir
Release folder to create. Default: <APK folder>\DorkNet-Quest-<ver>.
.PARAMETER BuildToolsDir
Android SDK build-tools dir holding aapt2. Default: resolved from $env:ANDROID_SDK_ROOT.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Apk,
[Parameter(Mandatory = $true)][string]$Obb,
[string]$OutDir,
[string]$BuildToolsDir
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem
function Step($m) { Write-Host "==> $m" -ForegroundColor Cyan }
function Need($path, $what) { if (-not (Test-Path -LiteralPath $path)) { throw "$what not found: $path" } }
Need $Apk 'patched APK'
Need $Obb 'OBB'
$Apk = (Resolve-Path -LiteralPath $Apk).Path
$Obb = (Resolve-Path -LiteralPath $Obb).Path
if (-not $BuildToolsDir) {
if (-not $env:ANDROID_SDK_ROOT) { throw "Pass -BuildToolsDir or set ANDROID_SDK_ROOT." }
$bt = Join-Path $env:ANDROID_SDK_ROOT 'build-tools'
$BuildToolsDir = (Get-ChildItem $bt -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName
}
$Aapt2 = Join-Path $BuildToolsDir 'aapt2.exe'
Need $Aapt2 'aapt2'
# ---- read the APK's identity --------------------------------------------
Step 'reading APK metadata'
$badging = & $Aapt2 dump badging $Apk 2>$null
if ($LASTEXITCODE) { throw 'aapt2 dump badging failed' }
function Badge($pattern) { foreach ($l in $badging) { if ($l -match $pattern) { return $Matches[1] } } return $null }
$pkg = Badge "^package: name='([^']+)'"
$verCode = Badge "versionCode='([^']+)'"
$verName = Badge "versionName='([^']+)'"
$minSdk = Badge "^(?:minSdkVersion|sdkVersion):'([^']+)'"
$tgtSdk = Badge "^targetSdkVersion:'([^']+)'"
$label = Badge "^application-label:'([^']*)'"
if (-not $pkg -or -not $verCode) { throw 'could not read package name / versionCode from the APK' }
$perms = @($badging | Where-Object { $_ -match "^uses-permission: name='([^']+)'" } | ForEach-Object { $_ -replace "^uses-permission: name='([^']+)'.*", '$1' })
$iconRef = @($badging | Where-Object { $_ -match "^application-icon-\d+:'([^']+)'" } |
ForEach-Object { [pscustomobject]@{ Density = [int]($_ -replace "^application-icon-(\d+):.*", '$1'); Path = ($_ -replace "^application-icon-\d+:'([^']+)'.*", '$1') } } |
Sort-Object Density -Descending | Select-Object -First 1 | ForEach-Object { $_.Path })
# The launcher icon may be an adaptive-icon XML; XAPK installers want a PNG, so
# fall back to the same-named PNG from the densest mipmap/drawable folder.
$iconPath = $null
if ($iconRef) {
$src = [System.IO.Compression.ZipFile]::OpenRead($Apk)
try {
if ($iconRef -like '*.png' -and $src.GetEntry($iconRef)) { $iconPath = $iconRef }
else {
$base = [System.IO.Path]::GetFileNameWithoutExtension($iconRef)
foreach ($d in 'xxxhdpi', 'xxhdpi', 'xhdpi', 'hdpi', 'mdpi', 'ldpi') {
foreach ($kind in 'mipmap', 'drawable') {
if (-not $iconPath -and $src.GetEntry("res/$kind-$d/$base.png")) { $iconPath = "res/$kind-$d/$base.png" }
}
}
}
} finally { $src.Dispose() }
}
Write-Host " $pkg version $verName ($verCode)"
$obbName = "main.$verCode.$pkg.obb"
$obbEntry = "Android/obb/$pkg/$obbName"
$apkEntry = "$pkg.apk"
if (-not $OutDir) { $OutDir = Join-Path (Split-Path -Parent $Apk) "DorkNet-Quest-$verName" }
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
$xapk = Join-Path $OutDir "DorkNet-$verName.xapk"
if (Test-Path $xapk) { Remove-Item -Force $xapk }
# ---- manifest.json -------------------------------------------------------
$manifest = [ordered]@{
xapk_version = 2
package_name = $pkg
name = if ($label) { $label } else { 'DorkNet' }
version_code = $verCode
version_name = $verName
min_sdk_version = $minSdk
target_sdk_version = $tgtSdk
permissions = $perms
total_size = (Get-Item $Apk).Length + (Get-Item $Obb).Length
expansions = @([ordered]@{ file = $obbEntry; install_location = 'EXTERNAL_STORAGE'; install_path = $obbEntry })
}
if ($iconPath) { $manifest.icon = 'icon.png' }
# ---- write the XAPK ------------------------------------------------------
Step "writing $xapk (the OBB is large; this takes a minute)"
$zip = [System.IO.Compression.ZipFile]::Open($xapk, [System.IO.Compression.ZipArchiveMode]::Create)
try {
$e = $zip.CreateEntry('manifest.json', [System.IO.Compression.CompressionLevel]::Optimal)
$w = New-Object System.IO.StreamWriter($e.Open(), (New-Object System.Text.UTF8Encoding($false)))
$w.Write(($manifest | ConvertTo-Json -Depth 5)); $w.Dispose()
if ($iconPath) {
$src = [System.IO.Compression.ZipFile]::OpenRead($Apk)
try {
$ie = $src.GetEntry($iconPath)
if ($ie) {
$dst = $zip.CreateEntry('icon.png', [System.IO.Compression.CompressionLevel]::NoCompression)
$in = $ie.Open(); $out = $dst.Open(); $in.CopyTo($out); $out.Dispose(); $in.Dispose()
}
} finally { $src.Dispose() }
}
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Apk, $apkEntry, [System.IO.Compression.CompressionLevel]::NoCompression) | Out-Null
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Obb, $obbEntry, [System.IO.Compression.CompressionLevel]::NoCompression) | Out-Null
} finally { $zip.Dispose() }
# ---- installers + readme -------------------------------------------------
Step 'adding installers'
foreach ($f in 'install.ps1', 'install.cmd', 'install.sh') {
Copy-Item (Join-Path $PSScriptRoot "installer\$f") (Join-Path $OutDir $f) -Force
}
$readme = @"
$($manifest.name) $verName - Rec Room for DorkNet (Meta Quest)
============================================================
1. Turn on developer mode for your headset (Meta Horizon phone app), plug it
in over USB and accept "Allow USB debugging" inside the headset.
2. Install:
Windows: double-click install.cmd
macOS / Linux: ./install.sh
The installer needs adb (Android platform-tools) on PATH or in
ANDROID_SDK_ROOT. It installs the app, copies the game data (~1.4 GB) and
offers to launch it.
3. In the headset: Library > Unknown Sources > $($manifest.name).
The app installs as $pkg alongside any store copy of Rec Room.
SideQuest users can also drop the .xapk onto SideQuest instead.
"@
Set-Content -Path (Join-Path $OutDir 'README.txt') -Value $readme -Encoding utf8
$sizeGb = '{0:N2}' -f ((Get-Item $xapk).Length / 1GB)
Write-Host ""
Write-Host "DONE. Package folder: $OutDir" -ForegroundColor Green
Write-Host " $(Split-Path -Leaf $xapk) ($sizeGb GB)" -ForegroundColor Green
Write-Host "Install with: $(Join-Path $OutDir 'install.cmd')" -ForegroundColor Green
Binary file not shown.
+6 -1
View File
@@ -57,6 +57,11 @@
#ifndef DN_ACCEPT_UNSIGNED_IMAGES
# define DN_ACCEPT_UNSIGNED_IMAGES true
#endif
// A headset with no remembered account: false opens the sign-in page, true
// keeps Rec Room's behaviour of creating a new account automatically.
#ifndef DN_AUTO_CREATE_ACCOUNT
# define DN_AUTO_CREATE_ACCOUNT false
#endif
#ifndef DN_LOG_HTTP
# define DN_LOG_HTTP true
#endif
@@ -82,7 +87,7 @@ void dn_load_runtime_config(char *server_host, size_t server_host_cap,
char *photon_realtime, char *photon_voice, char *photon_chat,
char *photon_server, size_t photon_server_cap, int *photon_port,
char *photon_region, size_t photon_region_cap,
bool *tls_bypass, bool *accept_images,
bool *tls_bypass, bool *accept_images, bool *auto_create_account,
bool *log_http, bool *debug_http, int *body_max);
#endif // DORKNET_QUEST_CONFIG_H
+163 -4
View File
@@ -59,6 +59,7 @@ static int g_photon_port = DN_PHOTON_PORT;
static char g_photon_region[16] = DN_PHOTON_REGION;
static bool g_tls_bypass = DN_TLS_TRUST_BYPASS;
static bool g_accept_images = DN_ACCEPT_UNSIGNED_IMAGES;
static bool g_auto_create_account = DN_AUTO_CREATE_ACCOUNT;
static bool g_log_http = DN_LOG_HTTP;
static bool g_debug_http = DN_DEBUG_HTTP;
static int g_body_max = DN_LOG_BODY_MAX;
@@ -89,8 +90,12 @@ static const size_t kArrayDataOff = 0x20;
// Oculus platform message types (dump.cs ONGCNIAMKCG.DJCEHCFKHME).
static const unsigned kMsgEntitlementGetIsViewerEntitled = 409688241u;
static const unsigned kMsgUserGetUserProof = 578880643u;
static const unsigned kMsgUserGetLoggedInUser = 1131361373u;
static const char *kPlaceholderNonce = "dorknet-no-platform-account";
static const unsigned long long kNoPlatformUserId = 1ULL;
// Stand-in ovrUserHandle for a GetLoggedInUser that Meta refused. Only our
// hooked ovr_User_* accessors ever see it; they never pass it to the SDK.
static void *const kPlaceholderUser = (void *)0x2;
static const size_t kLogChunk = 1500;
static const uint32_t kUnityTlsSuccess = 0; // UNITYTLS_X509VERIFY_SUCCESS / UNITYTLS_SUCCESS
@@ -103,12 +108,22 @@ typedef void *(*il2cpp_class_get_method_from_name_t)(void *klass, const char *na
typedef void *(*il2cpp_object_new_t)(void *klass);
typedef void *(*il2cpp_string_new_t)(const char *str);
typedef void *(*il2cpp_runtime_invoke_t)(void *method, void *obj, void **params, void **exc);
typedef void *(*il2cpp_domain_get_t)();
typedef void **(*il2cpp_domain_get_assemblies_t)(void *domain, size_t *size);
typedef void *(*il2cpp_assembly_get_image_t)(void *assembly);
typedef const char *(*il2cpp_image_get_name_t)(void *image);
typedef void *(*il2cpp_class_from_name_t)(void *image, const char *ns, const char *name);
static il2cpp_object_get_class_t il2cpp_object_get_class;
static il2cpp_class_get_method_from_name_t il2cpp_class_get_method_from_name;
static il2cpp_object_new_t il2cpp_object_new;
static il2cpp_string_new_t il2cpp_string_new;
static il2cpp_runtime_invoke_t il2cpp_runtime_invoke;
static il2cpp_domain_get_t il2cpp_domain_get;
static il2cpp_domain_get_assemblies_t il2cpp_domain_get_assemblies;
static il2cpp_assembly_get_image_t il2cpp_assembly_get_image;
static il2cpp_image_get_name_t il2cpp_image_get_name;
static il2cpp_class_from_name_t il2cpp_class_from_name;
static uintptr_t g_il2cpp_base;
@@ -139,6 +154,30 @@ static const unsigned char *invoke_bytes(void *obj, const char *method, size_t *
return (const unsigned char *)((char *)arr + kArrayDataOff);
}
static bool invoke_void(void *obj, const char *method) {
if (!obj) return false;
void *cls = il2cpp_object_get_class(obj);
if (!cls) return false;
void *m = il2cpp_class_get_method_from_name(cls, method, 0);
if (!m) return false;
void *exc = nullptr;
il2cpp_runtime_invoke(m, obj, nullptr, &exc);
return !exc;
}
static void *find_class(const char *image_name, const char *ns, const char *name) {
if (!il2cpp_domain_get || !il2cpp_domain_get_assemblies || !il2cpp_assembly_get_image ||
!il2cpp_image_get_name || !il2cpp_class_from_name) return nullptr;
size_t n = 0;
void **assemblies = il2cpp_domain_get_assemblies(il2cpp_domain_get(), &n);
for (size_t i = 0; assemblies && i < n; i++) {
void *image = il2cpp_assembly_get_image(assemblies[i]);
const char *iname = image ? il2cpp_image_get_name(image) : nullptr;
if (iname && !strcmp(iname, image_name)) return il2cpp_class_from_name(image, ns, name);
}
return nullptr;
}
static void invoke_str(void *obj, const char *method, char *out, size_t cap) {
out[0] = '\0';
if (!obj) return;
@@ -438,14 +477,73 @@ typedef unsigned (*ovr_GetType_t)(void *m);
static ovr_IsError_t orig_ovr_IsError;
static ovr_GetType_t ovr_GetType;
static bool is_bypassed_type(unsigned t) {
return t == kMsgEntitlementGetIsViewerEntitled || t == kMsgUserGetUserProof ||
t == kMsgUserGetLoggedInUser;
}
// The SDK's Message constructor checks IsError twice: on the message, and --
// when that passes -- on ovr_Message_GetNativeMessage(message). The inner
// message carries the platform's refusal too but doesn't report the outer
// type, so remember the inner handle of a bypassed message (same thread, the
// constructor asks about it immediately) and pass it as well.
static __thread void *t_bypassed_native;
static __thread unsigned t_bypassed_type;
static bool hk_ovr_IsError(void *m) {
bool real = orig_ovr_IsError(m);
if (!real || !ovr_GetType) return real;
unsigned t = ovr_GetType(m);
if (t == kMsgEntitlementGetIsViewerEntitled || t == kMsgUserGetUserProof) return false;
if (!real) return real;
if (m && m == t_bypassed_native) return false;
if (ovr_GetType && is_bypassed_type(ovr_GetType(m))) return false;
return real;
}
typedef void *(*ovr_GetNativeMessage_t)(void *m);
static ovr_GetNativeMessage_t orig_ovr_GetNativeMessage;
static void *hk_ovr_GetNativeMessage(void *m) {
void *native = orig_ovr_GetNativeMessage(m);
unsigned t = ovr_GetType ? ovr_GetType(m) : 0;
t_bypassed_native = is_bypassed_type(t) ? native : nullptr;
t_bypassed_type = t;
return native;
}
// OculusPlatformManager.LoadUser asks for the logged-in user; on a headset
// that reaches Meta, Meta refuses it for this re-packaged app, LoadUser
// rejects ("GetLoggedInUser failed") and Initialize shows "Unable to load
// Oculus user account". A headset that can't reach Meta gets a cached user
// and never hits this. Answer a refused request with a placeholder user whose
// ID is kNoPlatformUserId -- the same placeholder the server already treats
// as "no platform account" (PlayerService.IsPlaceholderPlatformId).
static void *(*orig_ovr_Message_GetUser)(void *m);
static void *hk_ovr_Message_GetUser(void *m) {
void *u = orig_ovr_Message_GetUser ? orig_ovr_Message_GetUser(m) : nullptr;
if (u) return u;
// The SDK reads the user from the inner native message
// (GetUser(GetNativeMessage(msg))), so match that handle as well.
bool logged_in_user = (m && m == t_bypassed_native && t_bypassed_type == kMsgUserGetLoggedInUser) ||
(ovr_GetType && ovr_GetType(m) == kMsgUserGetLoggedInUser);
if (!logged_in_user) return u;
LOGI("GetLoggedInUser refused by the platform; using the no-platform placeholder user");
return kPlaceholderUser;
}
// Accessors the SDK's User model reads. Real users pass straight through; the
// placeholder gets empty strings and zero values.
#define DN_USER_STR_ACCESSOR(name) static const char *(*orig_##name)(void *u); static const char *hk_##name(void *u) { if (u == kPlaceholderUser) return ""; return orig_##name ? orig_##name(u) : ""; }
#define DN_USER_VAL_ACCESSOR(name) static uintptr_t (*orig_##name)(void *u); static uintptr_t hk_##name(void *u) { if (u == kPlaceholderUser) return 0; return orig_##name ? orig_##name(u) : 0; }
DN_USER_STR_ACCESSOR(ovr_User_GetDisplayName)
DN_USER_STR_ACCESSOR(ovr_User_GetOculusID)
DN_USER_STR_ACCESSOR(ovr_User_GetImageUrl)
DN_USER_STR_ACCESSOR(ovr_User_GetSmallImageUrl)
DN_USER_STR_ACCESSOR(ovr_User_GetInviteToken)
DN_USER_STR_ACCESSOR(ovr_User_GetPresence)
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceDeeplinkMessage)
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceDestinationApiName)
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceLobbySessionId)
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceMatchSessionId)
DN_USER_VAL_ACCESSOR(ovr_User_GetPresenceStatus) // ovrUserPresenceStatus: 0 = Unknown
static void *(*orig_ovr_GetUserProof)(void *m);
static void *hk_ovr_GetUserProof(void *m) {
void *p = orig_ovr_GetUserProof ? orig_ovr_GetUserProof(m) : nullptr;
@@ -469,10 +567,46 @@ static unsigned long long hk_ovr_GetLoggedInUserID() {
static unsigned long long (*orig_ovr_User_GetID)(void *u);
static unsigned long long hk_ovr_User_GetID(void *u) {
if (u == kPlaceholderUser) return kNoPlatformUserId;
unsigned long long real = orig_ovr_User_GetID ? orig_ovr_User_GetID(u) : 0;
return real ? real : kNoPlatformUserId;
}
// ------------------------------------------------------------ title screen --
// TitleScreenFlowModel.IAKHIJIHAPJ(cachedLogin) is the boot-time auto-login:
// the title screen downloads the headset's remembered accounts and logs into
// one; with none (null) it creates a brand-new account instead
// (PJMGFADOEEM -> grant_type create_account). A server with sign-ups off
// refuses that, the error handler shows the connection-error page and the
// flow tries again, forever. Unless AutoCreateAccount is on, open the sign-in
// page instead (TitleScreenFlowModel.GoToLogin, what the "Log in" button
// does). ManuallyLogin carries the player into the game from there on its own,
// so the boot step gets a promise that never settles.
static void *(*orig_TitleAutoLogin)(void *thiz, void *cached, void *method);
static void *new_pending_promise() {
// MAMJDBJDFAF: the game's non-generic promise (RecRoom.Promises.Runtime);
// a fresh one stays pending.
static void *cls = find_class("RecRoom.Promises.Runtime.dll", "", "MAMJDBJDFAF");
if (!cls) return nullptr;
void *ctor = il2cpp_class_get_method_from_name(cls, ".ctor", 0);
if (!ctor) return nullptr;
void *p = il2cpp_object_new(cls);
void *exc = nullptr;
if (p) il2cpp_runtime_invoke(ctor, p, nullptr, &exc);
return exc ? nullptr : p;
}
static void *hk_TitleAutoLogin(void *thiz, void *cached, void *method) {
if (cached || g_auto_create_account) return orig_TitleAutoLogin(thiz, cached, method);
void *pending = new_pending_promise();
if (!pending || !invoke_void(thiz, "GoToLogin")) {
LOGE("no remembered account, but the sign-in page could not be opened; creating one as Rec Room does");
return orig_TitleAutoLogin(thiz, cached, method);
}
LOGI("no remembered account on this headset; opened the sign-in page instead of creating a new account");
return pending;
}
// ----------------------------------------------------------------- init ------
static uintptr_t module_base(const char *name) {
@@ -516,6 +650,12 @@ static bool resolve_il2cpp(void *h) {
SYM(il2cpp_string_new)
SYM(il2cpp_runtime_invoke)
#undef SYM
// Optional: only the title-screen sign-in redirect needs these.
il2cpp_domain_get = (il2cpp_domain_get_t)dlsym(h, "il2cpp_domain_get");
il2cpp_domain_get_assemblies = (il2cpp_domain_get_assemblies_t)dlsym(h, "il2cpp_domain_get_assemblies");
il2cpp_assembly_get_image = (il2cpp_assembly_get_image_t)dlsym(h, "il2cpp_assembly_get_image");
il2cpp_image_get_name = (il2cpp_image_get_name_t)dlsym(h, "il2cpp_image_get_name");
il2cpp_class_from_name = (il2cpp_class_from_name_t)dlsym(h, "il2cpp_class_from_name");
return true;
}
@@ -537,6 +677,8 @@ static void arm_oculus_platform_bypass() {
return;
}
void *a;
if ((a = dlsym(ovr, "ovr_Message_GetNativeMessage")))
A64HookFunction(a, (void *)hk_ovr_GetNativeMessage, (void **)&orig_ovr_GetNativeMessage);
if ((a = dlsym(ovr, "ovr_GetLoggedInUserID")))
A64HookFunction(a, (void *)hk_ovr_GetLoggedInUserID, (void **)&orig_ovr_GetLoggedInUserID);
if ((a = dlsym(ovr, "ovr_User_GetID")))
@@ -545,6 +687,21 @@ static void arm_oculus_platform_bypass() {
A64HookFunction(a, (void *)hk_ovr_GetUserProof, (void **)&orig_ovr_GetUserProof);
if ((a = dlsym(ovr, "ovr_UserProof_GetNonce")))
A64HookFunction(a, (void *)hk_ovr_GetNonce, (void **)&orig_ovr_GetNonce);
if ((a = dlsym(ovr, "ovr_Message_GetUser")))
A64HookFunction(a, (void *)hk_ovr_Message_GetUser, (void **)&orig_ovr_Message_GetUser);
#define DN_HOOK_USER(name) if ((a = dlsym(ovr, #name))) A64HookFunction(a, (void *)hk_##name, (void **)&orig_##name);
DN_HOOK_USER(ovr_User_GetDisplayName)
DN_HOOK_USER(ovr_User_GetOculusID)
DN_HOOK_USER(ovr_User_GetImageUrl)
DN_HOOK_USER(ovr_User_GetSmallImageUrl)
DN_HOOK_USER(ovr_User_GetInviteToken)
DN_HOOK_USER(ovr_User_GetPresence)
DN_HOOK_USER(ovr_User_GetPresenceDeeplinkMessage)
DN_HOOK_USER(ovr_User_GetPresenceDestinationApiName)
DN_HOOK_USER(ovr_User_GetPresenceLobbySessionId)
DN_HOOK_USER(ovr_User_GetPresenceMatchSessionId)
DN_HOOK_USER(ovr_User_GetPresenceStatus)
#undef DN_HOOK_USER
}
static void *init_thread(void *) {
@@ -562,7 +719,7 @@ static void *init_thread(void *) {
g_photon_realtime, g_photon_voice, g_photon_chat,
g_photon_server, sizeof(g_photon_server), &g_photon_port,
g_photon_region, sizeof(g_photon_region),
&g_tls_bypass, &g_accept_images, &g_log_http, &g_debug_http, &g_body_max);
&g_tls_bypass, &g_accept_images, &g_auto_create_account, &g_log_http, &g_debug_http, &g_body_max);
LOGI("DorkNet.Quest init: build %s/%s il2cpp @ 0x%lx; host-rewrite -> *.%s",
DN_TARGET_VERSION_NAME, DN_TARGET_VERSION_CODE, (unsigned long)g_il2cpp_base, g_server_host);
@@ -586,6 +743,8 @@ static void *init_thread(void *) {
arm(&dn_targets.pin_bc_verifier, (void *)hk_pin_true4, nullptr);
}
arm(&dn_targets.title_auto_login, (void *)hk_TitleAutoLogin, (void **)&orig_TitleAutoLogin);
if (g_accept_images)
arm(&dn_targets.image_signature_verify, (void *)hk_image_true, nullptr);
@@ -111,6 +111,12 @@
"length": 412,
"fingerprint": "0xb12b0044ac4f844f",
"why": "Log-only: prints the quit code and caller."
},
"title_auto_login": {
"rva": 58892048,
"length": 668,
"fingerprint": "0x83bebb9a30983b33",
"why": "Title-screen auto-login. With no remembered account (null) it creates a new account; unless AutoCreateAccount is on, open the sign-in page instead."
}
}
}
+6
View File
@@ -114,6 +114,12 @@
"class": "public class SessionManager ",
"method": "public static void TryApplicationQuit(int DKOGJICFAEP)",
"why": "Log-only: prints the quit code and caller."
},
{
"id": "title_auto_login",
"class": "public class TitleScreenFlowModel ",
"method": "private LDGADANDBIO IAKHIJIHAPJ(LBNJFPOLCDL.MDGJJCFGJNF AJCKAHHJDEN)",
"why": "Title-screen auto-login. With no remembered account (null) it creates a new account; unless AutoCreateAccount is on, open the sign-in page instead."
}
]
}
@@ -0,0 +1,78 @@
using System.Collections.Concurrent;
using System.Text.Json;
using DorkNet.Server.Hubs;
using Microsoft.AspNetCore.SignalR;
namespace DorkNet.Server.Tests;
/// <summary>
/// Stand-in for the notify hub that records every push instead of sending it,
/// so tests can assert on the exact frames the game client would receive.
/// A frame is the hub method plus its single string argument, parsed back into
/// the <c>{Id, Msg}</c> envelope.
/// </summary>
public sealed class CapturingHubContext : IHubContext<NotifyHub>
{
private readonly ConcurrentQueue<SentFrame> _sent = new();
public IHubClients Clients => new CapturingClients(this);
public IGroupManager Groups { get; } = new NoopGroups();
public IReadOnlyList<SentFrame> Sent => _sent.ToArray();
/// <summary>Frames sent to a target such as <c>group:player:12</c>, in
/// order, optionally filtered by envelope <c>Id</c>.</summary>
public IReadOnlyList<SentFrame> To(string target, string? id = null) =>
_sent.Where(f => f.Target == target && (id is null || f.Id == id)).ToList();
public static string PlayerGroup(long playerId) => $"group:{NotifyHub.GroupForPlayer(playerId)}";
internal void Record(string target, IReadOnlyList<string>? excluded, string method, object?[] args) =>
_sent.Enqueue(new SentFrame(target, excluded ?? Array.Empty<string>(), method,
args.Length == 1 && args[0] is string s ? s : JsonSerializer.Serialize(args)));
public sealed record SentFrame(string Target, IReadOnlyList<string> Excluded, string Method, string Payload)
{
public JsonElement Envelope => JsonDocument.Parse(Payload).RootElement;
public string? Id => Envelope.TryGetProperty("Id", out var id) ? id.GetString() : null;
public JsonElement Msg => Envelope.GetProperty("Msg");
}
private sealed class CapturingClients(CapturingHubContext owner) : IHubClients
{
private IClientProxy Proxy(string target, IReadOnlyList<string>? excluded = null) =>
new CapturingProxy(owner, target, excluded);
public IClientProxy All => Proxy("all");
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy("all", excludedConnectionIds);
public IClientProxy Client(string connectionId) => Proxy($"conn:{connectionId}");
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy($"conns:{string.Join(",", connectionIds)}");
public IClientProxy Group(string groupName) => Proxy($"group:{groupName}");
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) =>
Proxy($"group:{groupName}", excludedConnectionIds);
public IClientProxy Groups(IReadOnlyList<string> groupNames) => new FanOutProxy(groupNames.Select(g => Proxy($"group:{g}")).ToList());
public IClientProxy User(string userId) => Proxy($"user:{userId}");
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy($"users:{string.Join(",", userIds)}");
}
private sealed class CapturingProxy(CapturingHubContext owner, string target, IReadOnlyList<string>? excluded) : IClientProxy
{
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
owner.Record(target, excluded, method, args);
return Task.CompletedTask;
}
}
private sealed class FanOutProxy(IReadOnlyList<IClientProxy> proxies) : IClientProxy
{
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) =>
Task.WhenAll(proxies.Select(p => p.SendCoreAsync(method, args, cancellationToken)));
}
private sealed class NoopGroups : IGroupManager
{
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
@@ -9,7 +9,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.13" />
<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" />
@@ -0,0 +1,131 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json.Nodes;
using DorkNet.Server.Controllers.Site;
using DorkNet.Server.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
namespace DorkNet.Server.Tests;
/// <summary>The site's downloads proxy: off without config, only on the site's
/// own host, serves only files the manifest lists, and passes Range through so
/// the browser Quest installer can read an .xapk in place.</summary>
public sealed class DownloadsControllerTests
{
private const string Apex = "dorknet.example.com";
private const string Manifest = """
{"releases":[{"id":"2023.03","title":"March 2023","files":[
{"id":"quest","platform":"quest","name":"DorkNet-2023.03-Quest.xapk","path":"2023.03/DorkNet-2023.03-Quest.xapk","size":10,"sha256":"x"}]}]}
""";
[Fact]
public async Task Without_a_base_url_the_manifest_is_404()
{
var (controller, _) = Create(baseUrl: null, _ => throw new InvalidOperationException("no request expected"));
Assert.IsType<NotFoundObjectResult>(await controller.GetManifest(CancellationToken.None));
}
[Theory]
[InlineData("api.dorknet.example.com")]
[InlineData("cdn.dorknet.example.com")]
public async Task Other_hosts_get_404_without_touching_the_bucket(string host)
{
var (controller, ctx) = Create("http://bucket.internal:3902", _ => throw new InvalidOperationException("no request expected"));
ctx.Request.Host = new HostString(host);
Assert.IsType<NotFoundObjectResult>(await controller.GetManifest(CancellationToken.None));
await controller.GetFile("2023.03/DorkNet-2023.03-Quest.xapk", CancellationToken.None);
Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode);
}
[Fact]
public void Only_listed_paths_are_found()
{
var manifest = (JsonObject)JsonNode.Parse(Manifest)!;
Assert.NotNull(DownloadsService.FindFile(manifest, "2023.03/DorkNet-2023.03-Quest.xapk"));
Assert.Null(DownloadsService.FindFile(manifest, "manifest.json"));
Assert.Null(DownloadsService.FindFile(manifest, "2023.03/../manifest.json"));
}
[Fact]
public async Task Unlisted_file_is_404_and_never_fetched()
{
var requested = new List<string>();
var (controller, ctx) = Create("http://bucket.internal:3902", req =>
{
requested.Add(req.RequestUri!.AbsolutePath);
return Json(Manifest);
});
await controller.GetFile("index.html", CancellationToken.None);
Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode);
Assert.Equal(["/manifest.json"], requested);
}
[Fact]
public async Task Range_is_forwarded_and_the_partial_response_streamed()
{
HttpRequestMessage? fileRequest = null;
var (controller, ctx) = Create("http://bucket.internal:3902/", req =>
{
if (req.RequestUri!.AbsolutePath == "/manifest.json") return Json(Manifest);
fileRequest = req;
var res = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = new ByteArrayContent("abcd"u8.ToArray()) };
res.Content.Headers.ContentRange = new ContentRangeHeaderValue(2, 5, 10);
return res;
}, host: "downloads.example.com");
ctx.Request.Headers.Range = "bytes=2-5";
await controller.GetFile("2023.03/DorkNet-2023.03-Quest.xapk", CancellationToken.None);
Assert.NotNull(fileRequest);
Assert.Equal("/2023.03/DorkNet-2023.03-Quest.xapk", fileRequest!.RequestUri!.AbsolutePath);
Assert.Equal("bytes=2-5", fileRequest.Headers.Range!.ToString());
Assert.Equal("downloads.example.com", fileRequest.Headers.Host);
Assert.Equal(StatusCodes.Status206PartialContent, ctx.Response.StatusCode);
Assert.Equal("bytes 2-5/10", ctx.Response.Headers.ContentRange.ToString());
Assert.Equal("bytes", ctx.Response.Headers.AcceptRanges.ToString());
Assert.Equal("abcd", Encoding.ASCII.GetString(((MemoryStream)ctx.Response.Body).ToArray()));
}
private static (DownloadsController, HttpContext) Create(
string? baseUrl, Func<HttpRequestMessage, HttpResponseMessage> handler, string? host = null)
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["DorkNet:Downloads:BaseUrl"] = baseUrl,
["DorkNet:Downloads:Host"] = host,
}).Build();
var service = new DownloadsService(config, new StubFactory(handler), NullLogger<DownloadsService>.Instance);
var ctx = new DefaultHttpContext();
ctx.Request.Host = new HostString(Apex);
ctx.Response.Body = new MemoryStream();
var controller = new DownloadsController(service, new DomainConfig(Apex))
{
ControllerContext = new ControllerContext { HttpContext = ctx },
};
return (controller, ctx);
}
private static HttpResponseMessage Json(string body) =>
new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
private sealed class StubFactory(Func<HttpRequestMessage, HttpResponseMessage> handler) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new(new StubHandler(handler));
}
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> handler) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
=> Task.FromResult(handler(request));
}
}
+264
View File
@@ -0,0 +1,264 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace DorkNet.Server.Tests;
/// <summary>
/// Website / admin-panel sign-in with an external OpenID Connect provider.
/// The provider round-trip can't run in-process, so each test starts where the
/// provider callback leaves off: a <c>DorkNet.External</c> cookie holding the
/// provider identity, encrypted with the app's own cookie format. Everything
/// from there (link, session, admin SSO, unlink, limits) is the real code.
/// </summary>
public sealed class ExternalSignInTests : IClassFixture<DorkNetServerFactory>
{
private const string Issuer = "https://idp.example.com";
// Test player ids: sequential, in a range no other test uses.
private static long _nextPlayerId = 9_600_000;
private readonly DorkNetServerFactory _factory;
public ExternalSignInTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Linking_with_the_account_password_signs_in_and_the_next_sign_in_needs_no_password()
{
var (player, password) = await PlayerAsync();
var subject = Guid.NewGuid().ToString("N");
using var site = Client();
var link = await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = player.Username, password }, ExternalCookie(subject, "player@example.com"));
Assert.Equal(HttpStatusCode.OK, link.StatusCode);
var session = SessionCookie(link);
Assert.NotNull(session);
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
Assert.True(me.GetProperty("signedIn").GetBoolean());
Assert.Equal(player.Username, me.GetProperty("player").GetProperty("username").GetString());
Assert.Equal("player@example.com", me.GetProperty("player").GetProperty("email").GetString());
// Next time the provider identity alone is enough.
await using var scope = _factory.Services.CreateAsyncScope();
var signIn = scope.ServiceProvider.GetRequiredService<ExternalSignInService>();
var stored = await signIn.FindAsync(Issuer, subject);
Assert.Equal(player.Id, stored?.PlayerId);
}
[Fact]
public async Task Wrong_passwords_are_refused_then_rate_limited()
{
var (player, _) = await PlayerAsync();
var external = ExternalCookie(Guid.NewGuid().ToString("N"), "guess@example.com");
using var site = Client();
for (var i = 0; i < 5; i++)
{
using var r = await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = player.Username, password = "wrong-" + i }, external);
Assert.Equal(HttpStatusCode.Unauthorized, r.StatusCode);
}
using var blocked = await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = player.Username, password = "still-wrong" }, external);
Assert.Equal(HttpStatusCode.TooManyRequests, blocked.StatusCode);
}
[Fact]
public async Task An_identity_linked_to_one_account_cannot_be_linked_to_another()
{
var (first, firstPassword) = await PlayerAsync();
var (second, secondPassword) = await PlayerAsync();
var subject = Guid.NewGuid().ToString("N");
using var site = Client();
using var ok = await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = first.Username, password = firstPassword }, ExternalCookie(subject, "a@example.com"));
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
using var refused = await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = second.Username, password = secondPassword }, ExternalCookie(subject, "a@example.com"));
Assert.Equal(HttpStatusCode.Conflict, refused.StatusCode);
}
[Fact]
public async Task Admin_sso_issues_tokens_only_to_admins()
{
var (admin, adminPassword) = await PlayerAsync(isAdmin: true);
var (regular, regularPassword) = await PlayerAsync();
using var admins = Client("admin");
var adminSession = SessionCookie(await PostJsonAsync(admins, "/api/site/v1/account/link",
new { username = admin.Username, password = adminPassword }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
using var tokens = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", adminSession);
Assert.Equal(HttpStatusCode.OK, tokens.StatusCode);
var body = JsonDocument.Parse(await tokens.Content.ReadAsStringAsync()).RootElement;
Assert.False(string.IsNullOrEmpty(body.GetProperty("access_token").GetString()));
Assert.Equal(admin.Id, body.GetProperty("account_id").GetInt64());
var regularSession = SessionCookie(await PostJsonAsync(admins, "/api/site/v1/account/link",
new { username = regular.Username, password = regularPassword }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
using var refused = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", regularSession);
Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode);
using var anonymous = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", null);
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
}
[Fact]
public async Task Unlinking_ends_the_session()
{
var (player, password) = await PlayerAsync();
using var site = Client();
var session = SessionCookie(await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
using var unlink = await SendAsync(site, HttpMethod.Post, "/api/site/v1/account/unlink", session);
Assert.Equal(HttpStatusCode.OK, unlink.StatusCode);
// The old cookie is still in the browser, but the link behind it is gone.
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
Assert.False(me.GetProperty("signedIn").GetBoolean());
}
[Fact]
public async Task A_banned_player_cannot_link_and_loses_an_existing_session()
{
var (player, password) = await PlayerAsync();
using var site = Client();
var session = SessionCookie(await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
await db.Players.Where(p => p.Id == player.Id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.BannedUntil, DateTime.UtcNow.AddDays(1)));
}
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
Assert.False(me.GetProperty("signedIn").GetBoolean());
using var relink = await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), ""));
Assert.Equal(HttpStatusCode.Forbidden, relink.StatusCode);
}
[Fact]
public async Task Cookie_keys_are_stored_encrypted()
{
var (player, password) = await PlayerAsync();
using var site = Client();
using var link = await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), ""));
Assert.Equal(HttpStatusCode.OK, link.StatusCode);
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var stored = await db.DataProtectionKeys.Select(k => k.Xml).ToListAsync();
Assert.NotEmpty(stored);
Assert.All(stored, xml =>
{
Assert.Contains("dorknetEncryptedKey", xml);
Assert.DoesNotContain("masterKey", xml);
});
}
[Fact]
public void Key_encryption_round_trips_and_rejects_another_secret()
{
var element = new System.Xml.Linq.XElement("key", new System.Xml.Linq.XElement("masterKey", "secret-bytes"));
var encrypted = new DataProtectionKeyEncryption("first-secret").Encrypt(element).EncryptedElement;
Assert.DoesNotContain("secret-bytes", encrypted.ToString());
Assert.Equal(element.ToString(), new DataProtectionKeyEncryption("first-secret").DecryptElement(encrypted).ToString());
Assert.ThrowsAny<System.Security.Cryptography.CryptographicException>(
() => new DataProtectionKeyEncryption("other-secret").DecryptElement(encrypted));
}
[Fact]
public async Task Downloads_can_require_sign_in()
{
await using var factory = _factory.WithWebHostBuilder(b =>
{
b.UseSetting("DorkNet:Downloads:BaseUrl", "http://127.0.0.1:9");
b.UseSetting("DorkNet:Downloads:RequireSignIn", "true");
});
using var site = factory.CreateClient(new() { BaseAddress = new Uri($"http://{_factory.ApexDomain}") });
using var manifest = await site.GetAsync("/api/site/v1/downloads");
Assert.Equal(HttpStatusCode.Unauthorized, manifest.StatusCode);
using var file = await site.GetAsync("/api/site/v1/downloads/files/2023.03/DorkNet-2023.03-Quest.xapk");
Assert.Equal(HttpStatusCode.Unauthorized, file.StatusCode);
}
// ── helpers ────────────────────────────────────────────────────────────
private async Task<(PlayerEntity Player, string Password)> PlayerAsync(bool isAdmin = false)
{
var password = "pw-" + Guid.NewGuid().ToString("N")[..12];
var player = new PlayerEntity
{
Id = Interlocked.Increment(ref _nextPlayerId),
Username = "sso" + Guid.NewGuid().ToString("N")[..10],
DisplayName = "SSO test",
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
IsAdmin = isAdmin,
};
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.Players.Add(player);
await db.SaveChangesAsync();
return (player, password);
}
/// <summary>The cookie the provider callback leaves behind: the provider
/// identity in the DorkNet.External scheme, in the app's cookie format.</summary>
private string ExternalCookie(string subject, string email)
{
var options = _factory.Services.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>()
.Get(ExternalSignIn.ExternalScheme);
var identity = new ClaimsIdentity(new[]
{
new Claim("iss", Issuer), new Claim("sub", subject), new Claim("email", email),
}, "test");
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), ExternalSignIn.ExternalScheme);
return $"{options.Cookie.Name}={options.TicketDataFormat.Protect(ticket)}";
}
private static string? SessionCookie(HttpResponseMessage response) =>
response.Headers.TryGetValues("Set-Cookie", out var values)
? values.Select(v => v.Split(';')[0]).FirstOrDefault(v => v.StartsWith("dorknet_session=") && v.Length > "dorknet_session=".Length)
: null;
private HttpClient Client(string? subdomain = null)
{
var host = subdomain is null ? _factory.ApexDomain : $"{subdomain}.{_factory.ApexDomain}";
return _factory.CreateClient(new() { BaseAddress = new Uri($"http://{host}"), HandleCookies = false });
}
private static Task<HttpResponseMessage> SendAsync(HttpClient client, HttpMethod method, string path, string? cookie)
{
var request = new HttpRequestMessage(method, path);
if (cookie is not null) request.Headers.Add("Cookie", cookie);
return client.SendAsync(request);
}
private static Task<HttpResponseMessage> PostJsonAsync(HttpClient client, string path, object body, string? cookie)
{
var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(body) };
if (cookie is not null) request.Headers.Add("Cookie", cookie);
return client.SendAsync(request);
}
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path, string? cookie)
{
using var response = await SendAsync(client, HttpMethod.Get, path, cookie);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
}
@@ -0,0 +1,198 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Controllers.API.Inventory;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Hubs;
using DorkNet.Server.Services;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace DorkNet.Server.Tests;
/// <summary>
/// Push frames checked against the 2023-03-21 client's notification readers
/// (RecNet.Runtime). Every push is the hub method "Notification" with one
/// JSON string <c>{"Id":"&lt;key&gt;","Msg":{…}}</c>; the client looks the Id
/// up case-sensitively and parses Msg strictly into the handler's DTO, so a
/// wrong key or shape drops the push without any error.
/// </summary>
public sealed class NotificationContractTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public NotificationContractTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Friend_request_pushes_each_side_its_own_relationship_and_an_inbox_message()
{
var (factory, hub) = WithCapturingHub();
using var setup = ApiClient(factory);
var sender = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var target = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(factory, sender);
(await client.PostAsync($"/api/relationships/v2/sendfriendrequest?id={target.PlayerId}", null))
.EnsureSuccessStatusCode();
// RelationshipChanged "1" = the Relationship row from each side's view.
var toTarget = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "1"));
Assert.Equal("Notification", toTarget.Method);
Assert.Equal(sender.PlayerId, toTarget.Msg.GetProperty("PlayerID").GetInt64());
Assert.Equal(2, toTarget.Msg.GetProperty("RelationshipType").GetInt32()); // FriendRequestReceived
Assert.False(toTarget.Msg.TryGetProperty("Reason", out _));
var toSender = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "1"));
Assert.Equal(target.PlayerId, toSender.Msg.GetProperty("PlayerID").GetInt64());
Assert.Equal(1, toSender.Msg.GetProperty("RelationshipType").GetInt32()); // FriendRequestSent
// MessageReceived "2" — the "X sent you a friend request" inbox row.
var invite = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "2"));
Assert.Equal(4, invite.Msg.GetProperty("Type").GetInt32()); // FriendInvite
Assert.Equal(sender.PlayerId, invite.Msg.GetProperty("FromPlayerId").GetInt64());
using var targetClient = ApiClient(factory, target);
(await targetClient.PostAsync($"/api/relationships/v2/acceptfriendrequest?id={sender.PlayerId}", null))
.EnsureSuccessStatusCode();
Assert.Equal(3, hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "1")[^1]
.Msg.GetProperty("RelationshipType").GetInt32());
Assert.Equal(3, hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "1")[^1]
.Msg.GetProperty("RelationshipType").GetInt32());
var accepted = hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "2")[^1];
Assert.Equal(40, accepted.Msg.GetProperty("Type").GetInt32()); // FriendRequestAccepted
}
[Fact]
public async Task Favorite_pushes_the_callers_updated_row_to_the_caller_only()
{
var (factory, hub) = WithCapturingHub();
using var setup = ApiClient(factory);
var me = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var other = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(factory, me);
(await client.GetAsync($"/api/relationships/v1/favorite?id={other.PlayerId}")).EnsureSuccessStatusCode();
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(me.PlayerId), "1"));
Assert.Equal(other.PlayerId, frame.Msg.GetProperty("PlayerID").GetInt64());
Assert.Equal(1, frame.Msg.GetProperty("Favorited").GetInt32());
Assert.Empty(hub.To(CapturingHubContext.PlayerGroup(other.PlayerId), "1"));
}
[Fact]
public async Task Kick_is_the_flat_moderation_block_with_the_reason_in_VoteKickReason()
{
var (factory, hub) = WithCapturingHub();
await using var scope = factory.Services.CreateAsyncScope();
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
await notifications.KickPlayerAsync(42, "Too loud", gameSessionId: 777);
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(42), "22"));
var msg = frame.Msg;
// The handler only shows a reason for ReportCategory 10 + !IsHostKick.
Assert.Equal(10, msg.GetProperty("ReportCategory").GetInt32());
Assert.False(msg.GetProperty("IsHostKick").GetBoolean());
Assert.Equal("Too loud", msg.GetProperty("VoteKickReason").GetString());
Assert.Equal(777, msg.GetProperty("GameSessionId").GetInt64());
Assert.Equal(0, msg.GetProperty("Duration").GetInt32());
Assert.False(msg.TryGetProperty("Reason", out _)); // the old nested 2020 shape
}
[Theory]
[InlineData(NotificationService.LogoutReason.SessionTakeover, 2)]
[InlineData(NotificationService.LogoutReason.ForciblyLoggedOut, 3)]
[InlineData(NotificationService.LogoutReason.Banned, 4)]
public async Task Logout_reason_is_the_numeric_enum(NotificationService.LogoutReason reason, int wire)
{
var (factory, hub) = WithCapturingHub();
await using var scope = factory.Services.CreateAsyncScope();
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
await notifications.LogoutAsync(43, reason);
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(43), "6"));
var value = frame.Msg.GetProperty("Reason");
Assert.Equal(JsonValueKind.Number, value.ValueKind);
Assert.Equal(wire, value.GetInt32());
}
[Fact]
public async Task Presence_for_subscribers_goes_to_the_subjects_presence_group_minus_excluded_connections()
{
var (factory, hub) = WithCapturingHub();
await using var scope = factory.Services.CreateAsyncScope();
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
await notifications.PresenceToSubscribersAsync(55, room: null, isOnline: true, statusVisibility: 0,
excludeConnectionIds: ["self-conn", "friend-conn"]);
var frame = Assert.Single(hub.To($"group:{NotifyHub.PresenceGroupForPlayer(55)}", "PresenceUpdate"));
Assert.Equal(["self-conn", "friend-conn"], frame.Excluded);
Assert.Equal(55, frame.Msg.GetProperty("playerId").GetInt32());
Assert.True(frame.Msg.GetProperty("isOnline").GetBoolean());
Assert.Equal(JsonValueKind.String, frame.Msg.GetProperty("appVersion").ValueKind);
}
[Theory]
[InlineData("""{"PlayerIds":[1,2,3]}""", new long[] { 1, 2, 3 })]
[InlineData("""{"playerIds":["4",5]}""", new long[] { 4, 5 })]
[InlineData("""[6,7]""", new long[] { 6, 7 })]
[InlineData("""{"PlayerIds":[]}""", new long[0])]
public void SubscribeToPlayers_payload_is_read_in_any_casing(string json, long[] expected)
{
Assert.True(NotifyHub.TryReadPlayerIds(JsonDocument.Parse(json).RootElement, out var ids));
Assert.Equal(expected.OrderBy(x => x), ids.OrderBy(x => x));
}
[Fact]
public void Unreadable_SubscribeToPlayers_payload_is_rejected_not_read_as_empty()
{
Assert.False(NotifyHub.TryReadPlayerIds(JsonDocument.Parse("""{"Channels":[]}""").RootElement, out _));
}
[Fact]
public void Consumable_mapping_push_has_exactly_the_clients_keys()
{
var wire = JsonSerializer.SerializeToElement(InventoryController.ConsumableMappingWire(new PlayerInventoryEntity
{
Id = 9,
ItemSlug = "some-consumable",
Quantity = 3,
}));
var keys = wire.EnumerateObject().Select(p => p.Name).ToHashSet();
Assert.Equal(
new HashSet<string> { "Id", "ConsumableItemDesc", "Count", "InitialCount", "CreatedAt", "ActiveDurationMinutes", "IsActive", "IsTransferable" },
keys);
Assert.Equal(JsonValueKind.String, wire.GetProperty("ConsumableItemDesc").ValueKind);
Assert.Equal(3, wire.GetProperty("Count").GetInt32());
}
private (WebApplicationFactory<Program> Factory, CapturingHubContext Hub) WithCapturingHub()
{
var hub = new CapturingHubContext();
var factory = _factory.WithWebHostBuilder(builder =>
builder.ConfigureServices(services =>
{
services.RemoveAll<IHubContext<NotifyHub>>();
services.AddSingleton<IHubContext<NotifyHub>>(hub);
}));
return (factory, hub);
}
private HttpClient ApiClient(WebApplicationFactory<Program> factory, GameClientSession? session = null)
{
var client = factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
@@ -215,6 +215,88 @@ public sealed class OwnedAvatarItemsTests : IClassFixture<DorkNetServerFactory>
}
}
[Fact]
public async Task Owned_items_carry_the_same_name_tooltip_and_rarity_as_their_shop_tile()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
await using (var scope = _factory.Services.CreateAsyncScope())
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
.SetAllAvatarItemsOwnedEnabledAsync(true);
try
{
using var client = ApiClient(player);
var owned = await GetJsonAsync(client, "/api/avatar/v4/items");
var shop = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
var tiles = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
foreach (var row in shop.GetProperty("StoreItems").EnumerateArray())
{
var drop = row.GetProperty("GiftDrop");
var desc = drop.GetProperty("AvatarItemDesc").GetString();
if (!string.IsNullOrEmpty(desc)) tiles.TryAdd(desc, drop);
}
var matched = 0;
foreach (var item in owned.EnumerateArray())
{
if (!tiles.TryGetValue(item.GetProperty("AvatarItemDesc").GetString()!, out var tile)) continue;
matched++;
Assert.Equal(tile.GetProperty("FriendlyName").GetString(), item.GetProperty("FriendlyName").GetString());
Assert.Equal(tile.GetProperty("Tooltip").GetString(), item.GetProperty("Tooltip").GetString());
Assert.Equal(tile.GetProperty("Rarity").GetInt32(), item.GetProperty("Rarity").GetInt32());
}
Assert.True(matched > 0, "no owned item matched a shop tile");
}
finally
{
await using var scope = _factory.Services.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
.SetAllAvatarItemsOwnedEnabledAsync(false);
}
}
[Fact]
public async Task Base_avatar_items_are_only_the_custom_clothing_templates()
{
// The client flags every defaultbaseavataritems row as a custom-
// clothing template and leaves it out of the owned rebuild, so a
// normal item listed here showed a price even when owned.
using var anonymous = ApiClient();
var rows = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultbaseavataritems");
var byDesc = rows.EnumerateArray().ToDictionary(
r => r.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0],
r => r);
Assert.Equal(2, byDesc.Count);
Assert.Equal(2184, byDesc["c5d70cb4-71dd-4fe4-b719-34fe2073c611"].GetProperty("AvatarItemId").GetInt32());
Assert.Equal(2918, byDesc["95a519de-f2cb-429c-b014-508477f20d42"].GetProperty("AvatarItemId").GetInt32());
Assert.All(byDesc.Values, r => Assert.True(r.GetProperty("IsBaseAvatarItem").GetBoolean()));
// Templates aren't sold or listed as owned clothing.
var shop = await GetJsonAsync(anonymous, "/api/storefronts/v3/giftdropstore/3");
var shopDescs = shop.GetProperty("StoreItems").EnumerateArray()
.Select(r => r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString() ?? "");
Assert.DoesNotContain(shopDescs, d => byDesc.ContainsKey(d.Split(',')[0]));
await using (var scope = _factory.Services.CreateAsyncScope())
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
.SetAllAvatarItemsOwnedEnabledAsync(true);
try
{
var owned = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultunlocked");
var ownedDescs = owned.EnumerateArray().Select(r => r.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0]).ToList();
Assert.DoesNotContain(ownedDescs, byDesc.ContainsKey);
Assert.Contains("3c2ed30c-ca2a-4f9f-9bfa-ffe561c4a686", ownedDescs); // 80sHipHopJacket
}
finally
{
await using var scope = _factory.Services.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
.SetAllAvatarItemsOwnedEnabledAsync(false);
}
}
// Beard_Close: one of the client's defaultUnlockedVariants.
private const string DefaultItemGuid = "8d10cc78-6b00-45f3-affb-205e9cc5b03f";
@@ -42,7 +42,7 @@ public sealed class PrivateRoomInstanceTests : IClassFixture<DorkNetServerFactor
var text = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}");
using var json = JsonDocument.Parse(text);
var cloneId = json.RootElement.GetProperty("RoomId").GetInt64();
var cloneId = json.RootElement.GetProperty("value").GetProperty("RoomId").GetInt64();
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
+31 -7
View File
@@ -10,13 +10,15 @@ using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// The 2023 "copy room" flow POSTs <c>rooms/{id}/clone</c> and deserialises the
/// reply into <c>FGCPNAACHIK</c> — the SAME type <c>GET rooms/{id}</c> returns
/// (RecNet.Runtime/NLDBPDCNNCF.txt:4676 declares
/// The 2023 "copy room" flow POSTs <c>rooms/{id}/clone</c> and reads the reply
/// as the <c>{success, value, error}</c> envelope (the same RecNet rooms helper
/// as "save room"), whose <c>value</c> is <c>FGCPNAACHIK</c> — the SAME type
/// <c>GET rooms/{id}</c> returns (RecNet.Runtime/NLDBPDCNNCF.txt:4676 declares
/// <c>Task&lt;FGCPNAACHIK&gt; GDHIIAHCBMN(Int64, String, CancellationToken)</c>,
/// with the route literal on :4802).
/// with the route literal on :4802). A bare room with no <c>success</c> is
/// rejected with an empty error.
///
/// So the two responses must be shape-identical. When they drift, the server
/// So <c>value</c> and the GET response must be shape-identical. When they drift, the server
/// still answers 200 and the client throws while reading it — and because the
/// clone promise's exception is never observed, it surfaces from the finalizer
/// thread seconds later as a bare "Failed to copy room: Exception of type
@@ -63,8 +65,12 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
}
var getBody = await ReadJsonAsync(client, HttpMethod.Get, $"/rooms/{sourceId}");
var cloneBody = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
var cloneEnvelope = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
// The client reads clone through the {success, value, error} envelope.
Assert.True(cloneEnvelope.GetProperty("success").GetBoolean(), cloneEnvelope.ToString());
Assert.Equal("", cloneEnvelope.GetProperty("error").GetString());
var cloneBody = cloneEnvelope.GetProperty("value");
var expected = getBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
var actual = cloneBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
@@ -157,7 +163,7 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
var clone = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
var cloneId = clone.GetProperty("RoomId").GetInt64();
var cloneId = clone.GetProperty("value").GetProperty("RoomId").GetInt64();
await using (var scope = factory.Services.CreateAsyncScope())
{
@@ -203,6 +209,24 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
Task.FromResult(_objects.ContainsKey($"{bucket}/{key}"));
}
[Fact]
public async Task Clone_refusal_is_a_200_envelope_with_a_message()
{
using var client = Client();
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", session.AccessToken);
// No room has this id, so the clone is refused; the client shows
// `error`, which a bare 404 would leave empty.
var envelope = await ReadJsonAsync(client, HttpMethod.Post, "/rooms/987654321987/clone",
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", "NoSuchSource")]));
Assert.False(envelope.GetProperty("success").GetBoolean());
Assert.False(string.IsNullOrEmpty(envelope.GetProperty("error").GetString()));
Assert.Equal(JsonValueKind.Null, envelope.GetProperty("value").ValueKind);
}
private static async Task<JsonElement> ReadJsonAsync(
HttpClient client, HttpMethod method, string path, HttpContent? content = null)
{
@@ -0,0 +1,133 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// "Publish room" in the 2023 client: RoomModel.TryPublishRoom →
/// <c>PUT roomserver/rooms/{id}/accessibility</c> with form
/// <c>accessibility=1</c> (boxed Int32, NLDBPDCNNCF_NestedType_OMHIIEAKHCH),
/// typed <c>Task&lt;FGCPNAACHIK&gt;</c>. The button only exists when
/// get_CanPublishRoom holds: role ≥ CoOwner, not a dorm, <b>not IsRRO</b>,
/// not already public. A clone of a Rec Room Original template used to be
/// advertised as IsRRO and so could never be published.
/// </summary>
public sealed class RoomPublish2023Tests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public RoomPublish2023Tests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public void Only_system_owned_originals_are_RRO_on_the_wire()
{
Assert.True(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = true, CreatorPlayerId = PlayerService.SystemAccountId }));
Assert.False(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = true, CreatorPlayerId = 4242 }));
Assert.False(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = false, CreatorPlayerId = PlayerService.SystemAccountId }));
}
[Fact]
public async Task Clone_of_an_original_is_not_RRO_and_can_be_published_by_owner_and_co_owner()
{
using var setup = Client("api");
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var coOwner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var moderator = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var templateId = 9_100_000 + Random.Shared.Next(1, 99_999);
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.Rooms.Add(new RoomEntity
{
Id = templateId,
Name = $"Tmpl{Guid.NewGuid():N}"[..18],
CreatorPlayerId = PlayerService.SystemAccountId,
IsAGRoom = true,
Accessibility = 1,
CloningAllowed = false,
ImageName = "image_RecCenter.png",
LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
});
db.RoomScenes.Add(new RoomSceneEntity { RoomId = templateId, OrderIndex = 0, Name = "Home" });
await db.SaveChangesAsync();
}
using var ownerRooms = Client("rooms", owner);
var template = await GetJsonAsync(ownerRooms, $"/roomserver/rooms/{templateId}");
Assert.True(template.GetProperty("IsRRO").GetBoolean());
using var cloneResp = await ownerRooms.PostAsync($"/roomserver/rooms/{templateId}/clone",
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = $"Pub{Guid.NewGuid():N}"[..20] }));
var cloneText = await cloneResp.Content.ReadAsStringAsync();
Assert.True(cloneResp.IsSuccessStatusCode, cloneText);
var clone = JsonDocument.Parse(cloneText).RootElement.GetProperty("value").Clone();
var cloneId = clone.GetProperty("RoomId").GetInt64();
Assert.False(clone.GetProperty("IsRRO").GetBoolean());
Assert.Equal(0, clone.GetProperty("Accessibility").GetInt32());
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.RoomRoles.Add(new RoomRoleEntity { RoomId = cloneId, PlayerId = coOwner.PlayerId, Role = RoomRoleRules.StoredCoOwner, Accepted = true });
db.RoomRoles.Add(new RoomRoleEntity { RoomId = cloneId, PlayerId = moderator.PlayerId, Role = RoomRoleRules.StoredModerator, Accepted = true });
await db.SaveChangesAsync();
}
// Publish as the owner: full room details back, now public, still not RRO.
var published = await PutAccessibilityAsync(ownerRooms, cloneId, "1");
Assert.Equal(1, published.GetProperty("Accessibility").GetInt32());
Assert.False(published.GetProperty("IsRRO").GetBoolean());
Assert.True(published.TryGetProperty("SubRooms", out _));
// A moderator may not change accessibility; a co-owner may.
using var modRooms = Client("rooms", moderator);
using (var denied = await modRooms.PutAsync($"/roomserver/rooms/{cloneId}/accessibility",
new FormUrlEncodedContent(new Dictionary<string, string> { ["accessibility"] = "0" })))
{
// Refusals are 200 {success:false, error} so the client can show them.
using var doc = JsonDocument.Parse(await denied.Content.ReadAsStringAsync());
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
}
using var coRooms = Client("rooms", coOwner);
var unpublished = await PutAccessibilityAsync(coRooms, cloneId, "0");
Assert.Equal(0, unpublished.GetProperty("Accessibility").GetInt32());
}
private static async Task<JsonElement> PutAccessibilityAsync(HttpClient client, long roomId, string value)
{
using var response = await client.PutAsync($"/roomserver/rooms/{roomId}/accessibility",
new FormUrlEncodedContent(new Dictionary<string, string> { ["accessibility"] = value }));
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {body}");
// "change room accessibility" goes through the {success, value, error}
// helper (NLDBPDCNNCF.BDBHCEGKGDC<FGCPNAACHIK>), same as clone.
using var doc = JsonDocument.Parse(body);
Assert.True(doc.RootElement.GetProperty("success").GetBoolean(), body);
return doc.RootElement.GetProperty("value").Clone();
}
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
{
using var response = await client.GetAsync(path);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
private HttpClient Client(string subdomain, GameClientSession? session = null)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
+528
View File
@@ -0,0 +1,528 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// 2023 room roles, the co-owner invite flow and the sub-room role-permission
/// matrix, against the client contract:
/// <list type="bullet">
/// <item>Role enum OMMBGJMJJPN: None 0, Host 10, Moderator 20, CoOwner 30,
/// TemporaryCoOwner 31, Creator 255. The creator must be listed as 255 —
/// the client takes the local role from the Roles list and only lets a
/// player edit roles strictly below their own, Creator excepted
/// (OMJKHJLFOCO.DIIMLMKEJLC).</item>
/// <item>Invite: owner <c>PUT rooms/{id}/roles/{target}/invite</c> role=30;
/// invitee gets message Type 62 with RoomId; accepts with
/// <c>PUT rooms/{id}/roles/{me}</c> role=30, rejects with
/// <c>PUT rooms/{id}/roles/{me}/invite</c> role=0.</item>
/// <item>Permissions: <c>PUT rooms/{id}/subrooms/{sub}/permissions</c> raw
/// JSON array of {Permission, Role, Override, Type, Value}; read back from
/// <c>GET photon_access_token</c>.</item>
/// </list>
/// </summary>
public sealed class RoomRoles2023Tests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public RoomRoles2023Tests(DorkNetServerFactory factory) => _factory = factory;
// ── pure rules ──────────────────────────────────────────────────────
private static RoomEntity Room(long creator) => new() { Id = 42, CreatorPlayerId = creator };
private static RoomRoleEntity Row(long player, int stored, bool accepted = true) =>
new() { RoomId = 42, PlayerId = player, Role = stored, Accepted = accepted };
[Fact]
public void Creator_is_255_and_listed_once()
{
var room = Room(creator: 7);
var wires = RoomRoleRules.BuildWires(room, new[]
{
Row(7, RoomRoleRules.StoredCoOwner), // a stray row for the creator
Row(8, RoomRoleRules.StoredModerator),
Row(9, RoomRoleRules.StoredCoOwner, accepted: false),
});
Assert.Single(wires, w => w.AccountId == 7);
Assert.Equal(255, wires.Single(w => w.AccountId == 7).Role);
Assert.Equal(20, wires.Single(w => w.AccountId == 8).Role);
var invited = wires.Single(w => w.AccountId == 9);
Assert.Equal(0, invited.Role);
Assert.Equal(30, invited.InvitedRole);
}
[Fact]
public void Creator_may_edit_every_role_including_co_owner_and_creator_permissions()
{
var room = Room(creator: 7);
var rows = Array.Empty<RoomRoleEntity>();
foreach (var role in new[] { 0, 10, 20, 30, 31, 255 })
Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 7, role), $"creator role {role}");
}
[Fact]
public void Co_owner_may_edit_only_roles_below_their_own()
{
var room = Room(creator: 7);
var rows = new[] { Row(8, RoomRoleRules.StoredCoOwner), Row(9, RoomRoleRules.StoredModerator) };
Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 20));
Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 0));
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 30));
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 31));
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 255));
// A moderator edits nothing.
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 9, 0));
// Nor does a stranger.
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 99, 0));
}
[Fact]
public void Role_changes_follow_the_ladder()
{
var room = Room(creator: 7);
var rows = new List<RoomRoleEntity>
{
Row(8, RoomRoleRules.StoredCoOwner),
Row(9, RoomRoleRules.StoredModerator),
Row(10, RoomRoleRules.StoredCoOwner, accepted: false),
};
// Creator: anything on anyone but themselves.
Assert.Null(RoomRoleRules.CheckChange(room, rows, 7, 8, 0, false, out var k));
Assert.Equal(RoomRoleRules.ChangeKind.Clear, k);
Assert.Null(RoomRoleRules.CheckChange(room, rows, 7, 11, 30, true, out k));
Assert.Equal(RoomRoleRules.ChangeKind.Invite, k);
Assert.Equal("cannot_change_creator_role", RoomRoleRules.CheckChange(room, rows, 7, 7, 0, false, out _));
Assert.Equal("cannot_change_creator_role", RoomRoleRules.CheckChange(room, rows, 8, 7, 0, false, out _));
// Co-owner: only below 30, on targets below 30.
Assert.Null(RoomRoleRules.CheckChange(room, rows, 8, 11, 20, false, out _));
Assert.Null(RoomRoleRules.CheckChange(room, rows, 8, 9, 10, false, out _));
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 8, 11, 30, true, out _));
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 8, 10, 0, true, out _)); // invited co-owner
// …and cannot remove a peer co-owner.
Assert.Equal("forbidden", RoomRoleRules.CheckChange(
room, new List<RoomRoleEntity> { Row(8, 0), Row(12, 0) }, 8, 12, 0, false, out _));
// Moderator and strangers manage nobody.
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 9, 11, 10, false, out _));
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 99, 11, 10, false, out _));
// Self-service: accept exactly the invited role, decline, leave.
Assert.Null(RoomRoleRules.CheckChange(room, rows, 10, 10, 30, false, out k));
Assert.Equal(RoomRoleRules.ChangeKind.AcceptInvite, k);
Assert.Null(RoomRoleRules.CheckChange(room, rows, 10, 10, 0, true, out k));
Assert.Equal(RoomRoleRules.ChangeKind.Clear, k);
Assert.Null(RoomRoleRules.CheckChange(room, rows, 9, 9, 0, false, out _));
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 9, 9, 30, false, out _)); // no invite
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 10, 10, 20, false, out _)); // not the invited role
Assert.Equal("invalid_role", RoomRoleRules.CheckChange(room, rows, 7, 11, 255, false, out _));
Assert.Equal("invalid_role", RoomRoleRules.CheckChange(room, rows, 7, 11, 31, false, out _));
}
[Fact]
public void Permission_matrix_merges_stored_cells_over_defaults()
{
var merged = RoomPermissionMatrix.Merge(new[]
{
new RoomRolePermissionEntity { Id = 1, Role = 30, Permission = "13", Type = 0, Override = true, Value = "false" },
new RoomRolePermissionEntity { Id = 2, Role = 20, Permission = "13", Type = 0, Override = true, Value = "true" },
});
Assert.Equal("false", merged.Single(c => c.Role == 30 && c.Permission == "13").Value);
Assert.Equal("true", merged.Single(c => c.Role == 20 && c.Permission == "13").Value);
Assert.Equal("true", merged.Single(c => c.Role == 255 && c.Permission == "13").Value);
Assert.Equal(RoomPermissionMatrix.Defaults.Count + 1, merged.Count);
}
[Fact]
public void Permission_body_accepts_numbers_strings_and_enum_names()
{
var cells = RoomPermissionMatrix.Parse(
"""[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"false"},""" +
"""{"permission":"CAN_USE_MAKER_PEN","role":"Moderator","override":"true","type":"Bool","value":true},""" +
"""{"Permission":"20","Role":"255","Override":false,"Type":1,"Value":5}]""");
Assert.NotNull(cells);
Assert.Equal(3, cells!.Count);
Assert.Equal(new RoomPermissionMatrix.Cell("13", 30, true, 0, "false"), cells[0]);
Assert.Equal(new RoomPermissionMatrix.Cell("CAN_USE_MAKER_PEN", 20, true, 0, "true"), cells[1]);
Assert.Equal(new RoomPermissionMatrix.Cell("20", 255, false, 1, "5"), cells[2]);
Assert.Null(RoomPermissionMatrix.Parse("role=30"));
}
// ── over HTTP ───────────────────────────────────────────────────────
[Fact]
public async Task Roles_list_reports_the_creator_as_Creator_255()
{
var (owner, _, _) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId);
using var rooms = Client("rooms", owner);
var list = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}/roles");
var me = list.EnumerateArray().Single(e => e.GetProperty("AccountId").GetInt64() == owner.PlayerId);
Assert.Equal(255, me.GetProperty("Role").GetInt32());
var details = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}");
var detailsRole = details.GetProperty("Roles").EnumerateArray()
.Single(e => e.GetProperty("AccountId").GetInt64() == owner.PlayerId);
Assert.Equal(255, detailsRole.GetProperty("Role").GetInt32());
var single = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}/roles/{owner.PlayerId}");
Assert.Equal(255, single.GetProperty("Role").GetInt32());
}
[Fact]
public async Task Co_owner_invite_is_notified_then_accepted_by_the_invitee()
{
var (owner, invitee, _) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId);
using var ownerRooms = Client("rooms", owner);
var invited = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
var inviteeRow = RoleOf(invited, invitee.PlayerId);
Assert.Equal(0, inviteeRow.GetProperty("Role").GetInt32());
Assert.Equal(30, inviteeRow.GetProperty("InvitedRole").GetInt32());
// The invitee's inbox carries message Type 62 (RoomCoOwnerInvited) with RoomId.
long messageId;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var msg = await db.Messages.SingleAsync(m =>
m.RecipientPlayerId == invitee.PlayerId && m.RoomId == roomId
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerInvited);
Assert.Equal(owner.PlayerId, msg.SenderPlayerId);
Assert.Null(msg.ReadAt);
messageId = msg.Id;
}
// Accept: the invitee PUTs their own role to 30.
using var inviteeRooms = Client("rooms", invitee);
var accepted = await PutFormAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}", ("role", "30"));
var acceptedRow = RoleOf(accepted, invitee.PlayerId);
Assert.Equal(30, acceptedRow.GetProperty("Role").GetInt32());
Assert.Equal(0, acceptedRow.GetProperty("InvitedRole").GetInt32());
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
Assert.NotNull((await db.Messages.SingleAsync(m => m.Id == messageId)).ReadAt);
Assert.True(await db.RoomRoles.AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == invitee.PlayerId && r.Accepted && r.Role == RoomRoleRules.StoredCoOwner));
}
}
[Fact]
public async Task Invitee_rejects_through_the_invite_route_and_cannot_self_promote()
{
var (owner, invitee, stranger) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId);
using var ownerRooms = Client("rooms", owner);
await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
using var inviteeRooms = Client("rooms", invitee);
var rejected = await PutFormAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "0"));
Assert.DoesNotContain(rejected.GetProperty("Roles").EnumerateArray(),
e => e.GetProperty("AccountId").GetInt64() == invitee.PlayerId);
// With the invite gone, the same accept is refused.
using var again = await PutFormRawAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}", ("role", "30"));
await AssertRefusedAsync(again);
// A stranger can neither promote themselves nor invite anyone.
using var strangerRooms = Client("rooms", stranger);
using var self = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{stranger.PlayerId}", ("role", "20"));
await AssertRefusedAsync(self);
using var other = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
await AssertRefusedAsync(other);
}
[Fact]
public async Task Co_owner_manages_roles_below_but_not_co_owners()
{
var (owner, coOwner, target) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId, (coOwner.PlayerId, RoomRoleRules.StoredCoOwner));
using var coRooms = Client("rooms", coOwner);
var granted = await PutFormAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "20"));
Assert.Equal(20, RoleOf(granted, target.PlayerId).GetProperty("Role").GetInt32());
using var inviteCo = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}/invite", ("role", "30"));
await AssertRefusedAsync(inviteCo);
// The creator can directly add and remove a co-owner.
using var ownerRooms = Client("rooms", owner);
var promoted = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "30"));
Assert.Equal(30, RoleOf(promoted, target.PlayerId).GetProperty("Role").GetInt32());
// …and now the co-owner can't touch their peer.
using var demote = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0"));
await AssertRefusedAsync(demote);
var removed = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0"));
Assert.DoesNotContain(removed.GetProperty("Roles").EnumerateArray(),
e => e.GetProperty("AccountId").GetInt64() == target.PlayerId);
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
Assert.True(await db.Messages.AnyAsync(m => m.RecipientPlayerId == target.PlayerId && m.RoomId == roomId
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerAdded));
Assert.True(await db.Messages.AnyAsync(m => m.RecipientPlayerId == target.PlayerId && m.RoomId == roomId
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerRemoved));
}
[Fact]
public async Task Creator_saves_co_owner_and_creator_permissions_and_reads_them_back()
{
var (owner, coOwner, _) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId, (coOwner.PlayerId, RoomRoleRules.StoredCoOwner));
using var ownerRooms = Client("rooms", owner);
var body = """[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"false"},{"Permission":"20","Role":255,"Override":true,"Type":0,"Value":"false"}]""";
using (var put = await PutJsonRawAsync(ownerRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions", body))
{
var text = await put.Content.ReadAsStringAsync();
Assert.True(put.IsSuccessStatusCode, $"{(int)put.StatusCode}: {text}");
// {success, value: full room details, error}
var value = Envelope(text);
Assert.Equal(roomId, value.GetProperty("RoomId").GetInt64());
}
// The owner enters the room, then the matrix comes back with the token.
using var match = Client("match", owner);
using (var join = await match.PostAsync($"/matchmake/room/{roomId}", new FormUrlEncodedContent(new Dictionary<string, string>())))
Assert.True(join.IsSuccessStatusCode, await join.Content.ReadAsStringAsync());
var token = await GetJsonAsync(ownerRooms, "/roomserver/photon_access_token");
var cells = token.GetProperty("Permissions").EnumerateArray().ToList();
Assert.Equal("false", cells.Single(c => c.GetProperty("Role").GetInt32() == 30
&& c.GetProperty("Permission").GetString() == "13").GetProperty("Value").GetString());
Assert.Equal("false", cells.Single(c => c.GetProperty("Role").GetInt32() == 255
&& c.GetProperty("Permission").GetString() == "20").GetProperty("Value").GetString());
Assert.Equal("true", cells.Single(c => c.GetProperty("Role").GetInt32() == 255
&& c.GetProperty("Permission").GetString() == "13").GetProperty("Value").GetString());
// A co-owner may not edit the Co-Owner row, but may edit Moderator.
using var coRooms = Client("rooms", coOwner);
using (var denied = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
"""[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"true"}]"""))
await AssertRefusedAsync(denied);
using (var allowed = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
"""[{"Permission":"13","Role":20,"Override":true,"Type":0,"Value":"true"}]"""))
Envelope(await allowed.Content.ReadAsStringAsync());
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var stored = await db.RoomRolePermissions.Where(p => p.RoomId == roomId).ToListAsync();
Assert.Equal(3, stored.Count);
Assert.All(stored, p => Assert.Equal(0, p.SubRoomId)); // wire 1 → OrderIndex 0
Assert.Equal("false", stored.Single(p => p.Role == 30).Value); // the denied write changed nothing
}
[Fact]
public async Task Invite_pushes_the_message_and_room_and_permission_saves_push_the_access_token()
{
var hub = new CapturingHubContext();
var factory = _factory.WithWebHostBuilder(builder =>
builder.ConfigureServices(services =>
{
Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions
.RemoveAll<Microsoft.AspNetCore.SignalR.IHubContext<DorkNet.Server.Hubs.NotifyHub>>(services);
services.AddSingleton<Microsoft.AspNetCore.SignalR.IHubContext<DorkNet.Server.Hubs.NotifyHub>>(hub);
}));
HttpClient FactoryClient(string sub, GameClientSession? session = null)
{
var c = factory.CreateClient(new() { AllowAutoRedirect = false });
c.BaseAddress = new Uri($"http://{sub}.{_factory.ApexDomain}");
c.DefaultRequestHeaders.Accept.ParseAdd("application/json");
c.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
if (session is not null)
c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return c;
}
using var setup = FactoryClient("api");
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var invitee = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var roomId = await SeedRoomAsync(owner.PlayerId);
using var ownerRooms = FactoryClient("rooms", owner);
await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
var message = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(invitee.PlayerId), "2"));
Assert.Equal(62, message.Msg.GetProperty("Type").GetInt32());
Assert.Equal(roomId, message.Msg.GetProperty("RoomId").GetInt64());
Assert.Equal((int)owner.PlayerId, message.Msg.GetProperty("FromPlayerId").GetInt32());
var roomPush = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(invitee.PlayerId), "RoomUpdate"));
Assert.Equal(30, RoleOf(roomPush.Msg, invitee.PlayerId).GetProperty("InvitedRole").GetInt32());
using var match = FactoryClient("match", owner);
using (var join = await match.PostAsync($"/matchmake/room/{roomId}", new FormUrlEncodedContent(new Dictionary<string, string>())))
Assert.True(join.IsSuccessStatusCode, await join.Content.ReadAsStringAsync());
using (var put = await PutJsonRawAsync(ownerRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
"""[{"Permission":"21","Role":20,"Override":true,"Type":0,"Value":"true"}]"""))
Envelope(await put.Content.ReadAsStringAsync());
var token = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(owner.PlayerId), "PhotonAccessToken"));
Assert.True(token.Msg.GetProperty("RoomInstanceId").GetInt64() > 0);
Assert.Contains(token.Msg.GetProperty("Permissions").EnumerateArray(),
c => c.GetProperty("Role").GetInt32() == 20 && c.GetProperty("Permission").GetString() == "21");
}
[Fact]
public void Envelope_filter_wraps_success_and_turns_refusals_into_messages()
{
static JsonElement Body(Microsoft.AspNetCore.Mvc.IActionResult r) =>
JsonDocument.Parse(JsonSerializer.Serialize(((Microsoft.AspNetCore.Mvc.ObjectResult)r).Value)).RootElement.Clone();
var ok = Body(Controllers.RecNetEnvelopeAttribute.Wrap(new Microsoft.AspNetCore.Mvc.OkObjectResult(new { RoomId = 5 })));
Assert.True(ok.GetProperty("success").GetBoolean());
Assert.Equal(5, ok.GetProperty("value").GetProperty("RoomId").GetInt32());
Assert.Equal("", ok.GetProperty("error").GetString());
var forbid = Body(Controllers.RecNetEnvelopeAttribute.Wrap(new Microsoft.AspNetCore.Mvc.ForbidResult()));
Assert.False(forbid.GetProperty("success").GetBoolean());
Assert.False(string.IsNullOrWhiteSpace(forbid.GetProperty("error").GetString()));
var bad = Body(Controllers.RecNetEnvelopeAttribute.Wrap(
new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(new { error = "cannot_change_creator_role" })));
Assert.Equal("cannot_change_creator_role", bad.GetProperty("error").GetString());
// An envelope the handler built itself is left alone.
var already = new Microsoft.AspNetCore.Mvc.OkObjectResult(new { success = false, value = (object?)null, error = "x" });
Assert.Same(already, Controllers.RecNetEnvelopeAttribute.Wrap(already));
}
[Fact]
public async Task Room_mutations_answer_the_success_value_error_envelope_on_bare_and_roomserver_paths()
{
var (owner, invitee, _) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId);
using var rooms = Client("rooms", owner);
// The exact request the client sent in production (bare path, form role=30).
var invited = await PutFormAsync(rooms, $"/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
Assert.Equal(30, RoleOf(invited, invitee.PlayerId).GetProperty("InvitedRole").GetInt32());
foreach (var (path, key, value) in new[]
{
($"/rooms/{roomId}/description", "description", "hello"),
($"/roomserver/rooms/{roomId}/accessibility", "accessibility", "0"),
($"/roomserver/rooms/{roomId}/subrooms/1/name", "name", "Lobby"),
($"/roomserver/rooms/{roomId}/subrooms/1/maxplayers", "maxPlayers", "10"),
})
{
var details = await PutFormAsync(rooms, path, (key, value));
Assert.Equal(roomId, details.GetProperty("RoomId").GetInt64());
}
}
// ── helpers ─────────────────────────────────────────────────────────
private async Task<(GameClientSession A, GameClientSession B, GameClientSession C)> PlayersAsync()
{
using var setup = Client("api");
return (
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain),
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain),
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain));
}
private async Task<long> SeedRoomAsync(long creatorId, params (long PlayerId, int StoredRole)[] roles)
{
var roomId = 9_300_000 + Random.Shared.Next(1, 99_999);
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"Roles{Guid.NewGuid():N}"[..18],
CreatorPlayerId = creatorId,
ImageName = RoomService.DefaultRoomImageName,
Accessibility = 0,
LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
});
db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, OrderIndex = 0, Name = "Home" });
foreach (var (pid, role) in roles)
db.RoomRoles.Add(new RoomRoleEntity { RoomId = roomId, PlayerId = pid, Role = role, Accepted = true });
await db.SaveChangesAsync();
return roomId;
}
private static JsonElement RoleOf(JsonElement details, long playerId) =>
details.GetProperty("Roles").EnumerateArray()
.Single(e => e.GetProperty("AccountId").GetInt64() == playerId);
private static Task<HttpResponseMessage> PutFormRawAsync(HttpClient client, string path, params (string Key, string Value)[] fields)
=> client.PutAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value))));
private static async Task<JsonElement> PutFormAsync(HttpClient client, string path, params (string Key, string Value)[] fields)
{
using var response = await PutFormRawAsync(client, path, fields);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"PUT {path} -> {(int)response.StatusCode}: {body}");
return Envelope(body);
}
/// <summary>Every room mutation answers the {success, value, error}
/// envelope (NLDBPDCNNCF.BDBHCEGKGDC&lt;FGCPNAACHIK&gt;): assert success and
/// return the room details in <c>value</c>.</summary>
private static JsonElement Envelope(string body)
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.True(root.GetProperty("success").GetBoolean(), body);
Assert.Equal(string.Empty, root.GetProperty("error").GetString());
return root.GetProperty("value").Clone();
}
/// <summary>A refusal is HTTP 200 with success=false and a message: the
/// client shows the message, while a 403/404 surfaces only as the
/// message-less "Exception of type 'CEMNLBKJABA' was thrown".</summary>
private static async Task AssertRefusedAsync(HttpResponseMessage response)
{
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var doc = JsonDocument.Parse(body);
Assert.False(doc.RootElement.GetProperty("success").GetBoolean(), body);
Assert.False(string.IsNullOrWhiteSpace(doc.RootElement.GetProperty("error").GetString()), body);
Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("value").ValueKind);
}
private static Task<HttpResponseMessage> PutJsonRawAsync(HttpClient client, string path, string json)
=> client.PutAsync(path, new StringContent(json, Encoding.UTF8, "application/json"));
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
{
using var response = await client.GetAsync(path);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
private HttpClient Client(string subdomain, GameClientSession? session = null)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
+4 -2
View File
@@ -267,8 +267,10 @@ public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}");
using var json = JsonDocument.Parse(text);
var root = json.RootElement;
// Full room-details shape (FGCPNAACHIK), not a status wrapper.
// {success, value, error} envelope around the full room-details
// shape (FGCPNAACHIK).
Assert.True(json.RootElement.GetProperty("success").GetBoolean(), text);
var root = json.RootElement.GetProperty("value");
foreach (var key in new[] { "RoomId", "SubRooms", "Roles" })
Assert.True(root.TryGetProperty(key, out _), $"clone response missing '{key}': {text}");
return root.GetProperty("RoomId").GetInt64();
+2 -1
View File
@@ -416,9 +416,10 @@ public sealed class RroRoomBlobTests : IClassFixture<DorkNetServerFactory>
}
}
// The entry sub-room (OrderIndex 0) is wire SubRoomId 1 (RoomSceneIds).
private static JsonElement SubRoomZero(JsonElement details) =>
details.GetProperty("SubRooms").EnumerateArray()
.First(e => e.GetProperty("SubRoomId").GetInt64() == 0);
.First(e => e.GetProperty("SubRoomId").GetInt64() == 1);
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
{
+15 -8
View File
@@ -58,7 +58,9 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
await db.SaveChangesAsync();
}
// ── in-game: the owner's slider on sub-room 1 only ──────────────────
// ── in-game: the owner's slider on the second sub-room only ─────────
// The client addresses it by its wire SubRoomId, OrderIndex + 1 = 2
// (RoomSceneIds — the client will not load a sub-room whose id is 0).
// Sent exactly as the client sends it. NLDBPDCNNCF.MNBPCGJNLNP(Int64,
// Int64, Int32) builds a BLNOGFGHIIF whose single JSON key is
// "maxPlayers" and issues it with verb 3 = PUT — NOT the form encoding
@@ -66,19 +68,23 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
// real client's body went unread.
using var ownerClient = Client("rooms", owner);
using var slider = await ownerClient.PutAsJsonAsync(
$"/rooms/{roomId}/subrooms/1/maxplayers", new { maxPlayers = 24 });
$"/rooms/{roomId}/subrooms/2/maxplayers", new { maxPlayers = 24 });
var sliderBody = await slider.Content.ReadAsStringAsync();
Assert.True(slider.IsSuccessStatusCode,
$"owner PUT maxplayers -> {(int)slider.StatusCode}: {sliderBody}");
// The client deserialises the reply as FGCPNAACHIK — the room-details
// shape — so the slider's own response has to carry the new value.
var echoed = JsonDocument.Parse(sliderBody).RootElement
// The client reads the reply through the {success, value, error}
// helper with value = FGCPNAACHIK, so the slider's own response has to
// carry the new value inside the envelope.
var envelope = JsonDocument.Parse(sliderBody).RootElement;
Assert.True(envelope.GetProperty("success").GetBoolean(), sliderBody);
var echoed = envelope.GetProperty("value")
.GetProperty("SubRooms").EnumerateArray()
.First(e => e.GetProperty("SubRoomId").GetInt64() == 1);
.First(e => e.GetProperty("SubRoomId").GetInt64() == 2);
Assert.Equal(24, echoed.GetProperty("MaxPlayers").GetInt32());
// ── admin: reads what the owner just set, then edits sub-room 0 ─────
// (the admin API addresses sub-rooms by OrderIndex)
using var adminClient = Client("admin", admin);
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
var subRooms = listed.EnumerateArray()
@@ -99,8 +105,9 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
.ToDictionary(e => e.GetProperty("SubRoomId").GetInt64(),
e => e.GetProperty("MaxPlayers").GetInt32());
Assert.Equal(40, wire[0]);
Assert.Equal(24, wire[1]);
// Wire ids are OrderIndex + 1.
Assert.Equal(40, wire[1]);
Assert.Equal(24, wire[2]);
// The room-level figure is the room's advertised capacity, NOT a copy
// of sub-room 0 — otherwise the admin edit above would have moved it.
@@ -0,0 +1,277 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// Sub-room ids and the room instance browser for the 2023 client.
/// <list type="bullet">
/// <item>The client will not load a sub-room whose id is not positive
/// (SubroomModel.DOKCPLJOKIE: <c>RoomId &gt; 0 &amp;&amp; SubroomId &gt; 0</c>),
/// so the wire SubRoomId is OrderIndex + 1 everywhere the client sees it:
/// room details, sub-room routes, matchmaking and the instance browser.</item>
/// <item>A new room always owns a real, named entry sub-room, and a startup
/// pass repairs rooms that lack one.</item>
/// <item><c>GET room/{id}/instances</c> lists live instances with their real
/// players, including the private instance of a private room for the people
/// allowed into it.</item>
/// </list>
/// </summary>
public sealed class SubRoomsAndInstances2023Tests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public SubRoomsAndInstances2023Tests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public void Wire_sub_room_ids_are_positive_and_round_trip()
{
Assert.Equal(1, RoomSceneIds.ToWire(0));
Assert.Equal(3, RoomSceneIds.ToWire(2));
Assert.Equal(0, RoomSceneIds.ToOrderIndex(1));
Assert.Equal(2, RoomSceneIds.ToOrderIndex(3));
// 0 (cached client state, bare matchmake) is the entry sub-room.
Assert.Equal(0, RoomSceneIds.ToOrderIndex(0));
Assert.Equal(0, RoomSceneIds.ToOrderIndex(-1));
}
[Fact]
public async Task Details_matchmaking_and_sub_room_routes_share_the_wire_id()
{
var owner = await PlayerAsync();
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 1, scenes: new[] { "Home", "Arena" });
using var rooms = Client("rooms", owner);
var details = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}");
var subRooms = details.GetProperty("SubRooms").EnumerateArray().ToList();
Assert.Equal(new long[] { 1, 2 }, subRooms.Select(s => s.GetProperty("SubRoomId").GetInt64()));
Assert.Equal("Home", subRooms[0].GetProperty("Name").GetString());
// matchmake/room/{id}/{sub} takes the wire id and echoes it.
using var match = Client("match", owner);
var arena = await PostFormAsync(match, $"/matchmake/room/{roomId}/2");
Assert.Equal(2, arena.GetProperty("roomInstance").GetProperty("subRoomId").GetInt64());
var lobby = await PostFormAsync(match, $"/matchmake/room/{roomId}");
Assert.Equal(1, lobby.GetProperty("roomInstance").GetProperty("subRoomId").GetInt64());
// A sub-room route addressed by wire id 2 edits OrderIndex 1.
using (var rename = await rooms.PutAsync($"/roomserver/rooms/{roomId}/subrooms/2/name",
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = "Basement" })))
Assert.True(rename.IsSuccessStatusCode, await rename.Content.ReadAsStringAsync());
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
Assert.Equal("Basement", (await db.RoomScenes.SingleAsync(s => s.RoomId == roomId && s.OrderIndex == 1)).Name);
Assert.Equal("Home", (await db.RoomScenes.SingleAsync(s => s.RoomId == roomId && s.OrderIndex == 0)).Name);
}
[Fact]
public async Task Cloning_a_template_without_scene_rows_creates_a_named_home_sub_room()
{
var owner = await PlayerAsync();
var templateId = await SeedRoomAsync(1, accessibility: 1, scenes: Array.Empty<string>(), isAg: true);
using var rooms = Client("rooms", owner);
using var resp = await rooms.PostAsync($"/roomserver/rooms/{templateId}/clone",
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = $"Fresh{Guid.NewGuid():N}"[..20] }));
var text = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, text);
using var json = JsonDocument.Parse(text);
var value = json.RootElement.GetProperty("value");
var cloneId = value.GetProperty("RoomId").GetInt64();
var sub = value.GetProperty("SubRooms").EnumerateArray().Single();
Assert.Equal(1, sub.GetProperty("SubRoomId").GetInt64());
Assert.Equal("Home", sub.GetProperty("Name").GetString());
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var scene = await db.RoomScenes.SingleAsync(s => s.RoomId == cloneId);
Assert.Equal(0, scene.OrderIndex);
Assert.Equal("Home", scene.Name);
Assert.Equal("a75f7547-79eb-47c6-8986-6767abcb4f92", scene.RoomSceneLocationId);
}
[Fact]
public async Task Startup_repair_gives_scene_less_rooms_a_home_and_names_blank_rows()
{
var bare = await SeedRoomAsync(1, accessibility: 0, scenes: Array.Empty<string>());
var blank = await SeedRoomAsync(1, accessibility: 0, scenes: new[] { "", " " });
await using var scope = _factory.Services.CreateAsyncScope();
var service = scope.ServiceProvider.GetRequiredService<RoomService>();
var (added, renamed) = await service.RepairRoomScenesAsync();
Assert.True(added >= 1);
Assert.True(renamed >= 2);
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var home = await db.RoomScenes.SingleAsync(s => s.RoomId == bare);
Assert.Equal((0, "Home"), (home.OrderIndex, home.Name));
var names = await db.RoomScenes.Where(s => s.RoomId == blank).OrderBy(s => s.OrderIndex).Select(s => s.Name).ToListAsync();
Assert.Equal(new[] { "Home", "Subroom 2" }, names);
// Idempotent.
Assert.Equal((0, 0), await service.RepairRoomScenesAsync());
}
[Fact]
public async Task Room_level_restore_also_restores_the_entry_sub_room()
{
var owner = await PlayerAsync();
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 0, scenes: new[] { "Home", "Back" });
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
await db.RoomScenes.Where(s => s.RoomId == roomId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.DataBlobName, "blob-old"));
db.RoomDataBlobs.Add(new RoomDataBlobEntity { RoomId = roomId, BlobName = "blob-new", UploadedByPlayerId = owner.PlayerId });
await db.SaveChangesAsync();
}
using var api = Client("api", owner);
using var response = await api.PostAsync("/api/rooms/v1/restore", new FormUrlEncodedContent(new Dictionary<string, string>
{
["roomId"] = roomId.ToString(), ["blobName"] = "blob-new",
}));
Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync());
await using var check = _factory.Services.CreateAsyncScope();
var checkDb = check.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var blobs = await checkDb.RoomScenes.AsNoTracking().Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex).Select(s => s.DataBlobName).ToListAsync();
// The entry sub-room follows the restore; other sub-rooms keep theirs.
Assert.Equal(new[] { "blob-new", "blob-old" }, blobs);
}
[Fact]
public async Task Startup_repair_restores_a_missing_entry_sub_room_next_to_others()
{
var roomId = await SeedRoomAsync(1, accessibility: 0, scenes: new[] { "Home", "Back" });
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
await db.RoomScenes.Where(s => s.RoomId == roomId && s.OrderIndex == 0).ExecuteDeleteAsync();
var service = scope.ServiceProvider.GetRequiredService<RoomService>();
var (added, _) = await service.RepairRoomScenesAsync();
Assert.True(added >= 1);
var scenes = await db.RoomScenes.AsNoTracking().Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex).Select(s => new { s.OrderIndex, s.Name }).ToListAsync();
Assert.Equal(new[] { (0, "Home"), (1, "Back") }, scenes.Select(s => (s.OrderIndex, s.Name)));
Assert.Equal((0, 0), await service.RepairRoomScenesAsync());
}
[Fact]
public async Task Instance_browser_lists_the_private_instance_with_its_players_for_allowed_callers()
{
var owner = await PlayerAsync();
var coOwner = await PlayerAsync();
var stranger = await PlayerAsync();
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 0, scenes: new[] { "Home" });
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = coOwner.PlayerId, Role = RoomRoleRules.StoredCoOwner, Accepted = true,
});
await db.SaveChangesAsync();
}
using var ownerMatch = Client("match", owner);
var joined = await PostFormAsync(ownerMatch, $"/matchmake/room/{roomId}");
var instanceId = joined.GetProperty("roomInstance").GetProperty("roomInstanceId").GetInt64();
foreach (var viewer in new[] { owner, coOwner })
{
using var match = Client("match", viewer);
var list = await GetJsonAsync(match, $"/room/{roomId}/instances");
var row = list.EnumerateArray().Single();
Assert.Equal(instanceId, row.GetProperty("roomInstanceId").GetInt64());
Assert.Equal(roomId, row.GetProperty("roomId").GetInt64());
Assert.Equal(1, row.GetProperty("subRoomId").GetInt64());
Assert.False(row.GetProperty("isFull").GetBoolean());
Assert.Equal(new[] { (int)owner.PlayerId },
row.GetProperty("playerIds").EnumerateArray().Select(p => p.GetInt32()));
}
// Someone with no way into the private room is not shown it.
using var strangerMatch = Client("match", stranger);
var hidden = await GetJsonAsync(strangerMatch, $"/room/{roomId}/instances");
Assert.Empty(hidden.EnumerateArray());
}
[Fact]
public void Private_instance_visibility_rule()
{
var row = new PrivateInstanceEntity { Id = 5, OwnerPlayerId = 3 };
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, true, Array.Empty<long>(), row, false));
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, new long[] { 9 }, row, false));
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(3, false, Array.Empty<long>(), row, false));
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, Array.Empty<long>(), row, true));
Assert.False(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, Array.Empty<long>(), row, false));
Assert.False(Controllers.Match.MatchController.CanSeePrivateInstance(0, true, Array.Empty<long>(), row, true));
}
// ── helpers ─────────────────────────────────────────────────────────
private async Task<GameClientSession> PlayerAsync()
{
using var setup = Client("api");
return await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
}
private async Task<long> SeedRoomAsync(long creatorId, int accessibility, string[] scenes, bool isAg = false)
{
var roomId = 9_200_000 + Random.Shared.Next(1, 99_999);
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"Subs{Guid.NewGuid():N}"[..18],
CreatorPlayerId = creatorId,
ImageName = RoomService.DefaultRoomImageName,
Accessibility = accessibility,
CloningAllowed = true,
IsAGRoom = isAg,
LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
});
for (var i = 0; i < scenes.Length; i++)
db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, OrderIndex = i, Name = scenes[i] });
await db.SaveChangesAsync();
return roomId;
}
private static async Task<JsonElement> PostFormAsync(HttpClient client, string path)
{
using var response = await client.PostAsync(path, new FormUrlEncodedContent(new Dictionary<string, string>()));
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"POST {path} -> {(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
{
using var response = await client.GetAsync(path);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
private HttpClient Client(string subdomain, GameClientSession? session = null)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
@@ -115,10 +115,10 @@ public class AvatarItemsController(
// reverts to the real per-player wardrobe below. Bounded by the same
// maxItems ceiling so a future watch build can't be over-fed.
if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync())
return Ok(BuildEntireCatalogOwned(maxItems));
return await WithStoreInfoAsync(BuildEntireCatalogOwned(maxItems));
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(BuildDefaultUnlocked(maxItems));
if (pid is not long me) return await WithStoreInfoAsync(BuildDefaultUnlocked(maxItems));
var avatar = await db.Avatars
.Where(a => a.PlayerId == me)
@@ -134,7 +134,7 @@ public class AvatarItemsController(
.Where(slug => StoreService.TryGetHairDyePayload(slug, out _))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (owned.Count == 0 && ownedHairDyes.Count == 0) return Ok(BuildDefaultUnlocked(maxItems));
if (owned.Count == 0 && ownedHairDyes.Count == 0) return await WithStoreInfoAsync(BuildDefaultUnlocked(maxItems));
var catalog = _catalog.Value;
var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections);
@@ -167,6 +167,7 @@ public class AvatarItemsController(
// 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;
if (item.IsBaseAvatarItem) continue; // custom-clothing template, not clothing
var slot = catalog.SlotByGuid.TryGetValue(entry.BaseGuid, out var value) ? value : -1;
perSlot.TryGetValue(slot, out var count);
@@ -226,7 +227,34 @@ public class AvatarItemsController(
IsBaseAvatarItem = false,
});
}
return Ok(result);
return await WithStoreInfoAsync(result);
}
/// <summary>Give each owned row the same FriendlyName, Tooltip and
/// Rarity its shop tile shows. The client keeps this list's metadata
/// per item, and the catalog dump only carries asset names (e.g.
/// "(Shirt_Winter)" for colour variants) with no rarity or tooltip, so
/// the owned list disagreed with the shop for most items. Rows with no
/// shop tile keep the catalog name. Catalog DTOs are shared, so matched
/// rows are copied, never mutated.</summary>
private async Task<List<UnlockedAvatarItemDto>> WithStoreInfoAsync(List<UnlockedAvatarItemDto> rows)
{
var info = await StoreService.LoadAvatarItemStoreInfoAsync(db);
if (info.Count == 0) return rows;
return rows.Select(r => info.TryGetValue(r.AvatarItemDesc, out var s)
? new UnlockedAvatarItemDto
{
AvatarItemType = r.AvatarItemType,
AvatarItemDesc = r.AvatarItemDesc,
AvatarItemId = r.AvatarItemId,
PlatformMask = r.PlatformMask,
FriendlyName = string.IsNullOrWhiteSpace(s.FriendlyName) ? r.FriendlyName : s.FriendlyName,
Tooltip = s.Tooltip,
Rarity = s.Rarity,
TagList = r.TagList,
IsBaseAvatarItem = r.IsBaseAvatarItem,
}
: r).ToList();
}
/// <summary>The free items every player owns
@@ -250,6 +278,8 @@ public class AvatarItemsController(
foreach (var item in catalog.Items.Values)
{
if (result.Count >= maxItems) return result;
// Custom-clothing templates aren't ownable clothing.
if (item.IsBaseAvatarItem) continue;
result.Add(item);
}
// Colored variants aren't in the base avatar_item_lookup catalog, so
@@ -299,14 +329,33 @@ public class AvatarItemsController(
return result;
}
/// <summary>The custom-clothing templates ("base avatar items") and
/// their RecNet AvatarItemIds, as the real service served them. The
/// client flags every row of <c>defaultbaseavataritems</c> as a base
/// item (AvatarItemData.IsBaseAvatarItem, keyed by AvatarItemId in
/// OutfitManager.AODPBDNAGII) and leaves base items out of the owned
/// rebuild (MBPOFMEHDIJ), so they never read as owned clothing. This
/// endpoint used to return the first 100 catalog items, which turned
/// ordinary items (80sHipHopJacket, Beard Close, ...) into templates
/// that showed a price even when owned and equipped.</summary>
public static readonly IReadOnlyDictionary<string, int> BaseAvatarItemIds =
new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["c5d70cb4-71dd-4fe4-b719-34fe2073c611"] = 2184, // UGCTee_Shirt
["95a519de-f2cb-429c-b014-508477f20d42"] = 2918, // UGCPulloverHoodie_Shirt
};
public static bool IsBaseAvatarItemGuid(string? guid) =>
!string.IsNullOrEmpty(guid) && BaseAvatarItemIds.ContainsKey(guid);
[HttpGet("api/avatar/v1/defaultbaseavataritems")]
[AllowAnonymous]
public IActionResult DefaultBaseAvatarItems()
{
var rows = _catalog.Value.Items.Values
.OrderBy(i => i.AvatarItemType)
.ThenBy(i => i.FriendlyName)
.Take(100)
var items = _catalog.Value.Items;
var rows = BaseAvatarItemIds.Keys
.Where(items.ContainsKey)
.Select(g => items[g])
.ToList();
return Ok(rows);
}
@@ -379,8 +428,8 @@ public class AvatarItemsController(
.Select(guid => catalog.TryGetValue(guid, out var item) ? item : null)
.Where(item => item is not null)
.Select(item => item!)
.ToArray();
return Ok(rows);
.ToList();
return Ok(await WithStoreInfoAsync(rows));
}
/// <summary>Per-OutfitType slot index → list of GUIDs in that slot.
@@ -580,13 +629,13 @@ public class AvatarItemsController(
// GUID as a color GUID and log "Can not find hair dye color".
AvatarItemType = AvatarItemType.Outfit,
AvatarItemDesc = row.Guid + ",,,",
AvatarItemId = StableAvatarItemId(row.Guid),
AvatarItemId = BaseAvatarItemIds.TryGetValue(row.Guid, out var baseId) ? baseId : StableAvatarItemId(row.Guid),
PlatformMask = unchecked((int)0xFFFFFFFF),
FriendlyName = pretty,
Tooltip = string.Empty,
Rarity = 0,
TagList = string.Empty,
IsBaseAvatarItem = false,
IsBaseAvatarItem = IsBaseAvatarItemGuid(row.Guid),
};
if (!bySlot.TryGetValue(row.OutfitType, out var lst))
bySlot[row.OutfitType] = lst = new List<string>();
@@ -599,7 +648,7 @@ public class AvatarItemsController(
var defaults = dump.DefaultUnlockedVariants
.Where(v => string.IsNullOrEmpty(v.Swatch) && string.IsNullOrEmpty(v.Mask) && string.IsNullOrEmpty(v.Decal))
.Select(v => v.Item)
.Where(g => !string.IsNullOrEmpty(g) && items.ContainsKey(g))
.Where(g => !string.IsNullOrEmpty(g) && items.ContainsKey(g) && !IsBaseAvatarItemGuid(g))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
@@ -56,9 +56,12 @@ public class FreeGiftsController(DorkNetDbContext db, NotificationService notifi
await db.SaveChangesAsync();
foreach (var row in created)
{
// The full gift package, as GET api/avatar/v2/gifts lists it: the
// client consumes a gift by its Id, which the old hand-built
// payload didn't carry.
await notifications.NotifyAsync(row.RecipientPlayerId,
PushNotificationId.GiftPackageReceived,
new { FromPlayerId = sender, CurrencyType = currencyType, Currency = currency, Xp = xp, Message = message });
DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(row.Gift));
}
return Ok(new { Success = true, Sent = created.Count });
@@ -1295,39 +1295,14 @@ public class ImagesController(
.Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0);
private Task NotifyAccountImageChangedAsync(PlayerEntity player)
{
var account = new DorkNet.Models.Auth.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 DorkNet.Models.Auth.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));
}
/// <summary>Push the new profile image through the same account pushes
/// as every other profile edit: the owner's SelfAccountUpdate and an
/// AccountUpdate to everyone. A hand-built account here left out the
/// emoji, banner, pronouns, identity flags and creation date, so an image
/// change wiped them from the owner's cached account, and nobody else saw
/// the new picture.</summary>
private Task NotifyAccountImageChangedAsync(PlayerEntity player) =>
notifications.AccountChanged(player);
private static string NormaliseTaggedIds(string? csv, long exceptId)
{
@@ -1095,11 +1095,9 @@ public class InventionsController(
});
inv.CheerCount += 1;
await db.SaveChangesAsync();
if (inv.CreatorPlayerId != pid)
await notifications.NotifyAsync(inv.CreatorPlayerId,
PushNotificationId.InventionModerationStateChanged,
new { Type = "cheer", inv.Id, From = pid, inv.CheerCount });
// No push: InventionModerationStateChanged ("100") means "your
// inventions' moderation state changed" and takes no payload; sent
// on every cheer it only made the creator refetch for nothing.
}
else if (!req.Cheer && existing is not null)
{
@@ -1256,8 +1254,10 @@ public class InventionsController(
if (!canPush) return Forbid();
var subRoomId = req.SubRoomId;
// Wire SubRoomId (OrderIndex + 1) → the scene's OrderIndex.
var sceneOrderIndex = Services.RoomSceneIds.ToOrderIndex(subRoomId);
var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == subRoomId);
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == sceneOrderIndex);
var subRoomDataFilename = Clamp((req.SubRoomData?.Filename ?? string.Empty).Trim(), 128);
if (subRoomDataFilename.Length == 0)
@@ -1271,7 +1271,7 @@ public class InventionsController(
if (subRoomBlob is null)
return BadRequest(new { Error = "unknown SubRoomData.Filename" });
if (subRoomBlob.RoomId == 0) subRoomBlob.RoomId = room.Id;
subRoomBlob.SubRoomId ??= subRoomId;
subRoomBlob.SubRoomId ??= sceneOrderIndex;
var roomDataFilename = Clamp((req.RoomData?.Filename ?? string.Empty).Trim(), 128);
if (roomDataFilename.Length > 0)
@@ -279,11 +279,11 @@ public class InventoryController(
if (row.Quantity == 0) db.PlayerInventory.Remove(row);
await db.SaveChangesAsync();
// A consume always changes an existing mapping, so it is always
// ConsumableMappingRemoved (71) carrying the count that is left;
// Added (70) would make the client treat it as a new stack.
await notifications.NotifyAsync(pid,
row.Quantity > 0
? PushNotificationId.ConsumableMappingAdded
: PushNotificationId.ConsumableMappingRemoved,
new { row.Id, row.ItemSlug, row.Quantity });
PushNotificationId.ConsumableMappingRemoved, ConsumableMappingWire(row));
return Ok(new { row.Id, Count = row.Quantity });
}
@@ -332,8 +332,7 @@ public class InventoryController(
if (source.Quantity == 0) db.PlayerInventory.Remove(source);
await db.SaveChangesAsync();
await notifications.NotifyAsync(recipient,
PushNotificationId.ConsumableMappingAdded,
new { target.Id, target.ItemSlug, target.Quantity });
PushNotificationId.ConsumableMappingAdded, ConsumableMappingWire(target));
return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity });
}
@@ -449,6 +448,26 @@ public class InventoryController(
};
}
/// <summary>ConsumableMapping push payload ("70" Added / "71" Removed):
/// exactly the client's mapping DTO <c>CGCALDBHLGD</c> (formatter
/// <c>JICIOMDBPDO</c>) — Id (Int64), ConsumableItemDesc (STRING, not an
/// object), Count, InitialCount, CreatedAt, ActiveDurationMinutes (Int32?),
/// IsActive, IsTransferable. The old <c>{Id, ItemSlug, Quantity}</c> parsed
/// as a mapping with no item and a count of 0.</summary>
public static object ConsumableMappingWire(PlayerInventoryEntity inv) => new
{
inv.Id,
ConsumableItemDesc = StoreService.TryGetConsumableItemDesc(inv.ItemSlug, out var desc) ? desc : inv.ItemSlug,
Count = inv.Quantity,
InitialCount = inv.Quantity, // the original purchase count isn't tracked
CreatedAt = inv.AcquiredAt,
ActiveDurationMinutes = (int?)null,
inv.IsActive,
// Same as GET api/consumables/v2/getUnlocked, so a push never flips
// what the inventory list says.
IsTransferable = false,
};
private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item)
{
var type = StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc)
@@ -152,8 +152,6 @@ 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.");
}
return Ok(new { success = true, error = "" });
@@ -249,21 +247,14 @@ public class InGameModerationController(
"[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}",
caller, target, reason, p.BannedUntil, displayReason);
// Push the ban as a ModerationKick (22) with IsBan set, NOT as
// ModerationRoomBan (24): the 2023-03-21 client's moderation manager
// registers only 22, "ModerationUnkick" and 23
// (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335) — there is no handler
// for 24, so that push landed nowhere and the banned player kept playing
// until their next login. The 22 handler reads Msg.Reason as a
// ModerationBlockDetail, which ModerationKickPayload already produces.
await notifications.KickPlayerAsync(
target,
// `reason` is the numeric report category; DisplayReason is the
// moderator's text and is what the ban dialog shows.
string.IsNullOrWhiteSpace(displayReason)
? "You have been banned from this room."
: displayReason.Trim(),
isBan: true);
// Sign the banned player out with Logout "6" (Reason Banned). Not
// ModerationRoomBan (24): the 2023-03-21 client registers no handler
// for it (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335 register only
// 22, "ModerationUnkick" and 23), so that push landed nowhere. Not a
// ModerationKick either: a kick only sends the player to their dorm
// and they kept playing until their next login. The ban check refuses
// that next login.
await notifications.LogoutAsync(target, NotificationService.LogoutReason.Banned);
return Ok(new { success = true, error = "" });
}
@@ -34,6 +34,7 @@ namespace DorkNet.Server.Controllers.API.PlayerEvents;
public class PlayerEventsController(
DorkNetDbContext db,
NotificationService notifications,
SystemNotificationService systemNotifications,
PlayerPresenceService presence) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
@@ -393,7 +394,11 @@ public class PlayerEventsController(
if (evt is null) return NotFound();
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
var subRoomId = extras.SubRoomId is long sub && sub > 0 ? sub : (long?)null;
// The event stores the client's wire SubRoomId; presence and private
// instances hold the OrderIndex (RoomSceneIds).
var subRoomId = extras.SubRoomId is long sub && sub > 0
? RoomSceneIds.ToOrderIndex(sub)
: (long?)null;
// Same sourcing as the room browser: presence rows are the live truth
// (one instance per distinct PhotonRoomId), and the caller's OWN private
@@ -416,7 +421,7 @@ public class PlayerEventsController(
{
roomInstanceId = room.RoomInstanceId,
roomId = room.RoomId,
subRoomId = room.SubRoomId,
subRoomId = RoomSceneIds.ToWire(room.SubRoomId),
// MaxCapacity is the per-instance cap the matchmaker handed
// out, so this is a real answer rather than a constant false.
isFull = room.MaxCapacity > 0 && playerIds.Count >= room.MaxCapacity,
@@ -437,7 +442,7 @@ public class PlayerEventsController(
{
roomInstanceId = p.Id,
roomId = p.RoomId,
subRoomId = p.SubRoomId,
subRoomId = RoomSceneIds.ToWire(p.SubRoomId),
isFull = false,
createdAt = DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
playerIds = Array.Empty<int>(),
@@ -817,7 +822,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
await ApplyBodyToExtrasAsync(extras);
await SaveExtrasAsync(evt, extras);
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventUpdated);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
@@ -901,10 +906,7 @@ public class PlayerEventsController(
// Tell the creator someone RSVP'd. They can ignore but the
// watch will refresh their event detail card.
if (ev.CreatorPlayerId != Me)
await notifications.NotifyAsync(ev.CreatorPlayerId,
PushNotificationId.PlayerEventResponseChanged,
new { ev.Id, From = Me, body.Response });
await NotifyResponseChangedAsync(ev, row);
// Left as the historical shape on purpose: this route is 2020-only
// (the 2023 client GETs .../responses and POSTs v1/respond instead),
// so there is no 2023 DTO to match here.
@@ -947,10 +949,15 @@ public class PlayerEventsController(
var existing = await db.PlayerEventResponses
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
if (existing is null)
db.PlayerEventResponses.Add(new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response });
{
existing = new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response };
db.PlayerEventResponses.Add(existing);
}
else
existing.Response = response;
await db.SaveChangesAsync();
var responded = await db.PlayerEvents.FirstAsync(e => e.Id == evt);
await NotifyResponseChangedAsync(responded, existing);
return RespondResult(legacyForm, 0 /*Success*/);
}
@@ -968,9 +975,18 @@ public class PlayerEventsController(
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
if (eventId is not long evt) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
await db.PlayerEventResponses
var deleted = await db.PlayerEventResponses
.Where(r => r.PlayerId == Me && r.EventId == evt)
.ExecuteDeleteAsync();
if (deleted > 0)
{
// PlayerEventResponseDeleted "84" carries only PlayerEventId.
var creator = await db.PlayerEvents.Where(e => e.Id == evt)
.Select(e => (long?)e.CreatorPlayerId).FirstOrDefaultAsync();
if (creator is long c && c != Me)
await notifications.NotifyAsync(c, PushNotificationId.PlayerEventResponseDeleted,
new { PlayerEventId = evt });
}
return RespondResult(legacyForm, 0 /*Success*/);
}
@@ -993,6 +1009,7 @@ public class PlayerEventsController(
// has to echo the event that was deleted.
var extras = (await LoadExtrasAsync(new[] { evt }))[id];
var attendees = (await AttendeeCountsAsync(new[] { id })).GetValueOrDefault(id);
var notify = await AttendeeIdsAsync(evt);
db.PlayerEvents.Remove(evt);
await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync();
@@ -1000,6 +1017,7 @@ public class PlayerEventsController(
.Where(s => s.Key == ExtrasKey(id) || s.Key == ClubKey(id))
.ExecuteDeleteAsync();
await db.SaveChangesAsync();
await NotifyEventDeletedAsync(id, notify);
return Ok(Wrapper(evt, extras, attendees));
}
@@ -1021,6 +1039,7 @@ public class PlayerEventsController(
evt.Title = Clamp(value.Trim(), 128);
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
@@ -1034,6 +1053,7 @@ public class PlayerEventsController(
evt.Description = Clamp(value.Trim(), 2000);
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
@@ -1056,6 +1076,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.SubRoomId = await ReadLongFieldAsync("subRoomId", "SubRoomId");
await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
@@ -1072,6 +1093,7 @@ public class PlayerEventsController(
if (start is null && end is null) return BadRequest("missing_time");
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
@@ -1085,6 +1107,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.ImageName = Clamp(value.Trim(), 128);
await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
@@ -1099,6 +1122,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.Accessibility = accessibility;
await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
@@ -1120,6 +1144,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.Tags = tags;
await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
@@ -1158,6 +1183,7 @@ public class PlayerEventsController(
?? extras.CanRequestBroadcastPermissions;
await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
@@ -1188,6 +1214,7 @@ public class PlayerEventsController(
}
await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
@@ -1197,6 +1224,7 @@ public class PlayerEventsController(
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (ev is null) return NotFound();
if (ev.CreatorPlayerId != Me) return Forbid();
var notify = await AttendeeIdsAsync(ev);
db.PlayerEvents.Remove(ev);
// Cascade-clean the responses; SQLite has no FK cascade
// configured here so do it manually.
@@ -1206,6 +1234,7 @@ public class PlayerEventsController(
await db.PlayerSettings
.Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId))
.ExecuteDeleteAsync();
await NotifyEventDeletedAsync(eventId, notify);
return Ok();
}
@@ -1244,6 +1273,7 @@ public class PlayerEventsController(
var failed = new List<object>();
var sender = Me;
var inviteText = $"You're invited to '{ev.Title}' starting {ev.StartsAt:u}.";
var eventWire = await ToWireOneAsync(ev);
foreach (var rid in req.InvitedPlayerIds.Distinct())
{
var exists = await db.Players.AnyAsync(p => p.Id == rid);
@@ -1252,17 +1282,15 @@ public class PlayerEventsController(
failed.Add(new { InvitedPlayerId = rid, Result = 1 });
continue;
}
db.Messages.Add(new MessageEntity
{
SenderPlayerId = sender,
RecipientPlayerId = rid,
Body = inviteText,
});
await notifications.NotifyAsync(rid,
PushNotificationId.PlayerEventCreated,
new { ev.Id, ev.Title, ev.StartsAt, From = sender });
// The invite as a text message from the host, stored and pushed
// live (it used to be stored only, so it appeared after a relog).
await systemNotifications.SendAsync(rid,
SystemNotificationService.MessageType.TextMessage, fromPlayerId: sender, data: inviteText);
// And the event itself, so it shows in the invitee's event list:
// PlayerEventCreated "80" carries the full event (MDCBEPJCJPO) —
// the same object GET api/playerevents/v2/{id} returns.
await notifications.NotifyAsync(rid, PushNotificationId.PlayerEventCreated, eventWire);
}
await db.SaveChangesAsync();
return Ok(new { FailedInvites = failed, Result = 0 });
}
@@ -1289,21 +1317,56 @@ public class PlayerEventsController(
"BroadcastingRoomInstanceId", "broadcastingRoomInstanceId");
await SaveExtrasAsync(evt, extras);
// Attendees' event cards key off BroadcastingRoomInstanceId, so nudge
// them to re-fetch instead of sending a chat message.
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventStateChanged);
// Attendees' event cards key off BroadcastingRoomInstanceId, so send
// them the updated event.
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt));
}
private async Task NotifyAttendeesAsync(PlayerEventEntity evt, PushNotificationId id)
{
var recipients = await db.PlayerEventResponses
/// <summary>Everyone who answered the event with anything but NotGoing,
/// except its creator.</summary>
private async Task<List<long>> AttendeeIdsAsync(PlayerEventEntity evt) =>
(await db.PlayerEventResponses
.Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse)
.Select(r => r.PlayerId)
.Distinct()
.ToListAsync();
foreach (var playerId in recipients.Where(p => p != evt.CreatorPlayerId))
await notifications.NotifyAsync(playerId, id, new { PlayerEventId = evt.Id, evt.Title });
.ToListAsync())
.Where(p => p != evt.CreatorPlayerId)
.ToList();
/// <summary>PlayerEventUpdated "81" to the attendees, carrying the full
/// event (MDCBEPJCJPO, formatter CENEMCMGDKG) — the same object
/// GET api/playerevents/v2/{id} returns; the client replaces its cached
/// event with it. The old <c>{PlayerEventId, Title}</c> overwrote the
/// cached event with an empty name and default times, and the
/// PlayerEventStateChanged (85) used for broadcasts has no client handler
/// at all.</summary>
private async Task NotifyEventUpdatedAsync(PlayerEventEntity evt)
{
var recipients = await AttendeeIdsAsync(evt);
if (recipients.Count == 0) return;
var wire = await ToWireOneAsync(evt);
foreach (var playerId in recipients)
await notifications.NotifyAsync(playerId, PushNotificationId.PlayerEventUpdated, wire);
}
/// <summary>PlayerEventDeleted "82" ({PlayerEventId}) to the players who
/// were attending, collected before the rows were removed.</summary>
private async Task NotifyEventDeletedAsync(long eventId, IEnumerable<long> recipients)
{
foreach (var playerId in recipients)
await notifications.NotifyAsync(playerId, PushNotificationId.PlayerEventDeleted,
new { PlayerEventId = eventId });
}
/// <summary>PlayerEventResponseChanged "83" to the event's creator:
/// <c>{PlayerEvent, PlayerEventResponse}</c> (COAGAJPELCG), each the same
/// object the event and response REST routes return.</summary>
private async Task NotifyResponseChangedAsync(PlayerEventEntity evt, PlayerEventResponseEntity response)
{
if (evt.CreatorPlayerId == response.PlayerId) return;
await notifications.NotifyAsync(evt.CreatorPlayerId, PushNotificationId.PlayerEventResponseChanged,
new { PlayerEvent = await ToWireOneAsync(evt), PlayerEventResponse = ResponseWire(response) });
}
// ── Report ───────────────────────────────────────────────────────────
@@ -48,6 +48,7 @@ public class PlaylistsController(
/// query or JSON body so JSON callers (admin UI) keep working.</summary>
[HttpPost("/playlists")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> Create()
{
var name = await ReadFieldAsync("name", "Name");
@@ -87,6 +88,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/name")]
[HttpPut("/playlists/{playlistId:long}/name")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistName(long playlistId)
{
var value = await ReadFieldAsync("name", "Name", "value", "Value");
@@ -96,6 +98,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/description")]
[HttpPut("/playlists/{playlistId:long}/description")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistDescription(long playlistId)
{
var value = await ReadFieldAsync("description", "Description", "value", "Value");
@@ -105,6 +108,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/image")]
[HttpPut("/playlists/{playlistId:long}/image")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistImage(long playlistId)
{
var value = await ReadFieldAsync("imageName", "ImageName", "value", "Value");
@@ -116,6 +120,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/tags")]
[HttpPut("/playlists/{playlistId:long}/tags")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistTags(long playlistId)
{
var tags = await ReadFieldsAsync("tag", "Tag", "autoTag", "AutoTag", "tags", "Tags");
@@ -131,6 +136,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/visibility")]
[HttpPut("/playlists/{playlistId:long}/visibility")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistAccessibility(long playlistId)
{
var value = await ReadFieldAsync(
@@ -144,6 +150,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/levelvoting")]
[HttpPut("/playlists/{playlistId:long}/levelvoting")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistLevelVoting(long playlistId)
{
var value = await ReadFieldAsync("supportsLevelVoting", "SupportsLevelVoting", "value", "Value");
@@ -156,6 +163,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/restrictions")]
[HttpPut("/playlists/{playlistId:long}/restrictions")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistRestrictions(long playlistId)
{
var juniors = await ReadFieldAsync("supportsJuniors", "SupportsJuniors");
@@ -174,6 +182,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/warning")]
[HttpPut("/playlists/{playlistId:long}/warning")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistWarning(long playlistId)
{
var mask = await ReadFieldAsync("warningMask", "WarningMask");
@@ -295,6 +304,7 @@ public class PlaylistsController(
[HttpPut("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[HttpPost("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> AddRoom(long playlistId, long roomId)
{
try
@@ -307,6 +317,7 @@ public class PlaylistsController(
[HttpDelete("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> RemoveRoom(long playlistId, long roomId)
{
try
@@ -48,7 +48,11 @@ namespace DorkNet.Server.Controllers.API.Relationships.V2;
/// </summary>
[ApiController]
[Authorize]
public class RelationshipsController(DorkNetDbContext db, NotificationService notifications, ServerSettingsService settings) : ControllerBase
public class RelationshipsController(
DorkNetDbContext db,
NotificationService notifications,
SystemNotificationService systemNotifications,
ServerSettingsService settings) : ControllerBase
{
// RelationshipType — int values match Relationship.RelationshipType
// in the patched dump.cs.
@@ -226,7 +230,9 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
}
await db.SaveChangesAsync();
await notifications.FriendRequestReceived(id, me);
await PushPairAsync(me, id);
// The "X sent you a friend request" row in the target's notifications.
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendInvite, fromPlayerId: me);
return Ok(BuildRelationship(row, me));
}
@@ -251,7 +257,8 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
rel.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await notifications.FriendRequestAccepted(id, me);
await PushPairAsync(me, id);
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendRequestAccepted, fromPlayerId: me);
return Ok(BuildRelationship(rel, me));
}
@@ -285,7 +292,7 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
db.Relationships.RemoveRange(removable);
await db.SaveChangesAsync();
await notifications.FriendRemoved(id, me);
await PushPairAsync(me, id);
var remaining = pair.Except(removable).ToList();
return Ok(remaining.Count == 0 ? EmptyRelationship(id) : BuildMerged(me, id, remaining));
@@ -333,6 +340,8 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
existing.UpdatedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
await PushPairAsync(me, id);
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendRequestAccepted, fromPlayerId: me);
return Ok(BuildRelationship(existing, me));
}
@@ -411,6 +420,10 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
if (ignored is bool i) row.Ignored = i;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
// The HTTP reply is only an ack; the updated row reaches the client
// through RelationshipChanged. A preference is the caller's own bit,
// so only the caller is told.
await PushPairAsync(me, otherId, notifyOther: false);
return Ok(new Models.Auth.RecNetResult { Success = true, Error = string.Empty });
}
@@ -511,6 +524,36 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
// ── helpers ──────────────────────────────────────────────────────
/// <summary>Push RelationshipChanged for the pair (<paramref name="me"/>,
/// <paramref name="other"/>) after a change, each side getting its OWN view
/// of the relationship. Without this a friend request, accept or unfriend
/// only showed up after the friends list was refetched, and the client
/// never re-subscribed to the new friend's presence.</summary>
private async Task PushPairAsync(long me, long other, bool notifyOther = true)
{
var pair = await db.Relationships
.Where(r => (r.RequesterId == me && r.TargetId == other) ||
(r.RequesterId == other && r.TargetId == me))
.ToListAsync();
var globalFriends = await settings.IsGlobalFriendsEnabledAsync();
await notifications.RelationshipChanged(me, ViewOf(me, other, pair, globalFriends));
if (notifyOther)
await notifications.RelationshipChanged(other, ViewOf(other, me, pair, globalFriends));
}
/// <summary>The relationship from <paramref name="viewer"/>'s side, the way
/// <c>v2/get</c> would list it.</summary>
private static Dictionary<string, object> ViewOf(
long viewer, long other, List<RelationshipEntity> pair, bool globalFriends)
{
if (globalFriends
&& other != RelationshipQueries.SystemAccountId
&& !pair.Any(r => r.Status == EntityStatus.Blocked))
return SynthFriend(other, pair.FirstOrDefault(r => r.RequesterId == viewer), viewer);
return pair.Count == 0 ? EmptyRelationship(other) : BuildMerged(viewer, other, pair);
}
private async Task<HashSet<long>> FriendIdsAsync(long playerId)
{
var rows = await db.Relationships
@@ -35,6 +35,7 @@ public class RoomsController(
DomainConfig domain,
NotificationService notifications,
ServerSettingsService serverSettings,
SystemNotificationService systemNotifications,
ILogger<RoomsController> logger) : ControllerBase
{
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
@@ -574,6 +575,7 @@ public class RoomsController(
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")]
public async Task<IActionResult> SubRoomDataHistory(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var blobs = await db.RoomDataBlobs
.Where(b => b.RoomId == roomId
&& (b.SubRoomId == subRoomId || b.SubRoomId == null))
@@ -586,7 +588,7 @@ public class RoomsController(
.Take(50)
.Select(b => new
{
SubRoomId = subRoomId,
SubRoomId = RoomSceneIds.ToWire(subRoomId),
DataBlob = b.BlobName,
SavedByAccountId = b.UploadedByPlayerId,
CreatedAt = b.UploadedAt,
@@ -613,6 +615,7 @@ public class RoomsController(
[FromQuery] int? unityAssetTarget,
[FromQuery] int? unityAssetVersion)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -661,6 +664,7 @@ public class RoomsController(
[Authorize]
public async Task<IActionResult> SubRoom(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -689,6 +693,7 @@ public class RoomsController(
[FromQuery] int? unityAssetTarget,
[FromQuery] int? unityAssetVersion)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -731,6 +736,7 @@ public class RoomsController(
long roomId, long subRoomId,
[FromQuery] int skip = 0, [FromQuery] int take = 50)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -763,7 +769,7 @@ public class RoomsController(
saves.Add(new
{
SubRoomDataSaveId = currentSaveId,
SubRoomId = subRoomId,
SubRoomId = RoomSceneIds.ToWire(subRoomId),
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
ReferencedUnityAssetIds = Array.Empty<string>(),
@@ -809,7 +815,7 @@ public class RoomsController(
var modified = scene?.DataModifiedAt ?? room.UpdatedAt;
return new
{
SubRoomId = (long)orderIndex,
SubRoomId = RoomSceneIds.ToWire(orderIndex),
RoomId = room.Id,
Name = scene?.Name ?? "Home",
DataBlob = dataBlob,
@@ -824,7 +830,7 @@ public class RoomsController(
CurrentSave = new
{
SubRoomDataSaveId = scene?.StudioSubRoomDataSaveId ?? 0L,
SubRoomId = (long)orderIndex,
SubRoomId = RoomSceneIds.ToWire(orderIndex),
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
ReferencedUnityAssetIds = Array.Empty<string>(),
@@ -871,7 +877,7 @@ public class RoomsController(
return new
{
SubRoomDataSaveId = saveId,
SubRoomId = subRoomId,
SubRoomId = RoomSceneIds.ToWire(subRoomId),
UnityAssetId = unityAssetId,
SubRoomUnityAssetId = unityAssetId,
ReferencedUnityAssetIds = Array.Empty<string>(),
@@ -916,13 +922,14 @@ public class RoomsController(
public async Task<IActionResult> SubRoomRestoreData(
long roomId, long subRoomId, [FromForm] string? filename)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
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();
if (!await CanEditRoomAsync(room, pid)) return Forbid();
var blob = await db.RoomDataBlobs
.FirstOrDefaultAsync(b => b.BlobName == filename && b.RoomId == roomId);
@@ -1399,6 +1406,7 @@ public class RoomsController(
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
if (!room.IsDormRoom) await SyncEntrySceneBlobAsync(room.Id, blob.BlobName);
await db.SaveChangesAsync();
// Push the same SubscriptionUpdateRoom the save flow sends so
@@ -1472,10 +1480,27 @@ public class RoomsController(
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
if (!room.IsDormRoom) await SyncEntrySceneBlobAsync(roomId, blob.BlobName);
await db.SaveChangesAsync();
return Ok(new { restored = blob.BlobName });
}
/// <summary>A room-level restore also restores the entry sub-room
/// (OrderIndex 0): room details serve the entry scene's own blob ahead
/// of the room's, so updating only <c>Rooms.CurrentDataBlobName</c>
/// would leave the old version in place. The per-sub-room restore keeps
/// the two in step the other way round. Dorms keep their blob per
/// player (DormState) instead.</summary>
private Task SyncEntrySceneBlobAsync(long roomId, string blobName)
{
var now = DateTime.UtcNow;
return db.RoomScenes
.Where(s => s.RoomId == roomId && s.OrderIndex == 0)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.DataBlobName, blobName)
.SetProperty(x => x.DataModifiedAt, now));
}
/// <summary>GET <c>api/rooms/v{1,2}/personaldetails/{roomId}</c> —
/// per-caller bookmark/cheer flags for one room. Wire shape verified
/// against <c>RecNet.PersonalRoomDetails.Deserialize</c>
@@ -2001,7 +2026,7 @@ public class RoomsController(
UseLevelBasedMatchmaking = false,
UseAgeBasedMatchmaking = false,
UseRecRoyaleMatchmaking = false,
SubRoomId = (long)s.OrderIndex,
SubRoomId = RoomSceneIds.ToWire(s.OrderIndex),
RoomId = room.Id,
Name = s.Name,
UnityAssetId = s.StudioUnityAssetId,
@@ -2027,7 +2052,7 @@ public class RoomsController(
UseLevelBasedMatchmaking = false,
UseAgeBasedMatchmaking = false,
UseRecRoyaleMatchmaking = false,
SubRoomId = 0L,
SubRoomId = RoomSceneIds.ToWire(0),
RoomId = room.Id,
Name = "Home",
UnityAssetId = string.Empty,
@@ -2046,11 +2071,10 @@ public class RoomsController(
},
};
var wireRoles = new List<object>
{
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
};
wireRoles.AddRange(roleList.Select(BuildRoomRoleGrantWire));
// The creator is Creator (255), never CoOwner (30): the client reads
// the local player's role from this list and only lets a player edit
// roles strictly below their own (see RoomRoleRules).
var wireRoles = RoomRoleRules.BuildWires(room, roleList);
var tags = string.IsNullOrEmpty(room.TagsCsv)
? Array.Empty<object>()
@@ -2075,7 +2099,9 @@ public class RoomsController(
State = room.State,
Accessibility = room.Accessibility,
SupportsLevelVoting = room.SupportsLevelVoting,
IsRRO = room.IsAGRoom,
// Player rooms are never RRO on the wire (see RoomService.WireIsRRO):
// the client refuses publish and room settings for an RRO.
IsRRO = RoomService.WireIsRRO(room),
SupportsScreens = room.SupportsScreens,
SupportsWalkVR = room.SupportsWalkVR,
SupportsTeleportVR = room.SupportsTeleportVR,
@@ -2904,6 +2930,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/name")]
[HttpPut("roomserver/rooms/{roomId:long}/name")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> RenameRoom(
long roomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
@@ -2922,9 +2949,9 @@ public class RoomsController(
{
// 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();
// Room settings are creator / co-owner only; moderators and
// hosts moderate players, they don't edit the room.
if (!await CanEditRoomAsync(room, me)) return Forbid();
}
// Uniqueness — Rooms.Name has a unique index (DorkNetDbContext.cs:118).
@@ -2959,11 +2986,13 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> RenameSubroom(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var newName = body?.Name;
if (string.IsNullOrWhiteSpace(newName))
return BadRequest(new { Result = 1, error = "empty_name" });
@@ -2976,9 +3005,9 @@ public class RoomsController(
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();
// Room settings are creator / co-owner only; moderators and
// hosts moderate players, they don't edit the room.
if (!await CanEditRoomAsync(room, me)) return Forbid();
}
var scene = await db.RoomScenes.FirstOrDefaultAsync(s =>
@@ -3607,6 +3636,7 @@ public class RoomsController(
[HttpPost("rooms/{roomId:long}/subrooms")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> AddSubRoom(
long roomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] CreateSubRoomRequest req)
@@ -3614,7 +3644,7 @@ public class RoomsController(
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();
if (!await CanEditRoomAsync(room, pid)) return Forbid();
var name = (req.Name ?? string.Empty).Trim();
if (name.Length == 0) name = "NewScene";
@@ -3649,12 +3679,14 @@ public class RoomsController(
[HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[HttpDelete("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
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();
if (!await CanEditRoomAsync(room, pid)) return Forbid();
var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (scene is null) return NotFound();
@@ -3710,20 +3742,6 @@ public class RoomsController(
/// <summary>One row of the per-subroom role-permission matrix the 2023
/// client PUTs to <c>subrooms/{sub}/permissions</c>. Keys verified in the
/// writer <c>HDOJMEBCAGP.txt</c> ISIL 050/069/085/101/117.</summary>
public sealed class SubRoomPermissionEntry
{
// Field order + CLR types come from the BNLPBMJJOMM constructor
// (BNLPBMJJOMM.txt:126): (String Permission, OMMBGJMJJPN Role,
// Boolean Override, FEOJFGECKGH Type, String Value). The two enum
// members serialize as numbers (the role writer boxes them
// System.Int32 — BFKNABDHMGG.txt ISIL 015), hence int? here.
public string? Permission { get; set; }
public int? Role { get; set; }
public bool? Override { get; set; }
public int? Type { get; set; }
public string? Value { get; set; }
}
public sealed class PublishSubRoomSaveRequest
{
public long? SubRoomDataSaveId { get; set; }
@@ -3746,6 +3764,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[Authorize]
[RecNetEnvelope]
public Task<IActionResult> SubRoomMaxPlayers(
long roomId,
long subRoomId,
@@ -3763,6 +3782,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[Authorize]
[RecNetEnvelope]
public Task<IActionResult> SubRoomAccessibility(
long roomId,
long subRoomId,
@@ -3773,45 +3793,120 @@ public class RoomsController(
s.CanMatchmakeInto = a != AccessibilityPrivate;
});
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/permissions</c> — the
/// role-permission matrix. Client:
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/permissions</c> — "change
/// subroom permissions", the role-permission matrix. Client:
/// <c>NLDBPDCNNCF.AAMEOMHKPHF(long, long, IReadOnlyList&lt;BNLPBMJJOMM&gt;)</c>
/// (NLDBPDCNNCF.txt:9198, verb 3). Unlike its siblings this one does NOT
/// form-encode: the closure calls <c>BNDIAONDFFF.FJLLPHFOOJJ(String)</c>
/// (CCNOMNNHNNG.txt ISIL 009), which stamps body-kind 4 = raw string
/// (BNDIAONDFFF.txt:275, <c>mov dword ptr [rbx+38h],4</c>) — i.e. a raw
/// JSON ARRAY. FormOrJsonModelBinder handles that (non-form → JSON
/// deserialize into the List).
/// (NLDBPDCNNCF.txt:9198, verb 3, same typed helper as every other room
/// mutation, so the response is the full <c>FGCPNAACHIK</c> details).
/// Unlike its siblings the body is NOT form-encoded: the closure calls
/// <c>BNDIAONDFFF.FJLLPHFOOJJ(String)</c> (CCNOMNNHNNG.txt ISIL 009), a raw
/// JSON array of cells. The editor sends one changed cell per call
/// (OMJKHJLFOCO.AFHJLBBFAGP.&lt;LocalRequestModifyRolePermission&gt;g__ChangeAndRefresh
/// builds a one-element BNLPBMJJOMM[]), so each cell is upserted; cells not
/// sent are left alone.
///
/// <para><b>Scope note — deliberately not persisted.</b> DorkNet has no
/// per-subroom permission storage: <see cref="RoomSceneEntity"/> has no
/// column for it and there is no permissions table. Adding one means an
/// entity + DbContext + migration, which is outside this file. Until then
/// the matrix is accepted, logged, and the room details echoed back so the
/// client's promise resolves instead of 415-ing; the values do not survive
/// a round-trip. The old handler silently bound an unrelated DTO whose keys
/// the client never sends, which was strictly worse.</para></summary>
/// <para>Read back through <c>GET photon_access_token</c> (its
/// <c>Permissions</c> list), and pushed to everyone in that sub-room as
/// the <c>PhotonAccessToken</c> hub message so the change takes effect
/// without a rejoin.</para>
///
/// <para>Authorization mirrors the client's own rule
/// (OMJKHJLFOCO.DIIMLMKEJLC): the creator may edit every role, including
/// Co-Owner and Creator; a co-owner only the roles below their own;
/// nobody else may edit (<see cref="RoomRoleRules.CanEditRolePermissions"/>).</para></summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[Authorize]
public async Task<IActionResult> SubRoomPermissions(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] List<SubRoomPermissionEntry> req)
[RecNetEnvelope]
public async Task<IActionResult> SubRoomPermissions(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
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 roleRows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
if (!RoomRoleRules.CanEditRoom(room, roleRows, pid)) return Forbid();
string body;
using (var reader = new StreamReader(Request.Body))
body = await reader.ReadToEndAsync();
var cells = RoomPermissionMatrix.Parse(body);
if (cells is null) return BadRequest(new { error = "invalid_permissions_body" });
var denied = cells.Where(c => !RoomRoleRules.CanEditRolePermissions(room, roleRows, pid, c.Role)).ToList();
if (denied.Count > 0)
{
logger.LogWarning(
"[subroom-permissions] denied room={Room} sub={Sub} by={By} roles={Roles}",
roomId, subRoomId, pid, string.Join(",", denied.Select(d => d.Role).Distinct()));
return Forbid();
}
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound();
await db.SaveChangesAsync();
logger.LogWarning(
"[subroom-permissions] room={Room} sub={Sub} by={By} entries={Count} — accepted but NOT persisted (no permission storage yet)",
roomId, subRoomId, pid, req?.Count ?? 0);
var now = DateTime.UtcNow;
scene.DataModifiedAt = now;
for (var attempt = 1; ; attempt++)
{
var existing = await db.RoomRolePermissions
.Where(p => p.RoomId == roomId && p.SubRoomId == subRoomId)
.ToListAsync();
foreach (var cell in cells)
{
// Exact match, like the unique index on
// (RoomId, SubRoomId, Role, Permission).
var row = existing.FirstOrDefault(p =>
p.Role == cell.Role && string.Equals(p.Permission, cell.Permission, StringComparison.Ordinal));
if (row is null)
{
row = new RoomRolePermissionEntity
{
RoomId = roomId,
SubRoomId = subRoomId,
Role = cell.Role,
Permission = cell.Permission,
};
db.RoomRolePermissions.Add(row);
existing.Add(row);
}
row.Type = cell.Type;
row.Override = cell.Override;
row.Value = cell.Value;
row.UpdatedByPlayerId = pid;
row.UpdatedAt = now;
}
try
{
await db.SaveChangesAsync();
break;
}
catch (DbUpdateException) when (attempt < 3)
{
// Another editor inserted the same new cell between our read
// and our insert (unique index). Forget our pending rows and
// apply the cells again, now as updates of theirs.
foreach (var entry in db.ChangeTracker.Entries<RoomRolePermissionEntity>().ToList())
entry.State = EntityState.Detached;
}
}
logger.LogInformation(
"[subroom-permissions] room={Room} sub={Sub} by={By} cells={Count}",
roomId, subRoomId, pid, cells.Count);
// Everyone in that sub-room gets the new matrix; the client's
// PhotonAccessToken handler re-initialises its permissions from it.
var matrix = await RoomPermissionMatrix.LoadAsync(db, roomId, subRoomId);
foreach (var (instance, players) in presence.EnumerateActiveInstances(roomId, includePrivate: true))
{
if (instance.SubRoomId != subRoomId) continue;
var tokenBody = RoomPermissionMatrix.AccessTokenBody(instance.RoomInstanceId, matrix);
foreach (var other in players)
await notifications.NotifyTypedAsync(other, "PhotonAccessToken", tokenBody);
}
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
@@ -3840,12 +3935,14 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PublishSubRoomSave(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
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();
if (!await CanEditRoomAsync(room, pid)) return Forbid();
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound();
@@ -3919,12 +4016,14 @@ public class RoomsController(
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> CloneSubRoom(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
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();
if (!await CanEditRoomAsync(room, pid)) return Forbid();
var src = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (src is null) return NotFound();
@@ -3971,6 +4070,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[Authorize]
[RecNetEnvelope]
public Task<IActionResult> ModifySubRoom(
long roomId,
long subRoomId,
@@ -4010,11 +4110,13 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> MoveSubRoom(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMoveRequest req)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
@@ -4105,6 +4207,7 @@ public class RoomsController(
[Authorize]
public async Task<IActionResult> SubRoomData(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var body = await ReadSaveRoomSceneRequestAsync();
body.RoomSceneId = subRoomId;
return await SaveDataCore(body, roomId, wrapCreateModifyResponse: true);
@@ -4165,10 +4268,11 @@ public class RoomsController(
/// successful mutation surface as a client-side parse failure.</summary>
private async Task<IActionResult> MutateScene(long roomId, long subRoomId, Action<RoomSceneEntity> mutator)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
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();
if (!await CanEditRoomAsync(room, pid)) return Forbid();
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound();
mutator(scene);
@@ -4219,12 +4323,7 @@ public class RoomsController(
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)
{
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
};
list.AddRange(rows.Select(BuildRoomRoleGrantWire));
return Ok(list);
return Ok(RoomRoleRules.BuildWires(room, rows));
}
private static string CurrentOrSyntheticDataBlobName(RoomEntity room)
@@ -4339,30 +4438,38 @@ public class RoomsController(
/// <summary>GET <c>rooms/{id}/roles/{playerId}</c> — one role row
/// (<c>NLDBPDCNNCF.PCFOIPELKHN</c> → <c>Task&lt;EFHPLDPNGIM&gt;</c>,
/// NLDBPDCNNCF.txt:2192). roomserver/ twin required, same as the list
/// above.</summary>
/// above. The creator answers as Creator (255).</summary>
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpGet("roomserver/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(BuildRoomAccountRoleWire(playerId, 30));
var row = await db.RoomRoles
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId);
if (row is null) return NotFound();
return Ok(BuildRoomRoleGrantWire(row));
var rows = await db.RoomRoles
.Where(r => r.RoomId == roomId && r.PlayerId == playerId)
.ToListAsync();
var wire = RoomRoleRules.BuildWires(room, rows)
.FirstOrDefault(w => w.AccountId == RoomRoleRules.ToClientAccountId(playerId));
if (wire is null) return NotFound();
return Ok(wire);
}
public sealed class GrantRoleRequest { public int? Role { get; set; } }
/// <summary>PUT <c>rooms/{id}/roles/{playerId}</c> — owner grants
/// a role to a player. Auto-accepted (no invite step).
/// <c>NLDBPDCNNCF.LMIEIJMCEAB</c> ("change room account role",
/// NLDBPDCNNCF.txt:7380) is verb 3 = PUT, form key <c>role</c> carrying a
/// boxed Int32 (BFKNABDHMGG.txt ISIL 015), and returns the full
/// <c>FGCPNAACHIK</c> details — which this already does. Only the
/// roomserver/ twin was missing.</summary>
/// <summary>PUT <c>rooms/{id}/roles/{playerId}</c> — "change room account
/// role" (<c>NLDBPDCNNCF.LMIEIJMCEAB</c>, NLDBPDCNNCF.txt:7516, verb 3 =
/// PUT, single form key <c>role</c> boxed as Int32 —
/// NLDBPDCNNCF_NestedType_BFKNABDHMGG.txt ISIL 015-024), response the full
/// <c>FGCPNAACHIK</c> details. Three callers:
/// <list type="bullet">
/// <item>the owner's role button (AddRemovePlayerRoomRoleConditionalButton
/// .EEGJBELCBDI): Host/Moderator are set directly, and "Remove" sends
/// role 0;</item>
/// <item>the invitee accepting a co-owner invite (message Type 62,
/// KPFKPIKDBFJ.ELKAEBMMIGL / KLNLKDJJDLE Accept): their OWN account id
/// with role 30;</item>
/// <item>a co-owner rejecting a direct add (Type 60, COJCABEJDFN Reject):
/// their own id with role 0.</item>
/// </list>
/// Authorization is <see cref="RoomRoleRules.CheckChange"/>.</summary>
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
@@ -4372,45 +4479,20 @@ public class RoomsController(
[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);
[RecNetEnvelope]
public Task<IActionResult> GrantRole(long roomId, long playerId)
=> ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: false);
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.
/// <c>NLDBPDCNNCF.KIJDCLINOIC</c> ("invite account to role",
/// NLDBPDCNNCF.txt:7550) is verb 3 = PUT under the roomserver/ prefix.</summary>
/// <summary>PUT <c>rooms/{id}/roles/{playerId}/invite</c> — "invite account
/// to role" (<c>NLDBPDCNNCF.KIJDCLINOIC</c>, NLDBPDCNNCF.txt:7686, verb 3,
/// form key <c>role</c> as Int32 — NLDBPDCNNCF_NestedType_ENHMMCAOING.txt).
/// Only Co-Owner goes through an invite in this client: the owner's button
/// sends role 30 here, the invitee is told with a Type 62
/// (RoomCoOwnerInvited) message carrying RoomId, and either accepts with
/// <c>PUT roles/{me}</c> role 30 or rejects by sending role 0 back to THIS
/// route on their own id (KPFKPIKDBFJ.OGONAKHNMGN, KLNLKDJJDLE Reject),
/// which clears the pending invite but leaves any role they already
/// hold.</summary>
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")]
@@ -4420,62 +4502,168 @@ public class RoomsController(
[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)
[RecNetEnvelope]
public Task<IActionResult> InviteRole(long roomId, long playerId)
=> ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: true);
private async Task<IActionResult> ChangeRoomAccountRoleAsync(long roomId, long playerId, bool isInvite)
{
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);
var requested = await ReadClientRoomRoleAsync();
if (requested is null) return BadRequest(new { error = "missing_role" });
if (playerId <= 0 || !await db.Players.AnyAsync(p => p.Id == playerId))
return NotFound(new { error = "unknown_player" });
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)
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
var error = RoomRoleRules.CheckChange(
room, rows, me, playerId, requested.Value, isInvite, out var kind);
if (error is not null)
{
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
db.RoomRoles.Add(new RoomRoleEntity
logger.LogWarning(
"[room-roles] denied room={Room} actor={Actor} target={Target} role={Role} invite={Invite} error={Error}",
roomId, me, playerId, requested.Value, isInvite, error);
return error == "cannot_change_creator_role" || error == "invalid_role"
? BadRequest(new { error, role = requested.Value })
: Forbid();
}
var targetRows = rows.Where(r => r.PlayerId == playerId).ToList();
var wasCoOwner = targetRows.Any(r => r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
var hadCoOwnerInvite = targetRows.Any(r => !r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
RoomRoleRules.TryFromWire(requested.Value, out var storedRole);
switch (kind)
{
case RoomRoleRules.ChangeKind.Clear when isInvite:
// Reject / withdraw an invite: only the pending rows go.
db.RoomRoles.RemoveRange(targetRows.Where(r => !r.Accepted));
break;
case RoomRoleRules.ChangeKind.Clear:
db.RoomRoles.RemoveRange(targetRows);
break;
case RoomRoleRules.ChangeKind.Invite:
{
RoomId = roomId, PlayerId = playerId, Role = roomRole,
Accepted = false, GrantedByPlayerId = me,
});
// Already holding the role: nothing to invite to. Otherwise
// replace any other pending invite but keep the role the
// player holds until they accept.
if (targetRows.Any(r => r.Accepted && r.Role == storedRole)) break;
db.RoomRoles.RemoveRange(targetRows.Where(r => !r.Accepted && r.Role != storedRole));
var pending = targetRows.FirstOrDefault(r => !r.Accepted && r.Role == storedRole);
if (pending is null)
{
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = playerId, Role = storedRole,
Accepted = false, GrantedByPlayerId = me, GrantedAt = DateTime.UtcNow,
});
}
else
{
pending.GrantedByPlayerId = me;
pending.GrantedAt = DateTime.UtcNow;
}
break;
}
case RoomRoleRules.ChangeKind.AcceptInvite:
case RoomRoleRules.ChangeKind.Grant:
{
// One role per account: the new one replaces the rest
// (an accepted invite replaces the role held meanwhile).
var keep = targetRows.FirstOrDefault(r => r.Role == storedRole);
db.RoomRoles.RemoveRange(targetRows.Where(r => !ReferenceEquals(r, keep)));
if (keep is null)
{
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = playerId, Role = storedRole,
Accepted = true,
GrantedByPlayerId = kind == RoomRoleRules.ChangeKind.AcceptInvite
? targetRows.FirstOrDefault(r => !r.Accepted)?.GrantedByPlayerId ?? me
: me,
GrantedAt = DateTime.UtcNow,
});
}
else
{
keep.Accepted = true;
if (kind == RoomRoleRules.ChangeKind.Grant) keep.GrantedByPlayerId = me;
keep.GrantedAt = DateTime.UtcNow;
}
break;
}
}
else
await db.SaveChangesAsync();
var isCoOwnerNow = await db.RoomRoles.AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == playerId && r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
var hasCoOwnerInviteNow = await db.RoomRoles.AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == playerId && !r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
logger.LogInformation(
"[room-roles] room={Room} actor={Actor} target={Target} role={Role} invite={Invite} kind={Kind} coOwner={Was}->{Now}",
roomId, me, playerId, requested.Value, isInvite, kind, wasCoOwner, isCoOwnerNow);
// The invitee's notifications are how the role reaches them: the
// 2023 client renders message Types 60/61/62 (factory
// OCGKOJMFLAI.ALEHDHAEFOK, enum KJECOLODAFA.MEPCALGGMJC) and reads
// only RoomId from them. Self-service changes (accept / reject /
// leave) notify nobody.
if (playerId != me)
{
existing.Accepted = false;
if (!hadCoOwnerInvite && hasCoOwnerInviteNow)
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RoomCoOwnerInvited, me, roomId: roomId);
if (!wasCoOwner && isCoOwnerNow)
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RoomCoOwnerAdded, me, roomId: roomId);
if (wasCoOwner && !isCoOwnerNow)
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RoomCoOwnerRemoved, me, roomId: roomId);
}
await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
if (hadCoOwnerInvite && !hasCoOwnerInviteNow)
await MarkCoOwnerInviteMessagesReadAsync(playerId, roomId);
var details = await BuildRoomServerDetailsWithRolesAsync(roomId);
await PushRoomUpdateAsync(roomId, details, playerId, me);
return Ok(details);
}
private async Task<IActionResult> ClearRoomAccountRoleAsync(RoomEntity room, long playerId)
/// <summary>Once an invite is answered (or withdrawn) its Type 62 message
/// must stop offering Accept/Reject in the inbox.</summary>
private async Task MarkCoOwnerInviteMessagesReadAsync(long playerId, long roomId)
{
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)
var stale = await db.Messages
.Where(m => m.RecipientPlayerId == playerId
&& m.RoomId == roomId
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerInvited
&& m.ReadAt == null)
.ToListAsync();
if (rows.Count > 0)
db.RoomRoles.RemoveRange(rows);
if (stale.Count == 0) return;
foreach (var m in stale) m.ReadAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(room.Id));
}
private async Task<bool> CanManageRoomRolesAsync(RoomEntity room, long playerId)
/// <summary>Push the fresh room details as <c>RoomUpdate</c> (handler
/// IBEOONPEELF.txt:507, Action&lt;FGCPNAACHIK&gt;) to everyone currently
/// in the room plus the players whose view changed, so the role list,
/// the role-gated buttons and the local player's own role refresh
/// without leaving the room.</summary>
private async Task PushRoomUpdateAsync(long roomId, object details, params long[] alsoNotify)
{
if (room.CreatorPlayerId == playerId) return true;
return await db.RoomRoles.AnyAsync(r =>
r.RoomId == room.Id
&& r.PlayerId == playerId
&& r.Accepted
&& r.Role == 0);
var recipients = presence.EnumerateActiveInstances(roomId, includePrivate: true)
.SelectMany(i => i.PlayerIds)
.Concat(alsoNotify)
.Where(pid => pid > 0)
.Distinct()
.ToList();
foreach (var pid in recipients)
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, details);
}
private async Task<int?> ReadClientRoomRoleAsync()
@@ -4486,6 +4674,8 @@ public class RoomsController(
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 (TryParseRoleName(form["role"].FirstOrDefault() ?? form["Role"].FirstOrDefault(), out parsed))
return parsed;
}
if (int.TryParse(Request.Query["role"].FirstOrDefault(), out parsed)) return parsed;
@@ -4495,10 +4685,20 @@ public class RoomsController(
{
try
{
var body = await JsonSerializer.DeserializeAsync<GrantRoleRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (body?.Role is int bodyRole) return bodyRole;
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!prop.Name.Equals("role", StringComparison.OrdinalIgnoreCase)) continue;
if (prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out parsed))
return parsed;
if (prop.Value.ValueKind == JsonValueKind.String
&& (int.TryParse(prop.Value.GetString(), out parsed)
|| TryParseRoleName(prop.Value.GetString(), out parsed)))
return parsed;
}
}
}
catch (JsonException)
{
@@ -4509,64 +4709,34 @@ public class RoomsController(
return null;
}
private async Task RemoveOtherRoomRolesAsync(long roomId, long playerId, int keepRole)
/// <summary>OMMBGJMJJPN member names, for callers that box the enum
/// itself (Enum.ToString) instead of an Int32.</summary>
internal static bool TryParseRoleName(string? raw, out int role)
{
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
role = (raw ?? string.Empty).Trim().ToLowerInvariant() switch
{
30 => 0, // CoOwner
20 => 1, // Moderator
10 => 2, // Host
"none" => RoomRoleRules.WireNone,
"banned" => RoomRoleRules.WireBanned,
"host" => RoomRoleRules.WireHost,
"moderator" => RoomRoleRules.WireModerator,
"coowner" => RoomRoleRules.WireCoOwner,
"temporarycoowner" => RoomRoleRules.WireTemporaryCoOwner,
"creator" => RoomRoleRules.WireCreator,
_ => -1,
};
return roomRole >= 0;
return role >= 0;
}
private static object BuildRoomRoleGrantWire(RoomRoleEntity role)
=> BuildRoomAccountRoleWire(
role.PlayerId,
ToClientRoomRole(role.Role),
role.Accepted,
role.GrantedByPlayerId);
private static object BuildRoomAccountRoleWire(
long accountId,
int role,
bool accepted = true,
long? lastChangedByAccountId = null)
=> new
{
AccountId = ToClientAccountId(accountId),
Role = accepted ? role : 0,
LastChangedByAccountId = ToClientAccountId(lastChangedByAccountId),
InvitedRole = accepted ? 0 : role,
};
private static int ToClientRoomRole(int roomRole) => roomRole switch
/// <summary>Creator or accepted co-owner (see
/// <see cref="RoomRoleRules.CanEditRoom"/>).</summary>
private async Task<bool> CanEditRoomAsync(RoomEntity room, long playerId)
{
0 => 30, // CoOwner
1 => 20, // Moderator
2 => 10, // Host
_ => 0,
};
private static int ToClientAccountId(long accountId)
=> accountId > int.MaxValue
? int.MaxValue
: accountId < int.MinValue
? int.MinValue
: (int)accountId;
private static int? ToClientAccountId(long? accountId)
=> accountId.HasValue ? ToClientAccountId(accountId.Value) : null;
if (room.CreatorPlayerId == playerId) return true;
var rows = await db.RoomRoles
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
.ToListAsync();
return RoomRoleRules.CanEditRoom(room, rows, playerId);
}
private async Task<object> BuildRoomServerDetailsWithRolesAsync(long roomId)
{
@@ -501,6 +501,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/description")]
[HttpPut("roomserver/rooms/{roomId:long}/description")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareDescription(long roomId)
{
// RawValue, not the whitespace-skipping reader: clearing the
@@ -520,6 +521,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/image")]
[HttpPut("roomserver/rooms/{roomId:long}/image")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareImage(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -541,6 +543,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/tags")]
[HttpPut("roomserver/rooms/{roomId:long}/tags")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareTags(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -567,6 +570,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/accessibility")]
[HttpPut("roomserver/rooms/{roomId:long}/accessibility")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareAccessibility(long roomId)
{
var value = await ReadBareIntAsync("accessibility", "Accessibility", "value", "Value");
@@ -899,6 +903,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/cloning")]
[HttpPut("roomserver/rooms/{roomId:long}/cloning")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareCloning(long roomId)
{
var value = await ReadBareBoolAsync("cloningAllowed", "CloningAllowed", "value", "Value");
@@ -918,6 +923,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/automute")]
[HttpPut("roomserver/rooms/{roomId:long}/automute")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareAutomute(long roomId)
{
var value = await ReadBareBoolAsync("disable", "Disable", "DisableMicAutoMute", "value", "Value");
@@ -939,6 +945,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/restrictions")]
[HttpPut("roomserver/rooms/{roomId:long}/restrictions")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareRestrictions(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -965,6 +972,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/warning")]
[HttpPut("roomserver/rooms/{roomId:long}/warning")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareWarning(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -983,6 +991,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/creator")]
[HttpPut("roomserver/rooms/{roomId:long}/creator")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareCreator(long roomId)
{
var creatorId = await ReadBareLongAsync(
@@ -1001,6 +1010,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/allow_new_users")]
[HttpPut("roomserver/rooms/{roomId:long}/allow_new_users")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareAllowNewUsers(long roomId)
{
var value = await ReadBareBoolAsync("allowNewUsers", "AllowNewUsers", "value", "Value");
@@ -1015,6 +1025,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/min_level")]
[HttpPut("roomserver/rooms/{roomId:long}/min_level")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareMinLevel(long roomId)
{
var value = await ReadBareIntAsync("minLevel", "MinLevel", "level", "Level", "value", "Value");
@@ -1029,6 +1040,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
[HttpPut("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareMaxPlayerCalculationMode(long roomId)
{
var value = await ReadBareIntAsync(
@@ -1054,6 +1066,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/loadscreen")]
[HttpPut("roomserver/rooms/{roomId:long}/loadscreen")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareLoadScreen(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -1085,6 +1098,7 @@ public class RoomsModerationController(
[HttpDelete("rooms/{roomId:long}/loadscreen")]
[HttpDelete("roomserver/rooms/{roomId:long}/loadscreen")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareLoadScreenRemove(long roomId)
{
var imageName = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value")
@@ -1121,6 +1135,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/promo_images")]
[HttpPut("roomserver/rooms/{roomId:long}/promo_images")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BarePromoImagesAdd(long roomId)
{
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
@@ -1137,6 +1152,7 @@ public class RoomsModerationController(
[HttpDelete("rooms/{roomId:long}/promo_images")]
[HttpDelete("roomserver/rooms/{roomId:long}/promo_images")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BarePromoImagesRemove(long roomId)
{
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
@@ -1167,6 +1183,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/promo_external")]
[HttpPut("roomserver/rooms/{roomId:long}/promo_external")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BarePromoExternalAdd(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -1197,6 +1214,7 @@ public class RoomsModerationController(
[HttpDelete("rooms/{roomId:long}/promo_external")]
[HttpDelete("roomserver/rooms/{roomId:long}/promo_external")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BarePromoExternalRemove(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -1275,6 +1293,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
[HttpPut("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareAck(long roomId) =>
await ApplyAndReturn(roomId, _ => { /* value not persistable: no column */ });
@@ -1305,6 +1324,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/modify")]
[HttpPut("roomserver/rooms/{roomId:long}/modify")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareModify(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -1375,13 +1395,13 @@ public class RoomsModerationController(
{
var newName = await ReadCloneNameAsync();
var source = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (source is null) return NotFound();
if (source is null) return CloneResult(null, "That room doesn't exist.");
// RRO/AG rooms are first-party templates meant to be cloned — the
// room-creation "base room" picker starts a new build by cloning one
// (RecCenter, MakerRoom, …). Their seed rows have CloningAllowed=false,
// so without the IsAGRoom exception every base-room create 403s.
// so without the IsAGRoom exception every base-room create is refused.
if (!source.CloningAllowed && !source.IsAGRoom && source.CreatorPlayerId != Me)
return Forbid();
return CloneResult(null, "You can't clone this room!");
if (string.IsNullOrWhiteSpace(newName))
newName = $"{source.Name}Copy";
@@ -1432,6 +1452,13 @@ public class RoomsModerationController(
// it editable. Safe for admin: the purge gate and "RR Original"
// badge both key on CreatorPlayerId == SystemAccountId, so a
// user-owned clone is still shown as Custom and stays purgeable.
//
// The WIRE IsRRO is a different thing: it is false for every
// player-owned room (RoomService.WireIsRRO). The 2023 client's
// room-load pipeline never reads IsRRO (the baked scene comes
// from the sub-room's UnitySceneId, the blob step from DataBlob),
// while its owner UI refuses publish and every room setting on
// an RRO, which is what kept cloned rooms from being published.
IsAGRoom = source.IsAGRoom,
IsDormRoom = false,
CloningAllowed = source.CloningAllowed,
@@ -1509,6 +1536,18 @@ public class RoomsModerationController(
CanMatchmakeInto = s.CanMatchmakeInto,
DataModifiedAt = DateTime.UtcNow,
}).ToList();
// A template with no scene rows still has to give the new room a
// real entry sub-room (named "Home", from the clone's own location,
// blob and capacity). Without it the details served a synthesised
// placeholder and Room Settings > Subrooms showed an empty row.
if (clonedScenes.Count == 0)
{
var home = Services.RoomService.NewHomeScene(clone);
home.Id = ++nextSceneId;
clonedScenes.Add(home);
}
foreach (var scene in clonedScenes.Where(s => string.IsNullOrWhiteSpace(s.Name)))
scene.Name = Services.RoomService.DefaultSubRoomName(scene.OrderIndex);
if (clonedScenes.Count > 0)
{
db.RoomScenes.AddRange(clonedScenes);
@@ -1517,17 +1556,22 @@ public class RoomsModerationController(
await GiveCloneItsOwnBlobsAsync(clone, sourceScenes, clonedScenes, Me);
// The 2023 client's clone (RecNet.Runtime NLDBPDCNNCF.GDHIIAHCBMN)
// deserializes the response as the FULL room-details object
// (FGCPNAACHIK — the same type get-by-id / rename return on the
// roomserver host), NOT the slim list-room shape. This is the
// roomserver-family path (rooms/{id}/clone), so use
// BuildRoomServerDetails — the exact shape GET rooms/{id} returns for
// the 2023 client. Returning ToWireRoom (or the wrong details variant)
// made the strict reader fail → "Failed to copy room: Failed to clone
// room".
return Ok(RoomsController.BuildRoomServerDetails(clone, clonedScenes));
// The value is the FULL room-details object (FGCPNAACHIK), the exact
// shape GET rooms/{id} returns for the 2023 client — not the slim
// list-room shape, which made the strict reader fail.
return CloneResult(RoomsController.BuildRoomServerDetails(clone, clonedScenes));
}
/// <summary>Reply for <c>rooms/{id}/clone</c>. The 2023 client sends the
/// clone through the same RecNet rooms helper as "save room" and "restore
/// subroom save" (NLDBPDCNNCF.BDBHCEGKGDC with an op name), which reads the
/// legacy <c>{success, value, error}</c> envelope. A bare room-details body
/// has no <c>success</c>, so the client rejects it with an empty error:
/// "Failed to copy room: Exception of type 'CEMNLBKJABA' was thrown", even
/// though the room was created. Refusals are 200 with success=false and a
/// message the client shows.</summary>
private OkObjectResult CloneResult(object? room, string error = "") =>
Ok(new { success = error.Length == 0, value = room, error });
/// <summary>Give a freshly-cloned room its OWN copy of the source's save
/// data instead of pointing its scenes at the source's blob.
///
@@ -1575,8 +1619,11 @@ public class RoomsModerationController(
// The scene was created as a shallow copy, so its DataBlobName is
// currently the SOURCE's. Either a copy replaces it or it gets
// cleared — it must never survive as-is.
// A synthesised Home scene (template had no rows) has no source
// scene; it starts from the room-level blob it was built with.
var sourceBlob = i < sourceScenes.Count ? sourceScenes[i].DataBlobName : target.DataBlobName;
var copied = await CopyBlobForCloneAsync(
sourceScenes[i].DataBlobName, clone.Id, target.OrderIndex, callerId);
sourceBlob, clone.Id, target.OrderIndex, callerId);
target.DataBlobName = copied ?? string.Empty;
target.DataModifiedAt = DateTime.UtcNow;
@@ -1887,7 +1934,13 @@ public class RoomsModerationController(
/// <see cref="BareClone"/>). The slim RoomService.ToWireRoom has no
/// SubRooms/Roles/Tags/LoadScreens/PromoImages/DataBlob, so the strict
/// reader's dispose-walk NRE'd on the missing nested lists and every
/// mutation reported failure even though it had persisted.</summary>
/// mutation reported failure even though it had persisted.
///
/// <para>That helper is <c>NLDBPDCNNCF.BDBHCEGKGDC&lt;TResult&gt;</c>, the
/// <c>{success, value, error}</c> envelope reader, so the details are the
/// envelope's <c>value</c>: every caller carries
/// <see cref="Controllers.RecNetEnvelopeAttribute"/>. A bare details body
/// fails with "Exception of type 'CEMNLBKJABA' was thrown."</para></summary>
private async Task<object> BuildRoomDetailsAsync(RoomEntity room)
{
var scenes = await db.RoomScenes
@@ -156,9 +156,19 @@ public class StorefrontsBuyController(
await db.SaveChangesAsync();
// Push the buyer's balance update so their wallet UI refreshes.
// StorefrontBalancePurchase "62" is PurchaseBalanceModificationDTO
// {BalanceAddType, Delta, Balance, Platform, CurrencyType}; Balance
// is the absolute new balance, Delta only feeds the client's log.
await notifications.NotifyAsync(pid,
PushNotificationId.StorefrontBalancePurchase,
new { req.CurrencyType, Balance = newBalance, ItemId = item.Id, item.Slug });
new
{
BalanceAddType = 0,
Delta = (int)-item.Price,
Balance = newBalance,
Platform = 0,
req.CurrencyType,
});
if (isGift)
{
@@ -166,24 +176,13 @@ public class StorefrontsBuyController(
// GiftPackageReceived (id=30) to refresh /api/avatar/v2/gifts
// and pop the "you got a gift" toast; the gift box itself
// appears next time the recipient opens their inventory.
// GiftPackageReceived "30" carries the client's full gift package
// (FHMABOHAEED) — the same object GET api/avatar/v2/gifts lists.
// A hand-picked subset left out PlatformsToSpawnOn (-1 = every
// platform), so the box could count as spawning nowhere.
await notifications.NotifyAsync(recipient,
PushNotificationId.GiftPackageReceived,
new
{
Id = gift.Id,
Platform = -1,
Xp = 0,
Level = 1,
GiftRarity = 0,
GiftContext = gift.GiftContext,
FromPlayerId = gift.FromPlayerId,
AvatarItemType = gift.AvatarItemType,
AvatarItemDesc = avatarItemDesc,
ConsumableItemDesc = gift.ConsumableItemDesc ?? string.Empty,
EquipmentPrefabName = gift.EquipmentPrefabName,
EquipmentModificationGuid = gift.EquipmentModificationGuid,
Message = gift.Message ?? string.Empty,
});
DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(gift));
}
// For gift purchases the buyer doesn't get a box — their flow
@@ -154,7 +154,7 @@ public class AccountsController(
playerId, username, summary);
}
await notifications.KickPlayerAsync(playerId, "This account has been deleted.");
await notifications.LogoutAsync(playerId, NotificationService.LogoutReason.ForciblyLoggedOut);
presence.Clear(playerId);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
@@ -29,6 +29,7 @@ namespace DorkNet.Server.Controllers.Admin;
public class AdminController(
DorkNetDbContext db,
NotificationService notifications,
SystemNotificationService systemNotifications,
LevelService level,
CommunityBoardService communityBoard,
RoomBlobNormalizerService roomBlobNormalizer,
@@ -160,7 +161,9 @@ public class AdminController(
await LogAsync("ban_player", "player", id, body.Reason ?? "");
await db.SaveChangesAsync();
await notifications.KickPlayerAsync(id, body.Reason ?? "You have been banned.");
// A ban must sign the player out (a kick only sends them to the dorm);
// the next login is refused by the ban check.
await notifications.LogoutAsync(id, NotificationService.LogoutReason.Banned);
playerPresence.Clear(id);
return Ok(new { player.Id, player.BannedUntil });
@@ -243,7 +246,7 @@ public class AdminController(
await db.SaveChangesAsync();
await tx.CommitAsync();
await notifications.KickPlayerAsync(id, "Your account has been removed by an admin.");
await notifications.LogoutAsync(id, NotificationService.LogoutReason.ForciblyLoggedOut);
playerPresence.Clear(id);
return Ok(new
@@ -1570,10 +1573,13 @@ public class AdminController(
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>
/// <summary>Push fresh room details after role/owner mutations, to everyone
/// in the room plus the player whose role changed. The client caches the
/// room's Roles and role-gated UI reads that cache. The 2023 "RoomUpdate"
/// handler (IBEOONPEELF.txt:507) reads the FULL room (FGCPNAACHIK) — the
/// shape GET roomserver/rooms/{id} returns. The 2020 {Room, Scenes}
/// wrapper parsed as RoomId 0 with no roles, so role changes never
/// reached anyone until they re-entered the room.</summary>
private async Task NotifyRoomRolesChangedAsync(long roomId, long changedPlayerId, string reason)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
@@ -1587,7 +1593,7 @@ public class AdminController(
.ToListAsync();
var payload = DorkNet.Server.Controllers.API.Rooms.V2.RoomsController
.BuildRoomDetails(room, scenes, roles: roles);
.BuildRoomServerDetails(room, scenes, roles: roles);
var recipients = onlinePresence.OnlinePlayerIds()
.Where(pid => playerPresence.GetRoom(pid)?.RoomId == roomId)
@@ -1739,7 +1745,7 @@ public class AdminController(
{
var r = playerPresence.GetRoom(pid);
if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue;
await notifications.KickPlayerAsync(pid, reason);
await notifications.KickPlayerAsync(pid, reason, gameSessionId: instanceId);
playerPresence.Clear(pid);
kicked.Add(pid);
}
@@ -2396,7 +2402,9 @@ public class AdminController(
var reason = string.IsNullOrWhiteSpace(body?.Reason)
? "Kicked by an admin."
: body!.Reason!.Trim();
await notifications.KickPlayerAsync(id, reason);
// The room instance lets the client announce the kick to that room.
await notifications.KickPlayerAsync(id, reason,
gameSessionId: playerPresence.GetRoom(id)?.RoomInstanceId ?? 0);
playerPresence.Clear(id);
await LogAsync("kick_player", "player", id, body?.Reason ?? "");
await db.SaveChangesAsync();
@@ -3328,10 +3336,11 @@ public class AdminController(
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(body.NewPassword);
await LogAsync("set_password", "player", id, "");
await db.SaveChangesAsync();
// Push a logout so any active sessions on the old password get
// dropped — the watch re-authenticates with the new password.
await notifications.NotifyAsync(id, PushNotificationId.Logout,
new { Reason = "AdminPasswordReset" });
// Log out any session still on the old password; the client has to
// sign in again with the new one. The reason must be the numeric
// enum: the string "AdminPasswordReset" failed the client's parse and
// the push was dropped.
await notifications.LogoutAsync(id, NotificationService.LogoutReason.ForciblyLoggedOut);
return Ok(new { id });
}
@@ -3500,22 +3509,26 @@ public class AdminController(
playerPresence.SetRoom(id, roomInstance);
// Two pushes back-to-back: GameSession update changes the
// watch's cached RoomInstance (OnSubscriptionUpdateRoomInstance
// → UpdateCachedRoomInstance), and SubscriptionUpdateRoom
// refreshes the watch's RoomDetails cache so the room-info
// panel doesn't show stale data after the warp.
// The move itself comes from the presence set above: the client's
// next heartbeat sees it is out of sync and joins that instance.
// RoomInstanceUpdate only refreshes the client's cached copy of the
// instance, and RoomUpdate its cached room details, which must be
// the full room (FGCPNAACHIK) the roomserver GET returns.
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateGameSession, roomInstance);
var forceJoinScenes = await db.RoomScenes
.Where(sc => sc.RoomId == room.Id)
.OrderBy(sc => sc.OrderIndex)
.ToListAsync();
// With every role grant: the client replaces its cached room with
// this, so a payload without them would drop everyone's roles.
var forceJoinRoles = await db.RoomRoles
.Where(r => r.RoomId == room.Id)
.ToListAsync();
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateRoom,
new
{
Room = new { Id = room.Id, Name = room.Name },
RoomId = room.Id,
SubRoomId = subRoomId,
Reason = "AdminForceJoin",
});
DorkNet.Server.Controllers.API.Rooms.V2.RoomsController.BuildRoomServerDetails(
room, forceJoinScenes, roles: forceJoinRoles));
await LogAsync("force_join", "player", id,
$"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}");
@@ -3532,20 +3545,46 @@ public class AdminController(
// ── System ───────────────────────────────────────────────────────────
public sealed record SetMotdRequest(string Message);
public sealed record SetMotdRequest(string? Message, int? StartsInMinutes);
/// <summary>Push a server-maintenance broadcast to every connected
/// player. The MOTD config value is changed via appsettings.json
/// (server restart required); this is for ad-hoc announcements.</summary>
/// <summary>Broadcast to every connected player.
/// <list type="bullet">
/// <item><c>Message</c>: a coach message (MessageReceived, Type 100, from
/// the system account) in each online player's notifications —
/// the client's channel for free text from the server.</item>
/// <item><c>StartsInMinutes</c>: the client's own maintenance countdown
/// (ServerMaintenance "25", which reads only
/// <c>StartsInMinutes</c>; 0 cancels a running countdown).</item>
/// </list>
/// ServerMaintenance used to carry the text as <c>{Message}</c>, which the
/// client never reads: the text was never shown, and StartsInMinutes 0
/// cancelled any countdown instead.</summary>
[HttpPost("broadcast")]
public async Task<ActionResult> Broadcast([FromBody] SetMotdRequest body)
{
if (string.IsNullOrWhiteSpace(body.Message)) return BadRequest("empty message");
await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance,
new { Message = body.Message });
await LogAsync("broadcast", "system", 0, body.Message);
var message = body.Message?.Trim() ?? string.Empty;
if (message.Length == 0 && body.StartsInMinutes is null)
return BadRequest("empty message");
if (body.StartsInMinutes is < 0)
return BadRequest("StartsInMinutes must be 0 or more");
var recipients = onlinePresence.OnlinePlayerIds().ToList();
if (message.Length > 0)
{
foreach (var pid in recipients)
await systemNotifications.SendAsync(pid,
SystemNotificationService.MessageType.CoachMessage,
fromPlayerId: PlayerService.SystemAccountId,
data: message);
}
if (body.StartsInMinutes is int minutes)
await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance,
new { StartsInMinutes = minutes });
await LogAsync("broadcast", "system", 0,
body.StartsInMinutes is int m ? $"[maintenance in {m} min] {message}" : message);
await db.SaveChangesAsync();
return Ok();
return Ok(new { recipients = recipients.Count });
}
// ── Store catalog management ─────────────────────────────────────────
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
@@ -41,6 +42,27 @@ public class AdminLoginController(
if (!player.IsAdmin)
return StatusCode(403, new { error = "not_admin" });
return TokenResponse(player);
}
/// <summary>GET api/admin/v1/sso — exchange a single sign-on session
/// (<see cref="ExternalSignIn.WebScheme"/>, signed in on this admin host via
/// the external provider) for the same token pair <c>login</c> returns.
/// Admin rights still come from the account's <c>IsAdmin</c>.</summary>
[HttpGet("sso")]
public async Task<ActionResult> Sso()
{
var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme);
if (!web.Succeeded || ExternalSignIn.PlayerId(web.Principal) is not { } playerId)
return Unauthorized(new { error = "not_signed_in" });
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null) return Unauthorized(new { error = "not_signed_in" });
if (!player.IsAdmin) return StatusCode(403, new { error = "not_admin" });
return TokenResponse(player);
}
private OkObjectResult TokenResponse(Data.Entities.PlayerEntity player)
{
var (access, refresh) = authService.GenerateTokenPair(player.Id);
return Ok(new
{
@@ -460,6 +460,20 @@ public class ChatController(
var removed = await db.ChatThreadMembers
.Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me)
.ExecuteDeleteAsync();
if (removed > 0)
{
// "PlayerLeftChat" is an Action<ChatMessage> (DLDKCILCKNA.txt:721);
// the handler only reads ChatThreadId and SenderPlayerId (the
// player who left) and drops them from that thread's members.
// Sent to the remaining members, whose member lists otherwise
// stayed stale, and to the leaver's own other sessions.
var thread = await EnsureThreadRowAsync(chatThreadId);
var left = new ChatMessageEntity { ThreadKey = chatThreadId, SenderPlayerId = Me, Body = string.Empty };
var recipients = await ThreadRecipientsAsync(chatThreadId);
recipients.Add(Me);
foreach (var rid in recipients.Distinct())
await notifications.NotifyTypedAsync(rid, "PlayerLeftChat", ToWireMessage(left, thread.Id));
}
return Ok(removed > 0 ? ChatSuccess : ChatMembershipNotFound);
}
@@ -39,6 +39,7 @@ public class GoToController(
RoomService rooms,
DorkNet.Server.Data.DorkNetDbContext db,
NotificationService notifications,
SystemNotificationService systemNotifications,
JoinTimeoutService joinTimeouts,
ServerSettingsService serverSettings,
PresenceBroadcastService presenceFanout,
@@ -316,6 +317,10 @@ public class GoToController(
[FromForm(Name = "JoinMode")] int joinMode = 0,
[FromForm(Name = "AdditionalPlayerIds")] List<long>? additionalPlayerIds = null)
{
// The client sends the wire SubRoomId it read from the room details
// (OrderIndex + 1, see RoomSceneIds); everything below works in
// OrderIndex.
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId);
var room = await rooms.GetByIdAsync(roomId);
if (room is null)
{
@@ -877,11 +882,15 @@ public class GoToController(
logger.LogWarning(
"[goto-player] caller {Caller} tried to follow {Target} into private instance {Id} but isn't invited; rejecting",
callerId, playerId, theirRoom.RoomInstanceId);
// Notify the OWNER that someone tried to follow them in.
// Helps surface the rejection so the owner knows there's
// an interested party — they can choose to send an invite.
await notifications.PrivateInstanceJoinRejected(
ownerPlayerId: playerId, attemptedByPlayerId: callerId ?? 0);
// Tell the OWNER someone tried to follow them in, as the
// client's own "wants to join" message (RequestGameInvite,
// from the caller), so they can send an invite. At most once
// per requester, owner and instance per cooldown, so retrying
// doesn't fill the owner's inbox.
if (callerId is long requester
&& JoinRequestCooldown.TryStart(requester, playerId, theirRoom.RoomInstanceId))
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RequestGameInvite, fromPlayerId: requester);
return Ok(new MatchmakingResponseDto
{
ErrorCode = 26, // RoomInstanceIsPrivate
@@ -1547,9 +1556,25 @@ public class RoomInstanceDto
[JsonPropertyName("roomId")]
public long RoomId { get; set; }
[JsonPropertyName("subRoomId")]
/// <summary>The sub-room's <see cref="Data.Entities.RoomSceneEntity.OrderIndex"/>
/// (0 = the room's first sub-room). Server code reads and writes this;
/// the wire carries <see cref="WireSubRoomId"/>.</summary>
[JsonIgnore]
public long SubRoomId { get; set; }
/// <summary>The 2023 client's SubRoomId: OrderIndex + 1 (see
/// <see cref="Services.RoomSceneIds"/>). It must equal the
/// <c>SubRooms[].SubRoomId</c> the room details advertise — the client
/// compares the two (<c>SubroomModel.get_SubroomIsLocal</c>) and looks the
/// current sub-room up by it. Presence round-trips through this property
/// too, so stored JSON stays consistent.</summary>
[JsonPropertyName("subRoomId")]
public long WireSubRoomId
{
get => Services.RoomSceneIds.ToWire(SubRoomId);
set => SubRoomId = Services.RoomSceneIds.ToOrderIndex(value);
}
// STRING RoomSceneLocationId GUID — foreign-keys to
// AGRoomRuntimeConfig.Locations[].ReplicationId. Resolved via
// AGRoomSettings.TryGetRoomSceneLocationById (RVA 0x10F57D0). Default
@@ -1668,3 +1693,35 @@ public class RoomInstanceDto
};
}
}
/// <summary>Rate limit for "wants to join" messages: one per (requester,
/// owner, room instance) per <see cref="Window"/>. Later retries and other
/// instances still go through. Per replica, which is enough to stop a
/// retry loop from flooding an inbox.</summary>
internal static class JoinRequestCooldown
{
public static readonly TimeSpan Window = TimeSpan.FromMinutes(5);
private static readonly System.Collections.Concurrent.ConcurrentDictionary<(long, long, long), DateTime> Last = new();
/// <summary>True when no request for this key was sent within the window
/// (and records this one); false while it is cooling down.</summary>
public static bool TryStart(long requester, long owner, long roomInstanceId)
{
var now = DateTime.UtcNow;
if (Last.Count > 10_000)
foreach (var (key, at) in Last)
if (now - at > Window) Last.TryRemove(key, out _);
var k = (requester, owner, roomInstanceId);
while (true)
{
if (!Last.TryGetValue(k, out var prev))
{
if (Last.TryAdd(k, now)) return true;
continue;
}
if (now - prev < Window) return false;
if (Last.TryUpdate(k, now, prev)) return true;
}
}
}
@@ -140,66 +140,48 @@ public class MatchController(
});
}
/// <summary>GET <c>photon_access_token</c> — read by the room-permissions
/// runtime as <c>ALIIIAGAPND {RoomInstanceId, PhotonAccessToken,
/// Permissions}</c> (NLDBPDCNNCF.CEOPCANHPPH, verb 0, issued from the
/// rooms client, hence the <c>roomserver/</c> twin).
/// <c>Permissions</c> is the role-permission matrix of the sub-room the
/// player is in: the defaults plus whatever the room's editors saved with
/// <c>PUT rooms/{id}/subrooms/{sub}/permissions</c>
/// (<see cref="RoomPermissionMatrix"/>). It must not be empty: with no
/// Permissions the client falls into its persisted-role migration path,
/// which NREs during first spawn.</summary>
[HttpGet("/photon_access_token")]
public IActionResult PhotonAccessToken()
[HttpGet("/roomserver/photon_access_token")]
public async Task<IActionResult> PhotonAccessToken()
{
var pid = this.RequireCurrentPlayerId();
var appId = config["Photon:AppId"] ?? string.Empty;
var voiceAppId = config["Photon:VoiceAppId"] ?? string.Empty;
var region = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
var currentRoom = presence.GetRoom(pid);
var permissions = BuildPhotonPermissionSeed();
var permissions = currentRoom is null
? RoomPermissionMatrix.Merge(Array.Empty<RoomRolePermissionEntity>())
: await RoomPermissionMatrix.LoadAsync(db, currentRoom.RoomId, currentRoom.SubRoomId);
logger.LogInformation(
"[photon-token] player={PlayerId} roomInstance={RoomInstanceId} permissions={PermissionCount}",
pid, currentRoom?.RoomInstanceId ?? 0L, permissions.Length);
return Ok(new Dictionary<string, object>
{
// 2023 deserializes this endpoint as ALIIIAGAPND before room
// permissions initialization. If Permissions is missing/empty,
// OMJKHJLFOCO falls into the persisted-role migration path that
// currently NREs during first spawn.
["RoomInstanceId"] = currentRoom?.RoomInstanceId ?? 0L,
["PhotonAccessToken"] = string.Empty,
["Permissions"] = permissions,
"[photon-token] player={PlayerId} room={RoomId} sub={SubRoomId} roomInstance={RoomInstanceId} permissions={PermissionCount}",
pid, currentRoom?.RoomId ?? 0L, currentRoom?.SubRoomId ?? 0L, currentRoom?.RoomInstanceId ?? 0L, permissions.Count);
// Existing custom-auth/app-id shape kept for Photon auth callers.
["ResultCode"] = 1,
["Message"] = string.Empty,
["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture),
["PhotonAppId"] = appId,
["PhotonVoiceAppId"] = voiceAppId,
["AppId"] = appId,
["VoiceAppId"] = voiceAppId,
["CloudRegion"] = region,
["Region"] = region,
["Token"] = string.Empty,
});
var body = RoomPermissionMatrix.AccessTokenBody(currentRoom?.RoomInstanceId ?? 0L, permissions);
// Existing custom-auth/app-id shape kept for Photon auth callers; the
// ALIIIAGAPND reader ignores keys it does not register.
body["ResultCode"] = 1;
body["Message"] = string.Empty;
body["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture);
body["PhotonAppId"] = appId;
body["PhotonVoiceAppId"] = voiceAppId;
body["AppId"] = appId;
body["VoiceAppId"] = voiceAppId;
body["CloudRegion"] = region;
body["Region"] = region;
body["Token"] = string.Empty;
return Ok(body);
}
private static object[] BuildPhotonPermissionSeed()
=>
[
BuildPhotonPermission(13, 30), // Can Use Maker Pen
BuildPhotonPermission(20, 30), // Can Spawn Inventions
BuildPhotonPermission(21, 30), // Can Spawn Consumables
BuildPhotonPermission(22, 30), // Can Use Room Reset Button
BuildPhotonPermission(23, 30), // Can Use Play Gizmos Toggle
BuildPhotonPermission(13, 255), // Creator alias
BuildPhotonPermission(20, 255),
BuildPhotonPermission(21, 255),
BuildPhotonPermission(22, 255),
BuildPhotonPermission(23, 255),
];
private static object BuildPhotonPermission(int permission, int role) => new
{
Permission = permission.ToString(System.Globalization.CultureInfo.InvariantCulture),
Role = role,
Override = true,
Type = 0,
Value = "true",
};
[HttpPost("/matchmake/dorm")]
public async Task<IActionResult> MatchmakeDorm()
{
@@ -490,97 +472,125 @@ public class MatchController(
return Ok(session);
}
/// <summary>GET <c>match.*/room/{id}/instances</c> — the
/// "RoomInstanceBrowser" the watch shows when you tap a room tile.
/// Returns the currently-active instances so the player can see
/// which one their friends are in (and join it) instead of being
/// matchmade into a fresh empty room. Verified URL at
/// Matchmaking.txt:6286; response is a
/// <c>List&lt;SimpleRoomInstance&gt;</c> per
/// <c>SimpleRoomInstance.cs</c>: RoomInstanceId/RoomId/SubRoomId,
/// IsFull, CreatedAt, PlayerIds, SubroomName, PlayerCount,
/// HasModPresent, HashedInstanceId.
/// <summary>GET <c>match.*/room/{id}/instances</c> — the room instance
/// browser.
///
/// We derive instances from PlayerPresenceService's per-player
/// RoomInstanceDto: every distinct PhotonRoomId in the same RoomId
/// is one instance, with the players currently presenced there.
/// PrivateInstances are surfaced as well so the inviter and
/// invitee see the same room in the browser.</summary>
/// <para><b>Client contract.</b> <c>RecNet.Matchmaking.KAIPGDGACGG(long)</c>
/// (RecNet.Runtime/RecNet/Matchmaking.txt:13966, route at :14237, verb 0 =
/// GET on the matchmaking host) returns <c>List&lt;PNDCMIMEJLD&gt;</c>, a bare
/// array. The reader (JPNMBICAMEE.txt) takes exactly RoomInstanceId,
/// RoomId, SubRoomId, IsFull, CreatedAt and PlayerIds; the post-parse hook
/// (PNDCMIMEJLD.FKDDCNLJOLF) derives PlayerCount from PlayerIds.Count,
/// SubroomName from the cached room details' SubRooms by SubRoomId, and
/// HasModPresent from the room's Roles. The screen
/// (RoomInstanceBrowserScreen / RoomInstancePagedGrid) shows every row it
/// is given, current instance first then oldest CreatedAt first, and
/// renders the age as UtcNow - CreatedAt, so CreatedAt must be UTC.</para>
///
/// <para><b>What is listed.</b> Every live instance of the room: each
/// distinct Photon room in which at least one player is presenced with a
/// live activity marker (<see cref="PlayerPresenceService.EnumerateActiveInstances"/>).
/// Private instances are included only for callers allowed into them —
/// the room creator, accepted role holders, the instance owner, its
/// invitees, and players already inside — so another room's invite-only
/// match never leaks. Empty instances are not listed: there is nothing to
/// join and they are what made the old list stale.</para>
///
/// <para><b>Fixed defects.</b> The Redis presence path returned nothing at
/// all, so the browser was empty on every multi-replica deployment;
/// private instances (every user room is born private and has one
/// instance per sub-room) were dropped from presence and re-added only
/// for their owner with an empty PlayerIds, so they showed 0 players or
/// nothing; expired or crashed presences still counted; IsFull was always
/// false; and SubRoomId used the OrderIndex, which does not match the
/// room details' wire ids.</para></summary>
[HttpGet("/room/{roomId:long}/instances")]
[AllowAnonymous]
public async Task<IActionResult> RoomInstances(long roomId)
{
var presenced = presence.EnumerateActiveInstances(roomId).ToList();
// Only surface the caller's OWN private instances — we never
// leak someone else's PhotonRoomId into the browser. The Photon
// join check still requires CanJoinAsync, but redacting at the
// list level matches the existing RoomInstanceDto.Redact policy.
var pid = this.CurrentPlayerId() ?? 0;
var privates = pid == 0
? new List<Data.Entities.PrivateInstanceEntity>()
: await db.PrivateInstances
.Where(p => p.RoomId == roomId && p.OwnerPlayerId == pid)
.ToListAsync();
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return Ok(Array.Empty<object>());
// Merge: presence rows are authoritative (player currently
// there); add any owned private instance the inviter created
// but nobody's joined yet so the inviter can still see it.
var byKey = new Dictionary<string, SimpleInstance>(StringComparer.Ordinal);
foreach (var (room, playerIds) in presenced)
var live = presence.EnumerateActiveInstances(roomId, includePrivate: true).ToList();
var privateIds = live.Where(l => l.Room.IsPrivate).Select(l => l.Room.RoomInstanceId).Distinct().ToList();
var privateRows = privateIds.Count == 0
? new Dictionary<long, PrivateInstanceEntity>()
: await db.PrivateInstances.AsNoTracking()
.Where(p => privateIds.Contains(p.Id))
.ToDictionaryAsync(p => p.Id);
var callerHasRole = pid != 0 && (room.CreatorPlayerId == pid
|| await db.RoomRoles.AsNoTracking().AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == pid && r.Accepted));
var invitedTo = pid == 0 || privateIds.Count == 0
? new HashSet<long>()
: (await db.PrivateInstanceInvitees.AsNoTracking()
.Where(i => i.PlayerId == pid && privateIds.Contains(i.PrivateInstanceId))
.Select(i => i.PrivateInstanceId)
.ToListAsync()).ToHashSet();
var rows = new List<RoomInstanceRow>();
foreach (var (inst, playerIds) in live)
{
if (string.IsNullOrEmpty(room.PhotonRoomId)) continue;
byKey[room.PhotonRoomId] = new SimpleInstance(
RoomInstanceId: room.RoomInstanceId,
RoomId: room.RoomId,
SubRoomId: room.SubRoomId,
IsFull: false,
CreatedAt: DateTime.UtcNow,
PlayerIds: playerIds.Select(p => (int)p).ToList(),
SubroomName: room.Name ?? string.Empty,
PlayerCount: playerIds.Count,
HasModPresent: false,
HashedInstanceId: room.PhotonRoomId);
}
foreach (var p in privates)
{
if (string.IsNullOrEmpty(p.PhotonRoomId) || byKey.ContainsKey(p.PhotonRoomId)) continue;
byKey[p.PhotonRoomId] = new SimpleInstance(
RoomInstanceId: p.Id,
RoomId: p.RoomId,
SubRoomId: p.SubRoomId,
IsFull: false,
CreatedAt: DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
PlayerIds: new List<int>(),
SubroomName: p.Name,
PlayerCount: 0,
HasModPresent: false,
HashedInstanceId: p.PhotonRoomId);
privateRows.TryGetValue(inst.RoomInstanceId, out var privateRow);
if (inst.IsPrivate && !CanSeePrivateInstance(
pid, callerHasRole, playerIds, privateRow, invitedTo.Contains(inst.RoomInstanceId)))
continue;
var capacity = inst.MaxCapacity > 0 ? inst.MaxCapacity : privateRow?.MaxCapacity ?? 0;
rows.Add(new RoomInstanceRow(
RoomInstanceId: inst.RoomInstanceId,
RoomId: inst.RoomId,
SubRoomId: inst.SubRoomId,
IsFull: capacity > 0 && playerIds.Count >= capacity,
CreatedAt: privateRow is not null
? DateTime.SpecifyKind(privateRow.CreatedAt, DateTimeKind.Utc)
: DateTime.UtcNow,
PlayerIds: playerIds.Distinct().OrderBy(p => p).ToList()));
}
// Wire shape: SimpleRoomInstance.Deserialize reads CAMELCASE
// keys — verified at Cpp2IL_ISIL/.../RecNet/SimpleRoomInstance.txt
// lines 765-808 ({roomInstanceId, roomId, subRoomId, isFull,
// createdAt, playerIds}). SubroomName / PlayerCount /
// HasModPresent / HashedInstanceId are populated locally on the
// watch (PlayerIds.Count, local hash, etc.) — not read from
// JSON, so we don't send them. Earlier PascalCase response made
// every field default to 0/null on the watch, breaking the
// browser.
return Ok(byKey.Values.Select(i => new
{
roomInstanceId = i.RoomInstanceId,
roomId = i.RoomId,
subRoomId = i.SubRoomId,
isFull = i.IsFull,
createdAt = i.CreatedAt,
playerIds = i.PlayerIds.ToArray(),
}));
logger.LogInformation(
"[room-instances] room={Room} caller={Caller} live={Live} listed={Listed}",
roomId, pid, live.Count, rows.Count);
return Ok(rows.OrderBy(r => r.CreatedAt).Select(ToWire));
}
private sealed record SimpleInstance(
/// <summary>Who may see (and so be offered a join into) a private
/// instance in the browser.</summary>
public static bool CanSeePrivateInstance(
long callerId,
bool callerHasRoomRole,
IReadOnlyCollection<long> playersInside,
PrivateInstanceEntity? privateRow,
bool callerInvited)
{
if (callerId == 0) return false;
if (callerHasRoomRole) return true;
if (playersInside.Contains(callerId)) return true;
if (privateRow is not null && privateRow.OwnerPlayerId == callerId) return true;
return callerInvited;
}
internal sealed record RoomInstanceRow(
long RoomInstanceId, long RoomId, long SubRoomId, bool IsFull,
DateTime CreatedAt, List<int> PlayerIds, string SubroomName,
int PlayerCount, bool HasModPresent, string HashedInstanceId);
DateTime CreatedAt, List<long> PlayerIds);
/// <summary>PNDCMIMEJLD wire row. Account ids are Int32 on this DTO;
/// SubRoomId is the wire id the room details advertise
/// (<see cref="RoomSceneIds"/>), which the client uses to name the row.
/// The reader matches PascalCase, camelCase and lowercase keys; camelCase
/// is what this route has always sent.</summary>
internal static object ToWire(RoomInstanceRow r) => new
{
roomInstanceId = r.RoomInstanceId,
roomId = r.RoomId,
subRoomId = RoomSceneIds.ToWire(r.SubRoomId),
isFull = r.IsFull,
createdAt = r.CreatedAt,
playerIds = r.PlayerIds.Select(p => (int)p).ToArray(),
};
/// <summary>DELETE <c>match.*/invite/{id}</c> — Matchmaking.cs
/// DeclineRoomInvite. Verified URL at Matchmaking.txt:6505 (method
@@ -69,17 +69,15 @@ public class MatchPlayerController(
if (replaced)
{
log.LogWarning(
"[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; kicking old session(s) via ModerationKick push",
"[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; logging old session(s) out",
logScope, playerId, takeOverExclusiveSession);
// The new session hasn't opened its SignalR socket yet
// (negotiate fires AFTER /player/login on the watch's
// boot sequence — verified in the live trace), so the
// ModerationKick push hits only the OLDER session's
// existing connections. They'll show the "you've been
// kicked" dialog and gracefully disconnect via the
// watch's LocalModerationKickSelf path.
await notifications.KickStaleSession(playerId,
"Your account was signed in on another device.");
// (negotiate fires AFTER /player/login on the boot
// sequence — verified in the live trace), so the Logout
// push (Reason SessionTakeover) reaches only the OLDER
// session, which shows the client's own "signed in
// elsewhere" message and returns to the boot scene.
await notifications.LogoutStaleSession(playerId);
}
}
return Ok(Array.Empty<object>());
@@ -0,0 +1,115 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace DorkNet.Server.Controllers;
/// <summary>
/// Wraps an action's reply in the legacy <c>{success, value, error}</c>
/// envelope the 2023 rooms client reads for every room and playlist mutation.
///
/// <para><b>Client contract.</b> All of these calls go through one generic
/// helper, <c>NLDBPDCNNCF.BDBHCEGKGDC&lt;TResult&gt;(string opName, HTTPMethods,
/// string route, …)</c> (RVA 0x35E50A0, recnet-runtime-decomp/NLDBPDCNNCF.cs:2310;
/// called as <c>Call 0x1835E50A0</c> 47 times in
/// IsilDump/RecNet.Runtime/NLDBPDCNNCF.txt). It is the same helper, with the
/// same <c>BDBHCEGKGDC&lt;FGCPNAACHIK&gt;</c> instantiation token
/// <c>[0x1884C1948]</c>, that "clone room" uses (NLDBPDCNNCF.txt:4815-4823), and
/// clone is proven in game to need <c>{success, value, error}</c>
/// (docs/recroom-2023-room-save.md). A bare room-details body has no
/// <c>success</c>, so the helper rejects it with no message and the player sees
/// <c>Exception of type 'CEMNLBKJABA' was thrown.</c> even though the server
/// applied the change. The routes are: rooms/{0}/{accessibility,
/// allow_new_users, automute, cloning, comments, creator, description, image,
/// loadscreen, max_player_calculation_mode, min_level, modify, name,
/// promo_external, promo_images, restrictions, tags, voice_chat_encryption,
/// warning}, rooms/{0}/roles/{1}[/invite], rooms/{0}/subrooms[/{1}[/accessibility,
/// clone, maxplayers, modify, move, name, permissions, publish_save]],
/// playlists and playlists/{0}/{accessibility, description, image, levelvoting,
/// name, restrictions, rooms/{1}, tags, warning}.</para>
///
/// <para>A 2xx object result becomes <c>{success:true, value, error:""}</c>.
/// Anything else becomes HTTP 200 <c>{success:false, value:null, error}</c>
/// with a message the client can show, because a non-2xx reply also surfaces
/// only as the message-less CEMNLBKJABA.</para>
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public sealed class RecNetEnvelopeAttribute : ResultFilterAttribute
{
public override void OnResultExecuting(ResultExecutingContext context)
{
context.Result = Wrap(context.Result);
base.OnResultExecuting(context);
}
public static IActionResult Wrap(IActionResult result)
{
switch (result)
{
case ObjectResult obj when obj.StatusCode is null or (>= 200 and < 300):
return IsEnvelope(obj.Value)
? result
: Success(obj.Value);
case ObjectResult obj:
return Failure(ErrorText(obj.Value) ?? DefaultError(obj.StatusCode ?? 500));
case ForbidResult or ChallengeResult or UnauthorizedResult:
return Failure(DefaultError(403));
case StatusCodeResult code when code.StatusCode is >= 200 and < 300:
return Success(null);
case StatusCodeResult code:
return Failure(DefaultError(code.StatusCode));
case EmptyResult:
return Success(null);
default:
return result;
}
}
public static OkObjectResult Success(object? value) =>
new(new { success = true, value, error = string.Empty });
public static OkObjectResult Failure(string error) =>
new(new { success = false, value = (object?)null, error });
private static string DefaultError(int status) => status switch
{
400 => "That request was not valid.",
401 or 403 => "You don't have permission to do that.",
404 => "That room doesn't exist.",
409 => "That name is already taken.",
_ => "Something went wrong.",
};
/// <summary>An <c>error</c>/<c>Error</c> string on the failure body, if any.</summary>
private static string? ErrorText(object? value)
{
if (value is null) return null;
if (value is string s) return string.IsNullOrWhiteSpace(s) ? null : s;
try
{
using var doc = JsonDocument.Parse(JsonSerializer.Serialize(value));
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!prop.Name.Equals("error", StringComparison.OrdinalIgnoreCase)) continue;
if (prop.Value.ValueKind == JsonValueKind.String)
{
var text = prop.Value.GetString();
return string.IsNullOrWhiteSpace(text) ? null : text;
}
}
}
catch (NotSupportedException)
{
}
return null;
}
private static bool IsEnvelope(object? value)
{
if (value is null) return false;
var props = value.GetType().GetProperties();
bool Has(string name) => props.Any(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
return Has("success") && Has("value") && Has("error");
}
}
@@ -0,0 +1,76 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Site;
/// <summary>
/// The client builds CI publishes, for the site's Downloads page and the
/// Quest page's "latest build" install. Files are streamed from the downloads
/// bucket (<see cref="DownloadsService"/>) with Range support, so the browser
/// installer can read an .xapk's zip directory and entries without fetching
/// the whole file. Only files the manifest lists are served.
///
/// The builds contain Rec Room's own game files, so the routes answer only on
/// the site's own host (the apex) and 404 on every other host the server
/// accepts: the game's API/CDN subdomains are typically left open for the
/// client, while the site sits behind an access proxy (e.g. Cloudflare Access).
/// </summary>
[ApiController]
[AllowAnonymous]
[Route("api/site/v1/downloads")]
public class DownloadsController(DownloadsService downloads, DomainConfig domain) : ControllerBase
{
private bool Available => downloads.IsConfigured
&& string.Equals(Request.Host.Host, domain.Apex, StringComparison.OrdinalIgnoreCase);
/// <summary>With <see cref="DownloadsService.RequireSignIn"/>, only a
/// signed-in, linked player may read the builds.</summary>
private async Task<bool> SignedInOrOpenAsync() =>
!downloads.RequireSignIn
|| (await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme)).Succeeded;
/// <summary>GET /api/site/v1/downloads — the manifest as published
/// (snake_case keys), or 404 when downloads aren't set up or the request
/// isn't for the site's host.</summary>
[HttpGet]
public async Task<IActionResult> GetManifest(CancellationToken ct)
{
if (!Available) return NotFound(new { error = "Downloads aren't set up on this server." });
if (!await SignedInOrOpenAsync()) return Unauthorized(new { error = "sign_in_required" });
var manifest = await downloads.GetManifestAsync(ct);
Response.Headers.CacheControl = "no-cache";
return Content((manifest ?? new System.Text.Json.Nodes.JsonObject { ["releases"] = new System.Text.Json.Nodes.JsonArray() })
.ToJsonString(), "application/json");
}
/// <summary>GET /api/site/v1/downloads/files/{path} — one listed file,
/// streamed; honours Range (206).</summary>
[HttpGet("files/{**path}")]
public async Task GetFile(string path, CancellationToken ct)
{
if (!Available) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
if (!await SignedInOrOpenAsync()) { Response.StatusCode = StatusCodes.Status401Unauthorized; return; }
var manifest = await downloads.GetManifestAsync(ct);
var entry = manifest is null ? null : DownloadsService.FindFile(manifest, path);
if (entry is null) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
using var upstream = await downloads.GetFileAsync(path, Request.Headers.Range.ToString(), ct);
Response.StatusCode = (int)upstream.StatusCode;
if (!upstream.IsSuccessStatusCode) return;
var content = upstream.Content.Headers;
if (content.ContentLength is long length) Response.ContentLength = length;
if (content.ContentRange is not null) Response.Headers.ContentRange = content.ContentRange.ToString();
Response.Headers.AcceptRanges = "bytes";
if (upstream.Headers.ETag is not null) Response.Headers.ETag = upstream.Headers.ETag.ToString();
Response.Headers.CacheControl = "private, no-cache";
Response.ContentType = "application/octet-stream";
var name = (string?)entry["name"] ?? Path.GetFileName(path);
Response.Headers.ContentDisposition = new System.Net.Mime.ContentDisposition { FileName = name }.ToString();
await using var body = await upstream.Content.ReadAsStreamAsync(ct);
await body.CopyToAsync(Response.Body, 1 << 16, ct);
}
}
@@ -0,0 +1,178 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Site;
/// <summary>
/// Sign-in with the external OpenID Connect provider (<see cref="ExternalSignInOptions"/>)
/// for the website and the admin panel. The game client's login is separate.
///
/// <para>Flow: <c>login</c> challenges the provider; its callback
/// (<see cref="ExternalSignIn.CallbackPath"/>, handled by the OpenID Connect
/// middleware) stores the provider identity in the short-lived
/// <c>DorkNet.External</c> cookie and lands on <c>complete</c>. A linked identity
/// becomes a <c>DorkNet.Web</c> session at once; an unlinked one goes to the
/// website's <c>/link</c> page, where the player proves the account with its
/// password once (<c>account/link</c>).</para>
///
/// <para>Each host signs in on its own (website and admin panel), because the
/// hyphen host style puts them under different cookie domains.</para>
/// </summary>
[ApiController]
[AllowAnonymous]
public class ExternalSignInController(
IOptions<ExternalSignInOptions> options,
ExternalSignInService signIn,
LinkAttemptLimiter limiter,
DomainConfig domain,
ILogger<ExternalSignInController> log) : ControllerBase
{
private ExternalSignInOptions Options => options.Value;
/// <summary>GET /api/auth/external/login?returnUrl=/path — start sign-in.</summary>
[HttpGet(ExternalSignIn.LoginPath)]
public IActionResult Login([FromQuery] string? returnUrl)
{
if (!Options.IsConfigured) return NotFound(new { error = "sign_in_not_configured" });
var complete = $"{ExternalSignIn.CompletePath}?returnUrl={Uri.EscapeDataString(LocalUrl(returnUrl))}";
return Challenge(new AuthenticationProperties { RedirectUri = complete }, ExternalSignIn.OidcScheme);
}
/// <summary>GET /api/auth/external/complete — after the provider's callback.</summary>
[HttpGet(ExternalSignIn.CompletePath)]
public async Task<IActionResult> Complete([FromQuery] string? returnUrl)
{
returnUrl = LocalUrl(returnUrl);
var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme);
if (!external.Succeeded || ExternalIdentity(external.Principal) is not { } identity)
return Redirect(WithQuery(returnUrl, "signin_error", "no_identity"));
var (issuer, subject, email) = identity;
var link = await signIn.FindAsync(issuer, subject);
var player = link is null ? null : await signIn.ActivePlayerAsync(link);
if (link is not null && player is not null)
{
await signIn.TouchAsync(link, email);
await HttpContext.SignInAsync(ExternalSignIn.WebScheme, ExternalSignIn.PlayerPrincipal(player, link));
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
log.LogInformation("[signin] player={Player} via {Issuer}", player.Id, issuer);
return Redirect(returnUrl);
}
if (link is not null)
return Redirect(WithQuery(returnUrl, "signin_error", "account_unavailable"));
// Not linked yet. Linking happens on the website; the admin panel
// tells its user to link there first.
if (IsAdminHost())
return Redirect(WithQuery(returnUrl, "signin_error", "not_linked"));
return Redirect($"/link?returnUrl={Uri.EscapeDataString(returnUrl)}");
}
/// <summary>GET /api/site/v1/account/me — who is signed in, or which
/// provider identity is waiting to be linked.</summary>
[HttpGet("api/site/v1/account/me")]
public async Task<IActionResult> Me()
{
var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme);
var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme);
var pending = external.Succeeded ? ExternalIdentity(external.Principal) : null;
return Ok(new
{
signInAvailable = Options.IsConfigured,
provider = Options.DisplayName,
signedIn = web.Succeeded,
player = web.Succeeded
? new
{
id = ExternalSignIn.PlayerId(web.Principal)?.ToString(),
username = web.Principal!.Identity?.Name,
email = web.Principal.FindFirst(ExternalSignIn.EmailClaim)?.Value,
}
: null,
pendingLink = pending is { } p ? new { email = p.Email } : null,
});
}
public sealed record LinkRequest(string? Username, string? Password);
/// <summary>POST /api/site/v1/account/link {username, password} — link the
/// pending provider identity to the account these credentials prove.</summary>
[HttpPost("api/site/v1/account/link")]
public async Task<IActionResult> Link([FromBody] LinkRequest body)
{
var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme);
if (!external.Succeeded || ExternalIdentity(external.Principal) is not { } identity)
return Unauthorized(new { error = "no_pending_identity" });
var (issuer, subject, email) = identity;
var username = body.Username?.Trim() ?? string.Empty;
if (username.Length == 0 || string.IsNullOrEmpty(body.Password))
return BadRequest(new { error = "missing_credentials" });
var keys = new[] { $"sub:{issuer}|{subject}", $"user:{username.ToLowerInvariant()}" };
if (limiter.IsBlocked(keys))
return StatusCode(StatusCodes.Status429TooManyRequests, new { error = "too_many_attempts" });
var (outcome, player, link) = await signIn.LinkAsync(issuer, subject, email, username, body.Password);
switch (outcome)
{
case LinkOutcome.Linked:
limiter.Reset(keys);
await HttpContext.SignInAsync(ExternalSignIn.WebScheme, ExternalSignIn.PlayerPrincipal(player!, link!));
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
log.LogInformation("[signin] linked player={Player} to {Issuer}", player!.Id, issuer);
return Ok(new { linked = true, username = player.Username });
case LinkOutcome.InvalidCredentials:
limiter.RecordFailure(keys);
return Unauthorized(new { error = "invalid_credentials" });
case LinkOutcome.Banned:
return StatusCode(StatusCodes.Status403Forbidden, new { error = "banned" });
default:
return Conflict(new { error = "identity_linked_to_another_account" });
}
}
/// <summary>POST /api/site/v1/account/unlink — remove this provider identity
/// from the account and sign out.</summary>
[HttpPost("api/site/v1/account/unlink")]
public async Task<IActionResult> Unlink()
{
var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme);
if (!web.Succeeded || ExternalSignIn.PlayerId(web.Principal) is not { } playerId)
return Unauthorized(new { error = "not_signed_in" });
var issuer = web.Principal!.FindFirst(ExternalSignIn.IssuerClaim)?.Value ?? string.Empty;
await signIn.UnlinkAsync(playerId, issuer);
await HttpContext.SignOutAsync(ExternalSignIn.WebScheme);
return Ok(new { unlinked = true });
}
/// <summary>POST /api/site/v1/account/logout — end the session here.</summary>
[HttpPost("api/site/v1/account/logout")]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(ExternalSignIn.WebScheme);
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
return Ok(new { signedOut = true });
}
/// <summary>(issuer, subject, email) from the provider's claims. The
/// subject is the account key; the email is display only.</summary>
private (string Issuer, string Subject, string Email)? ExternalIdentity(System.Security.Claims.ClaimsPrincipal? p)
{
var subject = p?.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(subject)) return null;
var issuer = p!.FindFirst("iss")?.Value ?? Options.Authority;
return (issuer.TrimEnd('/'), subject, p.FindFirst("email")?.Value ?? string.Empty);
}
private string LocalUrl(string? url) =>
!string.IsNullOrEmpty(url) && Url.IsLocalUrl(url) ? url : "/";
private static string WithQuery(string url, string key, string value) =>
url + (url.Contains('?') ? "&" : "?") + key + "=" + Uri.EscapeDataString(value);
private bool IsAdminHost() =>
string.Equals(Request.Host.Host, domain.Sub("admin"), StringComparison.OrdinalIgnoreCase);
}
+21 -1
View File
@@ -1,9 +1,10 @@
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Data;
public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbContext(options)
public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbContext(options), IDataProtectionKeyContext
{
public DbSet<PlayerEntity> Players => Set<PlayerEntity>();
public DbSet<AvatarEntity> Avatars => Set<AvatarEntity>();
@@ -11,6 +12,7 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
public DbSet<RelationshipEntity> Relationships => Set<RelationshipEntity>();
public DbSet<RoomEntity> Rooms => Set<RoomEntity>();
public DbSet<RoomRoleEntity> RoomRoles => Set<RoomRoleEntity>();
public DbSet<RoomRolePermissionEntity> RoomRolePermissions => Set<RoomRolePermissionEntity>();
public DbSet<LeaderboardChannelMetaEntity> LeaderboardChannelMeta => Set<LeaderboardChannelMetaEntity>();
public DbSet<RoomBookmarkEntity> RoomBookmarks => Set<RoomBookmarkEntity>();
public DbSet<RoomDataBlobEntity> RoomDataBlobs => Set<RoomDataBlobEntity>();
@@ -87,9 +89,19 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
public DbSet<UgcPurchasableEntity> UgcPurchasables => Set<UgcPurchasableEntity>();
public DbSet<RoomConsumableEntity> RoomConsumables => Set<RoomConsumableEntity>();
public DbSet<RoomConsumableOwnershipEntity> RoomConsumableOwnership => Set<RoomConsumableOwnershipEntity>();
public DbSet<ExternalLoginEntity> ExternalLogins => Set<ExternalLoginEntity>();
// ASP.NET Core data-protection keys (sign-in cookies), kept in the
// database so a redeploy or a second replica doesn't sign everyone out.
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ExternalLoginEntity>(e =>
{
e.HasIndex(x => new { x.Issuer, x.Subject }).IsUnique();
e.HasIndex(x => x.PlayerId);
});
// SQLite-specific NOCASE collation works via UseCollation; Postgres
// doesn't have NOCASE. Provider-conditional helper applied to the
// three string columns the wire API matches case-insensitively
@@ -134,6 +146,14 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
e.HasIndex(p => new { p.ClubId, p.MembershipType }).IsUnique();
});
// One cell per (room, sub-room, role, permission): the sub-room
// permissions PUT upserts the cells it was sent.
modelBuilder.Entity<RoomRolePermissionEntity>(e =>
{
e.HasKey(p => p.Id);
e.HasIndex(p => new { p.RoomId, p.SubRoomId, p.Role, p.Permission }).IsUnique();
});
modelBuilder.Entity<ClubAdditionalImageEntity>(e =>
{
e.HasKey(i => i.Id);
@@ -0,0 +1,29 @@
namespace DorkNet.Server.Data.Entities;
/// <summary>
/// An identity from the external sign-in provider (OpenID Connect) linked to a
/// DorkNet account. One row per (Issuer, Subject): the provider's stable user
/// id, never the email, which can change or be reused. A player proves they own
/// the account once, with its password, when the link is made
/// (<c>ExternalSignInController.Link</c>); after that the provider alone signs
/// them in to the site and, for admins, the admin panel.
/// </summary>
public class ExternalLoginEntity
{
public long Id { get; set; }
public long PlayerId { get; set; }
/// <summary>The provider's issuer URL (the <c>iss</c> claim).</summary>
public string Issuer { get; set; } = string.Empty;
/// <summary>The provider's user id (the <c>sub</c> claim).</summary>
public string Subject { get; set; } = string.Empty;
/// <summary>The email the provider reported when the link was made or last
/// used. Shown to the player; never used to find the account.</summary>
public string Email { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime LastUsedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,53 @@
using System.ComponentModel.DataAnnotations;
namespace DorkNet.Server.Data.Entities;
/// <summary>
/// One cell of a sub-room's role-permission matrix, as written by
/// <c>PUT rooms/{roomId}/subrooms/{subRoomId}/permissions</c> and served back
/// in the <c>Permissions</c> list of <c>GET photon_access_token</c>.
///
/// The 2023-03-21 client models a cell as <c>BNLPBMJJOMM</c>
/// (formatter <c>RecNet.Runtime/HDOJMEBCAGP.txt</c>, keys
/// <c>Permission</c> / <c>Role</c> / <c>Override</c> / <c>Type</c> /
/// <c>Value</c>): Permission and Value are strings, Role is the room-role
/// enum <c>OMMBGJMJJPN</c> (None 0, Host 10, Moderator 20, CoOwner 30,
/// TemporaryCoOwner 31, Creator 255), Type is <c>FEOJFGECKGH</c>
/// (Bool 0, Int 1, StringList 2) and Override is a bool.
///
/// The editor sends only the cells it changed (the room-permissions runtime
/// builds a one-element <c>BNLPBMJJOMM[]</c> per edit,
/// <c>OMJKHJLFOCO.AFHJLBBFAGP.&lt;LocalRequestModifyRolePermission&gt;g__ChangeAndRefresh</c>),
/// so the write is an upsert per (room, sub-room, role, permission) and never
/// a replace of the whole matrix.
/// </summary>
public class RoomRolePermissionEntity
{
public long Id { get; set; }
public long RoomId { get; set; }
/// <summary>The wire sub-room id from the route (the same value the room
/// details advertise as <c>SubRooms[].SubRoomId</c>).</summary>
public long SubRoomId { get; set; }
/// <summary>Wire room-role value (OMMBGJMJJPN), e.g. 30 = CoOwner, 255 = Creator.</summary>
public int Role { get; set; }
/// <summary>The permission id exactly as the client sent it.</summary>
[MaxLength(128)]
public string Permission { get; set; } = string.Empty;
/// <summary>FEOJFGECKGH: 0 = Bool, 1 = Int, 2 = StringList.</summary>
public int Type { get; set; }
public bool Override { get; set; }
/// <summary>The value exactly as the client sent it (a string on the wire).</summary>
[MaxLength(4096)]
public string Value { get; set; } = string.Empty;
public long UpdatedByPlayerId { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
+11 -9
View File
@@ -1,20 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.15" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.13" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.13" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.13" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.13" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.13">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.13" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<!-- RenderedCompactJsonFormatter for stdout JSON logs in production
(Coolify-friendly). The host pipeline reads stdout line-by-line,
and structured JSON lets a future Loki/Datadog ingest parse fields
@@ -26,7 +28,7 @@
account-creation flow), and PlayerPresenceService heartbeats all
need shared state across replicas. -->
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="10.0.13" />
<!-- S3-compatible object storage. Production target: Garage in
Coolify (one-click). Dev fallback: local MinIO for parity
testing. The SDK speaks plain S3, so swapping endpoints
@@ -37,8 +39,8 @@
<!-- Password hashing for the real account auth flow. BCrypt embeds
cost+salt in the output string so we only need one column. -->
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.3.1" />
<!-- Server-side crop/resize for img.* CDN transforms (?cropSquare=1&width=N
on profile thumbnails). The 2020 client treats this as part of the
image CDN contract — it uploads a full-frame capture and expects the
+110 -37
View File
@@ -27,15 +27,16 @@ namespace DorkNet.Server.Hubs;
/// Pre-PR-2 used a local <c>static ConcurrentDictionary</c> which only
/// reflected this replica's connections.
///
/// Hub methods invoked by the client (verified by string-literal scan
/// of metadata at addresses 0x39b2470, 0x39b24c8, 0x39b4ba8):
/// "Subscribe" - generic topic subscribe
/// "Unsubscribe" - generic topic unsubscribe
/// "SubscribeToPlayers" - subscribe to per-player events (used by
/// RecNet.NotificationManager / SubscribeToPlayers)
/// All accept JsonElement so we don't lock ourselves into a parameter
/// schema we haven't verified yet, and return Task so the client's
/// awaited Invoke completes cleanly.
/// Hub methods the 2023-03-21 client sends (fire-and-forget, RecNet.Runtime
/// OMHPBIEHOHN):
/// "SubscribeToPlayers" - {PlayerIds}: players to get presence for
/// "SubscribeTo" - {Channels}: always empty in this build
/// "UnsubscribeFrom" - {Channels}: always empty in this build
/// There is no UnsubscribeFromPlayers; SubscribeToPlayers always carries the
/// complete set. All accept JsonElement and tolerate casing differences.
///
/// The client reconnects at most 3 times per login (its attempt counter is
/// never reset), so the server must not drop healthy connections.
/// </summary>
[Authorize]
public class NotifyHub(
@@ -65,28 +66,25 @@ public class NotifyHub(
// Instead: at the moment the SECOND session's WebSocket
// arrives, take a snapshot of the EXISTING connection ids
// (which still belong to the original session — we haven't
// joined the group yet) and push a targeted ModerationKick
// to those ids. The watch's OnModerationKick handler at
// PlayerReporting.txt:1857 shows the standard moderation
// dialog and routes through LocalModerationKickSelf, the
// same graceful disconnect path the original Rec Room used
// for real moderator kicks.
// joined the group yet) and send those ids a Logout with
// Reason SessionTakeover. The client shows its own "signed in
// elsewhere" message and returns to the boot scene. (A
// ModerationKick only sent the old session to its dorm, still
// signed in.)
//
// Targeting specific connection ids (not the group) means
// the new connection — about to join the group on the next
// line — never receives the kick, even with Redis backplane
// line — never receives it, even with Redis backplane
// routing across replicas.
var existing = presence.GetConnections(id);
if (existing.Count > 0)
{
logger.LogWarning(
"[hub] second-session connect for player={PlayerId} (conn={NewConn}); kicking {OldCount} existing connection(s)",
"[hub] second-session connect for player={PlayerId} (conn={NewConn}); logging out {OldCount} existing connection(s)",
id, Context.ConnectionId, existing.Count);
try
{
await notifications.KickConnectionsAsync(
existing,
"Your account was signed in on another device.");
await notifications.LogoutConnectionsAsync(existing);
}
catch (Exception ex)
{
@@ -156,36 +154,111 @@ public class NotifyHub(
await base.OnDisconnectedAsync(exception);
}
public Task SubscribeToPlayers(JsonElement payload)
/// <summary>SignalR group a connection joins to receive one player's
/// presence. Only presence is sent here — a subscriber must never receive
/// the subject's own pushes (DMs, gifts, …).</summary>
public static string PresenceGroupForPlayer(long playerId) => $"presence:{playerId}";
private const string SubscriptionsKey = "presence-subscriptions";
/// <summary>Most players one connection may subscribe to. The client sends
/// its relationships plus the players around it (dozens); anything far
/// past that is not a real client, and each id costs a group membership
/// on the backplane.</summary>
public const int MaxPresenceSubscriptions = 5000;
/// <summary><c>SubscribeToPlayers(PlayerSubscriptionListDTO {PlayerIds:
/// List&lt;int&gt;})</c> (RecNet.Runtime OMHPBIEHOHN.txt:3430): the full set
/// of players this client wants presence for — its relationships, the
/// players around it, and itself. Sent on every (re)connect and whenever
/// the set changes, always as the complete set, so it REPLACES the
/// connection's subscriptions. Presence for those players is then pushed
/// to <see cref="PresenceGroupForPlayer"/> by
/// <see cref="PresenceBroadcastService"/>, so non-friends the client is
/// looking at (people in the room, an open profile) stay current too.
///
/// The caller's own id is skipped: a presence frame about the local player
/// is applied as a correction and moves them. An unreadable payload
/// leaves the current subscriptions alone rather than clearing them.
/// Groups live per connection on its own replica, are routed cluster-wide
/// by the Redis backplane, and are dropped by SignalR on disconnect.</summary>
public async Task SubscribeToPlayers(JsonElement payload)
{
var me = TryGetPlayerId();
if (!TryReadPlayerIds(payload, out var requested))
{
logger.LogWarning(
"[hub] SubscribeToPlayers unreadable payload player={PlayerId} conn={ConnectionId} payload={Payload}",
me, Context.ConnectionId, RawPayload(payload));
return;
}
if (me is long self) requested.Remove(self);
if (requested.Count > MaxPresenceSubscriptions)
{
logger.LogWarning(
"[hub] SubscribeToPlayers over the {Max} limit player={PlayerId} conn={ConnectionId} count={Count}; kept the current set",
MaxPresenceSubscriptions, me, Context.ConnectionId, requested.Count);
return;
}
var current = Context.Items.TryGetValue(SubscriptionsKey, out var existing) && existing is HashSet<long> set
? set
: new HashSet<long>();
var added = requested.Except(current).ToList();
var removed = current.Except(requested).ToList();
foreach (var id in added)
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroupForPlayer(id));
foreach (var id in removed)
await Groups.RemoveFromGroupAsync(Context.ConnectionId, PresenceGroupForPlayer(id));
Context.Items[SubscriptionsKey] = requested;
logger.LogInformation(
"[hub] SubscribeToPlayers player={PlayerId} conn={ConnectionId} subscribed={Count} added={Added} removed={Removed}",
me, Context.ConnectionId, requested.Count, added.Count, removed.Count);
}
/// <summary><c>SubscribeTo({Channels: List&lt;string&gt;})</c>. The
/// 2023-03-21 client composes it on every connect but its channel set is
/// always empty, so there is nothing to subscribe to; logged only.</summary>
public Task SubscribeTo(JsonElement payload)
{
logger.LogInformation(
"[hub] SubscribeToPlayers player={PlayerId} conn={ConnectionId} payload={Payload}",
"[hub] SubscribeTo player={PlayerId} conn={ConnectionId} payload={Payload}",
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
return Task.CompletedTask;
}
public Task UnsubscribeFromPlayers(JsonElement payload)
/// <summary>Counterpart of <see cref="SubscribeTo"/>; logged only.</summary>
public Task UnsubscribeFrom(JsonElement payload)
{
logger.LogInformation(
"[hub] UnsubscribeFromPlayers player={PlayerId} conn={ConnectionId} payload={Payload}",
"[hub] UnsubscribeFrom player={PlayerId} conn={ConnectionId} payload={Payload}",
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
return Task.CompletedTask;
}
public Task Subscribe(JsonElement payload)
/// <summary>Reads <c>{PlayerIds:[…]}</c> (any key casing), or a bare
/// array, as player ids; numbers or numeric strings.</summary>
public static bool TryReadPlayerIds(JsonElement payload, out HashSet<long> ids)
{
logger.LogInformation(
"[hub] Subscribe player={PlayerId} conn={ConnectionId} payload={Payload}",
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
return Task.CompletedTask;
}
public Task Unsubscribe(JsonElement payload)
{
logger.LogInformation(
"[hub] Unsubscribe player={PlayerId} conn={ConnectionId} payload={Payload}",
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
return Task.CompletedTask;
ids = new HashSet<long>();
var list = payload;
if (payload.ValueKind == JsonValueKind.Object)
{
var match = payload.EnumerateObject()
.Where(prop => string.Equals(prop.Name, "PlayerIds", StringComparison.OrdinalIgnoreCase))
.Select(prop => (JsonElement?)prop.Value)
.FirstOrDefault();
if (match is not JsonElement value) return false;
list = value;
}
if (list.ValueKind != JsonValueKind.Array) return false;
foreach (var item in list.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.Number && item.TryGetInt64(out var n) && n > 0) ids.Add(n);
else if (item.ValueKind == JsonValueKind.String && long.TryParse(item.GetString(), out var p) && p > 0) ids.Add(p);
}
return true;
}
private long? TryGetPlayerId()
@@ -0,0 +1,3718 @@
// <auto-generated />
using System;
using DorkNet.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DorkNet.Server.Migrations
{
[DbContext(typeof(DorkNetDbContext))]
[Migration("20261024201104_RoomRolePermissions")]
partial class RoomRolePermissions
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("AdminPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<long>("TargetId")
.HasColumnType("INTEGER");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AdminPlayerId");
b.HasIndex("Timestamp");
b.HasIndex("TargetType", "TargetId");
b.ToTable("AdminActions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CustomAvatarItemsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EquippedItemsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FaceFeatures")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("TEXT");
b.Property<string>("HairColor")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("InventoryJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OutfitSelections")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("OutfitSelectionsV2")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("SavedOutfitsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SkinColor")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId")
.IsUnique();
b.ToTable("Avatars");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ClientVersion")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("GameSessionId")
.HasColumnType("INTEGER");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime?>("ReadAt")
.HasColumnType("TEXT");
b.Property<long>("ReporterPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ReadAt");
b.HasIndex("ReporterPlayerId");
b.ToTable("BugReports");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActionUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long?>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ExpiresAt");
b.HasIndex("PlayerId");
b.ToTable("Cards");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CharadesWordListEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsBuiltIn")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("WordsJson")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CharadesWordLists");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("ModerationState")
.HasColumnType("INTEGER");
b.Property<long>("SenderPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SentAt")
.HasColumnType("TEXT");
b.Property<string>("ThreadKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ThreadKey", "SentAt");
b.ToTable("ChatMessages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ThreadKey")
.IsRequired()
.HasMaxLength(96)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ThreadKey")
.IsUnique();
b.ToTable("ChatThreads");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("JoinedAt")
.HasColumnType("TEXT");
b.Property<long?>("LastReadMessageId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("SnoozeUntil")
.HasColumnType("TEXT");
b.Property<string>("ThreadKey")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("ThreadKey", "PlayerId")
.IsUnique();
b.ToTable("ChatThreadMembers");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CheeredAt")
.HasColumnType("TEXT");
b.Property<long>("FromPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetInventionId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPhotoId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetRoomId")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TargetPhotoId");
b.HasIndex("TargetPlayerId");
b.HasIndex("TargetRoomId");
b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type")
.IsUnique();
b.ToTable("Cheers");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAdditionalImageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("Slot")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "Slot")
.IsUnique();
b.ToTable("ClubAdditionalImages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("AuthorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "CreatedAt");
b.ToTable("ClubAnnouncements");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("AnnouncementId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("ReadAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("AnnouncementId", "PlayerId")
.IsUnique();
b.ToTable("ClubAnnouncementReads");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryAssignmentEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CategoryTagId")
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CategoryTagId");
b.HasIndex("ClubId", "CategoryTagId")
.IsUnique();
b.ToTable("ClubCategoryAssignments");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryTagEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Active")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Name");
b.ToTable("ClubCategoryTags");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowJuniors")
.HasColumnType("INTEGER");
b.Property<int>("BanStatus")
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("ClubChatEnabled")
.HasColumnType("INTEGER");
b.Property<int>("ClubType")
.HasColumnType("INTEGER");
b.Property<long?>("ClubhouseRoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsRRO")
.HasColumnType("INTEGER");
b.Property<int>("Joinability")
.HasColumnType("INTEGER");
b.Property<int>("MinLevel")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("Visibility")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Clubs");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<DateTime>("JoinedAt")
.HasColumnType("TEXT");
b.Property<int>("Permissions")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("ClubId", "PlayerId")
.IsUnique();
b.ToTable("ClubMemberships");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubRolePermissionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("ApproveMember")
.HasColumnType("INTEGER");
b.Property<bool>("BanUnban")
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<bool>("CreateEvent")
.HasColumnType("INTEGER");
b.Property<bool>("EditDetails")
.HasColumnType("INTEGER");
b.Property<bool>("EditPermissionSettings")
.HasColumnType("INTEGER");
b.Property<int>("MembershipType")
.HasColumnType("INTEGER");
b.Property<bool>("PostAnnouncement")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "MembershipType")
.IsUnique();
b.ToTable("ClubRolePermissions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SubscribedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId");
b.HasIndex("PlayerId", "ClubId")
.IsUnique();
b.ToTable("ClubSubscriptions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AssignedAt")
.HasColumnType("TEXT");
b.Property<string>("CohortKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Variant")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "CohortKey")
.IsUnique();
b.ToTable("CohortAssignments");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<string>("Json")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CommunityBoardRows");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<int>("MaxRedemptions")
.HasColumnType("INTEGER");
b.Property<int>("RedemptionCount")
.HasColumnType("INTEGER");
b.Property<long>("RewardAmount")
.HasColumnType("INTEGER");
b.Property<int>("RewardCurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("RewardItemSlug")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("RewardType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("Coupons");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CouponId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("RedeemedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CouponId", "PlayerId")
.IsUnique();
b.ToTable("CouponRedemptions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("Balance")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "CurrencyType")
.IsUnique();
b.ToTable("CurrencyBalances");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AssetName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("BaseAvatarItemId")
.HasColumnType("INTEGER");
b.Property<long>("CheerCount")
.HasColumnType("INTEGER");
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsFeatured")
.HasColumnType("INTEGER");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("PurchaseCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("IsFeatured");
b.HasIndex("IsPublic");
b.HasIndex("PublicId")
.IsUnique();
b.ToTable("CustomAvatarItems");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemOwnershipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AcquiredAt")
.HasColumnType("TEXT");
b.Property<long>("CustomAvatarItemId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CustomAvatarItemId");
b.HasIndex("PlayerId", "CustomAvatarItemId")
.IsUnique();
b.ToTable("CustomAvatarItemOwnership");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b =>
{
b.Property<long>("PlayerId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CurrentDataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("PlayerId");
b.ToTable("DormStates");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("GiftContext")
.HasColumnType("INTEGER");
b.Property<long>("GrantedItemId")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long>("Offer1ItemId")
.HasColumnType("INTEGER");
b.Property<long>("Offer2ItemId")
.HasColumnType("INTEGER");
b.Property<long>("Offer3ItemId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("RewardType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("SelectedAt")
.HasColumnType("TEXT");
b.Property<int?>("SelectedGiftDropId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "SelectedAt");
b.ToTable("GameRewardSelections");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActivityLevelId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<string>("PhotonRoomName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("PlayerCount")
.HasColumnType("INTEGER");
b.Property<string>("Region")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("RoomId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoomId", "Region");
b.ToTable("GameSessions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AvatarItemDescOrHairDyeDesc")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("AvatarItemType")
.HasColumnType("INTEGER");
b.Property<string>("ConsumableItemDesc")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("Consumed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ConsumedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Currency")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("EquipmentModificationGuid")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("EquipmentPrefabName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ErrorMessage")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("FromPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("GiftContext")
.HasColumnType("INTEGER");
b.Property<int>("GiftRarity")
.HasColumnType("INTEGER");
b.Property<bool>("IsGifted")
.HasColumnType("INTEGER");
b.Property<bool>("IsValid")
.HasColumnType("INTEGER");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("PackageMaterial")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PackageVariant")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<long>("RecipientPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("SourceStoreItemId")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsCurrentPlatform")
.HasColumnType("INTEGER");
b.Property<int>("Xp")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RecipientPlayerId", "Consumed");
b.ToTable("GiftPackages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long?>("CreationRoomId")
.HasColumnType("INTEGER");
b.Property<int>("CreatorPermission")
.HasColumnType("INTEGER");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CurrentBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("CurrentVersionNumber")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("FirstPublishedAt")
.HasColumnType("TEXT");
b.Property<int>("GeneralPermission")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsAgInvention")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsPublished")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("NumPlayersHaveUsedInRoom")
.HasColumnType("INTEGER");
b.Property<int>("Permission")
.HasColumnType("INTEGER");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("SpawnCount")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CheerCount");
b.HasIndex("CreatorPlayerId");
b.ToTable("Inventions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("InstantiationCost")
.HasColumnType("INTEGER");
b.Property<long>("InventionId")
.HasColumnType("INTEGER");
b.Property<int>("LightsCost")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("VersionNumber")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("InventionId", "VersionNumber")
.IsUnique();
b.ToTable("InventionVersions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("BannedAt")
.HasColumnType("TEXT");
b.Property<long>("BannedByAdminId")
.HasColumnType("INTEGER");
b.Property<string>("Cidr")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("Until")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Cidr");
b.ToTable("IpBans");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ItemKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "ItemKey", "ItemType")
.IsUnique();
b.ToTable("ItemWishlists");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.KeepsakeEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("EarnedAt")
.HasColumnType("TEXT");
b.Property<string>("EventKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("PlayerId", "EventKey");
b.ToTable("Keepsakes");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardChannelMetaEntity", b =>
{
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("Channel")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("LowerIsBetter")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("ValueFormat")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("RoomId", "Channel");
b.HasIndex("RoomId");
b.ToTable("LeaderboardChannelMeta");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("StatChannel")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long>("Value")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoomId", "PlayerId", "StatChannel")
.IsUnique();
b.HasIndex("RoomId", "StatChannel", "Value");
b.ToTable("LeaderboardStats");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Context")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int>("PlatformMask")
.HasColumnType("INTEGER");
b.Property<string>("RoomNamesCsv")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("LoadingScreenTips");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("ReadAt")
.HasColumnType("TEXT");
b.Property<long>("RecipientPlayerId")
.HasColumnType("INTEGER");
b.Property<long?>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SenderPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SentAt")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RecipientPlayerId", "SentAt");
b.HasIndex("SenderPlayerId", "SentAt");
b.ToTable("Messages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowAll")
.HasColumnType("INTEGER");
b.Property<bool>("AllowAnnouncements")
.HasColumnType("INTEGER");
b.Property<bool>("AllowEventInvite")
.HasColumnType("INTEGER");
b.Property<bool>("AllowFriendRequest")
.HasColumnType("INTEGER");
b.Property<bool>("AllowMessage")
.HasColumnType("INTEGER");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "Platform")
.IsUnique();
b.ToTable("NotificationPrefs");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("ClearedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsCompleted")
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "Key")
.IsUnique();
b.ToTable("ObjectiveProgress");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("FirstSeenAt")
.HasColumnType("TEXT");
b.Property<string>("LastIp")
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<string>("PlatformId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PendingDevices");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Caption")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<string>("TaggedPlayerIdsCsv")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<long>("UploaderPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("ViewCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("BlobName");
b.HasIndex("CreatedAt");
b.HasIndex("RoomId");
b.HasIndex("UploaderPlayerId");
b.ToTable("Photos");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<string>("PlatformUserId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "Platform", "PlatformUserId")
.IsUnique();
b.ToTable("PlatformIgnores");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("FirstSeen")
.HasColumnType("TEXT");
b.Property<bool>("IsBanned")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastSeen")
.HasColumnType("TEXT");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("DeviceId");
b.HasIndex("PlayerId");
b.ToTable("PlayerDevices");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Elo")
.HasColumnType("INTEGER");
b.Property<int>("GameMode")
.HasColumnType("INTEGER");
b.Property<int>("Losses")
.HasColumnType("INTEGER");
b.Property<int>("MatchesPlayed")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("Wins")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GameMode", "Elo");
b.HasIndex("PlayerId", "GameMode")
.IsUnique();
b.ToTable("PlayerElo");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("BannedUntil")
.HasColumnType("TEXT");
b.Property<string>("BannerImageName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Bio")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("Birthday")
.HasColumnType("TEXT");
b.Property<bool>("CanReceiveInvites")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedFromDeviceId")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DisplayEmoji")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("IdentityFlags")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCommunityTeam")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeveloper")
.HasColumnType("INTEGER");
b.Property<bool>("IsJunior")
.HasColumnType("INTEGER");
b.Property<bool>("IsVerified")
.HasColumnType("INTEGER");
b.Property<string>("LastIp")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("LastPlatform")
.HasColumnType("INTEGER");
b.Property<string>("LastPlatformId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("PersonalPronouns")
.HasColumnType("INTEGER");
b.Property<string>("Phone")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ProfileImageName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("Reputation")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("XP")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Players");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Capacity")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("EndsAt")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("StartsAt")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("StartsAt");
b.ToTable("PlayerEvents");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("EventId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Response")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("EventId", "PlayerId")
.IsUnique();
b.ToTable("PlayerEventResponses");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AcquiredAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("ItemSlug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Quantity")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("PlayerId", "ItemSlug")
.IsUnique();
b.ToTable("PlayerInventory");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "Key")
.IsUnique();
b.ToTable("PlayerSettings");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Accessibility")
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CustomWarning")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("FavoriteCount")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsCurated")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsJuniors")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsLevelVoting")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsScreens")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsTeleportVR")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsWalkVR")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.Property<int>("VisitorCount")
.HasColumnType("INTEGER");
b.Property<int>("WarningMask")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("IsCurated");
b.HasIndex("Name");
b.ToTable("Playlists");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistInteractionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Cheered")
.HasColumnType("INTEGER");
b.Property<bool>("Favorited")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("PlaylistId", "PlayerId")
.IsUnique();
b.ToTable("PlaylistInteractions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistRoomEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<long>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlaylistId");
b.HasIndex("RoomId");
b.HasIndex("PlaylistId", "OrderIndex");
b.ToTable("PlaylistRooms");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b =>
{
b.Property<long>("Id")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DataBlob")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Location")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("OwnerPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("PhotonRegion")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PhotonRoomId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("RoomCode")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SubRoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("OwnerPlayerId");
b.HasIndex("RoomId", "SubRoomId");
b.ToTable("PrivateInstances");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b =>
{
b.Property<long>("PrivateInstanceId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("InvitedAt")
.HasColumnType("TEXT");
b.Property<long?>("LatestInviteMessageId")
.HasColumnType("INTEGER");
b.HasKey("PrivateInstanceId", "PlayerId");
b.HasIndex("PlayerId");
b.ToTable("PrivateInstanceInvitees");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Token");
b.HasIndex("PlayerId", "Platform")
.IsUnique();
b.ToTable("PushTokens");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("Favorited")
.HasColumnType("INTEGER");
b.Property<bool>("Ignored")
.HasColumnType("INTEGER");
b.Property<bool>("Muted")
.HasColumnType("INTEGER");
b.Property<long>("RequesterId")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<long>("TargetId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RequesterId", "TargetId")
.IsUnique();
b.ToTable("Relationships");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Category")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("GameSessionId")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long>("ReporterPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("ResolutionNote")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("TEXT");
b.Property<long?>("ResolverAdminId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("TargetEventId")
.HasColumnType("INTEGER");
b.Property<long>("TargetInventionId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetRoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ReporterPlayerId");
b.HasIndex("ResolvedAt");
b.HasIndex("TargetPlayerId");
b.ToTable("Reports");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BanType")
.HasColumnType("INTEGER");
b.Property<long>("BannedByPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("BannedPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("Until")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("BannedPlayerId");
b.HasIndex("RoomId", "BannedPlayerId");
b.ToTable("RoomBans");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("PlayerId", "RoomId")
.IsUnique();
b.ToTable("RoomBookmarks");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<Guid?>("CurrencyId")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("PublicId")
.IsUnique();
b.HasIndex("RoomId");
b.ToTable("RoomConsumables");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableOwnershipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<Guid>("ConcurrencyCode")
.HasColumnType("TEXT");
b.Property<int>("Count")
.HasColumnType("INTEGER");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomConsumableId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoomConsumableId");
b.HasIndex("PlayerId", "RoomConsumableId")
.IsUnique();
b.ToTable("RoomConsumableOwnership");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyBalanceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("Balance")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomCurrencyId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoomCurrencyId");
b.HasIndex("PlayerId", "RoomCurrencyId")
.IsUnique();
b.ToTable("RoomCurrencyBalances");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("DailyLimit")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("PublicId")
.IsUnique();
b.HasIndex("RoomId");
b.ToTable("RoomCurrencies");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyPurchaseOfferEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Amount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomCurrencyId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PublicId")
.IsUnique();
b.HasIndex("RoomCurrencyId");
b.ToTable("RoomCurrencyPurchaseOffers");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<byte[]>("Bytes")
.HasColumnType("BLOB");
b.Property<string>("ReferencedFilenamesCsv")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long?>("SubRoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UploadedAt")
.HasColumnType("TEXT");
b.Property<long>("UploadedByPlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("BlobName")
.IsUnique();
b.HasIndex("RoomId");
b.ToTable("RoomDataBlobs");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Accessibility")
.HasColumnType("INTEGER");
b.Property<bool>("AllowNewUsers")
.HasColumnType("INTEGER");
b.Property<bool>("AllowsJuniors")
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<bool>("CloningAllowed")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CurrentDataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("CustomRoomWarning")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<bool>("DisableMicAutoMute")
.HasColumnType("INTEGER");
b.Property<int>("FavoriteCount")
.HasColumnType("INTEGER");
b.Property<bool>("HiddenFromBrowse")
.HasColumnType("INTEGER");
b.Property<double>("HotScore")
.HasColumnType("REAL");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsAGRoom")
.HasColumnType("INTEGER");
b.Property<bool>("IsDormRoom")
.HasColumnType("INTEGER");
b.Property<bool>("IsRoomLinkedToRecRoomStudio")
.HasColumnType("INTEGER");
b.Property<bool>("IsStudioRoom")
.HasColumnType("INTEGER");
b.Property<string>("LoadScreensJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<string>("LocationReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<int>("MaxPlayerCalculationMode")
.HasColumnType("INTEGER");
b.Property<int>("MinLevel")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<string>("PromoExternalContentJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<string>("PromoImagesJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<int>("RoomWarningMask")
.HasColumnType("INTEGER");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<string>("StudioSessionId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("SupportsLevelVoting")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsMobile")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsScreens")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsTeleportVR")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsVRLow")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsWalkVR")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.Property<int>("VisitorCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("HotScore");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Rooms");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(174)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("RoomId");
b.HasIndex("RoomId", "Name");
b.ToTable("RoomKeys");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyPurchaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("PaidPrice")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("PurchasedAt")
.HasColumnType("TEXT");
b.Property<long>("RoomKeyId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("RoomKeyId", "PlayerId")
.IsUnique();
b.ToTable("RoomKeyPurchases");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRoleEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Accepted")
.HasColumnType("INTEGER");
b.Property<DateTime>("GrantedAt")
.HasColumnType("TEXT");
b.Property<long?>("GrantedByPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoomId");
b.HasIndex("RoomId", "PlayerId", "Role")
.IsUnique();
b.ToTable("RoomRoles");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRolePermissionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Override")
.HasColumnType("INTEGER");
b.Property<string>("Permission")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SubRoomId")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long>("UpdatedByPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoomId", "SubRoomId", "Role", "Permission")
.IsUnique();
b.ToTable("RoomRolePermissions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("CanMatchmakeInto")
.HasColumnType("INTEGER");
b.Property<string>("DataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("DataModifiedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsSandbox")
.HasColumnType("INTEGER");
b.Property<int>("MaxPlayers")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<string>("RoomSceneLocationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("StudioAssetBundleNamesCsv")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<long?>("StudioSubRoomDataSaveId")
.HasColumnType("INTEGER");
b.Property<string>("StudioUnityAssetId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoomId", "Name");
b.HasIndex("RoomId", "OrderIndex")
.IsUnique();
b.ToTable("RoomScenes");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("FirstVisitAt")
.HasColumnType("TEXT");
b.Property<DateTime>("LastVisitAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("RoomId", "LastVisitAt");
b.HasIndex("RoomId", "PlayerId")
.IsUnique();
b.ToTable("RoomVisits");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChestsOpened")
.HasColumnType("INTEGER");
b.Property<DateTime>("CompletedAt")
.HasColumnType("TEXT");
b.Property<bool>("CustomGame")
.HasColumnType("INTEGER");
b.Property<int>("HealthPotionsConsumed")
.HasColumnType("INTEGER");
b.Property<int>("NumEliminations")
.HasColumnType("INTEGER");
b.Property<int>("Rank")
.HasColumnType("INTEGER");
b.Property<int>("SecondsAlive")
.HasColumnType("INTEGER");
b.Property<int>("SecondsInAir")
.HasColumnType("INTEGER");
b.Property<int>("ShieldPotionsConsumed")
.HasColumnType("INTEGER");
b.Property<bool>("WalkGame")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CompletedAt");
b.ToTable("RoyaleMatches");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("MatchId")
.HasColumnType("INTEGER");
b.Property<int>("NumEliminations")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Rank")
.HasColumnType("INTEGER");
b.Property<int>("SecondsAlive")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("MatchId", "PlayerId");
b.ToTable("RoyaleMatchPlayers");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CurrentLevelXPThreshold")
.HasColumnType("INTEGER");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<int>("NextLevelAcornReward")
.HasColumnType("INTEGER");
b.Property<long>("NextLevelXPThreshold")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("RankIdx")
.HasColumnType("INTEGER");
b.Property<string>("RankName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("TotalXP")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId")
.IsUnique();
b.ToTable("RoyalePlayerProgress");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<bool>("AllAvatarItemsOwned")
.HasColumnType("INTEGER");
b.Property<string>("BaseRoomNamesJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CharadesSlotBindingsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CircuitsV2LimitsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DiscoveredGameConfigsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("GlobalFriendsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("PlayMenuTagsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("ProfanityFilterDisabled")
.HasColumnType("INTEGER");
b.Property<string>("RecCenterDoorsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("RoomBlobVersionClampDisabled")
.HasColumnType("INTEGER");
b.Property<bool>("SignupsDisabled")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("WeeklyChallengeRewardJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("WeeklyChallengesCompletedRequired")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<string>("WeeklyChallengesJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.HasKey("Id");
b.ToTable("ServerSettings");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatedByPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Descriptor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("RedeemedAt")
.HasColumnType("TEXT");
b.Property<long?>("RedeemedByPlayerId")
.HasColumnType("INTEGER");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("SignupCodes");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("AvailableUntil")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<bool>("IsLimitedTime")
.HasColumnType("INTEGER");
b.Property<long>("Price")
.HasColumnType("INTEGER");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Storefront")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.HasIndex("Storefront");
b.HasIndex("IsActive", "Category");
b.ToTable("StoreItems");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("SubscriberPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SubscriberPlayerId");
b.HasIndex("TargetPlayerId");
b.HasIndex("SubscriberPlayerId", "TargetPlayerId")
.IsUnique();
b.ToTable("Subscriptions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b =>
{
b.Property<long>("Pk")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AssignedPlayerIdsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("AssignedPlayerNamesJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CommentsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("JiraBugUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("JiraUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MinNumAssignedPlayers")
.HasColumnType("INTEGER");
b.Property<string>("RoomName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TagsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<uint?>("TestPassId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Pk");
b.HasIndex("Id")
.IsUnique();
b.HasIndex("Status");
b.HasIndex("TestPassId");
b.ToTable("TestCases");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b =>
{
b.Property<uint>("Id")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("EndDate")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("StartDate")
.HasColumnType("TEXT");
b.Property<string>("TagsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("WasManuallyClosed")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("TestPasses");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsFeatured")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("PublicId")
.IsUnique();
b.HasIndex("RoomId");
b.ToTable("UgcPurchasables");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
{
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
.WithOne("Avatar")
.HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Player");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
{
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
.WithMany("Settings")
.HasForeignKey("PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Player");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
{
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester")
.WithMany("Relationships")
.HasForeignKey("RequesterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Requester");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
{
b.Navigation("Avatar");
b.Navigation("Relationships");
b.Navigation("Settings");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DorkNet.Server.Migrations
{
/// <inheritdoc />
public partial class RoomRolePermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "RoomRolePermissions",
columns: table => new
{
Id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
RoomId = table.Column<long>(type: "INTEGER", nullable: false),
SubRoomId = table.Column<long>(type: "INTEGER", nullable: false),
Role = table.Column<int>(type: "INTEGER", nullable: false),
Permission = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Type = table.Column<int>(type: "INTEGER", nullable: false),
Override = table.Column<bool>(type: "INTEGER", nullable: false),
Value = table.Column<string>(type: "TEXT", maxLength: 4096, nullable: false),
UpdatedByPlayerId = table.Column<long>(type: "INTEGER", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RoomRolePermissions", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_RoomRolePermissions_RoomId_SubRoomId_Role_Permission",
table: "RoomRolePermissions",
columns: new[] { "RoomId", "SubRoomId", "Role", "Permission" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RoomRolePermissions");
}
}
}
@@ -0,0 +1,3772 @@
// <auto-generated />
using System;
using DorkNet.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DorkNet.Server.Migrations
{
[DbContext(typeof(DorkNetDbContext))]
[Migration("20261025134141_ExternalSignIn")]
partial class ExternalSignIn
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.13");
modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("AdminPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<long>("TargetId")
.HasColumnType("INTEGER");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AdminPlayerId");
b.HasIndex("Timestamp");
b.HasIndex("TargetType", "TargetId");
b.ToTable("AdminActions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CustomAvatarItemsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EquippedItemsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FaceFeatures")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("TEXT");
b.Property<string>("HairColor")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("InventoryJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OutfitSelections")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("OutfitSelectionsV2")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("SavedOutfitsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SkinColor")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId")
.IsUnique();
b.ToTable("Avatars");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ClientVersion")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("GameSessionId")
.HasColumnType("INTEGER");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime?>("ReadAt")
.HasColumnType("TEXT");
b.Property<long>("ReporterPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ReadAt");
b.HasIndex("ReporterPlayerId");
b.ToTable("BugReports");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActionUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long?>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ExpiresAt");
b.HasIndex("PlayerId");
b.ToTable("Cards");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CharadesWordListEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsBuiltIn")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("WordsJson")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CharadesWordLists");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("ModerationState")
.HasColumnType("INTEGER");
b.Property<long>("SenderPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SentAt")
.HasColumnType("TEXT");
b.Property<string>("ThreadKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ThreadKey", "SentAt");
b.ToTable("ChatMessages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ThreadKey")
.IsRequired()
.HasMaxLength(96)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ThreadKey")
.IsUnique();
b.ToTable("ChatThreads");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("JoinedAt")
.HasColumnType("TEXT");
b.Property<long?>("LastReadMessageId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("SnoozeUntil")
.HasColumnType("TEXT");
b.Property<string>("ThreadKey")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("ThreadKey", "PlayerId")
.IsUnique();
b.ToTable("ChatThreadMembers");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CheeredAt")
.HasColumnType("TEXT");
b.Property<long>("FromPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetInventionId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPhotoId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetRoomId")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TargetPhotoId");
b.HasIndex("TargetPlayerId");
b.HasIndex("TargetRoomId");
b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type")
.IsUnique();
b.ToTable("Cheers");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAdditionalImageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("Slot")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "Slot")
.IsUnique();
b.ToTable("ClubAdditionalImages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("AuthorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "CreatedAt");
b.ToTable("ClubAnnouncements");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("AnnouncementId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("ReadAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("AnnouncementId", "PlayerId")
.IsUnique();
b.ToTable("ClubAnnouncementReads");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryAssignmentEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CategoryTagId")
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CategoryTagId");
b.HasIndex("ClubId", "CategoryTagId")
.IsUnique();
b.ToTable("ClubCategoryAssignments");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryTagEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Active")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Name");
b.ToTable("ClubCategoryTags");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowJuniors")
.HasColumnType("INTEGER");
b.Property<int>("BanStatus")
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("ClubChatEnabled")
.HasColumnType("INTEGER");
b.Property<int>("ClubType")
.HasColumnType("INTEGER");
b.Property<long?>("ClubhouseRoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsRRO")
.HasColumnType("INTEGER");
b.Property<int>("Joinability")
.HasColumnType("INTEGER");
b.Property<int>("MinLevel")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("Visibility")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Clubs");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<DateTime>("JoinedAt")
.HasColumnType("TEXT");
b.Property<int>("Permissions")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("ClubId", "PlayerId")
.IsUnique();
b.ToTable("ClubMemberships");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubRolePermissionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("ApproveMember")
.HasColumnType("INTEGER");
b.Property<bool>("BanUnban")
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<bool>("CreateEvent")
.HasColumnType("INTEGER");
b.Property<bool>("EditDetails")
.HasColumnType("INTEGER");
b.Property<bool>("EditPermissionSettings")
.HasColumnType("INTEGER");
b.Property<int>("MembershipType")
.HasColumnType("INTEGER");
b.Property<bool>("PostAnnouncement")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "MembershipType")
.IsUnique();
b.ToTable("ClubRolePermissions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SubscribedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId");
b.HasIndex("PlayerId", "ClubId")
.IsUnique();
b.ToTable("ClubSubscriptions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AssignedAt")
.HasColumnType("TEXT");
b.Property<string>("CohortKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Variant")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "CohortKey")
.IsUnique();
b.ToTable("CohortAssignments");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<string>("Json")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CommunityBoardRows");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<int>("MaxRedemptions")
.HasColumnType("INTEGER");
b.Property<int>("RedemptionCount")
.HasColumnType("INTEGER");
b.Property<long>("RewardAmount")
.HasColumnType("INTEGER");
b.Property<int>("RewardCurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("RewardItemSlug")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("RewardType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("Coupons");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CouponId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("RedeemedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CouponId", "PlayerId")
.IsUnique();
b.ToTable("CouponRedemptions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("Balance")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "CurrencyType")
.IsUnique();
b.ToTable("CurrencyBalances");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AssetName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("BaseAvatarItemId")
.HasColumnType("INTEGER");
b.Property<long>("CheerCount")
.HasColumnType("INTEGER");
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsFeatured")
.HasColumnType("INTEGER");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("PurchaseCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("IsFeatured");
b.HasIndex("IsPublic");
b.HasIndex("PublicId")
.IsUnique();
b.ToTable("CustomAvatarItems");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemOwnershipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AcquiredAt")
.HasColumnType("TEXT");
b.Property<long>("CustomAvatarItemId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CustomAvatarItemId");
b.HasIndex("PlayerId", "CustomAvatarItemId")
.IsUnique();
b.ToTable("CustomAvatarItemOwnership");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b =>
{
b.Property<long>("PlayerId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CurrentDataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("PlayerId");
b.ToTable("DormStates");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ExternalLoginEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Issuer")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("LastUsedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("Issuer", "Subject")
.IsUnique();
b.ToTable("ExternalLogins");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("GiftContext")
.HasColumnType("INTEGER");
b.Property<long>("GrantedItemId")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long>("Offer1ItemId")
.HasColumnType("INTEGER");
b.Property<long>("Offer2ItemId")
.HasColumnType("INTEGER");
b.Property<long>("Offer3ItemId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("RewardType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("SelectedAt")
.HasColumnType("TEXT");
b.Property<int?>("SelectedGiftDropId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "SelectedAt");
b.ToTable("GameRewardSelections");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActivityLevelId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<string>("PhotonRoomName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("PlayerCount")
.HasColumnType("INTEGER");
b.Property<string>("Region")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("RoomId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoomId", "Region");
b.ToTable("GameSessions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AvatarItemDescOrHairDyeDesc")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("AvatarItemType")
.HasColumnType("INTEGER");
b.Property<string>("ConsumableItemDesc")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("Consumed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ConsumedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Currency")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("EquipmentModificationGuid")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("EquipmentPrefabName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ErrorMessage")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("FromPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("GiftContext")
.HasColumnType("INTEGER");
b.Property<int>("GiftRarity")
.HasColumnType("INTEGER");
b.Property<bool>("IsGifted")
.HasColumnType("INTEGER");
b.Property<bool>("IsValid")
.HasColumnType("INTEGER");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("PackageMaterial")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PackageVariant")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<long>("RecipientPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("SourceStoreItemId")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsCurrentPlatform")
.HasColumnType("INTEGER");
b.Property<int>("Xp")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RecipientPlayerId", "Consumed");
b.ToTable("GiftPackages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long?>("CreationRoomId")
.HasColumnType("INTEGER");
b.Property<int>("CreatorPermission")
.HasColumnType("INTEGER");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CurrentBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("CurrentVersionNumber")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("FirstPublishedAt")
.HasColumnType("TEXT");
b.Property<int>("GeneralPermission")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsAgInvention")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsPublished")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("NumPlayersHaveUsedInRoom")
.HasColumnType("INTEGER");
b.Property<int>("Permission")
.HasColumnType("INTEGER");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("SpawnCount")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CheerCount");
b.HasIndex("CreatorPlayerId");
b.ToTable("Inventions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("InstantiationCost")
.HasColumnType("INTEGER");
b.Property<long>("InventionId")
.HasColumnType("INTEGER");
b.Property<int>("LightsCost")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("VersionNumber")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("InventionId", "VersionNumber")
.IsUnique();
b.ToTable("InventionVersions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("BannedAt")
.HasColumnType("TEXT");
b.Property<long>("BannedByAdminId")
.HasColumnType("INTEGER");
b.Property<string>("Cidr")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("Until")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Cidr");
b.ToTable("IpBans");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ItemKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "ItemKey", "ItemType")
.IsUnique();
b.ToTable("ItemWishlists");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.KeepsakeEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("EarnedAt")
.HasColumnType("TEXT");
b.Property<string>("EventKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("PlayerId", "EventKey");
b.ToTable("Keepsakes");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardChannelMetaEntity", b =>
{
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("Channel")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("LowerIsBetter")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("ValueFormat")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("RoomId", "Channel");
b.HasIndex("RoomId");
b.ToTable("LeaderboardChannelMeta");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("StatChannel")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long>("Value")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoomId", "PlayerId", "StatChannel")
.IsUnique();
b.HasIndex("RoomId", "StatChannel", "Value");
b.ToTable("LeaderboardStats");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Context")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int>("PlatformMask")
.HasColumnType("INTEGER");
b.Property<string>("RoomNamesCsv")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("LoadingScreenTips");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("ReadAt")
.HasColumnType("TEXT");
b.Property<long>("RecipientPlayerId")
.HasColumnType("INTEGER");
b.Property<long?>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SenderPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SentAt")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RecipientPlayerId", "SentAt");
b.HasIndex("SenderPlayerId", "SentAt");
b.ToTable("Messages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowAll")
.HasColumnType("INTEGER");
b.Property<bool>("AllowAnnouncements")
.HasColumnType("INTEGER");
b.Property<bool>("AllowEventInvite")
.HasColumnType("INTEGER");
b.Property<bool>("AllowFriendRequest")
.HasColumnType("INTEGER");
b.Property<bool>("AllowMessage")
.HasColumnType("INTEGER");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "Platform")
.IsUnique();
b.ToTable("NotificationPrefs");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("ClearedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsCompleted")
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "Key")
.IsUnique();
b.ToTable("ObjectiveProgress");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("FirstSeenAt")
.HasColumnType("TEXT");
b.Property<string>("LastIp")
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<string>("PlatformId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PendingDevices");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Caption")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<string>("TaggedPlayerIdsCsv")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<long>("UploaderPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("ViewCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("BlobName");
b.HasIndex("CreatedAt");
b.HasIndex("RoomId");
b.HasIndex("UploaderPlayerId");
b.ToTable("Photos");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<string>("PlatformUserId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "Platform", "PlatformUserId")
.IsUnique();
b.ToTable("PlatformIgnores");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("FirstSeen")
.HasColumnType("TEXT");
b.Property<bool>("IsBanned")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastSeen")
.HasColumnType("TEXT");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("DeviceId");
b.HasIndex("PlayerId");
b.ToTable("PlayerDevices");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Elo")
.HasColumnType("INTEGER");
b.Property<int>("GameMode")
.HasColumnType("INTEGER");
b.Property<int>("Losses")
.HasColumnType("INTEGER");
b.Property<int>("MatchesPlayed")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("Wins")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GameMode", "Elo");
b.HasIndex("PlayerId", "GameMode")
.IsUnique();
b.ToTable("PlayerElo");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("BannedUntil")
.HasColumnType("TEXT");
b.Property<string>("BannerImageName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Bio")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("Birthday")
.HasColumnType("TEXT");
b.Property<bool>("CanReceiveInvites")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedFromDeviceId")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DisplayEmoji")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("IdentityFlags")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCommunityTeam")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeveloper")
.HasColumnType("INTEGER");
b.Property<bool>("IsJunior")
.HasColumnType("INTEGER");
b.Property<bool>("IsVerified")
.HasColumnType("INTEGER");
b.Property<string>("LastIp")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("LastPlatform")
.HasColumnType("INTEGER");
b.Property<string>("LastPlatformId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("PersonalPronouns")
.HasColumnType("INTEGER");
b.Property<string>("Phone")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ProfileImageName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("Reputation")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("XP")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Players");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Capacity")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("EndsAt")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("StartsAt")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("StartsAt");
b.ToTable("PlayerEvents");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("EventId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Response")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("EventId", "PlayerId")
.IsUnique();
b.ToTable("PlayerEventResponses");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AcquiredAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("ItemSlug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Quantity")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("PlayerId", "ItemSlug")
.IsUnique();
b.ToTable("PlayerInventory");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "Key")
.IsUnique();
b.ToTable("PlayerSettings");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Accessibility")
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CustomWarning")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("FavoriteCount")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsCurated")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsJuniors")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsLevelVoting")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsScreens")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsTeleportVR")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsWalkVR")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.Property<int>("VisitorCount")
.HasColumnType("INTEGER");
b.Property<int>("WarningMask")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("IsCurated");
b.HasIndex("Name");
b.ToTable("Playlists");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistInteractionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Cheered")
.HasColumnType("INTEGER");
b.Property<bool>("Favorited")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("PlaylistId", "PlayerId")
.IsUnique();
b.ToTable("PlaylistInteractions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistRoomEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<long>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlaylistId");
b.HasIndex("RoomId");
b.HasIndex("PlaylistId", "OrderIndex");
b.ToTable("PlaylistRooms");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b =>
{
b.Property<long>("Id")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DataBlob")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Location")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("OwnerPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("PhotonRegion")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PhotonRoomId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("RoomCode")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SubRoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("OwnerPlayerId");
b.HasIndex("RoomId", "SubRoomId");
b.ToTable("PrivateInstances");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b =>
{
b.Property<long>("PrivateInstanceId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("InvitedAt")
.HasColumnType("TEXT");
b.Property<long?>("LatestInviteMessageId")
.HasColumnType("INTEGER");
b.HasKey("PrivateInstanceId", "PlayerId");
b.HasIndex("PlayerId");
b.ToTable("PrivateInstanceInvitees");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Token");
b.HasIndex("PlayerId", "Platform")
.IsUnique();
b.ToTable("PushTokens");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("Favorited")
.HasColumnType("INTEGER");
b.Property<bool>("Ignored")
.HasColumnType("INTEGER");
b.Property<bool>("Muted")
.HasColumnType("INTEGER");
b.Property<long>("RequesterId")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<long>("TargetId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RequesterId", "TargetId")
.IsUnique();
b.ToTable("Relationships");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Category")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("GameSessionId")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long>("ReporterPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("ResolutionNote")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("TEXT");
b.Property<long?>("ResolverAdminId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("TargetEventId")
.HasColumnType("INTEGER");
b.Property<long>("TargetInventionId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetRoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ReporterPlayerId");
b.HasIndex("ResolvedAt");
b.HasIndex("TargetPlayerId");
b.ToTable("Reports");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BanType")
.HasColumnType("INTEGER");
b.Property<long>("BannedByPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("BannedPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("Until")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("BannedPlayerId");
b.HasIndex("RoomId", "BannedPlayerId");
b.ToTable("RoomBans");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("PlayerId", "RoomId")
.IsUnique();
b.ToTable("RoomBookmarks");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<Guid?>("CurrencyId")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("PublicId")
.IsUnique();
b.HasIndex("RoomId");
b.ToTable("RoomConsumables");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableOwnershipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<Guid>("ConcurrencyCode")
.HasColumnType("TEXT");
b.Property<int>("Count")
.HasColumnType("INTEGER");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomConsumableId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoomConsumableId");
b.HasIndex("PlayerId", "RoomConsumableId")
.IsUnique();
b.ToTable("RoomConsumableOwnership");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyBalanceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("Balance")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomCurrencyId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoomCurrencyId");
b.HasIndex("PlayerId", "RoomCurrencyId")
.IsUnique();
b.ToTable("RoomCurrencyBalances");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("DailyLimit")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("PublicId")
.IsUnique();
b.HasIndex("RoomId");
b.ToTable("RoomCurrencies");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyPurchaseOfferEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Amount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomCurrencyId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PublicId")
.IsUnique();
b.HasIndex("RoomCurrencyId");
b.ToTable("RoomCurrencyPurchaseOffers");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<byte[]>("Bytes")
.HasColumnType("BLOB");
b.Property<string>("ReferencedFilenamesCsv")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long?>("SubRoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UploadedAt")
.HasColumnType("TEXT");
b.Property<long>("UploadedByPlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("BlobName")
.IsUnique();
b.HasIndex("RoomId");
b.ToTable("RoomDataBlobs");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Accessibility")
.HasColumnType("INTEGER");
b.Property<bool>("AllowNewUsers")
.HasColumnType("INTEGER");
b.Property<bool>("AllowsJuniors")
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<bool>("CloningAllowed")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CurrentDataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("CustomRoomWarning")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<bool>("DisableMicAutoMute")
.HasColumnType("INTEGER");
b.Property<int>("FavoriteCount")
.HasColumnType("INTEGER");
b.Property<bool>("HiddenFromBrowse")
.HasColumnType("INTEGER");
b.Property<double>("HotScore")
.HasColumnType("REAL");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsAGRoom")
.HasColumnType("INTEGER");
b.Property<bool>("IsDormRoom")
.HasColumnType("INTEGER");
b.Property<bool>("IsRoomLinkedToRecRoomStudio")
.HasColumnType("INTEGER");
b.Property<bool>("IsStudioRoom")
.HasColumnType("INTEGER");
b.Property<string>("LoadScreensJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<string>("LocationReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<int>("MaxPlayerCalculationMode")
.HasColumnType("INTEGER");
b.Property<int>("MinLevel")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<string>("PromoExternalContentJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<string>("PromoImagesJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<int>("RoomWarningMask")
.HasColumnType("INTEGER");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<string>("StudioSessionId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("SupportsLevelVoting")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsMobile")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsScreens")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsTeleportVR")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsVRLow")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsWalkVR")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.Property<int>("VisitorCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("HotScore");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Rooms");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(174)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("RoomId");
b.HasIndex("RoomId", "Name");
b.ToTable("RoomKeys");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyPurchaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("PaidPrice")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("PurchasedAt")
.HasColumnType("TEXT");
b.Property<long>("RoomKeyId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("RoomKeyId", "PlayerId")
.IsUnique();
b.ToTable("RoomKeyPurchases");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRoleEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Accepted")
.HasColumnType("INTEGER");
b.Property<DateTime>("GrantedAt")
.HasColumnType("TEXT");
b.Property<long?>("GrantedByPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoomId");
b.HasIndex("RoomId", "PlayerId", "Role")
.IsUnique();
b.ToTable("RoomRoles");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRolePermissionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Override")
.HasColumnType("INTEGER");
b.Property<string>("Permission")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SubRoomId")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long>("UpdatedByPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoomId", "SubRoomId", "Role", "Permission")
.IsUnique();
b.ToTable("RoomRolePermissions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("CanMatchmakeInto")
.HasColumnType("INTEGER");
b.Property<string>("DataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("DataModifiedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsSandbox")
.HasColumnType("INTEGER");
b.Property<int>("MaxPlayers")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<string>("RoomSceneLocationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("StudioAssetBundleNamesCsv")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<long?>("StudioSubRoomDataSaveId")
.HasColumnType("INTEGER");
b.Property<string>("StudioUnityAssetId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoomId", "Name");
b.HasIndex("RoomId", "OrderIndex")
.IsUnique();
b.ToTable("RoomScenes");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("FirstVisitAt")
.HasColumnType("TEXT");
b.Property<DateTime>("LastVisitAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("RoomId", "LastVisitAt");
b.HasIndex("RoomId", "PlayerId")
.IsUnique();
b.ToTable("RoomVisits");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChestsOpened")
.HasColumnType("INTEGER");
b.Property<DateTime>("CompletedAt")
.HasColumnType("TEXT");
b.Property<bool>("CustomGame")
.HasColumnType("INTEGER");
b.Property<int>("HealthPotionsConsumed")
.HasColumnType("INTEGER");
b.Property<int>("NumEliminations")
.HasColumnType("INTEGER");
b.Property<int>("Rank")
.HasColumnType("INTEGER");
b.Property<int>("SecondsAlive")
.HasColumnType("INTEGER");
b.Property<int>("SecondsInAir")
.HasColumnType("INTEGER");
b.Property<int>("ShieldPotionsConsumed")
.HasColumnType("INTEGER");
b.Property<bool>("WalkGame")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CompletedAt");
b.ToTable("RoyaleMatches");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("MatchId")
.HasColumnType("INTEGER");
b.Property<int>("NumEliminations")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Rank")
.HasColumnType("INTEGER");
b.Property<int>("SecondsAlive")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("MatchId", "PlayerId");
b.ToTable("RoyaleMatchPlayers");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CurrentLevelXPThreshold")
.HasColumnType("INTEGER");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<int>("NextLevelAcornReward")
.HasColumnType("INTEGER");
b.Property<long>("NextLevelXPThreshold")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("RankIdx")
.HasColumnType("INTEGER");
b.Property<string>("RankName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("TotalXP")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId")
.IsUnique();
b.ToTable("RoyalePlayerProgress");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<bool>("AllAvatarItemsOwned")
.HasColumnType("INTEGER");
b.Property<string>("BaseRoomNamesJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CharadesSlotBindingsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CircuitsV2LimitsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DiscoveredGameConfigsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("GlobalFriendsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("PlayMenuTagsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("ProfanityFilterDisabled")
.HasColumnType("INTEGER");
b.Property<string>("RecCenterDoorsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("RoomBlobVersionClampDisabled")
.HasColumnType("INTEGER");
b.Property<bool>("SignupsDisabled")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("WeeklyChallengeRewardJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("WeeklyChallengesCompletedRequired")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<string>("WeeklyChallengesJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.HasKey("Id");
b.ToTable("ServerSettings");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatedByPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Descriptor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("RedeemedAt")
.HasColumnType("TEXT");
b.Property<long?>("RedeemedByPlayerId")
.HasColumnType("INTEGER");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("SignupCodes");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("AvailableUntil")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<bool>("IsLimitedTime")
.HasColumnType("INTEGER");
b.Property<long>("Price")
.HasColumnType("INTEGER");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Storefront")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.HasIndex("Storefront");
b.HasIndex("IsActive", "Category");
b.ToTable("StoreItems");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("SubscriberPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SubscriberPlayerId");
b.HasIndex("TargetPlayerId");
b.HasIndex("SubscriberPlayerId", "TargetPlayerId")
.IsUnique();
b.ToTable("Subscriptions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b =>
{
b.Property<long>("Pk")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AssignedPlayerIdsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("AssignedPlayerNamesJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CommentsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("JiraBugUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("JiraUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MinNumAssignedPlayers")
.HasColumnType("INTEGER");
b.Property<string>("RoomName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TagsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<uint?>("TestPassId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Pk");
b.HasIndex("Id")
.IsUnique();
b.HasIndex("Status");
b.HasIndex("TestPassId");
b.ToTable("TestCases");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b =>
{
b.Property<uint>("Id")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("EndDate")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("StartDate")
.HasColumnType("TEXT");
b.Property<string>("TagsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("WasManuallyClosed")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("TestPasses");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsFeatured")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("PublicId")
.IsUnique();
b.HasIndex("RoomId");
b.ToTable("UgcPurchasables");
});
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("FriendlyName")
.HasColumnType("TEXT");
b.Property<string>("Xml")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("DataProtectionKeys");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
{
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
.WithOne("Avatar")
.HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Player");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
{
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
.WithMany("Settings")
.HasForeignKey("PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Player");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
{
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester")
.WithMany("Relationships")
.HasForeignKey("RequesterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Requester");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
{
b.Navigation("Avatar");
b.Navigation("Relationships");
b.Navigation("Settings");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,68 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DorkNet.Server.Migrations
{
/// <inheritdoc />
public partial class ExternalSignIn : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DataProtectionKeys",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
FriendlyName = table.Column<string>(type: "TEXT", nullable: true),
Xml = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_DataProtectionKeys", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ExternalLogins",
columns: table => new
{
Id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
PlayerId = table.Column<long>(type: "INTEGER", nullable: false),
Issuer = table.Column<string>(type: "TEXT", nullable: false),
Subject = table.Column<string>(type: "TEXT", nullable: false),
Email = table.Column<string>(type: "TEXT", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
LastUsedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExternalLogins", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ExternalLogins_Issuer_Subject",
table: "ExternalLogins",
columns: new[] { "Issuer", "Subject" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ExternalLogins_PlayerId",
table: "ExternalLogins",
column: "PlayerId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DataProtectionKeys");
migrationBuilder.DropTable(
name: "ExternalLogins");
}
}
}
@@ -15,7 +15,7 @@ namespace DorkNet.Server.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
modelBuilder.HasAnnotation("ProductVersion", "10.0.13");
modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b =>
{
@@ -972,6 +972,43 @@ namespace DorkNet.Server.Migrations
b.ToTable("DormStates");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ExternalLoginEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Issuer")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("LastUsedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("Issuer", "Subject")
.IsUnique();
b.ToTable("ExternalLogins");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
{
b.Property<long>("Id")
@@ -3021,6 +3058,51 @@ namespace DorkNet.Server.Migrations
b.ToTable("RoomRoles");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRolePermissionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Override")
.HasColumnType("INTEGER");
b.Property<string>("Permission")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SubRoomId")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long>("UpdatedByPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoomId", "SubRoomId", "Role", "Permission")
.IsUnique();
b.ToTable("RoomRolePermissions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b =>
{
b.Property<long>("Id")
@@ -3623,6 +3705,23 @@ namespace DorkNet.Server.Migrations
b.ToTable("UgcPurchasables");
});
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("FriendlyName")
.HasColumnType("TEXT");
b.Property<string>("Xml")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("DataProtectionKeys");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
{
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
+8 -3
View File
@@ -19,13 +19,18 @@ public class AuthService(IConfiguration config, DorkNetDbContext db)
// configs) and leave Jwt:Secret as the appsettings.json placeholder;
// the mismatched-secret bug shows up only there.
private string SecretKey =>
Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
?? config["Jwt:Secret"]
ConfiguredSecret(config)
?? throw new InvalidOperationException(
"JWT secret not configured. Set DORKNET_JWT_SECRET env var or " +
"Jwt:Secret in appsettings.Local.json.");
/// <summary>The server secret, in the one resolution order every user of
/// it shares (token signing, token validation, key encryption).</summary>
public static string? ConfiguredSecret(IConfiguration config) =>
Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
?? config["Jwt:Secret"];
public string GenerateToken(long playerId) => CreateJwt(playerId, TimeSpan.FromHours(12), "access");
/// <summary>
@@ -0,0 +1,67 @@
using System.Security.Cryptography;
using System.Text;
using System.Xml.Linq;
using Microsoft.AspNetCore.DataProtection.XmlEncryption;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Services;
/// <summary>
/// Encrypts the data-protection key ring (the keys behind the sign-in
/// cookies) before it is stored in the <c>DataProtectionKeys</c> table:
/// AES-256-GCM under a key derived (HKDF-SHA256) from the server secret.
/// The secret comes from the environment, not the database, so a database
/// backup alone holds no usable keys. Changing the secret makes the stored
/// keys unreadable: the server then creates a new key and everyone signs in
/// again.
/// </summary>
public sealed class DataProtectionKeyEncryption : IXmlEncryptor
{
private const int NonceSize = 12;
private const int TagSize = 16;
private static readonly byte[] Info = Encoding.UTF8.GetBytes("DorkNet.DataProtectionKeys.v1");
private static readonly XName Root = "dorknetEncryptedKey";
private readonly byte[] _key;
public DataProtectionKeyEncryption(string secret)
{
if (string.IsNullOrEmpty(secret))
throw new InvalidOperationException("The server secret is required to encrypt the data-protection keys.");
_key = HKDF.DeriveKey(HashAlgorithmName.SHA256, Encoding.UTF8.GetBytes(secret), 32, salt: null, info: Info);
}
public EncryptedXmlInfo Encrypt(XElement plaintextElement)
{
var plaintext = Encoding.UTF8.GetBytes(plaintextElement.ToString(SaveOptions.DisableFormatting));
var payload = new byte[NonceSize + TagSize + plaintext.Length];
var nonce = payload.AsSpan(0, NonceSize);
RandomNumberGenerator.Fill(nonce);
using (var aes = new AesGcm(_key, TagSize))
aes.Encrypt(nonce, plaintext, payload.AsSpan(NonceSize + TagSize), payload.AsSpan(NonceSize, TagSize));
return new EncryptedXmlInfo(
new XElement(Root, new XAttribute("version", "1"), Convert.ToBase64String(payload)),
typeof(Decryptor));
}
public XElement DecryptElement(XElement encrypted)
{
if (encrypted.Name != Root || (string?)encrypted.Attribute("version") != "1")
throw new CryptographicException("Unrecognised encrypted data-protection key.");
var payload = Convert.FromBase64String(encrypted.Value);
if (payload.Length < NonceSize + TagSize)
throw new CryptographicException("Encrypted data-protection key is truncated.");
var plaintext = new byte[payload.Length - NonceSize - TagSize];
using (var aes = new AesGcm(_key, TagSize))
aes.Decrypt(payload.AsSpan(0, NonceSize), payload.AsSpan(NonceSize + TagSize),
payload.AsSpan(NonceSize, TagSize), plaintext);
return XElement.Parse(Encoding.UTF8.GetString(plaintext));
}
/// <summary>Created by the data-protection system from the stored type
/// name, with the app's service provider.</summary>
public sealed class Decryptor(IServiceProvider services) : IXmlDecryptor
{
public XElement Decrypt(XElement encryptedElement) =>
services.GetRequiredService<DataProtectionKeyEncryption>().DecryptElement(encryptedElement);
}
}
+102
View File
@@ -0,0 +1,102 @@
using System.Net.Http.Headers;
using System.Text.Json.Nodes;
namespace DorkNet.Server.Services;
/// <summary>
/// Reads the client builds CI publishes (<c>ci/downloads/publish.py</c>): a
/// <c>manifest.json</c> plus the files it lists, in a bucket served over plain
/// HTTP (e.g. Garage's web endpoint on the internal network). The site's
/// Downloads and Quest pages fetch them through the server, so the bucket
/// itself never has to be reachable from outside.
///
/// Configured with <c>DorkNet:Downloads:BaseUrl</c> (the folder holding
/// <c>manifest.json</c>) and optionally <c>DorkNet:Downloads:Host</c> (the
/// Host header to send, for web endpoints that pick the bucket by host name).
/// Without a base URL <see cref="IsConfigured"/> is false and the site hides
/// both features.
/// </summary>
public sealed class DownloadsService
{
public const string HttpClientName = "downloads";
// How long the bucket may take to start answering. The body then streams
// with no limit (the client has none; the caller's abort token ends it).
private static readonly TimeSpan HeaderTimeout = TimeSpan.FromSeconds(30);
private readonly IHttpClientFactory _httpClientFactory;
private readonly Uri? _baseUri;
private readonly string? _host;
public DownloadsService(IConfiguration config, IHttpClientFactory httpClientFactory, ILogger<DownloadsService> log)
{
_httpClientFactory = httpClientFactory;
var baseUrl = config["DorkNet:Downloads:BaseUrl"]?.Trim();
if (!string.IsNullOrEmpty(baseUrl) && Uri.TryCreate(baseUrl.TrimEnd('/') + "/", UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
{
_baseUri = uri;
}
var host = config["DorkNet:Downloads:Host"]?.Trim();
_host = string.IsNullOrEmpty(host) ? null : host;
RequireSignIn = config.GetValue<bool>("DorkNet:Downloads:RequireSignIn");
if (_baseUri is null)
log.LogInformation("[downloads] no DorkNet:Downloads:BaseUrl; the Downloads page and Quest latest-build install are off");
else
log.LogInformation("[downloads] reading builds from {BaseUrl}{Host}", _baseUri, _host is null ? "" : $" (Host: {_host})");
}
public bool IsConfigured => _baseUri is not null;
/// <summary><c>DorkNet:Downloads:RequireSignIn</c>: serve the manifest and
/// files only to a signed-in, linked player (website sign-in).</summary>
public bool RequireSignIn { get; }
/// <summary>The published manifest, or null when none has been published yet.</summary>
public async Task<JsonObject?> GetManifestAsync(CancellationToken ct)
{
using var res = await SendAsync("manifest.json", range: null, ct);
if (res.StatusCode == System.Net.HttpStatusCode.NotFound) return null;
res.EnsureSuccessStatusCode();
return JsonNode.Parse(await res.Content.ReadAsStringAsync(ct)) as JsonObject;
}
/// <summary>A manifest entry by its bucket path, or null when the manifest
/// doesn't list it. Only listed files are ever served.</summary>
public static JsonObject? FindFile(JsonObject manifest, string path)
{
if (manifest["releases"] is not JsonArray releases) return null;
return releases.OfType<JsonObject>()
.SelectMany(r => r["files"] is JsonArray files ? files.OfType<JsonObject>() : [])
.FirstOrDefault(f => string.Equals((string?)f["path"], path, StringComparison.Ordinal));
}
/// <summary>GET a file, forwarding the caller's Range header. The caller
/// owns the response and streams its content.</summary>
public Task<HttpResponseMessage> GetFileAsync(string path, string? range, CancellationToken ct)
=> SendAsync(path, range, ct);
private async Task<HttpResponseMessage> SendAsync(string path, string? range, CancellationToken ct)
{
if (_baseUri is null) throw new InvalidOperationException("Downloads are not configured.");
var url = new Uri(_baseUri, string.Join('/', path.Split('/').Select(Uri.EscapeDataString)));
using var req = new HttpRequestMessage(HttpMethod.Get, url);
if (_host is not null) req.Headers.Host = _host;
if (!string.IsNullOrEmpty(range) && RangeHeaderValue.TryParse(range, out var r)) req.Headers.Range = r;
var client = _httpClientFactory.CreateClient(HttpClientName);
// The timeout covers only getting the headers: with ResponseHeadersRead
// the token is released once they arrive, and disposing the source
// here stops its timer before the body is streamed.
using var headers = CancellationTokenSource.CreateLinkedTokenSource(ct);
headers.CancelAfter(HeaderTimeout);
try
{
return await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, headers.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
throw new TimeoutException($"The downloads bucket didn't answer within {HeaderTimeout.TotalSeconds:0} s.");
}
}
}
+157
View File
@@ -0,0 +1,157 @@
using System.Collections.Concurrent;
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Services;
/// <summary>
/// External sign-in settings (<c>DorkNet:SignIn:*</c>): an OpenID Connect
/// provider players sign in with on the website and, for admins, the admin
/// panel. Any standard provider works (e.g. a Cloudflare Access SaaS app,
/// Google, Authentik). Unset, the feature is off and only the existing
/// username/password logins exist.
/// </summary>
public sealed class ExternalSignInOptions
{
/// <summary>The provider's issuer URL; discovery is read from
/// <c>{Authority}/.well-known/openid-configuration</c>.</summary>
public string Authority { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
/// <summary>The button label: "Sign in with {DisplayName}".</summary>
public string DisplayName { get; set; } = "single sign-on";
public string Scopes { get; set; } = "openid email profile";
/// <summary>Also read the provider's userinfo endpoint after sign-in. Off by
/// default: the ID token already carries <c>sub</c> and <c>email</c>, and
/// Cloudflare Access answers userinfo without a Content-Type, which the
/// OpenID Connect handler rejects.</summary>
public bool UseUserInfoEndpoint { get; set; }
public bool IsConfigured =>
Authority.Length > 0 && ClientId.Length > 0 && ClientSecret.Length > 0;
}
/// <summary>Scheme, path and claim names shared by the sign-in pieces.</summary>
public static class ExternalSignIn
{
/// <summary>The signed-in session (a linked DorkNet player), per host.</summary>
public const string WebScheme = "DorkNet.Web";
/// <summary>A provider identity that isn't linked to an account yet. Lives
/// only until the player links it (or 15 minutes).</summary>
public const string ExternalScheme = "DorkNet.External";
public const string OidcScheme = "DorkNet.Oidc";
// Everything lives under /api/: the website and admin hosts answer every
// other GET with their SPA's index.html before authentication runs.
public const string LoginPath = "/api/auth/external/login";
public const string CallbackPath = "/api/auth/external/callback";
public const string CompletePath = "/api/auth/external/complete";
public const string PlayerIdClaim = "dorknet:player_id";
public const string IssuerClaim = "dorknet:issuer";
public const string SubjectClaim = "dorknet:subject";
public const string EmailClaim = "dorknet:email";
public static ClaimsPrincipal PlayerPrincipal(PlayerEntity player, ExternalLoginEntity link) =>
new(new ClaimsIdentity(new[]
{
new Claim(PlayerIdClaim, player.Id.ToString()),
new Claim(ClaimTypes.Name, player.Username),
new Claim(IssuerClaim, link.Issuer),
new Claim(SubjectClaim, link.Subject),
new Claim(EmailClaim, link.Email),
}, WebScheme, ClaimTypes.Name, ClaimTypes.Role));
public static long? PlayerId(ClaimsPrincipal? principal) =>
long.TryParse(principal?.FindFirst(PlayerIdClaim)?.Value, out var id) ? id : null;
}
public enum LinkOutcome { Linked, InvalidCredentials, Banned, AlreadyLinkedElsewhere }
/// <summary>Links provider identities to DorkNet accounts and resolves them
/// back. An identity is (issuer, subject); the email is only shown.</summary>
public sealed class ExternalSignInService(DorkNetDbContext db)
{
public Task<ExternalLoginEntity?> FindAsync(string issuer, string subject) =>
db.ExternalLogins.FirstOrDefaultAsync(l => l.Issuer == issuer && l.Subject == subject);
/// <summary>The linked player, unless the link or the player is gone or the
/// player is banned.</summary>
public async Task<PlayerEntity?> ActivePlayerAsync(ExternalLoginEntity link)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == link.PlayerId);
return player is null || IsBanned(player) ? null : player;
}
public async Task TouchAsync(ExternalLoginEntity link, string email)
{
link.LastUsedAt = DateTime.UtcNow;
if (!string.IsNullOrEmpty(email)) link.Email = email;
await db.SaveChangesAsync();
}
/// <summary>Links (issuer, subject) to the account the credentials prove.
/// A player has one identity per provider: linking a new one replaces the
/// old. An identity already linked to a different account is refused.</summary>
public async Task<(LinkOutcome Outcome, PlayerEntity? Player, ExternalLoginEntity? Link)> LinkAsync(
string issuer, string subject, string email, string username, string password)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Username == username);
if (player?.PasswordHash is null || !BCrypt.Net.BCrypt.Verify(password, player.PasswordHash))
return (LinkOutcome.InvalidCredentials, null, null);
if (IsBanned(player)) return (LinkOutcome.Banned, null, null);
var existing = await FindAsync(issuer, subject);
if (existing is not null && existing.PlayerId != player.Id)
return (LinkOutcome.AlreadyLinkedElsewhere, null, null);
if (existing is null)
{
db.ExternalLogins.RemoveRange(
db.ExternalLogins.Where(l => l.PlayerId == player.Id && l.Issuer == issuer));
existing = new ExternalLoginEntity { PlayerId = player.Id, Issuer = issuer, Subject = subject };
db.ExternalLogins.Add(existing);
}
existing.Email = email;
existing.LastUsedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return (LinkOutcome.Linked, player, existing);
}
public async Task<bool> UnlinkAsync(long playerId, string issuer) =>
await db.ExternalLogins.Where(l => l.PlayerId == playerId && l.Issuer == issuer).ExecuteDeleteAsync() > 0;
private static bool IsBanned(PlayerEntity p) => p.BannedUntil is { } until && until > DateTime.UtcNow;
}
/// <summary>Caps failed link attempts (a password guess each) per provider
/// identity and per username: 5 per 15 minutes each. In memory, per process,
/// which is enough to stop guessing through the link form.</summary>
public sealed class LinkAttemptLimiter
{
private const int MaxFailures = 5;
private static readonly TimeSpan Window = TimeSpan.FromMinutes(15);
private readonly ConcurrentDictionary<string, (int Count, DateTime Since)> _failures = new();
public bool IsBlocked(params string[] keys) => keys.Any(k =>
_failures.TryGetValue(k, out var f) && DateTime.UtcNow - f.Since < Window && f.Count >= MaxFailures);
public void RecordFailure(params string[] keys)
{
var now = DateTime.UtcNow;
foreach (var k in keys)
_failures.AddOrUpdate(k, (1, now), (_, f) => now - f.Since >= Window ? (1, now) : (f.Count + 1, f.Since));
}
public void Reset(params string[] keys)
{
foreach (var k in keys) _failures.TryRemove(k, out _);
}
}
+12 -1
View File
@@ -41,6 +41,13 @@ public class LevelService(DorkNetDbContext db, NotificationService notifications
player.Level = LevelForXp(player.XP);
await db.SaveChangesAsync();
// Live level bar: "PlayerProgressionLevelUpdate" {PlayerId, Level, XP}
// (handler PBOGNAPEBAI.txt:263, DTO DGFGIFIHCAJ) — the same values
// api/players/v2/progression returns. Without it the watch kept the
// level and XP it loaded at login until the next relog.
await notifications.NotifyTypedAsync(playerId, "PlayerProgressionLevelUpdate",
new { PlayerId = (int)playerId, player.Level, player.XP });
if (player.Level > oldLevel)
{
// Level-up grants a small currency reward. The wallet UI
@@ -82,9 +89,13 @@ public class LevelService(DorkNetDbContext db, NotificationService notifications
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
// StorefrontBalanceUpdate "61" is BalanceResponseDTO {Balance,
// CurrencyType, Platform}; the client sets that (CurrencyType,
// Platform) bucket to Balance. Platform 0 matches every balance REST
// response, so the buckets line up.
await notifications.NotifyAsync(playerId,
PushNotificationId.StorefrontBalanceUpdate,
new { CurrencyType = currencyType, row.Balance, Reason = reason });
new { row.Balance, CurrencyType = currencyType, Platform = 0 });
return row.Balance;
}
+97 -89
View File
@@ -212,25 +212,17 @@ public class NotificationService(
// ── Higher-level event helpers ─────────────────────────────────────
public Task FriendRequestReceived(long targetPlayerId, long fromPlayerId) =>
NotifyAsync(targetPlayerId, PushNotificationId.RelationshipChanged,
new { Reason = "FriendRequestReceived", From = fromPlayerId });
public Task FriendRequestAccepted(long requesterPlayerId, long byPlayerId) =>
NotifyAsync(requesterPlayerId, PushNotificationId.RelationshipChanged,
new { Reason = "FriendRequestAccepted", By = byPlayerId });
public Task FriendRequestDeclined(long requesterPlayerId, long byPlayerId) =>
NotifyAsync(requesterPlayerId, PushNotificationId.RelationshipChanged,
new { Reason = "FriendRequestDeclined", By = byPlayerId });
public Task FriendRemoved(long otherPlayerId, long byPlayerId) =>
NotifyAsync(otherPlayerId, PushNotificationId.RelationshipChanged,
new { Reason = "FriendRemoved", By = byPlayerId });
public Task FriendBlocked(long blockedPlayerId, long byPlayerId) =>
NotifyAsync(blockedPlayerId, PushNotificationId.RelationshipChanged,
new { Reason = "FriendBlocked", By = byPlayerId });
/// <summary>Push one relationship row to <paramref name="recipientPlayerId"/>.
/// <paramref name="relationship"/> must be the recipient's OWN view of the
/// pair — the same <c>{PlayerID, RelationshipType, Muted, Ignored,
/// Favorited}</c> object <c>api/relationships/v2/get</c> returns. The 2023
/// handler (<c>EEGNOHOELBG.FOCBJAIDCBB</c>, Relationship
/// <c>ECGNEHMCGCN</c>) stores it under its <c>PlayerID</c> and raises the
/// changed event, which refreshes the friends list and the client's
/// SubscribeToPlayers set. Any other shape parses as PlayerID 0 and the
/// real row never updates.</summary>
public Task RelationshipChanged(long recipientPlayerId, object relationship) =>
NotifyAsync(recipientPlayerId, PushNotificationId.RelationshipChanged, relationship);
/// <summary>Push a presence delta so subscribed friends re-render
/// their friend-list row without waiting for the watch's 15-45 s
@@ -270,6 +262,23 @@ public class NotificationService(
await hub.Clients.Groups(groups).SendAsync(HubMethod, payload);
}
/// <summary>Push one player's presence to every connection that subscribed
/// to them with <c>SubscribeToPlayers</c>, except
/// <paramref name="excludeConnectionIds"/> — the subject's own connections
/// (a frame about the local player moves them) and anyone already sent the
/// frame through their friend group.</summary>
public async Task PresenceToSubscribersAsync(
long playerId,
RoomInstanceDto? room,
bool isOnline,
int statusVisibility,
IReadOnlyList<string> excludeConnectionIds)
{
var payload = BuildPresenceUpdatePayload(playerId, room, isOnline, statusVisibility);
await hub.Clients.GroupExcept(NotifyHub.PresenceGroupForPlayer(playerId), excludeConnectionIds)
.SendAsync(HubMethod, payload);
}
/// <summary>The PresenceUpdate envelope for one player, as a JSON string.
///
/// <paramref name="isOnline"/> is independent of the room: the 2023
@@ -302,7 +311,7 @@ public class NotificationService(
{
["roomInstanceId"] = redacted.RoomInstanceId,
["roomId"] = redacted.RoomId,
["subRoomId"] = redacted.SubRoomId,
["subRoomId"] = redacted.WireSubRoomId,
["roomInstanceType"] = redacted.RoomInstanceType,
["location"] = redacted.Location,
["dataBlob"] = redacted.DataBlob,
@@ -349,86 +358,85 @@ public class NotificationService(
public Task PlayerEnteredRoom(IEnumerable<long> friendIds, long playerId, RoomInstanceDto room) =>
PlayerPresenceChanged(friendIds, playerId, room);
/// <summary>Force every currently-connected SignalR session for
/// <paramref name="playerId"/> to disconnect via the watch's
/// ModerationKick flow. Wire path verified against decompilation:
/// <c>PushNotificationId.ModerationKick = 22</c> is registered
/// by <c>LCCEEFHOBEN.OFFLOPLJBBG</c> via the enum overload.
/// <c>LCCEEFHOBEN.KEIAFMLIMFA(Dictionary)</c> reads
/// <c>Msg.Reason</c> as a ModerationBlockDetail object, then
/// calls <c>PODFHPNOHGB</c> / <c>BootLocalPlayerToDormRoom</c>.
///
/// Used by <see cref="Controllers.Match.MatchPlayerController.PlayerLogin"/>
/// to enforce single-session-per-account: when a second login on
/// the same account replaces the LoginLock, the older SignalR
/// connections are kicked. The new session hasn't called
/// <c>negotiate</c> yet (it happens AFTER /player/login on the
/// boot sequence), so the kick only hits old sessions.
/// </summary>
public Task KickStaleSession(long playerId, string reason) =>
NotifyAsync(playerId, PushNotificationId.ModerationKick,
ModerationKickPayload(reason, isBan: false, source: "session_replaced"));
/// <summary>Client logout reasons (<c>Logout</c> "6" payload <c>Reason</c>,
/// enum <c>MPJOEALLGHE</c>, read by formatter <c>PBEDEFHAHID</c>). The
/// value must go out as a NUMBER: release builds obfuscate enum names, so a
/// string like "AdminPasswordReset" fails the parse and the whole push is
/// dropped. <c>SessionManager.JNNAOHMNGEM</c> shows its own "signed in
/// elsewhere" text for <see cref="SessionTakeover"/>, then returns the
/// client to the boot scene for every reason.</summary>
public enum LogoutReason
{
Unknown = 0,
UserInitiated = 1,
SessionTakeover = 2,
ForciblyLoggedOut = 3,
Banned = 4,
}
public Task KickPlayerAsync(long playerId, string reason, bool isBan = false) =>
NotifyAsync(playerId, PushNotificationId.ModerationKick,
ModerationKickPayload(reason, isBan, source: "admin_kick"));
/// <summary>Log every session of <paramref name="playerId"/> out to the
/// boot scene. Use this, not a kick, when the player must not stay signed
/// in: a ModerationKick only sends them to their dorm.</summary>
public Task LogoutAsync(long playerId, LogoutReason reason) =>
NotifyAsync(playerId, PushNotificationId.Logout, new { Reason = (int)reason });
/// <summary>Push a ModerationKick to a specific set of SignalR
/// connection ids — used by <see cref="DorkNet.Server.Hubs.NotifyHub.OnConnectedAsync"/>
/// to evict the previous session when a new device connects on the
/// same account. Targets specific ids (not the player group) so
/// the brand-new connection joining the group right after this
/// returns can't accidentally receive its own eviction notice.
/// Works across replicas because SignalR's Redis backplane routes
/// <summary>Single-session-per-account: when a second login replaces the
/// LoginLock, log the older session out. The new session hasn't called
/// <c>negotiate</c> yet (it happens AFTER /player/login on the boot
/// sequence), so only the old session's connections receive it.</summary>
public Task LogoutStaleSession(long playerId) =>
LogoutAsync(playerId, LogoutReason.SessionTakeover);
/// <summary>The same takeover logout, sent to specific SignalR connection
/// ids — used by <see cref="DorkNet.Server.Hubs.NotifyHub.OnConnectedAsync"/>
/// to evict the previous session when a new device connects on the same
/// account. Targets ids (not the player group) so the brand-new
/// connection, which joins the group right after, never receives it. Works
/// across replicas because the Redis backplane routes
/// <c>Clients.Clients(ids)</c> to whichever node owns each id.</summary>
public Task KickConnectionsAsync(IReadOnlyCollection<string> connectionIds, string reason)
public Task LogoutConnectionsAsync(IReadOnlyCollection<string> connectionIds)
{
if (connectionIds.Count == 0) return Task.CompletedTask;
var payload = Serialize(PushNotificationId.ModerationKick,
ModerationKickPayload(reason, isBan: false, source: "session_replaced"));
var payload = Serialize(PushNotificationId.Logout, new { Reason = (int)LogoutReason.SessionTakeover });
return hub.Clients.Clients(connectionIds).SendAsync(HubMethod, payload);
}
private static object ModerationKickPayload(string reason, bool isBan, string source)
/// <summary>Kick <paramref name="playerId"/> back to their dorm with
/// <paramref name="reason"/> shown in the kick dialog. The player stays
/// signed in; use <see cref="LogoutAsync"/> for bans and deletions.
/// Pass the room instance the player is in as
/// <paramref name="gameSessionId"/> to have the client announce the kick
/// to that room.</summary>
public Task KickPlayerAsync(long playerId, string reason, long gameSessionId = 0) =>
NotifyAsync(playerId, PushNotificationId.ModerationKick, ModerationKickPayload(reason, gameSessionId));
/// <summary>ModerationKick "22" payload: the FLAT moderation block
/// <c>PIBLFGPOCIB</c> (formatter <c>LAIJIAIHGCO</c>). The kick handler
/// (<c>SessionManager</c> HandleModerationKicked) starts from its own
/// "Kicked to Dormroom" text and only adds a reason as
/// a "Reason: " line from <c>VoteKickReason</c> when <c>ReportCategory</c> is 10
/// (VoteKick) and <c>IsHostKick</c> is false; <c>Message</c> is never
/// shown. When <c>GameSessionId</c> is the kicked player's current room
/// instance it also broadcasts the kick to that room, and
/// <c>Duration</c> &gt; 0 starts a timeout, so it stays 0. The old nested
/// <c>{Reason:{…}}</c> shape (from the 2020 client) parsed to all defaults,
/// which booted the player but dropped every reason.</summary>
private static object ModerationKickPayload(string reason, long gameSessionId)
{
var message = string.IsNullOrWhiteSpace(reason)
? "Kicked to Dormroom"
: reason.Trim();
var blockDetail = new
{
ReportCategory = 10, // PlayerReporting.ReportCategory.VoteKick
Duration = 0,
GameSessionId = 0L,
Message = message,
IsHostKick = true,
PlayerIdReporter = 1,
IsBan = isBan,
VoteKickReason = message,
};
var message = string.IsNullOrWhiteSpace(reason) ? "Kicked to Dormroom" : reason.Trim();
return new
{
Reason = blockDetail,
DisplayReason = message,
BannedByPlayerId = 1,
Source = source,
ReportCategory = 10, // VoteKick: the only category that shows a reason
Duration = 0,
GameSessionId = gameSessionId,
IsHostKick = false,
Message = message,
PlayerIdReporter = (int?)null,
IsBan = false,
IsVoiceModAutoban = false,
IsWarning = false,
VoteKickReason = message,
TimeoutStartedAt = (DateTime?)null,
};
}
/// <summary>Notify a private-instance owner that someone tried to
/// follow them in via "Go to Player" but was rejected for not
/// being on the invite list. Reuses the MessageReceived channel
/// since the watch already renders that as an inbox toast — no
/// new client-side handler needed. The body wording surfaces it
/// as "Player X tried to join your private match" so the owner
/// knows to send an explicit invite if they want to let them in.</summary>
public Task PrivateInstanceJoinRejected(long ownerPlayerId, long attemptedByPlayerId) =>
NotifyAsync(ownerPlayerId, PushNotificationId.MessageReceived,
new
{
Reason = "PrivateInstanceJoinRejected",
AttemptedBy = attemptedByPlayerId,
Preview = $"Player {attemptedByPlayerId} tried to join your private match",
});
}
@@ -148,6 +148,34 @@ public class OnlinePresenceService
lock (set) return set.ToArray();
}
/// <summary>Connection ids of every player in <paramref name="playerIds"/>,
/// in one go: the Redis lookups are issued together, so the multiplexer
/// pipelines them into one round trip instead of one per player.</summary>
public async Task<IReadOnlyCollection<string>> GetConnectionsAsync(IEnumerable<long> playerIds)
{
var ids = playerIds.Distinct().ToArray();
if (ids.Length == 0) return Array.Empty<string>();
if (_redis is { } mux)
{
try
{
var db = mux.GetDatabase();
var sets = await Task.WhenAll(ids.Select(id => db.SetMembersAsync(ConnsKey(id))));
return sets.SelectMany(s => s).Select(v => v.ToString()).ToArray();
}
catch
{
// Redis sick → fall through to the local fallback, as
// GetConnections does.
}
}
var result = new List<string>();
foreach (var id in ids)
if (_localConns.TryGetValue(id, out var set))
lock (set) result.AddRange(set);
return result;
}
/// <summary>Snapshot of currently-online player ids across the
/// cluster. Returns an empty array when nobody's online; never null.
/// Callers typically <c>.ToHashSet()</c> the result for membership
+101 -28
View File
@@ -105,6 +105,15 @@ public class PlayerPresenceService
var db = mux.GetDatabase();
db.StringSet(Key(playerId), json, PresenceTtl);
db.KeyDelete(NoRoomKey(playerId));
// Per-room membership index for the instance browser
// (EnumerateActiveInstances). Members are validated against
// the player's live presence on read, so a player who moved
// on is pruned lazily instead of needing the old room here.
if (room is not null && room.RoomId > 0)
{
db.SetAdd(RoomIndexKey(room.RoomId), playerId);
db.KeyExpire(RoomIndexKey(room.RoomId), PresenceTtl + PresenceTtl);
}
_log?.LogDebug("[presence] set redis player={PlayerId} room={Room} bytes={Bytes}",
playerId, room?.Name ?? "<null>", json.Length);
}
@@ -132,7 +141,19 @@ public class PlayerPresenceService
{
try
{
mux.GetDatabase().KeyExpire(Key(playerId), PresenceTtl);
var db = mux.GetDatabase();
db.KeyExpire(Key(playerId), PresenceTtl);
// Keep the room's membership index alive as long as its
// members are: a player refreshed only through here would
// otherwise outlive the set (SetRoom's 2 × TTL) and drop out
// of the instance browser and room pushes.
var json = db.StringGet(Key(playerId));
if (json.HasValue &&
JsonSerializer.Deserialize<RoomInstanceDto>(json.ToString(), JsonOpts) is { RoomId: > 0 } room)
{
db.SetAdd(RoomIndexKey(room.RoomId), playerId);
db.KeyExpire(RoomIndexKey(room.RoomId), PresenceTtl + PresenceTtl);
}
}
catch (Exception ex)
{
@@ -459,45 +480,97 @@ public class PlayerPresenceService
_localNoRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl);
}
/// <summary>List the currently-active instances of a given room,
/// grouped by PhotonRoomId, with the player ids currently presenced
/// in each. Drives the watch's RoomInstanceBrowser
/// (<c>GET match.*/room/{id}/instances</c>) so players can see
/// which instance their friends are in.
/// <summary>List the live instances of a given room, grouped by
/// PhotonRoomId (or RoomInstanceId when a row has none), with the player
/// ids currently presenced in each. Drives the room instance browser
/// (<c>GET match.*/room/{id}/instances</c>) and the event instance list.
///
/// Redis caveat: without a secondary index per room we'd need a
/// SCAN over every presence key — too expensive to do on each
/// request. For now the Redis path returns empty and the local
/// in-process dictionary is the source of truth. Single-instance
/// deployments work; multi-replica Redis deployments need a
/// per-room set index to be added before the browser is reliable
/// there.</summary>
public IEnumerable<(RoomInstanceDto Room, List<long> PlayerIds)> EnumerateActiveInstances(long roomId)
/// <para>Only players whose presence is still live count: an expired
/// room presence (<see cref="PresenceTtl"/>) is dropped, and so is a
/// player whose activity marker (<see cref="ActivityTtl"/>, refreshed by
/// every heartbeat and join) has lapsed. A client that crashed keeps its
/// room presence for up to ten minutes, but it is not in the instance and
/// must not inflate the player count or keep an empty instance listed.</para>
///
/// <para><b>Redis.</b> <see cref="SetRoom"/> records each player in a
/// per-room set (<c>presence-room:{roomId}</c>); this reads that set and
/// validates every member against their current presence, pruning the
/// ones that have moved on or expired. Without the index the Redis path
/// had nothing to enumerate and returned an empty list on every
/// multi-replica deployment.</para>
///
/// <para>Private instances are skipped unless
/// <paramref name="includePrivate"/> is set; callers that include them
/// must apply their own visibility rule.</para></summary>
public IEnumerable<(RoomInstanceDto Room, List<long> PlayerIds)> EnumerateActiveInstances(
long roomId, bool includePrivate = false)
{
if (_redis is not null) return Array.Empty<(RoomInstanceDto, List<long>)>();
var groups = new Dictionary<string, (RoomInstanceDto Room, List<long> PlayerIds)>(StringComparer.Ordinal);
foreach (var kv in _local)
var members = new List<(long PlayerId, RoomInstanceDto Room)>();
if (_redis is { } mux)
{
var room = kv.Value;
if (room is null || room.RoomId != roomId) continue;
// Skip private instances — those are surfaced separately
// in the controller via PrivateInstanceService so we never
// leak a photonRoomId for an invite-only match into the
// public browser.
if (room.IsPrivate) continue;
var key = string.IsNullOrEmpty(room.PhotonRoomId)
? $"<{room.RoomInstanceId}>"
: room.PhotonRoomId;
try
{
var db = mux.GetDatabase();
var stale = new List<RedisValue>();
foreach (var member in db.SetMembers(RoomIndexKey(roomId)))
{
if (!long.TryParse(member.ToString(), out var playerId))
{
stale.Add(member);
continue;
}
var room = GetRoom(playerId);
if (room is null || room.RoomId != roomId)
{
stale.Add(member);
continue;
}
members.Add((playerId, room));
}
if (stale.Count > 0)
db.SetRemove(RoomIndexKey(roomId), stale.ToArray());
}
catch (Exception ex)
{
_log?.LogWarning(ex, "[presence] redis room index scan failed room={RoomId}", roomId);
return Array.Empty<(RoomInstanceDto, List<long>)>();
}
}
else
{
foreach (var playerId in _local.Keys.ToList())
{
var room = GetRoom(playerId); // honours the room TTL
if (room is not null && room.RoomId == roomId)
members.Add((playerId, room));
}
}
var groups = new Dictionary<string, (RoomInstanceDto Room, List<long> PlayerIds)>(StringComparer.Ordinal);
foreach (var (playerId, room) in members)
{
if (room.IsPrivate && !includePrivate) continue;
if (!IsRecentlyActive(playerId)) continue;
var key = InstanceKey(room);
if (!groups.TryGetValue(key, out var entry))
{
entry = (room, new List<long>());
groups[key] = entry;
}
entry.PlayerIds.Add(kv.Key);
entry.PlayerIds.Add(playerId);
}
return groups.Values;
}
/// <summary>The identity two presence rows share when they are in the
/// same live instance: the Photon room, else the instance id.</summary>
public static string InstanceKey(RoomInstanceDto room)
=> string.IsNullOrEmpty(room.PhotonRoomId)
? "<" + room.RoomInstanceId + ">"
: room.PhotonRoomId;
private static string RoomIndexKey(long roomId) => "presence-room:" + roomId;
private static string Key(long playerId) => $"presence:{playerId}";
private static string ActiveKey(long playerId) => $"presence-active:{playerId}";
private static string BootDormGraceKey(long playerId) => $"presence-boot-dorm-grace:{playerId}";
@@ -36,7 +36,10 @@ public static class PresenceVisibility
/// RecNet.Runtime/RecNet/Matchmaking.txt:1436) feeds the payload straight into
/// FLAOOBNJAAD (:6917), the same routine the heartbeat uses to apply an out-of-sync
/// correction (:18467). A frame about the LOCAL player therefore moves that player, so
/// these frames go to friends only, never to the subject.
/// these frames never go to the subject. They go to the subject's friends and to every
/// other connection that subscribed to the subject with SubscribeToPlayers (see
/// NotifyHub.SubscribeToPlayers); non-friends only see the real presence when it is
/// Public.
/// </summary>
public sealed class PresenceBroadcastService(
IServiceScopeFactory scopes,
@@ -58,7 +61,7 @@ public sealed class PresenceBroadcastService(
presence.IsRecentlyActive(playerId) || connections.GetConnections(playerId).Count > 0;
/// <summary>Push the player's current presence (online state + committed
/// room) to every friend.</summary>
/// room) to every friend and subscriber.</summary>
public Task PublishCurrentAsync(long playerId) => PublishAsync(playerId, forceOffline: false);
/// <summary>Push an offline frame regardless of any lingering markers —
@@ -105,7 +108,6 @@ public sealed class PresenceBroadcastService(
.Where(id => id != playerId)
.Distinct()
.ToList();
if (friendIds.Count == 0) return;
var online = !forceOffline && IsOnline(playerId);
var room = online ? presence.GetRoom(playerId) : null;
@@ -134,6 +136,23 @@ public sealed class PresenceBroadcastService(
await notifications.PlayerPresenceChanged(visibleTo, playerId, room, online, visibility);
if (hiddenFrom.Count > 0)
await notifications.PlayerPresenceChanged(hiddenFrom, playerId, null, false, visibility);
// Everyone else who subscribed to this player (SubscribeToPlayers:
// people in the same room, an open profile). They are not friends,
// so they see the real presence only when it is Public, and the
// offline frame otherwise. Friends were served above and the
// subject must never get a frame about themselves, so their
// connections are excluded.
// Only friends who are online have connections; one call for the
// online set, then one pipelined lookup for all of them.
var onlineIds = connections.OnlinePlayerIds().ToHashSet();
var exclude = (await connections.GetConnectionsAsync(
friendIds.Where(onlineIds.Contains).Append(playerId)))
.Distinct()
.ToList();
var publicToAll = visibility == PresenceVisibility.Public && online;
await notifications.PresenceToSubscribersAsync(
playerId, publicToAll ? room : null, publicToAll, visibility, exclude);
}
catch (Exception ex)
{
@@ -0,0 +1,198 @@
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace DorkNet.Server.Services;
/// <summary>
/// A sub-room's role-permission matrix: the cells the 2023 client reads from
/// <c>GET photon_access_token</c> (<c>ALIIIAGAPND.Permissions</c>, a list of
/// <c>BNLPBMJJOMM</c>) and writes with
/// <c>PUT rooms/{id}/subrooms/{sub}/permissions</c>.
///
/// <para>The served list is the built-in defaults with every stored cell for
/// the room's sub-room applied on top: a stored cell replaces the default
/// with the same (Role, Permission), and cells with no default are appended.
/// The list must never be empty — with no Permissions the room-permissions
/// runtime (OMJKHJLFOCO) falls into its persisted-role migration path, which
/// NREs during first spawn.</para>
///
/// <para>Cell keys (formatter RecNet.Runtime/HDOJMEBCAGP.txt):
/// <c>Permission</c> (string), <c>Role</c> (OMMBGJMJJPN), <c>Override</c>
/// (bool), <c>Type</c> (FEOJFGECKGH: Bool 0 / Int 1 / StringList 2),
/// <c>Value</c> (string). Role and Type go out as numbers; requests are
/// accepted with numbers, numeric strings or enum names.</para>
/// </summary>
public static class RoomPermissionMatrix
{
/// <summary>One matrix cell.</summary>
public sealed record Cell(string Permission, int Role, bool Override, int Type, string Value);
/// <summary>The defaults every sub-room starts from: Co-Owner and Creator
/// may use the maker pen, spawn inventions and consumables, reset the
/// room and toggle play gizmos.</summary>
public static IReadOnlyList<Cell> Defaults { get; } = BuildDefaults();
private static IReadOnlyList<Cell> BuildDefaults()
{
var permissions = new[]
{
"13", // Can Use Maker Pen
"20", // Can Spawn Inventions
"21", // Can Spawn Consumables
"22", // Can Use Room Reset Button
"23", // Can Use Play Gizmos Toggle
};
var list = new List<Cell>();
foreach (var role in new[] { RoomRoleRules.WireCoOwner, RoomRoleRules.WireCreator })
foreach (var permission in permissions)
list.Add(new Cell(permission, role, true, 0, "true"));
return list;
}
/// <summary>Defaults merged with the stored cells.</summary>
public static List<Cell> Merge(IEnumerable<RoomRolePermissionEntity> stored)
{
var cells = Defaults.ToList();
foreach (var row in stored.OrderBy(r => r.Id))
{
var cell = new Cell(row.Permission, row.Role, row.Override, row.Type, row.Value);
var at = cells.FindIndex(c =>
c.Role == row.Role
&& string.Equals(c.Permission, row.Permission, StringComparison.Ordinal));
if (at >= 0) cells[at] = cell;
else cells.Add(cell);
}
return cells;
}
/// <summary>The served matrix for one sub-room (by OrderIndex).</summary>
public static async Task<List<Cell>> LoadAsync(DorkNetDbContext db, long roomId, long subRoomOrderIndex)
{
var stored = await db.RoomRolePermissions.AsNoTracking()
.Where(p => p.RoomId == roomId && p.SubRoomId == subRoomOrderIndex)
.ToListAsync();
return Merge(stored);
}
/// <summary>The <c>ALIIIAGAPND</c> body: <c>{RoomInstanceId,
/// PhotonAccessToken, Permissions}</c>. Used both by
/// <c>GET photon_access_token</c> and the <c>PhotonAccessToken</c> hub
/// push (IBEOONPEELF.txt:519 registers it as Action&lt;ALIIIAGAPND&gt;).</summary>
public static Dictionary<string, object> AccessTokenBody(long roomInstanceId, IEnumerable<Cell> cells)
=> new()
{
["RoomInstanceId"] = roomInstanceId,
["PhotonAccessToken"] = string.Empty,
["Permissions"] = cells.Select(ToWire).ToList(),
};
public static object ToWire(Cell c) => new
{
c.Permission,
c.Role,
c.Override,
c.Type,
c.Value,
};
/// <summary>Parse the PUT body: a JSON array of cells (an object wrapping
/// a <c>Permissions</c> array is accepted too). Returns null when the body
/// is not JSON or holds no array.</summary>
public static List<Cell>? Parse(string body)
{
if (string.IsNullOrWhiteSpace(body)) return null;
JsonDocument doc;
try { doc = JsonDocument.Parse(body); }
catch (JsonException) { return null; }
using (doc)
{
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object)
{
root = root.EnumerateObject()
.Where(p => p.Name.Equals("Permissions", StringComparison.OrdinalIgnoreCase))
.Select(p => p.Value)
.FirstOrDefault();
}
if (root.ValueKind != JsonValueKind.Array) return null;
var cells = new List<Cell>();
foreach (var item in root.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object) continue;
string? permission = null;
int? role = null;
bool @override = false;
int type = 0;
string value = string.Empty;
foreach (var prop in item.EnumerateObject())
{
switch (prop.Name.ToLowerInvariant())
{
case "permission":
permission = Scalar(prop.Value)?.Trim();
break;
case "role":
role = EnumValue(prop.Value, RoleName);
break;
case "type":
type = EnumValue(prop.Value, TypeName) ?? 0;
break;
case "override":
@override = prop.Value.ValueKind == JsonValueKind.True
|| (prop.Value.ValueKind == JsonValueKind.String
&& bool.TryParse(prop.Value.GetString(), out var b) && b);
break;
case "value":
value = Scalar(prop.Value) ?? string.Empty;
break;
}
}
if (string.IsNullOrEmpty(permission) || role is null) continue;
if (permission.Length > 128 || value.Length > 4096) continue;
cells.Add(new Cell(permission, role.Value, @override, type, value));
}
return cells;
}
}
private static string? Scalar(JsonElement e) => e.ValueKind switch
{
JsonValueKind.String => e.GetString(),
JsonValueKind.Number => e.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null or JsonValueKind.Undefined => null,
_ => e.GetRawText(),
};
private static int? EnumValue(JsonElement e, Func<string, int?> byName)
{
if (e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var n)) return n;
if (e.ValueKind != JsonValueKind.String) return null;
var s = e.GetString() ?? string.Empty;
return int.TryParse(s, out n) ? n : byName(s);
}
private static int? RoleName(string s) => s.Trim().ToLowerInvariant() switch
{
"none" => RoomRoleRules.WireNone,
"banned" => RoomRoleRules.WireBanned,
"host" => RoomRoleRules.WireHost,
"moderator" => RoomRoleRules.WireModerator,
"coowner" => RoomRoleRules.WireCoOwner,
"temporarycoowner" => RoomRoleRules.WireTemporaryCoOwner,
"creator" => RoomRoleRules.WireCreator,
_ => null,
};
private static int? TypeName(string s) => s.Trim().ToLowerInvariant() switch
{
"bool" => 0,
"int" => 1,
"stringlist" => 2,
_ => null,
};
}
+222
View File
@@ -0,0 +1,222 @@
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Services;
/// <summary>
/// The 2023 room-role ladder and who may change whose role.
///
/// <para><b>Wire enum.</b> The client's room role is <c>OMMBGJMJJPN : byte</c>
/// (recnet-runtime-decomp/OMMBGJMJJPN.cs): None 0, Banned 1, Host 10,
/// Moderator 20, CoOwner 30, TemporaryCoOwner 31, Creator 255. It is
/// gap-skipping, like the club MembershipType enum, so never treat it as an
/// index. <see cref="RoomRoleEntity.Role"/> stores a compact kind instead
/// (0 = CoOwner, 1 = Moderator, 2 = Host); <see cref="ToWire"/> and
/// <see cref="TryFromWire"/> convert.</para>
///
/// <para><b>The creator is 255, not 30.</b> The client reads the local
/// player's role straight out of the room details <c>Roles</c> list
/// (<c>FGCPNAACHIK.ONECLLALJEO</c>: first row whose AccountId matches), and
/// the room-permissions runtime only lets a player edit a role strictly
/// below their own, with Creator (255) the one unconditional pass
/// (<c>OMJKHJLFOCO.DIIMLMKEJLC</c>,
/// IsilDump/RecRoom.RoomPermissions.Runtime/OMJKHJLFOCO.txt:2367:
/// <c>if role == 255 return true; if target &gt;= role return false</c>).
/// Advertising the creator as CoOwner (30) therefore locked the room's own
/// creator out of the Co-Owner and Creator permission rows.</para>
///
/// <para><b>Server rule</b> (<see cref="CheckChange"/>): the creator may set
/// any role on anyone but themselves. Otherwise the actor must hold an
/// accepted CoOwner role, and both the target's current role (accepted or
/// still invited) and the requested role must be strictly below the
/// actor's. A player may always drop their own role or decline their own
/// invite (role 0), and may accept exactly the role they were invited to.</para>
/// </summary>
public static class RoomRoleRules
{
public const int WireNone = 0;
public const int WireBanned = 1;
public const int WireHost = 10;
public const int WireModerator = 20;
public const int WireCoOwner = 30;
public const int WireTemporaryCoOwner = 31;
public const int WireCreator = 255;
public const int StoredCoOwner = 0;
public const int StoredModerator = 1;
public const int StoredHost = 2;
/// <summary>Stored kind → wire role.</summary>
public static int ToWire(int storedRole) => storedRole switch
{
StoredCoOwner => WireCoOwner,
StoredModerator => WireModerator,
StoredHost => WireHost,
_ => WireNone,
};
/// <summary>Wire role → stored kind. Only the three persistable roles
/// map; None, Banned, TemporaryCoOwner (an in-instance grant) and Creator
/// (ownership, changed through <c>rooms/{id}/creator</c>) do not.</summary>
public static bool TryFromWire(int wireRole, out int storedRole)
{
storedRole = wireRole switch
{
WireCoOwner => StoredCoOwner,
WireModerator => StoredModerator,
WireHost => StoredHost,
_ => -1,
};
return storedRole >= 0;
}
/// <summary>The player's accepted wire role in the room: 255 for the
/// creator, the highest accepted grant otherwise, 0 for none.</summary>
public static int AcceptedRank(RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId)
{
if (room.CreatorPlayerId == playerId) return WireCreator;
return rows
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Accepted)
.Select(r => ToWire(r.Role))
.DefaultIfEmpty(WireNone)
.Max();
}
/// <summary>The highest role the player has been invited to and not yet
/// accepted, 0 for none.</summary>
public static int InvitedRank(RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId)
=> rows
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId && !r.Accepted)
.Select(r => ToWire(r.Role))
.DefaultIfEmpty(WireNone)
.Max();
/// <summary>True when the player may change room-level settings and the
/// sub-room settings that hang off them: the creator or an accepted
/// co-owner. This is the same gate as the bare room mutations
/// (RoomsModerationController.RequireOwnedRoomAsync) and room saves.</summary>
public static bool CanEditRoom(RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId)
=> AcceptedRank(room, rows, playerId) >= WireCoOwner;
/// <summary>What a role write will do once <see cref="CheckChange"/> allows it.</summary>
public enum ChangeKind
{
/// <summary>Grant or replace the target's role (accepted at once).</summary>
Grant,
/// <summary>Record a pending invite the target must accept.</summary>
Invite,
/// <summary>The target accepts their own pending invite.</summary>
AcceptInvite,
/// <summary>Remove the target's role and any pending invite.</summary>
Clear,
}
/// <summary>Decide whether <paramref name="actorId"/> may set
/// <paramref name="targetId"/>'s role to <paramref name="requestedWireRole"/>.
/// Returns null when allowed, otherwise an error code for the response.
/// <paramref name="kind"/> is what the caller should then do.</summary>
public static string? CheckChange(
RoomEntity room,
IReadOnlyCollection<RoomRoleEntity> rows,
long actorId,
long targetId,
int requestedWireRole,
bool isInvite,
out ChangeKind kind)
{
kind = requestedWireRole == WireNone
? ChangeKind.Clear
: isInvite ? ChangeKind.Invite : ChangeKind.Grant;
if (targetId == room.CreatorPlayerId)
return "cannot_change_creator_role";
if (requestedWireRole != WireNone && !TryFromWire(requestedWireRole, out _))
return "invalid_role";
var actorRank = AcceptedRank(room, rows, actorId);
// Self-service: anyone may give up their own role or decline their
// own invite, and accept the exact role they were invited to.
if (actorId == targetId && actorRank != WireCreator)
{
if (requestedWireRole == WireNone) return null;
if (!isInvite && requestedWireRole == InvitedRank(room, rows, targetId))
{
kind = ChangeKind.AcceptInvite;
return null;
}
return "forbidden";
}
if (actorRank == WireCreator) return null;
if (actorRank < WireCoOwner) return "forbidden";
var targetRank = Math.Max(
AcceptedRank(room, rows, targetId),
InvitedRank(room, rows, targetId));
if (targetRank >= actorRank) return "forbidden";
if (requestedWireRole >= actorRank) return "forbidden";
return null;
}
/// <summary>True when <paramref name="playerId"/> may write the
/// permission row for <paramref name="wireRole"/>: the creator may edit
/// every role (including CoOwner and Creator); a co-owner only the roles
/// below their own. Mirrors the client's OMJKHJLFOCO.DIIMLMKEJLC.</summary>
public static bool CanEditRolePermissions(
RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId, int wireRole)
{
var rank = AcceptedRank(room, rows, playerId);
if (rank == WireCreator) return true;
if (rank < WireCoOwner) return false;
// TemporaryCoOwner collapses to CoOwner on the client (ONECLLALJEO).
var target = wireRole == WireTemporaryCoOwner ? WireCoOwner : wireRole;
return target < rank;
}
/// <summary>The room details / roles-list rows: one per account, with
/// the creator first as 255 and every other account's accepted role in
/// <c>Role</c> and pending invite in <c>InvitedRole</c>. Rows the
/// creator happens to own in RoomRoles are ignored so the creator is
/// never listed twice (the client takes the first match).</summary>
public static List<RoomRoleWire> BuildWires(RoomEntity room, IEnumerable<RoomRoleEntity> rows)
{
var list = new List<RoomRoleWire>
{
new(ToClientAccountId(room.CreatorPlayerId), WireCreator, WireNone, null),
};
foreach (var group in rows
.Where(r => r.RoomId == room.Id && r.PlayerId != room.CreatorPlayerId)
.GroupBy(r => r.PlayerId)
.OrderBy(g => g.Key))
{
var accepted = group.Where(r => r.Accepted).ToList();
var invited = group.Where(r => !r.Accepted).ToList();
var role = accepted.Select(r => ToWire(r.Role)).DefaultIfEmpty(WireNone).Max();
var invitedRole = invited.Select(r => ToWire(r.Role)).DefaultIfEmpty(WireNone).Max();
var lastChangedBy = group
.OrderByDescending(r => r.GrantedAt)
.Select(r => r.GrantedByPlayerId)
.FirstOrDefault();
list.Add(new RoomRoleWire(
ToClientAccountId(group.Key),
role,
invitedRole,
lastChangedBy is long by ? ToClientAccountId(by) : null));
}
return list;
}
/// <summary>One <c>EFHPLDPNGIM</c> row: <c>{AccountId: Int32, Role,
/// InvitedRole, LastChangedByAccountId: Int32?}</c>.</summary>
public sealed record RoomRoleWire(int AccountId, int Role, int InvitedRole, int? LastChangedByAccountId);
/// <summary>Account ids are Int32 in the 2023 role DTO. Clamp rather than
/// wrap so an out-of-range id is obviously wrong.</summary>
public static int ToClientAccountId(long accountId)
=> accountId > int.MaxValue
? int.MaxValue
: accountId < int.MinValue
? int.MinValue
: (int)accountId;
}
+44
View File
@@ -0,0 +1,44 @@
namespace DorkNet.Server.Services;
/// <summary>
/// Converts between a sub-room's position in the room
/// (<see cref="Data.Entities.RoomSceneEntity.OrderIndex"/>, 0-based, what the
/// server stores and indexes on) and the <c>SubRoomId</c> the 2023 client
/// sees on the wire.
///
/// <para><b>Why they differ.</b> The client refuses to load a sub-room whose
/// id is not positive: <c>SubroomModel.DOKCPLJOKIE</c>
/// (IsilDump/Assembly-CSharp/RRUI/Data/SubroomModel.txt:3) is
/// <c>RoomId &gt; 0 &amp;&amp; SubroomId &gt; 0</c>, and
/// <c>Model`1.LNPEMMMGCPC</c> skips the load when it is false. With the
/// first sub-room advertised as id 0, its Room Settings &gt; Subrooms row
/// never fetched its details (<c>SubroomModel.KBOJBJNCOGK</c> →
/// <c>FGCPNAACHIK.OBFPEGLLELP(long?)</c>), so the row showed no name, and
/// every per-row action (rename, max players, permissions, duplicate) ran
/// against a null sub-room. Rec Room's own ids were large unique numbers;
/// the client needs only "positive and stable per room".</para>
///
/// <para><b>Mapping.</b> Wire id = OrderIndex + 1, so a room's first
/// sub-room is 1. Incoming ids go back through <see cref="ToOrderIndex"/>;
/// 0 (and anything negative) is accepted as an alias of the first sub-room,
/// which keeps cached client state, old presence rows and bare
/// <c>matchmake/room/{id}</c> joins working.</para>
///
/// <para><b>Where the conversion happens.</b> Only at the wire: the room
/// details <c>SubRooms[]</c>, the sub-room routes
/// (<c>rooms/{id}/subrooms/{sub}/*</c>), <c>matchmake/room/{id}/{sub}</c>,
/// the room-instance JSON (<c>RoomInstanceDto.subRoomId</c>), the instance
/// browser, sub-room save DTOs and permission rows. Everything stored
/// (RoomScenes, PrivateInstances, RoomDataBlobs, presence) keeps the
/// OrderIndex.</para>
/// </summary>
public static class RoomSceneIds
{
/// <summary>OrderIndex → wire SubRoomId (always ≥ 1).</summary>
public static long ToWire(long orderIndex) => Math.Max(0, orderIndex) + 1;
/// <summary>Wire SubRoomId → OrderIndex. 0 and negatives map to the first
/// sub-room.</summary>
public static int ToOrderIndex(long wireSubRoomId)
=> wireSubRoomId <= 1 ? 0 : (int)Math.Min(int.MaxValue, wireSubRoomId - 1);
}
+110 -1
View File
@@ -546,6 +546,94 @@ public class RoomService(DorkNetDbContext db)
return existing;
}
/// <summary>The name a sub-room gets when it has none: "Home" for the
/// entry sub-room, "Subroom N" (1-based, matching its wire id) for the
/// rest.</summary>
public static string DefaultSubRoomName(int orderIndex)
=> orderIndex <= 0 ? "Home" : $"Subroom {orderIndex + 1}";
/// <summary>A real entry sub-room row for a room that has none, built
/// from the room's own location, data blob and capacity. Callers add it
/// to the context.</summary>
public static RoomSceneEntity NewHomeScene(RoomEntity room) => new()
{
RoomId = room.Id,
OrderIndex = 0,
Name = DefaultSubRoomName(0),
RoomSceneLocationId = string.IsNullOrWhiteSpace(room.LocationReplicationId)
? new RoomSceneEntity().RoomSceneLocationId
: room.LocationReplicationId,
DataBlobName = room.CurrentDataBlobName ?? string.Empty,
MaxPlayers = room.MaxCapacity > 0 ? room.MaxCapacity : 8,
IsSandbox = false,
CanMatchmakeInto = true,
DataModifiedAt = DateTime.UtcNow,
};
/// <summary>Postgres only: moves the RoomScenes id sequence past the
/// highest existing id. Rows written with explicit ids (seeds, older
/// builds of the clone and repair code) leave it behind, and the next
/// insert that relies on it (add / clone sub-room) then collides on the
/// primary key. A no-op on SQLite, which assigns max + 1 itself.</summary>
public async Task SyncRoomSceneIdSequenceAsync()
{
if (!db.Database.IsNpgsql()) return;
await db.Database.ExecuteSqlRawAsync(
"""
SELECT setval(pg_get_serial_sequence('"RoomScenes"', 'Id'),
GREATEST(COALESCE((SELECT MAX("Id") FROM "RoomScenes"), 0), 1),
EXISTS (SELECT 1 FROM "RoomScenes"));
""");
}
/// <summary>Startup self-heal for sub-rooms (idempotent).
///
/// <para>Every non-dorm room must own at least one real RoomScenes row,
/// and every row must have a name. A room created by cloning a template
/// that had no scene rows (the 2023 "create room" flow clones a base room
/// such as MakerRoom) ended up with none, so the details builder served a
/// synthesised fallback and every sub-room mutation had to create the row
/// on the fly; a blank Name renders as an empty row in Room Settings &gt;
/// Subrooms. This inserts the missing entry sub-room (OrderIndex 0, the
/// room's own location / blob / capacity) and names unnamed rows. Dorms
/// are left alone: their details are shaped per player and their single
/// scene is synthesised on purpose.</para>
///
/// <para>It first moves the Postgres id sequence of RoomScenes past the
/// highest id (<see cref="SyncRoomSceneIdSequenceAsync"/>), then lets the
/// database assign ids like every other scene insert. Every service that
/// runs the database bootstrap calls this at startup, so on Postgres the
/// whole repair holds the bootstrap's advisory lock and replicas booting
/// together take turns.</para>
/// Returns (rooms given an entry scene, scenes renamed).</summary>
public async Task<(int ScenesAdded, int ScenesRenamed)> RepairRoomScenesAsync()
{
await using var tx = await db.Database.BeginTransactionAsync();
if (db.Database.IsNpgsql())
await db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_xact_lock(1146246987);");
// The entry sub-room is OrderIndex 0 (its blob is the room's own front
// door), so a room missing it is repaired even when it has others.
await SyncRoomSceneIdSequenceAsync();
var roomsWithoutEntry = await db.Rooms
.Where(r => !r.IsDormRoom && !db.RoomScenes.Any(s => s.RoomId == r.Id && s.OrderIndex == 0))
.ToListAsync();
foreach (var room in roomsWithoutEntry)
db.RoomScenes.Add(NewHomeScene(room));
var unnamed = await db.RoomScenes
.Where(s => s.Name == null || s.Name.Trim() == string.Empty)
.ToListAsync();
foreach (var scene in unnamed)
scene.Name = DefaultSubRoomName(scene.OrderIndex);
if (roomsWithoutEntry.Count > 0 || unnamed.Count > 0)
await db.SaveChangesAsync();
await tx.CommitAsync();
return (roomsWithoutEntry.Count, unnamed.Count);
}
/// <summary>Run once at startup to backfill <see cref="RoomEntity"/>
/// + <see cref="DormStateEntity"/> rows for every account that
/// pre-dates the auto-create-on-signup work. Idempotent — calls
@@ -1068,6 +1156,8 @@ public class RoomService(DorkNetDbContext db)
};
db.Rooms.Add(clone);
// Every room owns a real entry sub-room (see RepairRoomScenesAsync).
db.RoomScenes.Add(NewHomeScene(clone));
await db.SaveChangesAsync();
return new CloneResult(0 /* Success */, clone);
}
@@ -1081,6 +1171,25 @@ public class RoomService(DorkNetDbContext db)
/// AllowsJuniors, RoomWarningMask, CustomRoomWarning
/// Optional: IsDormRoom, SupportsVRLow, SupportsMobile, DisableMicAutoMute.
/// </summary>
/// <summary>The <c>IsRRO</c> the 2023 client is told: only a first-party
/// room (baked/AG and still owned by the system account) is a Rec Room
/// Original. A player's room is never one, even when it was cloned from an
/// RRO template and keeps <see cref="RoomEntity.IsAGRoom"/> for the baked
/// blob handling.
///
/// <para>The client gates the whole owner UI on it: RoomModel
/// get_CanPublishRoom, CanUnpublishRoom, CanDeleteRoom,
/// CanChangeRoomCreator, CanChangeRoomSetting, CanChangeCustomWarning,
/// CanChangeLoadingScreen and CanCreateNewSubroom all require
/// <c>!IsRRO</c> (IsilDump/Assembly-CSharp/RRUI/Data/RoomModel.txt:275,
/// 429, 560, 682, 1002, 1473, 1680, 1816), so a clone advertised as RRO
/// could never be published ("Cannot publish room") or have its settings
/// changed. The room-load pipeline (RecRoom.RoomLoading.Runtime) never
/// reads the flag: the scene comes from the sub-room's UnitySceneId and
/// the blob step from whether DataBlob is set.</para></summary>
public static bool WireIsRRO(RoomEntity r)
=> r.IsAGRoom && r.CreatorPlayerId == PlayerService.SystemAccountId;
public static object ToWireRoom(RoomEntity r) => new
{
RoomId = r.Id,
@@ -1093,7 +1202,7 @@ public class RoomService(DorkNetDbContext db)
Accessibility = r.Accessibility,
SupportsLevelVoting = r.SupportsLevelVoting,
IsAGRoom = r.IsAGRoom,
IsRRO = r.IsAGRoom,
IsRRO = WireIsRRO(r),
// 2020.12 Room.Deserialize (KLCOGEIGEBJ.PPGFHEDFBEA, the Room base)
// reads required keys: RoomId, IsDorm, CloningAllowed, DisableMicAutoMute,
// DisableRoomComments, EncryptVoiceChat. The 2020.03 key was IsDormRoom;
+34 -1
View File
@@ -179,7 +179,11 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
foreach (var row in stale)
{
var guid = row.Slug.Substring("wardrobe-".Length);
if (!safe.Contains(guid)) toDelete.Add(row.Id);
// Custom-clothing templates aren't sold; drop rows seeded
// before they were excluded.
if (!safe.Contains(guid) ||
DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.IsBaseAvatarItemGuid(guid))
toDelete.Add(row.Id);
}
if (toDelete.Count > 0)
{
@@ -690,6 +694,9 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
var added = false;
foreach (var (guid, dto) in catalog)
{
// The UGC templates are the base of custom clothing, not shop
// items (the real store never listed them).
if (dto.IsBaseAvatarItem) continue;
var slug = $"wardrobe-{guid}";
if (existingSlugs.Contains(slug)) continue;
existingSlugs.Add(slug);
@@ -1191,6 +1198,32 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
/// id-lists (toptoday) resolve against the gift-drop cache.</summary>
public static int PurchasableItemIdFor(StoreItemEntity i) => (int)(i.Id & 0x7fffffff);
/// <summary>Shop metadata for an avatar item, keyed by the exact
/// AvatarItemDesc a shop tile carries.</summary>
public sealed record AvatarItemStoreInfo(string FriendlyName, string Tooltip, int Rarity);
/// <summary>FriendlyName / Tooltip / Rarity of every avatar-item and
/// hair-dye shop tile, keyed by the same AvatarItemDesc
/// <see cref="BuildGiftDrop"/> sends (ordinal: colour-variant
/// combination ids are case-sensitive). The owned-items list uses it so
/// an item reads the same in the wardrobe as in the shop.</summary>
public static async Task<Dictionary<string, AvatarItemStoreInfo>> LoadAvatarItemStoreInfoAsync(DorkNetDbContext db)
{
var rows = await db.StoreItems.AsNoTracking()
.Select(i => new { i.Slug, i.DisplayName, i.Description, i.Category })
.ToListAsync();
var info = new Dictionary<string, AvatarItemStoreInfo>(StringComparer.Ordinal);
foreach (var row in rows)
{
string desc;
if (TryGetHairDyePayload(row.Slug, out _, out var hairColorGuid)) desc = hairColorGuid;
else if (!TryGetAvatarItemPayload(row.Slug, out _, out desc)) continue;
if (string.IsNullOrEmpty(desc)) continue;
info.TryAdd(desc, new AvatarItemStoreInfo(row.DisplayName ?? string.Empty, row.Description ?? string.Empty, MapRarity(row.Category)));
}
return info;
}
/// <summary>GiftManager.GiftRarity wire values:
/// None=-1, Common=0, Uncommon=10, Rare=20, Epic=30, Legendary=50.</summary>
private static int MapRarity(string? category) => category?.ToLowerInvariant() switch
@@ -28,6 +28,8 @@ public sealed class SystemNotificationService(DorkNetDbContext db, NotificationS
/// listed; add more as needed.</summary>
public static class MessageType
{
public const int FriendInvite = 4;
public const int RequestGameInvite = 10;
public const int TextMessage = 30;
public const int FriendRequestAccepted = 40;
public const int FriendCodeUsed = 41;
@@ -39,6 +41,8 @@ public sealed class SystemNotificationService(DorkNetDbContext db, NotificationS
public const int CreatorPublishedNewRoom = 70;
public const int PlayerAttendingEvent = 80;
public const int PlayerEventInvitation = 81;
public const int CoachMessage = 100;
public const int FriendIntroduction = 130;
}
/// <summary>Persist and push a system notification to
+199 -52
View File
@@ -15,9 +15,9 @@ namespace DorkNet.Server.Startup;
/// <item>SQLite — <c>Database.Migrate()</c> after
/// <see cref="BaselineExistingSchemaIfNeeded"/> reconciles any legacy
/// migration-history state.</item>
/// <item>Postgres — <c>EnsureCreated</c> behind a transaction-scoped
/// advisory lock so concurrent replica boots don't race on
/// <c>CREATE TABLE</c>. Connect retries cover the
/// <item>Postgres — <c>EnsureCreated</c>, with the whole bootstrap
/// (schema and seeds) behind a session advisory lock so concurrent
/// replica boots take turns instead of racing. Connect retries cover the
/// container-orchestrated boot window where Postgres DNS isn't
/// resolvable yet.</item>
/// </list>
@@ -40,61 +40,110 @@ public static class DatabaseBootstrap
await ConfigureSqliteForLocalConcurrencyAsync(db);
BaselineExistingSchemaIfNeeded(db);
db.Database.Migrate();
await RunUpgradesAndSeedsAsync(app, scope, db);
return;
}
else
// Postgres path: schema generated from the EF Core model via
// EnsureCreated, then the same upgrades and seeds as SQLite. The
// whole bootstrap holds a session-level advisory lock, so replicas
// booting against one database take turns: the first creates the
// schema and seeds, the rest find everything in place. Holding it
// only around EnsureCreated left the seeders racing (duplicate keys
// on a fresh database).
//
// Lock key 0x444F524B (ascii "DORK") is hard-coded; it just needs
// to be the same int64 across replicas and not collide with
// anything else in the database. It is released in the finally
// below, and Postgres drops it if the replica's connection dies, so
// a crashed replica can't wedge the next boot.
var conn = db.Database.GetDbConnection();
// Retry initial Postgres connect — on container-orchestrated boots
// (Coolify, docker-compose) the Postgres service's DNS often isn't
// resolvable yet when this app starts, surfacing as SocketException
// errno=11 EAGAIN from Npgsql's Dns.GetHostEntryOrAddressesCore.
// Postgres itself can also be up-but-not-accepting-connections for
// a few seconds while it replays WAL. 15 attempts × ~2s backoff
// covers the typical 5-30s warmup window without looping forever
// on a permanently broken config.
var connectLogger = app.Services.GetRequiredService<ILoggerFactory>()
.CreateLogger("DorkNet.Server.Bootstrap");
for (var attempt = 1; ; attempt++)
{
// Postgres path: schema generated from the EF Core model via
// EnsureCreated. Wrapped in a transaction-scoped advisory lock
// so concurrent replicas booting against the same database
// don't race on CREATE TABLE — only the first replica to take
// the lock runs EnsureCreated, the rest see "schema already
// exists" and proceed.
//
// Lock key 0x444F524B (ascii "DORK") is hard-coded; it just
// needs to be the same int64 across replicas and not collide
// with anything else in the database. pg_advisory_xact_lock
// releases automatically on COMMIT, so a crashed replica can't
// wedge the next boot.
var conn = db.Database.GetDbConnection();
// Retry initial Postgres connect — on container-orchestrated boots
// (Coolify, docker-compose) the Postgres service's DNS often isn't
// resolvable yet when this app starts, surfacing as SocketException
// errno=11 EAGAIN from Npgsql's Dns.GetHostEntryOrAddressesCore.
// Postgres itself can also be up-but-not-accepting-connections for
// a few seconds while it replays WAL. 15 attempts × ~2s backoff
// covers the typical 5-30s warmup window without looping forever
// on a permanently broken config.
var connectLogger = app.Services.GetRequiredService<ILoggerFactory>()
.CreateLogger("DorkNet.Server.Bootstrap");
for (var attempt = 1; ; attempt++)
try { await conn.OpenAsync(); break; }
catch (Exception ex) when (attempt < 15 &&
(ex is System.Net.Sockets.SocketException
|| ex is Npgsql.NpgsqlException
|| ex is System.Data.Common.DbException))
{
try { await conn.OpenAsync(); break; }
catch (Exception ex) when (attempt < 15 &&
(ex is System.Net.Sockets.SocketException
|| ex is Npgsql.NpgsqlException
|| ex is System.Data.Common.DbException))
{
var delay = TimeSpan.FromSeconds(Math.Min(attempt, 5));
connectLogger.LogWarning(
"[bootstrap] Postgres connect attempt {Attempt}/15 failed ({Type}: {Message}); retrying in {Delay}s",
attempt, ex.GetType().Name, ex.Message, delay.TotalSeconds);
await Task.Delay(delay);
}
var delay = TimeSpan.FromSeconds(Math.Min(attempt, 5));
connectLogger.LogWarning(
"[bootstrap] Postgres connect attempt {Attempt}/15 failed ({Type}: {Message}); retrying in {Delay}s",
attempt, ex.GetType().Name, ex.Message, delay.TotalSeconds);
await Task.Delay(delay);
}
await using var tx = await conn.BeginTransactionAsync();
await using (var lockCmd = conn.CreateCommand())
{
lockCmd.Transaction = tx;
lockCmd.CommandText = "SELECT pg_advisory_xact_lock(1146246987);";
await lockCmd.ExecuteNonQueryAsync();
}
db.Database.EnsureCreated();
await tx.CommitAsync();
await conn.CloseAsync();
}
// The connection stays open until the finally, so every step below
// (they all share this scope's DbContext) runs on the session that
// holds the lock. Waiting for it has no command timeout: another
// replica may be seeding a fresh database (thumbnail downloads
// included) for well over the default 30 s.
connectLogger.LogInformation("[bootstrap] waiting for the database bootstrap lock");
var previousTimeout = db.Database.GetCommandTimeout();
db.Database.SetCommandTimeout(0);
try
{
await db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_lock(1146246987);");
}
finally
{
db.Database.SetCommandTimeout(previousTimeout);
}
connectLogger.LogInformation("[bootstrap] holding the database bootstrap lock");
try
{
// Begin the transaction through EF, not on the raw connection:
// EnsureCreated runs its commands through the migration executor,
// which starts its own transaction unless EF already has one, and
// fails on a fresh database with "A transaction is already in
// progress" when the open one is invisible to it.
await using (var tx = await db.Database.BeginTransactionAsync())
{
db.Database.EnsureCreated();
await tx.CommitAsync();
}
await RunUpgradesAndSeedsAsync(app, scope, db);
}
finally
{
// Explicit: Npgsql pools connections, so closing alone would not
// end the session that holds the lock. If the connection is
// already broken the unlock fails too; that must not replace the
// real bootstrap error, and the connection still gets closed
// (Postgres then drops the lock with the session).
try
{
await db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_unlock(1146246987);");
}
catch (Exception ex)
{
connectLogger.LogWarning(ex, "[bootstrap] releasing the database bootstrap lock failed");
}
finally
{
await conn.CloseAsync();
}
}
}
/// <summary>The idempotent post-schema sequence both providers run:
/// column/table upgrades, system account and backfills, legacy data
/// transforms, canonical rooms and the per-feature seeders. Flips the
/// <c>/healthz</c> readiness flag at the end.</summary>
private static async Task RunUpgradesAndSeedsAsync(WebApplication app, IServiceScope scope, DorkNetDbContext db)
{
// Signup-code + pending-device tables. These post-date the
// consolidated Initial migration, and the Postgres path is
// EnsureCreated-only (no migration replay), so neither provider
@@ -102,6 +151,7 @@ public static class DatabaseBootstrap
// idempotent raw SQL that works on a fresh DB too (CREATE TABLE
// IF NOT EXISTS no-ops when EnsureCreated already built them).
await EnsureSignupCodeTablesAsync(db);
await EnsureExternalSignInTablesAsync(db);
await EnsureServerSettingsColumnsAsync(db);
await EnsureCharadesWordListTableAsync(db);
await EnsureInventorySchemaAsync(db);
@@ -164,6 +214,11 @@ public static class DatabaseBootstrap
await roomService.ApplyCanonicalOverridesAsync(http, imagesDir);
}
// Every non-dorm room needs a real, named entry sub-room (see
// RoomService.RepairRoomScenesAsync). Runs after the seed and the
// canonical overrides so it only fills what they left missing.
await roomService.RepairRoomScenesAsync();
// Seed default curated playlists, club category tags, store catalog.
// Must run after SeedAsync so they can reference RR-Originals.
var playlistService = scope.ServiceProvider.GetRequiredService<PlaylistService>();
@@ -211,6 +266,66 @@ public static class DatabaseBootstrap
/// tables on whichever provider is in use. Safe to run every boot:
/// CREATE TABLE / INDEX IF NOT EXISTS no-op once they exist (whether
/// created here or by a fresh-DB EnsureCreated).</summary>
/// <summary>External sign-in: the identities linked to accounts
/// (<c>ExternalLogins</c>) and the data-protection keys that encrypt the
/// sign-in cookies (<c>DataProtectionKeys</c>). Both post-date the Initial
/// migration, so like the signup-code tables they're created here for
/// either provider (a no-op once EnsureCreated or an earlier boot made them).</summary>
private static async Task EnsureExternalSignInTablesAsync(DorkNetDbContext db)
{
var pg = db.Database.IsNpgsql();
var statements = pg
? new[]
{
"""
CREATE TABLE IF NOT EXISTS "ExternalLogins" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"PlayerId" bigint NOT NULL,
"Issuer" text NOT NULL,
"Subject" text NOT NULL,
"Email" text NOT NULL DEFAULT '',
"CreatedAt" timestamp with time zone NOT NULL DEFAULT now(),
"LastUsedAt" timestamp with time zone NOT NULL DEFAULT now()
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ExternalLogins_Issuer_Subject"" ON ""ExternalLogins"" (""Issuer"", ""Subject"");",
@"CREATE INDEX IF NOT EXISTS ""IX_ExternalLogins_PlayerId"" ON ""ExternalLogins"" (""PlayerId"");",
"""
CREATE TABLE IF NOT EXISTS "DataProtectionKeys" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"FriendlyName" text NULL,
"Xml" text NULL
);
""",
}
: new[]
{
"""
CREATE TABLE IF NOT EXISTS "ExternalLogins" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_ExternalLogins" PRIMARY KEY AUTOINCREMENT,
"PlayerId" INTEGER NOT NULL,
"Issuer" TEXT NOT NULL,
"Subject" TEXT NOT NULL,
"Email" TEXT NOT NULL DEFAULT '',
"CreatedAt" TEXT NOT NULL,
"LastUsedAt" TEXT NOT NULL
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ExternalLogins_Issuer_Subject"" ON ""ExternalLogins"" (""Issuer"", ""Subject"");",
@"CREATE INDEX IF NOT EXISTS ""IX_ExternalLogins_PlayerId"" ON ""ExternalLogins"" (""PlayerId"");",
"""
CREATE TABLE IF NOT EXISTS "DataProtectionKeys" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_DataProtectionKeys" PRIMARY KEY AUTOINCREMENT,
"FriendlyName" TEXT NULL,
"Xml" TEXT NULL
);
""",
};
foreach (var sql in statements)
await db.Database.ExecuteSqlRawAsync(sql);
}
private static async Task EnsureSignupCodeTablesAsync(DorkNetDbContext db)
{
var pg = db.Database.IsNpgsql();
@@ -899,6 +1014,23 @@ public static class DatabaseBootstrap
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubAdditionalImages_ClubId_Slot"" ON ""ClubAdditionalImages"" (""ClubId"", ""Slot"");",
// 2023 sub-room role-permission matrix
// (PUT rooms/{id}/subrooms/{sub}/permissions → photon_access_token).
"""
CREATE TABLE IF NOT EXISTS "RoomRolePermissions" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"RoomId" bigint NOT NULL,
"SubRoomId" bigint NOT NULL,
"Role" integer NOT NULL,
"Permission" character varying(128) NOT NULL DEFAULT '',
"Type" integer NOT NULL DEFAULT 0,
"Override" boolean NOT NULL DEFAULT false,
"Value" character varying(4096) NOT NULL DEFAULT '',
"UpdatedByPlayerId" bigint NOT NULL DEFAULT 0,
"UpdatedAt" timestamp with time zone NOT NULL DEFAULT now()
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomRolePermissions_RoomId_SubRoomId_Role_Permission"" ON ""RoomRolePermissions"" (""RoomId"", ""SubRoomId"", ""Role"", ""Permission"");",
};
foreach (var sql in statements)
await db.Database.ExecuteSqlRawAsync(sql);
@@ -976,6 +1108,21 @@ public static class DatabaseBootstrap
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubAdditionalImages_ClubId_Slot"" ON ""ClubAdditionalImages"" (""ClubId"", ""Slot"");",
"""
CREATE TABLE IF NOT EXISTS "RoomRolePermissions" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_RoomRolePermissions" PRIMARY KEY AUTOINCREMENT,
"RoomId" INTEGER NOT NULL,
"SubRoomId" INTEGER NOT NULL,
"Role" INTEGER NOT NULL,
"Permission" TEXT NOT NULL DEFAULT '',
"Type" INTEGER NOT NULL DEFAULT 0,
"Override" INTEGER NOT NULL DEFAULT 0,
"Value" TEXT NOT NULL DEFAULT '',
"UpdatedByPlayerId" INTEGER NOT NULL DEFAULT 0,
"UpdatedAt" TEXT NOT NULL DEFAULT ''
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomRolePermissions_RoomId_SubRoomId_Role_Permission"" ON ""RoomRolePermissions"" (""RoomId"", ""SubRoomId"", ""Role"", ""Permission"");",
};
// CREATE ... IF NOT EXISTS is already idempotent; a failure here is a
// real problem and must fail startup.
@@ -238,6 +238,9 @@ public static class RequestTracingExtensions
return false;
var path = request.Path.Value ?? "";
// Client builds are streamed through (several GB): never buffer them.
if (path.StartsWith("/api/site/v1/downloads/files/", StringComparison.OrdinalIgnoreCase))
return false;
var ext = Path.GetExtension(path);
if (string.IsNullOrEmpty(ext))
return true;
@@ -1,4 +1,9 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.DataProtection.KeyManagement;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
@@ -29,6 +34,7 @@ public static class ServiceCollectionExtensions
AddVersionCompatibility(builder);
var redisConn = AddRedis(builder);
AddAuthentication(builder);
AddExternalSignIn(builder);
AddKestrelAndSignalR(builder, redisConn);
}
@@ -217,6 +223,13 @@ public static class ServiceCollectionExtensions
// so a deployment with no GitHub credentials behaves normally.
services.AddSingleton<IGitHubIssues, GitHubIssueService>();
services.AddHostedService<TestCaseIssueReconciler>();
// CI-published client builds for the site's Downloads / Quest pages.
// Off (IsConfigured=false) without DorkNet:Downloads:BaseUrl. No client
// timeout: a multi-GB file streams for longer than the default 100 s.
// DownloadsService limits the wait for headers itself, and the
// request's own abort token ends the stream.
services.AddHttpClient(DownloadsService.HttpClientName, c => c.Timeout = Timeout.InfiniteTimeSpan);
services.AddSingleton<DownloadsService>();
// Scoped (per-request) — these services hold DbContext references and
// must share the request's scope to participate in the request's
// transaction lifecycle. Pre-PR-3 they were Singleton with in-process
@@ -333,9 +346,7 @@ public static class ServiceCollectionExtensions
// older Coolify configs.
private static void AddAuthentication(WebApplicationBuilder builder)
{
var jwtSecret = Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
?? builder.Configuration["Jwt:Secret"]
var jwtSecret = AuthService.ConfiguredSecret(builder.Configuration)
?? throw new InvalidOperationException(
"JWT secret not configured. Set the DORKNET_JWT_SECRET env var or " +
"add `Jwt:Secret` to appsettings.Local.json (the install-plugin.ps1 " +
@@ -390,6 +401,119 @@ public static class ServiceCollectionExtensions
opt.JsonSerializerOptions.PropertyNamingPolicy = null);
}
// ── External sign-in (OpenID Connect) ─────────────────────────────────────
// Players sign in to the website (and admins to the admin panel) with an
// OpenID Connect provider, configured under DorkNet:SignIn. The game
// client's own token flow is untouched. Two cookie schemes, both per host
// (the hyphen host style puts the admin panel outside the apex's cookie
// domain): DorkNet.Web is the signed-in player, DorkNet.External a
// provider identity waiting to be linked to an account.
private static void AddExternalSignIn(WebApplicationBuilder builder)
{
var services = builder.Services;
var section = builder.Configuration.GetSection("DorkNet:SignIn");
services.Configure<ExternalSignInOptions>(section);
var signIn = section.Get<ExternalSignInOptions>() ?? new ExternalSignInOptions();
var https = !string.Equals(builder.Configuration["Domain:Scheme"] ?? "https", "http", StringComparison.OrdinalIgnoreCase);
var secure = https ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
services.AddScoped<ExternalSignInService>();
services.AddSingleton<LinkAttemptLimiter>();
// Cookie encryption keys live in the database, so a redeploy or a
// second replica doesn't invalidate everyone's session. They're
// stored encrypted with a key derived from the server secret, which
// lives outside the database, so a database backup alone can't forge
// sessions.
services.AddSingleton(new DataProtectionKeyEncryption(
AuthService.ConfiguredSecret(builder.Configuration) ?? string.Empty));
services.AddDataProtection()
.SetApplicationName("DorkNet")
.PersistKeysToDbContext<DorkNetDbContext>();
services.AddOptions<KeyManagementOptions>()
.Configure<DataProtectionKeyEncryption>((o, enc) => o.XmlEncryptor = enc);
var auth = services.AddAuthentication()
.AddCookie(ExternalSignIn.WebScheme, o =>
{
o.Cookie.Name = "dorknet_session";
o.Cookie.HttpOnly = true;
o.Cookie.SameSite = SameSiteMode.Lax;
o.Cookie.SecurePolicy = secure;
o.ExpireTimeSpan = TimeSpan.FromDays(30);
o.SlidingExpiration = true;
// API endpoints answer 401/403 instead of redirecting.
o.Events.OnRedirectToLogin = ctx => { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; };
o.Events.OnRedirectToAccessDenied = ctx => { ctx.Response.StatusCode = StatusCodes.Status403Forbidden; return Task.CompletedTask; };
// An unlinked identity, a deleted or banned player ends the session.
o.Events.OnValidatePrincipal = async ctx =>
{
var issuer = ctx.Principal?.FindFirst(ExternalSignIn.IssuerClaim)?.Value;
var subject = ctx.Principal?.FindFirst(ExternalSignIn.SubjectClaim)?.Value;
var svc = ctx.HttpContext.RequestServices.GetRequiredService<ExternalSignInService>();
var link = issuer is null || subject is null ? null : await svc.FindAsync(issuer, subject);
if (link is null || link.PlayerId != ExternalSignIn.PlayerId(ctx.Principal)
|| await svc.ActivePlayerAsync(link) is null)
{
ctx.RejectPrincipal();
await ctx.HttpContext.SignOutAsync(ExternalSignIn.WebScheme);
}
};
})
.AddCookie(ExternalSignIn.ExternalScheme, o =>
{
o.Cookie.Name = "dorknet_external";
o.Cookie.HttpOnly = true;
o.Cookie.SameSite = SameSiteMode.Lax;
o.Cookie.SecurePolicy = secure;
o.ExpireTimeSpan = TimeSpan.FromMinutes(15);
o.Events.OnRedirectToLogin = ctx => { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; };
});
if (!signIn.IsConfigured) return;
auth.AddOpenIdConnect(ExternalSignIn.OidcScheme, o =>
{
o.Authority = signIn.Authority;
o.ClientId = signIn.ClientId;
o.ClientSecret = signIn.ClientSecret;
o.ResponseType = "code";
o.UsePkce = true;
o.Scope.Clear();
foreach (var scope in signIn.Scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries))
o.Scope.Add(scope);
o.SignInScheme = ExternalSignIn.ExternalScheme;
o.CallbackPath = ExternalSignIn.CallbackPath;
o.GetClaimsFromUserInfoEndpoint = signIn.UseUserInfoEndpoint;
o.MapInboundClaims = false;
o.SaveTokens = false;
o.CorrelationCookie.SecurePolicy = secure;
o.NonceCookie.SecurePolicy = secure;
o.Events.OnRedirectToIdentityProvider = ctx =>
{
// Behind TLS-terminating proxies the request arrives as http;
// the provider must redirect back to the public URL of the
// host the sign-in started on (website or admin panel).
var domain = ctx.HttpContext.RequestServices.GetRequiredService<DomainConfig>();
ctx.ProtocolMessage.RedirectUri = $"{domain.Scheme}://{ctx.Request.Host}{ExternalSignIn.CallbackPath}";
return Task.CompletedTask;
};
o.Events.OnRemoteFailure = ctx =>
{
// e.g. the player cancelled at the provider: back to the login
// page (admin panel) or the site with an error instead of an
// exception page. The message is logged, not shown.
var domain = ctx.HttpContext.RequestServices.GetRequiredService<DomainConfig>();
var admin = string.Equals(ctx.Request.Host.Host, domain.Sub("admin"), StringComparison.OrdinalIgnoreCase);
ctx.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
.CreateLogger("DorkNet.SignIn").LogWarning(ctx.Failure, "[signin] provider sign-in failed");
ctx.Response.Redirect((admin ? "/login" : "/") + "?signin_error=provider_failed");
ctx.HandleResponse();
return Task.CompletedTask;
};
});
}
// ── Kestrel + SignalR ────────────────────────────────────────────────────
// Kestrel-level limits: defaults trim long-lived sockets earlier than our
// SignalR ping interval allows.
@@ -416,15 +540,18 @@ public static class ServiceCollectionExtensions
kopts.Limits.MinResponseDataRate = null;
});
// Keepalive cadence: server pings every 10s, gives the client 60s
// to ack before declaring the connection dead. The 10s ping forces
// frame traffic well below every proxy timeout we've seen in the
// path (Cloudflare edge ~100s, cloudflared 600s, Coolify/Traefik
// 180s, Kestrel 130s).
// Keepalive cadence: the server pings every 10s, which keeps frame
// traffic well below every proxy timeout we've seen in the path
// (Cloudflare edge ~100s, cloudflared 600s, Coolify/Traefik 180s,
// Kestrel 130s). The 2023 client pings every 15s, but from its main
// thread, so a long room-load hitch pauses its pings. It has no
// timeout of its own and reconnects at most 3 times per login, so a
// healthy socket the server drops can leave it without live
// notifications for the rest of the session. Give it 180s.
var signalR = builder.Services.AddSignalR(opts =>
{
opts.KeepAliveInterval = TimeSpan.FromSeconds(10);
opts.ClientTimeoutInterval = TimeSpan.FromSeconds(60);
opts.ClientTimeoutInterval = TimeSpan.FromSeconds(180);
opts.HandshakeTimeout = TimeSpan.FromSeconds(15);
// Allow large gift / community-board payloads (default 32 KB
// truncates anything bigger and disconnects the client).
+18 -11
View File
@@ -49,21 +49,28 @@ export function assetCdnUrl(path: string | null | undefined): string | null {
return subdomainUrl('cdn', path, undefined, true);
}
export function adminApex(): string {
if (typeof window === 'undefined') return 'rec.net';
// The server mounts this SPA on its own `admin` subdomain, so the admin
// host tells us the deployment's apex and subdomain style: `admin.<apex>`
// (dot style, `img.<apex>`) or `admin-<apex>` (hyphen style, `img-<apex>`).
function adminHost(): { apex: string; hyphen: boolean } {
if (typeof window === 'undefined') return { apex: 'rec.net', hyphen: false };
const host = window.location.hostname.toLowerCase();
if (host === 'localhost' || host.endsWith('.localhost')) return 'localhost';
if (host.endsWith('-dev.example.com')) return 'dev.example.com';
if (host === 'localhost' || host.endsWith('.localhost')) return { apex: 'localhost', hyphen: false };
const parts = host.split('.');
return parts.length > 2 ? parts.slice(1).join('.') : host;
const [first, ...rest] = host.split('.');
if (first.startsWith('admin-') && rest.length > 0)
return { apex: [first.slice('admin-'.length), ...rest].join('.'), hyphen: true };
return { apex: rest.length >= 2 ? rest.join('.') : host, hyphen: false };
}
function serviceHost(subdomain: string, apex: string): string {
return apex === 'dev.example.com'
? `${subdomain}-${apex}`
: `${subdomain}.${apex}`;
export function adminApex(): string {
return adminHost().apex;
}
function serviceHost(subdomain: string): string {
const { apex, hyphen } = adminHost();
return hyphen ? `${subdomain}-${apex}` : `${subdomain}.${apex}`;
}
function subdomainUrl(subdomain: string, path: string | null | undefined, query?: string, preserveSlashes = false): string | null {
@@ -74,7 +81,7 @@ function subdomainUrl(subdomain: string, path: string | null | undefined, query?
const encodedPath = preserveSlashes
? path.split('/').map(encodeURIComponent).join('/')
: encodeURIComponent(path);
return `${scheme}://${serviceHost(subdomain, apex)}/${encodedPath}${suffix}`;
return `${scheme}://${serviceHost(subdomain)}/${encodedPath}${suffix}`;
}
export interface Room {
@@ -5,18 +5,32 @@ import { useToast } from '../components/Toast';
export function Broadcast() {
const [message, setMessage] = useState('');
const [minutes, setMinutes] = useState('');
const [busy, setBusy] = useState(false);
const toast = useToast();
const startsInMinutes = minutes.trim() === '' ? null : Number(minutes);
const minutesValid = startsInMinutes === null || (Number.isInteger(startsInMinutes) && startsInMinutes >= 0);
const canSend = !busy && minutesValid && (message.trim() !== '' || startsInMinutes !== null);
const send = async (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim()) return;
if (!confirm(`Broadcast this to every connected player?\n\n"${message}"`)) return;
if (!canSend) return;
const parts = [
message.trim() && `"${message.trim()}"`,
startsInMinutes !== null &&
(startsInMinutes === 0 ? 'Cancel the maintenance countdown' : `Maintenance countdown: ${startsInMinutes} min`),
].filter(Boolean);
if (!confirm(`Broadcast this to every connected player?\n\n${parts.join('\n')}`)) return;
setBusy(true);
try {
await api('/broadcast', { method: 'POST', body: { Message: message } });
toast.push('Broadcast sent', 'success');
const res = await api<{ recipients: number }>('/broadcast', {
method: 'POST',
body: { Message: message.trim() || null, StartsInMinutes: startsInMinutes },
});
toast.push(`Broadcast sent to ${res.recipients} player(s)`, 'success');
setMessage('');
setMinutes('');
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
@@ -28,7 +42,7 @@ export function Broadcast() {
<div>
<PageHeader
title="Broadcast"
blurb="Push a server-maintenance notification to every connected player. The watch shows it as a system toast."
blurb="Message every connected player. The message lands in their in-game notifications from the system account; the optional countdown starts the game's own maintenance timer."
/>
<form onSubmit={send} className="card !p-5 max-w-2xl">
@@ -39,14 +53,33 @@ export function Broadcast() {
rows={5}
className="input mt-1.5 font-mono text-sm"
placeholder="Server going down for maintenance in 5 minutes — log out cleanly to avoid losing your dorm save."
required
/>
<div className="mt-2 text-xs text-ink-400">
{message.length} characters
</div>
<label className="label mt-4">Maintenance countdown (minutes, optional)</label>
<input
value={minutes}
onChange={e => setMinutes(e.target.value)}
inputMode="numeric"
className="input mt-1.5 w-40 text-sm"
placeholder="e.g. 5"
/>
<div className="mt-2 text-xs text-ink-400">
{minutesValid ? 'Leave empty for no countdown; 0 cancels a running one.' : 'Enter a whole number of minutes.'}
</div>
<div className="mt-4 flex gap-2 justify-end">
<button type="button" onClick={() => setMessage('')} className="btn-ghost text-xs" disabled={busy || !message}>Clear</button>
<button className="btn-primary text-xs" disabled={busy || !message.trim()}>
<button
type="button"
onClick={() => { setMessage(''); setMinutes(''); }}
className="btn-ghost text-xs"
disabled={busy || (!message && !minutes)}
>
Clear
</button>
<button className="btn-primary text-xs" disabled={!canSend}>
{busy ? 'Sending…' : 'Send broadcast'}
</button>
</div>
+87 -18
View File
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router';
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useLocation, useSearchParams } from 'react-router';
import { api, ApiError } from '../lib/api';
import { notifyAuthChange, setSession } from '../lib/auth';
import { adminApex } from '../lib/types';
import { BrandMark } from '../components/BrandMark';
interface LoginResponse {
@@ -12,14 +13,82 @@ interface LoginResponse {
display_name: string;
}
// The website, where an identity from the sign-in provider is linked to an
// account (the admin panel only signs in identities that are linked already).
function siteUrl(path: string): string {
const apex = adminApex();
return `${apex === 'localhost' ? 'http' : 'https'}://${apex}${path}`;
}
const SSO_ERRORS: Record<string, React.ReactNode> = {
not_admin: 'That account is not an admin.',
not_signed_in: "Single sign-on didn't finish. Try again.",
account_unavailable: 'The account linked to that sign-in is unavailable (deleted or banned).',
no_identity: "The sign-in provider didn't return an identity. Try again.",
provider_failed: 'The sign-in was cancelled or failed at the provider. Try again.',
};
export function Login() {
const navigate = useNavigate();
const location = useLocation();
const [params, setParams] = useSearchParams();
const from = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/';
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState<string | null>(null);
const [err, setErr] = useState<React.ReactNode | null>(null);
const [loading, setLoading] = useState(false);
// Single sign-on through the server's OpenID Connect provider, if set up.
const [sso, setSso] = useState<{ provider: string } | null>(null);
const ssoHandled = useRef(false);
const finish = (res: LoginResponse, to: string) => {
setSession(res.access_token, {
id: res.account_id,
username: res.username,
displayName: res.display_name,
});
notifyAuthChange();
navigate(to, { replace: true });
};
const errorText = (e: unknown, codes: Record<string, React.ReactNode>): React.ReactNode => {
if (e instanceof ApiError && e.body && typeof e.body === 'object' && 'error' in e.body) {
const code = (e.body as { error: string }).error;
return Object.hasOwn(codes, code) ? codes[code] : e.message;
}
return e instanceof Error ? e.message : String(e);
};
useEffect(() => {
fetch('/api/site/v1/account/me', { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((me) => { if (me?.signInAvailable) setSso({ provider: me.provider }); })
.catch(() => { /* no single sign-on: password only */ });
}, []);
// Back from the provider: ?sso=1 swaps the session for the admin token;
// ?signin_error=… explains why it didn't work.
useEffect(() => {
if (ssoHandled.current) return;
const signinError = params.get('signin_error');
if (signinError) {
ssoHandled.current = true;
setErr(signinError === 'not_linked'
? <>This sign-in isn't linked to a DorkNet account yet. Link it on the <a className="underline" href={siteUrl('/api/auth/external/login?returnUrl=%2Faccount')}>website</a> first, then sign in here again.</>
// Only known codes are shown: the query string is attacker-controllable.
: Object.hasOwn(SSO_ERRORS, signinError) ? SSO_ERRORS[signinError] : 'Single sign-on failed. Try again.');
setParams({}, { replace: true });
return;
}
if (params.get('sso') !== '1') return;
ssoHandled.current = true;
setLoading(true);
api<LoginResponse>('/sso')
.then((res) => finish(res, params.get('to') || '/'))
.catch((e) => { setErr(errorText(e, SSO_ERRORS)); setParams({}, { replace: true }); })
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -30,27 +99,19 @@ export function Login() {
method: 'POST',
body: { username, password },
});
setSession(res.access_token, {
id: res.account_id,
username: res.username,
displayName: res.display_name,
});
notifyAuthChange();
navigate(from, { replace: true });
finish(res, from);
} catch (e) {
if (e instanceof ApiError) {
if (e.body && typeof e.body === 'object' && 'error' in e.body) {
const code = (e.body as { error: string }).error;
if (code === 'not_admin') setErr('That account is not an admin.');
else if (code === 'invalid_credentials') setErr('Invalid username or password.');
else setErr(e.message);
} else setErr(e.message);
} else setErr(String(e));
setErr(errorText(e, {
not_admin: 'That account is not an admin.',
invalid_credentials: 'Invalid username or password.',
}));
} finally {
setLoading(false);
}
};
const ssoHref = `/api/auth/external/login?returnUrl=${encodeURIComponent(`/login?sso=1&to=${encodeURIComponent(from)}`)}`;
return (
<div className="min-h-full flex items-center justify-center px-4 py-12 bg-[linear-gradient(135deg,#0a0b0e_0%,#0d1d25_48%,#131418_100%)]">
<div className="w-full max-w-sm">
@@ -92,6 +153,14 @@ export function Login() {
<button type="submit" disabled={loading} className="btn-primary mt-1">
{loading ? 'Signing in…' : 'Sign in'}
</button>
{sso && (
<>
<div className="flex items-center gap-3 text-[11px] uppercase tracking-wide text-ink-500">
<span className="h-px flex-1 bg-ink-800" />or<span className="h-px flex-1 bg-ink-800" />
</div>
<a href={ssoHref} className="btn-secondary text-center">Sign in with {sso.provider}</a>
</>
)}
</form>
<p className="mt-6 text-center text-[11px] text-ink-500">DorkNet private server · admin console</p>
+98
View File
@@ -8,6 +8,11 @@
"name": "dorknet-site",
"version": "0.1.0",
"dependencies": {
"@yume-chan/adb": "2.6.4",
"@yume-chan/adb-credential-web": "2.1.0",
"@yume-chan/adb-daemon-webusb": "2.3.2",
"@yume-chan/android-bin": "2.1.0",
"@yume-chan/stream-extra": "2.6.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router": "^8.3.0"
@@ -1350,6 +1355,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/w3c-web-usb": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz",
"integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
@@ -1371,6 +1382,93 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/@yume-chan/adb": {
"version": "2.6.4",
"resolved": "https://registry.npmjs.org/@yume-chan/adb/-/adb-2.6.4.tgz",
"integrity": "sha512-aaN21ZOLjWW1nC09C21f0yQkCqxkuMWMqheryGEbmGLPhuzIyOhTurJK8cXh5ZnQ+ptdKBexqvoG6zYoul9XPA==",
"license": "MIT",
"dependencies": {
"@yume-chan/async": "^4.1.3",
"@yume-chan/event": "^2.0.0",
"@yume-chan/no-data-view": "^2.0.0",
"@yume-chan/stream-extra": "^2.6.1",
"@yume-chan/struct": "^2.3.2"
}
},
"node_modules/@yume-chan/adb-credential-web": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@yume-chan/adb-credential-web/-/adb-credential-web-2.1.0.tgz",
"integrity": "sha512-ps5XWt+xxm6Mi9eF80ePprPJ/uRFMhfR437betQE8hixrjdZpkZgMlqZU6GlnuebMi0djrbP8GWB49jJekJVYA==",
"license": "MIT",
"dependencies": {
"@yume-chan/adb": "^2.1.0"
}
},
"node_modules/@yume-chan/adb-daemon-webusb": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@yume-chan/adb-daemon-webusb/-/adb-daemon-webusb-2.3.2.tgz",
"integrity": "sha512-5ANaqsRJWFc5kKMnGALjYCrSXo5eC4u51dvYppvuBCLVjv12n7he2TPy5yEZ3XtlToRFS0kWCQC0XS8M60lRSA==",
"license": "MIT",
"dependencies": {
"@types/w3c-web-usb": "^1.0.12",
"@yume-chan/adb": "^2.3.1",
"@yume-chan/event": "^2.0.0",
"@yume-chan/stream-extra": "^2.1.0",
"@yume-chan/struct": "^2.3.2"
}
},
"node_modules/@yume-chan/android-bin": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@yume-chan/android-bin/-/android-bin-2.1.0.tgz",
"integrity": "sha512-is97IQwmSTmGCLe8GKO3rRDib2is6Xumo9KzeArgHwizIF0XnpCUm/xIVJLnpifR9PlMyLldDUttTZMTnaNivg==",
"license": "MIT",
"dependencies": {
"@yume-chan/adb": "^2.1.0",
"@yume-chan/stream-extra": "^2.1.0",
"@yume-chan/struct": "^2.0.1"
}
},
"node_modules/@yume-chan/async": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@yume-chan/async/-/async-4.1.3.tgz",
"integrity": "sha512-0vzhNJMkWUPyjKzUK4rqHEeCU6YQtF78RsB1kFRB6Y2BLupmEQNxcSb0mjKabPL9jZpCCiLa5KL8oTOJClUVaw==",
"license": "MIT"
},
"node_modules/@yume-chan/event": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@yume-chan/event/-/event-2.0.0.tgz",
"integrity": "sha512-z56MDOcX1QlgLUCuA6th3r10negVb7A3gzY//TwSC9ZOvzuRlrAqXcxZf1T3hHfNMk/NFO9RIgQgegXYSfaqLw==",
"license": "MIT",
"dependencies": {
"@yume-chan/async": "^4.0.2"
}
},
"node_modules/@yume-chan/no-data-view": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@yume-chan/no-data-view/-/no-data-view-2.0.0.tgz",
"integrity": "sha512-0GRJrrt6wtZlbiE92jocHOnaAvjQ+Y7xwwhwOPqLkwf90Kj1JIHJ5Zh4wJVQSQIkzfRSOpM+jeEQdC2K15snlA==",
"license": "MIT"
},
"node_modules/@yume-chan/stream-extra": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@yume-chan/stream-extra/-/stream-extra-2.6.1.tgz",
"integrity": "sha512-GQ+Q0SQf9p6Fl7ZK0UO5vHVGBJgkTJm/WXKurUMpjHQV4VUpjZ2hrQ744aCfT7WwSol7Bz/uEiXWmk240Hr/hg==",
"license": "MIT",
"dependencies": {
"@yume-chan/async": "^4.1.3",
"@yume-chan/struct": "^2.3.2"
}
},
"node_modules/@yume-chan/struct": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@yume-chan/struct/-/struct-2.3.2.tgz",
"integrity": "sha512-afoCnSKV+5HRK7e4innVd9YTYDyNWdjA1CVQa1j8rWYnmr7HGZfdkHMQr+AESLk6GxWFMzOIPQIG6nYOTGMFIw==",
"license": "MIT",
"dependencies": {
"@yume-chan/async": "^4.1.3",
"@yume-chan/no-data-view": "^2.0.0"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+5
View File
@@ -13,6 +13,11 @@
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@yume-chan/adb": "2.6.4",
"@yume-chan/adb-credential-web": "2.1.0",
"@yume-chan/adb-daemon-webusb": "2.3.2",
"@yume-chan/android-bin": "2.1.0",
"@yume-chan/stream-extra": "2.6.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router": "^8.3.0"
+11
View File
@@ -1,3 +1,4 @@
import { lazy, Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router';
import { Header } from './components/Header';
import { Home } from './pages/Home';
@@ -7,8 +8,14 @@ import { Players } from './pages/Players';
import { PlayerProfile } from './pages/PlayerProfile';
import { Rooms } from './pages/Rooms';
import { Join } from './pages/Join';
import { Downloads } from './pages/Downloads';
import { Link as LinkAccount } from './pages/Link';
import { Account } from './pages/Account';
import { NotFound } from './pages/NotFound';
// The Quest installer pulls in the WebUSB/ADB stack; load it only on /quest.
const Quest = lazy(() => import('./pages/Quest').then((m) => ({ default: m.Quest })));
export function App() {
return (
<div className="min-h-screen flex flex-col">
@@ -22,6 +29,10 @@ export function App() {
<Route path="/players/:id" element={<PlayerProfile />} />
<Route path="/rooms" element={<Rooms />} />
<Route path="/join" element={<Join />} />
<Route path="/quest" element={<Suspense fallback={null}><Quest /></Suspense>} />
<Route path="/downloads" element={<Downloads />} />
<Route path="/link" element={<LinkAccount />} />
<Route path="/account" element={<Account />} />
{/* Legacy paths from the vanilla-JS feed.localhost site */}
<Route path="/p/:id" element={<RedirectPhoto />} />
<Route path="/u/:id" element={<RedirectPlayer />} />
+62 -3
View File
@@ -1,6 +1,8 @@
import { NavLink, Link, useLocation, useNavigate } from 'react-router';
import { NavLink, Link, useLocation, useNavigate, useSearchParams } from 'react-router';
import { useEffect, useRef, useState } from 'react';
import { BrandMark } from './BrandMark';
import { getDownloads, SignInRequiredError } from '../lib/downloads';
import { errorCopy, getMe, signInUrl, signOut, SIGNIN_ERRORS, type AccountMe } from '../lib/account';
const NAV = [
{ to: '/', label: 'Home', exact: true },
@@ -8,7 +10,10 @@ const NAV = [
{ to: '/players', label: 'Players' },
{ to: '/rooms', label: 'Rooms' },
{ to: '/join', label: 'Join' },
{ to: '/quest', label: 'Quest' },
];
// Shown only when the server offers downloads (DorkNet:Downloads:BaseUrl).
const DOWNLOADS = { to: '/downloads', label: 'Downloads', exact: false };
export function Header() {
const [open, setOpen] = useState(false);
@@ -16,6 +21,39 @@ export function Header() {
const nav = useNavigate();
const loc = useLocation();
const inputRef = useRef<HTMLInputElement>(null);
const [hasDownloads, setHasDownloads] = useState(false);
const navItems = hasDownloads ? [...NAV, DOWNLOADS] : NAV;
const [me, setMe] = useState<AccountMe | null>(null);
const [params, setParams] = useSearchParams();
const signinError = params.get('signin_error');
useEffect(() => {
getDownloads().then((m) => setHasDownloads(m !== null))
.catch((e) => setHasDownloads(e instanceof SignInRequiredError));
getMe().then(setMe).catch(() => { /* site works without sign-in */ });
}, []);
const [signOutError, setSignOutError] = useState<string | null>(null);
const doSignOut = async () => {
setSignOutError(null);
try {
await signOut();
window.location.assign('/');
} catch (e) {
setSignOutError((e as Error).message);
}
};
// Sign in / account, shown only when the server has a sign-in provider.
const account = !me?.signInAvailable ? null : me.signedIn && me.player ? (
<span className="flex items-center gap-2 text-sm">
<Link to="/account" className="text-ink-200 hover:text-ink-50">{me.player.username}</Link>
<button type="button" onClick={doSignOut} className="btn-ghost !px-2 !py-1 text-xs">Sign out</button>
</span>
) : (
<a href={signInUrl()} className="btn-secondary !px-3 !py-1.5 text-sm">Sign in</a>
);
// Close the mobile drawer on route change so tapping a nav item
// doesn't leave a half-open overlay covering the page.
@@ -36,7 +74,7 @@ export function Header() {
</Link>
<nav className="hidden md:flex items-center gap-1 ml-2">
{NAV.map(n => (
{navItems.map(n => (
<NavLink
key={n.to}
to={n.to}
@@ -62,6 +100,8 @@ export function Header() {
/>
</form>
<div className="hidden md:flex shrink-0">{account}</div>
<button
type="button"
onClick={() => setOpen(o => !o)}
@@ -84,7 +124,7 @@ export function Header() {
/>
</form>
<nav className="flex flex-col gap-1">
{NAV.map(n => (
{navItems.map(n => (
<NavLink
key={n.to}
to={n.to}
@@ -97,6 +137,25 @@ export function Header() {
</NavLink>
))}
</nav>
{account && <div className="pt-1">{account}</div>}
</div>
</div>
)}
{signOutError && (
<div className="border-t border-danger/30 bg-danger/10">
<div className="mx-auto max-w-6xl px-4 sm:px-6 py-2 flex items-center justify-between gap-3 text-sm text-danger">
<span>Couldn't sign out ({signOutError}). You're still signed in; try again.</span>
<button type="button" className="btn-ghost !px-2 !py-1 text-xs" onClick={() => setSignOutError(null)}>Dismiss</button>
</div>
</div>
)}
{signinError && (
<div className="border-t border-danger/30 bg-danger/10">
<div className="mx-auto max-w-6xl px-4 sm:px-6 py-2 flex items-center justify-between gap-3 text-sm text-danger">
<span>Sign-in didn't work: {errorCopy(SIGNIN_ERRORS, signinError, 'something went wrong. Try again.')}</span>
<button type="button" className="btn-ghost !px-2 !py-1 text-xs" onClick={() => { params.delete('signin_error'); setParams(params, { replace: true }); }}>Dismiss</button>
</div>
</div>
)}
+73
View File
@@ -0,0 +1,73 @@
// Website sign-in through the server's OpenID Connect provider
// (ExternalSignInController). The session is an HttpOnly cookie on this host,
// so there is no token to keep here: same-origin fetches carry it.
import { get, post } from './api';
export interface AccountMe {
signInAvailable: boolean;
provider: string;
signedIn: boolean;
player: { id: string; username: string; email: string } | null;
pendingLink: { email: string } | null;
}
let cached: Promise<AccountMe> | null = null;
/** Who is signed in. Fetched once per page load; `fresh` re-reads it. */
export function getMe(fresh = false): Promise<AccountMe> {
if (!cached || fresh) cached = get<AccountMe>('/account/me').catch((e) => { cached = null; throw e; });
return cached;
}
/** `url` if it is a path on this site, else `/` (the server applies the same
* rule): never navigate to an address taken from the query string otherwise. */
export function localPath(url: string | null | undefined): string {
if (!url || !url.startsWith('/')) return '/';
// Parse it the way the browser will (it drops tabs and newlines and reads
// \ as /) and keep it only if it stays on this origin.
try {
const u = new URL(url, window.location.origin);
return u.origin === window.location.origin ? u.pathname + u.search + u.hash : '/';
} catch {
return '/';
}
}
/** Starts sign-in at the provider; comes back to `returnUrl` on this site. */
export function signInUrl(returnUrl = window.location.pathname + window.location.search): string {
return `/api/auth/external/login?returnUrl=${encodeURIComponent(returnUrl)}`;
}
// Each of these changes who is signed in, so the cached answer is dropped.
async function changesAccount<T>(request: Promise<T>): Promise<T> {
const result = await request;
cached = null;
return result;
}
export const link = (username: string, password: string) =>
changesAccount(post<{ linked: boolean; username: string }>('/account/link', { username, password }));
export const signOut = () => changesAccount(post('/account/logout', {}));
export const unlink = () => changesAccount(post('/account/unlink', {}));
// Server error code -> friendly copy.
export const LINK_ERRORS: Record<string, string> = {
invalid_credentials: "That username and password don't match a DorkNet account.",
missing_credentials: 'Enter your DorkNet username and password.',
too_many_attempts: 'Too many wrong attempts. Wait 15 minutes and try again.',
banned: 'That account is banned.',
identity_linked_to_another_account: 'This sign-in is already linked to a different DorkNet account.',
no_pending_identity: 'Your sign-in expired. Start again with "Sign in".',
};
/** Friendly copy for a known error code, else `fallback`. For codes read from
* the query string, pass a fixed fallback: anyone can put text in a link. */
export function errorCopy(map: Record<string, string>, code: string | null | undefined, fallback: string): string {
return code && Object.hasOwn(map, code) ? map[code] : fallback;
}
export const SIGNIN_ERRORS: Record<string, string> = {
account_unavailable: 'The account linked to this sign-in is unavailable (deleted or banned).',
no_identity: "The sign-in provider didn't return an identity. Try again.",
provider_failed: 'The sign-in was cancelled or failed at the provider. Try again.',
};
+3 -3
View File
@@ -1,7 +1,7 @@
// Lightweight fetch wrapper for the public-facing site. Talks to the
// apex-hosted PublicSiteController at /api/site/v1/*. Anonymous-only —
// the public site has no login flow, so there's no Authorization
// header or token plumbing.
// apex-hosted PublicSiteController at /api/site/v1/*. No Authorization
// header or token plumbing: the optional sign-in (lib/account.ts) is an
// HttpOnly cookie that same-origin fetches send on their own.
const BASE = '/api/site/v1';
+66
View File
@@ -0,0 +1,66 @@
// The client builds CI publishes (ci/downloads/publish.py), served through
// the server's DownloadsController. The manifest keeps the snake_case keys
// publish.py writes.
import { ApiError, get } from './api';
export interface DownloadFile {
id: string; // pc-full | pc-patch | quest | quest-installer
platform: 'pc' | 'quest' | string;
name: string;
path: string;
size: number;
sha256: string;
build?: string;
commit?: string;
built_at?: string;
}
export interface DownloadRelease {
id: string;
title: string;
game_version?: string;
pc_exe?: string;
files: DownloadFile[];
}
export interface DownloadsManifest {
releases: DownloadRelease[];
updated?: string;
}
/** The server only serves downloads to signed-in players
* (DorkNet:Downloads:RequireSignIn) and nobody is signed in. */
export class SignInRequiredError extends Error {
constructor() { super('sign_in_required'); this.name = 'SignInRequiredError'; }
}
let cached: Promise<DownloadsManifest | null> | null = null;
/** The manifest, or null when this server doesn't offer downloads. Fetched
* once per page load; pass `fresh` to re-read it. */
export function getDownloads(fresh = false): Promise<DownloadsManifest | null> {
if (!cached || fresh) {
cached = get<DownloadsManifest>('/downloads').catch((e) => {
if (e instanceof ApiError && e.status === 404) return null;
if (e instanceof ApiError && e.status === 401) { cached = null; throw new SignInRequiredError(); }
cached = null;
throw e;
});
}
return cached;
}
export const fileUrl = (f: DownloadFile) =>
`/api/site/v1/downloads/files/${f.path.split('/').map(encodeURIComponent).join('/')}`;
export interface QuestBuild { release: DownloadRelease; file: DownloadFile }
/** A published Quest package: the one at `path` when given, otherwise the
* newest release's. Null when there's no match. */
export function questBuild(m: DownloadsManifest | null, path?: string | null): QuestBuild | null {
for (const release of m?.releases ?? []) {
const file = release.files.find((f) => f.id === 'quest' && (!path || f.path === path));
if (file) return { release, file };
}
return null;
}
+142
View File
@@ -0,0 +1,142 @@
// Install a DorkNet .xapk onto a Meta Quest from the browser over WebUSB, using
// Tango (@yume-chan/adb). Mirrors DorkNet.Quest/scripts/installer/install.ps1:
// install the APK, copy each OBB to its install path (skipped when an identical
// size is already there), then launch.
import { Adb, AdbDaemonTransport } from '@yume-chan/adb';
import { AdbDaemonWebUsbDevice, AdbDaemonWebUsbDeviceManager } from '@yume-chan/adb-daemon-webusb';
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
import { PackageManager } from '@yume-chan/android-bin';
import type { ReadableStream as TangoReadableStream } from '@yume-chan/stream-extra';
import { entryStream, type Xapk } from './xapk';
export const webUsbSupported = () => typeof navigator !== 'undefined' && 'usb' in navigator && !!AdbDaemonWebUsbDeviceManager.BROWSER;
export interface Headset {
adb: Adb;
model: string;
serial: string;
}
export type Phase = 'app' | 'data' | 'launch';
export interface Progress {
phase: Phase;
label: string;
done: number;
total: number;
}
const credentialStore = new AdbWebCredentialStore('DorkNet Quest installer');
export async function connectHeadset(): Promise<Headset> {
const manager = AdbDaemonWebUsbDeviceManager.BROWSER;
if (!manager) throw new Error('This browser does not support WebUSB. Use Chrome or Edge on a computer.');
const device = await manager.requestDevice();
if (!device) throw new Error('No headset selected.');
let connection;
try {
connection = await device.connect();
} catch (e) {
if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) {
throw new Error('The headset is in use by another program. Close SideQuest, Meta Quest Developer Hub or adb (run "adb kill-server"), then try again.');
}
throw e;
}
const transport = await AdbDaemonTransport.authenticate({
serial: device.serial,
connection,
credentialStore,
});
const adb = new Adb(transport);
const model = (await adb.getProp('ro.product.model')).trim() || device.name;
return { adb, model, serial: device.serial };
}
function counted(stream: ReadableStream<Uint8Array>, onBytes: (n: number) => void): TangoReadableStream<Uint8Array> {
const counter = new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
onBytes(chunk.byteLength);
controller.enqueue(chunk);
},
});
return stream.pipeThrough(counter) as unknown as TangoReadableStream<Uint8Array>;
}
async function remoteSize(adb: Adb, path: string): Promise<number | null> {
const sync = await adb.sync();
try {
const st = await sync.lstat(path);
return Number(st.size);
} catch {
return null;
} finally {
await sync.dispose();
}
}
/**
* Install the package. `confirmUninstall` is asked before removing an existing
* copy signed with a different key (that clears its local data).
*/
export async function installXapk(
headset: Headset,
xapk: Xapk,
onProgress: (p: Progress) => void,
confirmUninstall: (pkg: string) => Promise<boolean>,
): Promise<void> {
const { adb } = headset;
const pkg = xapk.manifest.package_name;
const pm = new PackageManager(adb);
// ---- APK ----
const installApk = async () => {
let done = 0;
const total = xapk.apk.size;
onProgress({ phase: 'app', label: 'Installing the app', done, total });
const stream = counted(await entryStream(xapk.source, xapk.apk), (n) => {
done += n;
onProgress({ phase: 'app', label: 'Installing the app', done, total });
});
await pm.installStream(total, stream);
};
try {
await installApk();
} catch (e) {
const msg = String((e as Error)?.message ?? e);
if (!/INSTALL_FAILED_UPDATE_INCOMPATIBLE|signatures do not match/i.test(msg)) throw e;
if (!(await confirmUninstall(pkg))) throw new Error('Cancelled.');
await pm.uninstall(pkg);
await installApk();
}
// ---- game data (OBB) ----
for (const { entry, installPath } of xapk.expansions) {
const dest = `/sdcard/${installPath}`;
const total = entry.size;
if ((await remoteSize(adb, dest)) === total) {
onProgress({ phase: 'data', label: 'Game data already on the headset', done: total, total });
continue;
}
const dir = dest.substring(0, dest.lastIndexOf('/'));
await adb.subprocess.noneProtocol.spawnWaitText(['mkdir', '-p', dir]);
let done = 0;
onProgress({ phase: 'data', label: 'Copying game data', done, total });
const stream = counted(await entryStream(xapk.source, entry), (n) => {
done += n;
onProgress({ phase: 'data', label: 'Copying game data', done, total });
});
const sync = await adb.sync();
try {
await sync.write({ filename: dest, file: stream, permission: 0o660 });
} finally {
await sync.dispose();
}
}
// ---- launch ----
onProgress({ phase: 'launch', label: 'Starting the game', done: 1, total: 1 });
await adb.subprocess.noneProtocol.spawnWaitText(['monkey', '-p', pkg, '-c', 'android.intent.category.LAUNCHER', '1']);
}
+187
View File
@@ -0,0 +1,187 @@
// Read a DorkNet .xapk (zip) without loading it into memory, either from a
// File the player picked or from a URL that answers HTTP Range requests (the
// server's latest published build). DorkNet.Quest/scripts/build.py stores the
// APK and the OBB uncompressed, so those entries are plain byte ranges that
// stream straight through; manifest.json is deflated and goes through
// DecompressionStream. Handles zip64 so packages over 4 GB still open.
/** Random access to the package's bytes; `end` is exclusive. */
export interface ByteSource {
name: string;
size: number;
read(start: number, end: number): Promise<ArrayBuffer>;
stream(start: number, end: number): Promise<ReadableStream<Uint8Array<ArrayBuffer>>>;
}
export function fileSource(file: File): ByteSource {
return {
name: file.name,
size: file.size,
read: (start, end) => file.slice(start, end).arrayBuffer(),
stream: async (start, end) => file.slice(start, end).stream(),
};
}
/** A package on the server. `size` comes from the downloads manifest; every
* read is one Range request. */
export function urlSource(url: string, name: string, size: number): ByteSource {
const get = async (start: number, end: number): Promise<Response> => {
let res: Response;
try {
res = await fetch(url, { headers: { Range: `bytes=${start}-${end - 1}` }, cache: 'no-store' });
} catch {
// A cross-origin redirect (an expired sign-in at an access proxy) also lands here.
throw new Error("Couldn't reach the server. Reload the page (you may need to sign in again) and try again.");
}
if (res.status !== 206) {
throw new Error(res.status === 200
? "The server doesn't support partial downloads, so the package can't be read in place."
: `Downloading the package failed (HTTP ${res.status}).`);
}
return res;
};
const empty = () => new ReadableStream<Uint8Array<ArrayBuffer>>({ start: (c) => c.close() });
return {
name,
size,
read: async (start, end) => (end <= start ? new ArrayBuffer(0) : (await get(start, end)).arrayBuffer()),
stream: async (start, end) => {
if (end <= start) return empty();
const body = (await get(start, end)).body;
if (!body) throw new Error('The server sent an empty response.');
return body;
},
};
}
export interface XapkEntry {
name: string;
method: number; // 0 = stored, 8 = deflate
compressedSize: number;
size: number;
localHeaderOffset: number;
}
export interface XapkExpansion {
file: string;
install_path: string;
}
export interface XapkManifest {
package_name: string;
name?: string;
version_code?: string;
version_name?: string;
expansions?: XapkExpansion[];
}
export interface Xapk {
source: ByteSource;
manifest: XapkManifest;
entries: Map<string, XapkEntry>;
apk: XapkEntry;
expansions: { entry: XapkEntry; installPath: string }[];
}
const EOCD = 0x06054b50;
const ZIP64_LOCATOR = 0x07064b50;
const ZIP64_EOCD = 0x06064b50;
const CENTRAL = 0x02014b50;
const LOCAL = 0x04034b50;
async function bytes(src: ByteSource, start: number, end: number): Promise<DataView> {
return new DataView(await src.read(start, end));
}
function u64(v: DataView, at: number): number {
return Number(v.getBigUint64(at, true));
}
export async function openXapk(src: ByteSource): Promise<Xapk> {
// End of central directory: last 22 bytes plus up to 64 KiB of comment.
const tailStart = Math.max(0, src.size - (22 + 0xffff + 20));
const tail = await bytes(src, tailStart, src.size);
let eocd = -1;
for (let i = tail.byteLength - 22; i >= 0; i--) {
if (tail.getUint32(i, true) === EOCD) { eocd = i; break; }
}
if (eocd < 0) throw new Error('This is not a zip/.xapk file.');
let count = tail.getUint16(eocd + 10, true);
let cdSize = tail.getUint32(eocd + 12, true);
let cdOffset = tail.getUint32(eocd + 16, true);
if (cdOffset === 0xffffffff || count === 0xffff || cdSize === 0xffffffff) {
const loc = eocd - 20;
if (loc < 0 || tail.getUint32(loc, true) !== ZIP64_LOCATOR) throw new Error('Unsupported zip64 layout.');
const z64At = u64(tail, loc + 8);
const z64 = await bytes(src, z64At, z64At + 56);
if (z64.getUint32(0, true) !== ZIP64_EOCD) throw new Error('Unsupported zip64 layout.');
count = u64(z64, 32);
cdSize = u64(z64, 40);
cdOffset = u64(z64, 48);
}
const cd = await bytes(src, cdOffset, cdOffset + cdSize);
const entries = new Map<string, XapkEntry>();
const decoder = new TextDecoder();
let p = 0;
for (let n = 0; n < count; n++) {
if (cd.getUint32(p, true) !== CENTRAL) throw new Error('Corrupt zip directory.');
const method = cd.getUint16(p + 10, true);
let compressedSize = cd.getUint32(p + 20, true);
let size = cd.getUint32(p + 24, true);
const nameLen = cd.getUint16(p + 28, true);
const extraLen = cd.getUint16(p + 30, true);
const commentLen = cd.getUint16(p + 32, true);
let localHeaderOffset = cd.getUint32(p + 42, true);
const name = decoder.decode(new Uint8Array(cd.buffer, cd.byteOffset + p + 46, nameLen));
// zip64 extended info: present fields appear in this order, only for
// values saturated in the fixed header.
let e = p + 46 + nameLen;
const extraEnd = e + extraLen;
while (e + 4 <= extraEnd) {
const id = cd.getUint16(e, true);
const len = cd.getUint16(e + 2, true);
if (id === 0x0001) {
let q = e + 4;
if (size === 0xffffffff) { size = u64(cd, q); q += 8; }
if (compressedSize === 0xffffffff) { compressedSize = u64(cd, q); q += 8; }
if (localHeaderOffset === 0xffffffff) { localHeaderOffset = u64(cd, q); }
}
e += 4 + len;
}
entries.set(name, { name, method, compressedSize, size, localHeaderOffset });
p += 46 + nameLen + extraLen + commentLen;
}
const manifestEntry = entries.get('manifest.json');
if (!manifestEntry) throw new Error('Not an .xapk: manifest.json is missing.');
const manifest = JSON.parse(await new Response(await entryStream(src, manifestEntry)).text()) as XapkManifest;
if (!manifest.package_name) throw new Error('manifest.json has no package_name.');
const apk = entries.get(`${manifest.package_name}.apk`)
?? [...entries.values()].find((x) => x.name.endsWith('.apk'));
if (!apk) throw new Error('The .xapk contains no APK.');
const expansions = (manifest.expansions ?? []).map((x) => {
const entry = entries.get(x.file);
if (!entry) throw new Error(`The .xapk is missing ${x.file}.`);
return { entry, installPath: x.install_path };
});
return { source: src, manifest, entries, apk, expansions };
}
/** The entry's uncompressed bytes as a stream. */
export async function entryStream(src: ByteSource, entry: XapkEntry): Promise<ReadableStream<Uint8Array<ArrayBuffer>>> {
const local = await bytes(src, entry.localHeaderOffset, entry.localHeaderOffset + 30);
if (local.getUint32(0, true) !== LOCAL) throw new Error(`Corrupt entry ${entry.name}.`);
const start = entry.localHeaderOffset + 30 + local.getUint16(26, true) + local.getUint16(28, true);
const raw = await src.stream(start, start + entry.compressedSize);
if (entry.method === 0) return raw;
if (entry.method === 8) return raw.pipeThrough(new DecompressionStream('deflate-raw'));
throw new Error(`Unsupported compression in ${entry.name}.`);
}
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { Link as RouterLink } from 'react-router';
import { getMe, signInUrl, signOut, unlink, type AccountMe } from '../lib/account';
// The signed-in account: which DorkNet account and provider email are
// linked, plus sign out and unlink.
export function Account() {
const [me, setMe] = useState<AccountMe | null>(null);
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
getMe(true).then(setMe).catch((e) => setErr((e as Error).message));
}, []);
const run = async (action: () => Promise<unknown>) => {
setBusy(true);
try {
await action();
window.location.assign('/');
} catch (e) {
setErr((e as Error).message);
setBusy(false);
}
};
if (err) return <div className="max-w-lg mx-auto rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{err}</div>;
if (!me) return <p className="max-w-lg mx-auto text-sm text-ink-400">Loading…</p>;
if (!me.signedIn || !me.player) {
return (
<div className="max-w-lg mx-auto card !p-6 space-y-3 text-sm text-ink-300">
<h1 className="text-xl font-semibold text-ink-50">Account</h1>
<p>You're not signed in.</p>
{me.signInAvailable
? <a className="btn-primary inline-block" href={signInUrl('/account')}>Sign in with {me.provider}</a>
: <p className="text-ink-400">Sign-in isn't set up on this server.</p>}
</div>
);
}
return (
<div className="max-w-lg mx-auto space-y-4">
<h1 className="text-2xl font-semibold text-ink-50">Account</h1>
<div className="card !p-6 space-y-3 text-sm">
<div className="flex justify-between gap-3">
<span className="text-ink-400">DorkNet account</span>
<RouterLink className="text-brand-300 hover:text-brand-200" to={`/players/${me.player.id}`}>{me.player.username}</RouterLink>
</div>
<div className="flex justify-between gap-3">
<span className="text-ink-400">Signed in with {me.provider}</span>
<span className="text-ink-200">{me.player.email || '—'}</span>
</div>
<div className="flex flex-wrap gap-2 pt-2">
<button type="button" className="btn-secondary" disabled={busy} onClick={() => run(signOut)}>Sign out</button>
<button type="button" className="btn-ghost text-danger" disabled={busy}
onClick={() => { if (window.confirm(`Unlink this sign-in from ${me.player!.username}? You'll need the account's password to link it again.`)) void run(unlink); }}>
Unlink
</button>
</div>
</div>
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { fileUrl, getDownloads, SignInRequiredError, type DownloadFile, type DownloadRelease, type DownloadsManifest } from '../lib/downloads';
import { signInUrl } from '../lib/account';
const fmtSize = (n: number) =>
n >= 2 ** 30 ? `${(n / 2 ** 30).toFixed(2)} GB` : n >= 2 ** 20 ? `${(n / 2 ** 20).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 2 ** 10))} KB`;
const fmtDate = (iso?: string) => {
if (!iso) return '';
const d = new Date(iso);
return Number.isNaN(+d) ? '' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
};
const PLATFORMS = [
{ id: 'pc', title: 'PC', hint: 'Windows, with or without a VR headset.' },
{ id: 'quest', title: 'Meta Quest', hint: 'Needs developer mode on your headset and a USB cable.' },
];
// What each file is for, in players' terms. Keyed by the ids publish.py writes.
function describe(f: DownloadFile, rel: DownloadRelease): { title: string; primary?: boolean; body: React.ReactNode } {
switch (f.id) {
case 'pc-full':
return {
title: 'Full game', primary: true,
body: <>Rec Room {rel.title} with DorkNet already set up. Unzip it anywhere and
run {rel.pc_exe ? <code className="text-ink-200">{rel.pc_exe}</code> : 'the game'}.</>,
};
case 'pc-patch':
return {
title: 'Patch only',
body: <>Just the DorkNet part (MelonLoader and the DorkNet mod), for a copy of the game you already have. The
DorkNet launcher installs this for you; skip it if you got the full game.</>,
};
case 'quest':
return {
title: 'Quest app', primary: true,
body: <>Install it straight from this site: plug in your headset and use the <Link className="text-brand-300 hover:text-brand-200" to="/quest">Quest
page</Link> (Chrome or Edge). Or download it for SideQuest.</>,
};
case 'quest-installer':
return {
title: 'Install scripts',
body: <>Prefer the command line? Unzip these next to the Quest app and run <code className="text-ink-200">install.cmd</code> (Windows)
or <code className="text-ink-200">install.sh</code> (macOS / Linux). Needs adb.</>,
};
default:
return { title: f.name, body: null };
}
}
function FileCard({ f, rel }: { f: DownloadFile; rel: DownloadRelease }) {
const d = describe(f, rel);
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(f.sha256);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch { /* clipboard blocked: the full hash is in the tooltip */ }
};
return (
<article className={`card !p-5 flex flex-col gap-3 ${d.primary ? 'border-brand-500/50 ring-2 ring-brand-500/15' : ''}`}>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-ink-50">{d.title}</h3>
{d.primary && <span className="badge bg-brand-500/15 text-brand-200">Start here</span>}
</div>
<p className="text-sm text-ink-300">{d.body}</p>
<div className="font-mono text-xs text-ink-400 break-all">{f.name}</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-ink-400">
<span>{fmtSize(f.size)}</span>
{f.built_at && <span>Updated {fmtDate(f.built_at)}</span>}
{f.build && <span>Build {f.build}</span>}
</div>
<div className="mt-auto flex flex-wrap items-center gap-2 pt-1">
{f.id === 'quest' ? (
<>
<Link className="btn-primary" to={`/quest?file=${encodeURIComponent(f.path)}`}>Install on Quest</Link>
<a className="btn-secondary" href={fileUrl(f)} download={f.name}>Download</a>
</>
) : (
<a className={d.primary ? 'btn-primary' : 'btn-secondary'} href={fileUrl(f)} download={f.name}>Download</a>
)}
<button
type="button"
onClick={copy}
title={`Copy SHA-256: ${f.sha256}`}
className={`rounded-md border border-dashed px-2 py-1 font-mono text-xs transition-colors ${
copied ? 'border-success text-success' : 'border-ink-700 text-ink-400 hover:text-ink-100'}`}
>
{copied ? 'Copied' : `SHA-256 ${f.sha256.slice(0, 12)}…`}
</button>
</div>
</article>
);
}
export function Downloads() {
const [m, setM] = useState<DownloadsManifest | null | undefined>(undefined);
const [err, setErr] = useState<string | null>(null);
const [signInNeeded, setSignInNeeded] = useState(false);
useEffect(() => {
getDownloads(true).then(setM).catch((e) => {
if (e instanceof SignInRequiredError) setSignInNeeded(true);
else setErr((e as Error).message);
});
}, []);
const releases = (m?.releases ?? []).filter((r) => r.files.length);
return (
<div className="max-w-4xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-semibold text-ink-50">Downloads</h1>
<p className="text-sm text-ink-400">
Rec Room for DorkNet, built from the DorkNet source by the build server. The SHA-256 next to each file
lets you check your download.
</p>
</div>
{err && <div className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">Couldn't load the builds: {err}</div>}
{signInNeeded && (
<div className="card !p-5 flex flex-wrap items-center justify-between gap-3 text-sm text-ink-300">
<span>Sign in with your DorkNet account to see and download the builds.</span>
<a className="btn-primary" href={signInUrl('/downloads')}>Sign in</a>
</div>
)}
{m === undefined && !err && !signInNeeded && <p className="text-sm text-ink-400">Loading builds…</p>}
{m === null && <p className="text-sm text-ink-400">This server doesn't offer downloads.</p>}
{m && !releases.length && <p className="text-sm text-ink-400">No builds have been published yet.</p>}
{releases.map((rel) => (
<section key={rel.id} className="space-y-5">
<div className="flex flex-wrap items-baseline gap-x-3">
<h2 className="text-lg font-semibold text-ink-50">Rec Room {rel.title}</h2>
{rel.game_version && <span className="text-xs text-ink-400">Game build of {fmtDate(`${rel.game_version}T12:00:00Z`)}</span>}
</div>
{PLATFORMS.map((p) => {
const files = rel.files.filter((f) => f.platform === p.id);
if (!files.length) return null;
return (
<div key={p.id} className="space-y-2">
<div>
<h3 className="label">{p.title}</h3>
<p className="text-xs text-ink-400">{p.hint}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{files.map((f) => <FileCard key={f.id} f={f} rel={rel} />)}
</div>
</div>
);
})}
</section>
))}
{releases.length > 0 && (
<p className="border-t border-ink-800 pt-4 text-xs text-ink-400">
These builds contain Rec Room's own game files. They're for this community only: please don't re-share them.
</p>
)}
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { useEffect, useState } from 'react';
import { Link as RouterLink, useNavigate, useSearchParams } from 'react-router';
import { errorCopy, getMe, link, LINK_ERRORS, localPath, signInUrl, type AccountMe } from '../lib/account';
// After a first sign-in with the provider: prove which DorkNet account this
// identity belongs to, once, with that account's password.
export function Link() {
const [params] = useSearchParams();
const returnUrl = localPath(params.get('returnUrl'));
const navigate = useNavigate();
const [me, setMe] = useState<AccountMe | null>(null);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
getMe(true).then((m) => {
// Signed in with nothing waiting to be linked: nothing to do here. (A
// signed-in visitor can still link a new identity, e.g. a second
// account's.)
if (m.signedIn && !m.pendingLink) navigate(returnUrl, { replace: true });
else setMe(m);
}).catch((e) => setErr((e as Error).message));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
setErr(null);
try {
await link(username.trim(), password);
await getMe(true);
window.location.assign(returnUrl); // the header re-reads who is signed in
} catch (e) {
const key = (e as Error).message;
setErr(errorCopy(LINK_ERRORS, key, key));
} finally {
setBusy(false);
}
};
if (me && !me.pendingLink) {
return (
<div className="max-w-lg mx-auto card !p-6 space-y-3 text-sm text-ink-300">
<h1 className="text-xl font-semibold text-ink-50">Link your account</h1>
<p>Your sign-in expired before it was linked.</p>
{me.signInAvailable && <a className="btn-primary inline-block" href={signInUrl(returnUrl)}>Sign in again</a>}
</div>
);
}
return (
<div className="max-w-lg mx-auto space-y-4">
<div>
<h1 className="text-2xl font-semibold text-ink-50">Link your DorkNet account</h1>
<p className="text-sm text-ink-400">
You signed in{me?.pendingLink?.email ? <> as <span className="text-ink-200">{me.pendingLink.email}</span></> : null}.
Enter your DorkNet username and password once to connect them; after that, signing in is enough.
</p>
</div>
<form onSubmit={submit} className="card !p-6 space-y-4">
<div className="space-y-1.5">
<label className="label" htmlFor="username">DorkNet username</label>
<input id="username" className="input" autoComplete="username" required
value={username} onChange={(e) => setUsername(e.target.value)} />
</div>
<div className="space-y-1.5">
<label className="label" htmlFor="password">Password</label>
<input id="password" type="password" className="input" autoComplete="current-password" required
value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
{err && <div className="rounded-lg border border-danger/30 bg-danger/5 px-3 py-2 text-sm text-danger">{err}</div>}
<button type="submit" disabled={busy} className="btn-primary w-full">{busy ? 'Linking…' : 'Link account'}</button>
<p className="text-xs text-ink-500">
No account yet? <RouterLink className="text-brand-300 hover:text-brand-200" to="/join">Join with a signup code</RouterLink>, then come back here.
</p>
</form>
</div>
);
}

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