Compare commits

...

19 Commits

Author SHA1 Message Date
Alexa 94781fd11a fix(auth): don't read the provider's userinfo endpoint by default
Cloudflare Access answers userinfo without a Content-Type, which the
OpenID Connect handler rejects ("Unknown response type"), so every
sign-in failed after the code exchange. The ID token already carries
sub and email; DorkNet:SignIn:UseUserInfoEndpoint turns the call back
on for providers that need it.
2026-10-25 17:24:25 +02:00
Alexa 99f8c2e016 Merge pull request #73 from DorkSquadRR/feat/external-sign-in
feat(auth): external sign-in for the website and admin panel
2026-10-25 17:00:04 +02:00
Alexa d4ce92f1b4 test(auth): give sign-in test players sequential ids 2026-10-25 16:55:57 +02:00
Alexa c7a087eea1 fix(site): parse return paths as URLs; refresh account state after changes
- localPath resolves the path against this origin, so control characters
  or backslashes can't turn it into another site's address.
- link, sign-out and unlink drop the cached account answer.
2026-10-25 15:56:45 +02:00
Alexa 5bfa463528 fix(auth): show only known sign-in error codes
Sign-in errors arrive in the query string, so unknown codes get a fixed
message instead of being echoed into the banner.
2026-10-25 15:54:51 +02:00
Alexa 891ec89899 fix(auth): encrypt stored cookie keys; tighten the link and sign-out flows
- Data-protection keys are encrypted (AES-256-GCM, key derived from the
  server secret) before they reach the DataProtectionKeys table.
- /link only follows local return paths and keeps the form when a
  signed-in visitor has an identity waiting to be linked.
- A failed sign-out says so instead of reloading as if it worked.
2026-10-25 15:52:29 +02:00
Alexa f0e4679887 feat(auth): external sign-in for the website and admin panel
Players sign in to the website, and admins to the admin panel, with an
OpenID Connect provider (DorkNet:SignIn:*). The first sign-in links the
provider identity (issuer + subject) to a DorkNet account with that
account's password; after that the provider sign-in is enough.

- Cookie sessions per host, keys persisted in the database so redeploys
  keep people signed in; unlink, deletion or a ban ends the session.
- Link attempts limited to 5 failures per 15 minutes per identity and
  per username.
- Admin panel: "Sign in with ..." exchanges the session for the usual
  admin tokens via GET /api/admin/v1/sso (IsAdmin still required).
- Downloads: optional DorkNet:Downloads:RequireSignIn serves the
  manifest and files only to signed-in players; the Downloads and Quest
  pages offer sign-in instead.
- Website: sign-in in the header, /link and /account pages.

The game client's login is unchanged.
2026-10-25 15:47:56 +02:00
Alexa 260f6605fc fix(admin-ui): derive the apex and subdomain style from the admin host
The admin SPA special-cased one deployment's hostnames to choose between
dot (img.<apex>) and hyphen (img-<apex>) subdomains. The server always
mounts the SPA on its own admin subdomain, so read both from that:
admin.<apex> means dot style, admin-<apex> hyphen style.
2026-10-25 14:42:10 +02:00
Alexa 12da61407d Merge pull request #72 from DorkSquadRR/ci/client-only-push-builds
ci: build on push only for client-facing changes
2026-10-25 14:32:39 +02:00
Alexa 5b93af4ba9 Merge pull request #71 from DorkSquadRR/chore/dotnet-10
chore: move the server, services and tools to .NET 10
2026-10-25 14:32:17 +02:00
Alexa 51104464f0 Merge pull request #70 from DorkSquadRR/fix/2023-room-instances-publish-permissions
fix: 2023 room instances, publish, role permissions, co-owner invites and sub-rooms
2026-10-25 14:21:44 +02:00
Alexa 35768c05ed Merge pull request #69 from DorkSquadRR/fix/postgres-bootstrap
fix: Postgres bootstrap on a fresh database and with replicas booting together
2026-10-25 14:21:21 +02:00
Alexa 0a5c3a9a64 Merge remote-tracking branch 'origin/fix/postgres-bootstrap' into chore/dotnet-10
# Conflicts:
#	DorkNet.Server/Startup/DatabaseBootstrap.cs
2026-10-25 14:07:30 +02:00
Alexa 503d838fd1 ci: treat .markdown like .md in push builds
From the local CodeRabbit review: a Markdown-only push using the .markdown
extension still rebuilt Quest. Exclude both extensions in the push planner
and in the documented job path filter.
2026-10-25 13:51:26 +02:00
Alexa e29707deeb ci: keep the checkout so the path filter can skip non-client pushes
The job's path filter needs the last build's checkout to compare against.
The pipeline deleted the whole workspace after each run, so Jenkins polled
with "Working Directory does not exist", assumed changes, and started a
run for every push (builds #6 and #7: zero changed files). Delete only
out/ and quest-work/ instead.
2026-10-25 12:49:09 +02:00
Alexa 3b332a6476 fix: let EF own the Postgres bootstrap transaction (EF Core 10)
EF Core 10 runs EnsureCreated through the migration executor, which begins
its own transaction unless EF already has one. The bootstrap held the
advisory lock in a transaction begun on the raw connection, invisible to
EF, so a fresh Postgres database failed at startup with "A transaction is
already in progress". Begin it through db.Database and take the lock with
ExecuteSqlRaw so EnsureCreated runs inside it.
2026-10-25 12:32:09 +02:00
Alexa d295fd5c37 chore: Swashbuckle 10.3.1 for ASP.NET Core 10
Swashbuckle 7.3.1 is built against Microsoft.OpenApi 1.x; ASP.NET Core 10
brings Microsoft.OpenApi 2.x, and SwaggerGenerator.GetSwagger then fails to
load (TypeLoadException), which broke 105 of the server tests.
2026-10-25 12:22:38 +02:00
Alexa 8e12db660b chore: move the server, services and tools to .NET 10
.NET 8 and 9 reach end of support on 2026-11-10; .NET 10 is the LTS release
(supported until November 2028).

- net9.0 -> net10.0 for every server, service, gateway, test and tool project
- ASP.NET Core / EF Core / SignalR Redis 10.0.13, Npgsql EF 10.0.3,
  Serilog.AspNetCore 10.0.0
- Dockerfile / Dockerfile.service: sdk and aspnet 10.0 images
- release workflow: setup-dotnet 10.0.x
- Jenkins build agent: dotnet-sdk-10.0 (was 8.0); the ClientMod still
  targets net6.0, which MelonLoader loads
- README / architecture docs, .gitignore data paths

DorkNet.AdminMobile (MAUI) stays on net9.0 for now: building it needs the
Android/iOS workloads.
2026-10-25 12:12:57 +02:00
Alexa bafb380fc8 ci: build on push only for client-facing changes
Push builds used to rebuild both platforms for any Jenkinsfile or ci/jenkins/
change, and every build publishes to the downloads page. Now only
DorkNet.ClientMod/ and tools/build_pc.py (PC) or DorkNet.Quest/ (Quest)
changes rebuild, and Markdown never counts; CI-only changes take a manual
run. The README gives the matching Git path filter for the job, so other
pushes don't start a run at all.
2026-10-25 12:11:08 +02:00
55 changed files with 5420 additions and 115 deletions
@@ -25,7 +25,7 @@ jobs:
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
dotnet-version: 10.0.x
- name: Install protobuf compiler
run: |
@@ -40,7 +40,7 @@ jobs:
- name: Start server
run: |
dotnet DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
dotnet DorkNet.Server/bin/Debug/net10.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
echo "$!" > "$RUNNER_TEMP/dorknet-server.pid"
- name: Wait for server readiness
@@ -57,7 +57,7 @@ jobs:
- name: Run endpoint contract
run: |
dotnet run --project tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-build -- \
--server-assembly DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll \
--server-assembly DorkNet.Server/bin/Debug/net10.0/DorkNet.Server.dll \
--base-url http://127.0.0.1:18080 \
--report "$RUNNER_TEMP/endpoint-contract-report.json"
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
dotnet-version: '10.0.x'
# ── Server self-contained publish ────────────────────────────────
- name: Publish DorkNet.Server (win-x64, self-contained)
+2 -2
View File
@@ -12,8 +12,8 @@ dist/
# Local SQLite + uploaded blobs only — DON'T match the source-tree
# DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on
# case-insensitive filesystems. Use the precise runtime data dirs.
DorkNet.Server/bin/Debug/net9.0/data/
DorkNet.Server/bin/Release/net9.0/data/
DorkNet.Server/bin/Debug/net10.0/data/
DorkNet.Server/bin/Release/net10.0/data/
# appsettings.Production.json is intentionally versioned — contains
# only the Postgres provider switch + Kestrel bind address. All real
# secrets come from Coolify env vars at runtime.
+4 -4
View File
@@ -1,14 +1,14 @@
# DorkNet.Server container image — Coolify deploy target.
# ────────────────────────────────────────────────────────────────────
# Multi-stage: SDK image builds + publishes, runtime image is the
# slimmer aspnet:9.0 with only the published output.
# slimmer aspnet:10.0 with only the published output.
#
# Build context: repo root (so Tools/ and DorkNet.Models/ are visible
# to ProjectReference + dotnet restore). The .dockerignore at the
# repo root keeps the bin/, obj/, and other dev junk out of the
# context.
ARG DOTNET_VERSION=9.0
ARG DOTNET_VERSION=10.0
ARG NODE_VERSION=22
# ── admin SPA build stage ──────────────────────────────────────────
@@ -76,13 +76,13 @@ WORKDIR /app
# curl + wget for healthcheck probes — Coolify v4 defaults to wget for
# its own probe regardless of what the image's HEALTHCHECK uses, so
# both have to be present. The aspnet:9.0 base image strips both by
# both have to be present. The aspnet:10.0 base image strips both by
# default to keep the surface area small.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl wget \
&& rm -rf /var/lib/apt/lists/*
# Run as a non-root user — aspnet:9.0 ships with the `app` UID 1654.
# Run as a non-root user — aspnet:10.0 ships with the `app` UID 1654.
# Coolify mounts persistent volumes with write permission for this UID.
USER app
+1 -1
View File
@@ -1,4 +1,4 @@
ARG DOTNET_VERSION=9.0
ARG DOTNET_VERSION=10.0
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-alpine AS admin-ui
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -1,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.13" />
<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" />
+264
View File
@@ -0,0 +1,264 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace DorkNet.Server.Tests;
/// <summary>
/// Website / admin-panel sign-in with an external OpenID Connect provider.
/// The provider round-trip can't run in-process, so each test starts where the
/// provider callback leaves off: a <c>DorkNet.External</c> cookie holding the
/// provider identity, encrypted with the app's own cookie format. Everything
/// from there (link, session, admin SSO, unlink, limits) is the real code.
/// </summary>
public sealed class ExternalSignInTests : IClassFixture<DorkNetServerFactory>
{
private const string Issuer = "https://idp.example.com";
// Test player ids: sequential, in a range no other test uses.
private static long _nextPlayerId = 9_600_000;
private readonly DorkNetServerFactory _factory;
public ExternalSignInTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Linking_with_the_account_password_signs_in_and_the_next_sign_in_needs_no_password()
{
var (player, password) = await PlayerAsync();
var subject = Guid.NewGuid().ToString("N");
using var site = Client();
var link = await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = player.Username, password }, ExternalCookie(subject, "player@example.com"));
Assert.Equal(HttpStatusCode.OK, link.StatusCode);
var session = SessionCookie(link);
Assert.NotNull(session);
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
Assert.True(me.GetProperty("signedIn").GetBoolean());
Assert.Equal(player.Username, me.GetProperty("player").GetProperty("username").GetString());
Assert.Equal("player@example.com", me.GetProperty("player").GetProperty("email").GetString());
// Next time the provider identity alone is enough.
await using var scope = _factory.Services.CreateAsyncScope();
var signIn = scope.ServiceProvider.GetRequiredService<ExternalSignInService>();
var stored = await signIn.FindAsync(Issuer, subject);
Assert.Equal(player.Id, stored?.PlayerId);
}
[Fact]
public async Task Wrong_passwords_are_refused_then_rate_limited()
{
var (player, _) = await PlayerAsync();
var external = ExternalCookie(Guid.NewGuid().ToString("N"), "guess@example.com");
using var site = Client();
for (var i = 0; i < 5; i++)
{
using var r = await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = player.Username, password = "wrong-" + i }, external);
Assert.Equal(HttpStatusCode.Unauthorized, r.StatusCode);
}
using var blocked = await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = player.Username, password = "still-wrong" }, external);
Assert.Equal(HttpStatusCode.TooManyRequests, blocked.StatusCode);
}
[Fact]
public async Task An_identity_linked_to_one_account_cannot_be_linked_to_another()
{
var (first, firstPassword) = await PlayerAsync();
var (second, secondPassword) = await PlayerAsync();
var subject = Guid.NewGuid().ToString("N");
using var site = Client();
using var ok = await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = first.Username, password = firstPassword }, ExternalCookie(subject, "a@example.com"));
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
using var refused = await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = second.Username, password = secondPassword }, ExternalCookie(subject, "a@example.com"));
Assert.Equal(HttpStatusCode.Conflict, refused.StatusCode);
}
[Fact]
public async Task Admin_sso_issues_tokens_only_to_admins()
{
var (admin, adminPassword) = await PlayerAsync(isAdmin: true);
var (regular, regularPassword) = await PlayerAsync();
using var admins = Client("admin");
var adminSession = SessionCookie(await PostJsonAsync(admins, "/api/site/v1/account/link",
new { username = admin.Username, password = adminPassword }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
using var tokens = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", adminSession);
Assert.Equal(HttpStatusCode.OK, tokens.StatusCode);
var body = JsonDocument.Parse(await tokens.Content.ReadAsStringAsync()).RootElement;
Assert.False(string.IsNullOrEmpty(body.GetProperty("access_token").GetString()));
Assert.Equal(admin.Id, body.GetProperty("account_id").GetInt64());
var regularSession = SessionCookie(await PostJsonAsync(admins, "/api/site/v1/account/link",
new { username = regular.Username, password = regularPassword }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
using var refused = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", regularSession);
Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode);
using var anonymous = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", null);
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
}
[Fact]
public async Task Unlinking_ends_the_session()
{
var (player, password) = await PlayerAsync();
using var site = Client();
var session = SessionCookie(await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
using var unlink = await SendAsync(site, HttpMethod.Post, "/api/site/v1/account/unlink", session);
Assert.Equal(HttpStatusCode.OK, unlink.StatusCode);
// The old cookie is still in the browser, but the link behind it is gone.
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
Assert.False(me.GetProperty("signedIn").GetBoolean());
}
[Fact]
public async Task A_banned_player_cannot_link_and_loses_an_existing_session()
{
var (player, password) = await PlayerAsync();
using var site = Client();
var session = SessionCookie(await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
await db.Players.Where(p => p.Id == player.Id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.BannedUntil, DateTime.UtcNow.AddDays(1)));
}
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
Assert.False(me.GetProperty("signedIn").GetBoolean());
using var relink = await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), ""));
Assert.Equal(HttpStatusCode.Forbidden, relink.StatusCode);
}
[Fact]
public async Task Cookie_keys_are_stored_encrypted()
{
var (player, password) = await PlayerAsync();
using var site = Client();
using var link = await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), ""));
Assert.Equal(HttpStatusCode.OK, link.StatusCode);
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var stored = await db.DataProtectionKeys.Select(k => k.Xml).ToListAsync();
Assert.NotEmpty(stored);
Assert.All(stored, xml =>
{
Assert.Contains("dorknetEncryptedKey", xml);
Assert.DoesNotContain("masterKey", xml);
});
}
[Fact]
public void Key_encryption_round_trips_and_rejects_another_secret()
{
var element = new System.Xml.Linq.XElement("key", new System.Xml.Linq.XElement("masterKey", "secret-bytes"));
var encrypted = new DataProtectionKeyEncryption("first-secret").Encrypt(element).EncryptedElement;
Assert.DoesNotContain("secret-bytes", encrypted.ToString());
Assert.Equal(element.ToString(), new DataProtectionKeyEncryption("first-secret").DecryptElement(encrypted).ToString());
Assert.ThrowsAny<System.Security.Cryptography.CryptographicException>(
() => new DataProtectionKeyEncryption("other-secret").DecryptElement(encrypted));
}
[Fact]
public async Task Downloads_can_require_sign_in()
{
await using var factory = _factory.WithWebHostBuilder(b =>
{
b.UseSetting("DorkNet:Downloads:BaseUrl", "http://127.0.0.1:9");
b.UseSetting("DorkNet:Downloads:RequireSignIn", "true");
});
using var site = factory.CreateClient(new() { BaseAddress = new Uri($"http://{_factory.ApexDomain}") });
using var manifest = await site.GetAsync("/api/site/v1/downloads");
Assert.Equal(HttpStatusCode.Unauthorized, manifest.StatusCode);
using var file = await site.GetAsync("/api/site/v1/downloads/files/2023.03/DorkNet-2023.03-Quest.xapk");
Assert.Equal(HttpStatusCode.Unauthorized, file.StatusCode);
}
// ── helpers ────────────────────────────────────────────────────────────
private async Task<(PlayerEntity Player, string Password)> PlayerAsync(bool isAdmin = false)
{
var password = "pw-" + Guid.NewGuid().ToString("N")[..12];
var player = new PlayerEntity
{
Id = Interlocked.Increment(ref _nextPlayerId),
Username = "sso" + Guid.NewGuid().ToString("N")[..10],
DisplayName = "SSO test",
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
IsAdmin = isAdmin,
};
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.Players.Add(player);
await db.SaveChangesAsync();
return (player, password);
}
/// <summary>The cookie the provider callback leaves behind: the provider
/// identity in the DorkNet.External scheme, in the app's cookie format.</summary>
private string ExternalCookie(string subject, string email)
{
var options = _factory.Services.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>()
.Get(ExternalSignIn.ExternalScheme);
var identity = new ClaimsIdentity(new[]
{
new Claim("iss", Issuer), new Claim("sub", subject), new Claim("email", email),
}, "test");
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), ExternalSignIn.ExternalScheme);
return $"{options.Cookie.Name}={options.TicketDataFormat.Protect(ticket)}";
}
private static string? SessionCookie(HttpResponseMessage response) =>
response.Headers.TryGetValues("Set-Cookie", out var values)
? values.Select(v => v.Split(';')[0]).FirstOrDefault(v => v.StartsWith("dorknet_session=") && v.Length > "dorknet_session=".Length)
: null;
private HttpClient Client(string? subdomain = null)
{
var host = subdomain is null ? _factory.ApexDomain : $"{subdomain}.{_factory.ApexDomain}";
return _factory.CreateClient(new() { BaseAddress = new Uri($"http://{host}"), HandleCookies = false });
}
private static Task<HttpResponseMessage> SendAsync(HttpClient client, HttpMethod method, string path, string? cookie)
{
var request = new HttpRequestMessage(method, path);
if (cookie is not null) request.Headers.Add("Cookie", cookie);
return client.SendAsync(request);
}
private static Task<HttpResponseMessage> PostJsonAsync(HttpClient client, string path, object body, string? cookie)
{
var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(body) };
if (cookie is not null) request.Headers.Add("Cookie", cookie);
return client.SendAsync(request);
}
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path, string? cookie)
{
using var response = await SendAsync(client, HttpMethod.Get, path, cookie);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
}
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
@@ -41,6 +42,27 @@ public class AdminLoginController(
if (!player.IsAdmin)
return StatusCode(403, new { error = "not_admin" });
return TokenResponse(player);
}
/// <summary>GET api/admin/v1/sso — exchange a single sign-on session
/// (<see cref="ExternalSignIn.WebScheme"/>, signed in on this admin host via
/// the external provider) for the same token pair <c>login</c> returns.
/// Admin rights still come from the account's <c>IsAdmin</c>.</summary>
[HttpGet("sso")]
public async Task<ActionResult> Sso()
{
var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme);
if (!web.Succeeded || ExternalSignIn.PlayerId(web.Principal) is not { } playerId)
return Unauthorized(new { error = "not_signed_in" });
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null) return Unauthorized(new { error = "not_signed_in" });
if (!player.IsAdmin) return StatusCode(403, new { error = "not_admin" });
return TokenResponse(player);
}
private OkObjectResult TokenResponse(Data.Entities.PlayerEntity player)
{
var (access, refresh) = authService.GenerateTokenPair(player.Id);
return Ok(new
{
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
@@ -24,6 +25,12 @@ public class DownloadsController(DownloadsService downloads, DomainConfig domain
private bool Available => downloads.IsConfigured
&& string.Equals(Request.Host.Host, domain.Apex, StringComparison.OrdinalIgnoreCase);
/// <summary>With <see cref="DownloadsService.RequireSignIn"/>, only a
/// signed-in, linked player may read the builds.</summary>
private async Task<bool> SignedInOrOpenAsync() =>
!downloads.RequireSignIn
|| (await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme)).Succeeded;
/// <summary>GET /api/site/v1/downloads — the manifest as published
/// (snake_case keys), or 404 when downloads aren't set up or the request
/// isn't for the site's host.</summary>
@@ -31,6 +38,7 @@ public class DownloadsController(DownloadsService downloads, DomainConfig domain
public async Task<IActionResult> GetManifest(CancellationToken ct)
{
if (!Available) return NotFound(new { error = "Downloads aren't set up on this server." });
if (!await SignedInOrOpenAsync()) return Unauthorized(new { error = "sign_in_required" });
var manifest = await downloads.GetManifestAsync(ct);
Response.Headers.CacheControl = "no-cache";
return Content((manifest ?? new System.Text.Json.Nodes.JsonObject { ["releases"] = new System.Text.Json.Nodes.JsonArray() })
@@ -43,6 +51,7 @@ public class DownloadsController(DownloadsService downloads, DomainConfig domain
public async Task GetFile(string path, CancellationToken ct)
{
if (!Available) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
if (!await SignedInOrOpenAsync()) { Response.StatusCode = StatusCodes.Status401Unauthorized; return; }
var manifest = await downloads.GetManifestAsync(ct);
var entry = manifest is null ? null : DownloadsService.FindFile(manifest, path);
if (entry is null) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
@@ -0,0 +1,178 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Site;
/// <summary>
/// Sign-in with the external OpenID Connect provider (<see cref="ExternalSignInOptions"/>)
/// for the website and the admin panel. The game client's login is separate.
///
/// <para>Flow: <c>login</c> challenges the provider; its callback
/// (<see cref="ExternalSignIn.CallbackPath"/>, handled by the OpenID Connect
/// middleware) stores the provider identity in the short-lived
/// <c>DorkNet.External</c> cookie and lands on <c>complete</c>. A linked identity
/// becomes a <c>DorkNet.Web</c> session at once; an unlinked one goes to the
/// website's <c>/link</c> page, where the player proves the account with its
/// password once (<c>account/link</c>).</para>
///
/// <para>Each host signs in on its own (website and admin panel), because the
/// hyphen host style puts them under different cookie domains.</para>
/// </summary>
[ApiController]
[AllowAnonymous]
public class ExternalSignInController(
IOptions<ExternalSignInOptions> options,
ExternalSignInService signIn,
LinkAttemptLimiter limiter,
DomainConfig domain,
ILogger<ExternalSignInController> log) : ControllerBase
{
private ExternalSignInOptions Options => options.Value;
/// <summary>GET /api/auth/external/login?returnUrl=/path — start sign-in.</summary>
[HttpGet(ExternalSignIn.LoginPath)]
public IActionResult Login([FromQuery] string? returnUrl)
{
if (!Options.IsConfigured) return NotFound(new { error = "sign_in_not_configured" });
var complete = $"{ExternalSignIn.CompletePath}?returnUrl={Uri.EscapeDataString(LocalUrl(returnUrl))}";
return Challenge(new AuthenticationProperties { RedirectUri = complete }, ExternalSignIn.OidcScheme);
}
/// <summary>GET /api/auth/external/complete — after the provider's callback.</summary>
[HttpGet(ExternalSignIn.CompletePath)]
public async Task<IActionResult> Complete([FromQuery] string? returnUrl)
{
returnUrl = LocalUrl(returnUrl);
var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme);
if (!external.Succeeded || ExternalIdentity(external.Principal) is not { } identity)
return Redirect(WithQuery(returnUrl, "signin_error", "no_identity"));
var (issuer, subject, email) = identity;
var link = await signIn.FindAsync(issuer, subject);
var player = link is null ? null : await signIn.ActivePlayerAsync(link);
if (link is not null && player is not null)
{
await signIn.TouchAsync(link, email);
await HttpContext.SignInAsync(ExternalSignIn.WebScheme, ExternalSignIn.PlayerPrincipal(player, link));
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
log.LogInformation("[signin] player={Player} via {Issuer}", player.Id, issuer);
return Redirect(returnUrl);
}
if (link is not null)
return Redirect(WithQuery(returnUrl, "signin_error", "account_unavailable"));
// Not linked yet. Linking happens on the website; the admin panel
// tells its user to link there first.
if (IsAdminHost())
return Redirect(WithQuery(returnUrl, "signin_error", "not_linked"));
return Redirect($"/link?returnUrl={Uri.EscapeDataString(returnUrl)}");
}
/// <summary>GET /api/site/v1/account/me — who is signed in, or which
/// provider identity is waiting to be linked.</summary>
[HttpGet("api/site/v1/account/me")]
public async Task<IActionResult> Me()
{
var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme);
var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme);
var pending = external.Succeeded ? ExternalIdentity(external.Principal) : null;
return Ok(new
{
signInAvailable = Options.IsConfigured,
provider = Options.DisplayName,
signedIn = web.Succeeded,
player = web.Succeeded
? new
{
id = ExternalSignIn.PlayerId(web.Principal)?.ToString(),
username = web.Principal!.Identity?.Name,
email = web.Principal.FindFirst(ExternalSignIn.EmailClaim)?.Value,
}
: null,
pendingLink = pending is { } p ? new { email = p.Email } : null,
});
}
public sealed record LinkRequest(string? Username, string? Password);
/// <summary>POST /api/site/v1/account/link {username, password} — link the
/// pending provider identity to the account these credentials prove.</summary>
[HttpPost("api/site/v1/account/link")]
public async Task<IActionResult> Link([FromBody] LinkRequest body)
{
var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme);
if (!external.Succeeded || ExternalIdentity(external.Principal) is not { } identity)
return Unauthorized(new { error = "no_pending_identity" });
var (issuer, subject, email) = identity;
var username = body.Username?.Trim() ?? string.Empty;
if (username.Length == 0 || string.IsNullOrEmpty(body.Password))
return BadRequest(new { error = "missing_credentials" });
var keys = new[] { $"sub:{issuer}|{subject}", $"user:{username.ToLowerInvariant()}" };
if (limiter.IsBlocked(keys))
return StatusCode(StatusCodes.Status429TooManyRequests, new { error = "too_many_attempts" });
var (outcome, player, link) = await signIn.LinkAsync(issuer, subject, email, username, body.Password);
switch (outcome)
{
case LinkOutcome.Linked:
limiter.Reset(keys);
await HttpContext.SignInAsync(ExternalSignIn.WebScheme, ExternalSignIn.PlayerPrincipal(player!, link!));
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
log.LogInformation("[signin] linked player={Player} to {Issuer}", player!.Id, issuer);
return Ok(new { linked = true, username = player.Username });
case LinkOutcome.InvalidCredentials:
limiter.RecordFailure(keys);
return Unauthorized(new { error = "invalid_credentials" });
case LinkOutcome.Banned:
return StatusCode(StatusCodes.Status403Forbidden, new { error = "banned" });
default:
return Conflict(new { error = "identity_linked_to_another_account" });
}
}
/// <summary>POST /api/site/v1/account/unlink — remove this provider identity
/// from the account and sign out.</summary>
[HttpPost("api/site/v1/account/unlink")]
public async Task<IActionResult> Unlink()
{
var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme);
if (!web.Succeeded || ExternalSignIn.PlayerId(web.Principal) is not { } playerId)
return Unauthorized(new { error = "not_signed_in" });
var issuer = web.Principal!.FindFirst(ExternalSignIn.IssuerClaim)?.Value ?? string.Empty;
await signIn.UnlinkAsync(playerId, issuer);
await HttpContext.SignOutAsync(ExternalSignIn.WebScheme);
return Ok(new { unlinked = true });
}
/// <summary>POST /api/site/v1/account/logout — end the session here.</summary>
[HttpPost("api/site/v1/account/logout")]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(ExternalSignIn.WebScheme);
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
return Ok(new { signedOut = true });
}
/// <summary>(issuer, subject, email) from the provider's claims. The
/// subject is the account key; the email is display only.</summary>
private (string Issuer, string Subject, string Email)? ExternalIdentity(System.Security.Claims.ClaimsPrincipal? p)
{
var subject = p?.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(subject)) return null;
var issuer = p!.FindFirst("iss")?.Value ?? Options.Authority;
return (issuer.TrimEnd('/'), subject, p.FindFirst("email")?.Value ?? string.Empty);
}
private string LocalUrl(string? url) =>
!string.IsNullOrEmpty(url) && Url.IsLocalUrl(url) ? url : "/";
private static string WithQuery(string url, string key, string value) =>
url + (url.Contains('?') ? "&" : "?") + key + "=" + Uri.EscapeDataString(value);
private bool IsAdminHost() =>
string.Equals(Request.Host.Host, domain.Sub("admin"), StringComparison.OrdinalIgnoreCase);
}
+12 -1
View File
@@ -1,9 +1,10 @@
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Data;
public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbContext(options)
public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbContext(options), IDataProtectionKeyContext
{
public DbSet<PlayerEntity> Players => Set<PlayerEntity>();
public DbSet<AvatarEntity> Avatars => Set<AvatarEntity>();
@@ -88,9 +89,19 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
public DbSet<UgcPurchasableEntity> UgcPurchasables => Set<UgcPurchasableEntity>();
public DbSet<RoomConsumableEntity> RoomConsumables => Set<RoomConsumableEntity>();
public DbSet<RoomConsumableOwnershipEntity> RoomConsumableOwnership => Set<RoomConsumableOwnershipEntity>();
public DbSet<ExternalLoginEntity> ExternalLogins => Set<ExternalLoginEntity>();
// ASP.NET Core data-protection keys (sign-in cookies), kept in the
// database so a redeploy or a second replica doesn't sign everyone out.
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ExternalLoginEntity>(e =>
{
e.HasIndex(x => new { x.Issuer, x.Subject }).IsUnique();
e.HasIndex(x => x.PlayerId);
});
// SQLite-specific NOCASE collation works via UseCollation; Postgres
// doesn't have NOCASE. Provider-conditional helper applied to the
// three string columns the wire API matches case-insensitively
@@ -0,0 +1,29 @@
namespace DorkNet.Server.Data.Entities;
/// <summary>
/// An identity from the external sign-in provider (OpenID Connect) linked to a
/// DorkNet account. One row per (Issuer, Subject): the provider's stable user
/// id, never the email, which can change or be reused. A player proves they own
/// the account once, with its password, when the link is made
/// (<c>ExternalSignInController.Link</c>); after that the provider alone signs
/// them in to the site and, for admins, the admin panel.
/// </summary>
public class ExternalLoginEntity
{
public long Id { get; set; }
public long PlayerId { get; set; }
/// <summary>The provider's issuer URL (the <c>iss</c> claim).</summary>
public string Issuer { get; set; } = string.Empty;
/// <summary>The provider's user id (the <c>sub</c> claim).</summary>
public string Subject { get; set; } = string.Empty;
/// <summary>The email the provider reported when the link was made or last
/// used. Shown to the player; never used to find the account.</summary>
public string Email { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime LastUsedAt { get; set; } = DateTime.UtcNow;
}
+11 -9
View File
@@ -1,20 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.15" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.13" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.13" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.13" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.13" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.13">
<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.13" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<!-- 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
@@ -26,7 +28,7 @@
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="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="10.0.13" />
<!-- 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
@@ -37,8 +39,8 @@
<!-- Password hashing for the real account auth flow. BCrypt embeds
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="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.3.1" />
<!-- 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
@@ -0,0 +1,3772 @@
// <auto-generated />
using System;
using DorkNet.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DorkNet.Server.Migrations
{
[DbContext(typeof(DorkNetDbContext))]
[Migration("20261025134141_ExternalSignIn")]
partial class ExternalSignIn
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.13");
modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("AdminPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<long>("TargetId")
.HasColumnType("INTEGER");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AdminPlayerId");
b.HasIndex("Timestamp");
b.HasIndex("TargetType", "TargetId");
b.ToTable("AdminActions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CustomAvatarItemsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EquippedItemsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FaceFeatures")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("TEXT");
b.Property<string>("HairColor")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("InventoryJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OutfitSelections")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("OutfitSelectionsV2")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("SavedOutfitsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SkinColor")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId")
.IsUnique();
b.ToTable("Avatars");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ClientVersion")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("GameSessionId")
.HasColumnType("INTEGER");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime?>("ReadAt")
.HasColumnType("TEXT");
b.Property<long>("ReporterPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ReadAt");
b.HasIndex("ReporterPlayerId");
b.ToTable("BugReports");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActionUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long?>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ExpiresAt");
b.HasIndex("PlayerId");
b.ToTable("Cards");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CharadesWordListEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsBuiltIn")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("WordsJson")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CharadesWordLists");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("ModerationState")
.HasColumnType("INTEGER");
b.Property<long>("SenderPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SentAt")
.HasColumnType("TEXT");
b.Property<string>("ThreadKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ThreadKey", "SentAt");
b.ToTable("ChatMessages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ThreadKey")
.IsRequired()
.HasMaxLength(96)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ThreadKey")
.IsUnique();
b.ToTable("ChatThreads");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("JoinedAt")
.HasColumnType("TEXT");
b.Property<long?>("LastReadMessageId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("SnoozeUntil")
.HasColumnType("TEXT");
b.Property<string>("ThreadKey")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("ThreadKey", "PlayerId")
.IsUnique();
b.ToTable("ChatThreadMembers");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CheeredAt")
.HasColumnType("TEXT");
b.Property<long>("FromPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetInventionId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPhotoId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetRoomId")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TargetPhotoId");
b.HasIndex("TargetPlayerId");
b.HasIndex("TargetRoomId");
b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type")
.IsUnique();
b.ToTable("Cheers");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAdditionalImageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("Slot")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "Slot")
.IsUnique();
b.ToTable("ClubAdditionalImages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("AuthorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "CreatedAt");
b.ToTable("ClubAnnouncements");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("AnnouncementId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("ReadAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("AnnouncementId", "PlayerId")
.IsUnique();
b.ToTable("ClubAnnouncementReads");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryAssignmentEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CategoryTagId")
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CategoryTagId");
b.HasIndex("ClubId", "CategoryTagId")
.IsUnique();
b.ToTable("ClubCategoryAssignments");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryTagEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Active")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Name");
b.ToTable("ClubCategoryTags");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowJuniors")
.HasColumnType("INTEGER");
b.Property<int>("BanStatus")
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("ClubChatEnabled")
.HasColumnType("INTEGER");
b.Property<int>("ClubType")
.HasColumnType("INTEGER");
b.Property<long?>("ClubhouseRoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsRRO")
.HasColumnType("INTEGER");
b.Property<int>("Joinability")
.HasColumnType("INTEGER");
b.Property<int>("MinLevel")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("Visibility")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Clubs");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<DateTime>("JoinedAt")
.HasColumnType("TEXT");
b.Property<int>("Permissions")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("ClubId", "PlayerId")
.IsUnique();
b.ToTable("ClubMemberships");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubRolePermissionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("ApproveMember")
.HasColumnType("INTEGER");
b.Property<bool>("BanUnban")
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<bool>("CreateEvent")
.HasColumnType("INTEGER");
b.Property<bool>("EditDetails")
.HasColumnType("INTEGER");
b.Property<bool>("EditPermissionSettings")
.HasColumnType("INTEGER");
b.Property<int>("MembershipType")
.HasColumnType("INTEGER");
b.Property<bool>("PostAnnouncement")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "MembershipType")
.IsUnique();
b.ToTable("ClubRolePermissions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SubscribedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId");
b.HasIndex("PlayerId", "ClubId")
.IsUnique();
b.ToTable("ClubSubscriptions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AssignedAt")
.HasColumnType("TEXT");
b.Property<string>("CohortKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Variant")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "CohortKey")
.IsUnique();
b.ToTable("CohortAssignments");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<string>("Json")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CommunityBoardRows");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<int>("MaxRedemptions")
.HasColumnType("INTEGER");
b.Property<int>("RedemptionCount")
.HasColumnType("INTEGER");
b.Property<long>("RewardAmount")
.HasColumnType("INTEGER");
b.Property<int>("RewardCurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("RewardItemSlug")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("RewardType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("Coupons");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CouponId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("RedeemedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CouponId", "PlayerId")
.IsUnique();
b.ToTable("CouponRedemptions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("Balance")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "CurrencyType")
.IsUnique();
b.ToTable("CurrencyBalances");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AssetName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("BaseAvatarItemId")
.HasColumnType("INTEGER");
b.Property<long>("CheerCount")
.HasColumnType("INTEGER");
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsFeatured")
.HasColumnType("INTEGER");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("PurchaseCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("IsFeatured");
b.HasIndex("IsPublic");
b.HasIndex("PublicId")
.IsUnique();
b.ToTable("CustomAvatarItems");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemOwnershipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AcquiredAt")
.HasColumnType("TEXT");
b.Property<long>("CustomAvatarItemId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CustomAvatarItemId");
b.HasIndex("PlayerId", "CustomAvatarItemId")
.IsUnique();
b.ToTable("CustomAvatarItemOwnership");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b =>
{
b.Property<long>("PlayerId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CurrentDataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("PlayerId");
b.ToTable("DormStates");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ExternalLoginEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Issuer")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("LastUsedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("Issuer", "Subject")
.IsUnique();
b.ToTable("ExternalLogins");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("GiftContext")
.HasColumnType("INTEGER");
b.Property<long>("GrantedItemId")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long>("Offer1ItemId")
.HasColumnType("INTEGER");
b.Property<long>("Offer2ItemId")
.HasColumnType("INTEGER");
b.Property<long>("Offer3ItemId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("RewardType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("SelectedAt")
.HasColumnType("TEXT");
b.Property<int?>("SelectedGiftDropId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "SelectedAt");
b.ToTable("GameRewardSelections");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActivityLevelId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<string>("PhotonRoomName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("PlayerCount")
.HasColumnType("INTEGER");
b.Property<string>("Region")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("RoomId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoomId", "Region");
b.ToTable("GameSessions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AvatarItemDescOrHairDyeDesc")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("AvatarItemType")
.HasColumnType("INTEGER");
b.Property<string>("ConsumableItemDesc")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("Consumed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ConsumedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Currency")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("EquipmentModificationGuid")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("EquipmentPrefabName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ErrorMessage")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("FromPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("GiftContext")
.HasColumnType("INTEGER");
b.Property<int>("GiftRarity")
.HasColumnType("INTEGER");
b.Property<bool>("IsGifted")
.HasColumnType("INTEGER");
b.Property<bool>("IsValid")
.HasColumnType("INTEGER");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("PackageMaterial")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PackageVariant")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<long>("RecipientPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("SourceStoreItemId")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsCurrentPlatform")
.HasColumnType("INTEGER");
b.Property<int>("Xp")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RecipientPlayerId", "Consumed");
b.ToTable("GiftPackages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long?>("CreationRoomId")
.HasColumnType("INTEGER");
b.Property<int>("CreatorPermission")
.HasColumnType("INTEGER");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CurrentBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("CurrentVersionNumber")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("FirstPublishedAt")
.HasColumnType("TEXT");
b.Property<int>("GeneralPermission")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsAgInvention")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsPublished")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("NumPlayersHaveUsedInRoom")
.HasColumnType("INTEGER");
b.Property<int>("Permission")
.HasColumnType("INTEGER");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("SpawnCount")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CheerCount");
b.HasIndex("CreatorPlayerId");
b.ToTable("Inventions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("InstantiationCost")
.HasColumnType("INTEGER");
b.Property<long>("InventionId")
.HasColumnType("INTEGER");
b.Property<int>("LightsCost")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("VersionNumber")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("InventionId", "VersionNumber")
.IsUnique();
b.ToTable("InventionVersions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("BannedAt")
.HasColumnType("TEXT");
b.Property<long>("BannedByAdminId")
.HasColumnType("INTEGER");
b.Property<string>("Cidr")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("Until")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Cidr");
b.ToTable("IpBans");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ItemKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "ItemKey", "ItemType")
.IsUnique();
b.ToTable("ItemWishlists");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.KeepsakeEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("EarnedAt")
.HasColumnType("TEXT");
b.Property<string>("EventKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("PlayerId", "EventKey");
b.ToTable("Keepsakes");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardChannelMetaEntity", b =>
{
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("Channel")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("LowerIsBetter")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("ValueFormat")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("RoomId", "Channel");
b.HasIndex("RoomId");
b.ToTable("LeaderboardChannelMeta");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("StatChannel")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long>("Value")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoomId", "PlayerId", "StatChannel")
.IsUnique();
b.HasIndex("RoomId", "StatChannel", "Value");
b.ToTable("LeaderboardStats");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Context")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int>("PlatformMask")
.HasColumnType("INTEGER");
b.Property<string>("RoomNamesCsv")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("LoadingScreenTips");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("ReadAt")
.HasColumnType("TEXT");
b.Property<long>("RecipientPlayerId")
.HasColumnType("INTEGER");
b.Property<long?>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SenderPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SentAt")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RecipientPlayerId", "SentAt");
b.HasIndex("SenderPlayerId", "SentAt");
b.ToTable("Messages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowAll")
.HasColumnType("INTEGER");
b.Property<bool>("AllowAnnouncements")
.HasColumnType("INTEGER");
b.Property<bool>("AllowEventInvite")
.HasColumnType("INTEGER");
b.Property<bool>("AllowFriendRequest")
.HasColumnType("INTEGER");
b.Property<bool>("AllowMessage")
.HasColumnType("INTEGER");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "Platform")
.IsUnique();
b.ToTable("NotificationPrefs");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("ClearedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsCompleted")
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "Key")
.IsUnique();
b.ToTable("ObjectiveProgress");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("FirstSeenAt")
.HasColumnType("TEXT");
b.Property<string>("LastIp")
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<string>("PlatformId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PendingDevices");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Caption")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<string>("TaggedPlayerIdsCsv")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<long>("UploaderPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("ViewCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("BlobName");
b.HasIndex("CreatedAt");
b.HasIndex("RoomId");
b.HasIndex("UploaderPlayerId");
b.ToTable("Photos");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<string>("PlatformUserId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "Platform", "PlatformUserId")
.IsUnique();
b.ToTable("PlatformIgnores");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("FirstSeen")
.HasColumnType("TEXT");
b.Property<bool>("IsBanned")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastSeen")
.HasColumnType("TEXT");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("DeviceId");
b.HasIndex("PlayerId");
b.ToTable("PlayerDevices");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Elo")
.HasColumnType("INTEGER");
b.Property<int>("GameMode")
.HasColumnType("INTEGER");
b.Property<int>("Losses")
.HasColumnType("INTEGER");
b.Property<int>("MatchesPlayed")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("Wins")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GameMode", "Elo");
b.HasIndex("PlayerId", "GameMode")
.IsUnique();
b.ToTable("PlayerElo");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("BannedUntil")
.HasColumnType("TEXT");
b.Property<string>("BannerImageName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Bio")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("Birthday")
.HasColumnType("TEXT");
b.Property<bool>("CanReceiveInvites")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedFromDeviceId")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DisplayEmoji")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("IdentityFlags")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCommunityTeam")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeveloper")
.HasColumnType("INTEGER");
b.Property<bool>("IsJunior")
.HasColumnType("INTEGER");
b.Property<bool>("IsVerified")
.HasColumnType("INTEGER");
b.Property<string>("LastIp")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("LastPlatform")
.HasColumnType("INTEGER");
b.Property<string>("LastPlatformId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("PersonalPronouns")
.HasColumnType("INTEGER");
b.Property<string>("Phone")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ProfileImageName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("Reputation")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("XP")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Players");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Capacity")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("EndsAt")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("StartsAt")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("StartsAt");
b.ToTable("PlayerEvents");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("EventId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Response")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("EventId", "PlayerId")
.IsUnique();
b.ToTable("PlayerEventResponses");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AcquiredAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("ItemSlug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Quantity")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("PlayerId", "ItemSlug")
.IsUnique();
b.ToTable("PlayerInventory");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "Key")
.IsUnique();
b.ToTable("PlayerSettings");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Accessibility")
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CustomWarning")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("FavoriteCount")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsCurated")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsJuniors")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsLevelVoting")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsScreens")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsTeleportVR")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsWalkVR")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.Property<int>("VisitorCount")
.HasColumnType("INTEGER");
b.Property<int>("WarningMask")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("IsCurated");
b.HasIndex("Name");
b.ToTable("Playlists");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistInteractionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Cheered")
.HasColumnType("INTEGER");
b.Property<bool>("Favorited")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("PlaylistId", "PlayerId")
.IsUnique();
b.ToTable("PlaylistInteractions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistRoomEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<long>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlaylistId");
b.HasIndex("RoomId");
b.HasIndex("PlaylistId", "OrderIndex");
b.ToTable("PlaylistRooms");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b =>
{
b.Property<long>("Id")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DataBlob")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Location")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("OwnerPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("PhotonRegion")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PhotonRoomId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("RoomCode")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SubRoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("OwnerPlayerId");
b.HasIndex("RoomId", "SubRoomId");
b.ToTable("PrivateInstances");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b =>
{
b.Property<long>("PrivateInstanceId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("InvitedAt")
.HasColumnType("TEXT");
b.Property<long?>("LatestInviteMessageId")
.HasColumnType("INTEGER");
b.HasKey("PrivateInstanceId", "PlayerId");
b.HasIndex("PlayerId");
b.ToTable("PrivateInstanceInvitees");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Token");
b.HasIndex("PlayerId", "Platform")
.IsUnique();
b.ToTable("PushTokens");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("Favorited")
.HasColumnType("INTEGER");
b.Property<bool>("Ignored")
.HasColumnType("INTEGER");
b.Property<bool>("Muted")
.HasColumnType("INTEGER");
b.Property<long>("RequesterId")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<long>("TargetId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RequesterId", "TargetId")
.IsUnique();
b.ToTable("Relationships");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Category")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("GameSessionId")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long>("ReporterPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("ResolutionNote")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("TEXT");
b.Property<long?>("ResolverAdminId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("TargetEventId")
.HasColumnType("INTEGER");
b.Property<long>("TargetInventionId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetRoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ReporterPlayerId");
b.HasIndex("ResolvedAt");
b.HasIndex("TargetPlayerId");
b.ToTable("Reports");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BanType")
.HasColumnType("INTEGER");
b.Property<long>("BannedByPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("BannedPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("Until")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("BannedPlayerId");
b.HasIndex("RoomId", "BannedPlayerId");
b.ToTable("RoomBans");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("PlayerId", "RoomId")
.IsUnique();
b.ToTable("RoomBookmarks");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<Guid?>("CurrencyId")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("PublicId")
.IsUnique();
b.HasIndex("RoomId");
b.ToTable("RoomConsumables");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableOwnershipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<Guid>("ConcurrencyCode")
.HasColumnType("TEXT");
b.Property<int>("Count")
.HasColumnType("INTEGER");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomConsumableId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoomConsumableId");
b.HasIndex("PlayerId", "RoomConsumableId")
.IsUnique();
b.ToTable("RoomConsumableOwnership");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyBalanceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("Balance")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomCurrencyId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoomCurrencyId");
b.HasIndex("PlayerId", "RoomCurrencyId")
.IsUnique();
b.ToTable("RoomCurrencyBalances");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("DailyLimit")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("PublicId")
.IsUnique();
b.HasIndex("RoomId");
b.ToTable("RoomCurrencies");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyPurchaseOfferEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Amount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomCurrencyId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PublicId")
.IsUnique();
b.HasIndex("RoomCurrencyId");
b.ToTable("RoomCurrencyPurchaseOffers");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<byte[]>("Bytes")
.HasColumnType("BLOB");
b.Property<string>("ReferencedFilenamesCsv")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long?>("SubRoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UploadedAt")
.HasColumnType("TEXT");
b.Property<long>("UploadedByPlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("BlobName")
.IsUnique();
b.HasIndex("RoomId");
b.ToTable("RoomDataBlobs");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Accessibility")
.HasColumnType("INTEGER");
b.Property<bool>("AllowNewUsers")
.HasColumnType("INTEGER");
b.Property<bool>("AllowsJuniors")
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<bool>("CloningAllowed")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CurrentDataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("CustomRoomWarning")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<bool>("DisableMicAutoMute")
.HasColumnType("INTEGER");
b.Property<int>("FavoriteCount")
.HasColumnType("INTEGER");
b.Property<bool>("HiddenFromBrowse")
.HasColumnType("INTEGER");
b.Property<double>("HotScore")
.HasColumnType("REAL");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsAGRoom")
.HasColumnType("INTEGER");
b.Property<bool>("IsDormRoom")
.HasColumnType("INTEGER");
b.Property<bool>("IsRoomLinkedToRecRoomStudio")
.HasColumnType("INTEGER");
b.Property<bool>("IsStudioRoom")
.HasColumnType("INTEGER");
b.Property<string>("LoadScreensJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<string>("LocationReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<int>("MaxPlayerCalculationMode")
.HasColumnType("INTEGER");
b.Property<int>("MinLevel")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<string>("PromoExternalContentJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<string>("PromoImagesJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<int>("RoomWarningMask")
.HasColumnType("INTEGER");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<string>("StudioSessionId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("SupportsLevelVoting")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsMobile")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsScreens")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsTeleportVR")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsVRLow")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsWalkVR")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.Property<int>("VisitorCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("HotScore");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Rooms");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(174)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("RoomId");
b.HasIndex("RoomId", "Name");
b.ToTable("RoomKeys");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyPurchaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("PaidPrice")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("PurchasedAt")
.HasColumnType("TEXT");
b.Property<long>("RoomKeyId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("RoomKeyId", "PlayerId")
.IsUnique();
b.ToTable("RoomKeyPurchases");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRoleEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Accepted")
.HasColumnType("INTEGER");
b.Property<DateTime>("GrantedAt")
.HasColumnType("TEXT");
b.Property<long?>("GrantedByPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoomId");
b.HasIndex("RoomId", "PlayerId", "Role")
.IsUnique();
b.ToTable("RoomRoles");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRolePermissionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Override")
.HasColumnType("INTEGER");
b.Property<string>("Permission")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SubRoomId")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long>("UpdatedByPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoomId", "SubRoomId", "Role", "Permission")
.IsUnique();
b.ToTable("RoomRolePermissions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("CanMatchmakeInto")
.HasColumnType("INTEGER");
b.Property<string>("DataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("DataModifiedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsSandbox")
.HasColumnType("INTEGER");
b.Property<int>("MaxPlayers")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<string>("RoomSceneLocationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("StudioAssetBundleNamesCsv")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<long?>("StudioSubRoomDataSaveId")
.HasColumnType("INTEGER");
b.Property<string>("StudioUnityAssetId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoomId", "Name");
b.HasIndex("RoomId", "OrderIndex")
.IsUnique();
b.ToTable("RoomScenes");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("FirstVisitAt")
.HasColumnType("TEXT");
b.Property<DateTime>("LastVisitAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("RoomId", "LastVisitAt");
b.HasIndex("RoomId", "PlayerId")
.IsUnique();
b.ToTable("RoomVisits");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChestsOpened")
.HasColumnType("INTEGER");
b.Property<DateTime>("CompletedAt")
.HasColumnType("TEXT");
b.Property<bool>("CustomGame")
.HasColumnType("INTEGER");
b.Property<int>("HealthPotionsConsumed")
.HasColumnType("INTEGER");
b.Property<int>("NumEliminations")
.HasColumnType("INTEGER");
b.Property<int>("Rank")
.HasColumnType("INTEGER");
b.Property<int>("SecondsAlive")
.HasColumnType("INTEGER");
b.Property<int>("SecondsInAir")
.HasColumnType("INTEGER");
b.Property<int>("ShieldPotionsConsumed")
.HasColumnType("INTEGER");
b.Property<bool>("WalkGame")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CompletedAt");
b.ToTable("RoyaleMatches");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("MatchId")
.HasColumnType("INTEGER");
b.Property<int>("NumEliminations")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Rank")
.HasColumnType("INTEGER");
b.Property<int>("SecondsAlive")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("MatchId", "PlayerId");
b.ToTable("RoyaleMatchPlayers");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CurrentLevelXPThreshold")
.HasColumnType("INTEGER");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<int>("NextLevelAcornReward")
.HasColumnType("INTEGER");
b.Property<long>("NextLevelXPThreshold")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("RankIdx")
.HasColumnType("INTEGER");
b.Property<string>("RankName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("TotalXP")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId")
.IsUnique();
b.ToTable("RoyalePlayerProgress");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<bool>("AllAvatarItemsOwned")
.HasColumnType("INTEGER");
b.Property<string>("BaseRoomNamesJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CharadesSlotBindingsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CircuitsV2LimitsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DiscoveredGameConfigsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("GlobalFriendsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("PlayMenuTagsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("ProfanityFilterDisabled")
.HasColumnType("INTEGER");
b.Property<string>("RecCenterDoorsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("RoomBlobVersionClampDisabled")
.HasColumnType("INTEGER");
b.Property<bool>("SignupsDisabled")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("WeeklyChallengeRewardJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("WeeklyChallengesCompletedRequired")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<string>("WeeklyChallengesJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.HasKey("Id");
b.ToTable("ServerSettings");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatedByPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Descriptor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("RedeemedAt")
.HasColumnType("TEXT");
b.Property<long?>("RedeemedByPlayerId")
.HasColumnType("INTEGER");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("SignupCodes");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("AvailableUntil")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<bool>("IsLimitedTime")
.HasColumnType("INTEGER");
b.Property<long>("Price")
.HasColumnType("INTEGER");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Storefront")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.HasIndex("Storefront");
b.HasIndex("IsActive", "Category");
b.ToTable("StoreItems");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("SubscriberPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SubscriberPlayerId");
b.HasIndex("TargetPlayerId");
b.HasIndex("SubscriberPlayerId", "TargetPlayerId")
.IsUnique();
b.ToTable("Subscriptions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b =>
{
b.Property<long>("Pk")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AssignedPlayerIdsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("AssignedPlayerNamesJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CommentsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("JiraBugUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("JiraUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MinNumAssignedPlayers")
.HasColumnType("INTEGER");
b.Property<string>("RoomName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TagsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<uint?>("TestPassId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Pk");
b.HasIndex("Id")
.IsUnique();
b.HasIndex("Status");
b.HasIndex("TestPassId");
b.ToTable("TestCases");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b =>
{
b.Property<uint>("Id")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("EndDate")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("StartDate")
.HasColumnType("TEXT");
b.Property<string>("TagsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("WasManuallyClosed")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("TestPasses");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsFeatured")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("PublicId")
.IsUnique();
b.HasIndex("RoomId");
b.ToTable("UgcPurchasables");
});
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("FriendlyName")
.HasColumnType("TEXT");
b.Property<string>("Xml")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("DataProtectionKeys");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
{
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
.WithOne("Avatar")
.HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Player");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
{
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
.WithMany("Settings")
.HasForeignKey("PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Player");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
{
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester")
.WithMany("Relationships")
.HasForeignKey("RequesterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Requester");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
{
b.Navigation("Avatar");
b.Navigation("Relationships");
b.Navigation("Settings");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,68 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DorkNet.Server.Migrations
{
/// <inheritdoc />
public partial class ExternalSignIn : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DataProtectionKeys",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
FriendlyName = table.Column<string>(type: "TEXT", nullable: true),
Xml = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_DataProtectionKeys", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ExternalLogins",
columns: table => new
{
Id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
PlayerId = table.Column<long>(type: "INTEGER", nullable: false),
Issuer = table.Column<string>(type: "TEXT", nullable: false),
Subject = table.Column<string>(type: "TEXT", nullable: false),
Email = table.Column<string>(type: "TEXT", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
LastUsedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExternalLogins", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ExternalLogins_Issuer_Subject",
table: "ExternalLogins",
columns: new[] { "Issuer", "Subject" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ExternalLogins_PlayerId",
table: "ExternalLogins",
column: "PlayerId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DataProtectionKeys");
migrationBuilder.DropTable(
name: "ExternalLogins");
}
}
}
@@ -15,7 +15,7 @@ namespace DorkNet.Server.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
modelBuilder.HasAnnotation("ProductVersion", "10.0.13");
modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b =>
{
@@ -972,6 +972,43 @@ namespace DorkNet.Server.Migrations
b.ToTable("DormStates");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ExternalLoginEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Issuer")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("LastUsedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("Issuer", "Subject")
.IsUnique();
b.ToTable("ExternalLogins");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
{
b.Property<long>("Id")
@@ -3668,6 +3705,23 @@ namespace DorkNet.Server.Migrations
b.ToTable("UgcPurchasables");
});
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("FriendlyName")
.HasColumnType("TEXT");
b.Property<string>("Xml")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("DataProtectionKeys");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
{
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
+8 -3
View File
@@ -19,13 +19,18 @@ public class AuthService(IConfiguration config, DorkNetDbContext db)
// configs) and leave Jwt:Secret as the appsettings.json placeholder;
// the mismatched-secret bug shows up only there.
private string SecretKey =>
Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
?? config["Jwt:Secret"]
ConfiguredSecret(config)
?? throw new InvalidOperationException(
"JWT secret not configured. Set DORKNET_JWT_SECRET env var or " +
"Jwt:Secret in appsettings.Local.json.");
/// <summary>The server secret, in the one resolution order every user of
/// it shares (token signing, token validation, key encryption).</summary>
public static string? ConfiguredSecret(IConfiguration config) =>
Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
?? config["Jwt:Secret"];
public string GenerateToken(long playerId) => CreateJwt(playerId, TimeSpan.FromHours(12), "access");
/// <summary>
@@ -0,0 +1,67 @@
using System.Security.Cryptography;
using System.Text;
using System.Xml.Linq;
using Microsoft.AspNetCore.DataProtection.XmlEncryption;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Services;
/// <summary>
/// Encrypts the data-protection key ring (the keys behind the sign-in
/// cookies) before it is stored in the <c>DataProtectionKeys</c> table:
/// AES-256-GCM under a key derived (HKDF-SHA256) from the server secret.
/// The secret comes from the environment, not the database, so a database
/// backup alone holds no usable keys. Changing the secret makes the stored
/// keys unreadable: the server then creates a new key and everyone signs in
/// again.
/// </summary>
public sealed class DataProtectionKeyEncryption : IXmlEncryptor
{
private const int NonceSize = 12;
private const int TagSize = 16;
private static readonly byte[] Info = Encoding.UTF8.GetBytes("DorkNet.DataProtectionKeys.v1");
private static readonly XName Root = "dorknetEncryptedKey";
private readonly byte[] _key;
public DataProtectionKeyEncryption(string secret)
{
if (string.IsNullOrEmpty(secret))
throw new InvalidOperationException("The server secret is required to encrypt the data-protection keys.");
_key = HKDF.DeriveKey(HashAlgorithmName.SHA256, Encoding.UTF8.GetBytes(secret), 32, salt: null, info: Info);
}
public EncryptedXmlInfo Encrypt(XElement plaintextElement)
{
var plaintext = Encoding.UTF8.GetBytes(plaintextElement.ToString(SaveOptions.DisableFormatting));
var payload = new byte[NonceSize + TagSize + plaintext.Length];
var nonce = payload.AsSpan(0, NonceSize);
RandomNumberGenerator.Fill(nonce);
using (var aes = new AesGcm(_key, TagSize))
aes.Encrypt(nonce, plaintext, payload.AsSpan(NonceSize + TagSize), payload.AsSpan(NonceSize, TagSize));
return new EncryptedXmlInfo(
new XElement(Root, new XAttribute("version", "1"), Convert.ToBase64String(payload)),
typeof(Decryptor));
}
public XElement DecryptElement(XElement encrypted)
{
if (encrypted.Name != Root || (string?)encrypted.Attribute("version") != "1")
throw new CryptographicException("Unrecognised encrypted data-protection key.");
var payload = Convert.FromBase64String(encrypted.Value);
if (payload.Length < NonceSize + TagSize)
throw new CryptographicException("Encrypted data-protection key is truncated.");
var plaintext = new byte[payload.Length - NonceSize - TagSize];
using (var aes = new AesGcm(_key, TagSize))
aes.Decrypt(payload.AsSpan(0, NonceSize), payload.AsSpan(NonceSize + TagSize),
payload.AsSpan(NonceSize, TagSize), plaintext);
return XElement.Parse(Encoding.UTF8.GetString(plaintext));
}
/// <summary>Created by the data-protection system from the stored type
/// name, with the app's service provider.</summary>
public sealed class Decryptor(IServiceProvider services) : IXmlDecryptor
{
public XElement Decrypt(XElement encryptedElement) =>
services.GetRequiredService<DataProtectionKeyEncryption>().DecryptElement(encryptedElement);
}
}
@@ -39,6 +39,7 @@ public sealed class DownloadsService
}
var host = config["DorkNet:Downloads:Host"]?.Trim();
_host = string.IsNullOrEmpty(host) ? null : host;
RequireSignIn = config.GetValue<bool>("DorkNet:Downloads:RequireSignIn");
if (_baseUri is null)
log.LogInformation("[downloads] no DorkNet:Downloads:BaseUrl; the Downloads page and Quest latest-build install are off");
@@ -48,6 +49,10 @@ public sealed class DownloadsService
public bool IsConfigured => _baseUri is not null;
/// <summary><c>DorkNet:Downloads:RequireSignIn</c>: serve the manifest and
/// files only to a signed-in, linked player (website sign-in).</summary>
public bool RequireSignIn { get; }
/// <summary>The published manifest, or null when none has been published yet.</summary>
public async Task<JsonObject?> GetManifestAsync(CancellationToken ct)
{
+157
View File
@@ -0,0 +1,157 @@
using System.Collections.Concurrent;
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Services;
/// <summary>
/// External sign-in settings (<c>DorkNet:SignIn:*</c>): an OpenID Connect
/// provider players sign in with on the website and, for admins, the admin
/// panel. Any standard provider works (e.g. a Cloudflare Access SaaS app,
/// Google, Authentik). Unset, the feature is off and only the existing
/// username/password logins exist.
/// </summary>
public sealed class ExternalSignInOptions
{
/// <summary>The provider's issuer URL; discovery is read from
/// <c>{Authority}/.well-known/openid-configuration</c>.</summary>
public string Authority { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
/// <summary>The button label: "Sign in with {DisplayName}".</summary>
public string DisplayName { get; set; } = "single sign-on";
public string Scopes { get; set; } = "openid email profile";
/// <summary>Also read the provider's userinfo endpoint after sign-in. Off by
/// default: the ID token already carries <c>sub</c> and <c>email</c>, and
/// Cloudflare Access answers userinfo without a Content-Type, which the
/// OpenID Connect handler rejects.</summary>
public bool UseUserInfoEndpoint { get; set; }
public bool IsConfigured =>
Authority.Length > 0 && ClientId.Length > 0 && ClientSecret.Length > 0;
}
/// <summary>Scheme, path and claim names shared by the sign-in pieces.</summary>
public static class ExternalSignIn
{
/// <summary>The signed-in session (a linked DorkNet player), per host.</summary>
public const string WebScheme = "DorkNet.Web";
/// <summary>A provider identity that isn't linked to an account yet. Lives
/// only until the player links it (or 15 minutes).</summary>
public const string ExternalScheme = "DorkNet.External";
public const string OidcScheme = "DorkNet.Oidc";
// Everything lives under /api/: the website and admin hosts answer every
// other GET with their SPA's index.html before authentication runs.
public const string LoginPath = "/api/auth/external/login";
public const string CallbackPath = "/api/auth/external/callback";
public const string CompletePath = "/api/auth/external/complete";
public const string PlayerIdClaim = "dorknet:player_id";
public const string IssuerClaim = "dorknet:issuer";
public const string SubjectClaim = "dorknet:subject";
public const string EmailClaim = "dorknet:email";
public static ClaimsPrincipal PlayerPrincipal(PlayerEntity player, ExternalLoginEntity link) =>
new(new ClaimsIdentity(new[]
{
new Claim(PlayerIdClaim, player.Id.ToString()),
new Claim(ClaimTypes.Name, player.Username),
new Claim(IssuerClaim, link.Issuer),
new Claim(SubjectClaim, link.Subject),
new Claim(EmailClaim, link.Email),
}, WebScheme, ClaimTypes.Name, ClaimTypes.Role));
public static long? PlayerId(ClaimsPrincipal? principal) =>
long.TryParse(principal?.FindFirst(PlayerIdClaim)?.Value, out var id) ? id : null;
}
public enum LinkOutcome { Linked, InvalidCredentials, Banned, AlreadyLinkedElsewhere }
/// <summary>Links provider identities to DorkNet accounts and resolves them
/// back. An identity is (issuer, subject); the email is only shown.</summary>
public sealed class ExternalSignInService(DorkNetDbContext db)
{
public Task<ExternalLoginEntity?> FindAsync(string issuer, string subject) =>
db.ExternalLogins.FirstOrDefaultAsync(l => l.Issuer == issuer && l.Subject == subject);
/// <summary>The linked player, unless the link or the player is gone or the
/// player is banned.</summary>
public async Task<PlayerEntity?> ActivePlayerAsync(ExternalLoginEntity link)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == link.PlayerId);
return player is null || IsBanned(player) ? null : player;
}
public async Task TouchAsync(ExternalLoginEntity link, string email)
{
link.LastUsedAt = DateTime.UtcNow;
if (!string.IsNullOrEmpty(email)) link.Email = email;
await db.SaveChangesAsync();
}
/// <summary>Links (issuer, subject) to the account the credentials prove.
/// A player has one identity per provider: linking a new one replaces the
/// old. An identity already linked to a different account is refused.</summary>
public async Task<(LinkOutcome Outcome, PlayerEntity? Player, ExternalLoginEntity? Link)> LinkAsync(
string issuer, string subject, string email, string username, string password)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Username == username);
if (player?.PasswordHash is null || !BCrypt.Net.BCrypt.Verify(password, player.PasswordHash))
return (LinkOutcome.InvalidCredentials, null, null);
if (IsBanned(player)) return (LinkOutcome.Banned, null, null);
var existing = await FindAsync(issuer, subject);
if (existing is not null && existing.PlayerId != player.Id)
return (LinkOutcome.AlreadyLinkedElsewhere, null, null);
if (existing is null)
{
db.ExternalLogins.RemoveRange(
db.ExternalLogins.Where(l => l.PlayerId == player.Id && l.Issuer == issuer));
existing = new ExternalLoginEntity { PlayerId = player.Id, Issuer = issuer, Subject = subject };
db.ExternalLogins.Add(existing);
}
existing.Email = email;
existing.LastUsedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return (LinkOutcome.Linked, player, existing);
}
public async Task<bool> UnlinkAsync(long playerId, string issuer) =>
await db.ExternalLogins.Where(l => l.PlayerId == playerId && l.Issuer == issuer).ExecuteDeleteAsync() > 0;
private static bool IsBanned(PlayerEntity p) => p.BannedUntil is { } until && until > DateTime.UtcNow;
}
/// <summary>Caps failed link attempts (a password guess each) per provider
/// identity and per username: 5 per 15 minutes each. In memory, per process,
/// which is enough to stop guessing through the link form.</summary>
public sealed class LinkAttemptLimiter
{
private const int MaxFailures = 5;
private static readonly TimeSpan Window = TimeSpan.FromMinutes(15);
private readonly ConcurrentDictionary<string, (int Count, DateTime Since)> _failures = new();
public bool IsBlocked(params string[] keys) => keys.Any(k =>
_failures.TryGetValue(k, out var f) && DateTime.UtcNow - f.Since < Window && f.Count >= MaxFailures);
public void RecordFailure(params string[] keys)
{
var now = DateTime.UtcNow;
foreach (var k in keys)
_failures.AddOrUpdate(k, (1, now), (_, f) => now - f.Since >= Window ? (1, now) : (f.Count + 1, f.Since));
}
public void Reset(params string[] keys)
{
foreach (var k in keys) _failures.TryRemove(k, out _);
}
}
@@ -151,6 +151,7 @@ public static class DatabaseBootstrap
// idempotent raw SQL that works on a fresh DB too (CREATE TABLE
// IF NOT EXISTS no-ops when EnsureCreated already built them).
await EnsureSignupCodeTablesAsync(db);
await EnsureExternalSignInTablesAsync(db);
await EnsureServerSettingsColumnsAsync(db);
await EnsureCharadesWordListTableAsync(db);
await EnsureInventorySchemaAsync(db);
@@ -265,6 +266,66 @@ public static class DatabaseBootstrap
/// tables on whichever provider is in use. Safe to run every boot:
/// CREATE TABLE / INDEX IF NOT EXISTS no-op once they exist (whether
/// created here or by a fresh-DB EnsureCreated).</summary>
/// <summary>External sign-in: the identities linked to accounts
/// (<c>ExternalLogins</c>) and the data-protection keys that encrypt the
/// sign-in cookies (<c>DataProtectionKeys</c>). Both post-date the Initial
/// migration, so like the signup-code tables they're created here for
/// either provider (a no-op once EnsureCreated or an earlier boot made them).</summary>
private static async Task EnsureExternalSignInTablesAsync(DorkNetDbContext db)
{
var pg = db.Database.IsNpgsql();
var statements = pg
? new[]
{
"""
CREATE TABLE IF NOT EXISTS "ExternalLogins" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"PlayerId" bigint NOT NULL,
"Issuer" text NOT NULL,
"Subject" text NOT NULL,
"Email" text NOT NULL DEFAULT '',
"CreatedAt" timestamp with time zone NOT NULL DEFAULT now(),
"LastUsedAt" timestamp with time zone NOT NULL DEFAULT now()
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ExternalLogins_Issuer_Subject"" ON ""ExternalLogins"" (""Issuer"", ""Subject"");",
@"CREATE INDEX IF NOT EXISTS ""IX_ExternalLogins_PlayerId"" ON ""ExternalLogins"" (""PlayerId"");",
"""
CREATE TABLE IF NOT EXISTS "DataProtectionKeys" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"FriendlyName" text NULL,
"Xml" text NULL
);
""",
}
: new[]
{
"""
CREATE TABLE IF NOT EXISTS "ExternalLogins" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_ExternalLogins" PRIMARY KEY AUTOINCREMENT,
"PlayerId" INTEGER NOT NULL,
"Issuer" TEXT NOT NULL,
"Subject" TEXT NOT NULL,
"Email" TEXT NOT NULL DEFAULT '',
"CreatedAt" TEXT NOT NULL,
"LastUsedAt" TEXT NOT NULL
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ExternalLogins_Issuer_Subject"" ON ""ExternalLogins"" (""Issuer"", ""Subject"");",
@"CREATE INDEX IF NOT EXISTS ""IX_ExternalLogins_PlayerId"" ON ""ExternalLogins"" (""PlayerId"");",
"""
CREATE TABLE IF NOT EXISTS "DataProtectionKeys" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_DataProtectionKeys" PRIMARY KEY AUTOINCREMENT,
"FriendlyName" TEXT NULL,
"Xml" TEXT NULL
);
""",
};
foreach (var sql in statements)
await db.Database.ExecuteSqlRawAsync(sql);
}
private static async Task EnsureSignupCodeTablesAsync(DorkNetDbContext db)
{
var pg = db.Database.IsNpgsql();
@@ -1,4 +1,9 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.DataProtection.KeyManagement;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
@@ -29,6 +34,7 @@ public static class ServiceCollectionExtensions
AddVersionCompatibility(builder);
var redisConn = AddRedis(builder);
AddAuthentication(builder);
AddExternalSignIn(builder);
AddKestrelAndSignalR(builder, redisConn);
}
@@ -340,9 +346,7 @@ public static class ServiceCollectionExtensions
// older Coolify configs.
private static void AddAuthentication(WebApplicationBuilder builder)
{
var jwtSecret = Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
?? builder.Configuration["Jwt:Secret"]
var jwtSecret = AuthService.ConfiguredSecret(builder.Configuration)
?? 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 " +
@@ -397,6 +401,119 @@ public static class ServiceCollectionExtensions
opt.JsonSerializerOptions.PropertyNamingPolicy = null);
}
// ── External sign-in (OpenID Connect) ─────────────────────────────────────
// Players sign in to the website (and admins to the admin panel) with an
// OpenID Connect provider, configured under DorkNet:SignIn. The game
// client's own token flow is untouched. Two cookie schemes, both per host
// (the hyphen host style puts the admin panel outside the apex's cookie
// domain): DorkNet.Web is the signed-in player, DorkNet.External a
// provider identity waiting to be linked to an account.
private static void AddExternalSignIn(WebApplicationBuilder builder)
{
var services = builder.Services;
var section = builder.Configuration.GetSection("DorkNet:SignIn");
services.Configure<ExternalSignInOptions>(section);
var signIn = section.Get<ExternalSignInOptions>() ?? new ExternalSignInOptions();
var https = !string.Equals(builder.Configuration["Domain:Scheme"] ?? "https", "http", StringComparison.OrdinalIgnoreCase);
var secure = https ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
services.AddScoped<ExternalSignInService>();
services.AddSingleton<LinkAttemptLimiter>();
// Cookie encryption keys live in the database, so a redeploy or a
// second replica doesn't invalidate everyone's session. They're
// stored encrypted with a key derived from the server secret, which
// lives outside the database, so a database backup alone can't forge
// sessions.
services.AddSingleton(new DataProtectionKeyEncryption(
AuthService.ConfiguredSecret(builder.Configuration) ?? string.Empty));
services.AddDataProtection()
.SetApplicationName("DorkNet")
.PersistKeysToDbContext<DorkNetDbContext>();
services.AddOptions<KeyManagementOptions>()
.Configure<DataProtectionKeyEncryption>((o, enc) => o.XmlEncryptor = enc);
var auth = services.AddAuthentication()
.AddCookie(ExternalSignIn.WebScheme, o =>
{
o.Cookie.Name = "dorknet_session";
o.Cookie.HttpOnly = true;
o.Cookie.SameSite = SameSiteMode.Lax;
o.Cookie.SecurePolicy = secure;
o.ExpireTimeSpan = TimeSpan.FromDays(30);
o.SlidingExpiration = true;
// API endpoints answer 401/403 instead of redirecting.
o.Events.OnRedirectToLogin = ctx => { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; };
o.Events.OnRedirectToAccessDenied = ctx => { ctx.Response.StatusCode = StatusCodes.Status403Forbidden; return Task.CompletedTask; };
// An unlinked identity, a deleted or banned player ends the session.
o.Events.OnValidatePrincipal = async ctx =>
{
var issuer = ctx.Principal?.FindFirst(ExternalSignIn.IssuerClaim)?.Value;
var subject = ctx.Principal?.FindFirst(ExternalSignIn.SubjectClaim)?.Value;
var svc = ctx.HttpContext.RequestServices.GetRequiredService<ExternalSignInService>();
var link = issuer is null || subject is null ? null : await svc.FindAsync(issuer, subject);
if (link is null || link.PlayerId != ExternalSignIn.PlayerId(ctx.Principal)
|| await svc.ActivePlayerAsync(link) is null)
{
ctx.RejectPrincipal();
await ctx.HttpContext.SignOutAsync(ExternalSignIn.WebScheme);
}
};
})
.AddCookie(ExternalSignIn.ExternalScheme, o =>
{
o.Cookie.Name = "dorknet_external";
o.Cookie.HttpOnly = true;
o.Cookie.SameSite = SameSiteMode.Lax;
o.Cookie.SecurePolicy = secure;
o.ExpireTimeSpan = TimeSpan.FromMinutes(15);
o.Events.OnRedirectToLogin = ctx => { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; };
});
if (!signIn.IsConfigured) return;
auth.AddOpenIdConnect(ExternalSignIn.OidcScheme, o =>
{
o.Authority = signIn.Authority;
o.ClientId = signIn.ClientId;
o.ClientSecret = signIn.ClientSecret;
o.ResponseType = "code";
o.UsePkce = true;
o.Scope.Clear();
foreach (var scope in signIn.Scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries))
o.Scope.Add(scope);
o.SignInScheme = ExternalSignIn.ExternalScheme;
o.CallbackPath = ExternalSignIn.CallbackPath;
o.GetClaimsFromUserInfoEndpoint = signIn.UseUserInfoEndpoint;
o.MapInboundClaims = false;
o.SaveTokens = false;
o.CorrelationCookie.SecurePolicy = secure;
o.NonceCookie.SecurePolicy = secure;
o.Events.OnRedirectToIdentityProvider = ctx =>
{
// Behind TLS-terminating proxies the request arrives as http;
// the provider must redirect back to the public URL of the
// host the sign-in started on (website or admin panel).
var domain = ctx.HttpContext.RequestServices.GetRequiredService<DomainConfig>();
ctx.ProtocolMessage.RedirectUri = $"{domain.Scheme}://{ctx.Request.Host}{ExternalSignIn.CallbackPath}";
return Task.CompletedTask;
};
o.Events.OnRemoteFailure = ctx =>
{
// e.g. the player cancelled at the provider: back to the login
// page (admin panel) or the site with an error instead of an
// exception page. The message is logged, not shown.
var domain = ctx.HttpContext.RequestServices.GetRequiredService<DomainConfig>();
var admin = string.Equals(ctx.Request.Host.Host, domain.Sub("admin"), StringComparison.OrdinalIgnoreCase);
ctx.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
.CreateLogger("DorkNet.SignIn").LogWarning(ctx.Failure, "[signin] provider sign-in failed");
ctx.Response.Redirect((admin ? "/login" : "/") + "?signin_error=provider_failed");
ctx.HandleResponse();
return Task.CompletedTask;
};
});
}
// ── Kestrel + SignalR ────────────────────────────────────────────────────
// Kestrel-level limits: defaults trim long-lived sockets earlier than our
// SignalR ping interval allows.
+18 -11
View File
@@ -49,21 +49,28 @@ export function assetCdnUrl(path: string | null | undefined): string | null {
return subdomainUrl('cdn', path, undefined, true);
}
export function adminApex(): string {
if (typeof window === 'undefined') return 'rec.net';
// The server mounts this SPA on its own `admin` subdomain, so the admin
// host tells us the deployment's apex and subdomain style: `admin.<apex>`
// (dot style, `img.<apex>`) or `admin-<apex>` (hyphen style, `img-<apex>`).
function adminHost(): { apex: string; hyphen: boolean } {
if (typeof window === 'undefined') return { apex: 'rec.net', hyphen: false };
const host = window.location.hostname.toLowerCase();
if (host === 'localhost' || host.endsWith('.localhost')) return 'localhost';
if (host.endsWith('-dev.example.com')) return 'dev.example.com';
if (host === 'localhost' || host.endsWith('.localhost')) return { apex: 'localhost', hyphen: false };
const parts = host.split('.');
return parts.length > 2 ? parts.slice(1).join('.') : host;
const [first, ...rest] = host.split('.');
if (first.startsWith('admin-') && rest.length > 0)
return { apex: [first.slice('admin-'.length), ...rest].join('.'), hyphen: true };
return { apex: rest.length >= 2 ? rest.join('.') : host, hyphen: false };
}
function serviceHost(subdomain: string, apex: string): string {
return apex === 'dev.example.com'
? `${subdomain}-${apex}`
: `${subdomain}.${apex}`;
export function adminApex(): string {
return adminHost().apex;
}
function serviceHost(subdomain: string): string {
const { apex, hyphen } = adminHost();
return hyphen ? `${subdomain}-${apex}` : `${subdomain}.${apex}`;
}
function subdomainUrl(subdomain: string, path: string | null | undefined, query?: string, preserveSlashes = false): string | null {
@@ -74,7 +81,7 @@ function subdomainUrl(subdomain: string, path: string | null | undefined, query?
const encodedPath = preserveSlashes
? path.split('/').map(encodeURIComponent).join('/')
: encodeURIComponent(path);
return `${scheme}://${serviceHost(subdomain, apex)}/${encodedPath}${suffix}`;
return `${scheme}://${serviceHost(subdomain)}/${encodedPath}${suffix}`;
}
export interface Room {
+87 -18
View File
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router';
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useLocation, useSearchParams } from 'react-router';
import { api, ApiError } from '../lib/api';
import { notifyAuthChange, setSession } from '../lib/auth';
import { adminApex } from '../lib/types';
import { BrandMark } from '../components/BrandMark';
interface LoginResponse {
@@ -12,14 +13,82 @@ interface LoginResponse {
display_name: string;
}
// The website, where an identity from the sign-in provider is linked to an
// account (the admin panel only signs in identities that are linked already).
function siteUrl(path: string): string {
const apex = adminApex();
return `${apex === 'localhost' ? 'http' : 'https'}://${apex}${path}`;
}
const SSO_ERRORS: Record<string, React.ReactNode> = {
not_admin: 'That account is not an admin.',
not_signed_in: "Single sign-on didn't finish. Try again.",
account_unavailable: 'The account linked to that sign-in is unavailable (deleted or banned).',
no_identity: "The sign-in provider didn't return an identity. Try again.",
provider_failed: 'The sign-in was cancelled or failed at the provider. Try again.',
};
export function Login() {
const navigate = useNavigate();
const location = useLocation();
const [params, setParams] = useSearchParams();
const from = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/';
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState<string | null>(null);
const [err, setErr] = useState<React.ReactNode | null>(null);
const [loading, setLoading] = useState(false);
// Single sign-on through the server's OpenID Connect provider, if set up.
const [sso, setSso] = useState<{ provider: string } | null>(null);
const ssoHandled = useRef(false);
const finish = (res: LoginResponse, to: string) => {
setSession(res.access_token, {
id: res.account_id,
username: res.username,
displayName: res.display_name,
});
notifyAuthChange();
navigate(to, { replace: true });
};
const errorText = (e: unknown, codes: Record<string, React.ReactNode>): React.ReactNode => {
if (e instanceof ApiError && e.body && typeof e.body === 'object' && 'error' in e.body) {
const code = (e.body as { error: string }).error;
return Object.hasOwn(codes, code) ? codes[code] : e.message;
}
return e instanceof Error ? e.message : String(e);
};
useEffect(() => {
fetch('/api/site/v1/account/me', { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((me) => { if (me?.signInAvailable) setSso({ provider: me.provider }); })
.catch(() => { /* no single sign-on: password only */ });
}, []);
// Back from the provider: ?sso=1 swaps the session for the admin token;
// ?signin_error=… explains why it didn't work.
useEffect(() => {
if (ssoHandled.current) return;
const signinError = params.get('signin_error');
if (signinError) {
ssoHandled.current = true;
setErr(signinError === 'not_linked'
? <>This sign-in isn't linked to a DorkNet account yet. Link it on the <a className="underline" href={siteUrl('/api/auth/external/login?returnUrl=%2Faccount')}>website</a> first, then sign in here again.</>
// Only known codes are shown: the query string is attacker-controllable.
: Object.hasOwn(SSO_ERRORS, signinError) ? SSO_ERRORS[signinError] : 'Single sign-on failed. Try again.');
setParams({}, { replace: true });
return;
}
if (params.get('sso') !== '1') return;
ssoHandled.current = true;
setLoading(true);
api<LoginResponse>('/sso')
.then((res) => finish(res, params.get('to') || '/'))
.catch((e) => { setErr(errorText(e, SSO_ERRORS)); setParams({}, { replace: true }); })
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -30,27 +99,19 @@ export function Login() {
method: 'POST',
body: { username, password },
});
setSession(res.access_token, {
id: res.account_id,
username: res.username,
displayName: res.display_name,
});
notifyAuthChange();
navigate(from, { replace: true });
finish(res, from);
} catch (e) {
if (e instanceof ApiError) {
if (e.body && typeof e.body === 'object' && 'error' in e.body) {
const code = (e.body as { error: string }).error;
if (code === 'not_admin') setErr('That account is not an admin.');
else if (code === 'invalid_credentials') setErr('Invalid username or password.');
else setErr(e.message);
} else setErr(e.message);
} else setErr(String(e));
setErr(errorText(e, {
not_admin: 'That account is not an admin.',
invalid_credentials: 'Invalid username or password.',
}));
} finally {
setLoading(false);
}
};
const ssoHref = `/api/auth/external/login?returnUrl=${encodeURIComponent(`/login?sso=1&to=${encodeURIComponent(from)}`)}`;
return (
<div className="min-h-full flex items-center justify-center px-4 py-12 bg-[linear-gradient(135deg,#0a0b0e_0%,#0d1d25_48%,#131418_100%)]">
<div className="w-full max-w-sm">
@@ -92,6 +153,14 @@ export function Login() {
<button type="submit" disabled={loading} className="btn-primary mt-1">
{loading ? 'Signing in…' : 'Sign in'}
</button>
{sso && (
<>
<div className="flex items-center gap-3 text-[11px] uppercase tracking-wide text-ink-500">
<span className="h-px flex-1 bg-ink-800" />or<span className="h-px flex-1 bg-ink-800" />
</div>
<a href={ssoHref} className="btn-secondary text-center">Sign in with {sso.provider}</a>
</>
)}
</form>
<p className="mt-6 text-center text-[11px] text-ink-500">DorkNet private server · admin console</p>
+4
View File
@@ -9,6 +9,8 @@ import { PlayerProfile } from './pages/PlayerProfile';
import { Rooms } from './pages/Rooms';
import { Join } from './pages/Join';
import { Downloads } from './pages/Downloads';
import { Link as LinkAccount } from './pages/Link';
import { Account } from './pages/Account';
import { NotFound } from './pages/NotFound';
// The Quest installer pulls in the WebUSB/ADB stack; load it only on /quest.
@@ -29,6 +31,8 @@ export function App() {
<Route path="/join" element={<Join />} />
<Route path="/quest" element={<Suspense fallback={null}><Quest /></Suspense>} />
<Route path="/downloads" element={<Downloads />} />
<Route path="/link" element={<LinkAccount />} />
<Route path="/account" element={<Account />} />
{/* Legacy paths from the vanilla-JS feed.localhost site */}
<Route path="/p/:id" element={<RedirectPhoto />} />
<Route path="/u/:id" element={<RedirectPlayer />} />
+52 -3
View File
@@ -1,7 +1,8 @@
import { NavLink, Link, useLocation, useNavigate } from 'react-router';
import { NavLink, Link, useLocation, useNavigate, useSearchParams } from 'react-router';
import { useEffect, useRef, useState } from 'react';
import { BrandMark } from './BrandMark';
import { getDownloads } from '../lib/downloads';
import { getDownloads, SignInRequiredError } from '../lib/downloads';
import { errorCopy, getMe, signInUrl, signOut, SIGNIN_ERRORS, type AccountMe } from '../lib/account';
const NAV = [
{ to: '/', label: 'Home', exact: true },
@@ -23,10 +24,37 @@ export function Header() {
const [hasDownloads, setHasDownloads] = useState(false);
const navItems = hasDownloads ? [...NAV, DOWNLOADS] : NAV;
const [me, setMe] = useState<AccountMe | null>(null);
const [params, setParams] = useSearchParams();
const signinError = params.get('signin_error');
useEffect(() => {
getDownloads().then((m) => setHasDownloads(m !== null)).catch(() => {});
getDownloads().then((m) => setHasDownloads(m !== null))
.catch((e) => setHasDownloads(e instanceof SignInRequiredError));
getMe().then(setMe).catch(() => { /* site works without sign-in */ });
}, []);
const [signOutError, setSignOutError] = useState<string | null>(null);
const doSignOut = async () => {
setSignOutError(null);
try {
await signOut();
window.location.assign('/');
} catch (e) {
setSignOutError((e as Error).message);
}
};
// Sign in / account, shown only when the server has a sign-in provider.
const account = !me?.signInAvailable ? null : me.signedIn && me.player ? (
<span className="flex items-center gap-2 text-sm">
<Link to="/account" className="text-ink-200 hover:text-ink-50">{me.player.username}</Link>
<button type="button" onClick={doSignOut} className="btn-ghost !px-2 !py-1 text-xs">Sign out</button>
</span>
) : (
<a href={signInUrl()} className="btn-secondary !px-3 !py-1.5 text-sm">Sign in</a>
);
// Close the mobile drawer on route change so tapping a nav item
// doesn't leave a half-open overlay covering the page.
useEffect(() => { setOpen(false); }, [loc.pathname]);
@@ -72,6 +100,8 @@ export function Header() {
/>
</form>
<div className="hidden md:flex shrink-0">{account}</div>
<button
type="button"
onClick={() => setOpen(o => !o)}
@@ -107,6 +137,25 @@ export function Header() {
</NavLink>
))}
</nav>
{account && <div className="pt-1">{account}</div>}
</div>
</div>
)}
{signOutError && (
<div className="border-t border-danger/30 bg-danger/10">
<div className="mx-auto max-w-6xl px-4 sm:px-6 py-2 flex items-center justify-between gap-3 text-sm text-danger">
<span>Couldn't sign out ({signOutError}). You're still signed in; try again.</span>
<button type="button" className="btn-ghost !px-2 !py-1 text-xs" onClick={() => setSignOutError(null)}>Dismiss</button>
</div>
</div>
)}
{signinError && (
<div className="border-t border-danger/30 bg-danger/10">
<div className="mx-auto max-w-6xl px-4 sm:px-6 py-2 flex items-center justify-between gap-3 text-sm text-danger">
<span>Sign-in didn't work: {errorCopy(SIGNIN_ERRORS, signinError, 'something went wrong. Try again.')}</span>
<button type="button" className="btn-ghost !px-2 !py-1 text-xs" onClick={() => { params.delete('signin_error'); setParams(params, { replace: true }); }}>Dismiss</button>
</div>
</div>
)}
+73
View File
@@ -0,0 +1,73 @@
// Website sign-in through the server's OpenID Connect provider
// (ExternalSignInController). The session is an HttpOnly cookie on this host,
// so there is no token to keep here: same-origin fetches carry it.
import { get, post } from './api';
export interface AccountMe {
signInAvailable: boolean;
provider: string;
signedIn: boolean;
player: { id: string; username: string; email: string } | null;
pendingLink: { email: string } | null;
}
let cached: Promise<AccountMe> | null = null;
/** Who is signed in. Fetched once per page load; `fresh` re-reads it. */
export function getMe(fresh = false): Promise<AccountMe> {
if (!cached || fresh) cached = get<AccountMe>('/account/me').catch((e) => { cached = null; throw e; });
return cached;
}
/** `url` if it is a path on this site, else `/` (the server applies the same
* rule): never navigate to an address taken from the query string otherwise. */
export function localPath(url: string | null | undefined): string {
if (!url || !url.startsWith('/')) return '/';
// Parse it the way the browser will (it drops tabs and newlines and reads
// \ as /) and keep it only if it stays on this origin.
try {
const u = new URL(url, window.location.origin);
return u.origin === window.location.origin ? u.pathname + u.search + u.hash : '/';
} catch {
return '/';
}
}
/** Starts sign-in at the provider; comes back to `returnUrl` on this site. */
export function signInUrl(returnUrl = window.location.pathname + window.location.search): string {
return `/api/auth/external/login?returnUrl=${encodeURIComponent(returnUrl)}`;
}
// Each of these changes who is signed in, so the cached answer is dropped.
async function changesAccount<T>(request: Promise<T>): Promise<T> {
const result = await request;
cached = null;
return result;
}
export const link = (username: string, password: string) =>
changesAccount(post<{ linked: boolean; username: string }>('/account/link', { username, password }));
export const signOut = () => changesAccount(post('/account/logout', {}));
export const unlink = () => changesAccount(post('/account/unlink', {}));
// Server error code -> friendly copy.
export const LINK_ERRORS: Record<string, string> = {
invalid_credentials: "That username and password don't match a DorkNet account.",
missing_credentials: 'Enter your DorkNet username and password.',
too_many_attempts: 'Too many wrong attempts. Wait 15 minutes and try again.',
banned: 'That account is banned.',
identity_linked_to_another_account: 'This sign-in is already linked to a different DorkNet account.',
no_pending_identity: 'Your sign-in expired. Start again with "Sign in".',
};
/** Friendly copy for a known error code, else `fallback`. For codes read from
* the query string, pass a fixed fallback: anyone can put text in a link. */
export function errorCopy(map: Record<string, string>, code: string | null | undefined, fallback: string): string {
return code && Object.hasOwn(map, code) ? map[code] : fallback;
}
export const SIGNIN_ERRORS: Record<string, string> = {
account_unavailable: 'The account linked to this sign-in is unavailable (deleted or banned).',
no_identity: "The sign-in provider didn't return an identity. Try again.",
provider_failed: 'The sign-in was cancelled or failed at the provider. Try again.',
};
+3 -3
View File
@@ -1,7 +1,7 @@
// Lightweight fetch wrapper for the public-facing site. Talks to the
// apex-hosted PublicSiteController at /api/site/v1/*. Anonymous-only —
// the public site has no login flow, so there's no Authorization
// header or token plumbing.
// apex-hosted PublicSiteController at /api/site/v1/*. No Authorization
// header or token plumbing: the optional sign-in (lib/account.ts) is an
// HttpOnly cookie that same-origin fetches send on their own.
const BASE = '/api/site/v1';
+7
View File
@@ -28,6 +28,12 @@ export interface DownloadsManifest {
updated?: string;
}
/** The server only serves downloads to signed-in players
* (DorkNet:Downloads:RequireSignIn) and nobody is signed in. */
export class SignInRequiredError extends Error {
constructor() { super('sign_in_required'); this.name = 'SignInRequiredError'; }
}
let cached: Promise<DownloadsManifest | null> | null = null;
/** The manifest, or null when this server doesn't offer downloads. Fetched
@@ -36,6 +42,7 @@ export function getDownloads(fresh = false): Promise<DownloadsManifest | null> {
if (!cached || fresh) {
cached = get<DownloadsManifest>('/downloads').catch((e) => {
if (e instanceof ApiError && e.status === 404) return null;
if (e instanceof ApiError && e.status === 401) { cached = null; throw new SignInRequiredError(); }
cached = null;
throw e;
});
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { Link as RouterLink } from 'react-router';
import { getMe, signInUrl, signOut, unlink, type AccountMe } from '../lib/account';
// The signed-in account: which DorkNet account and provider email are
// linked, plus sign out and unlink.
export function Account() {
const [me, setMe] = useState<AccountMe | null>(null);
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
getMe(true).then(setMe).catch((e) => setErr((e as Error).message));
}, []);
const run = async (action: () => Promise<unknown>) => {
setBusy(true);
try {
await action();
window.location.assign('/');
} catch (e) {
setErr((e as Error).message);
setBusy(false);
}
};
if (err) return <div className="max-w-lg mx-auto rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{err}</div>;
if (!me) return <p className="max-w-lg mx-auto text-sm text-ink-400">Loading…</p>;
if (!me.signedIn || !me.player) {
return (
<div className="max-w-lg mx-auto card !p-6 space-y-3 text-sm text-ink-300">
<h1 className="text-xl font-semibold text-ink-50">Account</h1>
<p>You're not signed in.</p>
{me.signInAvailable
? <a className="btn-primary inline-block" href={signInUrl('/account')}>Sign in with {me.provider}</a>
: <p className="text-ink-400">Sign-in isn't set up on this server.</p>}
</div>
);
}
return (
<div className="max-w-lg mx-auto space-y-4">
<h1 className="text-2xl font-semibold text-ink-50">Account</h1>
<div className="card !p-6 space-y-3 text-sm">
<div className="flex justify-between gap-3">
<span className="text-ink-400">DorkNet account</span>
<RouterLink className="text-brand-300 hover:text-brand-200" to={`/players/${me.player.id}`}>{me.player.username}</RouterLink>
</div>
<div className="flex justify-between gap-3">
<span className="text-ink-400">Signed in with {me.provider}</span>
<span className="text-ink-200">{me.player.email || '—'}</span>
</div>
<div className="flex flex-wrap gap-2 pt-2">
<button type="button" className="btn-secondary" disabled={busy} onClick={() => run(signOut)}>Sign out</button>
<button type="button" className="btn-ghost text-danger" disabled={busy}
onClick={() => { if (window.confirm(`Unlink this sign-in from ${me.player!.username}? You'll need the account's password to link it again.`)) void run(unlink); }}>
Unlink
</button>
</div>
</div>
</div>
);
}
+14 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { fileUrl, getDownloads, type DownloadFile, type DownloadRelease, type DownloadsManifest } from '../lib/downloads';
import { fileUrl, getDownloads, SignInRequiredError, type DownloadFile, type DownloadRelease, type DownloadsManifest } from '../lib/downloads';
import { signInUrl } from '../lib/account';
const fmtSize = (n: number) =>
n >= 2 ** 30 ? `${(n / 2 ** 30).toFixed(2)} GB` : n >= 2 ** 20 ? `${(n / 2 ** 20).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 2 ** 10))} KB`;
@@ -96,9 +97,13 @@ function FileCard({ f, rel }: { f: DownloadFile; rel: DownloadRelease }) {
export function Downloads() {
const [m, setM] = useState<DownloadsManifest | null | undefined>(undefined);
const [err, setErr] = useState<string | null>(null);
const [signInNeeded, setSignInNeeded] = useState(false);
useEffect(() => {
getDownloads(true).then(setM).catch((e) => setErr((e as Error).message));
getDownloads(true).then(setM).catch((e) => {
if (e instanceof SignInRequiredError) setSignInNeeded(true);
else setErr((e as Error).message);
});
}, []);
const releases = (m?.releases ?? []).filter((r) => r.files.length);
@@ -114,7 +119,13 @@ export function Downloads() {
</div>
{err && <div className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">Couldn't load the builds: {err}</div>}
{m === undefined && !err && <p className="text-sm text-ink-400">Loading builds…</p>}
{signInNeeded && (
<div className="card !p-5 flex flex-wrap items-center justify-between gap-3 text-sm text-ink-300">
<span>Sign in with your DorkNet account to see and download the builds.</span>
<a className="btn-primary" href={signInUrl('/downloads')}>Sign in</a>
</div>
)}
{m === undefined && !err && !signInNeeded && <p className="text-sm text-ink-400">Loading builds…</p>}
{m === null && <p className="text-sm text-ink-400">This server doesn't offer downloads.</p>}
{m && !releases.length && <p className="text-sm text-ink-400">No builds have been published yet.</p>}
+82
View File
@@ -0,0 +1,82 @@
import { useEffect, useState } from 'react';
import { Link as RouterLink, useNavigate, useSearchParams } from 'react-router';
import { errorCopy, getMe, link, LINK_ERRORS, localPath, signInUrl, type AccountMe } from '../lib/account';
// After a first sign-in with the provider: prove which DorkNet account this
// identity belongs to, once, with that account's password.
export function Link() {
const [params] = useSearchParams();
const returnUrl = localPath(params.get('returnUrl'));
const navigate = useNavigate();
const [me, setMe] = useState<AccountMe | null>(null);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
getMe(true).then((m) => {
// Signed in with nothing waiting to be linked: nothing to do here. (A
// signed-in visitor can still link a new identity, e.g. a second
// account's.)
if (m.signedIn && !m.pendingLink) navigate(returnUrl, { replace: true });
else setMe(m);
}).catch((e) => setErr((e as Error).message));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
setErr(null);
try {
await link(username.trim(), password);
await getMe(true);
window.location.assign(returnUrl); // the header re-reads who is signed in
} catch (e) {
const key = (e as Error).message;
setErr(errorCopy(LINK_ERRORS, key, key));
} finally {
setBusy(false);
}
};
if (me && !me.pendingLink) {
return (
<div className="max-w-lg mx-auto card !p-6 space-y-3 text-sm text-ink-300">
<h1 className="text-xl font-semibold text-ink-50">Link your account</h1>
<p>Your sign-in expired before it was linked.</p>
{me.signInAvailable && <a className="btn-primary inline-block" href={signInUrl(returnUrl)}>Sign in again</a>}
</div>
);
}
return (
<div className="max-w-lg mx-auto space-y-4">
<div>
<h1 className="text-2xl font-semibold text-ink-50">Link your DorkNet account</h1>
<p className="text-sm text-ink-400">
You signed in{me?.pendingLink?.email ? <> as <span className="text-ink-200">{me.pendingLink.email}</span></> : null}.
Enter your DorkNet username and password once to connect them; after that, signing in is enough.
</p>
</div>
<form onSubmit={submit} className="card !p-6 space-y-4">
<div className="space-y-1.5">
<label className="label" htmlFor="username">DorkNet username</label>
<input id="username" className="input" autoComplete="username" required
value={username} onChange={(e) => setUsername(e.target.value)} />
</div>
<div className="space-y-1.5">
<label className="label" htmlFor="password">Password</label>
<input id="password" type="password" className="input" autoComplete="current-password" required
value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
{err && <div className="rounded-lg border border-danger/30 bg-danger/5 px-3 py-2 text-sm text-danger">{err}</div>}
<button type="submit" disabled={busy} className="btn-primary w-full">{busy ? 'Linking…' : 'Link account'}</button>
<p className="text-xs text-ink-500">
No account yet? <RouterLink className="text-brand-300 hover:text-brand-200" to="/join">Join with a signup code</RouterLink>, then come back here.
</p>
</form>
</div>
);
}
+13 -2
View File
@@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { fileSource, openXapk, urlSource, type Xapk } from '../lib/xapk';
import { connectHeadset, installXapk, webUsbSupported, type Headset, type Progress } from '../lib/questInstall';
import { fileUrl, getDownloads, questBuild, type QuestBuild } from '../lib/downloads';
import { fileUrl, getDownloads, questBuild, SignInRequiredError, type QuestBuild } from '../lib/downloads';
import { signInUrl } from '../lib/account';
const fmtSize = (n: number) =>
n >= 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1e3))} KB`;
@@ -47,6 +48,8 @@ export function Quest() {
const fileRef = useRef<HTMLInputElement>(null);
// The server's latest published Quest build, if it offers downloads.
const [latest, setLatest] = useState<QuestBuild | null>(null);
// Downloads exist but need a signed-in player (DorkNet:Downloads:RequireSignIn).
const [signInForLatest, setSignInForLatest] = useState(false);
const [params] = useSearchParams();
const open = async (load: () => Promise<Xapk>) => {
@@ -71,7 +74,10 @@ export function Quest() {
// that card's own file, which may not be the newest release's.
const wanted = params.get('file');
if (wanted) void pickBuild(questBuild(m, wanted));
}).catch(() => { /* no downloads: only the file picker */ });
}).catch((e) => {
// No downloads: only the file picker. Sign-in required: offer it.
if (e instanceof SignInRequiredError) setSignInForLatest(true);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -140,6 +146,11 @@ export function Quest() {
) : (
<p className="text-sm text-ink-400">Pick the <code className="text-ink-200">DorkNet-….xapk</code> you were given. It stays on your computer; nothing is uploaded.</p>
)}
{signInForLatest && !latest && (
<p className="text-sm text-ink-400">
<a className="text-brand-300 hover:text-brand-200" href={signInUrl('/quest')}>Sign in</a> to install the latest build straight from this server.
</p>
)}
<input ref={fileRef} type="file" accept=".xapk,.zip" className="hidden" onChange={(e) => pickFile(e.target.files?.[0])} />
<div className="flex flex-wrap items-center gap-3">
{latest && (
@@ -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>
Vendored
+21 -10
View File
@@ -9,8 +9,9 @@
// from the agent's environment (ARCHIVE_PC_REPO / ARCHIVE_QUEST_REPO), each
// overridable per run. A build is picked by snapshot tags.
//
// On a push, only the platforms whose code changed are rebuilt. A manual run
// picks the platforms with PLATFORMS. See ci/jenkins/README.md.
// A push builds only the platforms whose client code changed (never for
// docs or CI-only changes). A manual run picks the platforms with PLATFORMS.
// See ci/jenkins/README.md.
//
// Finished builds are renamed for players and published to the downloads bucket
// (ci/downloads) unless PUBLISH_DOWNLOADS is off. The build scripts keep their
@@ -76,10 +77,15 @@ pipeline {
for (cs in currentBuild.changeSets) {
for (entry in cs.items) changed.addAll(entry.affectedPaths)
}
def touches = { prefixes -> changed.any { p -> prefixes.any { p.startsWith(it) } } }
def common = ['Jenkinsfile', 'ci/jenkins/']
buildPc = touches(common + ['DorkNet.ClientMod/', 'tools/build_pc.py'])
buildQuest = touches(common + ['DorkNet.Quest/'])
// Only client-facing changes rebuild (and so republish) a
// platform; Markdown (.md, .markdown) doesn't count. The job's Git path filter
// uses the same paths, so other pushes don't start a run at all.
// A CI-only change (Jenkinsfile, ci/) takes a manual run.
def touches = { prefixes ->
changed.any { p -> !(p.toLowerCase() ==~ /.*\.(md|markdown)/) && prefixes.any { p.startsWith(it) } }
}
buildPc = touches(['DorkNet.ClientMod/', 'tools/build_pc.py'])
buildQuest = touches(['DorkNet.Quest/'])
echo "Push build; ${changed.size()} changed file(s)"
} else {
buildPc = params.PLATFORMS in ['both', 'pc']
@@ -220,10 +226,15 @@ pipeline {
archiveArtifacts artifacts: 'out/downloads/**', allowEmptyArchive: true, fingerprint: true
}
cleanup {
// Runs after archiving. Keeps the restic cache and restored stock
// builds ($DORKNET_CACHE on the agent, outside the workspace); drops
// the workspace's build output and scratch.
cleanWs(deleteDirs: true, notFailBuild: true)
// Runs after archiving. Drops the build output and scratch (several
// GB) but keeps the Git checkout: the job's path filter compares
// pushes against it, and without a workspace Jenkins treats every
// push as a change and starts a run. The restic cache and restored
// stock builds live outside the workspace ($DORKNET_CACHE).
cleanWs(deleteDirs: true, notFailBuild: true, patterns: [
[pattern: 'out/**', type: 'INCLUDE'],
[pattern: 'quest-work/**', type: 'INCLUDE'],
])
}
}
}
+12 -2
View File
@@ -64,7 +64,7 @@ What the 2023 client gets here that the 2020 branches don't have:
## Quickstart
**1. Install .NET 9 SDK** and (optionally) `dotnet-ef`:
**1. Install .NET 10 SDK** and (optionally) `dotnet-ef`:
```pwsh
dotnet tool install --global dotnet-ef
@@ -101,7 +101,7 @@ cd DorkNet
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`
@@ -431,6 +431,16 @@ hand it to the player, and they redeem it on the site's **`/join`** page —
which creates their account bound to the device their game client
reported, so the next launch logs straight in.
### Website and admin sign-in
Optionally, players sign in to the website (and admins to the admin panel)
with an OpenID Connect provider such as a Cloudflare Access SaaS app. Each
player links the provider identity to their DorkNet account once, with the
account's password, on the site's **`/link`** page; the Quest page's direct
install and the Downloads page can then be limited to signed-in players. The
game client's login is unchanged. Setup:
[`docs/deploy.md`](docs/deploy.md#external-sign-in-openid-connect).
### Everyone-is-friends toggle
For small servers where searching + friend-requesting each other is
+1 -1
View File
@@ -28,7 +28,7 @@ RUN apt-get update \
&& wget -q "https://packages.microsoft.com/config/debian/${VERSION_ID}/packages-microsoft-prod.deb" -O /tmp/ms.deb \
&& dpkg -i /tmp/ms.deb && rm /tmp/ms.deb \
&& apt-get update \
&& apt-get install -y --no-install-recommends dotnet-sdk-8.0 \
&& apt-get install -y --no-install-recommends dotnet-sdk-10.0 \
&& rm -rf /var/lib/apt/lists/*
# ---- Python with LIEF (DT_NEEDED injection into libmain.so) ---------------
+14 -3
View File
@@ -60,10 +60,21 @@ Resolve a table for another build locally with `build.py build --dump-cs`.
## Triggers
- **Push** to the branch (GitHub webhook) rebuilds only what changed:
- `DorkNet.ClientMod/` and the PC scripts rebuild PC;
- **Push** to the branch (GitHub webhook) builds only for client-facing
changes, and only the platform they touch:
- `DorkNet.ClientMod/` and `tools/build_pc.py` rebuild PC;
- `DorkNet.Quest/` rebuilds Quest;
- `Jenkinsfile` and `ci/jenkins/` rebuild both.
- Markdown files (`.md`, `.markdown`) never count; server, docs and CI-only pushes (including
the `Jenkinsfile` and `ci/`) build nothing. Run those manually.
Set the same filter on the job so other pushes don't start a run at all:
under the job's **Git** source, add **Additional Behaviours → Polling
ignores commits in certain paths** with included regions
`DorkNet[.]ClientMod/.*`, `DorkNet[.]Quest/.*`, `tools/build_pc[.]py` (one
per line) and excluded region `.*[.](md|markdown)`. The filter compares against the
last build's checkout, so the pipeline keeps it and deletes only `out/`
and its scratch folders after each run; without a checkout, Jenkins treats
every push as a change.
- **Build with Parameters** runs a manual build:
- `PLATFORMS` (both / pc / quest), and `PC_FULL_CLIENT` for the full PC client;
- snapshot tags, and per-run repository overrides;
+7
View File
@@ -41,6 +41,13 @@ there is always a bootstrap operator. The login form posts to
| `dorknet.admin.token` | DorkNet admin JWT |
| `dorknet.admin.me` | Admin identity object used by the layout |
With [external sign-in](deploy.md#external-sign-in-openid-connect)
configured, the login page also shows **Sign in with …**. It signs in with
the provider on the admin host, then `GET /api/admin/v1/sso` swaps that
session for the same token pair the password login returns. The identity
must already be linked to an account on the website, and the account still
needs `IsAdmin = true`.
Every admin API request sends `Authorization: Bearer <token>`. Protected
actions are guarded by `AdminOnlyAttribute`, which checks the resolved
player row still has `IsAdmin = true`. A `401` clears the local session
+14 -14
View File
@@ -35,20 +35,20 @@ DorkNet.sln
| Project | Responsibility | Target |
|---|---|---|
| `DorkNet.Server` | Shared REST/controller/service stack, database bootstrap, standalone fallback, monolith fallback | .NET 9 |
| `DorkNet.Models` | DTOs the wire protocol speaks. No project dependencies. | .NET 9 |
| `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.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 |
| `DorkNet.Services.Social` | Clubs, groups, announcements, player events, subscriptions | .NET 9 |
| `DorkNet.Services.Commerce` | Catalog, storefronts, econ, inventory, inventions | .NET 9 |
| `DorkNet.Services.Platform` | Service URLs, config, version checks, geo, strings, telemetry-style surfaces | .NET 9 |
| `DorkNet.Services.Moderation` | Bug reporting, player reporting, sanitize, path-routed admin API, testcase routes | .NET 9 |
| `DorkNet.Services.Web` | Public site, admin static host, same-origin admin browser API, feed static host, site API | .NET 9 |
| `DorkNet.Server` | Shared REST/controller/service stack, database bootstrap, standalone fallback, monolith fallback | .NET 10 |
| `DorkNet.Models` | DTOs the wire protocol speaks. No project dependencies. | .NET 10 |
| `DorkNet.Contracts` | Service names, route ownership, service-map options, health responses, probe responses | .NET 10 |
| `DorkNet.ServiceDefaults` | Common service health checks, JSON defaults, HTTP clients, service identity, route guard | .NET 10 |
| `DorkNet.Gateway` | Edge reverse proxy; routes host/path slices to services and exposes service health probes | .NET 10 |
| `DorkNet.Services.Identity` | Accounts, auth, platform login, JWT issuance route slice | .NET 10 |
| `DorkNet.Services.Rooms` | Rooms, room keys, matchmaking, discovery route slice | .NET 10 |
| `DorkNet.Services.Notify` | SignalR edge, messages/chat, notification fan-out route slice | .NET 10 |
| `DorkNet.Services.Content` | CDN paths, image/photo APIs, uploads, S3-backed blobs | .NET 10 |
| `DorkNet.Services.Social` | Clubs, groups, announcements, player events, subscriptions | .NET 10 |
| `DorkNet.Services.Commerce` | Catalog, storefronts, econ, inventory, inventions | .NET 10 |
| `DorkNet.Services.Platform` | Service URLs, config, version checks, geo, strings, telemetry-style surfaces | .NET 10 |
| `DorkNet.Services.Moderation` | Bug reporting, player reporting, sanitize, path-routed admin API, testcase routes | .NET 10 |
| `DorkNet.Services.Web` | Public site, admin static host, same-origin admin browser API, feed static host, site API | .NET 10 |
| `DorkNet.ClientMod` | MelonLoader 0.6.x IL2CPP mod — the client patcher, JSON config | .NET 6 |
| `DorkNet.Quest` | native hook library + build scripts for the Meta Quest client | C++/arm64 |
+68
View File
@@ -443,6 +443,74 @@ answer only on the apex host, where the site lives, and return 404 on every
other host the server accepts, so leaving the game's own subdomains open for
the client doesn't expose the files. Put the apex behind the proxy.
To serve the builds only to players signed in with a linked DorkNet account
(see [External sign-in](#external-sign-in-openid-connect)), also set:
```env
DorkNet__Downloads__RequireSignIn=true
```
The manifest and files then answer `401` until the visitor signs in, and the
Downloads and Quest pages offer **Sign in** instead.
---
## External sign-in (OpenID Connect)
Optional. Players sign in to the website, and admins to the admin panel,
with any OpenID Connect provider: a Cloudflare Access SaaS application,
Google, Authentik, Keycloak and so on. The game client keeps its own login.
The first time someone signs in, the website asks for their DorkNet username
and password, once, to link the provider identity to that account. After that
the provider sign-in is enough. The link is keyed on the provider's issuer and
subject (`iss` + `sub`), never on the email address, so a changed or reused
email can't take over an account. A player has one linked identity per
provider; **Account** (`/account`) shows it and can unlink it.
```env
# The provider's issuer URL (discovery at {Authority}/.well-known/openid-configuration).
DorkNet__SignIn__Authority=https://<provider>/...
DorkNet__SignIn__ClientId=<client id>
DorkNet__SignIn__ClientSecret=<client secret>
# Optional: button label ("Sign in with ..."), default "single sign-on".
DorkNet__SignIn__DisplayName=Cloudflare
# Optional: scopes, default "openid email profile".
DorkNet__SignIn__Scopes=openid email profile
# Optional: also read the userinfo endpoint (default false; the ID token is
# enough for most providers, and Cloudflare Access's userinfo can't be read).
DorkNet__SignIn__UseUserInfoEndpoint=false
```
Register these redirect URIs at the provider, one per host people sign in on:
```text
https://<site host>/api/auth/external/callback
https://<admin host>/api/auth/external/callback
```
The server builds the redirect URI from `Domain:Scheme` and the request's
host, so a TLS-terminating proxy in front doesn't turn it into `http://`.
**Cloudflare Access as the provider.** In Zero Trust, add an application of
type **SaaS**, choose **OIDC**, and enter the redirect URIs above. Give it the
same Access policies as the site, so the people who can open the site can
sign in. Copy the issuer, client ID and client secret into the settings
above.
Sessions are HttpOnly cookies per host (`dorknet_session`, 30 days, sliding).
The keys that encrypt them are stored in the database (`DataProtectionKeys`),
so redeploys and extra replicas keep everyone signed in. They're stored
encrypted with a key derived from the server secret (`DORKNET_JWT_SECRET`),
so a database backup alone can't forge sessions; changing that secret signs
everyone out. Unlinking the
identity, deleting the account or banning the player ends the session on the
next request. Wrong passwords on the link form are limited to 5 per 15
minutes per provider identity and per username.
Unset, the website shows no sign-in, and the admin panel only the password
form.
---
## Photon Cloud
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>