Compare commits

..

11 Commits

Author SHA1 Message Date
Alexa 212d4327da Migrate auth to Open.IdentityServer 2026-06-30 15:47:28 +03:00
Alexa b5dec4ba1b Handle signing certificate decode failures 2026-06-30 14:45:23 +03:00
Alexa 0cc78fbf9d Harden signing certificate base64 parsing 2026-06-30 14:36:11 +03:00
Alexa d9494373a9 Use native game auth client only 2026-06-30 14:29:55 +03:00
Alexa 2d6a6ec94c Support native game auth client 2026-06-30 14:16:33 +03:00
Alexa 8c4f33ca80 Restore device password grant compatibility 2026-06-30 14:05:57 +03:00
Alexa c50b305786 Support env-based signing certificate 2026-06-30 13:54:56 +03:00
Alexa 71df7a3ef1 Migrate server auth to IdentityServer 2026-06-30 13:27:54 +03:00
Alexa da596a2692 feat: Add PlayerReputation handling and WebSocket handshake for 2018 client 2026-06-08 01:27:08 +03:00
Alexa 2eba82b342 feat: Update 2018 client support with registration defaults and WebSocket handling 2026-06-07 16:35:07 +03:00
Alexa 579071858a feat: Add support for 2018 client API endpoints and data models
- Implemented new search endpoints for rooms to accommodate the 2018 client.
- Added support for retrieving room details and featured rooms in a format compatible with the 2018 client.
- Introduced new data models for login responses and player presence specific to the 2018 client.
- Created mappers to convert between internal entities and the 2018 wire formats for players and rooms.
- Updated existing storefront APIs to include versioned endpoints for the 2018 client.
- Enhanced the version controller to return dual-format responses for compatibility with both 2018 and 2020 clients.
- Refactored the configuration service to ensure trailing slashes in URLs for the 2018 client.
2026-06-07 15:32:52 +03:00
49 changed files with 3255 additions and 1561 deletions
-3
View File
@@ -11,9 +11,6 @@ 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
+4 -4
View File
@@ -1,14 +1,14 @@
# DorkNet.Server container image — Coolify deploy target.
# ────────────────────────────────────────────────────────────────────
# Multi-stage: SDK image builds + publishes, runtime image is the
# slimmer aspnet:9.0 with only the published output.
# slimmer aspnet:10.0 with only the published output.
#
# Build context: repo root (so Tools/ and DorkNet.Models/ are visible
# to ProjectReference + dotnet restore). The .dockerignore at the
# repo root keeps the bin/, obj/, and other dev junk out of the
# context.
ARG DOTNET_VERSION=9.0
ARG DOTNET_VERSION=10.0
ARG NODE_VERSION=20
# ── admin SPA build stage ──────────────────────────────────────────
@@ -76,13 +76,13 @@ WORKDIR /app
# curl + wget for healthcheck probes — Coolify v4 defaults to wget for
# its own probe regardless of what the image's HEALTHCHECK uses, so
# both have to be present. The aspnet:9.0 base image strips both by
# both have to be present. The aspnet:10.0 base image strips both by
# default to keep the surface area small.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl wget \
&& rm -rf /var/lib/apt/lists/*
# Run as a non-root user — aspnet:9.0 ships with the `app` UID 1654.
# Run as a non-root user — aspnet:10.0 ships with the `app` UID 1654.
# Coolify mounts persistent volumes with write permission for this UID.
USER app
+143
View File
@@ -0,0 +1,143 @@
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;
}
+32 -5
View File
@@ -50,13 +50,34 @@ public class RecRoomConfig
[JsonPropertyName("DailyObjectives")]
public List<List<DailyObjective>> DailyObjectives { get; set; } = DailyObjective.DefaultSets();
[JsonPropertyName("ConfigTable")]
// 2018.06 client (RecNet.OPOHNGAOCCD.Deserialize, RecNet.cs:9062-9068) reads
// ConfigTable as an ARRAY of {Key,Value} objects, NOT a JSON object map:
// foreach elem in dict["ConfigTable"]: table[elem["Key"]] = elem["Value"]
// The 2020 client read it as an object — but this branch targets 2018, so we
// emit the array form. Keep the settable Dictionary for existing call sites
// (ConfigService.GetConfig populates it) and project to the wire array.
[JsonIgnore]
public Dictionary<string, string> ConfigTable { get; set; } = [];
[JsonPropertyName("ConfigTable")]
public List<ConfigTableEntry> ConfigTableWire =>
ConfigTable.Select(kv => new ConfigTableEntry { Key = kv.Key, Value = kv.Value }).ToList();
[JsonPropertyName("ServiceUrls")]
public Dictionary<string, string> ServiceUrls { get; set; } = [];
}
// 2018 ConfigTable element shape: {"Key": "...", "Value": "..."} (PascalCase,
// LitJson Util.GetKey<string>("Key"/"Value")).
public class ConfigTableEntry
{
[JsonPropertyName("Key")]
public string Key { get; set; } = string.Empty;
[JsonPropertyName("Value")]
public string Value { get; set; } = string.Empty;
}
public class PhotonConfig
{
[JsonPropertyName("AppId")]
@@ -77,11 +98,17 @@ public class PhotonConfig
public class MatchmakingParams
{
[JsonPropertyName("MaxPlayersPerRoom")]
public int MaxPlayersPerRoom { get; set; } = 8;
// 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("MinPlayersToStart")]
public int MinPlayersToStart { get; set; } = 1;
[JsonPropertyName("PreferEmptyRoomsFrequency")]
public float PreferEmptyRoomsFrequency { get; set; } = 0.5f;
}
public class LevelProgressionEntry
+2 -2
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
+21
View File
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,48 @@
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; }
}
@@ -0,0 +1,590 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using Open.IdentityServer;
using Open.IdentityServer.Models;
using Open.IdentityServer.Services;
using Open.IdentityServer.Validation;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.Tokens;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Auth;
public sealed class IdentityServerSigningKeyProvider
{
private readonly string _legacySecret;
public IdentityServerSigningKeyProvider(IConfiguration config)
{
_legacySecret =
Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
?? config["Jwt:Secret"]
?? throw new InvalidOperationException(
"JWT secret not configured. Set DORKNET_JWT_SECRET env var or Jwt:Secret in appsettings.Local.json.");
CertificatePath = ResolveCertificatePath(config);
var password = config["IdentityServer:SigningCertificatePassword"] ?? _legacySecret;
var certificateBase64 = config["IdentityServer:SigningCertificateBase64"];
if (!string.IsNullOrWhiteSpace(certificateBase64))
{
LoadedFromBase64 = true;
Certificate = LoadCertificateFromBase64(certificateBase64, password);
}
else
{
Certificate = LoadOrCreateCertificate(CertificatePath, password);
}
SigningKey = new X509SecurityKey(Certificate);
LegacyJwtKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_legacySecret));
}
public string CertificatePath { get; }
public bool LoadedFromBase64 { get; }
public string CertificateSource => LoadedFromBase64
? "IdentityServer:SigningCertificateBase64"
: CertificatePath;
public X509Certificate2 Certificate { get; }
public SecurityKey SigningKey { get; }
public SecurityKey LegacyJwtKey { get; }
public SecurityKey[] ValidationKeys => new[] { SigningKey, LegacyJwtKey };
private static string ResolveCertificatePath(IConfiguration config)
{
var path = config["IdentityServer:SigningCertificatePath"];
if (string.IsNullOrWhiteSpace(path))
path = Path.Combine(AppContext.BaseDirectory, "data", "identityserver-signing.pfx");
return path;
}
private static X509Certificate2 LoadCertificateFromBase64(string certificateBase64, string password)
{
var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
var compact = NormalizeCertificateBase64(certificateBase64);
byte[] bytes;
try
{
bytes = Convert.FromBase64String(compact);
}
catch (FormatException ex)
{
throw new InvalidOperationException(
"IdentityServer signing certificate base64 is invalid. " +
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64, " +
"without a file path or shell assignment text.",
ex);
}
try
{
return new X509Certificate2(bytes, password, flags);
}
catch (CryptographicException ex)
{
if (TryLoadPemCertificate(bytes, password, flags, out var certificate))
return certificate;
throw new InvalidOperationException(
"IdentityServer signing certificate base64 decoded successfully, but it is not a valid PFX/PKCS#12 certificate. " +
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64 and make sure " +
"IdentityServer__SigningCertificatePassword matches the PFX password.",
ex);
}
}
private static bool TryLoadPemCertificate(
byte[] bytes,
string password,
X509KeyStorageFlags flags,
out X509Certificate2 certificate)
{
certificate = null!;
string pem;
try
{
pem = Encoding.UTF8.GetString(bytes);
}
catch (DecoderFallbackException)
{
return false;
}
if (!pem.Contains("-----BEGIN", StringComparison.Ordinal))
return false;
try
{
using var pemCertificate = X509Certificate2.CreateFromPem(pem, pem);
if (!pemCertificate.HasPrivateKey)
return false;
var pfxBytes = pemCertificate.Export(X509ContentType.Pfx, password);
certificate = new X509Certificate2(pfxBytes, password, flags);
return true;
}
catch (CryptographicException)
{
return false;
}
}
private static string NormalizeCertificateBase64(string value)
{
var text = value.Trim().Trim('"', '\'');
var equals = text.IndexOf('=');
if (equals > 0)
{
var key = text[..equals].Trim();
if (key.Equals("IdentityServer__SigningCertificateBase64", StringComparison.OrdinalIgnoreCase) ||
key.Equals("IdentityServer:SigningCertificateBase64", StringComparison.OrdinalIgnoreCase))
text = text[(equals + 1)..].Trim().Trim('"', '\'');
}
if (text.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
var comma = text.IndexOf(',');
if (comma >= 0)
text = text[(comma + 1)..];
}
if (text.StartsWith("base64:", StringComparison.OrdinalIgnoreCase))
text = text["base64:".Length..];
var compact = new string(text.Where(c => !char.IsWhiteSpace(c)).ToArray())
.Replace('-', '+')
.Replace('_', '/');
return (compact.Length % 4) switch
{
2 => compact + "==",
3 => compact + "=",
_ => compact,
};
}
private static X509Certificate2 LoadOrCreateCertificate(string path, string password)
{
var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
if (File.Exists(path))
return new X509Certificate2(path, password, flags);
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
"CN=DorkNet IdentityServer Signing",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: false));
using var created = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow.AddYears(10));
var bytes = created.Export(X509ContentType.Pfx, password);
File.WriteAllBytes(path, bytes);
return new X509Certificate2(bytes, password, flags);
}
}
public static class RecRoomIdentityServerRegistration
{
public static void AddRecRoomIdentityServer(
this IServiceCollection services,
IConfiguration config,
DomainConfig domain,
IdentityServerSigningKeyProvider signingKeyProvider)
{
services
.AddIdentityServer(options =>
{
options.IssuerUri = domain.AuthIssuer.TrimEnd('/');
})
.AddInMemoryIdentityResources(RecRoomIdentityServerConfig.IdentityResources)
.AddInMemoryApiScopes(RecRoomIdentityServerConfig.ApiScopes)
.AddInMemoryClients(RecRoomIdentityServerConfig.Clients)
.AddSigningCredential(signingKeyProvider.SigningKey, SecurityAlgorithms.RsaSha256)
.AddResourceOwnerValidator<RecRoomPasswordValidator>()
.AddExtensionGrantValidator<CachedLoginGrantValidator>()
.AddProfileService<RecRoomProfileService>();
}
}
internal static class RecRoomIdentityServerConfig
{
public const string ClientId = "recroom";
public const string ClientSecret = "VxZ53kgbbEaRoZAeMe00MagtgD12GLL2";
public const string GameClientScope = "gameClient";
public const string CachedLoginGrantType = "cached_login";
public static readonly IdentityResource[] IdentityResources =
{
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
};
public static readonly ApiScope[] ApiScopes =
{
new(GameClientScope, "Game client")
{
UserClaims = RecRoomIdentityClaims.UserClaimTypes,
},
new("api", "DorkNet API")
{
UserClaims = RecRoomIdentityClaims.UserClaimTypes,
},
};
public static readonly Client[] Clients =
{
new()
{
ClientId = ClientId,
ClientName = "Rec Room game client",
RequireClientSecret = true,
ClientSecrets = { new Secret(ClientSecret.Sha256()) },
AllowedGrantTypes = { GrantType.ResourceOwnerPassword, "refresh_token", CachedLoginGrantType },
AllowedScopes = RecRoomScopes(),
AllowOfflineAccess = true,
AccessTokenLifetime = 60 * 60 * 12,
AbsoluteRefreshTokenLifetime = 60 * 60 * 24 * 30,
SlidingRefreshTokenLifetime = 60 * 60 * 24 * 30,
RefreshTokenUsage = TokenUsage.ReUse,
RefreshTokenExpiration = TokenExpiration.Absolute,
UpdateAccessTokenClaimsOnRefresh = true,
AlwaysIncludeUserClaimsInIdToken = true,
},
};
private static ICollection<string> RecRoomScopes() =>
[
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.OfflineAccess,
GameClientScope,
"api",
];
}
public sealed class IdentityServerGameTokenRequestMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
if (!IsTokenRequest(context))
{
await next(context);
return;
}
var form = await context.Request.ReadFormAsync();
var values = form.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value,
StringComparer.OrdinalIgnoreCase);
var changed = false;
if (IsDevicePasswordGrant(values))
{
if (!values.ContainsKey("username") || string.IsNullOrWhiteSpace(values["username"].ToString()))
{
values["username"] = First(
values.TryGetValue("deviceId", out var deviceId) ? deviceId.ToString() : null,
values.TryGetValue("device_id", out var deviceIdSnake) ? deviceIdSnake.ToString() : null,
values.TryGetValue("platformId", out var platformId) ? platformId.ToString() : null,
values.TryGetValue("platform_id", out var platformIdSnake) ? platformIdSnake.ToString() : null,
"anonymous");
changed = true;
}
if (!values.ContainsKey("password") || string.IsNullOrWhiteSpace(values["password"].ToString()))
{
values["password"] = "__dorknet_device_login__";
values["dorknet_device_login"] = "true";
changed = true;
}
}
var scopes = values.TryGetValue("scope", out var rawScope)
? rawScope.ToString()
: string.Empty;
var mergedScopes = MergeScopes(scopes);
if (!string.Equals(scopes, mergedScopes, StringComparison.Ordinal))
{
values["scope"] = mergedScopes;
changed = true;
}
if (changed)
context.Features.Set<IFormFeature>(new FormFeature(new FormCollection(values)));
await next(context);
}
private static bool IsTokenRequest(HttpContext context) =>
HttpMethods.IsPost(context.Request.Method)
&& context.Request.Path.Equals("/connect/token", StringComparison.OrdinalIgnoreCase)
&& context.Request.HasFormContentType;
private static bool IsDevicePasswordGrant(Dictionary<string, StringValues> values)
{
if (!values.TryGetValue("grant_type", out var grantType) ||
!string.Equals(grantType.ToString(), GrantType.ResourceOwnerPassword, StringComparison.OrdinalIgnoreCase))
return false;
return !values.TryGetValue("password", out var password) ||
string.IsNullOrWhiteSpace(password.ToString());
}
private static string First(params string?[] values) =>
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "anonymous";
private static string MergeScopes(string existing)
{
var scopes = existing
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
scopes.Add(IdentityServerConstants.StandardScopes.OpenId);
scopes.Add(IdentityServerConstants.StandardScopes.Profile);
scopes.Add(IdentityServerConstants.StandardScopes.OfflineAccess);
scopes.Add(RecRoomIdentityServerConfig.GameClientScope);
return string.Join(' ', scopes);
}
}
public sealed class RecRoomPasswordValidator(
PlayerService playerService,
LevelService level,
ServerSettingsService settings,
DorkNetDbContext db,
ILogger<RecRoomPasswordValidator> logger) : IResourceOwnerPasswordValidator
{
public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
{
var raw = context.Request.Raw;
var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
var deviceLogin = string.Equals(raw.Get("dorknet_device_login"), "true", StringComparison.OrdinalIgnoreCase);
if (!deviceLogin && !string.IsNullOrEmpty(context.Password) && !string.IsNullOrEmpty(context.UserName))
{
var byName = await playerService.GetByUsernameAsync(context.UserName);
if (byName is null || byName.PasswordHash is null)
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"unknown_user_or_no_password");
return;
}
if (!BCrypt.Net.BCrypt.Verify(context.Password, byName.PasswordHash))
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"password_mismatch");
return;
}
await playerService.TagPlatformAsync(byName.Id, platform, platformId);
await TryAwardLoginXpAsync(byName.Id);
context.Result = await CreateSuccessAsync(byName, "password");
return;
}
var effectiveDeviceId = !string.IsNullOrWhiteSpace(deviceId)
? deviceId
: $"legacy-{(context.UserName ?? "anonymous").ToLowerInvariant()}";
if (await settings.AreSignupsDisabledAsync())
{
var existing = await playerService.GetByDeviceAsync(effectiveDeviceId);
if (existing is null)
{
logger.LogInformation(
"[auth] IdentityServer password grant device fallback refused - signups disabled (device={Device})",
effectiveDeviceId);
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"signups_disabled");
return;
}
}
var player = await playerService.GetOrCreateByDeviceAsync(
deviceId: effectiveDeviceId,
platform: platform,
platformId: platformId,
displayName: context.UserName);
await TryAwardLoginXpAsync(player.Id);
context.Result = await CreateSuccessAsync(player, "device_password");
}
private async Task<GrantValidationResult> CreateSuccessAsync(PlayerEntity player, string authMethod) =>
new(
subject: player.Id.ToString(),
authenticationMethod: authMethod,
claims: await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
private async Task TryAwardLoginXpAsync(long playerId)
{
var lastSeen = await db.Players
.Where(p => p.Id == playerId)
.Select(p => p.LastSeenAt)
.FirstOrDefaultAsync();
if (lastSeen.Date < DateTime.UtcNow.Date)
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
}
private static string? First(params string?[] values) =>
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
}
public sealed class CachedLoginGrantValidator(
PlayerService playerService,
LevelService level,
OrphanAccountTracker orphans,
DorkNetDbContext db,
ILogger<CachedLoginGrantValidator> logger) : IExtensionGrantValidator
{
public string GrantType => RecRoomIdentityServerConfig.CachedLoginGrantType;
public async Task ValidateAsync(ExtensionGrantValidationContext context)
{
var raw = context.Request.Raw;
if (!long.TryParse(raw.Get("account_id"), out var pickedId))
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"missing account_id");
return;
}
var picked = await playerService.GetByIdAsync(pickedId);
if (picked is null)
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"unknown account_id");
return;
}
var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
var pendingId = orphans.PeekPending(deviceId, platformId);
if (pendingId is long orphanId && orphanId != pickedId)
{
await playerService.DeleteOrphanAsync(orphanId);
logger.LogInformation(
"[orphan-cleanup] cached_login picked {PickedId}; deleted just-created {OrphanId}",
pickedId,
orphanId);
}
orphans.Clear(deviceId, platformId);
await TryAwardLoginXpAsync(picked.Id);
await playerService.TagPlatformAsync(picked.Id, platform, platformId);
context.Result = new GrantValidationResult(
subject: picked.Id.ToString(),
authenticationMethod: GrantType,
claims: await RecRoomIdentityClaims.CreateAsync(db, picked.Id, picked.Username));
}
private async Task TryAwardLoginXpAsync(long playerId)
{
var lastSeen = await db.Players
.Where(p => p.Id == playerId)
.Select(p => p.LastSeenAt)
.FirstOrDefaultAsync();
if (lastSeen.Date < DateTime.UtcNow.Date)
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
}
private static string? First(params string?[] values) =>
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
}
public sealed class RecRoomProfileService(PlayerService playerService, DorkNetDbContext db) : IProfileService
{
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
if (!TryGetSubjectId(context.Subject, out var playerId))
return;
var player = await playerService.GetByIdAsync(playerId);
if (player is null)
return;
context.IssuedClaims.AddRange(
await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
}
public async Task IsActiveAsync(IsActiveContext context)
{
context.IsActive =
TryGetSubjectId(context.Subject, out var playerId)
&& await playerService.GetByIdAsync(playerId) is not null;
}
private static bool TryGetSubjectId(ClaimsPrincipal subject, out long playerId)
{
var value = subject.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? subject.FindFirstValue(ClaimTypes.NameIdentifier);
return long.TryParse(value, out playerId);
}
}
public static class RecRoomIdentityClaims
{
public static readonly List<string> UserClaimTypes =
[
JwtRegisteredClaimNames.Sub,
ClaimTypes.NameIdentifier,
"name",
"role",
ClaimTypes.Role,
"roles",
"accountId",
"account_id",
"accountid",
];
public static async Task<List<Claim>> CreateAsync(DorkNetDbContext db, long playerId, string? username = null)
{
var playerInfo = await db.Players
.Where(p => p.Id == playerId)
.Select(p => new { p.Username, IsDev = p.IsDeveloper || p.IsAdmin })
.FirstOrDefaultAsync();
var name = username ?? playerInfo?.Username ?? playerId.ToString();
var isDev = playerInfo?.IsDev ?? false;
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, playerId.ToString()),
new(JwtRegisteredClaimNames.Sub, playerId.ToString()),
new("name", name),
new("accountId", playerId.ToString()),
new("account_id", playerId.ToString()),
new("accountid", playerId.ToString()),
new(ClaimTypes.Role, "gameClient"),
new("role", "gameClient"),
new("roles", "gameClient"),
};
if (isDev)
{
claims.Add(new Claim(ClaimTypes.Role, "developer"));
claims.Add(new Claim("role", "developer"));
claims.Add(new Claim("roles", "developer"));
}
return claims;
}
}
@@ -0,0 +1,31 @@
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 },
};
}
@@ -0,0 +1,43 @@
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,6 +26,7 @@ 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")]
@@ -39,6 +40,7 @@ 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,6 +68,13 @@ 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,27 +71,37 @@ 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] EquipmentUpdateRequest req)
public async Task<IActionResult> UpdateEquipment([FromBody] System.Text.Json.JsonElement body)
{
var pid = Me;
// 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");
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);
var row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug);
if (row is null)
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)
{
// Player doesn't own this; reject rather than silently
// creating ownership (avoids letting clients grant items
// to themselves).
return Forbid();
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");
}
row.IsActive = req.Favorited;
await db.SaveChangesAsync();
return Ok(new { Updated = true });
}
@@ -92,6 +92,7 @@ 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)
@@ -105,6 +106,7 @@ 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,29 +1,76 @@
using System.Net.WebSockets;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Notification.V2;
/// <summary>
/// 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.
/// api/notification/v2 — the JUNE-2018 client opens a raw WebSocket here for
/// push notifications (RecNet.cs:48895, RecNet.IJADBHMBMPG wraps a
/// BestHTTP.WebSocket with open/message(string)/error events). The 2020 client
/// replaced this with the SignalR hub at notify.{apex}/hub/v1.
///
/// This branch targets 2018, so we ACCEPT the WebSocket and hold it open. We
/// don't push anything yet (server-driven notifications are a later feature) —
/// an open, quiet socket keeps the client's notification subsystem "connected"
/// instead of erroring/retry-looping on the old 410 Gone. Incoming frames
/// (subscribe/auth) are drained and ignored.
/// </summary>
[ApiController]
[Route("api/[controller]/v2")]
public class NotificationController(DomainConfig domain) : ControllerBase
public class NotificationController(
DomainConfig domain,
ILogger<NotificationController> log) : ControllerBase
{
[HttpGet]
public IActionResult Connect() => StatusCode(StatusCodes.Status410Gone, new
public async Task Connect()
{
error = "use_signalr_hub",
hint = $"connect to {domain.Sub("notify")}/hub/v1 instead",
});
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;
}
/// <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>
using var ws = await HttpContext.WebSockets.AcceptWebSocketAsync();
log.LogInformation("[notify] 2018 client opened notification WebSocket");
// HANDSHAKE: on connect the 2018 client enters PerformingHandshake and
// blocks ("Connecting to RecNet") until the SERVER sends a handshake
// message — any valid JSON object marks the channel Connected
// (RecNet.cs:49616-49620, JFBBMEIDBNA just needs dict != null). Without
// it both sides wait for each other ~35s, then the client aborts the
// channel and shows "Failed to connect to RecNet". Send {} immediately.
var handshake = System.Text.Encoding.UTF8.GetBytes("{}");
await ws.SendAsync(handshake, WebSocketMessageType.Text, endOfMessage: true,
HttpContext.RequestAborted);
var buf = new byte[4096];
try
{
while (ws.State == WebSocketState.Open)
{
var result = await ws.ReceiveAsync(
new ArraySegment<byte>(buf), HttpContext.RequestAborted);
if (result.MessageType == WebSocketMessageType.Close)
{
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure,
"bye", HttpContext.RequestAborted);
break;
}
// Drain & ignore subscribe/auth frames — no server push yet.
}
}
catch (OperationCanceledException) { /* client navigated away */ }
catch (WebSocketException) { /* abrupt disconnect — normal */ }
log.LogDebug("[notify] notification WebSocket closed");
}
/// <summary>GET <c>api/notification/hub/v1</c> — 2020 SignalR hub pointer
/// (unused by the 2018 client; kept for any 2019/2020 caller).</summary>
[HttpGet("/api/notification/hub/v1")]
public IActionResult HubInfo() => Ok(new
{
@@ -0,0 +1,255 @@
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,9 +34,14 @@ 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 = "Type")] int type = 0)
[FromForm(Name = "PlayerIdTo")] long? playerIdTo,
[FromForm(Name = "Type")] int type = 0,
[FromForm(Name = "CheerCategory")] int? cheerCategory = null)
{
if (targetAccountId is not long target || target <= 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)
return Ok(new { success = false, error = "missing_target" });
if (Me == target)
return Ok(new { success = false, error = "cannot_cheer_self" });
@@ -65,8 +70,11 @@ 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)
public async Task<IActionResult> SetSelectedCheer(
[FromForm(Name = "Cheer")] int cheer = 0,
[FromForm(Name = "CheerCategory")] int? cheerCategory = null)
{
cheer = cheerCategory ?? cheer; // 2018 sends CheerCategory
var existing = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == Me && s.Key == "SelectedCheer");
if (existing is null)
@@ -1,68 +1,104 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Models.Players;
using DorkNet.Models.Auth;
using DorkNet.Server.Auth;
using DorkNet.Server.Compat2018;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Players.V2;
/// <summary>
/// api/players/* for the June-2018 client. This branch is 2018-only, so every
/// endpoint returns the 2018 <see cref="Player2018"/> shape (keys Id/Developer/
/// JuniorProfile/… — NOT the 2020 PlayerProfile's AccountId/IsDeveloper).
///
/// The 2018 client mixes path versions: profile fetch + batch are v1, while
/// search + displayname are v2. We map both. Param names follow the 2018 client
/// (search ?name=, displayname form Name).
/// </summary>
[ApiController]
[Route("api/[controller]/v2")]
[Authorize]
public class PlayersController(PlayerService playerService, NotificationService notificationService) : ControllerBase
{
private long CurrentPlayerId => this.RequireCurrentPlayerId();
[HttpGet("me")]
public async Task<ActionResult<PlayerProfile>> GetSelf()
// GET api/players/v1|v2/me
[HttpGet("/api/players/v1/me")]
[HttpGet("/api/players/v2/me")]
public async Task<ActionResult<Player2018>> GetSelf()
{
var player = await playerService.GetByIdAsync(CurrentPlayerId);
if (player is null) return NotFound();
return Ok(PlayerService.ToProfile(player));
return Ok(Player2018Mapper.From(player));
}
[HttpGet("{id:long}")]
public async Task<ActionResult<PlayerProfile>> GetById(long id)
// GET api/players/v1|v2/{id} — primary profile fetch (2018 uses v1).
[HttpGet("/api/players/v1/{id:long}")]
[HttpGet("/api/players/v2/{id:long}")]
public async Task<ActionResult<Player2018>> GetById(long id)
{
var player = await playerService.GetByIdAsync(id);
if (player is null) return NotFound();
return Ok(PlayerService.ToProfile(player));
return Ok(Player2018Mapper.From(player));
}
[HttpGet("search")]
public async Task<ActionResult<List<PlayerProfile>>> Search([FromQuery] string query)
// POST api/players/v1/list — batch profile resolution (JSON array of ids).
[HttpPost("/api/players/v1/list")]
[HttpPost("/api/players/v2/list")]
public async Task<ActionResult<List<Player2018>>> GetMany([FromBody] long[]? ids)
{
if (string.IsNullOrWhiteSpace(query)) return Ok(new List<PlayerProfile>());
var results = await playerService.SearchAsync(query);
return Ok(results.Select(PlayerService.ToProfile).ToList());
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());
}
[HttpPost("displayName")]
public async Task<ActionResult<PlayerProfile>> UpdateDisplayName([FromForm] string? DisplayName)
// 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)
{
if (string.IsNullOrWhiteSpace(DisplayName))
return BadRequest("DisplayName is required");
var updated = await playerService.UpdateDisplayNameAsync(CurrentPlayerId, DisplayName);
if (!updated) return NotFound();
var term = !string.IsNullOrWhiteSpace(name) ? name : query;
if (string.IsNullOrWhiteSpace(term)) return Ok(new List<Player2018>());
var results = await playerService.SearchAsync(term);
return Ok(results.Select(Player2018Mapper.From).ToList());
}
// POST api/players/v2/displayname (2018 form key "Name"). Also accept
// "DisplayName" for safety.
[HttpPost("/api/players/v1/displayname")]
[HttpPost("/api/players/v2/displayname")]
public async Task<ActionResult<Player2018>> UpdateDisplayName(
[FromForm] string? Name, [FromForm] string? DisplayName)
{
var newName = !string.IsNullOrWhiteSpace(Name) ? Name : DisplayName;
if (string.IsNullOrWhiteSpace(newName)) return BadRequest("Name is required");
if (!await playerService.UpdateDisplayNameAsync(CurrentPlayerId, newName)) return NotFound();
var player = await playerService.GetByIdAsync(CurrentPlayerId);
await notificationService.NotifyAsync(CurrentPlayerId, Models.Notification.PushNotificationId.SubscriptionUpdateProfile, PlayerService.ToProfile(player!));
return Ok(PlayerService.ToProfile(player!));
await notificationService.NotifyAsync(CurrentPlayerId,
Models.Notification.PushNotificationId.SubscriptionUpdateProfile, Player2018Mapper.From(player!));
return Ok(Player2018Mapper.From(player!));
}
[HttpPut("{id:long}")]
public async Task<ActionResult<PlayerProfile>> UpdateProfile(long id, [FromBody] UpdateProfileRequest req)
// 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)
{
if (id != CurrentPlayerId) return Forbid();
await playerService.UpdateBioAsync(CurrentPlayerId, Bio ?? string.Empty);
var player = await playerService.GetByIdAsync(CurrentPlayerId);
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!));
// 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));
}
}
@@ -0,0 +1,35 @@
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,6 +61,14 @@ 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
@@ -112,6 +120,7 @@ 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)
@@ -123,6 +132,7 @@ 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)
@@ -901,8 +911,15 @@ 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()
@@ -912,6 +929,7 @@ 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]
@@ -925,6 +943,7 @@ 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>());
@@ -972,6 +991,25 @@ 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,6 +51,7 @@ 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)
{
@@ -170,6 +171,7 @@ 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)
{
@@ -185,6 +187,7 @@ 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,15 +116,16 @@ public class StorefrontsController(
{
var pid = this.RequireCurrentPlayerId();
var balance = await level.GetBalanceAsync(pid, currencyType);
return Ok(new[]
// 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
{
new
{
CurrencyType = currencyType,
BalanceType = 0,
Balance = balance,
Platform = 0,
},
CurrencyType = currencyType,
BalanceType = 0,
Balance = balance,
Platform = 0,
});
}
@@ -6,7 +6,16 @@ namespace DorkNet.Server.Controllers.API.Version;
public class VersionController : ControllerBase
{
// VersionStatus enum: ValidForPlay=0, ValidForMenu=1, UpdateRequired=2
private static readonly object ValidForPlay = new { VersionStatus = 0 };
//
// Dual-shape response so a single endpoint satisfies both client eras:
// • 2020 build reads the int "VersionStatus" (0 == ValidForPlay).
// • 2018.06 build (api/versioncheck/v3) reads the bool "ValidVersion"
// via a case-sensitive dict index (RecNet.cs BPPMLPMDHCI.Deserialize
// -> Util.GetKey<bool>("ValidVersion")). A MISSING key throws
// KeyNotFoundException -> the connect coroutine shows "Rec Room
// update required" and aborts boot before login. Emitting both keys
// keeps each era reading only the field it knows.
private static readonly object ValidForPlay = new { VersionStatus = 0, ValidVersion = true };
[HttpGet("api/version/v1")]
[HttpGet("api/version")]
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
@@ -39,6 +40,7 @@ public class AdminController(
SignupCodeService signupCodes,
IObjectStorage storage,
DomainConfig domain,
IdentityServerSigningKeyProvider signingKeys,
ILogger<AdminController> adminLogger) : ControllerBase
{
private long CurrentAdminId => this.RequireCurrentPlayerId();
@@ -1159,6 +1161,52 @@ public class AdminController(
// ── Server settings (runtime toggles) ────────────────────────────────
[HttpGet("identityserver")]
public ActionResult GetIdentityServerSettings()
{
var issuer = domain.AuthIssuer.TrimEnd('/');
var client = RecRoomIdentityServerConfig.Clients[0];
var certPath = signingKeys.CertificatePath;
var fullCertPath = Path.GetFullPath(certPath);
var certificateExists = signingKeys.LoadedFromBase64 || System.IO.File.Exists(fullCertPath);
return Ok(new
{
Issuer = issuer,
DiscoveryUrl = $"{issuer}/.well-known/openid-configuration",
TokenEndpoint = $"{issuer}/connect/token",
UserInfoEndpoint = $"{issuer}/connect/userinfo",
Client = new
{
client.ClientId,
client.ClientName,
AllowedGrantTypes = client.AllowedGrantTypes.ToArray(),
AllowedScopes = client.AllowedScopes.ToArray(),
client.AllowOfflineAccess,
client.AccessTokenLifetime,
client.AbsoluteRefreshTokenLifetime,
client.SlidingRefreshTokenLifetime,
RefreshTokenUsage = client.RefreshTokenUsage.ToString(),
RefreshTokenExpiration = client.RefreshTokenExpiration.ToString(),
},
Signing = new
{
signingKeys.CertificateSource,
signingKeys.LoadedFromBase64,
CertificatePath = fullCertPath,
CertificateExists = certificateExists,
signingKeys.Certificate.Thumbprint,
NotBeforeUtc = signingKeys.Certificate.NotBefore.ToUniversalTime(),
NotAfterUtc = signingKeys.Certificate.NotAfter.ToUniversalTime(),
Algorithm = SecurityAlgorithms.RsaSha256,
},
PersistedGrants = new
{
Store = "in-memory",
Warning = "Refresh tokens are process-local until an operational store is configured.",
},
});
}
/// <summary>GET <c>api/admin/v1/settings</c> — current server-wide
/// toggle state. Single row, single round trip.</summary>
[HttpGet("settings")]
+54 -235
View File
@@ -1,5 +1,4 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
@@ -7,242 +6,26 @@ using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Auth;
/// <summary>
/// auth.rec.net — OAuth-style token endpoint used by 2019+ builds alongside PlatformLogin.
/// auth.rec.net game-specific auth helpers. OAuth/OIDC endpoints are owned by IdentityServer.
/// </summary>
[ApiController]
public class AuthController(
PlayerService playerService,
AuthService authService,
LevelService level,
OrphanAccountTracker orphans,
ServerSettingsService settings,
DorkNet.Server.Data.DorkNetDbContext db,
DomainConfig domain,
ILogger<AuthController> logger) : ControllerBase
AuthService authService) : ControllerBase
{
/// <summary>Award the per-day first-login XP if the calling
/// player hasn't been seen yet today (UTC).</summary>
private async Task TryAwardLoginXpAsync(long playerId)
{
var lastSeen = await db.Players
.Where(p => p.Id == playerId)
.Select(p => p.LastSeenAt)
.FirstOrDefaultAsync();
if (lastSeen.Date < DateTime.UtcNow.Date)
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
}
// OAuth token endpoint. The 2020 client uses three grant types here:
// - grant_type=cached_login → user clicked a remembered account on the
// account-selection screen. Body includes `account_id` (and platform
// creds). MUST issue a token for THAT specific account; otherwise the
// client receives a JWT for a different account and prompts the user
// to create a new one (because the JWT identity doesn't match what
// they picked).
// - grant_type=refresh_token → re-issue access token from the existing
// refresh JWT.
// - grant_type=password → first-time sign-in, resolve via deviceId
// (preferred) or fall back to a stable synthetic id derived from
// username so legacy callers don't share accounts.
[HttpPost("/connect/token")]
[Consumes("application/x-www-form-urlencoded")]
public async Task<IActionResult> Token(
[FromForm] string? grant_type,
[FromForm] string? username,
[FromForm] string? password,
[FromForm] string? refresh_token,
[FromForm] string? deviceId,
[FromForm(Name = "device_id")] string? device_id_snake,
[FromForm] int platform = 0,
[FromForm] string? platformId = null,
[FromForm(Name = "platform_id")] string? platform_id_snake = null,
[FromForm] long? account_id = null)
{
// The client mixes camelCase and snake_case parameter naming
// depending on grant type — accept both and merge.
var effDeviceId = !string.IsNullOrWhiteSpace(deviceId) ? deviceId : device_id_snake;
var effPlatformId = !string.IsNullOrWhiteSpace(platformId) ? platformId : platform_id_snake;
if (string.Equals(grant_type, "cached_login", StringComparison.OrdinalIgnoreCase))
{
if (account_id is not long pickedId)
return BadRequest(new { error = "invalid_request", error_description = "missing account_id" });
var picked = await playerService.GetByIdAsync(pickedId);
if (picked is null)
return Unauthorized(new { error = "invalid_grant", error_description = "unknown account_id" });
// Orphan cleanup: if the watch just created a brand-new
// account on account/create as part of its boot probe, but
// the user actually picked a different cached account here,
// the just-created row is unused. Delete it so the DB
// doesn't accumulate one orphan per game launch. When the
// user is genuinely creating a new account, the picked id
// matches the just-created id and we leave it.
var pendingId = orphans.PeekPending(effDeviceId, effPlatformId);
if (pendingId is long orphanId && orphanId != pickedId)
{
await playerService.DeleteOrphanAsync(orphanId);
logger.LogInformation(
"[orphan-cleanup] cached_login picked {PickedId}; deleted just-created {OrphanId}",
pickedId, orphanId);
}
orphans.Clear(effDeviceId, effPlatformId);
await TryAwardLoginXpAsync(picked.Id);
await playerService.TagPlatformAsync(picked.Id, platform, effPlatformId);
var (acc1, refr1) = authService.GenerateTokenPair(picked.Id);
return Ok(new
{
access_token = acc1,
token_type = "Bearer",
expires_in = 43200,
refresh_token = refr1,
scope = "openid profile",
});
}
if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(refresh_token))
return BadRequest(new { error = "invalid_request" });
var resolved = authService.ValidateToken(refresh_token);
if (resolved is not long refreshedId)
return Unauthorized(new { error = "invalid_grant" });
var existing = await playerService.GetByIdAsync(refreshedId);
if (existing is null)
return Unauthorized(new { error = "invalid_grant" });
var (acc, refr) = authService.GenerateTokenPair(existing.Id);
return Ok(new
{
access_token = acc,
token_type = "Bearer",
expires_in = 43200,
refresh_token = refr,
scope = "openid profile",
});
}
// password grant: two paths.
// • Username + password supplied: real password verify against
// PlayerEntity.PasswordHash. Wrong password → 401.
// • No password: legacy device-id synth path for the 2020
// client's first-launch flow where the player hasn't set a
// password yet (HasPassword returns false in the watch UI
// and prompts them to create one). Resolves the existing
// account (or creates a fresh one) by deviceId.
if (!string.IsNullOrEmpty(password) && !string.IsNullOrEmpty(username))
{
var byName = await playerService.GetByUsernameAsync(username);
if (byName is null || byName.PasswordHash is null)
return Unauthorized(new
{
error = "invalid_grant",
error_description = "unknown_user_or_no_password",
});
if (!BCrypt.Net.BCrypt.Verify(password, byName.PasswordHash))
return Unauthorized(new
{
error = "invalid_grant",
error_description = "password_mismatch",
});
// Tag the platform so the next boot's cached-login lookup
// finds this account — without this the watch's "remembered
// accounts" prompt would stay empty after a manual sign-in.
await playerService.TagPlatformAsync(byName.Id, platform, effPlatformId);
await TryAwardLoginXpAsync(byName.Id);
var pair = authService.GenerateTokenPair(byName.Id);
return Ok(new
{
access_token = pair.AccessToken,
token_type = "Bearer",
expires_in = 43200,
refresh_token = pair.RefreshToken,
scope = "openid profile",
});
}
// Device-id fallback path. Prefer the real deviceId; fall back
// to a stable legacy synthesis from username so older callers
// don't accidentally share an account.
var effectiveDeviceId = !string.IsNullOrWhiteSpace(effDeviceId)
? effDeviceId
: $"legacy-{(username ?? "anonymous").ToLowerInvariant()}";
// Honour the admin "signups disabled" toggle. If the device
// hasn't logged in before, GetOrCreateByDeviceAsync would mint
// a fresh account here — same outcome as hitting
// /account/create directly, which the toggle blocks. Refuse
// BEFORE creation so the flag isn't bypassable.
if (await settings.AreSignupsDisabledAsync())
{
var existing = await playerService.GetByDeviceAsync(effectiveDeviceId);
if (existing is null)
{
logger.LogInformation(
"[auth] /connect/token device fallback refused — signups disabled (device={Device})",
effectiveDeviceId);
return Unauthorized(new
{
error = "signups_disabled",
error_description = "Account creation is currently disabled by the server admin.",
});
}
}
var player = await playerService.GetOrCreateByDeviceAsync(
deviceId: effectiveDeviceId,
platform: platform,
platformId: effPlatformId,
displayName: username);
await TryAwardLoginXpAsync(player.Id);
var (access, refresh) = authService.GenerateTokenPair(player.Id);
return Ok(new
{
access_token = access,
token_type = "Bearer",
expires_in = 43200,
refresh_token = refresh,
scope = "openid profile",
});
}
[HttpGet("/connect/userinfo")]
public async Task<IActionResult> UserInfo()
{
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return Unauthorized();
var token = auth["Bearer ".Length..].Trim();
var id = authService.ValidateToken(token);
if (id is not long playerId) return Unauthorized();
var player = await playerService.GetByIdAsync(playerId);
if (player is null) return Unauthorized();
return Ok(new { sub = player.Id.ToString(), name = player.Username });
}
// Discovery document — issuer + endpoints derived from
// DomainConfig so a single DORKNET_DOMAIN env var keeps the
// OpenID document pointed at the live deployment.
[HttpGet("/.well-known/openid-configuration")]
public IActionResult OpenIdConfig()
{
var base_ = $"https://{domain.Sub("auth")}";
return Ok(new
{
issuer = base_,
token_endpoint = $"{base_}/connect/token",
userinfo_endpoint = $"{base_}/connect/userinfo",
});
}
// 2020.12 added an EAC challenge step in the login flow. The client GETs
// a challenge string and passes it to EACManager.GenerateChallengeResponse,
// which calls System.Convert.FromBase64CharPtr on the string. So the
// returned challenge MUST be valid base64 — sending arbitrary text throws
// FormatException client-side and kicks the login.
[HttpGet("/eac/challenge")]
[HttpPost("/eac/challenge")]
public IActionResult EacChallenge()
=> Content("\"AAAAAAAAAAAAAAAAAAAAAAAA\"", "application/json");
// Cached-logins lookup — boot-sequence call from ScreenAccountSelectionManager.
// URL: GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId}
// Returns List<Login.CachedLogin>. Empty array = no remembered accounts.
//
// Real lookup: any account whose last platformlogin matched
// (platform, platformId) shows up here, sorted most-recent-first. On a
// single-Steam-emu setup that's one row; on a multi-account dev box
// it's the full list the user has cycled through.
[HttpGet("/cachedlogin/forplatformid/{platform:int}/{platformId}")]
public async Task<IActionResult> CachedLoginsForPlatform(int platform, string platformId)
{
@@ -252,10 +35,8 @@ public class AuthController(
/// <summary>POST <c>cachedlogin/forplatformids</c> — bulk variant
/// the watch uses when hydrating multiple platform-id mappings
/// at once (e.g. populating a friend list from Steam ids). Body
/// is form-urlencoded <c>platform=&amp;platformIds=a,b,c</c> or
/// JSON with the same shape. Returns the flattened
/// <c>List&lt;CachedLogin&gt;</c> across every matched id.</summary>
/// at once. Body is form-urlencoded <c>platform=&amp;platformIds=a,b,c</c>
/// or JSON with the same shape.</summary>
[HttpPost("/cachedlogin/forplatformids")]
[HttpGet("/cachedlogin/forplatformids")]
public async Task<IActionResult> CachedLoginsForPlatformIds()
@@ -273,6 +54,7 @@ public class AuthController(
}
}
catch { /* not form */ }
if (string.IsNullOrWhiteSpace(rawIds))
{
int.TryParse(Request.Query["platform"], out platform);
@@ -295,13 +77,50 @@ public class AuthController(
return Ok(results);
}
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new()
[HttpGet("/cachedlogin/current")]
public async Task<IActionResult> CurrentCachedLogin()
{
var token = GetBearerToken();
if (token is null) return Unauthorized();
var id = authService.ValidateToken(token);
if (id is not long playerId) return Unauthorized();
var player = await playerService.GetByIdAsync(playerId);
if (player is null) return Unauthorized();
return Ok(ToCachedLogin(player, player.LastPlatform, player.LastPlatformId));
}
[HttpPost("/cachedlogin/migrate")]
public async Task<IActionResult> MigrateCachedLogin(
[FromForm] int platform = 0,
[FromForm] string? platformId = null)
{
var token = GetBearerToken();
if (token is null) return Unauthorized();
var id = authService.ValidateToken(token);
if (id is not long playerId) return Unauthorized();
await playerService.TagPlatformAsync(playerId, platform, platformId);
var player = await playerService.GetByIdAsync(playerId);
return player is null
? Unauthorized()
: Ok(ToCachedLogin(player, platform, platformId ?? string.Empty));
}
private string? GetBearerToken()
{
var auth = Request.Headers.Authorization.ToString();
if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return auth["Bearer ".Length..].Trim();
return Request.Cookies.TryGetValue(AuthService.AccessCookieName, out var cookieToken)
? cookieToken
: null;
}
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string? platformId) => new()
{
Platform = platform,
PlatformId = platformId,
PlatformId = platformId ?? string.Empty,
AccountId = (int)p.Id,
LastLoginTime = p.LastSeenAt,
RequirePassword = false,
};
}
+11 -9
View File
@@ -1,20 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.15" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
<PackageReference Include="Open.IdentityServer" Version="1.0.0" />
<PackageReference Include="MessagePack" Version="3.1.7" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<!-- RenderedCompactJsonFormatter for stdout JSON logs in production
(Coolify-friendly). The host pipeline reads stdout line-by-line,
and structured JSON lets a future Loki/Datadog ingest parse fields
@@ -25,8 +27,8 @@
scale: SignalR group fanout, OrphanAccountTracker (sticky-session
account-creation flow), and PlayerPresenceService heartbeats all
need shared state across replicas. -->
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="9.0.4" />
<PackageReference Include="StackExchange.Redis" Version="2.13.17" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="10.0.9" />
<!-- S3-compatible object storage. Production target: Garage in
Coolify (one-click). Dev fallback: local MinIO for parity
testing. The SDK speaks plain S3, so swapping endpoints
@@ -38,7 +40,7 @@
cost+salt in the output string so we only need one column. -->
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
<!-- Server-side crop/resize for img.* CDN transforms (?cropSquare=1&width=N
on profile thumbnails). The 2020 client treats this as part of the
image CDN contract — it uploads a full-frame capture and expects the
+1369 -6
View File
@@ -1,17 +1,1380 @@
using DorkNet.Server.Startup;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
using Serilog;
using Serilog.Formatting.Compact;
using StackExchange.Redis;
using System.Text;
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);
builder.AddDorkNetServices();
// ── 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");
var domainConfig = new DomainConfig(apex, domainScheme, domainPort, singleOriginBaseUrl);
builder.Services.AddSingleton(domainConfig);
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 signingKeyProvider = new IdentityServerSigningKeyProvider(builder.Configuration);
builder.Services.AddSingleton(signingKeyProvider);
builder.Services.AddRecRoomIdentityServer(builder.Configuration, domainConfig, signingKeyProvider);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
{
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKeys = signingKeyProvider.ValidationKeys,
ValidateIssuer = true,
ValidIssuers = new[] { domainConfig.AuthIssuer, AuthService.LegacyIssuer },
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;
if (string.IsNullOrEmpty(ctx.Token) &&
ctx.Request.Cookies.TryGetValue(AuthService.AccessCookieName, out var cookieToken) &&
!string.IsNullOrWhiteSpace(cookieToken))
{
ctx.Token = cookieToken;
}
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();
var app = builder.Build();
await app.RunDatabaseBootstrapAsync();
app.UseDorkNetPipeline();
// 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.UseMiddleware<IdentityServerGameTokenRequestMiddleware>();
app.UseIdentityServer();
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"));
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();
}
}
+19 -33
View File
@@ -3,12 +3,16 @@ using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
namespace DorkNet.Server.Services;
public class AuthService(IConfiguration config, DorkNetDbContext db)
public class AuthService(IConfiguration config, DorkNetDbContext db, DomainConfig domain, IdentityServerSigningKeyProvider signingKeyProvider)
{
public const string AccessCookieName = "dorknet_access_token";
public const string LegacyIssuer = "https://api.rec.net/";
// Resolution order MUST match Program.cs's JwtBearer validator setup —
// env var first, config key second — otherwise tokens are signed with
// one secret and validated against another and every authenticated
@@ -38,38 +42,17 @@ public class AuthService(IConfiguration config, DorkNetDbContext db)
private string CreateJwt(long playerId, TimeSpan lifetime, string tokenType)
{
var handler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
// Look up dev/admin flags so the JWT carries a "developer" role
// claim for accounts that should see the in-game debug button.
// The watch's Login.SetAccessToken parses the access_token's
// <c>role</c> claim — either a single string or a list — and
// calls <c>HasRole("developer", token)</c>; that result drives
// <c>Accounts.SetLocalAccountIsDev</c> and ultimately
// <c>SessionManager.IsDeveloper</c>, which the watch UI checks
// when deciding whether to show the dev console toggle.
var isDev = db.Players
.Where(p => p.Id == playerId)
.Select(p => (bool?)(p.IsDeveloper || p.IsAdmin))
.FirstOrDefault() ?? false;
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, playerId.ToString()),
new(ClaimTypes.Role, "gameClient"),
new("token_type", tokenType),
};
// Multiple Role claims become a JSON array on the wire — the
// watch's HasRole helper handles both string and List<string>
// forms.
if (isDev) claims.Add(new Claim(ClaimTypes.Role, "developer"));
var claims = RecRoomIdentityClaims.CreateAsync(db, playerId)
.GetAwaiter()
.GetResult();
claims.Add(new Claim("token_type", tokenType));
var descriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.Add(lifetime),
Issuer = "https://api.rec.net/",
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
Issuer = domain.AuthIssuer,
SigningCredentials = new SigningCredentials(signingKeyProvider.SigningKey, SecurityAlgorithms.RsaSha256),
};
return handler.WriteToken(handler.CreateJwtSecurityToken(descriptor));
@@ -80,19 +63,22 @@ public class AuthService(IConfiguration config, DorkNetDbContext db)
try
{
var handler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
var principal = handler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
IssuerSigningKeys = signingKeyProvider.ValidationKeys,
ValidateIssuer = true,
ValidIssuer = "https://api.rec.net/",
ValidIssuers = new[] { domain.AuthIssuer, LegacyIssuer },
ValidateAudience = false,
ClockSkew = TimeSpan.Zero,
}, out _);
var idClaim = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var idClaim = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value
?? principal.FindFirst("sub")?.Value
?? principal.FindFirst("accountId")?.Value
?? principal.FindFirst("account_id")?.Value
?? principal.FindFirst("accountid")?.Value;
return idClaim is not null ? long.Parse(idClaim) : null;
}
catch
+12 -3
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,12 +115,21 @@ 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] = domain.Url(host);
map[service] = EnsureTrailingSlash(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 + "/";
}
+1
View File
@@ -27,6 +27,7 @@ public sealed class DomainConfig
}
public string Sub(string prefix) => $"{prefix}.{Apex}";
public string AuthIssuer => SubUrl("auth");
public string Url(string host) => Port is null ? $"{Scheme}://{host}" : $"{Scheme}://{host}:{Port}";
public string SubUrl(string prefix) => Url(Sub(prefix));
public static bool MatchesSubdomain(string host, string prefix)
+48
View File
@@ -517,6 +517,7 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
HairColor = StarterHairColor,
SkinColor = StarterSkinColor,
};
ApplyRegistrationDefaults(player);
db.Players.Add(player);
await db.SaveChangesAsync();
@@ -596,6 +597,7 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
HairColor = StarterHairColor,
SkinColor = StarterSkinColor,
};
ApplyRegistrationDefaults(player);
db.Players.Add(player);
await db.SaveChangesAsync();
@@ -629,9 +631,55 @@ 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.
+6 -30
View File
@@ -839,37 +839,13 @@ public class RoomService(DorkNetDbContext db)
}
/// <summary>
/// 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.
/// 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"/>.
/// </summary>
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,
};
public static object ToWireRoom(RoomEntity r) => Compat2018.Room2018Mapper.From(r);
/// <summary>
/// Idempotent admin overrides applied AFTER SeedAsync. Keeps the seed
-349
View File
@@ -1,349 +0,0 @@
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();
}
}
}
@@ -1,92 +0,0 @@
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;
}
}
@@ -1,193 +0,0 @@
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);
}
}
@@ -1,227 +0,0 @@
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");
});
}
}
@@ -1,99 +0,0 @@
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);
});
});
}
}
+1 -15
View File
@@ -1,20 +1,6 @@
import { clearSession, getToken, notifyAuthChange } from './auth';
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';
}
const API_BASE = '/api/admin/v1';
export class ApiError extends Error {
constructor(public status: number, message: string, public body?: unknown) {
@@ -0,0 +1,209 @@
import { useEffect, useState } from 'react';
import { get } from '../lib/api';
import { RefreshCw } from '../components/Icons';
interface IdentityServerSettings {
issuer: string;
discoveryUrl: string;
tokenEndpoint: string;
userInfoEndpoint: string;
client: {
clientId: string;
clientName: string;
allowedGrantTypes: string[];
allowedScopes: string[];
allowOfflineAccess: boolean;
accessTokenLifetime: number;
absoluteRefreshTokenLifetime: number;
slidingRefreshTokenLifetime: number;
refreshTokenUsage: string;
refreshTokenExpiration: string;
};
signing: {
certificateSource: string;
loadedFromBase64: boolean;
certificatePath: string;
certificateExists: boolean;
thumbprint: string;
notBeforeUtc: string;
notAfterUtc: string;
algorithm: string;
};
persistedGrants: {
store: string;
warning: string;
};
}
export function IdentityServer() {
const [settings, setSettings] = useState<IdentityServerSettings | null>(null);
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const load = async () => {
setBusy(true);
try {
const data = await get<IdentityServerSettings>('/identityserver');
setSettings(data);
setErr(null);
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
};
useEffect(() => { void load(); }, []);
return (
<div>
<div className="mb-3 flex justify-end">
<button onClick={load} className="btn-secondary text-xs" disabled={busy}>
<RefreshCw className={busy ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{err && (
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{err}</div>
)}
{!settings && !err && (
<div className="card !p-6 text-center text-xs text-ink-400">Loading...</div>
)}
{settings && (
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-5">
<div className="card !p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-sm font-semibold text-ink-50">IdentityServer</h2>
<p className="mt-1 break-all font-mono text-xs text-ink-300">{settings.issuer}</p>
</div>
<div className="flex flex-wrap gap-2 text-xs">
<StatusBadge ok={settings.signing.certificateExists} on="Signing key" off="Missing key" />
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-3">
<Endpoint label="Discovery" value={settings.discoveryUrl} />
<Endpoint label="Token" value={settings.tokenEndpoint} />
<Endpoint label="UserInfo" value={settings.userInfoEndpoint} />
</div>
</div>
<div className="card !p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-ink-50">Client</h2>
<p className="mt-1 font-mono text-xs text-ink-400">{settings.client.clientId}</p>
</div>
<span className={settings.client.allowOfflineAccess ? 'badge-online' : 'badge-neutral'}>
Offline access {settings.client.allowOfflineAccess ? 'on' : 'off'}
</span>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<ChipList title="Grant types" values={settings.client.allowedGrantTypes} />
<ChipList title="Scopes" values={settings.client.allowedScopes} />
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-3">
<Metric label="Access token" value={duration(settings.client.accessTokenLifetime)} />
<Metric label="Refresh token" value={duration(settings.client.absoluteRefreshTokenLifetime)} />
<Metric label="Refresh mode" value={`${settings.client.refreshTokenUsage} / ${settings.client.refreshTokenExpiration}`} />
</div>
</div>
</div>
<div className="space-y-5">
<div className="card !p-5">
<h2 className="text-sm font-semibold text-ink-50">Signing certificate</h2>
<div className="mt-4 space-y-3">
<Detail label="Source" value={settings.signing.certificateSource} mono />
{!settings.signing.loadedFromBase64 && (
<Detail label="Path" value={settings.signing.certificatePath} mono />
)}
<Detail label="Thumbprint" value={settings.signing.thumbprint} mono />
<Detail label="Algorithm" value={settings.signing.algorithm} mono />
<Detail label="Valid from" value={formatDate(settings.signing.notBeforeUtc)} />
<Detail label="Valid until" value={formatDate(settings.signing.notAfterUtc)} />
</div>
</div>
<div className="card !p-5">
<h2 className="text-sm font-semibold text-ink-50">Operational store</h2>
<div className="mt-3 flex items-center justify-between gap-3">
<span className="text-xs text-ink-400">Persisted grants</span>
<span className="badge-junior">{settings.persistedGrants.store}</span>
</div>
<p className="mt-4 text-xs leading-5 text-ink-400">{settings.persistedGrants.warning}</p>
</div>
</div>
</div>
)}
</div>
);
}
function Endpoint({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-ink-800 bg-ink-950/40 p-3">
<div className="label">{label}</div>
<div className="mt-2 break-all font-mono text-xs text-ink-100">{value}</div>
</div>
);
}
function ChipList({ title, values }: { title: string; values: string[] }) {
return (
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-4">
<h3 className="text-xs font-semibold uppercase text-ink-400">{title}</h3>
<div className="mt-3 flex flex-wrap gap-2">
{values.map((value) => (
<span key={value} className="badge-neutral font-mono normal-case tracking-normal">{value}</span>
))}
</div>
</div>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-3">
<div className="text-[11px] uppercase text-ink-500">{label}</div>
<div className="mt-1 text-sm font-semibold text-ink-50">{value}</div>
</div>
);
}
function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<div className="label">{label}</div>
<div className={(mono ? 'font-mono ' : '') + 'mt-1 break-all text-xs text-ink-100'}>{value}</div>
</div>
);
}
function StatusBadge({ ok, on, off }: { ok: boolean; on: string; off: string }) {
return <span className={ok ? 'badge-online' : 'badge-junior'}>{ok ? on : off}</span>;
}
function formatDate(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function duration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return '0s';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m`;
return `${seconds}s`;
}
@@ -1,4 +1,5 @@
import { Settings } from './Settings';
import { IdentityServer } from './IdentityServer';
import { SignupCodes } from './SignupCodes';
import { PageHeader } from '../components/PageHeader';
import { Tabs, useTabParam } from '../components/Tabs';
@@ -7,19 +8,21 @@ import { Tabs, useTabParam } from '../components/Tabs';
// invite codes (an access-control setting in its own right). Old
// /signup-codes route redirects here with ?tab=signup.
export function SettingsHome() {
const [tab, setTab] = useTabParam<'server' | 'signup'>('server', ['server', 'signup']);
const [tab, setTab] = useTabParam<'server' | 'identity' | 'signup'>('server', ['server', 'identity', 'signup']);
return (
<div>
<PageHeader title="Settings" blurb="Runtime server toggles and single-use signup invite codes." />
<PageHeader title="Settings" blurb="Runtime server toggles, authentication, and single-use signup invite codes." />
<Tabs
tabs={[
{ key: 'server', label: 'Server' },
{ key: 'identity', label: 'IdentityServer' },
{ key: 'signup', label: 'Signup codes' },
]}
active={tab}
onChange={setTab}
/>
{tab === 'server' && <Settings embedded />}
{tab === 'identity' && <IdentityServer />}
{tab === 'signup' && <SignupCodes embedded />}
</div>
);
+22
View File
@@ -0,0 +1,22 @@
{
"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/DorkSquad/Dorknet">GitHub</a>
<a className="hover:text-ink-200" href="https://github.com/Alexa-RR/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/DorkSquad/Dorknet">GitHub</a></p>
<p>DorkNet · a private Rec Room server · <a href="https://github.com/Alexa-RR/Dorknet">GitHub</a></p>
</footer>
<div id="toast" class="toast" hidden></div>
-15
View File
@@ -11,8 +11,6 @@ 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
@@ -59,24 +57,11 @@ 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 DorkSquad <https://github.com/DorkSquad/Dorknet>
Copyright (C) 2026 Alexa <https://github.com/Alexa-RR/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/DorkSquad/Dorknet) by DorkSquad,
"Based on DorkNet (https://github.com/Alexa-RR/Dorknet) by Alexa,
licensed AGPL-3.0."
Acceptable placements: README, server console banner, in-game
credits screen, or web-facing footer of any deployment dashboard.
+5 -1
View File
@@ -72,13 +72,17 @@ cd DorkNet
| `ConnectionStrings:Redis` | `ConnectionStrings__Redis` | no | enables SignalR backplane |
| `Domain:Apex` | `DORKNET_DOMAIN` | no | defaults to `localhost`; set for production |
`/connect/token` issues access and refresh tokens with the configured
auth host as the issuer. Set `DORKNET_DOMAIN` for production so OpenID
discovery and JWT validation agree on `https://auth.<domain>`.
**4. Boot:**
```pwsh
dotnet run --project DorkNet.Server
```
First boot creates `bin/Debug/net9.0/data/dorknet.db`, applies all EF
First boot creates `bin/Debug/net10.0/data/dorknet.db`, applies all EF
migrations under `DorkNet.Server/Migrations/`, seeds canonical rooms +
store catalog, and starts listening. You should see `Application
started.` in the log; `curl http://localhost:8080/healthz` returns 200.
@@ -1,15 +0,0 @@
<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
@@ -1,145 +0,0 @@
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();
}