Compare commits

..

6 Commits

Author SHA1 Message Date
Alexa baa17c0fd6 Fix March admin API path in easy mode
Fixes #22 by keeping admin API calls under the /__dn/admin prefix when the March admin panel is served through easy mode.
2026-06-22 12:20:46 +03:00
Alexa 307ce93646 ci(release): grant contents:write to the release workflow (#17)
The workflow creates a GitHub release and uploads assets, which needs the contents:write token scope. Declaring it per-workflow (matching release-launcher.yml on main) lets the repo keep a read-only default GITHUB_TOKEN.
2026-06-16 11:56:35 +03:00
Alexa ecaf17b764 Merge pull request #2 from DorkSquadRR/modernize-march-startup
Update project links
2026-06-15 14:07:36 +03:00
Alexa 4985bb09d4 Update project links 2026-06-15 11:50:13 +03:00
Alexa a5b00980a5 Merge pull request #1 from DorkSquadRR/modernize-march-startup
Modernize March server startup
2026-06-15 11:44:06 +03:00
Alexa 32cd60792f Modernize March server startup 2026-06-15 11:36:36 +03:00
38 changed files with 1275 additions and 2299 deletions
+3
View File
@@ -11,6 +11,9 @@ on:
tags:
- 'v1-march-*'
permissions:
contents: write # create the GitHub release + upload assets
jobs:
release:
runs-on: windows-latest
+1 -1
View File
@@ -22,7 +22,7 @@ tmp/
## OS
.DS_Store
Thumbs.db
.backups
## Reverse-engineered artifacts (Il2CppDumper / Cpp2IL output, not for commit)
dump.cs
il2cpp.h
-143
View File
@@ -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;
}
+5 -32
View File
@@ -50,34 +50,13 @@ public class RecRoomConfig
[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();
public Dictionary<string, string> ConfigTable { get; set; } = [];
[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")]
@@ -98,17 +77,11 @@ public class PhotonConfig
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("MaxPlayersPerRoom")]
public int MaxPlayersPerRoom { get; set; } = 8;
[JsonPropertyName("PreferEmptyRoomsFrequency")]
public float PreferEmptyRoomsFrequency { get; set; } = 0.5f;
[JsonPropertyName("MinPlayersToStart")]
public int MinPlayersToStart { get; set; } = 1;
}
public class LevelProgressionEntry
-21
View File
@@ -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; }
}
-48
View File
@@ -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,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,
};
}
@@ -26,7 +26,6 @@ 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")]
@@ -40,7 +39,6 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
/// <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)
@@ -68,13 +68,6 @@ public class ImagesController(
[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()
{
@@ -71,37 +71,27 @@ public class InventoryController(
/// <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)
public async Task<IActionResult> UpdateEquipment([FromBody] EquipmentUpdateRequest req)
{
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);
// The slug for equipment is the modification GUID (or prefab
// name fallback when no mod is applied).
var slug = !string.IsNullOrWhiteSpace(req.ModificationGuid)
? req.ModificationGuid
: req.PrefabName;
if (string.IsNullOrWhiteSpace(slug)) return BadRequest("missing prefab/mod identifier");
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 row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug);
if (row is null)
{
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");
// Player doesn't own this; reject rather than silently
// creating ownership (avoids letting clients grant items
// to themselves).
return Forbid();
}
row.IsActive = req.Favorited;
await db.SaveChangesAsync();
return Ok(new { Updated = true });
}
@@ -92,7 +92,6 @@ public class MessagesController(
/// 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)
@@ -106,7 +105,6 @@ public class MessagesController(
}
[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(
@@ -1,76 +1,29 @@
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.
/// Legacy raw-WebSocket notification endpoint. Replaced by the SignalR
/// hub at <c>notify.{apex}/hub/v1</c>. Returns 410 Gone for any client
/// that still tries the old path.
/// </summary>
[ApiController]
[Route("api/[controller]/v2")]
public class NotificationController(
DomainConfig domain,
ILogger<NotificationController> log) : ControllerBase
public class NotificationController(DomainConfig domain) : ControllerBase
{
[HttpGet]
public async Task Connect()
public IActionResult Connect() => StatusCode(StatusCodes.Status410Gone, new
{
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;
}
error = "use_signalr_hub",
hint = $"connect to {domain.Sub("notify")}/hub/v1 instead",
});
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>
/// <summary>GET <c>api/notification/hub/v1</c> — returns the
/// SignalR hub URL the client should open. Without this the
/// watch falls back to its own default and may try the wrong
/// scheme. URL is built off the configured deployment apex
/// (DORKNET_DOMAIN).</summary>
[HttpGet("/api/notification/hub/v1")]
public IActionResult HubInfo() => Ok(new
{
@@ -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;
}
}
@@ -34,14 +34,9 @@ public class PlayerCheerController(
[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)
[FromForm(Name = "Type")] int type = 0)
{
// 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)
if (targetAccountId 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" });
@@ -70,11 +65,8 @@ public class PlayerCheerController(
/// 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)
public async Task<IActionResult> SetSelectedCheer([FromForm(Name = "Cheer")] int cheer = 0)
{
cheer = cheerCategory ?? cheer; // 2018 sends CheerCategory
var existing = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == Me && s.Key == "SelectedCheer");
if (existing is null)
@@ -1,104 +1,68 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Models.Auth;
using DorkNet.Models.Players;
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]
[Route("api/[controller]/v2")]
[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()
[HttpGet("me")]
public async Task<ActionResult<PlayerProfile>> GetSelf()
{
var player = await playerService.GetByIdAsync(CurrentPlayerId);
if (player is null) return NotFound();
return Ok(Player2018Mapper.From(player));
return Ok(PlayerService.ToProfile(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)
[HttpGet("{id:long}")]
public async Task<ActionResult<PlayerProfile>> GetById(long id)
{
var player = await playerService.GetByIdAsync(id);
if (player is null) return NotFound();
return Ok(Player2018Mapper.From(player));
return Ok(PlayerService.ToProfile(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)
[HttpGet("search")]
public async Task<ActionResult<List<PlayerProfile>>> Search([FromQuery] string query)
{
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());
if (string.IsNullOrWhiteSpace(query)) return Ok(new List<PlayerProfile>());
var results = await playerService.SearchAsync(query);
return Ok(results.Select(PlayerService.ToProfile).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)
[HttpPost("displayName")]
public async Task<ActionResult<PlayerProfile>> UpdateDisplayName([FromForm] string? DisplayName)
{
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());
}
if (string.IsNullOrWhiteSpace(DisplayName))
return BadRequest("DisplayName is required");
var updated = await playerService.UpdateDisplayNameAsync(CurrentPlayerId, DisplayName);
if (!updated) return NotFound();
// 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!));
await notificationService.NotifyAsync(CurrentPlayerId, Models.Notification.PushNotificationId.SubscriptionUpdateProfile, PlayerService.ToProfile(player!));
return Ok(PlayerService.ToProfile(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)
[HttpPut("{id:long}")]
public async Task<ActionResult<PlayerProfile>> UpdateProfile(long id, [FromBody] UpdateProfileRequest req)
{
await playerService.UpdateBioAsync(CurrentPlayerId, Bio ?? string.Empty);
var player = await playerService.GetByIdAsync(CurrentPlayerId);
return Ok(Player2018Mapper.From(player!));
}
if (id != CurrentPlayerId) return Forbid();
// 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));
if (req.DisplayName is not null)
await playerService.UpdateDisplayNameAsync(id, req.DisplayName);
if (req.Bio is not null)
await playerService.UpdateBioAsync(id, req.Bio);
var player = await playerService.GetByIdAsync(id);
return Ok(PlayerService.ToProfile(player!));
}
}
@@ -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);
}
}
@@ -61,14 +61,6 @@ public class RoomsController(
=> 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
@@ -120,7 +112,6 @@ public class RoomsController(
/// 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)
@@ -132,7 +123,6 @@ public class RoomsController(
/// <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)
@@ -911,15 +901,8 @@ public class RoomsController(
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()
@@ -929,7 +912,6 @@ public class RoomsController(
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]
@@ -943,7 +925,6 @@ public class RoomsController(
[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>());
@@ -991,25 +972,6 @@ public class RoomsController(
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>
@@ -51,7 +51,6 @@ public class StorefrontsBuyController(
public int GiftContext { get; set; }
}
[HttpPost("api/storefronts/v1/buyItem")]
[HttpPost("api/storefronts/v2/buyItem")]
public async Task<IActionResult> BuyItem([FromBody] RequestPurchaseItemDto req)
{
@@ -171,7 +170,6 @@ public class StorefrontsBuyController(
isGift ? null : gift.Id));
}
[HttpPost("api/storefronts/v1/buyTier")]
[HttpPost("api/storefronts/v2/buyTier")]
public async Task<IActionResult> BuyTier([FromBody] RequestPurchaseItemDto req)
{
@@ -187,7 +185,6 @@ public class StorefrontsBuyController(
return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType));
}
[HttpPost("api/storefronts/v1/buyElite")]
[HttpPost("api/storefronts/v2/buyElite")]
public async Task<IActionResult> BuyElite([FromBody] RequestPurchaseItemDto req)
{
@@ -116,16 +116,15 @@ public class StorefrontsController(
{
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
return Ok(new[]
{
CurrencyType = currencyType,
BalanceType = 0,
Balance = balance,
Platform = 0,
new
{
CurrencyType = currencyType,
BalanceType = 0,
Balance = balance,
Platform = 0,
},
});
}
@@ -6,16 +6,7 @@ namespace DorkNet.Server.Controllers.API.Version;
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 };
private static readonly object ValidForPlay = new { VersionStatus = 0 };
[HttpGet("api/version/v1")]
[HttpGet("api/version")]
+6 -1361
View File
@@ -1,1372 +1,17 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
using Serilog;
using Serilog.Formatting.Compact;
using StackExchange.Redis;
using System.Text;
using DorkNet.Server.Startup;
var builder = WebApplication.CreateBuilder(args);
// Personal/local-machine override layer. Loaded last so it wins over both
// appsettings.json and appsettings.{Environment}.json. Gitignored — use
// this for Photon AppId, JWT secret, and anything you don't want committed.
// appsettings.json and appsettings.{Environment}.json. Gitignored; use this
// for Photon AppId, JWT secret, and anything you don't want committed.
builder.Configuration.AddJsonFile("appsettings.Local.json",
optional: true, reloadOnChange: true);
// ── Logging ──────────────────────────────────────────────────────────────────
// Production: structured JSON (one JSON object per line) so Coolify's log
// pipeline / future Loki ingest can parse fields without regex.
// Development: human-readable text so the local console stays scannable.
// The JSON output is the same shape no matter the environment, just
// rendered differently — log keys like {Tag}, {Status}, {Method} stay
// queryable when we ship to a structured log store later.
builder.Host.UseSerilog((ctx, services, cfg) =>
{
cfg.ReadFrom.Configuration(ctx.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext();
if (ctx.HostingEnvironment.IsDevelopment())
cfg.WriteTo.Console();
else
cfg.WriteTo.Console(new RenderedCompactJsonFormatter());
});
// ── Database ──────────────────────────────────────────────────────────────────
// Provider switch — `Database:Provider` config key (or DATABASE__PROVIDER env var)
// chooses sqlite (default for local dev) or postgres (production). Each provider
// gets its own migrations assembly path so we can ship both schemas without
// EF Core complaining about provider-specific column types in the snapshot.
var dbProvider = (builder.Configuration["Database:Provider"] ?? "sqlite").ToLowerInvariant();
builder.Services.AddDbContext<DorkNetDbContext>(opt =>
{
// EF Core 9 throws on every connection if the model drifts from
// the migrations snapshot. We track migrations + the snapshot
// by hand for data-only fixes, so this warning is noise — Coolify
// and local-dev databases still apply real migrations from disk.
opt.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning));
if (dbProvider == "postgres")
{
// ConnectionStrings:Default is the standard ASP.NET Core key; env var
// form is ConnectionStrings__Default. Coolify sets this when you link
// the managed Postgres service.
var conn = builder.Configuration.GetConnectionString("Default")
?? throw new InvalidOperationException(
"Database:Provider=postgres but ConnectionStrings:Default is not set. " +
"Set the ConnectionStrings__Default env var or `ConnectionStrings:Default` " +
"in appsettings.Local.json.");
opt.UseNpgsql(conn, npg => npg.MigrationsAssembly("DorkNet.Server"));
}
else
{
// Honor an explicit path the host can set via env var
// (Database__SqlitePath) or appsettings (Database:SqlitePath).
// The Windows launcher sets this to %APPDATA%\DorkNet\dorknet.db
// so server installs don't accumulate state under <bin>\data\.
// Existing Docker / standalone deploys that mounted <bin>\data\
// keep working because that's still the fallback.
var dbPath = builder.Configuration["Database:SqlitePath"];
if (string.IsNullOrWhiteSpace(dbPath))
dbPath = Path.Combine(AppContext.BaseDirectory, "data", "dorknet.db");
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
opt.UseSqlite($"Data Source={dbPath}", lite => lite.MigrationsAssembly("DorkNet.Server"));
}
});
// ── Domain ────────────────────────────────────────────────────────────────────
// Single source of truth for the deployment apex. Read once at startup
// from DORKNET_DOMAIN (env-var) or Domain:Apex (config), defaulting to
// localhost so existing deploys keep working without setting the env var.
// Replaces the per-controller [Host(...)] filters that hard-coded
// rec.net/localhost pairs — the singleton DomainConfig is injected
// anywhere code needs to build outbound URLs (img.{apex}/...,
// cdn.{apex}/..., notify.{apex}/...). The HostFilteringMiddleware
// allowed-hosts list is derived from {apex} + *.{apex} so every
// subdomain under the configured apex reaches the controllers; the
// [Host] attributes previously acted as both routing keys AND host
// filters, but now they're gone and HostFiltering does the latter alone.
var apex =
builder.Configuration["Domain:Apex"]
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN")
?? "localhost";
var domainScheme =
builder.Configuration["Domain:Scheme"]
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN_SCHEME")
?? "https";
var domainPort =
builder.Configuration["Domain:Port"]
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN_PORT");
var singleOriginBaseUrl =
builder.Configuration["Domain:SingleOriginBaseUrl"]
?? Environment.GetEnvironmentVariable("DORKNET_SINGLE_ORIGIN_BASE_URL");
builder.Services.AddSingleton(new DomainConfig(apex, domainScheme, domainPort, singleOriginBaseUrl));
builder.Services.Configure<Microsoft.AspNetCore.HostFiltering.HostFilteringOptions>(opt =>
{
opt.AllowedHosts = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" };
opt.AllowEmptyHosts = true;
opt.IncludeFailureMessage = true;
});
Console.WriteLine($"[domain] apex={apex}, scheme={domainScheme}, port={domainPort}, singleOrigin={singleOriginBaseUrl}, allowedHosts=[{apex}, *.{apex}, localhost]");
// ── Services ──────────────────────────────────────────────────────────────────
builder.Services.AddScoped<PlayerService>();
builder.Services.AddScoped<ConfigService>();
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<RoomService>();
builder.Services.AddScoped<LevelService>();
builder.Services.AddScoped<StoreService>();
// Singleton — the protobuf blob is identical for every room and built
// once at startup; no need to rebuild per request.
builder.Services.AddSingleton<RoomDataBlobService>();
builder.Services.AddSingleton<RoomBlobNormalizerService>();
// IHttpClientFactory + the .htr asset mirror that the admin Import
// Room endpoint kicks off as fire-and-forget after a successful upload.
builder.Services.AddHttpClient();
builder.Services.AddSingleton<HtrAssetMirrorService>();
// Scoped (per-request) — these services hold DbContext references and
// must share the request's scope to participate in the request's
// transaction lifecycle. Pre-PR-3 they were Singleton with in-process
// ConcurrentDictionary state; PR 3 moved their state to Postgres and
// they now resolve from DI per-request like any other DbContext consumer.
builder.Services.AddScoped<GameSessionService>();
builder.Services.AddScoped<PrivateInstanceService>();
builder.Services.AddScoped<CommunityBoardService>();
builder.Services.AddScoped<ServerSettingsService>();
builder.Services.AddScoped<SignupCodeService>();
// Singletons — these own connectionless state (Redis-backed or
// process-local) and don't need a per-request scope.
builder.Services.AddSingleton<NotificationService>();
builder.Services.AddSingleton<OrphanAccountTracker>();
builder.Services.AddSingleton<PlayerPresenceService>();
builder.Services.AddSingleton<OnlinePresenceService>();
// Per-player rolling request log used by the admin UI's "Player logs"
// tab. Singleton because it's stateless beyond Redis + an in-process
// fallback queue; no DbContext involved.
builder.Services.AddSingleton<PlayerLogService>();
// S3-compatible object storage (Garage in production, MinIO/disk in dev)
// — holds profile images + room-blob bytes. Stateless wrapper around
// the AWS SDK; safe as a singleton.
builder.Services.AddSingleton<IObjectStorage, ObjectStorageService>();
builder.Services.AddSingleton<ImageSignatureService>();
// ── Redis ─────────────────────────────────────────────────────────────────────
// IConnectionMultiplexer is registered ONLY when a connection string is
// present. The Redis-backed services check for it via DI and fall back to
// process-local state when null. Local dev can run without Redis; Coolify
// production sets ConnectionStrings__Redis on the linked service.
//
// Coolify hands you a URI like
// redis://default:PASSWORD@host:6379/0
// but StackExchange.Redis.Connect(string) doesn't reliably handle the
// user-info segment of a URI (the "default:PASSWORD@" part) — passing
// the URI verbatim ends up with no password applied to the
// ConfigurationOptions and Connect throws a misleading
// "Error connecting right now". So we detect a URI prefix and translate
// to SE.Redis's native config string ("host:port,password=...,user=...,
// abortConnect=false") before handing off.
var redisConn = builder.Configuration.GetConnectionString("Redis");
if (!string.IsNullOrWhiteSpace(redisConn))
{
var seRedisConfig = NormalizeRedisConn(redisConn);
var mux = ConnectionMultiplexer.Connect(seRedisConfig);
builder.Services.AddSingleton<IConnectionMultiplexer>(mux);
}
static string NormalizeRedisConn(string raw)
{
if (!raw.StartsWith("redis://", StringComparison.OrdinalIgnoreCase) &&
!raw.StartsWith("rediss://", StringComparison.OrdinalIgnoreCase))
return raw; // already SE.Redis native config string
var uri = new Uri(raw);
var host = uri.Host;
var port = uri.Port > 0 ? uri.Port : 6379;
string? user = null, pass = null;
if (!string.IsNullOrEmpty(uri.UserInfo))
{
var parts = uri.UserInfo.Split(':', 2);
user = Uri.UnescapeDataString(parts[0]);
if (parts.Length == 2) pass = Uri.UnescapeDataString(parts[1]);
}
var opts = new StackExchange.Redis.ConfigurationOptions
{
EndPoints = { { host, port } },
AbortOnConnectFail = false,
Ssl = raw.StartsWith("rediss://", StringComparison.OrdinalIgnoreCase),
};
if (!string.IsNullOrEmpty(user)) opts.User = user;
if (!string.IsNullOrEmpty(pass)) opts.Password = pass;
return opts.ToString();
}
// ── JWT Auth ──────────────────────────────────────────────────────────────────
// Resolution order: env-var first (for production / containerised deploys),
// then `Jwt:Secret` from any configuration source (appsettings.Local.json
// is the same-machine convenience path the patcher script writes to).
// Never commit a real secret to appsettings.json — the gitignored
// appsettings.Local.json or the env-var are the only legitimate sources.
// DORKNET_JWT_SECRET is the canonical env var name; RECNET_JWT_SECRET
// kept for backward compat with older Coolify configs (will warn at
// startup, can be removed once everyone has migrated). AuthService
// has the matching fallback so signing + validation use the same key.
var jwtSecret = Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
?? builder.Configuration["Jwt:Secret"]
?? throw new InvalidOperationException(
"JWT secret not configured. Set the DORKNET_JWT_SECRET env var or " +
"add `Jwt:Secret` to appsettings.Local.json.");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
{
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
ValidateIssuer = true,
ValidIssuer = "https://api.rec.net/",
ValidateAudience = false,
ClockSkew = TimeSpan.Zero,
};
// SignalR over WebSocket: BestHTTP.SignalRCore (the 2020 watch's
// hub client) negotiates with Authorization: Bearer, but the
// WebSocket upgrade leg isn't guaranteed to keep the header
// depending on the proxy in front of us — Cloudflare Tunnel
// passes through, but Coolify's Caddy / browsers don't. The
// hub client falls back to ?access_token=<jwt> on the WS URL,
// which we extract here for any /hub/* path so the connection
// authenticates the same way negotiate did. Without this, the
// watch's RecNet.Notifications connect throws "Failed to
// connect to RecNet Notifications" mid-login.
opt.Events = new Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerEvents
{
OnMessageReceived = ctx =>
{
var accessToken = ctx.Request.Query["access_token"];
var path = ctx.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub"))
ctx.Token = accessToken;
return Task.CompletedTask;
},
};
});
builder.Services.AddAuthorization();
// Use C# property names verbatim — [JsonPropertyName] attributes on each
// model give the per-key casing the client's LitJson Util.GetKey calls
// expect (a mix of lowercase, camelCase and snake_case; verified by
// disassembling each Deserialize body). Don't apply a global naming policy
// or it will override the explicit per-key attributes.
builder.Services.AddControllers().AddJsonOptions(opt =>
opt.JsonSerializerOptions.PropertyNamingPolicy = null);
// Kestrel-level limits — the defaults trim long-lived sockets earlier
// than our SignalR ping interval allows. Specifically:
// * KeepAliveTimeout (HTTP keep-alive between requests, default 130s)
// applies to upgraded WebSocket connections in some hosting paths;
// bump to 10m so it never beats SignalR's own ping cadence.
// * RequestHeadersTimeout (default 30s) is for the initial upgrade
// handshake only — leave it.
// * MinRequestBodyDataRate / MinResponseDataRate enforce a minimum
// bytes/sec on streaming bodies; for chunked-upload room imports
// that's been the cause of mysterious abort-mid-write errors on
// slow links. Disabling them is safer than risking false-positive
// "client too slow" closures.
builder.WebHost.ConfigureKestrel(kopts =>
{
kopts.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(10);
kopts.Limits.MinRequestBodyDataRate = null;
kopts.Limits.MinResponseDataRate = null;
});
// SignalR — drives notify.rec.net/hub/v1, consumed client-side by
// BestHTTP.SignalRCore.HubConnection. The framework handles the
// /negotiate POST automatically. When Redis is configured, attach
// the StackExchange.Redis backplane so groups (player:N) fan out
// across replicas — without it, player A connected to replica 1
// would never receive a notification published from replica 2.
//
// Keepalive cadence: server pings every 10s, gives the client 60s
// to ack before declaring the connection dead. The 10s ping forces
// frame traffic well below every proxy timeout we've seen in the
// path (Cloudflare edge ~100s, cloudflared 600s, Coolify/Traefik
// 180s, Kestrel 130s). 60s client timeout is double the ping
// interval so a single dropped ping doesn't kill the connection.
var signalR = builder.Services.AddSignalR(opts =>
{
opts.KeepAliveInterval = TimeSpan.FromSeconds(10);
opts.ClientTimeoutInterval = TimeSpan.FromSeconds(60);
opts.HandshakeTimeout = TimeSpan.FromSeconds(15);
// Allow large gift / community-board payloads (default 32 KB
// truncates anything bigger and disconnects the client).
opts.MaximumReceiveMessageSize = 1024 * 1024;
// Surface transport errors so we can tell whether a disconnect
// was clean (Traefik idleTimeout) vs framing-level (cloudflared
// chunked-encoding mismatch).
opts.EnableDetailedErrors = true;
});
if (!string.IsNullOrWhiteSpace(redisConn))
signalR.AddStackExchangeRedis(NormalizeRedisConn(redisConn), opts =>
{
// Channel prefix keeps the SignalR backplane's pub/sub channels
// namespaced from any other Redis tenants in the same instance.
opts.Configuration.ChannelPrefix = RedisChannel.Literal("dorknet-signalr");
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.AddDorkNetServices();
var app = builder.Build();
// DomainConfig is the single source of truth for the deployment apex
// (DORKNET_DOMAIN). Resolve it once at startup; every downstream
// host-mount / redirect / hub binding below uses this instead of
// hardcoded rec.net/localhost literals.
var domainCfg = app.Services.GetRequiredService<DomainConfig>();
// Easy Launcher single-origin mode. localtunnel gives us one public
// hostname, so the launcher name-server emits URLs like
// https://abc.loca.lt/__dn/api/...; this middleware maps those path
// prefixes back into the same internal host layout used by advanced
// wildcard deployments. If Domain:SingleOriginBaseUrl is unset this is
// inert, preserving the normal subdomain behavior.
app.Use(async (ctx, next) =>
{
if (domainCfg.SingleOriginEnabled &&
TryMapSingleOriginPath(ctx.Request.Path, out var service, out var rest))
{
ctx.Request.Host = new HostString(service == "www" ? domainCfg.Apex : domainCfg.Sub(service));
ctx.Request.Path = string.IsNullOrEmpty(rest) ? "/" : rest;
}
await next();
});
// Keep endpoint selection after the single-origin path rewrite. Without an
// explicit routing point here, WebApplication can auto-insert routing before
// the rewrite middleware, causing /__dn/api/... requests to stay route misses
// even though the trace logs show the rewritten /api/... path.
app.UseRouting();
// Request/response tracing — emits one structured log per non-health request,
// with the response body included for any 4xx/5xx so we can see exactly what
// the game is asking for. This runs early enough to catch route misses,
// auth failures, storage/CDN host traffic, and controller responses.
var reqLogger = app.Services.GetRequiredService<ILoggerFactory>()
.CreateLogger("DorkNet.Server.HttpTrace");
var playerLog = app.Services.GetRequiredService<DorkNet.Server.Services.PlayerLogService>();
app.Use(async (ctx, next) =>
{
if (IsHealthProbe(ctx.Request))
{
await next();
return;
}
// WebSocket upgrades take over the underlying connection — wrapping the
// response body in a MemoryStream confuses the upgrade handshake and
// prevents SignalR's WebSocket transport from working. Skip tracing for
// upgrade requests and let them flow through unmodified.
if (ctx.WebSockets.IsWebSocketRequest)
{
reqLogger.LogInformation(
"[ws] {Method} {Host}{Path}{Query}",
ctx.Request.Method, ctx.Request.Host, ctx.Request.Path,
ctx.Request.QueryString.Value ?? "");
await next();
return;
}
var host = ctx.Request.Host.Host;
var path = ctx.Request.Path.Value ?? "";
var isStorageLike = host.StartsWith("storage.", StringComparison.OrdinalIgnoreCase)
|| host.StartsWith("cdn.", StringComparison.OrdinalIgnoreCase)
|| path.Contains("upload", StringComparison.OrdinalIgnoreCase);
if (isStorageLike)
{
reqLogger.LogInformation(
"[req:start] {Method} {Host}{Path}{Query} contentType={ContentType} contentLength={ContentLength}",
ctx.Request.Method, ctx.Request.Host, ctx.Request.Path,
ctx.Request.QueryString.Value ?? "", ctx.Request.ContentType ?? "",
ctx.Request.ContentLength);
}
var sw = System.Diagnostics.Stopwatch.StartNew();
var originalBody = ctx.Response.Body;
var captureResponse = ShouldCaptureResponse(ctx.Request);
MemoryStream? capture = null;
if (captureResponse)
{
capture = new MemoryStream();
ctx.Response.Body = capture;
}
string? requestBody = null;
if (ctx.Request.ContentLength is > 0 and < 4096 && IsTextLikeRequest(ctx.Request.ContentType))
{
ctx.Request.EnableBuffering();
using var reader = new StreamReader(ctx.Request.Body, leaveOpen: true);
requestBody = await reader.ReadToEndAsync();
ctx.Request.Body.Position = 0;
}
Exception? thrown = null;
try
{
await next();
}
catch (Exception ex)
{
thrown = ex;
throw;
}
finally
{
sw.Stop();
var status = thrown is null ? ctx.Response.StatusCode : 500;
var responseBody = "";
if (capture is not null)
{
try
{
capture.Seek(0, SeekOrigin.Begin);
if (ShouldReadResponseBody(ctx.Response.ContentType, status))
responseBody = await new StreamReader(capture, leaveOpen: true).ReadToEndAsync();
capture.Seek(0, SeekOrigin.Begin);
await capture.CopyToAsync(originalBody);
}
finally
{
ctx.Response.Body = originalBody;
await capture.DisposeAsync();
}
}
var trimmedRequest = requestBody is null ? null
: (requestBody.Length > 200 ? requestBody[..200] + "..." : requestBody);
var trimmedResponse = responseBody.Length > 300 ? responseBody[..300] + "..." : responseBody;
var level = thrown is not null || status >= 500 ? LogLevel.Error
: status >= 400 ? LogLevel.Warning
: LogLevel.Information;
reqLogger.Log(level, thrown,
"[req] {Status} {Method} {Host}{Path}{Query} {ElapsedMs}ms contentType={ContentType} contentLength={ContentLength} req={ReqBody} resp={RespBody}",
status, ctx.Request.Method, ctx.Request.Host, ctx.Request.Path,
ctx.Request.QueryString.Value ?? "", sw.ElapsedMilliseconds,
ctx.Request.ContentType ?? "", ctx.Request.ContentLength,
trimmedRequest ?? "", trimmedResponse);
// Per-player log — only record when we know who made the call.
var pid = DorkNet.Server.Auth.ControllerBaseExtensions.CurrentPlayerId(ctx.User);
if (pid is long playerId)
{
playerLog.Record(new DorkNet.Server.Services.PlayerLogEntry
{
Timestamp = DateTime.UtcNow,
PlayerId = playerId,
Method = ctx.Request.Method,
Host = ctx.Request.Host.Host,
Path = ctx.Request.Path.Value ?? string.Empty,
Query = ctx.Request.QueryString.Value ?? string.Empty,
Status = status,
ElapsedMs = sw.ElapsedMilliseconds,
ReqBody = trimmedRequest,
RespBody = trimmedResponse,
});
}
}
});
// ── Create/repair DB ──────────────────────────────────────────────────────────
// Local launcher installs use SQLite as an appliance database. For that path,
// create the current EF model directly instead of replaying the migration chain;
// then run small idempotent repair patches for DBs made by older launcher builds.
// Hosted Postgres keeps its existing EnsureCreated + explicit patch path below.
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
if (db.Database.IsSqlite())
{
db.Database.EnsureCreated();
ApplySqliteCompatibilityPatches(db);
// New tables that post-date this DB's EnsureCreated snapshot —
// EnsureCreated never revisits an existing file, so create them
// idempotently (matches the Postgres patch block below).
await db.Database.ExecuteSqlRawAsync(
@"CREATE TABLE IF NOT EXISTS ""SignupCodes"" (
""Id"" INTEGER NOT NULL CONSTRAINT ""PK_SignupCodes"" PRIMARY KEY AUTOINCREMENT,
""Code"" TEXT NOT NULL,
""Descriptor"" TEXT NOT NULL DEFAULT '',
""CreatedByPlayerId"" INTEGER NOT NULL DEFAULT 0,
""CreatedAt"" TEXT NOT NULL,
""ExpiresAt"" TEXT NULL,
""RedeemedByPlayerId"" INTEGER NULL,
""RedeemedAt"" TEXT NULL,
""Revoked"" INTEGER NOT NULL DEFAULT 0
);");
await db.Database.ExecuteSqlRawAsync(
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_SignupCodes_Code"" ON ""SignupCodes"" (""Code"");");
await db.Database.ExecuteSqlRawAsync(
@"CREATE TABLE IF NOT EXISTS ""PendingDevices"" (
""Id"" INTEGER NOT NULL CONSTRAINT ""PK_PendingDevices"" PRIMARY KEY AUTOINCREMENT,
""DeviceId"" TEXT NOT NULL,
""Platform"" INTEGER NOT NULL DEFAULT 0,
""PlatformId"" TEXT NOT NULL DEFAULT '',
""LastIp"" TEXT NULL,
""FirstSeenAt"" TEXT NOT NULL,
""LastSeenAt"" TEXT NOT NULL
);");
await db.Database.ExecuteSqlRawAsync(
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_PendingDevices_DeviceId"" ON ""PendingDevices"" (""DeviceId"");");
}
else
{
// Postgres path: schema generated from the EF Core model via
// EnsureCreated. Wrapped in a transaction-scoped advisory lock
// so concurrent replicas booting against the same database
// don't race on CREATE TABLE — only the first replica to take
// the lock runs EnsureCreated, the rest see "schema already
// exists" and proceed.
//
// Lock key 0x444F524B (ascii "DORK") is hard-coded; it just
// needs to be the same int64 across replicas and not collide
// with anything else in the database. pg_advisory_xact_lock
// releases automatically on COMMIT, so a crashed replica can't
// wedge the next boot.
//
// Future schema changes will swap EnsureCreated for
// db.Database.Migrate() once we cut a proper Postgres
// migrations set; the advisory-lock wrapping stays the same.
var conn = db.Database.GetDbConnection();
// Retry initial Postgres connect — on container-orchestrated boots
// (Coolify, docker-compose) the Postgres service's DNS often isn't
// resolvable yet when this app starts, surfacing as SocketException
// errno=11 EAGAIN from Npgsql's Dns.GetHostEntryOrAddressesCore.
// Postgres itself can also be up-but-not-accepting-connections for
// a few seconds while it replays WAL. 15 attempts × ~2s backoff
// covers the typical 5-30s warmup window without looping forever
// on a permanently broken config.
var connectLogger = app.Services.GetRequiredService<ILoggerFactory>()
.CreateLogger("DorkNet.Server.Bootstrap");
for (var attempt = 1; ; attempt++)
{
try { await conn.OpenAsync(); break; }
catch (Exception ex) when (attempt < 15 &&
(ex is System.Net.Sockets.SocketException
|| ex is Npgsql.NpgsqlException
|| ex is System.Data.Common.DbException))
{
var delay = TimeSpan.FromSeconds(Math.Min(attempt, 5));
connectLogger.LogWarning(
"[bootstrap] Postgres connect attempt {Attempt}/15 failed ({Type}: {Message}); retrying in {Delay}s",
attempt, ex.GetType().Name, ex.Message, delay.TotalSeconds);
await Task.Delay(delay);
}
}
await using var tx = await conn.BeginTransactionAsync();
await using (var lockCmd = conn.CreateCommand())
{
lockCmd.Transaction = tx;
lockCmd.CommandText = "SELECT pg_advisory_xact_lock(1146246987);";
await lockCmd.ExecuteNonQueryAsync();
}
db.Database.EnsureCreated();
await tx.CommitAsync();
// ── Post-EnsureCreated schema patches (Postgres only) ─────────
// EnsureCreated only fires once per database — it lays down the
// initial schema and never revisits it on later deploys. Any
// model change after the first boot (column added, NOT NULL
// dropped, etc.) leaves prod stuck on the bootstrap schema.
// EF migrations in this codebase are SQLite-only; Postgres
// schema drift has to be patched explicitly here.
//
// Each patch is idempotent — Postgres treats redundant
// ALTER COLUMN statements as no-ops, so safe on every boot.
// Add new patches at the bottom of this block; never remove
// older ones (older databases still need them when they catch
// up).
var bootstrapLogger = app.Services.GetRequiredService<ILoggerFactory>()
.CreateLogger("DorkNet.Server.Bootstrap");
async Task RunPatchAsync(string label, string sql)
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
try { await cmd.ExecuteNonQueryAsync(); }
catch (Exception ex)
{
bootstrapLogger.LogWarning(ex, "[schema-patch] {Label} failed", label);
}
}
// 2026-05-16 — RoomDataBlobs.Bytes nullable. The storage
// backfill (admin → Storage panel) needs to UPDATE this
// column to NULL after streaming the bytes to S3.
await RunPatchAsync("RoomDataBlobs.Bytes nullable",
@"ALTER TABLE ""RoomDataBlobs"" ALTER COLUMN ""Bytes"" DROP NOT NULL;");
// 2026-05-17 — ChatThreads / ChatThreadMembers added for
// group chats + per-thread snooze/last-read pointers. Both
// tables are EnsureCreated-skipped on existing DBs; recreate
// them idempotently here. Column order + types must match
// the entity classes (see Data/Entities/ChatThread*.cs).
await RunPatchAsync("ChatThreads table",
@"CREATE TABLE IF NOT EXISTS ""ChatThreads"" (
""Id"" bigserial PRIMARY KEY,
""ThreadKey"" varchar(96) NOT NULL,
""Name"" varchar(128) NOT NULL DEFAULT '',
""CreatorPlayerId"" bigint NOT NULL,
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
);");
await RunPatchAsync("ChatThreads ThreadKey unique index",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ChatThreads_ThreadKey""
ON ""ChatThreads"" (""ThreadKey"");");
await RunPatchAsync("ChatThreadMembers table",
@"CREATE TABLE IF NOT EXISTS ""ChatThreadMembers"" (
""Id"" bigserial PRIMARY KEY,
""ThreadKey"" text NOT NULL,
""PlayerId"" bigint NOT NULL,
""JoinedAt"" timestamp with time zone NOT NULL DEFAULT now(),
""SnoozeUntil"" timestamp with time zone NULL,
""LastReadMessageId"" bigint NULL
);");
await RunPatchAsync("ChatThreadMembers (ThreadKey, PlayerId) unique index",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ChatThreadMembers_ThreadKey_PlayerId""
ON ""ChatThreadMembers"" (""ThreadKey"", ""PlayerId"");");
await RunPatchAsync("ChatThreadMembers PlayerId index",
@"CREATE INDEX IF NOT EXISTS ""IX_ChatThreadMembers_PlayerId""
ON ""ChatThreadMembers"" (""PlayerId"");");
// 2026-05-17 — Relationships favorite/mute/ignore columns
// for v1/favorite, v1/mute, v1/ignore persistence. Default
// false on every row; existing relationships keep no
// preference set.
await RunPatchAsync("Relationships.Favorited column",
@"ALTER TABLE ""Relationships""
ADD COLUMN IF NOT EXISTS ""Favorited"" boolean NOT NULL DEFAULT false;");
await RunPatchAsync("Relationships.Muted column",
@"ALTER TABLE ""Relationships""
ADD COLUMN IF NOT EXISTS ""Muted"" boolean NOT NULL DEFAULT false;");
await RunPatchAsync("Relationships.Ignored column",
@"ALTER TABLE ""Relationships""
ADD COLUMN IF NOT EXISTS ""Ignored"" boolean NOT NULL DEFAULT false;");
// 2026-05-17 — Widen StoreItems.Slug to 128 chars to fit
// wardrobe-colored-{guid}-{color} SKUs. Postgres ALTER COLUMN
// is fast (metadata-only); idempotent if already 128+.
await RunPatchAsync("StoreItems.Slug widen to 128",
@"ALTER TABLE ""StoreItems"" ALTER COLUMN ""Slug"" TYPE varchar(128);");
// 2026-05-17 — Messages.Type column for RecNet.MessageType
// round-tripping (RequestGameInvite = 10, GameInvite = 0,
// TextMessage = 30, etc.). Before this column existed every
// message came back to the watch as Type=30, breaking the
// join-request flow.
await RunPatchAsync("Messages.Type column",
@"ALTER TABLE ""Messages""
ADD COLUMN IF NOT EXISTS ""Type"" integer NOT NULL DEFAULT 30;");
await RunPatchAsync("Messages.RoomId column",
@"ALTER TABLE ""Messages""
ADD COLUMN IF NOT EXISTS ""RoomId"" bigint NULL;");
// 2026-05-20 — PrivateInstanceInvitees.LatestInviteMessageId.
// Lets /goto/invite/{messageId} resolve the roomInstanceId via
// the invitee row when the watch's accept flow has already
// raced through DELETE /api/messages/v3/delete and the Message
// row is gone. Pre-patch the server returned ErrorCode 40
// ("invite expired") for accepts where DELETE landed first.
await RunPatchAsync("PrivateInstanceInvitees.LatestInviteMessageId column",
@"ALTER TABLE ""PrivateInstanceInvitees""
ADD COLUMN IF NOT EXISTS ""LatestInviteMessageId"" bigint NULL;");
// 2026-05-20 — coerce every existing PrivateInstance row to
// match the current Photon:CloudRegion. Older rows still carry
// whatever region was in config when their dorm was first
// registered; without this rewrite, an invitee whose
// /goto/invite resolves to one of those rows would be sent to
// the stale region while the inviter's most recent
// /goto/room/DormRoom puts them on the current region — two
// parallel Photon rooms, players can't see each other. The
// EnsureForDormAsync update path now refreshes PhotonRegion on
// each owner /goto, so going forward this drift is impossible;
// this one-shot UPDATE just cleans up the pre-fix backlog.
// Idempotent — the WHERE skips rows already on the right
// region.
var currentRegion = (app.Configuration["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
await RunPatchAsync($"PrivateInstances.PhotonRegion → {currentRegion}",
$@"UPDATE ""PrivateInstances""
SET ""PhotonRegion"" = '{currentRegion}'
WHERE ""PhotonRegion"" IS DISTINCT FROM '{currentRegion}';");
// 2026-05-18 — CommunityBoardRows table for the dorm community
// board (FeaturedPlayer/Announcement/InstagramImages/Videos).
// EnsureCreated skips this on databases created before the
// entity was added; without this patch the
// /api/communityboard/v1/current endpoint silently returns
// DefaultState because the row table doesn't exist (or the
// row was never persisted) and admins see an empty board.
await RunPatchAsync("CommunityBoardRows table",
@"CREATE TABLE IF NOT EXISTS ""CommunityBoardRows"" (
""Id"" integer PRIMARY KEY,
""Json"" text NOT NULL DEFAULT '{}',
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
);");
// 2026-05-19 — ServerSettings single-row table backing the
// admin SPA's runtime toggles (currently just SignupsDisabled).
// Same EnsureCreated-skip story as CommunityBoardRows above:
// existing prod DBs won't get the table without this idempotent
// patch.
await RunPatchAsync("ServerSettings table",
@"CREATE TABLE IF NOT EXISTS ""ServerSettings"" (
""Id"" integer PRIMARY KEY,
""SignupsDisabled"" boolean NOT NULL DEFAULT false,
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
);");
// 2026-05-31 — Weekly-challenge config columns on ServerSettings.
// Back the admin SPA's editable weekly slate + gift (XP/tokens +
// an optional store skin granted on completion). EnsureCreated-
// skipped on existing DBs, so add idempotently.
await RunPatchAsync("ServerSettings.WeeklyChallengesCompletedRequired column",
@"ALTER TABLE ""ServerSettings""
ADD COLUMN IF NOT EXISTS ""WeeklyChallengesCompletedRequired"" boolean NOT NULL DEFAULT true;");
await RunPatchAsync("ServerSettings.WeeklyChallengesJson column",
@"ALTER TABLE ""ServerSettings""
ADD COLUMN IF NOT EXISTS ""WeeklyChallengesJson"" text NOT NULL DEFAULT '';");
await RunPatchAsync("ServerSettings.WeeklyChallengeRewardJson column",
@"ALTER TABLE ""ServerSettings""
ADD COLUMN IF NOT EXISTS ""WeeklyChallengeRewardJson"" text NOT NULL DEFAULT '';");
// 2026-06-04 — "everyone is friends" admin toggle (synthesized at
// read time; see ServerSettingsEntity.GlobalFriendsEnabled).
await RunPatchAsync("ServerSettings.GlobalFriendsEnabled column",
@"ALTER TABLE ""ServerSettings""
ADD COLUMN IF NOT EXISTS ""GlobalFriendsEnabled"" boolean NOT NULL DEFAULT false;");
// 2026-05-19 — Players.IsCommunityTeam column. Backs the
// overhead-badge admin toggle alongside IsDeveloper; the
// RoleController ORs the two so either one unlocks the watch's
// in-settings developer-display slider (which renders
// "Community Team" / "Developer" above the player's head per
// Cpp2IL_ISIL/.../PlayerUI.txt:9085-9099).
await RunPatchAsync("Players.IsCommunityTeam column",
@"ALTER TABLE ""Players""
ADD COLUMN IF NOT EXISTS ""IsCommunityTeam"" boolean NOT NULL DEFAULT false;");
// 2026-05-20 — HiddenFromBrowse keeps admin-utility rooms
// (MakerRoom, EventRoom) and rooms-folded-into-others
// (paintball sub-maps, LaserTag Hangar) out of room browse,
// search, and the originals feed while leaving /goto + clone
// + admin access intact.
await RunPatchAsync("Rooms.HiddenFromBrowse column",
@"ALTER TABLE ""Rooms""
ADD COLUMN IF NOT EXISTS ""HiddenFromBrowse"" boolean NOT NULL DEFAULT false;");
// 2026-05-20 — RoomRoles table for per-room co-owner / moderator
// / host grants. Drives RoomDetails.CoOwners / Moderators / Hosts
// arrays (and InvitedCoOwners / InvitedModerators / InvitedHosts
// when Accepted=false). Unique on (RoomId, PlayerId, Role) so
// granting the same role twice is a no-op.
await RunPatchAsync("RoomRoles table",
@"CREATE TABLE IF NOT EXISTS ""RoomRoles"" (
""Id"" bigserial PRIMARY KEY,
""RoomId"" bigint NOT NULL,
""PlayerId"" bigint NOT NULL,
""Role"" integer NOT NULL,
""Accepted"" boolean NOT NULL DEFAULT true,
""GrantedByPlayerId"" bigint NULL,
""GrantedAt"" timestamp with time zone NOT NULL DEFAULT now()
);");
await RunPatchAsync("RoomRoles (RoomId, PlayerId, Role) unique index",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomRoles_RoomId_PlayerId_Role""
ON ""RoomRoles"" (""RoomId"", ""PlayerId"", ""Role"");");
await RunPatchAsync("RoomRoles RoomId index",
@"CREATE INDEX IF NOT EXISTS ""IX_RoomRoles_RoomId"" ON ""RoomRoles"" (""RoomId"");");
// 2026-05-20 — LeaderboardChannelMeta lets admins map any
// StatChannel int the watch reports to (room, name, sort
// direction). Drives the per-room Leaderboards tab in the
// admin SPA. KnownStatChannels in AdminController stays as
// hardcoded fallback for channels with no row here.
await RunPatchAsync("LeaderboardChannelMeta table",
@"CREATE TABLE IF NOT EXISTS ""LeaderboardChannelMeta"" (
""Channel"" integer PRIMARY KEY,
""RoomId"" bigint NOT NULL DEFAULT 0,
""Name"" varchar(128) NOT NULL DEFAULT '',
""LowerIsBetter"" boolean NOT NULL DEFAULT false,
""ValueFormat"" varchar(32) NOT NULL DEFAULT 'count',
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
);");
await RunPatchAsync("LeaderboardChannelMeta RoomId index",
@"CREATE INDEX IF NOT EXISTS ""IX_LeaderboardChannelMeta_RoomId""
ON ""LeaderboardChannelMeta"" (""RoomId"");");
// 2026-05-17 — LoadingScreenTips table for admin-editable
// dorm-load splash tips with optional uploaded images.
await RunPatchAsync("LoadingScreenTips table",
@"CREATE TABLE IF NOT EXISTS ""LoadingScreenTips"" (
""Id"" bigserial PRIMARY KEY,
""Title"" varchar(128) NOT NULL DEFAULT '',
""Message"" varchar(512) NOT NULL DEFAULT '',
""ImageName"" varchar(128) NOT NULL DEFAULT '',
""Context"" int NOT NULL DEFAULT 0,
""PlatformMask"" int NOT NULL DEFAULT -1,
""RoomNamesCsv"" varchar(512) NOT NULL DEFAULT '',
""SortOrder"" int NOT NULL DEFAULT 0,
""IsActive"" boolean NOT NULL DEFAULT true,
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
);");
// 2026-05-31 — Signup codes + pending-device capture for the
// admin-issued invite flow (site /join). New tables, so
// EnsureCreated-skipped on existing DBs.
await RunPatchAsync("SignupCodes table",
@"CREATE TABLE IF NOT EXISTS ""SignupCodes"" (
""Id"" bigserial PRIMARY KEY,
""Code"" text NOT NULL,
""Descriptor"" text NOT NULL DEFAULT '',
""CreatedByPlayerId"" bigint NOT NULL DEFAULT 0,
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
""ExpiresAt"" timestamp with time zone NULL,
""RedeemedByPlayerId"" bigint NULL,
""RedeemedAt"" timestamp with time zone NULL,
""Revoked"" boolean NOT NULL DEFAULT false
);");
await RunPatchAsync("SignupCodes Code unique index",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_SignupCodes_Code"" ON ""SignupCodes"" (""Code"");");
await RunPatchAsync("PendingDevices table",
@"CREATE TABLE IF NOT EXISTS ""PendingDevices"" (
""Id"" bigserial PRIMARY KEY,
""DeviceId"" text NOT NULL,
""Platform"" integer NOT NULL DEFAULT 0,
""PlatformId"" text NOT NULL DEFAULT '',
""LastIp"" text NULL,
""FirstSeenAt"" timestamp with time zone NOT NULL DEFAULT now(),
""LastSeenAt"" timestamp with time zone NOT NULL DEFAULT now()
);");
await RunPatchAsync("PendingDevices DeviceId unique index",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_PendingDevices_DeviceId"" ON ""PendingDevices"" (""DeviceId"");");
await conn.CloseAsync();
}
// Coach system account at Player.Id=1. The RR-Original room seeder
// below assigns CreatorPlayerId=1 to every canonical room
// (RecCenter, Paintball, Dodgeball, etc.), so the FK target needs
// to exist before we insert rooms — otherwise admin tools' "rooms
// by player N" queries return rows whose CreatorPlayerId points to
// a non-existent Player. Idempotent: the call no-ops once the row
// is there. Must run BEFORE roomService.SeedAsync().
var playerService = scope.ServiceProvider.GetRequiredService<PlayerService>();
await playerService.EnsureSystemAccountAsync();
// Self-heal the AvatarEntity seed on every startup so accounts
// created with old/broken starter values get the current safe
// GUIDs without needing a one-shot EF migration. Idempotent —
// only updates rows that need it (new accounts, accounts with
// the old mask/swatch starter, and any orphan empty rows).
await playerService.EnsureAvatarSeedAsync();
// Grant the starter RecCenterTokens balance to any account that
// doesn't yet have a CurrencyType=2 row. Pre-existing accounts
// (created before this seed shipped) booted into a 0-token wallet
// and saw "Not enough tokens" on every store tile — this brings
// them up to the same starting state new accounts get. Idempotent:
// only inserts where the row is absent, never overwrites a row
// that's already there (so spent-down accounts aren't refilled).
await playerService.BackfillStarterWalletsAsync();
// 2018 client: complete any account missing birthday/email/password so the
// (screen-mode-broken) signup step flow never appears. See
// PlayerService.ApplyRegistrationDefaults.
await playerService.BackfillAccountCompletionAsync();
// Backfill the per-player dorm room + DormStateEntity for any
// legacy account that signed in via a path that didn't run
// EnsurePersonalDormAsync (early-bird signups, manual SQL
// imports). Without these rows the watch's
// /api/rooms/v4/details/{dormId} → cdn.localhost/room/{blobName}
// chain hits an empty CurrentDataBlobName which the CDN serves
// as the default-dorm bytes — but only if the dorm room itself
// exists. This call guarantees both rows for every existing
// account before traffic starts. Runs after EnsureSystemAccount
// so Coach is excluded.
var roomService = scope.ServiceProvider.GetRequiredService<RoomService>();
await roomService.EnsureDormsForAllPlayersAsync();
// Seed canonical Rec Room Original rooms so the watch's "Trending" tab
// has content on first launch. Idempotent — RoomService.SeedAsync
// bails if any rooms already exist.
await roomService.SeedAsync();
// Canonical room overrides: rename BloodMoon → Crescendo, fold the
// paintball-map standalone rooms (River/Homestead/Quarry/Clearcut/
// Spillway/Drive-In) into Paintball as sub-rooms, fold LaserTagHangar
// into LaserTag, hide MakerRoom + EventRoom from browse, and pull
// down the user-supplied thumbnails for Crescendo + Paintball.
// Idempotent — re-running on an already-overridden DB no-ops.
using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) })
{
var imagesDir = Path.Combine(AppContext.BaseDirectory, "data", "images");
await roomService.ApplyCanonicalOverridesAsync(http, imagesDir);
}
// Seed the default store catalog so the Shop tab is populated on
// first boot. Idempotent — only inserts items whose slug isn't
// already present.
var storeService = scope.ServiceProvider.GetRequiredService<StoreService>();
await storeService.SeedAsync();
// Tell the /healthz probe migrations are done. Coolify's rolling
// deploy will hold traffic at the LB until this flips and the
// healthz response goes 503 → 200.
DorkNet.Server.Controllers.Health.HealthController.MigrationsComplete = true;
}
// ── Middleware ────────────────────────────────────────────────────────────────
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// HTTP → HTTPS redirect — only kicks in when an HTTPS endpoint is configured
// in appsettings (Kestrel-direct mode). When fronted by nginx, both endpoints
// are effectively HTTP from Kestrel's perspective and this is a no-op.
app.UseHttpsRedirection();
// 2-minute WebSocket pings are too lazy for cloudflared — the tunnel
// kills the underlying TCP stream after ~90-100s of silence even
// though SignalR's app-layer pings would arrive on schedule. Send a
// ping frame every 30s so the tunnel always sees fresh traffic; the
// ping payload is tiny, and SignalR's own KeepAliveInterval (15s)
// stays under this floor.
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30) });
// Static admin UI hosted on admin.rec.net (its own subdomain). The
// PhysicalFileProvider is rooted at wwwroot/admin so requests like
// admin.rec.net/style.css map to wwwroot/admin/style.css — the URL
// has no /admin prefix.
//
// Mounted BEFORE the tracing wrapper because StaticFileMiddleware uses
// IHttpResponseBodyFeature.SendFileAsync, which writes directly to the
// underlying connection and ignores any Response.Body replacement done
// downstream — putting it after tracing would result in the actual
// file being sent to the socket, then the tracing middleware copying
// its (empty) MemoryStream back over the same body and clobbering it.
//
// UseWhen (NOT MapWhen) so that requests for paths the admin UI
// doesn't serve (e.g. admin.rec.net/api/admin/v1/players) fall through
// to the controller pipeline. MapWhen would fork into a parallel
// pipeline that terminates without invoking matched endpoints.
// Helper: bind a per-host static-files branch. Each subdomain gets its
// own PhysicalFileProvider rooted at wwwroot/<subdir>, so requests like
// admin.rec.net/style.css map to wwwroot/admin/style.css with no path
// prefix munging required.
static void MountStaticHost(WebApplication app, string host, string subdir, bool spaFallback = false)
{
var staticHostLogger = app.Services.GetRequiredService<ILoggerFactory>()
.CreateLogger("DorkNet.Server.StaticHost");
var root = Path.Combine(app.Environment.ContentRootPath, "wwwroot", subdir);
staticHostLogger.LogInformation(
"[static-host] {Host} → {Root} (exists: {Exists}, spaFallback: {Spa})",
host, root, Directory.Exists(root), spaFallback);
if (!Directory.Exists(root)) return;
var files = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(root);
// Use UseDefaultFiles + UseStaticFiles directly rather than
// UseFileServer because the latter's nested options initialization
// doesn't always propagate the FileProvider correctly when wrapped
// inside UseWhen — leading to the static files middleware looking
// at the default WebRoot (wwwroot/) instead of our scoped folder
// (wwwroot/admin/), which makes /index.html requests fall through
// to the API catch-all.
var defaultFilesOpts = new DefaultFilesOptions { FileProvider = files };
var staticFilesOpts = new StaticFileOptions
{
FileProvider = files,
// Don't let the browser cache admin assets — we iterate on the
// HTML/JS/CSS often and a stale cached page silently swallows
// every change. Cache-Control: no-cache forces a conditional
// GET (If-Modified-Since) on every load; the file is small and
// the round-trip is fine for an admin UI used by 1-2 people.
OnPrepareResponse = ctx =>
{
var headers = ctx.Context.Response.Headers;
headers.CacheControl = "no-cache, no-store, must-revalidate";
headers.Pragma = "no-cache";
headers.Expires = "0";
},
};
app.UseWhen(ctx => ctx.Request.Host.Host.Equals(host, StringComparison.OrdinalIgnoreCase),
branch =>
{
// Diagnostic: confirm the branch is being entered AND the
// FileProvider can resolve the requested file. At Information
// level temporarily to debug the admin asset 404s.
//
// Endpoint-clearing rule: the auto-added UseRouting (which
// runs before our middleware in minimal hosting) may have
// matched a non-admin endpoint for paths the static-host
// should answer — typically NsController.Root for "/" on
// admin.localhost and the apex, which would serve the
// service-URL-map JSON instead of the SPA's index.html.
// StaticFileMiddleware's ValidateNoEndpoint guard skips
// serving whenever an endpoint is already matched, so we
// clear it for non-API paths to let UseStaticFiles run.
//
// BUT: /api/* paths MUST keep their matched endpoint —
// those are admin/site/feed API calls that the matching
// controller (AdminController, SiteController, etc.)
// needs to handle. Clearing the endpoint for /api/*
// would 404 every admin API call.
branch.Use(async (ctx, next) =>
{
var p = ctx.Request.Path.Value ?? "/";
var probe = files.GetFileInfo(p == "/" ? "/index.html" : p);
staticHostLogger.LogInformation(
"[static-host:{Host}] path={Path} probe={Probe} exists={Exists}",
host, p, probe.PhysicalPath ?? "<null>", probe.Exists);
if (!p.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
ctx.SetEndpoint(null);
await next();
});
branch.UseDefaultFiles(defaultFilesOpts);
branch.UseStaticFiles(staticFilesOpts);
// Terminal handler — runs AFTER UseStaticFiles. By this point
// the request either:
// (a) was a static file UseStaticFiles served — Response
// already started, we just return.
// (b) is an API call we want to fall through to controllers
// (e.g. admin.localhost/api/admin/v1/players,
// localhost/api/site/v1/feed) — call next().
// (c) is a SPA deep link (no extension) we want to answer
// with index.html so React Router can re-resolve.
// (d) is a missing static asset (has extension, file
// gone — e.g. /assets/index-<stalehash>.js after a
// redeploy). Return 404 with no body — DO NOT fall
// through to controllers. The catch-all used to
// answer these with JSON {} which a browser <script>
// loader rejects with "disallowed MIME type" and the
// page stays broken until the user hard-refreshes.
// 404 makes the browser drop its stale index.html
// cache and refetch on the next load.
branch.Use(async (ctx, next) =>
{
if (ctx.Response.HasStarted) return;
var path = ctx.Request.Path.Value ?? "/";
// /api/* paths are reserved for controllers (admin API,
// site API, photos feed API, etc.). Always pass through.
if (path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
{
await next();
return;
}
// Non-GET methods aren't ours either — let controllers
// handle POST/PUT/DELETE on this host.
if (ctx.Request.Method != "GET" && ctx.Request.Method != "HEAD")
{
await next();
return;
}
// GET with file extension — UseStaticFiles already tried
// and didn't serve, so the file is missing. 404 directly;
// don't fall through to controllers (a stale
// /assets/<hash>.js can't possibly resolve to anything
// useful, and serving HTML for a .js request breaks the
// browser's module loader).
if (Path.HasExtension(path))
{
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
// GET, no extension. SPA fallback if enabled (admin SPA,
// public site). Without it (feed.* legacy), 404 so
// missing deep links don't silently land on the homepage.
if (!spaFallback)
{
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
var index = files.GetFileInfo("/index.html");
if (!index.Exists)
{
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
ctx.Response.ContentType = "text/html; charset=utf-8";
var hdrs = ctx.Response.Headers;
hdrs.CacheControl = "no-cache, no-store, must-revalidate";
hdrs.Pragma = "no-cache";
hdrs.Expires = "0";
await using var stream = index.CreateReadStream();
await stream.CopyToAsync(ctx.Response.Body);
});
});
}
// Per-host static branches — admin SPA, public site, legacy feed.* —
// all keyed off the configured apex so a single DORKNET_DOMAIN env
// var moves every static host together (admin.{apex}, {apex},
// www.{apex}, feed.{apex}).
MountStaticHost(app, domainCfg.Sub("admin"), "admin", spaFallback: true);
// Public-facing site at the apex (e.g. localhost + www variant).
// React-router routes are client-side so spaFallback must be true —
// without it /players?q=foo, /photo/123, etc. would 404 on first
// load. PublicSiteController owns /api/site/v1/*; everything else
// extensionless falls back to index.html.
MountStaticHost(app, domainCfg.Apex, "site", spaFallback: true);
MountStaticHost(app, $"www.{domainCfg.Apex}", "site", spaFallback: true);
// Old feed.* subdomain kept as legacy; new visitors land on the apex.
MountStaticHost(app, domainCfg.Sub("feed"), "feed");
// Friendly redirect from the old api.{apex}/admin path → admin.{apex}.
app.Use(async (ctx, next) =>
{
if (ctx.Request.Host.Host.Equals(domainCfg.Sub("api"), StringComparison.OrdinalIgnoreCase) &&
(ctx.Request.Path == "/admin" || ctx.Request.Path.StartsWithSegments("/admin")))
{
ctx.Response.Redirect($"https://{domainCfg.Sub("admin")}/", permanent: false);
return;
}
await next();
});
// IP-level bans run BEFORE authentication so banned-IP traffic
// gets cut off at the earliest point possible — no JWT round-trip,
// no DB hit beyond the (small) IpBans lookup.
app.UseMiddleware<DorkNet.Server.Auth.IpBanCheckMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
// Sits between auth and the controllers — by this point ctx.User has the
// validated principal (or is empty for anonymous calls). Bans are enforced
// uniformly across every authenticated endpoint without each controller
// having to remember to check.
app.UseMiddleware<DorkNet.Server.Auth.BanCheckMiddleware>();
app.MapControllers();
// SignalR hub on the notify.* subdomain — RequireHost prevents the
// same path from being matched on api.* or other subdomains. The host
// is derived from DomainConfig so a single DORKNET_DOMAIN env var
// drives where the hub answers; setting DORKNET_DOMAIN=localhost keeps
// notify.localhost, rec.net keeps notify.rec.net, etc.
// Pin transport to WebSockets-only. Letting SignalR fall back to
// ServerSentEvents or LongPolling makes "is the proxy idle?" a
// per-poll question instead of a stream-level one — Coolify's
// Traefik or Cloudflare's edge can close a poll mid-flight and the
// watch's BestHTTP framing layer surfaces it as "TCP Stream closed
// unexpectedly" → "check your internet". WebSockets keep one socket
// hot with our 10s pings; the proxies all see continuous frame
// traffic and never mark it idle.
app.MapHub<DorkNet.Server.Hubs.NotifyHub>("/hub/v1", opts =>
{
opts.Transports = Microsoft.AspNetCore.Http.Connections.HttpTransportType.WebSockets;
// SignalR's per-connection idle disconnect — must be > the longest
// keepalive interval in the stack (we ping every 10s; 90s lets two
// pings be missed before the server gives up).
opts.TransportSendTimeout = TimeSpan.FromSeconds(30);
}).RequireHost(domainCfg.Sub("notify"));
await app.RunDatabaseBootstrapAsync();
app.UseDorkNetPipeline();
app.Run();
static bool TryMapSingleOriginPath(PathString path, out string service, out PathString rest)
{
service = "";
rest = PathString.Empty;
if (!path.StartsWithSegments("/__dn", out var tail)) return false;
var value = tail.Value ?? "";
if (value.Length <= 1) return false;
var slash = value.IndexOf('/', 1);
service = slash < 0 ? value[1..] : value[1..slash];
if (string.IsNullOrWhiteSpace(service)) return false;
rest = slash < 0 ? "/" : value[slash..];
return true;
}
static bool IsHealthProbe(HttpRequest request)
{
return request.Path.Equals("/healthz", StringComparison.OrdinalIgnoreCase);
}
static bool IsTextLikeRequest(string? contentType)
{
if (string.IsNullOrWhiteSpace(contentType)) return false;
return contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)
|| contentType.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)
|| contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase);
}
static bool ShouldCaptureResponse(HttpRequest request)
{
var host = request.Host.Host;
if (host.StartsWith("cdn.", StringComparison.OrdinalIgnoreCase))
return false;
var path = request.Path.Value ?? "";
var ext = Path.GetExtension(path);
if (string.IsNullOrEmpty(ext))
return true;
return ext.Equals(".json", StringComparison.OrdinalIgnoreCase)
|| ext.Equals(".txt", StringComparison.OrdinalIgnoreCase)
|| ext.Equals(".log", StringComparison.OrdinalIgnoreCase);
}
static bool ShouldReadResponseBody(string? contentType, int status)
{
if (status < 400) return false;
if (string.IsNullOrWhiteSpace(contentType)) return true;
return contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)
|| contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase);
}
// One-shot upgrade helper: when the SQLite db already contains tables but
// has no __EFMigrationsHistory entries (i.e. it was created by the legacy
// `EnsureCreated()` path before we shipped migrations), insert a history
// row for the Initial migration so Migrate() treats Initial as
// already-applied and runs only the subsequent column-add migrations.
//
// Detection: `Players` table exists AND __EFMigrationsHistory has zero
// rows. After this runs once, the inserted row keeps it from firing again.
//
// Safe to leave in indefinitely — on a fresh DB the `Players` table won't
// exist yet, so the helper short-circuits and Migrate() does the full
// CreateTable run as normal.
static void BaselineExistingSchemaIfNeeded(DorkNetDbContext db)
{
using var conn = db.Database.GetDbConnection();
conn.Open();
using (var probe = conn.CreateCommand())
{
probe.CommandText =
"SELECT name FROM sqlite_master WHERE type='table' AND name='Players';";
var hasPlayers = probe.ExecuteScalar() is not null;
if (!hasPlayers) return; // Fresh DB — let Migrate() do its full thing.
}
using (var historyProbe = conn.CreateCommand())
{
historyProbe.CommandText =
"SELECT name FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory';";
var historyExists = historyProbe.ExecuteScalar() is not null;
if (historyExists)
{
using var countCmd = conn.CreateCommand();
countCmd.CommandText = "SELECT COUNT(*) FROM __EFMigrationsHistory;";
var existingRows = Convert.ToInt64(countCmd.ExecuteScalar());
if (existingRows > 0) return; // Already on migrations — nothing to do.
}
else
{
using var createHistory = conn.CreateCommand();
createHistory.CommandText = """
CREATE TABLE "__EFMigrationsHistory" (
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
"ProductVersion" TEXT NOT NULL
);
""";
createHistory.ExecuteNonQuery();
}
}
using (var insert = conn.CreateCommand())
{
insert.CommandText = """
INSERT OR IGNORE INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260508092637_Initial', '9.0.4');
""";
insert.ExecuteNonQuery();
}
// A previous failed migrate may have left a stale row in
// __EFMigrationsLock (EF Core 9 acquires this lock around the migrate
// run; a crash before release leaves the row, blocking the next
// Migrate call indefinitely). Drop the table so Migrate re-creates
// it cleanly. Safe — the lock is per-application-instance and we're
// single-instance here.
using (var dropLock = conn.CreateCommand())
{
dropLock.CommandText = "DROP TABLE IF EXISTS __EFMigrationsLock;";
dropLock.ExecuteNonQuery();
}
Log.Information(
"[migrations] Detected legacy EnsureCreated DB; baselined as 20260508092637_Initial. Subsequent migrations will apply normally.");
}
static void ApplySqliteCompatibilityPatches(DorkNetDbContext db)
{
if (!db.Database.IsSqlite()) return;
AddSqliteColumnIfMissing(db, "Rooms", "HiddenFromBrowse",
@"""HiddenFromBrowse"" INTEGER NOT NULL DEFAULT 0");
// 2026-05-31 — weekly-challenge config columns (see ServerSettingsEntity).
AddSqliteColumnIfMissing(db, "ServerSettings", "WeeklyChallengesCompletedRequired",
@"""WeeklyChallengesCompletedRequired"" INTEGER NOT NULL DEFAULT 1");
AddSqliteColumnIfMissing(db, "ServerSettings", "WeeklyChallengesJson",
@"""WeeklyChallengesJson"" TEXT NOT NULL DEFAULT ''");
AddSqliteColumnIfMissing(db, "ServerSettings", "WeeklyChallengeRewardJson",
@"""WeeklyChallengeRewardJson"" TEXT NOT NULL DEFAULT ''");
// 2026-06-04 — "everyone is friends" admin toggle.
AddSqliteColumnIfMissing(db, "ServerSettings", "GlobalFriendsEnabled",
@"""GlobalFriendsEnabled"" INTEGER NOT NULL DEFAULT 0");
}
static void AddSqliteColumnIfMissing(DorkNetDbContext db, string table, string column, string definition)
{
var conn = db.Database.GetDbConnection();
var shouldClose = conn.State == System.Data.ConnectionState.Closed;
if (shouldClose) conn.Open();
try
{
var exists = false;
using var check = conn.CreateCommand();
check.CommandText = $"PRAGMA table_info(\"{table.Replace("\"", "\"\"")}\");";
{
using var reader = check.ExecuteReader();
while (reader.Read())
{
if (!string.Equals(reader.GetString(1), column, StringComparison.OrdinalIgnoreCase)) continue;
exists = true;
break;
}
}
if (exists)
{
Log.Information("[sqlite-compat] {Table}.{Column} already exists", table, column);
return;
}
using var alter = conn.CreateCommand();
alter.CommandText = $"ALTER TABLE \"{table.Replace("\"", "\"\"")}\" ADD COLUMN {definition};";
alter.ExecuteNonQuery();
Log.Information("[sqlite-compat] Added {Table}.{Column}", table, column);
}
finally
{
if (shouldClose) conn.Close();
}
}
+3 -12
View File
@@ -102,7 +102,7 @@ public class ConfigService(IConfiguration config, DomainConfig domain)
foreach (var (service, sub) in ServiceSubdomains)
{
var host = string.IsNullOrEmpty(sub) ? apex : $"{sub}.{apex}";
map[service] = $"https://{host}/";
map[service] = $"https://{host}";
}
return map;
}
@@ -115,21 +115,12 @@ public class ConfigService(IConfiguration config, DomainConfig domain)
if (domain.SingleOriginEnabled)
{
var prefix = string.IsNullOrEmpty(sub) ? "www" : sub;
map[service] = $"{domain.SingleOriginBaseUrl}/__dn/{prefix}/";
map[service] = $"{domain.SingleOriginBaseUrl}/__dn/{prefix}";
continue;
}
var host = string.IsNullOrEmpty(sub) ? domain.Apex : domain.Sub(sub);
map[service] = EnsureTrailingSlash(domain.Url(host));
map[service] = domain.Url(host);
}
return map;
}
// The 2018.06 client builds every API URL by RAW string concat —
// new Uri(string.Concat(serviceBase, "api/config/v2")) — so each service
// base MUST be an absolute origin ending in '/', else the host glues to
// the path ("https://api.apexapi/config/v2") and every call 404s. The
// 2020 client runs EnsureEndsWith('/') on these values itself, so the
// trailing slash is idempotent there — safe for both eras.
private static string EnsureTrailingSlash(string url)
=> url.EndsWith('/') ? url : url + "/";
}
-48
View File
@@ -517,7 +517,6 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
HairColor = StarterHairColor,
SkinColor = StarterSkinColor,
};
ApplyRegistrationDefaults(player);
db.Players.Add(player);
await db.SaveChangesAsync();
@@ -597,7 +596,6 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
HairColor = StarterHairColor,
SkinColor = StarterSkinColor,
};
ApplyRegistrationDefaults(player);
db.Players.Add(player);
await db.SaveChangesAsync();
@@ -631,55 +629,9 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
return true;
}
// 2018 client runs a signup step-flow (video→birthday→email→complete) for
// accounts missing these — and its screen-mode UI is unusable, so accounts
// are born COMPLETE: an adult birthday, a synthetic email, and a default
// password ("dorknet"). RegistrationStatus is reported as complete(10) in
// the Player2018 wire shape.
private static readonly DateTime DefaultBirthday = new(1990, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static readonly string DefaultPasswordHash = BCrypt.Net.BCrypt.HashPassword("dorknet", 11);
private static void ApplyRegistrationDefaults(PlayerEntity p)
{
p.Birthday ??= DefaultBirthday;
if (string.IsNullOrEmpty(p.Email))
p.Email = $"{(string.IsNullOrEmpty(p.Username) ? "player" : p.Username).ToLowerInvariant()}@dorknet.local";
if (string.IsNullOrEmpty(p.PasswordHash)) p.PasswordHash = DefaultPasswordHash;
// 2018 SCREEN MODE is a developer-gated feature: SessionManager.BIAOMKDOLDL
// (which keeps screen mode enabled post-login) = account.Developer
// (RecNet EEBPLECPEGD.Deserialize "Developer" key). Without it the client
// demands a headset (vr_device_required) after login. Grant it so the
// screen-mode (non-VR) client is playable on this local-test branch.
p.IsDeveloper = true;
}
/// <summary>Complete any account missing birthday/email/password so the 2018
/// client never enters its (broken-in-screen-mode) signup step flow. Runs at
/// startup like the other backfills.</summary>
public async Task BackfillAccountCompletionAsync()
{
var incomplete = await db.Players
.Where(p => p.Birthday == null || p.Email == null || p.PasswordHash == null || !p.IsDeveloper)
.ToListAsync();
if (incomplete.Count == 0) return;
foreach (var p in incomplete) ApplyRegistrationDefaults(p);
await db.SaveChangesAsync();
logger.LogInformation(
"[backfill] completed registration (birthday/email/password) for {N} account(s)", incomplete.Count);
}
public async Task<PlayerEntity?> GetByIdAsync(long id) =>
await db.Players.Include(p => p.Avatar).FirstOrDefaultAsync(p => p.Id == id);
/// <summary>Bulk profile fetch for the 2018 client's batch resolvers
/// (api/players/v1/list, presence). Single query; order is not guaranteed.</summary>
public async Task<List<PlayerEntity>> GetByIdsAsync(IEnumerable<long> ids)
{
var idList = ids.Distinct().ToList();
if (idList.Count == 0) return [];
return await db.Players.Where(p => idList.Contains(p.Id)).ToListAsync();
}
/// <summary>
/// Stamp the calling platform onto an existing account so the
/// next boot's <c>/cachedlogin/forplatformid</c> query finds it.
+30 -6
View File
@@ -839,13 +839,37 @@ public class RoomService(DorkNetDbContext db)
}
/// <summary>
/// Project a RoomEntity to the flat June-2018 wire room (RecNet
/// Room.Deserialize, RecNet.cs:78244). This branch is 2018-only, so every
/// room-returning endpoint emits this shape via the shared mapper — the
/// 2020 RoomDetails/Scenes shape crashes the 2018 deserializer. See
/// <see cref="Compat2018.Room2018Mapper"/>.
/// Project a RoomEntity to the wire shape Room.Deserialize at RVA
/// 0x114E430 expects. Required keys (PascalCase):
/// RoomId, Name, Description, CreatorPlayerId, ImageName, State,
/// Accessibility, SupportsLevelVoting, IsAGRoom, CloningAllowed,
/// SupportsScreens, SupportsWalkVR, SupportsTeleportVR,
/// AllowsJuniors, RoomWarningMask, CustomRoomWarning
/// Optional: IsDormRoom, SupportsVRLow, SupportsMobile, DisableMicAutoMute.
/// </summary>
public static object ToWireRoom(RoomEntity r) => Compat2018.Room2018Mapper.From(r);
public static object ToWireRoom(RoomEntity r) => new
{
RoomId = r.Id,
Name = r.Name,
Description = r.Description,
CreatorPlayerId = r.CreatorPlayerId,
ImageName = r.ImageName,
State = r.State,
Accessibility = r.Accessibility,
SupportsLevelVoting = r.SupportsLevelVoting,
IsAGRoom = r.IsAGRoom,
IsDormRoom = r.IsDormRoom,
CloningAllowed = r.CloningAllowed,
SupportsVRLow = r.SupportsVRLow,
SupportsMobile = r.SupportsMobile,
SupportsScreens = r.SupportsScreens,
SupportsWalkVR = r.SupportsWalkVR,
SupportsTeleportVR = r.SupportsTeleportVR,
AllowsJuniors = r.AllowsJuniors,
RoomWarningMask = r.RoomWarningMask,
CustomRoomWarning = r.CustomRoomWarning,
DisableMicAutoMute = r.DisableMicAutoMute,
};
/// <summary>
/// Idempotent admin overrides applied AFTER SeedAsync. Keeps the seed
+349
View File
@@ -0,0 +1,349 @@
using System.Data;
using System.Data.Common;
using DorkNet.Server.Controllers.Health;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Serilog;
namespace DorkNet.Server.Startup;
/// <summary>Boot-time schema repair, seed data, and health readiness.</summary>
public static class DatabaseBootstrap
{
public static async Task RunDatabaseBootstrapAsync(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
if (db.Database.IsSqlite())
{
db.Database.EnsureCreated();
ApplySqliteCompatibilityPatches(db);
await EnsureSqliteInviteTablesAsync(db);
}
else
{
await BootstrapPostgresAsync(app, db);
}
var playerService = scope.ServiceProvider.GetRequiredService<PlayerService>();
await playerService.EnsureSystemAccountAsync();
await playerService.EnsureAvatarSeedAsync();
await playerService.BackfillStarterWalletsAsync();
var roomService = scope.ServiceProvider.GetRequiredService<RoomService>();
await roomService.EnsureDormsForAllPlayersAsync();
await roomService.SeedAsync();
using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) })
{
var imagesDir = Path.Combine(AppContext.BaseDirectory, "data", "images");
await roomService.ApplyCanonicalOverridesAsync(http, imagesDir);
}
var storeService = scope.ServiceProvider.GetRequiredService<StoreService>();
await storeService.SeedAsync();
HealthController.MigrationsComplete = true;
}
private static async Task EnsureSqliteInviteTablesAsync(DorkNetDbContext db)
{
await db.Database.ExecuteSqlRawAsync(
@"CREATE TABLE IF NOT EXISTS ""SignupCodes"" (
""Id"" INTEGER NOT NULL CONSTRAINT ""PK_SignupCodes"" PRIMARY KEY AUTOINCREMENT,
""Code"" TEXT NOT NULL,
""Descriptor"" TEXT NOT NULL DEFAULT '',
""CreatedByPlayerId"" INTEGER NOT NULL DEFAULT 0,
""CreatedAt"" TEXT NOT NULL,
""ExpiresAt"" TEXT NULL,
""RedeemedByPlayerId"" INTEGER NULL,
""RedeemedAt"" TEXT NULL,
""Revoked"" INTEGER NOT NULL DEFAULT 0
);");
await db.Database.ExecuteSqlRawAsync(
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_SignupCodes_Code"" ON ""SignupCodes"" (""Code"");");
await db.Database.ExecuteSqlRawAsync(
@"CREATE TABLE IF NOT EXISTS ""PendingDevices"" (
""Id"" INTEGER NOT NULL CONSTRAINT ""PK_PendingDevices"" PRIMARY KEY AUTOINCREMENT,
""DeviceId"" TEXT NOT NULL,
""Platform"" INTEGER NOT NULL DEFAULT 0,
""PlatformId"" TEXT NOT NULL DEFAULT '',
""LastIp"" TEXT NULL,
""FirstSeenAt"" TEXT NOT NULL,
""LastSeenAt"" TEXT NOT NULL
);");
await db.Database.ExecuteSqlRawAsync(
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_PendingDevices_DeviceId"" ON ""PendingDevices"" (""DeviceId"");");
}
private static async Task BootstrapPostgresAsync(WebApplication app, DorkNetDbContext db)
{
var conn = db.Database.GetDbConnection();
var connectLogger = app.Services.GetRequiredService<ILoggerFactory>()
.CreateLogger("DorkNet.Server.Bootstrap");
for (var attempt = 1; ; attempt++)
{
try { await conn.OpenAsync(); break; }
catch (Exception ex) when (attempt < 15 &&
(ex is System.Net.Sockets.SocketException
|| ex is Npgsql.NpgsqlException
|| ex is DbException))
{
var delay = TimeSpan.FromSeconds(Math.Min(attempt, 5));
connectLogger.LogWarning(
"[bootstrap] Postgres connect attempt {Attempt}/15 failed ({Type}: {Message}); retrying in {Delay}s",
attempt, ex.GetType().Name, ex.Message, delay.TotalSeconds);
await Task.Delay(delay);
}
}
await using var tx = await conn.BeginTransactionAsync();
await using (var lockCmd = conn.CreateCommand())
{
lockCmd.Transaction = tx;
lockCmd.CommandText = "SELECT pg_advisory_xact_lock(1146246987);";
await lockCmd.ExecuteNonQueryAsync();
}
db.Database.EnsureCreated();
await tx.CommitAsync();
var bootstrapLogger = app.Services.GetRequiredService<ILoggerFactory>()
.CreateLogger("DorkNet.Server.Bootstrap");
await RunPostgresPatchAsync(conn, bootstrapLogger, "RoomDataBlobs.Bytes nullable",
@"ALTER TABLE ""RoomDataBlobs"" ALTER COLUMN ""Bytes"" DROP NOT NULL;");
await RunPostgresPatchAsync(conn, bootstrapLogger, "ChatThreads table",
@"CREATE TABLE IF NOT EXISTS ""ChatThreads"" (
""Id"" bigserial PRIMARY KEY,
""ThreadKey"" varchar(96) NOT NULL,
""Name"" varchar(128) NOT NULL DEFAULT '',
""CreatorPlayerId"" bigint NOT NULL,
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
);");
await RunPostgresPatchAsync(conn, bootstrapLogger, "ChatThreads ThreadKey unique index",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ChatThreads_ThreadKey""
ON ""ChatThreads"" (""ThreadKey"");");
await RunPostgresPatchAsync(conn, bootstrapLogger, "ChatThreadMembers table",
@"CREATE TABLE IF NOT EXISTS ""ChatThreadMembers"" (
""Id"" bigserial PRIMARY KEY,
""ThreadKey"" text NOT NULL,
""PlayerId"" bigint NOT NULL,
""JoinedAt"" timestamp with time zone NOT NULL DEFAULT now(),
""SnoozeUntil"" timestamp with time zone NULL,
""LastReadMessageId"" bigint NULL
);");
await RunPostgresPatchAsync(conn, bootstrapLogger, "ChatThreadMembers (ThreadKey, PlayerId) unique index",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ChatThreadMembers_ThreadKey_PlayerId""
ON ""ChatThreadMembers"" (""ThreadKey"", ""PlayerId"");");
await RunPostgresPatchAsync(conn, bootstrapLogger, "ChatThreadMembers PlayerId index",
@"CREATE INDEX IF NOT EXISTS ""IX_ChatThreadMembers_PlayerId""
ON ""ChatThreadMembers"" (""PlayerId"");");
await RunPostgresPatchAsync(conn, bootstrapLogger, "Relationships.Favorited column",
@"ALTER TABLE ""Relationships""
ADD COLUMN IF NOT EXISTS ""Favorited"" boolean NOT NULL DEFAULT false;");
await RunPostgresPatchAsync(conn, bootstrapLogger, "Relationships.Muted column",
@"ALTER TABLE ""Relationships""
ADD COLUMN IF NOT EXISTS ""Muted"" boolean NOT NULL DEFAULT false;");
await RunPostgresPatchAsync(conn, bootstrapLogger, "Relationships.Ignored column",
@"ALTER TABLE ""Relationships""
ADD COLUMN IF NOT EXISTS ""Ignored"" boolean NOT NULL DEFAULT false;");
await RunPostgresPatchAsync(conn, bootstrapLogger, "StoreItems.Slug widen to 128",
@"ALTER TABLE ""StoreItems"" ALTER COLUMN ""Slug"" TYPE varchar(128);");
await RunPostgresPatchAsync(conn, bootstrapLogger, "Messages.Type column",
@"ALTER TABLE ""Messages""
ADD COLUMN IF NOT EXISTS ""Type"" integer NOT NULL DEFAULT 30;");
await RunPostgresPatchAsync(conn, bootstrapLogger, "Messages.RoomId column",
@"ALTER TABLE ""Messages""
ADD COLUMN IF NOT EXISTS ""RoomId"" bigint NULL;");
await RunPostgresPatchAsync(conn, bootstrapLogger, "PrivateInstanceInvitees.LatestInviteMessageId column",
@"ALTER TABLE ""PrivateInstanceInvitees""
ADD COLUMN IF NOT EXISTS ""LatestInviteMessageId"" bigint NULL;");
var currentRegion = (app.Configuration["Photon:CloudRegion"] ?? "us").ToLowerInvariant().Replace("'", "''");
await RunPostgresPatchAsync(conn, bootstrapLogger, $"PrivateInstances.PhotonRegion -> {currentRegion}",
$@"UPDATE ""PrivateInstances""
SET ""PhotonRegion"" = '{currentRegion}'
WHERE ""PhotonRegion"" IS DISTINCT FROM '{currentRegion}';");
await RunPostgresPatchAsync(conn, bootstrapLogger, "CommunityBoardRows table",
@"CREATE TABLE IF NOT EXISTS ""CommunityBoardRows"" (
""Id"" integer PRIMARY KEY,
""Json"" text NOT NULL DEFAULT '{}',
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
);");
await RunPostgresPatchAsync(conn, bootstrapLogger, "ServerSettings table",
@"CREATE TABLE IF NOT EXISTS ""ServerSettings"" (
""Id"" integer PRIMARY KEY,
""SignupsDisabled"" boolean NOT NULL DEFAULT false,
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
);");
await RunPostgresPatchAsync(conn, bootstrapLogger, "ServerSettings.WeeklyChallengesCompletedRequired column",
@"ALTER TABLE ""ServerSettings""
ADD COLUMN IF NOT EXISTS ""WeeklyChallengesCompletedRequired"" boolean NOT NULL DEFAULT true;");
await RunPostgresPatchAsync(conn, bootstrapLogger, "ServerSettings.WeeklyChallengesJson column",
@"ALTER TABLE ""ServerSettings""
ADD COLUMN IF NOT EXISTS ""WeeklyChallengesJson"" text NOT NULL DEFAULT '';");
await RunPostgresPatchAsync(conn, bootstrapLogger, "ServerSettings.WeeklyChallengeRewardJson column",
@"ALTER TABLE ""ServerSettings""
ADD COLUMN IF NOT EXISTS ""WeeklyChallengeRewardJson"" text NOT NULL DEFAULT '';");
await RunPostgresPatchAsync(conn, bootstrapLogger, "ServerSettings.GlobalFriendsEnabled column",
@"ALTER TABLE ""ServerSettings""
ADD COLUMN IF NOT EXISTS ""GlobalFriendsEnabled"" boolean NOT NULL DEFAULT false;");
await RunPostgresPatchAsync(conn, bootstrapLogger, "Players.IsCommunityTeam column",
@"ALTER TABLE ""Players""
ADD COLUMN IF NOT EXISTS ""IsCommunityTeam"" boolean NOT NULL DEFAULT false;");
await RunPostgresPatchAsync(conn, bootstrapLogger, "Rooms.HiddenFromBrowse column",
@"ALTER TABLE ""Rooms""
ADD COLUMN IF NOT EXISTS ""HiddenFromBrowse"" boolean NOT NULL DEFAULT false;");
await RunPostgresPatchAsync(conn, bootstrapLogger, "RoomRoles table",
@"CREATE TABLE IF NOT EXISTS ""RoomRoles"" (
""Id"" bigserial PRIMARY KEY,
""RoomId"" bigint NOT NULL,
""PlayerId"" bigint NOT NULL,
""Role"" integer NOT NULL,
""Accepted"" boolean NOT NULL DEFAULT true,
""GrantedByPlayerId"" bigint NULL,
""GrantedAt"" timestamp with time zone NOT NULL DEFAULT now()
);");
await RunPostgresPatchAsync(conn, bootstrapLogger, "RoomRoles (RoomId, PlayerId, Role) unique index",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomRoles_RoomId_PlayerId_Role""
ON ""RoomRoles"" (""RoomId"", ""PlayerId"", ""Role"");");
await RunPostgresPatchAsync(conn, bootstrapLogger, "RoomRoles RoomId index",
@"CREATE INDEX IF NOT EXISTS ""IX_RoomRoles_RoomId"" ON ""RoomRoles"" (""RoomId"");");
await RunPostgresPatchAsync(conn, bootstrapLogger, "LeaderboardChannelMeta table",
@"CREATE TABLE IF NOT EXISTS ""LeaderboardChannelMeta"" (
""Channel"" integer PRIMARY KEY,
""RoomId"" bigint NOT NULL DEFAULT 0,
""Name"" varchar(128) NOT NULL DEFAULT '',
""LowerIsBetter"" boolean NOT NULL DEFAULT false,
""ValueFormat"" varchar(32) NOT NULL DEFAULT 'count',
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
);");
await RunPostgresPatchAsync(conn, bootstrapLogger, "LeaderboardChannelMeta RoomId index",
@"CREATE INDEX IF NOT EXISTS ""IX_LeaderboardChannelMeta_RoomId""
ON ""LeaderboardChannelMeta"" (""RoomId"");");
await RunPostgresPatchAsync(conn, bootstrapLogger, "LoadingScreenTips table",
@"CREATE TABLE IF NOT EXISTS ""LoadingScreenTips"" (
""Id"" bigserial PRIMARY KEY,
""Title"" varchar(128) NOT NULL DEFAULT '',
""Message"" varchar(512) NOT NULL DEFAULT '',
""ImageName"" varchar(128) NOT NULL DEFAULT '',
""Context"" int NOT NULL DEFAULT 0,
""PlatformMask"" int NOT NULL DEFAULT -1,
""RoomNamesCsv"" varchar(512) NOT NULL DEFAULT '',
""SortOrder"" int NOT NULL DEFAULT 0,
""IsActive"" boolean NOT NULL DEFAULT true,
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
);");
await RunPostgresPatchAsync(conn, bootstrapLogger, "SignupCodes table",
@"CREATE TABLE IF NOT EXISTS ""SignupCodes"" (
""Id"" bigserial PRIMARY KEY,
""Code"" text NOT NULL,
""Descriptor"" text NOT NULL DEFAULT '',
""CreatedByPlayerId"" bigint NOT NULL DEFAULT 0,
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
""ExpiresAt"" timestamp with time zone NULL,
""RedeemedByPlayerId"" bigint NULL,
""RedeemedAt"" timestamp with time zone NULL,
""Revoked"" boolean NOT NULL DEFAULT false
);");
await RunPostgresPatchAsync(conn, bootstrapLogger, "SignupCodes Code unique index",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_SignupCodes_Code"" ON ""SignupCodes"" (""Code"");");
await RunPostgresPatchAsync(conn, bootstrapLogger, "PendingDevices table",
@"CREATE TABLE IF NOT EXISTS ""PendingDevices"" (
""Id"" bigserial PRIMARY KEY,
""DeviceId"" text NOT NULL,
""Platform"" integer NOT NULL DEFAULT 0,
""PlatformId"" text NOT NULL DEFAULT '',
""LastIp"" text NULL,
""FirstSeenAt"" timestamp with time zone NOT NULL DEFAULT now(),
""LastSeenAt"" timestamp with time zone NOT NULL DEFAULT now()
);");
await RunPostgresPatchAsync(conn, bootstrapLogger, "PendingDevices DeviceId unique index",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_PendingDevices_DeviceId"" ON ""PendingDevices"" (""DeviceId"");");
await conn.CloseAsync();
}
private static async Task RunPostgresPatchAsync(DbConnection conn, Microsoft.Extensions.Logging.ILogger logger, string label, string sql)
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
try { await cmd.ExecuteNonQueryAsync(); }
catch (Exception ex)
{
logger.LogWarning(ex, "[schema-patch] {Label} failed", label);
}
}
private static void ApplySqliteCompatibilityPatches(DorkNetDbContext db)
{
if (!db.Database.IsSqlite()) return;
AddSqliteColumnIfMissing(db, "Rooms", "HiddenFromBrowse",
@"""HiddenFromBrowse"" INTEGER NOT NULL DEFAULT 0");
AddSqliteColumnIfMissing(db, "ServerSettings", "WeeklyChallengesCompletedRequired",
@"""WeeklyChallengesCompletedRequired"" INTEGER NOT NULL DEFAULT 1");
AddSqliteColumnIfMissing(db, "ServerSettings", "WeeklyChallengesJson",
@"""WeeklyChallengesJson"" TEXT NOT NULL DEFAULT ''");
AddSqliteColumnIfMissing(db, "ServerSettings", "WeeklyChallengeRewardJson",
@"""WeeklyChallengeRewardJson"" TEXT NOT NULL DEFAULT ''");
AddSqliteColumnIfMissing(db, "ServerSettings", "GlobalFriendsEnabled",
@"""GlobalFriendsEnabled"" INTEGER NOT NULL DEFAULT 0");
}
private static void AddSqliteColumnIfMissing(DorkNetDbContext db, string table, string column, string definition)
{
var conn = db.Database.GetDbConnection();
var shouldClose = conn.State == ConnectionState.Closed;
if (shouldClose) conn.Open();
try
{
var exists = false;
using var check = conn.CreateCommand();
check.CommandText = $"PRAGMA table_info(\"{table.Replace("\"", "\"\"")}\");";
using (var reader = check.ExecuteReader())
{
while (reader.Read())
{
if (!string.Equals(reader.GetString(1), column, StringComparison.OrdinalIgnoreCase)) continue;
exists = true;
break;
}
}
if (exists)
{
Log.Information("[sqlite-compat] {Table}.{Column} already exists", table, column);
return;
}
using var alter = conn.CreateCommand();
alter.CommandText = $"ALTER TABLE \"{table.Replace("\"", "\"\"")}\" ADD COLUMN {definition};";
alter.ExecuteNonQuery();
Log.Information("[sqlite-compat] Added {Table}.{Column}", table, column);
}
finally
{
if (shouldClose) conn.Close();
}
}
}
@@ -0,0 +1,92 @@
using DorkNet.Server.Auth;
using DorkNet.Server.Hubs;
using DorkNet.Server.Services;
using Microsoft.AspNetCore.Http.Connections;
namespace DorkNet.Server.Startup;
/// <summary>Assembles the DorkNet middleware pipeline in runtime order.</summary>
public static class MiddlewarePipelineExtensions
{
public static void UseDorkNetPipeline(this WebApplication app)
{
var domainCfg = app.Services.GetRequiredService<DomainConfig>();
// Easy Launcher single-origin mode. localtunnel gives us one public
// hostname, so /__dn/{service}/... is mapped to the internal subdomain
// host shape before endpoint selection runs.
app.Use(async (ctx, next) =>
{
if (domainCfg.SingleOriginEnabled &&
TryMapSingleOriginPath(ctx.Request.Path, out var service, out var rest))
{
ctx.Request.Host = new HostString(service == "www" ? domainCfg.Apex : domainCfg.Sub(service));
ctx.Request.Path = string.IsNullOrEmpty(rest) ? "/" : rest;
}
await next();
});
app.UseRouting();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseWebSockets(new Microsoft.AspNetCore.Builder.WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(30),
});
app.MountStaticHost(domainCfg.Sub("admin"), "admin", spaFallback: true);
app.MountStaticHost(domainCfg.Apex, "site", spaFallback: true);
app.MountStaticHost($"www.{domainCfg.Apex}", "site", spaFallback: true);
app.MountStaticHost(domainCfg.Sub("feed"), "feed");
app.UseRequestTracing();
app.Use(async (ctx, next) =>
{
if (ctx.Request.Host.Host.Equals(domainCfg.Sub("api"), StringComparison.OrdinalIgnoreCase) &&
(ctx.Request.Path == "/admin" || ctx.Request.Path.StartsWithSegments("/admin")))
{
ctx.Response.Redirect($"https://{domainCfg.Sub("admin")}/", permanent: false);
return;
}
await next();
});
app.UseMiddleware<IpBanCheckMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<BanCheckMiddleware>();
app.MapControllers();
app.MapHub<NotifyHub>("/hub/v1", opts =>
{
opts.Transports = HttpTransportType.WebSockets;
opts.TransportSendTimeout = TimeSpan.FromSeconds(30);
}).RequireHost(domainCfg.Sub("notify"));
}
private static bool TryMapSingleOriginPath(PathString path, out string service, out PathString rest)
{
service = "";
rest = PathString.Empty;
if (!path.StartsWithSegments("/__dn", out var tail)) return false;
var value = tail.Value ?? "";
if (value.Length <= 1) return false;
var slash = value.IndexOf('/', 1);
service = slash < 0 ? value[1..] : value[1..slash];
if (string.IsNullOrWhiteSpace(service)) return false;
rest = slash < 0 ? "/" : value[slash..];
return true;
}
}
@@ -0,0 +1,193 @@
using System.Text.RegularExpressions;
using DorkNet.Server.Auth;
using DorkNet.Server.Services;
namespace DorkNet.Server.Startup;
/// <summary>Per-request observability middleware for HTTP traces and player logs.</summary>
public static class RequestTracingExtensions
{
public static void UseRequestTracing(this WebApplication app)
{
var reqLogger = app.Services.GetRequiredService<ILoggerFactory>()
.CreateLogger("DorkNet.Server.HttpTrace");
var playerLog = app.Services.GetRequiredService<PlayerLogService>();
app.Use(async (ctx, next) =>
{
if (IsHealthProbe(ctx.Request))
{
await next();
return;
}
if (ctx.WebSockets.IsWebSocketRequest)
{
reqLogger.LogInformation(
"[ws] {Method} {Host}{Path}{Query}",
ctx.Request.Method, ctx.Request.Host, ctx.Request.Path,
ctx.Request.QueryString.Value ?? "");
await next();
return;
}
var host = ctx.Request.Host.Host;
var path = ctx.Request.Path.Value ?? "";
var isStorageLike = host.StartsWith("storage.", StringComparison.OrdinalIgnoreCase)
|| host.StartsWith("cdn.", StringComparison.OrdinalIgnoreCase)
|| path.Contains("upload", StringComparison.OrdinalIgnoreCase);
if (isStorageLike)
{
reqLogger.LogInformation(
"[req:start] {Method} {Host}{Path}{Query} contentType={ContentType} contentLength={ContentLength}",
ctx.Request.Method, ctx.Request.Host, ctx.Request.Path,
ctx.Request.QueryString.Value ?? "", ctx.Request.ContentType ?? "",
ctx.Request.ContentLength);
}
var sw = System.Diagnostics.Stopwatch.StartNew();
var originalBody = ctx.Response.Body;
var captureResponse = ShouldCaptureResponse(ctx.Request);
MemoryStream? capture = null;
if (captureResponse)
{
capture = new MemoryStream();
ctx.Response.Body = capture;
}
string? requestBody = null;
if (ctx.Request.ContentLength is > 0 and < 4096 && IsTextLikeRequest(ctx.Request.ContentType))
{
ctx.Request.EnableBuffering();
using var reader = new StreamReader(ctx.Request.Body, leaveOpen: true);
requestBody = await reader.ReadToEndAsync();
ctx.Request.Body.Position = 0;
}
Exception? thrown = null;
try
{
await next();
}
catch (Exception ex)
{
thrown = ex;
throw;
}
finally
{
sw.Stop();
var status = thrown is null ? ctx.Response.StatusCode : 500;
var responseBody = "";
if (capture is not null)
{
try
{
capture.Seek(0, SeekOrigin.Begin);
if (ShouldReadResponseBody(ctx.Response.ContentType, status))
responseBody = await new StreamReader(capture, leaveOpen: true).ReadToEndAsync();
capture.Seek(0, SeekOrigin.Begin);
await capture.CopyToAsync(originalBody);
}
finally
{
ctx.Response.Body = originalBody;
await capture.DisposeAsync();
}
}
var safeRequest = RedactSecrets(requestBody);
var safeResponse = RedactSecrets(responseBody) ?? "";
var trimmedRequest = safeRequest is null ? null
: (safeRequest.Length > 200 ? safeRequest[..200] + "..." : safeRequest);
var trimmedResponse = safeResponse.Length > 300 ? safeResponse[..300] + "..." : safeResponse;
var level = thrown is not null || status >= 500 ? LogLevel.Error
: status >= 400 ? LogLevel.Warning
: LogLevel.Information;
reqLogger.Log(level, thrown,
"[req] {Status} {Method} {Host}{Path}{Query} {ElapsedMs}ms contentType={ContentType} contentLength={ContentLength} req={ReqBody} resp={RespBody}",
status, ctx.Request.Method, ctx.Request.Host, ctx.Request.Path,
ctx.Request.QueryString.Value ?? "", sw.ElapsedMilliseconds,
ctx.Request.ContentType ?? "", ctx.Request.ContentLength,
trimmedRequest ?? "", trimmedResponse);
var pid = ControllerBaseExtensions.CurrentPlayerId(ctx.User);
if (pid is long playerId)
{
playerLog.Record(new PlayerLogEntry
{
Timestamp = DateTime.UtcNow,
PlayerId = playerId,
Method = ctx.Request.Method,
Host = ctx.Request.Host.Host,
Path = ctx.Request.Path.Value ?? string.Empty,
Query = ctx.Request.QueryString.Value ?? string.Empty,
Status = status,
ElapsedMs = sw.ElapsedMilliseconds,
ReqBody = trimmedRequest,
RespBody = trimmedResponse,
});
}
}
});
}
private const string SecretKeys =
"password|passwd|pwd|passwordhash|token|accesstoken|refreshtoken|idtoken|" +
"secret|clientsecret|apikey|api_key|authorization|deviceauth|sessionkey";
private static readonly Regex JsonSecretRegex = new(
"(\"(?:" + SecretKeys + ")\"\\s*:\\s*)\"(?:[^\"\\\\]|\\\\.)*\"",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex FormSecretRegex = new(
"(?<=^|&)(?<k>" + SecretKeys + ")=[^&]*",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static string? RedactSecrets(string? body)
{
if (string.IsNullOrEmpty(body)) return body;
if (body.IndexOf('"') < 0 && body.IndexOf('=') < 0) return body;
var redacted = JsonSecretRegex.Replace(body, "$1\"***\"");
redacted = FormSecretRegex.Replace(redacted, "${k}=***");
return redacted;
}
private static bool IsHealthProbe(HttpRequest request)
=> request.Path.Equals("/healthz", StringComparison.OrdinalIgnoreCase);
private static bool IsTextLikeRequest(string? contentType)
{
if (string.IsNullOrWhiteSpace(contentType)) return false;
return contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)
|| contentType.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)
|| contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase);
}
private static bool ShouldCaptureResponse(HttpRequest request)
{
var host = request.Host.Host;
if (host.StartsWith("cdn.", StringComparison.OrdinalIgnoreCase))
return false;
var path = request.Path.Value ?? "";
var ext = Path.GetExtension(path);
if (string.IsNullOrEmpty(ext))
return true;
return ext.Equals(".json", StringComparison.OrdinalIgnoreCase)
|| ext.Equals(".txt", StringComparison.OrdinalIgnoreCase)
|| ext.Equals(".log", StringComparison.OrdinalIgnoreCase);
}
private static bool ShouldReadResponseBody(string? contentType, int status)
{
if (status < 400) return false;
if (string.IsNullOrWhiteSpace(contentType)) return true;
return contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)
|| contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,227 @@
using System.Text;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Serilog;
using Serilog.Formatting.Compact;
using StackExchange.Redis;
namespace DorkNet.Server.Startup;
/// <summary>Central DI and host configuration for the DorkNet server.</summary>
public static class ServiceCollectionExtensions
{
public static void AddDorkNetServices(this WebApplicationBuilder builder)
{
ConfigureLogging(builder);
AddDatabase(builder);
AddDomain(builder);
AddCoreServices(builder.Services);
var redisConn = AddRedis(builder);
AddAuthentication(builder);
AddKestrelAndSignalR(builder, redisConn);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
}
private static void ConfigureLogging(WebApplicationBuilder builder)
{
builder.Host.UseSerilog((ctx, services, cfg) =>
{
cfg.ReadFrom.Configuration(ctx.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext();
if (ctx.HostingEnvironment.IsDevelopment())
cfg.WriteTo.Console();
else
cfg.WriteTo.Console(new RenderedCompactJsonFormatter());
});
}
private static void AddDatabase(WebApplicationBuilder builder)
{
var dbProvider = (builder.Configuration["Database:Provider"] ?? "sqlite").ToLowerInvariant();
builder.Services.AddDbContext<DorkNetDbContext>(opt =>
{
opt.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning));
if (dbProvider == "postgres")
{
var conn = builder.Configuration.GetConnectionString("Default")
?? throw new InvalidOperationException(
"Database:Provider=postgres but ConnectionStrings:Default is not set. " +
"Set the ConnectionStrings__Default env var or `ConnectionStrings:Default` " +
"in appsettings.Local.json.");
opt.UseNpgsql(conn, npg => npg.MigrationsAssembly("DorkNet.Server"));
}
else
{
var dbPath = builder.Configuration["Database:SqlitePath"];
if (string.IsNullOrWhiteSpace(dbPath))
dbPath = Path.Combine(AppContext.BaseDirectory, "data", "dorknet.db");
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
opt.UseSqlite($"Data Source={dbPath}", lite => lite.MigrationsAssembly("DorkNet.Server"));
}
});
}
private static void AddDomain(WebApplicationBuilder builder)
{
var apex =
builder.Configuration["Domain:Apex"]
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN")
?? "localhost";
var domainScheme =
builder.Configuration["Domain:Scheme"]
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN_SCHEME")
?? "https";
var domainPort =
builder.Configuration["Domain:Port"]
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN_PORT");
var singleOriginBaseUrl =
builder.Configuration["Domain:SingleOriginBaseUrl"]
?? Environment.GetEnvironmentVariable("DORKNET_SINGLE_ORIGIN_BASE_URL");
builder.Services.AddSingleton(new DomainConfig(apex, domainScheme, domainPort, singleOriginBaseUrl));
builder.Services.Configure<Microsoft.AspNetCore.HostFiltering.HostFilteringOptions>(opt =>
{
opt.AllowedHosts = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" };
opt.AllowEmptyHosts = true;
opt.IncludeFailureMessage = true;
});
Console.WriteLine($"[domain] apex={apex}, scheme={domainScheme}, port={domainPort}, singleOrigin={singleOriginBaseUrl}, allowedHosts=[{apex}, *.{apex}, localhost]");
}
private static void AddCoreServices(IServiceCollection services)
{
services.AddScoped<PlayerService>();
services.AddScoped<ConfigService>();
services.AddScoped<AuthService>();
services.AddScoped<RoomService>();
services.AddScoped<LevelService>();
services.AddScoped<StoreService>();
services.AddSingleton<RoomDataBlobService>();
services.AddSingleton<RoomBlobNormalizerService>();
services.AddHttpClient();
services.AddSingleton<HtrAssetMirrorService>();
services.AddScoped<GameSessionService>();
services.AddScoped<PrivateInstanceService>();
services.AddScoped<CommunityBoardService>();
services.AddScoped<ServerSettingsService>();
services.AddScoped<SignupCodeService>();
services.AddSingleton<NotificationService>();
services.AddSingleton<OrphanAccountTracker>();
services.AddSingleton<PlayerPresenceService>();
services.AddSingleton<OnlinePresenceService>();
services.AddSingleton<PlayerLogService>();
services.AddSingleton<IObjectStorage, ObjectStorageService>();
services.AddSingleton<ImageSignatureService>();
}
private static string? AddRedis(WebApplicationBuilder builder)
{
var redisConn = builder.Configuration.GetConnectionString("Redis");
if (string.IsNullOrWhiteSpace(redisConn)) return null;
var seRedisConfig = NormalizeRedisConn(redisConn);
var mux = ConnectionMultiplexer.Connect(seRedisConfig);
builder.Services.AddSingleton<IConnectionMultiplexer>(mux);
return redisConn;
}
internal static string NormalizeRedisConn(string raw)
{
if (!raw.StartsWith("redis://", StringComparison.OrdinalIgnoreCase) &&
!raw.StartsWith("rediss://", StringComparison.OrdinalIgnoreCase))
return raw;
var uri = new Uri(raw);
var host = uri.Host;
var port = uri.Port > 0 ? uri.Port : 6379;
string? user = null, pass = null;
if (!string.IsNullOrEmpty(uri.UserInfo))
{
var parts = uri.UserInfo.Split(':', 2);
user = Uri.UnescapeDataString(parts[0]);
if (parts.Length == 2) pass = Uri.UnescapeDataString(parts[1]);
}
var opts = new ConfigurationOptions
{
EndPoints = { { host, port } },
AbortOnConnectFail = false,
Ssl = raw.StartsWith("rediss://", StringComparison.OrdinalIgnoreCase),
};
if (!string.IsNullOrEmpty(user)) opts.User = user;
if (!string.IsNullOrEmpty(pass)) opts.Password = pass;
return opts.ToString();
}
private static void AddAuthentication(WebApplicationBuilder builder)
{
var jwtSecret = Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
?? builder.Configuration["Jwt:Secret"]
?? throw new InvalidOperationException(
"JWT secret not configured. Set the DORKNET_JWT_SECRET env var or " +
"add `Jwt:Secret` to appsettings.Local.json.");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
{
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
ValidateIssuer = true,
ValidIssuer = "https://api.rec.net/",
ValidateAudience = false,
ClockSkew = TimeSpan.Zero,
};
opt.Events = new JwtBearerEvents
{
OnMessageReceived = ctx =>
{
var accessToken = ctx.Request.Query["access_token"];
var path = ctx.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub"))
ctx.Token = accessToken;
return Task.CompletedTask;
},
};
});
builder.Services.AddAuthorization();
builder.Services.AddControllers().AddJsonOptions(opt =>
opt.JsonSerializerOptions.PropertyNamingPolicy = null);
}
private static void AddKestrelAndSignalR(WebApplicationBuilder builder, string? redisConn)
{
builder.WebHost.ConfigureKestrel(kopts =>
{
kopts.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(10);
kopts.Limits.MinRequestBodyDataRate = null;
kopts.Limits.MinResponseDataRate = null;
});
var signalR = builder.Services.AddSignalR(opts =>
{
opts.KeepAliveInterval = TimeSpan.FromSeconds(10);
opts.ClientTimeoutInterval = TimeSpan.FromSeconds(60);
opts.HandshakeTimeout = TimeSpan.FromSeconds(15);
opts.MaximumReceiveMessageSize = 1024 * 1024;
opts.EnableDetailedErrors = true;
});
if (!string.IsNullOrWhiteSpace(redisConn))
signalR.AddStackExchangeRedis(NormalizeRedisConn(redisConn), opts =>
{
opts.Configuration.ChannelPrefix = RedisChannel.Literal("dorknet-signalr");
});
}
}
@@ -0,0 +1,99 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
namespace DorkNet.Server.Startup;
/// <summary>Per-host static-files branching for admin, public site, and feed hosts.</summary>
public static class StaticHostExtensions
{
public static void MountStaticHost(this WebApplication app, string host, string subdir, bool spaFallback = false)
{
var staticHostLogger = app.Services.GetRequiredService<ILoggerFactory>()
.CreateLogger("DorkNet.Server.StaticHost");
var root = Path.Combine(app.Environment.ContentRootPath, "wwwroot", subdir);
staticHostLogger.LogInformation(
"[static-host] {Host} -> {Root} (exists: {Exists}, spaFallback: {Spa})",
host, root, Directory.Exists(root), spaFallback);
if (!Directory.Exists(root)) return;
var files = new PhysicalFileProvider(root);
var defaultFilesOpts = new DefaultFilesOptions { FileProvider = files };
var staticFilesOpts = new StaticFileOptions
{
FileProvider = files,
OnPrepareResponse = ctx =>
{
var headers = ctx.Context.Response.Headers;
headers.CacheControl = "no-cache, no-store, must-revalidate";
headers.Pragma = "no-cache";
headers.Expires = "0";
},
};
app.UseWhen(ctx => ctx.Request.Host.Host.Equals(host, StringComparison.OrdinalIgnoreCase),
branch =>
{
branch.Use(async (ctx, next) =>
{
var p = ctx.Request.Path.Value ?? "/";
var probe = files.GetFileInfo(p == "/" ? "/index.html" : p);
staticHostLogger.LogInformation(
"[static-host:{Host}] path={Path} probe={Probe} exists={Exists}",
host, p, probe.PhysicalPath ?? "<null>", probe.Exists);
if (!p.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
ctx.SetEndpoint(null);
await next();
});
branch.UseDefaultFiles(defaultFilesOpts);
branch.UseStaticFiles(staticFilesOpts);
branch.Use(async (ctx, next) =>
{
if (ctx.Response.HasStarted) return;
var path = ctx.Request.Path.Value ?? "/";
if (path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
{
await next();
return;
}
if (ctx.Request.Method != "GET" && ctx.Request.Method != "HEAD")
{
await next();
return;
}
if (Path.HasExtension(path))
{
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
if (!spaFallback)
{
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
var index = files.GetFileInfo("/index.html");
if (!index.Exists)
{
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
ctx.Response.ContentType = "text/html; charset=utf-8";
var hdrs = ctx.Response.Headers;
hdrs.CacheControl = "no-cache, no-store, must-revalidate";
hdrs.Pragma = "no-cache";
hdrs.Expires = "0";
await using var stream = index.CreateReadStream();
await stream.CopyToAsync(ctx.Response.Body);
});
});
}
}
+15 -1
View File
@@ -1,6 +1,20 @@
import { clearSession, getToken, notifyAuthChange } from './auth';
const API_BASE = '/api/admin/v1';
const API_BASE = adminApiBase();
function adminApiBase(): string {
if (typeof window === 'undefined') return '/api/admin/v1';
const marker = '/__dn/admin';
const path = window.location.pathname;
const lower = path.toLowerCase();
const markerAt = lower.indexOf(marker);
if (markerAt >= 0) {
return path.slice(0, markerAt + marker.length) + '/api/admin/v1';
}
return '/api/admin/v1';
}
export class ApiError extends Error {
constructor(public status: number, message: string, public body?: unknown) {
-22
View File
@@ -1,22 +0,0 @@
{
"Jwt": {
"Secret": "dorknet-local-dev-secret-please-change-0123456789abcdef"
},
"Domain": {
"Apex": "localhost",
"Scheme": "http",
"SingleOriginBaseUrl": "http://127.0.0.1:5005"
},
"Database": {
"Provider": "sqlite"
},
"Photon": {
"AppId": "",
"VoiceAppId": "",
"CloudRegion": "us",
"RequireCustomAuth": false
},
"Server": {
"MOTD": "DorkNet 2018 local test server"
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ export function App() {
<div className="mx-auto max-w-6xl px-4 sm:px-6 py-4 flex flex-wrap items-center justify-between gap-2">
<span>DorkNet — a private Rec Room server.</span>
<span>
<a className="hover:text-ink-200" href="https://github.com/Alexa-RR/Dorknet">GitHub</a>
<a className="hover:text-ink-200" href="https://github.com/DorkSquad/Dorknet">GitHub</a>
<span className="mx-2">·</span>
<a className="hover:text-ink-200" href="/admin">Admin</a>
</span>
+1 -1
View File
@@ -46,7 +46,7 @@
</main>
<footer class="site-footer">
<p>DorkNet · a private Rec Room server · <a href="https://github.com/Alexa-RR/Dorknet">GitHub</a></p>
<p>DorkNet · a private Rec Room server · <a href="https://github.com/DorkSquad/Dorknet">GitHub</a></p>
</footer>
<div id="toast" class="toast" hidden></div>
+15
View File
@@ -11,6 +11,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{07C2787E
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MigrateSqliteToPostgres", "Tools\MigrateSqliteToPostgres\MigrateSqliteToPostgres.csproj", "{70C60098-1B11-4C13-943B-A8A056429EAE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.ApiRouteCoverage", "tools\DorkNet.ApiRouteCoverage\DorkNet.ApiRouteCoverage.csproj", "{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -57,11 +59,24 @@ Global
{70C60098-1B11-4C13-943B-A8A056429EAE}.Release|x64.Build.0 = Release|Any CPU
{70C60098-1B11-4C13-943B-A8A056429EAE}.Release|x86.ActiveCfg = Release|Any CPU
{70C60098-1B11-4C13-943B-A8A056429EAE}.Release|x86.Build.0 = Release|Any CPU
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|x64.ActiveCfg = Debug|Any CPU
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|x64.Build.0 = Debug|Any CPU
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|x86.ActiveCfg = Debug|Any CPU
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|x86.Build.0 = Debug|Any CPU
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|Any CPU.Build.0 = Release|Any CPU
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|x64.ActiveCfg = Release|Any CPU
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|x64.Build.0 = Release|Any CPU
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|x86.ActiveCfg = Release|Any CPU
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{70C60098-1B11-4C13-943B-A8A056429EAE} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
EndGlobalSection
EndGlobal
+2 -2
View File
@@ -1,5 +1,5 @@
DorkNet — Rec Room Private Server
Copyright (C) 2026 Alexa <https://github.com/Alexa-RR/Dorknet>
Copyright (C) 2026 DorkSquad <https://github.com/DorkSquad/Dorknet>
This program is free software: you can redistribute it and/or modify it
under the terms of the GNU Affero General Public License as published
@@ -28,7 +28,7 @@ covered by AGPL §13 — you must:
2. Preserve this NOTICE file in your distribution.
3. Credit the upstream project visibly. At minimum:
"Based on DorkNet (https://github.com/Alexa-RR/Dorknet) by Alexa,
"Based on DorkNet (https://github.com/DorkSquad/Dorknet) by DorkSquad,
licensed AGPL-3.0."
Acceptable placements: README, server console banner, in-game
credits screen, or web-facing footer of any deployment dashboard.
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\..\DorkNet.Server\DorkNet.Server.csproj" />
</ItemGroup>
</Project>
+145
View File
@@ -0,0 +1,145 @@
using System.Reflection;
using System.Text.RegularExpressions;
using DorkNet.Server.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
var routes = DiscoverRoutes().OrderBy(r => r.Method).ThenBy(r => r.Template).ToArray();
var failures = new List<string>();
if (routes.Length == 0)
failures.Add("No HTTP controller routes were discovered.");
foreach (var route in routes)
{
if (string.IsNullOrWhiteSpace(route.Template))
failures.Add($"{route.Source}: empty route template");
if (route.SamplePath.Contains('{') || route.SamplePath.Contains('}'))
failures.Add($"{route.Source}: unresolved route parameter in {route.SamplePath}");
if (route.SamplePath.Contains('[') || route.SamplePath.Contains(']'))
failures.Add($"{route.Source}: unresolved token in {route.SamplePath}");
if (!route.SamplePath.StartsWith('/'))
failures.Add($"{route.Source}: sample path must be absolute: {route.SamplePath}");
}
Console.WriteLine($"Discovered {routes.Length} HTTP route cases.");
foreach (var route in routes)
Console.WriteLine($"{route.Method,-6} {route.SamplePath,-72} {route.Source}");
if (failures.Count == 0)
return 0;
Console.Error.WriteLine();
Console.Error.WriteLine("Route coverage failed:");
foreach (var failure in failures)
Console.Error.WriteLine($"- {failure}");
return 1;
static IEnumerable<RouteCase> DiscoverRoutes()
{
var controllerBaseType = typeof(ControllerBase);
var serverAssembly = typeof(DomainConfig).Assembly;
foreach (var controller in serverAssembly.GetTypes()
.Where(t => !t.IsAbstract && controllerBaseType.IsAssignableFrom(t)))
{
var controllerName = ControllerName(controller);
var controllerRouteTemplates = controller.GetCustomAttributes(inherit: true)
.OfType<IRouteTemplateProvider>()
.Select(a => a.Template)
.Where(t => !string.IsNullOrWhiteSpace(t))
.Cast<string>()
.DefaultIfEmpty("")
.ToArray();
foreach (var method in controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly))
{
var httpAttrs = method.GetCustomAttributes(inherit: true)
.OfType<HttpMethodAttribute>()
.ToArray();
if (httpAttrs.Length == 0) continue;
foreach (var attr in httpAttrs)
{
IEnumerable<string> attrMethods = attr.HttpMethods.Any()
? attr.HttpMethods
: new[] { "ANY" };
var actionName = method.Name;
foreach (var httpMethod in attrMethods)
{
foreach (var controllerTemplate in controllerRouteTemplates)
{
var template = CombineTemplates(controllerTemplate, attr.Template);
template = ReplaceRouteTokens(template, controllerName, actionName);
yield return new RouteCase(
httpMethod.ToUpperInvariant(),
template,
ToSamplePath(template),
$"{controller.FullName}.{method.Name}");
}
}
}
}
}
}
static string ControllerName(Type controller)
{
const string suffix = "Controller";
return controller.Name.EndsWith(suffix, StringComparison.Ordinal)
? controller.Name[..^suffix.Length]
: controller.Name;
}
static string CombineTemplates(string? controllerTemplate, string? actionTemplate)
{
controllerTemplate ??= "";
actionTemplate ??= "";
if (actionTemplate.StartsWith("~/", StringComparison.Ordinal))
return "/" + actionTemplate[2..].TrimStart('/');
if (actionTemplate.StartsWith("/", StringComparison.Ordinal))
return "/" + actionTemplate.TrimStart('/');
var left = controllerTemplate.Trim('/');
var right = actionTemplate.Trim('/');
if (string.IsNullOrEmpty(left) && string.IsNullOrEmpty(right)) return "/";
if (string.IsNullOrEmpty(left)) return "/" + right;
if (string.IsNullOrEmpty(right)) return "/" + left;
return "/" + left + "/" + right;
}
static string ReplaceRouteTokens(string template, string controllerName, string actionName)
{
return template
.Replace("[controller]", controllerName, StringComparison.OrdinalIgnoreCase)
.Replace("[action]", actionName, StringComparison.OrdinalIgnoreCase);
}
static string ToSamplePath(string template)
{
var path = RouteParameterRegex().Replace(template, match =>
{
var constraint = match.Groups["constraint"].Value.ToLowerInvariant();
if (constraint.Contains("long") || constraint.Contains("int")) return "1";
if (constraint.Contains("bool")) return "true";
if (constraint.Contains("guid")) return "00000000-0000-0000-0000-000000000001";
return "sample";
});
path = Regex.Replace(path, "/+", "/");
return path.StartsWith('/') ? path : "/" + path;
}
internal sealed record RouteCase(string Method, string Template, string SamplePath, string Source);
partial class Program
{
[GeneratedRegex(@"\{[*]?(?<name>[^}:=\?]+)(?<constraint>:[^}=\?]+)?(?<optional>\?)?(?<default>=[^}]+)?\}")]
private static partial Regex RouteParameterRegex();
}