feat(auth): external sign-in for the website and admin panel

Players sign in to the website, and admins to the admin panel, with an
OpenID Connect provider (DorkNet:SignIn:*). The first sign-in links the
provider identity (issuer + subject) to a DorkNet account with that
account's password; after that the provider sign-in is enough.

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

The game client's login is unchanged.
This commit is contained in:
Alexa
2026-10-25 15:47:56 +02:00
parent 260f6605fc
commit f0e4679887
27 changed files with 5138 additions and 31 deletions
+231
View File
@@ -0,0 +1,231 @@
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";
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 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 = 9_400_000 + Random.Shared.Next(1, 99_999),
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.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data; using DorkNet.Server.Data;
@@ -41,6 +42,27 @@ public class AdminLoginController(
if (!player.IsAdmin) if (!player.IsAdmin)
return StatusCode(403, new { error = "not_admin" }); 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); var (access, refresh) = authService.GenerateTokenPair(player.Id);
return Ok(new return Ok(new
{ {
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services; using DorkNet.Server.Services;
@@ -24,6 +25,12 @@ public class DownloadsController(DownloadsService downloads, DomainConfig domain
private bool Available => downloads.IsConfigured private bool Available => downloads.IsConfigured
&& string.Equals(Request.Host.Host, domain.Apex, StringComparison.OrdinalIgnoreCase); && 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 /// <summary>GET /api/site/v1/downloads — the manifest as published
/// (snake_case keys), or 404 when downloads aren't set up or the request /// (snake_case keys), or 404 when downloads aren't set up or the request
/// isn't for the site's host.</summary> /// 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) public async Task<IActionResult> GetManifest(CancellationToken ct)
{ {
if (!Available) return NotFound(new { error = "Downloads aren't set up on this server." }); 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); var manifest = await downloads.GetManifestAsync(ct);
Response.Headers.CacheControl = "no-cache"; Response.Headers.CacheControl = "no-cache";
return Content((manifest ?? new System.Text.Json.Nodes.JsonObject { ["releases"] = new System.Text.Json.Nodes.JsonArray() }) 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) public async Task GetFile(string path, CancellationToken ct)
{ {
if (!Available) { Response.StatusCode = StatusCodes.Status404NotFound; return; } if (!Available) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
if (!await SignedInOrOpenAsync()) { Response.StatusCode = StatusCodes.Status401Unauthorized; return; }
var manifest = await downloads.GetManifestAsync(ct); var manifest = await downloads.GetManifestAsync(ct);
var entry = manifest is null ? null : DownloadsService.FindFile(manifest, path); var entry = manifest is null ? null : DownloadsService.FindFile(manifest, path);
if (entry is null) { Response.StatusCode = StatusCodes.Status404NotFound; return; } if (entry is null) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
@@ -0,0 +1,178 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Site;
/// <summary>
/// Sign-in with the external OpenID Connect provider (<see cref="ExternalSignInOptions"/>)
/// for the website and the admin panel. The game client's login is separate.
///
/// <para>Flow: <c>login</c> challenges the provider; its callback
/// (<see cref="ExternalSignIn.CallbackPath"/>, handled by the OpenID Connect
/// middleware) stores the provider identity in the short-lived
/// <c>DorkNet.External</c> cookie and lands on <c>complete</c>. A linked identity
/// becomes a <c>DorkNet.Web</c> session at once; an unlinked one goes to the
/// website's <c>/link</c> page, where the player proves the account with its
/// password once (<c>account/link</c>).</para>
///
/// <para>Each host signs in on its own (website and admin panel), because the
/// hyphen host style puts them under different cookie domains.</para>
/// </summary>
[ApiController]
[AllowAnonymous]
public class ExternalSignInController(
IOptions<ExternalSignInOptions> options,
ExternalSignInService signIn,
LinkAttemptLimiter limiter,
DomainConfig domain,
ILogger<ExternalSignInController> log) : ControllerBase
{
private ExternalSignInOptions Options => options.Value;
/// <summary>GET /api/auth/external/login?returnUrl=/path — start sign-in.</summary>
[HttpGet(ExternalSignIn.LoginPath)]
public IActionResult Login([FromQuery] string? returnUrl)
{
if (!Options.IsConfigured) return NotFound(new { error = "sign_in_not_configured" });
var complete = $"{ExternalSignIn.CompletePath}?returnUrl={Uri.EscapeDataString(LocalUrl(returnUrl))}";
return Challenge(new AuthenticationProperties { RedirectUri = complete }, ExternalSignIn.OidcScheme);
}
/// <summary>GET /api/auth/external/complete — after the provider's callback.</summary>
[HttpGet(ExternalSignIn.CompletePath)]
public async Task<IActionResult> Complete([FromQuery] string? returnUrl)
{
returnUrl = LocalUrl(returnUrl);
var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme);
if (!external.Succeeded || ExternalIdentity(external.Principal) is not { } identity)
return Redirect(WithQuery(returnUrl, "signin_error", "no_identity"));
var (issuer, subject, email) = identity;
var link = await signIn.FindAsync(issuer, subject);
var player = link is null ? null : await signIn.ActivePlayerAsync(link);
if (link is not null && player is not null)
{
await signIn.TouchAsync(link, email);
await HttpContext.SignInAsync(ExternalSignIn.WebScheme, ExternalSignIn.PlayerPrincipal(player, link));
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
log.LogInformation("[signin] player={Player} via {Issuer}", player.Id, issuer);
return Redirect(returnUrl);
}
if (link is not null)
return Redirect(WithQuery(returnUrl, "signin_error", "account_unavailable"));
// Not linked yet. Linking happens on the website; the admin panel
// tells its user to link there first.
if (IsAdminHost())
return Redirect(WithQuery(returnUrl, "signin_error", "not_linked"));
return Redirect($"/link?returnUrl={Uri.EscapeDataString(returnUrl)}");
}
/// <summary>GET /api/site/v1/account/me — who is signed in, or which
/// provider identity is waiting to be linked.</summary>
[HttpGet("api/site/v1/account/me")]
public async Task<IActionResult> Me()
{
var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme);
var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme);
var pending = external.Succeeded ? ExternalIdentity(external.Principal) : null;
return Ok(new
{
signInAvailable = Options.IsConfigured,
provider = Options.DisplayName,
signedIn = web.Succeeded,
player = web.Succeeded
? new
{
id = ExternalSignIn.PlayerId(web.Principal)?.ToString(),
username = web.Principal!.Identity?.Name,
email = web.Principal.FindFirst(ExternalSignIn.EmailClaim)?.Value,
}
: null,
pendingLink = pending is { } p ? new { email = p.Email } : null,
});
}
public sealed record LinkRequest(string? Username, string? Password);
/// <summary>POST /api/site/v1/account/link {username, password} — link the
/// pending provider identity to the account these credentials prove.</summary>
[HttpPost("api/site/v1/account/link")]
public async Task<IActionResult> Link([FromBody] LinkRequest body)
{
var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme);
if (!external.Succeeded || ExternalIdentity(external.Principal) is not { } identity)
return Unauthorized(new { error = "no_pending_identity" });
var (issuer, subject, email) = identity;
var username = body.Username?.Trim() ?? string.Empty;
if (username.Length == 0 || string.IsNullOrEmpty(body.Password))
return BadRequest(new { error = "missing_credentials" });
var keys = new[] { $"sub:{issuer}|{subject}", $"user:{username.ToLowerInvariant()}" };
if (limiter.IsBlocked(keys))
return StatusCode(StatusCodes.Status429TooManyRequests, new { error = "too_many_attempts" });
var (outcome, player, link) = await signIn.LinkAsync(issuer, subject, email, username, body.Password);
switch (outcome)
{
case LinkOutcome.Linked:
limiter.Reset(keys);
await HttpContext.SignInAsync(ExternalSignIn.WebScheme, ExternalSignIn.PlayerPrincipal(player!, link!));
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
log.LogInformation("[signin] linked player={Player} to {Issuer}", player!.Id, issuer);
return Ok(new { linked = true, username = player.Username });
case LinkOutcome.InvalidCredentials:
limiter.RecordFailure(keys);
return Unauthorized(new { error = "invalid_credentials" });
case LinkOutcome.Banned:
return StatusCode(StatusCodes.Status403Forbidden, new { error = "banned" });
default:
return Conflict(new { error = "identity_linked_to_another_account" });
}
}
/// <summary>POST /api/site/v1/account/unlink — remove this provider identity
/// from the account and sign out.</summary>
[HttpPost("api/site/v1/account/unlink")]
public async Task<IActionResult> Unlink()
{
var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme);
if (!web.Succeeded || ExternalSignIn.PlayerId(web.Principal) is not { } playerId)
return Unauthorized(new { error = "not_signed_in" });
var issuer = web.Principal!.FindFirst(ExternalSignIn.IssuerClaim)?.Value ?? string.Empty;
await signIn.UnlinkAsync(playerId, issuer);
await HttpContext.SignOutAsync(ExternalSignIn.WebScheme);
return Ok(new { unlinked = true });
}
/// <summary>POST /api/site/v1/account/logout — end the session here.</summary>
[HttpPost("api/site/v1/account/logout")]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(ExternalSignIn.WebScheme);
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
return Ok(new { signedOut = true });
}
/// <summary>(issuer, subject, email) from the provider's claims. The
/// subject is the account key; the email is display only.</summary>
private (string Issuer, string Subject, string Email)? ExternalIdentity(System.Security.Claims.ClaimsPrincipal? p)
{
var subject = p?.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(subject)) return null;
var issuer = p!.FindFirst("iss")?.Value ?? Options.Authority;
return (issuer.TrimEnd('/'), subject, p.FindFirst("email")?.Value ?? string.Empty);
}
private string LocalUrl(string? url) =>
!string.IsNullOrEmpty(url) && Url.IsLocalUrl(url) ? url : "/";
private static string WithQuery(string url, string key, string value) =>
url + (url.Contains('?') ? "&" : "?") + key + "=" + Uri.EscapeDataString(value);
private bool IsAdminHost() =>
string.Equals(Request.Host.Host, domain.Sub("admin"), StringComparison.OrdinalIgnoreCase);
}
+12 -1
View File
@@ -1,9 +1,10 @@
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data.Entities; using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Data; 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<PlayerEntity> Players => Set<PlayerEntity>();
public DbSet<AvatarEntity> Avatars => Set<AvatarEntity>(); 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<UgcPurchasableEntity> UgcPurchasables => Set<UgcPurchasableEntity>();
public DbSet<RoomConsumableEntity> RoomConsumables => Set<RoomConsumableEntity>(); public DbSet<RoomConsumableEntity> RoomConsumables => Set<RoomConsumableEntity>();
public DbSet<RoomConsumableOwnershipEntity> RoomConsumableOwnership => Set<RoomConsumableOwnershipEntity>(); 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) 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 // SQLite-specific NOCASE collation works via UseCollation; Postgres
// doesn't have NOCASE. Provider-conditional helper applied to the // doesn't have NOCASE. Provider-conditional helper applied to the
// three string columns the wire API matches case-insensitively // 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;
}
+2
View File
@@ -8,6 +8,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.13" /> <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.AspNetCore.OpenApi" Version="10.0.13" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.13"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.13">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -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) protected override void BuildModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #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 => modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b =>
{ {
@@ -972,6 +972,43 @@ namespace DorkNet.Server.Migrations
b.ToTable("DormStates"); 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 => modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -3668,6 +3705,23 @@ namespace DorkNet.Server.Migrations
b.ToTable("UgcPurchasables"); 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 => modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
{ {
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
@@ -39,6 +39,7 @@ public sealed class DownloadsService
} }
var host = config["DorkNet:Downloads:Host"]?.Trim(); var host = config["DorkNet:Downloads:Host"]?.Trim();
_host = string.IsNullOrEmpty(host) ? null : host; _host = string.IsNullOrEmpty(host) ? null : host;
RequireSignIn = config.GetValue<bool>("DorkNet:Downloads:RequireSignIn");
if (_baseUri is null) if (_baseUri is null)
log.LogInformation("[downloads] no DorkNet:Downloads:BaseUrl; the Downloads page and Quest latest-build install are off"); 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; 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> /// <summary>The published manifest, or null when none has been published yet.</summary>
public async Task<JsonObject?> GetManifestAsync(CancellationToken ct) public async Task<JsonObject?> GetManifestAsync(CancellationToken ct)
{ {
+151
View File
@@ -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 // idempotent raw SQL that works on a fresh DB too (CREATE TABLE
// IF NOT EXISTS no-ops when EnsureCreated already built them). // IF NOT EXISTS no-ops when EnsureCreated already built them).
await EnsureSignupCodeTablesAsync(db); await EnsureSignupCodeTablesAsync(db);
await EnsureExternalSignInTablesAsync(db);
await EnsureServerSettingsColumnsAsync(db); await EnsureServerSettingsColumnsAsync(db);
await EnsureCharadesWordListTableAsync(db); await EnsureCharadesWordListTableAsync(db);
await EnsureInventorySchemaAsync(db); await EnsureInventorySchemaAsync(db);
@@ -265,6 +266,66 @@ public static class DatabaseBootstrap
/// tables on whichever provider is in use. Safe to run every boot: /// tables on whichever provider is in use. Safe to run every boot:
/// CREATE TABLE / INDEX IF NOT EXISTS no-op once they exist (whether /// CREATE TABLE / INDEX IF NOT EXISTS no-op once they exist (whether
/// created here or by a fresh-DB EnsureCreated).</summary> /// 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) private static async Task EnsureSignupCodeTablesAsync(DorkNetDbContext db)
{ {
var pg = db.Database.IsNpgsql(); var pg = db.Database.IsNpgsql();
@@ -1,4 +1,8 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
@@ -29,6 +33,7 @@ public static class ServiceCollectionExtensions
AddVersionCompatibility(builder); AddVersionCompatibility(builder);
var redisConn = AddRedis(builder); var redisConn = AddRedis(builder);
AddAuthentication(builder); AddAuthentication(builder);
AddExternalSignIn(builder);
AddKestrelAndSignalR(builder, redisConn); AddKestrelAndSignalR(builder, redisConn);
} }
@@ -397,6 +402,112 @@ public static class ServiceCollectionExtensions
opt.JsonSerializerOptions.PropertyNamingPolicy = null); 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.
services.AddDataProtection()
.SetApplicationName("DorkNet")
.PersistKeysToDbContext<DorkNetDbContext>();
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 + SignalR ────────────────────────────────────────────────────
// Kestrel-level limits: defaults trim long-lived sockets earlier than our // Kestrel-level limits: defaults trim long-lived sockets earlier than our
// SignalR ping interval allows. // SignalR ping interval allows.
+86 -18
View File
@@ -1,7 +1,8 @@
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate, useLocation } from 'react-router'; import { useNavigate, useLocation, useSearchParams } from 'react-router';
import { api, ApiError } from '../lib/api'; import { api, ApiError } from '../lib/api';
import { notifyAuthChange, setSession } from '../lib/auth'; import { notifyAuthChange, setSession } from '../lib/auth';
import { adminApex } from '../lib/types';
import { BrandMark } from '../components/BrandMark'; import { BrandMark } from '../components/BrandMark';
interface LoginResponse { interface LoginResponse {
@@ -12,14 +13,81 @@ interface LoginResponse {
display_name: string; 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() { export function Login() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [params, setParams] = useSearchParams();
const from = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/'; const from = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/';
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = 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); 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 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.</>
: SSO_ERRORS[signinError] ?? `Single sign-on failed (${signinError}).`);
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) => { const onSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -30,27 +98,19 @@ export function Login() {
method: 'POST', method: 'POST',
body: { username, password }, body: { username, password },
}); });
setSession(res.access_token, { finish(res, from);
id: res.account_id,
username: res.username,
displayName: res.display_name,
});
notifyAuthChange();
navigate(from, { replace: true });
} catch (e) { } catch (e) {
if (e instanceof ApiError) { setErr(errorText(e, {
if (e.body && typeof e.body === 'object' && 'error' in e.body) { not_admin: 'That account is not an admin.',
const code = (e.body as { error: string }).error; invalid_credentials: 'Invalid username or password.',
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));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const ssoHref = `/api/auth/external/login?returnUrl=${encodeURIComponent(`/login?sso=1&to=${encodeURIComponent(from)}`)}`;
return ( 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="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"> <div className="w-full max-w-sm">
@@ -92,6 +152,14 @@ export function Login() {
<button type="submit" disabled={loading} className="btn-primary mt-1"> <button type="submit" disabled={loading} className="btn-primary mt-1">
{loading ? 'Signing in…' : 'Sign in'} {loading ? 'Signing in…' : 'Sign in'}
</button> </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> </form>
<p className="mt-6 text-center text-[11px] text-ink-500">DorkNet private server · admin console</p> <p className="mt-6 text-center text-[11px] text-ink-500">DorkNet private server · admin console</p>
+4
View File
@@ -9,6 +9,8 @@ import { PlayerProfile } from './pages/PlayerProfile';
import { Rooms } from './pages/Rooms'; import { Rooms } from './pages/Rooms';
import { Join } from './pages/Join'; import { Join } from './pages/Join';
import { Downloads } from './pages/Downloads'; import { Downloads } from './pages/Downloads';
import { Link as LinkAccount } from './pages/Link';
import { Account } from './pages/Account';
import { NotFound } from './pages/NotFound'; import { NotFound } from './pages/NotFound';
// The Quest installer pulls in the WebUSB/ADB stack; load it only on /quest. // 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="/join" element={<Join />} />
<Route path="/quest" element={<Suspense fallback={null}><Quest /></Suspense>} /> <Route path="/quest" element={<Suspense fallback={null}><Quest /></Suspense>} />
<Route path="/downloads" element={<Downloads />} /> <Route path="/downloads" element={<Downloads />} />
<Route path="/link" element={<LinkAccount />} />
<Route path="/account" element={<Account />} />
{/* Legacy paths from the vanilla-JS feed.localhost site */} {/* Legacy paths from the vanilla-JS feed.localhost site */}
<Route path="/p/:id" element={<RedirectPhoto />} /> <Route path="/p/:id" element={<RedirectPhoto />} />
<Route path="/u/:id" element={<RedirectPlayer />} /> <Route path="/u/:id" element={<RedirectPlayer />} />
+37 -3
View File
@@ -1,7 +1,8 @@
import { NavLink, Link, useLocation, useNavigate } from 'react-router'; import { NavLink, Link, useLocation, useNavigate, useSearchParams } from 'react-router';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { BrandMark } from './BrandMark'; import { BrandMark } from './BrandMark';
import { getDownloads } from '../lib/downloads'; import { getDownloads, SignInRequiredError } from '../lib/downloads';
import { getMe, signInUrl, signOut, SIGNIN_ERRORS, type AccountMe } from '../lib/account';
const NAV = [ const NAV = [
{ to: '/', label: 'Home', exact: true }, { to: '/', label: 'Home', exact: true },
@@ -23,10 +24,31 @@ export function Header() {
const [hasDownloads, setHasDownloads] = useState(false); const [hasDownloads, setHasDownloads] = useState(false);
const navItems = hasDownloads ? [...NAV, DOWNLOADS] : NAV; const navItems = hasDownloads ? [...NAV, DOWNLOADS] : NAV;
const [me, setMe] = useState<AccountMe | null>(null);
const [params, setParams] = useSearchParams();
const signinError = params.get('signin_error');
useEffect(() => { 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 doSignOut = async () => {
await signOut().catch(() => {});
window.location.assign('/');
};
// 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 // Close the mobile drawer on route change so tapping a nav item
// doesn't leave a half-open overlay covering the page. // doesn't leave a half-open overlay covering the page.
useEffect(() => { setOpen(false); }, [loc.pathname]); useEffect(() => { setOpen(false); }, [loc.pathname]);
@@ -72,6 +94,8 @@ export function Header() {
/> />
</form> </form>
<div className="hidden md:flex shrink-0">{account}</div>
<button <button
type="button" type="button"
onClick={() => setOpen(o => !o)} onClick={() => setOpen(o => !o)}
@@ -107,6 +131,16 @@ export function Header() {
</NavLink> </NavLink>
))} ))}
</nav> </nav>
{account && <div className="pt-1">{account}</div>}
</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: {SIGNIN_ERRORS[signinError] ?? signinError}</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>
</div> </div>
)} )}
+46
View File
@@ -0,0 +1,46 @@
// 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;
}
/** 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)}`;
}
export const link = (username: string, password: string) =>
post<{ linked: boolean; username: string }>('/account/link', { username, password });
export const signOut = () => post('/account/logout', {});
export const unlink = () => 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".',
};
export const SIGNIN_ERRORS: Record<string, string> = {
account_unavailable: 'The account linked to this sign-in is unavailable (deleted or banned).',
no_identity: "The sign-in provider didn't return an identity. Try again.",
provider_failed: 'The sign-in was cancelled or failed at the provider. Try again.',
};
+3 -3
View File
@@ -1,7 +1,7 @@
// Lightweight fetch wrapper for the public-facing site. Talks to the // Lightweight fetch wrapper for the public-facing site. Talks to the
// apex-hosted PublicSiteController at /api/site/v1/*. Anonymous-only — // apex-hosted PublicSiteController at /api/site/v1/*. No Authorization
// the public site has no login flow, so there's no Authorization // header or token plumbing: the optional sign-in (lib/account.ts) is an
// header or token plumbing. // HttpOnly cookie that same-origin fetches send on their own.
const BASE = '/api/site/v1'; const BASE = '/api/site/v1';
+7
View File
@@ -28,6 +28,12 @@ export interface DownloadsManifest {
updated?: string; 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; let cached: Promise<DownloadsManifest | null> | null = null;
/** The manifest, or null when this server doesn't offer downloads. Fetched /** 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) { if (!cached || fresh) {
cached = get<DownloadsManifest>('/downloads').catch((e) => { cached = get<DownloadsManifest>('/downloads').catch((e) => {
if (e instanceof ApiError && e.status === 404) return null; if (e instanceof ApiError && e.status === 404) return null;
if (e instanceof ApiError && e.status === 401) { cached = null; throw new SignInRequiredError(); }
cached = null; cached = null;
throw e; throw e;
}); });
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { Link as RouterLink } from 'react-router';
import { getMe, signInUrl, signOut, unlink, type AccountMe } from '../lib/account';
// The signed-in account: which DorkNet account and provider email are
// linked, plus sign out and unlink.
export function Account() {
const [me, setMe] = useState<AccountMe | null>(null);
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
getMe(true).then(setMe).catch((e) => setErr((e as Error).message));
}, []);
const run = async (action: () => Promise<unknown>) => {
setBusy(true);
try {
await action();
window.location.assign('/');
} catch (e) {
setErr((e as Error).message);
setBusy(false);
}
};
if (err) return <div className="max-w-lg mx-auto rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{err}</div>;
if (!me) return <p className="max-w-lg mx-auto text-sm text-ink-400">Loading…</p>;
if (!me.signedIn || !me.player) {
return (
<div className="max-w-lg mx-auto card !p-6 space-y-3 text-sm text-ink-300">
<h1 className="text-xl font-semibold text-ink-50">Account</h1>
<p>You're not signed in.</p>
{me.signInAvailable
? <a className="btn-primary inline-block" href={signInUrl('/account')}>Sign in with {me.provider}</a>
: <p className="text-ink-400">Sign-in isn't set up on this server.</p>}
</div>
);
}
return (
<div className="max-w-lg mx-auto space-y-4">
<h1 className="text-2xl font-semibold text-ink-50">Account</h1>
<div className="card !p-6 space-y-3 text-sm">
<div className="flex justify-between gap-3">
<span className="text-ink-400">DorkNet account</span>
<RouterLink className="text-brand-300 hover:text-brand-200" to={`/players/${me.player.id}`}>{me.player.username}</RouterLink>
</div>
<div className="flex justify-between gap-3">
<span className="text-ink-400">Signed in with {me.provider}</span>
<span className="text-ink-200">{me.player.email || '—'}</span>
</div>
<div className="flex flex-wrap gap-2 pt-2">
<button type="button" className="btn-secondary" disabled={busy} onClick={() => run(signOut)}>Sign out</button>
<button type="button" className="btn-ghost text-danger" disabled={busy}
onClick={() => { if (window.confirm(`Unlink this sign-in from ${me.player!.username}? You'll need the account's password to link it again.`)) void run(unlink); }}>
Unlink
</button>
</div>
</div>
</div>
);
}
+14 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from 'react-router'; 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) => 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`; 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() { export function Downloads() {
const [m, setM] = useState<DownloadsManifest | null | undefined>(undefined); const [m, setM] = useState<DownloadsManifest | null | undefined>(undefined);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [signInNeeded, setSignInNeeded] = useState(false);
useEffect(() => { 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); const releases = (m?.releases ?? []).filter((r) => r.files.length);
@@ -114,7 +119,13 @@ export function Downloads() {
</div> </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>} {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 === 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>} {m && !releases.length && <p className="text-sm text-ink-400">No builds have been published yet.</p>}
+79
View File
@@ -0,0 +1,79 @@
import { useEffect, useState } from 'react';
import { Link as RouterLink, useNavigate, useSearchParams } from 'react-router';
import { getMe, link, LINK_ERRORS, 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 = 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) => {
if (m.signedIn) 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(LINK_ERRORS[key] ?? key);
} finally {
setBusy(false);
}
};
if (me && !me.pendingLink) {
return (
<div className="max-w-lg mx-auto card !p-6 space-y-3 text-sm text-ink-300">
<h1 className="text-xl font-semibold text-ink-50">Link your account</h1>
<p>Your sign-in expired before it was linked.</p>
{me.signInAvailable && <a className="btn-primary inline-block" href={signInUrl(returnUrl)}>Sign in again</a>}
</div>
);
}
return (
<div className="max-w-lg mx-auto space-y-4">
<div>
<h1 className="text-2xl font-semibold text-ink-50">Link your DorkNet account</h1>
<p className="text-sm text-ink-400">
You signed in{me?.pendingLink?.email ? <> as <span className="text-ink-200">{me.pendingLink.email}</span></> : null}.
Enter your DorkNet username and password once to connect them; after that, signing in is enough.
</p>
</div>
<form onSubmit={submit} className="card !p-6 space-y-4">
<div className="space-y-1.5">
<label className="label" htmlFor="username">DorkNet username</label>
<input id="username" className="input" autoComplete="username" required
value={username} onChange={(e) => setUsername(e.target.value)} />
</div>
<div className="space-y-1.5">
<label className="label" htmlFor="password">Password</label>
<input id="password" type="password" className="input" autoComplete="current-password" required
value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
{err && <div className="rounded-lg border border-danger/30 bg-danger/5 px-3 py-2 text-sm text-danger">{err}</div>}
<button type="submit" disabled={busy} className="btn-primary w-full">{busy ? 'Linking…' : 'Link account'}</button>
<p className="text-xs text-ink-500">
No account yet? <RouterLink className="text-brand-300 hover:text-brand-200" to="/join">Join with a signup code</RouterLink>, then come back here.
</p>
</form>
</div>
);
}
+13 -2
View File
@@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { fileSource, openXapk, urlSource, type Xapk } from '../lib/xapk'; import { fileSource, openXapk, urlSource, type Xapk } from '../lib/xapk';
import { connectHeadset, installXapk, webUsbSupported, type Headset, type Progress } from '../lib/questInstall'; 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) => 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`; 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); const fileRef = useRef<HTMLInputElement>(null);
// The server's latest published Quest build, if it offers downloads. // The server's latest published Quest build, if it offers downloads.
const [latest, setLatest] = useState<QuestBuild | null>(null); 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 [params] = useSearchParams();
const open = async (load: () => Promise<Xapk>) => { 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. // that card's own file, which may not be the newest release's.
const wanted = params.get('file'); const wanted = params.get('file');
if (wanted) void pickBuild(questBuild(m, wanted)); 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 // 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> <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])} /> <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"> <div className="flex flex-wrap items-center gap-3">
{latest && ( {latest && (
+10
View File
@@ -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 which creates their account bound to the device their game client
reported, so the next launch logs straight in. 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 ### Everyone-is-friends toggle
For small servers where searching + friend-requesting each other is For small servers where searching + friend-requesting each other is
+7
View File
@@ -41,6 +41,13 @@ there is always a bootstrap operator. The login form posts to
| `dorknet.admin.token` | DorkNet admin JWT | | `dorknet.admin.token` | DorkNet admin JWT |
| `dorknet.admin.me` | Admin identity object used by the layout | | `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 Every admin API request sends `Authorization: Bearer <token>`. Protected
actions are guarded by `AdminOnlyAttribute`, which checks the resolved actions are guarded by `AdminOnlyAttribute`, which checks the resolved
player row still has `IsAdmin = true`. A `401` clears the local session player row still has `IsAdmin = true`. A `401` clears the local session
+62
View File
@@ -443,6 +443,68 @@ 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 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. 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. 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 ## Photon Cloud