mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
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:
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link as RouterLink } from 'react-router';
|
||||||
|
import { getMe, signInUrl, signOut, unlink, type AccountMe } from '../lib/account';
|
||||||
|
|
||||||
|
// The signed-in account: which DorkNet account and provider email are
|
||||||
|
// linked, plus sign out and unlink.
|
||||||
|
export function Account() {
|
||||||
|
const [me, setMe] = useState<AccountMe | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getMe(true).then(setMe).catch((e) => setErr((e as Error).message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const run = async (action: () => Promise<unknown>) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
window.location.assign('/');
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (err) return <div className="max-w-lg mx-auto rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{err}</div>;
|
||||||
|
if (!me) return <p className="max-w-lg mx-auto text-sm text-ink-400">Loading…</p>;
|
||||||
|
|
||||||
|
if (!me.signedIn || !me.player) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg mx-auto card !p-6 space-y-3 text-sm text-ink-300">
|
||||||
|
<h1 className="text-xl font-semibold text-ink-50">Account</h1>
|
||||||
|
<p>You're not signed in.</p>
|
||||||
|
{me.signInAvailable
|
||||||
|
? <a className="btn-primary inline-block" href={signInUrl('/account')}>Sign in with {me.provider}</a>
|
||||||
|
: <p className="text-ink-400">Sign-in isn't set up on this server.</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg mx-auto space-y-4">
|
||||||
|
<h1 className="text-2xl font-semibold text-ink-50">Account</h1>
|
||||||
|
<div className="card !p-6 space-y-3 text-sm">
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<span className="text-ink-400">DorkNet account</span>
|
||||||
|
<RouterLink className="text-brand-300 hover:text-brand-200" to={`/players/${me.player.id}`}>{me.player.username}</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<span className="text-ink-400">Signed in with {me.provider}</span>
|
||||||
|
<span className="text-ink-200">{me.player.email || '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
|
<button type="button" className="btn-secondary" disabled={busy} onClick={() => run(signOut)}>Sign out</button>
|
||||||
|
<button type="button" className="btn-ghost text-danger" disabled={busy}
|
||||||
|
onClick={() => { if (window.confirm(`Unlink this sign-in from ${me.player!.username}? You'll need the account's password to link it again.`)) void run(unlink); }}>
|
||||||
|
Unlink
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { 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>}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user