mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4ce92f1b4 | |||
| c7a087eea1 | |||
| 5bfa463528 | |||
| 891ec89899 | |||
| f0e4679887 | |||
| 260f6605fc | |||
| 12da61407d | |||
| 5b93af4ba9 | |||
| 51104464f0 | |||
| 35768c05ed | |||
| 0a5c3a9a64 | |||
| 503d838fd1 | |||
| e29707deeb | |||
| 3b332a6476 | |||
| d295fd5c37 | |||
| 8e12db660b | |||
| bafb380fc8 |
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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">
|
||||
|
||||
<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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
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";
|
||||
|
||||
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 = true;
|
||||
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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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'],
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
|
||||
@@ -443,6 +443,71 @@ 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
|
||||
```
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user