mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ad6735e93 | |||
| a07e9f41ea | |||
| a23284ce56 | |||
| 926a5c9bba | |||
| 8ec79ad06c | |||
| b55627a10a | |||
| af29fc3e8e | |||
| 4cdc5ee6d0 | |||
| 1e0015c18e | |||
| e5fedb7376 | |||
| 704abe8df4 | |||
| ab435c3f45 | |||
| 7617ca0367 |
@@ -88,4 +88,3 @@ Recovered(*)/
|
||||
local-releases/**
|
||||
DorkNet.Models/bin-verify/**
|
||||
DorkNet.Models/bin-verify2/**
|
||||
identityserver-signing.pfx
|
||||
|
||||
+4
-4
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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,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">
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=&platformIds=a,b,c</c> or
|
||||
/// JSON with the same shape. Returns the flattened
|
||||
/// <c>List<CachedLogin></c> across every matched id.</summary>
|
||||
/// at once. Body is form-urlencoded <c>platform=&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);
|
||||
|
||||
@@ -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<CharadesWord></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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user