mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c531f856e2 | |||
| d3ca1f9297 | |||
| 75d187acf8 | |||
| e1fc786ac1 | |||
| 8f7fc09266 | |||
| e3fb9477b1 | |||
| d1b75aba57 | |||
| 8986b1301e | |||
| a9e08dbe0c | |||
| 3098593de5 | |||
| 0a76a4ec13 | |||
| 43b27e674a | |||
| f5ad4b59ef | |||
| 7852b410ee | |||
| 4f44505759 | |||
| 25662a864e | |||
| b6a0990afe | |||
| 71ea59db00 | |||
| f9a40ba788 | |||
| c366b6190a | |||
| 26786527cd | |||
| de98ea694f | |||
| a5f1d74a8e | |||
| 7d1ac82cf8 | |||
| 5452ff06ee | |||
| 5b98886937 | |||
| 7c974e39ab | |||
| 3aa0b55512 | |||
| 3039bebc6c | |||
| 65ca509fa8 | |||
| a5ae5da344 | |||
| b155ae5dbe | |||
| 446a23da2b | |||
| df9dac053d | |||
| ce0714ed1a | |||
| d1cc50622b | |||
| dc1e5436b5 | |||
| bcc5fe1bcb |
@@ -1,72 +0,0 @@
|
||||
# Keep the build context small + reproducible. Anything not strictly
|
||||
# needed for `dotnet restore && dotnet publish` is excluded.
|
||||
|
||||
# Build artefacts
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/*.user
|
||||
**/*.suo
|
||||
|
||||
# Dev-only configs (Local layer must NEVER ship into a container —
|
||||
# secrets are passed by env vars on Coolify).
|
||||
**/appsettings.Local.json
|
||||
**/appsettings.Development.json
|
||||
|
||||
# Local runtime data
|
||||
**/data/
|
||||
**/server.log
|
||||
**/*.log
|
||||
|
||||
# IDE noise
|
||||
.vs/
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Reverse-engineering scratch (not needed for production server)
|
||||
Cpp2ILOutput/
|
||||
Cpp2IL_CS/
|
||||
Cpp2IL_ISIL/
|
||||
Il2CppDump/
|
||||
RR_protos/
|
||||
RR_ProtobufDefinitions.zip
|
||||
GameAssembly.dll
|
||||
global-metadata.dat
|
||||
il2cpp.h
|
||||
dump.cs
|
||||
script.json
|
||||
RoomPersistenceManager_decompiled.txt
|
||||
|
||||
# Local TLS certs (mkcert) — never copy into the image
|
||||
*.pem
|
||||
hosts.dorknet.txt
|
||||
|
||||
# Tools that aren't part of the server runtime
|
||||
tools/
|
||||
Tools/
|
||||
|
||||
# Vite SPA build leftovers — node_modules is huge and the dist is
|
||||
# rebuilt fresh by the npm-build stage in the Dockerfile, so neither
|
||||
# should ship into the docker context. The source under admin-ui/src
|
||||
# and admin-ui/{package.json,vite.config.ts,...} is what we copy in.
|
||||
DorkNet.Server/admin-ui/node_modules/
|
||||
DorkNet.Server/admin-ui/dist/
|
||||
DorkNet.Server/admin-ui/.vite/
|
||||
DorkNet.Server/wwwroot/admin/assets/
|
||||
DorkNet.Server/wwwroot/admin/index.html
|
||||
DorkNet.Server/wwwroot/admin/*.map
|
||||
|
||||
# Top-level docs / planning
|
||||
README.md
|
||||
LICENSE
|
||||
NOTICE
|
||||
BUILD_PROPER_PLAN.md
|
||||
nginx.conf
|
||||
|
||||
# Other stages of the workflow
|
||||
*.md
|
||||
docs/
|
||||
@@ -0,0 +1,82 @@
|
||||
# Push / PR CI on `main` — fast feedback that the launcher + CLI
|
||||
# both still build, and that the CLI publishes for the cross-platform
|
||||
# RIDs the release workflow will package. Does not produce release
|
||||
# artifacts — only proves things compile.
|
||||
#
|
||||
# Runs on:
|
||||
# - every push to main
|
||||
# - every pull request targeting main
|
||||
# - manual workflow_dispatch (so you can re-run after a flake without
|
||||
# pushing an empty commit)
|
||||
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: "9.0.x"
|
||||
DOTNET_NOLOGO: "true"
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "true"
|
||||
|
||||
jobs:
|
||||
# ── Launcher needs Windows for the WPF target framework ────────────
|
||||
build-launcher:
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore launcher/DorkNet.Launcher.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build launcher/DorkNet.Launcher.csproj -c Release --no-restore
|
||||
|
||||
# A self-contained publish is the closest thing to "what we ship"
|
||||
# without invoking Inno Setup, which would require an extra
|
||||
# toolchain install for every CI run.
|
||||
- name: Publish (smoke)
|
||||
run: dotnet publish launcher/DorkNet.Launcher.csproj -c Release -r win-x64 --self-contained true --no-restore
|
||||
|
||||
# ── CLI builds on Ubuntu — proves the shared Backend/*.cs files
|
||||
# truly don't have Windows-only dependencies. The release pipeline
|
||||
# later publishes the matrix; here we just confirm restore + build
|
||||
# on the canonical Linux runner.
|
||||
build-cli:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
rid: [win-x64, linux-x64, linux-arm64, osx-x64, osx-arm64]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore cli/DorkNet.ServerCli/DorkNet.ServerCli.csproj
|
||||
|
||||
- name: Publish ${{ matrix.rid }}
|
||||
run: |
|
||||
dotnet publish cli/DorkNet.ServerCli/DorkNet.ServerCli.csproj \
|
||||
-c Release \
|
||||
-r ${{ matrix.rid }} \
|
||||
--self-contained true \
|
||||
--no-restore
|
||||
|
||||
- name: Smoke-test --help (host RID only)
|
||||
if: matrix.rid == 'linux-x64'
|
||||
run: |
|
||||
out=cli/DorkNet.ServerCli/bin/Release/net9.0/${{ matrix.rid }}/publish/dorknet-server
|
||||
chmod +x "$out"
|
||||
"$out" --help
|
||||
@@ -0,0 +1,221 @@
|
||||
# Tagged release pipeline for the host-side tooling on `main` —
|
||||
# the Windows GUI launcher (installer + portable) and the
|
||||
# cross-platform dorknet-server CLI. Triggers on a SemVer tag like
|
||||
# v0.2.0 (or v0.2.0-rc1 for pre-releases).
|
||||
#
|
||||
# Produces, on each release:
|
||||
# * dorknet-setup-<version>.exe Inno Setup installer
|
||||
# * dorknet.exe Portable single-file Windows build
|
||||
# * dorknet-server-win-x64.zip Cross-platform server CLI, per RID
|
||||
# * dorknet-server-linux-x64.tar.gz
|
||||
# * dorknet-server-linux-arm64.tar.gz
|
||||
# * dorknet-server-osx-x64.tar.gz
|
||||
# * dorknet-server-osx-arm64.tar.gz
|
||||
# * SHA256SUMS Checksums of all of the above
|
||||
#
|
||||
# All of these get attached to the GitHub release the tag creates.
|
||||
|
||||
name: release-launcher
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
- "v[0-9]+.[0-9]+.[0-9]+-*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to release (e.g. 0.2.0). Leave blank to use the workflow's git tag."
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: write # required to create / update GitHub releases
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: "9.0.x"
|
||||
DOTNET_NOLOGO: "true"
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "true"
|
||||
|
||||
jobs:
|
||||
# ── Resolve the version once and pass it to all downstream jobs ─────
|
||||
resolve-version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
steps:
|
||||
- id: v
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -n "${{ inputs.version }}" ]]; then
|
||||
v="${{ inputs.version }}"
|
||||
else
|
||||
v="${GITHUB_REF_NAME#v}"
|
||||
fi
|
||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||
echo "Releasing version: $v"
|
||||
|
||||
# ── Windows: GUI launcher (installer + portable) ─────────────────────
|
||||
build-launcher-windows:
|
||||
needs: resolve-version
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
|
||||
- name: Install Inno Setup
|
||||
run: choco install innosetup --no-progress -y
|
||||
|
||||
- name: Build installer + portable exe
|
||||
shell: pwsh
|
||||
run: |
|
||||
./installer/build-installer.ps1 -Version "${{ needs.resolve-version.outputs.version }}"
|
||||
|
||||
- name: Stage Windows artifacts
|
||||
shell: pwsh
|
||||
run: |
|
||||
$stage = "artifacts"
|
||||
New-Item -ItemType Directory -Force -Path $stage | Out-Null
|
||||
# Installer
|
||||
Copy-Item "installer/out/dorknet-setup-${{ needs.resolve-version.outputs.version }}.exe" $stage
|
||||
# Portable single-file build (same publish output the installer wraps)
|
||||
Copy-Item "launcher/bin/Release/net9.0-windows/win-x64/publish/dorknet.exe" $stage
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: launcher-windows
|
||||
path: artifacts/*
|
||||
|
||||
# ── Cross-platform server CLI, one RID per matrix entry ─────────────
|
||||
build-server-cli:
|
||||
needs: resolve-version
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- rid: win-x64
|
||||
os: windows-2022
|
||||
ext: zip
|
||||
- rid: linux-x64
|
||||
os: ubuntu-latest
|
||||
ext: tar.gz
|
||||
- rid: linux-arm64
|
||||
os: ubuntu-latest
|
||||
ext: tar.gz
|
||||
- rid: osx-x64
|
||||
os: macos-latest
|
||||
ext: tar.gz
|
||||
- rid: osx-arm64
|
||||
os: macos-latest
|
||||
ext: tar.gz
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
|
||||
- name: Publish dorknet-server for ${{ matrix.rid }}
|
||||
shell: bash
|
||||
run: |
|
||||
v="${{ needs.resolve-version.outputs.version }}"
|
||||
dotnet publish cli/DorkNet.ServerCli \
|
||||
-c Release \
|
||||
-r "${{ matrix.rid }}" \
|
||||
--self-contained true \
|
||||
-p:Version="$v" \
|
||||
-p:AssemblyVersion="${v}.0" \
|
||||
-p:FileVersion="${v}.0" \
|
||||
-p:InformationalVersion="$v"
|
||||
|
||||
- name: Package
|
||||
shell: bash
|
||||
run: |
|
||||
src="cli/DorkNet.ServerCli/bin/Release/net9.0/${{ matrix.rid }}/publish"
|
||||
out="artifacts"
|
||||
mkdir -p "$out"
|
||||
case "${{ matrix.ext }}" in
|
||||
zip)
|
||||
# Use PowerShell on Windows so the archive is consumable
|
||||
# by Windows Explorer + curl without weird zip flags.
|
||||
pwsh -Command "Compress-Archive -Path '$src/*' -DestinationPath '$out/dorknet-server-${{ matrix.rid }}.zip' -Force"
|
||||
;;
|
||||
tar.gz)
|
||||
# chmod +x first — single-file publish drops a binary
|
||||
# without the executable bit on macOS in some scenarios.
|
||||
chmod +x "$src"/dorknet-server || true
|
||||
tar -czf "$out/dorknet-server-${{ matrix.rid }}.tar.gz" -C "$src" .
|
||||
;;
|
||||
esac
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: server-cli-${{ matrix.rid }}
|
||||
path: artifacts/*
|
||||
|
||||
# ── Aggregate, checksum, attach to the release ──────────────────────
|
||||
release:
|
||||
needs: [resolve-version, build-launcher-windows, build-server-cli]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: List artifacts + compute SHA256SUMS
|
||||
run: |
|
||||
cd dist
|
||||
ls -la
|
||||
sha256sum * > SHA256SUMS
|
||||
cat SHA256SUMS
|
||||
|
||||
- name: Build release notes
|
||||
id: notes
|
||||
run: |
|
||||
v="${{ needs.resolve-version.outputs.version }}"
|
||||
{
|
||||
echo "## DorkNet launcher \`v$v\`"
|
||||
echo ""
|
||||
echo "Host-side release. For the matching server + client patcher per Rec Room build, see the per-version branch tags."
|
||||
echo ""
|
||||
echo "### Downloads"
|
||||
echo ""
|
||||
echo "| Platform | Use | Asset |"
|
||||
echo "| --- | --- | --- |"
|
||||
echo "| Windows | One-click installer (Start menu + auto-updates) | \`dorknet-setup-$v.exe\` |"
|
||||
echo "| Windows | Portable single-file launcher | \`dorknet.exe\` |"
|
||||
echo "| Linux x64 | Headless host CLI | \`dorknet-server-linux-x64.tar.gz\` |"
|
||||
echo "| Linux arm64 | Headless host CLI | \`dorknet-server-linux-arm64.tar.gz\` |"
|
||||
echo "| macOS Intel | Headless host CLI | \`dorknet-server-osx-x64.tar.gz\` |"
|
||||
echo "| macOS Apple Silicon | Headless host CLI | \`dorknet-server-osx-arm64.tar.gz\` |"
|
||||
echo "| Windows | Headless host CLI (same as GUI) | \`dorknet-server-win-x64.zip\` |"
|
||||
echo ""
|
||||
echo "Checksums in \`SHA256SUMS\`."
|
||||
echo ""
|
||||
echo "### Setup"
|
||||
echo ""
|
||||
echo "- Windows GUI: [easy-setup.md](https://github.com/DorkSquadRR/DorkNet/blob/main/docs/easy-setup.md)"
|
||||
echo "- Linux / macOS: [server-cli.md](https://github.com/DorkSquadRR/DorkNet/blob/main/docs/server-cli.md)"
|
||||
echo "- Photon setup: [photon-setup.md](https://github.com/DorkSquadRR/DorkNet/blob/main/docs/photon-setup.md)"
|
||||
} > notes.md
|
||||
|
||||
- name: Create / update GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Prerelease if the tag has a -suffix (e.g. v0.2.0-rc1).
|
||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||
body_path: notes.md
|
||||
generate_release_notes: false
|
||||
files: |
|
||||
dist/*
|
||||
# The installer registers HKCU\Software\DorkNet\Version on
|
||||
# install — that means the auto-updater sees this version
|
||||
# only after the tag actually exists on GitHub, so we let
|
||||
# the action create the release on first push of the tag.
|
||||
fail_on_unmatched_files: true
|
||||
@@ -1,55 +0,0 @@
|
||||
name: Release (march-2020-03-10)
|
||||
|
||||
# Auto-publishes dorknet-server + dorknet-clientpatch zips to a
|
||||
# GitHub Release on tag push. The launcher (on the public main
|
||||
# branch) fetches the most-recent tag matching its prefix and
|
||||
# downloads these assets, so the tag format MUST start with the
|
||||
# branch's release_tag_prefix declared in versions.json.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v1-march-*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: windows-latest
|
||||
|
||||
env:
|
||||
VERSION_KEY: march_2020_03_10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
# ── Server self-contained publish ────────────────────────────────
|
||||
- name: Publish DorkNet.Server (win-x64, self-contained)
|
||||
run: |
|
||||
dotnet publish DorkNet.Server/DorkNet.Server.csproj `
|
||||
-c Release -r win-x64 --self-contained `
|
||||
-p:PublishSingleFile=true `
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true `
|
||||
-p:EnableCompressionInSingleFile=true `
|
||||
-o publish/server
|
||||
|
||||
- name: Zip server publish
|
||||
run: |
|
||||
Compress-Archive -Path publish/server/* `
|
||||
-DestinationPath dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip
|
||||
|
||||
# ── Client patcher zip (built via tools/build-patcher-zip.ps1) ───
|
||||
- name: Build client patcher zip
|
||||
run: |
|
||||
.\tools\build-patcher-zip.ps1 -VersionKey ${{ env.VERSION_KEY }}
|
||||
|
||||
# ── Publish both as Release assets ───────────────────────────────
|
||||
- name: Upload Release assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip
|
||||
dist/dorknet-clientpatch-${{ env.VERSION_KEY }}.zip
|
||||
fail_on_unmatched_files: true
|
||||
+65
-62
@@ -1,67 +1,70 @@
|
||||
## .NET
|
||||
bin/
|
||||
obj/
|
||||
build/
|
||||
*.user
|
||||
*.suo
|
||||
# Build outputs
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
**/.vite/
|
||||
**/build/
|
||||
local-releases/
|
||||
**/installer/out/
|
||||
|
||||
# IDE
|
||||
.vs/
|
||||
.vscode/
|
||||
*.csproj.bak
|
||||
dist/
|
||||
local-releases/
|
||||
## Data / secrets
|
||||
# Local SQLite + uploaded blobs only — DON'T match the source-tree
|
||||
# DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on
|
||||
# case-insensitive filesystems. Use the precise runtime data dirs.
|
||||
DorkNet.Server/bin/Debug/net9.0/data/
|
||||
DorkNet.Server/bin/Release/net9.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.
|
||||
tmp/
|
||||
## OS
|
||||
*.user
|
||||
*.suo
|
||||
*.csproj.user
|
||||
|
||||
# Local config (NEVER commit)
|
||||
.claude/
|
||||
appsettings.json
|
||||
appsettings.Local.json
|
||||
appsettings.Development.json
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.backups
|
||||
# Runtime data
|
||||
data/*.db
|
||||
data/*.db-shm
|
||||
data/*.db-wal
|
||||
data/*.db.bak.*
|
||||
data/images/
|
||||
keys/
|
||||
**/MelonLoader/UserData/dorknet-clientmod.json
|
||||
**/MelonLoader/Latest.log
|
||||
**/MelonLoader/Logs/
|
||||
**/output_log.txt
|
||||
**/crash_reports/
|
||||
|
||||
# Decompilation dumps — never ship Rec Room IP derivative works
|
||||
Cpp2IL_ISIL/
|
||||
Cpp2IL_CS/
|
||||
**/Recroom_Release_Data/
|
||||
**/Recroom_Release.exe
|
||||
**/GameAssembly.dll
|
||||
**/global-metadata.dat
|
||||
**/version.dll
|
||||
|
||||
# TLS certs / private keys — never ship under any name
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.cer
|
||||
*.pfx
|
||||
*.p12
|
||||
*.jks
|
||||
*.asc
|
||||
*.gpg
|
||||
*.kdbx
|
||||
|
||||
# Maintainer-side scrub tool — operates ON this repo, must not be IN it
|
||||
tools/scrub-for-public.ps1
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
## Reverse-engineered artifacts (Il2CppDumper / Cpp2IL output, not for commit)
|
||||
dump.cs
|
||||
il2cpp.h
|
||||
script.json
|
||||
stringliteral.json
|
||||
Il2CppDump/
|
||||
Cpp2ILOutput/
|
||||
Cpp2IL_CS/
|
||||
Cpp2IL_ISIL/
|
||||
RoomPersistenceManager_decompiled.txt
|
||||
|
||||
## Per-machine editor / agent settings
|
||||
.claude/
|
||||
global-metadata.dat
|
||||
GameAssembly.dll
|
||||
_wildcard.rec.net+1.pem
|
||||
_wildcard.rec.net+1-key.pem
|
||||
dist$ZIPNAME.zip
|
||||
|
||||
## Vite admin SPA build output — derived from DorkNet.Server/admin-ui/
|
||||
## via `npm run build` (local) or the admin-ui Dockerfile stage (deploy).
|
||||
## Each build emits a new hash (assets/index-<hash>.js) so committing
|
||||
## these files leaves a stale index.html → JS hash mismatch the next
|
||||
## time someone rebuilds. Keep the source committed, regenerate output.
|
||||
DorkNet.Server/wwwroot/admin/index.html
|
||||
DorkNet.Server/wwwroot/admin/assets/
|
||||
DorkNet.Server/wwwroot/admin/*.map
|
||||
DorkNet.Server/admin-ui/node_modules/
|
||||
DorkNet.Server/admin-ui/dist/
|
||||
DorkNet.Server/admin-ui/.vite/
|
||||
DorkNet.Server/admin-ui/tsconfig.tsbuildinfo
|
||||
|
||||
## Public-facing site (Vite + React, DorkNet.Server/site/)
|
||||
## Same reasoning as admin-ui above: hashed output, regenerate per build.
|
||||
DorkNet.Server/wwwroot/site/index.html
|
||||
DorkNet.Server/wwwroot/site/assets/
|
||||
DorkNet.Server/wwwroot/site/*.map
|
||||
DorkNet.Server/site/node_modules/
|
||||
DorkNet.Server/site/dist/
|
||||
DorkNet.Server/site/.vite/
|
||||
DorkNet.Server/site/tsconfig.tsbuildinfo
|
||||
local-releases.zip
|
||||
# Test outputs
|
||||
TestResults/
|
||||
*.coverage
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
# Branches
|
||||
|
||||
DorkNet keeps one long-lived branch for each supported Rec Room client
|
||||
build. Each branch carries the server and client patcher for that
|
||||
build's wire protocol. Pick the branch that matches your install.
|
||||
|
||||
## Active branches
|
||||
|
||||
### `december-2020-12-18`
|
||||
|
||||
- **Client build:** Rec Room 2020.12.18
|
||||
- **Status:** active
|
||||
- **Notes:**
|
||||
- Single EF Initial migration; SQLite + Postgres both work out of the
|
||||
box.
|
||||
- Includes the chat-threads, clubs, playlists, weekly-challenge, and
|
||||
room-keys subsystems added late-2020.
|
||||
- Server's `Versions/Late2020VersionPlugin` is the active version
|
||||
plugin; client header `X-DorkNet-Version: december_2020_12_18`.
|
||||
|
||||
### `march-2020-03-10`
|
||||
|
||||
- **Client build:** Rec Room 2020.03.10 (also serves 2020.03.06)
|
||||
- **Status:** active
|
||||
- **Notes:**
|
||||
- Earlier wire shape — no clubs, no playlists, simpler store catalog.
|
||||
- Diverges from `december-2020-12-18` by ~170 files; cannot be
|
||||
unified with the plugin abstraction alone.
|
||||
|
||||
Setup starts the same way for both branches: pick the branch, then
|
||||
follow that branch's own deploy notes. See
|
||||
[docs/advanced-setup.md](docs/advanced-setup.md).
|
||||
|
||||
## How to pick
|
||||
|
||||
If you don't know which Rec Room build you have, check:
|
||||
|
||||
- **Steam**: right-click *Rec Room* → Properties → Local files →
|
||||
Browse → look at the `Recroom_Release_Data/StreamingAssets/version.txt`
|
||||
if present, or note the install timestamp.
|
||||
- **Manual install** (the typical DorkNet setup path): the build is in
|
||||
the folder name (`RecRoom-2020.12.18`, `RecRoom-2020.03.10`, etc.).
|
||||
- **Easy launcher**: auto-detects the build and picks the branch.
|
||||
|
||||
## Machine-readable manifest
|
||||
|
||||
[`versions.json`](versions.json) carries the same information in a
|
||||
format the Easy launcher and install scripts can read directly. When a
|
||||
new version branch lands, add it there too.
|
||||
|
||||
## Adding a new version branch
|
||||
|
||||
1. Land the server code on a new orphan branch named `<month>-<build>`
|
||||
(e.g. `august-2021-08-04`).
|
||||
2. Add a row to the active-branches list above.
|
||||
3. Add the matching entry to `versions.json`.
|
||||
4. Add a `docs/<month>-setup.md` setup guide.
|
||||
5. Tag a release on the new branch so the Easy launcher's
|
||||
release-fetcher picks it up.
|
||||
|
||||
Branches are intentionally long-lived — once a build is supported, its
|
||||
branch keeps getting patches against the same wire protocol. Don't
|
||||
rebase across branches; cherry-pick when a fix applies to multiple.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Contributing to DorkNet
|
||||
|
||||
Thanks for your interest. A few ground rules before you open a PR.
|
||||
|
||||
## Code style
|
||||
|
||||
- **C#**: nullable enabled. Match the file you're editing. No regions, no
|
||||
multi-paragraph XML docs, no async-without-await.
|
||||
- **TypeScript / React**: existing patterns — function components, named
|
||||
exports, tailwind classes, no CSS modules. On a version branch, lint
|
||||
with `npm run lint` from `DorkNet.Server/admin-ui/`.
|
||||
- **No AI-co-author trailers in commit messages.** Use a real attribution
|
||||
line or none at all.
|
||||
|
||||
## Branches and commits
|
||||
|
||||
- `main` is protected; PR from a feature branch.
|
||||
- Conventional commits welcome but not required. One-line summary should
|
||||
describe the *why*, not the *what* — the diff already shows what.
|
||||
|
||||
## Scope of contributions we welcome
|
||||
|
||||
- Bug fixes against documented behavior
|
||||
- Feature parity with the 2020 client (anything reverse-engineered cleanly)
|
||||
- Performance work
|
||||
- Better docs, screenshots, video walkthroughs
|
||||
- New translations of the admin UI
|
||||
|
||||
## Scope of contributions we don't accept
|
||||
|
||||
- **Rec Room game assets, decompiled source, or modified game binaries.**
|
||||
We never ship Rec Room IP. See [DISCLAIMER.md](DISCLAIMER.md). PRs that
|
||||
add these files will be closed.
|
||||
- **Cheats, exploits, or features that would harm other public Rec Room
|
||||
servers.** This project exists to let people self-host the 2020 backend
|
||||
for themselves; it isn't a vehicle for griefing live games.
|
||||
- **Crypto / NFT / monetization plugins.** Hard no.
|
||||
|
||||
## Reporting security issues
|
||||
|
||||
Don't open a public issue. Email the maintainer at the address in the repo
|
||||
metadata, or use GitHub's private vulnerability reporting.
|
||||
|
||||
## Setting up a dev environment
|
||||
|
||||
See [docs/advanced-setup.md](docs/advanced-setup.md) for the full server
|
||||
stack. The server and admin UI live on the version branches, not on
|
||||
`main`. For just hacking on the admin UI after you check out a version
|
||||
branch:
|
||||
|
||||
```bash
|
||||
cd DorkNet.Server/admin-ui
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Points at `http://localhost:5000` by default — start the server first.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Disclaimer
|
||||
|
||||
DorkNet is an independent, fan-made, clean-room reimplementation of
|
||||
backend protocols used by old **2020 Rec Room** builds. The supported
|
||||
client builds live on separate branches, currently March 2020 and
|
||||
December 2020.
|
||||
|
||||
It is **not** affiliated with, endorsed by, or sponsored by:
|
||||
|
||||
- Rec Room Inc.
|
||||
- Against Gravity (the original developer)
|
||||
- Photon (Exit Games GmbH)
|
||||
- Steam / Valve Corporation
|
||||
- Meta / Oculus
|
||||
|
||||
**Rec Room™**, the Rec Room logo, and all in-game art, audio, models,
|
||||
locations, characters, and brand assets are the intellectual property of
|
||||
Rec Room Inc. and are used here only for descriptive interoperability.
|
||||
|
||||
## What DorkNet does and doesn't ship
|
||||
|
||||
DorkNet's repository contains:
|
||||
- Original server code (ASP.NET Core)
|
||||
- Original admin tooling (React)
|
||||
- Original MelonLoader mod (C#)
|
||||
- Patcher scripts that operate on the user's own local Rec Room install
|
||||
|
||||
DorkNet's repository **does not** contain:
|
||||
- The Rec Room game executable, assets, or any redistributable Rec Room IP
|
||||
- Decompiled Rec Room source or IL2CPP dumps
|
||||
- Patched copies of `Recroom_Release.exe`, `global-metadata.dat`, or any
|
||||
Rec Room game file
|
||||
|
||||
You must acquire the matching 2020 Rec Room client yourself through
|
||||
whatever means are legal in your jurisdiction, for example SteamDB depot
|
||||
tools against an account that owns Rec Room. The patcher runs on your
|
||||
machine, modifies files locally, and never uploads the game anywhere.
|
||||
|
||||
## Use at your own risk
|
||||
|
||||
This is hobby software. There are no warranties of any kind. If running
|
||||
DorkNet violates a license you agreed to (e.g. Steam Subscriber Agreement,
|
||||
Rec Room Terms of Service), that's between you and the rights holder.
|
||||
DorkNet's authors take no responsibility for consequences arising from
|
||||
your use of the software.
|
||||
|
||||
## Takedown requests
|
||||
|
||||
If you represent Rec Room Inc. or another rights holder and wish to discuss
|
||||
this project, please open an issue on the public repository and we will
|
||||
respond promptly.
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
# 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.
|
||||
#
|
||||
# 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 NODE_VERSION=20
|
||||
|
||||
# ── admin SPA build stage ──────────────────────────────────────────
|
||||
# The Vite + React admin UI under DorkNet.Server/admin-ui builds in a
|
||||
# dedicated node image because the .NET SDK image doesn't ship npm.
|
||||
# Output is dropped into /wwwroot/admin and copied into the .NET build
|
||||
# stage just before `dotnet publish`, which is configured to skip its
|
||||
# own inline npm step via -p:SkipAdminUIBuild=true.
|
||||
FROM node:${NODE_VERSION}-alpine AS admin-ui
|
||||
WORKDIR /ui
|
||||
# Lockfile first for layer caching: when only application source
|
||||
# changes, the npm install step stays cached.
|
||||
COPY DorkNet.Server/admin-ui/package.json DorkNet.Server/admin-ui/package-lock.json* ./
|
||||
RUN npm install --no-fund --no-audit --loglevel=error
|
||||
# Now the source. vite.config.ts writes to ../wwwroot/admin/, which
|
||||
# from /ui is /wwwroot/admin/ — pre-create the parent so Vite's
|
||||
# emptyOutDir won't refuse to create above its cwd.
|
||||
COPY DorkNet.Server/admin-ui/ ./
|
||||
RUN mkdir -p /wwwroot/admin \
|
||||
&& npm run build \
|
||||
&& ls -la /wwwroot/admin \
|
||||
&& ls -la /wwwroot/admin/assets
|
||||
|
||||
# ── public-facing site build stage ─────────────────────────────────
|
||||
# Mirrors the admin-ui stage. Source under DorkNet.Server/site,
|
||||
# output emitted to /wwwroot/site for the .NET stage to copy.
|
||||
FROM node:${NODE_VERSION}-alpine AS site
|
||||
WORKDIR /ui
|
||||
COPY DorkNet.Server/site/package.json DorkNet.Server/site/package-lock.json* ./
|
||||
RUN npm install --no-fund --no-audit --loglevel=error
|
||||
COPY DorkNet.Server/site/ ./
|
||||
RUN mkdir -p /wwwroot/site \
|
||||
&& npm run build \
|
||||
&& ls -la /wwwroot/site
|
||||
|
||||
# ── build stage ────────────────────────────────────────────────────
|
||||
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy csproj files first so `dotnet restore` is cached when only
|
||||
# source files change. Web SDK pulls in transitive packages from the
|
||||
# DorkNet.Models project ref, so its csproj needs to be present too.
|
||||
COPY DorkNet.Server/DorkNet.Server.csproj DorkNet.Server/
|
||||
COPY DorkNet.Models/DorkNet.Models.csproj DorkNet.Models/
|
||||
RUN dotnet restore DorkNet.Server/DorkNet.Server.csproj
|
||||
|
||||
# Now copy the rest of the .NET source, then drop the prebuilt admin
|
||||
# SPA into wwwroot/admin so the publish step ships it as static content.
|
||||
COPY DorkNet.Server/ DorkNet.Server/
|
||||
COPY DorkNet.Models/ DorkNet.Models/
|
||||
COPY --from=admin-ui /wwwroot/admin/ DorkNet.Server/wwwroot/admin/
|
||||
COPY --from=site /wwwroot/site/ DorkNet.Server/wwwroot/site/
|
||||
|
||||
# Publish. SkipAdminUIBuild=true bypasses the csproj target that would
|
||||
# otherwise try to invoke npm (which this image doesn't have).
|
||||
RUN dotnet publish DorkNet.Server/DorkNet.Server.csproj \
|
||||
-c Release \
|
||||
--no-restore \
|
||||
-p:SkipAdminUIBuild=true \
|
||||
-o /app/publish
|
||||
|
||||
# ── runtime stage ──────────────────────────────────────────────────
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION} AS runtime
|
||||
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
|
||||
# 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.
|
||||
# Coolify mounts persistent volumes with write permission for this UID.
|
||||
USER app
|
||||
|
||||
COPY --from=build --chown=app:app /app/publish .
|
||||
|
||||
# Postgres provider is selected by appsettings.Production.json.
|
||||
# ASPNETCORE_ENVIRONMENT defaults to Production in the aspnet image
|
||||
# but we set it explicitly for clarity.
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production \
|
||||
ASPNETCORE_URLS=http://0.0.0.0:8080 \
|
||||
DOTNET_RUNNING_IN_CONTAINER=true \
|
||||
DOTNET_PRINT_TELEMETRY_MESSAGE=false
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# /healthz returns 503 until the migration + seed pass complete on
|
||||
# the singleton holding the pg_advisory_xact_lock; other replicas
|
||||
# block on the lock during the same window. Coolify's default 30s
|
||||
# probe interval is fine.
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD curl --fail --silent http://localhost:8080/healthz || exit 1
|
||||
|
||||
ENTRYPOINT ["dotnet", "DorkNet.Server.dll"]
|
||||
@@ -1,67 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>DorkNet.ClientMod</RootNamespace>
|
||||
<AssemblyName>DorkNet.ClientMod</AssemblyName>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
MelonLoader 0.6.x build of the DorkNet client patches. Resolves
|
||||
every game-side type at runtime via AccessTools.TypeByName, so
|
||||
Rec Room's IL2CPP-derived interop assemblies (Assembly-CSharp,
|
||||
Photon3Unity3D, UnityEngine.*) are NOT needed at compile time —
|
||||
they would be derivative works of the game anyway, so we never
|
||||
reference them. Only MelonLoader's own DLLs are needed.
|
||||
|
||||
By default the project downloads the pinned MelonLoader release
|
||||
into DorkNet.ClientMod/build/ and references those DLLs. Pass
|
||||
-p:MelonLoaderDir=<path-to-extracted-MelonLoader> only when you
|
||||
deliberately want to use a local override.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
|
||||
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)build\melonloader</MelonLoaderCacheRoot>
|
||||
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
|
||||
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="EnsureMelonLoaderReferences"
|
||||
BeforeTargets="ResolveAssemblyReferences"
|
||||
Condition="!Exists('$(MelonLoaderDir)\net6\MelonLoader.dll')">
|
||||
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
|
||||
<DownloadFile
|
||||
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
|
||||
DestinationFolder="$(MelonLoaderCacheRoot)"
|
||||
DestinationFileName="MelonLoader.x64.$(MelonLoaderVersion).zip"
|
||||
SkipUnchangedFiles="true" />
|
||||
<Unzip SourceFiles="$(MelonLoaderZip)"
|
||||
DestinationFolder="$(MelonLoaderCacheRoot)"
|
||||
OverwriteReadOnlyFiles="true" />
|
||||
</Target>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="MelonLoader">
|
||||
<HintPath>$(MelonLoaderDir)\net6\MelonLoader.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(MelonLoaderDir)\net6\0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Runtime">
|
||||
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Runtime.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Common">
|
||||
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Common.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,1396 +0,0 @@
|
||||
// DorkNet client mod (MelonLoader IL2CPP). Applies the minimum set of
|
||||
// Harmony patches needed for the 2020 watch to connect to a DorkNet
|
||||
// server:
|
||||
//
|
||||
// 1. URI rewrite — `.rec.net` → user-configured host
|
||||
// 2. Photon AppId — override the baked-in Photon Cloud AppId
|
||||
// 3. AuthValues — inject `userid` + `LoginLock` for /photon/customauth
|
||||
// 4. TLS trust bypass — make BouncyCastle never reject server certs
|
||||
//
|
||||
// Config lives in <MelonLoader>\UserData\dorknet-clientmod.json. The
|
||||
// install-melon.ps1 wrapper writes that file on install; you can also
|
||||
// edit it by hand and relaunch the client.
|
||||
//
|
||||
// Why string-based AccessTools lookups instead of typeof(Il2Cpp...):
|
||||
// MelonLoader's Il2CppInterop adds an `Il2Cpp` prefix to game-side
|
||||
// namespaces (so `RecNet.Core` becomes `Il2CppRecNet.Core`) but the
|
||||
// exact policy for globally-namespaced types like `PhotonNetwork` and
|
||||
// for nested types varies across MelonLoader minor versions. Looking
|
||||
// types up by their original game-side name via AccessTools.TypeByName
|
||||
// (which searches every loaded assembly) keeps this file working
|
||||
// regardless of the prefix scheme, and avoids needing a verified
|
||||
// Il2CppAssemblies directory just to write the source.
|
||||
|
||||
using HarmonyLib;
|
||||
using MelonLoader;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
|
||||
[assembly: MelonInfo(typeof(DorkNet.ClientMod.Mod), "DorkNet ClientMod", "1.0.0", "Dork Squad")]
|
||||
[assembly: MelonGame(null, null)]
|
||||
|
||||
namespace DorkNet.ClientMod;
|
||||
|
||||
public class Mod : MelonMod
|
||||
{
|
||||
internal static MelonLogger.Instance Log => Instance!.LoggerInstance;
|
||||
internal static Mod? Instance;
|
||||
internal static string? DiagnosticsLogPath;
|
||||
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
|
||||
private readonly HashSet<string> _diagnosticPatchLabels = new();
|
||||
private bool _networkPatchesRegistered;
|
||||
private bool _diagnosticCoreRegistered;
|
||||
private bool _diagnosticGameComplete;
|
||||
private int _diagnosticRetryFrame;
|
||||
private int _photonOverridePollFrame;
|
||||
|
||||
// Config backed by a plain JSON file under MelonLoader/UserData.
|
||||
// The defaults below match the recommended install, so dropping this
|
||||
// mod in without writing a config file still gives working behaviour.
|
||||
public static class Cfg
|
||||
{
|
||||
public static string ServerHost = "localhost";
|
||||
public static string SingleOriginBaseUrl = "";
|
||||
public static string PhotonAppId = "";
|
||||
public static string PhotonVoiceAppId = "";
|
||||
// Forced PhotonServerSettings.PreferredRegion (with HostType
|
||||
// switched to PhotonCloud) so two watches don't auto-ping
|
||||
// themselves into different "best" regions and end up in
|
||||
// parallel Photon rooms sharing only a name. PhotonPatches
|
||||
// .ApplyForcedRegion prefers the server-told region from
|
||||
// Matchmaking.LocalRoomInstance.PhotonRegionId when available
|
||||
// and falls back to this config value on initial boot before
|
||||
// any /goto has returned. Default matches the server's
|
||||
// appsettings.json Photon:CloudRegion ("eu").
|
||||
public static string PhotonCloudRegion = "eu";
|
||||
public static bool EnableTlsTrustBypass = true;
|
||||
// Photon Custom Auth injector was parked 2026-05-28; see
|
||||
// attic/AuthValuesInjector.cs.attic for the code + restore notes.
|
||||
// Force-enable the client's built-in RecRoom.Debugging.DebugConsole
|
||||
// (normally dev-gated) and silence CheatManager's tamper detectors
|
||||
// so movement/time commands (SetTimeScale, Fly, Teleport, …) don't
|
||||
// drop you to the dorm. Default OFF — purely opt-in. The toggle key
|
||||
// is a UnityEngine.KeyCode name; BackQuote is the `~` key.
|
||||
public static bool EnableDebugConsole = false;
|
||||
public static string DebugConsoleToggleKey = "BackQuote";
|
||||
}
|
||||
|
||||
public override void OnInitializeMelon()
|
||||
{
|
||||
Instance = this;
|
||||
Log.Msg("=== DorkNet ClientMod loading ===");
|
||||
LoadConfig();
|
||||
RegisterNetworkPatches();
|
||||
DiagnosticPatches.Write("[lifecycle] OnInitializeMelon");
|
||||
}
|
||||
|
||||
public override void OnLateInitializeMelon()
|
||||
{
|
||||
DiagnosticPatches.Write("[lifecycle] OnLateInitializeMelon");
|
||||
Log.Msg("=== Registering client patches ===");
|
||||
RegisterNetworkPatches();
|
||||
TryPatchByName("PhotonNetwork", "ConnectUsingSettings",
|
||||
prefix: nameof(PhotonPatches.PhotonAppIdOverride_Prefix));
|
||||
// NetworkingPeer.CallAuthenticate hook (Photon Custom Auth
|
||||
// injector) parked — see attic/AuthValuesInjector.cs.attic.
|
||||
TryPatchByName("BestHTTP.SecureProtocol.Org.BouncyCastle.Crypto.Tls.ServerOnlyTlsAuthentication",
|
||||
"NotifyServerCertificate",
|
||||
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
|
||||
TryPatchByName("Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication",
|
||||
"NotifyServerCertificate",
|
||||
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
|
||||
// Post-save blob-name refresh. The 2020 watch's
|
||||
// RoomPersistenceManager.RoomDataBlobName is only ever set by
|
||||
// /goto-driven flows — neither the SubscriptionUpdateRoom push
|
||||
// nor SubscriptionUpdatePresence pushes the server fires after
|
||||
// save propagate into this field. So the PostSaveReloading
|
||||
// sub-state's MasterReloadRoomAsync deserializes the cached
|
||||
// bytes from the OLD blob name (the one /goto returned at boot),
|
||||
// wiping any MakerPen object the user just placed even though
|
||||
// the new blob is already on S3.
|
||||
//
|
||||
// Verified live: after a dorm save with object placement, server
|
||||
// pushes SubscriptionUpdateRoom + PresenceUpdate both carrying
|
||||
// dataBlob="dorm_p<id>_v(N+1).dat", but the watch's
|
||||
// set_RoomDataBlobName trace immediately after the save shows
|
||||
// old="v(N)" new="v(N)" — no propagation, no fresh download,
|
||||
// no respawn. Leave+return triggers /goto which DOES update
|
||||
// RoomDataBlobName, so the fix is to do the same write
|
||||
// ourselves: when entering the SavingRoomSuccess state on the
|
||||
// RoomPersistenceManager, read the just-pushed
|
||||
// Rooms.LocalRoomScene.DataBlobName and force-set
|
||||
// __instance.RoomDataBlobName to it. That fires the real
|
||||
// setter → OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync
|
||||
// for the new blob → PostSaveReloading picks up the fresh
|
||||
// bytes and respawns the MakerPen objects.
|
||||
TryPatchByName("RecRoom.Persistence.RoomPersistenceManager",
|
||||
"set_RoomDataBlobName",
|
||||
args: new[] { typeof(string) },
|
||||
prefix: nameof(SavePatches.SetRoomDataBlobName_Prefix));
|
||||
// Intercept the SubscriptionUpdateRoom push BEFORE the watch's
|
||||
// (cachedRoomId, cachedSubRoomId) early-out gate drops it. We
|
||||
// stash the just-arrived Scenes[0].DataBlobName into a static
|
||||
// so the set_RoomDataBlobName hijack can use it — otherwise
|
||||
// LocalRoomDetails / LocalRoomScene never get the new blob
|
||||
// name on the second-or-later push for the same (room, sub)
|
||||
// tuple (= every post-save push in a dorm).
|
||||
TryPatchByName("RecNet.Rooms",
|
||||
"OnSubscriptionUpdateRoom",
|
||||
prefix: nameof(SavePatches.OnSubscriptionUpdateRoom_Prefix));
|
||||
// Force AccountExtensions.CanLocalPlayerChat to return true. The
|
||||
// 2020 watch's room-chat send path (PlayerEmotes.SendChatEmote
|
||||
// Coroutine d__53 offset 040) gates on this — if false, the
|
||||
// coroutine bails before raising the Photon RPC, so the sender
|
||||
// doesn't even see their own message. The check ANDs three
|
||||
// signals: LocalAccount.TreatAsJunior (ObscuredBool at offset
|
||||
// 0x40), a PlatformManager flag, and a ModerationManager
|
||||
// singleton field. At least one returns the wrong value against
|
||||
// our server. Force-allow — single-tenant private server, text
|
||||
// moderation isn't a concern.
|
||||
TryPatchByName("RecNet.AccountExtensions",
|
||||
"CanLocalPlayerChat",
|
||||
args: Type.EmptyTypes,
|
||||
postfix: nameof(ChatPatches.CanLocalPlayerChat_Postfix));
|
||||
// PlayerEmotes.RemoveInvalidCharactersFromMessage walks the chat
|
||||
// font asset's character table via TMP_FontAsset.HasCharacters
|
||||
// to strip glyphs the font can't render. On 2020 Rec Room the
|
||||
// chat font field can be null at the moment this fires (font
|
||||
// asset bundle hasn't loaded yet?) and HasCharacters NREs deep
|
||||
// inside Unity's TMP — which propagates out through the chat
|
||||
// send callback and aborts the RPC. The watch's own ISIL has
|
||||
// "skipping invalid character check" log paths for missing
|
||||
// assets but doesn't actually short-circuit on the null field
|
||||
// we hit. Skip the method entirely; chat lines will just keep
|
||||
// any glyphs the font can't render (most common: emoji).
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"RemoveInvalidCharactersFromMessage",
|
||||
prefix: nameof(ChatPatches.RemoveInvalidCharactersFromMessage_Prefix));
|
||||
// Trace hooks so we can see exactly where chat dies. Send path:
|
||||
// KeyboardInputField.submit → RoomChatMenu.SendEmoteMessage(string)
|
||||
// → PlayerEmotes.SendChatEmote(msg, true)
|
||||
// → SendChatEmoteCoroutine d__53 → PurifyString HTTP call
|
||||
// → b__0 callback (error, cleanVersion) — fires Photon RPC
|
||||
// AND local-echoes via ProcessNewChatMessageReceived
|
||||
// → on each receiver (including self): RpcChatEmote(msg) →
|
||||
// ProcessNewChatMessageReceived → ReceiveRoomChat appends to
|
||||
// the chat log buffer.
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"SendChatEmote",
|
||||
args: new[] { typeof(string), typeof(bool) },
|
||||
prefix: nameof(ChatPatches.SendChatEmote_Prefix));
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"ProcessNewChatMessageReceived",
|
||||
args: new[] { typeof(string), typeof(bool) },
|
||||
prefix: nameof(ChatPatches.ProcessNewChatMessageReceived_Prefix));
|
||||
TryPatchByName("RecRoom.Players.PlayerEmotes",
|
||||
"RpcChatEmote",
|
||||
args: new[] { typeof(string) },
|
||||
prefix: nameof(ChatPatches.RpcChatEmote_Prefix));
|
||||
// Debug console: skip CheatManager's tamper detectors so the
|
||||
// console's movement/time commands don't trip a dorm-drop. The
|
||||
// console UI toggle itself is driven from OnUpdate (hotkey) and
|
||||
// resolved lazily — only the anti-cheat skip needs a patch.
|
||||
if (Cfg.EnableDebugConsole)
|
||||
{
|
||||
foreach (var m in new[] { "OnTimeCheatDetected", "OnObscuredTypeCheatDetected", "OnHeightCheatDetected", "OnAdvancedMovementCheatDetected" })
|
||||
TryPatchByName("CheatManager", m, prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix));
|
||||
Log.Msg($"[debugconsole] enabled — CheatManager detectors silenced; press '{Cfg.DebugConsoleToggleKey}' to toggle the console.");
|
||||
}
|
||||
|
||||
RegisterDiagnostics();
|
||||
Log.Msg("=== Client patches registered ===");
|
||||
}
|
||||
|
||||
private void RegisterNetworkPatches()
|
||||
{
|
||||
if (_networkPatchesRegistered) return;
|
||||
|
||||
var complete = true;
|
||||
complete &= TryPatch("Uri ctor (string)", typeof(Uri), "ctor",
|
||||
new[] { typeof(string) },
|
||||
prefix: nameof(UriPatches.UriStringCtor_Prefix));
|
||||
complete &= TryPatch("Uri ctor (string, UriKind)", typeof(Uri), "ctor",
|
||||
new[] { typeof(string), typeof(UriKind) },
|
||||
prefix: nameof(UriPatches.UriStringCtor_Prefix));
|
||||
complete &= TryPatchByName("BestHTTP.HTTPRequest", "Send",
|
||||
prefix: nameof(UriPatches.HttpRequestSend_Prefix));
|
||||
complete &= TryPatchHttpManagerStringSendRequestOverloads();
|
||||
complete &= TryPatchByName("BestHTTP.HTTPManager", "SendRequest",
|
||||
args: new[] { ResolveType("BestHTTP.HTTPRequest") ?? typeof(object) },
|
||||
prefix: nameof(UriPatches.HttpManagerSendRequestObject_Prefix));
|
||||
|
||||
_networkPatchesRegistered = complete;
|
||||
}
|
||||
|
||||
private bool TryPatchHttpManagerStringSendRequestOverloads()
|
||||
{
|
||||
var type = ResolveType("BestHTTP.HTTPManager");
|
||||
if (type is null)
|
||||
{
|
||||
Log.Warning("[patch-miss] BestHTTP.HTTPManager.SendRequest(string): type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var prefix = GetPatchMethod(nameof(UriPatches.HttpManagerSendRequestString_Prefix));
|
||||
var patched = 0;
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.Name != "SendRequest") continue;
|
||||
var parameters = method.GetParameters();
|
||||
if (parameters.Length == 0 || parameters[0].ParameterType != typeof(string)) continue;
|
||||
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefix));
|
||||
patched++;
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
Log.Warning("[patch-miss] BestHTTP.HTTPManager.SendRequest(string)");
|
||||
return false;
|
||||
}
|
||||
|
||||
Log.Msg($"[patch-ok] BestHTTP.HTTPManager.SendRequest(string) overloads x{patched}");
|
||||
return true;
|
||||
}
|
||||
|
||||
public override void OnUpdate()
|
||||
{
|
||||
// Photon AppId override poller. The harmony hook on
|
||||
// PhotonNetwork.ConnectUsingSettings registers in IL2CPP but
|
||||
// doesn't actually fire in this build (verified in the
|
||||
// MelonLoader log — [patch-ok] appears but [photon-appid]
|
||||
// never does), so we poll until PhotonServerSettings is loaded
|
||||
// and write the AppId then. Stops calling once the override
|
||||
// succeeds. Throttled to every 6th frame (~10 Hz at 60 fps) so
|
||||
// the failure log doesn't drown the file when PhotonServerSettings
|
||||
// isn't loaded yet.
|
||||
if (!PhotonPatches.OverrideApplied && (_photonOverridePollFrame++ % 6) == 0)
|
||||
PhotonPatches.TryApplyOverride(reason: "update-poll");
|
||||
|
||||
if (Cfg.EnableDebugConsole) DebugConsolePatches.PollToggleKey();
|
||||
|
||||
if (_diagnosticGameComplete) return;
|
||||
if (_diagnosticRetryFrame++ > 3600) return;
|
||||
if ((_diagnosticRetryFrame % 60) != 0) return;
|
||||
|
||||
var logMisses = (_diagnosticRetryFrame % 300) == 0;
|
||||
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses);
|
||||
if (_diagnosticGameComplete)
|
||||
DiagnosticPatches.Write("[diagnostics] all game-side diagnostic hooks registered");
|
||||
}
|
||||
|
||||
public override void OnApplicationQuit()
|
||||
{
|
||||
DiagnosticPatches.Write("[lifecycle] OnApplicationQuit");
|
||||
}
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────
|
||||
private static void LoadConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Compute the UserData path ourselves rather than calling
|
||||
// MelonLoader.MelonUtils.UserDataDirectory (which is renamed
|
||||
// to MelonEnvironment.UserDataDirectory in newer 0.6.x builds
|
||||
// and shifts again in 0.7.x). Anchoring off the mod DLL's
|
||||
// location keeps this stable across versions: the DLL lives
|
||||
// in <game>/Mods/, so two dirname()'s reach the game root,
|
||||
// then MelonLoader/UserData/ is right next to it.
|
||||
var modDll = Assembly.GetExecutingAssembly().Location;
|
||||
var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty;
|
||||
var gameDir = Path.GetDirectoryName(modsDir) ?? string.Empty;
|
||||
var userData = Path.Combine(gameDir, "MelonLoader", "UserData");
|
||||
Directory.CreateDirectory(userData);
|
||||
DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log");
|
||||
var path = Path.Combine(userData, "dorknet-clientmod.json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Log.Msg($"[config] no file at {path}; using defaults (ServerHost={Cfg.ServerHost})");
|
||||
return;
|
||||
}
|
||||
var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
var r = doc.RootElement;
|
||||
if (r.TryGetProperty("ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost;
|
||||
if (r.TryGetProperty("SingleOriginBaseUrl", out v)) Cfg.SingleOriginBaseUrl = (v.GetString() ?? "").TrimEnd('/');
|
||||
if (r.TryGetProperty("PhotonAppId", out v)) Cfg.PhotonAppId = v.GetString() ?? Cfg.PhotonAppId;
|
||||
if (r.TryGetProperty("PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId;
|
||||
if (r.TryGetProperty("PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion;
|
||||
if (r.TryGetProperty("EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
|
||||
if (r.TryGetProperty("EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
|
||||
if (r.TryGetProperty("DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
|
||||
// "InjectAuthValues" key in the template is now ignored —
|
||||
// see attic/AuthValuesInjector.cs.attic.
|
||||
Log.Msg($"[config] loaded: ServerHost={Cfg.ServerHost}, SingleOrigin={(string.IsNullOrEmpty(Cfg.SingleOriginBaseUrl) ? "<off>" : Cfg.SingleOriginBaseUrl)}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "<unset>" : "<set>")}, " +
|
||||
$"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[config] load failed, using defaults: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Patch dispatch helpers ────────────────────────────────────────
|
||||
private bool TryPatch(string label, Type target, string methodName,
|
||||
Type[]? args, string? prefix = null, string? postfix = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var method = methodName == "ctor"
|
||||
? (MethodBase)target.GetConstructor(args ?? Type.EmptyTypes)!
|
||||
: (args is null ? AccessTools.Method(target, methodName)
|
||||
: AccessTools.Method(target, methodName, args));
|
||||
if (method is null)
|
||||
{
|
||||
Log.Warning($"[patch-miss] {label}");
|
||||
return false;
|
||||
}
|
||||
HarmonyInstance.Patch(method,
|
||||
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
|
||||
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
|
||||
Log.Msg($"[patch-ok] {label}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"[patch-fail] {label}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the game-side type by its ORIGINAL name (no Il2Cpp prefix)
|
||||
// by walking every loaded assembly. Tries the exact name first, then
|
||||
// the same name with an `Il2Cpp` prefix, then a substring scan as a
|
||||
// last resort — covers all the IL2CPP interop naming schemes the user
|
||||
// might end up with depending on the MelonLoader / Il2CppInterop
|
||||
// version installed.
|
||||
private bool TryPatchByName(string typeName, string methodName,
|
||||
Type[]? args = null, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
{
|
||||
var label = FormatPatchLabel(typeName, methodName, args);
|
||||
var type = ResolveType(typeName);
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
|
||||
return false;
|
||||
}
|
||||
return TryPatch(label, type, methodName, args, prefix, postfix);
|
||||
}
|
||||
|
||||
internal static Type? ResolveType(string name)
|
||||
{
|
||||
if (ResolvedTypeCache.TryGetValue(name, out var cached))
|
||||
return cached;
|
||||
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = asm.GetType(name, throwOnError: false);
|
||||
if (t is not null) return CacheResolvedType(name, t);
|
||||
var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false);
|
||||
if (prefixed is not null) return CacheResolvedType(name, prefixed);
|
||||
// For namespaced types like "RecNet.Core", also try with
|
||||
// the Il2Cpp prefix on the namespace (i.e. "Il2CppRecNet.Core").
|
||||
var dot = name.IndexOf('.');
|
||||
if (dot > 0)
|
||||
{
|
||||
var nsPrefixed = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), throwOnError: false);
|
||||
if (nsPrefixed is not null) return CacheResolvedType(name, nsPrefixed);
|
||||
}
|
||||
}
|
||||
catch { /* unloaded / dynamic asm — skip */ }
|
||||
}
|
||||
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
foreach (var t in GetLoadableTypes(asm))
|
||||
{
|
||||
if (MatchesRequestedType(t, name))
|
||||
return CacheResolvedType(name, t);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Type CacheResolvedType(string name, Type type)
|
||||
{
|
||||
ResolvedTypeCache[name] = type;
|
||||
return type;
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> GetLoadableTypes(Assembly asm)
|
||||
{
|
||||
try
|
||||
{
|
||||
return asm.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
var types = new List<Type>();
|
||||
foreach (var t in ex.Types)
|
||||
{
|
||||
if (t is not null) types.Add(t);
|
||||
}
|
||||
return types;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<Type>();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesRequestedType(Type type, string requested)
|
||||
{
|
||||
var prefixed = "Il2Cpp" + requested;
|
||||
if (type.FullName == requested || type.FullName == prefixed) return true;
|
||||
if (type.Name == requested || type.Name == prefixed) return true;
|
||||
return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
|
||||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
|
||||
}
|
||||
|
||||
private static string FormatPatchLabel(string typeName, string methodName, Type[]? args)
|
||||
{
|
||||
if (args is null) return $"{typeName}.{methodName}";
|
||||
var names = new List<string>();
|
||||
foreach (var arg in args)
|
||||
names.Add(arg.Name);
|
||||
return $"{typeName}.{methodName}({string.Join(",", names)})";
|
||||
}
|
||||
|
||||
private MethodInfo GetPatchMethod(string name)
|
||||
{
|
||||
// Look in all three patch holder classes — small enough that a
|
||||
// linear scan is cheaper than per-class lookups.
|
||||
foreach (var holder in new[] { typeof(UriPatches), typeof(PhotonPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(DebugConsolePatches) })
|
||||
{
|
||||
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (m is not null) return m;
|
||||
}
|
||||
throw new MissingMethodException($"patch method '{name}' not found on any holder");
|
||||
}
|
||||
|
||||
private void RegisterDiagnostics()
|
||||
{
|
||||
Log.Msg("=== Registering diagnostics ===");
|
||||
if (!_diagnosticCoreRegistered)
|
||||
{
|
||||
TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes,
|
||||
prefix: nameof(DiagnosticPatches.ApplicationQuit_Prefix));
|
||||
TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) },
|
||||
prefix: nameof(DiagnosticPatches.ApplicationQuitInt_Prefix));
|
||||
_diagnosticCoreRegistered = true;
|
||||
}
|
||||
|
||||
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses: true);
|
||||
Log.Msg("=== Diagnostics registered ===");
|
||||
}
|
||||
|
||||
private bool RegisterGameDiagnostics(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
complete &= TryPatchDiagnosticByName("BootSequence", "RegisterError", new[] { typeof(string) },
|
||||
prefix: nameof(DiagnosticPatches.BootSequenceRegisterError_Prefix),
|
||||
logMiss: logMisses);
|
||||
|
||||
foreach (var method in new[]
|
||||
{
|
||||
"UnityTimeCheat",
|
||||
"ObscuredTypeCheat",
|
||||
"DeveloperFlagCheat",
|
||||
"HeightChangeCheat",
|
||||
"AdvancedMotionCheat",
|
||||
"StreamingAssetCheat"
|
||||
})
|
||||
{
|
||||
complete &= TryPatchDiagnosticByName("AnalyticsHelper", method,
|
||||
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
|
||||
logMiss: logMisses);
|
||||
}
|
||||
|
||||
complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit",
|
||||
prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("SteamManager", "Awake",
|
||||
prefix: nameof(DiagnosticPatches.NamedMethod_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("SteamPlatformManager", "PreLoginInitialize",
|
||||
prefix: nameof(DiagnosticPatches.NamedMethod_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("SteamPlatformManager", "PostLoginInitialize",
|
||||
prefix: nameof(DiagnosticPatches.NamedMethod_Prefix),
|
||||
logMiss: logMisses);
|
||||
return complete;
|
||||
}
|
||||
|
||||
private bool TryPatchDiagnosticByName(string typeName, string methodName,
|
||||
Type[]? args = null, string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
{
|
||||
var label = FormatPatchLabel(typeName, methodName, args);
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
if (!TryPatchByName(typeName, methodName, args, prefix, postfix, logMiss))
|
||||
return false;
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Patch holders ─────────────────────────────────────────────────────
|
||||
// Each holder is a static class with public static Prefix methods that
|
||||
// Harmony invokes. Kept separate from Mod for readability and so the
|
||||
// reflection lookup in GetPatchMethod stays simple.
|
||||
|
||||
// ── Debug console access ───────────────────────────────────────────────
|
||||
// The 2020 client ships RecRoom.Debugging.DebugConsole — a real in-game
|
||||
// dev console (text input + a DebugConsoleCommandConfig command table:
|
||||
// SetTimeScale, Fly, Teleport, GoToRoom, KillAllEnemies, DebugCompleteDaily,
|
||||
// …). Retail gates it behind a developer flag. This holder force-toggles
|
||||
// its UI on a hotkey and (via the OnLateInitializeMelon patch above) skips
|
||||
// CheatManager's tamper detectors so movement/time commands don't drop you
|
||||
// to the dorm.
|
||||
//
|
||||
// Everything is resolved by reflection at runtime so the SAME code works on
|
||||
// both client builds despite their differing obfuscation:
|
||||
// * The show/hide toggle is the one non-compiler-generated private
|
||||
// instance void(bool) on DebugConsole — ShowInputField on 2020.03,
|
||||
// MCNBJFECHLJ on 2020.12. We try the readable name, then the obfuscated
|
||||
// one, then fall back to that structural match.
|
||||
// * The live component is found via UnityEngine.Object.FindObjectOfType
|
||||
// (name-stable) rather than the SingletonMonoBehaviour<T> accessor.
|
||||
// * Execute(string) keeps its readable name on both builds (kept here as
|
||||
// a scripted-command entry point even though the hotkey drives the UI).
|
||||
internal static class DebugConsolePatches
|
||||
{
|
||||
private static bool _consoleVisible;
|
||||
private static bool _pollErrorLogged;
|
||||
private static int _probeFrame;
|
||||
private static bool _probeDone;
|
||||
|
||||
private static bool _inputResolved;
|
||||
private static MethodInfo? _getKeyDown; // UnityEngine.Input.GetKeyDown(KeyCode)
|
||||
private static object? _toggleKeyValue; // boxed KeyCode enum value
|
||||
|
||||
private static bool _consoleResolved;
|
||||
private static Type? _consoleType;
|
||||
private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool)
|
||||
private static MethodInfo? _findObjectOfType; // UnityEngine.Object.FindObjectOfType(Type)
|
||||
|
||||
// Harmony prefix returning false → skips the original CheatManager
|
||||
// detector, so a tripped heuristic never runs its punish/drop path.
|
||||
public static bool SuppressCheatDetected_Prefix() => false;
|
||||
|
||||
// Polled every frame from Mod.OnUpdate while EnableDebugConsole is set.
|
||||
public static void PollToggleKey()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_inputResolved) ResolveInput();
|
||||
// One-shot probe ~3s in (≈180 frames @60fps): log whether the
|
||||
// console object actually exists, so we learn that even if the
|
||||
// hotkey never fires.
|
||||
if (!_probeDone && _probeFrame++ > 180)
|
||||
{
|
||||
_probeDone = true;
|
||||
ProbeConsole();
|
||||
}
|
||||
if (_getKeyDown is null || _toggleKeyValue is null) return;
|
||||
if (_getKeyDown.Invoke(null, new[] { _toggleKeyValue }) is true)
|
||||
{
|
||||
Mod.Log.Msg($"[debugconsole] toggle key '{Mod.Cfg.DebugConsoleToggleKey}' detected");
|
||||
Toggle();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_pollErrorLogged) { _pollErrorLogged = true; Mod.Log.Warning($"[debugconsole] poll failed (logged once): {ex}"); }
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic: reports whether a live DebugConsole component exists and
|
||||
// whether the show/hide method resolved — without needing a keypress.
|
||||
private static void ProbeConsole()
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] probe: DebugConsole type NOT resolved"); return; }
|
||||
try
|
||||
{
|
||||
var active = _findObjectOfType?.Invoke(null, new object[] { _consoleType });
|
||||
Mod.Log.Msg($"[debugconsole] probe: type={_consoleType.FullName}, toggleMethod={(_toggleMethod?.Name ?? "<none>")}, activeInstance={(active is null ? "null" : "found")}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] probe failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Runs DebugConsole.Execute(command) directly — bypasses the UI/gate
|
||||
// entirely. Handy for scripted one-shots; the hotkey path uses the UI.
|
||||
public static void ExecuteCommand(string command)
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null) return;
|
||||
try
|
||||
{
|
||||
var execute = AccessTools.Method(_consoleType, "Execute", new[] { typeof(string) });
|
||||
if (execute is null) { Mod.Log.Warning("[debugconsole] Execute(string) not found"); return; }
|
||||
execute.Invoke(null, new object[] { command });
|
||||
Mod.Log.Msg($"[debugconsole] executed: {command}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] execute '{command}' failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static void ResolveInput()
|
||||
{
|
||||
_inputResolved = true;
|
||||
var keyCodeType = Mod.ResolveType("UnityEngine.KeyCode");
|
||||
var inputType = Mod.ResolveType("UnityEngine.Input");
|
||||
if (keyCodeType is null || inputType is null)
|
||||
{
|
||||
Mod.Log.Warning("[debugconsole] UnityEngine.Input/KeyCode not found — hotkey disabled");
|
||||
return;
|
||||
}
|
||||
try { _toggleKeyValue = Enum.Parse(keyCodeType, Mod.Cfg.DebugConsoleToggleKey, ignoreCase: true); }
|
||||
catch
|
||||
{
|
||||
Mod.Log.Warning($"[debugconsole] '{Mod.Cfg.DebugConsoleToggleKey}' is not a KeyCode; defaulting to BackQuote");
|
||||
_toggleKeyValue = Enum.Parse(keyCodeType, "BackQuote");
|
||||
}
|
||||
_getKeyDown = AccessTools.Method(inputType, "GetKeyDown", new[] { keyCodeType });
|
||||
if (_getKeyDown is null) Mod.Log.Warning("[debugconsole] Input.GetKeyDown(KeyCode) not found");
|
||||
else Mod.Log.Msg($"[debugconsole] hotkey armed: '{Mod.Cfg.DebugConsoleToggleKey}' (poll live)");
|
||||
}
|
||||
|
||||
private static void ResolveConsole()
|
||||
{
|
||||
_consoleResolved = true;
|
||||
_consoleType = Mod.ResolveType("RecRoom.Debugging.DebugConsole");
|
||||
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] DebugConsole type not found"); return; }
|
||||
|
||||
// Prefer the readable 2020.03 name, then the 2020.12 obfuscated
|
||||
// name, then a structural fallback that fits both builds.
|
||||
_toggleMethod = AccessTools.Method(_consoleType, "ShowInputField", new[] { typeof(bool) })
|
||||
?? AccessTools.Method(_consoleType, "MCNBJFECHLJ", new[] { typeof(bool) })
|
||||
?? FindToggleByShape(_consoleType);
|
||||
if (_toggleMethod is null) Mod.Log.Warning("[debugconsole] show/hide method not found");
|
||||
|
||||
var objType = Mod.ResolveType("UnityEngine.Object");
|
||||
_findObjectOfType = objType is null ? null
|
||||
: AccessTools.Method(objType, "FindObjectOfType", new[] { typeof(Type) });
|
||||
if (_findObjectOfType is null) Mod.Log.Warning("[debugconsole] Object.FindObjectOfType(Type) not found");
|
||||
}
|
||||
|
||||
// The console's show/hide is the single private, instance, void method
|
||||
// taking exactly one bool, excluding compiler-generated members (the
|
||||
// property backing setter on 2020.12 is also void(bool) but is marked
|
||||
// [CompilerGenerated]).
|
||||
private static MethodInfo? FindToggleByShape(Type t)
|
||||
{
|
||||
MethodInfo? found = null;
|
||||
foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (m.ReturnType != typeof(void)) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length != 1 || ps[0].ParameterType != typeof(bool)) continue;
|
||||
if (m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
|
||||
if (m.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) continue;
|
||||
if (found is not null) return null; // ambiguous — don't guess
|
||||
found = m;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public static void Toggle()
|
||||
{
|
||||
if (!_consoleResolved) ResolveConsole();
|
||||
if (_consoleType is null || _toggleMethod is null || _findObjectOfType is null) return;
|
||||
try
|
||||
{
|
||||
var instance = _findObjectOfType.Invoke(null, new object[] { _consoleType });
|
||||
if (instance is null)
|
||||
{
|
||||
Mod.Log.Warning("[debugconsole] no live DebugConsole in scene — it may not be instantiated on this build");
|
||||
return;
|
||||
}
|
||||
// Best-effort: flip the static "show button" enable so the
|
||||
// game's own Update keeps the console available instead of
|
||||
// re-hiding it. Failures are ignored (obfuscated builds).
|
||||
EnableShowButton();
|
||||
_consoleVisible = !_consoleVisible;
|
||||
_toggleMethod.Invoke(instance, new object[] { _consoleVisible });
|
||||
Mod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static void EnableShowButton()
|
||||
{
|
||||
if (_consoleType is null) return;
|
||||
try
|
||||
{
|
||||
var prop = _consoleType.GetProperty("ShowButton", BindingFlags.Public | BindingFlags.Static);
|
||||
if (prop is not null && prop.CanWrite) { prop.SetValue(null, true); return; }
|
||||
foreach (var name in new[] { "showButton", "IEGCNLHPKFH" })
|
||||
{
|
||||
var f = _consoleType.GetField(name, BindingFlags.NonPublic | BindingFlags.Static);
|
||||
if (f is not null && f.FieldType == typeof(bool)) { f.SetValue(null, true); return; }
|
||||
}
|
||||
}
|
||||
catch { /* best-effort — the toggle still works without it */ }
|
||||
}
|
||||
}
|
||||
|
||||
internal static class UriPatches
|
||||
{
|
||||
public static void HttpManagerSendRequestString_Prefix(ref string url)
|
||||
{
|
||||
RewriteString(ref url, "httpmanager-rewrite");
|
||||
}
|
||||
|
||||
public static void HttpManagerSendRequestObject_Prefix(object request)
|
||||
{
|
||||
RewriteRequestUri(request, "httpmanager-request-rewrite");
|
||||
}
|
||||
|
||||
public static void UriStringCtor_Prefix(ref string uriString)
|
||||
{
|
||||
RewriteString(ref uriString, "uri-rewrite");
|
||||
}
|
||||
|
||||
public static void HttpRequestSend_Prefix(object __instance)
|
||||
{
|
||||
RewriteRequestUri(__instance, "http-rewrite");
|
||||
}
|
||||
|
||||
private static void RewriteRequestUri(object request, string label)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uriProp = request.GetType().GetProperty("Uri", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (uriProp?.GetValue(request) is not Uri uri) return;
|
||||
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return;
|
||||
|
||||
uriProp.SetValue(request, rewritten);
|
||||
Mod.Log.Msg($"[{label}] {uri} → {rewritten}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[{label}] failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RewriteString(ref string uriString, string label)
|
||||
{
|
||||
if (string.IsNullOrEmpty(uriString)) return;
|
||||
if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return;
|
||||
|
||||
if (!string.IsNullOrEmpty(Mod.Cfg.SingleOriginBaseUrl) &&
|
||||
Uri.TryCreate(uriString, UriKind.Absolute, out var uri))
|
||||
{
|
||||
var rewrittenUri = RewriteUri(uri);
|
||||
if (rewrittenUri is null) return;
|
||||
var rewrittenText = rewrittenUri.ToString();
|
||||
if (rewrittenText == uriString) return;
|
||||
Mod.Log.Msg($"[{label}] {uriString} → {rewrittenText}");
|
||||
uriString = rewrittenText;
|
||||
return;
|
||||
}
|
||||
|
||||
var host = Mod.Cfg.ServerHost;
|
||||
if (string.IsNullOrEmpty(host)) return;
|
||||
var rewritten = uriString.Replace(".rec.net", "." + host);
|
||||
if (rewritten == uriString) return;
|
||||
Mod.Log.Msg($"[{label}] {uriString} → {rewritten}");
|
||||
uriString = rewritten;
|
||||
}
|
||||
|
||||
private static Uri? RewriteUri(Uri uri)
|
||||
{
|
||||
var host = Mod.Cfg.ServerHost;
|
||||
if (string.IsNullOrEmpty(host)) return null;
|
||||
if (!uri.Host.EndsWith(".rec.net", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
|
||||
var service = uri.Host[..^".rec.net".Length];
|
||||
if (string.IsNullOrWhiteSpace(service)) return null;
|
||||
|
||||
if (!string.IsNullOrEmpty(Mod.Cfg.SingleOriginBaseUrl) &&
|
||||
Uri.TryCreate(Mod.Cfg.SingleOriginBaseUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
var path = CombinePath(baseUri.AbsolutePath, "__dn/" + service, uri.AbsolutePath);
|
||||
return new UriBuilder(baseUri)
|
||||
{
|
||||
Path = path,
|
||||
Query = uri.Query.TrimStart('?'),
|
||||
}.Uri;
|
||||
}
|
||||
|
||||
return new UriBuilder(uri)
|
||||
{
|
||||
Host = service + "." + host,
|
||||
}.Uri;
|
||||
}
|
||||
|
||||
private static string CombinePath(params string[] parts)
|
||||
{
|
||||
var cleaned = new List<string>();
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var p = (part ?? "").Trim('/');
|
||||
if (p.Length > 0) cleaned.Add(p);
|
||||
}
|
||||
return "/" + string.Join("/", cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class PhotonPatches
|
||||
{
|
||||
/// <summary>True once <see cref="TryApplyOverride"/> has successfully
|
||||
/// written the user's AppId into <c>PhotonServerSettings</c>. The
|
||||
/// per-frame poller in <see cref="Mod.OnUpdate"/> stops calling once
|
||||
/// this flips true.</summary>
|
||||
public static bool OverrideApplied;
|
||||
|
||||
public static void PhotonAppIdOverride_Prefix()
|
||||
{
|
||||
// Kept as a belt-and-braces hook in case the watch's actual
|
||||
// connect path does happen to flow through ConnectUsingSettings
|
||||
// (it doesn't, in March's IL2CPP build — verified via log). The
|
||||
// poller in OnUpdate is what actually gets the override in
|
||||
// before the wire connect.
|
||||
TryApplyOverride(reason: "prefix");
|
||||
}
|
||||
|
||||
/// <summary>Tracks how many polling attempts we've logged so the
|
||||
/// "type not found yet" line doesn't drown the log file. Cleared
|
||||
/// once the override succeeds.</summary>
|
||||
private static int _missLogCount;
|
||||
|
||||
/// <summary>Mutates <see cref="PhotonServerSettings.AppID"/> +
|
||||
/// <c>VoiceAppID</c> + region with the values from
|
||||
/// <see cref="Mod.Cfg"/>. Returns true once the override has been
|
||||
/// written successfully; subsequent calls become no-ops. Returns
|
||||
/// false (and logs nothing on most retries) when PhotonServerSettings
|
||||
/// hasn't been loaded by the watch yet — the poller retries every
|
||||
/// frame until the ScriptableObject shows up.</summary>
|
||||
public static bool TryApplyOverride(string reason)
|
||||
{
|
||||
if (OverrideApplied) return true;
|
||||
if (string.IsNullOrEmpty(Mod.Cfg.PhotonAppId)) return false;
|
||||
try
|
||||
{
|
||||
// Use Mod.ResolveType (the same resolver TryPatchByName uses)
|
||||
// — it falls back to a full assembly scan after the prefix
|
||||
// attempts, which is how it finds the IL2CPP-mangled types
|
||||
// that the local FindGameType helper misses.
|
||||
var photonNetwork = Mod.ResolveType("PhotonNetwork");
|
||||
if (photonNetwork is null)
|
||||
{
|
||||
// Log the miss only on the first call + once every ~10s
|
||||
// after that, to avoid the log spam previous build hit.
|
||||
if (_missLogCount == 0 || _missLogCount % 300 == 0)
|
||||
Mod.Log.Msg($"[photon-appid] PhotonNetwork type not found yet (#{_missLogCount}, {reason})");
|
||||
_missLogCount++;
|
||||
return false;
|
||||
}
|
||||
var settingsProp = photonNetwork.GetProperty("PhotonServerSettings", BindingFlags.Public | BindingFlags.Static);
|
||||
var settings = settingsProp?.GetValue(null);
|
||||
if (settings is null) return false; // ScriptableObject not loaded yet; quietly retry next frame.
|
||||
|
||||
var settingsType = settings.GetType();
|
||||
var appIdProp = settingsType.GetProperty("AppID") ?? settingsType.GetProperty("AppId");
|
||||
var voiceProp = settingsType.GetProperty("VoiceAppID") ?? settingsType.GetProperty("VoiceAppId");
|
||||
|
||||
var newId = Mod.Cfg.PhotonAppId;
|
||||
var newVoice = string.IsNullOrEmpty(Mod.Cfg.PhotonVoiceAppId) ? newId : Mod.Cfg.PhotonVoiceAppId;
|
||||
|
||||
appIdProp?.SetValue(settings, newId);
|
||||
voiceProp?.SetValue(settings, newVoice);
|
||||
Mod.Log.Msg($"[photon-appid] override AppID/VoiceAppID → {newId} / {newVoice} ({reason})");
|
||||
|
||||
// Force a deterministic Photon region so two watches running
|
||||
// this mod end up on the same Photon master and "JoinByName"
|
||||
// produces ONE shared room (not two parallel ones in eu/us
|
||||
// because each watch auto-picked a different best region).
|
||||
//
|
||||
// The 2020 watch's default HostType is BestRegion(4) — every
|
||||
// launch pings all Photon Cloud regions and picks the lowest
|
||||
// latency. Two players geographically apart hit different
|
||||
// "best" regions; same room name, two regions = two rooms.
|
||||
// Server-side our matchmaking response carries
|
||||
// `photonRegionId` but the watch's Photon code never reads
|
||||
// it; the only consumer is Matchmaking.SetPhotonRegionPings
|
||||
// which only reports pings *back* to the server.
|
||||
//
|
||||
// Strategy: switch HostType to PhotonCloud(1) and set
|
||||
// PreferredRegion. Prefer the server-told region (the most
|
||||
// recent Matchmaking.LocalRoomInstance.PhotonRegionId, if
|
||||
// any) over the config default — that way a server-side
|
||||
// region change propagates without redeploying the mod.
|
||||
ApplyForcedRegion(settings, settingsType);
|
||||
OverrideApplied = true;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[photon-appid] override failed ({reason}): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forces <c>PhotonServerSettings.HostType = PhotonCloud</c>
|
||||
/// and writes <c>PreferredRegion</c>. Honors any region the server
|
||||
/// has handed us in <c>Matchmaking.LocalRoomInstance.PhotonRegionId</c>
|
||||
/// first; falls back to <c>Cfg.PhotonCloudRegion</c> when no room
|
||||
/// instance has been received yet (initial boot).</summary>
|
||||
private static void ApplyForcedRegion(object settings, Type settingsType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hostTypeProp = settingsType.GetField("HostType")
|
||||
?? (System.Reflection.MemberInfo?)settingsType.GetProperty("HostType");
|
||||
var preferredProp = settingsType.GetField("PreferredRegion")
|
||||
?? (System.Reflection.MemberInfo?)settingsType.GetProperty("PreferredRegion");
|
||||
if (hostTypeProp is null || preferredProp is null)
|
||||
{
|
||||
Mod.Log.Warning("[photon-region] PhotonServerSettings.HostType/PreferredRegion not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// ServerSettings.HostingOption.PhotonCloud = 1 (verified in
|
||||
// Cpp2IL_CS/.../ServerSettings.cs:8). Resolve the actual enum
|
||||
// type from the field/property so we don't depend on a
|
||||
// specific Il2Cpp proxy namespace.
|
||||
Type? hostTypeEnum = (hostTypeProp as FieldInfo)?.FieldType
|
||||
?? ((PropertyInfo)hostTypeProp).PropertyType;
|
||||
Type? regionEnum = (preferredProp as FieldInfo)?.FieldType
|
||||
?? ((PropertyInfo)preferredProp).PropertyType;
|
||||
|
||||
// Prefer server-provided region over config. We read it via
|
||||
// the same reflection style as the existing patches so the
|
||||
// Il2Cpp type-name suffix doesn't bite us.
|
||||
string region = ReadServerProvidedRegion() ?? Mod.Cfg.PhotonCloudRegion ?? "us";
|
||||
|
||||
object hostValue = Enum.ToObject(hostTypeEnum!, 1); // PhotonCloud
|
||||
object regionValue;
|
||||
try { regionValue = Enum.Parse(regionEnum!, region.ToLowerInvariant(), ignoreCase: true); }
|
||||
catch
|
||||
{
|
||||
Mod.Log.Warning($"[photon-region] '{region}' is not a CloudRegionCode enum value; defaulting to us");
|
||||
regionValue = Enum.ToObject(regionEnum!, 1); // CloudRegionCode.us = 1
|
||||
region = "us";
|
||||
}
|
||||
|
||||
if (hostTypeProp is FieldInfo hostField) hostField.SetValue(settings, hostValue);
|
||||
else ((PropertyInfo)hostTypeProp).SetValue(settings, hostValue);
|
||||
if (preferredProp is FieldInfo prefField) prefField.SetValue(settings, regionValue);
|
||||
else ((PropertyInfo)preferredProp).SetValue(settings, regionValue);
|
||||
|
||||
Mod.Log.Msg($"[photon-region] forced HostType=PhotonCloud PreferredRegion={region}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[photon-region] apply failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Reads
|
||||
/// <c>RecNet.Matchmaking.LocalRoomInstance.PhotonRegionId</c> as a
|
||||
/// lowercase enum name (eu, us, asia, …). Returns null if no room
|
||||
/// instance has been received yet, or if any link in the chain is
|
||||
/// null. Server-told region wins over the static config default
|
||||
/// because the server is authoritative about which Photon region
|
||||
/// hosts the room the watch is about to join.</summary>
|
||||
private static string? ReadServerProvidedRegion()
|
||||
{
|
||||
try
|
||||
{
|
||||
var matchmaking = FindGameType("RecNet.Matchmaking");
|
||||
var localRoom = matchmaking?.GetProperty("LocalRoomInstance", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localRoom is null) return null;
|
||||
var regionObj = localRoom.GetType().GetProperty("PhotonRegionId")?.GetValue(localRoom);
|
||||
if (regionObj is null) return null;
|
||||
// CloudRegionCode enum.ToString() gives the lowercase region
|
||||
// name (eu, us, asia, …) — see CloudRegionCode.cs.
|
||||
return regionObj.ToString();
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// AuthValuesInjector_Prefix + GetCurrentLockToken parked 2026-05-28
|
||||
// — see attic/AuthValuesInjector.cs.attic. Photon Custom Auth is
|
||||
// now disabled at the AppId level in the Photon dashboard, so the
|
||||
// watch no longer needs to attach userid+LoginLock to Photon
|
||||
// Authenticate ops.
|
||||
|
||||
private static Type? FindGameType(string name) => Mod.ResolveType(name);
|
||||
}
|
||||
|
||||
internal static class SavePatches
|
||||
{
|
||||
// Last DataBlobName parsed off a SubscriptionUpdateRoom push for
|
||||
// our currently-loaded room. Captured BEFORE the watch's caching
|
||||
// gate (Rooms.OnSubscriptionUpdateRoom early-outs on a repeat
|
||||
// (roomId, subRoomId) tuple — which is every post-save push for a
|
||||
// dorm). SetRoomDataBlobName_Prefix reads this to hijack the
|
||||
// would-be no-op setter call into a real transition.
|
||||
private static volatile string? s_lastPushedBlobName;
|
||||
private static long s_lastPushedRoomId;
|
||||
private static long s_lastPushedSubRoomId;
|
||||
|
||||
// Hook on Notifications.OnNotificationReceived(string json). Fires for
|
||||
// every SignalR message before any handler dispatch. Parses the JSON
|
||||
// and, when the Id is SubscriptionUpdateRoom, stashes the new blob.
|
||||
public static void OnNotificationReceived_Prefix(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return;
|
||||
// Fast reject: only parse JSON if it could plausibly be a
|
||||
// SubscriptionUpdateRoom payload. Saves cycles on the (very
|
||||
// frequent) RoomInstanceUpdate / PresenceUpdate / chat pushes.
|
||||
if (message.IndexOf("\"SubscriptionUpdateRoom\"", StringComparison.Ordinal) < 0)
|
||||
return;
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(message);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
if (!root.TryGetProperty("Id", out var idEl) ||
|
||||
idEl.ValueKind != System.Text.Json.JsonValueKind.String ||
|
||||
!string.Equals(idEl.GetString(), "SubscriptionUpdateRoom", StringComparison.Ordinal))
|
||||
return;
|
||||
if (!root.TryGetProperty("Msg", out var msg) ||
|
||||
msg.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
if (!msg.TryGetProperty("Room", out var room) ||
|
||||
room.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||
long roomId = 0;
|
||||
if (room.TryGetProperty("RoomId", out var ridEl) &&
|
||||
ridEl.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
roomId = ridEl.GetInt64();
|
||||
if (!msg.TryGetProperty("Scenes", out var scenes) ||
|
||||
scenes.ValueKind != System.Text.Json.JsonValueKind.Array ||
|
||||
scenes.GetArrayLength() == 0) return;
|
||||
var first = scenes[0];
|
||||
long subRoomId = 0;
|
||||
if (first.TryGetProperty("RoomSceneId", out var sidEl) &&
|
||||
sidEl.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
subRoomId = sidEl.GetInt64();
|
||||
if (!first.TryGetProperty("DataBlobName", out var blobEl) ||
|
||||
blobEl.ValueKind != System.Text.Json.JsonValueKind.String) return;
|
||||
var blob = blobEl.GetString();
|
||||
if (string.IsNullOrEmpty(blob)) return;
|
||||
s_lastPushedRoomId = roomId;
|
||||
s_lastPushedSubRoomId = subRoomId;
|
||||
s_lastPushedBlobName = blob;
|
||||
Mod.Log.Msg($"[save-reload-fix] captured push via OnNotificationReceived: roomId={roomId} subRoomId={subRoomId} blob={blob}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[save-reload-fix] OnNotificationReceived prefix failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void OnSubscriptionUpdateRoom_Prefix(object message)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (message is null) return;
|
||||
var dict = message;
|
||||
|
||||
// The watch parses this dict as Dictionary<string, object>
|
||||
// via Util.GetKey. We need three keys: RoomId, SubRoomId,
|
||||
// and Scenes[0].DataBlobName. The Il2Cpp dict supports
|
||||
// get_Item(key) — call it via reflection.
|
||||
var getItem = dict.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (getItem is null) return;
|
||||
|
||||
// The push wire shape from server (BuildRoomDetails) is
|
||||
// {"Room":{...}, "Scenes":[{...}], ...}. The Room id lives
|
||||
// on the Room sub-object; the scene's blob on Scenes[0].
|
||||
object? roomObj = TryGet(dict, getItem, "Room");
|
||||
object? scenesObj = TryGet(dict, getItem, "Scenes");
|
||||
if (roomObj is null || scenesObj is null) return;
|
||||
|
||||
long roomId = 0;
|
||||
long subRoomId = 0;
|
||||
try
|
||||
{
|
||||
var roomItem = roomObj.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (roomItem is not null)
|
||||
{
|
||||
var rid = roomItem.Invoke(roomObj, new object[] { "RoomId" });
|
||||
if (rid is not null) roomId = Convert.ToInt64(rid.ToString());
|
||||
}
|
||||
}
|
||||
catch { /* roomId stays 0 — we'll log it and bail later */ }
|
||||
|
||||
// Scenes is an IL2CPP List<RoomScene>. Take the first
|
||||
// (current dorm has one scene; multi-scene rooms target
|
||||
// Scenes[currentSubRoomId] specifically, but the post-save
|
||||
// push always carries the scene that was just saved).
|
||||
object? firstScene = null;
|
||||
try
|
||||
{
|
||||
var scenesIndexer = scenesObj.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
firstScene = scenesIndexer?.Invoke(scenesObj, new object[] { 0 });
|
||||
}
|
||||
catch { /* leave firstScene null */ }
|
||||
if (firstScene is null) return;
|
||||
|
||||
string? newBlob = null;
|
||||
try
|
||||
{
|
||||
var sceneItem = firstScene.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||
if (sceneItem is not null)
|
||||
{
|
||||
var blob = sceneItem.Invoke(firstScene, new object[] { "DataBlobName" });
|
||||
newBlob = blob?.ToString();
|
||||
var sid = sceneItem.Invoke(firstScene, new object[] { "RoomSceneId" });
|
||||
if (sid is not null) subRoomId = Convert.ToInt64(sid.ToString());
|
||||
}
|
||||
}
|
||||
catch { /* leave newBlob null */ }
|
||||
|
||||
if (string.IsNullOrEmpty(newBlob)) return;
|
||||
|
||||
s_lastPushedRoomId = roomId;
|
||||
s_lastPushedSubRoomId = subRoomId;
|
||||
s_lastPushedBlobName = newBlob;
|
||||
Mod.Log.Msg($"[save-reload-fix] captured SubscriptionUpdateRoom push: roomId={roomId} subRoomId={subRoomId} blob={newBlob}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[save-reload-fix] OnSubscriptionUpdateRoom prefix failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static object? TryGet(object dict, MethodInfo getItem, string key)
|
||||
{
|
||||
try { return getItem.Invoke(dict, new object[] { key }); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// RoomPersistenceManager.set_RoomDataBlobName runs the change-event
|
||||
// path only when the new value differs from the cached field. The
|
||||
// 2020 watch's post-save propagation reads from RoomInstance.DataBlob
|
||||
// (set by /goto, never updated by SubscriptionUpdateRoom), so on a
|
||||
// save it calls the setter with the OLD blob — a no-op that skips
|
||||
// OnRoomDataBlobNameChanged. The cached PersistedRoomData stays
|
||||
// pointed at the boot-time bytes, and PostSaveReloading deserializes
|
||||
// those over the live world — wiping any MakerPen object the user
|
||||
// just placed.
|
||||
//
|
||||
// Live trace evidence (Latest.log, dorm save at 17:14): the watch's
|
||||
// own set_RoomDataBlobName fires with old="v10" new="v10" ~900ms
|
||||
// after b__4 returns True, even though the SubscriptionUpdateRoom
|
||||
// push already carried Scenes[0].DataBlobName="v11".
|
||||
//
|
||||
// Strategy: intercept the setter. If the incoming value matches the
|
||||
// current value (no-op) AND Rooms.LocalRoomScene.DataBlobName has a
|
||||
// newer version (the freshly-pushed one), substitute that into the
|
||||
// value parameter so the real setter sees a transition and fires
|
||||
// OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync(newBlob).
|
||||
// Falls through silently when no override is needed.
|
||||
public static void SetRoomDataBlobName_Prefix(object __instance, ref string value)
|
||||
{
|
||||
string currentForLog = "<unread>";
|
||||
string sceneForLog = "<unread>";
|
||||
string scenes0ForLog = "<unread>";
|
||||
try
|
||||
{
|
||||
if (__instance is null)
|
||||
{
|
||||
Mod.Log.Msg("[save-reload-fix] prefix: __instance null — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
var rpmType = __instance.GetType();
|
||||
var blobNameProp = rpmType.GetProperty("RoomDataBlobName",
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (blobNameProp is null)
|
||||
{
|
||||
Mod.Log.Msg($"[save-reload-fix] prefix: RoomDataBlobName property not found on {rpmType.FullName}");
|
||||
return;
|
||||
}
|
||||
currentForLog = (blobNameProp.GetValue(__instance) as string) ?? "<null>";
|
||||
|
||||
var roomsType = FindGameType("RecNet.Rooms");
|
||||
if (roomsType is null)
|
||||
{
|
||||
Mod.Log.Msg("[save-reload-fix] prefix: RecNet.Rooms type not found");
|
||||
return;
|
||||
}
|
||||
|
||||
var localScene = roomsType.GetProperty("LocalRoomScene",
|
||||
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localScene is not null)
|
||||
sceneForLog = (localScene.GetType().GetProperty("DataBlobName")?.GetValue(localScene) as string) ?? "<null>";
|
||||
|
||||
var localDetails = roomsType.GetProperty("LocalRoomDetails",
|
||||
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
if (localDetails is not null)
|
||||
{
|
||||
var scenes = localDetails.GetType().GetProperty("Scenes")?.GetValue(localDetails);
|
||||
if (scenes is not null)
|
||||
{
|
||||
var indexer = scenes.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
var first = indexer?.Invoke(scenes, new object[] { 0 });
|
||||
if (first is not null)
|
||||
scenes0ForLog = (first.GetType().GetProperty("DataBlobName")?.GetValue(first) as string) ?? "<null>";
|
||||
}
|
||||
}
|
||||
|
||||
// Unconditional trace of EVERYTHING we read so we can see in
|
||||
// the log exactly why we did or didn't override.
|
||||
Mod.Log.Msg($"[save-reload-fix] prefix fired: value=\"{value}\" current=\"{currentForLog}\" " +
|
||||
$"LocalRoomScene.DataBlobName=\"{sceneForLog}\" Scenes[0].DataBlobName=\"{scenes0ForLog}\" " +
|
||||
$"lastPushedBlob=\"{s_lastPushedBlobName ?? "<unset>"}\"");
|
||||
|
||||
// Pick a candidate "newer" blob name. Priority order:
|
||||
// 1. Last-pushed-from-SubscriptionUpdateRoom (captured by
|
||||
// OnSubscriptionUpdateRoom_Prefix BEFORE the watch's
|
||||
// cache gate drops the push).
|
||||
// 2. LocalRoomScene.DataBlobName.
|
||||
// 3. Scenes[0].DataBlobName.
|
||||
string? pushedBlob = s_lastPushedBlobName;
|
||||
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
|
||||
pushedBlob = sceneForLog is "<unread>" or "<null>" ? null : sceneForLog;
|
||||
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
|
||||
pushedBlob = scenes0ForLog is "<unread>" or "<null>" ? null : scenes0ForLog;
|
||||
|
||||
if (string.IsNullOrEmpty(pushedBlob)) return;
|
||||
if (string.Equals(pushedBlob, value, StringComparison.Ordinal)) return;
|
||||
if (string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal)) return;
|
||||
|
||||
Mod.Log.Msg($"[save-reload-fix] hijacking set_RoomDataBlobName({value}); substituting newer blob {pushedBlob}");
|
||||
value = pushedBlob;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[save-reload-fix] set_RoomDataBlobName prefix failed: {ex.Message} " +
|
||||
$"(value=\"{value}\" current=\"{currentForLog}\" scene=\"{sceneForLog}\" scenes0=\"{scenes0ForLog}\")");
|
||||
}
|
||||
}
|
||||
|
||||
private static Type? FindGameType(string name) => Mod.ResolveType(name);
|
||||
}
|
||||
|
||||
internal static class TlsPatches
|
||||
{
|
||||
// Both BouncyCastle TLS authenticators have the same `void
|
||||
// NotifyServerCertificate(...)` signature whose body throws when the
|
||||
// chain doesn't validate. Returning false from a Harmony prefix on a
|
||||
// void method skips the original — the certificate is accepted by
|
||||
// virtue of NotifyServerCertificate never throwing.
|
||||
public static bool NotifyServerCertificate_Prefix()
|
||||
{
|
||||
return !Mod.Cfg.EnableTlsTrustBypass; // false skips original; true (bypass off) lets it run
|
||||
}
|
||||
}
|
||||
|
||||
internal static class DiagnosticPatches
|
||||
{
|
||||
public static void Write(string message)
|
||||
{
|
||||
var line = $"[{DateTime.Now:HH:mm:ss.fff}] [diagnostic] {message}";
|
||||
try { Mod.Log.Msg(line); } catch { }
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(Mod.DiagnosticsLogPath))
|
||||
File.AppendAllText(Mod.DiagnosticsLogPath, line + Environment.NewLine);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static void ApplicationQuit_Prefix()
|
||||
{
|
||||
Write("[quit] UnityEngine.Application.Quit()");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void ApplicationQuitInt_Prefix(int exitCode)
|
||||
{
|
||||
Write($"[quit] UnityEngine.Application.Quit({exitCode})");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void BootSequenceRegisterError_Prefix(string error)
|
||||
{
|
||||
Write($"[boot-error] BootSequence.RegisterError: {error}");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void AnalyticsCheat_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[cheat-analytics] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static void NamedMethod_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[call] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
}
|
||||
|
||||
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
Write($"[quit-blocked] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||
WriteStack();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void WriteStack()
|
||||
{
|
||||
try
|
||||
{
|
||||
Write("[stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Write("[stack] failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ChatPatches
|
||||
{
|
||||
private static bool s_logged;
|
||||
|
||||
// Force RecNet.AccountExtensions.CanLocalPlayerChat to return true.
|
||||
// PlayerEmotes.SendChatEmoteCoroutine d__53 offset 040 calls this
|
||||
// before raising the in-room Photon chat RPC; if it returns false
|
||||
// the coroutine bails silently and the sender doesn't even see
|
||||
// their own message echo.
|
||||
public static void CanLocalPlayerChat_Postfix(ref bool __result)
|
||||
{
|
||||
if (!__result && !s_logged)
|
||||
{
|
||||
s_logged = true;
|
||||
Mod.Log.Msg("[chat-fix] CanLocalPlayerChat returned false; forcing true to unblock room chat");
|
||||
}
|
||||
__result = true;
|
||||
}
|
||||
|
||||
// Skip the original. Returning false from a Harmony prefix stops
|
||||
// the underlying call.
|
||||
public static bool RemoveInvalidCharactersFromMessage_Prefix()
|
||||
{
|
||||
Mod.Log.Msg("[chat-trace] RemoveInvalidCharactersFromMessage skipped");
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void SendChatEmote_Prefix(string message)
|
||||
{
|
||||
Mod.Log.Msg($"[chat-trace] SendChatEmote msg=\"{message}\"");
|
||||
}
|
||||
|
||||
public static void ProcessNewChatMessageReceived_Prefix(string message, bool playAudioForLocalPlayer)
|
||||
{
|
||||
Mod.Log.Msg($"[chat-trace] ProcessNewChatMessageReceived msg=\"{message}\" audio={playAudioForLocalPlayer}");
|
||||
}
|
||||
|
||||
public static void RpcChatEmote_Prefix(string message)
|
||||
{
|
||||
Mod.Log.Msg($"[chat-trace] RpcChatEmote (RPC RECEIVED) msg=\"{message}\"");
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// AuthValuesInjector — PARKED 2026-05-28
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Filename suffix is .cs.attic on purpose so the .NET SDK's globber
|
||||
// doesn't pick it up. Don't rename to .cs unless you're putting the
|
||||
// feature back.
|
||||
//
|
||||
// What this used to do
|
||||
// --------------------
|
||||
// Prefix-patched NetworkingPeer.CallAuthenticate so the 2020 watch
|
||||
// would attach a Photon Custom-Auth payload (`userid` +
|
||||
// `LoginLock`) to every Photon Authenticate op. The server's
|
||||
// /photon/customauth controller (PhotonCustomAuthController.cs) read
|
||||
// those parameters and validated them against the player's
|
||||
// /player/login lock.
|
||||
//
|
||||
// Why it's parked
|
||||
// ---------------
|
||||
// Photon Cloud's Custom Auth probe was getting 403'd at the WAF in
|
||||
// front of `auth.<apex>/photon/customauth` for hosts behind
|
||||
// Cloudflare or Tunnelto's proxy. The Photon NameServer then returned
|
||||
// `CustomAuthenticationFailed (ReturnCode 32755)` and the watch
|
||||
// bounced back to the dorm before a session could start.
|
||||
//
|
||||
// The cleaner fix turned out to be:
|
||||
// 1. Turn off Custom Authentication on the Photon AppId in the
|
||||
// dashboard (or flip "Anonymous connect" to Allow), so Photon
|
||||
// never calls our URL.
|
||||
// 2. Stop sending custom-auth params from the client — that's the
|
||||
// InjectAuthValues=false flag we shipped first. With the
|
||||
// dashboard side off too, the injector code below has nothing
|
||||
// to gate on, so it's removed from the live patch.
|
||||
//
|
||||
// How to put it back
|
||||
// ------------------
|
||||
// 1. Rename this file to AuthValuesInjector.cs (drop the .attic
|
||||
// suffix). It compiles as part of DorkNet.ClientMod.
|
||||
// 2. Reinstate the `InjectAuthValues` field on Mod.Cfg + the
|
||||
// config-load line in LoadConfig.
|
||||
// 3. Re-add the harmony patch hook in OnLateInitializeMelon:
|
||||
// TryPatchByName("NetworkingPeer", "CallAuthenticate",
|
||||
// prefix: nameof(PhotonPatches.AuthValuesInjector_Prefix));
|
||||
// 4. Flip `"InjectAuthValues": true` in
|
||||
// DorkNet.ClientMod/dorknet-clientmod.json.template (and the
|
||||
// copy under local-releases/).
|
||||
// 5. If you also want the server to enforce the lock, set
|
||||
// Photon:RequireCustomAuth=true on the server (env
|
||||
// Photon__RequireCustomAuth=true).
|
||||
//
|
||||
// The body below is verbatim what lived in Mod.cs's PhotonPatches
|
||||
// class. `FindGameType` is the same helper still in Mod.cs.
|
||||
|
||||
using System.Reflection;
|
||||
|
||||
namespace DorkNet.ClientMod.Attic;
|
||||
|
||||
internal static class AuthValuesInjector
|
||||
{
|
||||
public static void AuthValuesInjector_Prefix(object __instance)
|
||||
{
|
||||
if (!Mod.Cfg.InjectAuthValues) return;
|
||||
try
|
||||
{
|
||||
// Photon's AuthValues live on PhotonNetwork.AuthValues. The 2020
|
||||
// watch never sets them itself, so we set them right before
|
||||
// NetworkingPeer.CallAuthenticate forwards to the wire op. The
|
||||
// userid is RecNet.Core.LocalAccountId, the LoginLock comes
|
||||
// from PlayerPrefs key LoginLockTokenV2.
|
||||
// RecNet.Core.LocalAccountId is declared `int` in the game
|
||||
// build (dump.cs:573622 → 0x30 backing field). Convert via
|
||||
// Convert.ToInt64 so boxing as int still gives us the long we
|
||||
// need below; a direct (long) cast on boxed int would throw.
|
||||
var coreType = FindGameType("RecNet.Core");
|
||||
var accountIdObj = coreType?.GetProperty("LocalAccountId", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||
var accountId = accountIdObj is null ? 0L : Convert.ToInt64(accountIdObj);
|
||||
if (accountId <= 0)
|
||||
{
|
||||
Mod.Log.Msg("[auth-injector] skip — no LocalAccountId yet (pre-login Photon ping)");
|
||||
return;
|
||||
}
|
||||
|
||||
var photonNetwork = FindGameType("PhotonNetwork");
|
||||
var avProp = photonNetwork?.GetProperty("AuthValues", BindingFlags.Public | BindingFlags.Static);
|
||||
var existing = avProp?.GetValue(null);
|
||||
if (existing is not null)
|
||||
{
|
||||
var userIdProp = existing.GetType().GetProperty("UserId");
|
||||
var existingUserId = userIdProp?.GetValue(existing) as string;
|
||||
if (!string.IsNullOrEmpty(existingUserId)) return;
|
||||
}
|
||||
|
||||
var token = GetCurrentLockToken();
|
||||
|
||||
// Build a new AuthenticationValues. The type lives in the
|
||||
// ExitGames.Client.Photon.LoadBalancing namespace (game-side).
|
||||
// CustomAuthenticationType.Custom = 0 in this build.
|
||||
var authValuesType = FindGameType("ExitGames.Client.Photon.LoadBalancing.AuthenticationValues");
|
||||
if (authValuesType is null) { Mod.Log.Warning("[auth-injector] AuthenticationValues type not found"); return; }
|
||||
var av = Activator.CreateInstance(authValuesType);
|
||||
var authTypeProp = authValuesType.GetProperty("AuthType");
|
||||
if (authTypeProp is not null)
|
||||
{
|
||||
var customAuthValue = authTypeProp.PropertyType.IsEnum
|
||||
? Enum.ToObject(authTypeProp.PropertyType, 0)
|
||||
: Convert.ChangeType(0, authTypeProp.PropertyType);
|
||||
authTypeProp.SetValue(av, customAuthValue);
|
||||
}
|
||||
authValuesType.GetProperty("UserId")?.SetValue(av, accountId.ToString());
|
||||
var addParam = authValuesType.GetMethod("AddAuthParameter", new[] { typeof(string), typeof(string) });
|
||||
addParam?.Invoke(av, new object[] { "userid", accountId.ToString() });
|
||||
addParam?.Invoke(av, new object[] { "LoginLock", token ?? string.Empty });
|
||||
|
||||
avProp?.SetValue(null, av);
|
||||
Mod.Log.Msg($"[auth-injector] set AuthValues userid={accountId} LoginLock={(string.IsNullOrEmpty(token) ? "<missing>" : "<set>")}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[auth-injector] failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static string? GetCurrentLockToken()
|
||||
{
|
||||
try
|
||||
{
|
||||
var playerPrefs = FindGameType("UnityEngine.PlayerPrefs");
|
||||
var getString = playerPrefs?.GetMethod("GetString", new[] { typeof(string), typeof(string) });
|
||||
var raw = (string?)getString?.Invoke(null, new object[] { "LoginLockTokenV2", string.Empty });
|
||||
if (string.IsNullOrEmpty(raw)) return null;
|
||||
const string key = "\"LockToken\":\"";
|
||||
var ix = raw.IndexOf(key, StringComparison.Ordinal);
|
||||
if (ix < 0) return null;
|
||||
ix += key.Length;
|
||||
var end = raw.IndexOf('"', ix);
|
||||
return end < 0 ? null : raw[ix..end];
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// FindGameType is already defined inside Mod.cs's PhotonPatches
|
||||
// class (private static) — when re-instating, either drop this
|
||||
// private copy and inline the lookup, or move the helper to a
|
||||
// shared internal class. The current shape preserves the original
|
||||
// behaviour for an isolated paste-back.
|
||||
private static Type? FindGameType(string name)
|
||||
{
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = asm.GetType(name, throwOnError: false);
|
||||
if (t is not null) return t;
|
||||
var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false);
|
||||
if (prefixed is not null) return prefixed;
|
||||
var dot = name.IndexOf('.');
|
||||
if (dot > 0)
|
||||
{
|
||||
var nsPrefixed = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), throwOnError: false);
|
||||
if (nsPrefixed is not null) return nsPrefixed;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"ServerHost": "{HOST}",
|
||||
"SingleOriginBaseUrl": "{SINGLE_ORIGIN_BASE_URL}",
|
||||
"PhotonAppId": "{PHOTON_APPID}",
|
||||
"PhotonVoiceAppId": "{PHOTON_VOICE_APPID}",
|
||||
"PhotonCloudRegion": "{PHOTON_REGION}",
|
||||
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS}
|
||||
}
|
||||
Binary file not shown.
@@ -1 +0,0 @@
|
||||
471710
|
||||
@@ -1,36 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Login.CachedLogin — dump.cs:578388, Deserialize at RVA 0x1447BD0.
|
||||
///
|
||||
/// Verified by disassembly: reads platform, platformId, accountId,
|
||||
/// lastLoginTime (DateTime), requirePassword (default false). The Account
|
||||
/// field on the C# class is NOT read from the JSON dict — it is set via
|
||||
/// SetAccount(Account) after the fact, so the JSON shape doesn't need it.
|
||||
///
|
||||
/// Returned by:
|
||||
/// GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId}
|
||||
/// GET api.rec.net/api/platformlogin/cached
|
||||
/// GET accounts.rec.net/account/v1/savedlogins
|
||||
/// as a List<CachedLogin>. Empty list = no remembered accounts.
|
||||
/// </summary>
|
||||
public class CachedLogin
|
||||
{
|
||||
// PlatformManager.PlatformType (int)
|
||||
[JsonPropertyName("platform")]
|
||||
public int Platform { get; set; }
|
||||
|
||||
[JsonPropertyName("platformId")]
|
||||
public string PlatformId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("accountId")]
|
||||
public int AccountId { get; set; }
|
||||
|
||||
[JsonPropertyName("lastLoginTime")]
|
||||
public DateTime LastLoginTime { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[JsonPropertyName("requirePassword")]
|
||||
public bool RequirePassword { get; set; } = false;
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Auth;
|
||||
|
||||
// ── June-2018 (build 20180621_EA) login wire shapes ──────────────────────────
|
||||
//
|
||||
// The 2018 client speaks a DIFFERENT login protocol than the 2019/2020 OAuth
|
||||
// flow the rest of DorkNet implements:
|
||||
// • POST api/platformlogin/v1/{loginaccount,logincached,createaccount}
|
||||
// • request body is a JSON object (Unity JsonUtility / LitJson), NOT form
|
||||
// • response is the "login envelope" below, read by RecNet.cs:40014-40021
|
||||
// via case-sensitive LitJson keys.
|
||||
//
|
||||
// Keys verified from the assembly's REAL (name-preserved) Deserialize methods
|
||||
// via dnlib — the obfuscated-name sibling methods are Beebyte decoys with
|
||||
// scrambled literals (see project_2018_mono_obfuscation memory).
|
||||
|
||||
/// <summary>
|
||||
/// Login response envelope. RecNet login-response Deserialize (RecNet.cs:40016-40020)
|
||||
/// reads: Error (req string), Player (optional object), Token (req string),
|
||||
/// FirstLoginOfTheDay (req bool), AnalyticsSessionId (req long). Empty Error
|
||||
/// means success; a non-empty Error is shown to the user and aborts login.
|
||||
/// </summary>
|
||||
public class Login2018Response
|
||||
{
|
||||
[JsonPropertyName("Error")]
|
||||
public string Error { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Player")]
|
||||
public Player2018? Player { get; set; }
|
||||
|
||||
[JsonPropertyName("Token")]
|
||||
public string Token { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("FirstLoginOfTheDay")]
|
||||
public bool FirstLoginOfTheDay { get; set; }
|
||||
|
||||
[JsonPropertyName("AnalyticsSessionId")]
|
||||
public long AnalyticsSessionId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2018 player object embedded in the login envelope and returned bare from
|
||||
/// getcachedlogins. Keys + required/optional verified from RecNet.cs:64632-64667
|
||||
/// (RecNet.EEBPLECPEGD.Deserialize):
|
||||
/// REQUIRED (Util.GetKey throws if missing): Id, Username, DisplayName, XP,
|
||||
/// Level, RegistrationStatus, Developer, CanReceiveInvites, ProfileImageName,
|
||||
/// JuniorProfile, ForceJuniorImages, PendingJunior, HasBirthday.
|
||||
/// OPTIONAL: Bio (TryGetKey), AvoidJuniors (ContainsKey-guarded),
|
||||
/// PlayerReputation/PlatformId (GetObjectKey -> null ok; omitted here),
|
||||
/// GroupMemberships (ContainsKey-guarded; omitted).
|
||||
/// NOTE the 2018 client uses "Id" (not "AccountId") and "Developer" (not
|
||||
/// "IsDeveloper") — distinct from the 2020 PlayerProfile DTO.
|
||||
/// </summary>
|
||||
public class Player2018
|
||||
{
|
||||
[JsonPropertyName("Id")]
|
||||
public long Id { get; set; }
|
||||
|
||||
[JsonPropertyName("Username")]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("DisplayName")]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Bio")]
|
||||
public string Bio { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("XP")]
|
||||
public int XP { get; set; }
|
||||
|
||||
[JsonPropertyName("Level")]
|
||||
public int Level { get; set; } = 1;
|
||||
|
||||
// Registration step enum (ProfileSelectionManager.NFCBDCFLBOC):
|
||||
// invalid=-1, video=0, birthday=1, email=2, COMPLETE=10. Anything < 10
|
||||
// makes the client run the signup step flow (it got "stuck on birthday").
|
||||
// 10 = fully registered → client goes straight in-game.
|
||||
[JsonPropertyName("RegistrationStatus")]
|
||||
public int RegistrationStatus { get; set; } = 10;
|
||||
|
||||
[JsonPropertyName("Developer")]
|
||||
public bool Developer { get; set; }
|
||||
|
||||
[JsonPropertyName("CanReceiveInvites")]
|
||||
public bool CanReceiveInvites { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("ProfileImageName")]
|
||||
public string ProfileImageName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("JuniorProfile")]
|
||||
public bool JuniorProfile { get; set; }
|
||||
|
||||
[JsonPropertyName("ForceJuniorImages")]
|
||||
public bool ForceJuniorImages { get; set; }
|
||||
|
||||
[JsonPropertyName("PendingJunior")]
|
||||
public bool PendingJunior { get; set; }
|
||||
|
||||
[JsonPropertyName("HasBirthday")]
|
||||
public bool HasBirthday { get; set; }
|
||||
|
||||
[JsonPropertyName("AvoidJuniors")]
|
||||
public bool AvoidJuniors { get; set; }
|
||||
|
||||
// REQUIRED to be non-null: the post-login analytics Identify dereferences
|
||||
// localPlayer.PlayerReputation.<field> (RecNet.cs ~NECCOFKHCAA) — a null
|
||||
// here NREs the boot coroutine and the client hangs on splash_screen.
|
||||
[JsonPropertyName("PlayerReputation")]
|
||||
public PlayerReputation2018 PlayerReputation { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2018 PlayerReputation (RecNet.HOLNFPNKNGB.Deserialize) — all int. Keys exact
|
||||
/// incl. the "Noteriety" misspelling. Must be present (non-null) on the player.
|
||||
/// </summary>
|
||||
public class PlayerReputation2018
|
||||
{
|
||||
[JsonPropertyName("Noteriety")] public int Noteriety { get; set; }
|
||||
[JsonPropertyName("CheerGeneral")] public int CheerGeneral { get; set; }
|
||||
[JsonPropertyName("CheerHelpful")] public int CheerHelpful { get; set; }
|
||||
[JsonPropertyName("CheerGreatHost")] public int CheerGreatHost { get; set; }
|
||||
[JsonPropertyName("CheerSportsman")] public int CheerSportsman { get; set; }
|
||||
[JsonPropertyName("CheerCreative")] public int CheerCreative { get; set; }
|
||||
[JsonPropertyName("CheerCredit")] public int CheerCredit { get; set; }
|
||||
[JsonPropertyName("SubscriberCount")] public int SubscriberCount { get; set; }
|
||||
[JsonPropertyName("SubscribedCount")] public int SubscribedCount { get; set; }
|
||||
[JsonPropertyName("SelectedCheer")] public int SelectedCheer { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generic 2018 success envelope {Success, Message} (RecNet EFDOILDKBFK) used by
|
||||
/// registeraccount (email attach) and other simple POSTs. Note the key is
|
||||
/// "Message" (not "error").
|
||||
/// </summary>
|
||||
public class SuccessMessage2018
|
||||
{
|
||||
[JsonPropertyName("Success")]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[JsonPropertyName("Message")]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace DorkNet.Models.Auth;
|
||||
|
||||
public class LoginRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public long BuildTimestamp { get; set; }
|
||||
public int Platform { get; set; }
|
||||
public string? DeviceId { get; set; }
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Login.LoginResponse — dump.cs:578473 (private nested class),
|
||||
/// Deserialize at RVA 0x1447D10.
|
||||
///
|
||||
/// Verified by disassembly: this is a standard OAuth 2.0 RFC 6749
|
||||
/// password-grant token response. The Deserialize body has two branches:
|
||||
///
|
||||
/// error = dict["error"];
|
||||
/// if (string.IsNullOrEmpty(error)) {
|
||||
/// this.AccessToken = dict["access_token"];
|
||||
/// this.RefreshToken = dict["refresh_token"];
|
||||
/// } else {
|
||||
/// this.ErrorDescription = dict["error_description"];
|
||||
/// }
|
||||
///
|
||||
/// Returned by api/platformlogin/v5 (the 2020 build's login endpoint)
|
||||
/// and api/platformlogin/refresh.
|
||||
///
|
||||
/// All four keys are snake_case, NOT PascalCase. This is the standard
|
||||
/// OAuth response format — Rec Room conforms to RFC 6749 here.
|
||||
/// </summary>
|
||||
public class LoginResponse
|
||||
{
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; set; }
|
||||
|
||||
[JsonPropertyName("error_description")]
|
||||
public string? ErrorDescription { get; set; }
|
||||
|
||||
[JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("refresh_token")]
|
||||
public string RefreshToken { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Auth;
|
||||
|
||||
// =====================================================================
|
||||
// JSON keys verified by disassembling each .Deserialize method body
|
||||
// directly from GameAssembly.dll. Each key here is the literal string
|
||||
// passed to Util.GetKey / Util.GetKeyOrDefault inside the client. Util.GetKey
|
||||
// uses Dictionary<string,object>.TryGetValue, which is case-sensitive — so
|
||||
// the keys MUST match exactly.
|
||||
//
|
||||
// Convention summary (it's mixed):
|
||||
// RecNetResult lowercase "success", "error"
|
||||
// Account camelCase "accountId", "displayName", "isJunior"…
|
||||
// SelfAccount camelCase "email", "birthday", "juniorState"…
|
||||
// LoginResponse snake_case (OAuth) "access_token", "error_description"…
|
||||
// RefreshLoginResp. lowercase "token"
|
||||
// CachedLogin camelCase "platformId", "lastLoginTime"…
|
||||
// CreateAccountResp uses "value" as the wrapper key for the Account dict
|
||||
//
|
||||
// NOTE: TreatAsJunior on the C# side maps to JSON key "isJunior" — they
|
||||
// don't match. Account.Deserialize reads dict["isJunior"], not dict["treatAsJunior"].
|
||||
// =====================================================================
|
||||
|
||||
/// <summary>
|
||||
/// RecNet.RecNetResult — dump.cs:584325, Deserialize at RVA 0x1146340.
|
||||
/// Reads "success" (required) and "error" (optional).
|
||||
/// </summary>
|
||||
public class RecNetResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RecNet.Account — dump.cs:567684, Deserialize at RVA 0xF9DE50.
|
||||
/// JSON keys verified in the current 2020 IL2CPP build. Account.Deserialize
|
||||
/// reads profileImage.
|
||||
/// Other keys: accountId, username, displayName, isJunior, platforms. The C# Account class also has
|
||||
/// RawUsername and HasBirthday properties but Deserialize does not read
|
||||
/// them from JSON (they're set via MakeNameAdhereToPlatformRequirements
|
||||
/// or other flows). We expose them with reasonable camelCase keys for
|
||||
/// other endpoints that may consume them.
|
||||
/// </summary>
|
||||
public class RecNetAccount
|
||||
{
|
||||
private string profileImage = string.Empty;
|
||||
|
||||
[JsonPropertyName("accountId")]
|
||||
public int AccountId { get; set; }
|
||||
|
||||
[JsonPropertyName("username")]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("profileImage")]
|
||||
public string ProfileImage
|
||||
{
|
||||
get => profileImage;
|
||||
set => profileImage = value ?? string.Empty;
|
||||
}
|
||||
|
||||
// Account.Deserialize reads "isJunior" — NOT "treatAsJunior".
|
||||
// The C# property name and the JSON key name differ.
|
||||
[JsonPropertyName("isJunior")]
|
||||
public bool TreatAsJunior { get; set; } = false;
|
||||
|
||||
// PlatformManager.PlatformMask: Steam=1, Oculus=2, etc.
|
||||
[JsonPropertyName("platforms")]
|
||||
public int Platforms { get; set; } = 1;
|
||||
|
||||
// Not in Account.Deserialize body but kept for forward-compat.
|
||||
[JsonPropertyName("rawUsername")]
|
||||
public string RawUsername { get; set; } = string.Empty;
|
||||
|
||||
// Likewise.
|
||||
[JsonPropertyName("hasBirthday")]
|
||||
public bool HasBirthday { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RecNet.SelfAccount : Account — dump.cs:567797, Deserialize at RVA 0xAF4FB0.
|
||||
/// Calls base Account.Deserialize, then reads:
|
||||
/// email, phone, birthday, juniorState, parentAccountId.
|
||||
/// </summary>
|
||||
public class RecNetSelfAccount : RecNetAccount
|
||||
{
|
||||
[JsonPropertyName("email")]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("phone")]
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("birthday")]
|
||||
public DateTime? Birthday { get; set; } = new DateTime(2000, 1, 1);
|
||||
|
||||
// RecNet.JuniorState enum (int)
|
||||
[JsonPropertyName("juniorState")]
|
||||
public int JuniorState { get; set; } = 0;
|
||||
|
||||
[JsonPropertyName("parentAccountId")]
|
||||
public int? ParentAccountId { get; set; } = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accounts.CreateAccountResponse : RecNetResult — dump.cs:568167,
|
||||
/// Deserialize at RVA 0xFAA200.
|
||||
/// Calls base RecNetResult.Deserialize (reads "success", "error"),
|
||||
/// then reads the Account from dict["value"] — NOT dict["account"].
|
||||
/// Verified by disassembly: the only string literal after the base
|
||||
/// call is "value", passed to Util.GetObjectKey<object>.
|
||||
/// </summary>
|
||||
public class CreateAccountResponse : RecNetResult
|
||||
{
|
||||
[JsonPropertyName("value")]
|
||||
public RecNetAccount Account { get; set; } = new();
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Login.RefreshLoginResponse — dump.cs:578533 (private nested class),
|
||||
/// Deserialize at RVA 0x145B1E0.
|
||||
///
|
||||
/// Verified by disassembly: reads exactly one key — lowercase "token" —
|
||||
/// via Util.GetKey<string>. Single-field response from
|
||||
/// POST api/platformlogin/refresh: the new (rotated) bearer access token.
|
||||
/// </summary>
|
||||
public class RefreshLoginResponse
|
||||
{
|
||||
[JsonPropertyName("token")]
|
||||
public string Token { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Avatar;
|
||||
|
||||
public class AvatarData
|
||||
{
|
||||
[JsonPropertyName("AccountId")]
|
||||
public long AccountId { get; set; }
|
||||
|
||||
[JsonPropertyName("AvatarItemInstances")]
|
||||
public List<AvatarItemInstance> AvatarItemInstances { get; set; } = [];
|
||||
}
|
||||
|
||||
public class AvatarItemInstance
|
||||
{
|
||||
[JsonPropertyName("Id")]
|
||||
public long Id { get; set; }
|
||||
|
||||
[JsonPropertyName("ItemId")]
|
||||
public long ItemId { get; set; }
|
||||
|
||||
[JsonPropertyName("IsEquipped")]
|
||||
public bool IsEquipped { get; set; }
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Avatar;
|
||||
|
||||
public class AvatarItem
|
||||
{
|
||||
[JsonPropertyName("ItemId")]
|
||||
public long ItemId { get; set; }
|
||||
|
||||
[JsonPropertyName("Name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Equipped")]
|
||||
public bool Equipped { get; set; }
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Config;
|
||||
|
||||
// Required keys per RecNet.RecRoomConfig.Deserialize disassembly (RVA 0x1146FA0).
|
||||
// Required-throw keys: ShareBaseUrl, LevelProgressionMaps[].Level, [].RequiredXp.
|
||||
// Required-object keys (GetObjectKey, accept null): ServerMaintenance,
|
||||
// AutoMicMutingConfig.
|
||||
//
|
||||
// NOTE: the level entry uses "RequiredXp" (lowercase 'p') per the IL string
|
||||
// literal — NOT "RequiredXP". A capital P here would throw KeyNotFoundException.
|
||||
public class RecRoomConfig
|
||||
{
|
||||
[JsonPropertyName("MessageOfTheDay")]
|
||||
public string MessageOfTheDay { get; set; } = "Welcome to the private server!";
|
||||
|
||||
[JsonPropertyName("CdnBaseUri")]
|
||||
public string CdnBaseUri { get; set; } = string.Empty;
|
||||
|
||||
// Required by client (string, GetKey). Used for share-link generation.
|
||||
[JsonPropertyName("ShareBaseUrl")]
|
||||
public string ShareBaseUrl { get; set; } = "https://rec.net/";
|
||||
|
||||
// GetObjectKey<ServerMaintenanceDTO>. Required key. The deserializer
|
||||
// accepts null without throwing, but the C# property getter on the
|
||||
// client doesn't null-check before use during boot. Send a real DTO
|
||||
// with StartsInMinutes far in the future = "no upcoming maintenance".
|
||||
[JsonPropertyName("ServerMaintenance")]
|
||||
public ServerMaintenanceDTO? ServerMaintenance { get; set; } = new();
|
||||
|
||||
// GetObjectKey<AutoMicMutingConfig>. Required by the client AND
|
||||
// dereferenced by AudioManager.Initialize during PostLogin boot —
|
||||
// null causes:
|
||||
// NullReferenceException at RecNet.Config.get_AutoMicMutingConfig
|
||||
// at AudioManager.Initialize
|
||||
// which kills the boot sequence with "Error initializing Core Systems".
|
||||
// Must be a real object with all 8 float fields populated.
|
||||
[JsonPropertyName("AutoMicMutingConfig")]
|
||||
public AutoMicMutingConfig AutoMicMutingConfig { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("PhotonConfig")]
|
||||
public PhotonConfig PhotonConfig { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("MatchmakingParams")]
|
||||
public MatchmakingParams MatchmakingParams { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("LevelProgressionMaps")]
|
||||
public List<LevelProgressionEntry> LevelProgressionMaps { get; set; } = LevelProgressionEntry.Default();
|
||||
|
||||
[JsonPropertyName("DailyObjectives")]
|
||||
public List<List<DailyObjective>> DailyObjectives { get; set; } = DailyObjective.DefaultSets();
|
||||
|
||||
// 2018.06 client (RecNet.OPOHNGAOCCD.Deserialize, RecNet.cs:9062-9068) reads
|
||||
// ConfigTable as an ARRAY of {Key,Value} objects, NOT a JSON object map:
|
||||
// foreach elem in dict["ConfigTable"]: table[elem["Key"]] = elem["Value"]
|
||||
// The 2020 client read it as an object — but this branch targets 2018, so we
|
||||
// emit the array form. Keep the settable Dictionary for existing call sites
|
||||
// (ConfigService.GetConfig populates it) and project to the wire array.
|
||||
[JsonIgnore]
|
||||
public Dictionary<string, string> ConfigTable { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("ConfigTable")]
|
||||
public List<ConfigTableEntry> ConfigTableWire =>
|
||||
ConfigTable.Select(kv => new ConfigTableEntry { Key = kv.Key, Value = kv.Value }).ToList();
|
||||
|
||||
[JsonPropertyName("ServiceUrls")]
|
||||
public Dictionary<string, string> ServiceUrls { get; set; } = [];
|
||||
}
|
||||
|
||||
// 2018 ConfigTable element shape: {"Key": "...", "Value": "..."} (PascalCase,
|
||||
// LitJson Util.GetKey<string>("Key"/"Value")).
|
||||
public class ConfigTableEntry
|
||||
{
|
||||
[JsonPropertyName("Key")]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Value")]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class PhotonConfig
|
||||
{
|
||||
[JsonPropertyName("AppId")]
|
||||
public string AppId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("VoiceAppId")]
|
||||
public string VoiceAppId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("CloudRegion")]
|
||||
public string CloudRegion { get; set; } = "us";
|
||||
|
||||
[JsonPropertyName("CrcCheckEnabled")]
|
||||
public bool CrcCheckEnabled { get; set; } = false;
|
||||
|
||||
[JsonPropertyName("EnableServerTracingAfterDisconnect")]
|
||||
public bool EnableServerTracingAfterDisconnect { get; set; } = false;
|
||||
}
|
||||
|
||||
public class MatchmakingParams
|
||||
{
|
||||
// 2018 RecNet.LOIBFONMHMF.Deserialize reads exactly TWO required float keys
|
||||
// via Util.GetKey<Single> (verified via dnlib). It's fetched as an optional
|
||||
// object (GetObjectKey) inside config/v2, but once PRESENT the client
|
||||
// deserializes it — wrong/missing keys throw KeyNotFoundException →
|
||||
// "Received malformed RecNet response" → "Failed to connect to RecNet" at
|
||||
// boot. The 2020 MaxPlayersPerRoom/MinPlayersToStart keys are NOT read here.
|
||||
[JsonPropertyName("PreferFullRoomsFrequency")]
|
||||
public float PreferFullRoomsFrequency { get; set; } = 0.5f;
|
||||
|
||||
[JsonPropertyName("PreferEmptyRoomsFrequency")]
|
||||
public float PreferEmptyRoomsFrequency { get; set; } = 0.5f;
|
||||
}
|
||||
|
||||
public class LevelProgressionEntry
|
||||
{
|
||||
[JsonPropertyName("Level")]
|
||||
public int Level { get; set; }
|
||||
|
||||
// Note the lowercase 'p' — the client's RecRoomConfig.Deserialize calls
|
||||
// Util.GetKey<int>("RequiredXp", ...). PascalCase XP would miss.
|
||||
[JsonPropertyName("RequiredXp")]
|
||||
public int RequiredXp { get; set; }
|
||||
|
||||
public static List<LevelProgressionEntry> Default()
|
||||
{
|
||||
var entries = new List<LevelProgressionEntry>();
|
||||
for (int i = 0; i <= 30; i++)
|
||||
entries.Add(new LevelProgressionEntry { Level = i, RequiredXp = i * 500 });
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
// Inner item shape for RecRoomConfig.DailyObjectives, verified by the
|
||||
// disassembled iteration body — only "type" and "score" are read. Other
|
||||
// fields the client doesn't read get ignored, so we only send these two.
|
||||
public class DailyObjective
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public int Type { get; set; }
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public int Score { get; set; }
|
||||
|
||||
public static List<List<DailyObjective>> DefaultSets()
|
||||
{
|
||||
var sets = new List<List<DailyObjective>>();
|
||||
for (int day = 0; day < 7; day++)
|
||||
{
|
||||
sets.Add(
|
||||
[
|
||||
new DailyObjective { Type = 1, Score = 50 },
|
||||
new DailyObjective { Type = 2, Score = 30 },
|
||||
new DailyObjective { Type = 3, Score = 20 },
|
||||
]);
|
||||
}
|
||||
return sets;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RecNet.AutoMicMutingConfig — dump.cs class, Deserialize at RVA 0xFA3AE0.
|
||||
/// All 8 float fields are required (Util.GetKey<float>). Defaults are
|
||||
/// "feature effectively disabled" — high thresholds, no force-mute.
|
||||
/// </summary>
|
||||
public class AutoMicMutingConfig
|
||||
{
|
||||
[JsonPropertyName("MicSpamVolumeThreshold")]
|
||||
public float MicSpamVolumeThreshold { get; set; } = 1.0f;
|
||||
|
||||
[JsonPropertyName("MicVolumeSampleInterval")]
|
||||
public float MicVolumeSampleInterval { get; set; } = 0.1f;
|
||||
|
||||
[JsonPropertyName("MicVolumeSampleRollingWindowLength")]
|
||||
public float MicVolumeSampleRollingWindowLength { get; set; } = 5.0f;
|
||||
|
||||
[JsonPropertyName("MicSpamSamplePercentageForWarning")]
|
||||
public float MicSpamSamplePercentageForWarning { get; set; } = 1.0f;
|
||||
|
||||
[JsonPropertyName("MicSpamSamplePercentageForWarningToEnd")]
|
||||
public float MicSpamSamplePercentageForWarningToEnd { get; set; } = 0.5f;
|
||||
|
||||
[JsonPropertyName("MicSpamSamplePercentageForForceMute")]
|
||||
public float MicSpamSamplePercentageForForceMute { get; set; } = 1.0f;
|
||||
|
||||
[JsonPropertyName("MicSpamSamplePercentageForForceMuteToEnd")]
|
||||
public float MicSpamSamplePercentageForForceMuteToEnd { get; set; } = 0.5f;
|
||||
|
||||
[JsonPropertyName("MicSpamWarningStateVolumeMultiplier")]
|
||||
public float MicSpamWarningStateVolumeMultiplier { get; set; } = 1.0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RecNet.ServerMaintenanceDTO — dump.cs, Deserialize at RVA 0xAF5450.
|
||||
/// Single required key: StartsInMinutes (int). A large positive value
|
||||
/// means "maintenance window is far away" = effectively never.
|
||||
/// </summary>
|
||||
public class ServerMaintenanceDTO
|
||||
{
|
||||
[JsonPropertyName("StartsInMinutes")]
|
||||
public int StartsInMinutes { get; set; } = int.MaxValue;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,261 +0,0 @@
|
||||
version=1
|
||||
|
||||
# This file caches language service data to improve the performance of C# Dev Kit.
|
||||
# It is not intended for manual editing. It can safely be deleted and will be
|
||||
# regenerated automatically. For more information, see https://aka.ms/lscache
|
||||
#
|
||||
# To control where cache files are stored, use the following VS Code setting:
|
||||
# "dotnet.projectsystem.cacheInProjectFolder": true
|
||||
|
||||
[project]
|
||||
language=C#
|
||||
primary
|
||||
lastDtbSucceeded
|
||||
|
||||
[properties]
|
||||
AssemblyName=DorkNet.Models
|
||||
CommandLineArgsForDesignTimeEvaluation=-langversion:13.0 -define:TRACE
|
||||
CompilerGeneratedFilesOutputPath=
|
||||
MaxSupportedLangVersion=13.0
|
||||
ProjectAssetsFile=<PATH>obj/project.assets.json
|
||||
RootNamespace=DorkNet.Models
|
||||
RunAnalyzers=
|
||||
RunAnalyzersDuringLiveAnalysis=
|
||||
SolutionPath=<PATH>../DorkNet.sln
|
||||
TargetFrameworkIdentifier=.NETCoreApp
|
||||
TargetPath=<PATH>bin/Debug/net9.0/DorkNet.Models.dll
|
||||
TargetRefPath=<PATH>obj/Debug/net9.0/ref/DorkNet.Models.dll
|
||||
TemporaryDependencyNodeTargetIdentifier=net9.0
|
||||
|
||||
[commandLineArguments]
|
||||
/noconfig
|
||||
/unsafe-
|
||||
/checked-
|
||||
/nowarn:1701,1702,1701,1702
|
||||
/fullpaths
|
||||
/nostdlib+
|
||||
/errorreport:prompt
|
||||
/warn:9
|
||||
/define:TRACE;DEBUG;NET;NET9_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER
|
||||
/highentropyva+
|
||||
/nullable:enable
|
||||
/debug+
|
||||
/debug:portable
|
||||
/filealign:512
|
||||
/optimize-
|
||||
/out:obj\Debug\net9.0\DorkNet.Models.dll
|
||||
/refout:obj\Debug\net9.0\refint\DorkNet.Models.dll
|
||||
/target:library
|
||||
/warnaserror-
|
||||
/utf8output
|
||||
/deterministic+
|
||||
/langversion:13.0
|
||||
/warnaserror+:NU1605,SYSLIB0011
|
||||
|
||||
[sourceFiles]
|
||||
Auth/
|
||||
CachedLogin.cs
|
||||
LoginRequest.cs
|
||||
LoginResponse.cs
|
||||
RecNetResult.cs
|
||||
RefreshLoginResponse.cs
|
||||
Avatar/
|
||||
AvatarData.cs
|
||||
AvatarItem.cs
|
||||
Config/RecRoomConfig.cs
|
||||
GameSessions/GameSession.cs
|
||||
Notification/PushNotification.cs
|
||||
obj/Debug/net9.0/
|
||||
.NETCoreApp,Version=v9.0.AssemblyAttributes.cs
|
||||
DorkNet.Models.AssemblyInfo.cs
|
||||
DorkNet.Models.GlobalUsings.g.cs
|
||||
Players/
|
||||
ModerationBlockDetail.cs
|
||||
PlayerProfile.cs
|
||||
Progression.cs
|
||||
Reputation.cs
|
||||
UpdateProfileRequest.cs
|
||||
Relationships/Relationship.cs
|
||||
|
||||
[metadataReferences]
|
||||
<DOTNET>/packs/Microsoft.NETCore.App.Ref/9.0.16/ref/net9.0/
|
||||
Microsoft.CSharp.dll
|
||||
Microsoft.VisualBasic.Core.dll
|
||||
Microsoft.VisualBasic.dll
|
||||
Microsoft.Win32.Primitives.dll
|
||||
Microsoft.Win32.Registry.dll
|
||||
mscorlib.dll
|
||||
netstandard.dll
|
||||
System.AppContext.dll
|
||||
System.Buffers.dll
|
||||
System.Collections.Concurrent.dll
|
||||
System.Collections.dll
|
||||
System.Collections.Immutable.dll
|
||||
System.Collections.NonGeneric.dll
|
||||
System.Collections.Specialized.dll
|
||||
System.ComponentModel.Annotations.dll
|
||||
System.ComponentModel.DataAnnotations.dll
|
||||
System.ComponentModel.dll
|
||||
System.ComponentModel.EventBasedAsync.dll
|
||||
System.ComponentModel.Primitives.dll
|
||||
System.ComponentModel.TypeConverter.dll
|
||||
System.Configuration.dll
|
||||
System.Console.dll
|
||||
System.Core.dll
|
||||
System.Data.Common.dll
|
||||
System.Data.DataSetExtensions.dll
|
||||
System.Data.dll
|
||||
System.Diagnostics.Contracts.dll
|
||||
System.Diagnostics.Debug.dll
|
||||
System.Diagnostics.DiagnosticSource.dll
|
||||
System.Diagnostics.FileVersionInfo.dll
|
||||
System.Diagnostics.Process.dll
|
||||
System.Diagnostics.StackTrace.dll
|
||||
System.Diagnostics.TextWriterTraceListener.dll
|
||||
System.Diagnostics.Tools.dll
|
||||
System.Diagnostics.TraceSource.dll
|
||||
System.Diagnostics.Tracing.dll
|
||||
System.dll
|
||||
System.Drawing.dll
|
||||
System.Drawing.Primitives.dll
|
||||
System.Dynamic.Runtime.dll
|
||||
System.Formats.Asn1.dll
|
||||
System.Formats.Tar.dll
|
||||
System.Globalization.Calendars.dll
|
||||
System.Globalization.dll
|
||||
System.Globalization.Extensions.dll
|
||||
System.IO.Compression.Brotli.dll
|
||||
System.IO.Compression.dll
|
||||
System.IO.Compression.FileSystem.dll
|
||||
System.IO.Compression.ZipFile.dll
|
||||
System.IO.dll
|
||||
System.IO.FileSystem.AccessControl.dll
|
||||
System.IO.FileSystem.dll
|
||||
System.IO.FileSystem.DriveInfo.dll
|
||||
System.IO.FileSystem.Primitives.dll
|
||||
System.IO.FileSystem.Watcher.dll
|
||||
System.IO.IsolatedStorage.dll
|
||||
System.IO.MemoryMappedFiles.dll
|
||||
System.IO.Pipelines.dll
|
||||
System.IO.Pipes.AccessControl.dll
|
||||
System.IO.Pipes.dll
|
||||
System.IO.UnmanagedMemoryStream.dll
|
||||
System.Linq.dll
|
||||
System.Linq.Expressions.dll
|
||||
System.Linq.Parallel.dll
|
||||
System.Linq.Queryable.dll
|
||||
System.Memory.dll
|
||||
System.Net.dll
|
||||
System.Net.Http.dll
|
||||
System.Net.Http.Json.dll
|
||||
System.Net.HttpListener.dll
|
||||
System.Net.Mail.dll
|
||||
System.Net.NameResolution.dll
|
||||
System.Net.NetworkInformation.dll
|
||||
System.Net.Ping.dll
|
||||
System.Net.Primitives.dll
|
||||
System.Net.Quic.dll
|
||||
System.Net.Requests.dll
|
||||
System.Net.Security.dll
|
||||
System.Net.ServicePoint.dll
|
||||
System.Net.Sockets.dll
|
||||
System.Net.WebClient.dll
|
||||
System.Net.WebHeaderCollection.dll
|
||||
System.Net.WebProxy.dll
|
||||
System.Net.WebSockets.Client.dll
|
||||
System.Net.WebSockets.dll
|
||||
System.Numerics.dll
|
||||
System.Numerics.Vectors.dll
|
||||
System.ObjectModel.dll
|
||||
System.Reflection.DispatchProxy.dll
|
||||
System.Reflection.dll
|
||||
System.Reflection.Emit.dll
|
||||
System.Reflection.Emit.ILGeneration.dll
|
||||
System.Reflection.Emit.Lightweight.dll
|
||||
System.Reflection.Extensions.dll
|
||||
System.Reflection.Metadata.dll
|
||||
System.Reflection.Primitives.dll
|
||||
System.Reflection.TypeExtensions.dll
|
||||
System.Resources.Reader.dll
|
||||
System.Resources.ResourceManager.dll
|
||||
System.Resources.Writer.dll
|
||||
System.Runtime.CompilerServices.Unsafe.dll
|
||||
System.Runtime.CompilerServices.VisualC.dll
|
||||
System.Runtime.dll
|
||||
System.Runtime.Extensions.dll
|
||||
System.Runtime.Handles.dll
|
||||
System.Runtime.InteropServices.dll
|
||||
System.Runtime.InteropServices.JavaScript.dll
|
||||
System.Runtime.InteropServices.RuntimeInformation.dll
|
||||
System.Runtime.Intrinsics.dll
|
||||
System.Runtime.Loader.dll
|
||||
System.Runtime.Numerics.dll
|
||||
System.Runtime.Serialization.dll
|
||||
System.Runtime.Serialization.Formatters.dll
|
||||
System.Runtime.Serialization.Json.dll
|
||||
System.Runtime.Serialization.Primitives.dll
|
||||
System.Runtime.Serialization.Xml.dll
|
||||
System.Security.AccessControl.dll
|
||||
System.Security.Claims.dll
|
||||
System.Security.Cryptography.Algorithms.dll
|
||||
System.Security.Cryptography.Cng.dll
|
||||
System.Security.Cryptography.Csp.dll
|
||||
System.Security.Cryptography.dll
|
||||
System.Security.Cryptography.Encoding.dll
|
||||
System.Security.Cryptography.OpenSsl.dll
|
||||
System.Security.Cryptography.Primitives.dll
|
||||
System.Security.Cryptography.X509Certificates.dll
|
||||
System.Security.dll
|
||||
System.Security.Principal.dll
|
||||
System.Security.Principal.Windows.dll
|
||||
System.Security.SecureString.dll
|
||||
System.ServiceModel.Web.dll
|
||||
System.ServiceProcess.dll
|
||||
System.Text.Encoding.CodePages.dll
|
||||
System.Text.Encoding.dll
|
||||
System.Text.Encoding.Extensions.dll
|
||||
System.Text.Encodings.Web.dll
|
||||
System.Text.Json.dll
|
||||
System.Text.RegularExpressions.dll
|
||||
System.Threading.Channels.dll
|
||||
System.Threading.dll
|
||||
System.Threading.Overlapped.dll
|
||||
System.Threading.Tasks.Dataflow.dll
|
||||
System.Threading.Tasks.dll
|
||||
System.Threading.Tasks.Extensions.dll
|
||||
System.Threading.Tasks.Parallel.dll
|
||||
System.Threading.Thread.dll
|
||||
System.Threading.ThreadPool.dll
|
||||
System.Threading.Timer.dll
|
||||
System.Transactions.dll
|
||||
System.Transactions.Local.dll
|
||||
System.ValueTuple.dll
|
||||
System.Web.dll
|
||||
System.Web.HttpUtility.dll
|
||||
System.Windows.dll
|
||||
System.Xml.dll
|
||||
System.Xml.Linq.dll
|
||||
System.Xml.ReaderWriter.dll
|
||||
System.Xml.Serialization.dll
|
||||
System.Xml.XDocument.dll
|
||||
System.Xml.XmlDocument.dll
|
||||
System.Xml.XmlSerializer.dll
|
||||
System.Xml.XPath.dll
|
||||
System.Xml.XPath.XDocument.dll
|
||||
WindowsBase.dll
|
||||
|
||||
[analyzerReferences]
|
||||
<DOTNET>/packs/Microsoft.NETCore.App.Ref/9.0.16/analyzers/dotnet/cs/
|
||||
Microsoft.Interop.ComInterfaceGenerator.dll
|
||||
Microsoft.Interop.JavaScript.JSImportGenerator.dll
|
||||
Microsoft.Interop.LibraryImportGenerator.dll
|
||||
Microsoft.Interop.SourceGeneration.dll
|
||||
System.Text.Json.SourceGeneration.dll
|
||||
System.Text.RegularExpressions.Generator.dll
|
||||
<DOTNET>/sdk/9.0.314/Sdks/Microsoft.NET.Sdk/analyzers/
|
||||
Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll
|
||||
Microsoft.CodeAnalysis.NetAnalyzers.dll
|
||||
|
||||
[analyzerConfigFiles]
|
||||
<DOTNET>/sdk/9.0.314/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_9_default.globalconfig
|
||||
obj/Debug/net9.0/DorkNet.Models.GeneratedMSBuildEditorConfig.editorconfig
|
||||
@@ -1,73 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.GameSessions;
|
||||
|
||||
public class GameSession
|
||||
{
|
||||
[JsonPropertyName("GameSessionId")]
|
||||
public long GameSessionId { get; set; }
|
||||
|
||||
[JsonPropertyName("RegionId")]
|
||||
public string RegionId { get; set; } = "us";
|
||||
|
||||
[JsonPropertyName("RoomId")]
|
||||
public string RoomId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("RoomName")]
|
||||
public string RoomName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("ActivityLevelId")]
|
||||
public string ActivityLevelId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("EventId")]
|
||||
public long? EventId { get; set; }
|
||||
|
||||
[JsonPropertyName("Private")]
|
||||
public bool Private { get; set; }
|
||||
|
||||
[JsonPropertyName("GameInProgress")]
|
||||
public bool GameInProgress { get; set; }
|
||||
|
||||
[JsonPropertyName("MaxCapacity")]
|
||||
public int MaxCapacity { get; set; } = 8;
|
||||
|
||||
[JsonPropertyName("IsFull")]
|
||||
public bool IsFull { get; set; }
|
||||
|
||||
[JsonPropertyName("PlayerCount")]
|
||||
public int PlayerCount { get; set; }
|
||||
|
||||
[JsonPropertyName("PhotonRoomName")]
|
||||
public string PhotonRoomName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class JoinRandomGameSessionRequest
|
||||
{
|
||||
[JsonPropertyName("RoomId")]
|
||||
public string? RoomId { get; set; }
|
||||
|
||||
[JsonPropertyName("ActivityLevelId")]
|
||||
public string? ActivityLevelId { get; set; }
|
||||
|
||||
[JsonPropertyName("RegionId")]
|
||||
public string RegionId { get; set; } = "us";
|
||||
}
|
||||
|
||||
public class JoinGameSessionResponse
|
||||
{
|
||||
[JsonPropertyName("Result")]
|
||||
public JoinGameErrorCode Result { get; set; } = JoinGameErrorCode.Success;
|
||||
|
||||
[JsonPropertyName("GameSession")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public GameSession? GameSession { get; set; }
|
||||
}
|
||||
|
||||
public enum JoinGameErrorCode
|
||||
{
|
||||
Success = 0,
|
||||
Full = 1,
|
||||
NotFound = 2,
|
||||
Banned = 3,
|
||||
Error = 4,
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Notification;
|
||||
|
||||
public class PushNotification
|
||||
{
|
||||
[JsonPropertyName("Id")]
|
||||
public PushNotificationId Id { get; set; }
|
||||
|
||||
[JsonPropertyName("Msg")]
|
||||
public object? Msg { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the client's <c>RecNet.Notifications+PushNotificationId</c>
|
||||
/// enum exactly (decompiled at
|
||||
/// <c>Cpp2IL_CS/.../RecNet/Notifications.cs:52-89</c>). Values are
|
||||
/// non-contiguous because the client allocates them in groups (1–6
|
||||
/// core; 11–15 subscriptions; 20–25 moderation; 30–31 gifts;
|
||||
/// 60–62 storefronts; 70–71 consumables; 80–85 events; 90 chat;
|
||||
/// 95–96 community board; 100 inventions).
|
||||
///
|
||||
/// Server <c>NotifyAsync(playerId, id)</c> pushes raise an event with
|
||||
/// this int value over the SignalR hub. If the value doesn't match
|
||||
/// what the client expects for a given concept, the push is silently
|
||||
/// dropped — match the client byte-for-byte.
|
||||
/// </summary>
|
||||
public enum PushNotificationId
|
||||
{
|
||||
RelationshipChanged = 1,
|
||||
MessageReceived = 2,
|
||||
MessageDeleted = 3,
|
||||
PresenceHeartbeatResponse = 4,
|
||||
RefreshLogin = 5,
|
||||
Logout = 6,
|
||||
SubscriptionUpdateProfile = 11,
|
||||
SubscriptionUpdatePresence = 12,
|
||||
SubscriptionUpdateGameSession = 13,
|
||||
SubscriptionUpdateRoom = 15,
|
||||
ModerationQuitGame = 20,
|
||||
ModerationUpdateRequired = 21,
|
||||
ModerationKick = 22,
|
||||
ModerationKickAttemptFailed = 23,
|
||||
ModerationRoomBan = 24,
|
||||
ServerMaintenance = 25,
|
||||
GiftPackageReceived = 30,
|
||||
GiftPackageReceivedImmediate = 31,
|
||||
ProfileJuniorStatusUpdate = 40,
|
||||
RelationshipsInvalid = 50,
|
||||
StorefrontBalanceAdd = 60,
|
||||
StorefrontBalanceUpdate = 61,
|
||||
StorefrontBalancePurchase = 62,
|
||||
ConsumableMappingAdded = 70,
|
||||
ConsumableMappingRemoved = 71,
|
||||
PlayerEventCreated = 80,
|
||||
PlayerEventUpdated = 81,
|
||||
PlayerEventDeleted = 82,
|
||||
PlayerEventResponseChanged = 83,
|
||||
PlayerEventResponseDeleted = 84,
|
||||
PlayerEventStateChanged = 85,
|
||||
ChatMessageReceived = 90,
|
||||
CommunityBoardUpdate = 95,
|
||||
CommunityBoardAnnouncementUpdate = 96,
|
||||
InventionModerationStateChanged = 100,
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Players;
|
||||
|
||||
/// <summary>
|
||||
/// RecNet.ModerationBlockDetail — dump.cs:583626, Deserialize at RVA 0x1450830.
|
||||
///
|
||||
/// Verified by disassembly. Required keys (Util.GetKey, throws if missing):
|
||||
/// ReportCategory (int), Duration (int), GameSessionId (long), Message (string).
|
||||
/// Optional keys (Util.GetKeyOrDefault):
|
||||
/// IsHostKick (bool, default false), IsBan (bool, default false),
|
||||
/// PlayerIdReporter (Nullable<int>, default null).
|
||||
///
|
||||
/// Returned by:
|
||||
/// GET api.rec.net/api/PlayerReporting/v1/moderationBlockDetails
|
||||
/// (Single object, NOT a list — the client method GetModerationBlockDetails
|
||||
/// returns IPromise<ModerationBlockDetail>.)
|
||||
///
|
||||
/// To indicate "user is not blocked": ReportCategory=0, Duration=0,
|
||||
/// GameSessionId=0, Message="", IsBan=false, IsHostKick=false,
|
||||
/// PlayerIdReporter=null.
|
||||
/// </summary>
|
||||
public class ModerationBlockDetail
|
||||
{
|
||||
// PlayerReporting.ReportCategory enum (int)
|
||||
[JsonPropertyName("ReportCategory")]
|
||||
public int ReportCategory { get; set; } = 0;
|
||||
|
||||
[JsonPropertyName("Duration")]
|
||||
public int Duration { get; set; } = 0;
|
||||
|
||||
[JsonPropertyName("GameSessionId")]
|
||||
public long GameSessionId { get; set; } = 0;
|
||||
|
||||
[JsonPropertyName("Message")]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("IsHostKick")]
|
||||
public bool IsHostKick { get; set; } = false;
|
||||
|
||||
[JsonPropertyName("PlayerIdReporter")]
|
||||
public int? PlayerIdReporter { get; set; } = null;
|
||||
|
||||
[JsonPropertyName("IsBan")]
|
||||
public bool IsBan { get; set; } = false;
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Players;
|
||||
|
||||
public class PlayerProfile
|
||||
{
|
||||
private string? profileImage;
|
||||
|
||||
[JsonPropertyName("AccountId")]
|
||||
public long AccountId { get; set; }
|
||||
|
||||
[JsonPropertyName("Username")]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("DisplayName")]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("ProfileImage")]
|
||||
public string? ProfileImage
|
||||
{
|
||||
get => profileImage;
|
||||
set => profileImage = value;
|
||||
}
|
||||
|
||||
[JsonPropertyName("ProfileImageName")]
|
||||
public string? ProfileImageName
|
||||
{
|
||||
get => profileImage;
|
||||
set => profileImage = value;
|
||||
}
|
||||
|
||||
[JsonPropertyName("BannerImage")]
|
||||
public string? BannerImage { get; set; }
|
||||
|
||||
[JsonPropertyName("IsJunior")]
|
||||
public bool IsJunior { get; set; } = false;
|
||||
|
||||
[JsonPropertyName("Level")]
|
||||
public int Level { get; set; } = 1;
|
||||
|
||||
[JsonPropertyName("XP")]
|
||||
public int XP { get; set; } = 0;
|
||||
|
||||
[JsonPropertyName("Reputation")]
|
||||
public int Reputation { get; set; } = 0;
|
||||
|
||||
[JsonPropertyName("Bio")]
|
||||
public string Bio { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("IsVerified")]
|
||||
public bool IsVerified { get; set; } = false;
|
||||
|
||||
[JsonPropertyName("IsDeveloper")]
|
||||
public bool IsDeveloper { get; set; } = false;
|
||||
|
||||
[JsonPropertyName("IsCommunityTeam")]
|
||||
public bool IsCommunityTeam { get; set; } = false;
|
||||
|
||||
[JsonPropertyName("CanReceiveInvites")]
|
||||
public bool CanReceiveInvites { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("CreatedAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Players;
|
||||
|
||||
/// <summary>
|
||||
/// June-2018 presence record returned from POST api/presence/v2/list.
|
||||
/// Keys verified from RecNet.DBHHFILLFLC.Deserialize: PlayerId (long),
|
||||
/// IsOnline (bool), GameSession (optional object — null when the player is
|
||||
/// online but not in a session; the client null-checks it).
|
||||
/// </summary>
|
||||
public class Presence2018
|
||||
{
|
||||
[JsonPropertyName("PlayerId")]
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
[JsonPropertyName("IsOnline")]
|
||||
public bool IsOnline { get; set; }
|
||||
|
||||
[JsonPropertyName("GameSession")]
|
||||
public object? GameSession { get; set; }
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Players;
|
||||
|
||||
/// <summary>
|
||||
/// RecNet.Progression — dump.cs:582273, Deserialize at RVA 0x1142BB0.
|
||||
///
|
||||
/// Verified by disassembly: reads "PlayerId", "Level", "XP" via
|
||||
/// Util.GetKey<int>. All three keys are required (non-default) — missing
|
||||
/// any of them throws KeyNotFoundException at the client.
|
||||
///
|
||||
/// Note: the JSON uses "PlayerId" not "AccountId" even though the C# property
|
||||
/// is AccountId on most other types. Returned by:
|
||||
/// GET api.rec.net/api/players/v1/progression/{accountId}
|
||||
/// </summary>
|
||||
public class Progression
|
||||
{
|
||||
[JsonPropertyName("PlayerId")]
|
||||
public int PlayerId { get; set; }
|
||||
|
||||
[JsonPropertyName("Level")]
|
||||
public int Level { get; set; } = 1;
|
||||
|
||||
[JsonPropertyName("XP")]
|
||||
public int XP { get; set; } = 0;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Players;
|
||||
|
||||
/// <summary>
|
||||
/// RecNet.Reputation — dump.cs:584087, Deserialize at RVA 0x114A830.
|
||||
///
|
||||
/// Verified by disassembly. ALL Cheer*/Subscriber* keys are required
|
||||
/// (Util.GetKey<int>), missing any throws KeyNotFoundException.
|
||||
/// "SelectedCheer" is the only optional one (Util.GetKeyOrDefault<Nullable<int>>).
|
||||
///
|
||||
/// CRITICAL TYPO: the JSON key is "Noteriety" — the dev mistyped "Notoriety".
|
||||
/// The C# property is named Notoriety in dump.cs (line 584117) but the dict
|
||||
/// lookup uses "Noteriety". Server response MUST match the typo.
|
||||
///
|
||||
/// Returned by:
|
||||
/// GET api.rec.net/api/playerReputation/v1/{accountId}
|
||||
/// </summary>
|
||||
public class Reputation
|
||||
{
|
||||
[JsonPropertyName("AccountId")]
|
||||
public int AccountId { get; set; }
|
||||
|
||||
// sic: see class XML doc — typo in the binary's Util.GetKey call.
|
||||
[JsonPropertyName("Noteriety")]
|
||||
public float Notoriety { get; set; } = 0f;
|
||||
|
||||
[JsonPropertyName("CheerGeneral")]
|
||||
public int CheerGeneral { get; set; }
|
||||
|
||||
[JsonPropertyName("CheerHelpful")]
|
||||
public int CheerHelpful { get; set; }
|
||||
|
||||
[JsonPropertyName("CheerGreatHost")]
|
||||
public int CheerGreatHost { get; set; }
|
||||
|
||||
[JsonPropertyName("CheerSportsman")]
|
||||
public int CheerSportsman { get; set; }
|
||||
|
||||
[JsonPropertyName("CheerCreative")]
|
||||
public int CheerCreative { get; set; }
|
||||
|
||||
[JsonPropertyName("CheerCredit")]
|
||||
public int CheerCredit { get; set; }
|
||||
|
||||
[JsonPropertyName("SubscriberCount")]
|
||||
public int SubscriberCount { get; set; }
|
||||
|
||||
[JsonPropertyName("SubscribedCount")]
|
||||
public int SubscribedCount { get; set; }
|
||||
|
||||
// PlayerCheering.CheerCategory enum (int) — Nullable; null means "no
|
||||
// active cheer set". This is the only optional key.
|
||||
[JsonPropertyName("SelectedCheer")]
|
||||
public int? SelectedCheer { get; set; } = null;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Players;
|
||||
|
||||
public class UpdateProfileRequest
|
||||
{
|
||||
[JsonPropertyName("DisplayName")]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
[JsonPropertyName("Bio")]
|
||||
public string? Bio { get; set; }
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Relationships;
|
||||
|
||||
public class RelationshipData
|
||||
{
|
||||
[JsonPropertyName("AccountId")]
|
||||
public long AccountId { get; set; }
|
||||
|
||||
[JsonPropertyName("OtherAccountId")]
|
||||
public long OtherAccountId { get; set; }
|
||||
|
||||
[JsonPropertyName("Status")]
|
||||
public RelationshipStatus Status { get; set; }
|
||||
|
||||
[JsonPropertyName("CreatedAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public enum RelationshipStatus
|
||||
{
|
||||
None = 0,
|
||||
Friend = 1,
|
||||
PendingSent = 2,
|
||||
PendingReceived = 3,
|
||||
Blocked = 4,
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Rooms;
|
||||
|
||||
/// <summary>
|
||||
/// Flat June-2018 room object. The 2018 client (RecNet Room.Deserialize,
|
||||
/// RecNet.cs:78244) reads these keys directly off the room JSON — there is NO
|
||||
/// 2020-style RoomDetails/Scenes wrapper; DataBlobName + PersonalDetails are
|
||||
/// embedded right here. Required keys throw (Util.GetKey) if missing, so every
|
||||
/// field below must be present except the genuinely-optional ones noted.
|
||||
///
|
||||
/// Notes:
|
||||
/// • CreatorPlayerId is read as an int client-side; all account ids fit int.
|
||||
/// • Date keys are ISO-8601 (client DateTimeParse.ParseISO8601). Use real
|
||||
/// dates, never year-9999 (overflow — see WireDates).
|
||||
/// • ActivityLevelId parse failure is a non-fatal warning client-side, so ""
|
||||
/// is safe for non-activity rooms.
|
||||
/// • CoOwners/Hosts are int arrays (empty ok); PersonalDetails is an optional
|
||||
/// object (null ok).
|
||||
/// </summary>
|
||||
public class Room2018
|
||||
{
|
||||
[JsonPropertyName("RoomId")] public long RoomId { get; set; }
|
||||
[JsonPropertyName("Name")] public string Name { get; set; } = string.Empty;
|
||||
[JsonPropertyName("Description")] public string Description { get; set; } = string.Empty;
|
||||
[JsonPropertyName("CreatorPlayerId")] public long CreatorPlayerId { get; set; }
|
||||
[JsonPropertyName("ImageName")] public string ImageName { get; set; } = string.Empty;
|
||||
[JsonPropertyName("DataBlobName")] public string DataBlobName { get; set; } = string.Empty;
|
||||
[JsonPropertyName("ActivityLevelId")] public string ActivityLevelId { get; set; } = string.Empty;
|
||||
[JsonPropertyName("IsSandbox")] public bool IsSandbox { get; set; }
|
||||
[JsonPropertyName("Instanced")] public bool Instanced { get; set; } = true;
|
||||
[JsonPropertyName("MaxPlayers")] public int MaxPlayers { get; set; } = 20;
|
||||
[JsonPropertyName("Accessibility")] public int Accessibility { get; set; } = 1;
|
||||
[JsonPropertyName("AccessibilityLocked")] public bool AccessibilityLocked { get; set; }
|
||||
[JsonPropertyName("VisitorCount")] public int VisitorCount { get; set; }
|
||||
[JsonPropertyName("VisitCount")] public int VisitCount { get; set; }
|
||||
[JsonPropertyName("CheerCount")] public int CheerCount { get; set; }
|
||||
[JsonPropertyName("ReportCount")] public int ReportCount { get; set; }
|
||||
[JsonPropertyName("State")] public int State { get; set; }
|
||||
[JsonPropertyName("StateModifiedAt")] public DateTime StateModifiedAt { get; set; }
|
||||
[JsonPropertyName("CreatedAt")] public DateTime CreatedAt { get; set; }
|
||||
[JsonPropertyName("ModifiedAt")] public DateTime ModifiedAt { get; set; }
|
||||
[JsonPropertyName("LastVisitedAt")] public DateTime LastVisitedAt { get; set; }
|
||||
[JsonPropertyName("DataModifiedAt")] public DateTime DataModifiedAt { get; set; }
|
||||
[JsonPropertyName("CoOwners")] public List<int> CoOwners { get; set; } = [];
|
||||
[JsonPropertyName("Hosts")] public List<int> Hosts { get; set; } = [];
|
||||
[JsonPropertyName("PersonalDetails")] public object? PersonalDetails { get; set; }
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Action / controller filter that 403s any request whose
|
||||
/// JWT-resolved player doesn't have <c>IsAdmin == true</c>. Does not
|
||||
/// itself authenticate — pair it with <c>[Authorize]</c> to ensure a
|
||||
/// valid bearer is present before this runs.
|
||||
///
|
||||
/// Usage:
|
||||
/// <code>
|
||||
/// [Authorize]
|
||||
/// [AdminOnly]
|
||||
/// [Route("api/admin/v1")]
|
||||
/// public class AdminController { ... }
|
||||
/// </code>
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
|
||||
public class AdminOnlyAttribute : Attribute, IAsyncAuthorizationFilter
|
||||
{
|
||||
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
|
||||
{
|
||||
// AuthorizationFilterContext.HttpContext is always populated and
|
||||
// exposes the same ClaimsPrincipal a controller would see, so
|
||||
// there's no reason to round-trip through IHttpContextAccessor
|
||||
// (which isn't even registered in this app's DI container — the
|
||||
// earlier lookup crashed every admin request with
|
||||
// "No service for type IHttpContextAccessor has been registered").
|
||||
if (ControllerBaseExtensions.CurrentPlayerId(context.HttpContext.User) is not long id)
|
||||
{
|
||||
// No JWT → defer to [Authorize]'s 401.
|
||||
context.Result = new UnauthorizedResult();
|
||||
return;
|
||||
}
|
||||
|
||||
var db = context.HttpContext.RequestServices
|
||||
.GetRequiredService<DorkNetDbContext>();
|
||||
var isAdmin = await db.Players
|
||||
.Where(p => p.Id == id)
|
||||
.Select(p => p.IsAdmin)
|
||||
.FirstOrDefaultAsync();
|
||||
if (!isAdmin)
|
||||
context.Result = new ForbidResult();
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Short-circuits any authenticated request whose JWT-resolved player has
|
||||
/// a non-null <c>BannedUntil</c> in the future. Sits AFTER
|
||||
/// <c>UseAuthentication</c>/<c>UseAuthorization</c> in the pipeline so the
|
||||
/// claims principal is populated before we look at it.
|
||||
///
|
||||
/// Anonymous requests (no/invalid bearer) pass through unchanged — this
|
||||
/// middleware never short-circuits them. Whether they're allowed is the
|
||||
/// per-endpoint <c>[Authorize]</c> decision, not ours.
|
||||
///
|
||||
/// Lookup is one query per request against the Players table. Cached for
|
||||
/// the request lifetime via the scoped DbContext, so repeated bearer
|
||||
/// checks within one request hit the EF first-level cache.
|
||||
/// </summary>
|
||||
public class BanCheckMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext ctx, DorkNetDbContext db)
|
||||
{
|
||||
// Cheap early-out for anonymous traffic — no DB hit.
|
||||
var sub = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!long.TryParse(sub, out var playerId))
|
||||
{
|
||||
await next(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Project just the BannedUntil column — no need to materialise the
|
||||
// full player entity for every request.
|
||||
var bannedUntil = await db.Players
|
||||
.Where(p => p.Id == playerId)
|
||||
.Select(p => p.BannedUntil)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (bannedUntil.HasValue && bannedUntil.Value > DateTime.UtcNow)
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
ctx.Response.ContentType = "application/json";
|
||||
await ctx.Response.WriteAsync(JsonSerializer.Serialize(new
|
||||
{
|
||||
Error = "banned",
|
||||
Until = bannedUntil.Value.ToString("o"),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
await next(ctx);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace DorkNet.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Single canonical place for "who is the bearer-resolved player on this
|
||||
/// request." Replaces the per-controller copy-paste of:
|
||||
/// <code>
|
||||
/// private long CurrentPlayerId =>
|
||||
/// long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
/// </code>
|
||||
/// which historically existed in 10 controllers and silently NPE-bombed
|
||||
/// the action when the bearer was missing instead of returning 401.
|
||||
/// </summary>
|
||||
public static class ControllerBaseExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the bearer-resolved player id, or null when the caller is
|
||||
/// anonymous / the bearer is malformed. Use on optional-auth endpoints.
|
||||
/// </summary>
|
||||
public static long? CurrentPlayerId(this ControllerBase controller) =>
|
||||
CurrentPlayerId(controller.User);
|
||||
|
||||
/// <summary>Overload for non-controller callers (filters,
|
||||
/// middleware) that hold a <see cref="ClaimsPrincipal"/> directly.</summary>
|
||||
public static long? CurrentPlayerId(ClaimsPrincipal user)
|
||||
{
|
||||
var claim = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
return long.TryParse(claim, out var id) ? id : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the bearer-resolved player id; throws
|
||||
/// <see cref="UnauthorizedAccessException"/> when missing. Use on
|
||||
/// <c>[Authorize]</c>-decorated endpoints where a missing id is a
|
||||
/// programming error (the framework should have already 401'd).
|
||||
/// </summary>
|
||||
public static long RequireCurrentPlayerId(this ControllerBase controller) =>
|
||||
controller.CurrentPlayerId()
|
||||
?? throw new UnauthorizedAccessException(
|
||||
"Bearer token claim 'NameIdentifier' is missing or non-numeric. " +
|
||||
"If this fired on an [Authorize] endpoint, JWT validation upstream " +
|
||||
"is misconfigured.");
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace DorkNet.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Short-circuits any incoming request whose remote IP falls under an
|
||||
/// active <see cref="DorkNet.Server.Data.Entities.IpBanEntity"/> row
|
||||
/// with 403. Sits BEFORE authentication so banned IPs can't spend a
|
||||
/// round-trip negotiating a JWT — the connection terminates as early
|
||||
/// as possible.
|
||||
///
|
||||
/// Lookup is one query against IpBans per request. The table is small
|
||||
/// (a few rows in practice) and the simple "Cidr matches?" filter is
|
||||
/// done in C# rather than SQL because CIDR maths in SQLite is awkward.
|
||||
/// </summary>
|
||||
public class IpBanCheckMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext ctx, DorkNetDbContext db)
|
||||
{
|
||||
var remote = ctx.Connection.RemoteIpAddress;
|
||||
if (remote is null)
|
||||
{
|
||||
await next(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Materialise the active-ban list once per request. Could be
|
||||
// cached for ~30s with a hosted service if it ever becomes a
|
||||
// hot path, but right now this is unmeasurable.
|
||||
var bans = await db.IpBans
|
||||
.Where(b => b.Until == null || b.Until > DateTime.UtcNow)
|
||||
.Select(b => b.Cidr)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var cidr in bans)
|
||||
{
|
||||
if (CidrMatches(cidr, remote))
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await ctx.Response.WriteAsync("ip_banned");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await next(ctx);
|
||||
}
|
||||
|
||||
/// <summary>Match a single IP against a CIDR-or-bare-IP string.
|
||||
/// Bare IPs match exactly; CIDRs match if the prefix bits agree.
|
||||
/// Falls back to false on any parse error.</summary>
|
||||
private static bool CidrMatches(string cidr, IPAddress remote)
|
||||
{
|
||||
try
|
||||
{
|
||||
var slash = cidr.IndexOf('/');
|
||||
if (slash < 0)
|
||||
{
|
||||
return IPAddress.TryParse(cidr, out var single) &&
|
||||
single.Equals(remote);
|
||||
}
|
||||
|
||||
var addrPart = cidr[..slash];
|
||||
var prefixPart = cidr[(slash + 1)..];
|
||||
if (!IPAddress.TryParse(addrPart, out var net) ||
|
||||
!int.TryParse(prefixPart, out var prefix))
|
||||
return false;
|
||||
|
||||
// Only IPv4 networks for now — IPv6 CIDR matching is
|
||||
// straightforward but we'd need to mask 128-bit values
|
||||
// and the user's setup is single-host IPv4 anyway.
|
||||
if (net.AddressFamily != AddressFamily.InterNetwork ||
|
||||
remote.AddressFamily != AddressFamily.InterNetwork)
|
||||
return false;
|
||||
|
||||
var netBytes = net.GetAddressBytes();
|
||||
var remoteBytes = remote.GetAddressBytes();
|
||||
int fullBytes = prefix / 8;
|
||||
int extraBits = prefix % 8;
|
||||
|
||||
for (int i = 0; i < fullBytes; i++)
|
||||
if (netBytes[i] != remoteBytes[i]) return false;
|
||||
if (extraBits == 0) return true;
|
||||
|
||||
int mask = 0xFF << (8 - extraBits) & 0xFF;
|
||||
return (netBytes[fullBytes] & mask) == (remoteBytes[fullBytes] & mask);
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
using DorkNet.Models.Auth;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Compat2018;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="PlayerEntity"/> to the June-2018 wire player object
|
||||
/// (<see cref="Player2018"/>). Lives in the server (not DorkNet.Models) because
|
||||
/// the DTO project can't reference the data-layer entity. Shared by the
|
||||
/// platformlogin/v1, players/v1, and presence controllers so the 2018 player
|
||||
/// shape is produced in exactly one place.
|
||||
/// Keys verified against RecNet.EEBPLECPEGD.Deserialize (RecNet.cs:64632).
|
||||
/// </summary>
|
||||
public static class Player2018Mapper
|
||||
{
|
||||
public static Player2018 From(PlayerEntity p) => new()
|
||||
{
|
||||
Id = p.Id,
|
||||
Username = p.Username,
|
||||
DisplayName = string.IsNullOrEmpty(p.DisplayName) ? p.Username : p.DisplayName,
|
||||
Bio = p.Bio ?? string.Empty,
|
||||
XP = p.XP,
|
||||
Level = p.Level,
|
||||
Developer = p.IsDeveloper,
|
||||
CanReceiveInvites = p.CanReceiveInvites,
|
||||
ProfileImageName = p.ProfileImageName ?? string.Empty,
|
||||
JuniorProfile = p.IsJunior,
|
||||
HasBirthday = p.Birthday.HasValue,
|
||||
PlayerReputation = new PlayerReputation2018 { Noteriety = p.Reputation },
|
||||
};
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
using DorkNet.Models.Rooms;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Compat2018;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="RoomEntity"/> to the flat June-2018 wire room
|
||||
/// (<see cref="Room2018"/>). Server-side because DorkNet.Models can't reference
|
||||
/// the data entity. Some 2018 fields have no column (Instanced, MaxPlayers,
|
||||
/// ReportCount, ActivityLevelId) and get sensible defaults; IsSandbox is derived
|
||||
/// from IsAGRoom (official AG rooms are not sandbox/maker rooms).
|
||||
/// </summary>
|
||||
public static class Room2018Mapper
|
||||
{
|
||||
public static Room2018 From(RoomEntity r) => new()
|
||||
{
|
||||
RoomId = r.Id,
|
||||
Name = r.Name,
|
||||
Description = r.Description,
|
||||
CreatorPlayerId = r.CreatorPlayerId,
|
||||
ImageName = r.ImageName,
|
||||
DataBlobName = r.CurrentDataBlobName ?? string.Empty,
|
||||
ActivityLevelId = string.Empty,
|
||||
IsSandbox = !r.IsAGRoom,
|
||||
Instanced = true,
|
||||
MaxPlayers = 20,
|
||||
Accessibility = r.Accessibility,
|
||||
AccessibilityLocked = false,
|
||||
VisitorCount = r.VisitorCount,
|
||||
VisitCount = r.VisitCount,
|
||||
CheerCount = r.CheerCount,
|
||||
ReportCount = 0,
|
||||
State = r.State,
|
||||
StateModifiedAt = r.UpdatedAt,
|
||||
CreatedAt = r.CreatedAt,
|
||||
ModifiedAt = r.UpdatedAt,
|
||||
LastVisitedAt = r.UpdatedAt,
|
||||
DataModifiedAt = r.UpdatedAt,
|
||||
CoOwners = [],
|
||||
Hosts = [],
|
||||
PersonalDetails = null,
|
||||
};
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Account;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/account/* — same endpoints as accounts.rec.net but routed
|
||||
/// through the API service. The Accounts class in dump.cs doesn't expose its
|
||||
/// base URL constant so the game may use either host; we cover both.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class AccountApiController(
|
||||
PlayerService playerService,
|
||||
AuthService authService,
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
ServerSettingsService settings,
|
||||
SignupCodeService signupCodes,
|
||||
ILogger<AccountApiController> logger) : ControllerBase
|
||||
{
|
||||
[HttpPost("api/account/v1/create")]
|
||||
[HttpPost("api/account/v1")]
|
||||
[HttpPost("api/account/v2/create")]
|
||||
[HttpPost("api/createaccount")]
|
||||
public async Task<IActionResult> CreateAccount(
|
||||
[FromForm] int platform,
|
||||
[FromForm] string? platformId,
|
||||
[FromForm] string? deviceId,
|
||||
[FromForm] string? Username,
|
||||
[FromForm] string? DisplayName)
|
||||
{
|
||||
if (await settings.AreSignupsDisabledAsync())
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[api-account] api/account/v1/create refused — signups disabled (device={Device} platform={Platform})",
|
||||
deviceId, platform);
|
||||
await signupCodes.RecordPendingDeviceAsync(
|
||||
deviceId, platform, platformId, SignupCodeService.ClientIp(HttpContext));
|
||||
return Ok(new CreateAccountResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "Account creation is currently disabled by the server admin.",
|
||||
});
|
||||
}
|
||||
|
||||
var player = await playerService.GetOrCreateByDeviceAsync(
|
||||
deviceId: deviceId,
|
||||
platform: platform,
|
||||
platformId: platformId,
|
||||
displayName: DisplayName ?? Username);
|
||||
|
||||
return Ok(new CreateAccountResponse
|
||||
{
|
||||
Success = true,
|
||||
Error = string.Empty,
|
||||
Account = new RecNetAccount
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
RawUsername = player.Username,
|
||||
Username = player.Username,
|
||||
DisplayName = player.DisplayName ?? player.Username,
|
||||
ProfileImage = string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("api/account/v1/me")]
|
||||
[HttpGet("api/account/v2/me")]
|
||||
public async Task<IActionResult> GetMe()
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
return Ok(new RecNetSelfAccount
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
RawUsername = player.Username,
|
||||
Username = player.Username,
|
||||
DisplayName = player.DisplayName ?? player.Username,
|
||||
ProfileImage = player.ProfileImageName ?? string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
Email = player.Email ?? string.Empty,
|
||||
Phone = string.Empty,
|
||||
Birthday = new DateTime(2000, 1, 1),
|
||||
JuniorState = 0,
|
||||
ParentAccountId = null,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the player tied to the bearer token on the request. Returns
|
||||
/// null when no/invalid bearer is supplied — caller decides whether
|
||||
/// that's a 401 or a softer fallback. Anonymous-account creation was
|
||||
/// previously the fallback path; deleted in Phase 1c because every
|
||||
/// caller has gone through `/account/create` (deviceId-bound) before
|
||||
/// reaching us.
|
||||
/// </summary>
|
||||
private async Task<PlayerEntity?> GetCurrentPlayerAsync()
|
||||
{
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
var token = auth["Bearer ".Length..].Trim();
|
||||
var id = authService.ValidateToken(token);
|
||||
if (id is not long playerId) return null;
|
||||
return await playerService.GetByIdAsync(playerId);
|
||||
}
|
||||
|
||||
// Modification endpoints — POSTs land on api.* host with form body
|
||||
// carrying the new value. AuthAccountController on auth.* handles
|
||||
// the same shape with real persistence; we mirror that here so
|
||||
// either host works.
|
||||
|
||||
[HttpPost("api/account/v1/displayname")]
|
||||
public async Task<IActionResult> SetDisplayName([FromForm(Name = "displayName")] string? displayName,
|
||||
[FromForm(Name = "value")] string? value)
|
||||
{
|
||||
var p = await GetCurrentPlayerAsync();
|
||||
if (p is null) return Unauthorized();
|
||||
var name = (displayName ?? value ?? string.Empty).Trim();
|
||||
if (name.Length == 0) return BadRequest(new RecNetResult { Success = false, Error = "empty" });
|
||||
await playerService.UpdateDisplayNameAsync(p.Id, name);
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
[HttpPost("api/account/v1/bio")]
|
||||
public async Task<IActionResult> SetBio([FromForm(Name = "bio")] string? bio,
|
||||
[FromForm(Name = "value")] string? value)
|
||||
{
|
||||
var p = await GetCurrentPlayerAsync();
|
||||
if (p is null) return Unauthorized();
|
||||
await playerService.UpdateBioAsync(p.Id, (bio ?? value ?? string.Empty));
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
[HttpPost("api/account/v1/username")]
|
||||
public async Task<IActionResult> SetUsername([FromForm(Name = "username")] string? username,
|
||||
[FromForm(Name = "value")] string? value)
|
||||
{
|
||||
var p = await GetCurrentPlayerAsync();
|
||||
if (p is null) return Unauthorized();
|
||||
var newName = (username ?? value ?? string.Empty).Trim();
|
||||
if (newName.Length == 0) return BadRequest(new RecNetResult { Success = false, Error = "empty" });
|
||||
var taken = await db.Players.AnyAsync(x => x.Username == newName && x.Id != p.Id);
|
||||
if (taken) return Ok(new RecNetResult { Success = false, Error = "username_taken" });
|
||||
p.Username = newName;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
[HttpPost("api/account/v1/birthday")]
|
||||
public async Task<IActionResult> SetBirthday([FromForm(Name = "birthday")] string? birthday,
|
||||
[FromForm(Name = "value")] string? value)
|
||||
{
|
||||
var p = await GetCurrentPlayerAsync();
|
||||
if (p is null) return Unauthorized();
|
||||
var raw = birthday ?? value ?? string.Empty;
|
||||
if (DateTime.TryParse(raw, out var parsed))
|
||||
{
|
||||
p.Birthday = DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
[HttpPost("api/account/v1/email")]
|
||||
public async Task<IActionResult> SetEmail([FromForm(Name = "email")] string? email,
|
||||
[FromForm(Name = "value")] string? value)
|
||||
{
|
||||
var p = await GetCurrentPlayerAsync();
|
||||
if (p is null) return Unauthorized();
|
||||
var addr = (email ?? value ?? string.Empty).Trim();
|
||||
if (addr.Length > 0) p.Email = addr;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
[HttpPost("api/account/v1/phone")]
|
||||
[HttpPost("api/account/v1/confirmphone")]
|
||||
public async Task<IActionResult> SetPhone([FromForm(Name = "phone")] string? phone,
|
||||
[FromForm(Name = "value")] string? value)
|
||||
{
|
||||
var p = await GetCurrentPlayerAsync();
|
||||
if (p is null) return Unauthorized();
|
||||
var num = (phone ?? value ?? string.Empty).Trim();
|
||||
if (num.Length > 0) p.Phone = num;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
[HttpPost("api/account/v1/profileimage")]
|
||||
[HttpPost("api/account/me/profileimage")]
|
||||
[HttpPost("account/me/profileimage")]
|
||||
[HttpPost("profileimage")]
|
||||
[HttpPut("api/account/v1/profileimage")]
|
||||
[HttpPut("api/account/me/profileimage")]
|
||||
[HttpPut("account/me/profileimage")]
|
||||
[HttpPut("profileimage")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> SetProfileImage(
|
||||
[FromForm(Name = "imageName")] string? imageName,
|
||||
[FromForm(Name = "fileName")] string? fileName,
|
||||
[FromForm(Name = "image")] string? imageAlt)
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
|
||||
var name = imageName ?? fileName ?? imageAlt
|
||||
?? Request.Query["imageName"].ToString()
|
||||
?? Request.Query["fileName"].ToString();
|
||||
player.ProfileImageName = string.IsNullOrWhiteSpace(name) ? null : name.Trim();
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation(
|
||||
"[api-account] profile image set: player={PlayerId} -> {Name}",
|
||||
player.Id,
|
||||
player.ProfileImageName ?? "<cleared>");
|
||||
await NotifyAccountImageChangedAsync(player);
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
private Task NotifyAccountImageChangedAsync(PlayerEntity player)
|
||||
{
|
||||
var account = new RecNetAccount
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
RawUsername = player.Username,
|
||||
Username = player.Username,
|
||||
DisplayName = player.DisplayName ?? player.Username,
|
||||
ProfileImage = player.ProfileImageName ?? string.Empty,
|
||||
TreatAsJunior = player.IsJunior,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
};
|
||||
var selfAccount = new RecNetSelfAccount
|
||||
{
|
||||
AccountId = account.AccountId,
|
||||
RawUsername = account.RawUsername,
|
||||
Username = account.Username,
|
||||
DisplayName = account.DisplayName,
|
||||
ProfileImage = account.ProfileImage,
|
||||
TreatAsJunior = account.TreatAsJunior,
|
||||
HasBirthday = account.HasBirthday,
|
||||
Platforms = account.Platforms,
|
||||
Email = player.Email ?? string.Empty,
|
||||
Phone = player.Phone ?? string.Empty,
|
||||
Birthday = player.Birthday,
|
||||
JuniorState = player.IsJunior ? 1 : 0,
|
||||
ParentAccountId = null,
|
||||
};
|
||||
return Task.WhenAll(
|
||||
notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account),
|
||||
notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
|
||||
}
|
||||
|
||||
[HttpGet("api/account/v1/namegen")]
|
||||
[HttpGet("api/account/v1/namegeneration")]
|
||||
public IActionResult NameGen() => Ok(new
|
||||
{
|
||||
Nouns = new[] { "Player", "Hero", "Star", "Fox", "Wolf" },
|
||||
Adjectives = new[] { "Brave", "Swift", "Clever", "Bold", "Bright" },
|
||||
});
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Activities;
|
||||
|
||||
/// <summary>
|
||||
/// api.{rec.net,localhost}/api/activities/* — per-activity content
|
||||
/// (charades words, party-game prompts, etc.). The 2020 watch
|
||||
/// fetches these as a static list at room-join time; we ship an
|
||||
/// empty list so the activity's bundled defaults kick in.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class ActivitiesController : ControllerBase
|
||||
{
|
||||
[HttpGet("api/activities/charades/v1/words")]
|
||||
public IActionResult CharadesWords() => Ok(Array.Empty<object>());
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Announcements;
|
||||
|
||||
/// <summary>
|
||||
/// api.{rec.net,localhost}/api/announcement/v{1,2}/get — fetches the
|
||||
/// current server announcement (shown on the watch's home tab). The
|
||||
/// payload is sourced from <see cref="CommunityBoardService"/> so the
|
||||
/// admin SPA's "edit announcement" form is the single place to
|
||||
/// update what players see.
|
||||
///
|
||||
/// Wire shape per <c>RecNet/AnnouncementDTO.cs</c> — AnnouncementId,
|
||||
/// AnnouncementType, LinkType, Platform are all required via
|
||||
/// <c>Util.GetKey</c> (throws on miss); Title/Body/ImageName/LinkName/
|
||||
/// LinkUri/CreatedAt are GetKeyOrDefault.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class AnnouncementsController(CommunityBoardService communityBoard) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/announcement/v1/get")]
|
||||
[HttpGet("api/announcement/v2/get")]
|
||||
public async Task<IActionResult> Current()
|
||||
{
|
||||
var board = await communityBoard.GetAsync();
|
||||
var ann = board.CurrentAnnouncement;
|
||||
if (ann is null) return Ok(Array.Empty<object>());
|
||||
return Ok(new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
AnnouncementId = 1L,
|
||||
AnnouncementType = 0,
|
||||
Title = "Server Announcement",
|
||||
Body = ann.Message ?? string.Empty,
|
||||
ImageName = "",
|
||||
LinkType = 0,
|
||||
LinkName = "",
|
||||
LinkUri = ann.MoreInfoUrl ?? string.Empty,
|
||||
Platform = 0,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Photon custom-auth webhook (api.rec.net).
|
||||
///
|
||||
/// Configure in Photon dashboard:
|
||||
/// Authentication → Custom → URL = https://api.rec.net/api/auth/v1/photon
|
||||
/// Query params sent by client: token=<our_jwt>
|
||||
///
|
||||
/// Photon ResultCode: 1 = success, 2 = fail, 3 = param error
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class PhotonAuthController(AuthService authService, PlayerService playerService) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/auth/v1/photon")]
|
||||
[HttpPost("api/auth/v1/photon")]
|
||||
public async Task<IActionResult> Authenticate([FromQuery] string? token, [FromQuery] string? Token)
|
||||
{
|
||||
var jwt = token ?? Token;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(jwt))
|
||||
return Ok(new { ResultCode = 3, Message = "Missing token parameter" });
|
||||
|
||||
var playerId = authService.ValidateToken(jwt);
|
||||
if (playerId is null)
|
||||
return Ok(new { ResultCode = 2, Message = "Invalid or expired token" });
|
||||
|
||||
var player = await playerService.GetByIdAsync(playerId.Value);
|
||||
if (player is null)
|
||||
return Ok(new { ResultCode = 2, Message = "Player not found" });
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
ResultCode = 1,
|
||||
Message = "ok",
|
||||
UserId = player.Id.ToString(),
|
||||
Nickname = player.DisplayName ?? player.Username,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Avatar.V2;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/avatar/v2/gifts — wardrobe gift inbox. Mirrors
|
||||
/// the client's <c>RecNet.Avatars.DowloadGiftPackages</c> +
|
||||
/// <c>LocalRequestGiftPackage</c> + <c>LocalConsumeGiftPackage</c>
|
||||
/// flow. Wire DTO matches <c>RecNet.Avatars+GiftPackage</c>
|
||||
/// (<c>Cpp2IL_CS/.../RecNet/Avatars.cs:192-555</c>) field-for-field
|
||||
/// so the watch's deserialiser doesn't crash.
|
||||
///
|
||||
/// Server semantics:
|
||||
/// - <c>GET v2/gifts</c> returns unconsumed gifts for caller.
|
||||
/// - <c>POST v2/gifts/generate</c> rolls a random unlocked-avatar
|
||||
/// item and stamps a <see cref="GiftPackageEntity"/> for caller —
|
||||
/// used by drop tiles and "free daily" prompts.
|
||||
/// - <c>POST v2/gifts/consume/{id}</c> marks consumed and appends
|
||||
/// the <see cref="GiftPackageEntity.AvatarItemDescOrHairDyeDesc"/>
|
||||
/// to the player's <see cref="AvatarEntity.InventoryJson"/> so it
|
||||
/// shows up in their backpack on next load.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class AvatarGiftsController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
LevelService level) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/avatar/v2/gifts")]
|
||||
[HttpGet("api/avatar/v3/gifts")]
|
||||
[HttpGet("api/avatar/v4/gifts")]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var rows = await db.GiftPackages
|
||||
.Where(g => g.RecipientPlayerId == pid && !g.Consumed)
|
||||
.OrderByDescending(g => g.CreatedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
public sealed record GenerateGiftRequest(int? GiftContext);
|
||||
|
||||
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — server picks
|
||||
/// a random unlocked-avatar item and queues a gift package. The
|
||||
/// 2020 client posts to this when the user clicks a free-gift
|
||||
/// tile or hits a context the client maps to <c>GiftContext</c>.</summary>
|
||||
[HttpPost("api/avatar/v2/gifts/generate")]
|
||||
public async Task<IActionResult> Generate([FromBody] GenerateGiftRequest? req)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
// Pick a random wardrobe StoreItem (Slug starts with
|
||||
// "wardrobe-" — that's the convention StoreService uses for
|
||||
// catalogue rows derived from outfits_assets bundles).
|
||||
var pool = await db.StoreItems
|
||||
.Where(s => s.IsActive && s.Slug.StartsWith("wardrobe-"))
|
||||
.Select(s => new { s.Slug, s.DisplayName })
|
||||
.ToListAsync();
|
||||
if (pool.Count == 0) return StatusCode(503, "no wardrobe items in catalogue");
|
||||
|
||||
var rng = new Random();
|
||||
var pick = pool[rng.Next(pool.Count)];
|
||||
var guid = pick.Slug["wardrobe-".Length..];
|
||||
|
||||
// Bare GUIDs crash AvatarItem.FromRecNetString — append `,,,`
|
||||
// so the watch's parse-and-iterate doesn't blow up in
|
||||
// GiftManager.DequeueGift. See AvatarItemsController.cs:31-40
|
||||
// for the canonical wire-format note.
|
||||
var gift = new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = pid,
|
||||
FromPlayerId = null,
|
||||
AvatarItemType = 0, // Outfit
|
||||
AvatarItemDescOrHairDyeDesc = guid + ",,,",
|
||||
CurrencyType = 0, // Invalid (no currency in this gift)
|
||||
GiftContext = req?.GiftContext ?? 0,
|
||||
GiftRarity = RollRarity(rng),
|
||||
Message = $"Enjoy your new {pick.DisplayName}!",
|
||||
Platform = -1,
|
||||
PackageVariant = "Standard",
|
||||
PackageMaterial = string.Empty,
|
||||
IsValid = true,
|
||||
SupportsCurrentPlatform = true,
|
||||
};
|
||||
db.GiftPackages.Add(gift);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Push so the watch shows the new-gift toast.
|
||||
await notifications.NotifyAsync(pid,
|
||||
PushNotificationId.GiftPackageReceived, ToWire(gift));
|
||||
return Ok(ToWire(gift));
|
||||
}
|
||||
|
||||
[HttpPost("api/avatar/v2/gifts/consume/{id:long}")]
|
||||
public Task<IActionResult> ConsumeViaPath(long id) => ConsumeImpl(id);
|
||||
|
||||
/// <summary>POST <c>/api/avatar/v2/gifts/consume/</c> (note trailing
|
||||
/// slash, no id in path). The watch's <c>LocalConsumeGiftPackage</c>
|
||||
/// (verified at <c>Cpp2IL_ISIL/.../RecNet/Avatars_NestedType__LocalConsumeGiftPackage_d__19.txt:276-301</c>)
|
||||
/// posts to this exact URL with a form-urlencoded body containing
|
||||
/// <c>Id</c> and <c>UnlockedLevel</c> fields. The previous
|
||||
/// implementation took <c>[FromBody] ConsumeGiftRequest</c> which
|
||||
/// only accepts <c>application/json</c> — the watch's form-encoded
|
||||
/// body got 415 Unsupported Media Type
|
||||
/// (<c>output_log.txt:'Failed to consume gift … HTTP Error 415'</c>),
|
||||
/// which left the gift unconsumed → the avatar item never landed
|
||||
/// in the player's inventory → mirror-unequip rendered a missing
|
||||
/// item and crashed.
|
||||
///
|
||||
/// Accept both JSON and form-urlencoded by reading the raw body
|
||||
/// and pulling <c>Id</c> from whichever container we got.</summary>
|
||||
[HttpPost("api/avatar/v2/gifts/consume")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data", "text/plain")]
|
||||
public async Task<IActionResult> ConsumeViaBody()
|
||||
{
|
||||
long id = 0;
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
var raw = form["Id"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(raw)) raw = form["id"].ToString();
|
||||
long.TryParse(raw, out id);
|
||||
}
|
||||
else
|
||||
{
|
||||
using var reader = new System.IO.StreamReader(Request.Body);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
if (!string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(body);
|
||||
if (doc.RootElement.TryGetProperty("Id", out var v) ||
|
||||
doc.RootElement.TryGetProperty("id", out v))
|
||||
{
|
||||
if (v.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
v.TryGetInt64(out id);
|
||||
else if (v.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
long.TryParse(v.GetString(), out id);
|
||||
}
|
||||
}
|
||||
catch (System.Text.Json.JsonException) { /* fall through; id stays 0 */ }
|
||||
}
|
||||
}
|
||||
if (id == 0)
|
||||
{
|
||||
var raw = Request.Query["Id"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(raw)) raw = Request.Query["id"].ToString();
|
||||
long.TryParse(raw, out id);
|
||||
}
|
||||
if (id == 0) return BadRequest("missing Id");
|
||||
return await ConsumeImpl(id);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ConsumeImpl(long id)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var gift = await db.GiftPackages.FirstOrDefaultAsync(g => g.Id == id);
|
||||
if (gift is null) return NotFound();
|
||||
if (gift.RecipientPlayerId != pid) return Forbid();
|
||||
if (gift.Consumed) return Ok(ToWire(gift)); // idempotent
|
||||
|
||||
// Append avatar item to player's inventory list (JSON list of
|
||||
// GUID strings). Defensive parse — corrupted JSON resets to
|
||||
// empty list rather than 500.
|
||||
if (!string.IsNullOrEmpty(gift.AvatarItemDescOrHairDyeDesc))
|
||||
{
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||
if (avatar is null)
|
||||
{
|
||||
avatar = new AvatarEntity { PlayerId = pid };
|
||||
db.Avatars.Add(avatar);
|
||||
}
|
||||
try
|
||||
{
|
||||
var list = System.Text.Json.JsonSerializer
|
||||
.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
|
||||
if (!list.Contains(gift.AvatarItemDescOrHairDyeDesc))
|
||||
{
|
||||
list.Add(gift.AvatarItemDescOrHairDyeDesc);
|
||||
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list);
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
new[] { gift.AvatarItemDescOrHairDyeDesc });
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply currency / XP / level rewards bundled with the gift.
|
||||
// The previous implementation only handled the avatar item path,
|
||||
// so token gifts from the admin SPA marked themselves consumed
|
||||
// but never moved the player's wallet — the player tapped the
|
||||
// gift, the box played its animation, and the balance stayed
|
||||
// unchanged. CurrencyType is the canonical RecNet enum
|
||||
// (1 = Tokens, 2 = Coins; 0 means "no currency in this gift").
|
||||
if (gift.CurrencyType > 0 && gift.Currency > 0)
|
||||
{
|
||||
await level.GrantCurrencyAsync(pid, gift.CurrencyType, gift.Currency,
|
||||
$"gift_consumed:{gift.Id}");
|
||||
}
|
||||
if (gift.Xp > 0)
|
||||
{
|
||||
await level.AwardXpAsync(pid, gift.Xp, $"gift_consumed:{gift.Id}");
|
||||
}
|
||||
// Direct level grants bypass XP curves — same pattern
|
||||
// /api/admin/v1/players/{id}/levelup uses. We don't surface
|
||||
// that as a separate helper; bump the column inline.
|
||||
if (gift.Level > 0)
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
|
||||
if (player is not null) player.Level += gift.Level;
|
||||
}
|
||||
|
||||
gift.Consumed = true;
|
||||
gift.ConsumedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Profile-update push so the watch's wallet readout refreshes
|
||||
// without waiting for the next /balance poll. Mirrors what
|
||||
// /api/admin/v1/players/{id}/gift does after the inbox push so
|
||||
// the toast number animates up the moment the box closes.
|
||||
if (gift.CurrencyType > 0 || gift.Xp > 0 || gift.Level > 0)
|
||||
{
|
||||
await notifications.NotifyAsync(pid,
|
||||
PushNotificationId.SubscriptionUpdateProfile,
|
||||
new { Reason = "GiftConsumed", gift.CurrencyType, gift.Currency, gift.Xp, gift.Level });
|
||||
}
|
||||
|
||||
return Ok(ToWire(gift));
|
||||
}
|
||||
|
||||
/// <summary>Weighted rarity roll — Common 65, Uncommon 25,
|
||||
/// Rare 8, Epic 1.8, Legendary 0.2 percent.</summary>
|
||||
private static int RollRarity(Random rng) => rng.NextDouble() switch
|
||||
{
|
||||
< 0.65 => 0, // Common
|
||||
< 0.90 => 10, // Uncommon
|
||||
< 0.98 => 20, // Rare
|
||||
< 0.998 => 30, // Epic
|
||||
_ => 50, // Legendary
|
||||
};
|
||||
|
||||
private static object ToWire(GiftPackageEntity g) => new
|
||||
{
|
||||
g.Id,
|
||||
g.FromPlayerId,
|
||||
g.ConsumableItemDesc,
|
||||
g.AvatarItemType,
|
||||
AvatarItemDesc = g.AvatarItemDescOrHairDyeDesc,
|
||||
g.AvatarItemDescOrHairDyeDesc,
|
||||
g.EquipmentPrefabName,
|
||||
g.EquipmentModificationGuid,
|
||||
g.CurrencyType,
|
||||
g.Currency,
|
||||
g.Xp,
|
||||
g.Level,
|
||||
g.GiftContext,
|
||||
g.GiftRarity,
|
||||
g.Message,
|
||||
g.Platform,
|
||||
g.PackageMaterial,
|
||||
g.PackageVariant,
|
||||
g.Consumed,
|
||||
g.IsValid,
|
||||
g.ErrorMessage,
|
||||
g.SupportsCurrentPlatform,
|
||||
g.IsGifted,
|
||||
};
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Avatar.V2;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/avatar/v2 — older Avatar settings endpoint used by
|
||||
/// the 2020 client (the V3 endpoint is for newer builds and uses a
|
||||
/// different shape entirely).
|
||||
///
|
||||
/// Wire shape verified by disassembling RecNet.Avatar.Deserialize at
|
||||
/// RVA 0xFA3C30. Required keys (Util.GetKey<string>): OutfitSelections,
|
||||
/// HairColor, SkinColor, FaceFeatures. Empty strings satisfy the
|
||||
/// deserializer; the client interprets them as "use defaults" and falls
|
||||
/// back to its built-in starter avatar.
|
||||
///
|
||||
/// This implementation persists each set/get to <see cref="AvatarEntity"/>
|
||||
/// instead of stubbing — round-trip across sessions, multi-user-safe
|
||||
/// (one row per player keyed by PlayerId).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/avatar/v2")]
|
||||
public async Task<ActionResult<AvatarV2Dto>> GetAvatar()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid);
|
||||
return Ok(ToDto(a));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/avatar/v2/set — replace the player's avatar settings.
|
||||
///
|
||||
/// CRITICAL: do NOT use <c>[FromBody] AvatarV2Dto</c> here. The
|
||||
/// watch's <c>RecNet.Avatar.Serialize()</c> emits each of
|
||||
/// OutfitSelections / HairColor / SkinColor / FaceFeatures as
|
||||
/// dictionary values whose runtime types are determined by the
|
||||
/// IRecNetObject serializer it uses — FaceFeatures in particular
|
||||
/// sometimes ships as a nested JSON OBJECT instead of a JSON
|
||||
/// string, and the watch may also omit fields entirely when
|
||||
/// they're empty.
|
||||
///
|
||||
/// System.Text.Json's strict type binder responds to either of those
|
||||
/// with a 400 (model state invalid → ApiController auto-400) and
|
||||
/// the watch then retries every frame, accumulating "Failed to
|
||||
/// upload Rec Room player avatar changes: HTTP Error 400" errors
|
||||
/// (output_log.txt:852+, 1000+ repeats) until the player object
|
||||
/// gets destroyed.
|
||||
///
|
||||
/// Parse the body manually as JsonElement so we accept any shape
|
||||
/// the watch sends: string-or-object for FaceFeatures (re-encode
|
||||
/// the object as a string before persisting), missing fields (keep
|
||||
/// existing values), null values (treat as empty).
|
||||
/// </summary>
|
||||
[HttpPost("api/avatar/v2")]
|
||||
[HttpPost("api/avatar/v2/set")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "text/plain")]
|
||||
public async Task<ActionResult<AvatarV2Dto>> SetAvatar()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
|
||||
// Read the raw body so we control parsing — bypasses the
|
||||
// model binder's strict type matching that was causing 400s.
|
||||
string raw;
|
||||
using (var reader = new System.IO.StreamReader(Request.Body))
|
||||
raw = await reader.ReadToEndAsync();
|
||||
|
||||
string? outfitSelections = null;
|
||||
string? hairColor = null;
|
||||
string? skinColor = null;
|
||||
string? faceFeatures = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
outfitSelections = ReadStringField(root, "OutfitSelections");
|
||||
hairColor = ReadStringField(root, "HairColor");
|
||||
skinColor = ReadStringField(root, "SkinColor");
|
||||
faceFeatures = ReadStringField(root, "FaceFeatures");
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
// Fall through with all-null updates if the body isn't
|
||||
// JSON. Log so we can see what the watch actually sends.
|
||||
logger.LogWarning(
|
||||
"[avatar-set] could not parse body as JSON ({Msg}); raw={Raw}",
|
||||
ex.Message, raw.Length > 500 ? raw.Substring(0, 500) + "…" : raw);
|
||||
}
|
||||
}
|
||||
|
||||
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid);
|
||||
if (a is null)
|
||||
{
|
||||
a = new AvatarEntity { PlayerId = pid };
|
||||
db.Avatars.Add(a);
|
||||
}
|
||||
// Only overwrite fields the watch actually sent; missing keys
|
||||
// keep the previously-stored value so a partial PATCH-style
|
||||
// update doesn't accidentally wipe HairColor/SkinColor/etc.
|
||||
if (outfitSelections is not null) a.OutfitSelections = outfitSelections;
|
||||
if (hairColor is not null) a.HairColor = hairColor;
|
||||
if (skinColor is not null) a.SkinColor = skinColor;
|
||||
if (faceFeatures is not null) a.FaceFeatures = faceFeatures;
|
||||
a.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToDto(a));
|
||||
}
|
||||
|
||||
/// <summary>Read a field that should be a string but may arrive
|
||||
/// as <c>null</c>, a JSON object/array (re-encoded back to a JSON
|
||||
/// string), or be missing entirely (return null → caller keeps
|
||||
/// the existing DB value).</summary>
|
||||
private static string? ReadStringField(JsonElement root, string name)
|
||||
{
|
||||
if (!root.TryGetProperty(name, out var v)) return null;
|
||||
return v.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => v.GetString() ?? string.Empty,
|
||||
JsonValueKind.Null => string.Empty,
|
||||
JsonValueKind.Object => v.GetRawText(),
|
||||
JsonValueKind.Array => v.GetRawText(),
|
||||
JsonValueKind.Number => v.GetRawText(),
|
||||
JsonValueKind.True or JsonValueKind.False => v.GetRawText(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Public lookup of another player's equipped avatar — the
|
||||
/// client uses this to render remote players' avatars in shared
|
||||
/// rooms. No auth requirement on read so anonymous calls in the
|
||||
/// boot path don't 401.</summary>
|
||||
[HttpGet("api/avatar/v2/equipped/{playerId:long}")]
|
||||
[HttpGet("api/avatar/v3/equipped/{playerId:long}")]
|
||||
[HttpGet("api/avatar/v4/equipped/{playerId:long}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AvatarV2Dto>> GetEquippedFor(long playerId)
|
||||
{
|
||||
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == playerId);
|
||||
return Ok(ToDto(a));
|
||||
}
|
||||
|
||||
/// <summary>Bare <c>api/avatar/v{2,3}/equipped</c> returns the
|
||||
/// caller's own equipped avatar. Without the bare path the watch's
|
||||
/// "what am I wearing" query falls through to the catch-all and
|
||||
/// gets <c>[]</c>.</summary>
|
||||
[HttpGet("api/avatar/v2/equipped")]
|
||||
[HttpGet("api/avatar/v3/equipped")]
|
||||
[HttpGet("api/avatar/v4/equipped")]
|
||||
public async Task<ActionResult<AvatarV2Dto>> GetEquippedSelf()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid);
|
||||
return Ok(ToDto(a));
|
||||
}
|
||||
|
||||
private static AvatarV2Dto ToDto(AvatarEntity? a) => new()
|
||||
{
|
||||
OutfitSelections = a?.OutfitSelections ?? string.Empty,
|
||||
FaceFeatures = a?.FaceFeatures ?? string.Empty,
|
||||
HairColor = a?.HairColor ?? string.Empty,
|
||||
SkinColor = a?.SkinColor ?? string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
public class AvatarV2Dto
|
||||
{
|
||||
[JsonPropertyName("OutfitSelections")]
|
||||
public string OutfitSelections { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("HairColor")]
|
||||
public string HairColor { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("SkinColor")]
|
||||
public string SkinColor { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("FaceFeatures")]
|
||||
public string FaceFeatures { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Avatar.V3;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Wire
|
||||
/// type <c>RecNet.SavedOutfit</c> (per agent ISIL extraction):
|
||||
/// <c>Slot(int), PreviewImageName(string), OutfitSelections(string),
|
||||
/// FaceFeatures(string), SkinColor(string), HairColor(string)</c>.
|
||||
///
|
||||
/// State persists in <see cref="AvatarEntity.SavedOutfitsJson"/> as
|
||||
/// a JSON list of <see cref="SavedOutfitDto"/> records keyed by
|
||||
/// Slot. Caps at 20 entries per player to keep the column under
|
||||
/// the SQLite text-blob comfort zone.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private const int MaxSlots = 20;
|
||||
|
||||
[HttpGet("api/avatar/v1/saved")]
|
||||
[HttpGet("api/avatar/v2/saved")]
|
||||
[HttpGet("api/avatar/v3/saved")]
|
||||
[HttpGet("api/avatar/v4/saved")]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||
var list = ParseList(avatar?.SavedOutfitsJson);
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
/// <summary>POST <c>v3/saved/set</c> — upsert one outfit by Slot.
|
||||
/// Body is the SavedOutfit shape directly.</summary>
|
||||
[HttpPost("api/avatar/v1/saved/set")]
|
||||
[HttpPost("api/avatar/v3/saved/set")]
|
||||
[HttpPost("api/avatar/v2/saved/set")]
|
||||
public async Task<IActionResult> Set([FromBody] SavedOutfitDto body)
|
||||
{
|
||||
if (body is null) return BadRequest("missing body");
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||
if (avatar is null)
|
||||
{
|
||||
avatar = new AvatarEntity { PlayerId = pid };
|
||||
db.Avatars.Add(avatar);
|
||||
}
|
||||
var list = ParseList(avatar.SavedOutfitsJson);
|
||||
var idx = list.FindIndex(o => o.Slot == body.Slot);
|
||||
if (idx >= 0) list[idx] = body;
|
||||
else
|
||||
{
|
||||
if (list.Count >= MaxSlots) return BadRequest($"max {MaxSlots} saved outfits per player");
|
||||
list.Add(body);
|
||||
}
|
||||
avatar.SavedOutfitsJson = JsonSerializer.Serialize(list);
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(body);
|
||||
}
|
||||
|
||||
[HttpDelete("api/avatar/v3/saved/{slot:int}")]
|
||||
[HttpDelete("api/avatar/v2/saved/{slot:int}")]
|
||||
public async Task<IActionResult> Delete(int slot)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||
if (avatar is null) return NotFound();
|
||||
var list = ParseList(avatar.SavedOutfitsJson);
|
||||
var removed = list.RemoveAll(o => o.Slot == slot);
|
||||
if (removed == 0) return NotFound();
|
||||
avatar.SavedOutfitsJson = JsonSerializer.Serialize(list);
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private static List<SavedOutfitDto> ParseList(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return new();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<SavedOutfitDto>>(json) ?? new();
|
||||
}
|
||||
catch { return new(); }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SavedOutfitDto
|
||||
{
|
||||
[JsonPropertyName("Slot")] public int Slot { get; set; }
|
||||
[JsonPropertyName("PreviewImageName")] public string PreviewImageName { get; set; } = string.Empty;
|
||||
[JsonPropertyName("OutfitSelections")] public string OutfitSelections { get; set; } = string.Empty;
|
||||
[JsonPropertyName("FaceFeatures")] public string FaceFeatures { get; set; } = string.Empty;
|
||||
[JsonPropertyName("SkinColor")] public string SkinColor { get; set; } = string.Empty;
|
||||
[JsonPropertyName("HairColor")] public string HairColor { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Avatar.V4;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/avatar/v{2,3,4}/items — list of avatar items the
|
||||
/// caller has UNLOCKED. The watch's
|
||||
/// <c>RecNet.Avatars+<DowloadUnlockedAvatarItems></c> coroutine
|
||||
/// (Cpp2IL_ISIL/.../Avatars_NestedType__DowloadUnlockedAvatarItems_d__21.txt:149
|
||||
/// hits <c>"{api/avatar/}v4/items"</c>) parses the result as
|
||||
/// <c>List<UnlockedAvatarItem></c>; the wardrobe UI then
|
||||
/// renders a "buy" button for any item NOT in this list and an
|
||||
/// "equip" affordance for items that are.
|
||||
///
|
||||
/// Wire shape per
|
||||
/// Cpp2IL_ISIL/.../Avatars_NestedType_UnlockedAvatarItem.txt:186-212:
|
||||
/// "AvatarItemType" (int — Outfit=0 / HairDye=1, NOT slot-based),
|
||||
/// "AvatarItemDesc" (string — the item GUID with three empty
|
||||
/// customizations appended, see below),
|
||||
/// "PlatformMask" (int), "FriendlyName" (string),
|
||||
/// "Tooltip" (string), "Rarity" (int).
|
||||
///
|
||||
/// IMPORTANT — <c>AvatarItemDesc</c> format: the client passes this
|
||||
/// string to <c>AvatarItem.FromRecNetString(string)</c>
|
||||
/// (Cpp2IL_ISIL/.../RecRoom/Avatar/Data/Runtime/AvatarItem.txt:81-257).
|
||||
/// The desc is <c>String.Split(',')</c> into <c>guid + customization[0..3]</c>
|
||||
/// — the inner overload then indexes <c>customization[0]</c>,
|
||||
/// <c>customization[1]</c>, <c>customization[2]</c> and throws
|
||||
/// <c>ArgumentException</c> at <c>ArraySegment.get_Current</c> if the
|
||||
/// segment is shorter than 3. Every desc we ship MUST therefore have
|
||||
/// at least 4 comma-separated parts, so we suffix bare GUIDs with
|
||||
/// <c>",,,"</c> (three empty customizations).
|
||||
///
|
||||
/// CATALOG SOURCE — <c>data/avatar_item_lookup.json</c>. Generated by
|
||||
/// the manual/CI-only BepInEx patch under
|
||||
/// <c>tools/AvatarCatalogDumpPatch</c>. That patch reads the watch's bundled
|
||||
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
|
||||
/// SerializedDictionary at boot and writes every (guid, AvatarItemData)
|
||||
/// pair to JSON. THIS is the authoritative safe list — any GUID NOT
|
||||
/// in this file throws <c>InvalidOperationException("Missing data
|
||||
/// for guid …")</c> inside the watch's wardrobe parser, which aborts
|
||||
/// the entire coroutine on the first bad item and leaves the drawer
|
||||
/// empty. Items extracted from <c>outfits_assets_*.bundle</c>
|
||||
/// MonoBehaviours (the older <c>data/wardrobe_items.json</c>) are
|
||||
/// MASK + SWATCH asset GUIDs, NOT AvatarItem GUIDs, so that file
|
||||
/// can't be used as a safelist.
|
||||
///
|
||||
/// PER-PLAYER ownership: the watch intersects each store tile's
|
||||
/// <c>AvatarItemDesc</c> against THIS list to flip the buy-button
|
||||
/// to "Already Owned." Returning the global wardrobe here therefore
|
||||
/// makes every store item appear owned. We instead intersect the
|
||||
/// caller's <see cref="AvatarEntity.InventoryJson"/> with the master
|
||||
/// catalog so the watch sees only items the player actually owns.
|
||||
/// Anonymous calls return [].
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) : ControllerBase
|
||||
{
|
||||
/// <summary>Master catalog keyed by GUID. Loaded once on first
|
||||
/// request from <c>data/avatar_item_lookup.json</c>. Same
|
||||
/// lazy-load idiom as before but the catalog itself is now a
|
||||
/// dump of the watch's bundled
|
||||
/// <c>avatarItemDataLookup</c> instead of the asset-bundle
|
||||
/// MonoBehaviour scan in the previous wardrobe_items.json.</summary>
|
||||
private static readonly Lazy<CatalogData> _catalog = new(LoadCatalog);
|
||||
|
||||
[HttpGet("api/avatar/v2/items")]
|
||||
[HttpGet("api/avatar/v3/items")]
|
||||
[HttpGet("api/avatar/v4/items")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<UnlockedAvatarItemDto>>> Get()
|
||||
{
|
||||
// Keep this switch as a fast kill-switch, but default it on now that
|
||||
// the response is bounded below. The risky path is not the parser
|
||||
// itself; it is feeding the 2020 watch too many BrowsableAvatarItem /
|
||||
// DynamicAvatarItemImposter rows at once.
|
||||
if (!config.GetValue("Avatar:EnableUnlockedItems", true))
|
||||
return Ok(new List<UnlockedAvatarItemDto>());
|
||||
|
||||
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 30), 1, 120);
|
||||
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 4), 1, 20);
|
||||
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>());
|
||||
|
||||
var avatar = await db.Avatars
|
||||
.Where(a => a.PlayerId == me)
|
||||
.Select(a => new { a.InventoryJson, a.OutfitSelections })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var owned = ParseInventory(avatar?.InventoryJson);
|
||||
var ownedHairDyeSlugs = await db.PlayerInventory
|
||||
.Where(i => i.PlayerId == me && i.Quantity > 0)
|
||||
.Select(i => i.ItemSlug)
|
||||
.ToListAsync();
|
||||
var ownedHairDyes = ownedHairDyeSlugs
|
||||
.Where(slug => StoreService.TryGetHairDyePayload(slug, out _))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (owned.Count == 0 && ownedHairDyes.Count == 0) return Ok(new List<UnlockedAvatarItemDto>());
|
||||
|
||||
var catalog = _catalog.Value;
|
||||
var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections);
|
||||
var ordered = owned
|
||||
.OrderByDescending(guid => equipped.Contains(guid))
|
||||
.ThenBy(guid => catalog.SlotByGuid.TryGetValue(guid, out var slot) ? slot : int.MaxValue)
|
||||
.ThenBy(guid => guid, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count);
|
||||
var perSlot = new Dictionary<int, int>();
|
||||
var result = new List<UnlockedAvatarItemDto>(Math.Min(owned.Count, maxItems));
|
||||
foreach (var guid in ordered)
|
||||
{
|
||||
if (result.Count >= maxOutfitItems) break;
|
||||
// Intersect against the safelist. Items not in the bundled
|
||||
// lookup would crash the watch's wardrobe parser, so they're
|
||||
// silently dropped here.
|
||||
if (!catalog.Items.TryGetValue(guid, out var item)) continue;
|
||||
|
||||
var slot = catalog.SlotByGuid.TryGetValue(guid, out var value) ? value : -1;
|
||||
perSlot.TryGetValue(slot, out var count);
|
||||
if (count >= maxPerSlot && !equipped.Contains(guid)) continue;
|
||||
|
||||
perSlot[slot] = count + 1;
|
||||
result.Add(item);
|
||||
if (result.Count >= maxItems) break;
|
||||
}
|
||||
|
||||
foreach (var slug in ownedHairDyes)
|
||||
{
|
||||
if (result.Count >= maxItems) break;
|
||||
if (!StoreService.TryGetHairDyePayload(slug, out var hairColorGuid)) continue;
|
||||
StoreService.TryGetHairDyeName(slug, out var friendlyName);
|
||||
result.Add(new UnlockedAvatarItemDto
|
||||
{
|
||||
AvatarItemType = AvatarItemType.HairDye,
|
||||
AvatarItemDesc = hairColorGuid,
|
||||
PlatformMask = unchecked((int)0xFFFFFFFF),
|
||||
FriendlyName = friendlyName,
|
||||
Tooltip = string.Empty,
|
||||
Rarity = 0,
|
||||
});
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>The set of GUIDs the bundled
|
||||
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
|
||||
/// contains. Exposed so other controllers (StoreService seed,
|
||||
/// PlayerService starter inventory) can validate their seed
|
||||
/// data against the same safe list.</summary>
|
||||
public static IReadOnlySet<string> SafeGuids => _catalog.Value.SafeGuids;
|
||||
|
||||
/// <summary>Master catalog of all renderable items, keyed by
|
||||
/// GUID. Used by callers that need item metadata (slot type,
|
||||
/// name, hair-color flags) outside of the per-player ownership
|
||||
/// check.</summary>
|
||||
public static IReadOnlyDictionary<string, UnlockedAvatarItemDto> Catalog
|
||||
=> _catalog.Value.Items;
|
||||
|
||||
/// <summary>Per-OutfitType slot index → list of GUIDs in that slot.
|
||||
/// Used by <see cref="DorkNet.Server.Services.PlayerService"/> to
|
||||
/// build a starter loadout (one per slot) and a starter inventory
|
||||
/// (a few per slot).</summary>
|
||||
public static IReadOnlyDictionary<int, List<string>> BySlot
|
||||
=> _catalog.Value.BySlot;
|
||||
|
||||
public static bool TryGetOutfitType(string guid, out int outfitType) =>
|
||||
_catalog.Value.SlotByGuid.TryGetValue(guid, out outfitType);
|
||||
|
||||
private static List<string> ParseInventory(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return new();
|
||||
try
|
||||
{
|
||||
var strings = JsonSerializer.Deserialize<List<string>>(json);
|
||||
if (strings is not null) return strings;
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new();
|
||||
|
||||
var result = new List<string>();
|
||||
foreach (var element in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
string? value = null;
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
value = element.GetString();
|
||||
else if (element.ValueKind == JsonValueKind.Object &&
|
||||
element.TryGetProperty("itemId", out var itemId) &&
|
||||
itemId.ValueKind == JsonValueKind.String)
|
||||
value = itemId.GetString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value)) continue;
|
||||
const string wardrobePrefix = "wardrobe-";
|
||||
if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal))
|
||||
value = value[wardrobePrefix.Length..];
|
||||
var comma = value.IndexOf(',');
|
||||
if (comma >= 0)
|
||||
value = value[..comma];
|
||||
result.Add(value);
|
||||
}
|
||||
|
||||
return result
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
catch { return new(); }
|
||||
}
|
||||
|
||||
private static HashSet<string> ParseEquippedItemGuids(string? outfitSelections)
|
||||
{
|
||||
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrWhiteSpace(outfitSelections)) return result;
|
||||
|
||||
foreach (var selection in outfitSelections.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var comma = selection.IndexOf(',');
|
||||
var guid = comma >= 0 ? selection[..comma] : selection;
|
||||
if (!string.IsNullOrWhiteSpace(guid))
|
||||
result.Add(guid.Trim());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed record CatalogData(
|
||||
IReadOnlyDictionary<string, UnlockedAvatarItemDto> Items,
|
||||
IReadOnlySet<string> SafeGuids,
|
||||
IReadOnlyDictionary<int, List<string>> BySlot,
|
||||
IReadOnlyDictionary<string, int> SlotByGuid);
|
||||
|
||||
private static CatalogData LoadCatalog()
|
||||
{
|
||||
// Try both lowercase 'data/' (csproj None+Link target) and
|
||||
// uppercase 'Data/' (git-tracked source folder) so case-sensitivity
|
||||
// mismatches between Windows (where the dev wrote the path) and
|
||||
// Linux (where Coolify builds the container) can't lose the lookup
|
||||
// file. Without this fallback, a single missed file → empty
|
||||
// SafeGuids → StoreService.SeedAsync skips its stale-row cleanup
|
||||
// → 418 swatch-GUID rows ship live → every store tile renders as
|
||||
// Hat_Angler.
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "data", "avatar_item_lookup.json"),
|
||||
Path.Combine(AppContext.BaseDirectory, "Data", "avatar_item_lookup.json"),
|
||||
Path.Combine(Directory.GetCurrentDirectory(), "data", "avatar_item_lookup.json"),
|
||||
Path.Combine(Directory.GetCurrentDirectory(), "Data", "avatar_item_lookup.json"),
|
||||
};
|
||||
var path = candidates.FirstOrDefault(System.IO.File.Exists);
|
||||
Console.WriteLine(path is null
|
||||
? $"[avatar-catalog] avatar_item_lookup.json not found in any of: {string.Join(", ", candidates)}"
|
||||
: $"[avatar-catalog] loading from {path}");
|
||||
if (path is null) return new CatalogData(
|
||||
new Dictionary<string, UnlockedAvatarItemDto>(),
|
||||
new HashSet<string>(),
|
||||
new Dictionary<int, List<string>>(),
|
||||
new Dictionary<string, int>());
|
||||
|
||||
using var fs = System.IO.File.OpenRead(path);
|
||||
var dump = JsonSerializer.Deserialize<LookupFile>(fs,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? new LookupFile();
|
||||
|
||||
var items = new Dictionary<string, UnlockedAvatarItemDto>(dump.Items.Count, StringComparer.OrdinalIgnoreCase);
|
||||
var safe = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var bySlot = new Dictionary<int, List<string>>();
|
||||
var slotByGuid = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var row in dump.Items)
|
||||
{
|
||||
if (string.IsNullOrEmpty(row.Guid)) continue;
|
||||
safe.Add(row.Guid);
|
||||
// Rec Room's bundled item names are wrapped in parens like
|
||||
// "(Hat_Wizard)" — strip those and the underscores for a
|
||||
// friendlier UI label.
|
||||
var pretty = PrettyName(row.Name);
|
||||
items[row.Guid] = new UnlockedAvatarItemDto
|
||||
{
|
||||
// AvatarItemType is the wire enum the watch parses
|
||||
// (Outfit=0 / HairDye=1), not the slot. Rows in
|
||||
// avatarItemDataLookup are wearable avatar items, including
|
||||
// hair meshes that *use* hair color. They must still be sent
|
||||
// as Outfit; sending HairDye makes the client treat the item
|
||||
// GUID as a color GUID and log "Can not find hair dye color".
|
||||
AvatarItemType = AvatarItemType.Outfit,
|
||||
AvatarItemDesc = row.Guid + ",,,",
|
||||
PlatformMask = unchecked((int)0xFFFFFFFF),
|
||||
FriendlyName = pretty,
|
||||
Tooltip = string.Empty,
|
||||
Rarity = 0,
|
||||
};
|
||||
if (!bySlot.TryGetValue(row.OutfitType, out var lst))
|
||||
bySlot[row.OutfitType] = lst = new List<string>();
|
||||
lst.Add(row.Guid);
|
||||
slotByGuid[row.Guid] = row.OutfitType;
|
||||
}
|
||||
|
||||
return new CatalogData(items, safe, bySlot, slotByGuid);
|
||||
}
|
||||
|
||||
private static string PrettyName(string raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw)) return string.Empty;
|
||||
// Strip leading/trailing parens that the bundled config wraps
|
||||
// names in, then turn underscores into spaces. Keep it simple —
|
||||
// the watch shows whatever string we send.
|
||||
var trimmed = raw.Trim().TrimStart('(').TrimEnd(')');
|
||||
// Drop common asset-name suffixes. Items in the runtime config
|
||||
// use the raw asset name (e.g. "Hat_Wizard", "shirt_sherlock");
|
||||
// the watch never normalises these, so we do it server-side.
|
||||
var s = trimmed.Replace('_', ' ').Trim();
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>Shape of <c>data/avatar_item_lookup.json</c> as written
|
||||
/// by <c>tools/AvatarCatalogDumpPatch</c>.</summary>
|
||||
private sealed class LookupFile
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public List<LookupItem> Items { get; set; } = new();
|
||||
|
||||
// Auxiliary GUID lists (prefab/mask/swatch/decal). Not used
|
||||
// server-side yet — they'd matter for resolving asset
|
||||
// references the watch might subsequently request, but the
|
||||
// wire protocol only ever ships the AvatarItem GUID.
|
||||
[JsonPropertyName("prefab_guids")]
|
||||
public List<string> PrefabGuids { get; set; } = new();
|
||||
[JsonPropertyName("mask_guids")]
|
||||
public List<string> MaskGuids { get; set; } = new();
|
||||
[JsonPropertyName("swatch_guids")]
|
||||
public List<string> SwatchGuids { get; set; } = new();
|
||||
[JsonPropertyName("decal_guids")]
|
||||
public List<string> DecalGuids { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class LookupItem
|
||||
{
|
||||
[JsonPropertyName("guid")] public string Guid { get; set; } = string.Empty;
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
|
||||
[JsonPropertyName("outfit_type")] public int OutfitType { get; set; }
|
||||
[JsonPropertyName("outfit_set")] public int OutfitSet { get; set; }
|
||||
[JsonPropertyName("drawer_index")] public int DrawerIndex { get; set; }
|
||||
[JsonPropertyName("hides_hair")] public bool HidesHair { get; set; }
|
||||
[JsonPropertyName("hides_hand")] public bool HidesHand { get; set; }
|
||||
[JsonPropertyName("uses_hair_color")] public bool UsesHairColor { get; set; }
|
||||
[JsonPropertyName("uses_team_color")] public bool UsesTeamColor { get; set; }
|
||||
[JsonPropertyName("is_team_item")] public bool IsTeamItem { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Mirrors <c>RecNet.Avatars+AvatarItemType</c>
|
||||
/// (Avatars.cs:186-190).</summary>
|
||||
private static class AvatarItemType
|
||||
{
|
||||
public const int Outfit = 0;
|
||||
public const int HairDye = 1;
|
||||
}
|
||||
|
||||
/// <summary>OutfitType slot enum mirrored from
|
||||
/// <c>RecRoom.Avatar.Data.Shared.OutfitType</c>. Used by other
|
||||
/// callers (PlayerService, StoreService) for slot-aware seeding.</summary>
|
||||
public static class Slot
|
||||
{
|
||||
public const int Hat = 0;
|
||||
public const int BackHead = 1;
|
||||
public const int Hair = 2;
|
||||
public const int Eye = 10;
|
||||
public const int Mouth = 20;
|
||||
public const int Neck = 100;
|
||||
public const int Shirt = 101;
|
||||
public const int Belt = 102;
|
||||
public const int Pocket = 103;
|
||||
public const int TeamShirt = 104;
|
||||
public const int Wrist = 200;
|
||||
public const int Glove = 201;
|
||||
public const int Watch = 202;
|
||||
public const int TeamWrist = 203;
|
||||
}
|
||||
}
|
||||
|
||||
public class UnlockedAvatarItemDto
|
||||
{
|
||||
[JsonPropertyName("AvatarItemType")]
|
||||
public int AvatarItemType { get; set; }
|
||||
|
||||
[JsonPropertyName("AvatarItemDesc")]
|
||||
public string AvatarItemDesc { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("PlatformMask")]
|
||||
public int PlatformMask { get; set; }
|
||||
|
||||
[JsonPropertyName("FriendlyName")]
|
||||
public string FriendlyName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Tooltip")]
|
||||
public string Tooltip { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Rarity")]
|
||||
public int Rarity { get; set; }
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.BugReporting;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/bugreporting/* — in-game "Report a Bug" UI.
|
||||
/// Wire request DTO matches <c>RecNet.BugReporting+BugReportDTO</c>
|
||||
/// (<c>Cpp2IL_CS/.../RecNet/BugReporting.cs</c>): <c>Summary,
|
||||
/// Description, TestCaseKey, BuildVersion, BuildTimestamp,
|
||||
/// BundleVersionCode</c>.
|
||||
///
|
||||
/// Plus <c>POST /api/bugreporting/v1/submit</c> (legacy v1 path) and
|
||||
/// <c>POST /api/bugreporting/v2/submit</c> (renamed v2). The
|
||||
/// <c>ReportBug</c> client API also includes screenshot + log byte
|
||||
/// arrays as multipart parts; we accept-and-discard those (no
|
||||
/// storage flow yet) but persist the text fields.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class BugReportsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
public sealed class BugReportRequest
|
||||
{
|
||||
public string? Summary { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? TestCaseKey { get; set; }
|
||||
public string? BuildVersion { get; set; }
|
||||
public long? BuildTimestamp { get; set; }
|
||||
public int? BundleVersionCode { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/bugreporting/v1/submit")]
|
||||
[HttpPost("api/bugreporting/v2/submit")]
|
||||
[HttpPost("api/bugreporting/v1/reportbug")]
|
||||
[HttpPost("api/bugreporting/v2/reportbug")]
|
||||
[Consumes("application/json")]
|
||||
public async Task<IActionResult> SubmitJson([FromBody] BugReportRequest req)
|
||||
=> await PersistAsync(req);
|
||||
|
||||
/// <summary>Multipart form variant — the watch posts screenshot
|
||||
/// + log as separate parts. We pull the text-only fields and
|
||||
/// drop the binary attachments.</summary>
|
||||
[HttpPost("api/bugreporting/v1/submit-multipart")]
|
||||
[HttpPost("api/bugreporting/v2/reportbug-multipart")]
|
||||
[Consumes("multipart/form-data")]
|
||||
public async Task<IActionResult> SubmitMultipart(
|
||||
[FromForm(Name = "Summary")] string? summary,
|
||||
[FromForm(Name = "Description")] string? description,
|
||||
[FromForm(Name = "TestCaseKey")] string? testCaseKey,
|
||||
[FromForm(Name = "BuildVersion")] string? buildVersion,
|
||||
[FromForm(Name = "BuildTimestamp")] long? buildTimestamp)
|
||||
=> await PersistAsync(new BugReportRequest
|
||||
{
|
||||
Summary = summary,
|
||||
Description = description,
|
||||
TestCaseKey = testCaseKey,
|
||||
BuildVersion = buildVersion,
|
||||
BuildTimestamp = buildTimestamp,
|
||||
});
|
||||
|
||||
private async Task<IActionResult> PersistAsync(BugReportRequest req)
|
||||
{
|
||||
var pid = Me;
|
||||
var summary = (req.Summary ?? string.Empty).Trim();
|
||||
var body = (req.Description ?? string.Empty).Trim();
|
||||
if (summary.Length == 0 && body.Length == 0)
|
||||
return BadRequest(new { Error = "summary or description required" });
|
||||
|
||||
db.BugReports.Add(new BugReportEntity
|
||||
{
|
||||
ReporterPlayerId = pid,
|
||||
Title = summary[..Math.Min(128, summary.Length)],
|
||||
Body = body[..Math.Min(4000, body.Length)],
|
||||
ClientVersion = req.BuildVersion ?? string.Empty,
|
||||
Platform = HttpContext.Request.Headers.UserAgent.ToString()[..Math.Min(32, HttpContext.Request.Headers.UserAgent.ToString().Length)],
|
||||
Category = string.IsNullOrEmpty(req.TestCaseKey) ? "bug" : $"bug:{req.TestCaseKey}",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Submitted = true });
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Cards;
|
||||
|
||||
/// <summary>
|
||||
/// api.{rec.net,localhost}/api/cards/v1/all — home-screen card panel
|
||||
/// (announcements, promos, daily-bonus tiles). Returns global cards
|
||||
/// PLUS any cards targeted at the caller, sorted by Priority then
|
||||
/// CreatedAt. Expired cards filtered out server-side.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class CardsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/cards/v1/all")]
|
||||
public async Task<IActionResult> All()
|
||||
{
|
||||
var pid = this.CurrentPlayerId();
|
||||
var now = DateTime.UtcNow;
|
||||
var rows = await db.Cards
|
||||
.Where(c => (c.PlayerId == null || c.PlayerId == pid) &&
|
||||
(c.ExpiresAt == null || c.ExpiresAt > now))
|
||||
.OrderByDescending(c => c.Priority).ThenByDescending(c => c.CreatedAt)
|
||||
.Take(50)
|
||||
.Select(c => new
|
||||
{
|
||||
c.Id,
|
||||
c.Title,
|
||||
c.Body,
|
||||
c.ImageName,
|
||||
c.ActionUrl,
|
||||
c.Category,
|
||||
c.CreatedAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.CommunityBoard;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/communityboard/v1/current — backs the dorm's
|
||||
/// community board. The watch's
|
||||
/// <c>RecNet.CommunityBoard.GetCurrent</c> hits
|
||||
/// <c>"{api/communityboard/}v1/current"</c>
|
||||
/// (Cpp2IL_ISIL/.../CommunityBoard.txt:902) and parses the response as
|
||||
/// a <c>CommunityBoardDTO</c> with FeaturedPlayer / FeaturedRoomGroup /
|
||||
/// CurrentAnnouncement / InstagramImages / Videos
|
||||
/// (CommunityBoard_NestedType_CommunityBoardDTO.txt:460-480).
|
||||
///
|
||||
/// State lives in <see cref="CommunityBoardService"/> as a single
|
||||
/// DB row keyed at <c>CommunityBoardRows.Id = 1</c>. Admins update it
|
||||
/// via <c>POST /api/admin/v1/communityboard</c> from the admin UI.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class CommunityBoardController(
|
||||
CommunityBoardService board,
|
||||
DorkNetDbContext db,
|
||||
DomainConfig domain) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/communityboard/v1/current")]
|
||||
[HttpGet("/api/communityboard/v2/current")]
|
||||
public async Task<IActionResult> Current()
|
||||
{
|
||||
var state = await board.GetAsync();
|
||||
|
||||
// InstagramImage URL backfill — the watch's
|
||||
// InstagramImageDTO.Deserialize calls a string-typed GetKey for
|
||||
// both ImageName AND ImageUrl
|
||||
// (CommunityBoard_NestedType_InstagramImageDTO.txt:85-91); an
|
||||
// empty URL yields a broken tile. The admin SPA persists only
|
||||
// ImageName, so we derive the URL on read against the configured
|
||||
// deployment apex (DORKNET_DOMAIN).
|
||||
var instagram = state.InstagramImages.Select(img => new
|
||||
{
|
||||
ImageName = img.ImageName,
|
||||
ImageUrl = string.IsNullOrEmpty(img.ImageUrl) && !string.IsNullOrEmpty(img.ImageName)
|
||||
? $"https://{domain.Sub("img")}/{img.ImageName}"
|
||||
: img.ImageUrl,
|
||||
}).ToArray();
|
||||
|
||||
// FeaturedRoomGroup expansion — the watch's FeaturedRoomGroupDTO
|
||||
// (Rooms_NestedType_FeaturedRoomGroupDTO.txt:75-93) reads
|
||||
// FeaturedRooms as <c>List<FeaturedRoom></c> where each
|
||||
// FeaturedRoom is <c>{RoomName, RoomId, ImageName}</c> with
|
||||
// RoomId required (Util.GetKey at FeaturedRoom.txt:415-417).
|
||||
// The admin SPA persists the list as a bare <c>List<long></c>
|
||||
// of room ids — which the watch can't deserialize, throwing
|
||||
// KeyNotFoundException for "RoomId" on each item. With the list
|
||||
// throwing, GetCurrentCommunityBoardData fails and the whole
|
||||
// community-board panel renders empty. Expand the ids to full
|
||||
// FeaturedRoom shapes here so the wire payload deserialises.
|
||||
object? featuredRoomGroup = null;
|
||||
if (state.FeaturedRoomGroup is { } group)
|
||||
{
|
||||
var ids = group.FeaturedRooms.ToList();
|
||||
var rooms = ids.Count == 0
|
||||
? new()
|
||||
: await db.Rooms
|
||||
.Where(r => ids.Contains(r.Id))
|
||||
.Select(r => new { r.Id, r.Name, r.ImageName })
|
||||
.ToDictionaryAsync(r => r.Id);
|
||||
featuredRoomGroup = new
|
||||
{
|
||||
Name = group.Name ?? string.Empty,
|
||||
FeaturedRooms = ids.Select(id => rooms.TryGetValue(id, out var r)
|
||||
? new { RoomName = r.Name, RoomId = r.Id, ImageName = r.ImageName ?? string.Empty }
|
||||
: new { RoomName = string.Empty, RoomId = id, ImageName = string.Empty })
|
||||
.ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
FeaturedPlayer = state.FeaturedPlayer,
|
||||
FeaturedRoomGroup = featuredRoomGroup,
|
||||
CurrentAnnouncement = state.CurrentAnnouncement,
|
||||
InstagramImages = instagram,
|
||||
Videos = state.Videos,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Config.V1;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v1")]
|
||||
public class ConfigController(ConfigService configService) : ControllerBase
|
||||
{
|
||||
[HttpGet("motd")]
|
||||
public ActionResult<string> GetMotd()
|
||||
{
|
||||
var cfg = configService.GetConfig($"{Request.Scheme}://{Request.Host}");
|
||||
return Ok(cfg.MessageOfTheDay);
|
||||
}
|
||||
|
||||
[HttpGet("objectives")]
|
||||
public ActionResult<List<object>> GetObjectives() => Ok(new List<object>());
|
||||
|
||||
// AmplitudeConfig.Deserialize expects key "AmplitudeKey"
|
||||
[HttpGet("amplitude")]
|
||||
public ActionResult<object> GetAmplitude() => Ok(new { AmplitudeKey = string.Empty });
|
||||
|
||||
/// <summary>
|
||||
/// `Config.GetCohortNUXButtonConfigs(cohortId)` — returns the four
|
||||
/// "What would you like to do first?" buttons shown in the OOBE
|
||||
/// (RegistrationSceneNUX → ChooseCohortScreen). Each button maps to
|
||||
/// a room the new player can teleport into.
|
||||
///
|
||||
/// Wire shape per CohortNUXButtonConfig (TypeDefIndex 11516) — list of:
|
||||
/// Version (int)
|
||||
/// ButtonNumber (int, 0..3 → Button1..Button4)
|
||||
/// Override (int, 0=None, 1=Hotlist, 2=Custom)
|
||||
/// CustomRoomName (string)
|
||||
/// CustomTitle (string)
|
||||
/// DefaultRoomName (string)
|
||||
/// DefaultTitle (string)
|
||||
///
|
||||
/// The screen picks `CustomRoomName` if Override=Custom, else the
|
||||
/// `DefaultRoomName` (resolved by Rooms.GetByName). For simplicity all
|
||||
/// four use Override=None and route to Rec Room Original rooms we
|
||||
/// already seeded — picking the most newcomer-friendly ones.
|
||||
/// </summary>
|
||||
[HttpGet("cohortnux/{cohortId:int}")]
|
||||
public IActionResult GetCohortNux(int cohortId) => Ok(new[]
|
||||
{
|
||||
new {
|
||||
Version = 1, ButtonNumber = 0, Override = 0,
|
||||
CustomRoomName = "", CustomTitle = "",
|
||||
DefaultRoomName = "RecCenter",
|
||||
DefaultTitle = "Hang out at the Rec Center",
|
||||
},
|
||||
new {
|
||||
Version = 1, ButtonNumber = 1, Override = 0,
|
||||
CustomRoomName = "", CustomTitle = "",
|
||||
DefaultRoomName = "Paintball",
|
||||
DefaultTitle = "Play Paintball",
|
||||
},
|
||||
new {
|
||||
Version = 1, ButtonNumber = 2, Override = 0,
|
||||
CustomRoomName = "", CustomTitle = "",
|
||||
DefaultRoomName = "Dodgeball",
|
||||
DefaultTitle = "Play Dodgeball",
|
||||
},
|
||||
new {
|
||||
Version = 1, ButtonNumber = 3, Override = 0,
|
||||
CustomRoomName = "", CustomTitle = "",
|
||||
DefaultRoomName = "GoldenTrophy",
|
||||
DefaultTitle = "Quest for the Golden Trophy",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Config.V1;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full Rec Room service URL map. Some builds fetch this standalone
|
||||
/// at /api/config/v1/services to discover where the other subdomains live.
|
||||
/// Apex comes from <see cref="DomainConfig"/> (DORKNET_DOMAIN), so a single
|
||||
/// env var controls every URL emitted.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/config/v1")]
|
||||
public class ServiceUrlsController(DomainConfig domain) : ControllerBase
|
||||
{
|
||||
[HttpGet("services")]
|
||||
public ActionResult<Dictionary<string, string>> Get()
|
||||
=> Ok(ConfigService.BuildServiceUrlMap(domain));
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Models.Config;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Config.V2;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v2")]
|
||||
public class ConfigController(ConfigService configService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public ActionResult<RecRoomConfig> GetConfig()
|
||||
{
|
||||
var baseUrl = $"{Request.Scheme}://{Request.Host}";
|
||||
return Ok(configService.GetConfig(baseUrl));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Events.V3;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v3")]
|
||||
public class EventsController : ControllerBase
|
||||
{
|
||||
[HttpGet("list")]
|
||||
public ActionResult<List<object>> GetActiveEvents() => Ok(new List<object>());
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.GameConfigurations;
|
||||
|
||||
[ApiController]
|
||||
public class GameConfigurationsController : ControllerBase
|
||||
{
|
||||
// Actual path used by the 2020 build
|
||||
[HttpGet("api/gameconfigs/v1/all")]
|
||||
[HttpGet("api/gameconfigs/v1")]
|
||||
// Legacy path we originally assumed
|
||||
[HttpGet("api/gameconfigurations/v1/all")]
|
||||
[HttpGet("api/gameconfigurations/v1")]
|
||||
public ActionResult<List<object>> GetAll() => Ok(new List<object>());
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Models.GameSessions;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.GameSessions.V2;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v2")]
|
||||
[Authorize]
|
||||
public class GameSessionsController(GameSessionService sessionService) : ControllerBase
|
||||
{
|
||||
private long CurrentPlayerId => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpPost("joinRandom")]
|
||||
public async Task<ActionResult<JoinGameSessionResponse>> JoinRandom([FromBody] JoinRandomGameSessionRequest req)
|
||||
{
|
||||
var session = await sessionService.JoinOrCreateAsync(req.RoomId, req.ActivityLevelId, req.RegionId);
|
||||
return Ok(new JoinGameSessionResponse
|
||||
{
|
||||
Result = JoinGameErrorCode.Success,
|
||||
GameSession = session,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("joinById")]
|
||||
public async Task<ActionResult<JoinGameSessionResponse>> JoinById([FromBody] JoinByIdRequest req)
|
||||
{
|
||||
var session = await sessionService.GetByIdAsync(req.GameSessionId);
|
||||
if (session is null)
|
||||
{
|
||||
return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.NotFound });
|
||||
}
|
||||
|
||||
if (session.IsFull)
|
||||
{
|
||||
return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.Full });
|
||||
}
|
||||
|
||||
return Ok(new JoinGameSessionResponse
|
||||
{
|
||||
Result = JoinGameErrorCode.Success,
|
||||
GameSession = session,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("leave")]
|
||||
public async Task<ActionResult> Leave([FromBody] long sessionId)
|
||||
{
|
||||
await sessionService.PlayerLeftAsync(sessionId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpGet("{id:long}")]
|
||||
public async Task<ActionResult<GameSession>> GetSession(long id)
|
||||
{
|
||||
var session = await sessionService.GetByIdAsync(id);
|
||||
if (session is null) return NotFound();
|
||||
return Ok(session);
|
||||
}
|
||||
}
|
||||
|
||||
public class JoinByIdRequest
|
||||
{
|
||||
public long GameSessionId { get; set; }
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Groups;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/groups/* — clubs / groups. Wire types from
|
||||
/// <c>RecNet.Group</c> + <c>RecNet.GroupMembership</c>:
|
||||
///
|
||||
/// • Group: <c>GroupId, Name, Description, CreatedAt, ImageName,
|
||||
/// BanStatus, CreatorId, NumMembers, Members</c>.
|
||||
/// • GroupMembership: <c>GroupId, PlayerId, Permissions</c>
|
||||
/// (GroupMembershipPermissions flags).
|
||||
///
|
||||
/// URL surface (verb + template) verified against
|
||||
/// <c>Cpp2IL_ISIL/.../RecNet/Groups.txt</c>:
|
||||
/// GET api/groups/v1/{id}
|
||||
/// GET api/groups/v1/name/{name}
|
||||
/// GET api/groups/v1/memberships/{playerId}
|
||||
/// POST api/groups/v1 (create — body CreateGroupRequest)
|
||||
/// POST api/groups/v1/delete/{id}
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class GroupsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
private long? MeOrNull => this.CurrentPlayerId();
|
||||
|
||||
[HttpGet("api/groups/v1/{id:long}")]
|
||||
public async Task<IActionResult> GetById(long id)
|
||||
{
|
||||
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
|
||||
if (g is null) return NotFound();
|
||||
var members = await db.ClubMemberships
|
||||
.Where(m => m.ClubId == id)
|
||||
.ToListAsync();
|
||||
return Ok(ToWire(g, members));
|
||||
}
|
||||
|
||||
[HttpGet("api/groups/v1/name/{name}")]
|
||||
public async Task<IActionResult> GetByName(string name)
|
||||
{
|
||||
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Name == name);
|
||||
if (g is null) return NotFound();
|
||||
var members = await db.ClubMemberships
|
||||
.Where(m => m.ClubId == g.Id)
|
||||
.ToListAsync();
|
||||
return Ok(ToWire(g, members));
|
||||
}
|
||||
|
||||
[HttpGet("api/groups/v1/memberships/{playerId:long}")]
|
||||
public async Task<IActionResult> Memberships(long playerId)
|
||||
{
|
||||
var rows = await (from m in db.ClubMemberships
|
||||
join c in db.Clubs on m.ClubId equals c.Id
|
||||
where m.PlayerId == playerId
|
||||
select new { m, c }).ToListAsync();
|
||||
return Ok(rows.Select(r => new
|
||||
{
|
||||
GroupId = r.c.Id,
|
||||
PlayerId = (int)r.m.PlayerId,
|
||||
Permissions = r.m.Permissions,
|
||||
Group = ToWire(r.c, new List<ClubMembershipEntity>()),
|
||||
}));
|
||||
}
|
||||
|
||||
public sealed class CreateGroupRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/groups/v1")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Create([FromBody] CreateGroupRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Name))
|
||||
return BadRequest(new { Result = 1, Error = "name required" });
|
||||
var pid = Me;
|
||||
var dupe = await db.Clubs.AnyAsync(c => c.Name == req.Name);
|
||||
if (dupe) return Conflict(new { Result = 2, Error = "name taken" });
|
||||
|
||||
var club = new ClubEntity
|
||||
{
|
||||
Name = req.Name.Trim(),
|
||||
Description = req.Description ?? string.Empty,
|
||||
ImageName = req.ImageName ?? string.Empty,
|
||||
CreatorPlayerId = pid,
|
||||
BanStatus = 0,
|
||||
};
|
||||
db.Clubs.Add(club);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Owner membership uses GroupMembershipPermissions.Owner = 127.
|
||||
db.ClubMemberships.Add(new ClubMembershipEntity
|
||||
{
|
||||
ClubId = club.Id,
|
||||
PlayerId = pid,
|
||||
Permissions = 127,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
Result = 0,
|
||||
Group = ToWire(club, new List<ClubMembershipEntity>
|
||||
{
|
||||
new() { ClubId = club.Id, PlayerId = pid, Permissions = 127 },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
public sealed class UpdateGroupRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/groups/v1/{id}</c> — owner-only update of
|
||||
/// description / image (per <c>RecNet.Groups.UpdateGroup</c> body
|
||||
/// shape). Mirrors the create response so the watch's
|
||||
/// <c>CreateModifyGroupResponse</c> deserialiser parses both.</summary>
|
||||
[HttpPost("api/groups/v1/{id:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Update(long id, [FromBody] UpdateGroupRequest req)
|
||||
{
|
||||
var pid = Me;
|
||||
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
|
||||
if (g is null) return NotFound();
|
||||
if (g.CreatorPlayerId != pid) return Forbid();
|
||||
if (req.Description is not null) g.Description = req.Description;
|
||||
if (req.ImageName is not null) g.ImageName = req.ImageName;
|
||||
// Name updates are routed to the dedicated endpoint below
|
||||
// (uniqueness check + name index has to round-trip).
|
||||
g.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
var members = await db.ClubMemberships.Where(m => m.ClubId == id).ToListAsync();
|
||||
return Ok(new { Result = 0, Group = ToWire(g, members) });
|
||||
}
|
||||
|
||||
public sealed class UpdateGroupNameRequest { public string? Name { get; set; } }
|
||||
|
||||
/// <summary>POST <c>api/groups/v1/name/{id}</c> — owner-only
|
||||
/// rename. Unique-name collision returns Result=2 to match the
|
||||
/// client's <c>CreateModifyGroupStatus</c> enum.</summary>
|
||||
[HttpPost("api/groups/v1/name/{id:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> UpdateName(long id, [FromBody] UpdateGroupNameRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Name))
|
||||
return BadRequest(new { Result = 1, Error = "name required" });
|
||||
var pid = Me;
|
||||
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
|
||||
if (g is null) return NotFound();
|
||||
if (g.CreatorPlayerId != pid) return Forbid();
|
||||
var dupe = await db.Clubs.AnyAsync(c => c.Id != id && c.Name == req.Name);
|
||||
if (dupe) return Conflict(new { Result = 2, Error = "name taken" });
|
||||
g.Name = req.Name.Trim();
|
||||
g.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
var members = await db.ClubMemberships.Where(m => m.ClubId == id).ToListAsync();
|
||||
return Ok(new { Result = 0, Group = ToWire(g, members) });
|
||||
}
|
||||
|
||||
[HttpPost("api/groups/v1/delete/{id:long}")]
|
||||
[HttpDelete("api/groups/v1/delete/{id:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Delete(long id)
|
||||
{
|
||||
var pid = Me;
|
||||
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
|
||||
if (g is null) return NotFound();
|
||||
if (g.CreatorPlayerId != pid) return Forbid();
|
||||
var memberships = await db.ClubMemberships.Where(m => m.ClubId == id).ToListAsync();
|
||||
db.ClubMemberships.RemoveRange(memberships);
|
||||
db.Clubs.Remove(g);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Deleted = true });
|
||||
}
|
||||
|
||||
[HttpPost("api/groups/v1/{id:long}/join")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Join(long id)
|
||||
{
|
||||
var pid = Me;
|
||||
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
|
||||
if (g is null) return NotFound();
|
||||
var existing = await db.ClubMemberships
|
||||
.FirstOrDefaultAsync(m => m.ClubId == id && m.PlayerId == pid);
|
||||
if (existing is not null) return Ok(new { Joined = true });
|
||||
db.ClubMemberships.Add(new ClubMembershipEntity
|
||||
{
|
||||
ClubId = id,
|
||||
PlayerId = pid,
|
||||
Permissions = 0, // Member
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Joined = true });
|
||||
}
|
||||
|
||||
[HttpPost("api/groups/v1/{id:long}/leave")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Leave(long id)
|
||||
{
|
||||
var pid = Me;
|
||||
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
|
||||
if (g is null) return NotFound();
|
||||
if (g.CreatorPlayerId == pid)
|
||||
return BadRequest(new { Error = "owner cannot leave; transfer ownership first" });
|
||||
var row = await db.ClubMemberships
|
||||
.FirstOrDefaultAsync(m => m.ClubId == id && m.PlayerId == pid);
|
||||
if (row is null) return NotFound();
|
||||
db.ClubMemberships.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Left = true });
|
||||
}
|
||||
|
||||
private static object ToWire(ClubEntity g, List<ClubMembershipEntity> members) => new
|
||||
{
|
||||
GroupId = g.Id,
|
||||
g.Name,
|
||||
g.Description,
|
||||
g.CreatedAt,
|
||||
g.ImageName,
|
||||
BanStatus = g.BanStatus,
|
||||
CreatorId = (int)g.CreatorPlayerId,
|
||||
NumMembers = members.Count,
|
||||
Members = members.Select(m => new
|
||||
{
|
||||
GroupId = g.Id,
|
||||
PlayerId = (int)m.PlayerId,
|
||||
Permissions = m.Permissions,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,859 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Models.Auth;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Images;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/images/v* — in-game camera photo endpoints. The 2020
|
||||
/// watch's camera UI ("save photo to album", "share photo") posts here.
|
||||
///
|
||||
/// Wire shape (observed from server logs — the 2020 watch hits these
|
||||
/// without a corresponding storage.rec.net/upload, meaning the bytes
|
||||
/// come straight in this request body):
|
||||
/// POST api/images/v4/uploadsaved
|
||||
/// - body: multipart/form-data with the image file plus optional
|
||||
/// fields (Caption, Description, RoomId, IsPublic, TaggedPlayerIds).
|
||||
/// On the 2020 client the field name varies — we accept "File",
|
||||
/// "Image", and any *.png/*.jpg upload for safety.
|
||||
/// - response: an ImageInfo-shaped object (PascalCase keys). The
|
||||
/// watch's success path calls a JSON parser whose strict keys are
|
||||
/// AccountId, ImageName, CreatedAt — so all three are required.
|
||||
/// Returning [] (the previous catch-all) made the watch silently
|
||||
/// drop the photo.
|
||||
///
|
||||
/// Saved photos are stored as <see cref="PhotoEntity"/> rows with
|
||||
/// IsPublic=false by default. A separate Share button (POST
|
||||
/// api/images/v4/share/{id}) flips IsPublic=true and surfaces them on
|
||||
/// feed.rec.net.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class ImagesController(
|
||||
DorkNetDbContext db,
|
||||
PlayerPresenceService presence,
|
||||
LevelService level,
|
||||
IObjectStorage storage,
|
||||
ImageSignatureService signatures,
|
||||
NotificationService notifications,
|
||||
DomainConfig domain,
|
||||
ILogger<ImagesController> logger) : ControllerBase
|
||||
{
|
||||
private static readonly string ImageDir =
|
||||
Path.Combine(AppContext.BaseDirectory, "data", "images");
|
||||
|
||||
private static readonly byte[] TransparentPng = new byte[]
|
||||
{
|
||||
0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A,0x00,0x00,0x00,0x0D,
|
||||
0x49,0x48,0x44,0x52,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01,
|
||||
0x08,0x06,0x00,0x00,0x00,0x1F,0x15,0xC4,0x89,0x00,0x00,0x00,
|
||||
0x0D,0x49,0x44,0x41,0x54,0x78,0x9C,0x63,0x00,0x01,0x00,0x00,
|
||||
0x05,0x00,0x01,0x0D,0x0A,0x2D,0xB4,0x00,0x00,0x00,0x00,0x49,
|
||||
0x45,0x4E,0x44,0xAE,0x42,0x60,0x82,
|
||||
};
|
||||
|
||||
/// <summary>POST api/images/v{2-5}/uploadsaved — receive an image
|
||||
/// from the in-game camera "save to album" button. Persists the
|
||||
/// bytes in <see cref="RoomDataBlobEntity"/> and creates a
|
||||
/// <see cref="PhotoEntity"/> row keyed by the uploader. The photo
|
||||
/// defaults to IsPublic=false (saved-only) so it doesn't surface
|
||||
/// on the public feed unless the user explicitly hits Share.</summary>
|
||||
[HttpPost("api/images/v2/uploadsaved")]
|
||||
[HttpPost("api/images/v3/uploadsaved")]
|
||||
[HttpPost("api/images/v4/uploadsaved")]
|
||||
[HttpPost("api/images/v5/uploadsaved")]
|
||||
// 2018 flat-screen (non-VR) camera save posts a single 'image' part to
|
||||
// uploadsavedsingle (RecNet.cs:35080); ReadImageBytesAsync already falls
|
||||
// back to the first uploaded file, so the same handler serves it.
|
||||
[HttpPost("api/images/v2/uploadsavedsingle")]
|
||||
[HttpPost("api/images/v3/uploadsavedsingle")]
|
||||
[HttpPost("api/images/v4/uploadsavedsingle")]
|
||||
[HttpPost("api/images/v5/uploadsavedsingle")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> UploadSaved()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var bytes = await ReadImageBytesAsync();
|
||||
if (bytes is null || bytes.Length == 0)
|
||||
{
|
||||
logger.LogWarning("[images] uploadsaved by {Pid}: no file bytes received", pid);
|
||||
return BadRequest(new { error = "missing_file" });
|
||||
}
|
||||
|
||||
// Pull optional metadata from the form (the 2020 watch sometimes
|
||||
// includes a caption / room context, sometimes posts bytes only).
|
||||
var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null;
|
||||
var caption = form?["Caption"].ToString() ?? form?["Description"].ToString() ?? string.Empty;
|
||||
var taggedIds = form?["TaggedPlayerIds"].ToString() ?? string.Empty;
|
||||
long roomId = 0;
|
||||
if (form is not null && long.TryParse(form["RoomId"].ToString(), out var rid)) roomId = rid;
|
||||
if (roomId == 0) roomId = presence.GetRoom(pid)?.RoomId ?? 0;
|
||||
var isPublic = string.Equals(form?["IsPublic"].ToString(), "true", StringComparison.OrdinalIgnoreCase);
|
||||
var isProfileThumbnail = IsProfileThumbnail(form);
|
||||
LogUploadForm("uploadsaved", pid, form, isProfileThumbnail);
|
||||
|
||||
// Detect format. The watch uses PNG; if a JPEG slips through we
|
||||
// tag it accordingly so the cdn serves the right MIME.
|
||||
var extension = SniffExtension(bytes);
|
||||
var blobName = $"img_p{pid}_{Guid.NewGuid():N}.{extension}";
|
||||
|
||||
// Dual-write during the migration window: S3 is the canonical
|
||||
// store going forward, but we keep the DB Bytes column populated
|
||||
// so reads from older code paths still work. PR 4 drops the
|
||||
// column once every blob has a confirmed S3 copy.
|
||||
try
|
||||
{
|
||||
using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(4));
|
||||
var (bucket, key) = BlobRouter.Route(blobName);
|
||||
await storage.PutAsync(
|
||||
bucket, key, bytes,
|
||||
extension == "png" ? "image/png" : "image/jpeg",
|
||||
s3Timeout.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(
|
||||
ex,
|
||||
"[images] S3 upload failed for {Blob}; keeping DB fallback so the client does not fail",
|
||||
blobName);
|
||||
}
|
||||
|
||||
db.RoomDataBlobs.Add(new RoomDataBlobEntity
|
||||
{
|
||||
RoomId = 0,
|
||||
BlobName = blobName,
|
||||
UploadedByPlayerId = pid,
|
||||
UploadedAt = DateTime.UtcNow,
|
||||
ReferencedFilenamesCsv = string.Empty,
|
||||
});
|
||||
|
||||
var photo = new PhotoEntity
|
||||
{
|
||||
UploaderPlayerId = pid,
|
||||
BlobName = blobName,
|
||||
Caption = caption.Trim(),
|
||||
TaggedPlayerIdsCsv = NormaliseTaggedIds(taggedIds, exceptId: pid),
|
||||
RoomId = roomId,
|
||||
IsPublic = isPublic,
|
||||
};
|
||||
db.Photos.Add(photo);
|
||||
|
||||
if (isProfileThumbnail)
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
|
||||
if (player is not null)
|
||||
{
|
||||
player.ProfileImageName = blobName;
|
||||
logger.LogInformation(
|
||||
"[images] profile thumbnail upload set profile image: player={PlayerId} blob={Blob}",
|
||||
pid,
|
||||
blobName);
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (isProfileThumbnail)
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
|
||||
if (player is not null)
|
||||
await NotifyAccountImageChangedAsync(player);
|
||||
}
|
||||
|
||||
// Small XP bump for taking a photo — encourages camera use.
|
||||
await level.AwardXpAsync(pid, LevelService.RoomVisitXp, $"photo_saved:{photo.Id}");
|
||||
|
||||
logger.LogInformation(
|
||||
"[images] uploaded saved photo {Id} by {Pid} ({Bytes} bytes) → {Blob}",
|
||||
photo.Id, pid, bytes.Length, blobName);
|
||||
|
||||
return Ok(BuildImageInfo(photo, pid));
|
||||
}
|
||||
|
||||
/// <summary>POST api/images/v{1-5}/profile — direct profile image upload.
|
||||
/// Older clients use this instead of tagging an uploadsaved request as a
|
||||
/// ProfileThumbnail, so this route must always update Account.ProfileImage.</summary>
|
||||
[HttpPost("api/images/v1/profile")]
|
||||
[HttpPost("api/images/v2/profile")]
|
||||
[HttpPost("api/images/v3/profile")]
|
||||
[HttpPost("api/images/v4/profile")]
|
||||
[HttpPost("api/images/v5/profile")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> UploadProfile()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var bytes = await ReadImageBytesAsync();
|
||||
if (bytes is null || bytes.Length == 0)
|
||||
{
|
||||
logger.LogWarning("[images] profile upload by {Pid}: no file bytes received", pid);
|
||||
return BadRequest(new { error = "missing_file" });
|
||||
}
|
||||
|
||||
var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null;
|
||||
LogUploadForm("profile", pid, form, isProfile: true);
|
||||
|
||||
var extension = SniffExtension(bytes);
|
||||
var blobName = $"img_p{pid}_{Guid.NewGuid():N}.{extension}";
|
||||
var contentType = extension == "png" ? "image/png" : "image/jpeg";
|
||||
|
||||
try
|
||||
{
|
||||
using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(4));
|
||||
var (bucket, key) = BlobRouter.Route(blobName);
|
||||
await storage.PutAsync(bucket, key, bytes, contentType, s3Timeout.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(
|
||||
ex,
|
||||
"[images] profile S3 upload failed for {Blob}; keeping DB fallback so the client does not fail",
|
||||
blobName);
|
||||
}
|
||||
|
||||
db.RoomDataBlobs.Add(new RoomDataBlobEntity
|
||||
{
|
||||
RoomId = 0,
|
||||
BlobName = blobName,
|
||||
UploadedByPlayerId = pid,
|
||||
UploadedAt = DateTime.UtcNow,
|
||||
ReferencedFilenamesCsv = string.Empty,
|
||||
});
|
||||
|
||||
var photo = new PhotoEntity
|
||||
{
|
||||
UploaderPlayerId = pid,
|
||||
BlobName = blobName,
|
||||
Caption = string.Empty,
|
||||
TaggedPlayerIdsCsv = string.Empty,
|
||||
RoomId = presence.GetRoom(pid)?.RoomId ?? 0,
|
||||
IsPublic = false,
|
||||
};
|
||||
db.Photos.Add(photo);
|
||||
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
|
||||
if (player is not null)
|
||||
player.ProfileImageName = blobName;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (player is not null)
|
||||
await NotifyAccountImageChangedAsync(player);
|
||||
|
||||
logger.LogInformation(
|
||||
"[images] uploaded profile photo {Id} by {Pid} ({Bytes} bytes) -> {Blob}",
|
||||
photo.Id, pid, bytes.Length, blobName);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
ImageName = blobName,
|
||||
AccountId = (int)pid,
|
||||
CreatedAt = photo.CreatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET api/images/v{2-5}/saved — list the caller's saved
|
||||
/// photos. The watch's "My Photos" tab calls this to populate the
|
||||
/// album view.</summary>
|
||||
[HttpGet("api/images/v2/saved")]
|
||||
[HttpGet("api/images/v3/saved")]
|
||||
[HttpGet("api/images/v4/saved")]
|
||||
[HttpGet("api/images/v5/saved")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetSaved()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var rows = await db.Photos
|
||||
.Where(p => p.UploaderPlayerId == pid && p.DeletedAt == null)
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Take(200)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(p => BuildImageInfo(p, pid)));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/images/v2/named</c> — the watch's
|
||||
/// <c>Images.DownloadNamedImageMappings</c> endpoint. Returns a
|
||||
/// list of <c>NamedImageDTO</c> entries; an empty list is a valid
|
||||
/// response (the watch's <c>ExpectListResponse</c> rejects an
|
||||
/// object body but tolerates an empty array).</summary>
|
||||
[HttpGet("api/images/v2/named")]
|
||||
public IActionResult NamedImages() => Ok(Array.Empty<object>());
|
||||
|
||||
/// <summary>GET <c>api/images/v1/slideshow</c> — the watch's lobby
|
||||
/// slideshow info. Response shape per <c>SlideshowInfoDTO.Deserialize</c>:
|
||||
/// <c>{ValidTill (DateTime, required), Images (list, required)}</c>.
|
||||
/// Far-future <c>ValidTill</c> stops the watch from re-polling.</summary>
|
||||
[HttpGet("api/images/v1/slideshow")]
|
||||
public IActionResult SlideshowInfo() => Ok(new
|
||||
{
|
||||
// WireDates.FarFuture (2099), NOT year 9999 — a 9999 ValidTill
|
||||
// overflows the watch's local-timezone date parse in some DST
|
||||
// timezones → "malformed RecNet response". See WireDates.
|
||||
ValidTill = WireDates.FarFuture,
|
||||
Images = Array.Empty<object>(),
|
||||
});
|
||||
|
||||
/// <summary>Binary image fallback for clients that request image bytes
|
||||
/// through api/images/{name} instead of img.rec.net/{name}. Specific JSON
|
||||
/// endpoints above still win route matching; this only handles CDN-shaped
|
||||
/// image paths.</summary>
|
||||
[HttpGet("api/images/{*path}", Order = 100)]
|
||||
public async Task<IActionResult> ServeImageFromApiHost(string? path)
|
||||
{
|
||||
var fileName = Path.GetFileName(path ?? string.Empty);
|
||||
// Strip the leading '$' sigil that ImageData.image_name uses for
|
||||
// hash-addressed assets. Watch sends "%24<hash>.jpg"; route
|
||||
// decode gives us "$<hash>.jpg"; the bytes are stored at
|
||||
// "<hash>.jpg". Without the strip, placed in-world polaroids
|
||||
// miss in S3 and the watch renders the "?" fallback.
|
||||
if (!string.IsNullOrEmpty(fileName) && fileName[0] == '$') fileName = fileName[1..];
|
||||
if (string.IsNullOrWhiteSpace(fileName) || !IsImageName(fileName))
|
||||
return NotFound();
|
||||
|
||||
var result = await LoadImageBytesAsync(fileName);
|
||||
if (result.Bytes is { Length: > 0 })
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[images] api image hit host={Host} path={Path} file={File} source={Source} bytes={Bytes} query='{Query}'",
|
||||
Request.Host.Host,
|
||||
path,
|
||||
fileName,
|
||||
result.Source,
|
||||
result.Bytes.Length,
|
||||
Request.QueryString);
|
||||
Response.Headers.CacheControl = "public, max-age=300";
|
||||
signatures.AddContentSignature(Response, result.Bytes);
|
||||
return File(result.Bytes, MimeFromName(fileName));
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"[images] api image MISS host={Host} path={Path} file={File} query='{Query}' -> transparent png",
|
||||
Request.Host.Host,
|
||||
path,
|
||||
fileName,
|
||||
Request.QueryString);
|
||||
Response.Headers.CacheControl = "public, max-age=60";
|
||||
signatures.AddContentSignature(Response, TransparentPng);
|
||||
return File(TransparentPng, "image/png");
|
||||
}
|
||||
|
||||
/// <summary>GET api/images/v{2-5}/byaccount/{accountId} — list a
|
||||
/// player's PUBLIC photos. Drives the "their album" tab in
|
||||
/// the watch's player profile.</summary>
|
||||
[HttpGet("api/images/v2/byaccount/{accountId:long}")]
|
||||
[HttpGet("api/images/v3/byaccount/{accountId:long}")]
|
||||
[HttpGet("api/images/v4/byaccount/{accountId:long}")]
|
||||
[HttpGet("api/images/v5/byaccount/{accountId:long}")]
|
||||
public async Task<IActionResult> GetByAccount(long accountId,
|
||||
[FromQuery] int take = 50, [FromQuery] int skip = 0)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 200);
|
||||
skip = Math.Max(0, skip);
|
||||
var rows = await db.Photos
|
||||
.Where(p => p.UploaderPlayerId == accountId && p.IsPublic && p.DeletedAt == null)
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Skip(skip).Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(p => BuildImageInfo(p, accountId)));
|
||||
}
|
||||
|
||||
/// <summary>POST api/images/v{2-5}/share/{id} — flip a saved photo
|
||||
/// to public so it shows up on feed.rec.net. The watch's "Share"
|
||||
/// button hits this; if the watch posts to a different URL we'll
|
||||
/// add it when we see it in logs.</summary>
|
||||
[HttpPost("api/images/v2/share/{id:long}")]
|
||||
[HttpPost("api/images/v3/share/{id:long}")]
|
||||
[HttpPost("api/images/v4/share/{id:long}")]
|
||||
[HttpPost("api/images/v5/share/{id:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Share(long id)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (photo is null) return NotFound();
|
||||
if (photo.UploaderPlayerId != pid) return Forbid();
|
||||
photo.IsPublic = true;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(BuildImageInfo(photo, pid));
|
||||
}
|
||||
|
||||
/// <summary>DELETE api/images/v{2-5}/{id} — uploader (or admin)
|
||||
/// soft-deletes a saved photo.</summary>
|
||||
[HttpDelete("api/images/v2/{id:long}")]
|
||||
[HttpDelete("api/images/v3/{id:long}")]
|
||||
[HttpDelete("api/images/v4/{id:long}")]
|
||||
[HttpDelete("api/images/v5/{id:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Delete(long id)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (photo is null) return NotFound();
|
||||
if (photo.UploaderPlayerId != pid)
|
||||
{
|
||||
var isAdmin = await db.Players.Where(x => x.Id == pid).Select(x => x.IsAdmin).FirstOrDefaultAsync();
|
||||
if (!isAdmin) return Forbid();
|
||||
}
|
||||
photo.DeletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { deleted = id });
|
||||
}
|
||||
|
||||
// ── Legacy v1 ops (real persistence, was Ack-only stubs) ─────────
|
||||
|
||||
public sealed class CheerImageRequest
|
||||
{
|
||||
public long ImageId { get; set; }
|
||||
public long PhotoId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/images/v1/cheer</c> — cheer a photo.
|
||||
/// Was previously Ack-only; now persists a <c>CheerEntity</c> row
|
||||
/// keyed on <c>TargetPhotoId</c> + bumps <c>Photos.CheerCount</c>.
|
||||
/// Idempotent per (player, photo).</summary>
|
||||
[HttpPost("api/images/v1/cheer")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CheerV1([FromBody] CheerImageRequest? body,
|
||||
[FromForm(Name = "ImageId")] long? imageIdForm,
|
||||
[FromForm(Name = "PhotoId")] long? photoIdForm)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var photoId = body?.PhotoId ?? body?.ImageId
|
||||
?? photoIdForm ?? imageIdForm ?? 0;
|
||||
if (photoId <= 0) return BadRequest(new { error = "missing photoId" });
|
||||
|
||||
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId && p.DeletedAt == null);
|
||||
if (photo is null) return NotFound();
|
||||
|
||||
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
|
||||
c.FromPlayerId == pid && c.TargetPhotoId == photoId);
|
||||
if (existing is null)
|
||||
{
|
||||
db.Cheers.Add(new CheerEntity
|
||||
{
|
||||
FromPlayerId = pid,
|
||||
TargetPhotoId = photoId,
|
||||
CheeredAt = DateTime.UtcNow,
|
||||
});
|
||||
photo.CheerCount += 1;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Ok(new { photo.Id, photo.CheerCount });
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/images/v1/listsaved</c> — the 2020 client
|
||||
/// deserializes this as SavedImagesListDTO { Images: string[] }.
|
||||
/// Do not return the richer v2/v4 photo-object list here.</summary>
|
||||
[HttpGet("api/images/v1/listsaved")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> ListSavedV1()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var names = await db.Photos
|
||||
.Where(p => p.UploaderPlayerId == pid && p.DeletedAt == null)
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Select(p => p.BlobName)
|
||||
.Take(200)
|
||||
.ToListAsync();
|
||||
return Ok(new { Images = names });
|
||||
}
|
||||
|
||||
public sealed class ModifyAccessibilityRequest
|
||||
{
|
||||
public long PhotoId { get; set; }
|
||||
public long ImageId { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
public bool IsPublic { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/images/v1/modifyaccessibility</c> — flip
|
||||
/// the IsPublic flag on a saved photo. Was Ack-only; now writes
|
||||
/// the column.</summary>
|
||||
[HttpPost("api/images/v1/modifyaccessibility")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> ModifyAccessibility(
|
||||
[FromBody] ModifyAccessibilityRequest? body,
|
||||
[FromForm(Name = "PhotoId")] long? photoIdForm,
|
||||
[FromForm(Name = "ImageId")] long? imageIdForm,
|
||||
[FromForm(Name = "ImageName")] string? imageNameForm,
|
||||
[FromForm(Name = "IsPublic")] bool? isPublicForm)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var photoId = body?.PhotoId ?? body?.ImageId
|
||||
?? photoIdForm ?? imageIdForm ?? 0;
|
||||
var imageName = body?.ImageName ?? imageNameForm;
|
||||
if (photoId <= 0 && string.IsNullOrWhiteSpace(imageName))
|
||||
return BadRequest(new { error = "missing photoId" });
|
||||
var isPublic = body?.IsPublic ?? isPublicForm ?? true;
|
||||
|
||||
var photo = photoId > 0
|
||||
? await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId)
|
||||
: await db.Photos.FirstOrDefaultAsync(p => p.BlobName == imageName);
|
||||
if (photo is null) return NotFound();
|
||||
if (photo.UploaderPlayerId != pid) return Forbid();
|
||||
photo.IsPublic = isPublic;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { photo.Id, photo.IsPublic });
|
||||
}
|
||||
|
||||
public sealed class DeleteSavedRequest
|
||||
{
|
||||
public long PhotoId { get; set; }
|
||||
public long ImageId { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/images/v1/deletesaved</c> + DELETE alias —
|
||||
/// soft-delete a saved photo. Was Ack-only; now flips
|
||||
/// <c>DeletedAt</c>.</summary>
|
||||
[HttpPost("api/images/v1/deletesaved")]
|
||||
[HttpDelete("api/images/v1/deletesaved/{id:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> DeleteSavedV1(
|
||||
long? id,
|
||||
[FromBody] DeleteSavedRequest? body,
|
||||
[FromForm(Name = "PhotoId")] long? photoIdForm,
|
||||
[FromForm(Name = "ImageId")] long? imageIdForm,
|
||||
[FromForm(Name = "ImageName")] string? imageNameForm)
|
||||
{
|
||||
var photoId = id ?? body?.PhotoId ?? body?.ImageId
|
||||
?? photoIdForm ?? imageIdForm ?? 0;
|
||||
var imageName = body?.ImageName ?? imageNameForm;
|
||||
if (photoId <= 0 && string.IsNullOrWhiteSpace(imageName))
|
||||
return BadRequest(new { error = "missing photoId" });
|
||||
if (photoId <= 0)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var photo = await db.Photos.FirstOrDefaultAsync(p =>
|
||||
p.UploaderPlayerId == pid && p.BlobName == imageName && p.DeletedAt == null);
|
||||
if (photo is null) return NotFound();
|
||||
photo.DeletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
return await Delete(photoId);
|
||||
}
|
||||
|
||||
public sealed class SendLinkRequest
|
||||
{
|
||||
public long PhotoId { get; set; }
|
||||
public long ImageId { get; set; }
|
||||
public List<long>? RecipientIds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/images/v1/sendlink</c> — send a link to a
|
||||
/// photo as a DM to one or more recipients. Was Ack-only; now
|
||||
/// inserts one <see cref="MessageEntity"/> per recipient.</summary>
|
||||
[HttpPost("api/images/v1/sendlink")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SendLink(
|
||||
[FromBody] SendLinkRequest? body,
|
||||
[FromForm(Name = "PhotoId")] long? photoIdForm,
|
||||
[FromForm(Name = "ImageId")] long? imageIdForm,
|
||||
[FromForm(Name = "RecipientIds")] string? recipientIdsForm)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var photoId = body?.PhotoId ?? body?.ImageId ?? photoIdForm ?? imageIdForm ?? 0;
|
||||
if (photoId <= 0) return BadRequest(new { error = "missing photoId" });
|
||||
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId);
|
||||
if (photo is null) return NotFound();
|
||||
|
||||
var recipients = body?.RecipientIds ?? ParseIdList(recipientIdsForm) ?? new();
|
||||
if (recipients.Count == 0) return Ok(new { Sent = 0 });
|
||||
|
||||
var preview = $"shared a photo (id {photoId})";
|
||||
foreach (var rid in recipients.Distinct())
|
||||
{
|
||||
db.Messages.Add(new MessageEntity
|
||||
{
|
||||
SenderPlayerId = pid,
|
||||
RecipientPlayerId = rid,
|
||||
Body = $"{preview} → /img/{photo.BlobName}",
|
||||
});
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Sent = recipients.Count });
|
||||
}
|
||||
|
||||
private static List<long>? ParseIdList(string? csv)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(csv)) return null;
|
||||
return csv.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => long.TryParse(s.Trim(), out var v) ? v : 0L)
|
||||
.Where(v => v > 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Read raw image bytes from the request — supports both
|
||||
/// multipart/form-data (the typical case, with field "File" or
|
||||
/// "Image") and a raw octet-stream body (some 2020 client paths).</summary>
|
||||
private async Task<byte[]?> ReadImageBytesAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
// Try common field names; fall back to the first uploaded file.
|
||||
var file = form.Files["File"]
|
||||
?? form.Files["Image"]
|
||||
?? form.Files["image"]
|
||||
?? form.Files["file"]
|
||||
?? form.Files.FirstOrDefault();
|
||||
if (file is null || file.Length == 0) return null;
|
||||
using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
// Raw body fallback — when the watch posts the bytes directly.
|
||||
if (Request.ContentLength is > 0)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await Request.Body.CopyToAsync(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Detect PNG vs JPEG by magic bytes — anything else
|
||||
/// defaults to .png since the 2020 client's camera always saves
|
||||
/// PNG.</summary>
|
||||
private static string SniffExtension(byte[] bytes)
|
||||
{
|
||||
if (bytes.Length >= 4 &&
|
||||
bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47)
|
||||
return "png";
|
||||
if (bytes.Length >= 3 &&
|
||||
bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF)
|
||||
return "jpg";
|
||||
return "png";
|
||||
}
|
||||
|
||||
private async Task<(byte[]? Bytes, string Source)> LoadImageBytesAsync(string fileName)
|
||||
{
|
||||
var (bucket, key) = BlobRouter.Route(fileName);
|
||||
try
|
||||
{
|
||||
using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
var s3Bytes = await storage.GetAsync(bucket, key, s3Timeout.Token);
|
||||
if (s3Bytes is { Length: > 0 })
|
||||
return (s3Bytes, $"s3:{bucket}/{key}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex,
|
||||
"[images] API image S3 lookup threw for {File} bucket={Bucket} key={Key}",
|
||||
fileName, bucket, key);
|
||||
}
|
||||
|
||||
// S3 is the only canonical store. Disk-on-server is a legacy
|
||||
// surface used by tools/fetch-room-images.py'd thumbnails that
|
||||
// ship with the server — keep that as a last resort, but DB
|
||||
// bytes are gone.
|
||||
var full = Path.Combine(ImageDir, fileName);
|
||||
if (System.IO.File.Exists(full))
|
||||
return (await System.IO.File.ReadAllBytesAsync(full), "disk");
|
||||
|
||||
return (null, "miss");
|
||||
}
|
||||
|
||||
|
||||
private static bool TryGetPlayerId(string fileName, string prefix, out long playerId)
|
||||
{
|
||||
playerId = 0;
|
||||
if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
var start = prefix.Length;
|
||||
var end = fileName.IndexOf('_', start);
|
||||
return end > start && long.TryParse(fileName[start..end], out playerId);
|
||||
}
|
||||
|
||||
private static bool IsImageName(string name)
|
||||
{
|
||||
var ext = Path.GetExtension(name).ToLowerInvariant();
|
||||
return ext is ".png" or ".jpg" or ".jpeg" or ".gif" or ".webp" or ".avif";
|
||||
}
|
||||
|
||||
private static string MimeFromName(string name)
|
||||
{
|
||||
var ext = Path.GetExtension(name).ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".avif" => "image/avif",
|
||||
_ => "image/png",
|
||||
};
|
||||
}
|
||||
|
||||
private void LogUploadForm(string endpoint, long playerId, IFormCollection? form, bool isProfile)
|
||||
{
|
||||
if (form is null)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[images] {Endpoint} metadata player={PlayerId} form=false profile={Profile}",
|
||||
endpoint,
|
||||
playerId,
|
||||
isProfile);
|
||||
return;
|
||||
}
|
||||
|
||||
var fileNames = string.Join(
|
||||
",",
|
||||
form.Files.Select(f => $"{f.Name}:{f.FileName}:{f.Length}"));
|
||||
var fieldNames = string.Join(",", form.Keys);
|
||||
var meta = form["imgMeta"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(meta))
|
||||
meta = form["imageMeta"].ToString();
|
||||
if (meta.Length > 512)
|
||||
meta = meta[..512];
|
||||
|
||||
logger.LogInformation(
|
||||
"[images] {Endpoint} metadata player={PlayerId} files={Files} fields={Fields} profile={Profile} imgMeta={ImgMeta}",
|
||||
endpoint,
|
||||
playerId,
|
||||
fileNames,
|
||||
fieldNames,
|
||||
isProfile,
|
||||
meta);
|
||||
}
|
||||
|
||||
private static bool IsProfileThumbnail(IFormCollection? form)
|
||||
{
|
||||
if (form is null) return false;
|
||||
|
||||
static bool IsProfileValue(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return false;
|
||||
return value.Trim().Equals("4", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Contains("ProfileThumbnail", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (IsProfileValue(form["SavedImageType"].ToString())
|
||||
|| IsProfileValue(form["savedImageType"].ToString())
|
||||
|| IsProfileValue(form["ImageType"].ToString())
|
||||
|| IsProfileValue(form["imageType"].ToString()))
|
||||
return true;
|
||||
|
||||
var meta = form["imgMeta"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(meta))
|
||||
meta = form["imageMeta"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(meta))
|
||||
return false;
|
||||
|
||||
if (IsProfileValue(meta))
|
||||
return true;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(meta);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
||||
return false;
|
||||
foreach (var name in new[] { "savedImageType", "SavedImageType", "imageType", "ImageType" })
|
||||
{
|
||||
if (!doc.RootElement.TryGetProperty(name, out var value))
|
||||
continue;
|
||||
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var n) && n == 4)
|
||||
return true;
|
||||
if (value.ValueKind == JsonValueKind.String && IsProfileValue(value.GetString()))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Build the response shape every images/v* endpoint
|
||||
/// returns. PascalCase keys + redundant aliases so whichever JSON
|
||||
/// parser the 2020 watch uses, it finds the field it wants. The
|
||||
/// most important keys are AccountId, ImageName, CreatedAt — those
|
||||
/// are what the watch's strict deserialiser reads. <c>Url</c>
|
||||
/// is built against the configured deployment apex (DORKNET_DOMAIN)
|
||||
/// so every callsite for the URL string flows through one source
|
||||
/// of truth.</summary>
|
||||
private object BuildImageInfo(PhotoEntity p, long accountId)
|
||||
{
|
||||
var cdnHost = domain.Sub("cdn");
|
||||
return new
|
||||
{
|
||||
// Primary keys the 2020 watch's ImageInfo deserialiser reads.
|
||||
ImageName = p.BlobName,
|
||||
AccountId = (int)accountId,
|
||||
ImageType = 0,
|
||||
CreatedAt = p.CreatedAt,
|
||||
// Bonus fields — useful for other clients (and our own feed UI).
|
||||
Id = p.Id,
|
||||
Filename = p.BlobName,
|
||||
Url = $"https://{cdnHost}/{p.BlobName}",
|
||||
Caption = p.Caption,
|
||||
Description = p.Caption,
|
||||
RoomId = p.RoomId,
|
||||
IsPublic = p.IsPublic,
|
||||
CheerCount = p.CheerCount,
|
||||
ViewCount = p.ViewCount,
|
||||
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<long> ParseTagged(string? csv) =>
|
||||
string.IsNullOrWhiteSpace(csv)
|
||||
? Enumerable.Empty<long>()
|
||||
: csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.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));
|
||||
}
|
||||
|
||||
private static string NormaliseTaggedIds(string? csv, long exceptId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(csv)) return string.Empty;
|
||||
var ids = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(s => long.TryParse(s, out var v) ? v : 0L)
|
||||
.Where(v => v > 0 && v != exceptId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
return string.Join(",", ids);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Images.V2;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v2")]
|
||||
[Authorize]
|
||||
public class ImagesController : ControllerBase
|
||||
{
|
||||
private static readonly string ImageDir =
|
||||
Path.Combine(AppContext.BaseDirectory, "data", "images");
|
||||
|
||||
[HttpPost("upload")]
|
||||
public async Task<ActionResult<UploadImageResponse>> Upload(IFormFile file)
|
||||
{
|
||||
Directory.CreateDirectory(ImageDir);
|
||||
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (ext is not (".png" or ".jpg" or ".jpeg"))
|
||||
return BadRequest("Only PNG/JPG accepted");
|
||||
|
||||
var name = $"{Guid.NewGuid():N}{ext}";
|
||||
var path = Path.Combine(ImageDir, name);
|
||||
|
||||
await using var stream = System.IO.File.Create(path);
|
||||
await file.CopyToAsync(stream);
|
||||
|
||||
var baseUrl = $"{Request.Scheme}://{Request.Host}";
|
||||
return Ok(new UploadImageResponse { ImageName = name, Url = $"{baseUrl}/img/{name}" });
|
||||
}
|
||||
}
|
||||
|
||||
public class UploadImageResponse
|
||||
{
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
public string Url { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,594 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Inventions;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/inventions/* — Maker-Pen creations the player can
|
||||
/// re-spawn or share. URL surface + verbs verified against
|
||||
/// <c>Cpp2IL_ISIL/.../RecNet/Inventions.txt</c>: the "update*"
|
||||
/// routes use <c>Core.Get</c> (GET) despite being mutations, so we
|
||||
/// expose them as [HttpGet]; only <c>cheer</c>, <c>report</c>,
|
||||
/// <c>settags</c>, <c>save</c>, <c>addversion</c>, and <c>batch</c>
|
||||
/// are POST. <c>delete</c>, <c>publish</c>, <c>unpublish</c>,
|
||||
/// <c>download</c> are also GET.
|
||||
///
|
||||
/// Wire DTO matches <c>RecNet.Invention</c>
|
||||
/// (<c>Cpp2IL_CS/.../RecNet/Invention.cs</c>) byte-for-byte: keys
|
||||
/// <c>InventionId, ReplicationId, CreatorPlayerId, Name, Description,
|
||||
/// ImageName, CurrentVersionNumber, IsPublished, ModifiedAt,
|
||||
/// CreatedAt, FirstPublishedAt, CreationRoomId,
|
||||
/// NumPlayersHaveUsedInRoom, NumDownloads, CheerCount,
|
||||
/// CreatorPermission, GeneralPermission, IsAgInvention</c>.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class InventionsController(
|
||||
DorkNetDbContext db,
|
||||
LevelService level,
|
||||
NotificationService notifications) : ControllerBase
|
||||
{
|
||||
private long? CurrentPlayerIdOrNull => this.CurrentPlayerId();
|
||||
private long CurrentPlayerId => this.RequireCurrentPlayerId();
|
||||
|
||||
// ── Browse ───────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("api/inventions/v3/popular")]
|
||||
public async Task<ActionResult> Popular([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && i.IsPublished)
|
||||
.OrderByDescending(i => i.CheerCount)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v3/saved")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Saved([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var pid = CurrentPlayerId;
|
||||
var rows = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && i.CreatorPlayerId == pid)
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v3/created")]
|
||||
[Authorize]
|
||||
public Task<ActionResult> Created([FromQuery] int take = 50) => Saved(take);
|
||||
|
||||
/// <summary><c>GetLocalPlayerInventions</c> — same as Saved but
|
||||
/// at v1 path. Watch hits both depending on tab.</summary>
|
||||
[HttpGet("api/inventions/v1/mine")]
|
||||
[Authorize]
|
||||
public Task<ActionResult> Mine() => Saved(100);
|
||||
|
||||
[HttpGet("api/inventions/v1/search")]
|
||||
public async Task<ActionResult> Search([FromQuery] string value = "")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return Ok(Array.Empty<object>());
|
||||
|
||||
// The watch's @-prefix means "search by player". Two callers
|
||||
// use this with different payload shapes — both end up here:
|
||||
// * PlayerDetailsWatchUIFlow.SetAccount → @<accountId> (numeric)
|
||||
// loads the Inventions tab on a player profile. Verified
|
||||
// in Cpp2IL — see PlayerDetailsWatchUIFlow.txt:1867,1950.
|
||||
// * Inventions search UI → @<username> (text) free-text
|
||||
// prefix match on Username.
|
||||
if (value.StartsWith('@'))
|
||||
{
|
||||
var tail = value[1..];
|
||||
List<long> creators;
|
||||
if (long.TryParse(tail, out var accountId))
|
||||
{
|
||||
creators = new List<long> { accountId };
|
||||
}
|
||||
else
|
||||
{
|
||||
creators = await db.Players
|
||||
.Where(p => p.Username.StartsWith(tail))
|
||||
.Select(p => p.Id).Take(50).ToListAsync();
|
||||
}
|
||||
var byCreator = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && i.IsPublished
|
||||
&& creators.Contains(i.CreatorPlayerId))
|
||||
.OrderByDescending(i => i.CheerCount)
|
||||
.Take(50)
|
||||
.ToListAsync();
|
||||
return Ok(byCreator.Select(ToWire));
|
||||
}
|
||||
|
||||
var v = value.ToLowerInvariant();
|
||||
var rows = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && i.IsPublished
|
||||
&& (i.Name.ToLower().Contains(v)
|
||||
|| i.TagsCsv.ToLower().Contains(v)))
|
||||
.OrderByDescending(i => i.CheerCount)
|
||||
.Take(50)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
public sealed record InventionBatchRequest(List<long> InventionIds);
|
||||
|
||||
[HttpPost("api/inventions/v1/batch")]
|
||||
public async Task<ActionResult> Batch([FromBody] InventionBatchRequest req)
|
||||
{
|
||||
if (req?.InventionIds is null || req.InventionIds.Count == 0)
|
||||
return Ok(Array.Empty<object>());
|
||||
var ids = req.InventionIds.Take(200).ToList();
|
||||
var rows = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/inventions/v1/tagfilters</c> — drives
|
||||
/// <c>RecNet.Inventions.GetTags</c>. The watch deserialises the
|
||||
/// response as <c>RecNet.GetFiltersResponse</c> (an
|
||||
/// <c>IRecNetObject</c> with <c>PinnedFilters</c> +
|
||||
/// <c>PopularFilters</c> string-list properties), then a
|
||||
/// <c>Func<GetFiltersResponse, List<string>></c>
|
||||
/// projects whichever list the caller wanted. Verified at
|
||||
/// Cpp2IL_ISIL/.../RecNet/Inventions.txt:4834-4852 — the URL
|
||||
/// literal is followed by a <c>typeof(Func`2<GetFiltersResponse,
|
||||
/// List`1<String>>)</c>. Returning a bare string array
|
||||
/// (the previous shape) tripped the LitJson importer's
|
||||
/// <c>List → Dictionary</c> cast inside <c>Util.Deserialize<T></c>
|
||||
/// and surfaced as "Failed to get tags: Malformed Response"
|
||||
/// (output_log.txt:1602+1639) — and that bubbles up as the
|
||||
/// dorm-load destabilisation we've been hunting.</summary>
|
||||
[HttpGet("api/inventions/v1/tagfilters")]
|
||||
public IActionResult TagFilters()
|
||||
{
|
||||
var tags = new[]
|
||||
{
|
||||
"sport", "game", "vehicle", "weapon", "decor", "tool",
|
||||
"art", "music", "puzzle", "combat", "build", "race",
|
||||
};
|
||||
return Ok(new
|
||||
{
|
||||
PinnedFilters = tags,
|
||||
PopularFilters = tags,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/inventions/v1/creatorIds</c> — the distinct
|
||||
/// creator account ids of every invention placed in a room
|
||||
/// (<c>GetCreatorIdsOfInventionsInRoom</c>, called by the 2020.03
|
||||
/// watch on room load). It uses these to resolve which creators it
|
||||
/// must request invention permissions for before spawning a room's
|
||||
/// invention-based geometry. Returns <c>List<int></c> — empty
|
||||
/// when the room has no tracked inventions.</summary>
|
||||
[HttpGet("api/inventions/v1/creatorIds")]
|
||||
public async Task<IActionResult> CreatorIdsInRoom([FromQuery] long roomId)
|
||||
{
|
||||
var inventions = await ResolveRoomInventionsAsync(roomId);
|
||||
var creatorIds = inventions
|
||||
.Select(i => (int)i.CreatorPlayerId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
return Ok(creatorIds);
|
||||
}
|
||||
|
||||
/// <summary>Resolve the inventions that belong to a room. Direct
|
||||
/// matches are inventions whose
|
||||
/// <see cref="InventionEntity.CreationRoomId"/> equals the room's
|
||||
/// local id (RR-Originals, dorms, anything created in-place).
|
||||
/// Zip-imported rooms instead carry each invention's ORIGINAL Rec
|
||||
/// Room CreationRoomId, which won't equal our local id and can't be
|
||||
/// reverse-resolved without an OriginalRoomId column; we approximate
|
||||
/// that cluster as the most-frequent non-local CreationRoomId among
|
||||
/// the room creator's inventions. The two sets are unioned so a room
|
||||
/// with both kinds resolves fully.</summary>
|
||||
private async Task<List<InventionEntity>> ResolveRoomInventionsAsync(long roomId)
|
||||
{
|
||||
var creatorId = await db.Rooms
|
||||
.Where(r => r.Id == roomId)
|
||||
.Select(r => (long?)r.CreatorPlayerId)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
long? importedRoomId = null;
|
||||
if (creatorId is long creator)
|
||||
{
|
||||
importedRoomId = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && i.CreatorPlayerId == creator
|
||||
&& i.CreationRoomId != null && i.CreationRoomId != roomId)
|
||||
.GroupBy(i => i.CreationRoomId)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Select(g => g.Key)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
return await db.Inventions
|
||||
.Where(i => !i.IsDeleted
|
||||
&& (i.CreationRoomId == roomId
|
||||
|| (importedRoomId != null && i.CreationRoomId == importedRoomId)))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
// ── Single fetch ─────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("api/inventions/v1")]
|
||||
public async Task<ActionResult> SingleByQuery([FromQuery] long inventionId)
|
||||
{
|
||||
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
|
||||
if (i is null) return NotFound();
|
||||
if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull)
|
||||
return Forbid();
|
||||
return Ok(ToWire(i));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/details")]
|
||||
public async Task<ActionResult> Details([FromQuery] long inventionId)
|
||||
{
|
||||
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
|
||||
if (i is null) return NotFound();
|
||||
if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull)
|
||||
return Forbid();
|
||||
var versions = await db.InventionVersions
|
||||
.Where(v => v.InventionId == inventionId)
|
||||
.OrderByDescending(v => v.VersionNumber)
|
||||
.ToListAsync();
|
||||
return Ok(new
|
||||
{
|
||||
Invention = ToWire(i),
|
||||
Versions = versions.Select(ToVersionWire),
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/personaldetails/{id:long}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> PersonalDetails(long id)
|
||||
{
|
||||
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
|
||||
if (i is null) return NotFound();
|
||||
return Ok(new
|
||||
{
|
||||
Invention = ToWire(i),
|
||||
CanEdit = i.CreatorPlayerId == CurrentPlayerId,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/versions")]
|
||||
public async Task<ActionResult> Versions([FromQuery] long inventionId)
|
||||
{
|
||||
var rows = await db.InventionVersions
|
||||
.Where(v => v.InventionId == inventionId)
|
||||
.OrderByDescending(v => v.VersionNumber)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToVersionWire));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/version")]
|
||||
public async Task<ActionResult> Version(
|
||||
[FromQuery] long inventionId, [FromQuery] int version)
|
||||
{
|
||||
var v = await db.InventionVersions
|
||||
.FirstOrDefaultAsync(x => x.InventionId == inventionId && x.VersionNumber == version);
|
||||
if (v is null) return NotFound();
|
||||
return Ok(ToVersionWire(v));
|
||||
}
|
||||
|
||||
// ── Create / version ─────────────────────────────────────────────────
|
||||
|
||||
public sealed record SaveInventionRequest(
|
||||
string Name, string? Description, string? ImageName,
|
||||
int? Permission, string? Tags, string BlobName,
|
||||
long? CreationRoomId, int? InstantiationCost, int? LightsCost);
|
||||
|
||||
[HttpPost("api/inventions/v3/save")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Save([FromBody] SaveInventionRequest req)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest("missing name");
|
||||
if (string.IsNullOrWhiteSpace(req.BlobName)) return BadRequest("missing BlobName");
|
||||
|
||||
var inv = new InventionEntity
|
||||
{
|
||||
CreatorPlayerId = pid,
|
||||
ReplicationId = Guid.NewGuid().ToString("D"),
|
||||
Name = req.Name.Trim(),
|
||||
Description = req.Description ?? string.Empty,
|
||||
ImageName = req.ImageName ?? string.Empty,
|
||||
Permission = Math.Clamp(req.Permission ?? 0, 0, 2),
|
||||
CurrentBlobName = req.BlobName,
|
||||
TagsCsv = req.Tags ?? string.Empty,
|
||||
CreationRoomId = req.CreationRoomId,
|
||||
CreatorPermission = 100, // Unlimited for the creator
|
||||
GeneralPermission = 0, // Unassigned (private) until publish
|
||||
IsPublished = false,
|
||||
CurrentVersionNumber = 1,
|
||||
};
|
||||
db.Inventions.Add(inv);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Snapshot the v1 version row.
|
||||
var v1 = new InventionVersionEntity
|
||||
{
|
||||
InventionId = inv.Id,
|
||||
ReplicationId = Guid.NewGuid().ToString("D"),
|
||||
VersionNumber = 1,
|
||||
BlobName = req.BlobName,
|
||||
InstantiationCost = req.InstantiationCost ?? 0,
|
||||
LightsCost = req.LightsCost ?? 0,
|
||||
};
|
||||
db.InventionVersions.Add(v1);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await level.AwardXpAsync(pid, LevelService.InventionSavedXp, $"invention_save:{inv.Id}");
|
||||
return Ok(ToWire(inv));
|
||||
}
|
||||
|
||||
public sealed record AddVersionRequest(
|
||||
long InventionId, string BlobName,
|
||||
int? InstantiationCost, int? LightsCost);
|
||||
|
||||
[HttpPost("api/inventions/v3/addversion")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> AddVersion([FromBody] AddVersionRequest req)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
if (inv.CreatorPlayerId != pid) return Forbid();
|
||||
if (string.IsNullOrWhiteSpace(req.BlobName)) return BadRequest("missing BlobName");
|
||||
|
||||
var nextVer = inv.CurrentVersionNumber + 1;
|
||||
inv.CurrentBlobName = req.BlobName;
|
||||
inv.CurrentVersionNumber = nextVer;
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
db.InventionVersions.Add(new InventionVersionEntity
|
||||
{
|
||||
InventionId = inv.Id,
|
||||
ReplicationId = Guid.NewGuid().ToString("D"),
|
||||
VersionNumber = nextVer,
|
||||
BlobName = req.BlobName,
|
||||
InstantiationCost = req.InstantiationCost ?? 0,
|
||||
LightsCost = req.LightsCost ?? 0,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
}
|
||||
|
||||
// ── GET-based mutations (Core.Get pattern) ───────────────────────────
|
||||
|
||||
/// <summary>Watch sends ONE of name/description/imgName/permission
|
||||
/// per request — `UpdateInvention*` ISIL builds different
|
||||
/// query strings. Pull whichever ones came in and apply.</summary>
|
||||
[HttpGet("api/inventions/v1/update")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Update(
|
||||
[FromQuery] long inventionId,
|
||||
[FromQuery] string? name,
|
||||
[FromQuery] string? description,
|
||||
[FromQuery] string? imgName,
|
||||
[FromQuery] int? permission)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
if (inv.CreatorPlayerId != pid) return Forbid();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(name)) inv.Name = name.Trim();
|
||||
if (description is not null) inv.Description = description;
|
||||
if (imgName is not null) inv.ImageName = imgName;
|
||||
if (permission.HasValue)
|
||||
{
|
||||
inv.GeneralPermission = ClampInventionPermission(permission.Value);
|
||||
if (inv.GeneralPermission > 0 && !inv.IsPublished)
|
||||
{
|
||||
inv.IsPublished = true;
|
||||
inv.FirstPublishedAt = DateTime.UtcNow;
|
||||
}
|
||||
// Mirror legacy 0/1/2 column for back-compat.
|
||||
inv.Permission = inv.GeneralPermission >= 60 ? 2 : (inv.GeneralPermission >= 20 ? 1 : 0);
|
||||
}
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/delete")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Delete([FromQuery] long inventionId)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId);
|
||||
if (inv is null) return NotFound();
|
||||
if (inv.CreatorPlayerId != pid) return Forbid();
|
||||
inv.IsDeleted = true;
|
||||
inv.GeneralPermission = 0;
|
||||
inv.IsPublished = false;
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v2/publish")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Publish(
|
||||
[FromQuery] long inventionId, [FromQuery] int permissionLevel)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
if (inv.CreatorPlayerId != pid) return Forbid();
|
||||
var perm = ClampInventionPermission(permissionLevel);
|
||||
if (perm == 0) return BadRequest("permissionLevel must be > 0 (Unassigned)");
|
||||
inv.GeneralPermission = perm;
|
||||
inv.IsPublished = true;
|
||||
inv.FirstPublishedAt ??= DateTime.UtcNow;
|
||||
inv.Permission = perm >= 60 ? 2 : (perm >= 20 ? 1 : 0);
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/unpublish")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Unpublish([FromQuery] long inventionId)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
if (inv.CreatorPlayerId != pid) return Forbid();
|
||||
inv.GeneralPermission = 0;
|
||||
inv.IsPublished = false;
|
||||
inv.Permission = 0;
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/download")]
|
||||
public async Task<ActionResult> Download([FromQuery] long inventionId)
|
||||
{
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
inv.SpawnCount += 1;
|
||||
var pid = CurrentPlayerIdOrNull;
|
||||
if (pid is long me && me != inv.CreatorPlayerId)
|
||||
{
|
||||
// NumPlayersHaveUsedInRoom — increment per-distinct-player.
|
||||
// Approximate by distinct cheer/check; for now bump once
|
||||
// per download by non-creator.
|
||||
inv.NumPlayersHaveUsedInRoom += 1;
|
||||
}
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
}
|
||||
|
||||
// ── POST mutations ───────────────────────────────────────────────────
|
||||
|
||||
public sealed record SetTagsRequest(long InventionId, string? AutoTags, string? PlayerAddedTags);
|
||||
|
||||
[HttpPost("api/inventions/v1/settags")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> SetTags([FromBody] SetTagsRequest req)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
if (inv.CreatorPlayerId != pid) return Forbid();
|
||||
var combined = string.Join(',', new[] { req.AutoTags, req.PlayerAddedTags }
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(',');
|
||||
inv.TagsCsv = combined;
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
}
|
||||
|
||||
public sealed record CheerRequest(long InventionId);
|
||||
|
||||
[HttpPost("api/inventions/v1/cheer")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Cheer([FromBody] CheerRequest req)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
|
||||
// Idempotent: one cheer per (player, invention).
|
||||
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
|
||||
c.FromPlayerId == pid && c.TargetInventionId == req.InventionId);
|
||||
if (existing is null)
|
||||
{
|
||||
db.Cheers.Add(new CheerEntity
|
||||
{
|
||||
FromPlayerId = pid,
|
||||
TargetInventionId = req.InventionId,
|
||||
CheeredAt = DateTime.UtcNow,
|
||||
});
|
||||
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 });
|
||||
}
|
||||
return Ok(new { inv.Id, inv.CheerCount });
|
||||
}
|
||||
|
||||
public sealed record ReportInventionRequest(
|
||||
long InventionId, int? ReportCategory, string? Details);
|
||||
|
||||
[HttpPost("api/inventions/v1/report")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Report([FromBody] ReportInventionRequest req)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
ReporterPlayerId = pid,
|
||||
TargetPlayerId = inv.CreatorPlayerId,
|
||||
TargetInventionId = req.InventionId,
|
||||
Category = req.ReportCategory ?? 5 /* Other */,
|
||||
Message = (req.Details ?? string.Empty)[..Math.Min(1000, (req.Details ?? string.Empty).Length)],
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Reported = true });
|
||||
}
|
||||
|
||||
// ── Wire serializers ─────────────────────────────────────────────────
|
||||
|
||||
private static int ClampInventionPermission(int v) => v switch
|
||||
{
|
||||
0 or 10 or 20 or 40 or 60 or 80 or 100 => v,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
private static object ToWire(InventionEntity i) => new
|
||||
{
|
||||
InventionId = i.Id,
|
||||
ReplicationId = string.IsNullOrEmpty(i.ReplicationId)
|
||||
? Guid.Empty.ToString("D") : i.ReplicationId,
|
||||
// Wire field is int — IDs in this server are <2^31 so safe.
|
||||
CreatorPlayerId = (int)i.CreatorPlayerId,
|
||||
i.Name,
|
||||
i.Description,
|
||||
i.ImageName,
|
||||
i.CurrentVersionNumber,
|
||||
i.IsPublished,
|
||||
ModifiedAt = i.UpdatedAt,
|
||||
i.CreatedAt,
|
||||
i.FirstPublishedAt,
|
||||
i.CreationRoomId,
|
||||
i.NumPlayersHaveUsedInRoom,
|
||||
NumDownloads = i.SpawnCount,
|
||||
i.CheerCount,
|
||||
i.CreatorPermission,
|
||||
i.GeneralPermission,
|
||||
i.IsAgInvention,
|
||||
};
|
||||
|
||||
private static object ToVersionWire(InventionVersionEntity v) => new
|
||||
{
|
||||
v.InventionId,
|
||||
ReplicationId = string.IsNullOrEmpty(v.ReplicationId)
|
||||
? Guid.Empty.ToString("D") : v.ReplicationId,
|
||||
v.VersionNumber,
|
||||
v.InstantiationCost,
|
||||
v.LightsCost,
|
||||
v.BlobName,
|
||||
};
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/{equipment,consumables}/* — backs the watch's
|
||||
/// Backpack tab. Wire DTOs match the decompiled client classes:
|
||||
///
|
||||
/// • <c>RecNet.Equipment</c> — fields: <c>PrefabName, ModificationGuid,
|
||||
/// PlatformMask, IsPlatformLocked, FriendlyName, Tooltip, Rarity,
|
||||
/// Favorited</c>.
|
||||
/// • <c>RecNet.Consumable</c> — fields: <c>Id(long), ConsumableType,
|
||||
/// PlatformMask, Count, InitialCount, UnlockedLevel, CreatedAt,
|
||||
/// ActiveDurationMinutes(int?), ConsumableItem(nested), Category,
|
||||
/// IsActive, IsPlatformLocked</c>.
|
||||
///
|
||||
/// Persistence: one <see cref="PlayerInventoryEntity"/> row per
|
||||
/// (player, item). For equipment, Quantity=1 + IsActive tracks
|
||||
/// favourite. For consumables, Quantity is the stack count + IsActive
|
||||
/// tracks "currently activated".
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class InventoryController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
// Categories the StoreItem table uses for equipment vs
|
||||
// consumables. The seed data tags items per these strings.
|
||||
private static readonly HashSet<string> EquipmentCategories = new()
|
||||
{
|
||||
"equipment", "tool", "maker_pen", "weapon",
|
||||
};
|
||||
private static readonly HashSet<string> ConsumableCategories = new()
|
||||
{
|
||||
"consumable",
|
||||
};
|
||||
|
||||
// ── Equipment ────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("api/equipment/v1/getUnlocked")]
|
||||
[HttpGet("api/equipment/v2/getUnlocked")]
|
||||
[HttpGet("api/equipment/v3/getUnlocked")]
|
||||
public async Task<IActionResult> GetUnlockedEquipment()
|
||||
{
|
||||
var pid = Me;
|
||||
var rows = await (from inv in db.PlayerInventory
|
||||
join item in db.StoreItems on inv.ItemSlug equals item.Slug
|
||||
where inv.PlayerId == pid
|
||||
&& EquipmentCategories.Contains(item.Category)
|
||||
select new { inv, item }).ToListAsync();
|
||||
return Ok(rows.Select(r => ToEquipmentWire(r.inv, r.item)));
|
||||
}
|
||||
|
||||
public sealed class EquipmentUpdateRequest
|
||||
{
|
||||
public string PrefabName { get; set; } = string.Empty;
|
||||
public string ModificationGuid { get; set; } = string.Empty;
|
||||
public bool Favorited { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/equipment/v1/update</c> — toggle the
|
||||
/// "favorited" flag on a piece of equipment (the watch lets the
|
||||
/// player pin commonly-used tools).</summary>
|
||||
// The 2018 client posts a JSON ARRAY of equipment objects (RecNet.cs:21812)
|
||||
// — each {PrefabName, ModificationGuid, UnlockedLevel, Favorited}. Accept an
|
||||
// array OR a single object so both shapes bind. Slug = modification GUID, or
|
||||
// the prefab name when no mod is applied. Unowned items are skipped (not a
|
||||
// hard reject) so one stale entry doesn't drop the whole batch.
|
||||
[HttpPost("api/equipment/v1/update")]
|
||||
public async Task<IActionResult> UpdateEquipment([FromBody] System.Text.Json.JsonElement body)
|
||||
{
|
||||
var pid = Me;
|
||||
var items = new List<System.Text.Json.JsonElement>();
|
||||
if (body.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
items.AddRange(body.EnumerateArray());
|
||||
else if (body.ValueKind == System.Text.Json.JsonValueKind.Object)
|
||||
items.Add(body);
|
||||
|
||||
static string? Str(System.Text.Json.JsonElement e, string k)
|
||||
=> e.TryGetProperty(k, out var v) && v.ValueKind == System.Text.Json.JsonValueKind.String
|
||||
? v.GetString() : null;
|
||||
static bool Bool(System.Text.Json.JsonElement e, string k)
|
||||
=> e.TryGetProperty(k, out var v) && (v.ValueKind == System.Text.Json.JsonValueKind.True
|
||||
|| (v.ValueKind == System.Text.Json.JsonValueKind.String && v.GetString() == "true"));
|
||||
|
||||
foreach (var el in items)
|
||||
{
|
||||
var slug = Str(el, "ModificationGuid") is { Length: > 0 } mod ? mod : Str(el, "PrefabName");
|
||||
if (string.IsNullOrWhiteSpace(slug)) continue;
|
||||
var row = await db.PlayerInventory
|
||||
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug);
|
||||
if (row is null) continue; // skip items the player doesn't own
|
||||
row.IsActive = Bool(el, "Favorited");
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Updated = true });
|
||||
}
|
||||
|
||||
// ── Consumables ──────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("api/consumables/v1/getUnlocked")]
|
||||
public async Task<IActionResult> GetUnlockedConsumables()
|
||||
{
|
||||
var pid = Me;
|
||||
var rows = await (from inv in db.PlayerInventory
|
||||
join item in db.StoreItems on inv.ItemSlug equals item.Slug
|
||||
where inv.PlayerId == pid
|
||||
&& ConsumableCategories.Contains(item.Category)
|
||||
select new { inv, item }).ToListAsync();
|
||||
return Ok(rows.Select(r => ToConsumableWire(r.inv, r.item)));
|
||||
}
|
||||
|
||||
public sealed class ConsumableItemRequest
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public int DeltaCount { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/consumables/v1/consume")]
|
||||
public async Task<IActionResult> Consume([FromBody] ConsumableItemRequest req)
|
||||
{
|
||||
var pid = Me;
|
||||
var row = await db.PlayerInventory
|
||||
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == req.Id);
|
||||
if (row is null) return NotFound();
|
||||
var delta = Math.Max(1, Math.Abs(req.DeltaCount));
|
||||
row.Quantity = Math.Max(0, row.Quantity - delta);
|
||||
if (row.Quantity == 0) db.PlayerInventory.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await notifications.NotifyAsync(pid,
|
||||
row.Quantity > 0
|
||||
? PushNotificationId.ConsumableMappingAdded
|
||||
: PushNotificationId.ConsumableMappingRemoved,
|
||||
new { row.Id, row.ItemSlug, row.Quantity });
|
||||
return Ok(new { row.Id, Count = row.Quantity });
|
||||
}
|
||||
|
||||
public sealed class ActivateConsumableRequest
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/consumables/v1/updateActive")]
|
||||
public async Task<IActionResult> UpdateActive([FromBody] ActivateConsumableRequest req)
|
||||
{
|
||||
var pid = Me;
|
||||
var row = await db.PlayerInventory
|
||||
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == req.Id);
|
||||
if (row is null) return NotFound();
|
||||
row.IsActive = req.IsActive;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { row.Id, row.IsActive });
|
||||
}
|
||||
|
||||
// ── Wire serializers ─────────────────────────────────────────────────
|
||||
|
||||
private static object ToEquipmentWire(PlayerInventoryEntity inv, StoreItemEntity item) => new
|
||||
{
|
||||
PrefabName = item.Slug, // for store-derived items the slug doubles as prefab name
|
||||
ModificationGuid = inv.ItemSlug,
|
||||
PlatformMask = -1, // All
|
||||
IsPlatformLocked = false,
|
||||
FriendlyName = item.DisplayName,
|
||||
Tooltip = item.Description,
|
||||
Rarity = 0, // Common (no rarity column on StoreItem yet)
|
||||
Favorited = inv.IsActive,
|
||||
};
|
||||
|
||||
private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item)
|
||||
{
|
||||
var type = StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc)
|
||||
? consumableDesc
|
||||
: item.Slug;
|
||||
var category = StoreService.TryGetHairDyePayload(item.Slug, out _)
|
||||
? 6
|
||||
: 0;
|
||||
|
||||
return new
|
||||
{
|
||||
inv.Id,
|
||||
ConsumableItemDesc = type,
|
||||
ConsumableType = type,
|
||||
PlatformMask = -1,
|
||||
Count = inv.Quantity,
|
||||
InitialCount = inv.Quantity, // we don't track original purchase count
|
||||
UnlockedLevel = 0,
|
||||
CreatedAt = inv.AcquiredAt,
|
||||
ActiveDurationMinutes = (int?)null,
|
||||
ConsumableItem = new
|
||||
{
|
||||
Id = inv.Id,
|
||||
Type = type,
|
||||
FriendlyName = item.DisplayName,
|
||||
Tooltip = item.Description,
|
||||
ImageName = item.ImageName,
|
||||
},
|
||||
Category = category,
|
||||
inv.IsActive,
|
||||
IsPlatformLocked = false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Leaderboard;
|
||||
|
||||
/// <summary>
|
||||
/// leaderboard.{rec.net,localhost}/leaderboard/* — per-stat-channel
|
||||
/// leaderboard reads + writes. URLs verified against
|
||||
/// <c>Cpp2IL_ISIL/.../RecNet/Leaderboards.txt:1934, 2416, 2700</c>:
|
||||
///
|
||||
/// POST leaderboard/SetStat body: SetStatRequestDTO
|
||||
/// <c>{StatChannel(int), RoomId(long), StatValue(int)}</c>
|
||||
/// POST leaderboard/GetPlayerRank body: GetPlayerRankData
|
||||
/// <c>{PlayerId(int), StatChannel(int), RoomId(long)}</c>
|
||||
///
|
||||
/// **Subdomain matters**: the client uses
|
||||
/// <c>Core.SendRequest(method, Service.Leaderboard, requestUri)</c>
|
||||
/// where <c>Service.Leaderboard = 9</c> resolves via the
|
||||
/// <c>RecRoomConfig.ServiceUrls</c> map (we serve via
|
||||
/// <c>api/config/v2</c>) to <c>https://leaderboard.{apex}</c>. So
|
||||
/// the actual outbound URL is <c>leaderboard.localhost/leaderboard/SetStat</c>
|
||||
/// — not <c>api.localhost/api/leaderboard/...</c>.
|
||||
///
|
||||
/// Response shapes:
|
||||
/// SetStat → RequestResult ack <c>{success:true,error:""}</c>
|
||||
/// GetPlayerRank → FullLeaderboard.Entry — <b>camelCase</b> per
|
||||
/// <c>FullLeaderboard_NestedType_Entry.txt:111-121</c>:
|
||||
/// <c>{playerId(int), score(long), rank(int)}</c>.
|
||||
///
|
||||
/// Without explicit subdomain routing the request fell through to
|
||||
/// <see cref="GlobalCatchAllController"/> which returns <c>[]</c>;
|
||||
/// the client's <c>Util.Deserialize<Entry></c> tried to cast
|
||||
/// list-as-dictionary →
|
||||
/// <c>InvalidCastException: Unable to cast 'List' to 'Dictionary'</c>
|
||||
/// → "Failed to retrieve leaderboard: Malformed Response".
|
||||
///
|
||||
/// We bind both the leaderboard subdomain (canonical) AND the api
|
||||
/// host (defensive — some 2020 builds may proxy via api/) so either
|
||||
/// path lands here.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class LeaderboardController(
|
||||
DorkNetDbContext db,
|
||||
PlayerPresenceService playerPresence) : ControllerBase
|
||||
{
|
||||
public sealed class SetStatRequest
|
||||
{
|
||||
public int StatChannel { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public int StatValue { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/leaderboard/SetStat</c> on the leaderboard
|
||||
/// subdomain (canonical client path) AND <c>/api/leaderboard/SetStat</c>
|
||||
/// on the api host (legacy / proxy fallback).
|
||||
///
|
||||
/// **Wire semantics**: the watch's
|
||||
/// <c>Leaderboards.ConditionalSetStat_ReturningNewValue</c>
|
||||
/// (Cpp2IL_ISIL/.../RecNet/Leaderboards.txt:645) handles the
|
||||
/// per-channel direction (high-score vs low-time-better) LOCALLY
|
||||
/// via a SetMode enum and only POSTs to <c>SetStat</c> when the
|
||||
/// new value should replace the cached one. The wire request
|
||||
/// itself carries no direction hint — it's just
|
||||
/// <c>{StatChannel, RoomId, StatValue}</c>. So the server must
|
||||
/// trust the client and assign unconditionally; an earlier
|
||||
/// <c>Math.Max</c> here silently kept the LARGEST value, which
|
||||
/// broke channels like StuntRunner (channel 7, low elapsed time
|
||||
/// wins) where the watch posts the new best and we'd discard it
|
||||
/// for any prior worse value.</summary>
|
||||
[HttpPost("/leaderboard/SetStat")]
|
||||
[HttpPost("/api/leaderboard/SetStat")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SetStat([FromBody] SetStatRequest req)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
|
||||
s.PlayerId == pid && s.StatChannel == req.StatChannel);
|
||||
if (row is null)
|
||||
{
|
||||
row = new LeaderboardStatEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
StatChannel = req.StatChannel,
|
||||
Value = req.StatValue,
|
||||
};
|
||||
db.LeaderboardStats.Add(row);
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = req.StatValue;
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// Auto-attach the channel to whichever room the score came
|
||||
// from so every room implicitly gets its own leaderboards.
|
||||
// The watch's <c>SetStatRequestDTO</c> carries
|
||||
// <c>RoomId</c> (see Cpp2IL_ISIL .../SetStatRequestDTO);
|
||||
// when it's zero (rare — direct stat report outside a room
|
||||
// context) fall back to the player's current presence so a
|
||||
// stray score still gets bucketed somewhere visible.
|
||||
var roomId = req.RoomId > 0 ? req.RoomId : (playerPresence.GetRoom(pid)?.RoomId ?? 0);
|
||||
if (roomId > 0)
|
||||
{
|
||||
var meta = await db.LeaderboardChannelMeta
|
||||
.FirstOrDefaultAsync(c => c.Channel == req.StatChannel);
|
||||
if (meta is null)
|
||||
{
|
||||
// First time this channel reports. Stamp a meta row so
|
||||
// the admin SPA's per-room Leaderboards tab surfaces it
|
||||
// immediately, with a placeholder name the admin can
|
||||
// rename later. Direction defaults to higher-is-better;
|
||||
// admin flips the toggle for time-trial channels.
|
||||
db.LeaderboardChannelMeta.Add(new LeaderboardChannelMetaEntity
|
||||
{
|
||||
Channel = req.StatChannel,
|
||||
RoomId = roomId,
|
||||
Name = $"Channel {req.StatChannel}",
|
||||
LowerIsBetter = false,
|
||||
ValueFormat = "count",
|
||||
});
|
||||
}
|
||||
else if (meta.RoomId == 0)
|
||||
{
|
||||
// Channel registered globally (e.g. from the hardcoded
|
||||
// KnownStatChannels fallback or an unscoped admin
|
||||
// entry). Bind it to this room now that we know which
|
||||
// room it came from. Don't re-bind if it already
|
||||
// points at a different room — the admin must be the
|
||||
// one to reassign once a channel has a home.
|
||||
meta.RoomId = roomId;
|
||||
meta.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
public sealed class GetPlayerRankRequest
|
||||
{
|
||||
public int PlayerId { get; set; }
|
||||
public int StatChannel { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("/leaderboard/GetPlayerRank")]
|
||||
[HttpPost("/api/leaderboard/GetPlayerRank")]
|
||||
public async Task<IActionResult> GetPlayerRank([FromBody] GetPlayerRankRequest req)
|
||||
{
|
||||
var pid = req.PlayerId > 0
|
||||
? (long)req.PlayerId
|
||||
: this.CurrentPlayerId() ?? 0;
|
||||
// Empty/no-row case: return a zero entry. The client tolerates
|
||||
// this — `Entry.Deserialize` reads playerId/score/rank as
|
||||
// primitives with defaults.
|
||||
if (pid == 0)
|
||||
return Ok(new { playerId = 0, score = 0L, rank = 0 });
|
||||
|
||||
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
|
||||
s.PlayerId == pid && s.StatChannel == req.StatChannel);
|
||||
if (row is null)
|
||||
return Ok(new { playerId = (int)pid, score = 0L, rank = 0 });
|
||||
|
||||
var higherCount = await db.LeaderboardStats
|
||||
.CountAsync(s => s.StatChannel == req.StatChannel && s.Value > row.Value);
|
||||
return Ok(new
|
||||
{
|
||||
playerId = (int)pid,
|
||||
score = row.Value,
|
||||
rank = higherCount + 1,
|
||||
});
|
||||
}
|
||||
|
||||
public sealed class GetNearbyScoresRequest
|
||||
{
|
||||
public int PlayerId { get; set; }
|
||||
public int StatChannel { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public int WindowSize { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/leaderboard/GetNearbyScores</c> — returns the
|
||||
/// N entries on either side of the caller's rank. Wire shape per
|
||||
/// <c>GetNearbyScoresRequestDTO.cs</c>: PlayerId/StatChannel/RoomId
|
||||
/// inherited from GetRankRequestDTO + WindowSize.</summary>
|
||||
[HttpPost("/leaderboard/GetNearbyScores")]
|
||||
[HttpPost("/api/leaderboard/GetNearbyScores")]
|
||||
public async Task<IActionResult> GetNearbyScores([FromBody] GetNearbyScoresRequest req)
|
||||
{
|
||||
var pid = req.PlayerId > 0 ? (long)req.PlayerId : this.CurrentPlayerId() ?? 0;
|
||||
var window = Math.Clamp(req.WindowSize, 1, 50);
|
||||
|
||||
var all = await db.LeaderboardStats
|
||||
.Where(s => s.StatChannel == req.StatChannel)
|
||||
.OrderByDescending(s => s.Value)
|
||||
.Select(s => new { s.PlayerId, s.Value })
|
||||
.ToListAsync();
|
||||
if (all.Count == 0)
|
||||
{
|
||||
// Empty case must still come back as a SingleLeaderboard
|
||||
// Dictionary, not a bare array — the 2020 watch's
|
||||
// SingleLeaderboard.Deserialize reads dict["rows"]
|
||||
// (Cpp2IL_ISIL/.../RecNet/SingleLeaderboard.txt:60).
|
||||
// Returning `[]` cast-fails as List→Dictionary inside
|
||||
// RecNet.Util.Deserialize, logged repeatedly as
|
||||
// "Received malformed RecNet response: '[]'" and
|
||||
// eventually starves the StuntRunner UI of a usable
|
||||
// leaderboard.
|
||||
return Ok(new { rows = Array.Empty<object>() });
|
||||
}
|
||||
|
||||
var ranked = all.Select((r, i) => new
|
||||
{
|
||||
playerId = (int)r.PlayerId,
|
||||
score = r.Value,
|
||||
rank = i + 1,
|
||||
}).ToList();
|
||||
|
||||
var myIdx = ranked.FindIndex(r => r.playerId == (int)pid);
|
||||
if (myIdx < 0)
|
||||
{
|
||||
return Ok(new { rows = ranked.Take(window * 2 + 1).ToArray() });
|
||||
}
|
||||
var lo = Math.Max(0, myIdx - window);
|
||||
var hi = Math.Min(ranked.Count - 1, myIdx + window);
|
||||
return Ok(new { rows = ranked.GetRange(lo, hi - lo + 1).ToArray() });
|
||||
}
|
||||
|
||||
public sealed class GetRanksRequest
|
||||
{
|
||||
public int PlayerId { get; set; }
|
||||
public int StatChannel { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public int RankStart { get; set; }
|
||||
public int RankEnd { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/leaderboard/GetRanks</c> — returns a slice of
|
||||
/// the leaderboard between RankStart and RankEnd (inclusive,
|
||||
/// 1-indexed). Wire shape per <c>GetRanksRequestDTO.cs</c>.</summary>
|
||||
[HttpPost("/leaderboard/GetRanks")]
|
||||
[HttpPost("/api/leaderboard/GetRanks")]
|
||||
public async Task<IActionResult> GetRanks([FromBody] GetRanksRequest req)
|
||||
{
|
||||
var start = Math.Max(1, req.RankStart);
|
||||
var end = Math.Max(start, req.RankEnd);
|
||||
var skip = start - 1;
|
||||
var take = Math.Clamp(end - skip, 1, 200);
|
||||
|
||||
var rows = await db.LeaderboardStats
|
||||
.Where(s => s.StatChannel == req.StatChannel)
|
||||
.OrderByDescending(s => s.Value)
|
||||
.Skip(skip).Take(take)
|
||||
.ToListAsync();
|
||||
var entries = rows.Select((s, i) => new
|
||||
{
|
||||
playerId = (int)s.PlayerId,
|
||||
score = s.Value,
|
||||
rank = start + i,
|
||||
}).ToArray();
|
||||
// SingleLeaderboard wrapper — see GetNearbyScores above.
|
||||
return Ok(new { rows = entries });
|
||||
}
|
||||
|
||||
/// <summary>Top-N for a channel. Used by the admin tooling and
|
||||
/// by the watch's full-leaderboard scoreboard view.</summary>
|
||||
[HttpGet("/leaderboard/Top")]
|
||||
[HttpGet("/api/leaderboard/Top")]
|
||||
public async Task<IActionResult> Top([FromQuery] int channel, [FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 200);
|
||||
var rows = await db.LeaderboardStats
|
||||
.Where(s => s.StatChannel == channel)
|
||||
.OrderByDescending(s => s.Value)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
// FullLeaderboard wire shape: GlobalOverall / GlobalPeriodic /
|
||||
// FriendsOverall / FriendsPeriodic + NextResetUTC. Use the
|
||||
// same Entry list for all four buckets — friends-vs-global
|
||||
// can come later.
|
||||
var entries = rows.Select((s, i) => new
|
||||
{
|
||||
playerId = (int)s.PlayerId,
|
||||
score = s.Value,
|
||||
rank = i + 1,
|
||||
}).ToArray();
|
||||
return Ok(new
|
||||
{
|
||||
GlobalOverall = entries,
|
||||
GlobalPeriodic = entries,
|
||||
FriendsOverall = Array.Empty<object>(),
|
||||
FriendsPeriodic = Array.Empty<object>(),
|
||||
NextResetUTC = DateTime.UtcNow.AddDays(7),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,586 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Messages.V2;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/messages/v2/* — direct-message inbox API. Backed
|
||||
/// by <see cref="MessageEntity"/>. Sends fan out a SignalR push
|
||||
/// (<c>MessageReceived</c>) so the recipient's watch refreshes
|
||||
/// immediately.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v2")]
|
||||
[Authorize]
|
||||
public class MessagesController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
PrivateInstanceService privateInstances,
|
||||
OnlinePresenceService onlinePresence,
|
||||
ServerSettingsService serverSettings,
|
||||
ILogger<MessagesController> logger) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
/// <summary>List the caller's inbox, newest-first. Response is a
|
||||
/// flat array of <see cref="MessageDto"/>; the watch groups by
|
||||
/// sender locally. v1/v3 paths share the same handler — the watch
|
||||
/// uses whichever URL its <c>RecNet.Messages</c> static helper
|
||||
/// built from <c>GameConfig.MessageServiceVersion</c>.</summary>
|
||||
[HttpGet("get")]
|
||||
[HttpGet("/api/messages/v1/get")]
|
||||
[HttpGet("/api/messages/v3/get")]
|
||||
[HttpGet("/api/messages/v1/onlineStatus")]
|
||||
public async Task<ActionResult<List<MessageDto>>> GetMessages([FromQuery] int take = 100)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 200);
|
||||
var rows = await db.Messages
|
||||
.Where(m => m.RecipientPlayerId == Me || m.SenderPlayerId == Me)
|
||||
// Defensive filter for the empty-body ghost rows that
|
||||
// accumulated before SendFormMessage rejected them. The
|
||||
// watch's MessagesScreenItem renders TextMessage (type 30)
|
||||
// as "Message : {body}", so an empty body becomes a
|
||||
// useless "Message :" entry the user has to scroll past.
|
||||
// Control-channel types (10 RequestGameInvite, 20
|
||||
// FriendStatusOnline, etc.) have their own labels and
|
||||
// legitimately carry no body, so we keep them.
|
||||
.Where(m => m.Type != 30 || (m.Body != null && m.Body != ""))
|
||||
.OrderByDescending(m => m.SentAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(await ToDtosAsync(rows));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/messages/v1/unread</c> — list of unread
|
||||
/// messages addressed to the caller. The watch uses the count for
|
||||
/// the inbox badge and the entries themselves for the toast
|
||||
/// preview.</summary>
|
||||
[HttpGet("/api/messages/v1/unread")]
|
||||
public async Task<ActionResult<List<MessageDto>>> Unread()
|
||||
{
|
||||
var rows = await db.Messages
|
||||
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null)
|
||||
// Same ghost-row filter as GetMessages above — keep the
|
||||
// unread badge count honest by hiding empty TextMessages.
|
||||
.Where(m => m.Type != 30 || (m.Body != null && m.Body != ""))
|
||||
.OrderByDescending(m => m.SentAt)
|
||||
.Take(100)
|
||||
.ToListAsync();
|
||||
return Ok(await ToDtosAsync(rows));
|
||||
}
|
||||
|
||||
public sealed class DeleteRequest
|
||||
{
|
||||
public long? Id { get; set; }
|
||||
public long? MessageId { get; set; }
|
||||
public List<long>? MessageIds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/messages/v3/delete</c> — hard-delete one or
|
||||
/// more messages addressed to the caller. The 2020 watch's inbox
|
||||
/// "delete" action posts JSON <c>{"MessageIds":[8]}</c> (plural
|
||||
/// array, verified from the live trace where the body was
|
||||
/// <c>{"MessageIds":[8]}</c> against <c>/api/messages/v3/delete</c>);
|
||||
/// some older / SPA call sites still post <c>id</c> or
|
||||
/// <c>messageId</c> as a form/query. Accept all three shapes and
|
||||
/// scope every delete to <c>RecipientPlayerId == Me</c> so the
|
||||
/// sender can't unsend.</summary>
|
||||
[HttpPost("/api/messages/v3/delete")]
|
||||
[HttpPost("/api/messages/v2/delete")]
|
||||
[HttpPost("/api/messages/v1/delete")]
|
||||
[Consumes("application/json")]
|
||||
public async Task<IActionResult> DeleteJson([FromBody] DeleteRequest body)
|
||||
{
|
||||
var ids = new List<long>();
|
||||
if (body.Id is long a && a > 0) ids.Add(a);
|
||||
if (body.MessageId is long b && b > 0) ids.Add(b);
|
||||
if (body.MessageIds is { Count: > 0 })
|
||||
ids.AddRange(body.MessageIds.Where(v => v > 0));
|
||||
return await DeleteByIds(ids);
|
||||
}
|
||||
|
||||
[HttpPost("/api/messages/v3/delete")]
|
||||
[HttpPost("/api/messages/v2/delete")]
|
||||
[HttpPost("/api/messages/v1/delete")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> DeleteForm(
|
||||
[FromForm(Name = "id")] long? formId,
|
||||
[FromForm(Name = "messageId")] long? formMessageId,
|
||||
[FromForm(Name = "messageIds")] string? messageIdsCsv)
|
||||
{
|
||||
var ids = new List<long>();
|
||||
if (formId is long a && a > 0) ids.Add(a);
|
||||
if (formMessageId is long b && b > 0) ids.Add(b);
|
||||
if (long.TryParse(Request.Query["id"], out var qi) && qi > 0) ids.Add(qi);
|
||||
if (!string.IsNullOrWhiteSpace(messageIdsCsv))
|
||||
{
|
||||
foreach (var s in messageIdsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||
if (long.TryParse(s.Trim(), out var v) && v > 0) ids.Add(v);
|
||||
}
|
||||
return await DeleteByIds(ids);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> DeleteByIds(List<long> ids)
|
||||
{
|
||||
ids = ids.Distinct().ToList();
|
||||
if (ids.Count == 0) return BadRequest(new { error = "missing_id" });
|
||||
var deleted = await db.Messages
|
||||
.Where(m => ids.Contains(m.Id) && m.RecipientPlayerId == Me)
|
||||
.ExecuteDeleteAsync();
|
||||
return Ok(new { success = true, error = "", deleted });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/messages/v3/markRead</c> — bulk
|
||||
/// mark-as-read for every unread message addressed to the caller.
|
||||
/// Optional <c>ids</c> CSV scopes to specific message ids.</summary>
|
||||
[HttpPost("/api/messages/v3/markRead")]
|
||||
[HttpPost("/api/messages/v1/markRead")]
|
||||
public async Task<IActionResult> MarkReadBulk([FromForm(Name = "ids")] string? ids)
|
||||
{
|
||||
IQueryable<MessageEntity> q = db.Messages
|
||||
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null);
|
||||
if (!string.IsNullOrWhiteSpace(ids))
|
||||
{
|
||||
var idSet = ids
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(s => long.TryParse(s, out var v) ? v : 0L)
|
||||
.Where(v => v > 0)
|
||||
.ToHashSet();
|
||||
if (idSet.Count > 0) q = q.Where(m => idSet.Contains(m.Id));
|
||||
}
|
||||
var now = DateTime.UtcNow;
|
||||
await q.ExecuteUpdateAsync(s => s.SetProperty(m => m.ReadAt, now));
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
public sealed record SendRequest(long RecipientId, string Body, long? RoomId = null);
|
||||
|
||||
/// <summary>Send a direct message — JSON wire shape used by some
|
||||
/// SPA/admin callers. The 2020 watch posts to the same path with
|
||||
/// form-urlencoded fields (<see cref="SendFormMessage"/>); the
|
||||
/// <c>[Consumes]</c> attribute disambiguates which action MVC
|
||||
/// picks. Empty bodies are rejected so the inbox doesn't get
|
||||
/// padded with whitespace pings; messages over 2000 chars are
|
||||
/// truncated to fit the column.</summary>
|
||||
[HttpPost("send")]
|
||||
[HttpPost("/api/messages/v1/send")]
|
||||
[Consumes("application/json")]
|
||||
public async Task<ActionResult<MessageDto>> SendMessage([FromBody] SendRequest req)
|
||||
{
|
||||
if (req.RecipientId <= 0 || req.RecipientId == Me)
|
||||
return BadRequest(new { error = "invalid_recipient" });
|
||||
var trimmed = (req.Body ?? string.Empty).Trim();
|
||||
if (trimmed.Length == 0) return BadRequest(new { error = "empty_body" });
|
||||
if (trimmed.Length > 2000) trimmed = trimmed[..2000];
|
||||
|
||||
var entry = new MessageEntity
|
||||
{
|
||||
SenderPlayerId = Me,
|
||||
RecipientPlayerId = req.RecipientId,
|
||||
Body = trimmed,
|
||||
Type = 30, // TextMessage
|
||||
RoomId = req.RoomId,
|
||||
};
|
||||
db.Messages.Add(entry);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await BroadcastMessageAsync(entry);
|
||||
return Ok(ToDto(entry));
|
||||
}
|
||||
|
||||
/// <summary>Form-urlencoded variant — what the 2020 watch actually
|
||||
/// posts when the user taps "Send Join Request" or any other
|
||||
/// <c>RecNet.Messages.SendMessage(player, type, data)</c> caller.
|
||||
/// Wire shape (verified against client log <c>contentType=
|
||||
/// application/x-www-form-urlencoded</c>, body <c>ToPlayerId=…&
|
||||
/// Type=10&Data=</c>): the <c>Type</c> integer is a
|
||||
/// <c>RecNet.Message.MessageType</c> enum value
|
||||
/// (<c>Cpp2IL_CS/.../RecNet/Message.cs</c>) — 10 =
|
||||
/// RequestGameInvite, 30 = TextMessage, etc. Empty <c>Data</c> is
|
||||
/// allowed for control-channel types (join request, declines)
|
||||
/// where the message itself is the signal.</summary>
|
||||
[HttpPost("send")]
|
||||
[HttpPost("/api/messages/v1/send")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<ActionResult<MessageDto>> SendFormMessage(
|
||||
[FromForm(Name = "ToPlayerId")] long toPlayerId,
|
||||
[FromForm(Name = "Type")] int type,
|
||||
[FromForm(Name = "Data")] string? data,
|
||||
[FromForm(Name = "RoomId")] long? roomId)
|
||||
{
|
||||
if (toPlayerId <= 0 || toPlayerId == Me)
|
||||
return BadRequest(new { error = "invalid_recipient" });
|
||||
var trimmed = (data ?? string.Empty);
|
||||
if (trimmed.Length > 2000) trimmed = trimmed[..2000];
|
||||
|
||||
// For TextMessage (type 30) the body IS the message — empty
|
||||
// is meaningless and renders as a ghost "Message :" row in
|
||||
// the watch's inbox (MessagesScreenItem format string at
|
||||
// Cpp2IL_ISIL/.../MessagesScreenItem.txt:2145 is
|
||||
// "Message : {0}"). Control-channel types (RequestGameInvite,
|
||||
// declines, FriendStatusOnline, etc.) intentionally carry
|
||||
// empty Data — they get rendered by their own label string
|
||||
// in the same screen item.
|
||||
if (type == 30 && trimmed.Trim().Length == 0)
|
||||
return BadRequest(new { error = "empty_text_message" });
|
||||
|
||||
var entry = new MessageEntity
|
||||
{
|
||||
SenderPlayerId = Me,
|
||||
RecipientPlayerId = toPlayerId,
|
||||
Body = trimmed,
|
||||
Type = type,
|
||||
RoomId = roomId,
|
||||
};
|
||||
db.Messages.Add(entry);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation(
|
||||
"[messages] send from={From} to={To} type={Type} dataLen={Len}",
|
||||
Me, toPlayerId, type, trimmed.Length);
|
||||
|
||||
await BroadcastMessageAsync(entry);
|
||||
return Ok(ToDto(entry));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>invite</c> — handles the watch's
|
||||
/// <c>RecNet.Messages.SendGameInvite(playerId, roomInstanceId)</c>
|
||||
/// (verified at <c>Cpp2IL_ISIL/.../Messages.txt:613-773</c>): form
|
||||
/// fields <c>playerId</c> (recipient) + <c>roomInstanceId</c>
|
||||
/// (the inviter's private match id). Two side-effects:
|
||||
/// 1. Add the recipient to the invite list for that instance so
|
||||
/// <c>POST /goto/instance/{id}</c> accepts them.
|
||||
/// 2. Drop a Type-6 (GameInviteV2) message in their inbox carrying the
|
||||
/// RoomInstanceId so the message UI's "Join" button knows
|
||||
/// where to send them.
|
||||
/// Registered at multiple paths because the exact URL the watch
|
||||
/// builds for "invite" depends on the messages-namespace base —
|
||||
/// rather than guess wrong and silently fall through to the
|
||||
/// wildcard, we cover all plausible mounts and log the one that
|
||||
/// actually fires.</summary>
|
||||
[HttpPost("/invite")]
|
||||
[HttpPost("/api/invite")]
|
||||
[HttpPost("/api/messages/v1/invite")]
|
||||
[HttpPost("/api/messages/v2/invite")]
|
||||
[HttpPost("/api/messages/v3/invite")]
|
||||
public async Task<ActionResult> SendInvite(
|
||||
[FromForm(Name = "playerId")] long playerId,
|
||||
[FromForm(Name = "roomInstanceId")] long roomInstanceId)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[invite] from={From} → to={To} instance={Instance} (path={Path})",
|
||||
Me, playerId, roomInstanceId, Request.Path);
|
||||
|
||||
if (playerId <= 0 || playerId == Me)
|
||||
return BadRequest(new { error = "invalid_recipient" });
|
||||
|
||||
// Only the OWNER of the private instance can invite into it.
|
||||
// Otherwise an attacker who somehow learned a roomInstanceId
|
||||
// could invite themselves (or anyone) freely.
|
||||
var inst = await privateInstances.GetAsync(roomInstanceId);
|
||||
if (inst is null)
|
||||
return BadRequest(new { error = "instance_not_found", roomInstanceId });
|
||||
if (inst.OwnerPlayerId != Me)
|
||||
return Forbid();
|
||||
|
||||
// Persist a real RecNet.Message.MessageType.GameInviteV2 (=6)
|
||||
// so the watch's GameInviteController calls
|
||||
// Matchmaking.AcceptRoomInvite(inviteId, roomId), which posts
|
||||
// goto/invite/{inviteId}. Type 0 follows the sender's current
|
||||
// presence instead and can land in the wrong instance.
|
||||
//
|
||||
// Two saves: the first allocates the message Id (which becomes
|
||||
// the `inviteId` the watch sends back in /goto/invite/{N}); the
|
||||
// second backfills the JSON Body once we know it. We can't
|
||||
// pre-allocate the Id because EF Core's identity columns only
|
||||
// populate on SaveChanges.
|
||||
var roomName = await db.Rooms
|
||||
.Where(r => r.Id == inst.RoomId)
|
||||
.Select(r => r.Name)
|
||||
.FirstOrDefaultAsync() ?? "Private match";
|
||||
|
||||
var entry = new MessageEntity
|
||||
{
|
||||
SenderPlayerId = Me,
|
||||
RecipientPlayerId = playerId,
|
||||
// Placeholder so the row passes the NOT NULL constraint
|
||||
// until we overwrite it post-save with the real JSON.
|
||||
Body = string.Empty,
|
||||
Type = 6,
|
||||
RoomId = inst.RoomId,
|
||||
};
|
||||
db.Messages.Add(entry);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// InviteAsync AFTER the message save so we can pin the freshly-
|
||||
// allocated Message.Id onto the invitee row's
|
||||
// LatestInviteMessageId. This is what /goto/invite/{N} falls
|
||||
// back to when the watch's accept flow has already raced
|
||||
// through DELETE /api/messages/v3/delete — the message row is
|
||||
// gone but the (instance, player) → messageId mapping survives.
|
||||
await privateInstances.InviteAsync(roomInstanceId, playerId, messageId: entry.Id);
|
||||
|
||||
// Body shape MUST be the JSON the watch's Message.Deserialize
|
||||
// builds RoomInviteDetails from (verified at
|
||||
// Cpp2IL_ISIL/.../RecNet/Message.txt:786-805 — for Type 6/7
|
||||
// the parser hits `Json.Deserialize` and reads "inviteId" +
|
||||
// "name"; for other types it `Int32.Parse`s the body).
|
||||
//
|
||||
// inviteId → the message Id; watch echoes it back in
|
||||
// /goto/invite/{inviteId}, server looks the
|
||||
// row up to resolve roomInstanceId.
|
||||
// name → display string in the invite toast / row.
|
||||
// roomInstanceId → extra field the watch ignores; server uses
|
||||
// it (via TryParseRoomInstanceId's JSON
|
||||
// fallback) so /goto/invite/{N} doesn't have
|
||||
// to re-query the private-instance service.
|
||||
//
|
||||
// The roomInstanceId MUST be a JSON STRING, not a number. LitJson
|
||||
// chokes on the dict-cast when a value is a 64-bit integer larger
|
||||
// than int32 — confirmed via SaveDebugMod trace: the watch logs
|
||||
// `data={"inviteId":31,"name":"Dorm_1811750","roomInstanceId":6546545006375796736}`
|
||||
// `RoomInviteDetails=<null>`
|
||||
// `System.NullReferenceException at RecNet.Util.GetKey from
|
||||
// RecNet.Message.Deserialize`
|
||||
// Json.Deserialize returns a null cast-result on the long value,
|
||||
// GetKey("inviteId") then NREs and the whole parse aborts before
|
||||
// RoomInviteDetails is built. Stringifying the value sidesteps
|
||||
// LitJson's long-int handling entirely; the watch only reads
|
||||
// inviteId + name so the string form is invisible to it.
|
||||
entry.Body = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
inviteId = entry.Id,
|
||||
name = roomName,
|
||||
roomInstanceId = roomInstanceId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await BroadcastMessageAsync(entry);
|
||||
return Ok(new { success = true, messageId = entry.Id });
|
||||
}
|
||||
|
||||
/// <summary>Mark a single message as read. No-op if not
|
||||
/// addressed to the caller — prevents the sender from "reading"
|
||||
/// the recipient's copy.</summary>
|
||||
[HttpPost("markread/{id:long}")]
|
||||
public async Task<ActionResult> MarkRead(long id)
|
||||
{
|
||||
var msg = await db.Messages.FirstOrDefaultAsync(m =>
|
||||
m.Id == id && m.RecipientPlayerId == Me);
|
||||
if (msg is null) return NotFound();
|
||||
if (msg.ReadAt is null)
|
||||
{
|
||||
msg.ReadAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
|
||||
public sealed class SendMultipleBody
|
||||
{
|
||||
public List<long>? ToPlayerIds { get; set; }
|
||||
public int Type { get; set; }
|
||||
public string? Data { get; set; }
|
||||
public long? RoomId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/messages/v1/sendMultiple</c> — fan-out a
|
||||
/// message to many recipients. Persists one MessageEntity per
|
||||
/// recipient. Wire shape: <c>{ToPlayerIds (int[]), Type (int), Data (string)}</c>.</summary>
|
||||
[HttpPost("/api/messages/v1/sendMultiple")]
|
||||
[Consumes("application/json")]
|
||||
public async Task<IActionResult> SendMultiple([FromBody] SendMultipleBody body)
|
||||
{
|
||||
if (body.ToPlayerIds is null || body.ToPlayerIds.Count == 0)
|
||||
return Ok(Array.Empty<object>());
|
||||
var entries = new List<MessageEntity>();
|
||||
foreach (var to in body.ToPlayerIds.Distinct().Where(id => id > 0 && id != Me))
|
||||
{
|
||||
var entry = new MessageEntity
|
||||
{
|
||||
SenderPlayerId = Me,
|
||||
RecipientPlayerId = to,
|
||||
Body = body.Data ?? string.Empty,
|
||||
Type = body.Type,
|
||||
RoomId = body.RoomId,
|
||||
};
|
||||
entries.Add(entry);
|
||||
db.Messages.Add(entry);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
await BroadcastMessageAsync(entry);
|
||||
}
|
||||
return Ok(Array.Empty<object>());
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/offlineinvite/v1/send</c> — send an invite
|
||||
/// message to a player who's offline; surfaces in their inbox on
|
||||
/// next login. Wire shape: form-urlencoded <c>RecipientId</c> +
|
||||
/// <c>Data</c>. Idempotent per (sender, recipient, body).</summary>
|
||||
[HttpPost("/api/offlineinvite/v1/send")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> OfflineInviteSend(
|
||||
[FromForm(Name = "RecipientId")] long? recipientId,
|
||||
[FromForm(Name = "Data")] string? data)
|
||||
{
|
||||
if (recipientId is not long rid || rid <= 0 || rid == Me)
|
||||
return Ok(new { success = false, error = "invalid_recipient" });
|
||||
var entry = new MessageEntity
|
||||
{
|
||||
SenderPlayerId = Me,
|
||||
RecipientPlayerId = rid,
|
||||
Body = data ?? string.Empty,
|
||||
Type = 0,
|
||||
};
|
||||
db.Messages.Add(entry);
|
||||
await db.SaveChangesAsync();
|
||||
await BroadcastMessageAsync(entry);
|
||||
return Ok(new { success = true, messageId = entry.Id });
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/offlineinvite/v1/all</c> — invites
|
||||
/// received while the player was offline. Pulls unread
|
||||
/// MessageEntity rows whose body looks like an invite.</summary>
|
||||
[HttpGet("/api/offlineinvite/v1/all")]
|
||||
public async Task<IActionResult> OfflineInviteAll()
|
||||
{
|
||||
var rows = await db.Messages
|
||||
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null
|
||||
&& (m.Body.Contains("invite") || m.Body.Contains("event")))
|
||||
.OrderByDescending(m => m.SentAt)
|
||||
.Take(50)
|
||||
.Select(m => new
|
||||
{
|
||||
InviteId = m.Id,
|
||||
FromPlayerId = (int)m.SenderPlayerId,
|
||||
m.Body,
|
||||
SentAt = m.SentAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
/// <summary>The boot sequence asks for online friends. We
|
||||
/// derive that from the SignalR hub's connected-player set —
|
||||
/// the response shape is <c>[ {AccountId, Online}, ... ]</c>.</summary>
|
||||
[HttpGet("/api/messages/v1/favoriteFriendOnlineStatus")]
|
||||
public async Task<ActionResult> FavoriteFriendOnlineStatus()
|
||||
{
|
||||
// Honors the global-friends toggle (everyone is a friend when on).
|
||||
var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, Me);
|
||||
var online = onlinePresence.OnlinePlayerIds().ToHashSet();
|
||||
return Ok(friendIds.Select(id => new
|
||||
{
|
||||
AccountId = id,
|
||||
Online = online.Contains(id),
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>Wire shape verified against
|
||||
/// <c>Cpp2IL_ISIL/.../RecNet/Message.txt:591-787</c>.
|
||||
/// <c>RecNet.Message.Deserialize</c> calls <c>Util.GetKey</c>
|
||||
/// (throws on miss) for: <c>Id</c> (long), <c>FromPlayerId</c> (int),
|
||||
/// <c>SentTime</c> (DateTime), <c>Type</c> (int — MessageType enum),
|
||||
/// and reads <c>Data</c> (string) via <c>Util.GetKeyOrDefault</c>.
|
||||
/// Optional nullable wrappers: <c>RoomId</c>, <c>PlayerEventId</c>.
|
||||
/// Previous response used <c>{Id, Sender, Recipient, Body, SentAt, ReadAt}</c>
|
||||
/// which crashed at <c>Util.GetKey("FromPlayerId")</c>.</summary>
|
||||
public sealed record MessageDto(
|
||||
long Id,
|
||||
long FromPlayerId,
|
||||
DateTime SentTime,
|
||||
int Type,
|
||||
string Data,
|
||||
long? RoomId,
|
||||
long? PlayerEventId);
|
||||
|
||||
private async Task<List<MessageDto>> ToDtosAsync(List<MessageEntity> rows)
|
||||
{
|
||||
var instanceIds = rows
|
||||
.Where(m => IsInstanceInviteType(m.Type)
|
||||
&& m.RoomId is null
|
||||
&& long.TryParse(m.Body, System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out _))
|
||||
.Select(m => long.Parse(m.Body, System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var roomByInstance = instanceIds.Count == 0
|
||||
? new Dictionary<long, long>()
|
||||
: await db.PrivateInstances
|
||||
.Where(i => instanceIds.Contains(i.Id))
|
||||
.Select(i => new { i.Id, i.RoomId })
|
||||
.ToDictionaryAsync(i => i.Id, i => i.RoomId);
|
||||
|
||||
return rows.Select(m =>
|
||||
{
|
||||
long? roomId = m.RoomId;
|
||||
if (roomId is null
|
||||
&& IsInstanceInviteType(m.Type)
|
||||
&& long.TryParse(m.Body, System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var instanceId)
|
||||
&& roomByInstance.TryGetValue(instanceId, out var rid))
|
||||
{
|
||||
roomId = rid;
|
||||
}
|
||||
return ToDto(m, roomId);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static bool IsInstanceInviteType(int type) => type is 6 or 7;
|
||||
|
||||
/// <summary>Push a <c>MessageReceived</c> SignalR notification to BOTH
|
||||
/// the recipient AND the sender. The recipient half is the obvious one
|
||||
/// — that's what fires the watch's <c>OnMessageAdded</c> event and
|
||||
/// gives them the toast / inbox-row update. The sender-half is less
|
||||
/// obvious but equally needed: the watch's <c>Messages.SendMessage</c>
|
||||
/// (Messages.txt:1790-1900) just does <c>ExpectHttpStatusSuccess</c>
|
||||
/// on the response and never touches the local <c>MessageList</c>,
|
||||
/// so without a self-targeted push the sender's chat thread doesn't
|
||||
/// know about its own outgoing message until the next
|
||||
/// <c>RefreshList</c> (which only fires on hub-reconnect or first
|
||||
/// inbox open). Pushing to both sides keeps both watches in sync
|
||||
/// in real time. Dedup happens client-side in
|
||||
/// <c>OnMessageReceived</c> via the "already-in-MessageList by Id"
|
||||
/// check (Messages.txt:2790-2793), so a player who somehow ends up
|
||||
/// as both sender and recipient (admin self-messaging) only sees
|
||||
/// the message once.</summary>
|
||||
private async Task BroadcastMessageAsync(MessageEntity entry)
|
||||
{
|
||||
var dto = ToDto(entry);
|
||||
await notifications.NotifyAsync(entry.RecipientPlayerId,
|
||||
PushNotificationId.MessageReceived, dto);
|
||||
if (entry.SenderPlayerId != entry.RecipientPlayerId)
|
||||
{
|
||||
await notifications.NotifyAsync(entry.SenderPlayerId,
|
||||
PushNotificationId.MessageReceived, dto);
|
||||
}
|
||||
}
|
||||
|
||||
private static MessageDto ToDto(MessageEntity m, long? roomId = null) =>
|
||||
new(
|
||||
Id: m.Id,
|
||||
FromPlayerId: m.SenderPlayerId,
|
||||
SentTime: m.SentAt,
|
||||
// Round-trip the stored Type verbatim. Earlier we coerced
|
||||
// Type==0 → 30 for "pre-Type-column rows", but the
|
||||
// Messages.Type Postgres patch (Program.cs:612-614) added
|
||||
// the column with DEFAULT 30 and backfilled every legacy
|
||||
// row to 30, so any Type==0 row in the DB now is a real
|
||||
// GameInvite (e.g. SendInvite above). Coercing it to 30
|
||||
// routes it through TextMessageController and renders
|
||||
// "Message : <instanceId>" with no Accept button.
|
||||
Type: m.Type,
|
||||
Data: m.Body,
|
||||
RoomId: roomId ?? m.RoomId,
|
||||
PlayerEventId: null);
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Moderation;
|
||||
|
||||
/// <summary>
|
||||
/// In-game moderation endpoints invoked by the watch's player-action
|
||||
/// dialogs. URLs / form-fields verified against the Cpp2IL ISIL dump:
|
||||
///
|
||||
/// POST api/PlayerReporting/v3/create
|
||||
/// PlayerReporting.txt:1696 + 1712-1822
|
||||
/// Form: PlayerIdReported (int), ReportCategory (int), Details (string),
|
||||
/// HeightReporter (float "F2"), HeightReported (float "F2"),
|
||||
/// RoomId (long).
|
||||
///
|
||||
/// POST api/PlayerReporting/v3/voteToKick
|
||||
/// PlayerReporting.txt:3175 + 3215-3237
|
||||
/// Form: PlayerId (long), Response (bool), GameSessionId (long).
|
||||
///
|
||||
/// POST api/PlayerReporting/v1/instantKick
|
||||
/// PlayerReporting.txt:3417 (single) + 3590 (bulk)
|
||||
/// Body: PlayerId(s) — one or many. Admin/dev only.
|
||||
///
|
||||
/// POST api/PlayerReporting/v1/deviceId
|
||||
/// PlayerReporting.txt:3736 + 3749
|
||||
/// Form: oldDeviceId (string).
|
||||
///
|
||||
/// POST api/PlayersBanned/v2/ban
|
||||
/// Moderation.txt:623-702
|
||||
/// Form: PlayerId (long), Reason (int), BanType (int),
|
||||
/// DisplayReason (string), BannedUntil (string MM/dd/yyyy HH:mm).
|
||||
/// Admin only.
|
||||
///
|
||||
/// Without these handlers the watch fell through to the JSON catch-all
|
||||
/// returning <c>{}</c>, which the OkResponse parser tolerates but the
|
||||
/// admin-action then has no observable effect — i.e. "the admin
|
||||
/// system doesn't work at all".
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class InGameModerationController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
ILogger<InGameModerationController> logger) : ControllerBase
|
||||
{
|
||||
// ── /v3/create — file a player report ────────────────────────────
|
||||
[HttpPost("api/PlayerReporting/v3/create")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> CreateReport(
|
||||
[FromForm(Name = "PlayerIdReported")] long? playerIdReported,
|
||||
[FromForm(Name = "ReportCategory")] int reportCategory = 0,
|
||||
[FromForm(Name = "Details")] string? details = null,
|
||||
[FromForm(Name = "HeightReporter")] float? heightReporter = null,
|
||||
[FromForm(Name = "HeightReported")] float? heightReported = null,
|
||||
[FromForm(Name = "RoomId")] long roomId = 0)
|
||||
{
|
||||
if (playerIdReported is not long target || target <= 0)
|
||||
return Ok(new { success = false, error = "missing_target" });
|
||||
|
||||
var reporter = this.RequireCurrentPlayerId();
|
||||
if (reporter == target)
|
||||
return Ok(new { success = false, error = "cannot_report_self" });
|
||||
|
||||
var trimmed = (details ?? string.Empty).Trim();
|
||||
if (trimmed.Length > 1000) trimmed = trimmed[..1000];
|
||||
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
ReporterPlayerId = reporter,
|
||||
TargetPlayerId = target,
|
||||
Category = reportCategory,
|
||||
Message = trimmed,
|
||||
RoomId = roomId,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation(
|
||||
"[mod] report filed: reporter={Reporter} target={Target} category={Cat} room={Room}",
|
||||
reporter, target, reportCategory, roomId);
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
// ── /v3/voteToKick — room-vote kick ──────────────────────────────
|
||||
[HttpPost("api/PlayerReporting/v3/voteToKick")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public IActionResult VoteToKick(
|
||||
[FromForm(Name = "PlayerId")] long? playerId,
|
||||
[FromForm(Name = "Response")] bool? response,
|
||||
[FromForm(Name = "GameSessionId")] long gameSessionId = 0)
|
||||
{
|
||||
var caller = this.RequireCurrentPlayerId();
|
||||
logger.LogInformation(
|
||||
"[mod] vote-to-kick by {Caller}: target={Target} yes={Yes} session={Session}",
|
||||
caller, playerId, response, gameSessionId);
|
||||
// Real implementation would tally votes per (session, target) and
|
||||
// push a kick once the threshold tripped. For now we accept the
|
||||
// vote without side-effects so the watch UI's "voted" indicator
|
||||
// shows.
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
// ── /v1/instantKick — admin one-shot ─────────────────────────────
|
||||
/// <summary>
|
||||
/// Admin/dev one-shot kick. The watch posts either
|
||||
/// <c>?playerId=N</c> for a single target (PlayerReporting.txt:3417)
|
||||
/// or a list body for bulk kicks (3590). Either form lands here.
|
||||
/// Gated to <see cref="PlayerEntity.IsAdmin"/> /
|
||||
/// <see cref="PlayerEntity.IsDeveloper"/>.
|
||||
/// </summary>
|
||||
[HttpPost("api/PlayerReporting/v1/instantKick")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> InstantKick(
|
||||
[FromQuery(Name = "playerId")] long? singleId,
|
||||
[FromForm(Name = "PlayerId")] long? formId,
|
||||
[FromForm(Name = "PlayerIds")] string? formIds)
|
||||
{
|
||||
var caller = this.RequireCurrentPlayerId();
|
||||
if (!await IsCallerAdminOrDevAsync(caller))
|
||||
return Forbid();
|
||||
|
||||
var ids = new List<long>();
|
||||
if (singleId is long s && s > 0) ids.Add(s);
|
||||
if (formId is long f && f > 0) ids.Add(f);
|
||||
if (!string.IsNullOrEmpty(formIds))
|
||||
{
|
||||
foreach (var part in formIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(part, out var v) && v > 0) ids.Add(v);
|
||||
}
|
||||
ids = ids.Distinct().ToList();
|
||||
if (ids.Count == 0) return Ok(new { success = false, error = "missing_target" });
|
||||
|
||||
foreach (var target in ids)
|
||||
{
|
||||
logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target);
|
||||
// Push a SignalR ModerationKick notification — the watch's
|
||||
// PlayerReporting.OnModerationKick handler boots the player
|
||||
// back to their dorm.
|
||||
await notifications.NotifyAsync(
|
||||
target,
|
||||
Models.Notification.PushNotificationId.ModerationKick);
|
||||
}
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
// ── /v1/deviceId — log mismatched device id ──────────────────────
|
||||
[HttpPost("api/PlayerReporting/v1/deviceId")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public IActionResult MismatchedDeviceWarning(
|
||||
[FromForm(Name = "oldDeviceId")] string? oldDeviceId)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[mod] mismatched device id reported: caller={Caller} old={Old}",
|
||||
this.CurrentPlayerId(), oldDeviceId);
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
// ── /api/PlayersBanned/v2/ban — admin ban ────────────────────────
|
||||
[HttpPost("api/PlayersBanned/v2/ban")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> Ban(
|
||||
[FromForm(Name = "PlayerId")] long? playerId,
|
||||
[FromForm(Name = "Reason")] int reason = 0,
|
||||
[FromForm(Name = "BanType")] int banType = 0,
|
||||
[FromForm(Name = "DisplayReason")] string? displayReason = null,
|
||||
[FromForm(Name = "BannedUntil")] string? bannedUntil = null)
|
||||
{
|
||||
var caller = this.RequireCurrentPlayerId();
|
||||
if (!await IsCallerAdminOrDevAsync(caller))
|
||||
return Forbid();
|
||||
|
||||
if (playerId is not long target || target <= 0)
|
||||
return Ok(new { success = false, error = "missing_target" });
|
||||
|
||||
// BannedUntil arrives as "MM/dd/yyyy HH:mm" per Moderation.txt:696.
|
||||
DateTime? until = null;
|
||||
if (!string.IsNullOrEmpty(bannedUntil) &&
|
||||
DateTime.TryParseExact(bannedUntil, "MM/dd/yyyy HH:mm",
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
|
||||
out var parsed))
|
||||
{
|
||||
until = parsed;
|
||||
}
|
||||
|
||||
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == target);
|
||||
if (p is null) return Ok(new { success = false, error = "no_such_player" });
|
||||
p.BannedUntil = until ?? DateTime.UtcNow.AddYears(99);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation(
|
||||
"[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}",
|
||||
caller, target, reason, p.BannedUntil, displayReason);
|
||||
|
||||
// Push a ModerationRoomBan notification so the target watch
|
||||
// shows the ban dialog and disconnects (PlayerReporting.txt:2253).
|
||||
await notifications.NotifyAsync(
|
||||
target,
|
||||
Models.Notification.PushNotificationId.ModerationRoomBan);
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
private async Task<bool> IsCallerAdminOrDevAsync(long callerId) =>
|
||||
await db.Players
|
||||
.Where(p => p.Id == callerId)
|
||||
.Select(p => p.IsAdmin || p.IsDeveloper)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
using System.Net.WebSockets;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Notification.V2;
|
||||
|
||||
/// <summary>
|
||||
/// api/notification/v2 — the JUNE-2018 client opens a raw WebSocket here for
|
||||
/// push notifications (RecNet.cs:48895, RecNet.IJADBHMBMPG wraps a
|
||||
/// BestHTTP.WebSocket with open/message(string)/error events). The 2020 client
|
||||
/// replaced this with the SignalR hub at notify.{apex}/hub/v1.
|
||||
///
|
||||
/// This branch targets 2018, so we ACCEPT the WebSocket and hold it open. We
|
||||
/// don't push anything yet (server-driven notifications are a later feature) —
|
||||
/// an open, quiet socket keeps the client's notification subsystem "connected"
|
||||
/// instead of erroring/retry-looping on the old 410 Gone. Incoming frames
|
||||
/// (subscribe/auth) are drained and ignored.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v2")]
|
||||
public class NotificationController(
|
||||
DomainConfig domain,
|
||||
ILogger<NotificationController> log) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task Connect()
|
||||
{
|
||||
if (!HttpContext.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
// Non-WS probe (or a curl check) — return an empty notification
|
||||
// list rather than 410 so HTTP callers don't treat it as an error.
|
||||
Response.StatusCode = StatusCodes.Status200OK;
|
||||
Response.ContentType = "application/json";
|
||||
await Response.WriteAsync("[]");
|
||||
return;
|
||||
}
|
||||
|
||||
using var ws = await HttpContext.WebSockets.AcceptWebSocketAsync();
|
||||
log.LogInformation("[notify] 2018 client opened notification WebSocket");
|
||||
|
||||
// HANDSHAKE: on connect the 2018 client enters PerformingHandshake and
|
||||
// blocks ("Connecting to RecNet") until the SERVER sends a handshake
|
||||
// message — any valid JSON object marks the channel Connected
|
||||
// (RecNet.cs:49616-49620, JFBBMEIDBNA just needs dict != null). Without
|
||||
// it both sides wait for each other ~35s, then the client aborts the
|
||||
// channel and shows "Failed to connect to RecNet". Send {} immediately.
|
||||
var handshake = System.Text.Encoding.UTF8.GetBytes("{}");
|
||||
await ws.SendAsync(handshake, WebSocketMessageType.Text, endOfMessage: true,
|
||||
HttpContext.RequestAborted);
|
||||
|
||||
var buf = new byte[4096];
|
||||
try
|
||||
{
|
||||
while (ws.State == WebSocketState.Open)
|
||||
{
|
||||
var result = await ws.ReceiveAsync(
|
||||
new ArraySegment<byte>(buf), HttpContext.RequestAborted);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure,
|
||||
"bye", HttpContext.RequestAborted);
|
||||
break;
|
||||
}
|
||||
// Drain & ignore subscribe/auth frames — no server push yet.
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { /* client navigated away */ }
|
||||
catch (WebSocketException) { /* abrupt disconnect — normal */ }
|
||||
log.LogDebug("[notify] notification WebSocket closed");
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/notification/hub/v1</c> — 2020 SignalR hub pointer
|
||||
/// (unused by the 2018 client; kept for any 2019/2020 caller).</summary>
|
||||
[HttpGet("/api/notification/hub/v1")]
|
||||
public IActionResult HubInfo() => Ok(new
|
||||
{
|
||||
Url = $"https://{domain.Sub("notify")}/hub/v1",
|
||||
Protocol = "signalr",
|
||||
Version = 1,
|
||||
});
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.NotificationPrefs;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/messages/v1/IOSGetNotificationPreferences +
|
||||
/// IOSModifyNotificationPreferences. Wire DTO matches
|
||||
/// <c>RecNet.IOSNotificationPreferences</c> (decompiled at
|
||||
/// <c>Cpp2IL_CS/.../RecNet/IOSNotificationPreferences.cs</c>):
|
||||
/// <c>ChatMessage(bool), FriendInvite(bool), FavoriteFriendOnline(bool)</c>.
|
||||
///
|
||||
/// Persists via <see cref="NotificationPrefsEntity"/> keyed by
|
||||
/// (PlayerId, Platform). Defaults to all-on when no row exists yet.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class IOSNotificationPrefsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpGet("api/messages/v1/IOSGetNotificationPreferences")]
|
||||
public async Task<IActionResult> Get()
|
||||
{
|
||||
var pid = Me;
|
||||
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == pid);
|
||||
return Ok(ToWire(row));
|
||||
}
|
||||
|
||||
public sealed class IOSPrefsRequest
|
||||
{
|
||||
public bool ChatMessage { get; set; } = true;
|
||||
public bool FriendInvite { get; set; } = true;
|
||||
public bool FavoriteFriendOnline { get; set; } = true;
|
||||
}
|
||||
|
||||
[HttpPost("api/messages/v1/IOSModifyNotificationPreferences")]
|
||||
public async Task<IActionResult> Modify([FromBody] IOSPrefsRequest body)
|
||||
{
|
||||
var pid = Me;
|
||||
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == pid);
|
||||
if (row is null)
|
||||
{
|
||||
row = new NotificationPrefsEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
Platform = "ios",
|
||||
};
|
||||
db.NotificationPrefs.Add(row);
|
||||
}
|
||||
row.AllowMessage = body.ChatMessage;
|
||||
row.AllowFriendRequest = body.FriendInvite;
|
||||
row.AllowAnnouncements = body.FavoriteFriendOnline;
|
||||
row.AllowAll = body.ChatMessage || body.FriendInvite || body.FavoriteFriendOnline;
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(row));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>IOSResetNotificationPreferencesBadgeCount</c> —
|
||||
/// the watch sends this when the user opens the iOS app to clear
|
||||
/// the badge counter. No persistent state to mutate; ack.</summary>
|
||||
[HttpPost("api/messages/v1/IOSResetNotificationPreferencesBadgeCount")]
|
||||
public IActionResult ResetBadge() => Ok(new { Reset = true });
|
||||
|
||||
public sealed class SaveTokenRequest
|
||||
{
|
||||
public string? Token { get; set; }
|
||||
public string? DeviceToken { get; set; }
|
||||
public string? Platform { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/messages/v1/IOSSaveDeviceToken</c> —
|
||||
/// upserts the caller's APNS token to
|
||||
/// <see cref="PushTokenEntity"/> for a future APNS proxy to fan
|
||||
/// out push notifications.</summary>
|
||||
[HttpPost("api/messages/v1/IOSSaveDeviceToken")]
|
||||
public async Task<IActionResult> SaveDeviceToken(
|
||||
[FromBody] SaveTokenRequest? body,
|
||||
[FromForm(Name = "Token")] string? tokenForm,
|
||||
[FromForm(Name = "DeviceToken")] string? deviceTokenForm)
|
||||
{
|
||||
var pid = Me;
|
||||
var token = body?.Token ?? body?.DeviceToken ?? tokenForm ?? deviceTokenForm ?? string.Empty;
|
||||
var platform = body?.Platform ?? "ios";
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return BadRequest(new { error = "missing token" });
|
||||
|
||||
var row = await db.PushTokens.FirstOrDefaultAsync(t =>
|
||||
t.PlayerId == pid && t.Platform == platform);
|
||||
if (row is null)
|
||||
{
|
||||
row = new PushTokenEntity { PlayerId = pid, Platform = platform };
|
||||
db.PushTokens.Add(row);
|
||||
}
|
||||
row.Token = token;
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Saved = true });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/messages/v1/IOSClearDeviceToken</c> —
|
||||
/// drops every push-token row for the caller (e.g. on iOS
|
||||
/// logout).</summary>
|
||||
[HttpPost("api/messages/v1/IOSClearDeviceToken")]
|
||||
public async Task<IActionResult> ClearDeviceToken()
|
||||
{
|
||||
var pid = Me;
|
||||
var rows = await db.PushTokens.Where(t => t.PlayerId == pid).ToListAsync();
|
||||
if (rows.Count > 0)
|
||||
{
|
||||
db.PushTokens.RemoveRange(rows);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Ok(new { Cleared = rows.Count });
|
||||
}
|
||||
|
||||
private static object ToWire(NotificationPrefsEntity? r) => new
|
||||
{
|
||||
ChatMessage = r?.AllowMessage ?? true,
|
||||
FriendInvite = r?.AllowFriendRequest ?? true,
|
||||
FavoriteFriendOnline = r?.AllowAnnouncements ?? true,
|
||||
};
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Photos;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/photos/v1/* — social-feed wrapper around the
|
||||
/// in-game camera. The camera itself uses storage.rec.net/upload with
|
||||
/// FileType=Image to push the PNG bytes; that returns a Filename. The
|
||||
/// watch's "Share" panel then POSTs here with that Filename, a caption
|
||||
/// and any tagged players to create the public-feed photo.
|
||||
///
|
||||
/// Public reads (feed, by-player, by-room) are anonymous so the
|
||||
/// feed.rec.net frontend can browse without an admin token.
|
||||
/// Mutations (post, cheer, delete) require auth.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
// Two hosts: api.rec.net is what the in-game watch hits; feed.rec.net
|
||||
// also serves these endpoints so the public frontend website can do
|
||||
// same-origin fetches without CORS.
|
||||
[Route("api/photos/v1")]
|
||||
public class PhotosController(
|
||||
DorkNetDbContext db,
|
||||
PlayerPresenceService presence,
|
||||
NotificationService notifications,
|
||||
LevelService level,
|
||||
DomainConfig domain) : ControllerBase
|
||||
{
|
||||
public sealed record PostPhotoRequest(
|
||||
string ImageName,
|
||||
string? Caption,
|
||||
string? TaggedPlayerIds,
|
||||
long? RoomId,
|
||||
bool? IsPublic);
|
||||
|
||||
/// <summary>POST api/photos/v1/post — promote an uploaded image
|
||||
/// blob to the photo feed. Body: ImageName (filename returned by
|
||||
/// storage.rec.net/upload), Caption, TaggedPlayerIds (CSV),
|
||||
/// optional RoomId (defaults to caller's current room from
|
||||
/// PlayerPresenceService), IsPublic (defaults true). Returns the
|
||||
/// created PhotoEntity.</summary>
|
||||
[HttpPost("post")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> PostPhoto([FromBody] PostPhotoRequest body)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
if (string.IsNullOrWhiteSpace(body.ImageName))
|
||||
return BadRequest(new { error = "missing_image_name" });
|
||||
|
||||
// Verify the blob actually exists and was uploaded by us. Without
|
||||
// this check anyone could cite another player's filename and
|
||||
// post-attribute the photo to themselves.
|
||||
var blob = await db.RoomDataBlobs
|
||||
.Where(b => b.BlobName == body.ImageName)
|
||||
.Select(b => new { b.UploadedByPlayerId })
|
||||
.FirstOrDefaultAsync();
|
||||
if (blob is null)
|
||||
return NotFound(new { error = "image_blob_not_found" });
|
||||
if (blob.UploadedByPlayerId != me)
|
||||
return Forbid();
|
||||
|
||||
// Auto-resolve current room if the caller didn't supply one.
|
||||
var roomId = body.RoomId ?? presence.GetRoom(me)?.RoomId ?? 0;
|
||||
|
||||
var photo = new PhotoEntity
|
||||
{
|
||||
UploaderPlayerId = me,
|
||||
BlobName = body.ImageName,
|
||||
Caption = (body.Caption ?? string.Empty).Trim(),
|
||||
TaggedPlayerIdsCsv = NormaliseTaggedIds(body.TaggedPlayerIds, exceptId: me),
|
||||
RoomId = roomId,
|
||||
IsPublic = body.IsPublic ?? true,
|
||||
};
|
||||
db.Photos.Add(photo);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Posting a photo gives a small XP bump — encourages camera use.
|
||||
await level.AwardXpAsync(me, LevelService.InventionSavedXp, $"photo_posted:{photo.Id}");
|
||||
|
||||
// Push to tagged players so their "Photos of me" feed refreshes.
|
||||
foreach (var taggedId in ParseTagged(photo.TaggedPlayerIdsCsv))
|
||||
await notifications.NotifyAsync(taggedId,
|
||||
PushNotificationId.SubscriptionUpdateProfile,
|
||||
new { Reason = "TaggedInPhoto", PhotoId = photo.Id, From = me });
|
||||
|
||||
return Ok(ToDto(photo));
|
||||
}
|
||||
|
||||
/// <summary>GET api/photos/v1/feed — public newest-first feed.
|
||||
/// Anonymous-safe (so the frontend website can render without a
|
||||
/// JWT). Soft-deleted and private photos are excluded.</summary>
|
||||
[HttpGet("feed")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> Feed([FromQuery] int take = 20, [FromQuery] int skip = 0)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
skip = Math.Max(0, skip);
|
||||
var rows = await db.Photos
|
||||
.Where(p => p.IsPublic && p.DeletedAt == null)
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Skip(skip).Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(await EnrichAsync(rows));
|
||||
}
|
||||
|
||||
/// <summary>GET api/photos/v1/by/{playerId} — photos posted by a
|
||||
/// specific player. Anonymous-safe. Private photos hidden unless
|
||||
/// the caller is the uploader (or an admin).</summary>
|
||||
[HttpGet("by/{playerId:long}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> ByPlayer(long playerId,
|
||||
[FromQuery] int take = 20, [FromQuery] int skip = 0)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
skip = Math.Max(0, skip);
|
||||
var me = this.CurrentPlayerId();
|
||||
var includePrivate = me == playerId || await IsAdminAsync(me);
|
||||
|
||||
var q = db.Photos.Where(p => p.UploaderPlayerId == playerId && p.DeletedAt == null);
|
||||
if (!includePrivate) q = q.Where(p => p.IsPublic);
|
||||
var rows = await q
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Skip(skip).Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(await EnrichAsync(rows));
|
||||
}
|
||||
|
||||
/// <summary>GET api/photos/v1/of/{playerId} — photos the player
|
||||
/// is tagged in. Anonymous-safe.</summary>
|
||||
[HttpGet("of/{playerId:long}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> OfPlayer(long playerId,
|
||||
[FromQuery] int take = 20, [FromQuery] int skip = 0)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
skip = Math.Max(0, skip);
|
||||
var needle = $",{playerId},";
|
||||
// SQLite EF translates `string.Contains` to LIKE — wrapping the
|
||||
// CSV with leading/trailing commas avoids "12" matching "112".
|
||||
var rows = await db.Photos
|
||||
.Where(p => p.IsPublic && p.DeletedAt == null &&
|
||||
("," + p.TaggedPlayerIdsCsv + ",").Contains(needle))
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Skip(skip).Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(await EnrichAsync(rows));
|
||||
}
|
||||
|
||||
/// <summary>GET api/photos/v1/in/{roomId} — photos taken in a
|
||||
/// specific room. Anonymous-safe.</summary>
|
||||
[HttpGet("in/{roomId:long}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> InRoom(long roomId,
|
||||
[FromQuery] int take = 20, [FromQuery] int skip = 0)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
skip = Math.Max(0, skip);
|
||||
var rows = await db.Photos
|
||||
.Where(p => p.RoomId == roomId && p.IsPublic && p.DeletedAt == null)
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Skip(skip).Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(await EnrichAsync(rows));
|
||||
}
|
||||
|
||||
/// <summary>GET api/photos/v1/{id} — single photo detail.
|
||||
/// Increments ViewCount as a side effect (denormalised for the
|
||||
/// "trending" sort).</summary>
|
||||
[HttpGet("{id:long}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> Get(long id)
|
||||
{
|
||||
var p = await db.Photos.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (p is null || p.DeletedAt is not null) return NotFound();
|
||||
if (!p.IsPublic)
|
||||
{
|
||||
var me = this.CurrentPlayerId();
|
||||
if (me != p.UploaderPlayerId && !await IsAdminAsync(me)) return NotFound();
|
||||
}
|
||||
p.ViewCount += 1;
|
||||
await db.SaveChangesAsync();
|
||||
var enriched = await EnrichAsync(new[] { p });
|
||||
return Ok(enriched.First());
|
||||
}
|
||||
|
||||
/// <summary>POST api/photos/v1/{id}/cheer — like a photo. Idempotent
|
||||
/// per (caller, photo) pair. Pushes a notification to the
|
||||
/// uploader so their watch can refresh the count.</summary>
|
||||
[HttpPost("{id:long}/cheer")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Cheer(long id)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (photo is null || photo.DeletedAt is not null) return NotFound();
|
||||
|
||||
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
|
||||
c.FromPlayerId == me && c.TargetPhotoId == id &&
|
||||
c.TargetPlayerId == 0 && c.TargetRoomId == 0);
|
||||
if (existing is not null) return Ok(new { already_cheered = true });
|
||||
|
||||
db.Cheers.Add(new CheerEntity
|
||||
{
|
||||
FromPlayerId = me,
|
||||
TargetPhotoId = id,
|
||||
});
|
||||
photo.CheerCount += 1;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (photo.UploaderPlayerId != me)
|
||||
{
|
||||
await level.AwardXpAsync(photo.UploaderPlayerId,
|
||||
LevelService.CheerReceivedXp, $"photo_cheer:{id}");
|
||||
await notifications.NotifyAsync(photo.UploaderPlayerId,
|
||||
PushNotificationId.SubscriptionUpdateProfile,
|
||||
new { Reason = "PhotoCheer", PhotoId = id, From = me });
|
||||
}
|
||||
return Ok(new { cheered = true, count = photo.CheerCount });
|
||||
}
|
||||
|
||||
/// <summary>DELETE api/photos/v1/{id} — soft-delete. Allowed for
|
||||
/// the uploader or an admin. The blob bytes stay in
|
||||
/// RoomDataBlobs for audit; the row's DeletedAt timestamp hides
|
||||
/// it from feeds.</summary>
|
||||
[HttpDelete("{id:long}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Delete(long id)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (photo is null) return NotFound();
|
||||
if (photo.UploaderPlayerId != me && !await IsAdminAsync(me))
|
||||
return Forbid();
|
||||
photo.DeletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { deleted = id });
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Trim/dedupe tagged ids and drop the uploader's own id.
|
||||
/// CSV in, CSV out.</summary>
|
||||
private static string NormaliseTaggedIds(string? csv, long exceptId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(csv)) return string.Empty;
|
||||
var ids = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(s => long.TryParse(s, out var v) ? v : 0L)
|
||||
.Where(v => v > 0 && v != exceptId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
return string.Join(",", ids);
|
||||
}
|
||||
|
||||
private static IEnumerable<long> ParseTagged(string? csv) =>
|
||||
string.IsNullOrWhiteSpace(csv)
|
||||
? Enumerable.Empty<long>()
|
||||
: csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(s => long.TryParse(s, out var v) ? v : 0L)
|
||||
.Where(v => v > 0);
|
||||
|
||||
private async Task<bool> IsAdminAsync(long? playerId)
|
||||
{
|
||||
if (playerId is not long id) return false;
|
||||
return await db.Players.Where(p => p.Id == id).Select(p => p.IsAdmin).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <summary>Materialise photo rows into the wire DTO shape. One
|
||||
/// extra DB hit fetches the uploader display names and room names
|
||||
/// in batch so the JSON has human-readable labels — saves the
|
||||
/// frontend from a per-row N+1.</summary>
|
||||
private async Task<List<object>> EnrichAsync(IEnumerable<PhotoEntity> photos)
|
||||
{
|
||||
var photoList = photos.ToList();
|
||||
if (photoList.Count == 0) return new();
|
||||
|
||||
var uploaderIds = photoList.Select(p => p.UploaderPlayerId).Distinct().ToList();
|
||||
var roomIds = photoList.Select(p => p.RoomId).Where(id => id > 0).Distinct().ToList();
|
||||
|
||||
var uploaderNames = await db.Players
|
||||
.Where(p => uploaderIds.Contains(p.Id))
|
||||
.Select(p => new { p.Id, p.DisplayName, p.Username })
|
||||
.ToListAsync();
|
||||
var roomNames = await db.Rooms
|
||||
.Where(r => roomIds.Contains(r.Id))
|
||||
.Select(r => new { r.Id, r.Name })
|
||||
.ToListAsync();
|
||||
|
||||
var uMap = uploaderNames.ToDictionary(u => u.Id);
|
||||
var rMap = roomNames.ToDictionary(r => r.Id);
|
||||
|
||||
return photoList.Select(p =>
|
||||
{
|
||||
uMap.TryGetValue(p.UploaderPlayerId, out var u);
|
||||
rMap.TryGetValue(p.RoomId, out var r);
|
||||
return (object)new
|
||||
{
|
||||
p.Id,
|
||||
p.UploaderPlayerId,
|
||||
UploaderDisplayName = u?.DisplayName ?? $"Player_{p.UploaderPlayerId}",
|
||||
UploaderUsername = u?.Username ?? string.Empty,
|
||||
p.BlobName,
|
||||
ImageUrl = $"https://{domain.Sub("cdn")}/{p.BlobName}",
|
||||
p.Caption,
|
||||
p.RoomId,
|
||||
RoomName = r?.Name ?? string.Empty,
|
||||
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
|
||||
p.IsPublic,
|
||||
p.CheerCount,
|
||||
p.ViewCount,
|
||||
p.CreatedAt,
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private object ToDto(PhotoEntity p) => new
|
||||
{
|
||||
p.Id,
|
||||
p.UploaderPlayerId,
|
||||
p.BlobName,
|
||||
ImageUrl = $"https://{domain.Sub("cdn")}/{p.BlobName}",
|
||||
p.Caption,
|
||||
p.RoomId,
|
||||
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
|
||||
p.IsPublic,
|
||||
p.CheerCount,
|
||||
p.ViewCount,
|
||||
p.CreatedAt,
|
||||
};
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Auth;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Compat2018;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.PlatformLogin.V1;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/platformlogin/v1/* — the JUNE-2018 login family.
|
||||
///
|
||||
/// The 2018 client (build 20180621_EA) logs in via a JSON body and parses the
|
||||
/// PascalCase "login envelope" {Error, Player, Token, FirstLoginOfTheDay,
|
||||
/// AnalyticsSessionId} (RecNet.cs:40014-40021). This is wholly different from
|
||||
/// the 2019/2020 OAuth password-grant flow handled by the V2 controller, so it
|
||||
/// lives in its own versioned controller. This branch targets the 2018 client;
|
||||
/// the V2 controller still serves 2019/2020 builds.
|
||||
///
|
||||
/// Bodies are read leniently from JSON OR form OR query so JsonUtility,
|
||||
/// LitJson, and form-encoded callers all bind. The single login Token IS the
|
||||
/// JWT access token — the 2018 client re-attaches it as "Bearer {Token}" on
|
||||
/// every authenticated call and refreshes it via GET v1/refreshlogin.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/platformlogin/v1")]
|
||||
public class PlatformLoginV1Controller(
|
||||
PlayerService playerService,
|
||||
AuthService authService,
|
||||
ServerSettingsService settings,
|
||||
SignupCodeService signupCodes,
|
||||
DorkNetDbContext db,
|
||||
ILogger<PlatformLoginV1Controller> logger) : ControllerBase
|
||||
{
|
||||
// POST api/platformlogin/v1/loginaccount — primary sign-in.
|
||||
[HttpPost("loginaccount")]
|
||||
public async Task<IActionResult> LoginAccount()
|
||||
{
|
||||
var p = await ReadParamsAsync();
|
||||
var username = p.Get("Username");
|
||||
var password = p.Get("Password");
|
||||
var deviceId = p.Get("DeviceId");
|
||||
var platformId = p.Get("PlatformId");
|
||||
var platform = p.GetInt("Platform");
|
||||
|
||||
PlayerEntity player;
|
||||
|
||||
// Username + password: real credential check against PasswordHash.
|
||||
if (!string.IsNullOrEmpty(password) && !string.IsNullOrEmpty(username))
|
||||
{
|
||||
var byName = await playerService.GetByUsernameAsync(username);
|
||||
if (byName?.PasswordHash is null ||
|
||||
!BCrypt.Net.BCrypt.Verify(password, byName.PasswordHash))
|
||||
return Ok(Fail("Incorrect username or password."));
|
||||
player = byName;
|
||||
await playerService.TagPlatformAsync(player.Id, platform, platformId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Device-id fallback (no password yet). Honour the signups-disabled
|
||||
// toggle: refuse before minting a brand-new account for an unknown
|
||||
// device, and stash it for /join code redemption.
|
||||
if (await settings.AreSignupsDisabledAsync()
|
||||
&& await playerService.GetByDeviceAsync(deviceId) is null)
|
||||
{
|
||||
await signupCodes.RecordPendingDeviceAsync(
|
||||
deviceId, platform, platformId, SignupCodeService.ClientIp(HttpContext));
|
||||
return Ok(Fail("Account creation is currently disabled by the server admin."));
|
||||
}
|
||||
player = await playerService.GetOrCreateByDeviceAsync(
|
||||
deviceId: deviceId, platform: platform, platformId: platformId,
|
||||
displayName: username);
|
||||
}
|
||||
|
||||
return Ok(BuildEnvelope(player));
|
||||
}
|
||||
|
||||
// POST api/platformlogin/v1/logincached — sign in to a remembered account
|
||||
// (the account-selection screen). Body carries PlayerId.
|
||||
[HttpPost("logincached")]
|
||||
public async Task<IActionResult> LoginCached()
|
||||
{
|
||||
var p = await ReadParamsAsync();
|
||||
var id = p.GetLong("PlayerId");
|
||||
if (id == 0) return Ok(Fail("Missing PlayerId."));
|
||||
var player = await playerService.GetByIdAsync(id);
|
||||
if (player is null) return Ok(Fail("That account no longer exists."));
|
||||
await playerService.TagPlatformAsync(player.Id, p.GetInt("Platform"), p.Get("PlatformId"));
|
||||
return Ok(BuildEnvelope(player));
|
||||
}
|
||||
|
||||
// POST api/platformlogin/v1/createaccount — create AND sign in in one call.
|
||||
[HttpPost("createaccount")]
|
||||
public async Task<IActionResult> CreateAccount()
|
||||
{
|
||||
var p = await ReadParamsAsync();
|
||||
var deviceId = p.Get("DeviceId");
|
||||
var platformId = p.Get("PlatformId");
|
||||
var platform = p.GetInt("Platform");
|
||||
|
||||
if (await settings.AreSignupsDisabledAsync()
|
||||
&& await playerService.GetByDeviceAsync(deviceId) is null)
|
||||
{
|
||||
await signupCodes.RecordPendingDeviceAsync(
|
||||
deviceId, platform, platformId, SignupCodeService.ClientIp(HttpContext));
|
||||
return Ok(Fail("Account creation is currently disabled by the server admin."));
|
||||
}
|
||||
|
||||
var player = await playerService.CreateNewAccountAsync(
|
||||
deviceId: deviceId, platform: platform, platformId: platformId,
|
||||
displayName: p.Get("Username"));
|
||||
|
||||
// Attach birthday / email if the signup form supplied them.
|
||||
var birthdayTicks = p.GetLong("Birthday");
|
||||
var email = p.Get("Email");
|
||||
if (birthdayTicks != 0 || !string.IsNullOrEmpty(email))
|
||||
{
|
||||
var entity = await db.Players.FirstOrDefaultAsync(x => x.Id == player.Id);
|
||||
if (entity is not null)
|
||||
{
|
||||
if (birthdayTicks != 0)
|
||||
entity.Birthday = new DateTime(birthdayTicks, DateTimeKind.Utc);
|
||||
if (!string.IsNullOrEmpty(email)) entity.Email = email;
|
||||
await db.SaveChangesAsync();
|
||||
player = entity;
|
||||
}
|
||||
}
|
||||
return Ok(BuildEnvelope(player, firstLogin: true));
|
||||
}
|
||||
|
||||
// POST api/platformlogin/v1/registeraccount?Email=... — attach an email to
|
||||
// the authenticated account. Reads {Success, Message} (NOT "error").
|
||||
[HttpPost("registeraccount")]
|
||||
public async Task<IActionResult> RegisterAccount([FromQuery(Name = "Email")] string? email)
|
||||
{
|
||||
var pid = this.CurrentPlayerId() ?? 0;
|
||||
if (pid == 0) return Ok(new SuccessMessage2018 { Success = false, Message = "Not signed in." });
|
||||
var entity = await db.Players.FirstOrDefaultAsync(x => x.Id == pid);
|
||||
if (entity is null) return Ok(new SuccessMessage2018 { Success = false, Message = "Account not found." });
|
||||
if (!string.IsNullOrWhiteSpace(email)) { entity.Email = email; await db.SaveChangesAsync(); }
|
||||
return Ok(new SuccessMessage2018 { Success = true, Message = string.Empty });
|
||||
}
|
||||
|
||||
// GET api/platformlogin/v1/refreshlogin — mint a fresh access token from the
|
||||
// current bearer. Response is {Token} (PascalCase). The 2018 client calls
|
||||
// Application.Quit() on an empty Token, so a 401 (no body) is the correct
|
||||
// "session ended" signal.
|
||||
[HttpGet("refreshlogin")]
|
||||
public async Task<IActionResult> RefreshLogin()
|
||||
{
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return Unauthorized();
|
||||
var token = auth["Bearer ".Length..].Trim();
|
||||
if (authService.ValidateToken(token) is not long pid) return Unauthorized();
|
||||
var player = await playerService.GetByIdAsync(pid);
|
||||
if (player is null) return Unauthorized();
|
||||
var (access, _) = authService.GenerateTokenPair(player.Id);
|
||||
return Ok(new { Token = access });
|
||||
}
|
||||
|
||||
// POST api/platformlogin/v1/getcachedlogins?Platform=&PlatformId= — the
|
||||
// account-selection list. Returns a BARE JSON ARRAY of Player2018 objects.
|
||||
[HttpPost("getcachedlogins")]
|
||||
[HttpGet("getcachedlogins")]
|
||||
public async Task<IActionResult> GetCachedLogins()
|
||||
{
|
||||
// The 2018 client sends Platform/PlatformId in the FORM BODY
|
||||
// (application/x-www-form-urlencoded), not the query string — so read
|
||||
// from form/query/json. Reading [FromQuery] only returned [] (empty
|
||||
// account grid + broke +profile auto-login). Verified from the wire:
|
||||
// req=Platform=0&PlatformId=76561198969189675.
|
||||
var p = await ReadParamsAsync();
|
||||
var players = await playerService.GetCachedLoginsAsync(p.GetInt("Platform"), p.Get("PlatformId"));
|
||||
return Ok(players.Select(ToPlayer2018).ToList());
|
||||
}
|
||||
|
||||
// POST api/platformlogin/v1/removecachedlogin?Platform=&PlatformId= —
|
||||
// "forget this account". No persistent cache to clear; ack with 200.
|
||||
[HttpPost("removecachedlogin")]
|
||||
public IActionResult RemoveCachedLogin() => Ok(new SuccessMessage2018 { Success = true });
|
||||
|
||||
// POST api/platformlogin/v1/logout?LoginLockToken=... — fire-and-forget.
|
||||
[HttpPost("logout")]
|
||||
public IActionResult Logout() => Ok(new SuccessMessage2018 { Success = true });
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private Login2018Response BuildEnvelope(PlayerEntity player, bool firstLogin = false)
|
||||
{
|
||||
var (access, _) = authService.GenerateTokenPair(player.Id);
|
||||
// First-login-of-the-day is cosmetic (drives a daily XP bonus); derive
|
||||
// it from LastSeenAt where we still have the pre-update value.
|
||||
var first = firstLogin || player.LastSeenAt.Date < DateTime.UtcNow.Date;
|
||||
return new Login2018Response
|
||||
{
|
||||
Error = string.Empty,
|
||||
Player = ToPlayer2018(player),
|
||||
Token = access,
|
||||
FirstLoginOfTheDay = first,
|
||||
AnalyticsSessionId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
};
|
||||
}
|
||||
|
||||
private static Login2018Response Fail(string message) => new() { Error = message };
|
||||
|
||||
private static Player2018 ToPlayer2018(PlayerEntity p) => Player2018Mapper.From(p);
|
||||
|
||||
/// <summary>Read request params from JSON body, form, and query — merged,
|
||||
/// case-insensitive — so JsonUtility/LitJson/form/query callers all bind.</summary>
|
||||
private async Task<ParamBag> ReadParamsAsync()
|
||||
{
|
||||
var bag = new ParamBag();
|
||||
foreach (var kv in Request.Query) bag.Set(kv.Key, kv.Value.ToString());
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
try { foreach (var kv in Request.Form) bag.Set(kv.Key, kv.Value.ToString()); }
|
||||
catch { /* not a form */ }
|
||||
}
|
||||
else if (Request.ContentLength is > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
Request.Body.Position = 0;
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
bag.Set(prop.Name, prop.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => prop.Value.GetString(),
|
||||
JsonValueKind.Number => prop.Value.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
_ => null,
|
||||
});
|
||||
Request.Body.Position = 0;
|
||||
}
|
||||
catch { /* not JSON */ }
|
||||
}
|
||||
return bag;
|
||||
}
|
||||
|
||||
private sealed class ParamBag
|
||||
{
|
||||
private readonly Dictionary<string, string?> _d = new(StringComparer.OrdinalIgnoreCase);
|
||||
public void Set(string k, string? v) { if (!string.IsNullOrEmpty(v)) _d[k] = v; }
|
||||
public string? Get(string k) => _d.TryGetValue(k, out var v) ? v : null;
|
||||
public int GetInt(string k) => int.TryParse(Get(k), out var v) ? v : 0;
|
||||
public long GetLong(string k) => long.TryParse(Get(k), out var v) ? v : 0;
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Auth;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.PlatformLogin.V2;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/platformlogin/* — OAuth-style password-grant login.
|
||||
/// Login.LOGIN_API in dump.cs (line 578249) is "api/platformlogin/" — the
|
||||
/// version suffix (v2 / v5) is appended by callers. The 2020 build uses v5.
|
||||
///
|
||||
/// Request body is application/x-www-form-urlencoded (HTTPUrlEncodedForm in
|
||||
/// Login.LoginHelper, dump.cs:578319), NOT JSON. Form fields are bound
|
||||
/// case-insensitively by ASP.NET Core, so the parameter names don't have to
|
||||
/// match the on-the-wire casing exactly.
|
||||
///
|
||||
/// Response is a Login.LoginResponse: { Error, ErrorDescription,
|
||||
/// AccessToken, RefreshToken } — JSON keys must be PascalCase (the
|
||||
/// Util.GetKey calls inside Login.LoginResponse.Deserialize pass PascalCase
|
||||
/// string literals; verified via global-metadata.dat scan).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/platformlogin")]
|
||||
public class PlatformLoginController(
|
||||
PlayerService playerService,
|
||||
AuthService authService,
|
||||
ServerSettingsService settings,
|
||||
SignupCodeService signupCodes,
|
||||
DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
// Login.LoginToAccount → POST api/platformlogin/v2 / v5
|
||||
// Form fields the 2020 client sends are not yet fully confirmed; common
|
||||
// OAuth-2 password-grant + Rec Room platform fields:
|
||||
// grant_type, username, password, platform, platformId, deviceId,
|
||||
// buildTimestamp, version. Bind permissively and ignore extras.
|
||||
[HttpPost("v2")]
|
||||
[HttpPost("v5")]
|
||||
public async Task<ActionResult<LoginResponse>> Login(
|
||||
[FromForm(Name = "username")] string? Username,
|
||||
[FromForm(Name = "Name")] string? Name,
|
||||
[FromForm(Name = "platform")] int Platform = 0,
|
||||
[FromForm(Name = "platformId")] string? PlatformId = null,
|
||||
[FromForm(Name = "deviceId")] string? DeviceId = null,
|
||||
[FromForm(Name = "buildTimestamp")] long BuildTimestamp = 0,
|
||||
[FromForm(Name = "version")] string? Version = null)
|
||||
{
|
||||
var displayName = Username ?? Name ?? PlatformId ?? "Player";
|
||||
|
||||
// Admin "signups disabled" gate. Mirrors the same guard on
|
||||
// /connect/token's device-id fallback in AuthController — if
|
||||
// the device has no existing account, GetOrCreate would mint a
|
||||
// new one here, bypassing the toggle. Refuse pre-creation.
|
||||
if (await settings.AreSignupsDisabledAsync()
|
||||
&& await playerService.GetByDeviceAsync(DeviceId) is null)
|
||||
{
|
||||
// Stash the refused device so the player can claim it on the
|
||||
// /join page (matched by IP) with a signup code.
|
||||
await signupCodes.RecordPendingDeviceAsync(
|
||||
DeviceId, Platform, PlatformId, SignupCodeService.ClientIp(HttpContext));
|
||||
return Unauthorized(new LoginResponse
|
||||
{
|
||||
Error = "signups_disabled",
|
||||
ErrorDescription = "Account creation is currently disabled by the server admin.",
|
||||
});
|
||||
}
|
||||
|
||||
// Account identity is keyed off deviceId. The service handles the
|
||||
// null/empty case by synthesising a stable id from platformId or a
|
||||
// GUID — either way, no two unrelated callers ever share an account.
|
||||
var player = await playerService.GetOrCreateByDeviceAsync(
|
||||
deviceId: DeviceId,
|
||||
platform: Platform,
|
||||
platformId: PlatformId,
|
||||
displayName: displayName);
|
||||
var (access, refresh) = authService.GenerateTokenPair(player.Id);
|
||||
|
||||
return Ok(new LoginResponse
|
||||
{
|
||||
AccessToken = access,
|
||||
RefreshToken = refresh,
|
||||
});
|
||||
}
|
||||
|
||||
// Login.DownloadCachedLogins → GET api/platformlogin/cached
|
||||
// Returns List<Login.CachedLogin>. Empty array means no remembered accounts.
|
||||
//
|
||||
// The 2020 client doesn't include a platformId in the path here, but
|
||||
// BestHTTP appends current-session credentials as query params on these
|
||||
// bootstrap calls — so we accept platform/platformId via query or form
|
||||
// and reuse the same lookup as auth.rec.net/cachedlogin/forplatformid.
|
||||
[HttpGet("cached")]
|
||||
[HttpGet("cachedlogins")]
|
||||
[HttpGet("savedlogins")]
|
||||
[HttpGet("v1/cached")]
|
||||
[HttpGet("v2/cached")]
|
||||
public async Task<IActionResult> GetCachedLogins(
|
||||
[FromQuery] int platform = 0,
|
||||
[FromQuery] string? platformId = null)
|
||||
{
|
||||
var players = await playerService.GetCachedLoginsAsync(platform, platformId);
|
||||
return Ok(players.Select(p => new CachedLogin
|
||||
{
|
||||
Platform = platform,
|
||||
PlatformId = platformId ?? string.Empty,
|
||||
AccountId = (int)p.Id,
|
||||
LastLoginTime = p.LastSeenAt,
|
||||
RequirePassword = false,
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
// Login.RemoveCachedLogin → DELETE api/platformlogin/cached
|
||||
[HttpDelete("cached")]
|
||||
[HttpDelete("cachedlogins")]
|
||||
public IActionResult RemoveCachedLogin() => Ok(new { });
|
||||
|
||||
// Login.RefreshLogin → POST api/platformlogin/refresh
|
||||
// Response is Login.RefreshLoginResponse { Token } — single field, the
|
||||
// new access token. (Note: dump.cs Login.RefreshLoginResponse has only
|
||||
// "Token", not "AccessToken".)
|
||||
[HttpPost("refresh")]
|
||||
public async Task<IActionResult> Refresh()
|
||||
{
|
||||
// Refresh requires a valid bearer (access OR refresh JWT); both are
|
||||
// signed with the same secret so ValidateToken accepts either.
|
||||
// Refusing to "GetOrCreateAsync(null)" here means a stale install
|
||||
// can't quietly mint a brand-new account when its refresh token is
|
||||
// gone — it has to go through the cached-login flow instead.
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return Unauthorized();
|
||||
var token = auth["Bearer ".Length..].Trim();
|
||||
var resolved = authService.ValidateToken(token);
|
||||
if (resolved is not long playerId)
|
||||
return Unauthorized();
|
||||
var player = await playerService.GetByIdAsync(playerId);
|
||||
if (player is null) return Unauthorized();
|
||||
var (access, _) = authService.GenerateTokenPair(player.Id);
|
||||
return Ok(new RefreshLoginResponse { Token = access });
|
||||
}
|
||||
|
||||
// Login.LoginToCachedAccount(int accountId) → POST api/platformlogin/cached/{accountId}
|
||||
// Returns the same Login.LoginResponse shape as the regular login.
|
||||
[HttpPost("cached/{accountId:long}")]
|
||||
public async Task<IActionResult> LoginToCachedAccount(
|
||||
long accountId,
|
||||
[FromForm] int platform = 0,
|
||||
[FromForm(Name = "platformId")] string? platformId = null,
|
||||
[FromForm(Name = "platform_id")] string? platformIdSnake = null)
|
||||
{
|
||||
// No GetOrCreateAsync(null) fallback — if the cached id doesn't
|
||||
// resolve, the cache is stale (another DB instance, deleted account,
|
||||
// etc.) and we'd rather force the client back to the create-account
|
||||
// flow than silently mint a new player.
|
||||
var player = await playerService.GetByIdAsync(accountId);
|
||||
if (player is null) return Unauthorized();
|
||||
await playerService.TagPlatformAsync(player.Id, platform, platformId ?? platformIdSnake);
|
||||
var (access, refresh) = authService.GenerateTokenPair(player.Id);
|
||||
return Ok(new LoginResponse
|
||||
{
|
||||
AccessToken = access,
|
||||
RefreshToken = refresh,
|
||||
});
|
||||
}
|
||||
|
||||
// Login.CheckHasPassword → GET api/platformlogin/haspassword
|
||||
// Client uses ExpectPrimitiveResponse<bool> → Convert.ChangeType
|
||||
// on the raw response, so we MUST emit a bare boolean literal.
|
||||
// Real password state lives on PlayerEntity.PasswordHash;
|
||||
// AuthAccountController on auth.* does the same lookup against the
|
||||
// authenticated player. We mirror it here so the api.* host also
|
||||
// returns the truthful value instead of an always-true stub.
|
||||
[HttpGet("haspassword")]
|
||||
public async Task<IActionResult> HasPassword()
|
||||
{
|
||||
var pid = this.CurrentPlayerId() ?? 0;
|
||||
if (pid == 0) return Content("false", "application/json");
|
||||
var player = await playerService.GetByIdAsync(pid);
|
||||
var has = player?.PasswordHash is { Length: > 0 };
|
||||
return Content(has ? "true" : "false", "application/json");
|
||||
}
|
||||
|
||||
// Login.CreatePassword / ChangePassword / ResetPassword on auth.*
|
||||
// already do the real bcrypt write — they're wired in
|
||||
// AuthAccountController. The api.* path duplicates the route
|
||||
// without persisting; rather than mirror the full logic here,
|
||||
// delegate to AuthService.ChangePassword for consistency.
|
||||
[HttpPost("password")]
|
||||
[HttpPut("password")]
|
||||
public async Task<IActionResult> SetPassword([FromForm(Name = "newPassword")] string? newPassword,
|
||||
[FromForm(Name = "password")] string? legacy)
|
||||
{
|
||||
var pid = this.CurrentPlayerId() ?? 0;
|
||||
if (pid == 0) return Unauthorized();
|
||||
var pw = newPassword ?? legacy ?? string.Empty;
|
||||
if (pw.Length < 6) return Ok(new RecNetResult { Success = false, Error = "password_too_short" });
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
|
||||
if (player is null) return NotFound();
|
||||
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(pw, workFactor: 11);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
[HttpPost("resetpassword")]
|
||||
public IActionResult ResetPassword()
|
||||
{
|
||||
// Real password reset would email a token; on a private
|
||||
// server with no SMTP this is an explicit no-op (the admin
|
||||
// SPA "change password" tool is the supported path).
|
||||
return Ok(new RecNetResult { Success = false, Error = "reset_not_supported" });
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.PlayerCheer;
|
||||
|
||||
/// <summary>
|
||||
/// api.{rec.net,localhost}/api/PlayerCheer/v1/* — peer cheer flow.
|
||||
/// Cheers between players are stored as <see cref="CheerEntity"/>
|
||||
/// rows keyed by <c>TargetPlayerId</c>. The "selected cheer" badge
|
||||
/// each player can pin to their profile is stored as a
|
||||
/// <see cref="PlayerSettingEntity"/> with key <c>SelectedCheer</c>.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class PlayerCheerController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
LevelService level) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
/// <summary>POST <c>api/PlayerCheer/v1/create</c> — caller cheers
|
||||
/// the target player. <c>Type</c> matches the same enum as
|
||||
/// <c>CheerEntity.Type</c>. Idempotent on (caller, target, type)
|
||||
/// to match <c>PlayerStateController.CheerPlayer</c>, so the same
|
||||
/// category click doesn't inflate the count.</summary>
|
||||
[HttpPost("api/PlayerCheer/v1/create")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> Create(
|
||||
[FromForm(Name = "TargetAccountId")] long? targetAccountId,
|
||||
[FromForm(Name = "PlayerIdTo")] long? playerIdTo,
|
||||
[FromForm(Name = "Type")] int type = 0,
|
||||
[FromForm(Name = "CheerCategory")] int? cheerCategory = null)
|
||||
{
|
||||
// 2018 client posts PlayerIdTo + CheerCategory (RecNet.cs); 2020 posts
|
||||
// TargetAccountId + Type. Accept both.
|
||||
type = cheerCategory ?? type;
|
||||
if ((targetAccountId ?? playerIdTo) is not long target || target <= 0)
|
||||
return Ok(new { success = false, error = "missing_target" });
|
||||
if (Me == target)
|
||||
return Ok(new { success = false, error = "cannot_cheer_self" });
|
||||
|
||||
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
|
||||
c.FromPlayerId == Me && c.TargetPlayerId == target &&
|
||||
c.TargetRoomId == 0 && c.Type == type);
|
||||
if (existing is not null) return Ok(new { success = true, already_cheered = true });
|
||||
|
||||
db.Cheers.Add(new CheerEntity
|
||||
{
|
||||
FromPlayerId = Me,
|
||||
TargetPlayerId = target,
|
||||
Type = type,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
await level.AwardXpAsync(target, LevelService.CheerReceivedXp, $"cheer_from:{Me}");
|
||||
await notifications.NotifyAsync(target,
|
||||
PushNotificationId.SubscriptionUpdateProfile,
|
||||
new { Reason = "CheerReceived", From = Me, Type = type });
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/PlayerCheer/v1/SetSelectedCheer</c> — pin
|
||||
/// a cheer category as the caller's selected badge. Stored on the
|
||||
/// PlayerSetting key <c>SelectedCheer</c> (string-form int).</summary>
|
||||
[HttpPost("api/PlayerCheer/v1/SetSelectedCheer")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> SetSelectedCheer(
|
||||
[FromForm(Name = "Cheer")] int cheer = 0,
|
||||
[FromForm(Name = "CheerCategory")] int? cheerCategory = null)
|
||||
{
|
||||
cheer = cheerCategory ?? cheer; // 2018 sends CheerCategory
|
||||
var existing = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == Me && s.Key == "SelectedCheer");
|
||||
if (existing is null)
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = Me, Key = "SelectedCheer", Value = cheer.ToString() });
|
||||
else
|
||||
existing.Value = cheer.ToString();
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/PlayerCheer/v1/all</c> — every cheer the
|
||||
/// caller has received. Drives the profile's "X cheers" badge.</summary>
|
||||
[HttpGet("api/PlayerCheer/v1/all")]
|
||||
public async Task<IActionResult> All()
|
||||
{
|
||||
var rows = await db.Cheers
|
||||
.Where(c => c.TargetPlayerId == Me)
|
||||
.OrderByDescending(c => c.CheeredAt)
|
||||
.Take(200)
|
||||
.Select(c => new
|
||||
{
|
||||
Id = c.Id,
|
||||
FromPlayerId = c.FromPlayerId,
|
||||
Type = c.Type,
|
||||
CheeredAt = c.CheeredAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.PlayerElo;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/PlayerElo/* — match-result Elo deltas. The 2020
|
||||
/// client posts after each ranked match (Royale, Paintball, Soccer)
|
||||
/// with a delta the server applies to the per-player Elo row.
|
||||
///
|
||||
/// Wire request matches <c>RecNet.Elo.ReportPlayerElo</c>:
|
||||
/// <c>{GameMode(int), EloDelta(int), Won(bool)}</c>
|
||||
/// (we tolerate a few synonym fields). Response: the new Elo as a
|
||||
/// JSON primitive int.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class PlayerEloController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
public sealed class ReportEloRequest
|
||||
{
|
||||
public int GameMode { get; set; }
|
||||
public int EloDelta { get; set; }
|
||||
public bool Won { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/PlayerElo/v1/reportPlayerElo")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> ReportPlayerElo([FromBody] ReportEloRequest req)
|
||||
{
|
||||
var pid = Me;
|
||||
var row = await db.PlayerElo.FirstOrDefaultAsync(p =>
|
||||
p.PlayerId == pid && p.GameMode == req.GameMode);
|
||||
if (row is null)
|
||||
{
|
||||
row = new PlayerEloEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
GameMode = req.GameMode,
|
||||
};
|
||||
db.PlayerElo.Add(row);
|
||||
}
|
||||
// Clamp Elo to a reasonable range to avoid overflow exploits.
|
||||
row.Elo = Math.Clamp(row.Elo + req.EloDelta, 0, 5000);
|
||||
row.MatchesPlayed += 1;
|
||||
if (req.Won) row.Wins += 1; else row.Losses += 1;
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(row.Elo);
|
||||
}
|
||||
|
||||
[HttpGet("api/PlayerElo/v1/get")]
|
||||
public async Task<IActionResult> Get(
|
||||
[FromQuery] long? playerId, [FromQuery] int gameMode = 0)
|
||||
{
|
||||
var pid = playerId ?? this.CurrentPlayerId() ?? 0;
|
||||
if (pid == 0) return Ok(new { Elo = 1000, Wins = 0, Losses = 0 });
|
||||
var row = await db.PlayerElo.FirstOrDefaultAsync(p =>
|
||||
p.PlayerId == pid && p.GameMode == gameMode);
|
||||
return Ok(row is null
|
||||
? new { Elo = 1000, Wins = 0, Losses = 0 }
|
||||
: new { row.Elo, row.Wins, row.Losses });
|
||||
}
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.PlayerEvents;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/playerevents/v1/* — create, list, RSVP for
|
||||
/// player-scheduled gatherings.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class PlayerEventsController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
// ── Browse (v2) ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v2</c> — upcoming events list.
|
||||
/// Returns the next 50 events ordered by start time. Wire shape
|
||||
/// is the standard PlayerEvent (Id, CreatorPlayerId, RoomId,
|
||||
/// Title, Description, StartsAt, EndsAt, Capacity).</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v2")]
|
||||
public async Task<ActionResult> ListUpcoming(
|
||||
[FromQuery] int skip = 0, [FromQuery] int take = 50)
|
||||
{
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => e.EndsAt > DateTime.UtcNow)
|
||||
.OrderBy(e => e.StartsAt)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v2/{eventId:long}")]
|
||||
public async Task<ActionResult> GetOne(long eventId)
|
||||
{
|
||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
|
||||
if (ev is null) return NotFound();
|
||||
return Ok(ToWire(ev));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1/all</c> — the watch's
|
||||
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd
|
||||
/// events). Response keys (both required, via
|
||||
/// <c>Util.GetObjectListKey</c>): <c>Created</c>, <c>Responses</c>.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/all")]
|
||||
public async Task<ActionResult> AllPlayerEvents()
|
||||
{
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is not long me)
|
||||
return Ok(new { Created = Array.Empty<object>(), Responses = Array.Empty<object>() });
|
||||
|
||||
var created = await db.PlayerEvents
|
||||
.Where(e => e.CreatorPlayerId == me)
|
||||
.OrderByDescending(e => e.StartsAt)
|
||||
.Select(e => new
|
||||
{
|
||||
e.Id,
|
||||
e.Title,
|
||||
e.Description,
|
||||
e.RoomId,
|
||||
e.StartsAt,
|
||||
e.EndsAt,
|
||||
e.Capacity,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var responses = await db.PlayerEventResponses
|
||||
.Where(r => r.PlayerId == me)
|
||||
.Select(r => new
|
||||
{
|
||||
r.Id,
|
||||
r.EventId,
|
||||
r.Response,
|
||||
r.CreatedAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { Created = created, Responses = responses });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
|
||||
/// alternate RSVP route the watch uses for the response-list
|
||||
/// flow (older / sub-room view). Body is the same RsvpRequest
|
||||
/// as <c>v1/{id}/rsvp</c>; behaves identically.</summary>
|
||||
[HttpPost("api/playerevents/v1/{eventId:long}/responses")]
|
||||
public Task<ActionResult> RespondList(long eventId, [FromBody] RsvpRequest body)
|
||||
=> Rsvp(eventId, body);
|
||||
|
||||
/// <summary>Wire shape verified against
|
||||
/// <c>Cpp2IL_ISIL/.../RecNet/PlayerEvent.txt:769-815</c>:
|
||||
/// PascalCase keys <c>PlayerEventId, Name, Description,
|
||||
/// StartTime, EndTime, CreatorPlayerId, AttendeeCount, RoomId,
|
||||
/// ImageName, Accessibility</c>. Server-side we store these
|
||||
/// under different names (Title, StartsAt, EndsAt, Capacity) —
|
||||
/// remap explicitly here so the watch's
|
||||
/// <c>PlayerEvent.Deserialize</c> picks them up.</summary>
|
||||
private static object ToWire(PlayerEventEntity ev) => new
|
||||
{
|
||||
PlayerEventId = ev.Id,
|
||||
Name = ev.Title,
|
||||
ev.Description,
|
||||
StartTime = ev.StartsAt,
|
||||
EndTime = ev.EndsAt,
|
||||
CreatorPlayerId = (int)ev.CreatorPlayerId,
|
||||
AttendeeCount = 0, // computed at query time when needed
|
||||
ev.RoomId,
|
||||
ImageName = string.Empty,
|
||||
Accessibility = 1, // Public
|
||||
};
|
||||
|
||||
public sealed record CreateEventRequest(
|
||||
string Title, string? Description, long RoomId,
|
||||
DateTime StartsAt, DateTime EndsAt, int Capacity);
|
||||
|
||||
[HttpPost("api/playerevents/v1/create")]
|
||||
[HttpPost("api/playerevents/v1")]
|
||||
public async Task<ActionResult> Create([FromBody] CreateEventRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Title))
|
||||
return BadRequest("missing_title");
|
||||
var ev = new PlayerEventEntity
|
||||
{
|
||||
CreatorPlayerId = Me,
|
||||
RoomId = req.RoomId,
|
||||
Title = req.Title.Trim(),
|
||||
Description = (req.Description ?? string.Empty).Trim(),
|
||||
StartsAt = req.StartsAt,
|
||||
EndsAt = req.EndsAt > req.StartsAt ? req.EndsAt : req.StartsAt.AddHours(1),
|
||||
Capacity = Math.Max(0, req.Capacity),
|
||||
};
|
||||
db.PlayerEvents.Add(ev);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
ev.Id, ev.Title, ev.Description, ev.RoomId,
|
||||
ev.StartsAt, ev.EndsAt, ev.Capacity,
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record RsvpRequest(int Response);
|
||||
|
||||
[HttpPost("api/playerevents/v1/{eventId:long}/rsvp")]
|
||||
public async Task<ActionResult> Rsvp(long eventId, [FromBody] RsvpRequest body)
|
||||
{
|
||||
if (body.Response < 0 || body.Response > 2)
|
||||
return BadRequest("invalid_response");
|
||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
|
||||
if (ev is null) return NotFound();
|
||||
|
||||
var row = await db.PlayerEventResponses
|
||||
.FirstOrDefaultAsync(r => r.EventId == eventId && r.PlayerId == Me);
|
||||
if (row is null)
|
||||
{
|
||||
row = new PlayerEventResponseEntity { EventId = eventId, PlayerId = Me };
|
||||
db.PlayerEventResponses.Add(row);
|
||||
}
|
||||
row.Response = body.Response;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// 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 });
|
||||
return Ok(new { row.EventId, row.PlayerId, row.Response });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/respond</c> — set the
|
||||
/// caller's RSVP state for an event. <c>Response</c> is the
|
||||
/// <c>PlayerEventResponseType</c> enum (0=Going, 1=Maybe, 2=Pass).
|
||||
/// Idempotent — re-responding overwrites.</summary>
|
||||
[HttpPost("api/playerevents/v1/respond")]
|
||||
public async Task<IActionResult> RespondForm(
|
||||
[FromForm(Name = "EventId")] long? eventId,
|
||||
[FromForm(Name = "Response")] int response = 0)
|
||||
{
|
||||
if (eventId is not long evt || evt <= 0) return Ok(new { success = false });
|
||||
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 });
|
||||
else
|
||||
existing.Response = response;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop
|
||||
/// the caller's RSVP row.</summary>
|
||||
[HttpPost("api/playerevents/v1/deleteResponse")]
|
||||
public async Task<IActionResult> DeleteResponseForm([FromForm(Name = "EventId")] long? eventId)
|
||||
{
|
||||
if (eventId is not long evt) return Ok(new { success = true });
|
||||
await db.PlayerEventResponses
|
||||
.Where(r => r.PlayerId == Me && r.EventId == evt)
|
||||
.ExecuteDeleteAsync();
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
/// <summary>DELETE <c>api/playerevents/v2/delete/{id}</c> — hard
|
||||
/// delete an event the caller created.</summary>
|
||||
[HttpDelete("api/playerevents/v2/delete/{id:long}")]
|
||||
public async Task<IActionResult> DeleteByPath(long id)
|
||||
{
|
||||
var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == id && e.CreatorPlayerId == Me);
|
||||
if (evt is null) return NotFound();
|
||||
db.PlayerEvents.Remove(evt);
|
||||
await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync();
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
[HttpDelete("api/playerevents/v1/{eventId:long}")]
|
||||
public async Task<ActionResult> Delete(long eventId)
|
||||
{
|
||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
|
||||
if (ev is null) return NotFound();
|
||||
if (ev.CreatorPlayerId != Me) return Forbid();
|
||||
db.PlayerEvents.Remove(ev);
|
||||
// Cascade-clean the responses; SQLite has no FK cascade
|
||||
// configured here so do it manually.
|
||||
var responses = db.PlayerEventResponses.Where(r => r.EventId == eventId);
|
||||
db.PlayerEventResponses.RemoveRange(responses);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// ── Bulk-invite (Phase 8) ────────────────────────────────────────────
|
||||
|
||||
public sealed class BulkInviteRequest
|
||||
{
|
||||
public long PlayerEventId { get; set; }
|
||||
public List<int>? InvitedPlayerIds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/api/playerevents/v1/bulkInvite</c> — wire
|
||||
/// type per agent ISIL extraction: <c>BulkInviteRequest{PlayerEventId,
|
||||
/// InvitedPlayerIds:List<int>}</c>; response
|
||||
/// <c>BulkInviteResponse{FailedInvites,Result(CreateModifyPlayerEventStatus)}</c>.
|
||||
/// We insert one MessageEntity per recipient so the watch's inbox
|
||||
/// shows the invite, and push a per-recipient notification.</summary>
|
||||
[HttpPost("api/playerevents/v1/bulkInvite")]
|
||||
public async Task<IActionResult> BulkInvite([FromBody] BulkInviteRequest req)
|
||||
{
|
||||
if (req?.InvitedPlayerIds is null || req.InvitedPlayerIds.Count == 0)
|
||||
return Ok(new { FailedInvites = Array.Empty<object>(), Result = 0 });
|
||||
|
||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
|
||||
if (ev is null) return NotFound();
|
||||
if (ev.CreatorPlayerId != Me) return Forbid();
|
||||
|
||||
var failed = new List<object>();
|
||||
var sender = Me;
|
||||
var inviteText = $"You're invited to '{ev.Title}' starting {ev.StartsAt:u}.";
|
||||
foreach (var rid in req.InvitedPlayerIds.Distinct())
|
||||
{
|
||||
var exists = await db.Players.AnyAsync(p => p.Id == rid);
|
||||
if (!exists)
|
||||
{
|
||||
failed.Add(new { PlayerId = rid, Error = "unknown player" });
|
||||
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 });
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { FailedInvites = failed, Result = 0 });
|
||||
}
|
||||
|
||||
// ── Report (Phase 8) ─────────────────────────────────────────────────
|
||||
|
||||
public sealed class PlayerEventReportRequest
|
||||
{
|
||||
public int ReportCategory { get; set; }
|
||||
public long PlayerEventId { get; set; }
|
||||
public string? Details { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v1/report")]
|
||||
public async Task<IActionResult> Report([FromBody] PlayerEventReportRequest req)
|
||||
{
|
||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
|
||||
if (ev is null) return NotFound();
|
||||
var msg = (req.Details ?? string.Empty).Trim();
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
ReporterPlayerId = Me,
|
||||
TargetPlayerId = ev.CreatorPlayerId,
|
||||
TargetEventId = req.PlayerEventId,
|
||||
Category = req.ReportCategory,
|
||||
Message = msg[..Math.Min(1000, msg.Length)],
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Reported = true });
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.PlayerReporting;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/PlayerReporting/v1/* — real player report flow.
|
||||
/// The 2020 client's "report player" UI in the watch posts here.
|
||||
/// Form fields the watch sends (per the in-game send_report dialog):
|
||||
/// <c>TargetPlayerId</c> — int account id of the offender
|
||||
/// <c>Type</c> — RoomieReportCategory enum (1..5)
|
||||
/// <c>Message</c> — free-form description (capped at 1000)
|
||||
/// <c>GameSessionId</c> — optional, current session for context
|
||||
/// <c>RoomId</c> — optional, room the offence happened in
|
||||
///
|
||||
/// Anyone authenticated can file a report. Admins read the queue
|
||||
/// from <c>GET api/admin/v1/reports</c>.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class ReportsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpPost("api/PlayerReporting/v1/report")]
|
||||
[HttpPost("api/PlayerReporting/v1/playerreport")]
|
||||
[HttpPost("api/PlayerReporting/v2/report")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> SubmitReport(
|
||||
[FromForm(Name = "TargetPlayerId")] long? targetPlayerId,
|
||||
[FromForm(Name = "Type")] int type = 0,
|
||||
[FromForm(Name = "Message")] string? message = null,
|
||||
[FromForm(Name = "GameSessionId")] long gameSessionId = 0,
|
||||
[FromForm(Name = "RoomId")] long roomId = 0)
|
||||
{
|
||||
if (targetPlayerId is not long target || target <= 0)
|
||||
return BadRequest(new { success = false, error = "missing_target" });
|
||||
|
||||
var reporter = this.RequireCurrentPlayerId();
|
||||
if (reporter == target)
|
||||
return BadRequest(new { success = false, error = "cannot_report_self" });
|
||||
|
||||
// Cap message length to match the watch UI's enforcement +
|
||||
// the column max — anything longer is almost certainly junk.
|
||||
var trimmed = (message ?? string.Empty).Trim();
|
||||
if (trimmed.Length > 1000) trimmed = trimmed[..1000];
|
||||
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
ReporterPlayerId = reporter,
|
||||
TargetPlayerId = target,
|
||||
Category = type,
|
||||
Message = trimmed,
|
||||
GameSessionId = gameSessionId,
|
||||
RoomId = roomId,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/PlayerReporting/v2/detail?playerId={id}</c>
|
||||
/// — moderator detail panel. Lists recent reports against the
|
||||
/// target player. Public — admin-only mutations are elsewhere.</summary>
|
||||
[HttpGet("api/PlayerReporting/v2/detail")]
|
||||
public async Task<IActionResult> ReportDetail(
|
||||
[FromQuery] long? playerId, [FromQuery] long? accountId)
|
||||
{
|
||||
var pid = playerId ?? accountId ?? 0;
|
||||
if (pid <= 0) return Ok(new { PlayerId = 0L, OpenReports = 0, RecentReports = Array.Empty<object>() });
|
||||
var reports = await db.Reports
|
||||
.Where(r => r.TargetPlayerId == pid)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Take(20)
|
||||
.ToListAsync();
|
||||
return Ok(new
|
||||
{
|
||||
PlayerId = pid,
|
||||
OpenReports = reports.Count(r => r.ResolvedAt == null),
|
||||
RecentReports = reports.Select(r => new
|
||||
{
|
||||
r.Id, r.Category, r.Message,
|
||||
r.ReporterPlayerId, r.RoomId, r.CreatedAt, r.ResolvedAt,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
public sealed class V2BanRequest
|
||||
{
|
||||
public long PlayerId { get; set; }
|
||||
public int? Category { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
public DateTime? Until { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/PlayerReporting/v2/ban</c> — admin-only.
|
||||
/// Sets <see cref="PlayerEntity.BannedUntil"/> + appends a
|
||||
/// resolved <see cref="ReportEntity"/> for the audit trail.</summary>
|
||||
[HttpPost("api/PlayerReporting/v2/ban")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> V2Ban([FromBody] V2BanRequest req)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
if (req.PlayerId <= 0) return BadRequest(new { error = "missing playerId" });
|
||||
|
||||
var isAdmin = await db.Players
|
||||
.Where(p => p.Id == me)
|
||||
.Select(p => p.IsAdmin)
|
||||
.FirstOrDefaultAsync();
|
||||
if (!isAdmin) return Forbid();
|
||||
|
||||
var target = await db.Players.FirstOrDefaultAsync(p => p.Id == req.PlayerId);
|
||||
if (target is null) return NotFound();
|
||||
target.BannedUntil = req.Until ?? DateTime.UtcNow.AddYears(100);
|
||||
var reason = req.Reason ?? string.Empty;
|
||||
if (reason.Length > 1000) reason = reason[..1000];
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
ReporterPlayerId = me,
|
||||
TargetPlayerId = req.PlayerId,
|
||||
Category = req.Category ?? 5,
|
||||
Message = reason,
|
||||
ResolvedAt = DateTime.UtcNow,
|
||||
ResolverAdminId = me,
|
||||
ResolutionNote = "v2/ban",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Banned = true, target.Id, target.BannedUntil });
|
||||
}
|
||||
|
||||
public sealed class HileRequest
|
||||
{
|
||||
public string? Detail { get; set; }
|
||||
public string? Body { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public int? Type { get; set; }
|
||||
public long? GameSessionId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/PlayerReporting/v1/hile</c> — anti-cheat
|
||||
/// heartbeat. Stored as a <see cref="BugReportEntity"/> tagged
|
||||
/// <c>hile</c> so admins can audit the stream.</summary>
|
||||
[HttpPost("api/PlayerReporting/v1/hile")]
|
||||
public async Task<IActionResult> Hile()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
return await SaveHileAsync(
|
||||
form["Message"].FirstOrDefault() ??
|
||||
form["Detail"].FirstOrDefault() ??
|
||||
form["Body"].FirstOrDefault(),
|
||||
int.TryParse(form["Type"].FirstOrDefault(), out var t) ? t : (int?)null,
|
||||
long.TryParse(form["GameSessionId"].FirstOrDefault(), out var g) ? g : (long?)null);
|
||||
}
|
||||
|
||||
HileRequest? body = null;
|
||||
try
|
||||
{
|
||||
body = await JsonSerializer.DeserializeAsync<HileRequest>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch (JsonException) { }
|
||||
|
||||
return await SaveHileAsync(
|
||||
body?.Message ?? body?.Detail ?? body?.Body,
|
||||
body?.Type,
|
||||
body?.GameSessionId);
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/PlayerReporting/v1/log</c> — bare client
|
||||
/// telemetry log. Persisted as a low-priority BugReport for admin
|
||||
/// visibility but never acted on automatically.</summary>
|
||||
[HttpPost("api/PlayerReporting/v1/log")]
|
||||
public async Task<IActionResult> ReportLog()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
await SaveHileAsync(
|
||||
form["Message"].FirstOrDefault() ?? form["Body"].FirstOrDefault(),
|
||||
null, null);
|
||||
}
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
private async Task<IActionResult> SaveHileAsync(string? rawDetail, int? type, long? gameSessionId)
|
||||
{
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is not long me) return Ok(new { success = true });
|
||||
var detail = (rawDetail ?? string.Empty).Trim();
|
||||
if (type is int hileType)
|
||||
detail = detail.Length == 0 ? $"Type={hileType}" : $"Type={hileType}; {detail}";
|
||||
if (detail.Length > 4000) detail = detail[..4000];
|
||||
db.BugReports.Add(new BugReportEntity
|
||||
{
|
||||
ReporterPlayerId = me,
|
||||
Title = "hile heartbeat",
|
||||
Body = detail,
|
||||
GameSessionId = gameSessionId ?? 0,
|
||||
Category = "hile",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Models.Players;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.PlayerState;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net endpoints that return player-state objects whose Deserialize
|
||||
/// methods do strict required-key reads via Util.GetKey<T>. The previous
|
||||
/// catch-all returned {} or {"Success":true} for these, which crashed the
|
||||
/// client with KeyNotFoundException at:
|
||||
/// RecNet.Progression.Deserialize → "PlayerId", "Level", "XP"
|
||||
/// RecNet.Reputation.Deserialize → "AccountId", "Noteriety" (sic), Cheer*, Sub*
|
||||
/// RecNet.ModerationBlockDetail.Deserialize → "ReportCategory", "Duration", "GameSessionId", "Message"
|
||||
///
|
||||
/// Each endpoint here returns the minimum-viable shape (zeros / empty
|
||||
/// strings) that satisfies every required key. Real values can be plugged
|
||||
/// in later when persistence is added for these.
|
||||
///
|
||||
/// Specific routes here win over the api/PlayerReporting/{*path} and
|
||||
/// api.rec.net catch-alls.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class PlayerStateController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
LevelService level) : ControllerBase
|
||||
{
|
||||
// RecNet.Progressions.GetProgressionById / GetMyProgression — real
|
||||
// values from PlayerEntity.Level / .XP. Default 1/0 when the
|
||||
// account hasn't been seen yet (synthetic ids passed in queries).
|
||||
[HttpGet("api/players/v1/progression/{accountId:long}")]
|
||||
public async Task<ActionResult<Progression>> GetProgression(long accountId)
|
||||
{
|
||||
var p = await db.Players
|
||||
.Where(p => p.Id == accountId)
|
||||
.Select(p => new { p.Id, p.Level, p.XP })
|
||||
.FirstOrDefaultAsync();
|
||||
return Ok(new Progression
|
||||
{
|
||||
PlayerId = (int)(p?.Id ?? accountId),
|
||||
Level = p?.Level ?? 1,
|
||||
XP = p?.XP ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("api/players/v1/progression/bulk")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<ActionResult<List<Progression>>> GetProgressionBulk(
|
||||
[FromForm(Name = "Ids")] string? ids)
|
||||
{
|
||||
var idList = ParseIds(ids).Select(i => (long)i).ToList();
|
||||
if (idList.Count == 0) return Ok(new List<Progression>());
|
||||
var rows = await db.Players
|
||||
.Where(p => idList.Contains(p.Id))
|
||||
.Select(p => new Progression
|
||||
{
|
||||
PlayerId = (int)p.Id,
|
||||
Level = p.Level,
|
||||
XP = p.XP,
|
||||
})
|
||||
.ToListAsync();
|
||||
// Synthesise zeros for any ids the watch asked about that
|
||||
// don't exist in our DB — keeps array length stable.
|
||||
var found = rows.Select(r => r.PlayerId).ToHashSet();
|
||||
rows.AddRange(idList
|
||||
.Where(id => !found.Contains((int)id))
|
||||
.Select(id => new Progression { PlayerId = (int)id }));
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
// RecNet.Reputations.GetReputationById — aggregates CheerEntity
|
||||
// by category. Each Cheer* count is the sum of distinct cheers
|
||||
// received from other players in that category.
|
||||
[HttpGet("api/playerReputation/v1/{accountId:long}")]
|
||||
public async Task<ActionResult<Reputation>> GetReputation(long accountId) =>
|
||||
Ok(await BuildReputationAsync(accountId));
|
||||
|
||||
[HttpPost("api/playerReputation/v1/bulk")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<ActionResult<List<Reputation>>> GetReputationBulk(
|
||||
[FromForm(Name = "Ids")] string? ids)
|
||||
{
|
||||
var idList = ParseIds(ids).Select(i => (long)i).ToList();
|
||||
var result = new List<Reputation>(idList.Count);
|
||||
foreach (var id in idList)
|
||||
result.Add(await BuildReputationAsync(id));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>POST api/playerReputation/v1/cheer/{playerId} — cheer
|
||||
/// another player. Idempotent on (caller, target, type) so
|
||||
/// repeat-clicking the same category doesn't inflate the count.
|
||||
/// Pushes a presence-update notification so the target's watch
|
||||
/// refreshes their cheer total.</summary>
|
||||
[HttpPost("api/playerReputation/v1/cheer/{playerId:long}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> CheerPlayer(long playerId, [FromQuery] int type = 0)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
if (me == playerId) return BadRequest("cannot_cheer_self");
|
||||
|
||||
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
|
||||
c.FromPlayerId == me && c.TargetPlayerId == playerId &&
|
||||
c.TargetRoomId == 0 && c.Type == type);
|
||||
if (existing is not null) return Ok(new { already_cheered = true });
|
||||
|
||||
db.Cheers.Add(new CheerEntity
|
||||
{
|
||||
FromPlayerId = me,
|
||||
TargetPlayerId = playerId,
|
||||
Type = type,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Reward the cheered player with a small XP bump.
|
||||
await level.AwardXpAsync(playerId, LevelService.CheerReceivedXp, $"cheer_from:{me}");
|
||||
|
||||
await notifications.NotifyAsync(playerId,
|
||||
PushNotificationId.SubscriptionUpdateProfile,
|
||||
new { Reason = "CheerReceived", From = me, Type = type });
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST api/rooms/v1/cheer/{roomId} — cheer a room.
|
||||
/// Same idempotency rules as player cheers; bumps
|
||||
/// <see cref="RoomEntity.CheerCount"/> as a denormalised counter
|
||||
/// so the watch's "Hot" rooms feed doesn't have to JOIN-aggregate
|
||||
/// on every request.</summary>
|
||||
[HttpPost("api/rooms/v1/cheer/{roomId:long}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> CheerRoom(long roomId, [FromQuery] int type = 0)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
|
||||
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
|
||||
c.FromPlayerId == me && c.TargetRoomId == roomId &&
|
||||
c.TargetPlayerId == 0 && c.Type == type);
|
||||
if (existing is not null) return Ok(new { already_cheered = true });
|
||||
|
||||
db.Cheers.Add(new CheerEntity
|
||||
{
|
||||
FromPlayerId = me,
|
||||
TargetRoomId = roomId,
|
||||
Type = type,
|
||||
});
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is not null)
|
||||
{
|
||||
room.CheerCount += 1;
|
||||
// Cheers feed the Hot-tab ranking — weight 5x a first
|
||||
// visit so the "Trending" feed reflects what players
|
||||
// actually like, not just what they passed through.
|
||||
room.HotScore += 10.0;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private async Task<Reputation> BuildReputationAsync(long accountId)
|
||||
{
|
||||
var rows = await db.Cheers
|
||||
.Where(c => c.TargetPlayerId == accountId)
|
||||
.GroupBy(c => c.Type)
|
||||
.Select(g => new { Type = g.Key, Count = g.Count() })
|
||||
.ToListAsync();
|
||||
int Count(int type) => rows.FirstOrDefault(r => r.Type == type)?.Count ?? 0;
|
||||
|
||||
var subscriberCount = await db.Subscriptions
|
||||
.CountAsync(s => s.TargetPlayerId == accountId);
|
||||
var subscribedCount = await db.Subscriptions
|
||||
.CountAsync(s => s.SubscriberPlayerId == accountId);
|
||||
|
||||
// SelectedCheer is the pinned badge category the player chose
|
||||
// via PlayerCheerController.SetSelectedCheer. Stored as a
|
||||
// PlayerSettingEntity with key "SelectedCheer" and Value = the
|
||||
// category int as string. Null when unset — UI just shows no
|
||||
// pinned badge.
|
||||
var selected = await db.PlayerSettings
|
||||
.Where(s => s.PlayerId == accountId && s.Key == "SelectedCheer")
|
||||
.Select(s => s.Value)
|
||||
.FirstOrDefaultAsync();
|
||||
int? selectedCheer = int.TryParse(selected, out var sc) ? sc : null;
|
||||
|
||||
return new Reputation
|
||||
{
|
||||
AccountId = (int)accountId,
|
||||
Notoriety = rows.Sum(r => r.Count),
|
||||
CheerGeneral = Count(0),
|
||||
CheerHelpful = Count(1),
|
||||
CheerGreatHost = Count(2),
|
||||
CheerSportsman = Count(3),
|
||||
CheerCreative = Count(4),
|
||||
CheerCredit = Count(5),
|
||||
SelectedCheer = selectedCheer,
|
||||
SubscriberCount = subscriberCount,
|
||||
SubscribedCount = subscribedCount,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "1,2,3" → [1, 2, 3]. The client formats Ids as a comma-separated
|
||||
/// string when calling AddField on the form. Tolerant of empty input
|
||||
/// and whitespace.
|
||||
/// </summary>
|
||||
private static IEnumerable<int> ParseIds(string? ids) =>
|
||||
string.IsNullOrWhiteSpace(ids)
|
||||
? Enumerable.Empty<int>()
|
||||
: ids.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(s => int.TryParse(s, out var v) ? v : 0)
|
||||
.Where(v => v != 0);
|
||||
|
||||
// RecNet.PlayerReporting.GetModerationBlockDetails
|
||||
// IMPORTANT: returns a single object, not an array. Defaults indicate
|
||||
// "user is not currently blocked / kicked".
|
||||
// ASP.NET Core route matching is case-insensitive, so a single attribute
|
||||
// covers both "PlayerReporting" and "playerreporting" callers.
|
||||
[HttpGet("api/PlayerReporting/v1/moderationBlockDetails")]
|
||||
public ActionResult<ModerationBlockDetail> GetModerationBlockDetails() =>
|
||||
Ok(new ModerationBlockDetail());
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.PlayerSubscriptions;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/playersubscriptions/v1/* — asymmetric follow
|
||||
/// graph. The <c>my</c> endpoint returns who the caller follows;
|
||||
/// subscribe/unsubscribe mutate the row directly. Reputation
|
||||
/// aggregates pull SubscriberCount / SubscribedCount counts from
|
||||
/// here.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v1")]
|
||||
[Authorize]
|
||||
public class PlayerSubscriptionsController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpGet("my")]
|
||||
public async Task<ActionResult> My()
|
||||
{
|
||||
var subs = await db.Subscriptions
|
||||
.Where(s => s.SubscriberPlayerId == Me)
|
||||
.Select(s => new { s.TargetPlayerId, s.CreatedAt })
|
||||
.ToListAsync();
|
||||
return Ok(subs);
|
||||
}
|
||||
|
||||
[HttpGet("{playerId:long}/subscribers")]
|
||||
public async Task<ActionResult> Subscribers(long playerId)
|
||||
{
|
||||
var subs = await db.Subscriptions
|
||||
.Where(s => s.TargetPlayerId == playerId)
|
||||
.Select(s => new { s.SubscriberPlayerId, s.CreatedAt })
|
||||
.ToListAsync();
|
||||
return Ok(subs);
|
||||
}
|
||||
|
||||
[HttpPost("subscribe/{targetId:long}")]
|
||||
public async Task<ActionResult> Subscribe(long targetId)
|
||||
{
|
||||
if (targetId == Me) return BadRequest("cannot_subscribe_to_self");
|
||||
|
||||
var existing = await db.Subscriptions.FirstOrDefaultAsync(s =>
|
||||
s.SubscriberPlayerId == Me && s.TargetPlayerId == targetId);
|
||||
if (existing is not null) return Ok(new { already_subscribed = true });
|
||||
|
||||
db.Subscriptions.Add(new SubscriptionEntity
|
||||
{
|
||||
SubscriberPlayerId = Me,
|
||||
TargetPlayerId = targetId,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Tell the target their subscriber count changed so the watch
|
||||
// refreshes their profile page if open.
|
||||
await notifications.NotifyAsync(targetId,
|
||||
PushNotificationId.SubscriptionUpdateProfile,
|
||||
new { Reason = "NewSubscriber", From = Me });
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpDelete("subscribe/{targetId:long}")]
|
||||
[HttpPost("unsubscribe/{targetId:long}")]
|
||||
public async Task<ActionResult> Unsubscribe(long targetId)
|
||||
{
|
||||
var row = await db.Subscriptions.FirstOrDefaultAsync(s =>
|
||||
s.SubscriberPlayerId == Me && s.TargetPlayerId == targetId);
|
||||
if (row is null) return NotFound();
|
||||
db.Subscriptions.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Models.Auth;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Compat2018;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Players.V2;
|
||||
|
||||
/// <summary>
|
||||
/// api/players/* for the June-2018 client. This branch is 2018-only, so every
|
||||
/// endpoint returns the 2018 <see cref="Player2018"/> shape (keys Id/Developer/
|
||||
/// JuniorProfile/… — NOT the 2020 PlayerProfile's AccountId/IsDeveloper).
|
||||
///
|
||||
/// The 2018 client mixes path versions: profile fetch + batch are v1, while
|
||||
/// search + displayname are v2. We map both. Param names follow the 2018 client
|
||||
/// (search ?name=, displayname form Name).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class PlayersController(PlayerService playerService, NotificationService notificationService) : ControllerBase
|
||||
{
|
||||
private long CurrentPlayerId => this.RequireCurrentPlayerId();
|
||||
|
||||
// GET api/players/v1|v2/me
|
||||
[HttpGet("/api/players/v1/me")]
|
||||
[HttpGet("/api/players/v2/me")]
|
||||
public async Task<ActionResult<Player2018>> GetSelf()
|
||||
{
|
||||
var player = await playerService.GetByIdAsync(CurrentPlayerId);
|
||||
if (player is null) return NotFound();
|
||||
return Ok(Player2018Mapper.From(player));
|
||||
}
|
||||
|
||||
// GET api/players/v1|v2/{id} — primary profile fetch (2018 uses v1).
|
||||
[HttpGet("/api/players/v1/{id:long}")]
|
||||
[HttpGet("/api/players/v2/{id:long}")]
|
||||
public async Task<ActionResult<Player2018>> GetById(long id)
|
||||
{
|
||||
var player = await playerService.GetByIdAsync(id);
|
||||
if (player is null) return NotFound();
|
||||
return Ok(Player2018Mapper.From(player));
|
||||
}
|
||||
|
||||
// POST api/players/v1/list — batch profile resolution (JSON array of ids).
|
||||
[HttpPost("/api/players/v1/list")]
|
||||
[HttpPost("/api/players/v2/list")]
|
||||
public async Task<ActionResult<List<Player2018>>> GetMany([FromBody] long[]? ids)
|
||||
{
|
||||
if (ids is null || ids.Length == 0) return Ok(new List<Player2018>());
|
||||
var players = await playerService.GetByIdsAsync(ids);
|
||||
return Ok(players.Select(Player2018Mapper.From).ToList());
|
||||
}
|
||||
|
||||
// GET api/players/v2/search?name= (2018) — also accept ?query= for safety.
|
||||
[HttpGet("/api/players/v1/search")]
|
||||
[HttpGet("/api/players/v2/search")]
|
||||
public async Task<ActionResult<List<Player2018>>> Search(
|
||||
[FromQuery] string? name, [FromQuery] string? query)
|
||||
{
|
||||
var term = !string.IsNullOrWhiteSpace(name) ? name : query;
|
||||
if (string.IsNullOrWhiteSpace(term)) return Ok(new List<Player2018>());
|
||||
var results = await playerService.SearchAsync(term);
|
||||
return Ok(results.Select(Player2018Mapper.From).ToList());
|
||||
}
|
||||
|
||||
// POST api/players/v2/displayname (2018 form key "Name"). Also accept
|
||||
// "DisplayName" for safety.
|
||||
[HttpPost("/api/players/v1/displayname")]
|
||||
[HttpPost("/api/players/v2/displayname")]
|
||||
public async Task<ActionResult<Player2018>> UpdateDisplayName(
|
||||
[FromForm] string? Name, [FromForm] string? DisplayName)
|
||||
{
|
||||
var newName = !string.IsNullOrWhiteSpace(Name) ? Name : DisplayName;
|
||||
if (string.IsNullOrWhiteSpace(newName)) return BadRequest("Name is required");
|
||||
if (!await playerService.UpdateDisplayNameAsync(CurrentPlayerId, newName)) return NotFound();
|
||||
var player = await playerService.GetByIdAsync(CurrentPlayerId);
|
||||
await notificationService.NotifyAsync(CurrentPlayerId,
|
||||
Models.Notification.PushNotificationId.SubscriptionUpdateProfile, Player2018Mapper.From(player!));
|
||||
return Ok(Player2018Mapper.From(player!));
|
||||
}
|
||||
|
||||
// POST api/players/v1/bio (2018 form key "Bio").
|
||||
[HttpPost("/api/players/v1/bio")]
|
||||
[HttpPost("/api/players/v2/bio")]
|
||||
public async Task<ActionResult<Player2018>> UpdateBio([FromForm] string? Bio)
|
||||
{
|
||||
await playerService.UpdateBioAsync(CurrentPlayerId, Bio ?? string.Empty);
|
||||
var player = await playerService.GetByIdAsync(CurrentPlayerId);
|
||||
return Ok(Player2018Mapper.From(player!));
|
||||
}
|
||||
|
||||
// POST api/players/v1/avoidJuniors (form "AvoidJuniors"). Best-effort:
|
||||
// acknowledged so the toggle in the watch settings doesn't 404. (Server
|
||||
// doesn't currently filter matchmaking on this flag.)
|
||||
[HttpPost("/api/players/v1/avoidJuniors")]
|
||||
[HttpPut("/api/players/v1/avoidJuniors")]
|
||||
public async Task<ActionResult<Player2018>> AvoidJuniors()
|
||||
{
|
||||
var player = await playerService.GetByIdAsync(CurrentPlayerId);
|
||||
if (player is null) return NotFound();
|
||||
return Ok(Player2018Mapper.From(player));
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.PlayersBanned;
|
||||
|
||||
/// <summary>
|
||||
/// api.{rec.net,localhost}/api/PlayersBanned/v1/all — current global
|
||||
/// ban list. The watch's mod panel queries this on open.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class PlayersBannedController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/PlayersBanned/v1/all")]
|
||||
public async Task<IActionResult> All()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var rows = await db.Players
|
||||
.Where(p => p.BannedUntil != null && p.BannedUntil > now)
|
||||
.Select(p => new
|
||||
{
|
||||
PlayerId = (int)p.Id,
|
||||
p.Username,
|
||||
BannedUntil = p.BannedUntil,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Models.Players;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Presence;
|
||||
|
||||
/// <summary>
|
||||
/// api/presence/* — the June-2018 client's friend-presence poll (every ~30s,
|
||||
/// RecNet.cs:63249). POST api/presence/v2/list takes a JSON array of account
|
||||
/// ids and returns one <see cref="Presence2018"/> per id. There is no 2020
|
||||
/// equivalent on this host (the 2020 client used match.rec.net/player), so this
|
||||
/// controller is 2018-specific. Online state comes from
|
||||
/// <see cref="OnlinePresenceService"/>; GameSession is left null for now (the
|
||||
/// client renders "online" without it and fills in room info on join).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class PresenceController(OnlinePresenceService presence) : ControllerBase
|
||||
{
|
||||
[HttpPost("/api/presence/v2/list")]
|
||||
[HttpPost("/api/presence/v1/list")]
|
||||
public ActionResult<List<Presence2018>> List([FromBody] long[]? ids)
|
||||
{
|
||||
if (ids is null || ids.Length == 0) return Ok(new List<Presence2018>());
|
||||
var online = presence.OnlinePlayerIds().ToHashSet();
|
||||
var result = ids.Distinct().Select(id => new Presence2018
|
||||
{
|
||||
PlayerId = id,
|
||||
IsOnline = online.Contains(id),
|
||||
GameSession = null,
|
||||
}).ToList();
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -1,607 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.ProgressionApi;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/{challenge,checklist,objectives}/* — daily
|
||||
/// challenges, onboarding checklist, and the full objectives surface
|
||||
/// (myprogress, cleargroup, updateobjective, completegroup). All
|
||||
/// concepts share the same persistence model:
|
||||
/// <see cref="ObjectiveProgressEntity"/> rows keyed by a prefix:
|
||||
/// <c>challenge:{id}</c>, <c>checklist:{id}</c>, <c>group:{id}</c>,
|
||||
/// <c>obj:{group}:{index}</c>.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class ProgressionController(
|
||||
DorkNetDbContext db,
|
||||
LevelService level,
|
||||
ServerSettingsService serverSettings,
|
||||
StoreService store) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
private long? MeOrNull => this.CurrentPlayerId();
|
||||
private const int RecCenterTokens = 2;
|
||||
|
||||
// ── Challenges ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>api/challenge/v{1,2}/getCurrent</c> — returns
|
||||
/// the active daily-challenge map (NOT a flat challenge object).
|
||||
/// Verified against
|
||||
/// <c>Cpp2IL_ISIL/.../RecNet/Challenges.txt:105-184</c>
|
||||
/// (<c>GetCurrentChallenges</c> hits this URL and parses as
|
||||
/// <c>ChallengeMap</c>). Wire shape per
|
||||
/// <c>RecNet/ChallengeMap.txt:540-616</c>:
|
||||
///
|
||||
/// <c>ChallengeMapId</c> (int, required), <c>CompletedRequired</c>
|
||||
/// (bool, required), <c>StartAt</c>/<c>EndAt</c>/<c>ServerTime</c>
|
||||
/// (DateTime, required), <c>Challenges</c> (List<RecNetChallenge>,
|
||||
/// required), <c>Gift</c> (ChallengeGift, required),
|
||||
/// <c>ChallengeThemeString</c> (string, optional).
|
||||
///
|
||||
/// Each <c>RecNetChallenge</c> requires only <c>ChallengeId</c>;
|
||||
/// <c>Name</c>/<c>Config</c>/<c>Description</c>/<c>Tooltip</c>/<c>Complete</c>
|
||||
/// are optional. Each <c>ChallengeGift</c> requires <c>GiftDropId</c>,
|
||||
/// <c>Xp</c>, <c>Level</c>, <c>GiftContext</c>, <c>GiftRarity</c>.
|
||||
///
|
||||
/// Previous flat-challenge response crashed
|
||||
/// <c>ChallengeMap.Deserialize</c> at <c>Util.GetKey("ChallengeMapId")</c>.</summary>
|
||||
[HttpGet("api/challenge/v1/getCurrent")]
|
||||
[HttpGet("api/challenge/v2/getCurrent")]
|
||||
public async Task<IActionResult> CurrentChallenge()
|
||||
{
|
||||
var pid = MeOrNull;
|
||||
var now = DateTime.UtcNow;
|
||||
var start = now.Date.AddDays(-(int)now.DayOfWeek);
|
||||
var mapId = CurrentChallengeMapId();
|
||||
|
||||
var weekly = await serverSettings.GetWeeklyChallengesAsync();
|
||||
var defs = weekly.Challenges;
|
||||
|
||||
var keys = defs.Select(c => ChallengeProgressKey(mapId, c.Index)).ToArray();
|
||||
var rows = pid is long me && keys.Length > 0
|
||||
? await db.ObjectiveProgress
|
||||
.Where(o => o.PlayerId == me && keys.Contains(o.Key))
|
||||
.ToDictionaryAsync(o => o.Key, o => o.IsCompleted)
|
||||
: new Dictionary<string, bool>();
|
||||
|
||||
bool Done(int idx) => rows.TryGetValue(ChallengeProgressKey(mapId, idx), out var v) && v;
|
||||
|
||||
var reward = weekly.Reward;
|
||||
return Ok(new
|
||||
{
|
||||
ChallengeMapId = mapId,
|
||||
CompletedRequired = weekly.CompletedRequired,
|
||||
StartAt = start,
|
||||
EndAt = start.AddDays(7),
|
||||
ServerTime = now,
|
||||
ChallengeThemeString = "Weekly",
|
||||
Challenges = defs.Select(c => new
|
||||
{
|
||||
ChallengeId = ChallengeId(mapId, c.Index),
|
||||
c.Name,
|
||||
c.Config,
|
||||
c.Description,
|
||||
c.Tooltip,
|
||||
Complete = Done(c.Index),
|
||||
}),
|
||||
Gift = BuildGiftDto(mapId, reward),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Project a <see cref="WeeklyChallengeReward"/> onto the
|
||||
/// 2020.03 <c>ChallengeGift</c> wire shape (dump.cs ChallengeGift,
|
||||
/// TypeDefIndex 11455). <c>AvatarItemType</c> is sent as
|
||||
/// <c>Outfit</c> (0) whenever an <c>AvatarItemDesc</c> is present and
|
||||
/// <c>null</c> otherwise — the client's gift-card render binds the
|
||||
/// avatar preview through it, and an <c>AvatarItemDesc</c> with no
|
||||
/// <c>AvatarItemType</c> leaves that binding half-populated (same
|
||||
/// failure mode StoreService documents for the shop grid).</summary>
|
||||
private static object BuildGiftDto(int mapId, WeeklyChallengeReward reward) => new
|
||||
{
|
||||
GiftDropId = reward.GiftDropId != 0 ? reward.GiftDropId : mapId,
|
||||
reward.AvatarItemDesc,
|
||||
AvatarItemType = string.IsNullOrEmpty(reward.AvatarItemDesc)
|
||||
? (int?)null
|
||||
: StoreAvatarItemTypeOutfit,
|
||||
reward.ConsumableItemDesc,
|
||||
reward.EquipmentPrefabName,
|
||||
reward.EquipmentModificationGuid,
|
||||
Xp = reward.Xp,
|
||||
reward.Level,
|
||||
GiftContext = reward.GiftContext,
|
||||
GiftRarity = reward.GiftRarity,
|
||||
};
|
||||
|
||||
/// <summary>AvatarItemType.Outfit — kept in sync with
|
||||
/// <c>StoreService</c>'s constant of the same value.</summary>
|
||||
private const int StoreAvatarItemTypeOutfit = 0;
|
||||
|
||||
// ObjectiveProgress row key for a weekly challenge slot.
|
||||
private static string ChallengeProgressKey(int mapId, int index) => $"challenge:{mapId}:{index}";
|
||||
|
||||
// Stable per-map ChallengeId the watch round-trips: mapId*10 + slot
|
||||
// index. Caps the slate at 10 challenges (NormalizeWeeklyChallenges
|
||||
// enforces the same Take(10)).
|
||||
private static int ChallengeId(int mapId, int index) => mapId * 10 + index;
|
||||
|
||||
// Recover the slot index from whatever the watch echoes back. Prefer
|
||||
// the mapId*10 + index encoding; fall back to the low digit for
|
||||
// clients that send a bare id. Clamp into the configured range.
|
||||
private static int ChallengeIndex(int mapId, int challengeId, int challengeCount)
|
||||
{
|
||||
if (challengeId >= mapId * 10 && challengeId < mapId * 10 + 10)
|
||||
return Math.Clamp(challengeId - mapId * 10, 0, challengeCount - 1);
|
||||
|
||||
return Math.Clamp(Math.Abs(challengeId % 10), 0, challengeCount - 1);
|
||||
}
|
||||
|
||||
public sealed class UpdateChallengeRequest
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ChallengeMapId { get; set; }
|
||||
public int ChallengeId { get; set; }
|
||||
public int Progress { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/challenge/v2/updateProgress")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> UpdateChallengeProgress([FromForm] UpdateChallengeRequest formReq)
|
||||
{
|
||||
var pid = Me;
|
||||
var req = await ReadChallengeRequestAsync(formReq);
|
||||
var mapId = req.ChallengeMapId != 0 ? req.ChallengeMapId : CurrentChallengeMapId();
|
||||
var challengeId = req.ChallengeId != 0 ? req.ChallengeId : req.Id;
|
||||
|
||||
var weekly = await serverSettings.GetWeeklyChallengesAsync();
|
||||
var challengeCount = Math.Max(1, weekly.Challenges.Count);
|
||||
var index = ChallengeIndex(mapId, challengeId, challengeCount);
|
||||
|
||||
var key = ChallengeProgressKey(mapId, index);
|
||||
var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key);
|
||||
if (row is null)
|
||||
{
|
||||
row = new ObjectiveProgressEntity { PlayerId = pid, Key = key };
|
||||
db.ObjectiveProgress.Add(row);
|
||||
}
|
||||
if (!row.IsCompleted)
|
||||
{
|
||||
row.IsCompleted = true;
|
||||
row.ClearedAt = DateTime.UtcNow;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var weeklyRewarded = false;
|
||||
var allKeys = weekly.Challenges
|
||||
.Select(c => ChallengeProgressKey(mapId, c.Index))
|
||||
.ToArray();
|
||||
var completeCount = await db.ObjectiveProgress
|
||||
.CountAsync(o => o.PlayerId == pid && allKeys.Contains(o.Key) && o.IsCompleted);
|
||||
if (completeCount >= allKeys.Length)
|
||||
{
|
||||
var reward = weekly.Reward;
|
||||
weeklyRewarded = await GrantRewardOnceAsync(
|
||||
pid,
|
||||
$"weekly-challenge:{mapId}",
|
||||
xp: reward.Xp,
|
||||
tokens: reward.Tokens,
|
||||
reason: $"weekly_challenge:{mapId}");
|
||||
|
||||
// Land the actual skin/consumable, not just XP + tokens — the
|
||||
// December build only granted currency, so configured item
|
||||
// rewards showed on the watch but never reached the player's
|
||||
// inventory. Gated behind the same once-only sentinel so a
|
||||
// re-submit doesn't re-grant: only grant the item on the boot
|
||||
// transition where currency was also granted.
|
||||
if (weeklyRewarded && !string.IsNullOrWhiteSpace(reward.Slug))
|
||||
await store.GrantItemFreeBySlugAsync(pid, reward.Slug);
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
ChallengeMapId = mapId,
|
||||
ChallengeId = challengeId,
|
||||
Progress = Math.Max(req.Progress, 10),
|
||||
IsCompleted = true,
|
||||
ClearedAt = row.ClearedAt,
|
||||
Rewarded = weeklyRewarded,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Checklist ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>api/checklist/v{1,2}/current</c> — returns
|
||||
/// a flat <c>List<ChecklistObjective></c>, NOT a wrapper
|
||||
/// object. Wire shape per
|
||||
/// <c>Cpp2IL_CS/.../RecNet/Checklist.cs</c>:
|
||||
/// <c>Order(int), Objective(ObjectiveType int), Count(int),
|
||||
/// CreditAmount(int)</c>. All four keys are required
|
||||
/// (<c>Util.GetKey</c>, not <c>OrDefault</c>).
|
||||
///
|
||||
/// Completion state is tracked separately by the client via
|
||||
/// <see cref="ObjectiveProgress"/> rows it cross-references — we
|
||||
/// don't put a per-item "done" flag on the wire; the client
|
||||
/// matches by Order. Returning an empty list ([]) is also valid
|
||||
/// per the deserialiser (List wrapper accepts empty).</summary>
|
||||
[HttpGet("api/checklist/v1/current")]
|
||||
[HttpGet("api/checklist/v2/current")]
|
||||
public IActionResult CurrentChecklist()
|
||||
{
|
||||
// Default NUX checklist for a brand-new account. Each row
|
||||
// maps an in-game step to an ObjectiveType enum value
|
||||
// (decompiled from ProgressionManager.cs).
|
||||
return Ok(new[]
|
||||
{
|
||||
new { Order = 0, Objective = 38 /* SaveOutfitSlot */, Count = 1, CreditAmount = 25 },
|
||||
new { Order = 1, Objective = 32 /* VisitACustomRoom */, Count = 1, CreditAmount = 25 },
|
||||
new { Order = 2, Objective = 2 /* AddAFriend */, Count = 1, CreditAmount = 25 },
|
||||
new { Order = 3, Objective = 30 /* GoToRecCenter */, Count = 1, CreditAmount = 25 },
|
||||
new { Order = 4, Objective = 6 /* CheerAPlayer */, Count = 1, CreditAmount = 25 },
|
||||
});
|
||||
}
|
||||
|
||||
public sealed class CompleteChecklistRequest
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ItemIndex { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/checklist/v1/complete")]
|
||||
[HttpPost("api/checklist/v2/complete")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CompleteChecklist([FromBody] CompleteChecklistRequest req)
|
||||
{
|
||||
var pid = Me;
|
||||
var id = req.ItemIndex != 0 ? req.ItemIndex : req.Id;
|
||||
var key = $"checklist:{id}";
|
||||
var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key);
|
||||
if (row is null)
|
||||
{
|
||||
row = new ObjectiveProgressEntity { PlayerId = pid, Key = key };
|
||||
db.ObjectiveProgress.Add(row);
|
||||
}
|
||||
row.IsCompleted = true;
|
||||
row.ClearedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
var rewarded = await GrantRewardOnceAsync(
|
||||
pid,
|
||||
$"checklist:{id}",
|
||||
xp: 25,
|
||||
tokens: 25,
|
||||
reason: $"checklist:{id}");
|
||||
var balance = await level.GetBalanceAsync(pid, RecCenterTokens);
|
||||
return Ok(BalanceUpdateResponse(balance, RecCenterTokens, rewarded ? 25 : 0, 303));
|
||||
}
|
||||
|
||||
// ── Objectives bulk-complete (real persistence) ──────────────────────
|
||||
|
||||
/// <summary>POST <c>api/objectives/v1/completegroup</c> — replaces
|
||||
/// the previous shape-stub in MissingEndpointsController. Now
|
||||
/// upserts a real <see cref="ObjectiveProgressEntity"/> row keyed
|
||||
/// by <c>group:{id}</c>.</summary>
|
||||
public sealed class UpdateObjectiveRequest
|
||||
{
|
||||
public int Group { get; set; }
|
||||
public int Index { get; set; }
|
||||
public float Progress { get; set; }
|
||||
public bool IsCompleted { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/objectives/v1/updateobjective</c> — per-
|
||||
/// objective progress update. Was previously caught by the
|
||||
/// namespace wildcard returning <c>[]</c>; now persists to
|
||||
/// <see cref="ObjectiveProgressEntity"/> keyed
|
||||
/// <c>obj:{group}:{index}</c> so progress survives sessions.
|
||||
/// Wire shape from <c>RecNet.Objectives.UpdateObjectiveProgress</c>.</summary>
|
||||
[HttpPost("api/objectives/v1/updateobjective")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> UpdateObjective([FromBody] UpdateObjectiveRequest req)
|
||||
{
|
||||
var pid = Me;
|
||||
var key = $"obj:{req.Group}:{req.Index}";
|
||||
var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key);
|
||||
if (row is null)
|
||||
{
|
||||
row = new ObjectiveProgressEntity { PlayerId = pid, Key = key };
|
||||
db.ObjectiveProgress.Add(row);
|
||||
}
|
||||
if (req.IsCompleted || req.Progress >= 1f)
|
||||
{
|
||||
row.IsCompleted = true;
|
||||
row.ClearedAt ??= DateTime.UtcNow;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
var rewardKey = $"obj:{req.Group}:{req.Index}";
|
||||
var isRewarded = await IsRewardedAsync(pid, rewardKey);
|
||||
return Ok(new
|
||||
{
|
||||
req.Group,
|
||||
req.Index,
|
||||
Progress = row.IsCompleted ? 1f : req.Progress,
|
||||
VisualProgress = row.IsCompleted ? 1f : req.Progress,
|
||||
IsCompleted = row.IsCompleted,
|
||||
IsRewarded = isRewarded,
|
||||
IsDirty = false,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("api/objectives/v1/completegroup")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CompleteGroup([FromBody] CompleteGroupBody body)
|
||||
{
|
||||
var pid = Me;
|
||||
var key = $"group:{body.Group}";
|
||||
var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key);
|
||||
if (row is null)
|
||||
{
|
||||
row = new ObjectiveProgressEntity { PlayerId = pid, Key = key };
|
||||
db.ObjectiveProgress.Add(row);
|
||||
}
|
||||
row.IsCompleted = true;
|
||||
row.ClearedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
var rewarded = await GrantObjectiveGroupRewardAsync(pid, body.Group);
|
||||
return Ok(new
|
||||
{
|
||||
Group = body.Group,
|
||||
IsCompleted = true,
|
||||
ClearedAt = row.ClearedAt!.Value.ToString("o"),
|
||||
RequiresCompleteOnServer = false,
|
||||
IsRewarded = true,
|
||||
Rewarded = rewarded,
|
||||
});
|
||||
}
|
||||
|
||||
public sealed class CompleteGroupBody { public int Group { get; set; } }
|
||||
|
||||
/// <summary>GET <c>api/objectives/v1/myprogress</c> +
|
||||
/// <c>api/players/v2/objectives</c> — the watch's daily/weekly
|
||||
/// objectives checklist. Returns
|
||||
/// <c>{Objectives: [...], ObjectiveGroups: [...]}</c> per
|
||||
/// <c>Objectives.MyProgress.Deserialize</c> (RVA 0x14510A0).
|
||||
/// Empty lists are acceptable; both keys must be present.</summary>
|
||||
[HttpGet("api/objectives/v1/myprogress")]
|
||||
[HttpGet("api/players/v2/objectives")]
|
||||
public async Task<IActionResult> MyObjectiveProgress()
|
||||
{
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is not long me)
|
||||
return Ok(new { Objectives = Array.Empty<object>(), ObjectiveGroups = Array.Empty<object>() });
|
||||
|
||||
var rows = await db.ObjectiveProgress
|
||||
.Where(o => o.PlayerId == me)
|
||||
.ToListAsync();
|
||||
|
||||
var rewardKeys = rows
|
||||
.Where(o => o.Key.StartsWith("reward:"))
|
||||
.Select(o => o.Key["reward:".Length..])
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var objectives = rows
|
||||
.Where(o => o.Key.StartsWith("obj:"))
|
||||
.Select(o =>
|
||||
{
|
||||
var (grp, idx) = ParseObjectiveKey(o.Key);
|
||||
return new
|
||||
{
|
||||
Index = idx,
|
||||
Group = grp,
|
||||
Progress = o.IsCompleted ? 1f : 0f,
|
||||
VisualProgress = o.IsCompleted ? 1f : 0f,
|
||||
IsCompleted = o.IsCompleted,
|
||||
IsRewarded = rewardKeys.Contains(o.Key),
|
||||
IsDirty = false,
|
||||
};
|
||||
});
|
||||
|
||||
var groups = rows
|
||||
.Where(o => o.Key.StartsWith("group:"))
|
||||
.Select(o => new
|
||||
{
|
||||
Group = int.TryParse(o.Key["group:".Length..], out var g) ? g : 0,
|
||||
IsCompleted = o.IsCompleted,
|
||||
ClearedAt = o.ClearedAt ?? new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
RequiresCompleteOnServer = false,
|
||||
IsRewarded = rewardKeys.Contains(o.Key),
|
||||
});
|
||||
|
||||
return Ok(new { Objectives = objectives, ObjectiveGroups = groups });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/objectives/v1/cleargroup</c> — flips a
|
||||
/// group's <c>IsCompleted</c> flag, used by the checklist's
|
||||
/// "claim group reward" button. Response is a single
|
||||
/// <c>ObjectiveGroupProgress</c> with Group/IsCompleted/ClearedAt
|
||||
/// required keys; <c>RequiresCompleteOnServer</c> is optional.</summary>
|
||||
[HttpPost("api/objectives/v1/cleargroup")]
|
||||
public async Task<IActionResult> ClearGroup([FromForm(Name = "group")] int? group)
|
||||
{
|
||||
var pid = this.CurrentPlayerId();
|
||||
var key = $"group:{group ?? 0}";
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
if (pid is long me)
|
||||
{
|
||||
var row = await db.ObjectiveProgress
|
||||
.FirstOrDefaultAsync(o => o.PlayerId == me && o.Key == key);
|
||||
if (row is null)
|
||||
{
|
||||
row = new ObjectiveProgressEntity { PlayerId = me, Key = key };
|
||||
db.ObjectiveProgress.Add(row);
|
||||
}
|
||||
row.IsCompleted = true;
|
||||
row.ClearedAt = now;
|
||||
await db.SaveChangesAsync();
|
||||
await GrantObjectiveGroupRewardAsync(me, group ?? 0);
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Group = group ?? 0,
|
||||
IsCompleted = true,
|
||||
ClearedAt = now.ToString("o"),
|
||||
RequiresCompleteOnServer = false,
|
||||
IsRewarded = true,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/players/v2/objectives</c> — batched
|
||||
/// ProgressionManager completion records. These are separate from
|
||||
/// ObjectiveProgress rows: they grant XP/currency for daily
|
||||
/// objective events and must be idempotent per UTC day.</summary>
|
||||
[HttpPost("api/players/v2/objectives")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CompletePlayerObjectives([FromBody] List<PlayerObjectiveCompletionRecord>? records)
|
||||
{
|
||||
var pid = Me;
|
||||
var grantedXp = 0;
|
||||
var grantedTokens = 0;
|
||||
foreach (var record in records ?? new())
|
||||
{
|
||||
var (xp, tokens, reason) = RewardForObjectiveType(record.ObjectiveType, record.AdditionalXp);
|
||||
if (xp <= 0 && tokens <= 0) continue;
|
||||
if (await GrantRewardOnceAsync(pid, $"{reason}:{DateTime.UtcNow:yyyyMMdd}", xp, tokens, reason))
|
||||
{
|
||||
grantedXp += xp;
|
||||
grantedTokens += tokens;
|
||||
}
|
||||
}
|
||||
|
||||
var balance = await level.GetBalanceAsync(pid, RecCenterTokens);
|
||||
return Ok(new
|
||||
{
|
||||
GrantedXp = grantedXp,
|
||||
GrantedCurrency = grantedTokens,
|
||||
CurrencyType = RecCenterTokens,
|
||||
Balance = balance,
|
||||
BalanceType = 0,
|
||||
});
|
||||
}
|
||||
|
||||
public sealed class PlayerObjectiveCompletionRecord
|
||||
{
|
||||
public int ObjectiveType { get; set; }
|
||||
public bool InParty { get; set; }
|
||||
public int AdditionalXp { get; set; }
|
||||
}
|
||||
|
||||
private static (int Group, int Index) ParseObjectiveKey(string key)
|
||||
{
|
||||
const string prefix = "obj:";
|
||||
if (!key.StartsWith(prefix)) return (-1, -1);
|
||||
var rest = key[prefix.Length..];
|
||||
var colon = rest.IndexOf(':');
|
||||
if (colon <= 0) return (-1, -1);
|
||||
var grp = int.TryParse(rest[..colon], out var g) ? g : -1;
|
||||
var idx = int.TryParse(rest[(colon + 1)..], out var i) ? i : -1;
|
||||
return (grp, idx);
|
||||
}
|
||||
|
||||
private static int CurrentChallengeMapId() =>
|
||||
(int)((DateTime.UtcNow - new DateTime(2020, 1, 1)).Days / 7 % 52 + 1);
|
||||
|
||||
private async Task<UpdateChallengeRequest> ReadChallengeRequestAsync(UpdateChallengeRequest formReq)
|
||||
{
|
||||
if (Request.HasFormContentType || formReq.Id != 0 || formReq.ChallengeId != 0 || formReq.ChallengeMapId != 0)
|
||||
return formReq;
|
||||
|
||||
try
|
||||
{
|
||||
var body = await System.Text.Json.JsonSerializer.DeserializeAsync<UpdateChallengeRequest>(
|
||||
Request.Body,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
return body ?? formReq;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return formReq;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> GrantObjectiveGroupRewardAsync(long playerId, int group)
|
||||
{
|
||||
var (xp, tokens, name) = group switch
|
||||
{
|
||||
0 => (150, 150, "daily-objectives"),
|
||||
1 => (100, 100, "new-player-punch-card"),
|
||||
2 => (75, 75, "orientation-checklist"),
|
||||
_ => (50, 50, $"objective-group-{group}"),
|
||||
};
|
||||
return await GrantRewardOnceAsync(playerId, $"group:{group}", xp, tokens, name);
|
||||
}
|
||||
|
||||
private static (int Xp, int Tokens, string Reason) RewardForObjectiveType(int objectiveType, int additionalXp)
|
||||
{
|
||||
var xp = Math.Max(additionalXp, 0);
|
||||
return objectiveType switch
|
||||
{
|
||||
10 or 11 or 12 => (Math.Max(xp, 25), 25, $"daily-objective:{objectiveType}"),
|
||||
13 => (Math.Max(xp, 150), 150, "all-daily-objectives"),
|
||||
14 => (Math.Max(xp, 50), 50, "complete-any-daily"),
|
||||
15 => (Math.Max(xp, 75), 75, "complete-any-weekly"),
|
||||
25 => (Math.Max(xp, 25), 25, "nux-punchcard-objective"),
|
||||
26 => (Math.Max(xp, 100), 100, "nux-all-punchcard-objectives"),
|
||||
_ => (xp, 0, $"objective:{objectiveType}"),
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<bool> IsRewardedAsync(long playerId, string rewardKey) =>
|
||||
await db.ObjectiveProgress.AnyAsync(o =>
|
||||
o.PlayerId == playerId && o.Key == $"reward:{rewardKey}" && o.IsCompleted);
|
||||
|
||||
private async Task<bool> GrantRewardOnceAsync(long playerId, string rewardKey, int xp, int tokens, string reason)
|
||||
{
|
||||
var sentinelKey = $"reward:{rewardKey}";
|
||||
var exists = await db.ObjectiveProgress.AnyAsync(o =>
|
||||
o.PlayerId == playerId && o.Key == sentinelKey && o.IsCompleted);
|
||||
if (exists) return false;
|
||||
|
||||
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Key = sentinelKey,
|
||||
IsCompleted = true,
|
||||
ClearedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (xp > 0) await level.AwardXpAsync(playerId, xp, reason);
|
||||
if (tokens > 0) await level.GrantCurrencyAsync(playerId, RecCenterTokens, tokens, reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static object BalanceUpdateResponse(long balance, int currencyType, int delta, int balanceAddType) => new
|
||||
{
|
||||
Balance = balance,
|
||||
CurrencyType = currencyType,
|
||||
BalanceType = 0,
|
||||
BalanceUpdates = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
UpdateResponse = 0,
|
||||
Data = delta <= 0
|
||||
? Array.Empty<object>()
|
||||
: new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
BalanceAddType = balanceAddType,
|
||||
BaseAward = delta,
|
||||
BonusAward = 0,
|
||||
RateLimit = 0,
|
||||
CurrentCount = 1,
|
||||
Total = delta,
|
||||
BalanceType = 0,
|
||||
BalanceInGiftBox = false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.QuickPlay;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/quickPlay/* — the watch's "Quick Play" tile picks
|
||||
/// a popular open AG room when the player wants to jump in
|
||||
/// somewhere social without browsing.
|
||||
///
|
||||
/// • <c>GET v{1,2}/getandclear</c> — returns the next match the
|
||||
/// user was queued for, then clears it. We just synthesise a
|
||||
/// "join hot AG room" target on each call (no real queue).
|
||||
/// • <c>POST v1/set</c> — stores a desired-match tag. We log + ack;
|
||||
/// no persistence needed for a single-region private server.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class QuickPlayController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
/// <summary>GET <c>v{1,2}/getandclear</c> — the watch polls this
|
||||
/// on every connect / navigation to check whether the user has a
|
||||
/// pending quickplay queue. Returning a real {RoomId, RoomName}
|
||||
/// makes the client treat it as "you queued, go there now" and
|
||||
/// immediately teleports — that's WHY users were randomly
|
||||
/// spawning in Paddleball etc.
|
||||
///
|
||||
/// Correct behaviour: return empty {} unless we actually have a
|
||||
/// queued match for the caller. The /set endpoint is what
|
||||
/// queues; getandclear is the consume.
|
||||
///
|
||||
/// We don't persist queue state on a single-region private
|
||||
/// server (no real matchmaking pool), so always return empty.</summary>
|
||||
[HttpGet("api/quickPlay/v1/getandclear")]
|
||||
[HttpGet("api/quickPlay/v2/getandclear")]
|
||||
public IActionResult GetAndClear() => Ok(new { });
|
||||
|
||||
public sealed class SetQuickPlayRequest
|
||||
{
|
||||
public string? Tag { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/quickPlay/v1/set")]
|
||||
[Authorize]
|
||||
public IActionResult Set([FromBody] SetQuickPlayRequest? req)
|
||||
{
|
||||
// Preference is per-session; we don't persist (no benefit in
|
||||
// a small private deployment). Just ack.
|
||||
return Ok(new { Tag = req?.Tag ?? string.Empty, Set = true });
|
||||
}
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
// Storage-side enum aliased so the wire mapping stays explicit at
|
||||
// every callsite. Wire enum values (RelationshipType /
|
||||
// ReciprocalStatus) live in this file as constants — they're tied
|
||||
// to the disassembly and not used elsewhere.
|
||||
using EntityStatus = DorkNet.Server.Data.Entities.RelationshipStatus;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Relationships.V2;
|
||||
|
||||
/// <summary>
|
||||
/// api/relationships/v2 — friendship + reciprocal-state endpoints.
|
||||
///
|
||||
/// All routes / wire shapes verified against the patched 2020 client:
|
||||
/// Cpp2IL_ISIL/IsilDump/Assembly-CSharp/RecNet/Relationships.txt
|
||||
/// Cpp2IL_ISIL/IsilDump/Assembly-CSharp/RecNet/Relationship.txt
|
||||
///
|
||||
/// Wire response = a Relationship object (or List of them for /get).
|
||||
/// Relationship.Deserialize strict-reads via Util.GetKey<T>:
|
||||
/// "PlayerID" (int) — REQUIRED — the OTHER account's id
|
||||
/// "RelationshipType" (int) — REQUIRED — 0..3
|
||||
/// "Muted" (int) — REQUIRED — ReciprocalStatus 0..3
|
||||
/// "Ignored" (int) — REQUIRED — ReciprocalStatus 0..3
|
||||
/// "Favorited" (int) — optional (Util.GetKeyOrDefault, defaults 0)
|
||||
///
|
||||
/// RelationshipType (from caller's perspective):
|
||||
/// 0 None, 1 FriendRequestSent, 2 FriendRequestReceived, 3 Friend
|
||||
/// ReciprocalStatus (symmetric flag — who set it):
|
||||
/// 0 None, 1 Local (just me), 2 Remote (just them), 3 Mutual
|
||||
///
|
||||
/// URLs called from the watch (Relationships.txt, all under
|
||||
/// "api/relationships/"):
|
||||
/// POST v2/addfriend?id={N} AddFriend
|
||||
/// POST v2/removefriend?id={N} RemoveFriend / cancel-pending
|
||||
/// POST v2/sendfriendrequest?id={N} SendFriendRequest
|
||||
/// POST v2/acceptfriendrequest?id={N} AcceptFriendRequest
|
||||
/// POST v1/favorite?id={N} FavoritePlayer
|
||||
/// POST v1/unfavorite?id={N} UnfavoritePlayer
|
||||
/// POST v1/mute / unmute / ignore / unignore (PostPreferenceChange)
|
||||
/// GET v2/get GetMyRelationships
|
||||
/// All POST URLs return one Relationship; GET returns List<Relationship>.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class RelationshipsController(DorkNetDbContext db, NotificationService notifications, ServerSettingsService settings) : ControllerBase
|
||||
{
|
||||
// RelationshipType — int values match Relationship.RelationshipType
|
||||
// in the patched dump.cs.
|
||||
private const int RT_None = 0;
|
||||
private const int RT_FriendRequestSent = 1;
|
||||
private const int RT_FriendRequestReceived = 2;
|
||||
private const int RT_Friend = 3;
|
||||
|
||||
// ReciprocalStatus values — also match the dump.cs enum.
|
||||
private const int RS_None = 0;
|
||||
private const int RS_Local = 1;
|
||||
private const int RS_Remote = 2;
|
||||
private const int RS_Mutual = 3;
|
||||
|
||||
private long CurrentPlayerId => this.RequireCurrentPlayerId();
|
||||
|
||||
// ── GET /api/relationships/v2/get ────────────────────────────────
|
||||
// Watch URL: "api/relationships/v2/get". Returns the caller's full
|
||||
// relationship list as List<Relationship>.
|
||||
[HttpGet("api/relationships/v2/get")]
|
||||
public async Task<ActionResult> GetMyRelationships()
|
||||
{
|
||||
var me = CurrentPlayerId;
|
||||
|
||||
// Global-friends mode (admin toggle): synthesize a Friend row for
|
||||
// every other account WITHOUT persisting anything. Real rows are
|
||||
// overlaid so mute/favorite still apply and Blocked stays non-friend.
|
||||
if (await settings.IsGlobalFriendsEnabledAsync())
|
||||
return Ok(await BuildGlobalFriendListAsync(me));
|
||||
|
||||
var rels = await db.Relationships
|
||||
.Where(r => r.RequesterId == me || r.TargetId == me)
|
||||
.ToListAsync();
|
||||
// Merge the (up to two) rows per pair so a favorite/mute/ignore the
|
||||
// caller set on a friendship the OTHER player initiated isn't dropped
|
||||
// by the watch (which keys its list by PlayerID — duplicate ids lose).
|
||||
var wire = rels
|
||||
.GroupBy(r => r.RequesterId == me ? r.TargetId : r.RequesterId)
|
||||
.Select(g => BuildMerged(me, g.Key, g.ToList()))
|
||||
.ToList();
|
||||
return Ok(wire);
|
||||
}
|
||||
|
||||
/// <summary>Synthesize the caller's friend list as "everyone" for the
|
||||
/// global-friends toggle. One Friend entry per other account; real rows
|
||||
/// are overlaid so a Block stays None and mute/favorite carry through.</summary>
|
||||
private async Task<List<Dictionary<string, object>>> BuildGlobalFriendListAsync(long me)
|
||||
{
|
||||
var realRows = await db.Relationships
|
||||
.Where(r => r.RequesterId == me || r.TargetId == me)
|
||||
.ToListAsync();
|
||||
// Group both directional rows per pair so the caller's favorite/mute/
|
||||
// ignore (stored on the row where they're the requester) survives even
|
||||
// when the friendship row itself was created by the other player.
|
||||
var realByOther = realRows
|
||||
.GroupBy(r => r.RequesterId == me ? r.TargetId : r.RequesterId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var others = await db.Players
|
||||
.Where(p => p.Id != me && p.Id != RelationshipQueries.SystemAccountId)
|
||||
.Select(p => new { p.Id, p.Username })
|
||||
.ToListAsync();
|
||||
|
||||
var wire = new List<Dictionary<string, object>>(others.Count);
|
||||
foreach (var other in others)
|
||||
{
|
||||
var otherId = other.Id;
|
||||
if (realByOther.TryGetValue(otherId, out var pair))
|
||||
{
|
||||
// A real block wins — that pair is genuinely not friends.
|
||||
if (pair.Any(r => r.Status == EntityStatus.Blocked)) { wire.Add(EmptyRelationship(otherId)); continue; }
|
||||
// Carry the caller's own preference bits from their row.
|
||||
var mineRow = pair.FirstOrDefault(r => r.RequesterId == me);
|
||||
wire.Add(SynthFriend(otherId, mineRow, me));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Hide auto-generated Player_NNN placeholders from the
|
||||
// synthesized list (they never set a real username).
|
||||
if (RelationshipQueries.IsPlaceholderUsername(other.Username)) continue;
|
||||
wire.Add(SynthFriend(otherId, null, me));
|
||||
}
|
||||
}
|
||||
return wire;
|
||||
}
|
||||
|
||||
/// <summary>A synthetic Friend relationship for global-friends mode,
|
||||
/// carrying mute/ignore/favorite from the caller's real row when present.</summary>
|
||||
private static Dictionary<string, object> SynthFriend(long other, RelationshipEntity? real, long me)
|
||||
{
|
||||
var mineDir = real is not null && real.RequesterId == me;
|
||||
return new()
|
||||
{
|
||||
["PlayerID"] = (int)other,
|
||||
["RelationshipType"] = RT_Friend,
|
||||
["Muted"] = mineDir && real!.Muted ? 1 : 0,
|
||||
["Ignored"] = mineDir && real!.Ignored ? 1 : 0,
|
||||
["Favorited"] = mineDir && real!.Favorited ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/relationships/v2/personaldetails/{playerId}</c>
|
||||
/// — single relationship row from the caller's perspective. Used
|
||||
/// by the watch's profile-card view to determine whether to show
|
||||
/// the "Add Friend" or "Remove Friend" affordance. Returns the
|
||||
/// same Relationship wire shape as <c>v2/get</c>'s list elements.</summary>
|
||||
[HttpGet("api/relationships/v2/personaldetails/{playerId:long}")]
|
||||
public async Task<ActionResult> PersonalDetails(long playerId)
|
||||
{
|
||||
var me = CurrentPlayerId;
|
||||
if (playerId <= 0 || playerId == me) return Ok(EmptyRelationship(playerId));
|
||||
var pair = await db.Relationships
|
||||
.Where(r => (r.RequesterId == me && r.TargetId == playerId) ||
|
||||
(r.RequesterId == playerId && r.TargetId == me))
|
||||
.ToListAsync();
|
||||
var blocked = pair.Any(r => r.Status == EntityStatus.Blocked);
|
||||
var mineRow = pair.FirstOrDefault(r => r.RequesterId == me);
|
||||
|
||||
// Global-friends mode: report Friend for any real account that isn't
|
||||
// the system account and isn't explicitly blocked. Placeholder
|
||||
// Player_NNN accounts are hidden unless there's a genuine row.
|
||||
if (playerId != RelationshipQueries.SystemAccountId
|
||||
&& !blocked
|
||||
&& await settings.IsGlobalFriendsEnabledAsync())
|
||||
{
|
||||
var isPlaceholder = pair.Count == 0 && RelationshipQueries.IsPlaceholderUsername(
|
||||
await db.Players.Where(p => p.Id == playerId)
|
||||
.Select(p => p.Username).FirstOrDefaultAsync());
|
||||
if (!isPlaceholder) return Ok(SynthFriend(playerId, mineRow, me));
|
||||
}
|
||||
|
||||
return Ok(pair.Count == 0 ? EmptyRelationship(playerId) : BuildMerged(me, playerId, pair));
|
||||
}
|
||||
|
||||
// ── /api/relationships/v2/sendfriendrequest?id={N} ───────────────
|
||||
// Watch URL from Relationships.txt: "{0}v2/sendfriendrequest?id={1}".
|
||||
// The ISIL labels the call site Core.Post but the live trace shows
|
||||
// the watch sending GET — accepting both is harmless and the
|
||||
// safest cross-build choice.
|
||||
[HttpGet("api/relationships/v2/sendfriendrequest")]
|
||||
[HttpPost("api/relationships/v2/sendfriendrequest")]
|
||||
public async Task<ActionResult> SendFriendRequest([FromQuery] long id)
|
||||
{
|
||||
var me = CurrentPlayerId;
|
||||
if (id <= 0 || id == me)
|
||||
return Ok(EmptyRelationship(id));
|
||||
|
||||
var pair = await db.Relationships
|
||||
.Where(r => (r.RequesterId == me && r.TargetId == id) ||
|
||||
(r.RequesterId == id && r.TargetId == me))
|
||||
.ToListAsync();
|
||||
|
||||
// Anything that already defines a status (friend/pending/block) wins —
|
||||
// a None row is preference-only and doesn't count as a relationship.
|
||||
if (pair.Any(r => r.Status != EntityStatus.None))
|
||||
return Ok(BuildMerged(me, id, pair));
|
||||
|
||||
// Promote the caller's own preference-only row if present (keeping its
|
||||
// favorite/mute/ignore bits) rather than spawning a second row.
|
||||
var row = pair.FirstOrDefault(r => r.RequesterId == me);
|
||||
if (row is not null)
|
||||
{
|
||||
row.Status = EntityStatus.PendingSent;
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
row = new RelationshipEntity
|
||||
{
|
||||
RequesterId = me,
|
||||
TargetId = id,
|
||||
Status = EntityStatus.PendingSent,
|
||||
};
|
||||
db.Relationships.Add(row);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await notifications.FriendRequestReceived(id, me);
|
||||
return Ok(BuildRelationship(row, me));
|
||||
}
|
||||
|
||||
// ── /api/relationships/v2/acceptfriendrequest?id={N} ────────────
|
||||
[HttpGet("api/relationships/v2/acceptfriendrequest")]
|
||||
[HttpPost("api/relationships/v2/acceptfriendrequest")]
|
||||
public async Task<ActionResult> AcceptFriendRequest([FromQuery] long id)
|
||||
{
|
||||
var me = CurrentPlayerId;
|
||||
if (id <= 0)
|
||||
return Ok(EmptyRelationship(id));
|
||||
|
||||
// Only an incoming pending row can be accepted. The other side
|
||||
// must have requested us — RequesterId=them, TargetId=me.
|
||||
var rel = await db.Relationships.FirstOrDefaultAsync(r =>
|
||||
r.RequesterId == id && r.TargetId == me &&
|
||||
r.Status == EntityStatus.PendingSent);
|
||||
if (rel is null)
|
||||
return Ok(EmptyRelationship(id));
|
||||
|
||||
rel.Status = EntityStatus.Friend;
|
||||
rel.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await notifications.FriendRequestAccepted(id, me);
|
||||
return Ok(BuildRelationship(rel, me));
|
||||
}
|
||||
|
||||
// ── /api/relationships/v2/removefriend?id={N} ────────────────────
|
||||
// Symmetrical: undoes a friendship OR cancels a pending request OR
|
||||
// declines an incoming one. Block rows are left alone — the watch
|
||||
// has a separate explicit unblock path. Live trace shows the watch
|
||||
// sending GET (BestHTTP queueing oddity vs the ISIL Core.Post call);
|
||||
// accept both verbs.
|
||||
[HttpGet("api/relationships/v2/removefriend")]
|
||||
[HttpPost("api/relationships/v2/removefriend")]
|
||||
public async Task<ActionResult> RemoveFriend([FromQuery] long id)
|
||||
{
|
||||
var me = CurrentPlayerId;
|
||||
if (id <= 0)
|
||||
return Ok(EmptyRelationship(id));
|
||||
|
||||
var pair = await db.Relationships
|
||||
.Where(r => (r.RequesterId == me && r.TargetId == id) ||
|
||||
(r.RequesterId == id && r.TargetId == me))
|
||||
.ToListAsync();
|
||||
if (pair.Count == 0)
|
||||
return Ok(EmptyRelationship(id));
|
||||
|
||||
// Drop every non-block row for the pair (friendship row + any stray
|
||||
// preference row), leaving a block intact — the watch unblocks
|
||||
// through a separate path.
|
||||
var removable = pair.Where(r => r.Status != EntityStatus.Blocked).ToList();
|
||||
if (removable.Count == 0)
|
||||
return Ok(BuildMerged(me, id, pair));
|
||||
|
||||
db.Relationships.RemoveRange(removable);
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.FriendRemoved(id, me);
|
||||
|
||||
var remaining = pair.Except(removable).ToList();
|
||||
return Ok(remaining.Count == 0 ? EmptyRelationship(id) : BuildMerged(me, id, remaining));
|
||||
}
|
||||
|
||||
// ── /api/relationships/v2/addfriend?id={N} ───────────────────────
|
||||
// Per the disassembly, AddFriend is its own endpoint distinct from
|
||||
// SendFriendRequest. Live trace shows the watch sending GET; ISIL
|
||||
// says Core.Post. Accept both verbs.
|
||||
[HttpGet("api/relationships/v2/addfriend")]
|
||||
[HttpPost("api/relationships/v2/addfriend")]
|
||||
public async Task<ActionResult> AddFriend([FromQuery] long id)
|
||||
{
|
||||
var me = CurrentPlayerId;
|
||||
if (id <= 0 || id == me)
|
||||
return Ok(EmptyRelationship(id));
|
||||
|
||||
var pair = await db.Relationships
|
||||
.Where(r => (r.RequesterId == me && r.TargetId == id) ||
|
||||
(r.RequesterId == id && r.TargetId == me))
|
||||
.ToListAsync();
|
||||
|
||||
// Don't override a block, and don't spawn a duplicate when a
|
||||
// friendship row already exists (in either direction).
|
||||
if (pair.Any(r => r.Status == EntityStatus.Blocked) ||
|
||||
pair.Any(r => r.Status == EntityStatus.Friend))
|
||||
return Ok(BuildMerged(me, id, pair));
|
||||
|
||||
// Promote the caller's own row (preference-only or pending) to Friend,
|
||||
// or create one if none exists.
|
||||
var existing = pair.FirstOrDefault(r => r.RequesterId == me);
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new RelationshipEntity
|
||||
{
|
||||
RequesterId = me,
|
||||
TargetId = id,
|
||||
Status = EntityStatus.Friend,
|
||||
};
|
||||
db.Relationships.Add(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Status = EntityStatus.Friend;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(BuildRelationship(existing, me));
|
||||
}
|
||||
|
||||
// ── v1 favorite / unfavorite ─────────────────────────────────────
|
||||
// Watch URLs: "api/relationships/v1/favorite?id={N}" and
|
||||
// "api/relationships/v1/unfavorite?id={N}". These return a
|
||||
// boolean-ish RecNetResult per the FavoritePlayer/UnfavoritePlayer
|
||||
// disassembly (Core.Post … ExpectHttpStatusSuccess), not a
|
||||
// Relationship. The flag is persisted on the caller's directional row
|
||||
// and surfaced back through v2/get + personaldetails (see BuildMerged).
|
||||
[HttpPost("api/relationships/v1/favorite")]
|
||||
public Task<IActionResult> Favorite([FromQuery] long id) => SetPreference(id, favorited: true);
|
||||
|
||||
[HttpPost("api/relationships/v1/unfavorite")]
|
||||
public Task<IActionResult> Unfavorite([FromQuery] long id) => SetPreference(id, favorited: false);
|
||||
|
||||
[HttpPost("api/relationships/v1/mute")]
|
||||
public Task<IActionResult> Mute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
|
||||
=> SetPreference(id != 0 ? id : formId ?? 0, muted: true);
|
||||
|
||||
[HttpPost("api/relationships/v1/unmute")]
|
||||
public Task<IActionResult> Unmute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
|
||||
=> SetPreference(id != 0 ? id : formId ?? 0, muted: false);
|
||||
|
||||
[HttpPost("api/relationships/v1/ignore")]
|
||||
public Task<IActionResult> Ignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
|
||||
=> SetPreference(id != 0 ? id : formId ?? 0, ignored: true);
|
||||
|
||||
[HttpPost("api/relationships/v1/unignore")]
|
||||
public Task<IActionResult> Unignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
|
||||
=> SetPreference(id != 0 ? id : formId ?? 0, ignored: false);
|
||||
|
||||
/// <summary>Update favorited/muted/ignored on the caller's row.
|
||||
/// Creates a placeholder row when none exists (so an "ignore"
|
||||
/// before any friend request is still recorded).</summary>
|
||||
private async Task<IActionResult> SetPreference(long otherId,
|
||||
bool? favorited = null, bool? muted = null, bool? ignored = null)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
if (otherId <= 0 || otherId == me)
|
||||
return Ok(new Models.Auth.RecNetResult { Success = false, Error = "invalid_id" });
|
||||
|
||||
var row = await db.Relationships.FirstOrDefaultAsync(r =>
|
||||
r.RequesterId == me && r.TargetId == otherId);
|
||||
if (row is null)
|
||||
{
|
||||
// A preference (favorite/mute/ignore) is the caller's own bit and
|
||||
// lives on their own directional row. Status None — favorite/mute/
|
||||
// ignore must NOT fabricate a friendship; the real friendship row
|
||||
// (this direction or the other) carries the actual status and the
|
||||
// read path merges the two.
|
||||
row = new RelationshipEntity
|
||||
{
|
||||
RequesterId = me,
|
||||
TargetId = otherId,
|
||||
Status = EntityStatus.None,
|
||||
};
|
||||
db.Relationships.Add(row);
|
||||
}
|
||||
if (favorited is bool f) row.Favorited = f;
|
||||
if (muted is bool m) row.Muted = m;
|
||||
if (ignored is bool i) row.Ignored = i;
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new Models.Auth.RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
public sealed class BulkIgnoreRequest
|
||||
{
|
||||
public int Platform { get; set; }
|
||||
public List<string>? PlatformUserIds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/relationships/v1/bulkignoreplatformusers</c>
|
||||
/// — bulk-import Steam/Oculus blocks. Persists to
|
||||
/// <see cref="PlatformIgnoreEntity"/> so when those users sign
|
||||
/// up later we can auto-set their relationship to Ignored.</summary>
|
||||
[HttpPost("api/relationships/v1/bulkignoreplatformusers")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> BulkIgnorePlatformUsers([FromBody] BulkIgnoreRequest req)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
if (req?.PlatformUserIds is null || req.PlatformUserIds.Count == 0)
|
||||
return Ok(new { Imported = 0 });
|
||||
|
||||
var added = 0;
|
||||
foreach (var puid in req.PlatformUserIds.Distinct())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(puid)) continue;
|
||||
var exists = await db.PlatformIgnores.AnyAsync(i =>
|
||||
i.PlayerId == me && i.Platform == req.Platform && i.PlatformUserId == puid);
|
||||
if (exists) continue;
|
||||
db.PlatformIgnores.Add(new PlatformIgnoreEntity
|
||||
{
|
||||
PlayerId = me,
|
||||
Platform = req.Platform,
|
||||
PlatformUserId = puid,
|
||||
});
|
||||
added++;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Imported = added });
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Map a storage row to the watch's wire RelationshipType
|
||||
/// from <paramref name="me"/>'s perspective. PendingSent flips
|
||||
/// direction depending on which side originated.</summary>
|
||||
private static int MapType(RelationshipEntity r, long me) => r.Status switch
|
||||
{
|
||||
EntityStatus.Friend => RT_Friend,
|
||||
EntityStatus.Blocked => RT_None, // Blocked is not a friendship; the watch reads block state via a separate flag we don't track yet.
|
||||
EntityStatus.PendingSent => r.RequesterId == me ? RT_FriendRequestSent : RT_FriendRequestReceived,
|
||||
EntityStatus.PendingReceived => r.RequesterId == me ? RT_FriendRequestReceived : RT_FriendRequestSent,
|
||||
_ => RT_None,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Build the wire object the patched <c>Relationship.Deserialize</c>
|
||||
/// reads. Keys are case-sensitive (Util.GetKey is a Dictionary
|
||||
/// TryGetValue) — we ship exactly what's in the disassembly.
|
||||
/// Implemented as a Dictionary because System.Text.Json's
|
||||
/// case-insensitive collision check rejects anonymous types with
|
||||
/// PascalCase property names like "PlayerID" + "Muted" if any
|
||||
/// other property could collide on lowercase — using a Dictionary
|
||||
/// avoids that whole class of trouble.
|
||||
/// </summary>
|
||||
private static Dictionary<string, object> BuildRelationship(RelationshipEntity r, long me)
|
||||
{
|
||||
var other = r.RequesterId == me ? r.TargetId : r.RequesterId;
|
||||
// ReciprocalStatus enum: 0=None, 1=ByMe, 2=ByThem, 3=Both.
|
||||
// Our schema only tracks "by me" (the requester's perspective)
|
||||
// so we can answer ByMe / None; ByThem / Both would require
|
||||
// looking up the OTHER direction's row too. Cheap extra query
|
||||
// omitted — the watch's UI only consumes the ByMe bit.
|
||||
var mineDir = r.RequesterId == me;
|
||||
var muted = mineDir && r.Muted ? 1 : 0;
|
||||
var ignored = mineDir && r.Ignored ? 1 : 0;
|
||||
var favored = mineDir && r.Favorited ? 1 : 0;
|
||||
return new()
|
||||
{
|
||||
["PlayerID"] = (int)other,
|
||||
["RelationshipType"] = MapType(r, me),
|
||||
["Muted"] = muted,
|
||||
["Ignored"] = ignored,
|
||||
["Favorited"] = favored,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Merge all rows for one pair (up to two — one per direction)
|
||||
/// into a single wire object from <paramref name="me"/>'s view. The
|
||||
/// relationship type comes from whichever row defines it (Blocked >
|
||||
/// Friend > Pending); the favorite/mute/ignore bits are the caller's
|
||||
/// own, read from the row where the caller is the requester. Without this
|
||||
/// a favorite the caller set on a friendship the OTHER player initiated
|
||||
/// lands on a separate row and is dropped by the watch (its list is keyed
|
||||
/// by PlayerID, so a duplicate id loses).</summary>
|
||||
private static Dictionary<string, object> BuildMerged(long me, long other, List<RelationshipEntity> pair)
|
||||
{
|
||||
var statusRow =
|
||||
pair.FirstOrDefault(r => r.Status == EntityStatus.Blocked)
|
||||
?? pair.FirstOrDefault(r => r.Status == EntityStatus.Friend)
|
||||
?? pair.FirstOrDefault(r => r.Status is EntityStatus.PendingSent or EntityStatus.PendingReceived)
|
||||
?? pair[0];
|
||||
var mineRow = pair.FirstOrDefault(r => r.RequesterId == me);
|
||||
return new()
|
||||
{
|
||||
["PlayerID"] = (int)other,
|
||||
["RelationshipType"] = MapType(statusRow, me),
|
||||
["Muted"] = mineRow is { Muted: true } ? 1 : 0,
|
||||
["Ignored"] = mineRow is { Ignored: true } ? 1 : 0,
|
||||
["Favorited"] = mineRow is { Favorited: true } ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>"None" relationship for a target the caller has nothing
|
||||
/// on file with — used as a no-op success response so the watch's
|
||||
/// Relationship.Deserialize stays satisfied.</summary>
|
||||
private static Dictionary<string, object> EmptyRelationship(long other) => new()
|
||||
{
|
||||
["PlayerID"] = (int)other,
|
||||
["RelationshipType"] = RT_None,
|
||||
["Muted"] = RS_None,
|
||||
["Ignored"] = RS_None,
|
||||
["Favorited"] = RS_None,
|
||||
};
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Role;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/role/* — role / privilege probes the watch consults
|
||||
/// when deciding whether to surface dev-only UI (the in-watch debug
|
||||
/// console button, hidden admin-tool tab, etc).
|
||||
///
|
||||
/// URL verified against
|
||||
/// Cpp2IL_ISIL/IsilDump/Assembly-CSharp/RecNet/Accounts.txt:5168
|
||||
/// (<c>"role/developer/{0}"</c>) called from
|
||||
/// <c>RecNet.Accounts.GetAccountIsDeveloper</c>. Response shape is a
|
||||
/// bare JSON boolean — the watch wraps the call with
|
||||
/// <c>PromiseExtensions.ExpectPrimitiveResponse<bool></c>, so an
|
||||
/// array or object response throws InvalidCastException and silently
|
||||
/// disables the dev UI.
|
||||
///
|
||||
/// To make a player a developer (and unlock the watch's
|
||||
/// debug-console button), set <c>Players.IsDeveloper = 1</c> in the
|
||||
/// SQLite DB or via the admin UI's player editor.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class RoleController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
// Service-routed call from Accounts.GetAccountIsDeveloper. The
|
||||
// 2020 dump's SendRequest carries `Service = 0` (Auth) — verified
|
||||
// at Cpp2IL_ISIL/.../RecNet/Accounts.txt:5183-5186 against the
|
||||
// RecNet.Service enum in dump.cs:586523-586538 (Auth=0). Service.Auth
|
||||
// maps to <c>auth.{apex}</c> in ConfigService's ServiceUrls, and
|
||||
// the path the watch appends has NO `/api/` prefix — it's just
|
||||
// <c>role/developer/{id}</c>. The previous <c>/api/role/...</c>-only
|
||||
// route never matched, so every nameplate probe came back 404 and
|
||||
// the watch logged "Failed to GetIsDeveloper for accountId X: HTTP
|
||||
// Error 404" once per remote player per scene load.
|
||||
[HttpGet("/role/developer/{accountId:long}")]
|
||||
[HttpGet("/api/role/developer/{accountId:long}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<bool>> IsDeveloper(long accountId)
|
||||
{
|
||||
// OR IsCommunityTeam into the answer so the watch unlocks the
|
||||
// in-settings "Developer Display Mode" slider for community-team
|
||||
// members too — the slider's positions render the overhead
|
||||
// badge as "Community Team" / "Developer" (see
|
||||
// Cpp2IL_ISIL/.../PlayerUI.txt:9085-9099). The 2020 watch never
|
||||
// probes a separate community-team role, so collapsing both
|
||||
// flags into this one endpoint is the only way to reach the
|
||||
// badge UI without patching the binary.
|
||||
var isDev = await db.Players
|
||||
.Where(p => p.Id == accountId)
|
||||
.Select(p => (bool?)(p.IsDeveloper || p.IsCommunityTeam || p.IsAdmin))
|
||||
.FirstOrDefaultAsync();
|
||||
return Ok(isDev ?? false);
|
||||
}
|
||||
|
||||
/// <summary>Mirror endpoint for the admin role — same shape, in case
|
||||
/// later builds added an <c>/api/role/admin/{id}</c> probe. The 2020
|
||||
/// dump only references the developer one but admins also get all
|
||||
/// dev privileges, so the result is unioned.</summary>
|
||||
[HttpGet("/role/admin/{accountId:long}")]
|
||||
[HttpGet("/api/role/admin/{accountId:long}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<bool>> IsAdmin(long accountId)
|
||||
{
|
||||
var isAdmin = await db.Players
|
||||
.Where(p => p.Id == accountId)
|
||||
.Select(p => (bool?)p.IsAdmin)
|
||||
.FirstOrDefaultAsync();
|
||||
return Ok(isAdmin ?? false);
|
||||
}
|
||||
|
||||
/// <summary>Future-proofing for a hypothetical
|
||||
/// <c>role/communityteam/{id}</c> probe — not referenced by the 2020
|
||||
/// build, but mirrors the developer shape so a patched watch can
|
||||
/// distinguish the two roles without a server change.</summary>
|
||||
[HttpGet("/role/communityteam/{accountId:long}")]
|
||||
[HttpGet("/api/role/communityteam/{accountId:long}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<bool>> IsCommunityTeam(long accountId)
|
||||
{
|
||||
var isCt = await db.Players
|
||||
.Where(p => p.Id == accountId)
|
||||
.Select(p => (bool?)p.IsCommunityTeam)
|
||||
.FirstOrDefaultAsync();
|
||||
return Ok(isCt ?? false);
|
||||
}
|
||||
}
|
||||
@@ -1,1100 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Controllers.Match;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Rooms.V2;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/rooms/* — DB-backed implementation of the watch's
|
||||
/// Rooms tab.
|
||||
///
|
||||
/// Replaces the previous stubs in MiscStubsController for:
|
||||
/// v1/hot, v1/filters, v1/tags, v1/search, v2/baserooms, v2/myrooms,
|
||||
/// v2/mybookmarks, v2/mymoderated, v2/mysubscribed, v2/myrecent,
|
||||
/// v2/name/{name}, v2/{id}, v3/{id}, v3/details/{id}, v4/details/{id},
|
||||
/// v1/personaldetails/{id}, v2/personaldetails/{id}.
|
||||
///
|
||||
/// All endpoints serialize Rooms via RoomService.ToWireRoom which matches
|
||||
/// Room.Deserialize at RVA 0x114E430 (PascalCase keys, all 16 required
|
||||
/// fields plus optional VR-low / mobile / mic-mute flags).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class RoomsController(
|
||||
RoomService rooms,
|
||||
DorkNetDbContext db,
|
||||
PlayerPresenceService presence,
|
||||
OnlinePresenceService onlinePresence,
|
||||
DomainConfig domain,
|
||||
NotificationService notifications,
|
||||
ILogger<RoomsController> logger) : ControllerBase
|
||||
{
|
||||
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
|
||||
|
||||
// ── Browse / Hot / Search ────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// `Rooms.GetHotRooms(roomScoreType, tags)` — the watch's "Trending" tab.
|
||||
/// `roomScoreType` is ignored (we always sort by HotScore). `tags` is
|
||||
/// passed through as a substring filter against the comma-separated
|
||||
/// TagsCsv column (e.g. `#community`, `#recroomoriginal`).
|
||||
/// </summary>
|
||||
[HttpGet("api/rooms/v1/hot")]
|
||||
public async Task<IActionResult> Hot(
|
||||
[FromQuery] string? roomScoreType,
|
||||
[FromQuery] string? tags)
|
||||
=> Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList());
|
||||
|
||||
/// <summary>
|
||||
/// `Rooms.SearchForRooms(query)` — free-text room search.
|
||||
/// </summary>
|
||||
[HttpGet("api/rooms/v1/search")]
|
||||
[HttpGet("api/rooms/v2/search")]
|
||||
public async Task<IActionResult> Search(
|
||||
[FromQuery(Name = "query")] string? query,
|
||||
[FromQuery(Name = "value")] string? value)
|
||||
=> Ok((await rooms.SearchAsync(query ?? value ?? string.Empty))
|
||||
.Select(RoomService.ToWireRoom).ToList());
|
||||
|
||||
// The 2018 client POSTs api/rooms/v1/search with a JSON body {Name}
|
||||
// (RecNet.cs:85205) instead of a query param. Same projection.
|
||||
[HttpPost("api/rooms/v1/search")]
|
||||
[HttpPost("api/rooms/v2/search")]
|
||||
public async Task<IActionResult> SearchPost([FromBody] RoomSearch2018? body)
|
||||
=> Ok((await rooms.SearchAsync(body?.Name ?? string.Empty))
|
||||
.Select(RoomService.ToWireRoom).ToList());
|
||||
|
||||
/// <summary>GET <c>api/rooms/v2/live</c> — currently-active rooms
|
||||
/// (rooms with at least one player presence). Same shape as Hot
|
||||
/// — order by HotScore as a proxy for "live activity" since we
|
||||
/// don't track instance population separately yet.</summary>
|
||||
[HttpGet("api/rooms/v2/live")]
|
||||
public async Task<IActionResult> Live([FromQuery] string? roomScoreType)
|
||||
=> Ok((await rooms.HotAsync(null)).Select(RoomService.ToWireRoom).ToList());
|
||||
|
||||
/// <summary>
|
||||
/// `Rooms.GetBaseRooms` — list of "base" rooms used by Rec Room Originals
|
||||
/// to seed the room creation UI. Returns the same set as #recroomoriginal.
|
||||
/// </summary>
|
||||
[HttpGet("api/rooms/v2/baserooms")]
|
||||
[HttpGet("api/rooms/v3/baserooms")]
|
||||
public async Task<IActionResult> BaseRooms()
|
||||
=> Ok((await rooms.HotAsync("#recroomoriginal")).Select(RoomService.ToWireRoom).ToList());
|
||||
|
||||
/// <summary>
|
||||
/// `Rooms.GetFilters` — pinned/popular tag chips shown above the room
|
||||
/// list in the watch.
|
||||
/// </summary>
|
||||
// Tag wire format: NO leading '#'. The watch's RoomFilterChip prepends
|
||||
// '#' at render time, so sending "#community" produces "##community" in
|
||||
// the UI. Names stored in the DB also drop the prefix.
|
||||
[HttpGet("api/rooms/v1/filters")]
|
||||
public IActionResult Filters() => Ok(new
|
||||
{
|
||||
PinnedFilters = new[] { "community", "recroomoriginal", "featured", "quest" },
|
||||
PopularFilters = new[] { "paintball", "dodgeball", "soccer", "lasertag", "discgolf" },
|
||||
});
|
||||
|
||||
[HttpGet("api/rooms/v1/tags")]
|
||||
public IActionResult Tags() => Ok(new[] {
|
||||
"community", "recroomoriginal", "featured", "quest",
|
||||
"paintball", "dodgeball", "soccer", "lasertag", "discgolf",
|
||||
});
|
||||
|
||||
[HttpGet("api/rooms/v1/pinnedtags")]
|
||||
public IActionResult PinnedTags() => Ok(new[] { "community", "recroomoriginal", "featured" });
|
||||
|
||||
[HttpGet("api/rooms/v1/populartags")]
|
||||
public IActionResult PopularTags() => Ok(new[] { "paintball", "dodgeball", "soccer" });
|
||||
|
||||
// ── Single room lookups ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// `Rooms.GetByName(roomName)` — used when the client constructs the
|
||||
/// share-link target or when /goto/room/{name} runs. Falls back to a
|
||||
/// synthesised "Room_<name>" for unknown names so the deserializer
|
||||
/// doesn't crash on 404.
|
||||
/// </summary>
|
||||
[HttpGet("api/rooms/v1/name/{roomName}")]
|
||||
[HttpGet("api/rooms/v2/name/{roomName}")]
|
||||
[HttpGet("api/rooms/v3/name/{roomName}")]
|
||||
public async Task<IActionResult> ByName(string roomName)
|
||||
{
|
||||
var r = await rooms.GetByNameAsync(roomName);
|
||||
return Ok(RoomService.ToWireRoom(r ?? Synthetic(roomName)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// `Rooms.GetById(roomId)` — same shape as ByName.
|
||||
/// </summary>
|
||||
[HttpGet("api/rooms/v1/{roomId:long}")]
|
||||
[HttpGet("api/rooms/v2/{roomId:long}")]
|
||||
[HttpGet("api/rooms/v3/{roomId:long}")]
|
||||
public async Task<IActionResult> ById(long roomId)
|
||||
{
|
||||
var r = await rooms.GetByIdAsync(roomId);
|
||||
return Ok(RoomService.ToWireRoom(r ?? Synthetic($"Room_{roomId}", roomId)));
|
||||
}
|
||||
|
||||
// ── RoomDetails (the boot-critical one) ─────────────────────────────
|
||||
//
|
||||
// /v4/details/{roomId} is hit *during* the matchmaking goto flow.
|
||||
// RoomDetails.Deserialize at RVA 0x114C580 expects PascalCase keys:
|
||||
// Room (object), Scenes (list), CoOwners/InvitedCoOwners,
|
||||
// Moderators/InvitedModerators, Hosts/InvitedHosts (all int lists),
|
||||
// CheerCount, FavoriteCount, VisitCount, Tags.
|
||||
//
|
||||
// Scenes is populated for every room. The persistence coroutine
|
||||
// (`RoomPersistenceManager+<DownloadRoomDataBlobCoroutine>`) runs
|
||||
// unconditionally on scene load and dereferences
|
||||
// `Rooms.LocalRoomScene.DataBlobName` on its first MoveNext — so
|
||||
// returning Scenes=[] makes LocalRoomScene null and NPEs the boot.
|
||||
//
|
||||
// The historical "Scenes=[] short-circuits the persistence manager"
|
||||
// comment that used to live here was the reverse of what actually
|
||||
// happens in this build. Confirmed empirically with the [req] log:
|
||||
// /v4/details/1 fires before scene load for the dorm too, so
|
||||
// Rooms.LocalRoom is populated and the coroutine just needs a
|
||||
// populated Scenes[0] to get a non-null LocalRoomScene.
|
||||
|
||||
/// <summary>GET api/rooms/v1/datahistory/{roomId} — returns the
|
||||
/// version history for a room as a list of saves. Drives the
|
||||
/// watch's "Restore to old version" UI in the room-settings tab.
|
||||
///
|
||||
/// The watch sometimes calls this with <c>roomId=0</c>, which we
|
||||
/// interpret as "the caller's currently-presence-tracked room"
|
||||
/// (typically their dorm). Anything else is a literal room id.
|
||||
///
|
||||
/// Wire shape: <c>List<RoomDataHistoryDTO></c>. Deserializer
|
||||
/// reads EXACTLY three PascalCase keys (verified at
|
||||
/// <c>Cpp2IL_ISIL/.../RecNet/Rooms_NestedType_RoomDataHistoryDTO.txt</c>):
|
||||
/// <c>RoomDataHistoryId</c> (long), <c>DataBlobName</c> (string),
|
||||
/// <c>CreatedAt</c> (DateTime). Extra/wrong keys aren't ignored —
|
||||
/// missing required keys throw <c>KeyNotFoundException</c> in
|
||||
/// LitJson and the watch shows an empty history list. Don't add
|
||||
/// helper fields here; pipe them through a separate admin endpoint
|
||||
/// if needed.</summary>
|
||||
[HttpGet("api/rooms/v1/datahistory/{roomId:long}")]
|
||||
[HttpGet("api/rooms/v2/datahistory/{roomId:long}")]
|
||||
public async Task<IActionResult> DataHistory(long roomId)
|
||||
{
|
||||
// 0 is a sentinel — fall back to the caller's current room
|
||||
// from PlayerPresenceService.
|
||||
if (roomId == 0 && CurrentPlayerId is long pid &&
|
||||
presence.GetRoom(pid)?.RoomId is long currentId)
|
||||
{
|
||||
roomId = currentId;
|
||||
}
|
||||
if (roomId == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var blobs = await db.RoomDataBlobs
|
||||
.Where(b => b.RoomId == roomId)
|
||||
.OrderByDescending(b => b.UploadedAt)
|
||||
.Take(50)
|
||||
.Select(b => new
|
||||
{
|
||||
RoomDataHistoryId = b.Id,
|
||||
DataBlobName = b.BlobName,
|
||||
CreatedAt = b.UploadedAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(blobs);
|
||||
}
|
||||
|
||||
/// <summary>POST api/rooms/v4/saveData — the watch's
|
||||
/// <c>RecNet.Rooms.UploadLocalRoomSceneData</c> save flow.
|
||||
/// Body shape mirrors <c>RecNet.Rooms+SaveRoomSceneRequest</c>
|
||||
/// (Cpp2IL_CS RecNet/Rooms.cs:858): RoomSceneId, RoomDataFilename,
|
||||
/// InventionUsages[], CreatorActionContext, RequestPlayerId.
|
||||
///
|
||||
/// Persists the new <c>CurrentDataBlobName</c> on the room
|
||||
/// (and, for dorms, the per-player DormStateEntity) so the next
|
||||
/// /v4/details + cdn fetch loads what just got uploaded. Returns
|
||||
/// a populated <c>RoomScene</c> JSON — that's what the watch's
|
||||
/// continuation callback (b__1 in DisplayClass124_0) expects;
|
||||
/// returning an empty body trips a "HTTP Error 404" path in
|
||||
/// BestHTTP's Future framework even though the HTTP status was
|
||||
/// fine.</summary>
|
||||
[HttpPost("api/rooms/v4/saveData")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SaveData([FromBody] SaveRoomSceneRequest body)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
|
||||
// Resolve the room. The watch sends RoomSceneId (the scene's
|
||||
// OrderIndex per BuildRoomDetails above — 0 for single-scene
|
||||
// rooms). To find the parent room we cross-reference the
|
||||
// player's current presence (which RecordResponseAsync stamps
|
||||
// on every /goto). This is the only signal we have without a
|
||||
// RoomId on the request body.
|
||||
var current = presence.GetRoom(pid);
|
||||
if (current is null)
|
||||
{
|
||||
// No active room — without it we can't pick a target row.
|
||||
// Return a degenerate RoomScene so the deserializer doesn't
|
||||
// throw, but flag CanMatchmakeInto=false so the watch
|
||||
// doesn't think the save succeeded.
|
||||
return Ok(new
|
||||
{
|
||||
RoomSceneId = body.RoomSceneId,
|
||||
RoomId = 0L,
|
||||
RoomSceneLocationId = "76d98498-60a1-430c-ab76-b54a29b7a163",
|
||||
Name = "Home",
|
||||
IsSandbox = false,
|
||||
DataBlobName = body.RoomDataFilename ?? string.Empty,
|
||||
MaxPlayers = 8,
|
||||
CanMatchmakeInto = false,
|
||||
DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
});
|
||||
}
|
||||
|
||||
var roomId = current.RoomId;
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null) return NotFound(new { error = "room_not_found" });
|
||||
|
||||
var newBlob = body.RoomDataFilename?.Trim() ?? string.Empty;
|
||||
|
||||
// Multi-scene rooms have a RoomScenes row per scene. The 2020
|
||||
// client usually sends the scene's OrderIndex, but some flows
|
||||
// send a stale/non-index id while the local room is still a
|
||||
// single-scene dorm/custom room. In that common single-scene
|
||||
// case, stamp the only row anyway; otherwise the pushed
|
||||
// RoomDetails can keep Scenes[0].DataBlobName at v(N) while
|
||||
// Rooms.CurrentDataBlobName is v(N+1), and the post-save reload
|
||||
// reads stale bytes.
|
||||
var sceneRowsForRoom = await db.RoomScenes
|
||||
.Where(s => s.RoomId == roomId)
|
||||
.OrderBy(s => s.OrderIndex)
|
||||
.ToListAsync();
|
||||
var sceneRow = sceneRowsForRoom.FirstOrDefault(s => s.OrderIndex == (int)body.RoomSceneId)
|
||||
?? (sceneRowsForRoom.Count == 1 ? sceneRowsForRoom[0] : null);
|
||||
|
||||
// Dorms: per-player state, keyed by the calling player so two
|
||||
// people in the same dorm room ID save into separate slots.
|
||||
if (room.IsDormRoom)
|
||||
{
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
|
||||
var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == pid);
|
||||
if (dormState is null)
|
||||
{
|
||||
dormState = new DormStateEntity { PlayerId = pid };
|
||||
db.DormStates.Add(dormState);
|
||||
}
|
||||
dormState.CurrentDataBlobName = newBlob;
|
||||
dormState.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Owned rooms: only the creator can save. Admin override
|
||||
// intentionally not included — admins should use /restore
|
||||
// when they need to roll a room back.
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
}
|
||||
|
||||
if (sceneRow is not null)
|
||||
{
|
||||
sceneRow.DataBlobName = newBlob;
|
||||
sceneRow.DataModifiedAt = DateTime.UtcNow;
|
||||
}
|
||||
await db.Rooms
|
||||
.Where(r => r.Id == roomId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(r => r.CurrentDataBlobName, newBlob)
|
||||
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// The watch's MasterUploadRoomDataBlobCoroutine waits for BOTH
|
||||
// the /upload + /saveData responses AND a SubscriptionUpdateRoom
|
||||
// SignalR push before flipping the save state machine out of
|
||||
// SAVING. Without this push the spinner stays "Saving..." forever
|
||||
// even though both HTTP calls returned 200 — verified via the
|
||||
// RoomDetailsUpdatedCallback closure in
|
||||
// RoomPersistenceManager+<>c__DisplayClass174_0.
|
||||
//
|
||||
// For dorms we re-shape with the caller as creator + their own
|
||||
// DormStateEntity blob name so the watch's set_LocalRoomDetails
|
||||
// matches LocalRoomDetails.RoomId and triggers
|
||||
// SafeRaiseRoomDetailsUpdated(roomId).
|
||||
var pushRoom = room;
|
||||
if (room.IsDormRoom)
|
||||
pushRoom = CloneWithCreator(room, pid, newBlob);
|
||||
else
|
||||
pushRoom = CloneWithCreator(room, room.CreatorPlayerId, newBlob);
|
||||
foreach (var scene in sceneRowsForRoom)
|
||||
{
|
||||
if (sceneRow is not null && scene.Id == sceneRow.Id)
|
||||
{
|
||||
scene.DataBlobName = newBlob;
|
||||
scene.DataModifiedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
var savedSceneId = sceneRow?.OrderIndex ?? body.RoomSceneId;
|
||||
var roomDetailsPayload = BuildRoomDetails(pushRoom, sceneRowsForRoom, savedSceneId, newBlob);
|
||||
logger.LogInformation(
|
||||
"[rooms-save] saveData player={PlayerId} room={RoomId} requestedScene={RequestedSceneId} savedScene={SavedSceneId} blob={BlobName} sceneRow={SceneRowFound} scenes={SceneCount}",
|
||||
pid, roomId, body.RoomSceneId, savedSceneId, newBlob, sceneRow is not null, sceneRowsForRoom.Count);
|
||||
var playersInInstance = onlinePresence.OnlinePlayerIds()
|
||||
.Where(playerId =>
|
||||
{
|
||||
var playerRoom = presence.GetRoom(playerId);
|
||||
return playerRoom is not null
|
||||
&& playerRoom.RoomId == roomId
|
||||
&& playerRoom.RoomInstanceId == current.RoomInstanceId;
|
||||
})
|
||||
.Append(pid)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
logger.LogInformation(
|
||||
"[rooms-save] fanout room update player={PlayerId} room={RoomId} instance={InstanceId} blob={BlobName} recipients={RecipientCount}",
|
||||
pid, roomId, current.RoomInstanceId, newBlob, playersInInstance.Length);
|
||||
|
||||
var updatedRoomInstances = new Dictionary<long, RoomInstanceDto>();
|
||||
foreach (var playerId in playersInInstance)
|
||||
{
|
||||
var currentPresence = presence.GetRoom(playerId);
|
||||
if (currentPresence is null && playerId == pid)
|
||||
{
|
||||
currentPresence = current;
|
||||
}
|
||||
if (currentPresence is null) continue;
|
||||
|
||||
currentPresence.DataBlob = newBlob;
|
||||
presence.SetRoom(playerId, currentPresence);
|
||||
updatedRoomInstances[playerId] = currentPresence;
|
||||
}
|
||||
|
||||
// SubscriptionUpdateRoom delivers the new RoomDetails (with
|
||||
// Scenes[0].DataBlobName = newBlob). Rooms.OnSubscriptionUpdateRoom
|
||||
// → StoreRoomDetailsInCache → set_LocalRoomDetails iterates
|
||||
// Scenes for one matching RoomSceneId == LocalSubRoomId and writes
|
||||
// LocalRoomScene to that scene, which is what DownloadRoomData
|
||||
// BlobCoroutine reads when PostSaveReloading runs.
|
||||
foreach (var playerId in playersInInstance)
|
||||
{
|
||||
await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdateRoom, roomDetailsPayload);
|
||||
}
|
||||
|
||||
// RoomInstanceUpdate keeps the matchmaking-side dataBlob and any
|
||||
// friends-list "in room X" indicators in sync. No nudge — live
|
||||
// doesn't send one, and the fake sub-room id confuses the watch's
|
||||
// OnLocalGameSessionUpdated scene re-resolve.
|
||||
foreach (var (playerId, currentPresence) in updatedRoomInstances)
|
||||
{
|
||||
await notifications.NotifyAsync(
|
||||
playerId,
|
||||
PushNotificationId.SubscriptionUpdateGameSession,
|
||||
currentPresence);
|
||||
}
|
||||
|
||||
// PresenceUpdate keeps the next heartbeat aligned with the blob
|
||||
// applied through the room-details push above.
|
||||
foreach (var (playerId, currentPresence) in updatedRoomInstances)
|
||||
{
|
||||
var presencePayload = new
|
||||
{
|
||||
playerId = (int)playerId,
|
||||
statusVisibility = 1,
|
||||
deviceClass = 0,
|
||||
vrMovementMode = 0,
|
||||
roomInstance = new
|
||||
{
|
||||
roomInstanceId = currentPresence.RoomInstanceId,
|
||||
roomId = currentPresence.RoomId,
|
||||
subRoomId = currentPresence.SubRoomId,
|
||||
location = currentPresence.Location,
|
||||
photonRegionId = currentPresence.PhotonRegionId,
|
||||
photonRoomId = currentPresence.PhotonRoomId,
|
||||
maxCapacity = currentPresence.MaxCapacity,
|
||||
isFull = currentPresence.IsFull,
|
||||
isPrivate = currentPresence.IsPrivate,
|
||||
isInProgress = currentPresence.IsInProgress,
|
||||
dataBlob = currentPresence.DataBlob,
|
||||
eventId = currentPresence.EventId,
|
||||
name = currentPresence.Name,
|
||||
},
|
||||
};
|
||||
await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdatePresence, presencePayload);
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
RoomSceneId = savedSceneId,
|
||||
RoomId = roomId,
|
||||
RoomSceneLocationId = sceneRow?.RoomSceneLocationId ?? room.LocationReplicationId,
|
||||
Name = sceneRow?.Name ?? "Home",
|
||||
IsSandbox = sceneRow?.IsSandbox ?? false,
|
||||
DataBlobName = newBlob,
|
||||
MaxPlayers = sceneRow?.MaxPlayers ?? 8,
|
||||
CanMatchmakeInto = sceneRow?.CanMatchmakeInto ?? true,
|
||||
DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
});
|
||||
}
|
||||
|
||||
public class SaveRoomSceneRequest
|
||||
{
|
||||
public long RoomSceneId { get; set; }
|
||||
public string? RoomDataFilename { get; set; }
|
||||
public List<long>? InventionUsages { get; set; }
|
||||
public CreatorActionContextDto? CreatorActionContext { get; set; }
|
||||
public long RequestPlayerId { get; set; }
|
||||
}
|
||||
|
||||
public class CreatorActionContextDto
|
||||
{
|
||||
public bool IsTeachableMomentRunning { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST api/rooms/v1/restore — switch a room back to a
|
||||
/// previous save. Body: roomId, blobName (or version id). Updates
|
||||
/// the player's DormStateEntity (for dorms) or RoomEntity
|
||||
/// CurrentDataBlobName (for owned rooms) so the next visit loads
|
||||
/// the historical bytes.</summary>
|
||||
/// <summary>POST <c>api/rooms/v1/datahistory/restore</c> — restore a
|
||||
/// room to a previous save.
|
||||
///
|
||||
/// Request body: <c>RestoreDataHistoryRequest</c> serialized by
|
||||
/// Unity's <c>JsonUtility.ToJson</c>, which uses C# field names.
|
||||
/// Verified at <c>Cpp2IL_ISIL/.../Rooms.txt:13513-14</c> — the only
|
||||
/// field on the request type is the long history id at offset +16,
|
||||
/// and Unity's serializer names it <c>roomDataHistoryId</c>. We
|
||||
/// look up <see cref="RoomDataBlobEntity"/> by that id, find its
|
||||
/// parent room, and rewrite <c>CurrentDataBlobName</c> (and the
|
||||
/// dorm-state row for personal dorms) so the next room reload
|
||||
/// downloads the historical bytes.
|
||||
///
|
||||
/// Response: <c>CreateModifyRoomSceneResponse</c> — keys are
|
||||
/// PascalCase per <c>Cpp2IL_ISIL/.../Rooms_NestedType_CreateModifyRoomSceneResponse.txt</c>:
|
||||
/// <c>Result</c> (int, CreateModifyRoomStatus: 0=Success), and
|
||||
/// <c>RoomScene</c> (the restored scene). The watch's
|
||||
/// <c>ProcessCreateModifyRoomSceneResponse</c> chain expects both;
|
||||
/// returning a bare <c>{restored: "..."}</c> like the old impl makes
|
||||
/// the watch's deserializer throw and the restore silently fails.
|
||||
/// The legacy form-urlencoded path stays under
|
||||
/// <c>/api/rooms/v1/restore</c> for the admin SPA's restore UI.
|
||||
/// </summary>
|
||||
public sealed class RestoreDataHistoryRequest
|
||||
{
|
||||
// Unity JsonUtility serializes C# private backing fields by
|
||||
// their declared name. The watch's request type has a single
|
||||
// field which Unity emits as "roomDataHistoryId" (camelCase) —
|
||||
// [JsonPropertyName] here matches that wire key explicitly so
|
||||
// System.Text.Json picks it up regardless of any future global
|
||||
// naming policy.
|
||||
[System.Text.Json.Serialization.JsonPropertyName("roomDataHistoryId")]
|
||||
public long RoomDataHistoryId { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/rooms/v1/datahistory/restore")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> RestoreDataHistory([FromBody] RestoreDataHistoryRequest body)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var blob = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.Id == body.RoomDataHistoryId);
|
||||
if (blob is null) return NotFound(new { error = "version_not_found" });
|
||||
|
||||
var room = await rooms.GetByIdAsync(blob.RoomId);
|
||||
if (room is null) return NotFound(new { error = "room_not_found" });
|
||||
|
||||
// Ownership: only the dorm owner / room creator can restore.
|
||||
// Admin override is intentionally NOT included here — restore
|
||||
// is destructive (overrides the player's chosen state) so we
|
||||
// require the actual creator.
|
||||
if (room.CreatorPlayerId != pid)
|
||||
return Forbid();
|
||||
|
||||
if (room.IsDormRoom)
|
||||
{
|
||||
var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == pid);
|
||||
if (dormState is null)
|
||||
{
|
||||
dormState = new DormStateEntity { PlayerId = pid };
|
||||
db.DormStates.Add(dormState);
|
||||
}
|
||||
dormState.CurrentDataBlobName = blob.BlobName;
|
||||
dormState.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
await db.Rooms
|
||||
.Where(r => r.Id == room.Id)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
|
||||
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Push the same SubscriptionUpdateRoom the save flow sends so
|
||||
// the watch's StoreRoomDetailsInCache → SafeRaiseRoomDetailsUpdated
|
||||
// chain fires and any open room-detail panels refresh.
|
||||
var pushRoom = room.IsDormRoom ? CloneWithCreator(room, pid, blob.BlobName) : CloneWithCreator(room, room.CreatorPlayerId, blob.BlobName);
|
||||
var allScenes = await db.RoomScenes
|
||||
.Where(s => s.RoomId == room.Id)
|
||||
.OrderBy(s => s.OrderIndex)
|
||||
.ToListAsync();
|
||||
var roomDetailsPayload = BuildRoomDetails(pushRoom, allScenes, 0L, blob.BlobName);
|
||||
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, roomDetailsPayload);
|
||||
|
||||
// CreateModifyRoomSceneResponse shape. RoomScene fields match
|
||||
// RecNet.RoomScene.Deserialize (PascalCase, RoomSceneId,
|
||||
// RoomId, RoomSceneLocationId, Name, IsSandbox, DataBlobName,
|
||||
// MaxPlayers, CanMatchmakeInto, DataModifiedAt).
|
||||
return Ok(new
|
||||
{
|
||||
Result = 0, // CreateModifyRoomStatus.Success
|
||||
RoomScene = new
|
||||
{
|
||||
RoomSceneId = 0L,
|
||||
RoomId = room.Id,
|
||||
RoomSceneLocationId = room.LocationReplicationId,
|
||||
Name = room.Name,
|
||||
IsSandbox = false,
|
||||
DataBlobName = blob.BlobName,
|
||||
MaxPlayers = 8,
|
||||
CanMatchmakeInto = true,
|
||||
DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Legacy form-urlencoded restore — kept for the admin SPA's
|
||||
/// "Restore version" picker which posts {roomId, blobName} fields.
|
||||
/// New callers (the watch) use the JSON variant above.</summary>
|
||||
[HttpPost("api/rooms/v1/restore")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> RestoreVersionLegacy(
|
||||
[FromForm] long roomId,
|
||||
[FromForm] string? blobName,
|
||||
[FromForm] long version = 0)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null) return NotFound(new { error = "room_not_found" });
|
||||
|
||||
var blob = !string.IsNullOrEmpty(blobName)
|
||||
? await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == blobName && b.RoomId == roomId)
|
||||
: await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.Id == version && b.RoomId == roomId);
|
||||
if (blob is null) return NotFound(new { error = "version_not_found" });
|
||||
|
||||
if (room.CreatorPlayerId != pid)
|
||||
return Forbid();
|
||||
|
||||
if (room.IsDormRoom)
|
||||
{
|
||||
var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == pid);
|
||||
if (dormState is null)
|
||||
{
|
||||
dormState = new DormStateEntity { PlayerId = pid };
|
||||
db.DormStates.Add(dormState);
|
||||
}
|
||||
dormState.CurrentDataBlobName = blob.BlobName;
|
||||
dormState.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
await db.Rooms
|
||||
.Where(r => r.Id == roomId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
|
||||
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { restored = blob.BlobName });
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// (<c>Cpp2IL_ISIL/.../PersonalRoomDetails.txt:85-91</c>):
|
||||
/// <c>IsCheering</c> - bool, REQUIRED (Util.GetKey, throws if missing)
|
||||
/// <c>IsBookmarked</c> - bool, REQUIRED
|
||||
/// Both keys are PascalCase. Returning the full room-details payload
|
||||
/// here (which is what we used to do) makes the deserialiser throw
|
||||
/// KeyNotFoundException for IsCheering, and that blocks the entire
|
||||
/// /goto/* flow because RecNet.Rooms.GetPersonalRoomDetails is
|
||||
/// awaited before SessionManager.JoinRoom can run.</summary>
|
||||
[HttpGet("api/rooms/v2/personaldetails/{roomId:long}")]
|
||||
[HttpGet("api/rooms/v1/personaldetails/{roomId:long}")]
|
||||
public async Task<IActionResult> PersonalDetails(long roomId)
|
||||
{
|
||||
var pid = CurrentPlayerId ?? 0;
|
||||
bool cheering = false, bookmarked = false;
|
||||
if (pid > 0)
|
||||
{
|
||||
cheering = await db.Cheers.AnyAsync(c => c.TargetRoomId == roomId && c.FromPlayerId == pid);
|
||||
bookmarked = await db.RoomBookmarks.AnyAsync(b => b.RoomId == roomId && b.PlayerId == pid);
|
||||
}
|
||||
return Ok(new { IsCheering = cheering, IsBookmarked = bookmarked });
|
||||
}
|
||||
|
||||
[HttpGet("api/rooms/v4/details/{roomId:long}")]
|
||||
[HttpGet("api/rooms/v3/details/{roomId:long}")]
|
||||
public async Task<IActionResult> Details(long roomId)
|
||||
{
|
||||
var r = await rooms.GetByIdAsync(roomId);
|
||||
var room = r ?? Synthetic($"Room_{roomId}", roomId);
|
||||
|
||||
// Dorm-specific shaping: each player has their own first-class
|
||||
// dorm row. Only the dorm's actual creator should see Creator
|
||||
// role / Maker Pen. Guests loading someone else's dorm must see
|
||||
// the owner as CreatorPlayerId, while still receiving the
|
||||
// owner's DormStateEntity blob so they load the same room state.
|
||||
if (room.IsDormRoom)
|
||||
{
|
||||
var dormBlobName = await db.DormStates
|
||||
.Where(d => d.PlayerId == room.CreatorPlayerId)
|
||||
.Select(d => d.CurrentDataBlobName)
|
||||
.FirstOrDefaultAsync()
|
||||
?? string.Empty;
|
||||
room = CloneWithCreator(room, room.CreatorPlayerId, dormBlobName);
|
||||
}
|
||||
|
||||
var scenes = await db.RoomScenes
|
||||
.Where(s => s.RoomId == room.Id)
|
||||
.OrderBy(s => s.OrderIndex)
|
||||
.ToListAsync();
|
||||
var roomRoles = await db.RoomRoles
|
||||
.Where(r => r.RoomId == room.Id)
|
||||
.ToListAsync();
|
||||
return Ok(BuildRoomDetails(room, scenes, roles: roomRoles));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shallow clone of a RoomEntity with a different CreatorPlayerId
|
||||
/// and (optionally) a different CurrentDataBlobName. Used to give
|
||||
/// the per-caller dorm the appearance of being authored by the
|
||||
/// caller and pointing at the caller's own most recent save.
|
||||
/// Doesn't touch the DB — purely a response-shaping utility.
|
||||
/// </summary>
|
||||
public static RoomEntity CloneWithCreator(
|
||||
RoomEntity src, long creatorId, string? overrideBlobName = null) => new()
|
||||
{
|
||||
Id = src.Id,
|
||||
Name = src.Name,
|
||||
Description = src.Description,
|
||||
CreatorPlayerId = creatorId,
|
||||
ImageName = src.ImageName,
|
||||
State = src.State,
|
||||
Accessibility = src.Accessibility,
|
||||
SupportsLevelVoting = src.SupportsLevelVoting,
|
||||
IsAGRoom = src.IsAGRoom,
|
||||
IsDormRoom = src.IsDormRoom,
|
||||
CloningAllowed = src.CloningAllowed,
|
||||
SupportsVRLow = src.SupportsVRLow,
|
||||
SupportsMobile = src.SupportsMobile,
|
||||
SupportsScreens = src.SupportsScreens,
|
||||
SupportsWalkVR = src.SupportsWalkVR,
|
||||
SupportsTeleportVR = src.SupportsTeleportVR,
|
||||
AllowsJuniors = src.AllowsJuniors,
|
||||
RoomWarningMask = src.RoomWarningMask,
|
||||
CustomRoomWarning = src.CustomRoomWarning,
|
||||
DisableMicAutoMute = src.DisableMicAutoMute,
|
||||
LocationReplicationId = src.LocationReplicationId,
|
||||
TagsCsv = src.TagsCsv,
|
||||
CheerCount = src.CheerCount,
|
||||
FavoriteCount = src.FavoriteCount,
|
||||
VisitCount = src.VisitCount,
|
||||
HotScore = src.HotScore,
|
||||
CreatedAt = src.CreatedAt,
|
||||
UpdatedAt = src.UpdatedAt,
|
||||
CurrentDataBlobName = overrideBlobName ?? src.CurrentDataBlobName,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Wire shape for the RoomDetails object — matches RoomDetails.Deserialize
|
||||
/// at RVA 0x114C580. JSON key names DO NOT necessarily match the C#
|
||||
/// property/field names from dump.cs — the deserializer is hand-written
|
||||
/// and uses different (mostly PascalCase) literal strings. Verified
|
||||
/// empirically: this exact shape gets past Deserialize successfully.
|
||||
/// </summary>
|
||||
public static object BuildRoomDetails(
|
||||
RoomEntity room,
|
||||
IReadOnlyList<RoomSceneEntity>? sceneRows = null,
|
||||
long? overrideSceneId = null,
|
||||
string? overrideDataBlobName = null,
|
||||
IReadOnlyList<RoomRoleEntity>? roles = null)
|
||||
{
|
||||
// Tags is a list of OBJECTS, not strings. RoomDetails.Deserialize
|
||||
// does Util.DeserializeList<RoomTag>(tags) — each entry is parsed as
|
||||
// a Dictionary with `Type` (int RoomTagType: 0=Auto, 1=PlayerAdded,
|
||||
// 2=AG) and `Tag` (string). Sending bare strings crashes:
|
||||
// InvalidCastException: Unable to cast 'String' to 'Dictionary`2'
|
||||
var tags = string.IsNullOrEmpty(room.TagsCsv)
|
||||
? Array.Empty<object>()
|
||||
: room.TagsCsv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(t => (object)new { Type = 0, Tag = t })
|
||||
.ToArray();
|
||||
|
||||
// Scenes population: every room must produce a Scenes[0] entry
|
||||
// with RoomSceneId=0 (matching the matchmaking SubRoomId=0) so
|
||||
// that Rooms.set_LocalRoomDetails actually transitions
|
||||
// Rooms.LocalRoomScene to the new room's scene reference.
|
||||
//
|
||||
// Why every room: the setter (verified in Cpp2IL ISIL of
|
||||
// RecNet.Rooms.set_LocalRoomDetails) iterates Scenes looking
|
||||
// for a RoomSceneId match; if none matches (Scenes=[] or
|
||||
// mismatched), it falls through WITHOUT writing LocalRoomScene
|
||||
// — so the field stays stale at whatever the previous room set.
|
||||
// After the dorm fix landed, that stale value started leaking
|
||||
// into the rec center: the dorm sets LocalRoomScene → user
|
||||
// goes to rec center → if we send Scenes=[], LocalRoomScene
|
||||
// STAYS pointing at the dorm scene → DownloadRoomDataBlob
|
||||
// Coroutine reads scene.DataBlobName from the dorm and
|
||||
// downloads room_1_v1.dat (smoking gun in the server log)
|
||||
// during the rec center load. The rec center master init
|
||||
// blocks → FROSTBITE.
|
||||
//
|
||||
// The DataBlobName field is what determines whether the
|
||||
// persistence coroutine actually downloads anything.
|
||||
// OnRoomDataBlobNameChanged (RoomPersistenceManager.txt ISIL
|
||||
// {027-040}) short-circuits when DataBlobName is empty: it
|
||||
// sets GetRoomDataBlobPromise to a CompletedPromise<byte[]>
|
||||
// with empty bytes and returns immediately — no HTTP fetch,
|
||||
// no DeserializeRoomDataBlobCoroutine, the master init
|
||||
// proceeds with default-zero permissions.
|
||||
//
|
||||
// • Dorm / customisable rooms: DataBlobName is either the
|
||||
// latest uploaded blob name (RoomEntity.CurrentDataBlobName,
|
||||
// written by StorageController on /upload) or, before any
|
||||
// save, "room_<id>_v1.dat" — which the catch-all serves
|
||||
// from RoomDataBlobService (the all-perms default blob).
|
||||
// • AG-Original rooms: DataBlobName="" → completed-promise
|
||||
// short-circuit → no download → no leaked stale-blob
|
||||
// behaviour → master flow proceeds → spawn fires. Maker
|
||||
// Pen stays locked, which matches public-server semantics
|
||||
// (you can't edit the rec center).
|
||||
//
|
||||
// Heuristic: "customisable" iff it's the dorm OR a user-created
|
||||
// room (CreatorPlayerId != 1, the seeded system account that
|
||||
// owns AG-Originals like the rec center). User clones inherit
|
||||
// the all-perms default blob until their first save replaces
|
||||
// it via StorageController; the rec center / paintball / etc.
|
||||
// serve "" so the persistence flow short-circuits.
|
||||
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1;
|
||||
var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName)
|
||||
? room.CurrentDataBlobName
|
||||
: customisable ? $"room_{room.Id}_v1.dat" : "";
|
||||
|
||||
// Multi-scene rooms (imported via tools/import-room.ps1) carry one
|
||||
// RoomSceneEntity row per sub-room. Emit the full Scenes[] array
|
||||
// when present so cross-scene portals (Lobby→Ch1, etc.) can
|
||||
// resolve. Single-scene rooms (every legacy room, every dorm,
|
||||
// every AG-Original) fall back to the synthesised Scenes[0] from
|
||||
// RoomEntity fields.
|
||||
object[] scenes;
|
||||
if (sceneRows is { Count: > 0 })
|
||||
{
|
||||
scenes = sceneRows
|
||||
.Select(s => (object)new
|
||||
{
|
||||
RoomSceneId = (long)s.OrderIndex,
|
||||
RoomId = room.Id,
|
||||
RoomSceneLocationId = s.RoomSceneLocationId,
|
||||
Name = s.Name,
|
||||
IsSandbox = s.IsSandbox,
|
||||
DataBlobName = !string.IsNullOrWhiteSpace(overrideDataBlobName) &&
|
||||
(overrideSceneId == s.OrderIndex || sceneRows.Count == 1)
|
||||
? overrideDataBlobName
|
||||
: s.DataBlobName,
|
||||
MaxPlayers = s.MaxPlayers,
|
||||
CanMatchmakeInto = s.CanMatchmakeInto,
|
||||
DataModifiedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
|
||||
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
scenes = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
RoomSceneId = 0L, // MUST match RoomInstance.SubRoomId
|
||||
RoomId = room.Id,
|
||||
RoomSceneLocationId = room.LocationReplicationId,
|
||||
Name = "Home",
|
||||
IsSandbox = false,
|
||||
DataBlobName = dataBlobName,
|
||||
MaxPlayers = 8,
|
||||
CanMatchmakeInto = true,
|
||||
// Use the room's actual UpdatedAt so the watch's
|
||||
// "is my local cache stale?" check sees an accurate
|
||||
// freshness timestamp. Hardcoding the build date
|
||||
// (2020-03-06) made the watch flag every dorm visit
|
||||
// as "room is not up to date" because the cached
|
||||
// local copy was always newer than the server's
|
||||
// declared timestamp.
|
||||
DataModifiedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt)
|
||||
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Owners tile (UIRoomDetails bottom-left) reads CoOwners — the
|
||||
// 2020 RoomDetails.Deserialize reads keys WITHOUT the "Ids"
|
||||
// suffix (verified at RoomDetails.txt:1096/1141/1146/1191/1196:
|
||||
// "CoOwners" / "InvitedCoOwners" / "Moderators" /
|
||||
// "InvitedModerators" / "Hosts" / "InvitedHosts"). The
|
||||
// GetListKey calls are strict — wrong names throw
|
||||
// KeyNotFoundException → "Failed to get room details:
|
||||
// Malformed Response" → dorm load stalls in a redirect loop.
|
||||
//
|
||||
// CreatorPlayerId is always implicitly a CoOwner so RR Originals
|
||||
// surface Coach (id=1) as the owner instead of rendering blank.
|
||||
// Additional grants come from RoomRoleEntity rows when supplied;
|
||||
// callers without role data (clone/myrooms/etc.) ship just the
|
||||
// implicit creator.
|
||||
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
|
||||
int[] PlayersIn(int role, bool accepted) => roleList
|
||||
.Where(r => r.Role == role && r.Accepted == accepted)
|
||||
.Select(r => (int)r.PlayerId)
|
||||
.ToArray();
|
||||
var coOwnerSet = new HashSet<int> { (int)room.CreatorPlayerId };
|
||||
foreach (var pid in PlayersIn(0, true)) coOwnerSet.Add(pid);
|
||||
return new
|
||||
{
|
||||
Room = RoomService.ToWireRoom(room),
|
||||
Scenes = scenes,
|
||||
CoOwners = coOwnerSet.ToArray(),
|
||||
InvitedCoOwners = PlayersIn(0, false),
|
||||
Moderators = PlayersIn(1, true),
|
||||
InvitedModerators = PlayersIn(1, false),
|
||||
Hosts = PlayersIn(2, true),
|
||||
InvitedHosts = PlayersIn(2, false),
|
||||
CheerCount = room.CheerCount,
|
||||
FavoriteCount = room.FavoriteCount,
|
||||
VisitCount = room.VisitCount,
|
||||
// VisitorCount is the official Rec.Net "unique visitors"
|
||||
// companion field (see Stats payload at apim.rec.net).
|
||||
// 2020 RoomDetails.Deserialize doesn't read it but leaving
|
||||
// it on the wire is harmless and lets the admin UI / any
|
||||
// future client surface the number.
|
||||
VisitorCount = room.VisitorCount,
|
||||
Tags = tags,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ── Clone / create ───────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// `Rooms.CloneRoom(roomIdToClone, name)` — POST api/rooms/v1/clone
|
||||
/// with body `{"Name":"<new>","RoomId":<sourceId>}`.
|
||||
///
|
||||
/// Response shape (CreateModifyRoomResponse.Deserialize, RVA 0xAF1950):
|
||||
/// Result (int CreateModifyRoomStatus: 0=Success, 1=Unknown,
|
||||
/// 2=PermissionDenied, 3=RoomNotActive,
|
||||
/// 4=RoomDoesNotExist, 5=RoomHasNoDataBlob, …)
|
||||
/// RoomDetails (RoomDetails object, only populated on Result=0)
|
||||
/// </summary>
|
||||
[HttpPost("api/rooms/v1/clone")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Clone([FromBody] CloneRoomRequest req)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
if (pid is null) return Unauthorized();
|
||||
|
||||
// CloneAsync sets the CreateModifyRoomStatus code itself
|
||||
// (Success/RoomDoesNotExist/DuplicateName/InappropriateName) so the
|
||||
// watch can show the right toast instead of a generic "Unknown".
|
||||
var result = await rooms.CloneAsync(req.RoomId, req.Name ?? string.Empty, pid.Value);
|
||||
return Ok(new
|
||||
{
|
||||
Result = result.Status,
|
||||
RoomDetails = result.Room is null ? null : BuildRoomDetails(result.Room),
|
||||
});
|
||||
}
|
||||
|
||||
public class CloneRoomRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
}
|
||||
|
||||
// 2018 POST api/rooms/v1/search body {Name} (RecNet.cs:85205).
|
||||
public class RoomSearch2018
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
// ── My-Rooms tabs ────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("api/rooms/v1/myrooms")]
|
||||
[HttpGet("api/rooms/v2/myrooms")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> MyRooms()
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
if (pid is null) return Ok(Array.Empty<object>());
|
||||
return Ok((await rooms.CreatedByAsync(pid.Value)).Select(RoomService.ToWireRoom).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("api/rooms/v1/mybookmarkedrooms")]
|
||||
[HttpGet("api/rooms/v2/mybookmarks")]
|
||||
[HttpGet("api/rooms/v2/mybookmarkedrooms")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> MyBookmarks()
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
if (pid is null) return Ok(Array.Empty<object>());
|
||||
return Ok((await rooms.BookmarkedByAsync(pid.Value)).Select(RoomService.ToWireRoom).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("api/rooms/v2/mymoderated")]
|
||||
[HttpGet("api/rooms/v1/modrooms")]
|
||||
[HttpGet("api/rooms/v2/mysubscribed")]
|
||||
[HttpGet("api/rooms/v1/myrecent")]
|
||||
[HttpGet("api/rooms/v2/myrecent")]
|
||||
public IActionResult MyOtherTabs() => Ok(Array.Empty<object>());
|
||||
|
||||
// ── Bookmark mutations ───────────────────────────────────────────────
|
||||
|
||||
[HttpPost("api/rooms/v1/bookmark/{roomId:long}")]
|
||||
[HttpPost("api/rooms/v2/bookmark/{roomId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Bookmark(long roomId)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
if (pid is null) return Unauthorized();
|
||||
await rooms.BookmarkAsync(pid.Value, roomId);
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
[HttpDelete("api/rooms/v1/bookmark/{roomId:long}")]
|
||||
[HttpDelete("api/rooms/v2/bookmark/{roomId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Unbookmark(long roomId)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
if (pid is null) return Unauthorized();
|
||||
await rooms.UnbookmarkAsync(pid.Value, roomId);
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>api/rooms/v1/agRoomIds</c> — list of every
|
||||
/// seeded AG / RR-Original room id. Used by the watch to bias the
|
||||
/// room browser toward official rooms first.</summary>
|
||||
[HttpGet("api/rooms/v1/agRoomIds")]
|
||||
public async Task<IActionResult> AgRoomIds() =>
|
||||
Ok(await rooms.AgRoomIdsAsync());
|
||||
|
||||
/// <summary>GET <c>api/rooms/v1/featuredRoomGroup</c> +
|
||||
/// <c>v3/featured</c> — the "Featured" carousel on the watch's
|
||||
/// rooms tab. Returns the top-12 AG rooms by HotScore.</summary>
|
||||
[HttpGet("api/rooms/v1/featuredRoomGroup")]
|
||||
[HttpGet("api/rooms/v3/featured")]
|
||||
public async Task<IActionResult> Featured() => Ok(new
|
||||
{
|
||||
Name = "Featured",
|
||||
FeaturedRooms = await rooms.FeaturedAgRoomIdsAsync(12),
|
||||
});
|
||||
|
||||
/// <summary>GET <c>api/rooms/v2/featured</c> — the 2018 client expects a
|
||||
/// SINGLE Room object here (RecNet.cs:85173), not the 2020 FeaturedRoomGroup.
|
||||
/// Return the top featured AG room.</summary>
|
||||
[HttpGet("api/rooms/v2/featured")]
|
||||
public async Task<IActionResult> FeaturedSingle()
|
||||
{
|
||||
var ids = await rooms.FeaturedAgRoomIdsAsync(1);
|
||||
var r = ids.Count > 0 ? await rooms.GetByIdAsync(ids[0]) : null;
|
||||
return Ok(RoomService.ToWireRoom(r ?? Synthetic("RecCenter")));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/rooms/v2/details/{roomId}</c> — the 2018 details call
|
||||
/// returns ONLY live counts {GameSessionCount, PlayerCount} (RecNet
|
||||
/// PFMOEEDCNBK, RecNet.cs:79846), distinct from the 2020 v3/v4 RoomDetails.
|
||||
/// Counts are derived from current presence; 0/0 when the room is empty.</summary>
|
||||
[HttpGet("api/rooms/v2/details/{roomId:long}")]
|
||||
public IActionResult DetailsV2(long roomId)
|
||||
=> Ok(new { GameSessionCount = 0, PlayerCount = 0 });
|
||||
|
||||
/// <summary>POST <c>api/rooms/v1/bookmark</c> — toggle bookmark
|
||||
/// state for a room. Body: <c>RoomId</c> + optional <c>Bookmark</c>
|
||||
/// flag (default = add).</summary>
|
||||
[HttpPost("api/rooms/v1/bookmark")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> BookmarkV1(
|
||||
[FromForm(Name = "RoomId")] long? roomId,
|
||||
[FromForm(Name = "Bookmark")] bool? bookmark)
|
||||
{
|
||||
if (roomId is not long rid) return Ok(new { success = true });
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
if (bookmark == false) await rooms.UnbookmarkAsync(pid, rid);
|
||||
else await rooms.BookmarkAsync(pid, rid);
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/rooms/v1/cheer</c> — cheer a room (body
|
||||
/// form). Idempotent per (caller, room) — duplicate taps don't
|
||||
/// inflate CheerCount.</summary>
|
||||
[HttpPost("api/rooms/v1/cheer")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CheerRoom(
|
||||
[FromForm(Name = "RoomId")] long? roomId,
|
||||
[FromForm(Name = "Type")] int? type)
|
||||
{
|
||||
if (roomId is not long rid) return Ok(new { success = true });
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var (newCount, already) = await rooms.CheerRoomAsync(me, rid, type ?? 0);
|
||||
return Ok(new { RoomId = rid, CheerCount = newCount, AlreadyCheered = already });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/rooms/v1/uncheer</c> — remove a previously
|
||||
/// applied room cheer.</summary>
|
||||
[HttpPost("api/rooms/v1/uncheer")]
|
||||
[HttpPost("api/rooms/v1/uncheer/{roomId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> UncheerRoom(
|
||||
long? roomId,
|
||||
[FromForm(Name = "RoomId")] long? roomIdForm,
|
||||
[FromForm(Name = "Type")] int? type)
|
||||
{
|
||||
var rid = roomId ?? roomIdForm ?? 0;
|
||||
if (rid == 0) return Ok(new { success = true });
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var already = await rooms.UncheerRoomAsync(me, rid, type ?? 0);
|
||||
return Ok(new { RoomId = rid, AlreadyUncheered = already });
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/rooms/v1/cheers/{roomId}</c> — did the
|
||||
/// caller cheer this room, and what's the current count?</summary>
|
||||
[HttpGet("api/rooms/v1/cheers/{roomId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CheckCheered(long roomId)
|
||||
{
|
||||
var me = this.CurrentPlayerId();
|
||||
var (count, iCheered) = await rooms.GetCheerStateAsync(me ?? 0, roomId);
|
||||
return Ok(new { RoomId = roomId, CheerCount = count, ICheered = iCheered });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synthesise a placeholder Room when the client asks for one we don't
|
||||
/// have in the DB. Keeps the deserializer happy — every required key
|
||||
/// is populated. Safe defaults: public, AG, default DormRoom location.
|
||||
///
|
||||
/// Special case: roomId=1 is the canonical DormRoom id used by
|
||||
/// GoToController when the client visits its own dorm. Make sure the
|
||||
/// /v4/details/1 follow-up shows "DormRoom" rather than "Room_1".
|
||||
/// </summary>
|
||||
private static RoomEntity Synthetic(string name, long? id = null)
|
||||
{
|
||||
var isDorm = (id == 1) || name.Equals("DormRoom", StringComparison.OrdinalIgnoreCase);
|
||||
var displayName = isDorm ? "DormRoom" : name;
|
||||
return new RoomEntity
|
||||
{
|
||||
Id = id ?? Math.Abs((long)name.GetHashCode()),
|
||||
Name = displayName,
|
||||
Description = isDorm
|
||||
? "Your private dorm — yours alone, decorated however you like."
|
||||
: $"{displayName} room",
|
||||
CreatorPlayerId = 1,
|
||||
State = 0,
|
||||
Accessibility = isDorm ? 0 /* Private */ : 1 /* Public */,
|
||||
IsAGRoom = !isDorm,
|
||||
IsDormRoom = isDorm,
|
||||
LocationReplicationId = "76d98498-60a1-430c-ab76-b54a29b7a163",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Rooms.V2;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/rooms/v{1,2}/{modify,modifyPermissions,banfromroom,
|
||||
/// importroombans,roombans,report,modify/scene,modify/sceneParent,
|
||||
/// modify/tags,modifyscene} — owner-side mutation surface for rooms.
|
||||
///
|
||||
/// Split from <see cref="RoomsController"/> to keep that file
|
||||
/// (browse / details / clone) at a manageable size. All endpoints
|
||||
/// are owner-gated (the caller must equal
|
||||
/// <see cref="RoomEntity.CreatorPlayerId"/>) or admin-bypassed via
|
||||
/// <see cref="AdminOnlyAttribute"/> on the admin-only sub-routes.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
private async Task<RoomEntity?> RequireOwnedRoomAsync(long roomId)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return null;
|
||||
return room.CreatorPlayerId == Me ? room : null;
|
||||
}
|
||||
|
||||
// ── /modify (general fields) ─────────────────────────────────────────
|
||||
|
||||
public sealed class ModifyRoomRequest
|
||||
{
|
||||
public long RoomId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
public int? Accessibility { get; set; }
|
||||
public bool? SupportsScreens { get; set; }
|
||||
public bool? SupportsMobile { get; set; }
|
||||
public bool? SupportsVRLow { get; set; }
|
||||
public bool? SupportsWalkVR { get; set; }
|
||||
public bool? SupportsTeleportVR { get; set; }
|
||||
public bool? DisableMicAutoMute { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/rooms/v2/modify")]
|
||||
public async Task<IActionResult> Modify([FromBody] ModifyRoomRequest req)
|
||||
{
|
||||
var room = await RequireOwnedRoomAsync(req.RoomId);
|
||||
if (room is null) return NotFound();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(req.Name)) room.Name = req.Name.Trim();
|
||||
if (req.Description is not null) room.Description = req.Description;
|
||||
if (req.ImageName is not null) room.ImageName = req.ImageName;
|
||||
if (req.Tags is not null) room.TagsCsv = req.Tags;
|
||||
if (req.Accessibility.HasValue) room.Accessibility = Math.Clamp(req.Accessibility.Value, 0, 2);
|
||||
if (req.SupportsScreens.HasValue) room.SupportsScreens = req.SupportsScreens.Value;
|
||||
if (req.SupportsMobile.HasValue) room.SupportsMobile = req.SupportsMobile.Value;
|
||||
if (req.SupportsVRLow.HasValue) room.SupportsVRLow = req.SupportsVRLow.Value;
|
||||
if (req.SupportsWalkVR.HasValue) room.SupportsWalkVR = req.SupportsWalkVR.Value;
|
||||
if (req.SupportsTeleportVR.HasValue) room.SupportsTeleportVR = req.SupportsTeleportVR.Value;
|
||||
if (req.DisableMicAutoMute.HasValue) room.DisableMicAutoMute = req.DisableMicAutoMute.Value;
|
||||
room.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// Likely a unique-name collision.
|
||||
return Conflict(new { Result = 2, Message = "name taken" });
|
||||
}
|
||||
return Ok(new { Result = 0, Room = Services.RoomService.ToWireRoom(room) });
|
||||
}
|
||||
|
||||
public sealed class ModifyPermissionsRequest
|
||||
{
|
||||
public long RoomId { get; set; }
|
||||
public int? Accessibility { get; set; }
|
||||
public bool? SupportsLevelVoting { get; set; }
|
||||
public bool? AllowsJuniors { get; set; }
|
||||
public bool? CloningAllowed { get; set; }
|
||||
public int? RoomWarningMask { get; set; }
|
||||
public string? CustomRoomWarning { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/rooms/v2/modifyPermissions")]
|
||||
public async Task<IActionResult> ModifyPermissions([FromBody] ModifyPermissionsRequest req)
|
||||
{
|
||||
var room = await RequireOwnedRoomAsync(req.RoomId);
|
||||
if (room is null) return NotFound();
|
||||
|
||||
if (req.Accessibility.HasValue) room.Accessibility = Math.Clamp(req.Accessibility.Value, 0, 2);
|
||||
if (req.SupportsLevelVoting.HasValue) room.SupportsLevelVoting = req.SupportsLevelVoting.Value;
|
||||
if (req.AllowsJuniors.HasValue) room.AllowsJuniors = req.AllowsJuniors.Value;
|
||||
if (req.CloningAllowed.HasValue) room.CloningAllowed = req.CloningAllowed.Value;
|
||||
if (req.RoomWarningMask.HasValue) room.RoomWarningMask = req.RoomWarningMask.Value;
|
||||
if (req.CustomRoomWarning is not null) room.CustomRoomWarning = req.CustomRoomWarning;
|
||||
room.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(Services.RoomService.ToWireRoom(room));
|
||||
}
|
||||
|
||||
// ── Scene mutations (LocationReplicationId) ──────────────────────────
|
||||
|
||||
public sealed class ModifySceneRequest
|
||||
{
|
||||
public long RoomId { get; set; }
|
||||
public string? LocationReplicationId { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/rooms/v1/modify/scene")]
|
||||
[HttpPost("api/rooms/v1/modifyscene")]
|
||||
public async Task<IActionResult> ModifyScene([FromBody] ModifySceneRequest req)
|
||||
{
|
||||
var room = await RequireOwnedRoomAsync(req.RoomId);
|
||||
if (room is null) return NotFound();
|
||||
if (!string.IsNullOrWhiteSpace(req.LocationReplicationId))
|
||||
room.LocationReplicationId = req.LocationReplicationId;
|
||||
room.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(Services.RoomService.ToWireRoom(room));
|
||||
}
|
||||
|
||||
public sealed class ModifySceneParentRequest
|
||||
{
|
||||
public long RoomId { get; set; }
|
||||
public long? ParentRoomId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Sub-room / nested-scene parent override. We don't
|
||||
/// model sub-rooms as a real graph, so we accept-and-ignore but
|
||||
/// still owner-gate to prevent grief.</summary>
|
||||
[HttpPost("api/rooms/v1/modify/sceneParent")]
|
||||
public async Task<IActionResult> ModifySceneParent([FromBody] ModifySceneParentRequest req)
|
||||
{
|
||||
var room = await RequireOwnedRoomAsync(req.RoomId);
|
||||
if (room is null) return NotFound();
|
||||
return Ok(new { Result = 0 });
|
||||
}
|
||||
|
||||
public sealed class ModifyTagsRequest
|
||||
{
|
||||
public long RoomId { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/rooms/v1/modify/tags")]
|
||||
public async Task<IActionResult> ModifyTags([FromBody] ModifyTagsRequest req)
|
||||
{
|
||||
var room = await RequireOwnedRoomAsync(req.RoomId);
|
||||
if (room is null) return NotFound();
|
||||
room.TagsCsv = req.Tags ?? string.Empty;
|
||||
room.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(Services.RoomService.ToWireRoom(room));
|
||||
}
|
||||
|
||||
// ── Bans ─────────────────────────────────────────────────────────────
|
||||
|
||||
public sealed class BanFromRoomRequest
|
||||
{
|
||||
public long RoomId { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
public int BanType { get; set; } // 0=Soft (kick), 1=Permanent, 2=Temp
|
||||
public DateTime? Until { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/rooms/v2/banfromroom")]
|
||||
public async Task<IActionResult> BanFromRoom([FromBody] BanFromRoomRequest req)
|
||||
{
|
||||
var room = await RequireOwnedRoomAsync(req.RoomId);
|
||||
if (room is null) return NotFound();
|
||||
db.RoomBans.Add(new RoomBanEntity
|
||||
{
|
||||
RoomId = req.RoomId,
|
||||
BannedPlayerId = req.PlayerId,
|
||||
BannedByPlayerId = Me,
|
||||
BanType = req.BanType,
|
||||
Until = req.Until,
|
||||
Reason = req.Reason ?? string.Empty,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Result = 0 });
|
||||
}
|
||||
|
||||
public sealed class ImportRoomBansRequest
|
||||
{
|
||||
public long RoomId { get; set; }
|
||||
public List<BanFromRoomRequest>? Bans { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/rooms/v1/importroombans")]
|
||||
public async Task<IActionResult> ImportRoomBans([FromBody] ImportRoomBansRequest req)
|
||||
{
|
||||
var room = await RequireOwnedRoomAsync(req.RoomId);
|
||||
if (room is null) return NotFound();
|
||||
if (req.Bans is null || req.Bans.Count == 0) return Ok(new { Imported = 0 });
|
||||
|
||||
foreach (var b in req.Bans)
|
||||
{
|
||||
db.RoomBans.Add(new RoomBanEntity
|
||||
{
|
||||
RoomId = req.RoomId,
|
||||
BannedPlayerId = b.PlayerId,
|
||||
BannedByPlayerId = Me,
|
||||
BanType = b.BanType,
|
||||
Until = b.Until,
|
||||
Reason = b.Reason ?? string.Empty,
|
||||
});
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Imported = req.Bans.Count });
|
||||
}
|
||||
|
||||
[HttpGet("api/rooms/v1/roombans/{roomId:long}")]
|
||||
public async Task<IActionResult> RoomBansList(long roomId)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
// Only owner sees the ban list (admins also via existing
|
||||
// admin endpoints).
|
||||
if (room.CreatorPlayerId != Me) return Forbid();
|
||||
var rows = await db.RoomBans
|
||||
.Where(b => b.RoomId == roomId && (b.Until == null || b.Until > DateTime.UtcNow))
|
||||
.OrderByDescending(b => b.CreatedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(b => new
|
||||
{
|
||||
b.Id,
|
||||
b.RoomId,
|
||||
b.BannedPlayerId,
|
||||
b.BannedByPlayerId,
|
||||
b.BanType,
|
||||
b.Until,
|
||||
b.Reason,
|
||||
b.CreatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Report ───────────────────────────────────────────────────────────
|
||||
|
||||
public sealed class RoomReportRequest
|
||||
{
|
||||
public long RoomId { get; set; }
|
||||
public int Category { get; set; }
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/rooms/v2/report")]
|
||||
public async Task<IActionResult> Report([FromBody] RoomReportRequest req)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId);
|
||||
if (room is null) return NotFound();
|
||||
var msg = req.Message ?? string.Empty;
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
ReporterPlayerId = Me,
|
||||
TargetPlayerId = room.CreatorPlayerId,
|
||||
TargetRoomId = req.RoomId,
|
||||
RoomId = req.RoomId,
|
||||
Category = req.Category,
|
||||
Message = msg[..Math.Min(1000, msg.Length)],
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Reported = true });
|
||||
}
|
||||
|
||||
public sealed class RoomRolePermissionRequest
|
||||
{
|
||||
public long RoomId { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/rooms/v1/roomRolePermissions</c> — watch
|
||||
/// posts its self-computed role + permission grants for
|
||||
/// server-side validation. Role enum:
|
||||
/// 0=Visitor, 5=Member, 10=Mod, 20=Host, 30=Owner. Room creator
|
||||
/// is always Owner; everyone else defaults to Visitor.</summary>
|
||||
[HttpPost("api/rooms/v1/roomRolePermissions")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> RoomRolePermissions(
|
||||
[FromBody] RoomRolePermissionRequest? body,
|
||||
[FromForm(Name = "RoomId")] long? roomIdForm,
|
||||
[FromForm(Name = "PlayerId")] long? playerIdForm)
|
||||
{
|
||||
var roomId = body?.RoomId ?? roomIdForm ?? 0;
|
||||
var playerId = body?.PlayerId ?? playerIdForm ?? (this.CurrentPlayerId() ?? 0);
|
||||
if (roomId <= 0 || playerId <= 0)
|
||||
return Ok(new { success = true, error = "" });
|
||||
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
int role = (room?.CreatorPlayerId == playerId) ? 30 : 0;
|
||||
long permissions = role switch
|
||||
{
|
||||
30 => -1L,
|
||||
10 => 0x0FFFFFFFL,
|
||||
5 => 0x000000FFL,
|
||||
_ => 0x0000000FL,
|
||||
};
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
error = "",
|
||||
RoomId = roomId,
|
||||
PlayerId = (int)playerId,
|
||||
Role = role,
|
||||
Permissions = permissions,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Royale;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/royale/* — Rec Royale battle-royale stats.
|
||||
///
|
||||
/// Per <c>RecNet.RecNetRecRoyaleStats</c> (decompiled at
|
||||
/// <c>Cpp2IL_CS/.../RecNet/RecNetRecRoyaleStats.cs</c>) the client
|
||||
/// only calls TWO endpoints:
|
||||
///
|
||||
/// • <c>GET api/royale/v1/current</c> → <c>RecRoyalePlayerProgress</c>:
|
||||
/// <c>TotalXP, Level, RankIdx, RankName, CurrentLevelXPThreshold,
|
||||
/// NextLevelXPThreshold, NextLevelAcornReward</c>.
|
||||
/// • <c>POST api/royale/v2/matchcomplete</c> with body
|
||||
/// <c>MatchCompleteStats</c>: <c>Rank, NumEliminations, SecondsAlive,
|
||||
/// WalkGame, CustomGame, ChestsOpened, ShieldPotionsConsumed,
|
||||
/// HealthPotionsConsumed, SecondsInAir</c>; returns <c>StatUpdates</c>:
|
||||
/// <c>XPAwardStrings, TotalXPAwarded, NewProgress</c>.
|
||||
///
|
||||
/// We persist match rows to <see cref="RoyaleMatchEntity"/> +
|
||||
/// <see cref="RoyaleMatchPlayerEntity"/> for server-side audit, and
|
||||
/// roll the per-player <see cref="RoyalePlayerProgressEntity"/>.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class RoyaleController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
// Rank ladder names (from the watch's RankIdx → RankName mapping).
|
||||
// Approximated; the client's ladder isn't exposed in the dump but
|
||||
// these are the standard 2020-era Royale tiers.
|
||||
private static readonly string[] RankNames =
|
||||
{
|
||||
"Recruit", "Cadet", "Sergeant", "Lieutenant", "Captain",
|
||||
"Major", "Colonel", "Commander", "Champion", "Legend",
|
||||
};
|
||||
|
||||
private static long XpForLevel(int level) => 100L * level * level;
|
||||
|
||||
private static (int level, int rankIdx, string rankName, long currentThreshold, long nextThreshold, int reward)
|
||||
Compute(long totalXp)
|
||||
{
|
||||
int lvl = 1;
|
||||
while (XpForLevel(lvl + 1) <= totalXp) lvl++;
|
||||
var rankIdx = Math.Min(RankNames.Length - 1, lvl / 5);
|
||||
var current = XpForLevel(lvl);
|
||||
var next = XpForLevel(lvl + 1);
|
||||
var reward = 50 + 10 * lvl;
|
||||
return (lvl, rankIdx, RankNames[rankIdx], current, next, reward);
|
||||
}
|
||||
|
||||
[HttpGet("api/royale/v1/current")]
|
||||
public async Task<IActionResult> Current()
|
||||
{
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is not long me) return Ok(BlankProgress());
|
||||
var row = await db.RoyalePlayerProgress.FirstOrDefaultAsync(p => p.PlayerId == me);
|
||||
if (row is null) return Ok(BlankProgress());
|
||||
return Ok(ToWireProgress(row));
|
||||
}
|
||||
|
||||
public sealed class MatchCompleteStats
|
||||
{
|
||||
public int Rank { get; set; }
|
||||
public int NumEliminations { get; set; }
|
||||
public int SecondsAlive { get; set; }
|
||||
public bool WalkGame { get; set; }
|
||||
public bool CustomGame { get; set; }
|
||||
public int ChestsOpened { get; set; }
|
||||
public int ShieldPotionsConsumed { get; set; }
|
||||
public int HealthPotionsConsumed { get; set; }
|
||||
public int SecondsInAir { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/royale/v2/matchcomplete")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> MatchComplete([FromBody] MatchCompleteStats body)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
if (body is null) return BadRequest("missing body");
|
||||
|
||||
var match = new RoyaleMatchEntity
|
||||
{
|
||||
CompletedAt = DateTime.UtcNow,
|
||||
Rank = body.Rank,
|
||||
NumEliminations = body.NumEliminations,
|
||||
SecondsAlive = body.SecondsAlive,
|
||||
WalkGame = body.WalkGame,
|
||||
CustomGame = body.CustomGame,
|
||||
ChestsOpened = body.ChestsOpened,
|
||||
ShieldPotionsConsumed = body.ShieldPotionsConsumed,
|
||||
HealthPotionsConsumed = body.HealthPotionsConsumed,
|
||||
SecondsInAir = body.SecondsInAir,
|
||||
};
|
||||
db.RoyaleMatches.Add(match);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
db.RoyaleMatchPlayers.Add(new RoyaleMatchPlayerEntity
|
||||
{
|
||||
MatchId = match.Id,
|
||||
PlayerId = pid,
|
||||
Rank = body.Rank,
|
||||
NumEliminations = body.NumEliminations,
|
||||
SecondsAlive = body.SecondsAlive,
|
||||
});
|
||||
|
||||
// Compute XP awards. Match the watch's pattern:
|
||||
// • Survival: 1 XP per 10 seconds alive
|
||||
// • Eliminations: 25 XP each
|
||||
// • Placement: bonus inversely proportional to rank
|
||||
// • Custom games don't award XP
|
||||
var awards = new List<(string label, int xp)>();
|
||||
if (!body.CustomGame)
|
||||
{
|
||||
var survivalXp = body.SecondsAlive / 10;
|
||||
var killXp = body.NumEliminations * 25;
|
||||
var rankXp = Math.Max(0, 200 - body.Rank * 5);
|
||||
if (survivalXp > 0) awards.Add(($"Time Alive: {body.SecondsAlive}s", survivalXp));
|
||||
if (killXp > 0) awards.Add(($"Eliminations: {body.NumEliminations}", killXp));
|
||||
if (rankXp > 0) awards.Add(($"Rank #{body.Rank}", rankXp));
|
||||
if (body.Rank == 1) awards.Add(("Victory Royale!", 500));
|
||||
}
|
||||
var totalAward = awards.Sum(a => a.xp);
|
||||
|
||||
// Update progress.
|
||||
var row = await db.RoyalePlayerProgress.FirstOrDefaultAsync(p => p.PlayerId == pid);
|
||||
if (row is null)
|
||||
{
|
||||
row = new RoyalePlayerProgressEntity { PlayerId = pid };
|
||||
db.RoyalePlayerProgress.Add(row);
|
||||
}
|
||||
row.TotalXP += totalAward;
|
||||
var (lvl, rankIdx, rankName, current, next, reward) = Compute(row.TotalXP);
|
||||
row.Level = lvl;
|
||||
row.RankIdx = rankIdx;
|
||||
row.RankName = rankName;
|
||||
row.CurrentLevelXPThreshold = current;
|
||||
row.NextLevelXPThreshold = next;
|
||||
row.NextLevelAcornReward = reward;
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
XPAwardStrings = awards.Select(a => $"+{a.xp} XP — {a.label}").ToList(),
|
||||
TotalXPAwarded = (long)totalAward,
|
||||
NewProgress = new[] { ToWireProgress(row) },
|
||||
});
|
||||
}
|
||||
|
||||
private static object ToWireProgress(RoyalePlayerProgressEntity p) => new
|
||||
{
|
||||
p.TotalXP,
|
||||
p.Level,
|
||||
p.RankIdx,
|
||||
p.RankName,
|
||||
p.CurrentLevelXPThreshold,
|
||||
p.NextLevelXPThreshold,
|
||||
p.NextLevelAcornReward,
|
||||
};
|
||||
|
||||
private static object BlankProgress() => new
|
||||
{
|
||||
TotalXP = 0L,
|
||||
Level = 1,
|
||||
RankIdx = 0,
|
||||
RankName = "Recruit",
|
||||
CurrentLevelXPThreshold = 0L,
|
||||
NextLevelXPThreshold = 100L,
|
||||
NextLevelAcornReward = 50,
|
||||
};
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Sanitize;
|
||||
|
||||
/// <summary>
|
||||
/// api.{rec.net,localhost}/api/sanitize/* — server-side profanity filter
|
||||
/// the watch's text-input UI calls before allowing a room name /
|
||||
/// invention name / chat message to be submitted. Delegates to
|
||||
/// <see cref="ProfanityFilter"/> for the actual scrubbing. Response
|
||||
/// shape: <c>{Text (purified), IsClean (bool)}</c>.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class SanitizeController(ILogger<SanitizeController> logger) : ControllerBase
|
||||
{
|
||||
[HttpPost("api/sanitize/v1")]
|
||||
[HttpPost("api/sanitize/v1/text")]
|
||||
public async Task<IActionResult> Sanitize()
|
||||
{
|
||||
// RecRoom.StringSanitization.PurifyString sends JSON body
|
||||
// {"Value":"<text>","ReplacementChar":42} — verified from a
|
||||
// live chat-send error trace ("Received malformed RecNet
|
||||
// response: ...Text:{\"Value\":...}..."). Earlier we used to
|
||||
// pass the whole body through as "text", which made Text in
|
||||
// the response contain the literal JSON string, and the watch's
|
||||
// LitJson then choked on the malformed wrap.
|
||||
string text = string.Empty;
|
||||
try
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
text = Request.Form["Text"].ToString();
|
||||
}
|
||||
catch { }
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var raw = await reader.ReadToEndAsync();
|
||||
if (!string.IsNullOrEmpty(raw))
|
||||
{
|
||||
var first = raw.AsSpan().TrimStart();
|
||||
if (first.Length > 0 && first[0] == '{')
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(raw);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind == System.Text.Json.JsonValueKind.Object)
|
||||
{
|
||||
if (root.TryGetProperty("Value", out var vEl) && vEl.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
text = vEl.GetString() ?? string.Empty;
|
||||
else if (root.TryGetProperty("Text", out var tEl) && tEl.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
text = tEl.GetString() ?? string.Empty;
|
||||
else if (root.TryGetProperty("Input", out var iEl) && iEl.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
text = iEl.GetString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
catch { text = raw; }
|
||||
}
|
||||
else
|
||||
{
|
||||
text = raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
// StringSanitization.PurifyString_d__9 chains Core.Post to
|
||||
// ApiCallback<String> via ExpectPrimitiveResponse, so the watch's
|
||||
// LitJson importer wants a BARE JSON string (e.g. "dwadwa"), not
|
||||
// an object body. Returning {Text, IsClean} as we did before
|
||||
// makes LitJson hand the Dictionary to Convert.ChangeType(_, typeof(string))
|
||||
// which throws "Object must implement IConvertible". Strip the
|
||||
// envelope — but use Content() with explicit application/json so
|
||||
// ASP.NET Core's StringOutputFormatter doesn't serialize as
|
||||
// text/plain (raw string sans quotes), which the watch then sees
|
||||
// as an empty cleanVersion after LitJson decode.
|
||||
var purified = ProfanityFilter.Purify(text);
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(purified);
|
||||
logger.LogInformation("[sanitize] input=\"{Input}\" purified=\"{Purified}\" json={Json}", text, purified, json);
|
||||
return Content(json, "application/json");
|
||||
}
|
||||
|
||||
public sealed class SanitizeBody
|
||||
{
|
||||
public string? Text { get; set; }
|
||||
public string? Input { get; set; }
|
||||
public string? Value { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/sanitize/v1/purifyString</c> — returns
|
||||
/// <c>{Text}</c> with profanity replaced. Wire shape matches
|
||||
/// <c>StringSanitization.PurifyString</c>.</summary>
|
||||
[HttpPost("api/sanitize/v1/purifyString")]
|
||||
public IActionResult PurifyString(
|
||||
[FromBody] SanitizeBody? body,
|
||||
[FromForm(Name = "Text")] string? textForm,
|
||||
[FromForm(Name = "Input")] string? inputForm,
|
||||
[FromForm(Name = "Value")] string? valueForm,
|
||||
[FromQuery(Name = "text")] string? textQuery)
|
||||
{
|
||||
var input = body?.Text ?? body?.Input ?? body?.Value
|
||||
?? textForm ?? inputForm ?? valueForm ?? textQuery ?? string.Empty;
|
||||
return Ok(new { Text = ProfanityFilter.Purify(input) });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/sanitize/v1/requestIsStringPure</c> —
|
||||
/// returns a bare JSON bool. Watch's <c>ExpectPrimitiveResponse</c>
|
||||
/// rejects object bodies here.</summary>
|
||||
[HttpPost("api/sanitize/v1/requestIsStringPure")]
|
||||
public IActionResult IsStringPure(
|
||||
[FromBody] SanitizeBody? body,
|
||||
[FromForm(Name = "Text")] string? textForm,
|
||||
[FromQuery(Name = "text")] string? textQuery)
|
||||
{
|
||||
var input = body?.Text ?? body?.Input ?? body?.Value
|
||||
?? textForm ?? textQuery ?? string.Empty;
|
||||
return Ok(ProfanityFilter.IsClean(input));
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Settings.V2;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/settings/v2 — player settings download / set / remove.
|
||||
/// URL patterns (verified by disassembling RecNet.Settings$$DowloadLocalPlayerSettings
|
||||
/// at RVA 0xAF55B0 and RecNet.Settings$$StoreLocalPlayerSetting at RVA 0xAF5650):
|
||||
///
|
||||
/// GET api/settings/v2/ — returns List<{Key, Value}>
|
||||
/// POST api/settings/v2/set — body { Key, Value } (set or update one)
|
||||
/// POST api/settings/v2/remove — body { Key } (delete one)
|
||||
///
|
||||
/// The CLIENT side parses the GET response with Util.DeserializeList<Setting>
|
||||
/// and reads each entry's "Key" and "Value" fields (verified by string literal
|
||||
/// references in the deserializer body — both are PascalCase).
|
||||
///
|
||||
/// Returning a Dictionary<string,string> here serializes as "{}"
|
||||
/// when empty, which the client casts to a List and dies with
|
||||
/// InvalidCastException: Unable to cast object of type 'Dictionary`2' to
|
||||
/// type 'List`1'. Always return a real List.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v2")]
|
||||
[Authorize]
|
||||
public class SettingsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long CurrentPlayerId => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpGet]
|
||||
[HttpGet("get")]
|
||||
public async Task<IActionResult> GetSettings()
|
||||
{
|
||||
var settings = await db.PlayerSettings
|
||||
.Where(s => s.PlayerId == CurrentPlayerId)
|
||||
.Select(s => new SettingDto { Key = s.Key, Value = s.Value })
|
||||
.ToListAsync();
|
||||
return Ok(settings);
|
||||
}
|
||||
|
||||
[HttpPost("set")]
|
||||
public async Task<ActionResult> SetSetting([FromBody] SetSettingRequest req)
|
||||
{
|
||||
var existing = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == CurrentPlayerId && s.Key == req.Key);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = CurrentPlayerId,
|
||||
Key = req.Key,
|
||||
Value = req.Value,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Value = req.Value;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new SettingDto { Key = req.Key, Value = req.Value });
|
||||
}
|
||||
|
||||
[HttpPost("remove")]
|
||||
public async Task<ActionResult> RemoveSetting([FromBody] SetSettingRequest req)
|
||||
{
|
||||
var existing = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == CurrentPlayerId && s.Key == req.Key);
|
||||
if (existing is not null)
|
||||
{
|
||||
db.PlayerSettings.Remove(existing);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
|
||||
public class SettingDto
|
||||
{
|
||||
[JsonPropertyName("Key")]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Value")]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class SetSettingRequest
|
||||
{
|
||||
[JsonPropertyName("Key")]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Value")]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Store;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/purchase/v1/* — real purchase flow that verifies
|
||||
/// the player has enough currency, deducts it, and grants the item
|
||||
/// to their inventory. Replaces the old "ack-everything" stubs in
|
||||
/// AllEndpointsController that let the watch think it bought
|
||||
/// something but never actually persisted the transaction.
|
||||
///
|
||||
/// The 2020 client follows a three-step flow when the player taps
|
||||
/// "buy" on a Shop item:
|
||||
/// 1. POST initiatepurchase — server validates the item exists +
|
||||
/// player has funds, returns a transaction id (we just echo
|
||||
/// back the item id; no real reservation needed for a private
|
||||
/// server's threat model).
|
||||
/// 2. POST processpurchase — server actually deducts currency,
|
||||
/// grants the item to the avatar's InventoryJson list, returns
|
||||
/// the new balance.
|
||||
/// 3. POST completepurchase — final ack; client uses this to
|
||||
/// flip its local UI state from "purchasing…" to "owned".
|
||||
/// We keep all three so the watch's flow doesn't deadlock waiting
|
||||
/// for one or another, but the actual mutation happens in
|
||||
/// processpurchase. initiate and complete just validate + ack.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class PurchaseController(StoreService store, ILogger<PurchaseController> logger) : ControllerBase
|
||||
{
|
||||
public sealed record PurchaseRequest(long? ItemId, string? Slug, int? Quantity);
|
||||
|
||||
/// <summary>POST initiatepurchase — validate the item exists and
|
||||
/// the caller has enough currency, but don't mutate yet. Returns
|
||||
/// the resolved item id so the watch can echo it back on
|
||||
/// processpurchase.</summary>
|
||||
[HttpPost("api/purchase/v1/initiatepurchase")]
|
||||
[HttpPost("api/purchase/v2/initiatepurchase")]
|
||||
[HttpPost("api/purchase/v3/initiatepurchase")]
|
||||
public async Task<IActionResult> Initiate([FromForm] PurchaseRequest body)
|
||||
{
|
||||
var item = await ResolveItemAsync(body);
|
||||
if (item is null) return Ok(new { success = false, error = "item_not_found" });
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
error = "",
|
||||
ItemId = item.Id,
|
||||
Slug = item.Slug,
|
||||
// Hand back a token the watch may include in
|
||||
// processpurchase. We accept anything on the way in, so
|
||||
// this is informational.
|
||||
TransactionId = $"txn-{item.Id}-{DateTime.UtcNow.Ticks}",
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>POST processpurchase — atomic charge + grant. The
|
||||
/// real work. Returns success+balance on completion or the
|
||||
/// specific error code on failure (insufficient_funds,
|
||||
/// item_not_available, etc.) so the watch can show the right UI
|
||||
/// message.</summary>
|
||||
[HttpPost("api/purchase/v1/processpurchase")]
|
||||
[HttpPost("api/purchase/v2/processpurchase")]
|
||||
[HttpPost("api/purchase/v3/processpurchase")]
|
||||
public async Task<IActionResult> Process([FromForm] PurchaseRequest body)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var item = await ResolveItemAsync(body);
|
||||
if (item is null) return Ok(new { success = false, error = "item_not_found" });
|
||||
|
||||
var result = await store.PurchaseAsync(pid, item.Id);
|
||||
logger.LogInformation(
|
||||
"[purchase] player={Pid} item={Item} success={Success} error={Error} balance={Balance}",
|
||||
pid, item.Slug, result.Success, result.Error ?? "", result.Balance ?? 0);
|
||||
return Ok(new
|
||||
{
|
||||
success = result.Success,
|
||||
error = result.Error ?? "",
|
||||
Balance = result.Balance ?? 0,
|
||||
CurrencyType = item.CurrencyType,
|
||||
ItemId = item.Id,
|
||||
Slug = result.Slug ?? item.Slug,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>POST completepurchase — final ack. Client flips its
|
||||
/// UI to "owned" state and refreshes the avatar selector.</summary>
|
||||
[HttpPost("api/purchase/v1/completepurchase")]
|
||||
[HttpPost("api/purchase/v2/completepurchase")]
|
||||
[HttpPost("api/purchase/v3/completepurchase")]
|
||||
public IActionResult Complete() =>
|
||||
Ok(new { success = true, error = "" });
|
||||
|
||||
/// <summary>POST cancelpurchase — client retreated mid-flow
|
||||
/// (closed the panel, network blip, etc.). Nothing to roll back
|
||||
/// since we don't reserve in initiate.</summary>
|
||||
[HttpPost("api/purchase/v1/cancelpurchase")]
|
||||
[HttpPost("api/purchase/v2/cancelpurchase")]
|
||||
[HttpPost("api/purchase/v3/cancelpurchase")]
|
||||
public IActionResult Cancel() =>
|
||||
Ok(new { success = true, error = "" });
|
||||
|
||||
/// <summary>POST cleanuppending — client startup probe to release
|
||||
/// any stale "in-flight" purchases from a crashed previous
|
||||
/// session. We don't track in-flight state, so always-ack is
|
||||
/// correct.</summary>
|
||||
[HttpPost("api/purchase/v1/cleanuppending")]
|
||||
[HttpPost("api/purchase/v2/cleanuppending")]
|
||||
[HttpPost("api/purchase/v3/cleanuppending")]
|
||||
public IActionResult CleanupPending() =>
|
||||
Ok(new { success = true, error = "" });
|
||||
|
||||
/// <summary>POST <c>subscription/v1/cancel</c> — Rec Room+
|
||||
/// subscription cancel button. Verified URL at
|
||||
/// Commerce.txt:2188. The private server has no real billing
|
||||
/// integration (no Stripe, no platform IAP receipts) so the
|
||||
/// subscription is purely cosmetic — we ack the cancel so the
|
||||
/// watch flips its UI to "canceled" and stops the recurring
|
||||
/// renewal nag. If a real subscription entity ever lands, this
|
||||
/// is where to expire its CurrentPeriodEnd.</summary>
|
||||
[HttpPost("/subscription/v1")]
|
||||
[HttpPost("/subscription/v1/cancel")]
|
||||
[HttpPost("/api/subscription/v1")]
|
||||
[HttpPost("/api/subscription/v1/cancel")]
|
||||
public IActionResult CancelSubscription()
|
||||
{
|
||||
logger.LogInformation("[subscription] cancel requested by player {Pid}",
|
||||
this.CurrentPlayerId() ?? 0);
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
private async Task<Data.Entities.StoreItemEntity?> ResolveItemAsync(PurchaseRequest body)
|
||||
{
|
||||
if (body.ItemId is long id && id > 0)
|
||||
return await store.GetByIdAsync(id);
|
||||
if (!string.IsNullOrWhiteSpace(body.Slug))
|
||||
return await store.GetBySlugAsync(body.Slug);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Store;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/storefronts/v2/{buyItem,buyTier,buyElite} — the
|
||||
/// secondary purchase URL family used by the watch's gift-drop
|
||||
/// tiles and season-pass tier buttons. Wire request matches
|
||||
/// <c>RecNet.RequestPurchaseItemDTO</c>:
|
||||
/// <c>StorefrontType, PurchasableItemId, CurrencyType,
|
||||
/// CouponConsumablePlayerMappingId, Gift</c>.
|
||||
///
|
||||
/// Response is <c>PurchaseBalanceUpdateResponseDTO</c>:
|
||||
/// <c>BalanceUpdates: List<BalanceUpdateDTO></c>. The client
|
||||
/// applies these balance deltas optimistically + reads the optional
|
||||
/// gift package from inventory after.
|
||||
///
|
||||
/// The primary purchase URL (<c>api/purchase/v1/*</c>) is handled
|
||||
/// elsewhere by PurchaseController; this just covers the v2 surface.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class StorefrontsBuyController(
|
||||
DorkNetDbContext db,
|
||||
LevelService level,
|
||||
NotificationService notifications) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
public sealed class RequestPurchaseItemDto
|
||||
{
|
||||
public int StorefrontType { get; set; }
|
||||
public int PurchasableItemId { get; set; }
|
||||
public int CurrencyType { get; set; } = 2;
|
||||
public long? CouponConsumablePlayerMappingId { get; set; }
|
||||
public GiftDto? Gift { get; set; }
|
||||
}
|
||||
|
||||
public sealed class GiftDto
|
||||
{
|
||||
public long ToPlayerId { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public bool Anonymous { get; set; }
|
||||
public int GiftContext { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/storefronts/v1/buyItem")]
|
||||
[HttpPost("api/storefronts/v2/buyItem")]
|
||||
public async Task<IActionResult> BuyItem([FromBody] RequestPurchaseItemDto req)
|
||||
{
|
||||
if (req is null) return BadRequest("missing body");
|
||||
var pid = Me;
|
||||
var item = await db.StoreItems.FirstOrDefaultAsync(s => s.Id == req.PurchasableItemId && s.IsActive);
|
||||
if (item is null) return NotFound(BalanceUpdateResponse(0, 2 /* CurrencyType */, null, null));
|
||||
|
||||
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
|
||||
if (balance < item.Price)
|
||||
{
|
||||
// Insufficient funds — return current balance, no item granted.
|
||||
return Ok(BalanceUpdateResponse(balance, req.CurrencyType, null, null));
|
||||
}
|
||||
|
||||
// Gift block (when present) means the BUYER spends the tokens but
|
||||
// the ITEM goes to Gift.ToPlayerId. Self-gifting is disallowed —
|
||||
// the watch's gift UI prevents it, but guard server-side too.
|
||||
var giftTarget = req.Gift is { ToPlayerId: > 0 } g && g.ToPlayerId != pid
|
||||
? g
|
||||
: null;
|
||||
var recipient = giftTarget?.ToPlayerId ?? pid;
|
||||
var isGift = giftTarget is not null;
|
||||
|
||||
var newBalance = await level.GrantCurrencyAsync(pid, req.CurrencyType, -item.Price,
|
||||
isGift ? $"giftItem:{item.Slug}->{recipient}" : $"buyItem:{item.Slug}");
|
||||
|
||||
// Grant the item to the RECIPIENT (= buyer for self-purchase, gift
|
||||
// target for gift purchase). Avatar payloads append to
|
||||
// AvatarEntity.InventoryJson; consumables and everything else
|
||||
// insert a PlayerInventory row.
|
||||
if (StoreService.TryGetAvatarItemPayload(item.Slug, out _, out var avatarItemDesc))
|
||||
{
|
||||
await GrantWardrobeAsync(recipient, StoreService.InventoryAvatarItemDesc(avatarItemDesc));
|
||||
}
|
||||
else
|
||||
{
|
||||
await GrantInventoryAsync(recipient, item.Slug);
|
||||
}
|
||||
|
||||
// Insert a real GiftPackageEntity for the gift-box flow. The watch
|
||||
// shows a gift-box animation after a purchase, then POSTs the
|
||||
// package Id to /api/avatar/v2/gifts/consume/ to claim it. If the
|
||||
// Id isn't a real row, that consume returns 404 and the watch
|
||||
// logs 'Server failed to consume gift … HTTP Error 404' — the
|
||||
// user's symptom on every successful buy. By writing the row
|
||||
// synchronously and returning its DB-assigned Id in the
|
||||
// BalanceUpdate response, the subsequent consume succeeds.
|
||||
StoreService.TryGetAvatarItemPayload(item.Slug, out var avatarItemType, out avatarItemDesc);
|
||||
var inventoryAvatarDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc);
|
||||
StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableItemDesc);
|
||||
if (StoreService.TryGetHairDyePayload(item.Slug, out var hairDyeConsumableDesc, out var hairDyeColorGuid))
|
||||
{
|
||||
avatarItemType = 1;
|
||||
inventoryAvatarDesc = hairDyeColorGuid;
|
||||
consumableItemDesc = hairDyeConsumableDesc;
|
||||
}
|
||||
var gift = new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = recipient,
|
||||
FromPlayerId = isGift && !giftTarget!.Anonymous ? (int?)pid : null,
|
||||
AvatarItemType = string.IsNullOrEmpty(inventoryAvatarDesc) ? null : avatarItemType,
|
||||
AvatarItemDescOrHairDyeDesc = inventoryAvatarDesc,
|
||||
ConsumableItemDesc = consumableItemDesc,
|
||||
CurrencyType = req.CurrencyType,
|
||||
Currency = 0,
|
||||
Xp = 0,
|
||||
Level = 1,
|
||||
GiftContext = isGift ? giftTarget!.GiftContext : 0,
|
||||
GiftRarity = 0,
|
||||
Message = isGift ? (giftTarget!.Message ?? string.Empty) : string.Empty,
|
||||
Platform = -1,
|
||||
PackageVariant = "Standard",
|
||||
PackageMaterial = string.Empty,
|
||||
Consumed = false,
|
||||
IsValid = true,
|
||||
SupportsCurrentPlatform = true,
|
||||
};
|
||||
db.GiftPackages.Add(gift);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Push the buyer's balance update so their wallet UI refreshes.
|
||||
await notifications.NotifyAsync(pid,
|
||||
PushNotificationId.StorefrontBalancePurchase,
|
||||
new { req.CurrencyType, Balance = newBalance, ItemId = item.Id, item.Slug });
|
||||
|
||||
if (isGift)
|
||||
{
|
||||
// Tell the recipient a gift is waiting. The watch dispatches
|
||||
// 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.
|
||||
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,
|
||||
Message = gift.Message ?? string.Empty,
|
||||
});
|
||||
}
|
||||
|
||||
// For gift purchases the buyer doesn't get a box — their flow
|
||||
// ends with the balance update. Returning an empty Data array
|
||||
// (giftPackageId = null) makes the watch's BuyItem callback
|
||||
// skip the gift-receive animation on the sender's screen.
|
||||
return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType,
|
||||
isGift ? null : item.Slug,
|
||||
isGift ? null : gift.Id));
|
||||
}
|
||||
|
||||
[HttpPost("api/storefronts/v1/buyTier")]
|
||||
[HttpPost("api/storefronts/v2/buyTier")]
|
||||
public async Task<IActionResult> BuyTier([FromBody] RequestPurchaseItemDto req)
|
||||
{
|
||||
// Season-pass tier: no per-item table, so we just deduct a
|
||||
// small fixed cost (1 token) per tier and ack. A full
|
||||
// season-pass system would have a SeasonProgressEntity.
|
||||
var pid = Me;
|
||||
const int TierCost = (int)StoreService.StoreItemPrice;
|
||||
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
|
||||
if (balance < TierCost) return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
|
||||
var newBalance = await level.GrantCurrencyAsync(pid, req.CurrencyType, -TierCost,
|
||||
$"buyTier:{req.PurchasableItemId}");
|
||||
return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType));
|
||||
}
|
||||
|
||||
[HttpPost("api/storefronts/v1/buyElite")]
|
||||
[HttpPost("api/storefronts/v2/buyElite")]
|
||||
public async Task<IActionResult> BuyElite([FromBody] RequestPurchaseItemDto req)
|
||||
{
|
||||
// Elite-tier purchase (paid premium track). Without real
|
||||
// money flow on a private server we just ack.
|
||||
var pid = Me;
|
||||
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
|
||||
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private async Task GrantWardrobeAsync(long pid, string guid)
|
||||
{
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||
if (avatar is null)
|
||||
{
|
||||
avatar = new AvatarEntity { PlayerId = pid };
|
||||
db.Avatars.Add(avatar);
|
||||
}
|
||||
try
|
||||
{
|
||||
var list = System.Text.Json.JsonSerializer
|
||||
.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
|
||||
if (!list.Contains(guid))
|
||||
{
|
||||
list.Add(guid);
|
||||
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list);
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(new[] { guid });
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GrantInventoryAsync(long pid, string slug)
|
||||
{
|
||||
var row = await db.PlayerInventory
|
||||
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug);
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerInventory.Add(new PlayerInventoryEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
ItemSlug = slug,
|
||||
Quantity = 1,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Quantity += 1;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static object BalanceUpdateResponse(long balance, int currencyType,
|
||||
string? grantedSlug = null, long? giftPackageId = null)
|
||||
{
|
||||
// PurchaseBalanceUpdateResponseDTO<GiftPackage> wire shape, verified
|
||||
// against Cpp2IL_ISIL/.../RecNet/Avatars_NestedType_GiftPackage.txt:
|
||||
//
|
||||
// PurchaseBalanceUpdateResponseDTO EXTENDS BalanceResponseDTO,
|
||||
// so the watch first runs BalanceResponseDTO.Deserialize which
|
||||
// does Util.GetKey<long>("Balance") / GetKey<int>("CurrencyType")
|
||||
// / GetKey<int>("BalanceType") — REQUIRED, KeyNotFoundException
|
||||
// on miss. Mirror the trio on the OUTER object.
|
||||
//
|
||||
// Then it reads BalanceUpdates: List<BalanceUpdateDTO<GiftPackage>>.
|
||||
// Each inner BalanceUpdateDTO has UpdateResponse + Data, where
|
||||
// Data is List<GiftPackage>. GiftPackage.Deserialize REQUIRES:
|
||||
// "Id" (long) | Util.GetKey
|
||||
// "Platform" (int) | Util.GetKey
|
||||
// "Xp" (int) | Util.GetKey
|
||||
// "Level" (int) | Util.GetKey
|
||||
// "GiftRarity" (int) | Util.GetKey
|
||||
// "GiftContext" (int) | Util.GetKey
|
||||
// Plus optional FromPlayerId / AvatarItemType / AvatarItemDesc /
|
||||
// ConsumableItemDesc / EquipmentPrefabName / EquipmentModificationGuid /
|
||||
// CurrencyType / Currency / Message.
|
||||
//
|
||||
// The previous shape shipped {CurrencyType,Balance,BalanceType,Platform}
|
||||
// in the Data array — those don't match GiftPackage's required keys,
|
||||
// so Util.GetKey<long>("Id") threw KeyNotFoundException, the
|
||||
// ApiCallback received a non-null error string, and StorePurchaseItem
|
||||
// Screen logged the error as "Null". The user's
|
||||
// output_log.txt:920 shows that callback path firing on every
|
||||
// purchase attempt. New shape ships a real (synthetic) GiftPackage
|
||||
// with all required keys + the granted item's GUID under
|
||||
// AvatarItemDesc so the watch's gift-receive flow can preview the
|
||||
// unlocked item.
|
||||
//
|
||||
// CRITICAL: the watch follows up every purchase with a POST to
|
||||
// /api/avatar/v2/gifts/consume/ containing this Id. If the Id
|
||||
// isn't a real GiftPackageEntity row in the DB, consume returns
|
||||
// 404 and the watch's <RunConsumeGift> logs 'Server failed to
|
||||
// consume gift … HTTP Error 404', which the player-destroy
|
||||
// cascade picks up on (the user's symptom on every buy:
|
||||
// game crashes after opening the gift box). Caller MUST pass
|
||||
// <paramref name="giftPackageId"/> = the saved
|
||||
// GiftPackageEntity.Id. The previous code used
|
||||
// DateTime.UtcNow.Ticks as a fake id.
|
||||
var giftId = giftPackageId ?? DateTime.UtcNow.Ticks;
|
||||
// Pull the GUID off the slug for wardrobe-* purchases so the
|
||||
// watch's GiftPackage receives the AvatarItem GUID and can
|
||||
// render the unlock animation. dorknet-* slugs leave the desc
|
||||
// empty (those are non-wardrobe SKUs).
|
||||
var avatarItemType = 0;
|
||||
var avatarItemDesc = string.Empty;
|
||||
var consumableItemDesc = string.Empty;
|
||||
if (grantedSlug is not null)
|
||||
{
|
||||
StoreService.TryGetAvatarItemPayload(grantedSlug, out avatarItemType, out avatarItemDesc);
|
||||
StoreService.TryGetConsumableItemDesc(grantedSlug, out consumableItemDesc);
|
||||
if (StoreService.TryGetHairDyePayload(grantedSlug, out var hairDyeConsumableDesc, out var hairDyeColorGuid))
|
||||
{
|
||||
avatarItemType = 1;
|
||||
avatarItemDesc = hairDyeColorGuid;
|
||||
consumableItemDesc = hairDyeConsumableDesc;
|
||||
}
|
||||
}
|
||||
|
||||
var giftPackage = new
|
||||
{
|
||||
// REQUIRED via Util.GetKey — KeyNotFoundException on miss.
|
||||
Id = giftId,
|
||||
Platform = 0,
|
||||
Xp = 0,
|
||||
Level = 1,
|
||||
GiftRarity = 0,
|
||||
GiftContext = 0,
|
||||
// Optional — surfaced to the watch's gift-preview UI.
|
||||
FromPlayerId = (int?)null,
|
||||
AvatarItemType = string.IsNullOrEmpty(avatarItemDesc) ? (int?)null : avatarItemType,
|
||||
AvatarItemDesc = avatarItemDesc,
|
||||
AvatarItemDescOrHairDyeDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc),
|
||||
ConsumableItemDesc = consumableItemDesc,
|
||||
EquipmentPrefabName = string.Empty,
|
||||
EquipmentModificationGuid = string.Empty,
|
||||
CurrencyType = currencyType,
|
||||
Currency = 0,
|
||||
Message = string.Empty,
|
||||
Consumed = false,
|
||||
ErrorMessage = string.Empty,
|
||||
};
|
||||
|
||||
return new
|
||||
{
|
||||
// BalanceResponseDTO required trio.
|
||||
Balance = balance,
|
||||
CurrencyType = currencyType,
|
||||
BalanceType = 0,
|
||||
// PurchaseBalanceUpdateResponseDTO.BalanceUpdates =
|
||||
// List<BalanceUpdateDTO<GiftPackage>>.
|
||||
BalanceUpdates = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
UpdateResponse = 0, // UpdateResponseTypes.Success
|
||||
Data = grantedSlug is null
|
||||
? Array.Empty<object>()
|
||||
: new object[] { giftPackage },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,499 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Store;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/storefronts/v* — real implementation backed by the
|
||||
/// <see cref="StoreService"/> catalog (StoreItems table). Replaces the
|
||||
/// previous AllEndpointsController stubs that returned empty lists,
|
||||
/// which made the watch's Shop tab look broken.
|
||||
///
|
||||
/// Wire shape: every endpoint returns either a list of items (for
|
||||
/// catalog endpoints) or a StorefrontDTO with a StoreItems array.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class StorefrontsController(
|
||||
StoreService store,
|
||||
DorkNetDbContext db,
|
||||
IConfiguration config,
|
||||
LevelService level,
|
||||
DomainConfig domain) : ControllerBase
|
||||
{
|
||||
/// <summary>GET api/storefronts/v3/all — full active catalog. The
|
||||
/// watch's Shop tab uses this to populate every category.</summary>
|
||||
[HttpGet("api/storefronts/v1/all")]
|
||||
[HttpGet("api/storefronts/v2/all")]
|
||||
[HttpGet("api/storefronts/v3/all")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> All()
|
||||
{
|
||||
var items = await store.GetAllActiveAsync();
|
||||
return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>GET api/storefronts/v3/skus — alias for the full
|
||||
/// catalog. Some 2020 client paths call this instead of /all
|
||||
/// during the watch's "browse all SKUs" flow.</summary>
|
||||
[HttpGet("api/storefronts/v3/skus")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Skus()
|
||||
{
|
||||
var items = await store.GetAllActiveAsync();
|
||||
return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>GET api/storefronts/v3/season — items currently in the
|
||||
/// season-pass storefront. Empty for now since we don't run
|
||||
/// seasons; returning an empty list rather than the catch-all so
|
||||
/// the deserialiser sees the right shape.</summary>
|
||||
[HttpGet("api/storefronts/v3/season")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Season()
|
||||
{
|
||||
var items = await store.GetActiveByStorefrontAsync("season:1");
|
||||
return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>GET api/storefronts/v3/giftdrop — list of available
|
||||
/// gift-drop storefront ids. The watch then queries each one via
|
||||
/// /giftdropstore/{id} below to render the rotating shelf.</summary>
|
||||
[HttpGet("api/storefronts/v3/giftdrop")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult GiftDropList() =>
|
||||
Ok(StoreService.GetStorefrontDefinitions()
|
||||
.Where(s => s.StorefrontType is > 0)
|
||||
.Select(s => s.StorefrontType!.Value)
|
||||
.Distinct()
|
||||
.OrderBy(i => i)
|
||||
.ToArray());
|
||||
|
||||
/// <summary>GET api/storefronts/v3/giftdropstore/{id} — one
|
||||
/// storefront shelf. The <c>{id}</c> is a value from the
|
||||
/// <c>RecNet.StorefrontTypes</c> enum (verified against
|
||||
/// Cpp2IL_CS/.../RecNet/StorefrontTypes.cs):
|
||||
/// None=0, LaserTag=1, RecCenter=2, <b>Watch=3</b>,
|
||||
/// Quest_*=100..103, RecRoyale=200, Cafe=300, Paintball=400..406,
|
||||
/// Bowling=500, StuntRunner=600, DormMirror=700.
|
||||
/// The Watch storefront (id=3) is the in-game Shop's Featured /
|
||||
/// Clothing / Other grid. That grid assumes every gift drop has a
|
||||
/// renderable AvatarItemDesc, so only wardrobe-backed rows are
|
||||
/// surfaced there. Other storefronts use the canonical
|
||||
/// <c>giftdrop:{id}</c> key plus the shared <c>rro</c> and
|
||||
/// <c>all</c> admin keys.
|
||||
/// </summary>
|
||||
[HttpGet("api/storefronts/v3/giftdropstore/{id:int}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GiftDropStore(int id)
|
||||
{
|
||||
var (key, items) = id switch
|
||||
{
|
||||
3 => ($"watch", await store.GetRenderableGiftDropItemsAsync()),
|
||||
_ => (StoreService.StorefrontKeyForType(id), await store.GetRoomStorefrontItemsAsync(id)),
|
||||
};
|
||||
return Ok(StoreService.ToStorefrontDto(key, items, domain.Apex, id));
|
||||
}
|
||||
|
||||
/// <summary>GET api/storefronts/v1/balance/{type} — currency
|
||||
/// balance for the caller. Wire shape is a list of
|
||||
/// {CurrencyType, Balance, BalanceType, Platform} entries. The
|
||||
/// watch displays the Balance value next to item prices.</summary>
|
||||
[HttpGet("api/storefronts/v1/balance/{currencyType:int}")]
|
||||
[HttpGet("api/storefronts/v2/balance/{currencyType:int}")]
|
||||
[HttpGet("api/storefronts/v3/balance/{currencyType:int}")]
|
||||
[HttpGet("api/storefronts/v4/balance/{currencyType:int}")]
|
||||
[HttpGet("api/storefronts/v4/balance")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Balance(int currencyType = 2,
|
||||
[FromServices] LevelService level = null!)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var balance = await level.GetBalanceAsync(pid, currencyType);
|
||||
// 2018 client (RecNet.cs:94092, KKBGNKJHGJF<BalanceResponseDTO>) parses a
|
||||
// SINGLE object reading Balance(long)+CurrencyType(int) off the root —
|
||||
// reading "Balance" off an array root throws. This branch is 2018-only,
|
||||
// so the per-currency balance returns a single object, not an array.
|
||||
return Ok(new
|
||||
{
|
||||
CurrencyType = currencyType,
|
||||
BalanceType = 0,
|
||||
Balance = balance,
|
||||
Platform = 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/storefronts/v{1-4}/balanceAddType/{type}/{tier}</c>
|
||||
/// — purchase-tier metadata for a currency. The watch uses this
|
||||
/// to populate the "add tokens" tier list. Empty for a private
|
||||
/// server with no IAP.</summary>
|
||||
[HttpGet("api/storefronts/v1/balanceAddType/{currencyType:int}/{tierId:int}")]
|
||||
[HttpGet("api/storefronts/v2/balanceAddType/{currencyType:int}/{tierId:int}")]
|
||||
[HttpGet("api/storefronts/v3/balanceAddType/{currencyType:int}/{tierId:int}")]
|
||||
[HttpGet("api/storefronts/v4/balanceAddType/{currencyType:int}/{tierId:int}")]
|
||||
public IActionResult BalanceAddType(int currencyType, int tierId)
|
||||
=> Ok(BalanceAddConfig(currencyType, tierId));
|
||||
|
||||
/// <summary>GET <c>api/storefronts/v2/balance</c> — every currency
|
||||
/// balance for the caller in a single response. The watch's
|
||||
/// wallet UI uses this to render all four currency chips at once.
|
||||
/// CurrencyType per <c>CurrencyType.cs</c>: 0=Token, 1=Cheer,
|
||||
/// 2=Coin, 3=Premium.</summary>
|
||||
[HttpGet("api/storefronts/v2/balance")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> AllBalances()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var coin = await level.GetBalanceAsync(pid, 2);
|
||||
var token = await level.GetBalanceAsync(pid, 0);
|
||||
return Ok(new[]
|
||||
{
|
||||
new { CurrencyType = 2, BalanceType = 0, Balance = coin, Platform = 0 },
|
||||
new { CurrencyType = 0, BalanceType = 0, Balance = token, Platform = 0 },
|
||||
new { CurrencyType = 1, BalanceType = 0, Balance = 0L, Platform = 0 },
|
||||
new { CurrencyType = 3, BalanceType = 0, Balance = 0L, Platform = 0 },
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/storefronts/v1/current</c> — single active
|
||||
/// storefront the watch's "today's deals" panel renders. Returns
|
||||
/// the <c>main</c> storefront's items as a single PurchasableItem
|
||||
/// list (bounded to keep older watches snappy).</summary>
|
||||
[HttpGet("api/storefronts/v1/current")]
|
||||
public async Task<IActionResult> Current()
|
||||
{
|
||||
if (!config.GetValue("Store:EnableWatchGiftDrops", true))
|
||||
return Ok(StoreService.ToStorefrontDto("Main", Array.Empty<StoreItemEntity>(), domain.Apex));
|
||||
|
||||
IQueryable<StoreItemEntity> query = db.StoreItems
|
||||
.Where(s => s.IsActive && s.Storefront == "main")
|
||||
.OrderByDescending(s => s.UpdatedAt);
|
||||
var max = config.GetValue("Store:MaxCurrentStorefrontItems", 96);
|
||||
if (max > 0) query = query.Take(max);
|
||||
var rows = await query.ToListAsync();
|
||||
return Ok(StoreService.ToStorefrontDto("Main", rows, domain.Apex));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/storefronts/v1/season/{seasonId}</c> —
|
||||
/// season-pass tier list, sourced from StoreItems tagged
|
||||
/// <c>Storefront = "season:{id}"</c>.</summary>
|
||||
[HttpGet("api/storefronts/v1/season/{seasonId:int}")]
|
||||
public async Task<IActionResult> Season(int seasonId)
|
||||
{
|
||||
var key = $"season:{seasonId}";
|
||||
var tiers = await db.StoreItems
|
||||
.Where(s => s.IsActive && s.Storefront == key)
|
||||
.OrderBy(s => s.Price)
|
||||
.Select(s => new
|
||||
{
|
||||
TierId = s.Id,
|
||||
s.DisplayName,
|
||||
s.Description,
|
||||
s.Price,
|
||||
CurrencyType = s.CurrencyType,
|
||||
s.ImageName,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(new
|
||||
{
|
||||
SeasonId = seasonId,
|
||||
SeasonType = seasonId,
|
||||
Active = tiers.Count > 0,
|
||||
StartsAt = DateTime.UtcNow.AddDays(-30),
|
||||
EndsAt = DateTime.UtcNow.AddDays(30),
|
||||
Tiers = tiers,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/storefronts/v1/objectives</c> — season
|
||||
/// objective progress for the caller, keyed
|
||||
/// <c>season:{n}</c>.</summary>
|
||||
[HttpGet("api/storefronts/v1/objectives")]
|
||||
public async Task<IActionResult> Objectives()
|
||||
{
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is not long me) return Ok(Array.Empty<object>());
|
||||
var rows = await db.ObjectiveProgress
|
||||
.Where(o => o.PlayerId == me && o.Key.StartsWith("season:"))
|
||||
.Select(o => new { Key = o.Key, IsCompleted = o.IsCompleted, ClearedAt = o.ClearedAt })
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/storefronts/v1/objectives</c> —
|
||||
/// <c>Storefronts.CompleteObjectives</c> uploads completion
|
||||
/// records and expects a <c>BalanceUpdateResponseDTO</c> back.
|
||||
/// We ship the wrapper with the required Balance/CurrencyType/
|
||||
/// BalanceType trio so <c>BalanceResponseDTO.Deserialize</c>
|
||||
/// doesn't throw, plus an empty <c>BalanceUpdates</c>.</summary>
|
||||
[HttpPost("api/storefronts/v1/objectives")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CompleteObjectives()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var records = await ReadStorefrontObjectiveRecordsAsync();
|
||||
var total = 0;
|
||||
foreach (var record in records ?? new())
|
||||
{
|
||||
if (record.CompletionPercentage <= 0f) continue;
|
||||
var rewardKey = $"storefront-objective:{record.ObjectiveType}:{DateTime.UtcNow:yyyyMMdd}";
|
||||
if (await MarkRewardOnceAsync(pid, rewardKey))
|
||||
{
|
||||
var award = record.ObjectiveType switch
|
||||
{
|
||||
10 or 11 or 12 => 25,
|
||||
13 => 150,
|
||||
14 => 50,
|
||||
15 => 75,
|
||||
4000 or 4001 => 50,
|
||||
_ => Math.Max(5, (int)Math.Round(25 * Math.Clamp(record.CompletionPercentage, 0f, 1f))),
|
||||
};
|
||||
total += award;
|
||||
}
|
||||
}
|
||||
|
||||
if (total > 0)
|
||||
{
|
||||
await level.AwardXpAsync(pid, total, "storefront-objectives");
|
||||
await level.GrantCurrencyAsync(pid, 2, total, "storefront-objectives");
|
||||
}
|
||||
var balance = await level.GetBalanceAsync(pid, 2);
|
||||
return Ok(BalanceUpdateResponse(balance, 2, new[]
|
||||
{
|
||||
RewardModification(100, total, total, currentCount: total > 0 ? 1 : 0),
|
||||
}));
|
||||
}
|
||||
|
||||
public sealed class StorefrontObjectiveCompletionRecord
|
||||
{
|
||||
public int ObjectiveType { get; set; }
|
||||
public float CompletionPercentage { get; set; }
|
||||
public long? RoomId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class GrantBalanceRewardDto
|
||||
{
|
||||
public int CurrencyType { get; set; } = 2;
|
||||
public List<GrantBalanceRequest> BalanceAdds { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class GrantBalanceRequest
|
||||
{
|
||||
public float Multiplier { get; set; } = 1f;
|
||||
public int BalanceAddType { get; set; } = 1;
|
||||
}
|
||||
|
||||
[HttpPost("api/storefronts/v2/balance")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> ModifyBalance()
|
||||
{
|
||||
var body = await ReadGrantBalanceRewardAsync();
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var currencyType = body.CurrencyType == 0 ? 2 : body.CurrencyType;
|
||||
var modifications = new List<object>();
|
||||
var total = 0;
|
||||
|
||||
foreach (var add in body.BalanceAdds ?? new())
|
||||
{
|
||||
var config = BalanceAddConfigValues(currencyType, add.BalanceAddType);
|
||||
var multiplier = Math.Clamp(add.Multiplier <= 0 ? 1f : add.Multiplier, 0f, config.MaxPartialMultiplier);
|
||||
var baseAward = config.IgnorePartialMultiplier
|
||||
? config.BaseAward
|
||||
: (int)Math.Round(config.BaseAward * multiplier);
|
||||
baseAward = Math.Max(0, baseAward);
|
||||
if (baseAward == 0) continue;
|
||||
total += baseAward;
|
||||
modifications.Add(RewardModification(add.BalanceAddType, baseAward, baseAward, currentCount: 1));
|
||||
}
|
||||
|
||||
if (total > 0)
|
||||
{
|
||||
await level.GrantCurrencyAsync(pid, currencyType, total, "storefront-balance-reward");
|
||||
await level.AwardXpAsync(pid, Math.Max(10, total / 2), "storefront-balance-reward");
|
||||
}
|
||||
|
||||
var balance = await level.GetBalanceAsync(pid, currencyType);
|
||||
return Ok(BalanceUpdateResponse(balance, currencyType, modifications));
|
||||
}
|
||||
|
||||
public sealed class BalanceAddTypeRequest { public long Amount { get; set; } }
|
||||
|
||||
/// <summary>POST <c>api/storefronts/v1/balanceAddType/{type}/{playerId}</c>
|
||||
/// — admin-only currency credit. Grants via
|
||||
/// <see cref="LevelService.GrantCurrencyAsync"/>.</summary>
|
||||
[HttpPost("api/storefronts/v1/balanceAddType/{type:int}/{playerId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> BalanceAddTypeAdmin(int type, long playerId, [FromBody] BalanceAddTypeRequest? body)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var isAdmin = await db.Players.Where(p => p.Id == me).Select(p => p.IsAdmin).FirstOrDefaultAsync();
|
||||
if (!isAdmin) return Forbid();
|
||||
|
||||
var amount = body?.Amount ?? 0L;
|
||||
if (amount == 0) return Ok(new { Added = 0L });
|
||||
var newBalance = await level.GrantCurrencyAsync(playerId, type, amount, $"balanceAddType:{me}");
|
||||
return Ok(new
|
||||
{
|
||||
CurrencyType = type,
|
||||
Balance = newBalance,
|
||||
BalanceType = 0,
|
||||
Platform = 0,
|
||||
});
|
||||
}
|
||||
|
||||
private static object BalanceAddConfig(int currencyType, int balanceAddType)
|
||||
{
|
||||
var cfg = BalanceAddConfigValues(currencyType, balanceAddType);
|
||||
return new
|
||||
{
|
||||
CurrencyType = currencyType,
|
||||
BalanceAddType = balanceAddType,
|
||||
cfg.BaseAward,
|
||||
BonusAwardMin = 0,
|
||||
BonusAwardMax = 0,
|
||||
RateLimitType = 0,
|
||||
IgnorePartialMultiplier = cfg.IgnorePartialMultiplier,
|
||||
MaxPartialMultiplier = (int)Math.Ceiling(cfg.MaxPartialMultiplier),
|
||||
RateLimit = 0,
|
||||
BalanceInGiftBox = false,
|
||||
};
|
||||
}
|
||||
|
||||
private static (int BaseAward, float MaxPartialMultiplier, bool IgnorePartialMultiplier)
|
||||
BalanceAddConfigValues(int currencyType, int balanceAddType) => balanceAddType switch
|
||||
{
|
||||
1 => (100, 10f, false), // DirectBalanceWithMultiplier: Stunt Runner / banked loot.
|
||||
10 => (25, 1f, true), // NUXChallenge.
|
||||
11 => (100, 1f, true), // AllNUXChallenges.
|
||||
100 => (25, 1f, true), // DailyChallenge.
|
||||
101 => (150, 1f, true), // AllDailyChallenges.
|
||||
200 => (50, 1f, true), // FinishActivity.
|
||||
1000 => (100, 1f, true), // WonGame.
|
||||
1001 => (25, 1f, true), // LostGame.
|
||||
_ => (25, 1f, true),
|
||||
};
|
||||
|
||||
private async Task<GrantBalanceRewardDto> ReadGrantBalanceRewardAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
var dto = new GrantBalanceRewardDto
|
||||
{
|
||||
CurrencyType = int.TryParse(form["CurrencyType"], out var ct) ? ct : 2,
|
||||
};
|
||||
if (int.TryParse(form["BalanceAddType"], out var bat) || int.TryParse(form["balanceAddType"], out bat))
|
||||
{
|
||||
dto.BalanceAdds.Add(new GrantBalanceRequest
|
||||
{
|
||||
BalanceAddType = bat,
|
||||
Multiplier = float.TryParse(form["Multiplier"], out var mult) ? mult : 1f,
|
||||
});
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var dto = await JsonSerializer.DeserializeAsync<GrantBalanceRewardDto>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
return dto ?? new GrantBalanceRewardDto();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new GrantBalanceRewardDto();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<StorefrontObjectiveCompletionRecord>> ReadStorefrontObjectiveRecordsAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
if (!int.TryParse(form["objectiveType"], out var objectiveType)
|
||||
&& !int.TryParse(form["ObjectiveType"], out objectiveType))
|
||||
{
|
||||
return new();
|
||||
}
|
||||
return new()
|
||||
{
|
||||
new StorefrontObjectiveCompletionRecord
|
||||
{
|
||||
ObjectiveType = objectiveType,
|
||||
CompletionPercentage = float.TryParse(form["completionPercentage"], out var pct)
|
||||
|| float.TryParse(form["CompletionPercentage"], out pct)
|
||||
? pct
|
||||
: 1f,
|
||||
RoomId = long.TryParse(form["roomId"], out var roomId)
|
||||
|| long.TryParse(form["RoomId"], out roomId)
|
||||
? roomId
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var rows = await JsonSerializer.DeserializeAsync<List<StorefrontObjectiveCompletionRecord>>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
return rows ?? new();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> MarkRewardOnceAsync(long playerId, string rewardKey)
|
||||
{
|
||||
var key = $"reward:{rewardKey}";
|
||||
if (await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == playerId && o.Key == key && o.IsCompleted))
|
||||
return false;
|
||||
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Key = key,
|
||||
IsCompleted = true,
|
||||
ClearedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static object RewardModification(int balanceAddType, int baseAward, int total, int currentCount) => new
|
||||
{
|
||||
BalanceAddType = balanceAddType,
|
||||
BaseAward = baseAward,
|
||||
BonusAward = 0,
|
||||
RateLimit = 0,
|
||||
CurrentCount = currentCount,
|
||||
Total = total,
|
||||
BalanceType = 0,
|
||||
BalanceInGiftBox = false,
|
||||
};
|
||||
|
||||
private static object BalanceUpdateResponse(long balance, int currencyType, IEnumerable<object> modifications) => new
|
||||
{
|
||||
Balance = balance,
|
||||
CurrencyType = currencyType,
|
||||
BalanceType = 0,
|
||||
BalanceUpdates = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
UpdateResponse = 0,
|
||||
Data = modifications.Where(m => m is not null).ToArray(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.TestCaseManagement;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/testcasemanagement/* — internal QA tooling. URL
|
||||
/// surface verified against
|
||||
/// <c>Cpp2IL_ISIL/.../RecNet/TestCaseManagement.txt</c>:
|
||||
///
|
||||
/// GET api/testcasemanagement/v1/testpasssummary → List<TestPass>
|
||||
/// GET api/testcasemanagement/v1/testpass/{id} → TestPass
|
||||
/// GET api/testcasemanagement/v1/testcase/{id} → TestCase
|
||||
/// POST api/testcasemanagement/v1/testcase/{id}/claim
|
||||
/// POST api/testcasemanagement/v1/testcase/{id}/unclaim
|
||||
/// POST api/testcasemanagement/v1/testcase/{id}/status
|
||||
///
|
||||
/// Wire DTOs match <c>RecNet.TestCase</c> + <c>RecNet.TestPass</c>
|
||||
/// exactly:
|
||||
///
|
||||
/// • TestCase: <c>Id(string), Key, Title, Description, RoomName,
|
||||
/// Status(TestCaseStatus enum 0=NotYetTested/1=Claimed/2=Failed/3=Passed),
|
||||
/// MinNumAssignedPlayers, AssignedPlayerIds(List<int>),
|
||||
/// AssignedPlayerNames, Tags, JiraUrl, JiraBugUrl</c>.
|
||||
/// • TestPass: <c>Id(uint), Name, Description, StartDate, EndDate?,
|
||||
/// WasManuallyClosed, TestCases(List<TestCase>), Tags,
|
||||
/// NumTestCases, NumPassedTestCases, NumFailedTestCases</c>.
|
||||
///
|
||||
/// Routes are reachable by anyone (the watch only renders this tab
|
||||
/// when the player has the developer role; client-side gate). The
|
||||
/// claim/status mutations no-op if the caller isn't authenticated.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class TestCaseManagementController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/testcasemanagement/v1/testpasssummary")]
|
||||
public async Task<IActionResult> TestPassSummaries()
|
||||
{
|
||||
var passes = await db.TestPasses.ToListAsync();
|
||||
var caseCountsByPass = await db.TestCases
|
||||
.Where(c => c.TestPassId != null)
|
||||
.GroupBy(c => c.TestPassId!.Value)
|
||||
.Select(g => new PassCounts(g.Key, g.Count(),
|
||||
g.Count(c => c.Status == 3), g.Count(c => c.Status == 2)))
|
||||
.ToDictionaryAsync(x => x.PassId);
|
||||
return Ok(passes.Select(p => ToWirePass(p, new List<TestCaseEntity>(), caseCountsByPass)));
|
||||
}
|
||||
|
||||
[HttpGet("api/testcasemanagement/v1/testpass/{id:long}")]
|
||||
public async Task<IActionResult> TestPass(long id)
|
||||
{
|
||||
if (id < 0 || id > uint.MaxValue) return NotFound();
|
||||
var pid = (uint)id;
|
||||
var pass = await db.TestPasses.FirstOrDefaultAsync(p => p.Id == pid);
|
||||
if (pass is null) return NotFound();
|
||||
var cases = await db.TestCases.Where(c => c.TestPassId == pid).ToListAsync();
|
||||
var counts = new Dictionary<uint, PassCounts>
|
||||
{
|
||||
[pid] = new(pid, cases.Count, cases.Count(c => c.Status == 3), cases.Count(c => c.Status == 2)),
|
||||
};
|
||||
return Ok(ToWirePass(pass, cases, counts));
|
||||
}
|
||||
|
||||
private record PassCounts(uint PassId, int Total, int Passed, int Failed);
|
||||
|
||||
[HttpGet("api/testcasemanagement/v1/testcase/{id}")]
|
||||
public async Task<IActionResult> TestCase(string id)
|
||||
{
|
||||
var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id);
|
||||
if (tc is null) return NotFound();
|
||||
return Ok(ToWireCase(tc));
|
||||
}
|
||||
|
||||
[HttpPost("api/testcasemanagement/v1/testcase/{id}/claim")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Claim(string id)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id);
|
||||
if (tc is null) return NotFound();
|
||||
|
||||
var player = await db.Players.Where(p => p.Id == pid).Select(p => p.Username).FirstOrDefaultAsync();
|
||||
var ids = ParseIntList(tc.AssignedPlayerIdsJson);
|
||||
var names = ParseStringList(tc.AssignedPlayerNamesJson);
|
||||
if (!ids.Contains((int)pid))
|
||||
{
|
||||
ids.Add((int)pid);
|
||||
names.Add(player ?? $"Player {pid}");
|
||||
tc.AssignedPlayerIdsJson = JsonSerializer.Serialize(ids);
|
||||
tc.AssignedPlayerNamesJson = JsonSerializer.Serialize(names);
|
||||
}
|
||||
tc.Status = 1; // Claimed
|
||||
tc.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWireCase(tc));
|
||||
}
|
||||
|
||||
[HttpPost("api/testcasemanagement/v1/testcase/{id}/unclaim")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Unclaim(string id)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id);
|
||||
if (tc is null) return NotFound();
|
||||
var ids = ParseIntList(tc.AssignedPlayerIdsJson);
|
||||
var names = ParseStringList(tc.AssignedPlayerNamesJson);
|
||||
var idx = ids.IndexOf((int)pid);
|
||||
if (idx >= 0)
|
||||
{
|
||||
ids.RemoveAt(idx);
|
||||
if (idx < names.Count) names.RemoveAt(idx);
|
||||
tc.AssignedPlayerIdsJson = JsonSerializer.Serialize(ids);
|
||||
tc.AssignedPlayerNamesJson = JsonSerializer.Serialize(names);
|
||||
}
|
||||
if (ids.Count == 0 && tc.Status == 1) tc.Status = 0; // back to NotYetTested
|
||||
tc.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWireCase(tc));
|
||||
}
|
||||
|
||||
public sealed class StatusUpdateRequest
|
||||
{
|
||||
public int NewStatus { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/testcasemanagement/v1/testcase/{id}/status")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> UpdateStatus(string id, [FromBody] StatusUpdateRequest req)
|
||||
{
|
||||
var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id);
|
||||
if (tc is null) return NotFound();
|
||||
if (req.NewStatus is < 0 or > 3) return BadRequest("invalid status");
|
||||
tc.Status = req.NewStatus;
|
||||
tc.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWireCase(tc));
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private static List<int> ParseIntList(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return new();
|
||||
try { return JsonSerializer.Deserialize<List<int>>(json) ?? new(); }
|
||||
catch { return new(); }
|
||||
}
|
||||
|
||||
private static List<string> ParseStringList(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return new();
|
||||
try { return JsonSerializer.Deserialize<List<string>>(json) ?? new(); }
|
||||
catch { return new(); }
|
||||
}
|
||||
|
||||
private static object ToWireCase(TestCaseEntity c) => new
|
||||
{
|
||||
c.Id,
|
||||
c.Key,
|
||||
c.Title,
|
||||
c.Description,
|
||||
c.RoomName,
|
||||
c.Status,
|
||||
c.MinNumAssignedPlayers,
|
||||
AssignedPlayerIds = ParseIntList(c.AssignedPlayerIdsJson),
|
||||
AssignedPlayerNames = ParseStringList(c.AssignedPlayerNamesJson),
|
||||
Tags = ParseStringList(c.TagsJson),
|
||||
c.JiraUrl,
|
||||
c.JiraBugUrl,
|
||||
};
|
||||
|
||||
private static object ToWirePass(TestPassEntity p, List<TestCaseEntity> cases,
|
||||
IDictionary<uint, PassCounts> counts)
|
||||
{
|
||||
var c = counts.TryGetValue(p.Id, out var v) ? v : new PassCounts(p.Id, 0, 0, 0);
|
||||
return new
|
||||
{
|
||||
p.Id,
|
||||
p.Name,
|
||||
p.Description,
|
||||
p.StartDate,
|
||||
p.EndDate,
|
||||
p.WasManuallyClosed,
|
||||
TestCases = cases.Select(ToWireCase),
|
||||
Tags = ParseStringList(p.TagsJson),
|
||||
NumTestCases = c.Total,
|
||||
NumPassedTestCases = c.Passed,
|
||||
NumFailedTestCases = c.Failed,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Version;
|
||||
|
||||
[ApiController]
|
||||
public class VersionController : ControllerBase
|
||||
{
|
||||
// VersionStatus enum: ValidForPlay=0, ValidForMenu=1, UpdateRequired=2
|
||||
//
|
||||
// Dual-shape response so a single endpoint satisfies both client eras:
|
||||
// • 2020 build reads the int "VersionStatus" (0 == ValidForPlay).
|
||||
// • 2018.06 build (api/versioncheck/v3) reads the bool "ValidVersion"
|
||||
// via a case-sensitive dict index (RecNet.cs BPPMLPMDHCI.Deserialize
|
||||
// -> Util.GetKey<bool>("ValidVersion")). A MISSING key throws
|
||||
// KeyNotFoundException -> the connect coroutine shows "Rec Room
|
||||
// update required" and aborts boot before login. Emitting both keys
|
||||
// keeps each era reading only the field it knows.
|
||||
private static readonly object ValidForPlay = new { VersionStatus = 0, ValidVersion = true };
|
||||
|
||||
[HttpGet("api/version/v1")]
|
||||
[HttpGet("api/version")]
|
||||
public ActionResult<object> GetV1() => Ok(ValidForPlay);
|
||||
|
||||
// Actual path the 2020 build uses
|
||||
[HttpGet("api/versioncheck/v4")]
|
||||
[HttpGet("api/versioncheck/v3")]
|
||||
[HttpGet("api/versioncheck/v2")]
|
||||
[HttpGet("api/versioncheck/v1")]
|
||||
[HttpGet("api/versioncheck")]
|
||||
public ActionResult<object> GetCheck() => Ok(ValidForPlay);
|
||||
}
|
||||
@@ -1,573 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Auth;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// accounts.rec.net — account management.
|
||||
/// Shapes match dump.cs:
|
||||
/// RecNet.Account { AccountId, RawUsername, Username, DisplayName,
|
||||
/// ProfileImage, TreatAsJunior, HasBirthday, Platforms }
|
||||
/// RecNet.SelfAccount Account + { Email, Phone, Birthday, JuniorState,
|
||||
/// ParentAccountId }
|
||||
/// RecNet.RecNetResult { Success, Error }
|
||||
/// Accounts.CreateAccountResponse RecNetResult + { Account }
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class AccountsController(
|
||||
PlayerService playerService,
|
||||
AuthService authService,
|
||||
OrphanAccountTracker orphans,
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
ServerSettingsService settings,
|
||||
ILogger<AccountsController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet("/account/v1/savedlogins")]
|
||||
[HttpGet("/account/v2/savedlogins")]
|
||||
[HttpGet("/account/v1/cachedlogins")]
|
||||
public async Task<IActionResult> GetSavedLogins(
|
||||
[FromQuery] int platform = 0,
|
||||
[FromQuery] string? platformId = null)
|
||||
{
|
||||
var players = await playerService.GetCachedLoginsAsync(platform, platformId);
|
||||
return Ok(players.Select(p => new CachedLogin
|
||||
{
|
||||
Platform = platform,
|
||||
PlatformId = platformId ?? string.Empty,
|
||||
AccountId = (int)p.Id,
|
||||
LastLoginTime = p.LastSeenAt,
|
||||
RequirePassword = false,
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
// Accounts.GetLocalAccount → SelfAccount shape.
|
||||
// The 2020 client calls the unversioned /account/me path (observed in
|
||||
// server logs); v1/v2 forms are kept for older builds and api.rec.net
|
||||
// bridging. Returns SelfAccount, NOT base Account (this is the *self*
|
||||
// endpoint — RecNet.Login.<>c.<LoginHelper>b__35_4 expects a SelfAccount).
|
||||
[HttpGet("/account/me")]
|
||||
[HttpGet("/account/v1/me")]
|
||||
[HttpGet("/account/v2/me")]
|
||||
[HttpGet("/account/v1/account")]
|
||||
public async Task<IActionResult> GetMe()
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
return Ok(BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the player tied to the bearer token on the request. Returns
|
||||
/// null when no/invalid bearer is supplied — the caller decides whether
|
||||
/// that is a 401 (every "self" endpoint should be) or a noisier
|
||||
/// fallback. Anonymous-account creation was previously the fallback
|
||||
/// path; that's gone now because every caller has gone through
|
||||
/// `/account/create` (deviceId-bound) before reaching us.
|
||||
/// </summary>
|
||||
private async Task<Data.Entities.PlayerEntity?> GetCurrentPlayerAsync()
|
||||
{
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
var token = auth["Bearer ".Length..].Trim();
|
||||
var id = authService.ValidateToken(token);
|
||||
if (id is not long playerId) return null;
|
||||
return await playerService.GetByIdAsync(playerId);
|
||||
}
|
||||
|
||||
// Accounts.GetAccountById → Account shape. The 2020 watch posts to
|
||||
// the BARE <c>/account/{id}</c> path (no <c>v1</c> prefix) when
|
||||
// resolving a room's creator from RoomDetails — verified in the
|
||||
// output_log "Failed to GetAccountById: HTTP Error 404" trace where
|
||||
// PlayerDetailsWatchUIFlow.SetAccount NREs because the GetAccountById
|
||||
// promise rejected with 404. Accept both shapes.
|
||||
[HttpGet("/account/{accountId:long}")]
|
||||
[HttpGet("/account/v1/{accountId:long}")]
|
||||
[HttpGet("/account/v1/account/{accountId:long}")]
|
||||
public async Task<IActionResult> GetAccountById(long accountId)
|
||||
{
|
||||
var player = await playerService.GetByIdAsync(accountId);
|
||||
if (player is null) return NotFound();
|
||||
return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
|
||||
}
|
||||
|
||||
// Accounts.GetAccountByUsername
|
||||
[HttpGet("/account/v1/username/{username}")]
|
||||
public async Task<IActionResult> GetAccountByUsername(string username)
|
||||
{
|
||||
var player = await playerService.GetByUsernameAsync(username);
|
||||
if (player is null) return NotFound();
|
||||
return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
|
||||
}
|
||||
|
||||
// Accounts.CreateAccount — actual URL observed in server logs:
|
||||
// POST accounts.rec.net/account/create
|
||||
// body: platform=0&platformId=<steamId>&deviceId=<hash>
|
||||
// Game expects CreateAccountResponse {Success, Error, Account}. Lambda
|
||||
// <CreateAccount>b__29_0 NPEs if response.Account is null after deserializing.
|
||||
// Clean PascalCase here — multi-case keys appear to confuse LitJson's typed
|
||||
// path so its TypeMap.Add throws "Key: none" or leaves Account unpopulated.
|
||||
[HttpPost("/account/create")]
|
||||
[HttpPost("/account/v1/create")]
|
||||
[HttpPost("/account/v1")]
|
||||
[HttpPost("/account/v2/create")]
|
||||
public async Task<IActionResult> CreateAccount(
|
||||
[FromForm] int platform,
|
||||
[FromForm] string? platformId,
|
||||
[FromForm] string? deviceId)
|
||||
{
|
||||
// Admin kill-switch. Watch's <CreateAccount>b__29_0 lambda
|
||||
// (Accounts_NestedType___c.txt:311) branches on Success: false
|
||||
// → ErroredPromise<Account> with the Error string surfaced to
|
||||
// the player; true → reads response.Account. Account stays a
|
||||
// non-null stub even on refusal so LitJson's typed Deserialize
|
||||
// path doesn't fault before the Success check runs.
|
||||
if (await settings.AreSignupsDisabledAsync())
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[accounts] account/create refused — signups disabled (device={Device} platform={Platform} platformId={PlatformId})",
|
||||
deviceId, platform, platformId);
|
||||
return Ok(new CreateAccountResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "Account creation is currently disabled by the server admin.",
|
||||
});
|
||||
}
|
||||
|
||||
// The 2020 watch's "Create new account" UI and its boot-time
|
||||
// "ensure my account exists" probe BOTH hit this endpoint with
|
||||
// identical request bodies. To support multi-account-per-device
|
||||
// we always create a brand-new row; the boot-flow case (where
|
||||
// the user actually wants their cached account) is handled by
|
||||
// the OrphanAccountTracker — when the next cached_login picks
|
||||
// a DIFFERENT account_id, that handler deletes this just-
|
||||
// created row.
|
||||
var player = await playerService.CreateNewAccountAsync(
|
||||
deviceId: deviceId,
|
||||
platform: platform,
|
||||
platformId: platformId);
|
||||
|
||||
orphans.TrackCreation(deviceId, platformId, player.Id);
|
||||
|
||||
logger.LogInformation(
|
||||
"[accounts] account/create created {Id} ({Username}) for device {Device} platform={Platform} platformId={PlatformId}",
|
||||
player.Id, player.Username, deviceId, platform, platformId);
|
||||
|
||||
return Ok(new CreateAccountResponse
|
||||
{
|
||||
Success = true,
|
||||
Error = string.Empty,
|
||||
Account = BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName),
|
||||
});
|
||||
}
|
||||
|
||||
// Accounts.SearchForAccounts — used by the watch's typeahead AND the
|
||||
// localhost public site's account search. Anonymous-safe (no JWT
|
||||
// required) so the front-facing site can call it directly.
|
||||
[HttpGet("/account/v1/search")]
|
||||
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Search([FromQuery] string? query, [FromQuery] int take = 20)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query)) return Ok(Array.Empty<object>());
|
||||
var clamped = Math.Clamp(take, 1, 50);
|
||||
var matches = await playerService.SearchAsync(query.Trim(), clamped);
|
||||
return Ok(matches
|
||||
.Select(p => BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
// Accounts.GetAccountsBulk / GetAccountsFromPlatformIds / FromEmails / FromPhones
|
||||
// Form body: Ids=1,2,3 (BestHTTP HTTPFormBase.AddField). Accept both
|
||||
// GET and POST and any content type so route matching succeeds even
|
||||
// when the client sends multipart vs urlencoded.
|
||||
[HttpPost("/account/v1/bulk")]
|
||||
[HttpGet("/account/v1/bulk")]
|
||||
[HttpPost("/account/bulk")]
|
||||
[HttpGet("/account/bulk")]
|
||||
[HttpPost("/account/v1/platformids")]
|
||||
[HttpPost("/account/v1/emails")]
|
||||
[HttpPost("/account/v1/phones")]
|
||||
[HttpPost("/account/v1/byplatformid")]
|
||||
public async Task<IActionResult> Bulk()
|
||||
{
|
||||
// The watch calls this route family with payloads keyed by
|
||||
// either Ids (account ids), Emails (email addresses), or
|
||||
// Phones (E.164 strings) depending on which Accounts.GetXxx
|
||||
// helper fired it. Read all three plus the lowercase / singular
|
||||
// variants the cached-account hydration uses.
|
||||
string? rawIds = null;
|
||||
string? rawEmails = null;
|
||||
string? rawPhones = null;
|
||||
try
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
rawIds = NonEmptyOrNull(Request.Form["Ids"].ToString())
|
||||
?? NonEmptyOrNull(Request.Form["ids"].ToString())
|
||||
?? NonEmptyOrNull(Request.Form["Id"].ToString())
|
||||
?? NonEmptyOrNull(Request.Form["id"].ToString());
|
||||
rawEmails = NonEmptyOrNull(Request.Form["Emails"].ToString())
|
||||
?? NonEmptyOrNull(Request.Form["emails"].ToString())
|
||||
?? NonEmptyOrNull(Request.Form["Email"].ToString())
|
||||
?? NonEmptyOrNull(Request.Form["email"].ToString());
|
||||
rawPhones = NonEmptyOrNull(Request.Form["Phones"].ToString())
|
||||
?? NonEmptyOrNull(Request.Form["phones"].ToString())
|
||||
?? NonEmptyOrNull(Request.Form["Phone"].ToString())
|
||||
?? NonEmptyOrNull(Request.Form["phone"].ToString());
|
||||
}
|
||||
}
|
||||
catch { /* form parse may fail on non-form bodies */ }
|
||||
rawIds ??= NonEmptyOrNull(Request.Query["Ids"].ToString())
|
||||
?? NonEmptyOrNull(Request.Query["ids"].ToString())
|
||||
?? NonEmptyOrNull(Request.Query["Id"].ToString())
|
||||
?? NonEmptyOrNull(Request.Query["id"].ToString());
|
||||
|
||||
static string? NonEmptyOrNull(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
|
||||
|
||||
// Build the set of player ids to hydrate from whatever the
|
||||
// caller sent. Each source contributes additively — the watch
|
||||
// never mixes (Ids + Emails) in one call, but the union is
|
||||
// harmless when it happens.
|
||||
var ids = new HashSet<long>();
|
||||
if (rawIds is not null)
|
||||
{
|
||||
foreach (var n in rawIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(n, out var v) && v > 0) ids.Add(v);
|
||||
}
|
||||
if (rawEmails is not null)
|
||||
{
|
||||
var emails = rawEmails.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(e => e.ToLowerInvariant())
|
||||
.Where(e => e.Length > 0)
|
||||
.ToList();
|
||||
if (emails.Count > 0)
|
||||
{
|
||||
var matched = await db.Players
|
||||
.Where(p => p.Email != null && emails.Contains(p.Email.ToLower()))
|
||||
.Select(p => p.Id)
|
||||
.ToListAsync();
|
||||
foreach (var id in matched) ids.Add(id);
|
||||
}
|
||||
}
|
||||
if (rawPhones is not null)
|
||||
{
|
||||
var phones = rawPhones.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(p => p.Length > 0)
|
||||
.ToList();
|
||||
if (phones.Count > 0)
|
||||
{
|
||||
var matched = await db.Players
|
||||
.Where(p => p.Phone != null && phones.Contains(p.Phone))
|
||||
.Select(p => p.Id)
|
||||
.ToListAsync();
|
||||
foreach (var id in matched) ids.Add(id);
|
||||
}
|
||||
}
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
// Strip ids the caller has blocked, and ids that have blocked the
|
||||
// caller. This prevents `bulk` from being used as a cheap oracle
|
||||
// ("who blocked me?"). When the caller is anonymous (no JWT) we
|
||||
// skip the filter entirely — `bulk` is also used during boot for
|
||||
// public profile hydration before the player has authenticated.
|
||||
var me = this.CurrentPlayerId();
|
||||
if (me is long callerId)
|
||||
{
|
||||
var blocked = await db.Relationships
|
||||
.Where(r => r.Status == RelationshipStatus.Blocked &&
|
||||
((r.RequesterId == callerId && ids.Contains(r.TargetId)) ||
|
||||
(r.TargetId == callerId && ids.Contains(r.RequesterId))))
|
||||
.Select(r => r.RequesterId == callerId ? r.TargetId : r.RequesterId)
|
||||
.ToListAsync();
|
||||
foreach (var b in blocked) ids.Remove(b);
|
||||
}
|
||||
|
||||
var accounts = new List<object>();
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var p = await playerService.GetByIdAsync(id);
|
||||
if (p is not null)
|
||||
accounts.Add(BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName));
|
||||
}
|
||||
return Ok(accounts);
|
||||
}
|
||||
|
||||
// Accounts.GetNameGenerationOptions → NameGenDTO {Nouns, Adjectives}
|
||||
[HttpGet("/account/v1/namegen")]
|
||||
[HttpGet("/account/v1/namegeneration")]
|
||||
public IActionResult NameGen() => Ok(new
|
||||
{
|
||||
Nouns = new[] { "Player", "Hero", "Star", "Fox", "Wolf" },
|
||||
Adjectives = new[] { "Brave", "Swift", "Clever", "Bold", "Bright" },
|
||||
});
|
||||
|
||||
// Phone-confirm is the one mutation we don't persist — there's no
|
||||
// SMS provider in the loop. Acknowledge silently.
|
||||
[HttpPost("/account/v1/confirmphone")]
|
||||
public IActionResult ConfirmPhone() =>
|
||||
Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
|
||||
/// <summary>POST /account/v1/birthday — sets the player's
|
||||
/// birthday and updates IsJunior accordingly. Rec Room's junior
|
||||
/// cutoff is <13 years old; we recompute it on every birthday
|
||||
/// write so a freshly turned 13 player loses the junior flag the
|
||||
/// next time they update.</summary>
|
||||
[HttpPost("/account/v1/birthday")]
|
||||
[HttpPost("/account/me/birthday")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> SetBirthday([FromForm(Name = "birthday")] string? birthday)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(birthday) ||
|
||||
!DateTime.TryParse(birthday, out var bday))
|
||||
return Ok(new RecNetResult { Success = false, Error = "invalid_date" });
|
||||
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
player.Birthday = bday.Date;
|
||||
player.IsJunior = AgeYears(bday) < 13;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
/// <summary>POST /account/v1/email — set or clear contact email.
|
||||
/// Persisted verbatim; format isn't validated server-side, the
|
||||
/// in-game email keyboard does it.</summary>
|
||||
[HttpPost("/account/v1/email")]
|
||||
[HttpPost("/account/me/email")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> SetEmail([FromForm(Name = "email")] string? email)
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
player.Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim();
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
/// <summary>POST /account/v1/phone — set or clear contact phone.</summary>
|
||||
[HttpPost("/account/v1/phone")]
|
||||
[HttpPost("/account/me/phone")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> SetPhone([FromForm(Name = "phone")] string? phone)
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
player.Phone = string.IsNullOrWhiteSpace(phone) ? null : phone.Trim();
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
/// <summary>POST /account/v1/profileimage — body: imageName=...
|
||||
/// or fileName=... The client first POSTs the bytes to
|
||||
/// <c>storage.rec.net/upload</c> with FileType=Image, gets back a
|
||||
/// Filename, then sends that filename here. We persist it on
|
||||
/// PlayerEntity.ProfileImageName; reads from /account/me +
|
||||
/// /account/v1/{id} surface it.</summary>
|
||||
[HttpPost("/account/v1/profileimage")]
|
||||
[HttpPost("/account/me/profileimage")]
|
||||
[HttpPost("/profileimage")]
|
||||
[HttpPut("/account/v1/profileimage")]
|
||||
[HttpPut("/account/me/profileimage")]
|
||||
[HttpPut("/profileimage")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> SetProfileImage(
|
||||
[FromForm(Name = "imageName")] string? imageName,
|
||||
[FromForm(Name = "fileName")] string? fileName,
|
||||
[FromForm(Name = "image")] string? imageAlt)
|
||||
{
|
||||
// Fall back to query string for the rare client paths that
|
||||
// send these fields as URL params instead of form fields.
|
||||
var name = imageName ?? fileName ?? imageAlt
|
||||
?? Request.Query["imageName"].ToString()
|
||||
?? Request.Query["fileName"].ToString();
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
player.ProfileImageName = string.IsNullOrWhiteSpace(name) ? null : name.Trim();
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation(
|
||||
"[accounts] profile image set: player={PlayerId} → {Name}",
|
||||
player.Id, player.ProfileImageName ?? "<cleared>");
|
||||
await NotifyAccountImageChangedAsync(player);
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
private Task NotifyAccountImageChangedAsync(PlayerEntity player)
|
||||
{
|
||||
var account = BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName);
|
||||
var selfAccount = BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName);
|
||||
return Task.WhenAll(
|
||||
notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account),
|
||||
notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
|
||||
}
|
||||
|
||||
private static int AgeYears(DateTime birthday)
|
||||
{
|
||||
var today = DateTime.UtcNow.Date;
|
||||
var age = today.Year - birthday.Year;
|
||||
if (birthday.Date > today.AddYears(-age)) age--;
|
||||
return age;
|
||||
}
|
||||
|
||||
// Everyone is a dev for now — exposes the in-game debug console + dev
|
||||
// tools. Per user request: easier debugging while iterating on the
|
||||
// private server. Flip back to false later if you want shipping behavior.
|
||||
[HttpGet("/account/v1/{accountId:long}/isdeveloper")]
|
||||
[HttpGet("/account/me/isdeveloper")]
|
||||
public IActionResult IsDeveloper(long accountId = 0) => Ok(new { IsDeveloper = true });
|
||||
|
||||
/// <summary>
|
||||
/// PUT /account/me/username — body: username=NewName
|
||||
/// Real handler that persists the change to the DB. Was previously
|
||||
/// going to GlobalCatchAllController and returning the shotgun, which
|
||||
/// meant username changes didn't actually save.
|
||||
/// </summary>
|
||||
[HttpPut("/account/me/username")]
|
||||
[HttpPost("/account/me/username")]
|
||||
[HttpPost("/account/v1/username")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> ChangeUsername([FromForm(Name = "username")] string? username)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
return Ok(new { success = false, error = "Username required" });
|
||||
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
await playerService.UpdateUsernameAsync(player.Id, username.Trim());
|
||||
var updated = await playerService.GetByIdAsync(player.Id);
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
error = "",
|
||||
account = new
|
||||
{
|
||||
accountId = (int)player.Id,
|
||||
username = updated?.Username ?? username,
|
||||
displayName = updated?.DisplayName ?? username,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PUT /account/me/displayname — same flow as username but only updates
|
||||
/// the DisplayName field (Username stays as-is).
|
||||
/// </summary>
|
||||
[HttpPut("/account/me/displayname")]
|
||||
[HttpPost("/account/me/displayname")]
|
||||
[HttpPost("/account/v1/displayname")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> ChangeDisplayName([FromForm(Name = "displayName")] string? displayName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
return Ok(new { success = false, error = "DisplayName required" });
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
await playerService.UpdateDisplayNameAsync(player.Id, displayName.Trim());
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PUT /account/me/bio — body: bio=...
|
||||
/// </summary>
|
||||
[HttpPut("/account/me/bio")]
|
||||
[HttpPost("/account/me/bio")]
|
||||
[HttpPost("/account/v1/bio")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> ChangeBio([FromForm(Name = "bio")] string? bio)
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
await playerService.UpdateBioAsync(player.Id, bio ?? string.Empty);
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
// ParentalControl.Deserialize at RVA 0x14543A0 — required keys:
|
||||
// accountId (int, GetKey)
|
||||
// disallowInAppPurchases (bool, GetKey)
|
||||
// Returning {} crashes the client with
|
||||
// "Failed to GetLocalAccountParentalControl: Malformed Response".
|
||||
[HttpGet("/parentalcontrol/me")]
|
||||
[HttpGet("/parentalcontrol/v1/me")]
|
||||
public async Task<IActionResult> ParentalControlMe()
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
return Ok(new
|
||||
{
|
||||
accountId = (int)player.Id,
|
||||
disallowInAppPurchases = false,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/parentalcontrol/{accountId:long}")]
|
||||
[HttpGet("/parentalcontrol/v1/{accountId:long}")]
|
||||
public IActionResult ParentalControlById(long accountId) => Ok(new
|
||||
{
|
||||
accountId = (int)accountId,
|
||||
disallowInAppPurchases = false,
|
||||
});
|
||||
|
||||
[HttpGet("/account/v1/me/disallowiap")]
|
||||
public IActionResult DisallowIap() => Ok(new { Disallow = false });
|
||||
|
||||
/// <summary>GET /account/{id}/bio — public bio lookup. Watch URL
|
||||
/// from <c>Cpp2IL_ISIL/.../RecNet/Accounts.txt</c> line 5686:
|
||||
/// <c>"account/{0}/bio"</c> (no v1 prefix). Wire shape from
|
||||
/// BioDTO.txt: required keys <c>"accountId"</c> (int, Util.GetKey)
|
||||
/// and <c>"bio"</c> (string, optional via the GetKeyOrDefault-
|
||||
/// flavoured 0x180EE6770 helper). Returning the previous
|
||||
/// <c>{ Bio = ... }</c> PascalCase shape made the watch's
|
||||
/// <c>BioDTO.Deserialize</c> KeyNotFoundException on
|
||||
/// <c>accountId</c>, which surfaced as "Failed to GetBio for
|
||||
/// accountId N: Malformed Response" in the client log.</summary>
|
||||
[HttpGet("/account/{accountId:long}/bio")]
|
||||
[HttpGet("/account/v1/{accountId:long}/bio")]
|
||||
public async Task<IActionResult> GetBio(long accountId)
|
||||
{
|
||||
var p = await playerService.GetByIdAsync(accountId);
|
||||
return Ok(new { accountId = (int)accountId, bio = p?.Bio ?? string.Empty });
|
||||
}
|
||||
|
||||
[HttpGet("/account/v1/{accountId:long}/platformid")]
|
||||
public IActionResult GetPlatformId(long accountId) => Ok(new { PlatformId = 0L });
|
||||
|
||||
// Previously had a /{*path} catch-all that returned [] / {}. Removed —
|
||||
// unknown accounts URLs now 404 so we notice them in logs and wire
|
||||
// them properly rather than silently masking missing endpoints.
|
||||
|
||||
private static RecNetAccount BuildAccount(long id, string username, string? displayName, string? profileImage = null)
|
||||
=> new()
|
||||
{
|
||||
AccountId = (int)id,
|
||||
RawUsername = username,
|
||||
Username = username,
|
||||
DisplayName = displayName ?? username,
|
||||
ProfileImage = profileImage ?? string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
};
|
||||
|
||||
private static RecNetSelfAccount BuildSelfAccount(long id, string username, string? displayName, string? profileImage = null)
|
||||
=> new()
|
||||
{
|
||||
AccountId = (int)id,
|
||||
RawUsername = username,
|
||||
Username = username,
|
||||
DisplayName = displayName ?? username,
|
||||
ProfileImage = profileImage ?? string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
Email = string.Empty,
|
||||
Phone = string.Empty,
|
||||
Birthday = new DateTime(2000, 1, 1),
|
||||
JuniorState = 0,
|
||||
ParentAccountId = null,
|
||||
};
|
||||
}
|
||||
@@ -1,3155 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Hubs;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/admin/v1/* — privileged moderation endpoints. Every
|
||||
/// action is gated by <see cref="AdminOnlyAttribute"/>, which checks
|
||||
/// <see cref="PlayerEntity.IsAdmin"/>; every state mutation is logged
|
||||
/// to <see cref="AdminActionEntity"/> for audit replay.
|
||||
///
|
||||
/// The first account created on a fresh DB gets <c>IsAdmin = true</c>
|
||||
/// (see <see cref="PlayerService.GetOrCreateByDeviceAsync"/>) so a
|
||||
/// freshly-bootstrapped private server always has at least one root
|
||||
/// admin without manual SQL.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/v1")]
|
||||
[Authorize]
|
||||
[AdminOnly]
|
||||
public class AdminController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
LevelService level,
|
||||
CommunityBoardService communityBoard,
|
||||
RoomBlobNormalizerService roomBlobNormalizer,
|
||||
HtrAssetMirrorService htrMirror,
|
||||
OnlinePresenceService onlinePresence,
|
||||
PlayerPresenceService playerPresence,
|
||||
PlayerLogService playerLog,
|
||||
ServerSettingsService serverSettings,
|
||||
SignupCodeService signupCodes,
|
||||
IObjectStorage storage,
|
||||
DomainConfig domain,
|
||||
ILogger<AdminController> adminLogger) : ControllerBase
|
||||
{
|
||||
private long CurrentAdminId => this.RequireCurrentPlayerId();
|
||||
|
||||
// ── Player request logs ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>Most recent HTTP requests made by a single player, newest
|
||||
/// first. Backed by <see cref="PlayerLogService"/>'s Redis ring buffer
|
||||
/// (in-process fallback for dev) — capped at
|
||||
/// <see cref="PlayerLogService.MaxEntriesPerPlayer"/> entries per
|
||||
/// player with a 7-day key TTL. Used by the admin UI's "Player logs"
|
||||
/// tab to spot what API call a player was making when they hit a bug.</summary>
|
||||
[HttpGet("players/{id:long}/logs")]
|
||||
public ActionResult PlayerLogs(long id, [FromQuery] int take = 200)
|
||||
{
|
||||
var entries = playerLog.GetRecent(id, take);
|
||||
return Ok(entries);
|
||||
}
|
||||
|
||||
// ── Players ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>List players with online status, paginated. Used by the
|
||||
/// admin watch tab. Default page size 50, max 200 to keep responses
|
||||
/// reasonable.</summary>
|
||||
[HttpGet("players")]
|
||||
public async Task<ActionResult> ListPlayers(
|
||||
[FromQuery] string? query, [FromQuery] int take = 50, [FromQuery] int skip = 0)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 200);
|
||||
skip = Math.Max(0, skip);
|
||||
|
||||
IQueryable<PlayerEntity> q = db.Players.OrderByDescending(p => p.LastSeenAt);
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
q = q.Where(p => p.Username.Contains(query) || p.DisplayName.Contains(query));
|
||||
|
||||
var page = await q.Skip(skip).Take(take).ToListAsync();
|
||||
var onlineSet = onlinePresence.OnlinePlayerIds().ToHashSet();
|
||||
return Ok(page.Select(p => new
|
||||
{
|
||||
p.Id,
|
||||
p.Username,
|
||||
p.DisplayName,
|
||||
p.Email,
|
||||
p.IsAdmin,
|
||||
p.IsDeveloper,
|
||||
p.IsCommunityTeam,
|
||||
p.IsVerified,
|
||||
p.IsJunior,
|
||||
p.BannedUntil,
|
||||
p.LastIp,
|
||||
p.LastSeenAt,
|
||||
p.CreatedAt,
|
||||
p.Level,
|
||||
p.XP,
|
||||
p.ProfileImageName,
|
||||
Online = onlineSet.Contains(p.Id),
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>Single-player detail — bundles the player record plus
|
||||
/// their currency balances so the admin UI can render an edit page
|
||||
/// in one round trip.</summary>
|
||||
[HttpGet("players/{id:long}")]
|
||||
public async Task<ActionResult> GetPlayer(long id)
|
||||
{
|
||||
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (p is null) return NotFound();
|
||||
var balances = await db.CurrencyBalances
|
||||
.Where(c => c.PlayerId == id)
|
||||
.Select(c => new { c.CurrencyType, c.Balance })
|
||||
.ToListAsync();
|
||||
var avatar = await db.Avatars
|
||||
.Where(a => a.PlayerId == id)
|
||||
.Select(a => new { a.OutfitSelections, a.HairColor, a.SkinColor, a.FaceFeatures })
|
||||
.FirstOrDefaultAsync();
|
||||
return Ok(new
|
||||
{
|
||||
p.Id,
|
||||
p.Username,
|
||||
p.DisplayName,
|
||||
p.Bio,
|
||||
p.Email,
|
||||
p.IsAdmin,
|
||||
p.IsDeveloper,
|
||||
p.IsCommunityTeam,
|
||||
p.IsVerified,
|
||||
p.IsJunior,
|
||||
p.BannedUntil,
|
||||
p.LastIp,
|
||||
p.LastSeenAt,
|
||||
p.CreatedAt,
|
||||
p.Level,
|
||||
p.XP,
|
||||
p.ProfileImageName,
|
||||
Online = onlinePresence.OnlinePlayerIds().Contains(p.Id),
|
||||
Balances = balances,
|
||||
Avatar = avatar,
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record BanRequest(int DurationDays, string? Reason);
|
||||
|
||||
/// <summary>Ban a player for the given number of days. The
|
||||
/// <see cref="BanCheckMiddleware"/> 401s any subsequent request
|
||||
/// from the player until <c>BannedUntil</c> elapses; we also push
|
||||
/// a <c>ModerationKick</c> notification so the watch sees the ban
|
||||
/// immediately.</summary>
|
||||
[HttpPost("players/{id:long}/ban")]
|
||||
public async Task<ActionResult> BanPlayer(long id, [FromBody] BanRequest body)
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (player is null) return NotFound();
|
||||
if (player.IsAdmin) return BadRequest("Cannot ban an admin. Demote first.");
|
||||
|
||||
var days = Math.Clamp(body.DurationDays, 1, 3650);
|
||||
player.BannedUntil = DateTime.UtcNow.AddDays(days);
|
||||
|
||||
await LogAsync("ban_player", "player", id, body.Reason ?? "");
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await notifications.NotifyAsync(id, PushNotificationId.ModerationKick,
|
||||
new { Reason = body.Reason ?? "", Until = player.BannedUntil });
|
||||
|
||||
return Ok(new { player.Id, player.BannedUntil });
|
||||
}
|
||||
|
||||
[HttpPost("players/{id:long}/unban")]
|
||||
public async Task<ActionResult> UnbanPlayer(long id, [FromBody] BanRequest? body)
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (player is null) return NotFound();
|
||||
|
||||
player.BannedUntil = null;
|
||||
await LogAsync("unban_player", "player", id, body?.Reason ?? "");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { player.Id, player.BannedUntil });
|
||||
}
|
||||
|
||||
[HttpPost("players/{id:long}/promote")]
|
||||
public async Task<ActionResult> Promote(long id)
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (player is null) return NotFound();
|
||||
player.IsAdmin = true;
|
||||
await LogAsync("promote_admin", "player", id, "");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { player.Id, player.IsAdmin });
|
||||
}
|
||||
|
||||
[HttpPost("players/{id:long}/demote")]
|
||||
public async Task<ActionResult> Demote(long id)
|
||||
{
|
||||
// Refuse to demote yourself — guards against the last admin
|
||||
// accidentally dropping privileges and being unable to recover.
|
||||
if (id == CurrentAdminId)
|
||||
return BadRequest("Cannot demote yourself. Have another admin do it.");
|
||||
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (player is null) return NotFound();
|
||||
player.IsAdmin = false;
|
||||
await LogAsync("demote_admin", "player", id, "");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { player.Id, player.IsAdmin });
|
||||
}
|
||||
|
||||
// ── Rooms ────────────────────────────────────────────────────────────
|
||||
|
||||
public sealed record DeleteRoomRequest(string? Reason);
|
||||
|
||||
/// <summary>Soft-delete a room by setting <c>State = 1</c>
|
||||
/// (Archived). The room stops appearing in browse/search results;
|
||||
/// its data is still retrievable for an undelete if needed.</summary>
|
||||
/// <summary>GET <c>api/admin/v1/diagnostics/room-images</c> —
|
||||
/// emergency diagnostic for "room tile shows smiley" symptoms.
|
||||
/// Returns one row per seeded room with: the DB ImageName, whether
|
||||
/// the corresponding file exists in <c>data/images/</c>, and the
|
||||
/// fully-qualified URL the client would fetch. Lets us tell at a
|
||||
/// glance whether the bug is (a) DB never got real CDN names from
|
||||
/// the backfill, (b) PNG missing on disk, or (c) wrong URL host
|
||||
/// being served by the config endpoint.</summary>
|
||||
[HttpGet("diagnostics/room-images")]
|
||||
public async Task<IActionResult> DiagnosticsRoomImages()
|
||||
{
|
||||
var imgDir = Path.Combine(AppContext.BaseDirectory, "data", "images");
|
||||
var rooms = await db.Rooms
|
||||
.Where(r => r.Id >= 100 && r.Id < 1000)
|
||||
.OrderBy(r => r.Id)
|
||||
.Select(r => new { r.Id, r.Name, r.ImageName })
|
||||
.ToListAsync();
|
||||
var rows = rooms.Select(r =>
|
||||
{
|
||||
var path = Path.Combine(imgDir, r.ImageName ?? string.Empty);
|
||||
return new
|
||||
{
|
||||
r.Id,
|
||||
r.Name,
|
||||
r.ImageName,
|
||||
FileExists = !string.IsNullOrEmpty(r.ImageName) && System.IO.File.Exists(path),
|
||||
FullPath = path,
|
||||
FetchUrl = $"https://{domain.Sub("img")}/{r.ImageName}",
|
||||
};
|
||||
}).ToList();
|
||||
return Ok(new
|
||||
{
|
||||
ImageDirOnDisk = imgDir,
|
||||
ImageDirExists = Directory.Exists(imgDir),
|
||||
ImageFileCount = Directory.Exists(imgDir) ? Directory.GetFiles(imgDir).Length : 0,
|
||||
RoomImagesJsonExists = System.IO.File.Exists(
|
||||
Path.Combine(AppContext.BaseDirectory, "data", "room_images.json")),
|
||||
Rooms = rows,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/players/{id}/clothing-debug</c> —
|
||||
/// inspect a single player's wardrobe state. Shows the raw
|
||||
/// AvatarEntity columns the watch reads (OutfitSelections,
|
||||
/// InventoryJson, HairColor, SkinColor), the parsed-and-filtered
|
||||
/// list of items the player will see in their wardrobe drawer
|
||||
/// (intersection of InventoryJson with the bundled safelist),
|
||||
/// and any GUIDs in InventoryJson that AREN'T in the safelist
|
||||
/// (those would crash the watch's parser if shipped — silently
|
||||
/// dropped by AvatarItemsController.Get(), but worth knowing
|
||||
/// they're sitting in the inventory).
|
||||
///
|
||||
/// Mostly here as a one-call sanity check after the avatar
|
||||
/// pipeline overhaul: hit it against the player ID, eyeball the
|
||||
/// drawer-count, confirm the ratio matches what's in the DB.</summary>
|
||||
[HttpGet("players/{id:long}/clothing-debug")]
|
||||
public async Task<ActionResult> ClothingDebug(long id)
|
||||
{
|
||||
var avatar = await db.Avatars
|
||||
.Where(a => a.PlayerId == id)
|
||||
.Select(a => new
|
||||
{
|
||||
a.OutfitSelections,
|
||||
a.InventoryJson,
|
||||
a.HairColor,
|
||||
a.SkinColor,
|
||||
a.FaceFeatures,
|
||||
a.UpdatedAt,
|
||||
})
|
||||
.FirstOrDefaultAsync();
|
||||
if (avatar is null) return NotFound(new { Message = $"No AvatarEntity for player {id}" });
|
||||
|
||||
var safe = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.SafeGuids;
|
||||
var catalog = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.Catalog;
|
||||
|
||||
// Parse the player's inventory + intersect against the bundled
|
||||
// safelist. Items that pass through end up in
|
||||
// /api/avatar/v4/items as renderable wardrobe entries; items
|
||||
// that fail are silently dropped at request time.
|
||||
List<string> inventoryGuids;
|
||||
try
|
||||
{
|
||||
inventoryGuids = System.Text.Json.JsonSerializer
|
||||
.Deserialize<List<string>>(avatar.InventoryJson ?? "[]") ?? new();
|
||||
}
|
||||
catch { inventoryGuids = new(); }
|
||||
|
||||
var owned = inventoryGuids.Where(g => safe.Contains(g)).ToList();
|
||||
var unsafeGuids = inventoryGuids.Where(g => !safe.Contains(g)).ToList();
|
||||
|
||||
// Parse OutfitSelections (the equipped loadout) the same way
|
||||
// the watch does — comma-separated, each segment a GUID. Show
|
||||
// which slot each one resolves to + whether it's in the safe
|
||||
// catalog.
|
||||
var equipped = (avatar.OutfitSelections ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(g => g.Trim())
|
||||
.Where(g => g.Length > 0)
|
||||
.Select(g => new
|
||||
{
|
||||
Guid = g,
|
||||
InSafelist = safe.Contains(g),
|
||||
FriendlyName = catalog.TryGetValue(g, out var c) ? c.FriendlyName : null,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
PlayerId = id,
|
||||
avatar.UpdatedAt,
|
||||
avatar.HairColor,
|
||||
avatar.SkinColor,
|
||||
avatar.FaceFeatures,
|
||||
OutfitSelections = avatar.OutfitSelections,
|
||||
EquippedItems = equipped,
|
||||
InventoryRaw = avatar.InventoryJson,
|
||||
InventoryCount = inventoryGuids.Count,
|
||||
OwnedAndRenderable = owned.Count,
|
||||
OwnedAndRenderableGuids = owned,
|
||||
UnsafeOwnedGuids = unsafeGuids,
|
||||
CatalogTotal = catalog.Count,
|
||||
SafelistTotal = safe.Count,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/rooms</c> — flat list of every
|
||||
/// room in the DB (id + name + creator + blob count) for the admin
|
||||
/// UI's room-pickers. Custom (AG) rooms first, then RR Originals;
|
||||
/// each ordered by name. The blob count helps the .htr mirror
|
||||
/// dropdown skip rooms that have no blobs to scan.</summary>
|
||||
[HttpGet("rooms")]
|
||||
public async Task<ActionResult> ListAllRooms()
|
||||
{
|
||||
var rooms = await db.Rooms
|
||||
.OrderByDescending(r => r.IsAGRoom)
|
||||
.ThenBy(r => r.Name)
|
||||
.Select(r => new
|
||||
{
|
||||
r.Id,
|
||||
r.Name,
|
||||
r.IsAGRoom,
|
||||
r.IsDormRoom,
|
||||
r.CreatorPlayerId,
|
||||
BlobCount = db.RoomDataBlobs.Count(b => b.RoomId == r.Id),
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(rooms);
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/rooms/{id}/visitors</c> — most
|
||||
/// recent unique visitors of a room. Joins
|
||||
/// <see cref="RoomVisitEntity"/> against <see cref="PlayerEntity"/>
|
||||
/// so the admin UI can show "who's been here lately" with
|
||||
/// usernames + per-player visit counts.</summary>
|
||||
[HttpGet("rooms/{id:long}/visitors")]
|
||||
public async Task<ActionResult> RoomVisitors(long id, [FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 200);
|
||||
var rows = await (from v in db.RoomVisits
|
||||
join p in db.Players on v.PlayerId equals p.Id
|
||||
where v.RoomId == id
|
||||
orderby v.LastVisitAt descending
|
||||
select new
|
||||
{
|
||||
PlayerId = v.PlayerId,
|
||||
p.Username,
|
||||
p.DisplayName,
|
||||
v.VisitCount,
|
||||
v.FirstVisitAt,
|
||||
v.LastVisitAt,
|
||||
})
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpDelete("rooms/{id:long}")]
|
||||
public async Task<ActionResult> DeleteRoom(long id, [FromBody] DeleteRoomRequest? body)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (room is null) return NotFound();
|
||||
room.State = 1; // Archived
|
||||
await LogAsync("delete_room", "room", id, body?.Reason ?? "");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { room.Id, room.State });
|
||||
}
|
||||
|
||||
// The bulk "purge every custom room" endpoint was removed: a single
|
||||
// wrong click could wipe every imported map in one shot. Per-room
|
||||
// purge (below) is the only hard-delete path, gated by two
|
||||
// independent room-name typed confirmations to make accidental
|
||||
// wipes effectively impossible.
|
||||
|
||||
public sealed record HardDeleteRoomRequest(
|
||||
string? Reason,
|
||||
// The admin UI prompts for the room name TWICE in separate
|
||||
// inputs — both have to match the room's actual Name (exactly,
|
||||
// case-sensitive) before the request will fire. Same pattern
|
||||
// GitHub uses for repository deletion.
|
||||
string? ConfirmName1,
|
||||
string? ConfirmName2);
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/rooms/{id}/purge</c> — hard-delete a
|
||||
/// single custom room. Refuses to act on TRUE RR Originals (owned
|
||||
/// by the system seed account) or dorms; for those, the soft-delete
|
||||
/// via DELETE /rooms/{id} is the only supported path so a fat
|
||||
/// finger doesn't wipe canonical content.
|
||||
///
|
||||
/// A user-cloned room often inherits <c>IsAGRoom=true</c> from its
|
||||
/// source — that doesn't make it canonical, it just means the
|
||||
/// matchmaker uses the AG-room join path. Gate purely on
|
||||
/// <c>CreatorPlayerId == SystemAccountId</c>, which is the only
|
||||
/// reliable "is this actually a seeded RR Original?" signal.
|
||||
///
|
||||
/// Requires BOTH <c>ConfirmName1</c> and <c>ConfirmName2</c> in the
|
||||
/// body to exactly match the room's current Name. Both must match
|
||||
/// independently — the SPA renders two separate inputs and the
|
||||
/// admin types the name twice. Returns 400 with
|
||||
/// <c>confirm_name_mismatch</c> if either is missing/wrong so the
|
||||
/// SPA can surface the error inline without leaking which one
|
||||
/// failed.</summary>
|
||||
[HttpPost("rooms/{id:long}/purge")]
|
||||
public async Task<ActionResult> HardDeleteRoom(long id, [FromBody] HardDeleteRoomRequest? body)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (room is null) return NotFound();
|
||||
if (room.IsDormRoom)
|
||||
return BadRequest(new { error = "refuses_to_hard_delete_dorm", note = "Dorms are per-player state; never purge." });
|
||||
if (room.IsAGRoom && room.CreatorPlayerId == PlayerService.SystemAccountId)
|
||||
return BadRequest(new { error = "refuses_to_hard_delete_seeded_room", note = "This row is owned by the system seed account — soft-archive via DELETE /rooms/{id} instead." });
|
||||
|
||||
var confirm1 = body?.ConfirmName1 ?? string.Empty;
|
||||
var confirm2 = body?.ConfirmName2 ?? string.Empty;
|
||||
if (!string.Equals(confirm1, room.Name, StringComparison.Ordinal)
|
||||
|| !string.Equals(confirm2, room.Name, StringComparison.Ordinal))
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
error = "confirm_name_mismatch",
|
||||
note = "Both ConfirmName1 and ConfirmName2 must exactly equal the room's current Name (case-sensitive).",
|
||||
});
|
||||
}
|
||||
|
||||
var scenes = await db.RoomScenes.Where(s => s.RoomId == id).ToListAsync();
|
||||
var roomBlobs = await db.RoomDataBlobs.Where(b => b.RoomId == id).ToListAsync();
|
||||
RoomDataBlobEntity? imageBlob = null;
|
||||
if (!string.IsNullOrEmpty(room.ImageName))
|
||||
{
|
||||
imageBlob = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.RoomId == 0 && b.BlobName == room.ImageName);
|
||||
}
|
||||
|
||||
db.RoomScenes.RemoveRange(scenes);
|
||||
db.RoomDataBlobs.RemoveRange(roomBlobs);
|
||||
if (imageBlob is not null) db.RoomDataBlobs.Remove(imageBlob);
|
||||
db.Rooms.Remove(room);
|
||||
|
||||
await LogAsync("hard_delete_room", "room", id,
|
||||
$"name={room.Name} reason={body?.Reason ?? ""} blobs={roomBlobs.Count} scenes={scenes.Count}");
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
deleted = id,
|
||||
name = room.Name,
|
||||
blobs = roomBlobs.Count,
|
||||
scenes = scenes.Count,
|
||||
thumbnail = imageBlob is not null,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Leaderboards ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verified stat-channel metadata. The 2020 watch's
|
||||
/// <c>ConditionalSetStat_ReturningNewValue</c> handles the
|
||||
/// high-vs-low-is-better decision locally, so the server doesn't
|
||||
/// enforce direction — this is purely for the admin SPA to render
|
||||
/// channels sensibly.
|
||||
///
|
||||
/// Only entries cross-referenced against decompilation live here.
|
||||
/// Channel 7 is verified by
|
||||
/// <c>StuntRunnerManager.get_TotalTimeStatChannelForLeaderboard</c>
|
||||
/// (StuntRunnerManager.txt:220-228 returns the literal 7); the
|
||||
/// stored value is milliseconds, hence <c>ValueFormat = "time-ms"</c>.
|
||||
/// Other channels intentionally stay un-named — they used to ship
|
||||
/// with best-effort guesses ("Paintball wins", "Dodgeball wins",
|
||||
/// etc.) that were almost certainly wrong, so the admin UI now
|
||||
/// renders unknown channels as "Channel N" rather than misleading
|
||||
/// labels. Add new entries here only when you've verified the
|
||||
/// channel mapping in the decompiled client.</summary>
|
||||
private static readonly Dictionary<int, StatChannelMeta> KnownStatChannels = new()
|
||||
{
|
||||
[7] = new("Stunt Runner total time", true, "time-ms"),
|
||||
};
|
||||
|
||||
private sealed record StatChannelMeta(string Name, bool LowerIsBetter, string ValueFormat);
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/leaderboards/channels</c> — summary
|
||||
/// of every stat channel that has at least one row. Used by the
|
||||
/// Leaderboards SPA tab to populate the channel picker.</summary>
|
||||
[HttpGet("leaderboards/channels")]
|
||||
public async Task<ActionResult> ListChannels()
|
||||
{
|
||||
var rows = await db.LeaderboardStats
|
||||
.GroupBy(s => s.StatChannel)
|
||||
.Select(g => new
|
||||
{
|
||||
Channel = g.Key,
|
||||
EntryCount = g.Count(),
|
||||
MinValue = g.Min(x => x.Value),
|
||||
MaxValue = g.Max(x => x.Value),
|
||||
})
|
||||
.OrderBy(r => r.Channel)
|
||||
.ToListAsync();
|
||||
// DB-defined meta beats hardcoded KnownStatChannels so admins
|
||||
// can rename / reassign channels live without a code change.
|
||||
var dbMeta = await db.LeaderboardChannelMeta.ToDictionaryAsync(c => c.Channel);
|
||||
var labelled = rows.Select(r =>
|
||||
{
|
||||
dbMeta.TryGetValue(r.Channel, out var live);
|
||||
KnownStatChannels.TryGetValue(r.Channel, out var fallback);
|
||||
return new
|
||||
{
|
||||
r.Channel,
|
||||
Name = live?.Name is { Length: > 0 } n ? n : (fallback?.Name ?? $"Channel {r.Channel}"),
|
||||
RoomId = live?.RoomId ?? 0,
|
||||
LowerIsBetter = live?.LowerIsBetter ?? fallback?.LowerIsBetter ?? false,
|
||||
ValueFormat = live?.ValueFormat ?? fallback?.ValueFormat ?? "count",
|
||||
Verified = live is not null || fallback is not null,
|
||||
r.EntryCount,
|
||||
r.MinValue,
|
||||
r.MaxValue,
|
||||
};
|
||||
}).ToList();
|
||||
return Ok(new
|
||||
{
|
||||
Channels = labelled,
|
||||
// Surface the known set as well so the SPA can offer
|
||||
// "create a row in channel X" even when the channel is
|
||||
// currently empty.
|
||||
Known = KnownStatChannels.Select(kv => new
|
||||
{
|
||||
Channel = kv.Key,
|
||||
kv.Value.Name,
|
||||
kv.Value.LowerIsBetter,
|
||||
kv.Value.ValueFormat,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/leaderboards/{channel}</c> — every
|
||||
/// stat row in a channel with the player's username, sorted by
|
||||
/// the channel's natural direction (lower-is-better channels
|
||||
/// sorted ascending, everything else descending).</summary>
|
||||
[HttpGet("leaderboards/{channel:int}")]
|
||||
public async Task<ActionResult> ChannelDetail(int channel, [FromQuery] int take = 100)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 1000);
|
||||
var liveMeta = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == channel);
|
||||
KnownStatChannels.TryGetValue(channel, out var fallback);
|
||||
var name = liveMeta?.Name is { Length: > 0 } ln ? ln : (fallback?.Name ?? $"Channel {channel}");
|
||||
var lowerIsBetter = liveMeta?.LowerIsBetter ?? fallback?.LowerIsBetter ?? false;
|
||||
var valueFormat = liveMeta?.ValueFormat ?? fallback?.ValueFormat ?? "count";
|
||||
var query = db.LeaderboardStats
|
||||
.Where(s => s.StatChannel == channel);
|
||||
query = lowerIsBetter
|
||||
? query.OrderBy(s => s.Value)
|
||||
: query.OrderByDescending(s => s.Value);
|
||||
|
||||
var rows = await (from s in query.Take(take)
|
||||
join p in db.Players on s.PlayerId equals p.Id into pj
|
||||
from p in pj.DefaultIfEmpty()
|
||||
select new
|
||||
{
|
||||
s.Id,
|
||||
s.PlayerId,
|
||||
Username = p == null ? null : p.Username,
|
||||
DisplayName = p == null ? null : p.DisplayName,
|
||||
s.Value,
|
||||
s.UpdatedAt,
|
||||
}).ToListAsync();
|
||||
return Ok(new
|
||||
{
|
||||
Channel = channel,
|
||||
Name = name,
|
||||
RoomId = liveMeta?.RoomId ?? 0,
|
||||
LowerIsBetter = lowerIsBetter,
|
||||
ValueFormat = valueFormat,
|
||||
Verified = liveMeta is not null || fallback is not null,
|
||||
Entries = rows.Select((r, i) => new
|
||||
{
|
||||
Rank = i + 1,
|
||||
r.Id,
|
||||
r.PlayerId,
|
||||
r.Username,
|
||||
r.DisplayName,
|
||||
r.Value,
|
||||
r.UpdatedAt,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record SetLeaderboardScoreRequest(long PlayerId, int Value);
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/leaderboards/{channel}/score</c>
|
||||
/// — upsert a single player's score in a channel.</summary>
|
||||
[HttpPost("leaderboards/{channel:int}/score")]
|
||||
public async Task<ActionResult> SetScore(int channel, [FromBody] SetLeaderboardScoreRequest body)
|
||||
{
|
||||
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
|
||||
s.PlayerId == body.PlayerId && s.StatChannel == channel);
|
||||
if (row is null)
|
||||
{
|
||||
row = new LeaderboardStatEntity
|
||||
{
|
||||
PlayerId = body.PlayerId,
|
||||
StatChannel = channel,
|
||||
Value = body.Value,
|
||||
};
|
||||
db.LeaderboardStats.Add(row);
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = body.Value;
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
await LogAsync("set_leaderboard_score", "player", body.PlayerId, $"channel={channel} value={body.Value}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { row.PlayerId, row.StatChannel, row.Value });
|
||||
}
|
||||
|
||||
[HttpDelete("leaderboards/{channel:int}/score/{playerId:long}")]
|
||||
public async Task<ActionResult> DeleteScore(int channel, long playerId)
|
||||
{
|
||||
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
|
||||
s.PlayerId == playerId && s.StatChannel == channel);
|
||||
if (row is null) return NotFound();
|
||||
db.LeaderboardStats.Remove(row);
|
||||
await LogAsync("delete_leaderboard_score", "player", playerId, $"channel={channel}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { deleted = playerId, channel });
|
||||
}
|
||||
|
||||
/// <summary>DELETE <c>api/admin/v1/leaderboards/{channel}</c> —
|
||||
/// wipe every entry in a stat channel. Used for season resets or
|
||||
/// to clear test data.</summary>
|
||||
[HttpDelete("leaderboards/{channel:int}")]
|
||||
public async Task<ActionResult> WipeChannel(int channel)
|
||||
{
|
||||
var rows = await db.LeaderboardStats.Where(s => s.StatChannel == channel).ToListAsync();
|
||||
if (rows.Count == 0) return Ok(new { wiped = 0, channel });
|
||||
db.LeaderboardStats.RemoveRange(rows);
|
||||
await LogAsync("wipe_leaderboard_channel", "system", channel, $"entries={rows.Count}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { wiped = rows.Count, channel });
|
||||
}
|
||||
|
||||
// ── Rec Room Originals (RROs) ────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/rooms/originals</c> — the canonical
|
||||
/// Rec Room Originals (rooms whose <c>CreatorPlayerId ==
|
||||
/// PlayerService.SystemAccountId</c>). Backs the RRO admin tab
|
||||
/// which lets admins flip per-room properties (cloning, max
|
||||
/// players, description) without scrolling through the global
|
||||
/// rooms list.</summary>
|
||||
[HttpGet("rooms/originals")]
|
||||
public async Task<ActionResult> ListOriginals()
|
||||
{
|
||||
var rooms = await db.Rooms
|
||||
.Where(r => r.CreatorPlayerId == PlayerService.SystemAccountId)
|
||||
.OrderBy(r => r.Name)
|
||||
.Select(r => new
|
||||
{
|
||||
r.Id,
|
||||
r.Name,
|
||||
r.Description,
|
||||
r.ImageName,
|
||||
r.Accessibility,
|
||||
r.CloningAllowed,
|
||||
r.IsAGRoom,
|
||||
r.IsDormRoom,
|
||||
r.State,
|
||||
r.TagsCsv,
|
||||
r.HotScore,
|
||||
r.VisitCount,
|
||||
r.VisitorCount,
|
||||
r.CheerCount,
|
||||
r.CurrentDataBlobName,
|
||||
SceneCount = db.RoomScenes.Count(s => s.RoomId == r.Id),
|
||||
BlobCount = db.RoomDataBlobs.Count(b => b.RoomId == r.Id),
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(rooms);
|
||||
}
|
||||
|
||||
public sealed record UpdateRoomPropsRequest(
|
||||
string? Description,
|
||||
bool? CloningAllowed,
|
||||
int? Accessibility,
|
||||
string? TagsCsv,
|
||||
double? HotScore,
|
||||
int? State,
|
||||
string? ImageName);
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/rooms/{id}/props</c> — partial
|
||||
/// update of room properties. Only the non-null fields in the
|
||||
/// body get written; the rest are left untouched. Backs the
|
||||
/// RRO edit form and is also usable for regular custom rooms.</summary>
|
||||
[HttpPost("rooms/{id:long}/props")]
|
||||
public async Task<ActionResult> UpdateRoomProps(long id, [FromBody] UpdateRoomPropsRequest body)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (room is null) return NotFound();
|
||||
var changes = new List<string>();
|
||||
if (body.Description is string desc)
|
||||
{
|
||||
room.Description = desc;
|
||||
changes.Add($"description={desc.Length} chars");
|
||||
}
|
||||
if (body.CloningAllowed is bool c) { room.CloningAllowed = c; changes.Add($"cloning={c}"); }
|
||||
if (body.Accessibility is int a) { room.Accessibility = a; changes.Add($"accessibility={a}"); }
|
||||
if (body.TagsCsv is string tags) { room.TagsCsv = tags; changes.Add($"tags={tags}"); }
|
||||
if (body.HotScore is double h) { room.HotScore = h; changes.Add($"hot={h}"); }
|
||||
if (body.State is int st) { room.State = st; changes.Add($"state={st}"); }
|
||||
if (body.ImageName is string img) { room.ImageName = img; changes.Add($"image={img}"); }
|
||||
|
||||
if (changes.Count == 0) return Ok(new { room.Id, unchanged = true });
|
||||
|
||||
await LogAsync("update_room_props", "room", id, string.Join(", ", changes));
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { room.Id, room.Name, applied = changes });
|
||||
}
|
||||
|
||||
// ── Room detail (unified per-room view backing the admin SPA) ─────────
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/rooms/{id}</c> — every column on the
|
||||
/// room plus owner/co-owner/mod/host display info, blob count, scene
|
||||
/// count, current data blob name, and the latest CheerCount. The
|
||||
/// admin SPA's per-room detail page consumes this in one shot rather
|
||||
/// than fanning out to the legacy <c>/rooms</c>, <c>/rooms/originals</c>,
|
||||
/// <c>/playerReputation</c> endpoints separately.</summary>
|
||||
[HttpGet("rooms/{id:long}")]
|
||||
public async Task<ActionResult> GetRoomDetail(long id)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (room is null) return NotFound();
|
||||
|
||||
var sceneCount = await db.RoomScenes.CountAsync(s => s.RoomId == id);
|
||||
var blobCount = await db.RoomDataBlobs.CountAsync(b => b.RoomId == id);
|
||||
var roles = await db.RoomRoles
|
||||
.Where(r => r.RoomId == id)
|
||||
.OrderBy(r => r.Role).ThenBy(r => r.GrantedAt)
|
||||
.ToListAsync();
|
||||
|
||||
// Hydrate display names for creator + every role grant in one
|
||||
// hit so the UI doesn't have to follow up with /players/{id}.
|
||||
var playerIds = roles.Select(r => r.PlayerId)
|
||||
.Append(room.CreatorPlayerId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var players = await db.Players
|
||||
.Where(p => playerIds.Contains(p.Id))
|
||||
.Select(p => new { p.Id, p.Username, p.DisplayName, p.ProfileImageName })
|
||||
.ToListAsync();
|
||||
|
||||
object PlayerInfo(long pid) =>
|
||||
players.FirstOrDefault(p => p.Id == pid) is { } p
|
||||
? new { id = p.Id, username = p.Username, displayName = p.DisplayName, profileImageName = p.ProfileImageName }
|
||||
: new { id = pid, username = $"#{pid}", displayName = "", profileImageName = (string?)null };
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
room.Id,
|
||||
room.Name,
|
||||
room.Description,
|
||||
room.ImageName,
|
||||
room.State,
|
||||
room.Accessibility,
|
||||
room.IsAGRoom,
|
||||
room.IsDormRoom,
|
||||
room.CloningAllowed,
|
||||
room.SupportsLevelVoting,
|
||||
room.SupportsVRLow,
|
||||
room.SupportsMobile,
|
||||
room.SupportsScreens,
|
||||
room.SupportsWalkVR,
|
||||
room.SupportsTeleportVR,
|
||||
room.AllowsJuniors,
|
||||
room.DisableMicAutoMute,
|
||||
room.RoomWarningMask,
|
||||
room.CustomRoomWarning,
|
||||
room.TagsCsv,
|
||||
room.CheerCount,
|
||||
room.FavoriteCount,
|
||||
room.VisitCount,
|
||||
room.VisitorCount,
|
||||
room.HotScore,
|
||||
room.LocationReplicationId,
|
||||
room.CurrentDataBlobName,
|
||||
room.CreatedAt,
|
||||
room.UpdatedAt,
|
||||
sceneCount,
|
||||
blobCount,
|
||||
owner = PlayerInfo(room.CreatorPlayerId),
|
||||
roles = roles.Select(r => new
|
||||
{
|
||||
r.Id,
|
||||
r.PlayerId,
|
||||
r.Role,
|
||||
r.Accepted,
|
||||
r.GrantedByPlayerId,
|
||||
r.GrantedAt,
|
||||
player = PlayerInfo(r.PlayerId),
|
||||
}).ToList(),
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record AddRoleRequest(long PlayerId, int Role, bool Accepted = true);
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/rooms/{id}/roles</c> — grant a
|
||||
/// (PlayerId, Role) pair on the room. Role: 0=CoOwner, 1=Moderator,
|
||||
/// 2=Host. Idempotent — re-granting the same role flips Accepted
|
||||
/// only.</summary>
|
||||
[HttpPost("rooms/{id:long}/roles")]
|
||||
public async Task<ActionResult> AddRoomRole(long id, [FromBody] AddRoleRequest body)
|
||||
{
|
||||
if (body is null) return BadRequest("missing body");
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (room is null) return NotFound();
|
||||
if (body.PlayerId <= 0) return BadRequest("missing player");
|
||||
if (body.Role is < 0 or > 2) return BadRequest("invalid role");
|
||||
|
||||
var existing = await db.RoomRoles.FirstOrDefaultAsync(r =>
|
||||
r.RoomId == id && r.PlayerId == body.PlayerId && r.Role == body.Role);
|
||||
if (existing is null)
|
||||
{
|
||||
db.RoomRoles.Add(new RoomRoleEntity
|
||||
{
|
||||
RoomId = id,
|
||||
PlayerId = body.PlayerId,
|
||||
Role = body.Role,
|
||||
Accepted = body.Accepted,
|
||||
GrantedByPlayerId = CurrentAdminId,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Accepted = body.Accepted;
|
||||
}
|
||||
await LogAsync("room_role_add", "room", id, $"player={body.PlayerId} role={body.Role}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { ok = true });
|
||||
}
|
||||
|
||||
/// <summary>DELETE <c>api/admin/v1/rooms/{id}/roles/{playerId}/{role}</c>
|
||||
/// — revoke a specific role grant. No-op if the row doesn't exist.</summary>
|
||||
[HttpDelete("rooms/{id:long}/roles/{playerId:long}/{role:int}")]
|
||||
public async Task<ActionResult> RemoveRoomRole(long id, long playerId, int role)
|
||||
{
|
||||
var row = await db.RoomRoles.FirstOrDefaultAsync(r =>
|
||||
r.RoomId == id && r.PlayerId == playerId && r.Role == role);
|
||||
if (row is null) return Ok(new { ok = true, deleted = 0 });
|
||||
db.RoomRoles.Remove(row);
|
||||
await LogAsync("room_role_remove", "room", id, $"player={playerId} role={role}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { ok = true, deleted = 1 });
|
||||
}
|
||||
|
||||
public sealed record TransferOwnerRequest(long NewCreatorPlayerId);
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/rooms/{id}/owner</c> — reassign the
|
||||
/// room's CreatorPlayerId. The previous owner is automatically added
|
||||
/// as a CoOwner so the original creator keeps a foothold on their
|
||||
/// own room. Reject self-transfers.</summary>
|
||||
[HttpPost("rooms/{id:long}/owner")]
|
||||
public async Task<ActionResult> TransferOwner(long id, [FromBody] TransferOwnerRequest body)
|
||||
{
|
||||
if (body is null || body.NewCreatorPlayerId <= 0) return BadRequest("missing new owner");
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId == body.NewCreatorPlayerId) return Ok(new { ok = true, unchanged = true });
|
||||
|
||||
var previousOwner = room.CreatorPlayerId;
|
||||
room.CreatorPlayerId = body.NewCreatorPlayerId;
|
||||
room.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Auto-grant the former owner CoOwner (id=0) so they don't lose
|
||||
// access to a room they built. Idempotent — re-running this in
|
||||
// a fast undo-redo flow won't accumulate dupes.
|
||||
if (previousOwner > 0 && !await db.RoomRoles.AnyAsync(r =>
|
||||
r.RoomId == id && r.PlayerId == previousOwner && r.Role == 0))
|
||||
{
|
||||
db.RoomRoles.Add(new RoomRoleEntity
|
||||
{
|
||||
RoomId = id,
|
||||
PlayerId = previousOwner,
|
||||
Role = 0,
|
||||
Accepted = true,
|
||||
GrantedByPlayerId = CurrentAdminId,
|
||||
});
|
||||
}
|
||||
|
||||
await LogAsync("room_transfer_owner", "room", id, $"from={previousOwner} to={body.NewCreatorPlayerId}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { ok = true });
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/rooms/{id}/instances</c> — active
|
||||
/// Photon instances for one specific room. Subset of the global
|
||||
/// <c>/instances</c> endpoint pre-filtered to one room; the admin
|
||||
/// SPA's room detail page embeds these so admins don't have to
|
||||
/// scroll a global instance list looking for matches.
|
||||
///
|
||||
/// The <c>masterPlayerId</c> field is best-effort: Photon's master
|
||||
/// is whoever has the lowest <c>ActorNumber</c> in the room (i.e.
|
||||
/// the first to join). The server doesn't see ActorNumbers, but
|
||||
/// the player with the lowest DorkNet player id among current
|
||||
/// participants is a reasonable proxy on dorms (creator joined
|
||||
/// first) and pretty good on user rooms. For non-dorm rooms with
|
||||
/// the creator absent, the value just identifies a stable "lead"
|
||||
/// participant — the room won't fall apart if it's not the actual
|
||||
/// Photon master, and admins primarily use the badge to know
|
||||
/// which row to click "Pull in" against to land in the right
|
||||
/// shard.</summary>
|
||||
[HttpGet("rooms/{id:long}/instances")]
|
||||
public async Task<ActionResult> GetRoomInstances(long id)
|
||||
{
|
||||
var room = await db.Rooms.Where(r => r.Id == id)
|
||||
.Select(r => new { r.Id, r.CreatorPlayerId, r.IsDormRoom })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var onlinePids = onlinePresence.OnlinePlayerIds().ToList();
|
||||
var byInstance = new Dictionary<long, (long roomInstanceId, long roomId, long subRoomId, string roomName, string photonRoomId, string photonRegionId, string location, int maxCapacity, bool isPrivate, List<long> pids)>();
|
||||
foreach (var pid in onlinePids)
|
||||
{
|
||||
var r = playerPresence.GetRoom(pid);
|
||||
if (r is null || r.RoomId != id) continue;
|
||||
if (!byInstance.TryGetValue(r.RoomInstanceId, out var slot))
|
||||
{
|
||||
slot = (r.RoomInstanceId, r.RoomId, r.SubRoomId, r.Name ?? "", r.PhotonRoomId ?? "", r.PhotonRegionId ?? "", r.Location ?? "", r.MaxCapacity, r.IsPrivate, new List<long>());
|
||||
}
|
||||
slot.pids.Add(pid);
|
||||
byInstance[r.RoomInstanceId] = slot;
|
||||
}
|
||||
if (byInstance.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var allPids = byInstance.Values.SelectMany(v => v.pids).Distinct().ToList();
|
||||
var players = await db.Players
|
||||
.Where(p => allPids.Contains(p.Id))
|
||||
.Select(p => new { p.Id, p.Username, p.DisplayName })
|
||||
.ToDictionaryAsync(p => p.Id);
|
||||
|
||||
return Ok(byInstance.Values.Select(i =>
|
||||
{
|
||||
// Master heuristic: dorm owner if present, else lowest pid.
|
||||
long master;
|
||||
if (room is { IsDormRoom: true } && i.pids.Contains(room.CreatorPlayerId))
|
||||
master = room.CreatorPlayerId;
|
||||
else
|
||||
master = i.pids.OrderBy(p => p).FirstOrDefault();
|
||||
return new
|
||||
{
|
||||
roomInstanceId = i.roomInstanceId,
|
||||
roomId = i.roomId,
|
||||
subRoomId = i.subRoomId,
|
||||
roomName = i.roomName,
|
||||
photonRoomId = i.photonRoomId,
|
||||
photonRegionId = i.photonRegionId,
|
||||
location = i.location,
|
||||
maxCapacity = i.maxCapacity,
|
||||
isPrivate = i.isPrivate,
|
||||
masterPlayerId = master,
|
||||
participants = i.pids.Select(pid => players.TryGetValue(pid, out var p)
|
||||
? new { id = p.Id, username = p.Username, displayName = p.DisplayName, isMaster = pid == master }
|
||||
: new { id = pid, username = $"#{pid}", displayName = "", isMaster = pid == master }).ToList(),
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
public sealed record PullIntoInstanceRequest(long PlayerId);
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/rooms/{id}/instances/{instanceId}/pull</c>
|
||||
/// — pull a specific player into one already-active instance of this
|
||||
/// room. Wraps the existing player force-join flow but pre-fills the
|
||||
/// instance and validates that the target instance is actually live
|
||||
/// in this room. Useful for "drop me into the shard the streamer is
|
||||
/// already in" without having to dig the instance id out by hand.</summary>
|
||||
[HttpPost("rooms/{id:long}/instances/{instanceId:long}/pull")]
|
||||
public async Task<ActionResult> PullIntoInstance(long id, long instanceId, [FromBody] PullIntoInstanceRequest body)
|
||||
{
|
||||
if (body is null || body.PlayerId <= 0) return BadRequest("missing player");
|
||||
|
||||
// Find any participant currently in the target instance — we
|
||||
// need their RoomInstanceDto to know which Photon shard to
|
||||
// route the pulled player into. If nobody's online in the
|
||||
// instance anymore the instance has effectively died; reject.
|
||||
DorkNet.Server.Controllers.Match.RoomInstanceDto? sample = null;
|
||||
foreach (var pid in onlinePresence.OnlinePlayerIds())
|
||||
{
|
||||
var r = playerPresence.GetRoom(pid);
|
||||
if (r is not null && r.RoomId == id && r.RoomInstanceId == instanceId)
|
||||
{
|
||||
sample = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sample is null) return NotFound(new { error = "instance_not_active" });
|
||||
|
||||
// Re-stamp presence on the target so their next heartbeat /
|
||||
// goto sees us pointing at this instance. Push a SignalR
|
||||
// SubscriptionUpdateGameSession so the watch's matchmaking
|
||||
// updates LocalRoomInstance immediately.
|
||||
playerPresence.SetRoom(body.PlayerId, sample);
|
||||
await notifications.NotifyAsync(body.PlayerId,
|
||||
PushNotificationId.SubscriptionUpdateGameSession,
|
||||
sample);
|
||||
await LogAsync("instance_pull", "instance", instanceId, $"player={body.PlayerId} room={id}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { ok = true, instanceId, roomInstanceId = sample.RoomInstanceId });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/rooms/{id}/instances/{instanceId}/close</c>
|
||||
/// — kick every player currently in the instance via ModerationKick
|
||||
/// pushes and clear their presence so the server-side view matches.
|
||||
/// The Photon room itself isn't directly destroyed (we don't have
|
||||
/// Photon admin credentials); it dies on its own once the last
|
||||
/// participant disconnects. In practice every kick fires within
|
||||
/// the same second, so the room is empty seconds later.</summary>
|
||||
[HttpPost("rooms/{id:long}/instances/{instanceId:long}/close")]
|
||||
public async Task<ActionResult> CloseInstance(long id, long instanceId, [FromBody] KickRequest? body)
|
||||
{
|
||||
var reason = body?.Reason ?? "Instance closed by admin";
|
||||
var kicked = new List<long>();
|
||||
foreach (var pid in onlinePresence.OnlinePlayerIds().ToList())
|
||||
{
|
||||
var r = playerPresence.GetRoom(pid);
|
||||
if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue;
|
||||
await notifications.NotifyAsync(pid, PushNotificationId.ModerationKick, new { Reason = reason });
|
||||
playerPresence.Clear(pid);
|
||||
kicked.Add(pid);
|
||||
}
|
||||
if (kicked.Count > 0)
|
||||
{
|
||||
await LogAsync("instance_close", "instance", instanceId, $"room={id} kicked={kicked.Count}");
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Ok(new { ok = true, kicked });
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/rooms/{id}/leaderboards</c> — every
|
||||
/// stat channel mapped to this room via
|
||||
/// <see cref="LeaderboardChannelMetaEntity"/>, plus the row count
|
||||
/// per channel and the top scorer. Channels that report scores but
|
||||
/// haven't been mapped yet show up in
|
||||
/// <see cref="LeaderboardOrphans"/> instead.</summary>
|
||||
[HttpGet("rooms/{id:long}/leaderboards")]
|
||||
public async Task<ActionResult> GetRoomLeaderboards(long id)
|
||||
{
|
||||
var metas = await db.LeaderboardChannelMeta
|
||||
.Where(c => c.RoomId == id)
|
||||
.OrderBy(c => c.Channel)
|
||||
.ToListAsync();
|
||||
if (metas.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var channels = metas.Select(m => m.Channel).ToList();
|
||||
var counts = await db.LeaderboardStats
|
||||
.Where(s => channels.Contains(s.StatChannel))
|
||||
.GroupBy(s => s.StatChannel)
|
||||
.Select(g => new { Channel = g.Key, Count = g.Count(), Best = g.Max(x => x.Value), Worst = g.Min(x => x.Value) })
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(metas.Select(m =>
|
||||
{
|
||||
var stat = counts.FirstOrDefault(c => c.Channel == m.Channel);
|
||||
return new
|
||||
{
|
||||
m.Channel,
|
||||
m.Name,
|
||||
m.LowerIsBetter,
|
||||
m.ValueFormat,
|
||||
EntryCount = stat?.Count ?? 0,
|
||||
BestValue = stat is null ? (long?)null : (m.LowerIsBetter ? stat.Worst : stat.Best),
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
public sealed record UpsertChannelMetaRequest(int Channel, long RoomId, string Name, bool LowerIsBetter, string? ValueFormat);
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/leaderboards/meta</c> — register or
|
||||
/// rename a stat channel + assign it to a room. Idempotent; updates
|
||||
/// the existing row when the channel already has metadata. Does NOT
|
||||
/// touch the value rows themselves — admins can register a channel
|
||||
/// before any scores are reported.</summary>
|
||||
[HttpPost("leaderboards/meta")]
|
||||
public async Task<ActionResult> UpsertChannelMeta([FromBody] UpsertChannelMetaRequest body)
|
||||
{
|
||||
if (body is null) return BadRequest("missing body");
|
||||
var row = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == body.Channel);
|
||||
if (row is null)
|
||||
{
|
||||
row = new LeaderboardChannelMetaEntity { Channel = body.Channel };
|
||||
db.LeaderboardChannelMeta.Add(row);
|
||||
}
|
||||
row.RoomId = body.RoomId;
|
||||
row.Name = body.Name ?? string.Empty;
|
||||
row.LowerIsBetter = body.LowerIsBetter;
|
||||
row.ValueFormat = string.IsNullOrWhiteSpace(body.ValueFormat) ? "count" : body.ValueFormat;
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
await LogAsync("leaderboard_meta_upsert", "channel", body.Channel, $"room={body.RoomId} name={row.Name}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { row.Channel, row.RoomId, row.Name, row.LowerIsBetter, row.ValueFormat });
|
||||
}
|
||||
|
||||
/// <summary>DELETE <c>api/admin/v1/leaderboards/meta/{channel}</c> —
|
||||
/// remove the metadata row. The channel goes back to showing as
|
||||
/// "Channel N" / unscoped. Existing score rows are kept.</summary>
|
||||
[HttpDelete("leaderboards/meta/{channel:int}")]
|
||||
public async Task<ActionResult> DeleteChannelMeta(int channel)
|
||||
{
|
||||
var row = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == channel);
|
||||
if (row is null) return Ok(new { ok = true, deleted = 0 });
|
||||
db.LeaderboardChannelMeta.Remove(row);
|
||||
await LogAsync("leaderboard_meta_remove", "channel", channel, "");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { ok = true, deleted = 1 });
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/leaderboards/orphans</c> — every
|
||||
/// stat channel id that has at least one score row but no meta row.
|
||||
/// Used by the admin Leaderboards UI to surface "you have data for
|
||||
/// channel 11 — want to give it a name and assign it to a room?".
|
||||
/// Critical for figuring out which channels Stunt Runner courses
|
||||
/// actually use: load a course, drive it, then orphans surfaces
|
||||
/// the channel id the game just reported.</summary>
|
||||
[HttpGet("leaderboards/orphans")]
|
||||
public async Task<ActionResult> LeaderboardOrphans()
|
||||
{
|
||||
var mapped = await db.LeaderboardChannelMeta.Select(c => c.Channel).ToListAsync();
|
||||
var rows = await db.LeaderboardStats
|
||||
.Where(s => !mapped.Contains(s.StatChannel))
|
||||
.GroupBy(s => s.StatChannel)
|
||||
.Select(g => new { Channel = g.Key, EntryCount = g.Count(), LastSeen = g.Max(s => s.UpdatedAt) })
|
||||
.OrderByDescending(r => r.LastSeen)
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
// ── Server settings (runtime toggles) ────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/settings</c> — current server-wide
|
||||
/// toggle state. Single row, single round trip.</summary>
|
||||
[HttpGet("settings")]
|
||||
public async Task<ActionResult> GetServerSettings()
|
||||
{
|
||||
var row = await serverSettings.GetAsync();
|
||||
return Ok(new
|
||||
{
|
||||
row.SignupsDisabled,
|
||||
row.GlobalFriendsEnabled,
|
||||
row.WeeklyChallengesCompletedRequired,
|
||||
row.UpdatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record SignupsToggleRequest(bool Disabled);
|
||||
public sealed record GlobalFriendsToggleRequest(bool Enabled);
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/settings/global-friends</c> — flip the
|
||||
/// "everyone is friends" toggle. No relationship rows are written; the
|
||||
/// friend graph is synthesized at read time (see
|
||||
/// <see cref="RelationshipQueries"/>). After flipping, broadcast
|
||||
/// <c>RelationshipsInvalid</c> to every connected watch so it re-fetches
|
||||
/// <c>api/relationships/v2/get</c> and shows the new state live — no
|
||||
/// relog (the 2020 watch handles that push via
|
||||
/// <c>Relationships.RefreshList</c>).</summary>
|
||||
[HttpPost("settings/global-friends")]
|
||||
public async Task<ActionResult> SetGlobalFriends([FromBody] GlobalFriendsToggleRequest body)
|
||||
{
|
||||
var row = await serverSettings.SetGlobalFriendsEnabledAsync(body.Enabled);
|
||||
await LogAsync(body.Enabled ? "global_friends_enabled" : "global_friends_disabled", "system", 0, "");
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Live refresh: every connected watch re-pulls its relationship list.
|
||||
await notifications.BroadcastAsync(DorkNet.Models.Notification.PushNotificationId.RelationshipsInvalid);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
row.SignupsDisabled,
|
||||
row.GlobalFriendsEnabled,
|
||||
row.WeeklyChallengesCompletedRequired,
|
||||
row.UpdatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/settings/signups</c> — flip the
|
||||
/// server-wide account-creation kill switch. While disabled, both
|
||||
/// <c>POST /account/create</c> and <c>POST /api/account/v1/create</c>
|
||||
/// reply with <c>Success=false</c> + a user-facing error string and
|
||||
/// the watch surfaces that to whoever tried to sign up. Existing
|
||||
/// logins are untouched.</summary>
|
||||
[HttpPost("settings/signups")]
|
||||
public async Task<ActionResult> SetSignupsDisabled([FromBody] SignupsToggleRequest body)
|
||||
{
|
||||
var row = await serverSettings.SetSignupsDisabledAsync(body.Disabled);
|
||||
await LogAsync(body.Disabled ? "signups_disabled" : "signups_enabled", "system", 0, "");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
row.SignupsDisabled,
|
||||
row.GlobalFriendsEnabled,
|
||||
row.WeeklyChallengesCompletedRequired,
|
||||
row.UpdatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Weekly challenges ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/settings/weekly-challenges</c> — the
|
||||
/// current weekly slate + reward the watch's challenge map is built
|
||||
/// from (<see cref="ProgressionApi.ProgressionController"/>).</summary>
|
||||
[HttpGet("settings/weekly-challenges")]
|
||||
public async Task<ActionResult> GetWeeklyChallenges()
|
||||
{
|
||||
var weekly = await serverSettings.GetWeeklyChallengesAsync();
|
||||
return Ok(weekly);
|
||||
}
|
||||
|
||||
/// <summary>GET
|
||||
/// <c>api/admin/v1/settings/weekly-challenges/reward-options</c> —
|
||||
/// the store items that can be assigned as the weekly gift. Only
|
||||
/// avatar outfits and consumables are offered: the 2020.03
|
||||
/// <see cref="StoreService"/> exposes
|
||||
/// <c>TryGetAvatarItemPayload</c> / <c>TryGetConsumableItemDesc</c>
|
||||
/// but has no equipment payload helper, so equipment gifts aren't
|
||||
/// assignable on this build. Each option carries the descriptors the
|
||||
/// watch's <c>ChallengeGift</c> render needs plus the
|
||||
/// <c>Slug</c> the server grants on completion.</summary>
|
||||
[HttpGet("settings/weekly-challenges/reward-options")]
|
||||
public async Task<ActionResult> GetWeeklyChallengeRewardOptions()
|
||||
{
|
||||
var rows = await db.StoreItems
|
||||
.Where(i => i.IsActive)
|
||||
.OrderBy(i => i.DisplayName)
|
||||
.ToListAsync();
|
||||
|
||||
static object Option(
|
||||
string kind,
|
||||
StoreItemEntity item,
|
||||
string avatarItemDesc = "",
|
||||
string consumableItemDesc = "") => new
|
||||
{
|
||||
Kind = kind,
|
||||
Label = $"{item.DisplayName} ({item.Slug})",
|
||||
Slug = item.Slug,
|
||||
GiftDropId = (int)(item.Id & 0x7fffffff),
|
||||
AvatarItemDesc = avatarItemDesc,
|
||||
ConsumableItemDesc = consumableItemDesc,
|
||||
};
|
||||
|
||||
var avatarItems = rows
|
||||
.Select(i => StoreService.TryGetAvatarItemPayload(i.Slug, out _, out var desc)
|
||||
? Option("avatar", i, avatarItemDesc: desc)
|
||||
: null)
|
||||
.Where(o => o is not null)
|
||||
.ToArray();
|
||||
|
||||
var consumables = rows
|
||||
.Select(i => StoreService.TryGetConsumableItemDesc(i.Slug, out var desc)
|
||||
? Option("consumable", i, consumableItemDesc: desc)
|
||||
: null)
|
||||
.Where(o => o is not null)
|
||||
.ToArray();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
AvatarItems = avatarItems,
|
||||
Consumables = consumables,
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record WeeklyChallengeSettingsRequest(
|
||||
bool? CompletedRequired,
|
||||
List<WeeklyChallengeTemplate>? Challenges,
|
||||
WeeklyChallengeReward? Reward);
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/settings/weekly-challenges</c> —
|
||||
/// replace the weekly slate + reward. Normalisation (indexing,
|
||||
/// trimming, the Take(10) cap) happens in
|
||||
/// <see cref="ServerSettingsService.SetWeeklyChallengesAsync"/>.</summary>
|
||||
[HttpPost("settings/weekly-challenges")]
|
||||
public async Task<ActionResult> SetWeeklyChallenges([FromBody] WeeklyChallengeSettingsRequest body)
|
||||
{
|
||||
var completedRequired = body.CompletedRequired ?? true;
|
||||
var challenges = body.Challenges ?? ServerSettingsService.DefaultWeeklyChallenges();
|
||||
var weekly = await serverSettings.SetWeeklyChallengesAsync(completedRequired, challenges, body.Reward);
|
||||
await LogAsync("weekly_challenges_updated", "system", 0, "");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(weekly);
|
||||
}
|
||||
|
||||
// ── Signup codes ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/signup-codes</c> — every issued code
|
||||
/// with its status (unused / redeemed-by-whom / revoked / expired)
|
||||
/// for the admin invite tracker.</summary>
|
||||
[HttpGet("signup-codes")]
|
||||
public async Task<ActionResult> GetSignupCodes([FromQuery] int take = 200)
|
||||
{
|
||||
var codes = await signupCodes.ListAsync(take);
|
||||
var redeemerIds = codes.Where(c => c.RedeemedByPlayerId is not null)
|
||||
.Select(c => c.RedeemedByPlayerId!.Value).Distinct().ToList();
|
||||
var redeemers = redeemerIds.Count == 0
|
||||
? new Dictionary<long, string>()
|
||||
: await db.Players.Where(p => redeemerIds.Contains(p.Id))
|
||||
.ToDictionaryAsync(p => p.Id, p => p.Username);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
return Ok(codes.Select(c => new
|
||||
{
|
||||
c.Id,
|
||||
c.Code,
|
||||
c.Descriptor,
|
||||
c.CreatedAt,
|
||||
c.ExpiresAt,
|
||||
c.Revoked,
|
||||
c.RedeemedAt,
|
||||
c.RedeemedByPlayerId,
|
||||
RedeemedByUsername = c.RedeemedByPlayerId is { } rid && redeemers.TryGetValue(rid, out var u) ? u : null,
|
||||
Status = c.RedeemedByPlayerId is not null ? "redeemed"
|
||||
: c.Revoked ? "revoked"
|
||||
: c.ExpiresAt is { } e && e <= now ? "expired"
|
||||
: "active",
|
||||
}));
|
||||
}
|
||||
|
||||
public sealed record GenerateSignupCodeRequest(string? Descriptor, DateTime? ExpiresAt);
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/signup-codes</c> — mint a new
|
||||
/// single-use code with an optional descriptor + expiry.</summary>
|
||||
[HttpPost("signup-codes")]
|
||||
public async Task<ActionResult> GenerateSignupCode([FromBody] GenerateSignupCodeRequest body)
|
||||
{
|
||||
var expires = body.ExpiresAt is { } e ? DateTime.SpecifyKind(e, DateTimeKind.Utc) : (DateTime?)null;
|
||||
var code = await signupCodes.GenerateAsync(body.Descriptor ?? string.Empty, expires, CurrentAdminId);
|
||||
await LogAsync("signup_code_created", "signup_code", code.Id, code.Descriptor);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { code.Id, code.Code, code.Descriptor, code.CreatedAt, code.ExpiresAt });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/signup-codes/{id}/revoke</c> — kill an
|
||||
/// unused code. Already-redeemed codes can't be revoked.</summary>
|
||||
[HttpPost("signup-codes/{id:long}/revoke")]
|
||||
public async Task<ActionResult> RevokeSignupCode(long id)
|
||||
{
|
||||
var ok = await signupCodes.RevokeAsync(id);
|
||||
if (!ok) return BadRequest(new { Error = "cannot_revoke" });
|
||||
await LogAsync("signup_code_revoked", "signup_code", id, "");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Revoked = true });
|
||||
}
|
||||
|
||||
// ── Audit log ────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("audit")]
|
||||
public async Task<ActionResult> GetAuditLog([FromQuery] int take = 100)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 500);
|
||||
var rows = await db.AdminActions
|
||||
.OrderByDescending(a => a.Timestamp)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
// ── Reports moderation ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>List unresolved player reports, oldest-first.
|
||||
/// Admins triage from this queue.</summary>
|
||||
[HttpGet("reports")]
|
||||
public async Task<ActionResult> ListReports([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 200);
|
||||
var rows = await db.Reports
|
||||
.Where(r => r.ResolvedAt == null)
|
||||
.OrderBy(r => r.CreatedAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
public sealed record ResolveReportRequest(string? Note);
|
||||
|
||||
/// <summary>Mark a report as resolved with an optional note.
|
||||
/// The actual moderation action (ban, warn, dismiss) is the
|
||||
/// admin's call; this endpoint only closes the report row.</summary>
|
||||
[HttpPost("reports/{id:long}/resolve")]
|
||||
public async Task<ActionResult> ResolveReport(long id, [FromBody] ResolveReportRequest? body)
|
||||
{
|
||||
var rep = await db.Reports.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (rep is null) return NotFound();
|
||||
if (rep.ResolvedAt is not null) return Ok(new { already_resolved = true });
|
||||
|
||||
rep.ResolvedAt = DateTime.UtcNow;
|
||||
rep.ResolverAdminId = CurrentAdminId;
|
||||
rep.ResolutionNote = (body?.Note ?? string.Empty).Trim();
|
||||
await LogAsync("resolve_report", "report", id, rep.ResolutionNote);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { rep.Id, rep.ResolvedAt });
|
||||
}
|
||||
|
||||
// ── Kick / IP ban ────────────────────────────────────────────────────
|
||||
|
||||
public sealed record KickRequest(string? Reason);
|
||||
|
||||
/// <summary>Force-disconnect a player from the SignalR hub and
|
||||
/// signal them to reload via a ModerationKick push. Doesn't ban
|
||||
/// — the player can immediately reconnect — but boots them out
|
||||
/// of whatever room/session they're in. For temporary
|
||||
/// moderation actions where a full ban is too heavy.</summary>
|
||||
[HttpPost("players/{id:long}/kick")]
|
||||
public async Task<ActionResult> KickPlayer(long id, [FromBody] KickRequest? body)
|
||||
{
|
||||
await notifications.NotifyAsync(id, PushNotificationId.ModerationKick,
|
||||
new { Reason = body?.Reason ?? "" });
|
||||
await LogAsync("kick_player", "player", id, body?.Reason ?? "");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { kicked = id });
|
||||
}
|
||||
|
||||
public sealed record IpBanRequest(string Cidr, string? Reason, int? DurationDays);
|
||||
|
||||
/// <summary>Add an IP-level ban. Optional duration in days
|
||||
/// (null = permanent until removed). Format: dotted IPv4 or
|
||||
/// CIDR like <c>1.2.3.0/24</c>.</summary>
|
||||
[HttpPost("ipbans")]
|
||||
public async Task<ActionResult> AddIpBan([FromBody] IpBanRequest body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body.Cidr))
|
||||
return BadRequest("missing cidr");
|
||||
|
||||
var until = body.DurationDays is int days
|
||||
? DateTime.UtcNow.AddDays(Math.Clamp(days, 1, 3650))
|
||||
: (DateTime?)null;
|
||||
|
||||
var entry = new IpBanEntity
|
||||
{
|
||||
Cidr = body.Cidr.Trim(),
|
||||
Reason = (body.Reason ?? string.Empty).Trim(),
|
||||
BannedByAdminId = CurrentAdminId,
|
||||
Until = until,
|
||||
};
|
||||
db.IpBans.Add(entry);
|
||||
await LogAsync("ipban_add", "ip", 0, $"{body.Cidr} until {until:o}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(entry);
|
||||
}
|
||||
|
||||
[HttpDelete("ipbans/{id:long}")]
|
||||
public async Task<ActionResult> RemoveIpBan(long id)
|
||||
{
|
||||
var ban = await db.IpBans.FirstOrDefaultAsync(b => b.Id == id);
|
||||
if (ban is null) return NotFound();
|
||||
db.IpBans.Remove(ban);
|
||||
await LogAsync("ipban_remove", "ip", id, ban.Cidr);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpGet("ipbans")]
|
||||
public async Task<ActionResult> ListIpBans()
|
||||
{
|
||||
var rows = await db.IpBans
|
||||
.OrderByDescending(b => b.BannedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
// ── Stats ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Real-time server snapshot for the admin dashboard.
|
||||
/// Every aggregate is one cheap query — designed to be hit by
|
||||
/// the admin watch tab on a 5-second timer without melting the
|
||||
/// SQLite write path.</summary>
|
||||
[HttpGet("stats")]
|
||||
public async Task<ActionResult> Stats()
|
||||
{
|
||||
var totalPlayers = await db.Players.CountAsync();
|
||||
var totalRooms = await db.Rooms.CountAsync();
|
||||
var totalInventions = await db.Inventions.CountAsync();
|
||||
var openReports = await db.Reports.CountAsync(r => r.ResolvedAt == null);
|
||||
var bannedNow = await db.Players.CountAsync(p =>
|
||||
p.BannedUntil != null && p.BannedUntil > DateTime.UtcNow);
|
||||
var activeIpBans = await db.IpBans.CountAsync(b =>
|
||||
b.Until == null || b.Until > DateTime.UtcNow);
|
||||
|
||||
var online = onlinePresence.OnlinePlayerIds().ToArray();
|
||||
|
||||
var topRooms = await db.Rooms
|
||||
.OrderByDescending(r => r.VisitCount)
|
||||
.Take(10)
|
||||
.Select(r => new { r.Id, r.Name, r.VisitCount, r.VisitorCount, r.CheerCount })
|
||||
.ToListAsync();
|
||||
|
||||
var recentJoins = await db.Players
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Take(10)
|
||||
.Select(p => new { p.Id, p.Username, p.CreatedAt })
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Players = new
|
||||
{
|
||||
Total = totalPlayers,
|
||||
OnlineNow = online.Length,
|
||||
BannedNow = bannedNow,
|
||||
},
|
||||
Rooms = new { Total = totalRooms, TopByVisits = topRooms },
|
||||
Inventions = totalInventions,
|
||||
Moderation = new
|
||||
{
|
||||
OpenReports = openReports,
|
||||
ActiveIpBans = activeIpBans,
|
||||
},
|
||||
RecentJoins = recentJoins,
|
||||
ServerTime = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Avatar / outfit / inventory grants ──────────────────────────────
|
||||
|
||||
public sealed record OutfitRequest(string? OutfitSelections, string? FaceFeatures, string? HairColor, string? SkinColor);
|
||||
|
||||
/// <summary>Get a player's current avatar so the admin UI can
|
||||
/// pre-fill its edit form. Returns the same DTO shape as
|
||||
/// <c>GET api/avatar/v2/equipped/{id}</c>.</summary>
|
||||
[HttpGet("players/{id:long}/avatar")]
|
||||
public async Task<ActionResult> GetAvatar(long id)
|
||||
{
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id);
|
||||
if (avatar is null) return NotFound();
|
||||
return Ok(new
|
||||
{
|
||||
avatar.PlayerId,
|
||||
avatar.OutfitSelections,
|
||||
avatar.FaceFeatures,
|
||||
avatar.HairColor,
|
||||
avatar.SkinColor,
|
||||
avatar.UpdatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/players/{id}/avatar/reset</c> —
|
||||
/// wipe a player's equipped outfit + inventory back to the canonical
|
||||
/// 2020 starter values. Recovery path for the
|
||||
/// "watch crashes after ~10 minutes" symptom where a player has an
|
||||
/// item GUID (e.g. <c>Eye_MirrorShades_PinkClear</c>) whose
|
||||
/// outfits_assets bundle isn't shipped with this build, so
|
||||
/// <see cref="OutfitManager"/>.SpawnOutfitImposter throws a
|
||||
/// Dependency Exception from Addressables and the engine eventually
|
||||
/// tears down the local player. Resetting clears those entries
|
||||
/// from <see cref="AvatarEntity.OutfitSelections"/> and
|
||||
/// <see cref="AvatarEntity.InventoryJson"/> so the next avatar
|
||||
/// load only references items the local build has on disk.</summary>
|
||||
[HttpPost("players/{id:long}/avatar/reset")]
|
||||
public async Task<ActionResult> ResetAvatar(long id)
|
||||
{
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id);
|
||||
if (avatar is null)
|
||||
{
|
||||
avatar = new AvatarEntity { PlayerId = id };
|
||||
db.Avatars.Add(avatar);
|
||||
}
|
||||
avatar.OutfitSelections = PlayerService.StarterOutfitSelections;
|
||||
avatar.InventoryJson = PlayerService.StarterInventoryJson;
|
||||
avatar.HairColor = PlayerService.StarterHairColor;
|
||||
avatar.SkinColor = PlayerService.StarterSkinColor;
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await LogAsync("reset_avatar", "player", id, "");
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Push a profile update so the affected watch re-fetches its
|
||||
// avatar without waiting for the next scene load.
|
||||
await notifications.NotifyAsync(id,
|
||||
PushNotificationId.SubscriptionUpdateProfile,
|
||||
new { Reason = "AdminAvatarReset" });
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
avatar.PlayerId,
|
||||
avatar.OutfitSelections,
|
||||
avatar.HairColor,
|
||||
avatar.SkinColor,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Replace another player's outfit / hair / skin colour.
|
||||
/// Pushes <c>SubscriptionUpdateProfile</c> so the affected watch
|
||||
/// re-fetches its avatar and the new clothes propagate to other
|
||||
/// players in the same room.</summary>
|
||||
[HttpPost("players/{id:long}/avatar")]
|
||||
public async Task<ActionResult> SetAvatar(long id, [FromBody] OutfitRequest body)
|
||||
{
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id);
|
||||
if (avatar is null)
|
||||
{
|
||||
avatar = new AvatarEntity { PlayerId = id };
|
||||
db.Avatars.Add(avatar);
|
||||
}
|
||||
if (body.OutfitSelections is not null) avatar.OutfitSelections = body.OutfitSelections;
|
||||
if (body.FaceFeatures is not null) avatar.FaceFeatures = body.FaceFeatures;
|
||||
if (body.HairColor is not null) avatar.HairColor = body.HairColor;
|
||||
if (body.SkinColor is not null) avatar.SkinColor = body.SkinColor;
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await LogAsync("set_avatar", "player", id, body.OutfitSelections ?? "");
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await notifications.NotifyAsync(id,
|
||||
PushNotificationId.SubscriptionUpdateProfile,
|
||||
new { Reason = "AdminAvatarUpdate" });
|
||||
|
||||
return Ok(new { avatar.PlayerId, avatar.OutfitSelections, avatar.HairColor, avatar.SkinColor });
|
||||
}
|
||||
|
||||
public sealed record GrantItemRequest(string ItemId, int Quantity);
|
||||
|
||||
/// <summary>Append an item GUID to the player's inventory JSON list.
|
||||
/// The client treats <c>InventoryJson</c> as the truth source for
|
||||
/// what they own; once granted, the next avatar fetch shows the
|
||||
/// item available in the customisation menu.</summary>
|
||||
[HttpPost("players/{id:long}/inventory/grant")]
|
||||
public async Task<ActionResult> GrantItem(long id, [FromBody] GrantItemRequest body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body.ItemId)) return BadRequest("missing itemId");
|
||||
var qty = Math.Clamp(body.Quantity, 1, 1000);
|
||||
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id);
|
||||
if (avatar is null)
|
||||
{
|
||||
avatar = new AvatarEntity { PlayerId = id };
|
||||
db.Avatars.Add(avatar);
|
||||
}
|
||||
|
||||
// Inventory wire format: JSON array of { itemId, quantity }. Merge
|
||||
// by itemId so repeat grants don't duplicate rows.
|
||||
var list = ParseInventory(avatar.InventoryJson);
|
||||
var existing = list.FirstOrDefault(e => e.ItemId == body.ItemId);
|
||||
if (existing is null)
|
||||
list.Add(new InventoryEntry { ItemId = body.ItemId, Quantity = qty });
|
||||
else
|
||||
existing.Quantity += qty;
|
||||
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list);
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await LogAsync("grant_item", "player", id, $"{body.ItemId} x{qty}");
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await notifications.NotifyAsync(id,
|
||||
PushNotificationId.SubscriptionUpdateProfile,
|
||||
new { Reason = "InventoryGrant", body.ItemId, Quantity = qty });
|
||||
|
||||
return Ok(new { granted = body.ItemId, quantity = qty });
|
||||
}
|
||||
|
||||
public sealed record CurrencyRequest(int CurrencyType, long Amount, string? Reason);
|
||||
|
||||
/// <summary>Adjust a player's currency wallet. Positive amounts
|
||||
/// grant; negative deduct (clamped to zero by
|
||||
/// <see cref="LevelService.GrantCurrencyAsync"/>). The client's
|
||||
/// next storefronts/balance fetch returns the new value.</summary>
|
||||
[HttpPost("players/{id:long}/currency")]
|
||||
public async Task<ActionResult> GrantCurrency(long id, [FromBody] CurrencyRequest body)
|
||||
{
|
||||
var newBalance = await level.GrantCurrencyAsync(
|
||||
id, body.CurrencyType, body.Amount, body.Reason ?? "admin_grant");
|
||||
await LogAsync("grant_currency", "player", id,
|
||||
$"type={body.CurrencyType} amount={body.Amount}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { id, body.CurrencyType, balance = newBalance });
|
||||
}
|
||||
|
||||
public sealed record XpRequest(int Amount, string? Reason);
|
||||
|
||||
/// <summary>Grant XP to a player. Triggers level-up rewards via
|
||||
/// <see cref="LevelService.AwardXpAsync"/> if the new XP crosses a
|
||||
/// level threshold.</summary>
|
||||
[HttpPost("players/{id:long}/xp")]
|
||||
public async Task<ActionResult> GrantXp(long id, [FromBody] XpRequest body)
|
||||
{
|
||||
var (lvl, xp) = await level.AwardXpAsync(id, body.Amount, body.Reason ?? "admin_grant");
|
||||
await LogAsync("grant_xp", "player", id, $"amount={body.Amount}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { id, level = lvl, xp });
|
||||
}
|
||||
|
||||
public sealed record ProfileFlagsRequest(
|
||||
bool? IsVerified,
|
||||
bool? IsDeveloper,
|
||||
bool? IsJunior,
|
||||
bool? IsCommunityTeam);
|
||||
|
||||
/// <summary>Toggle the verified / developer / community-team /
|
||||
/// junior flags on a player profile. Verified shows up as the
|
||||
/// blue-checkmark badge on the watch's profile card; IsDeveloper
|
||||
/// and IsCommunityTeam both unlock the watch's overhead-badge
|
||||
/// slider (see <see cref="DorkNet.Server.Controllers.API.Role.RoleController.IsDeveloper"/>)
|
||||
/// — once unlocked, the player picks "Community Team" or
|
||||
/// "Developer" themselves from the in-watch settings menu and the
|
||||
/// label renders above their head for every other player in the
|
||||
/// room. We expose both as separate admin flags so the audit log
|
||||
/// preserves the distinction even though the 2020 watch can't
|
||||
/// enforce one badge over the other.</summary>
|
||||
[HttpPost("players/{id:long}/flags")]
|
||||
public async Task<ActionResult> SetFlags(long id, [FromBody] ProfileFlagsRequest body)
|
||||
{
|
||||
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (p is null) return NotFound();
|
||||
if (body.IsVerified is bool v) p.IsVerified = v;
|
||||
if (body.IsDeveloper is bool d) p.IsDeveloper = d;
|
||||
if (body.IsJunior is bool j) p.IsJunior = j;
|
||||
if (body.IsCommunityTeam is bool ct) p.IsCommunityTeam = ct;
|
||||
await LogAsync("set_flags", "player", id,
|
||||
$"verified={p.IsVerified} dev={p.IsDeveloper} communityteam={p.IsCommunityTeam} junior={p.IsJunior}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { p.Id, p.IsVerified, p.IsDeveloper, p.IsCommunityTeam, p.IsJunior });
|
||||
}
|
||||
|
||||
public sealed record DisplayNameRequest(string DisplayName);
|
||||
|
||||
/// <summary>Force-set a player's display name (admin override of
|
||||
/// the normal username-uniqueness flow).</summary>
|
||||
[HttpPost("players/{id:long}/displayName")]
|
||||
public async Task<ActionResult> SetDisplayName(long id, [FromBody] DisplayNameRequest body)
|
||||
{
|
||||
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (p is null) return NotFound();
|
||||
if (string.IsNullOrWhiteSpace(body.DisplayName)) return BadRequest("empty");
|
||||
p.DisplayName = body.DisplayName.Trim();
|
||||
await LogAsync("set_display_name", "player", id, p.DisplayName);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { p.Id, p.DisplayName });
|
||||
}
|
||||
|
||||
public sealed record UsernameRequest(string Username);
|
||||
|
||||
/// <summary>Force-set a player's username (the unique @handle).
|
||||
/// Unlike the normal account flow this changes ONLY the username, not
|
||||
/// the display name, and rejects a collision outright rather than
|
||||
/// auto-suffixing — the admin types the exact handle they want.
|
||||
/// Username rules mirror the account API: 2–24 chars, letters /
|
||||
/// digits / underscore / hyphen.</summary>
|
||||
[HttpPost("players/{id:long}/username")]
|
||||
public async Task<ActionResult> SetUsername(long id, [FromBody] UsernameRequest body)
|
||||
{
|
||||
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (p is null) return NotFound();
|
||||
|
||||
var name = (body.Username ?? string.Empty).Trim();
|
||||
if (name.Length is < 2 or > 24
|
||||
|| !name.All(ch => char.IsLetterOrDigit(ch) || ch is '_' or '-'))
|
||||
return BadRequest(new { Error = "Username must be 2–24 chars: letters, numbers, _ or -." });
|
||||
|
||||
if (await db.Players.AnyAsync(x => x.Username == name && x.Id != id))
|
||||
return BadRequest(new { Error = "That username is already taken." });
|
||||
|
||||
var previous = p.Username;
|
||||
p.Username = name;
|
||||
await LogAsync("set_username", "player", id, $"{previous} -> {name}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { p.Id, p.Username });
|
||||
}
|
||||
|
||||
private sealed class InventoryEntry
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("itemId")]
|
||||
public string ItemId { get; set; } = string.Empty;
|
||||
[System.Text.Json.Serialization.JsonPropertyName("quantity")]
|
||||
public int Quantity { get; set; }
|
||||
}
|
||||
|
||||
private static List<InventoryEntry> ParseInventory(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json) || json == "[]") return new();
|
||||
try
|
||||
{
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<InventoryEntry>>(json) ?? new();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Avatar catalog (gifting picker source) ───────────────────────────
|
||||
|
||||
/// <summary>One giftable avatar item for the Gift picker. <c>Desc</c> is
|
||||
/// the exact RecNet item string to gift — a bare base GUID for the
|
||||
/// default colour, or a <c>{guid},{swatch},{mask},</c> string for a
|
||||
/// colour variant. <c>Slot</c> is the real OutfitType (head/torso/…),
|
||||
/// not the always-Outfit AvatarItemType.</summary>
|
||||
private sealed record GiftItemRow(
|
||||
string Desc, string Guid, int Slot, string FriendlyName, string? Color, bool IsVariant);
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/avatar-items</c> — every giftable avatar
|
||||
/// item: each base item (default colour) PLUS every colour variant the
|
||||
/// store seeds from <c>data/color_variants.json</c>. Backs the Gift
|
||||
/// page's item picker so admins can browse by friendly name + colour
|
||||
/// instead of pasting descs by hand. Read-only. Base catalog source is
|
||||
/// <c>data/avatar_item_lookup.json</c>, parsed once at startup by
|
||||
/// <see cref="DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController"/>;
|
||||
/// variants come from <see cref="DorkNet.Server.Services.StoreService.ColorVariantCatalog"/>.</summary>
|
||||
[HttpGet("avatar-items")]
|
||||
public ActionResult ListAvatarItems()
|
||||
{
|
||||
var catalog = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.Catalog;
|
||||
var safe = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.SafeGuids;
|
||||
var rows = new List<GiftItemRow>(catalog.Count + DorkNet.Server.Services.StoreService.ColorVariantCatalog.Count);
|
||||
|
||||
// Base items (default colour). Real slot from the bundled lookup —
|
||||
// the DTO's AvatarItemType is always Outfit, which is useless as a
|
||||
// slot filter, so resolve the actual OutfitType per GUID.
|
||||
foreach (var kv in catalog)
|
||||
{
|
||||
if (!safe.Contains(kv.Key)) continue; // GUIDs the watch can't render
|
||||
var slot = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController
|
||||
.TryGetOutfitType(kv.Key, out var s) ? s : 0;
|
||||
rows.Add(new GiftItemRow(kv.Key, kv.Key, slot, kv.Value.FriendlyName, null, false));
|
||||
}
|
||||
|
||||
// Colour variants — the "dozens of headphones" the admin sees in the
|
||||
// store but not here. Each gifts as "{item},{swatch},{mask}," so the
|
||||
// recipient gets the exact swatch, not the default colour.
|
||||
foreach (var v in DorkNet.Server.Services.StoreService.ColorVariantCatalog.Values)
|
||||
{
|
||||
if (string.IsNullOrEmpty(v.item_guid) || string.IsNullOrEmpty(v.swatch_guid)) continue;
|
||||
if (!safe.Contains(v.item_guid)) continue; // base must be renderable
|
||||
var baseName = catalog.TryGetValue(v.item_guid, out var bd) && !string.IsNullOrEmpty(bd.FriendlyName)
|
||||
? bd.FriendlyName
|
||||
: v.item_name;
|
||||
var desc = $"{v.item_guid},{v.swatch_guid},{v.mask_guid},";
|
||||
rows.Add(new GiftItemRow(desc, v.item_guid,
|
||||
v.item_outfit_type ?? 0, $"{baseName} — {v.color}", v.color, true));
|
||||
}
|
||||
|
||||
return Ok(rows
|
||||
.OrderBy(r => r.Slot)
|
||||
.ThenBy(r => r.FriendlyName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList());
|
||||
}
|
||||
|
||||
// ── Send gift package (in-game gift box popup) ────────────────────────
|
||||
|
||||
public sealed record SendGiftRequest(
|
||||
string? AvatarItemGuid,
|
||||
int? AvatarItemType, // 0 = Outfit, 1 = HairDye (per RecNet.Avatars+AvatarItemType)
|
||||
int? CurrencyType, // 1 = Tokens, 2 = Coins (0 = none)
|
||||
int? Currency,
|
||||
int? Xp,
|
||||
int? Level,
|
||||
string? Message,
|
||||
int? Rarity); // 0 Common, 10 Uncommon, 20 Rare, 30 Epic, 50 Legendary
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/players/{id}/gift</c> — drop a
|
||||
/// <see cref="GiftPackageEntity"/> into the recipient's gift inbox.
|
||||
/// The watch's <c>RecNet.Avatars.DowloadGiftPackages</c> loop picks
|
||||
/// it up on the next poll and shows the standard gift-box popup;
|
||||
/// the player taps "open" to fire <c>LocalConsumeGiftPackage</c>,
|
||||
/// which hits <see cref="DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ConsumeViaBody"/>
|
||||
/// and finally writes the avatar item into <c>InventoryJson</c>.
|
||||
/// We DO NOT touch InventoryJson here — that bypasses the gift
|
||||
/// flow and the player never sees the popup.
|
||||
///
|
||||
/// Also pushes a <see cref="PushNotificationId.GiftPackageReceived"/>
|
||||
/// over SignalR so an online player sees the toast immediately
|
||||
/// without waiting for the next /gifts poll.</summary>
|
||||
[HttpPost("players/{id:long}/gift")]
|
||||
public async Task<ActionResult> SendGift(long id, [FromBody] SendGiftRequest body)
|
||||
{
|
||||
var recipient = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (recipient is null) return NotFound();
|
||||
|
||||
var hasItem = !string.IsNullOrWhiteSpace(body.AvatarItemGuid);
|
||||
var hasCurrency = body.CurrencyType is int ct && ct > 0 && body.Currency is int c && c > 0;
|
||||
var hasXp = body.Xp is int xp && xp > 0;
|
||||
var hasLevel = body.Level is int lvl && lvl > 0;
|
||||
if (!hasItem && !hasCurrency && !hasXp && !hasLevel)
|
||||
return BadRequest(new { error = "empty_gift", message = "Provide at least one of: avatar item, currency, XP, level." });
|
||||
|
||||
// Wire shape MUST match the working /api/avatar/v2/gifts/generate
|
||||
// flow exactly. The watch's ParseGiftPackageReceived
|
||||
// (Avatars.txt:616) gates on IsValid + SupportsCurrentPlatform,
|
||||
// then runs the gift through GiftManager. Two deviations from
|
||||
// /generate were breaking us:
|
||||
// 1. FromPlayerId set to the admin's id — the watch's P2P-gift
|
||||
// rendering path looks up sender info; if that lookup
|
||||
// doesn't return what the popup renderer expects, the toast
|
||||
// still fires from the SignalR push but the box never
|
||||
// materializes. /generate uses null; we match.
|
||||
// 2. IsGifted = true tagged it as a player-to-player gift,
|
||||
// which routes through a different code path entirely
|
||||
// (separate inbox / different popup). /generate leaves it
|
||||
// at the default false; we match.
|
||||
// CRITICAL invariants for AvatarItemDescOrHairDyeDesc, both
|
||||
// verified against the in-game crash trace:
|
||||
//
|
||||
// 1. AvatarItemType MUST be null when no avatar item is in the
|
||||
// gift. Setting it to 0 (Outfit) with an empty desc routes
|
||||
// GiftManager.DequeueGift past the HasAvatarItemOrHairDye
|
||||
// gate into OutfitManager.MarkGiftPackageItemAvailableOnCurrentPlatform("")
|
||||
// → AvatarItem.FromRecNetString("") → "empty RecNet string"
|
||||
// AvatarParseException.
|
||||
//
|
||||
// 2. When there IS an item, AvatarItemDescOrHairDyeDesc must
|
||||
// have at least 4 comma-separated parts. The client's
|
||||
// FromRecNetString(string) splits on `,` then constructs
|
||||
// ArraySegment(parts, 1, 3) — parts.Length < 4 triggers
|
||||
// ArgumentException at ArraySegmentEnumerator.get_Current
|
||||
// (output_log.txt:1567). Bare GUIDs crash; we suffix `,,,`
|
||||
// for three empty default customisations. See
|
||||
// AvatarItemsController.cs:31-40 for the canonical comment.
|
||||
var gift = new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = id,
|
||||
FromPlayerId = null,
|
||||
AvatarItemType = hasItem ? (body.AvatarItemType ?? 0) : (int?)null,
|
||||
AvatarItemDescOrHairDyeDesc = hasItem
|
||||
? NormalizeAvatarItemDesc(body.AvatarItemGuid!.Trim())
|
||||
: string.Empty,
|
||||
CurrencyType = hasCurrency ? body.CurrencyType!.Value : 0,
|
||||
Currency = hasCurrency ? body.Currency!.Value : 0,
|
||||
Xp = hasXp ? body.Xp!.Value : 0,
|
||||
Level = hasLevel ? body.Level!.Value : 0,
|
||||
GiftContext = 0,
|
||||
GiftRarity = body.Rarity ?? 0,
|
||||
Message = string.IsNullOrWhiteSpace(body.Message)
|
||||
? "A gift from the admins."
|
||||
: body.Message.Trim(),
|
||||
Platform = -1,
|
||||
PackageVariant = "Standard",
|
||||
PackageMaterial = string.Empty,
|
||||
IsValid = true,
|
||||
SupportsCurrentPlatform = true,
|
||||
};
|
||||
db.GiftPackages.Add(gift);
|
||||
await LogAsync("send_gift", "player", id,
|
||||
$"item={(hasItem ? gift.AvatarItemDescOrHairDyeDesc : "-")} " +
|
||||
$"currency={(hasCurrency ? $"{gift.CurrencyType}×{gift.Currency}" : "-")} " +
|
||||
$"xp={gift.Xp} level={gift.Level}");
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// GiftPackageReceivedImmediate (31), NOT GiftPackageReceived (30).
|
||||
// GiftManager.OnGiftPackageReceivedEvent (GiftManager.txt:501) takes
|
||||
// a `showImmediately` bool — true for id 31, false for id 30. When
|
||||
// false the handler returns *without* calling ReceiveGifts, so no
|
||||
// box pops. Admin gifts want the box now, not "next time you open
|
||||
// the gift inbox", so push 31. The watch's RecNet.Avatars handler
|
||||
// registers BOTH ids (Avatars.txt:177 + 192), and 31 is the only
|
||||
// one that ends up invoking the GiftManager popup path.
|
||||
await notifications.NotifyAsync(id,
|
||||
PushNotificationId.GiftPackageReceivedImmediate,
|
||||
new
|
||||
{
|
||||
gift.Id,
|
||||
gift.FromPlayerId,
|
||||
gift.AvatarItemType,
|
||||
AvatarItemDesc = gift.AvatarItemDescOrHairDyeDesc,
|
||||
gift.AvatarItemDescOrHairDyeDesc,
|
||||
gift.EquipmentPrefabName,
|
||||
gift.EquipmentModificationGuid,
|
||||
gift.CurrencyType,
|
||||
gift.Currency,
|
||||
gift.Xp,
|
||||
gift.Level,
|
||||
gift.GiftContext,
|
||||
gift.GiftRarity,
|
||||
gift.Message,
|
||||
gift.Platform,
|
||||
gift.PackageMaterial,
|
||||
gift.PackageVariant,
|
||||
gift.Consumed,
|
||||
gift.IsValid,
|
||||
gift.ErrorMessage,
|
||||
gift.SupportsCurrentPlatform,
|
||||
gift.IsGifted,
|
||||
});
|
||||
|
||||
return Ok(new { gift.Id, gift.RecipientPlayerId });
|
||||
}
|
||||
|
||||
/// <summary>Pad a bare avatar item GUID with three empty default
|
||||
/// customisations so the watch's <c>AvatarItem.FromRecNetString</c>
|
||||
/// doesn't crash on <c>ArraySegment(parts, 1, 3)</c> when the
|
||||
/// string only contains the GUID. Idempotent — descs that already
|
||||
/// have at least 4 comma-separated parts are returned untouched.</summary>
|
||||
private static string NormalizeAvatarItemDesc(string descOrGuid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(descOrGuid)) return string.Empty;
|
||||
var commaCount = 0;
|
||||
foreach (var ch in descOrGuid) if (ch == ',') commaCount++;
|
||||
return commaCount >= 3 ? descOrGuid : descOrGuid + new string(',', 3 - commaCount);
|
||||
}
|
||||
|
||||
// ── Pending gift inbox (clean-up) ────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/players/{id}/gifts</c> — list the
|
||||
/// recipient's pending (unconsumed) gift packages. Used by the admin
|
||||
/// Gift page to surface broken / abandoned gifts that the watch
|
||||
/// can't render so we can delete them. Includes consumed ones too
|
||||
/// in case we want to audit what's been opened.</summary>
|
||||
[HttpGet("players/{id:long}/gifts")]
|
||||
public async Task<ActionResult> ListPlayerGifts(long id, [FromQuery] bool includeConsumed = false)
|
||||
{
|
||||
IQueryable<GiftPackageEntity> q = db.GiftPackages.Where(g => g.RecipientPlayerId == id);
|
||||
if (!includeConsumed) q = q.Where(g => !g.Consumed);
|
||||
var rows = await q
|
||||
.OrderByDescending(g => g.CreatedAt)
|
||||
.Take(200)
|
||||
.Select(g => new
|
||||
{
|
||||
g.Id,
|
||||
g.RecipientPlayerId,
|
||||
g.FromPlayerId,
|
||||
g.AvatarItemType,
|
||||
g.AvatarItemDescOrHairDyeDesc,
|
||||
g.CurrencyType,
|
||||
g.Currency,
|
||||
g.Xp,
|
||||
g.Level,
|
||||
g.GiftContext,
|
||||
g.GiftRarity,
|
||||
g.Message,
|
||||
g.Consumed,
|
||||
g.ConsumedAt,
|
||||
g.IsValid,
|
||||
g.IsGifted,
|
||||
g.CreatedAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
/// <summary>DELETE <c>api/admin/v1/gifts/{id}</c> — hard-delete a
|
||||
/// gift package row. Use when a gift is broken (wrong shape, won't
|
||||
/// render its box in the watch) or was a mis-fire from a test send.
|
||||
/// Does not affect the player's actual inventory if they'd already
|
||||
/// consumed it — that write happened during consume and is final.</summary>
|
||||
[HttpDelete("gifts/{id:long}")]
|
||||
public async Task<ActionResult> DeleteGift(long id)
|
||||
{
|
||||
var gift = await db.GiftPackages.FirstOrDefaultAsync(g => g.Id == id);
|
||||
if (gift is null) return NotFound();
|
||||
var recipient = gift.RecipientPlayerId;
|
||||
db.GiftPackages.Remove(gift);
|
||||
await LogAsync("delete_gift", "gift", id, $"recipient={recipient} consumed={gift.Consumed}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { deleted = id });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/players/{id}/gifts/clear</c> —
|
||||
/// nuke every unconsumed gift for a player. Useful after a burst of
|
||||
/// broken admin-test gifts to reset the inbox to a clean state.
|
||||
/// Consumed gifts are left alone (they're history, not state).</summary>
|
||||
[HttpPost("players/{id:long}/gifts/clear")]
|
||||
public async Task<ActionResult> ClearPlayerGifts(long id)
|
||||
{
|
||||
var pending = await db.GiftPackages
|
||||
.Where(g => g.RecipientPlayerId == id && !g.Consumed)
|
||||
.ToListAsync();
|
||||
db.GiftPackages.RemoveRange(pending);
|
||||
await LogAsync("clear_gifts", "player", id, $"removed={pending.Count}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { removed = pending.Count });
|
||||
}
|
||||
|
||||
// ── Password reset (admin override) ──────────────────────────────────
|
||||
|
||||
public sealed record SetPasswordRequest(string NewPassword);
|
||||
|
||||
/// <summary>Admin-set a new password for any account. Hashes with
|
||||
/// BCrypt and persists onto <see cref="PlayerEntity.PasswordHash"/>.
|
||||
/// The audit row records the action but never the plaintext.
|
||||
/// Refuses passwords shorter than 8 chars — same floor as the
|
||||
/// self-service flow.</summary>
|
||||
[HttpPost("players/{id:long}/password")]
|
||||
public async Task<ActionResult> SetPassword(long id, [FromBody] SetPasswordRequest body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body.NewPassword) || body.NewPassword.Length < 8)
|
||||
return BadRequest(new { error = "password_too_short", min = 8 });
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (player is null) return NotFound();
|
||||
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" });
|
||||
return Ok(new { id });
|
||||
}
|
||||
|
||||
// ── Live instance viewer ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/instances</c> — list every active room
|
||||
/// instance an online player is currently sitting in. Joins the live
|
||||
/// online-player set from <see cref="OnlinePresenceService"/> against
|
||||
/// <see cref="PlayerPresenceService"/> (which holds the last-known
|
||||
/// <see cref="RoomInstanceDto"/> per player, TTL'd to 45 seconds) and
|
||||
/// groups by <c>RoomInstanceId</c>. Empty list when no one is online.</summary>
|
||||
[HttpGet("instances")]
|
||||
public async Task<ActionResult> ListInstances()
|
||||
{
|
||||
var onlineIds = onlinePresence.OnlinePlayerIds().ToHashSet();
|
||||
if (onlineIds.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
// Bulk-pull names so we can render usernames per participant.
|
||||
var nameMap = await db.Players
|
||||
.Where(p => onlineIds.Contains(p.Id))
|
||||
.Select(p => new { p.Id, p.Username, p.DisplayName })
|
||||
.ToDictionaryAsync(p => p.Id, p => new { p.Username, p.DisplayName });
|
||||
|
||||
var roomNameMap = await db.Rooms
|
||||
.Select(r => new { r.Id, r.Name })
|
||||
.ToDictionaryAsync(r => r.Id, r => r.Name);
|
||||
|
||||
var grouped = new Dictionary<long, InstanceGroup>();
|
||||
foreach (var pid in onlineIds)
|
||||
{
|
||||
var room = playerPresence.GetRoom(pid);
|
||||
if (room is null) continue;
|
||||
if (!grouped.TryGetValue(room.RoomInstanceId, out var g))
|
||||
{
|
||||
g = new InstanceGroup
|
||||
{
|
||||
RoomInstanceId = room.RoomInstanceId,
|
||||
RoomId = room.RoomId,
|
||||
SubRoomId = room.SubRoomId,
|
||||
RoomName = roomNameMap.GetValueOrDefault(room.RoomId, $"#{room.RoomId}"),
|
||||
PhotonRoomId = room.PhotonRoomId,
|
||||
PhotonRegionId = room.PhotonRegionId,
|
||||
Location = room.Location,
|
||||
MaxCapacity = room.MaxCapacity,
|
||||
IsPrivate = room.IsPrivate,
|
||||
Participants = new(),
|
||||
};
|
||||
grouped[room.RoomInstanceId] = g;
|
||||
}
|
||||
nameMap.TryGetValue(pid, out var nm);
|
||||
g.Participants.Add(new InstanceParticipant
|
||||
{
|
||||
Id = pid,
|
||||
Username = nm?.Username ?? $"#{pid}",
|
||||
DisplayName = nm?.DisplayName ?? $"#{pid}",
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(grouped.Values
|
||||
.OrderByDescending(g => g.Participants.Count)
|
||||
.ThenBy(g => g.RoomName));
|
||||
}
|
||||
|
||||
private sealed class InstanceGroup
|
||||
{
|
||||
public long RoomInstanceId { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public long SubRoomId { get; set; }
|
||||
public string RoomName { get; set; } = "";
|
||||
public string PhotonRoomId { get; set; } = "";
|
||||
public string PhotonRegionId { get; set; } = "";
|
||||
public string Location { get; set; } = "";
|
||||
public int MaxCapacity { get; set; }
|
||||
public bool IsPrivate { get; set; }
|
||||
public List<InstanceParticipant> Participants { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class InstanceParticipant
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Username { get; set; } = "";
|
||||
public string DisplayName { get; set; } = "";
|
||||
}
|
||||
|
||||
// ── Force-join (experimental) ────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Force-join body. <c>RoomInstanceId</c> + <c>PhotonRoomId</c> are
|
||||
/// optional — when omitted, the watch lands in a fresh matchmade
|
||||
/// instance of the room. When provided (from the Instances browser
|
||||
/// → "send into this match" flow), we target that exact Photon room
|
||||
/// so the admin can drop a player into a specific public or private
|
||||
/// instance other players are already in.
|
||||
/// </summary>
|
||||
public sealed record ForceJoinRequest(
|
||||
long RoomId,
|
||||
long? SubRoomId,
|
||||
long? RoomInstanceId,
|
||||
string? PhotonRoomId,
|
||||
string? PhotonRegionId);
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/players/{id}/forcejoin</c> — rewrite
|
||||
/// the target player's <see cref="PlayerPresenceService"/> entry to
|
||||
/// the destination room (and optionally a specific Photon instance),
|
||||
/// then push <c>SubscriptionUpdateGameSession</c> + <c>SubscriptionUpdateRoom</c>
|
||||
/// so the watch's cached RoomInstance + RoomDetails match the new
|
||||
/// server-side state. The watch's
|
||||
/// <c>OnPresenceHeartbeatResponse</c> detects the change on its
|
||||
/// next heartbeat and refreshes its cache; pair with
|
||||
/// <c>POST players/{id}/kick</c> to also drop the player out of
|
||||
/// their current Photon match so they re-matchmake into the new
|
||||
/// target on reconnect.
|
||||
///
|
||||
/// When <c>RoomInstanceId</c> + <c>PhotonRoomId</c> are supplied,
|
||||
/// the player lands in that EXACT instance (public or private). Use
|
||||
/// the Instances browser to enumerate available Photon rooms and
|
||||
/// pick one. With both fields omitted, a fresh deterministic
|
||||
/// instance id is used so the player matchmakes normally.</summary>
|
||||
[HttpPost("players/{id:long}/forcejoin")]
|
||||
public async Task<ActionResult> ForceJoin(long id, [FromBody] ForceJoinRequest body)
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (player is null) return NotFound();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == body.RoomId);
|
||||
if (room is null) return NotFound(new { error = "room_not_found" });
|
||||
|
||||
// Build the destination RoomInstance. When the admin picked a
|
||||
// specific instance from the Instances browser, RoomInstanceId
|
||||
// + PhotonRoomId arrive together — use them verbatim so the
|
||||
// player lands in THAT match, not a fresh one. Otherwise mint
|
||||
// deterministic defaults that match what /goto/room would
|
||||
// produce for a first join (roomId*100k + subId base; bare
|
||||
// ^roomname_id photon room name).
|
||||
var subRoomId = body.SubRoomId ?? 0;
|
||||
var photonRoomId = !string.IsNullOrEmpty(body.PhotonRoomId)
|
||||
? body.PhotonRoomId
|
||||
: $"^{room.Name.ToLowerInvariant()}_{room.Id}";
|
||||
var roomInstanceId = body.RoomInstanceId
|
||||
?? (room.Id * 100_000L + subRoomId);
|
||||
var photonRegionId = !string.IsNullOrEmpty(body.PhotonRegionId)
|
||||
? body.PhotonRegionId
|
||||
: "us";
|
||||
|
||||
var roomInstance = new DorkNet.Server.Controllers.Match.RoomInstanceDto
|
||||
{
|
||||
RoomInstanceId = roomInstanceId,
|
||||
RoomId = room.Id,
|
||||
SubRoomId = subRoomId,
|
||||
Location = room.LocationReplicationId,
|
||||
PhotonRegionId = photonRegionId,
|
||||
PhotonRoomId = photonRoomId,
|
||||
Name = room.Name,
|
||||
MaxCapacity = 8,
|
||||
IsFull = false,
|
||||
IsPrivate = false,
|
||||
IsInProgress = true,
|
||||
};
|
||||
|
||||
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.
|
||||
await notifications.NotifyAsync(id,
|
||||
PushNotificationId.SubscriptionUpdateGameSession, roomInstance);
|
||||
await notifications.NotifyAsync(id,
|
||||
PushNotificationId.SubscriptionUpdateRoom,
|
||||
new
|
||||
{
|
||||
Room = new { Id = room.Id, Name = room.Name },
|
||||
RoomId = room.Id,
|
||||
SubRoomId = subRoomId,
|
||||
Reason = "AdminForceJoin",
|
||||
});
|
||||
|
||||
await LogAsync("force_join", "player", id,
|
||||
$"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
id,
|
||||
roomId = body.RoomId,
|
||||
subRoomId,
|
||||
roomInstanceId,
|
||||
photonRoomId,
|
||||
});
|
||||
}
|
||||
|
||||
// ── System ───────────────────────────────────────────────────────────
|
||||
|
||||
public sealed record SetMotdRequest(string Message);
|
||||
|
||||
/// <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>
|
||||
[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);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// ── Store catalog management ─────────────────────────────────────────
|
||||
|
||||
/// <summary>Canonical storefront keys the admin UI can assign to
|
||||
/// store items. Exact <c>giftdrop:N</c> keys target a single
|
||||
/// in-room shelf; <c>rro</c> and <c>all</c> are shared groups.</summary>
|
||||
[HttpGet("storefronts")]
|
||||
public ActionResult ListStorefronts() =>
|
||||
Ok(StoreService.GetStorefrontDefinitions().Select(s => new
|
||||
{
|
||||
s.Key,
|
||||
s.StorefrontType,
|
||||
s.DisplayName,
|
||||
s.Scope,
|
||||
}));
|
||||
|
||||
/// <summary>List every item in the catalog (including inactive
|
||||
/// rows) so the admin UI can render the full catalog and let an
|
||||
/// admin flip flags / edit prices.</summary>
|
||||
[HttpGet("storeitems")]
|
||||
public async Task<ActionResult> ListStoreItems(
|
||||
[FromQuery] string? storefront, [FromQuery] string? category,
|
||||
[FromQuery] int take = 200, [FromQuery] int skip = 0)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 500);
|
||||
skip = Math.Max(0, skip);
|
||||
IQueryable<StoreItemEntity> q = db.StoreItems;
|
||||
if (!string.IsNullOrWhiteSpace(storefront)) q = q.Where(i => i.Storefront == storefront);
|
||||
if (!string.IsNullOrWhiteSpace(category)) q = q.Where(i => i.Category == category);
|
||||
var rows = await q.OrderBy(i => i.Storefront).ThenBy(i => i.Category).ThenBy(i => i.Price)
|
||||
.Skip(skip).Take(take).ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
public sealed record StoreItemUpsertRequest(
|
||||
string? Slug, string? DisplayName, string? Description,
|
||||
string? Category, string? ImageName, int? CurrencyType,
|
||||
long? Price, bool? IsActive, bool? IsLimitedTime,
|
||||
DateTime? AvailableUntil, string? Storefront);
|
||||
|
||||
/// <summary>Create a new catalog item.</summary>
|
||||
[HttpPost("storeitems")]
|
||||
public async Task<ActionResult> CreateStoreItem([FromBody] StoreItemUpsertRequest body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body.Slug)) return BadRequest("missing_slug");
|
||||
if (string.IsNullOrWhiteSpace(body.DisplayName)) return BadRequest("missing_display_name");
|
||||
var existing = await db.StoreItems.AnyAsync(i => i.Slug == body.Slug);
|
||||
if (existing) return Conflict(new { error = "slug_taken" });
|
||||
var item = new StoreItemEntity
|
||||
{
|
||||
Slug = body.Slug.Trim(),
|
||||
DisplayName = body.DisplayName.Trim(),
|
||||
Description = (body.Description ?? string.Empty).Trim(),
|
||||
Category = (body.Category ?? "accessory").Trim().ToLowerInvariant(),
|
||||
ImageName = body.ImageName ?? string.Empty,
|
||||
CurrencyType = body.CurrencyType ?? 2,
|
||||
Price = Math.Max(0, body.Price ?? 0),
|
||||
IsActive = body.IsActive ?? true,
|
||||
IsLimitedTime = body.IsLimitedTime ?? false,
|
||||
AvailableUntil = body.AvailableUntil,
|
||||
Storefront = (body.Storefront ?? "main").Trim(),
|
||||
};
|
||||
db.StoreItems.Add(item);
|
||||
await LogAsync("create_store_item", "storeitem", 0, item.Slug);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(item);
|
||||
}
|
||||
|
||||
/// <summary>Patch an existing catalog item — any null field is
|
||||
/// left untouched so the admin UI can do partial edits.</summary>
|
||||
[HttpPost("storeitems/{id:long}")]
|
||||
[HttpPut("storeitems/{id:long}")]
|
||||
public async Task<ActionResult> UpdateStoreItem(long id, [FromBody] StoreItemUpsertRequest body)
|
||||
{
|
||||
var item = await db.StoreItems.FirstOrDefaultAsync(i => i.Id == id);
|
||||
if (item is null) return NotFound();
|
||||
if (body.DisplayName is not null) item.DisplayName = body.DisplayName.Trim();
|
||||
if (body.Description is not null) item.Description = body.Description.Trim();
|
||||
if (body.Category is not null) item.Category = body.Category.Trim().ToLowerInvariant();
|
||||
if (body.ImageName is not null) item.ImageName = body.ImageName;
|
||||
if (body.CurrencyType is int ct) item.CurrencyType = ct;
|
||||
if (body.Price is long p) item.Price = Math.Max(0, p);
|
||||
if (body.IsActive is bool a) item.IsActive = a;
|
||||
if (body.IsLimitedTime is bool lt) item.IsLimitedTime = lt;
|
||||
if (body.AvailableUntil.HasValue) item.AvailableUntil = body.AvailableUntil;
|
||||
if (body.Storefront is not null) item.Storefront = body.Storefront.Trim();
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await LogAsync("update_store_item", "storeitem", id, item.Slug);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(item);
|
||||
}
|
||||
|
||||
/// <summary>Delete a catalog item. Soft-flag is recommended via
|
||||
/// IsActive=false; hard delete is here for cleanup of test
|
||||
/// items.</summary>
|
||||
[HttpDelete("storeitems/{id:long}")]
|
||||
public async Task<ActionResult> DeleteStoreItem(long id)
|
||||
{
|
||||
var item = await db.StoreItems.FirstOrDefaultAsync(i => i.Id == id);
|
||||
if (item is null) return NotFound();
|
||||
db.StoreItems.Remove(item);
|
||||
await LogAsync("delete_store_item", "storeitem", id, item.Slug);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { deleted = id });
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private Task LogAsync(string action, string targetType, long targetId, string reason)
|
||||
{
|
||||
db.AdminActions.Add(new AdminActionEntity
|
||||
{
|
||||
AdminPlayerId = CurrentAdminId,
|
||||
Action = action,
|
||||
TargetType = targetType,
|
||||
TargetId = targetId,
|
||||
Reason = reason,
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── Community Board ──────────────────────────────────────────────
|
||||
// Backs the dorm community board panel. The watch hits
|
||||
// /api/communityboard/v1/current; admins edit here.
|
||||
|
||||
/// <summary>GET the current board state. Same shape the watch
|
||||
/// receives — handy for the admin UI to populate its form.</summary>
|
||||
[HttpGet("communityboard")]
|
||||
public async Task<ActionResult<CommunityBoardState>> GetCommunityBoard()
|
||||
=> Ok(await communityBoard.GetAsync());
|
||||
|
||||
/// <summary>POST a full replacement state. Any null nested objects
|
||||
/// (FeaturedPlayer / FeaturedRoomGroup / CurrentAnnouncement)
|
||||
/// clear that section. Lists default to empty if omitted.</summary>
|
||||
[HttpPost("communityboard")]
|
||||
public async Task<ActionResult<CommunityBoardState>> SetCommunityBoard(
|
||||
[FromBody] CommunityBoardState body)
|
||||
{
|
||||
body.InstagramImages ??= new();
|
||||
body.Videos ??= new();
|
||||
var saved = await communityBoard.UpdateAsync(body);
|
||||
await LogAsync("set_community_board", "global", 0,
|
||||
$"announcement=\"{saved.CurrentAnnouncement?.Message ?? ""}\" " +
|
||||
$"featuredPlayer={saved.FeaturedPlayer?.Id} " +
|
||||
$"videos={saved.Videos.Count} instagram={saved.InstagramImages.Count}");
|
||||
return Ok(saved);
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/communityboard/instagram/upload</c>
|
||||
/// — accept a single image file, persist it as a global
|
||||
/// <see cref="RoomDataBlobEntity"/> at <c>RoomId=0</c> under a
|
||||
/// generated hash filename, return the filename + a ready-to-paste
|
||||
/// img.* URL. The Community Board SPA section pipes a file picker
|
||||
/// into this so admins don't have to know what an
|
||||
/// <c>ImageName</c> is — they just drop a JPG/PNG and we wire it up.
|
||||
///
|
||||
/// Cap: 25 MB. Anything larger is almost certainly a mistake for an
|
||||
/// Instagram-strip tile and would also push us close to the CF edge
|
||||
/// limit on a non-chunked upload.</summary>
|
||||
[HttpPost("communityboard/instagram/upload")]
|
||||
[DisableRequestSizeLimit]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 25_000_000)]
|
||||
public async Task<ActionResult> UploadInstagramImage([FromForm(Name = "file")] IFormFile? file)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
return BadRequest(new { error = "missing_file" });
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (ext is not (".jpg" or ".jpeg" or ".png" or ".webp"))
|
||||
return BadRequest(new { error = "unsupported_extension", allowed = new[] { ".jpg", ".jpeg", ".png", ".webp" } });
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms);
|
||||
var bytes = ms.ToArray();
|
||||
|
||||
// Hashed filename so re-uploads of the same content don't
|
||||
// proliferate and so the URL is content-addressed. Prefix
|
||||
// `cb_insta_` to keep these distinguishable from room-image
|
||||
// blobs at a glance when grepping the table.
|
||||
using var sha = System.Security.Cryptography.SHA1.Create();
|
||||
var hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
|
||||
var imageName = $"cb_insta_{hash[..24]}{ext}";
|
||||
|
||||
var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == imageName);
|
||||
if (existing is null)
|
||||
{
|
||||
var (bucket, key) = BlobRouter.Route(imageName);
|
||||
await storage.PutAsync(bucket, key, bytes,
|
||||
ext == ".png" ? "image/png" : "image/jpeg");
|
||||
db.RoomDataBlobs.Add(new RoomDataBlobEntity
|
||||
{
|
||||
RoomId = 0,
|
||||
BlobName = imageName,
|
||||
UploadedByPlayerId = CurrentAdminId,
|
||||
UploadedAt = DateTime.UtcNow,
|
||||
});
|
||||
await LogAsync("cb_insta_upload", "community_board", 0, $"image={imageName} bytes={bytes.Length}");
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Use the configured apex (DomainConfig) so the returned URL
|
||||
// works whether the admin is on localhost, rec.net, or a future
|
||||
// deployment apex — driven solely by the DORKNET_DOMAIN env var.
|
||||
return Ok(new
|
||||
{
|
||||
imageName,
|
||||
imageUrl = $"https://{domain.Sub("img")}/{imageName}",
|
||||
bytes = bytes.Length,
|
||||
reused = existing is not null,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/communityboard/video/upload</c> —
|
||||
/// accept a single video file and persist it as a global
|
||||
/// <see cref="RoomDataBlobEntity"/> at <c>RoomId=0</c> under a hashed
|
||||
/// <c>cb_video_*</c> name. The board's Videos section binds the
|
||||
/// returned <c>blobName</c> straight into <c>VideoState.BlobName</c>
|
||||
/// so the watch's <c>CommunityBoardVideoData</c> resolves it to
|
||||
/// <c>cdn.{apex}/<blobName></c>.
|
||||
///
|
||||
/// Cap: 100 MB — picks the same ceiling Cloudflare enforces at the
|
||||
/// edge so a non-chunked POST never sees a 413. Anything bigger
|
||||
/// should be hosted externally and pasted into <c>SourceUrl</c>.</summary>
|
||||
// [RequestSizeLimit] alone is enough — pairing it with
|
||||
// [DisableRequestSizeLimit] was undefined-behaviour territory
|
||||
// (Microsoft docs explicitly say "don't combine them"). Setting
|
||||
// 100 MB at the action keeps Kestrel from clipping the body at its
|
||||
// 30 MB default while still capping uploads under Cloudflare's
|
||||
// free-tier edge limit.
|
||||
[HttpPost("communityboard/video/upload")]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 100_000_000, ValueLengthLimit = 100_000_000)]
|
||||
[RequestSizeLimit(100_000_000)]
|
||||
public async Task<ActionResult> UploadCommunityBoardVideo([FromForm(Name = "file")] IFormFile? file)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
return BadRequest(new { error = "missing_file" });
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (ext is not (".mp4" or ".webm" or ".mov" or ".m4v"))
|
||||
return BadRequest(new {
|
||||
error = "unsupported_extension",
|
||||
allowed = new[] { ".mp4", ".webm", ".mov", ".m4v" },
|
||||
});
|
||||
|
||||
var declaredSize = file.Length;
|
||||
using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms);
|
||||
var bytes = ms.ToArray();
|
||||
|
||||
// Catch the "Kestrel/Cloudflare clipped the body" case BEFORE
|
||||
// we hash + write — IFormFile.Length reports what the multipart
|
||||
// parser saw in the part header, so a mismatch with how many
|
||||
// bytes the stream actually produced is a transport-level
|
||||
// truncation and the resulting blob would be the corrupted file
|
||||
// the user saw on download.
|
||||
if (bytes.LongLength != declaredSize)
|
||||
{
|
||||
adminLogger.LogError(
|
||||
"[cb-video-upload] size mismatch — multipart says {Declared:N0} bytes, stream produced {Read:N0} bytes (file={Name})",
|
||||
declaredSize, bytes.LongLength, file.FileName);
|
||||
return BadRequest(new {
|
||||
error = "upload_truncated",
|
||||
declaredBytes = declaredSize,
|
||||
receivedBytes = bytes.LongLength,
|
||||
});
|
||||
}
|
||||
|
||||
// Content-addressed name keeps re-uploads from proliferating —
|
||||
// identical bytes resolve to the same row + S3 object regardless
|
||||
// of original filename. `cb_video_` prefix keeps it greppable.
|
||||
using var sha = System.Security.Cryptography.SHA1.Create();
|
||||
var hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
|
||||
var blobName = $"cb_video_{hash[..24]}{ext}";
|
||||
var (bucket, key) = BlobRouter.Route(blobName);
|
||||
|
||||
// We were previously skipping S3 PUT when the DB row already
|
||||
// existed. That created a zombie state: if the first upload's
|
||||
// PUT silently failed (Garage outage, bad creds) the row was
|
||||
// committed but the bytes weren't, and every subsequent
|
||||
// upload of the same content returned `reused: true` without
|
||||
// ever fixing the missing object. Verify S3 has the object;
|
||||
// re-PUT if it doesn't.
|
||||
var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == blobName);
|
||||
var alreadyOnS3 = existing is not null && await storage.ExistsAsync(bucket, key);
|
||||
|
||||
if (!alreadyOnS3)
|
||||
{
|
||||
await storage.PutAsync(bucket, key, bytes, MimeFromExt(ext));
|
||||
// Post-PUT HEAD: Garage occasionally accepts a PutObject
|
||||
// and returns 200 without persisting (auth misconfig,
|
||||
// bucket policy reject). HeadObject is the cheapest way to
|
||||
// confirm the bytes are actually queryable before we
|
||||
// pretend the upload succeeded.
|
||||
if (!await storage.ExistsAsync(bucket, key))
|
||||
{
|
||||
adminLogger.LogError(
|
||||
"[cb-video-upload] PUT returned OK but HEAD says {Bucket}/{Key} missing — Garage may be silently dropping",
|
||||
bucket, key);
|
||||
return StatusCode(500, new { error = "s3_put_lost", bucket, key });
|
||||
}
|
||||
adminLogger.LogInformation(
|
||||
"[cb-video-upload] stored {Bucket}/{Key} ({Bytes:N0} bytes, ext={Ext})",
|
||||
bucket, key, bytes.LongLength, ext);
|
||||
}
|
||||
if (existing is null)
|
||||
{
|
||||
db.RoomDataBlobs.Add(new RoomDataBlobEntity
|
||||
{
|
||||
RoomId = 0,
|
||||
BlobName = blobName,
|
||||
UploadedByPlayerId = CurrentAdminId,
|
||||
UploadedAt = DateTime.UtcNow,
|
||||
});
|
||||
await LogAsync("cb_video_upload", "community_board", 0, $"video={blobName} bytes={bytes.Length}");
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
blobName,
|
||||
videoUrl = $"https://{domain.Sub("cdn")}/video/{blobName}",
|
||||
bytes = bytes.Length,
|
||||
reused = existing is not null && alreadyOnS3,
|
||||
repaired = existing is not null && !alreadyOnS3,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/communityboard/videothumb/upload</c>
|
||||
/// — accept a single image and persist it under a hashed
|
||||
/// <c>cb_thumb_*</c> name for use as <see cref="VideoState"/>'s
|
||||
/// <c>ThumbnailBlobName</c>. Mechanically identical to the Instagram
|
||||
/// upload endpoint, just with a different prefix so thumbnail vs.
|
||||
/// gallery images are distinguishable at a glance in the blob table.
|
||||
///
|
||||
/// Cap: 25 MB. The watch renders thumbnails at small sizes; bigger
|
||||
/// is wasted bandwidth.</summary>
|
||||
[HttpPost("communityboard/videothumb/upload")]
|
||||
[DisableRequestSizeLimit]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 25_000_000)]
|
||||
public async Task<ActionResult> UploadCommunityBoardVideoThumbnail(
|
||||
[FromForm(Name = "file")] IFormFile? file)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
return BadRequest(new { error = "missing_file" });
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (ext is not (".jpg" or ".jpeg" or ".png" or ".webp"))
|
||||
return BadRequest(new {
|
||||
error = "unsupported_extension",
|
||||
allowed = new[] { ".jpg", ".jpeg", ".png", ".webp" },
|
||||
});
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms);
|
||||
var bytes = ms.ToArray();
|
||||
|
||||
using var sha = System.Security.Cryptography.SHA1.Create();
|
||||
var hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
|
||||
var blobName = $"cb_thumb_{hash[..24]}{ext}";
|
||||
|
||||
var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == blobName);
|
||||
if (existing is null)
|
||||
{
|
||||
var (bucket, key) = BlobRouter.Route(blobName);
|
||||
await storage.PutAsync(bucket, key, bytes,
|
||||
ext == ".png" ? "image/png" : ext == ".webp" ? "image/webp" : "image/jpeg");
|
||||
db.RoomDataBlobs.Add(new RoomDataBlobEntity
|
||||
{
|
||||
RoomId = 0,
|
||||
BlobName = blobName,
|
||||
UploadedByPlayerId = CurrentAdminId,
|
||||
UploadedAt = DateTime.UtcNow,
|
||||
});
|
||||
await LogAsync("cb_videothumb_upload", "community_board", 0, $"thumb={blobName} bytes={bytes.Length}");
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
blobName,
|
||||
imageUrl = $"https://{domain.Sub("img")}/{blobName}",
|
||||
bytes = bytes.Length,
|
||||
reused = existing is not null,
|
||||
});
|
||||
}
|
||||
|
||||
// MIME mapping for the video upload — the CDN serves whatever
|
||||
// Content-Type we wrote to S3, so getting this right makes the
|
||||
// watch's HTTPVideoStreamPlayer accept the response without
|
||||
// sniffing. Default to mp4 since that's what most modern exporters
|
||||
// produce; unknown extensions fall back to octet-stream which the
|
||||
// watch usually still handles but is worth flagging.
|
||||
private static string MimeFromExt(string ext) => ext switch
|
||||
{
|
||||
".mp4" => "video/mp4",
|
||||
".m4v" => "video/mp4",
|
||||
".webm" => "video/webm",
|
||||
".mov" => "video/quicktime",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
|
||||
// ── Loading screen tips ──────────────────────────────────────────────
|
||||
|
||||
public sealed class LoadingScreenTipDto
|
||||
{
|
||||
public long? Id { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
public int Context { get; set; }
|
||||
public int PlatformMask { get; set; } = -1;
|
||||
public string RoomNamesCsv { get; set; } = string.Empty;
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/loadingscreentips</c> — list every
|
||||
/// tip (including inactive ones) for the admin SPA.</summary>
|
||||
[HttpGet("loadingscreentips")]
|
||||
public async Task<IActionResult> ListLoadingScreenTips()
|
||||
{
|
||||
var rows = await db.LoadingScreenTips
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.Id)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToLoadingScreenTipDto));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/loadingscreentips</c> — create
|
||||
/// a new tip. Returns the persisted row including its assigned
|
||||
/// Id so the SPA can update its local state.</summary>
|
||||
[HttpPost("loadingscreentips")]
|
||||
public async Task<IActionResult> CreateLoadingScreenTip([FromBody] LoadingScreenTipDto body)
|
||||
{
|
||||
var entity = new Data.Entities.LoadingScreenTipEntity
|
||||
{
|
||||
Title = (body.Title ?? string.Empty).Trim(),
|
||||
Message = (body.Message ?? string.Empty).Trim(),
|
||||
ImageName = (body.ImageName ?? string.Empty).Trim(),
|
||||
Context = body.Context,
|
||||
PlatformMask = body.PlatformMask,
|
||||
RoomNamesCsv = NormaliseCsv(body.RoomNamesCsv),
|
||||
SortOrder = body.SortOrder,
|
||||
IsActive = body.IsActive,
|
||||
};
|
||||
db.LoadingScreenTips.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
await LogAsync("create_loading_tip", "global", entity.Id, entity.Title);
|
||||
return Ok(ToLoadingScreenTipDto(entity));
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>api/admin/v1/loadingscreentips/{id}</c> — update
|
||||
/// an existing tip.</summary>
|
||||
[HttpPut("loadingscreentips/{id:long}")]
|
||||
public async Task<IActionResult> UpdateLoadingScreenTip(long id, [FromBody] LoadingScreenTipDto body)
|
||||
{
|
||||
var row = await db.LoadingScreenTips.FirstOrDefaultAsync(t => t.Id == id);
|
||||
if (row is null) return NotFound();
|
||||
row.Title = (body.Title ?? string.Empty).Trim();
|
||||
row.Message = (body.Message ?? string.Empty).Trim();
|
||||
row.ImageName = (body.ImageName ?? string.Empty).Trim();
|
||||
row.Context = body.Context;
|
||||
row.PlatformMask = body.PlatformMask;
|
||||
row.RoomNamesCsv = NormaliseCsv(body.RoomNamesCsv);
|
||||
row.SortOrder = body.SortOrder;
|
||||
row.IsActive = body.IsActive;
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
await LogAsync("update_loading_tip", "global", id, row.Title);
|
||||
return Ok(ToLoadingScreenTipDto(row));
|
||||
}
|
||||
|
||||
/// <summary>DELETE <c>api/admin/v1/loadingscreentips/{id}</c> — hard
|
||||
/// delete a tip. (Toggling <c>IsActive=false</c> via the update
|
||||
/// endpoint is the soft-delete path.)</summary>
|
||||
[HttpDelete("loadingscreentips/{id:long}")]
|
||||
public async Task<IActionResult> DeleteLoadingScreenTip(long id)
|
||||
{
|
||||
var row = await db.LoadingScreenTips.FirstOrDefaultAsync(t => t.Id == id);
|
||||
if (row is null) return NotFound();
|
||||
db.LoadingScreenTips.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
await LogAsync("delete_loading_tip", "global", id, row.Title);
|
||||
return Ok(new { deleted = id });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/loadingscreentips/upload</c> —
|
||||
/// accept an image file (jpg/png/webp), persist it via the same
|
||||
/// content-addressed flow as the Community Board uploader, and
|
||||
/// return the BlobName + img.* URL. The SPA pipes this into the
|
||||
/// tip's <c>ImageName</c> field.</summary>
|
||||
[HttpPost("loadingscreentips/upload")]
|
||||
[DisableRequestSizeLimit]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 25_000_000)]
|
||||
public async Task<IActionResult> UploadLoadingScreenTipImage([FromForm(Name = "file")] IFormFile? file)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
return BadRequest(new { error = "missing_file" });
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (ext is not (".jpg" or ".jpeg" or ".png" or ".webp"))
|
||||
return BadRequest(new { error = "unsupported_extension", allowed = new[] { ".jpg", ".jpeg", ".png", ".webp" } });
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms);
|
||||
var bytes = ms.ToArray();
|
||||
|
||||
using var sha = System.Security.Cryptography.SHA1.Create();
|
||||
var hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
|
||||
var imageName = $"loadingtip_{hash[..24]}{ext}";
|
||||
|
||||
var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == imageName);
|
||||
if (existing is null)
|
||||
{
|
||||
var (bucket, key) = BlobRouter.Route(imageName);
|
||||
await storage.PutAsync(bucket, key, bytes,
|
||||
ext == ".png" ? "image/png" : "image/jpeg");
|
||||
db.RoomDataBlobs.Add(new RoomDataBlobEntity
|
||||
{
|
||||
RoomId = 0,
|
||||
BlobName = imageName,
|
||||
UploadedByPlayerId = CurrentAdminId,
|
||||
UploadedAt = DateTime.UtcNow,
|
||||
});
|
||||
await LogAsync("loading_tip_upload", "global", 0, $"image={imageName} bytes={bytes.Length}");
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
imageName,
|
||||
imageUrl = $"https://{domain.Sub("img")}/{imageName}",
|
||||
bytes = bytes.Length,
|
||||
reused = existing is not null,
|
||||
});
|
||||
}
|
||||
|
||||
private static LoadingScreenTipDto ToLoadingScreenTipDto(Data.Entities.LoadingScreenTipEntity t) => new()
|
||||
{
|
||||
Id = t.Id,
|
||||
Title = t.Title,
|
||||
Message = t.Message,
|
||||
ImageName = t.ImageName,
|
||||
Context = t.Context,
|
||||
PlatformMask = t.PlatformMask,
|
||||
RoomNamesCsv = t.RoomNamesCsv,
|
||||
SortOrder = t.SortOrder,
|
||||
IsActive = t.IsActive,
|
||||
};
|
||||
|
||||
private static string NormaliseCsv(string? csv)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(csv)) return string.Empty;
|
||||
var parts = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return string.Join(",", parts);
|
||||
}
|
||||
|
||||
// ── Room import ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/rooms/import</c> — multipart upload
|
||||
/// of an archived multi-scene room. Each .room file (one per scene)
|
||||
/// is uploaded with its filename including the scene folder, e.g.
|
||||
/// <c>Lobby/abc.room</c> or <c>Ch1_Headfirst/def.room</c>. The form
|
||||
/// can either send the per-file folder via <c>webkitRelativePath</c>
|
||||
/// (browser folder picker preserves it) or via the filename itself.
|
||||
///
|
||||
/// Server inserts:
|
||||
/// 1. A <see cref="RoomEntity"/> row for the new room (id 1000+).
|
||||
/// 2. One <see cref="RoomDataBlobEntity"/> per uploaded file,
|
||||
/// named <c>{roomname}_{scenefolder}.room</c> (lowercased).
|
||||
/// 3. One <see cref="RoomSceneEntity"/> per scene with the entry
|
||||
/// scene at OrderIndex=0 and the rest in folder order.
|
||||
///
|
||||
/// Default <c>RoomSceneLocationId</c> for every scene is the
|
||||
/// MakerRoom (Basement) GUID — the canonical 2020 custom-room
|
||||
/// canvas. If the import was a 2020-era export of an apartment
|
||||
/// scene, edit <see cref="RoomSceneEntity.RoomSceneLocationId"/>
|
||||
/// per-row after import.
|
||||
///
|
||||
/// Returns 400 if a room with this name already exists or no
|
||||
/// files were uploaded.</summary>
|
||||
[HttpPost("rooms/import")]
|
||||
[DisableRequestSizeLimit]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 500_000_000)]
|
||||
public async Task<IActionResult> ImportRoom(
|
||||
[FromForm(Name = "name")] string? name,
|
||||
[FromForm(Name = "description")] string? description,
|
||||
[FromForm(Name = "entryScene")] string? entryScene,
|
||||
[FromForm(Name = "creatorPlayerId")] long? creatorPlayerId,
|
||||
[FromForm(Name = "scenePaths")] List<string>? scenePaths,
|
||||
[FromForm(Name = "files")] List<IFormFile>? files,
|
||||
[FromForm(Name = "normalizeBlobs")] bool? normalizeBlobs)
|
||||
{
|
||||
// Per-import "run blob bytes through the 2020 protobuf normaliser"
|
||||
// toggle, mirroring RoomZipImportController. Default OFF — see
|
||||
// that file's notes for why the round-trip currently crashes the
|
||||
// watch on load. Diagnostic Normalize() call still runs so its
|
||||
// parse-OK / parse-FAIL log line remains in the import telemetry.
|
||||
var normalize = normalizeBlobs ?? false;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return BadRequest(new { error = "name_required" });
|
||||
if (files is null || files.Count == 0)
|
||||
return BadRequest(new { error = "no_files" });
|
||||
|
||||
var roomName = name.Trim();
|
||||
if (await db.Rooms.AnyAsync(r => r.Name == roomName))
|
||||
return BadRequest(new { error = "duplicate_name", message = $"A room named '{roomName}' already exists." });
|
||||
|
||||
// Pair each uploaded file with its scene folder name. Browsers
|
||||
// submit folder uploads as files with FileName = "Lobby/blob.room"
|
||||
// when webkitdirectory is used; fall back to a parallel
|
||||
// scenePaths[] form field when the client supplies it explicitly
|
||||
// (e.g. uploading individual files via JS FormData with the
|
||||
// scene name set per entry).
|
||||
//
|
||||
// Each blob is also passed through RoomBlobNormalizerService
|
||||
// (round-trip via the full 2020 PersistedRoomData schema). That
|
||||
// strips redundant default-value encodings the modern client
|
||||
// emits — without this, the watch's stricter 2020 parser logs
|
||||
// "Error attempting to parse room data stream" and boots the
|
||||
// player to dorm. Normalize() falls back to the original bytes
|
||||
// if parse fails, so a corrupted upload doesn't drop content.
|
||||
var perScene = new List<(string SceneName, string OriginalName, byte[] Bytes, RoomBlobNormalizerService.Result Norm)>();
|
||||
for (int i = 0; i < files.Count; i++)
|
||||
{
|
||||
var f = files[i];
|
||||
// FormFile.FileName is "scenefolder/blob.room" when uploaded
|
||||
// via webkitdirectory. ScenePaths (if provided) overrides.
|
||||
var sceneName = scenePaths is { } sp && i < sp.Count && !string.IsNullOrWhiteSpace(sp[i])
|
||||
? sp[i].Trim()
|
||||
: ExtractSceneFolder(f.FileName);
|
||||
if (string.IsNullOrWhiteSpace(sceneName))
|
||||
{
|
||||
return BadRequest(new {
|
||||
error = "missing_scene_folder",
|
||||
message = $"File '{f.FileName}' has no scene folder. Either upload via folder picker, or supply scenePaths[] alongside files[]."
|
||||
});
|
||||
}
|
||||
using var ms = new MemoryStream();
|
||||
await f.CopyToAsync(ms);
|
||||
var raw = ms.ToArray();
|
||||
var norm = roomBlobNormalizer.Normalize(raw);
|
||||
var persistedBytes = normalize ? norm.Bytes : raw;
|
||||
adminLogger.LogInformation(
|
||||
"[import] scene={Scene} file={File} raw={Raw:N0} → normalized={Out:N0} ({Status}) persistedFrom={Source}",
|
||||
sceneName, f.FileName, raw.Length, norm.Bytes.Length,
|
||||
norm.Normalized ? "ok" : $"fail: {norm.Error}",
|
||||
normalize ? "normaliser" : "raw");
|
||||
perScene.Add((sceneName, f.FileName, persistedBytes, norm));
|
||||
}
|
||||
|
||||
// Pick entry scene: explicit override → "Lobby" if present →
|
||||
// alphabetically-first folder.
|
||||
var sceneNames = perScene.Select(s => s.SceneName).ToList();
|
||||
var entry = !string.IsNullOrWhiteSpace(entryScene)
|
||||
? perScene.FirstOrDefault(s => string.Equals(s.SceneName, entryScene, StringComparison.OrdinalIgnoreCase))
|
||||
: default;
|
||||
if (entry == default)
|
||||
{
|
||||
entry = perScene.FirstOrDefault(s => string.Equals(s.SceneName, "Lobby", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
if (entry == default) entry = perScene.OrderBy(s => s.SceneName).First();
|
||||
|
||||
// Allocate a new id in the user-room range (>=1000).
|
||||
var newRoomId = (await db.Rooms
|
||||
.Where(r => r.Id >= 1000)
|
||||
.Select(r => (long?)r.Id)
|
||||
.MaxAsync() ?? 999) + 1;
|
||||
var creator = creatorPlayerId ?? CurrentAdminId;
|
||||
const string makerRoomLocation = "a75f7547-79eb-47c6-8986-6767abcb4f92";
|
||||
|
||||
// Build BlobName per scene.
|
||||
var slug = roomName.ToLowerInvariant().Replace(' ', '_');
|
||||
string MakeBlobName(string sceneName) => $"{slug}_{sceneName.ToLowerInvariant()}.room";
|
||||
|
||||
// Reorder so entry scene is OrderIndex=0.
|
||||
var ordered = new List<(string SceneName, string OriginalName, byte[] Bytes, RoomBlobNormalizerService.Result Norm)> { entry };
|
||||
ordered.AddRange(perScene.Where(s => s.SceneName != entry.SceneName));
|
||||
|
||||
using var tx = await db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
// Insert blobs — bytes go to S3, DB only stores metadata.
|
||||
foreach (var s in ordered)
|
||||
{
|
||||
var blobName = MakeBlobName(s.SceneName);
|
||||
var (bucket, key) = BlobRouter.Route(blobName);
|
||||
await storage.PutAsync(bucket, key, s.Bytes, "application/octet-stream");
|
||||
db.RoomDataBlobs.Add(new RoomDataBlobEntity
|
||||
{
|
||||
RoomId = newRoomId,
|
||||
BlobName = blobName,
|
||||
UploadedByPlayerId = creator,
|
||||
UploadedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
// Insert Room row pointing at entry scene's blob.
|
||||
var entryBlob = MakeBlobName(entry.SceneName);
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = newRoomId,
|
||||
Name = roomName,
|
||||
Description = string.IsNullOrWhiteSpace(description)
|
||||
? $"Imported via admin upload ({ordered.Count} scenes)"
|
||||
: description.Trim(),
|
||||
CreatorPlayerId = creator,
|
||||
ImageName = string.Empty,
|
||||
State = 0,
|
||||
Accessibility = 1,
|
||||
IsAGRoom = false,
|
||||
IsDormRoom = false,
|
||||
CloningAllowed = true,
|
||||
LocationReplicationId = makerRoomLocation,
|
||||
TagsCsv = "community",
|
||||
CheerCount = 0,
|
||||
FavoriteCount = 0,
|
||||
VisitCount = 0,
|
||||
VisitorCount = 0,
|
||||
HotScore = 5.0,
|
||||
CurrentDataBlobName = entryBlob,
|
||||
});
|
||||
|
||||
// Insert RoomScenes rows.
|
||||
int idx = 0;
|
||||
foreach (var s in ordered)
|
||||
{
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{
|
||||
RoomId = newRoomId,
|
||||
OrderIndex = idx++,
|
||||
Name = s.SceneName,
|
||||
RoomSceneLocationId = makerRoomLocation,
|
||||
DataBlobName = MakeBlobName(s.SceneName),
|
||||
MaxPlayers = 8,
|
||||
IsSandbox = false,
|
||||
CanMatchmakeInto = true,
|
||||
DataModifiedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await tx.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
await LogAsync("import_room", "room", newRoomId,
|
||||
$"name={roomName} scenes={ordered.Count} entry={entry.SceneName}");
|
||||
|
||||
// Fire-and-forget: scan the just-imported scene blobs for
|
||||
// Holotar/AudioSampler .htr references and download each from
|
||||
// cdn.rec.net into RoomDataBlobs (BlobName="{hash}.htr",
|
||||
// RoomId=0). The watch's holotar projectors and audio
|
||||
// samplers can then pull their content from cdn.localhost
|
||||
// instead of failing on the public CDN. Doing this in the
|
||||
// background keeps the upload response snappy (the .htr
|
||||
// download set for a YarrHarrHeist-sized room is ~25 MB).
|
||||
htrMirror.EnqueueAsync(
|
||||
ordered.Select(s => s.Bytes).ToList(),
|
||||
contextLabel: $"room={roomName}");
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
roomId = newRoomId,
|
||||
roomName,
|
||||
sceneCount = ordered.Count,
|
||||
entryScene = entry.SceneName,
|
||||
normalizedSceneCount = ordered.Count(s => s.Norm.Normalized),
|
||||
normalizationFailures = ordered.Where(s => !s.Norm.Normalized)
|
||||
.Select(s => new { scene = s.SceneName, error = s.Norm.Error }),
|
||||
htrMirrorStarted = true,
|
||||
scenes = ordered.Select((s, i) => new
|
||||
{
|
||||
orderIndex = i,
|
||||
name = s.SceneName,
|
||||
blobName = MakeBlobName(s.SceneName),
|
||||
rawBytes = s.Norm.InputBytes,
|
||||
bytes = s.Bytes.Length,
|
||||
normalized = s.Norm.Normalized,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/rooms/{id}/mirror-htr</c> —
|
||||
/// re-run the HoloTar / AudioSampler .htr mirror for an existing
|
||||
/// room's blobs. Useful to (a) backfill assets for rooms imported
|
||||
/// before the auto-mirror existed, (b) debug whether
|
||||
/// HtrAssetMirrorService can actually extract refs from the C#
|
||||
/// generated 2020 PersistedRoomData schema. Synchronous (awaits the
|
||||
/// download stream) so the response carries the result counts —
|
||||
/// the import-time path uses fire-and-forget instead.</summary>
|
||||
[HttpPost("rooms/{roomId:long}/mirror-htr")]
|
||||
public async Task<IActionResult> MirrorHtrForRoom(long roomId)
|
||||
{
|
||||
// Stream bytes back from S3 — DB carries only metadata now.
|
||||
var blobNames = await db.RoomDataBlobs
|
||||
.Where(b => b.RoomId == roomId)
|
||||
.Select(b => b.BlobName)
|
||||
.ToListAsync();
|
||||
if (blobNames.Count == 0) return NotFound(new { error = "no_blobs", roomId });
|
||||
var blobs = new List<byte[]>(blobNames.Count);
|
||||
foreach (var blobName in blobNames)
|
||||
{
|
||||
var (bucket, key) = BlobRouter.Route(blobName);
|
||||
var bytes = await storage.GetAsync(bucket, key);
|
||||
if (bytes is { Length: > 0 }) blobs.Add(bytes);
|
||||
}
|
||||
if (blobs.Count == 0) return NotFound(new { error = "blobs_missing_in_s3", roomId, expected = blobNames.Count });
|
||||
var result = await htrMirror.MirrorAsync(blobs, contextLabel: $"manual room={roomId}");
|
||||
await LogAsync("mirror_htr", "room", roomId,
|
||||
$"refs={result.TotalRefs} skipped={result.AlreadyMirrored} inserted={result.Inserted} parseFails={result.RoomBlobParseFailures}");
|
||||
return Ok(new
|
||||
{
|
||||
roomId,
|
||||
scannedBlobs = blobs.Count,
|
||||
uniqueRefs = result.TotalRefs,
|
||||
alreadyMirrored = result.AlreadyMirrored,
|
||||
downloaded = result.Inserted,
|
||||
roomParseFailures = result.RoomBlobParseFailures,
|
||||
assetDownloadFailures = result.AssetDownloadFailures,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Pull the immediate-parent folder out of a multi-segment
|
||||
/// filename like "Lobby/abc.room" or "YarrHarrHeist/Lobby/abc.room".
|
||||
/// Returns the second-to-last segment (the file's parent folder).
|
||||
/// Returns null if the filename has no folder component.</summary>
|
||||
private static string? ExtractSceneFolder(string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName)) return null;
|
||||
// Browsers may use either separator depending on platform.
|
||||
var parts = fileName.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 2) return null;
|
||||
// The .room file's parent folder is the scene name.
|
||||
return parts[^2];
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// admin.rec.net/api/admin/v1/login — same-origin login endpoint for
|
||||
/// the browser-based admin UI. Wraps a username+password verify
|
||||
/// against PlayerEntity.PasswordHash and returns the JWT pair the UI
|
||||
/// can stash in localStorage. Refuses non-admin accounts up front so a
|
||||
/// regular player password can't be used to scope into the admin tools
|
||||
/// even if the JWT is valid against api/admin/v1/* (the AdminOnly
|
||||
/// filter would catch it anyway, but the early reject saves a
|
||||
/// confused-looking 403).
|
||||
///
|
||||
/// Lives on admin.rec.net so the static admin page (also served from
|
||||
/// admin.rec.net) can fetch with a same-origin request and skip CORS
|
||||
/// entirely.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/v1")]
|
||||
public class AdminLoginController(
|
||||
DorkNetDbContext db,
|
||||
AuthService authService) : ControllerBase
|
||||
{
|
||||
public sealed record AdminLoginRequest(string Username, string Password);
|
||||
|
||||
[HttpPost("login")]
|
||||
public async Task<ActionResult> Login([FromBody] AdminLoginRequest body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body.Username) || string.IsNullOrWhiteSpace(body.Password))
|
||||
return BadRequest(new { error = "missing_credentials" });
|
||||
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Username == body.Username);
|
||||
if (player is null || player.PasswordHash is null)
|
||||
return Unauthorized(new { error = "invalid_credentials" });
|
||||
if (!BCrypt.Net.BCrypt.Verify(body.Password, player.PasswordHash))
|
||||
return Unauthorized(new { error = "invalid_credentials" });
|
||||
if (!player.IsAdmin)
|
||||
return StatusCode(403, new { error = "not_admin" });
|
||||
|
||||
var (access, refresh) = authService.GenerateTokenPair(player.Id);
|
||||
return Ok(new
|
||||
{
|
||||
access_token = access,
|
||||
refresh_token = refresh,
|
||||
account_id = player.Id,
|
||||
username = player.Username,
|
||||
display_name = player.DisplayName,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,1209 +0,0 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Google.Protobuf;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// admin.rec.net/api/admin/v1/rooms/zip-bulk-import — preferred room
|
||||
/// import path going forward. The legacy multi-file endpoint
|
||||
/// (<c>POST api/admin/v1/rooms/import</c>) stays alongside this for
|
||||
/// tooling that already targets it.
|
||||
///
|
||||
/// The archive format mirrors RecNet's own room export shape so a
|
||||
/// user can download a room from rec.net, drop the zip into the admin
|
||||
/// page, and we re-create it with full fidelity — including the
|
||||
/// <c>.htr</c> AudioSampler / Holotar assets already extracted into
|
||||
/// each subroom folder so we never have to hit Rec Room's CDN:
|
||||
///
|
||||
/// root.zip/
|
||||
/// Rooms/Room_<id>_<name>_<timestamp>/
|
||||
/// RoomDetails.json (full RecNet v4 details schema)
|
||||
/// RoomImage.jpg | <hash>.jpg
|
||||
/// Promo/Image/PromoImage_*.jpg
|
||||
/// SubRooms/<SceneName>/
|
||||
/// Subroom.json (per-subroom manifest)
|
||||
/// <hash>.room (referenced by Subroom.CurrentSave.DataBlob)
|
||||
/// AudioSampler/<hash>.htr
|
||||
/// Holotar/<hash>.htr
|
||||
/// Image/PVImage_<hash>.jpg
|
||||
/// Inventions/Invention_<id>_<name>_<timestamp>/
|
||||
/// Invention.json
|
||||
/// InventionDetails.json
|
||||
/// InventionVersion.json
|
||||
/// InventionImage.jpg
|
||||
/// <hash>.inv
|
||||
///
|
||||
/// Entry-scene resolution: the order in <c>RoomDetails.SubRooms[]</c>
|
||||
/// is the entry order (RecNet API convention). The first entry is the
|
||||
/// entry scene. The admin can override with an explicit
|
||||
/// <c>RoomDetails.EntryScene</c> field.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/v1/rooms")]
|
||||
[Authorize]
|
||||
[AdminOnly]
|
||||
public class RoomZipImportController(
|
||||
DorkNetDbContext db,
|
||||
RoomBlobNormalizerService roomBlobNormalizer,
|
||||
IObjectStorage storage,
|
||||
ILogger<RoomZipImportController> logger) : ControllerBase
|
||||
{
|
||||
private long CurrentAdminId => this.RequireCurrentPlayerId();
|
||||
|
||||
// ── Room-blob normaliser toggle ─────────────────────────────────────
|
||||
// The normaliser re-encodes .room bytes through the 2020 schema to
|
||||
// strip non-canonical encodings. It's been tripping the watch into
|
||||
// "Error attempting to parse room data stream" — the generated 2020
|
||||
// protobuf bindings appear to be missing fields or to emit them in
|
||||
// a wire-order the watch's parser disagrees with.
|
||||
//
|
||||
// Per-import flag (`normalizeBlobs` in the form / finalize body)
|
||||
// defaults OFF. The SPA's importer surfaces it as a checkbox so an
|
||||
// admin can opt back in once we're confident a particular zip's
|
||||
// contents survive the re-encode. The diagnostic parse still runs
|
||||
// either way (its parse-OK / parse-FAIL log line is useful), but
|
||||
// when the flag is false the original bytes are what reach S3.
|
||||
|
||||
/// <summary>Write a blob to S3 at its canonical (bucket, key) AND
|
||||
/// record the metadata row in <see cref="RoomDataBlobEntity"/>.
|
||||
/// Bytes are NOT persisted on the DB row — S3 is the only place
|
||||
/// they live. Previously the importer wrote bytes to the
|
||||
/// <c>Bytes</c> column and skipped S3 entirely, so freshly
|
||||
/// imported rooms were only reachable through the read-path's
|
||||
/// DB-fallback branch. Centralised here so every importer call
|
||||
/// site (scene saves, .htr/PV/polaroid assets, .meta sentinels,
|
||||
/// invention saves, room thumbnails, promo images) ends up in
|
||||
/// S3 the same way.</summary>
|
||||
private async Task<RoomDataBlobEntity> WriteBlobAsync(
|
||||
long roomId, string blobName, byte[] bytes, long uploaderPlayerId,
|
||||
string? referencesCsv = null, string? contentTypeOverride = null)
|
||||
{
|
||||
var (bucket, key) = BlobRouter.Route(blobName);
|
||||
var contentType = contentTypeOverride ?? MimeFromName(blobName);
|
||||
try
|
||||
{
|
||||
await storage.PutAsync(bucket, key, bytes, contentType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex,
|
||||
"[zip-import] S3 PUT failed bucket={Bucket} key={Key} blob={Blob} bytes={Bytes} — row will still be recorded so the import isn't lost",
|
||||
bucket, key, blobName, bytes.Length);
|
||||
}
|
||||
|
||||
var entity = new RoomDataBlobEntity
|
||||
{
|
||||
RoomId = roomId,
|
||||
BlobName = blobName,
|
||||
UploadedByPlayerId = uploaderPlayerId,
|
||||
UploadedAt = DateTime.UtcNow,
|
||||
ReferencedFilenamesCsv = referencesCsv ?? string.Empty,
|
||||
};
|
||||
db.RoomDataBlobs.Add(entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
private static string MimeFromName(string name) => Path.GetExtension(name).ToLowerInvariant() switch
|
||||
{
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".mp4" => "video/mp4",
|
||||
".htr" => "application/octet-stream",
|
||||
".room" => "application/octet-stream",
|
||||
".meta" => "application/octet-stream",
|
||||
".inv" => "application/octet-stream",
|
||||
".dat" => "application/octet-stream",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
// ── RoomDetails.json DTOs ──────────────────────────────────────────
|
||||
|
||||
public sealed class RoomDetailsDto
|
||||
{
|
||||
public long? RoomId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? FriendlyName { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
public int? WarningMask { get; set; }
|
||||
public string? CustomWarning { get; set; }
|
||||
public long? CreatorAccountId { get; set; }
|
||||
public int? State { get; set; }
|
||||
public int? Accessibility { get; set; }
|
||||
public bool? SupportsLevelVoting { get; set; }
|
||||
public bool? IsRRO { get; set; }
|
||||
public bool? SupportsScreens { get; set; }
|
||||
public bool? SupportsWalkVR { get; set; }
|
||||
public bool? SupportsTeleportVR { get; set; }
|
||||
public bool? SupportsVRLow { get; set; }
|
||||
public bool? SupportsMobile { get; set; }
|
||||
public bool? SupportsJuniors { get; set; }
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
public bool? IsDorm { get; set; }
|
||||
public int? MaxPlayers { get; set; }
|
||||
public bool? CloningAllowed { get; set; }
|
||||
public bool? DisableMicAutoMute { get; set; }
|
||||
public StatsDto? Stats { get; set; }
|
||||
public List<TagDto>? Tags { get; set; }
|
||||
public List<SubRoomDto>? SubRooms { get; set; }
|
||||
public string? EntryScene { get; set; } // admin override; not in real exports
|
||||
}
|
||||
|
||||
public sealed class StatsDto
|
||||
{
|
||||
public int? CheerCount { get; set; }
|
||||
public int? FavoriteCount { get; set; }
|
||||
public int? VisitorCount { get; set; }
|
||||
public int? VisitCount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TagDto
|
||||
{
|
||||
public string? Tag { get; set; }
|
||||
public int? Type { get; set; }
|
||||
public bool? IsPrimaryGenre { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SubRoomDto
|
||||
{
|
||||
public long? SubRoomId { get; set; }
|
||||
public string? UnitySceneId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public bool? IsSandbox { get; set; }
|
||||
public int? MaxPlayers { get; set; }
|
||||
public int? Accessibility { get; set; }
|
||||
public CurrentSaveDto? CurrentSave { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CurrentSaveDto
|
||||
{
|
||||
public long? SubRoomDataSaveId { get; set; }
|
||||
public string? DataBlob { get; set; }
|
||||
}
|
||||
|
||||
// ── Invention DTOs ────────────────────────────────────────────────
|
||||
|
||||
public sealed class InventionJsonDto
|
||||
{
|
||||
public long? InventionId { get; set; }
|
||||
public string? ReplicationId { get; set; }
|
||||
public long? CreatorPlayerId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? LongDescription { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
public int? UgcVersion { get; set; }
|
||||
public int? CurrentVersionNumber { get; set; }
|
||||
public int? LatestVersionNumber { get; set; }
|
||||
public int? Accessibility { get; set; }
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
public DateTime? ModifiedAt { get; set; }
|
||||
public DateTime? FirstPublishedAt { get; set; }
|
||||
public long? CreationRoomId { get; set; }
|
||||
public int? NumPlayersHaveUsedInRoom { get; set; }
|
||||
public int? CheerCount { get; set; }
|
||||
public int? CreatorPermission { get; set; }
|
||||
public int? GeneralPermission { get; set; }
|
||||
public bool? IsAgInvention { get; set; }
|
||||
public bool? IsRecRoomApproved { get; set; }
|
||||
}
|
||||
|
||||
public sealed class InventionVersionDto
|
||||
{
|
||||
public int? VersionNumber { get; set; }
|
||||
public string? BlobName { get; set; }
|
||||
}
|
||||
|
||||
public sealed class InventionDetailsDto
|
||||
{
|
||||
public List<TagDto>? Tags { get; set; }
|
||||
}
|
||||
|
||||
// ── Endpoint ──────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost("zip-bulk-import")]
|
||||
[DisableRequestSizeLimit]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)] // 2 GB
|
||||
public async Task<IActionResult> ZipImport(
|
||||
[FromForm(Name = "archive")] IFormFile? archive,
|
||||
[FromForm(Name = "creatorPlayerId")] long? creatorPlayerId,
|
||||
[FromForm(Name = "normalizeBlobs")] bool? normalizeBlobs)
|
||||
{
|
||||
if (archive is null || archive.Length == 0)
|
||||
return BadRequest(new { error = "missing_archive" });
|
||||
|
||||
await using var stream = archive.OpenReadStream();
|
||||
return await RunImportAsync(
|
||||
stream, archive.Length, creatorPlayerId,
|
||||
normalizeBlobs: normalizeBlobs ?? false);
|
||||
}
|
||||
|
||||
// ── Chunked upload (Cloudflare 100MB workaround) ─────────────────
|
||||
//
|
||||
// The whole multipart-zip path 413s through Cloudflare's edge for
|
||||
// anything over 100 MB on free/pro plans. Real RecNet exports
|
||||
// routinely run 150-300 MB. Splitting the upload into ≤90 MB
|
||||
// chunks keeps each request safely under the cap; the server
|
||||
// appends chunks to a temp file and only invokes the import logic
|
||||
// once the finalize call comes in.
|
||||
//
|
||||
// Sessions are in-memory and short-lived (admins use the importer
|
||||
// synchronously). If the process restarts before finalize, the
|
||||
// upload is lost — acceptable for an admin tool used by 1-2 people.
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<Guid, ZipUploadSession> Sessions = new();
|
||||
|
||||
public sealed class ZipUploadSession
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string TempPath { get; init; } = string.Empty;
|
||||
public long TotalBytes { get; set; }
|
||||
public long ReceivedBytes { get; set; }
|
||||
public string FileName { get; init; } = string.Empty;
|
||||
public long AdminPlayerId { get; init; }
|
||||
public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed record ChunkInitRequest(string FileName, long TotalBytes);
|
||||
|
||||
/// <summary>POST <c>zip-upload-init</c> — allocates a temp file and
|
||||
/// returns an <c>uploadId</c> the SPA threads through subsequent
|
||||
/// chunk POSTs. Capped at 2 GB total; opens a sparse temp file so
|
||||
/// disk usage tracks actual bytes received.</summary>
|
||||
[HttpPost("zip-upload-init")]
|
||||
public ActionResult ChunkInit([FromBody] ChunkInitRequest body)
|
||||
{
|
||||
if (body.TotalBytes <= 0 || body.TotalBytes > 2_000_000_000)
|
||||
return BadRequest(new { error = "invalid_size" });
|
||||
|
||||
// Prune stale sessions before allocating a new one. Anything
|
||||
// older than 2 hours with no activity is abandoned.
|
||||
foreach (var (sid, sess) in Sessions)
|
||||
{
|
||||
if (DateTime.UtcNow - sess.CreatedAt > TimeSpan.FromHours(2))
|
||||
{
|
||||
Sessions.TryRemove(sid, out _);
|
||||
try { if (System.IO.File.Exists(sess.TempPath)) System.IO.File.Delete(sess.TempPath); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
var safeName = Path.GetFileName(body.FileName);
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"dorknet-ziplupload-{id:N}.zip");
|
||||
// Pre-allocate the file at full length so chunk writes at
|
||||
// arbitrary offsets work without the OS having to grow it
|
||||
// page-by-page during the random writes.
|
||||
using (var fs = new FileStream(tempPath, FileMode.Create, FileAccess.Write))
|
||||
{
|
||||
fs.SetLength(body.TotalBytes);
|
||||
}
|
||||
Sessions[id] = new ZipUploadSession
|
||||
{
|
||||
Id = id,
|
||||
TempPath = tempPath,
|
||||
TotalBytes = body.TotalBytes,
|
||||
FileName = safeName,
|
||||
AdminPlayerId = CurrentAdminId,
|
||||
};
|
||||
return Ok(new { uploadId = id });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>zip-upload-chunk/{id}?offset=N</c> — writes the
|
||||
/// posted body at <c>offset</c> in the session's temp file.
|
||||
/// Per-call size capped at 100 MB (Kestrel-side, well below the
|
||||
/// Cloudflare 100 MB edge cap so we never see a 413). Body is the
|
||||
/// raw chunk bytes; the SPA's <c>uploadZipInChunks</c> helper sends
|
||||
/// the right slice.</summary>
|
||||
[HttpPost("zip-upload-chunk/{id:guid}")]
|
||||
[DisableRequestSizeLimit]
|
||||
[RequestSizeLimit(100_000_000)]
|
||||
public async Task<IActionResult> ChunkAppend(Guid id, [FromQuery] long offset)
|
||||
{
|
||||
if (!Sessions.TryGetValue(id, out var session)) return NotFound(new { error = "unknown_session" });
|
||||
if (session.AdminPlayerId != CurrentAdminId) return Forbid();
|
||||
if (offset < 0 || offset >= session.TotalBytes)
|
||||
return BadRequest(new { error = "bad_offset", offset, total = session.TotalBytes });
|
||||
|
||||
await using var fs = new FileStream(session.TempPath, FileMode.Open, FileAccess.Write, FileShare.Read);
|
||||
fs.Position = offset;
|
||||
var written = 0L;
|
||||
await Request.Body.CopyToAsync(fs);
|
||||
written = fs.Position - offset;
|
||||
session.ReceivedBytes = Math.Max(session.ReceivedBytes, fs.Position);
|
||||
return Ok(new
|
||||
{
|
||||
uploadId = id,
|
||||
offset,
|
||||
written,
|
||||
received = session.ReceivedBytes,
|
||||
total = session.TotalBytes,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Body for <c>zip-upload-finalize</c>. <see cref="CreatorPlayerId"/>
|
||||
/// stamps every imported room/invention with that player as the
|
||||
/// owner. <see cref="SelectedRoomFolders"/> and
|
||||
/// <see cref="SelectedInventionFolders"/>, when non-null and
|
||||
/// non-empty, restrict the import to only those top-level folders
|
||||
/// inside the archive — the SPA's preview offers a checkbox per
|
||||
/// room/invention so admins can skip a bad export's broken entry
|
||||
/// without rebuilding the zip. Null/empty means "import
|
||||
/// everything" (back-compat with older SPA builds).</summary>
|
||||
public sealed record ChunkFinalizeRequest(
|
||||
long? CreatorPlayerId,
|
||||
List<string>? SelectedRoomFolders,
|
||||
List<string>? SelectedInventionFolders,
|
||||
bool? NormalizeBlobs);
|
||||
|
||||
/// <summary>POST <c>zip-upload-finalize/{id}</c> — runs the existing
|
||||
/// zip import on the assembled temp file then deletes it. Same
|
||||
/// response shape as <c>zip-bulk-import</c>.</summary>
|
||||
[HttpPost("zip-upload-finalize/{id:guid}")]
|
||||
public async Task<IActionResult> ChunkFinalize(Guid id, [FromBody] ChunkFinalizeRequest? body)
|
||||
{
|
||||
if (!Sessions.TryGetValue(id, out var session)) return NotFound(new { error = "unknown_session" });
|
||||
if (session.AdminPlayerId != CurrentAdminId) return Forbid();
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = System.IO.File.OpenRead(session.TempPath);
|
||||
return await RunImportAsync(
|
||||
stream,
|
||||
session.TotalBytes,
|
||||
body?.CreatorPlayerId,
|
||||
body?.SelectedRoomFolders,
|
||||
body?.SelectedInventionFolders,
|
||||
normalizeBlobs: body?.NormalizeBlobs ?? false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Sessions.TryRemove(id, out _);
|
||||
try { if (System.IO.File.Exists(session.TempPath)) System.IO.File.Delete(session.TempPath); }
|
||||
catch (Exception ex) { logger.LogWarning(ex, "[zip-import] failed to clean up {Path}", session.TempPath); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>DELETE <c>zip-upload-abort/{id}</c> — drop an in-flight
|
||||
/// upload session (e.g. user navigated away). No-op if the id is
|
||||
/// already gone.</summary>
|
||||
[HttpDelete("zip-upload-abort/{id:guid}")]
|
||||
public IActionResult ChunkAbort(Guid id)
|
||||
{
|
||||
if (Sessions.TryRemove(id, out var session))
|
||||
{
|
||||
try { if (System.IO.File.Exists(session.TempPath)) System.IO.File.Delete(session.TempPath); }
|
||||
catch (Exception ex) { logger.LogWarning(ex, "[zip-import] failed to clean up {Path}", session.TempPath); }
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// ── Shared import core (called by both single-shot and chunked) ──
|
||||
|
||||
private async Task<IActionResult> RunImportAsync(
|
||||
Stream archiveStream,
|
||||
long archiveBytes,
|
||||
long? creatorPlayerId,
|
||||
List<string>? selectedRoomFolders = null,
|
||||
List<string>? selectedInventionFolders = null,
|
||||
bool normalizeBlobs = false)
|
||||
{
|
||||
var creator = creatorPlayerId ?? CurrentAdminId;
|
||||
if (!await db.Players.AnyAsync(p => p.Id == creator))
|
||||
return BadRequest(new { error = "creator_not_found", creator });
|
||||
|
||||
using var zip = new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: false);
|
||||
|
||||
// Index entries by normalized path. We do path-based lookups
|
||||
// throughout (per-room folder, per-subroom folder, per-invention
|
||||
// folder), so a flat dictionary keyed by full path is simplest.
|
||||
var entryByPath = new Dictionary<string, ZipArchiveEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var e in zip.Entries)
|
||||
{
|
||||
if (e.FullName.EndsWith("/", StringComparison.Ordinal)) continue;
|
||||
entryByPath[Normalize(e.FullName)] = e;
|
||||
}
|
||||
|
||||
// Find every room manifest (`Rooms/<folder>/RoomDetails.json`).
|
||||
// Folder names are like "Room_3163127_YarrHarrHeist_20260509_180319"
|
||||
// but we don't parse anything out of them — RoomDetails.json is
|
||||
// the source of truth.
|
||||
var roomFolders = entryByPath.Keys
|
||||
.Where(k => k.StartsWith("Rooms/", StringComparison.OrdinalIgnoreCase)
|
||||
&& k.EndsWith("/RoomDetails.json", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(k => k.Substring(0, k.Length - "/RoomDetails.json".Length))
|
||||
.ToList();
|
||||
|
||||
// Inventions can live either at the zip root (Inventions/Invention_<id>_<name>_<ts>/)
|
||||
// OR inside a room folder (Rooms/Room_<id>_<name>_<ts>/Inventions/Invention_…).
|
||||
// Both layouts show up in the wild — handle either.
|
||||
var inventionFolders = entryByPath.Keys
|
||||
.Where(k => k.EndsWith("/Invention.json", StringComparison.OrdinalIgnoreCase)
|
||||
&& (k.StartsWith("Inventions/", StringComparison.OrdinalIgnoreCase)
|
||||
|| k.Contains("/Inventions/", StringComparison.OrdinalIgnoreCase)))
|
||||
.Select(k => k[..^"/Invention.json".Length])
|
||||
.ToList();
|
||||
|
||||
if (roomFolders.Count == 0 && inventionFolders.Count == 0)
|
||||
return BadRequest(new { error = "empty_archive_no_manifests" });
|
||||
|
||||
// If the admin supplied an explicit selection (non-null,
|
||||
// non-empty), filter to just those folders. Comparison is
|
||||
// case-insensitive and tolerates leading/trailing slashes that
|
||||
// the client might have included. Anything in the selection
|
||||
// that we can't match against the archive is just ignored —
|
||||
// the per-room report will simply not include it.
|
||||
if (selectedRoomFolders is { Count: > 0 })
|
||||
{
|
||||
var allowed = selectedRoomFolders
|
||||
.Select(s => Normalize(s).TrimEnd('/'))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
roomFolders = roomFolders.Where(f => allowed.Contains(f)).ToList();
|
||||
}
|
||||
if (selectedInventionFolders is { Count: > 0 })
|
||||
{
|
||||
var allowed = selectedInventionFolders
|
||||
.Select(s => Normalize(s).TrimEnd('/'))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
inventionFolders = inventionFolders.Where(f => allowed.Contains(f)).ToList();
|
||||
}
|
||||
|
||||
var roomReports = new List<object>();
|
||||
long nextRoomId = (await db.Rooms
|
||||
.Where(r => r.Id >= 1000)
|
||||
.Select(r => (long?)r.Id)
|
||||
.MaxAsync() ?? 999) + 1;
|
||||
|
||||
foreach (var folder in roomFolders)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await ImportRoomAsync(folder, nextRoomId, creator, entryByPath, normalizeBlobs);
|
||||
roomReports.Add(result);
|
||||
if (result.GetType().GetProperty("ok")!.GetValue(result) as bool? == true)
|
||||
nextRoomId++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "[zip-import] room folder='{Folder}' failed", folder);
|
||||
roomReports.Add(new { folder, ok = false, error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
var inventionReports = new List<object>();
|
||||
foreach (var folder in inventionFolders)
|
||||
{
|
||||
try { inventionReports.Add(await ImportInventionAsync(folder, creator, entryByPath)); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "[zip-import] invention folder='{Folder}' failed", folder);
|
||||
inventionReports.Add(new { folder, ok = false, error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
archiveBytes,
|
||||
roomCount = roomFolders.Count,
|
||||
inventionCount = inventionFolders.Count,
|
||||
rooms = roomReports,
|
||||
inventions = inventionReports,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Per-room import ───────────────────────────────────────────────
|
||||
|
||||
private async Task<object> ImportRoomAsync(
|
||||
string roomFolder,
|
||||
long roomId,
|
||||
long creator,
|
||||
Dictionary<string, ZipArchiveEntry> entries,
|
||||
bool normalizeBlobs)
|
||||
{
|
||||
// RoomDetails.json
|
||||
var detailsKey = $"{roomFolder}/RoomDetails.json";
|
||||
var details = await ReadJsonAsync<RoomDetailsDto>(entries[detailsKey]) ?? new RoomDetailsDto();
|
||||
var name = !string.IsNullOrWhiteSpace(details.Name) ? details.Name!.Trim() : Path.GetFileName(roomFolder);
|
||||
|
||||
if (await db.Rooms.AnyAsync(r => r.Name == name))
|
||||
return new { name, ok = false, error = "duplicate_name", folder = roomFolder };
|
||||
|
||||
// Subroom manifests + blobs. Walk each folder under
|
||||
// <roomFolder>/SubRooms/<scene>/ and pair its Subroom.json with
|
||||
// the .room blob it references via CurrentSave.DataBlob, plus
|
||||
// any AudioSampler/Holotar .htr files alongside.
|
||||
var subroomRoot = $"{roomFolder}/SubRooms/";
|
||||
var scenes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var path in entries.Keys.Where(k => k.StartsWith(subroomRoot, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var rel = path.Substring(subroomRoot.Length);
|
||||
var slash = rel.IndexOf('/');
|
||||
if (slash <= 0) continue;
|
||||
var sceneName = rel.Substring(0, slash);
|
||||
if (!scenes.ContainsKey(sceneName)) scenes[sceneName] = sceneName;
|
||||
}
|
||||
var resolvedScenes = new List<ResolvedSubroom>();
|
||||
var missingScenes = new List<string>();
|
||||
|
||||
foreach (var sceneName in scenes.Keys)
|
||||
{
|
||||
var sceneFolder = $"{subroomRoot}{sceneName}";
|
||||
var subroomJsonKey = $"{sceneFolder}/Subroom.json";
|
||||
if (!entries.TryGetValue(subroomJsonKey, out var subroomJsonEntry))
|
||||
{
|
||||
missingScenes.Add($"{sceneName} (missing Subroom.json)");
|
||||
continue;
|
||||
}
|
||||
var manifest = await ReadJsonAsync<SubRoomDto>(subroomJsonEntry) ?? new SubRoomDto();
|
||||
var blobFile = manifest.CurrentSave?.DataBlob;
|
||||
if (string.IsNullOrWhiteSpace(blobFile))
|
||||
{
|
||||
missingScenes.Add($"{sceneName} (no CurrentSave.DataBlob)");
|
||||
continue;
|
||||
}
|
||||
var blobKey = $"{sceneFolder}/{blobFile}";
|
||||
if (!entries.TryGetValue(blobKey, out var blobEntry))
|
||||
{
|
||||
missingScenes.Add($"{sceneName} → {blobFile}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var blobBytes = await ReadAllAsync(blobEntry);
|
||||
// The normaliser still runs so we keep the parse-OK / parse-FAIL
|
||||
// log line — useful telemetry even when we're not persisting
|
||||
// its output. Per-import `normalizeBlobs` flag (defaulting
|
||||
// off because the re-encode currently crashes the watch on
|
||||
// load) decides which bytes actually reach S3.
|
||||
var norm = roomBlobNormalizer.Normalize(blobBytes);
|
||||
var persistedBytes = normalizeBlobs ? norm.Bytes : blobBytes;
|
||||
|
||||
// Collect .htr asset blobs that live alongside this subroom.
|
||||
var htrAssets = new List<(string Name, ZipArchiveEntry Entry)>();
|
||||
foreach (var sub in new[] { "AudioSampler", "Holotar" })
|
||||
{
|
||||
var prefix = $"{sceneFolder}/{sub}/";
|
||||
foreach (var (k, e) in entries.Where(kv => kv.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
if (k.EndsWith(".htr", StringComparison.OrdinalIgnoreCase))
|
||||
htrAssets.Add((Path.GetFileName(k), e));
|
||||
}
|
||||
}
|
||||
// Image/ folder: persistence-view previews (PVImage_<hash>.ext)
|
||||
// and — since the exporter consolidated layouts — also the
|
||||
// placed-in-world polaroids that used to live in Polaroids/.
|
||||
//
|
||||
// The watch fetches BOTH URLs for the same content:
|
||||
// * `img.localhost/PVImage_<hash>.png` — persistence-view preview
|
||||
// * `img.localhost/<hash>.png` — placed polaroid (room blob
|
||||
// references this bare-hash form, decoded directly from
|
||||
// the .room protobuf in our sample)
|
||||
// So for any `PVImage_`-prefixed file we save the bytes under
|
||||
// BOTH BlobNames. Same file in S3/DB twice is cheap and saves
|
||||
// us from missing one of the lookup paths.
|
||||
const string pvPrefix = "PVImage_";
|
||||
var pvImages = new List<(string Name, ZipArchiveEntry Entry)>();
|
||||
var polaroids = new List<(string Name, ZipArchiveEntry Entry)>();
|
||||
var imageFolderPrefix = $"{sceneFolder}/Image/";
|
||||
foreach (var (k, e) in entries.Where(kv => kv.Key.StartsWith(imageFolderPrefix, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var fileName = Path.GetFileName(k);
|
||||
pvImages.Add((fileName, e));
|
||||
if (fileName.StartsWith(pvPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var bareHashName = fileName[pvPrefix.Length..];
|
||||
polaroids.Add((bareHashName, e));
|
||||
}
|
||||
}
|
||||
// Legacy fallback: older archives shipped polaroids as a
|
||||
// separate Polaroids/ subfolder. Still support those so we
|
||||
// don't regress on older test zips that haven't been re-
|
||||
// exported. The persistence loop's de-dupe will collapse
|
||||
// overlaps from the Image/-derived path.
|
||||
var polaroidFolderPrefix = $"{sceneFolder}/Polaroids/";
|
||||
foreach (var (k, e) in entries.Where(kv => kv.Key.StartsWith(polaroidFolderPrefix, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
polaroids.Add((Path.GetFileName(k), e));
|
||||
}
|
||||
|
||||
resolvedScenes.Add(new ResolvedSubroom(
|
||||
Manifest: manifest,
|
||||
SceneName: sceneName,
|
||||
Bytes: persistedBytes,
|
||||
RawBytes: blobBytes.Length,
|
||||
// When the toggle is off we still report `norm.Normalized` —
|
||||
// it reflects whether the diagnostic parse succeeded, not
|
||||
// whether we actually applied the normalised output.
|
||||
NormalizedOk: norm.Normalized,
|
||||
HtrAssets: htrAssets,
|
||||
PvImages: pvImages,
|
||||
Polaroids: polaroids));
|
||||
logger.LogInformation(
|
||||
"[zip-import] room='{Room}' scene='{Scene}' blob={Blob} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol}",
|
||||
name, sceneName, blobFile, blobBytes.Length, norm.Bytes.Length,
|
||||
normalizeBlobs ? "normaliser" : "raw",
|
||||
htrAssets.Count, pvImages.Count, polaroids.Count);
|
||||
}
|
||||
|
||||
if (resolvedScenes.Count == 0)
|
||||
return new { name, ok = false, error = "no_resolvable_subrooms", missingScenes, folder = roomFolder };
|
||||
|
||||
// Entry resolution: order subrooms by manifest order if
|
||||
// RoomDetails.SubRooms[] is present; otherwise by sceneName.
|
||||
// EntryScene field overrides everything.
|
||||
var manifestOrder = (details.SubRooms ?? new())
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.Name))
|
||||
.Select(s => s.Name!)
|
||||
.ToList();
|
||||
if (manifestOrder.Count > 0)
|
||||
{
|
||||
resolvedScenes = resolvedScenes
|
||||
.OrderBy(r => manifestOrder.IndexOf(r.SceneName) is int i && i >= 0 ? i : int.MaxValue)
|
||||
.ThenBy(r => r.SceneName)
|
||||
.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
resolvedScenes = resolvedScenes.OrderBy(r => r.SceneName).ToList();
|
||||
}
|
||||
|
||||
// Entry resolution cascade:
|
||||
// 1. Explicit `RoomDetails.EntryScene` override (admin-set,
|
||||
// not in real exports).
|
||||
// 2. <hash>.meta protobuf at the room root — RecNet's
|
||||
// published-snapshot manifest. Field 2 is the entry
|
||||
// subroom INDEX into RoomDetails.SubRooms[] (0-based).
|
||||
// Decoded directly without a generated schema because
|
||||
// the message is tiny (6 bytes total in the known sample).
|
||||
// 3. First entry in manifest order.
|
||||
// 4. Alphabetical fallback.
|
||||
ResolvedSubroom? metaEntry = null;
|
||||
int? metaIndex = null;
|
||||
string? metaFileName = null;
|
||||
var metaCandidate = entries
|
||||
.Where(kv => kv.Key.StartsWith($"{roomFolder}/", StringComparison.OrdinalIgnoreCase)
|
||||
&& !kv.Key[$"{roomFolder}/".Length..].Contains('/')
|
||||
&& kv.Key.EndsWith(".meta", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(kv => kv.Value)
|
||||
.FirstOrDefault();
|
||||
if (metaCandidate is not null)
|
||||
{
|
||||
metaFileName = Path.GetFileName(metaCandidate.FullName);
|
||||
var metaBytes = await ReadAllAsync(metaCandidate);
|
||||
metaIndex = TryReadEntrySubroomIndexFromMeta(metaBytes);
|
||||
logger.LogInformation("[zip-import] room='{Room}' .meta={Meta} bytes={Bytes} entryIndex={Idx}",
|
||||
name, metaFileName, metaBytes.Length, metaIndex);
|
||||
if (metaIndex is int idx && idx >= 0 && idx < manifestOrder.Count)
|
||||
{
|
||||
var sceneAtIndex = manifestOrder[idx];
|
||||
metaEntry = resolvedScenes.FirstOrDefault(r =>
|
||||
string.Equals(r.SceneName, sceneAtIndex, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
ResolvedSubroom entry;
|
||||
string entrySource;
|
||||
if (!string.IsNullOrWhiteSpace(details.EntryScene)
|
||||
&& resolvedScenes.FirstOrDefault(r => string.Equals(r.SceneName, details.EntryScene, StringComparison.OrdinalIgnoreCase)) is { } overrideEntry)
|
||||
{
|
||||
entry = overrideEntry; entrySource = "RoomDetails.EntryScene";
|
||||
}
|
||||
else if (metaEntry is not null)
|
||||
{
|
||||
entry = metaEntry; entrySource = $"meta_index={metaIndex}";
|
||||
}
|
||||
else
|
||||
{
|
||||
entry = resolvedScenes[0]; entrySource = manifestOrder.Count > 0 ? "manifest_first" : "alphabetical_first";
|
||||
}
|
||||
|
||||
// Image: try ImageName lookup first (RecNet-style hash filename),
|
||||
// then RoomImage.* fallback (convenience format).
|
||||
ZipArchiveEntry? imageEntry = null;
|
||||
if (!string.IsNullOrWhiteSpace(details.ImageName)
|
||||
&& entries.TryGetValue($"{roomFolder}/{details.ImageName}", out var hashed))
|
||||
{
|
||||
imageEntry = hashed;
|
||||
}
|
||||
else
|
||||
{
|
||||
imageEntry = entries.FirstOrDefault(kv =>
|
||||
kv.Key.StartsWith($"{roomFolder}/RoomImage.", StringComparison.OrdinalIgnoreCase)).Value;
|
||||
}
|
||||
|
||||
// Reorder so entry is first. Manifest order preserved for the rest.
|
||||
var ordered = new List<ResolvedSubroom> { entry };
|
||||
ordered.AddRange(resolvedScenes.Where(r => !ReferenceEquals(r, entry)));
|
||||
|
||||
var slug = name.ToLowerInvariant().Replace(' ', '_');
|
||||
// Prefix with room_<id>_ so BlobRouter parks every .room scene
|
||||
// save alongside its room_<id>_v*.dat versioned snapshots in
|
||||
// saves/room/<id>/. Same routing parser handles both shapes;
|
||||
// no per-extension special case.
|
||||
string MakeBlobName(string sceneName) => $"room_{roomId}_{slug}_{sceneName.ToLowerInvariant()}.room";
|
||||
|
||||
// Use the entry subroom's UnitySceneId as the room's location.
|
||||
var entryLocation = entry.Manifest.UnitySceneId ?? "a75f7547-79eb-47c6-8986-6767abcb4f92";
|
||||
|
||||
// Persist .htr assets and PV images at the GLOBAL level
|
||||
// (RoomId=0) under their original hash filenames. The CDN
|
||||
// / img.* controllers look up by exact blob name, so this
|
||||
// matches what HtrAssetMirrorService would have produced if it
|
||||
// had to fetch them from rec.net.
|
||||
//
|
||||
// We track three counters per asset type:
|
||||
// * referenced — total unique filenames the archive points at
|
||||
// * imported — newly inserted into RoomDataBlobs this run
|
||||
// * alreadyInDb — already present from a prior import / mirror
|
||||
// The third is important to surface: bulk-purge-custom keeps
|
||||
// shared assets intentionally (other rooms might use them),
|
||||
// so a re-import after purge will legitimately report 0 newly
|
||||
// imported with non-zero already-in-DB. Reporting only "0
|
||||
// imported" reads as "no HTR uploaded" which is wrong.
|
||||
var htrAssetCount = 0;
|
||||
var pvImageCount = 0;
|
||||
var polaroidCount = 0;
|
||||
var allHtrAssets = ordered.SelectMany(s => s.HtrAssets).ToList();
|
||||
var allPvImages = ordered.SelectMany(s => s.PvImages).ToList();
|
||||
var allPolaroids = ordered.SelectMany(s => s.Polaroids).ToList();
|
||||
|
||||
// De-dupe within the archive first (same .htr can appear under
|
||||
// multiple scenes), then de-dupe against the DB. Both layers
|
||||
// matter for accurate reporting.
|
||||
var uniqueHtrNames = allHtrAssets.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var uniquePvNames = allPvImages.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var uniquePolaroidNames = allPolaroids.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
var existingHtrNames = new HashSet<string>(
|
||||
await db.RoomDataBlobs
|
||||
.Where(b => uniqueHtrNames.Contains(b.BlobName))
|
||||
.Select(b => b.BlobName).ToListAsync(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var existingPvNames = new HashSet<string>(
|
||||
await db.RoomDataBlobs
|
||||
.Where(b => uniquePvNames.Contains(b.BlobName))
|
||||
.Select(b => b.BlobName).ToListAsync(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var existingPolaroidNames = new HashSet<string>(
|
||||
await db.RoomDataBlobs
|
||||
.Where(b => uniquePolaroidNames.Contains(b.BlobName))
|
||||
.Select(b => b.BlobName).ToListAsync(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var htrAlreadyInDbAtStart = existingHtrNames.Count;
|
||||
var pvAlreadyInDbAtStart = existingPvNames.Count;
|
||||
var polaroidAlreadyInDbAtStart = existingPolaroidNames.Count;
|
||||
|
||||
var imageBlobName = string.Empty;
|
||||
|
||||
await using (var tx = await db.Database.BeginTransactionAsync())
|
||||
{
|
||||
// Subroom blobs
|
||||
foreach (var s in ordered)
|
||||
{
|
||||
await WriteBlobAsync(roomId, MakeBlobName(s.SceneName), s.Bytes, creator);
|
||||
}
|
||||
// HTR assets (shared content — keyed by hash, RoomId=0)
|
||||
foreach (var (hname, hentry) in allHtrAssets)
|
||||
{
|
||||
if (existingHtrNames.Contains(hname)) continue;
|
||||
existingHtrNames.Add(hname);
|
||||
var bytes = await ReadAllAsync(hentry);
|
||||
await WriteBlobAsync(0, hname, bytes, creator);
|
||||
htrAssetCount++;
|
||||
}
|
||||
// PV images (persistence-view previews; shared)
|
||||
foreach (var (pname, pentry) in allPvImages)
|
||||
{
|
||||
if (existingPvNames.Contains(pname)) continue;
|
||||
existingPvNames.Add(pname);
|
||||
var bytes = await ReadAllAsync(pentry);
|
||||
await WriteBlobAsync(0, pname, bytes, creator);
|
||||
pvImageCount++;
|
||||
}
|
||||
// Polaroids (placed in-world; watch fetches by bare hash).
|
||||
foreach (var (polName, polEntry) in allPolaroids)
|
||||
{
|
||||
if (existingPolaroidNames.Contains(polName)) continue;
|
||||
existingPolaroidNames.Add(polName);
|
||||
var bytes = await ReadAllAsync(polEntry);
|
||||
await WriteBlobAsync(0, polName, bytes, creator);
|
||||
polaroidCount++;
|
||||
}
|
||||
// Room-root opaque marker files (e.g. <hash>.meta — 6-byte
|
||||
// sentinel the exporter drops, purpose unclear but watching
|
||||
// for fetches just in case). Rename to room_<id>_<hash>.meta
|
||||
// so BlobRouter parks them in saves/room/<id>/ alongside the
|
||||
// versioned save snapshots — keeps every byte that belongs
|
||||
// to a specific room in one S3 folder.
|
||||
var rootMetaPrefix = $"{roomFolder}/";
|
||||
foreach (var (k, mentry) in entries.Where(kv =>
|
||||
kv.Key.StartsWith(rootMetaPrefix, StringComparison.OrdinalIgnoreCase)
|
||||
&& !kv.Key[rootMetaPrefix.Length..].Contains('/')
|
||||
&& kv.Key.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var originalName = Path.GetFileName(k);
|
||||
var fname = $"room_{roomId}_{originalName}";
|
||||
if (await db.RoomDataBlobs.AnyAsync(b => b.BlobName == fname)) continue;
|
||||
var bytes = await ReadAllAsync(mentry);
|
||||
await WriteBlobAsync(roomId, fname, bytes, creator);
|
||||
}
|
||||
|
||||
// Room image
|
||||
if (imageEntry is not null)
|
||||
{
|
||||
var bytes = await ReadAllAsync(imageEntry);
|
||||
var ext = Path.GetExtension(imageEntry.Name).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(ext)) ext = ".jpg";
|
||||
imageBlobName = $"image_room_{roomId}{ext}";
|
||||
await WriteBlobAsync(0, imageBlobName, bytes, creator);
|
||||
}
|
||||
|
||||
// Promo images (kept under their original hashed names so
|
||||
// the room's promo strip resolves them via img.* CDN).
|
||||
var promoPrefix = $"{roomFolder}/Promo/Image/";
|
||||
foreach (var (k, pentry) in entries.Where(kv => kv.Key.StartsWith(promoPrefix, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var fname = Path.GetFileName(k);
|
||||
if (await db.RoomDataBlobs.AnyAsync(b => b.BlobName == fname)) continue;
|
||||
var bytes = await ReadAllAsync(pentry);
|
||||
await WriteBlobAsync(0, fname, bytes, creator);
|
||||
}
|
||||
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = name,
|
||||
Description = details.Description?.Trim() ?? $"Imported from zip ({ordered.Count} scenes)",
|
||||
CreatorPlayerId = creator,
|
||||
ImageName = imageBlobName,
|
||||
State = details.State ?? 0,
|
||||
Accessibility = details.Accessibility ?? 1,
|
||||
IsAGRoom = false,
|
||||
IsDormRoom = details.IsDorm ?? false,
|
||||
CloningAllowed = details.CloningAllowed ?? true,
|
||||
SupportsLevelVoting = details.SupportsLevelVoting ?? false,
|
||||
SupportsVRLow = details.SupportsVRLow ?? true,
|
||||
SupportsMobile = details.SupportsMobile ?? false,
|
||||
SupportsScreens = details.SupportsScreens ?? true,
|
||||
SupportsWalkVR = details.SupportsWalkVR ?? true,
|
||||
SupportsTeleportVR = details.SupportsTeleportVR ?? true,
|
||||
AllowsJuniors = details.SupportsJuniors ?? true,
|
||||
RoomWarningMask = details.WarningMask ?? 0,
|
||||
CustomRoomWarning = details.CustomWarning ?? string.Empty,
|
||||
DisableMicAutoMute = details.DisableMicAutoMute ?? false,
|
||||
LocationReplicationId = entryLocation,
|
||||
TagsCsv = JoinTags(details.Tags),
|
||||
CheerCount = details.Stats?.CheerCount ?? 0,
|
||||
FavoriteCount = details.Stats?.FavoriteCount ?? 0,
|
||||
VisitCount = details.Stats?.VisitCount ?? 0,
|
||||
VisitorCount = details.Stats?.VisitorCount ?? 0,
|
||||
HotScore = 5.0,
|
||||
CurrentDataBlobName = MakeBlobName(entry.SceneName),
|
||||
CreatedAt = details.CreatedAt ?? DateTime.UtcNow,
|
||||
});
|
||||
|
||||
int idx = 0;
|
||||
foreach (var s in ordered)
|
||||
{
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{
|
||||
RoomId = roomId,
|
||||
OrderIndex = idx++,
|
||||
Name = s.SceneName,
|
||||
RoomSceneLocationId = s.Manifest.UnitySceneId ?? entryLocation,
|
||||
DataBlobName = MakeBlobName(s.SceneName),
|
||||
MaxPlayers = s.Manifest.MaxPlayers ?? 8,
|
||||
IsSandbox = s.Manifest.IsSandbox ?? false,
|
||||
CanMatchmakeInto = true,
|
||||
DataModifiedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
db.AdminActions.Add(new AdminActionEntity
|
||||
{
|
||||
AdminPlayerId = CurrentAdminId,
|
||||
Action = "zip_import_room",
|
||||
TargetType = "room",
|
||||
TargetId = roomId,
|
||||
Reason = $"name={name} scenes={ordered.Count} entry={entry.SceneName} src={entrySource} " +
|
||||
$"htr={htrAssetCount}new+{htrAlreadyInDbAtStart}existed " +
|
||||
$"pv={pvImageCount}new+{pvAlreadyInDbAtStart}existed " +
|
||||
$"polaroids={polaroidCount}new+{polaroidAlreadyInDbAtStart}existed",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
name,
|
||||
ok = true,
|
||||
roomId,
|
||||
folder = roomFolder,
|
||||
sceneCount = ordered.Count,
|
||||
entryScene = entry.SceneName,
|
||||
entryDetectionSource = entrySource,
|
||||
missingScenes = missingScenes.Count > 0 ? missingScenes : null,
|
||||
tags = details.Tags?.Select(t => t.Tag).Where(t => !string.IsNullOrWhiteSpace(t)).ToList(),
|
||||
imageBlob = string.IsNullOrEmpty(imageBlobName) ? null : imageBlobName,
|
||||
htrAssets = new
|
||||
{
|
||||
referenced = uniqueHtrNames.Count,
|
||||
newlyImported = htrAssetCount,
|
||||
alreadyInDb = htrAlreadyInDbAtStart,
|
||||
},
|
||||
pvImages = new
|
||||
{
|
||||
referenced = uniquePvNames.Count,
|
||||
newlyImported = pvImageCount,
|
||||
alreadyInDb = pvAlreadyInDbAtStart,
|
||||
},
|
||||
polaroids = new
|
||||
{
|
||||
referenced = uniquePolaroidNames.Count,
|
||||
newlyImported = polaroidCount,
|
||||
alreadyInDb = polaroidAlreadyInDbAtStart,
|
||||
},
|
||||
// Legacy fields kept for back-compat with anything that
|
||||
// already reads them; new code should prefer htrAssets /
|
||||
// pvImages / polaroids above.
|
||||
htrAssetsImported = htrAssetCount,
|
||||
pvImagesImported = pvImageCount,
|
||||
stats = new
|
||||
{
|
||||
cheers = details.Stats?.CheerCount ?? 0,
|
||||
favorites = details.Stats?.FavoriteCount ?? 0,
|
||||
visitors = details.Stats?.VisitorCount ?? 0,
|
||||
visits = details.Stats?.VisitCount ?? 0,
|
||||
},
|
||||
scenes = ordered.Select((s, i) => new
|
||||
{
|
||||
orderIndex = i,
|
||||
name = s.SceneName,
|
||||
blobName = MakeBlobName(s.SceneName),
|
||||
bytes = s.Bytes.Length,
|
||||
rawBytes = s.RawBytes,
|
||||
normalized = s.NormalizedOk,
|
||||
unitySceneId = s.Manifest.UnitySceneId,
|
||||
isSandbox = s.Manifest.IsSandbox,
|
||||
maxPlayers = s.Manifest.MaxPlayers,
|
||||
htrAssets = s.HtrAssets.Count,
|
||||
pvImages = s.PvImages.Count,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Per-invention import ─────────────────────────────────────────
|
||||
|
||||
private async Task<object> ImportInventionAsync(
|
||||
string folder,
|
||||
long creator,
|
||||
Dictionary<string, ZipArchiveEntry> entries)
|
||||
{
|
||||
// Invention.json
|
||||
var meta = await ReadJsonAsync<InventionJsonDto>(entries[$"{folder}/Invention.json"])
|
||||
?? new InventionJsonDto();
|
||||
InventionVersionDto version = entries.TryGetValue($"{folder}/InventionVersion.json", out var ve)
|
||||
? await ReadJsonAsync<InventionVersionDto>(ve) ?? new InventionVersionDto()
|
||||
: new InventionVersionDto();
|
||||
InventionDetailsDto extras = entries.TryGetValue($"{folder}/InventionDetails.json", out var de)
|
||||
? await ReadJsonAsync<InventionDetailsDto>(de) ?? new InventionDetailsDto()
|
||||
: new InventionDetailsDto();
|
||||
|
||||
var name = !string.IsNullOrWhiteSpace(meta.Name) ? meta.Name!.Trim() : Path.GetFileName(folder);
|
||||
|
||||
// Find the .inv blob — by manifest reference if present, else
|
||||
// first .inv file in the invention folder.
|
||||
ZipArchiveEntry? blobEntry = null;
|
||||
if (!string.IsNullOrWhiteSpace(version.BlobName)
|
||||
&& entries.TryGetValue($"{folder}/{version.BlobName}", out var byManifest))
|
||||
{
|
||||
blobEntry = byManifest;
|
||||
}
|
||||
else
|
||||
{
|
||||
blobEntry = entries.FirstOrDefault(kv =>
|
||||
kv.Key.StartsWith($"{folder}/", StringComparison.OrdinalIgnoreCase)
|
||||
&& kv.Key.EndsWith(".inv", StringComparison.OrdinalIgnoreCase)).Value;
|
||||
}
|
||||
if (blobEntry is null)
|
||||
return new { name, ok = false, error = "no_inv_blob", folder };
|
||||
|
||||
// Image (optional)
|
||||
ZipArchiveEntry? imageEntry = null;
|
||||
if (!string.IsNullOrWhiteSpace(meta.ImageName)
|
||||
&& entries.TryGetValue($"{folder}/{meta.ImageName}", out var byHash))
|
||||
{
|
||||
imageEntry = byHash;
|
||||
}
|
||||
else
|
||||
{
|
||||
imageEntry = entries.FirstOrDefault(kv =>
|
||||
kv.Key.StartsWith($"{folder}/InventionImage.", StringComparison.OrdinalIgnoreCase)).Value;
|
||||
}
|
||||
|
||||
var blobBytes = await ReadAllAsync(blobEntry);
|
||||
|
||||
var entity = new InventionEntity
|
||||
{
|
||||
CreatorPlayerId = creator,
|
||||
ReplicationId = !string.IsNullOrWhiteSpace(meta.ReplicationId)
|
||||
? meta.ReplicationId!.Trim()
|
||||
: Guid.NewGuid().ToString("N"),
|
||||
Name = name,
|
||||
Description = meta.Description?.Trim() ?? string.Empty,
|
||||
ImageName = string.Empty, // set below after we know the blob name
|
||||
IsAgInvention = meta.IsAgInvention ?? true,
|
||||
IsPublished = false,
|
||||
Permission = meta.Accessibility ?? 0,
|
||||
CreatorPermission = meta.CreatorPermission ?? 100,
|
||||
GeneralPermission = meta.GeneralPermission ?? 0,
|
||||
CurrentVersionNumber = meta.CurrentVersionNumber ?? version.VersionNumber ?? 1,
|
||||
NumPlayersHaveUsedInRoom = meta.NumPlayersHaveUsedInRoom ?? 0,
|
||||
CheerCount = meta.CheerCount ?? 0,
|
||||
CreationRoomId = meta.CreationRoomId,
|
||||
TagsCsv = JoinTags(extras.Tags),
|
||||
CreatedAt = meta.CreatedAt ?? DateTime.UtcNow,
|
||||
UpdatedAt = meta.ModifiedAt ?? DateTime.UtcNow,
|
||||
FirstPublishedAt = meta.FirstPublishedAt,
|
||||
};
|
||||
db.Inventions.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var blobName = $"invention_{entity.Id}_v{entity.CurrentVersionNumber}.dat";
|
||||
entity.CurrentBlobName = blobName;
|
||||
await WriteBlobAsync(0, blobName, blobBytes, creator);
|
||||
|
||||
string? imageBlobName = null;
|
||||
if (imageEntry is not null)
|
||||
{
|
||||
var bytes = await ReadAllAsync(imageEntry);
|
||||
var ext = Path.GetExtension(imageEntry.Name).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(ext)) ext = ".jpg";
|
||||
imageBlobName = $"image_invention_{entity.Id}{ext}";
|
||||
entity.ImageName = imageBlobName;
|
||||
await WriteBlobAsync(0, imageBlobName, bytes, creator);
|
||||
}
|
||||
|
||||
db.AdminActions.Add(new AdminActionEntity
|
||||
{
|
||||
AdminPlayerId = CurrentAdminId,
|
||||
Action = "zip_import_invention",
|
||||
TargetType = "invention",
|
||||
TargetId = entity.Id,
|
||||
Reason = $"name={name} blob={blobName} bytes={blobBytes.Length}",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation(
|
||||
"[zip-import] invention id={Id} name={Name} bytes={Bytes:N0} image={Image}",
|
||||
entity.Id, name, blobBytes.Length, imageBlobName ?? "(none)");
|
||||
|
||||
return new
|
||||
{
|
||||
name,
|
||||
ok = true,
|
||||
inventionId = entity.Id,
|
||||
folder,
|
||||
blobName,
|
||||
imageBlob = imageBlobName,
|
||||
bytes = blobBytes.Length,
|
||||
tags = extras.Tags?.Select(t => t.Tag).Where(t => !string.IsNullOrWhiteSpace(t)).ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private static string Normalize(string path) => path.Replace('\\', '/').TrimStart('/');
|
||||
|
||||
private static async Task<byte[]> ReadAllAsync(ZipArchiveEntry entry)
|
||||
{
|
||||
await using var s = entry.Open();
|
||||
using var ms = new MemoryStream();
|
||||
await s.CopyToAsync(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static async Task<T?> ReadJsonAsync<T>(ZipArchiveEntry entry)
|
||||
{
|
||||
await using var s = entry.Open();
|
||||
return await JsonSerializer.DeserializeAsync<T>(s, JsonOpts);
|
||||
}
|
||||
|
||||
private static string JoinTags(List<TagDto>? tags)
|
||||
{
|
||||
if (tags is null || tags.Count == 0) return "community";
|
||||
var values = tags
|
||||
.Select(t => t.Tag?.Trim())
|
||||
.Where(t => !string.IsNullOrEmpty(t))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return values.Count > 0 ? string.Join(',', values!) : "community";
|
||||
}
|
||||
|
||||
private sealed record ResolvedSubroom(
|
||||
SubRoomDto Manifest,
|
||||
string SceneName,
|
||||
byte[] Bytes,
|
||||
int RawBytes,
|
||||
bool NormalizedOk,
|
||||
List<(string Name, ZipArchiveEntry Entry)> HtrAssets,
|
||||
List<(string Name, ZipArchiveEntry Entry)> PvImages,
|
||||
List<(string Name, ZipArchiveEntry Entry)> Polaroids);
|
||||
|
||||
/// <summary>Decode RecNet's room-snapshot manifest <c>.meta</c> and
|
||||
/// return the entry-subroom index it encodes, or null if the file
|
||||
/// isn't shaped like one. The full <c>.proto</c> schema isn't part
|
||||
/// of the generated C# bindings, but the known-sample bytes are
|
||||
/// trivially small and parseable by hand:
|
||||
///
|
||||
/// 10 01 1A 02 08 01
|
||||
/// ┬──── ┬──────────
|
||||
/// │ │
|
||||
/// │ └─ field 3 (length-delimited), inner = { field 1 (varint) = 1 }
|
||||
/// └─ field 2 (varint) = 1
|
||||
///
|
||||
/// Empirically field 2 is the entry subroom INDEX into
|
||||
/// <c>RoomDetails.SubRooms[]</c> (0-based), and field 3's nested
|
||||
/// field 1 echoes the same value (probably "published version =
|
||||
/// 1"). We only need field 2 for the entry-selection decision.</summary>
|
||||
private static int? TryReadEntrySubroomIndexFromMeta(byte[] bytes)
|
||||
{
|
||||
if (bytes is null || bytes.Length == 0) return null;
|
||||
try
|
||||
{
|
||||
using var input = new CodedInputStream(bytes);
|
||||
while (!input.IsAtEnd)
|
||||
{
|
||||
var tag = input.ReadTag();
|
||||
var field = WireFormat.GetTagFieldNumber(tag);
|
||||
var wire = WireFormat.GetTagWireType(tag);
|
||||
if (field == 2 && wire == WireFormat.WireType.Varint)
|
||||
{
|
||||
return (int)input.ReadInt64();
|
||||
}
|
||||
input.SkipLastField();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// admin.*/api/admin/v1/storage/backfill — one-shot migrator that
|
||||
/// moves the legacy <c>RoomDataBlobs.Bytes</c> column out of the
|
||||
/// database and into S3 at the canonical key from
|
||||
/// <see cref="BlobRouter.Route"/>. After every row's bytes have been
|
||||
/// uploaded and verified, a follow-up EF migration drops the
|
||||
/// <c>Bytes</c> column entirely.
|
||||
///
|
||||
/// Why this exists: every controller that writes new content already
|
||||
/// targets S3 directly (the importer, photo upload, profile image,
|
||||
/// dorm save, room save, …). The <c>Bytes</c> column only holds
|
||||
/// pre-cutover data — rooms imported before the S3-only refactor
|
||||
/// landed. This endpoint drains that legacy column into S3 once and
|
||||
/// then we never look at it again.
|
||||
///
|
||||
/// Runs as a background task so the HTTP request returns immediately
|
||||
/// even for a multi-GB drain. Progress streams to the server log and
|
||||
/// to a small in-memory <see cref="BackfillStatus"/> snapshot the UI
|
||||
/// can poll. Idempotent — each row is checked against
|
||||
/// <see cref="IObjectStorage.ExistsAsync"/> first, and rows whose
|
||||
/// bytes are already in S3 get their DB <c>Bytes</c> cleared without
|
||||
/// re-upload.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/v1/storage")]
|
||||
[Authorize]
|
||||
[AdminOnly]
|
||||
public class StorageBackfillController(
|
||||
ILogger<StorageBackfillController> log,
|
||||
IServiceScopeFactory scopeFactory) : ControllerBase
|
||||
{
|
||||
private static readonly object _gate = new();
|
||||
private static BackfillStatus _status = BackfillStatus.Idle;
|
||||
|
||||
/// <summary>POST <c>/storage/backfill?dryRun=true</c> — start a
|
||||
/// drain pass. Returns 200 immediately; the actual work happens
|
||||
/// in a background task. Re-POSTing while running returns 409.
|
||||
/// Set <c>?dryRun=true</c> on the first pass to log every
|
||||
/// (row → bucket/key) decision without writing to S3 or clearing
|
||||
/// the DB <c>Bytes</c> column.</summary>
|
||||
[HttpPost("backfill")]
|
||||
public IActionResult Start([FromQuery] bool dryRun = false)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_status.Running)
|
||||
return Conflict(new { error = "already_running", since = _status.StartedAt });
|
||||
_status = BackfillStatus.Starting(dryRun);
|
||||
}
|
||||
_ = Task.Run(() => RunAsync(dryRun));
|
||||
return Ok(new { started = true, dryRun });
|
||||
}
|
||||
|
||||
/// <summary>GET <c>/storage/backfill/status</c> — current
|
||||
/// progress. Updated row-by-row during a run.</summary>
|
||||
[HttpGet("backfill/status")]
|
||||
public IActionResult Status() => Ok(_status);
|
||||
|
||||
private async Task RunAsync(bool dryRun)
|
||||
{
|
||||
var startedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
// Phase 1: pull the list of row ids that still have bytes.
|
||||
// Materialise just the ids (not the bytes) up-front so we
|
||||
// don't hold a reader open across per-row S3 PUTs — that
|
||||
// would conflict with the per-row ExecuteUpdateAsync on
|
||||
// Npgsql, which doesn't allow a second command while a
|
||||
// reader is mid-iteration. Bytes get fetched one row at a
|
||||
// time below so RAM only holds one blob at a time.
|
||||
// Filter on Bytes != null only — Npgsql can't translate
|
||||
// .Length on a byte[] property (it sees it as Enumerable.Any
|
||||
// and bails). Empty-byte-array rows don't exist in our
|
||||
// schema; rows either hold real bytes or NULL after backfill.
|
||||
// We still re-check Length on the materialised row before
|
||||
// uploading, defensive against any future client-evaluated
|
||||
// edge case.
|
||||
List<long> rowIds;
|
||||
long total;
|
||||
await using (var scope = scopeFactory.CreateAsyncScope())
|
||||
{
|
||||
var dbScoped = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
rowIds = await dbScoped.RoomDataBlobs
|
||||
.AsNoTracking()
|
||||
.Where(b => b.Bytes != null)
|
||||
.OrderBy(b => b.Id)
|
||||
.Select(b => b.Id)
|
||||
.ToListAsync();
|
||||
total = rowIds.Count;
|
||||
}
|
||||
log.LogInformation(
|
||||
"[backfill] starting — rowsWithBytes={Total} dryRun={DryRun}",
|
||||
total, dryRun);
|
||||
lock (_gate) _status = _status with { Total = total };
|
||||
|
||||
// Phase 2: per-row upload + clear. Each row gets its own
|
||||
// short-lived DbContext so the reader/writer conflict is
|
||||
// structurally impossible. Memory-bound at one row's
|
||||
// bytes plus query overhead.
|
||||
foreach (var rowId in rowIds)
|
||||
{
|
||||
await using var rowScope = scopeFactory.CreateAsyncScope();
|
||||
var rowDb = rowScope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var storageScoped = rowScope.ServiceProvider.GetRequiredService<IObjectStorage>();
|
||||
|
||||
var row = await rowDb.RoomDataBlobs
|
||||
.AsNoTracking()
|
||||
.Where(b => b.Id == rowId)
|
||||
.Select(b => new { b.Id, b.BlobName, b.Bytes })
|
||||
.FirstOrDefaultAsync();
|
||||
if (row is null || row.Bytes is null || row.Bytes.Length == 0)
|
||||
{
|
||||
Bump(s => s with { Skipped = s.Skipped + 1 });
|
||||
continue;
|
||||
}
|
||||
|
||||
var (bucket, key) = BlobRouter.Route(row.BlobName);
|
||||
try
|
||||
{
|
||||
if (dryRun)
|
||||
{
|
||||
log.LogInformation(
|
||||
"[backfill:dry] {Blob} → {Bucket}/{Key} ({Bytes} B)",
|
||||
row.BlobName, bucket, key, row.Bytes.Length);
|
||||
Bump(s => s with { Uploaded = s.Uploaded + 1, LastBlobName = row.BlobName });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Upload unconditionally (S3 PUT is idempotent /
|
||||
// overwriting). Verifying with ExistsAsync first
|
||||
// would let us skip already-mirrored rows, but a
|
||||
// mismatch between S3 and DB bytes (corrupted
|
||||
// earlier upload, partial backfill, etc.) would
|
||||
// leave the divergence in place. Re-uploading
|
||||
// guarantees S3 == DB before we clear DB.
|
||||
var contentType = MimeFromName(row.BlobName);
|
||||
// Per-row timeout: 4 minutes generous enough for a
|
||||
// 100 MB blob over a 5 Mbps origin link, well
|
||||
// inside the 5-minute S3 SDK ceiling. Without a
|
||||
// CT, a hung S3 connection could freeze the whole
|
||||
// drain indefinitely.
|
||||
using var putCt = new CancellationTokenSource(TimeSpan.FromMinutes(4));
|
||||
await storageScoped.PutAsync(bucket, key, row.Bytes, contentType, putCt.Token);
|
||||
|
||||
// SAFETY GATE: confirm S3 actually persisted the
|
||||
// object before dropping the DB copy. PutAsync
|
||||
// returning success isn't enough — some S3 impls
|
||||
// can ack a write that disappears (eventual
|
||||
// consistency, replication lag, write buffer
|
||||
// dropped on shutdown). HEAD costs nothing.
|
||||
using var headCt = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
if (!await storageScoped.ExistsAsync(bucket, key, headCt.Token))
|
||||
throw new InvalidOperationException(
|
||||
$"S3 PUT {bucket}/{key} returned success but ExistsAsync says no — refusing to clear DB bytes");
|
||||
|
||||
await rowDb.RoomDataBlobs
|
||||
.Where(b => b.Id == row.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(b => b.Bytes, (byte[]?)null));
|
||||
Bump(s => s with { Uploaded = s.Uploaded + 1, LastBlobName = row.BlobName });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.LogWarning(ex, "[backfill] failed {Blob} → {Bucket}/{Key} — DB bytes left intact",
|
||||
row.BlobName, bucket, key);
|
||||
Bump(s => s with { Failed = s.Failed + 1, LastBlobName = row.BlobName });
|
||||
}
|
||||
}
|
||||
|
||||
var elapsed = (int)(DateTime.UtcNow - startedAt).TotalMilliseconds;
|
||||
lock (_gate) _status = _status with
|
||||
{
|
||||
Running = false,
|
||||
FinishedAt = DateTime.UtcNow,
|
||||
ElapsedMs = elapsed,
|
||||
};
|
||||
log.LogInformation(
|
||||
"[backfill] finished — total={Total} uploaded={Up} alreadyMirrored={Already} skipped={Skip} failed={Fail} dryRun={Dry} elapsed={Ms}ms",
|
||||
_status.Total, _status.Uploaded, _status.AlreadyMirrored, _status.Skipped, _status.Failed, dryRun, elapsed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.LogError(ex, "[backfill] crashed");
|
||||
lock (_gate) _status = _status with { Running = false, FinishedAt = DateTime.UtcNow, Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
private static void Bump(Func<BackfillStatus, BackfillStatus> mutate)
|
||||
{
|
||||
lock (_gate) _status = mutate(_status);
|
||||
}
|
||||
|
||||
private static string MimeFromName(string name) => Path.GetExtension(name).ToLowerInvariant() switch
|
||||
{
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".mp4" => "video/mp4",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
|
||||
public sealed record BackfillStatus(
|
||||
bool Running,
|
||||
bool DryRun,
|
||||
DateTime? StartedAt,
|
||||
DateTime? FinishedAt,
|
||||
long Total,
|
||||
long Uploaded,
|
||||
long AlreadyMirrored,
|
||||
long Skipped,
|
||||
long Failed,
|
||||
string? LastBlobName,
|
||||
int? ElapsedMs,
|
||||
string? Error)
|
||||
{
|
||||
public static BackfillStatus Idle => new(false, false, null, null, 0, 0, 0, 0, 0, null, null, null);
|
||||
public static BackfillStatus Starting(bool dryRun) =>
|
||||
new(true, dryRun, DateTime.UtcNow, null, 0, 0, 0, 0, 0, null, null, null);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user