Compare commits

..

13 Commits

Author SHA1 Message Date
Alexa 4ad6735e93 Use token response generator for legacy fields 2026-06-30 17:15:20 +03:00
Alexa a07e9f41ea Match legacy token cookie behavior 2026-06-30 17:01:59 +03:00
Alexa a23284ce56 Update SQLite native bundle 2026-06-30 16:21:12 +03:00
Alexa 926a5c9bba Support bearer auth on hyphen hosts 2026-06-30 16:15:52 +03:00
Alexa 8ec79ad06c Fix token cookie propagation 2026-06-30 16:10:54 +03:00
Alexa b55627a10a Migrate auth to Open.IdentityServer 2026-06-30 15:47:28 +03:00
Alexa af29fc3e8e Handle signing certificate decode failures 2026-06-30 14:44:39 +03:00
Alexa 4cdc5ee6d0 Harden signing certificate base64 parsing 2026-06-30 14:34:32 +03:00
Alexa 1e0015c18e Use native game auth client only 2026-06-30 14:29:56 +03:00
Alexa e5fedb7376 Support native game auth client 2026-06-30 14:16:34 +03:00
Alexa 704abe8df4 Restore device password grant compatibility 2026-06-30 14:06:01 +03:00
Alexa ab435c3f45 Support env-based signing certificate 2026-06-30 13:54:58 +03:00
Alexa 7617ca0367 Migrate server auth to IdentityServer 2026-06-30 13:27:56 +03:00
55 changed files with 1266 additions and 1975 deletions
-1
View File
@@ -88,4 +88,3 @@ Recovered(*)/
local-releases/**
DorkNet.Models/bin-verify/**
DorkNet.Models/bin-verify2/**
identityserver-signing.pfx
+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
+1 -1
View File
@@ -1,4 +1,4 @@
ARG DOTNET_VERSION=9.0
ARG DOTNET_VERSION=10.0
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine AS admin-ui
+73
View File
@@ -2756,6 +2756,8 @@ internal static class AuthPatches
var accessToken = __0?.ToString();
if (LooksLikeJwt(accessToken))
_latestAccessToken = accessToken;
else if (string.IsNullOrWhiteSpace(__0?.ToString()) && string.IsNullOrWhiteSpace(__1?.ToString()))
_latestAccessToken = null;
if (_tokenLogCount++ < 3)
{
@@ -2782,6 +2784,75 @@ internal static class AuthPatches
private static string Presence(object? value) =>
string.IsNullOrWhiteSpace(value?.ToString()) ? "missing" : "present";
public static void ApplyBearerTokenHeader(object? request)
{
try
{
if (request is null || string.IsNullOrWhiteSpace(_latestAccessToken)) return;
var uri = TryReadUri(request);
if (uri is null || !IsDorkNetServiceHost(uri.Host)) return;
var type = request.GetType();
var setHeader = AccessTools.Method(type, "SetHeader", new[] { typeof(string), typeof(string) })
?? AccessTools.Method(type, "AddHeader", new[] { typeof(string), typeof(string) });
setHeader?.Invoke(request, new object[] { "Authorization", "Bearer " + _latestAccessToken });
}
catch (Exception ex)
{
if (_tokenLogCount++ < 6)
Mod.Log.Warning($"[auth-trace] bearer propagation failed: {ex.Message}");
}
}
private static Uri? TryReadUri(object request)
{
var type = request.GetType();
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
if (TryGetUri(type.GetProperty("Uri", flags), request, out var uri)) return uri;
if (TryGetUri(type.GetField("<Uri>k__BackingField", flags), request, out uri)) return uri;
foreach (var prop in type.GetProperties(flags))
{
if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
if (TryGetUri(prop, request, out uri)) return uri;
}
foreach (var field in type.GetFields(flags))
{
if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
if (TryGetUri(field, request, out uri)) return uri;
}
return null;
}
private static bool TryGetUri(PropertyInfo? prop, object request, out Uri? uri)
{
uri = null;
if (prop is null || !prop.CanRead || prop.GetIndexParameters().Length != 0) return false;
uri = prop.GetValue(request) as Uri;
return uri is not null;
}
private static bool TryGetUri(FieldInfo? field, object request, out Uri? uri)
{
uri = null;
if (field is null) return false;
uri = field.GetValue(request) as Uri;
return uri is not null;
}
private static bool IsDorkNetServiceHost(string host)
{
var configuredHost = Mod.Cfg.ServerHost?.Trim().TrimStart('.');
if (string.IsNullOrWhiteSpace(configuredHost)) return false;
return host.Equals(configuredHost, StringComparison.OrdinalIgnoreCase)
|| host.EndsWith("." + configuredHost, StringComparison.OrdinalIgnoreCase)
|| host.EndsWith("-" + configuredHost, StringComparison.OrdinalIgnoreCase);
}
}
internal static class HttpTracePatches
@@ -3559,6 +3630,7 @@ internal static class UriPatches
public static void HttpManagerSendRequestObject_Prefix(object request)
{
RewriteRequestUri(request, "httpmanager-request-rewrite");
AuthPatches.ApplyBearerTokenHeader(request);
}
public static void UriStringCtor_Prefix(ref string uriString)
@@ -3570,6 +3642,7 @@ internal static class UriPatches
public static void HttpRequestSend_Prefix(object __instance)
{
RewriteRequestUri(__instance, "http-rewrite");
AuthPatches.ApplyBearerTokenHeader(__instance);
}
private static void RewriteRequestUri(object request, string label)
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -46,12 +46,6 @@ public static class DorkNetRouteOwnership
"/discover",
"/featuredrooms",
"/goto",
// 2023 clients renamed the whole goto/* matchmaking surface
// to matchmake/* (matchmake/room, /instance, /invite,
// /chatinvite, …). Same controller as /goto, so it belongs
// to the match service; without this it fell through to the
// Monolith default owner.
"/matchmake",
"/hot_rooms",
"/hot_roomsandplaylists",
"/invite",
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -1,88 +0,0 @@
using System.Net;
using System.Text;
using System.Text.Json;
namespace DorkNet.Server.Tests;
/// <summary>
/// End-to-end coverage for the 3D Charades word-list feature and the
/// profanity-filter toggle. Boots the full server (which runs the seeder)
/// against SQLite via <see cref="DorkNetServerFactory"/> and exercises the
/// public wire endpoints the March 2023 client actually calls.
/// </summary>
public sealed class CharadesWordListTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public CharadesWordListTests(DorkNetServerFactory factory) => _factory = factory;
private HttpClient ApiClient()
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
return client;
}
[Theory]
[InlineData("Charades")] // enum-name form
[InlineData("CharadesAprilFoolsDay")]
[InlineData("Icebreakers")]
[InlineData("0")] // int form the client may send
[InlineData("1")]
[InlineData("2")]
public async Task Words_endpoint_returns_seeded_deck_in_wire_shape(string source)
{
using var client = ApiClient();
using var res = await client.GetAsync($"/api/activities/charades/v1/words/{source}");
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.True(doc.RootElement.GetArrayLength() > 0, $"expected a non-empty deck for '{source}'");
var first = doc.RootElement[0];
// Exact wire key casing the client's LitJson importer reads.
Assert.True(first.TryGetProperty("EN_US", out var en) && en.ValueKind == JsonValueKind.String,
"each card must expose a string EN_US field");
Assert.True(first.TryGetProperty("Difficulty", out var diff) && diff.ValueKind == JsonValueKind.Number,
"each card must expose a numeric Difficulty field");
}
[Fact]
public async Task December_noparam_route_serves_the_charades_slot()
{
using var client = ApiClient();
using var res = await client.GetAsync("/api/activities/charades/v1/words");
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
Assert.True(doc.RootElement.GetArrayLength() > 0);
}
[Fact]
public async Task Unknown_source_falls_back_to_charades_slot()
{
using var client = ApiClient();
using var res = await client.GetAsync("/api/activities/charades/v1/words/totally-bogus");
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.True(doc.RootElement.GetArrayLength() > 0);
}
[Fact]
public async Task Profanity_filter_is_active_by_default()
{
using var client = ApiClient();
using var res = await client.PostAsync(
"/api/sanitize/v1/requestIsStringPure",
new StringContent("{\"Value\":\"you piece of shit\"}", Encoding.UTF8, "application/json"));
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
var body = (await res.Content.ReadAsStringAsync()).Trim();
// Default state: filter on → the profane string is NOT pure.
Assert.Equal("false", body.ToLowerInvariant());
}
}
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
@@ -9,7 +9,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
@@ -17,7 +17,6 @@
<ItemGroup>
<ProjectReference Include="..\DorkNet.Server\DorkNet.Server.csproj" />
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
+2 -17
View File
@@ -1,11 +1,8 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HostFiltering;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
namespace DorkNet.Server.Tests;
@@ -38,8 +35,8 @@ public sealed class DorkNetServerFactory : WebApplicationFactory<Program>
["S3:Endpoint"] = "",
["S3:AccessKey"] = "",
["S3:SecretKey"] = "",
["DorkNet:DefaultClientVersion"] = "december_2020_12_18",
["DorkNet:SupportedVersions:0"] = "december_2020_12_18",
["DorkNet:DefaultClientVersion"] = "march_2023_03_21",
["DorkNet:SupportedVersions:0"] = "march_2023_03_21",
});
});
builder.ConfigureServices(services =>
@@ -56,18 +53,6 @@ public sealed class DorkNetServerFactory : WebApplicationFactory<Program>
options.AllowEmptyHosts = true;
options.IncludeFailureMessage = true;
});
services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtSecret)),
ValidateIssuer = true,
ValidIssuer = "https://api.rec.net/",
ValidateAudience = false,
ClockSkew = TimeSpan.Zero,
};
});
});
}
@@ -61,8 +61,8 @@ public sealed class GameClientEndpointContractTests :
ToHttpMethod(route.Method),
new Uri($"http://{host}{path}"));
request.Headers.Accept.ParseAdd("application/json");
request.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18");
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
request.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
if (NeedsRequestBody(request.Method))
+6 -4
View File
@@ -21,11 +21,13 @@ public static class GameClientSessionFactory
HttpMethod.Post,
new Uri($"http://auth.{apexDomain}/connect/token"));
tokenRequest.Headers.Accept.ParseAdd("application/json");
tokenRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18");
tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
tokenRequest.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
tokenRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "password",
["client_id"] = "recroom",
["client_secret"] = "VxZ53kgbbEaRoZAeMe00MagtgD12GLL2",
["username"] = "EndpointContract",
["device_id"] = deviceId,
["platform"] = "0",
@@ -49,8 +51,8 @@ public static class GameClientSessionFactory
HttpMethod.Get,
new Uri($"http://auth.{apexDomain}/connect/userinfo"));
userInfoRequest.Headers.Accept.ParseAdd("application/json");
userInfoRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18");
userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
userInfoRequest.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
userInfoRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var userInfoResponse = await client.SendAsync(userInfoRequest);
@@ -1,65 +0,0 @@
using System.Net;
using DorkNet.Contracts;
namespace DorkNet.Server.Tests;
/// <summary>
/// Regression coverage for the 2023 "pull a player into a private instance"
/// flow. The 2023 client renamed the matchmaking surface from <c>goto/*</c>
/// to <c>matchmake/*</c> and added <c>matchmake/chatinvite/{account}/{instance}</c>
/// for chat party invites; the server had neither the routes nor the gateway
/// ownership entry, so those requests 404'd.
/// </summary>
public sealed class MatchmakePullInviteTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public MatchmakePullInviteTests(DorkNetServerFactory factory) => _factory = factory;
private HttpClient ApiClient()
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
return client;
}
[Theory]
[InlineData("/matchmake/instance/999")]
[InlineData("/matchmake/invite/999")]
[InlineData("/matchmake/chatinvite/2/16777216")] // {accountId}/{roomInstanceId}
[InlineData("/matchmake/player/2")]
[InlineData("/matchmake/event/999")]
[InlineData("/matchmake/club/999")]
[InlineData("/matchmake/code/5/ABCDEF")] // {roomId}/{code}
public async Task Matchmake_pull_routes_exist(string path)
{
using var client = ApiClient();
using var res = await client.PostAsync(path, new StringContent(string.Empty));
// The bug was a hard 404 (no route). These resolve to a dorm-fallback
// matchmaking response when the instance/invite doesn't exist, so any
// non-404 status proves the route is now mounted.
Assert.NotEqual(HttpStatusCode.NotFound, res.StatusCode);
}
[Theory]
[InlineData("/matchmake/instance/999")]
[InlineData("/matchmake/invite/999")]
[InlineData("/matchmake/chatinvite/2/16777216")]
[InlineData("/matchmake/room/5")]
[InlineData("/matchmake/player/2")]
[InlineData("/matchmake/event/999")]
[InlineData("/matchmake/club/999")]
[InlineData("/matchmake/code/5/ABCDEF")]
public void Matchmake_paths_route_to_same_service_as_goto(string path)
{
const string apex = "dork.test";
var gotoOwner = DorkNetRouteOwnership.ResolvePublicService($"api.{apex}", "/goto/instance/999", apex);
var owner = DorkNetRouteOwnership.ResolvePublicService($"api.{apex}", path, apex);
// matchmake/* must land on the same service that owns goto/* — before
// the fix it fell through to the Monolith default owner.
Assert.Equal(gotoOwner, owner);
}
}
@@ -11,11 +11,11 @@ namespace DorkNet.Server.Tests;
public sealed class VersionCheckControllerTests
{
[Fact]
public void December_2020_build_is_valid_with_minimal_service_config()
public void March_2023_build_is_valid_with_minimal_service_config()
{
var controller = CreateController();
var result = Assert.IsType<OkObjectResult>(controller.Check("20201210", "0"));
var result = Assert.IsType<OkObjectResult>(controller.Check("20230321", "0"));
Assert.Equal(0, ReadVersionStatus(result.Value));
}
@@ -37,9 +37,9 @@ public sealed class VersionCheckControllerTests
[new Late2020VersionPlugin()],
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"december_2020_12_18",
"march_2023_03_21",
},
"december_2020_12_18");
"march_2023_03_21");
return new VersionCheckController(
config,
@@ -0,0 +1,785 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using Open.IdentityServer;
using Open.IdentityServer.Models;
using Open.IdentityServer.ResponseHandling;
using Open.IdentityServer.Services;
using Open.IdentityServer.Stores;
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>();
services.AddTransient<ITokenResponseGenerator, RecRoomTokenResponseGenerator>();
}
}
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,
DomainConfig domain,
IConfiguration config,
ILogger<IdentityServerGameTokenRequestMiddleware> logger)
{
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 InvokeAndPersistAccessTokenCookieAsync(context);
}
private async Task InvokeAndPersistAccessTokenCookieAsync(HttpContext context)
{
var originalBody = context.Response.Body;
await using var bufferedBody = new MemoryStream();
context.Response.Body = bufferedBody;
try
{
await next(context);
bufferedBody.Position = 0;
if (IsSuccessfulJsonResponse(context.Response))
TryPersistAccessTokenCookie(context, bufferedBody);
bufferedBody.Position = 0;
await bufferedBody.CopyToAsync(originalBody);
}
finally
{
context.Response.Body = originalBody;
}
}
private static bool IsSuccessfulJsonResponse(HttpResponse response) =>
response.StatusCode is >= 200 and < 300
&& response.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true;
private void TryPersistAccessTokenCookie(HttpContext context, Stream responseBody)
{
try
{
using var document = JsonDocument.Parse(responseBody);
if (!document.RootElement.TryGetProperty("access_token", out var accessTokenElement))
return;
var accessToken = accessTokenElement.GetString();
if (string.IsNullOrWhiteSpace(accessToken))
return;
var expires = DateTimeOffset.UtcNow.AddHours(12);
if (document.RootElement.TryGetProperty("expires_in", out var expiresInElement) &&
expiresInElement.TryGetInt32(out var expiresIn) &&
expiresIn > 0)
{
expires = DateTimeOffset.UtcNow.AddSeconds(expiresIn);
}
var options = new CookieOptions
{
HttpOnly = true,
Secure = string.Equals(domain.Scheme, "https", StringComparison.OrdinalIgnoreCase),
SameSite = SameSiteMode.None,
Expires = expires,
IsEssential = true,
};
var cookieDomain = ResolveCookieDomain(context.Request.Host.Host);
if (cookieDomain is not null)
options.Domain = cookieDomain;
context.Response.Cookies.Append(AuthService.AccessCookieName, accessToken, options);
logger.LogInformation(
"[auth-cookie] issued access cookie host={Host} domain={Domain} secure={Secure} sameSite={SameSite} essential={IsEssential}",
context.Request.Host.Host,
cookieDomain ?? "<host-only>",
options.Secure,
options.SameSite,
options.IsEssential);
}
catch (JsonException)
{
// Leave the token response untouched if the upstream payload is not JSON.
}
}
private string? ResolveCookieDomain(string host)
{
var configuredCookieDomain =
Environment.GetEnvironmentVariable("DORKNET_AUTH_COOKIE_DOMAIN")
?? config["Auth:CookieDomain"]
?? config["IdentityServer:CookieDomain"];
if (!string.IsNullOrWhiteSpace(configuredCookieDomain))
return configuredCookieDomain.Trim().TrimStart('.') is { Length: > 0 } value ? "." + value : null;
var apex = domain.Apex.Trim().TrimStart('.');
if (string.IsNullOrWhiteSpace(apex) ||
apex.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
apex.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase) ||
apex.Equals("::1", StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (host.Equals(apex, StringComparison.OrdinalIgnoreCase) ||
host.EndsWith("." + apex, StringComparison.OrdinalIgnoreCase))
{
return "." + apex;
}
if (domain.UsesHyphenSubdomains)
{
var parent = HyphenCookieParent(apex, host);
if (parent is not null)
return "." + parent;
}
return null;
}
private static string? HyphenCookieParent(string apex, string host)
{
var parts = apex.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 3)
return null;
var environmentLabel = parts[0];
var parent = string.Join('.', parts.Skip(1));
if (!host.EndsWith("." + parent, StringComparison.OrdinalIgnoreCase))
return null;
var hostLabel = host[..^(parent.Length + 1)];
return hostLabel.EndsWith("-" + environmentLabel, StringComparison.OrdinalIgnoreCase)
? parent
: null;
}
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 RecRoomTokenResponseGenerator(
TimeProvider clock,
ITokenService tokenService,
IRefreshTokenService refreshTokenService,
IScopeParser scopeParser,
IResourceStore resources,
IClientStore clients,
ILogger<TokenResponseGenerator> logger) : ITokenResponseGenerator
{
private readonly TokenResponseGenerator _inner = new(
clock,
tokenService,
refreshTokenService,
scopeParser,
resources,
clients,
logger);
public async Task<TokenResponse> ProcessAsync(TokenRequestValidationResult request)
{
var response = await _inner.ProcessAsync(request);
AddLegacyGameFields(response);
return response;
}
private static void AddLegacyGameFields(TokenResponse response)
{
if (string.IsNullOrWhiteSpace(response.AccessToken))
return;
response.Custom ??= new Dictionary<string, object>(StringComparer.Ordinal);
AddIfMissing(response.Custom, "Access_token", response.AccessToken);
AddIfMissing(response.Custom, "accessToken", response.AccessToken);
AddIfMissing(response.Custom, "AccessToken", response.AccessToken);
AddIfMissing(response.Custom, "token", response.AccessToken);
AddIfMissing(response.Custom, "Token", response.AccessToken);
AddIfMissing(response.Custom, "tokenType", "Bearer");
AddIfMissing(response.Custom, "TokenType", "Bearer");
AddIfMissing(response.Custom, "expiresIn", response.AccessTokenLifetime);
AddIfMissing(response.Custom, "ExpiresIn", response.AccessTokenLifetime);
if (!string.IsNullOrWhiteSpace(response.RefreshToken))
{
AddIfMissing(response.Custom, "Refresh_token", response.RefreshToken);
AddIfMissing(response.Custom, "refreshToken", response.RefreshToken);
AddIfMissing(response.Custom, "RefreshToken", response.RefreshToken);
}
var signingKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
AddIfMissing(response.Custom, "key", signingKey);
AddIfMissing(response.Custom, "Key", signingKey);
}
private static void AddIfMissing(IDictionary<string, object> values, string key, object value)
{
if (!values.ContainsKey(key))
values[key] = value;
}
}
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;
}
}
@@ -1,32 +1,16 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Activities;
/// <summary>
/// api.{rec.net,localhost}/api/activities/* — per-activity content
/// (charades words, party-game prompts, etc.).
///
/// The March 2023 watch fetches a 3D Charades deck at card-box spawn from
/// <c>api/activities/charades/v1/words/{source}</c>, where <c>{source}</c>
/// is one of three baked <c>CardBox.cardSource</c> slots — <c>Charades</c>,
/// <c>CharadesAprilFoolsDay</c>, or <c>Icebreakers</c> (verified in the
/// 2023.03.21 il2cpp dump). We resolve the admin-bound word list for the
/// requested slot via <see cref="CharadesWordListService"/> and return the
/// wire array of <c>{ EN_US, Difficulty }</c>. The older December route
/// (no <c>{source}</c>) maps to the default Charades slot.
/// (charades words, party-game prompts, etc.). The 2020 watch
/// fetches these as a static list at room-join time; we ship an
/// empty list so the activity's bundled defaults kick in.
/// </summary>
[ApiController]
public class ActivitiesController(CharadesWordListService charades) : ControllerBase
public class ActivitiesController : ControllerBase
{
[HttpGet("api/activities/charades/v1/words/{source}")]
public async Task<IActionResult> CharadesWords(string source)
=> Ok(await charades.ResolveWireWordsAsync(source));
/// <summary>December 2020 shape — no source segment. Serves the default
/// Charades slot so the older client still gets a populated deck.</summary>
[HttpGet("api/activities/charades/v1/words")]
public async Task<IActionResult> CharadesWordsDefault()
=> Ok(await charades.ResolveWireWordsAsync(
nameof(CharadesWordListService.CharadesSlot.Charades)));
public IActionResult CharadesWords() => Ok(Array.Empty<object>());
}
@@ -10,29 +10,10 @@ namespace DorkNet.Server.Controllers.API.Sanitize;
/// invention name / chat message to be submitted. Delegates to
/// <see cref="ProfanityFilter"/> for the actual scrubbing. Response
/// shape: <c>{Text (purified), IsClean (bool)}</c>.
///
/// When the admin has flipped
/// <see cref="Data.Entities.ServerSettingsEntity.ProfanityFilterDisabled"/>,
/// every route short-circuits to "clean" — input is returned unmodified
/// and purity checks always pass — so nothing gets censored.
/// </summary>
[ApiController]
public class SanitizeController(
ILogger<SanitizeController> logger,
ServerSettingsService settings) : ControllerBase
public class SanitizeController(ILogger<SanitizeController> logger) : ControllerBase
{
/// <summary>Purify unless the filter is admin-disabled, in which case
/// the input is returned untouched.</summary>
private async Task<string> PurifyAsync(string input)
=> await settings.IsProfanityFilterDisabledAsync()
? input
: ProfanityFilter.Purify(input);
/// <summary>Clean-check unless the filter is admin-disabled, in which
/// case everything is treated as clean.</summary>
private async Task<bool> IsCleanAsync(string input)
=> await settings.IsProfanityFilterDisabledAsync() || ProfanityFilter.IsClean(input);
[HttpPost("api/sanitize/v1")]
[HttpPost("api/sanitize/v1/text")]
public async Task<IActionResult> Sanitize()
@@ -92,7 +73,7 @@ public class SanitizeController(
// ASP.NET Core's StringOutputFormatter doesn't serialize as
// text/plain (raw string sans quotes), which the watch then sees
// as an empty cleanVersion after LitJson decode.
var purified = await PurifyAsync(text);
var purified = ProfanityFilter.Purify(text);
var json = System.Text.Json.JsonSerializer.Serialize(purified);
logger.LogInformation("[sanitize] input=\"{Input}\" purified=\"{Purified}\" json={Json}", text, purified, json);
return Content(json, "application/json");
@@ -114,7 +95,7 @@ public class SanitizeController(
/// <c>{Text}</c> with profanity replaced. Wire shape matches
/// <c>StringSanitization.PurifyString</c>.</summary>
[HttpPost("api/sanitize/v1/purifyString")]
public async Task<IActionResult> PurifyString(
public IActionResult PurifyString(
[FromBody] SanitizeBody? body,
[FromForm(Name = "Text")] string? textForm,
[FromForm(Name = "Input")] string? inputForm,
@@ -123,28 +104,28 @@ public class SanitizeController(
{
var input = body?.Text ?? body?.Input ?? body?.Value
?? textForm ?? inputForm ?? valueForm ?? textQuery ?? string.Empty;
return Ok(new { Text = await PurifyAsync(input) });
return Ok(new { Text = ProfanityFilter.Purify(input) });
}
/// <summary>POST <c>api/sanitize/v1/requestIsStringPure</c> —
/// returns a bare JSON bool. Watch's <c>ExpectPrimitiveResponse</c>
/// rejects object bodies here.</summary>
[HttpPost("api/sanitize/v1/requestIsStringPure")]
public async Task<IActionResult> IsStringPure(
public IActionResult IsStringPure(
[FromBody] SanitizeBody? body,
[FromForm(Name = "Text")] string? textForm,
[FromQuery(Name = "text")] string? textQuery)
{
var input = body?.Text ?? body?.Input ?? body?.Value
?? textForm ?? textQuery ?? string.Empty;
return Ok(await IsCleanAsync(input));
return Ok(ProfanityFilter.IsClean(input));
}
/// <summary>POST <c>api/sanitize/v1/isPure</c> — 2023 route used
/// by CV2 maker pen text such as port names. It imports an object
/// field named <c>IsPure</c>.</summary>
[HttpPost("api/sanitize/v1/isPure")]
public async Task<IActionResult> IsPureV1(
public IActionResult IsPureV1(
[FromBody] SanitizeBody? body,
[FromForm(Name = "Text")] string? textForm,
[FromForm(Name = "Input")] string? inputForm,
@@ -153,7 +134,7 @@ public class SanitizeController(
{
var input = body?.Text ?? body?.Input ?? body?.Value
?? textForm ?? inputForm ?? valueForm ?? textQuery ?? string.Empty;
var isPure = await IsCleanAsync(input);
var isPure = ProfanityFilter.IsClean(input);
logger.LogInformation(
"[sanitize] isPure context={Context} intent={Intent} ruleset={Ruleset} valueLength={ValueLength} isPure={IsPure}",
body?.Context ?? string.Empty,
@@ -170,13 +151,13 @@ public class SanitizeController(
/// used by room/settings forms. Its importer reads an <c>IsPure</c>
/// object field, unlike the newer bare-bool route above.</summary>
[HttpPost("api/sanitize/isPure")]
public async Task<IActionResult> IsPure(
public IActionResult IsPure(
[FromBody] SanitizeBody? body,
[FromForm(Name = "Text")] string? textForm,
[FromQuery(Name = "text")] string? textQuery)
{
var input = body?.Text ?? body?.Input ?? body?.Value
?? textForm ?? textQuery ?? string.Empty;
return Ok(new { IsPure = await IsCleanAsync(input) });
return Ok(new { IsPure = ProfanityFilter.IsClean(input) });
}
}
@@ -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;
@@ -36,10 +37,10 @@ public class AdminController(
PlayerPresenceService playerPresence,
PlayerLogService playerLog,
ServerSettingsService serverSettings,
CharadesWordListService charades,
SignupCodeService signupCodes,
IObjectStorage storage,
DomainConfig domain,
IdentityServerSigningKeyProvider signingKeys,
ILogger<AdminController> adminLogger) : ControllerBase
{
private long CurrentAdminId => this.RequireCurrentPlayerId();
@@ -1480,6 +1481,53 @@ 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")]
@@ -1491,121 +1539,12 @@ public class AdminController(
row.SignupsDisabled,
row.GlobalFriendsEnabled,
row.WeeklyChallengesCompletedRequired,
row.ProfanityFilterDisabled,
row.UpdatedAt,
});
}
public sealed record SignupsToggleRequest(bool Disabled);
public sealed record GlobalFriendsToggleRequest(bool Enabled);
public sealed record ProfanityFilterToggleRequest(bool Disabled);
/// <summary>POST <c>api/admin/v1/settings/profanity</c> — flip the
/// server-side profanity filter. While disabled, every
/// <c>api/sanitize/*</c> route returns input unchanged and treats all
/// text as clean, so room/invention names and chat are never censored.
/// Takes effect immediately (checked per request).</summary>
[HttpPost("settings/profanity")]
public async Task<ActionResult> SetProfanityFilterDisabled([FromBody] ProfanityFilterToggleRequest body)
{
var row = await serverSettings.SetProfanityFilterDisabledAsync(body.Disabled);
await LogAsync(body.Disabled ? "profanity_filter_disabled" : "profanity_filter_enabled", "system", 0, "");
await db.SaveChangesAsync();
return Ok(new
{
row.SignupsDisabled,
row.GlobalFriendsEnabled,
row.WeeklyChallengesCompletedRequired,
row.ProfanityFilterDisabled,
row.UpdatedAt,
});
}
// ── 3D Charades word lists ───────────────────────────────────────────
public sealed record CharadesWordDto(string Text, int Difficulty);
public sealed record CharadesWordListWriteDto(string? Name, List<CharadesWordDto>? Words);
public sealed record CharadesImportDto(string? Text, int DefaultDifficulty, bool Replace);
public sealed record CharadesBindingsDto(long Charades, long CharadesAprilFoolsDay, long Icebreakers);
private static List<CharadesWordListService.CharadesWord>? MapWords(List<CharadesWordDto>? words)
=> words?.Select(w => new CharadesWordListService.CharadesWord(w.Text, w.Difficulty)).ToList();
/// <summary>GET <c>api/admin/v1/charades/wordlists</c> — the whole word
/// list library plus the live slot→list bindings, so the SPA can render
/// the editor and the three slot pickers in one round trip.</summary>
[HttpGet("charades/wordlists")]
public async Task<ActionResult> ListCharadesWordLists()
{
var lists = await charades.GetAllAsync();
var bindings = await charades.GetBindingsAsync();
return Ok(new { lists, bindings });
}
/// <summary>POST <c>api/admin/v1/charades/wordlists</c> — create a new
/// word list (optionally pre-populated).</summary>
[HttpPost("charades/wordlists")]
public async Task<ActionResult> CreateCharadesWordList([FromBody] CharadesWordListWriteDto body)
{
var dto = await charades.CreateAsync(body.Name ?? string.Empty, MapWords(body.Words));
await LogAsync("create_charades_list", "global", dto.Id, dto.Name);
return Ok(dto);
}
/// <summary>PUT <c>api/admin/v1/charades/wordlists/{id}</c> — rename
/// and/or replace the words of a list. Null fields are left as-is.</summary>
[HttpPut("charades/wordlists/{id:long}")]
public async Task<ActionResult> UpdateCharadesWordList(long id, [FromBody] CharadesWordListWriteDto body)
{
var dto = await charades.UpdateAsync(id, body.Name, MapWords(body.Words));
if (dto is null) return NotFound();
await LogAsync("update_charades_list", "global", id, dto.Name);
return Ok(dto);
}
/// <summary>DELETE <c>api/admin/v1/charades/wordlists/{id}</c> — remove a
/// list. Any slot pointing at it is unbound (falls back to its built-in).</summary>
[HttpDelete("charades/wordlists/{id:long}")]
public async Task<ActionResult> DeleteCharadesWordList(long id)
{
var ok = await charades.DeleteAsync(id);
if (!ok) return NotFound();
await LogAsync("delete_charades_list", "global", id, "");
return Ok(new { deleted = id });
}
/// <summary>POST <c>api/admin/v1/charades/wordlists/{id}/import</c> —
/// bulk-add cards from pasted text (one phrase per line, optional
/// <c>| difficulty</c> suffix). <c>Replace=true</c> overwrites the
/// list; otherwise the cards are appended.</summary>
[HttpPost("charades/wordlists/{id:long}/import")]
public async Task<ActionResult> ImportCharadesWordList(long id, [FromBody] CharadesImportDto body)
{
var dto = await charades.ImportAsync(id, body.Text ?? string.Empty, body.DefaultDifficulty, body.Replace);
if (dto is null) return NotFound();
await LogAsync("import_charades_list", "global", id, dto.Name);
return Ok(dto);
}
/// <summary>PUT <c>api/admin/v1/charades/bindings</c> — set which list is
/// live for each of the client's three card-source slots. A list id of
/// 0 unbinds that slot (falls back to its built-in). This is the
/// "switch between lists" control.</summary>
[HttpPut("charades/bindings")]
public async Task<ActionResult> SetCharadesBindings([FromBody] CharadesBindingsDto body)
{
foreach (var listId in new[] { body.Charades, body.CharadesAprilFoolsDay, body.Icebreakers })
{
if (listId != 0 && !await db.CharadesWordLists.AnyAsync(l => l.Id == listId))
return BadRequest(new { error = "unknown_list", id = listId });
}
var bindings = await serverSettings.SetCharadesSlotBindingsAsync(
new CharadesSlotBindings(body.Charades, body.CharadesAprilFoolsDay, body.Icebreakers));
await LogAsync("set_charades_bindings", "system", 0,
$"{bindings.Charades}/{bindings.CharadesAprilFoolsDay}/{bindings.Icebreakers}");
return Ok(bindings);
}
/// <summary>POST <c>api/admin/v1/settings/global-friends</c> — flip the
/// "everyone is friends" toggle. No relationship rows are written; the
+20 -287
View File
@@ -1,6 +1,4 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
@@ -8,258 +6,18 @@ 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");
}
private static Dictionary<string, object> BuildTokenResponse(string accessToken, string refreshToken)
{
var signingKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
return new()
{
["access_token"] = accessToken,
["Access_token"] = accessToken,
["accessToken"] = accessToken,
["AccessToken"] = accessToken,
["token"] = accessToken,
["Token"] = accessToken,
["token_type"] = "Bearer",
["tokenType"] = "Bearer",
["TokenType"] = "Bearer",
["expires_in"] = 43200,
["expiresIn"] = 43200,
["ExpiresIn"] = 43200,
["refresh_token"] = refreshToken,
["Refresh_token"] = refreshToken,
["refreshToken"] = refreshToken,
["RefreshToken"] = refreshToken,
["key"] = signingKey,
["Key"] = signingKey,
["scope"] = "openid profile",
};
}
private IActionResult TokenOk(string accessToken, string refreshToken)
{
var cookie = new CookieOptions
{
HttpOnly = true,
Secure = string.Equals(domain.Scheme, "https", StringComparison.OrdinalIgnoreCase),
SameSite = SameSiteMode.None,
Expires = DateTimeOffset.UtcNow.AddHours(12),
IsEssential = true,
};
if (domain.Apex.Contains('.'))
cookie.Domain = "." + domain.Apex;
Response.Cookies.Append(AuthService.AccessCookieName, accessToken, cookie);
return Ok(BuildTokenResponse(accessToken, refreshToken));
}
// 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 TokenOk(acc1, refr1);
}
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 TokenOk(acc, refr);
}
// 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 TokenOk(pair.AccessToken, pair.RefreshToken);
}
// 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 TokenOk(access, refresh);
}
[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 like
// "dorknet-eac-disabled" throws FormatException client-side and kicks
// the login. 24 chars of base64 'A's decodes to 18 zero bytes; matches
// the rough size of a real EAC handshake nonce.
// 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()
@@ -268,11 +26,6 @@ public class AuthController(
// 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)
{
@@ -282,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()
@@ -303,6 +54,7 @@ public class AuthController(
}
}
catch { /* not form */ }
if (string.IsNullOrWhiteSpace(rawIds))
{
int.TryParse(Request.Query["platform"], out platform);
@@ -328,10 +80,8 @@ public class AuthController(
[HttpGet("/cachedlogin/current")]
public async Task<IActionResult> CurrentCachedLogin()
{
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return Unauthorized();
var token = auth["Bearer ".Length..].Trim();
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);
@@ -344,10 +94,8 @@ public class AuthController(
[FromForm] int platform = 0,
[FromForm] string? platformId = null)
{
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return Unauthorized();
var token = auth["Bearer ".Length..].Trim();
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);
@@ -357,37 +105,22 @@ public class AuthController(
: Ok(ToCachedLogin(player, platform, platformId ?? string.Empty));
}
[HttpPost("/connect/deviceauthorization")]
[HttpGet("/connect/deviceauthorization")]
public async Task<IActionResult> DeviceAuthorization()
private string? GetBearerToken()
{
var deviceCode = Guid.NewGuid().ToString("N");
var userCode = deviceCode[..8].ToUpperInvariant();
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = RelationshipQueries.SystemAccountId,
Key = $"deviceauthorization:{deviceCode}",
Value = $"{userCode}|{DateTime.UtcNow.AddMinutes(15):O}",
});
await db.SaveChangesAsync();
return Ok(new
{
device_code = deviceCode,
user_code = userCode,
verification_uri = $"https://{domain.Sub("auth")}/device",
verification_uri_complete = $"https://{domain.Sub("auth")}/device?user_code={userCode}",
expires_in = 900,
interval = 5,
});
var auth = Request.Headers.Authorization.ToString();
if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return auth["Bearer ".Length..].Trim();
return Request.Cookies.TryGetValue(AuthService.AccessCookieName, out var cookieToken)
? cookieToken
: null;
}
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new()
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string? platformId) => new()
{
Platform = platform,
PlatformId = platformId,
PlatformId = platformId ?? string.Empty,
AccountId = (int)p.Id,
LastLoginTime = p.LastSeenAt,
RequirePassword = false,
};
}
@@ -410,32 +410,14 @@ public class GoToController(
};
[HttpPost("/goto/event/{eventId:long}")]
// 2023 rename goto/event → matchmake/event (RecNet.Matchmaking HHDMGIMLBKF).
[HttpPost("/matchmake/event/{eventId:long}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToEvent(long eventId)
{
// Matchmake the caller into the event's host room. Falls back to
// the dorm if the event is unknown or has no room set.
var roomId = await db.PlayerEvents.AsNoTracking()
.Where(e => e.Id == eventId)
.Select(e => (long?)e.RoomId)
.FirstOrDefaultAsync();
string? roomName = null;
if (roomId is long rid && rid > 0)
roomName = await db.Rooms.AsNoTracking()
.Where(r => r.Id == rid)
.Select(r => r.Name)
.FirstOrDefaultAsync();
var resp = await BuildResponseAsync(
string.IsNullOrWhiteSpace(roomName) ? "DormRoom" : roomName);
var resp = await BuildResponseAsync("DormRoom");
await RecordResponseAsync(resp);
return Ok(resp);
}
[HttpPost("/goto/club/{clubId:long}")]
// 2023 rename goto/club → matchmake/club (RecNet.Matchmaking KPAIGCCHIBN).
[HttpPost("/matchmake/club/{clubId:long}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToClub(long clubId)
{
var club = await db.Clubs.AsNoTracking().FirstOrDefaultAsync(c => c.Id == clubId);
@@ -448,20 +430,7 @@ public class GoToController(
});
}
// Land in the club's clubhouse room when it has one set; otherwise
// fall back to the Rec Center. Either way the response carries the
// ClubId so the watch's club UI knows which club it matchmade for.
string roomName = "RecCenter";
if (club.ClubhouseRoomId is long clubhouseId && clubhouseId > 0)
{
var name = await db.Rooms.AsNoTracking()
.Where(r => r.Id == clubhouseId)
.Select(r => r.Name)
.FirstOrDefaultAsync();
if (!string.IsNullOrWhiteSpace(name)) roomName = name;
}
var resp = await BuildResponseAsync(roomName);
var resp = await BuildResponseAsync("RecCenter");
resp.RoomInstance!.ClubId = club.Id;
await RecordResponseAsync(resp);
return Ok(resp);
@@ -469,12 +438,7 @@ public class GoToController(
[HttpPost("/goto/code")]
[HttpPost("/goto/code/{code}")]
// 2023 sends the room code as the second segment behind the room id
// (RecNet.Matchmaking CPINEGCODMD → "matchmake/code/{roomId}/{code}").
// The code alone resolves the private instance, so the roomId is just
// routing context we don't need — resolve by code either way.
[HttpPost("/matchmake/code/{roomId:long}/{code}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToCode(string? code = null, long roomId = 0)
public async Task<ActionResult<MatchmakingResponseDto>> GoToCode(string? code = null)
{
code ??= Request.HasFormContentType
? (await Request.ReadFormAsync())["code"].FirstOrDefault()
@@ -541,11 +505,6 @@ public class GoToController(
/// dorm if the instance was forgotten (server restart, owner
/// already left and nobody re-created it).</summary>
[HttpPost("/goto/instance/{roomInstanceId:long}")]
// The 2023 client renamed the whole matchmaking surface from goto/* to
// matchmake/* (verified 2023.03.21 il2cpp dump — RecNet.Matchmaking
// ANOAAIHGHMG builds "matchmake/instance/{0}"). Same handler, both
// mounts, so December and 2023 clients each hit the path they build.
[HttpPost("/matchmake/instance/{roomInstanceId:long}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToInstance(long roomInstanceId)
{
var resp = await BuildPrivateInstanceJoinResponseAsync(roomInstanceId);
@@ -553,51 +512,7 @@ public class GoToController(
return Ok(resp);
}
/// <summary>POST <c>matchmake/chatinvite/{accountId}/{roomInstanceId}</c>
/// — the 2023 "pull into my match" path. When a party owner invites
/// someone over chat (PlayerParty.AcceptChatPartyInvite →
/// RecNet.Matchmaking chatinvite, verified in the 2023.03.21 il2cpp
/// dump), the invitee's watch posts here to matchmake into the owner's
/// instance. The two path params are the inviter's account id and the
/// room instance id in an order the client doesn't document; a private
/// instance id is always ≥ 2^24 while account ids are small sequential,
/// so we take the param that resolves to a registered instance (falling
/// back to the larger value). Unlike the message-based
/// <c>POST /invite</c> flow, a chat party invite writes no invitee row,
/// so we add the caller to the allow-list here before resolving the
/// join — the owner explicitly pulled them in.</summary>
[HttpPost("/matchmake/chatinvite/{p0:long}/{p1:long}")]
public async Task<ActionResult<MatchmakingResponseDto>> MatchmakeChatInvite(long p0, long p1)
{
var roomInstanceId = await ResolveChatInviteInstanceAsync(p0, p1);
if (this.CurrentPlayerId() is long callerId && roomInstanceId > 0)
await privateInstances.InviteAsync(roomInstanceId, callerId);
var resp = await BuildPrivateInstanceJoinResponseAsync(roomInstanceId);
await RecordResponseAsync(resp);
logger.LogInformation(
"[matchmake-chatinvite] caller={Caller} p0={P0} p1={P1} → instance={Instance} error={Error}",
this.CurrentPlayerId(), p0, p1, roomInstanceId, resp.ErrorCode);
return Ok(resp);
}
/// <summary>Pick the room-instance param out of a chatinvite's two path
/// segments. Prefers whichever resolves to a registered private
/// instance; if neither does (e.g. a public-room party pull whose id is
/// synthetic), falls back to the larger value since instance ids dwarf
/// account ids.</summary>
private async Task<long> ResolveChatInviteInstanceAsync(long p0, long p1)
{
if (await privateInstances.GetAsync(p0) is not null) return p0;
if (await privateInstances.GetAsync(p1) is not null) return p1;
return Math.Max(p0, p1);
}
[HttpPost("/goto/invite/{inviteId:long}")]
// 2023 rename of goto/invite → matchmake/invite (RecNet.Matchmaking
// OIEDPBLNMOP). {inviteId} is the invite message id, same as December.
[HttpPost("/matchmake/invite/{inviteId:long}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToInvite(long inviteId)
{
var callerId = this.CurrentPlayerId();
@@ -843,9 +758,6 @@ public class GoToController(
/// or on the invite list per <see cref="PrivateInstanceService.CanJoin"/>;
/// otherwise we refuse with errorCode=9 (RoomNotJoinable).</summary>
[HttpPost("/goto/player/{playerId:long}")]
// 2023 rename goto/player → matchmake/player (RecNet.Matchmaking
// FJAFOJHHKPO). Follow a friend into their current room/instance.
[HttpPost("/matchmake/player/{playerId:long}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToPlayer(long playerId)
{
var theirRoom = presence.GetRoom(playerId);
-1
View File
@@ -60,7 +60,6 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
public DbSet<GameSessionEntity> GameSessions => Set<GameSessionEntity>();
public DbSet<CommunityBoardEntity> CommunityBoardRows => Set<CommunityBoardEntity>();
public DbSet<LoadingScreenTipEntity> LoadingScreenTips => Set<LoadingScreenTipEntity>();
public DbSet<CharadesWordListEntity> CharadesWordLists => Set<CharadesWordListEntity>();
public DbSet<ServerSettingsEntity> ServerSettings => Set<ServerSettingsEntity>();
public DbSet<RoomKeyEntity> RoomKeys => Set<RoomKeyEntity>();
public DbSet<RoomKeyPurchaseEntity> RoomKeyPurchases => Set<RoomKeyPurchaseEntity>();
@@ -1,46 +0,0 @@
namespace DorkNet.Server.Data.Entities;
/// <summary>
/// One admin-authored 3D Charades word list. The March 2023 client
/// pulls a deck at card-box spawn time from
/// <c>api/activities/charades/v1/words/{source}</c>, where
/// <c>{source}</c> is one of three baked <c>CardBox.cardSource</c>
/// slots — <c>Charades</c>, <c>CharadesAprilFoolsDay</c>, or
/// <c>Icebreakers</c> (verified in the 2023.03.21 il2cpp dump:
/// <c>GEFMIBEPMKJ.EODJJDILECA</c>). The game itself only ever requests
/// those three ids.
///
/// To let admins keep an unlimited library and freely switch which list
/// each slot serves, every list is a row here (name + words), and the
/// live slot→list binding lives in
/// <see cref="ServerSettingsEntity.CharadesSlotBindingsJson"/>. This is
/// the same admin-editable-collection shape as
/// <see cref="LoadingScreenTipEntity"/>: a plain entity CRUD'd from the
/// SPA, created on existing DBs via the idempotent bootstrap (it
/// post-dates the consolidated Initial migration).
/// </summary>
public class CharadesWordListEntity
{
public long Id { get; set; }
/// <summary>Admin-facing display name, e.g. "Default", "Movie night",
/// "April Fools". Not sent to the client.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Serialized <c>List&lt;CharadesWord&gt;</c> — each entry is
/// <c>{ Text, Difficulty }</c>. <c>Text</c> becomes the wire
/// <c>EN_US</c> field; <c>Difficulty</c> is the client
/// <c>CNMMMNJJDMM</c> enum (0 easy, 1 hard, 10 very hard, 20
/// icebreaker). Stored as JSON so a list can grow to hundreds of
/// cards without a child table.</summary>
public string WordsJson { get; set; } = "[]";
/// <summary>True for the lists seeded on first boot (Default /
/// April Fools / Icebreakers). Purely informational — admins may
/// still rename, edit, or delete them; it only gates the one-time
/// seed so we don't duplicate them every startup.</summary>
public bool IsBuiltIn { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -64,23 +64,5 @@ public class ServerSettingsEntity
/// migration each time.</summary>
public string DiscoveredGameConfigsJson { get; set; } = string.Empty;
/// <summary>When true, the server-side profanity filter behind
/// <c>api/sanitize/*</c> is bypassed — every string is treated as
/// clean and returned unmodified. Built for private friend servers
/// that don't want room/invention names or chat censored. Off by
/// default (filter active). See <see cref="Services.ProfanityFilter"/>
/// and <c>SanitizeController</c>.</summary>
public bool ProfanityFilterDisabled { get; set; }
/// <summary>Which charades word list (row id in
/// <see cref="CharadesWordListEntity"/>) is live for each of the
/// client's three baked card-source slots. JSON object keyed by the
/// client enum name — <c>{"Charades":id,"CharadesAprilFoolsDay":id,
/// "Icebreakers":id}</c>. Empty/0 or a dangling id falls back to the
/// list the seeder created for that slot. Lets admins keep an
/// unlimited library and switch which list a slot serves without a
/// redeploy.</summary>
public string CharadesSlotBindingsJson { get; set; } = string.Empty;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
+12 -9
View File
@@ -1,20 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.15" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
<PackageReference Include="Open.IdentityServer" Version="1.0.0" />
<PackageReference Include="MessagePack" Version="3.1.7" />
<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="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
<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 +28,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 +41,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
+17 -42
View File
@@ -3,13 +3,15 @@ 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
@@ -40,47 +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(JwtRegisteredClaimNames.Sub, playerId.ToString()),
new("accountId", playerId.ToString()),
new("account_id", playerId.ToString()),
new("accountid", playerId.ToString()),
new(ClaimTypes.Role, "gameClient"),
new("roles", "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"));
claims.Add(new Claim("roles", "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));
@@ -91,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
@@ -1,442 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace DorkNet.Server.Services;
/// <summary>
/// Admin-managed library of 3D Charades word lists plus the live
/// slot→list bindings the March 2023 client reads.
///
/// <para>Wire contract (verified in the 2023.03.21 il2cpp dump —
/// <c>CardBox</c> / <c>GEFMIBEPMKJ</c>): the client GETs
/// <c>api/activities/charades/v1/words/{source}</c> at card-box spawn,
/// where <c>{source}</c> is one of three baked <c>cardSource</c> enum
/// slots (<see cref="CharadesSlot"/>). The response is a JSON array of
/// <c>{ "EN_US": string, "Difficulty": int }</c> where Difficulty is the
/// client <c>CNMMMNJJDMM</c> enum (<see cref="DifficultyEasy"/> etc.).</para>
///
/// <para>Storage: each list is a <see cref="CharadesWordListEntity"/> row
/// (unlimited library); the live slot bindings live in
/// <see cref="ServerSettingsEntity.CharadesSlotBindingsJson"/> via
/// <see cref="ServerSettingsService"/>. Switching a slot just repoints its
/// binding — no redeploy, effective on the next card-box refresh.</para>
/// </summary>
public class CharadesWordListService(DorkNetDbContext db, ServerSettingsService settings)
{
/// <summary>The three baked <c>CardBox.cardSource</c> values the client
/// can request. Integer values match the client
/// <c>GEFMIBEPMKJ.EODJJDILECA</c> enum.</summary>
public enum CharadesSlot
{
Charades = 0,
CharadesAprilFoolsDay = 1,
Icebreakers = 2,
}
// Client CNMMMNJJDMM difficulty enum values.
public const int DifficultyEasy = 0;
public const int DifficultyHard = 1;
public const int DifficultyVeryHard = 10;
public const int DifficultyIcebreaker = 20;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
/// <summary>One card as stored/edited in the admin UI.</summary>
public sealed record CharadesWord(string Text, int Difficulty);
/// <summary>A word list as returned to the admin SPA.</summary>
public sealed record CharadesWordListDto(
long Id,
string Name,
IReadOnlyList<CharadesWord> Words,
bool IsBuiltIn,
DateTime UpdatedAt);
/// <summary>Wire entry. Property names are pinned to the exact casing
/// the client's LitJson importer reads (<c>EN_US</c>, <c>Difficulty</c>);
/// LitJson key lookup is case-sensitive.</summary>
public sealed record CharadesWordWire(
[property: JsonPropertyName("EN_US")] string EN_US,
[property: JsonPropertyName("Difficulty")] int Difficulty);
// ---- library CRUD ----------------------------------------------------
public async Task<IReadOnlyList<CharadesWordListDto>> GetAllAsync()
{
var rows = await db.CharadesWordLists.AsNoTracking()
.OrderByDescending(l => l.IsBuiltIn)
.ThenBy(l => l.Id)
.ToListAsync();
return rows.Select(ToDto).ToList();
}
public async Task<CharadesWordListDto?> GetAsync(long id)
{
var row = await db.CharadesWordLists.AsNoTracking()
.FirstOrDefaultAsync(l => l.Id == id);
return row is null ? null : ToDto(row);
}
public async Task<CharadesWordListDto> CreateAsync(
string name, IReadOnlyList<CharadesWord>? words = null, bool isBuiltIn = false)
{
var row = new CharadesWordListEntity
{
Name = CleanName(name),
WordsJson = SerializeWords(NormalizeWords(words ?? [])),
IsBuiltIn = isBuiltIn,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
db.CharadesWordLists.Add(row);
await db.SaveChangesAsync();
return ToDto(row);
}
/// <summary>Rename and/or replace the words of a list. Null args are
/// left untouched.</summary>
public async Task<CharadesWordListDto?> UpdateAsync(
long id, string? name, IReadOnlyList<CharadesWord>? words)
{
var row = await db.CharadesWordLists.FirstOrDefaultAsync(l => l.Id == id);
if (row is null) return null;
if (name is not null) row.Name = CleanName(name);
if (words is not null) row.WordsJson = SerializeWords(NormalizeWords(words));
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return ToDto(row);
}
public async Task<bool> DeleteAsync(long id)
{
var row = await db.CharadesWordLists.FirstOrDefaultAsync(l => l.Id == id);
if (row is null) return false;
db.CharadesWordLists.Remove(row);
await db.SaveChangesAsync();
// If any slot was pointing at the list we just deleted, drop the
// dangling binding so resolution falls back cleanly.
var bindings = await settings.GetCharadesSlotBindingsAsync();
if (bindings.Charades == id || bindings.CharadesAprilFoolsDay == id || bindings.Icebreakers == id)
{
await settings.SetCharadesSlotBindingsAsync(new CharadesSlotBindings(
bindings.Charades == id ? 0 : bindings.Charades,
bindings.CharadesAprilFoolsDay == id ? 0 : bindings.CharadesAprilFoolsDay,
bindings.Icebreakers == id ? 0 : bindings.Icebreakers));
}
return true;
}
/// <summary>Append (or replace) cards parsed from pasted text.
/// One phrase per line; an optional <c>| difficulty</c> suffix per line
/// (easy/hard/veryhard/icebreaker or a raw int) overrides
/// <paramref name="defaultDifficulty"/>.</summary>
public async Task<CharadesWordListDto?> ImportAsync(
long id, string pasteText, int defaultDifficulty, bool replace)
{
var row = await db.CharadesWordLists.FirstOrDefaultAsync(l => l.Id == id);
if (row is null) return null;
var incoming = ParsePaste(pasteText, defaultDifficulty);
var combined = replace
? incoming
: DeserializeWords(row.WordsJson).Concat(incoming).ToList();
row.WordsJson = SerializeWords(NormalizeWords(combined));
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return ToDto(row);
}
// ---- slot bindings ---------------------------------------------------
public Task<CharadesSlotBindings> GetBindingsAsync() => settings.GetCharadesSlotBindingsAsync();
public async Task<CharadesSlotBindings> SetBindingAsync(CharadesSlot slot, long listId)
{
// A listId of 0 clears the binding (falls back to the built-in for
// that slot). A non-zero id must reference an existing list.
if (listId != 0 && !await db.CharadesWordLists.AnyAsync(l => l.Id == listId))
throw new ArgumentException($"No charades word list with id {listId}.");
var current = await settings.GetCharadesSlotBindingsAsync();
var next = slot switch
{
CharadesSlot.Charades => current with { Charades = listId },
CharadesSlot.CharadesAprilFoolsDay => current with { CharadesAprilFoolsDay = listId },
CharadesSlot.Icebreakers => current with { Icebreakers = listId },
_ => current,
};
return await settings.SetCharadesSlotBindingsAsync(next);
}
// ---- client resolution ----------------------------------------------
/// <summary>Resolve the wire deck for a client-requested source. The
/// route param may be the enum name (<c>Charades</c>) or its int
/// (<c>0</c>); unknown values fall back to the Charades slot. Returns
/// the bound list's cards, or that slot's built-in seed list if the
/// binding is empty/dangling, or an empty array as a last resort.</summary>
public async Task<IReadOnlyList<CharadesWordWire>> ResolveWireWordsAsync(string source)
{
var slot = ParseSlot(source);
var bindings = await settings.GetCharadesSlotBindingsAsync();
var boundId = slot switch
{
CharadesSlot.CharadesAprilFoolsDay => bindings.CharadesAprilFoolsDay,
CharadesSlot.Icebreakers => bindings.Icebreakers,
_ => bindings.Charades,
};
CharadesWordListEntity? row = null;
if (boundId != 0)
row = await db.CharadesWordLists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == boundId);
// Fallback: the built-in list whose name matches the slot's default.
row ??= await db.CharadesWordLists.AsNoTracking()
.Where(l => l.IsBuiltIn && l.Name == DefaultListName(slot))
.FirstOrDefaultAsync();
var words = row is null ? [] : DeserializeWords(row.WordsJson);
return words.Select(w => new CharadesWordWire(w.Text, w.Difficulty)).ToList();
}
// ---- seeding ---------------------------------------------------------
/// <summary>Seed the three built-in lists (Default / April Fools /
/// Icebreakers) and point each slot at them. Idempotent: no-ops once any
/// list exists, so admin edits are never clobbered on restart.</summary>
public async Task SeedAsync()
{
if (await db.CharadesWordLists.AnyAsync()) return;
var defaultList = await CreateAsync(
DefaultListName(CharadesSlot.Charades),
DefaultCharadesWords, isBuiltIn: true);
var aprilList = await CreateAsync(
DefaultListName(CharadesSlot.CharadesAprilFoolsDay),
AprilFoolsWords, isBuiltIn: true);
var iceList = await CreateAsync(
DefaultListName(CharadesSlot.Icebreakers),
IcebreakerWords, isBuiltIn: true);
await settings.SetCharadesSlotBindingsAsync(new CharadesSlotBindings(
defaultList.Id, aprilList.Id, iceList.Id));
}
// ---- helpers ---------------------------------------------------------
public static IReadOnlyList<CharadesWord> ParsePaste(string text, int defaultDifficulty)
{
if (string.IsNullOrWhiteSpace(text)) return [];
var result = new List<CharadesWord>();
foreach (var raw in text.Split('\n'))
{
var line = raw.Trim().TrimEnd('\r').Trim();
if (line.Length == 0) continue;
var difficulty = defaultDifficulty;
var pipe = line.LastIndexOf('|');
if (pipe > 0 && pipe < line.Length - 1)
{
var maybe = line[(pipe + 1)..].Trim();
if (TryParseDifficulty(maybe, out var d))
{
difficulty = d;
line = line[..pipe].Trim();
}
}
if (line.Length == 0) continue;
result.Add(new CharadesWord(line, difficulty));
}
return result;
}
private static bool TryParseDifficulty(string s, out int difficulty)
{
switch (s.Replace(" ", string.Empty).ToLowerInvariant())
{
case "easy": difficulty = DifficultyEasy; return true;
case "hard": difficulty = DifficultyHard; return true;
case "veryhard": case "vhard": case "stupidhard": difficulty = DifficultyVeryHard; return true;
case "icebreaker": case "icebreakers": difficulty = DifficultyIcebreaker; return true;
}
if (int.TryParse(s, out var n) &&
n is DifficultyEasy or DifficultyHard or DifficultyVeryHard or DifficultyIcebreaker)
{
difficulty = n;
return true;
}
difficulty = DifficultyEasy;
return false;
}
private static CharadesSlot ParseSlot(string source)
{
if (string.IsNullOrWhiteSpace(source)) return CharadesSlot.Charades;
var s = source.Trim();
if (int.TryParse(s, out var n) && Enum.IsDefined(typeof(CharadesSlot), n))
return (CharadesSlot)n;
if (Enum.TryParse<CharadesSlot>(s, ignoreCase: true, out var slot))
return slot;
return CharadesSlot.Charades;
}
private static string DefaultListName(CharadesSlot slot) => slot switch
{
CharadesSlot.CharadesAprilFoolsDay => "April Fools (impossible)",
CharadesSlot.Icebreakers => "Icebreakers",
_ => "Default",
};
private static CharadesWordListDto ToDto(CharadesWordListEntity row) => new(
row.Id, row.Name, DeserializeWords(row.WordsJson), row.IsBuiltIn, row.UpdatedAt);
private static string CleanName(string name)
{
var trimmed = (name ?? string.Empty).Trim();
if (trimmed.Length == 0) trimmed = "Untitled list";
return trimmed.Length > 128 ? trimmed[..128] : trimmed;
}
/// <summary>Trim, drop blanks, clamp difficulty to a known value, and
/// de-duplicate case-insensitively (keeping first occurrence).</summary>
private static IReadOnlyList<CharadesWord> NormalizeWords(IReadOnlyList<CharadesWord> words)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var result = new List<CharadesWord>();
foreach (var w in words)
{
var text = (w.Text ?? string.Empty).Trim();
if (text.Length == 0) continue;
if (text.Length > 256) text = text[..256];
if (!seen.Add(text)) continue;
var difficulty = w.Difficulty is DifficultyEasy or DifficultyHard
or DifficultyVeryHard or DifficultyIcebreaker
? w.Difficulty
: DifficultyEasy;
result.Add(new CharadesWord(text, difficulty));
}
return result;
}
private static string SerializeWords(IReadOnlyList<CharadesWord> words) =>
JsonSerializer.Serialize(words, JsonOptions);
private static IReadOnlyList<CharadesWord> DeserializeWords(string json)
{
if (string.IsNullOrWhiteSpace(json)) return [];
try
{
return JsonSerializer.Deserialize<List<CharadesWord>>(json, JsonOptions) ?? [];
}
catch
{
return [];
}
}
// ---- seed content ----------------------------------------------------
private static CharadesWord E(string t) => new(t, DifficultyEasy);
private static CharadesWord H(string t) => new(t, DifficultyHard);
private static CharadesWord V(string t) => new(t, DifficultyVeryHard);
private static CharadesWord I(string t) => new(t, DifficultyIcebreaker);
/// <summary>A broad, family-friendly default deck spread across the
/// three charades difficulties (easy / hard / very hard).</summary>
private static readonly IReadOnlyList<CharadesWord> DefaultCharadesWords =
[
// Easy — single actable words
E("Dog"), E("Cat"), E("Elephant"), E("Monkey"), E("Snake"), E("Rabbit"),
E("Penguin"), E("Shark"), E("Spider"), E("Kangaroo"), E("Frog"), E("Horse"),
E("Running"), E("Swimming"), E("Dancing"), E("Sleeping"), E("Eating"), E("Jumping"),
E("Crying"), E("Laughing"), E("Sneezing"), E("Fishing"), E("Cooking"), E("Painting"),
E("Guitar"), E("Piano"), E("Drums"), E("Basketball"), E("Soccer"), E("Tennis"),
E("Skateboard"), E("Bicycle"), E("Airplane"), E("Rocket"), E("Boat"), E("Train"),
E("Umbrella"), E("Telephone"), E("Camera"), E("Toothbrush"), E("Scissors"), E("Hammer"),
E("Pizza"), E("Ice cream"), E("Banana"), E("Popcorn"), E("Birthday cake"), E("Hamburger"),
E("Robot"), E("Ghost"), E("Zombie"), E("Superhero"), E("Pirate"), E("Wizard"),
// Hard — phrases, titles, actions
H("Riding a roller coaster"), H("Brushing your teeth"), H("Tying your shoes"),
H("Blowing out candles"), H("Milking a cow"), H("Climbing a ladder"),
H("Walking a tightrope"), H("Playing video games"), H("Taking a selfie"),
H("Surfing a wave"), H("Building a sandcastle"), H("Chopping wood"),
H("Conducting an orchestra"), H("Directing traffic"), H("Juggling"),
H("The Statue of Liberty"), H("A washing machine"), H("A vending machine"),
H("A traffic light"), H("A grandfather clock"), H("A roller coaster"),
H("Peter Pan"), H("Sherlock Holmes"), H("Frankenstein"), H("Cinderella"),
H("Harry Potter"), H("Spider-Man"), H("The Loch Ness Monster"),
H("Doing laundry"), H("Mowing the lawn"), H("Flying a kite"),
H("Scuba diving"), H("Rock climbing"), H("Bowling a strike"),
// Very hard — abstract-but-still-actable, longer titles
V("Photosynthesis"), V("The theory of relativity"), V("A midlife crisis"),
V("Rush hour traffic"), V("Herding cats"), V("A house of cards"),
V("The circle of life"), V("Breaking the fourth wall"), V("A wild goose chase"),
V("Death and taxes"), V("The domino effect"), V("Time flies when you're having fun"),
V("A blessing in disguise"), V("Barking up the wrong tree"),
V("The elephant in the room"), V("Raining cats and dogs"),
V("A penny for your thoughts"), V("Bite the bullet"),
V("The tip of the iceberg"), V("Once in a blue moon"),
];
/// <summary>April Fools deck — concepts that are (nearly) impossible to
/// act out. All flagged very hard.</summary>
private static readonly IReadOnlyList<CharadesWord> AprilFoolsWords =
[
V("The concept of Tuesday"), V("Existential dread"), V("The smell of rain"),
V("Object-oriented programming"), V("The year 1997"), V("Nostalgia"),
V("The color infrared"), V("A prime number"), V("Dramatic irony"),
V("The stock market"), V("Deja vu"), V("The taste of the number seven"),
V("Quantum entanglement"), V("The feeling of Sunday night"),
V("Sarcasm"), V("The concept of infinity"), V("Static electricity"),
V("The sound of one hand clapping"), V("Impostor syndrome"),
V("The general theory of relativity"), V("A tax audit"),
V("The plot of Inception"), V("Schadenfreude"), V("The Dewey Decimal System"),
V("Awkward silence"), V("The gluten in bread"), V("Inflation"),
V("The concept of a concept"), V("Wi-Fi"), V("The color of Wednesday"),
V("A recursive function"), V("The heat death of the universe"),
V("Peripheral vision"), V("The smell of a new car"),
V("Cryptocurrency"), V("The uncanny valley"), V("A palindrome"),
V("The passage of time"), V("Cognitive dissonance"),
V("The concept of nothing at all"),
];
/// <summary>Icebreaker prompts for the Icebreakers card source. All
/// flagged with the icebreaker difficulty so they group correctly.</summary>
private static readonly IReadOnlyList<CharadesWord> IcebreakerWords =
[
I("Your dream vacation"), I("A hidden talent you have"),
I("Your favorite childhood snack"), I("The best gift you ever got"),
I("A superpower you'd want"), I("Your go-to karaoke song"),
I("The last show you binged"), I("Your comfort food"),
I("A place you'd love to visit"), I("Your favorite season"),
I("The best concert you've been to"), I("Your dream job as a kid"),
I("A weird food combo you love"), I("Your favorite board game"),
I("The best advice you've received"), I("A skill you'd love to learn"),
I("Your favorite holiday"), I("The first thing you'd buy if you won the lottery"),
I("Your most-used emoji"), I("A movie you can quote by heart"),
I("Your favorite way to relax"), I("The pet you have or wish you had"),
I("A hobby you picked up recently"), I("Your favorite ice cream flavor"),
I("The best trip you've ever taken"), I("A song stuck in your head"),
I("Your favorite thing about Rec Room"), I("The coolest place you've been"),
I("A talent show act you'd perform"), I("Your dream superpower team-up"),
];
}
/// <summary>Which word list (row id) is live for each of the client's
/// three baked charades card-source slots. A 0 means "unbound" — the
/// resolver falls back to that slot's built-in seed list.</summary>
public sealed record CharadesSlotBindings(
long Charades,
long CharadesAprilFoolsDay,
long Icebreakers)
{
public static CharadesSlotBindings Empty => new(0, 0, 0);
}
+2
View File
@@ -28,6 +28,8 @@ public sealed class DomainConfig
return UsesHyphenSubdomains ? $"{prefix}-{Apex}" : $"{prefix}.{Apex}";
}
public string AuthIssuer => Url("auth");
public string Url(string prefix, string path = "")
{
var host = string.IsNullOrEmpty(prefix) ? Apex : Sub(prefix);
@@ -151,52 +151,6 @@ public class ServerSettingsService(DorkNetDbContext db)
return ToWeeklySettings(existing);
}
/// <summary>Whether the server-side profanity filter behind
/// <c>api/sanitize/*</c> is bypassed. Off by default (filter active).</summary>
public async Task<bool> IsProfanityFilterDisabledAsync()
{
var row = await db.ServerSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == RowId);
return row?.ProfanityFilterDisabled ?? false;
}
public async Task<ServerSettingsEntity> SetProfanityFilterDisabledAsync(bool disabled)
{
var existing = await GetTrackedRowAsync();
existing.ProfanityFilterDisabled = disabled;
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return existing;
}
/// <summary>Live charades slot→list bindings. Empty/unparseable JSON
/// resolves to <see cref="CharadesSlotBindings.Empty"/> (every slot
/// falls back to its built-in seed list).</summary>
public async Task<CharadesSlotBindings> GetCharadesSlotBindingsAsync()
{
var row = await GetAsync();
if (string.IsNullOrWhiteSpace(row.CharadesSlotBindingsJson))
return CharadesSlotBindings.Empty;
try
{
return JsonSerializer.Deserialize<CharadesSlotBindings>(
row.CharadesSlotBindingsJson, JsonOptions) ?? CharadesSlotBindings.Empty;
}
catch
{
return CharadesSlotBindings.Empty;
}
}
public async Task<CharadesSlotBindings> SetCharadesSlotBindingsAsync(CharadesSlotBindings bindings)
{
var existing = await GetTrackedRowAsync();
existing.CharadesSlotBindingsJson = JsonSerializer.Serialize(bindings, JsonOptions);
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return bindings;
}
private async Task<ServerSettingsEntity> GetTrackedRowAsync()
{
var existing = await db.ServerSettings.FirstOrDefaultAsync(s => s.Id == RowId);
@@ -103,7 +103,6 @@ public static class DatabaseBootstrap
// IF NOT EXISTS no-ops when EnsureCreated already built them).
await EnsureSignupCodeTablesAsync(db);
await EnsureServerSettingsColumnsAsync(db);
await EnsureCharadesWordListTableAsync(db);
await EnsureInventorySchemaAsync(db);
await EnsureLeaderboardSchemaAsync(db);
await EnsureRoomColumnsAsync(db);
@@ -169,13 +168,6 @@ public static class DatabaseBootstrap
var storeService = scope.ServiceProvider.GetRequiredService<StoreService>();
await storeService.SeedAsync();
// Seed the three built-in 3D Charades word lists (Default / April
// Fools / Icebreakers) and bind each client card-source slot to
// them. Idempotent — no-ops once any list exists so admin edits
// survive restarts.
var charadesService = scope.ServiceProvider.GetRequiredService<CharadesWordListService>();
await charadesService.SeedAsync();
// Tell the /healthz probe migrations are done. Coolify's rolling
// deploy holds traffic at the LB until this flips and /healthz
// returns 200.
@@ -446,10 +438,6 @@ public static class DatabaseBootstrap
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""DiscoveredGameConfigsJson"" text NOT NULL DEFAULT '';");
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""GlobalFriendsEnabled"" boolean NOT NULL DEFAULT false;");
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""ProfanityFilterDisabled"" boolean NOT NULL DEFAULT false;");
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""CharadesSlotBindingsJson"" text NOT NULL DEFAULT '';");
return;
}
@@ -500,30 +488,6 @@ public static class DatabaseBootstrap
// ignore the duplicate-column error; any other schema problem
// will surface when EF reads ServerSettings.
}
try
{
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""ServerSettings"" ADD COLUMN ""ProfanityFilterDisabled"" INTEGER NOT NULL DEFAULT 0;");
}
catch
{
// SQLite has no ADD COLUMN IF NOT EXISTS. If it already exists,
// ignore the duplicate-column error; any other schema problem
// will surface when EF reads ServerSettings.
}
try
{
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""ServerSettings"" ADD COLUMN ""CharadesSlotBindingsJson"" TEXT NOT NULL DEFAULT '';");
}
catch
{
// SQLite has no ADD COLUMN IF NOT EXISTS. If it already exists,
// ignore the duplicate-column error; any other schema problem
// will surface when EF reads ServerSettings.
}
}
private static async Task EnsureInventorySchemaAsync(DorkNetDbContext db)
@@ -661,41 +625,6 @@ public static class DatabaseBootstrap
@"CREATE INDEX IF NOT EXISTS ""IX_LeaderboardStats_RoomId_StatChannel_Value"" ON ""LeaderboardStats"" (""RoomId"", ""StatChannel"", ""Value"");");
}
/// <summary>Idempotently create the CharadesWordLists table on both
/// providers. Post-dates the consolidated Initial migration (same
/// rationale as <see cref="EnsureSignupCodeTablesAsync"/>): Postgres
/// is EnsureCreated-only and the SQLite Initial migration doesn't
/// include it, so we create it here with CREATE TABLE IF NOT EXISTS
/// (a no-op on a fresh DB where EnsureCreated/convention already
/// built it).</summary>
private static async Task EnsureCharadesWordListTableAsync(DorkNetDbContext db)
{
var provider = db.Database.ProviderName ?? string.Empty;
var pg = provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase);
var sql = pg
? """
CREATE TABLE IF NOT EXISTS "CharadesWordLists" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"Name" text NOT NULL DEFAULT '',
"WordsJson" text NOT NULL DEFAULT '[]',
"IsBuiltIn" boolean NOT NULL DEFAULT false,
"CreatedAt" timestamp with time zone NOT NULL DEFAULT now(),
"UpdatedAt" timestamp with time zone NOT NULL DEFAULT now()
);
"""
: """
CREATE TABLE IF NOT EXISTS "CharadesWordLists" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CharadesWordLists" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NOT NULL DEFAULT '',
"WordsJson" TEXT NOT NULL DEFAULT '[]',
"IsBuiltIn" INTEGER NOT NULL DEFAULT 0,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
""";
await db.Database.ExecuteSqlRawAsync(sql);
}
private static async Task EnsureMarch2023TablesAsync(DorkNetDbContext db)
{
var provider = db.Database.ProviderName ?? string.Empty;
@@ -87,6 +87,9 @@ public static class MiddlewarePipelineExtensions
// calls (login, photon custom auth) are gated too.
app.UseMiddleware<VersionDetectionMiddleware>();
app.UseMiddleware<IdentityServerGameTokenRequestMiddleware>();
app.UseIdentityServer();
app.UseAuthentication();
app.UseAuthorization();
// Sits between auth and the controllers — by this point ctx.User has
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using DorkNet.Server.Auth;
using DorkNet.Server.Compat;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
@@ -24,11 +25,11 @@ public static class ServiceCollectionExtensions
{
ConfigureLogging(builder);
AddDatabase(builder);
AddDomain(builder);
var domain = AddDomain(builder);
AddCoreServices(builder.Services);
AddVersionCompatibility(builder);
var redisConn = AddRedis(builder);
AddAuthentication(builder);
AddAuthentication(builder, domain);
AddKestrelAndSignalR(builder, redisConn);
}
@@ -108,7 +109,7 @@ public static class ServiceCollectionExtensions
// Replaces per-controller [Host(...)] filters that hard-coded
// rec.net/localhost pairs — the singleton DomainConfig is injected
// anywhere code needs to build outbound URLs.
private static void AddDomain(WebApplicationBuilder builder)
private static DomainConfig AddDomain(WebApplicationBuilder builder)
{
var apex =
builder.Configuration["Domain:Apex"]
@@ -147,6 +148,7 @@ public static class ServiceCollectionExtensions
opt.IncludeFailureMessage = true;
});
Console.WriteLine($"[domain] apex={apex}, style={domain.HostStyle}, allowedHosts=[{string.Join(", ", allowedHosts)}]");
return domain;
}
private static readonly string[] KnownServiceSubdomains =
@@ -217,7 +219,6 @@ public static class ServiceCollectionExtensions
services.AddScoped<PrivateInstanceService>();
services.AddScoped<CommunityBoardService>();
services.AddScoped<ServerSettingsService>();
services.AddScoped<CharadesWordListService>();
// Singletons — these own connectionless state (Redis-backed or
// process-local) and don't need a per-request scope.
services.AddSingleton<NotificationService>();
@@ -320,15 +321,11 @@ public static class ServiceCollectionExtensions
// Never commit a real secret to appsettings.json. DORKNET_JWT_SECRET is the
// canonical env var name; RECNET_JWT_SECRET kept for backward compat with
// older Coolify configs.
private static void AddAuthentication(WebApplicationBuilder builder)
private static void AddAuthentication(WebApplicationBuilder builder, DomainConfig domain)
{
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 (the install-plugin.ps1 " +
"script generates one automatically on same-machine setup).");
var signingKeyProvider = new IdentityServerSigningKeyProvider(builder.Configuration);
builder.Services.AddSingleton(signingKeyProvider);
builder.Services.AddRecRoomIdentityServer(builder.Configuration, domain, signingKeyProvider);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
@@ -336,9 +333,9 @@ public static class ServiceCollectionExtensions
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
IssuerSigningKeys = signingKeyProvider.ValidationKeys,
ValidateIssuer = true,
ValidIssuer = "https://api.rec.net/",
ValidIssuers = new[] { domain.AuthIssuer, AuthService.LegacyIssuer },
ValidateAudience = false,
ClockSkew = TimeSpan.Zero,
};
@@ -1,429 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { api, get } from '../lib/api';
import { PageHeader } from '../components/PageHeader';
import { useToast } from '../components/Toast';
import { Plus, Trash, RefreshCw } from '../components/Icons';
// Difficulty values mirror the client CNMMMNJJDMM enum (verified in the
// 2023.03.21 il2cpp dump). The Charades slot uses easy/hard/very-hard; the
// Icebreakers slot uses its own value so cards group correctly in-game.
const DIFFICULTIES = [
{ value: 0, label: 'Easy' },
{ value: 1, label: 'Hard' },
{ value: 10, label: 'Very hard' },
{ value: 20, label: 'Icebreaker' },
] as const;
// The three baked client card-source slots. Each is fed by whichever
// library list its binding points at.
const SLOTS = [
{
key: 'charades' as const,
title: 'Charades',
blurb: 'The main 3D Charades deck players draw from.',
},
{
key: 'charadesAprilFoolsDay' as const,
title: 'April Fools',
blurb: 'The seasonal "impossible to explain" deck.',
},
{
key: 'icebreakers' as const,
title: 'Icebreakers',
blurb: 'Prompt-style cards for the Icebreakers card box.',
},
];
interface CharadesWord {
text: string;
difficulty: number;
}
interface WordList {
id: number;
name: string;
words: CharadesWord[];
isBuiltIn: boolean;
updatedAt: string;
}
interface SlotBindings {
charades: number;
charadesAprilFoolsDay: number;
icebreakers: number;
}
interface WordListsResponse {
lists: WordList[];
bindings: SlotBindings;
}
export function Charades({ embedded }: { embedded?: boolean } = {}) {
const [lists, setLists] = useState<WordList[] | null>(null);
const [bindings, setBindings] = useState<SlotBindings | null>(null);
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const toast = useToast();
const load = async () => {
try {
const data = await get<WordListsResponse>('/charades/wordlists');
setLists(data.lists);
setBindings(data.bindings);
setErr(null);
} catch (e) {
setErr((e as Error).message);
}
};
useEffect(() => { void load(); }, []);
const updateLocal = (idx: number, next: WordList) => {
setLists(cur => {
if (!cur) return cur;
const copy = cur.slice();
copy[idx] = next;
return copy;
});
};
const addNew = async () => {
setBusy(true);
try {
const created = await api<WordList>('/charades/wordlists', {
method: 'POST',
body: { Name: 'New list', Words: [] },
});
setLists([...(lists ?? []), created]);
toast.push('List created.', 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
const saveList = async (idx: number) => {
if (!lists) return;
const l = lists[idx];
try {
const saved = await api<WordList>(`/charades/wordlists/${l.id}`, {
method: 'PUT',
body: { Name: l.name, Words: l.words },
});
updateLocal(idx, saved);
toast.push('Saved.', 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
const removeList = async (idx: number) => {
if (!lists) return;
const l = lists[idx];
const boundSlot = bindings && (
bindings.charades === l.id ||
bindings.charadesAprilFoolsDay === l.id ||
bindings.icebreakers === l.id);
const warn = boundSlot
? ` It's currently live on a slot — that slot will fall back to its built-in list.`
: '';
if (!confirm(`Delete list "${l.name || '(untitled)'}"?${warn}`)) return;
try {
await api(`/charades/wordlists/${l.id}`, { method: 'DELETE' });
setLists(lists.filter((_, i) => i !== idx));
// Reflect any binding fallback locally.
setBindings(cur => cur ? {
charades: cur.charades === l.id ? 0 : cur.charades,
charadesAprilFoolsDay: cur.charadesAprilFoolsDay === l.id ? 0 : cur.charadesAprilFoolsDay,
icebreakers: cur.icebreakers === l.id ? 0 : cur.icebreakers,
} : cur);
toast.push('Deleted.', 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
const importInto = async (idx: number, text: string, defaultDifficulty: number, replace: boolean) => {
if (!lists) return;
const l = lists[idx];
try {
const saved = await api<WordList>(`/charades/wordlists/${l.id}/import`, {
method: 'POST',
body: { Text: text, DefaultDifficulty: defaultDifficulty, Replace: replace },
});
updateLocal(idx, saved);
toast.push(replace ? 'List replaced from paste.' : 'Cards imported.', 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
const saveBindings = async () => {
if (!bindings) return;
setBusy(true);
try {
const saved = await api<SlotBindings>('/charades/bindings', {
method: 'PUT',
body: {
Charades: bindings.charades,
CharadesAprilFoolsDay: bindings.charadesAprilFoolsDay,
Icebreakers: bindings.icebreakers,
},
});
setBindings(saved);
toast.push('Live slots updated — effective on next card-box refresh.', 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
const headerActions = (
<div className="flex gap-2">
<button onClick={load} className="btn-secondary text-xs" disabled={busy}>
<RefreshCw className={busy ? 'animate-spin' : ''} /> Refresh
</button>
<button onClick={addNew} className="btn-primary text-xs" disabled={busy}>
<Plus /> New list
</button>
</div>
);
return (
<div>
{embedded ? (
<div className="flex justify-end mb-3">{headerActions}</div>
) : (
<PageHeader
title="3D Charades words"
blurb="Keep an unlimited library of word lists and pick which one is live for each in-game card box. The March 2023 client fetches the bound list at card-box spawn."
actions={headerActions}
/>
)}
{err && (
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{err}</div>
)}
{lists === null && !err && (
<div className="card !p-6 text-center text-xs text-ink-400">Loading…</div>
)}
{lists && bindings && (
<div className="card !p-5 mb-5">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-ink-50">Live card slots</h2>
<p className="mt-1 text-xs text-ink-400">
The client only ever requests these three card sources. Point each at any list in
your library to switch decks — no redeploy. Changes apply the next time a card box
spawns (room rejoin).
</p>
</div>
<button onClick={saveBindings} disabled={busy} className="btn-primary text-xs shrink-0">
{busy ? 'Saving…' : 'Save slots'}
</button>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
{SLOTS.map(slot => (
<label key={slot.key} className="block rounded-lg border border-ink-800 bg-ink-950/30 p-3">
<span className="text-sm font-semibold text-ink-100">{slot.title}</span>
<span className="mt-0.5 block text-[11px] text-ink-500">{slot.blurb}</span>
<select
className="input mt-2 text-xs"
value={bindings[slot.key]}
onChange={e => setBindings({ ...bindings, [slot.key]: parseInt(e.target.value) || 0 })}
>
<option value={0}>— built-in fallback —</option>
{lists.map(l => (
<option key={l.id} value={l.id}>
{l.name} ({l.words.length})
</option>
))}
</select>
</label>
))}
</div>
</div>
)}
{lists && lists.length === 0 && (
<div className="card !p-6 text-center text-xs text-ink-400">
No lists yet — add one with the button above.
</div>
)}
{lists && (
<div className="space-y-3">
{lists.map((l, i) => (
<ListEditor
key={l.id}
list={l}
liveSlots={liveSlotsFor(l.id, bindings)}
onChange={next => updateLocal(i, next)}
onSave={() => saveList(i)}
onRemove={() => removeList(i)}
onImport={(text, diff, replace) => importInto(i, text, diff, replace)}
/>
))}
</div>
)}
</div>
);
}
function liveSlotsFor(id: number, bindings: SlotBindings | null): string[] {
if (!bindings) return [];
const out: string[] = [];
if (bindings.charades === id) out.push('Charades');
if (bindings.charadesAprilFoolsDay === id) out.push('April Fools');
if (bindings.icebreakers === id) out.push('Icebreakers');
return out;
}
function ListEditor({
list,
liveSlots,
onChange,
onSave,
onRemove,
onImport,
}: {
list: WordList;
liveSlots: string[];
onChange: (next: WordList) => void;
onSave: () => void;
onRemove: () => void;
onImport: (text: string, defaultDifficulty: number, replace: boolean) => void;
}) {
const [open, setOpen] = useState(false);
const [pasteText, setPasteText] = useState('');
const [pasteDifficulty, setPasteDifficulty] = useState(0);
const [pasteReplace, setPasteReplace] = useState(false);
const counts = useMemo(() => {
const by = new Map<number, number>();
for (const w of list.words) by.set(w.difficulty, (by.get(w.difficulty) ?? 0) + 1);
return by;
}, [list.words]);
const setWord = (idx: number, patch: Partial<CharadesWord>) => {
const words = list.words.slice();
words[idx] = { ...words[idx], ...patch };
onChange({ ...list, words });
};
const removeWord = (idx: number) => {
onChange({ ...list, words: list.words.filter((_, i) => i !== idx) });
};
const addWord = () => {
onChange({ ...list, words: [...list.words, { text: '', difficulty: 0 }] });
};
const runImport = () => {
if (!pasteText.trim()) return;
onImport(pasteText, pasteDifficulty, pasteReplace);
setPasteText('');
};
return (
<div className="card !p-4">
<div className="flex items-center gap-3">
<input
value={list.name}
onChange={e => onChange({ ...list, name: e.target.value })}
className="input text-sm font-semibold flex-1 min-w-0"
maxLength={128}
/>
{list.isBuiltIn && <span className="badge-admin shrink-0">built-in</span>}
{liveSlots.map(s => <span key={s} className="badge-online shrink-0">live: {s}</span>)}
<span className="text-[11px] text-ink-500 shrink-0">{list.words.length} cards</span>
<button onClick={() => setOpen(o => !o)} className="btn-secondary text-xs shrink-0">
{open ? 'Collapse' : 'Edit'}
</button>
</div>
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-ink-400">
{DIFFICULTIES.map(d => counts.get(d.value)
? <span key={d.value} className="badge-neutral">{d.label}: {counts.get(d.value)}</span>
: null)}
</div>
{open && (
<div className="mt-4 space-y-4">
<div className="space-y-2">
{list.words.map((w, i) => (
<div key={i} className="flex items-center gap-2">
<input
value={w.text}
onChange={e => setWord(i, { text: e.target.value })}
placeholder="Card phrase"
className="input text-xs flex-1"
maxLength={256}
/>
<select
value={w.difficulty}
onChange={e => setWord(i, { difficulty: parseInt(e.target.value) })}
className="input text-xs w-32 shrink-0"
>
{DIFFICULTIES.map(d => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
<button onClick={() => removeWord(i)} className="btn-ghost text-xs text-danger shrink-0" title="Remove card">
<Trash />
</button>
</div>
))}
<button onClick={addWord} className="btn-secondary text-xs"><Plus /> Add card</button>
</div>
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-3">
<h4 className="text-xs font-semibold text-ink-200">Import from paste</h4>
<p className="mt-0.5 text-[11px] text-ink-500">
One phrase per line. Add <span className="font-mono">| easy</span>,{' '}
<span className="font-mono">| hard</span>, <span className="font-mono">| veryhard</span> or{' '}
<span className="font-mono">| icebreaker</span> after a line to set its difficulty.
</p>
<textarea
value={pasteText}
onChange={e => setPasteText(e.target.value)}
placeholder={'Dancing\nRiding a bike | hard\nThe concept of time | veryhard'}
className="input text-xs w-full mt-2 font-mono"
rows={5}
/>
<div className="mt-2 flex flex-wrap items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-ink-300">
Default difficulty
<select
value={pasteDifficulty}
onChange={e => setPasteDifficulty(parseInt(e.target.value))}
className="input text-xs w-28"
>
{DIFFICULTIES.map(d => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
</label>
<label className="flex items-center gap-1.5 text-xs text-ink-300 cursor-pointer">
<input type="checkbox" checked={pasteReplace} onChange={e => setPasteReplace(e.target.checked)} />
Replace existing cards
</label>
<div className="flex-1" />
<button onClick={runImport} disabled={!pasteText.trim()} className="btn-primary text-xs">
{pasteReplace ? 'Replace from paste' : 'Import cards'}
</button>
</div>
</div>
</div>
)}
<div className="mt-4 flex items-center gap-3">
<span className="text-[11px] text-ink-600 flex-1">
Last saved {new Date(list.updatedAt).toLocaleString()}
</span>
<button onClick={onSave} className="btn-primary text-xs">Save</button>
<button onClick={onRemove} className="btn-ghost text-xs text-danger" title="Delete list">
<Trash />
</button>
</div>
</div>
);
}
+5 -10
View File
@@ -1,31 +1,26 @@
import { CommunityBoard } from './CommunityBoard';
import { LoadingTips } from './LoadingTips';
import { Charades } from './Charades';
import { PageHeader } from '../components/PageHeader';
import { Tabs, useTabParam } from '../components/Tabs';
// The player-facing content surfaces the watch pulls at runtime — the
// community board and loading-screen tips shown on dorm load, plus the 3D
// Charades word lists fetched at card-box spawn — share one page. Old
// /community and /loading-tips routes redirect here.
// The two player-facing content surfaces the watch pulls on dorm load —
// the community board and the loading-screen tips — share one page.
// Old /community and /loading-tips routes redirect here.
export function Content() {
const [tab, setTab] = useTabParam<'community' | 'tips' | 'charades'>(
'community', ['community', 'tips', 'charades']);
const [tab, setTab] = useTabParam<'community' | 'tips'>('community', ['community', 'tips']);
return (
<div>
<PageHeader title="Content" blurb="Player-facing content: the community board, loading-screen tips, and 3D Charades word lists." />
<PageHeader title="Content" blurb="Player-facing content shown on dorm load: the community board and loading-screen tips." />
<Tabs
tabs={[
{ key: 'community', label: 'Community board' },
{ key: 'tips', label: 'Loading tips' },
{ key: 'charades', label: 'Charades words' },
]}
active={tab}
onChange={setTab}
/>
{tab === 'community' && <CommunityBoard embedded />}
{tab === 'tips' && <LoadingTips embedded />}
{tab === 'charades' && <Charades embedded />}
</div>
);
}
@@ -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`;
}
@@ -8,7 +8,6 @@ interface ServerSettings {
signupsDisabled: boolean;
globalFriendsEnabled: boolean;
weeklyChallengesCompletedRequired: boolean;
profanityFilterDisabled: boolean;
updatedAt: string;
}
@@ -216,30 +215,6 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
}
};
const toggleProfanity = async () => {
if (!settings) return;
const next = !settings.profanityFilterDisabled;
const verb = next ? 'turn OFF the profanity filter' : 'turn the profanity filter back on';
if (!confirm(
next
? 'Turn off the profanity filter? Room names, invention names and chat will no longer be censored by the server. Toggle back on any time.'
: `Really ${verb}? Text will be filtered again server-side.`,
)) return;
setBusy(true);
try {
const updated = await api<ServerSettings>('/settings/profanity', {
method: 'POST',
body: { Disabled: next },
});
setSettings(updated);
toast.push(next ? 'Profanity filter disabled.' : 'Profanity filter enabled.', 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
const updateWeeklyChallenge = (index: number, patch: Partial<WeeklyChallenge>) => {
setWeekly((current) => {
if (!current) return current;
@@ -510,37 +485,6 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
</div>
)}
{settings && (
<div className="card !p-5 max-w-2xl mt-5">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-ink-50">Profanity filter</h2>
<p className="mt-1 text-xs text-ink-400">
The server-side filter behind <span className="font-mono">api/sanitize/*</span> that
the watch calls before letting a player submit a room name, invention name, or chat
message. Turn it off to stop censoring text entirely — every check returns "clean"
and input is passed through unchanged. Takes effect immediately.
</p>
<p className="mt-2 text-[11px] text-ink-500">
Last changed {new Date(settings.updatedAt).toLocaleString()}.
</p>
</div>
<button
onClick={toggleProfanity}
disabled={busy}
className={(settings.profanityFilterDisabled ? 'btn-primary' : 'btn-danger') + ' text-xs shrink-0'}
>
{busy ? 'Working…' : settings.profanityFilterDisabled ? 'Enable filter' : 'Disable filter'}
</button>
</div>
<div className="mt-4 text-xs">
{settings.profanityFilterDisabled
? <span className="badge-banned">Filter off — nothing censored</span>
: <span className="badge-online">Filter active</span>}
</div>
</div>
)}
{gameConfigs && (
<DiscoveredGameConfigsPanel
settings={gameConfigs}
@@ -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>
);
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
+5 -1
View File
@@ -72,13 +72,17 @@ cd DorkNet
| `S3:SecretKey` | `S3__SecretKey` | production | S3 secret key |
| `S3:Region` | `S3__Region` | no | defaults to `garage`; use `auto` for R2 |
`/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 a local standalone server:**
```pwsh
dotnet run --project DorkNet.Server
```
First boot creates `bin/Debug/net9.0/data/dorknet.db`, runs every
First boot creates `bin/Debug/net10.0/data/dorknet.db`, runs every
`LegacyUpgrades` pass, seeds canonical rooms / store catalog / club
categories / playlists, and starts listening. You should see
`Application started.` in the log; `curl http://localhost:8080/healthz`
+2 -35
View File
@@ -57,8 +57,8 @@ that outer check.
| Overview | `/` | Live ops dashboard, online players, active sessions, quick kick/ban/broadcast actions |
| Moderation | `/players` | Player directory, bans, reports, per-player ban/grant/gift/password/avatar/account actions |
| Activity | `/activity` | Admin audit log and per-player request logs |
| Content | `/rooms`, `/rooms/:id`, `/import-room`, `/content` | Room list/detail, room import, instances, leaderboards, community board, loading tips, 3D Charades word lists |
| Operations | `/broadcast`, `/settings` | Server broadcast, server toggles (signups, everyone-is-friends, profanity filter), signup codes, weekly challenges, Play menu tags, Rec Center doors, game config values |
| Content | `/rooms`, `/rooms/:id`, `/import-room`, `/content` | Room list/detail, room import, instances, leaderboards, community board, loading tips |
| Operations | `/broadcast`, `/settings` | Server broadcast, server toggles, signup codes, weekly challenges, Play menu tags, Rec Center doors, game config values |
Several older admin URLs are kept as redirects:
@@ -76,39 +76,6 @@ Several older admin URLs are kept as redirects:
`/import-room-legacy` still exists for the legacy room importer.
## 3D Charades word lists (`/content?tab=charades`)
The March 2023 client fetches a charades deck at card-box spawn from
`GET api/activities/charades/v1/words/{source}`, where `{source}` is one
of three baked `CardBox.cardSource` slots — `Charades`,
`CharadesAprilFoolsDay`, and `Icebreakers` (verified in the 2023.03.21
il2cpp dump). The response is a JSON array of
`{ "EN_US": "<phrase>", "Difficulty": <int> }` (`Difficulty` is the client
`CNMMMNJJDMM` enum: 0 easy, 1 hard, 10 very hard, 20 icebreaker).
The admin tab exposes:
- A **library** of unlimited named word lists (`CharadesWordListEntity`
rows), each with a per-card difficulty. Paste-import accepts one phrase
per line with an optional `| easy|hard|veryhard|icebreaker` suffix.
- **Live card slots** — three dropdowns binding each client slot to any
library list. Switching a slot just repoints its binding
(`ServerSettingsEntity.CharadesSlotBindingsJson`); it takes effect on the
next card-box refresh (room rejoin). An unbound slot falls back to the
built-in list seeded for it.
Three built-in lists (Default / April Fools / Icebreakers) are seeded on
first boot and bound to their slots. Seeding is idempotent — admin edits
survive restarts.
## Profanity filter toggle (`/settings`)
`ServerSettingsEntity.ProfanityFilterDisabled` gates the server-side
`api/sanitize/*` filter. When on, every sanitize route returns input
unchanged and treats all text as clean, so room/invention names and chat
are never censored. Off by default; checked per request so it takes effect
immediately.
## Build And Deploy
The service Dockerfile builds the admin SPA for service images that need
+5 -1
View File
@@ -38,7 +38,7 @@ DorkNet.sln
| `DorkNet.Contracts` | Service names, route ownership, service-map options, health responses, probe responses | .NET 9 |
| `DorkNet.ServiceDefaults` | Common service health checks, JSON defaults, HTTP clients, service identity, route guard | .NET 9 |
| `DorkNet.Gateway` | Edge reverse proxy; routes host/path slices to services and exposes service health probes | .NET 9 |
| `DorkNet.Services.Identity` | Accounts, auth, platform login, JWT issuance route slice | .NET 9 |
| `DorkNet.Services.Identity` | Accounts, auth, platform login, OpenID discovery, JWT issuance route slice | .NET 9 |
| `DorkNet.Services.Rooms` | Rooms, room keys, matchmaking, discovery route slice | .NET 9 |
| `DorkNet.Services.Notify` | SignalR edge, messages/chat, notification fan-out route slice | .NET 9 |
| `DorkNet.Services.Content` | CDN paths, image/photo APIs, uploads, S3-backed blobs | .NET 9 |
@@ -113,6 +113,10 @@ Compose network and forwards Cloudflare Tunnel traffic to
configured with `DORKNET_S3_*` environment variables; the compose files
do not run MinIO or Garage.
The identity slice is the token issuer in the service split. Discovery at
`/.well-known/openid-configuration` advertises the configured auth host,
and access/refresh tokens minted by `/connect/token` use that same issuer.
`Dockerfile.service` is the reusable service image. It accepts
`PROJECT_PATH` and `APP_DLL` build args, rebuilds the admin/site Vite
SPAs for service images that need static assets, publishes the selected
+5
View File
@@ -42,6 +42,11 @@ shared `DorkNet.Server` controller/service stack behind route ownership
guards, so response contracts stay identical while the architecture is
split.
Auth tokens are issued by the identity service using the configured
auth host (`https://auth.<apex>` or the equivalent configured host style)
as the JWT/OpenID issuer. `DORKNET_JWT_SECRET` still supplies the signing
key for every service that validates bearer tokens.
---
## Dokploy Microservices
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
+2 -2
View File
@@ -51,8 +51,8 @@ static Assembly LoadServerAssembly(string[] args)
var repositoryRoot = FindRepositoryRoot();
var candidates = new[]
{
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Debug", "net9.0", "DorkNet.Server.dll"),
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net9.0", "DorkNet.Server.dll")
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Debug", "net10.0", "DorkNet.Server.dll"),
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net10.0", "DorkNet.Server.dll")
};
var existingCandidates = candidates.Where(File.Exists).ToArray();
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
+2 -2
View File
@@ -333,8 +333,8 @@ static Assembly LoadServerAssembly(string? configuredPath)
var repositoryRoot = FindRepositoryRoot();
var candidates = new[]
{
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Debug", "net9.0", "DorkNet.Server.dll"),
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net9.0", "DorkNet.Server.dll")
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Debug", "net10.0", "DorkNet.Server.dll"),
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net10.0", "DorkNet.Server.dll")
};
var existingCandidates = candidates.Where(File.Exists).ToArray();
if (existingCandidates.Length == 0)